Skip to content

Commit ab71390

Browse files
author
nejc
committed
Add ImmutableStruct implementation with validation rules
1 parent fa35f67 commit ab71390

File tree

11 files changed

+768
-0
lines changed

11 files changed

+768
-0
lines changed
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Nejcc\PhpDatatypes\Composite\Struct;
6+
7+
use Nejcc\PhpDatatypes\Interfaces\StructInterface;
8+
use Nejcc\PhpDatatypes\Exceptions\InvalidArgumentException;
9+
use Nejcc\PhpDatatypes\Exceptions\ImmutableException;
10+
use Nejcc\PhpDatatypes\Exceptions\ValidationException;
11+
12+
/**
13+
* ImmutableStruct - An immutable struct implementation with field validation
14+
* and nested struct support.
15+
*/
16+
class ImmutableStruct implements StructInterface
17+
{
18+
/**
19+
* @var array<string, array{
20+
* type: string,
21+
* value: mixed,
22+
* required: bool,
23+
* default: mixed,
24+
* rules: ValidationRule[]
25+
* }> The struct fields
26+
*/
27+
private array $fields;
28+
29+
/**
30+
* @var bool Whether the struct is frozen (immutable)
31+
*/
32+
private bool $frozen = false;
33+
34+
/**
35+
* Create a new ImmutableStruct instance
36+
*
37+
* @param array<string, array{
38+
* type: string,
39+
* required?: bool,
40+
* default?: mixed,
41+
* rules?: ValidationRule[]
42+
* }> $fieldDefinitions Field definitions
43+
* @param array<string, mixed> $initialValues Initial values for fields
44+
* @throws InvalidArgumentException If field definitions are invalid or initial values don't match
45+
* @throws ValidationException If validation rules fail
46+
*/
47+
public function __construct(array $fieldDefinitions, array $initialValues = [])
48+
{
49+
$this->fields = [];
50+
$this->initializeFields($fieldDefinitions);
51+
$this->setInitialValues($initialValues);
52+
$this->frozen = true;
53+
}
54+
55+
/**
56+
* Initialize the struct fields from definitions
57+
*
58+
* @param array<string, array{
59+
* type: string,
60+
* required?: bool,
61+
* default?: mixed,
62+
* rules?: ValidationRule[]
63+
* }> $fieldDefinitions
64+
* @throws InvalidArgumentException If field definitions are invalid
65+
*/
66+
private function initializeFields(array $fieldDefinitions): void
67+
{
68+
foreach ($fieldDefinitions as $name => $definition) {
69+
if (!isset($definition['type'])) {
70+
throw new InvalidArgumentException("Field '$name' must have a type definition");
71+
}
72+
73+
$this->fields[$name] = [
74+
'type' => $definition['type'],
75+
'value' => $definition['default'] ?? null,
76+
'required' => $definition['required'] ?? false,
77+
'default' => $definition['default'] ?? null,
78+
'rules' => $definition['rules'] ?? []
79+
];
80+
}
81+
}
82+
83+
/**
84+
* Set initial values for fields
85+
*
86+
* @param array<string, mixed> $initialValues
87+
* @throws InvalidArgumentException If initial values don't match field definitions
88+
* @throws ValidationException If validation rules fail
89+
*/
90+
private function setInitialValues(array $initialValues): void
91+
{
92+
foreach ($initialValues as $name => $value) {
93+
if (!isset($this->fields[$name])) {
94+
throw new InvalidArgumentException("Field '$name' is not defined in the struct");
95+
}
96+
$this->set($name, $value);
97+
}
98+
99+
// Validate required fields
100+
foreach ($this->fields as $name => $field) {
101+
if ($field['required'] && $field['value'] === null) {
102+
throw new InvalidArgumentException("Required field '$name' has no value");
103+
}
104+
}
105+
}
106+
107+
/**
108+
* Create a new struct with updated values
109+
*
110+
* @param array<string, mixed> $values New values to set
111+
* @return self A new struct instance with the updated values
112+
* @throws InvalidArgumentException If values don't match field definitions
113+
* @throws ValidationException If validation rules fail
114+
*/
115+
public function with(array $values): self
116+
{
117+
$newFields = [];
118+
foreach ($this->fields as $name => $field) {
119+
$newFields[$name] = [
120+
'type' => $field['type'],
121+
'required' => $field['required'],
122+
'default' => $field['default'],
123+
'rules' => $field['rules']
124+
];
125+
}
126+
127+
$newStruct = new self($newFields, $values);
128+
return $newStruct;
129+
}
130+
131+
/**
132+
* Get a new struct with a single field updated
133+
*
134+
* @param string $name Field name
135+
* @param mixed $value New value
136+
* @return self A new struct instance with the updated field
137+
* @throws InvalidArgumentException If the field doesn't exist or value doesn't match type
138+
* @throws ValidationException If validation rules fail
139+
*/
140+
public function withField(string $name, mixed $value): self
141+
{
142+
return $this->with([$name => $value]);
143+
}
144+
145+
/**
146+
* {@inheritDoc}
147+
*/
148+
public function set(string $name, mixed $value): void
149+
{
150+
if ($this->frozen) {
151+
throw new ImmutableException("Cannot modify a frozen struct");
152+
}
153+
154+
if (!isset($this->fields[$name])) {
155+
throw new InvalidArgumentException("Field '$name' does not exist in the struct");
156+
}
157+
158+
$this->validateValue($name, $value);
159+
$this->fields[$name]['value'] = $value;
160+
}
161+
162+
/**
163+
* {@inheritDoc}
164+
*/
165+
public function get(string $name): mixed
166+
{
167+
if (!isset($this->fields[$name])) {
168+
throw new InvalidArgumentException("Field '$name' does not exist in the struct");
169+
}
170+
171+
return $this->fields[$name]['value'];
172+
}
173+
174+
/**
175+
* {@inheritDoc}
176+
*/
177+
public function getFields(): array
178+
{
179+
return $this->fields;
180+
}
181+
182+
/**
183+
* Get the type of a field
184+
*
185+
* @param string $name Field name
186+
* @return string The field type
187+
* @throws InvalidArgumentException If the field doesn't exist
188+
*/
189+
public function getFieldType(string $name): string
190+
{
191+
if (!isset($this->fields[$name])) {
192+
throw new InvalidArgumentException("Field '$name' does not exist in the struct");
193+
}
194+
195+
return $this->fields[$name]['type'];
196+
}
197+
198+
/**
199+
* Check if a field is required
200+
*
201+
* @param string $name Field name
202+
* @return bool True if the field is required
203+
* @throws InvalidArgumentException If the field doesn't exist
204+
*/
205+
public function isFieldRequired(string $name): bool
206+
{
207+
if (!isset($this->fields[$name])) {
208+
throw new InvalidArgumentException("Field '$name' does not exist in the struct");
209+
}
210+
211+
return $this->fields[$name]['required'];
212+
}
213+
214+
/**
215+
* Get the validation rules for a field
216+
*
217+
* @param string $name Field name
218+
* @return ValidationRule[] The field's validation rules
219+
* @throws InvalidArgumentException If the field doesn't exist
220+
*/
221+
public function getFieldRules(string $name): array
222+
{
223+
if (!isset($this->fields[$name])) {
224+
throw new InvalidArgumentException("Field '$name' does not exist in the struct");
225+
}
226+
227+
return $this->fields[$name]['rules'];
228+
}
229+
230+
/**
231+
* Validate a value against a field's type and rules
232+
*
233+
* @param string $name Field name
234+
* @param mixed $value Value to validate
235+
* @throws InvalidArgumentException If the value doesn't match the field type
236+
* @throws ValidationException If validation rules fail
237+
*/
238+
private function validateValue(string $name, mixed $value): void
239+
{
240+
$type = $this->fields[$name]['type'];
241+
$actualType = get_debug_type($value);
242+
243+
// Handle nullable types
244+
if ($this->isNullable($type) && $value === null) {
245+
return;
246+
}
247+
248+
$baseType = $this->stripNullable($type);
249+
250+
// Handle nested structs
251+
if (is_subclass_of($baseType, StructInterface::class)) {
252+
if (!($value instanceof $baseType)) {
253+
throw new InvalidArgumentException(
254+
"Field '$name' expects type '$type', but got '$actualType'"
255+
);
256+
}
257+
return;
258+
}
259+
260+
// Handle primitive types
261+
if ($actualType !== $baseType && !is_subclass_of($value, $baseType)) {
262+
throw new InvalidArgumentException(
263+
"Field '$name' expects type '$type', but got '$actualType'"
264+
);
265+
}
266+
267+
// Apply validation rules
268+
foreach ($this->fields[$name]['rules'] as $rule) {
269+
$rule->validate($value, $name);
270+
}
271+
}
272+
273+
/**
274+
* Check if a type is nullable
275+
*
276+
* @param string $type Type to check
277+
* @return bool True if the type is nullable
278+
*/
279+
private function isNullable(string $type): bool
280+
{
281+
return str_starts_with($type, '?');
282+
}
283+
284+
/**
285+
* Strip nullable prefix from a type
286+
*
287+
* @param string $type Type to strip
288+
* @return string Type without nullable prefix
289+
*/
290+
private function stripNullable(string $type): string
291+
{
292+
return ltrim($type, '?');
293+
}
294+
295+
/**
296+
* Convert the struct to an array
297+
*
298+
* @return array<string, mixed> The struct data
299+
*/
300+
public function toArray(): array
301+
{
302+
$result = [];
303+
foreach ($this->fields as $name => $field) {
304+
$value = $field['value'];
305+
if ($value instanceof StructInterface) {
306+
$result[$name] = $value->toArray();
307+
} else {
308+
$result[$name] = $value;
309+
}
310+
}
311+
return $result;
312+
}
313+
314+
/**
315+
* String representation of the struct
316+
*
317+
* @return string
318+
*/
319+
public function __toString(): string
320+
{
321+
return json_encode($this->toArray());
322+
}
323+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Nejcc\PhpDatatypes\Composite\Struct\Rules;
6+
7+
use Nejcc\PhpDatatypes\Composite\Struct\ValidationRule;
8+
use Nejcc\PhpDatatypes\Exceptions\ValidationException;
9+
10+
class CompositeRule implements ValidationRule
11+
{
12+
/**
13+
* @var ValidationRule[]
14+
*/
15+
private array $rules;
16+
17+
/**
18+
* Create a new composite validation rule
19+
*
20+
* @param ValidationRule ...$rules The rules to combine
21+
*/
22+
public function __construct(ValidationRule ...$rules)
23+
{
24+
$this->rules = $rules;
25+
}
26+
27+
public function validate(mixed $value, string $fieldName): bool
28+
{
29+
foreach ($this->rules as $rule) {
30+
$rule->validate($value, $fieldName);
31+
}
32+
33+
return true;
34+
}
35+
36+
/**
37+
* Create a new composite rule from an array of rules
38+
*
39+
* @param ValidationRule[] $rules
40+
* @return self
41+
*/
42+
public static function fromArray(array $rules): self
43+
{
44+
return new self(...$rules);
45+
}
46+
47+
/**
48+
* Add a rule to the composite
49+
*
50+
* @param ValidationRule $rule
51+
* @return self A new composite rule with the added rule
52+
*/
53+
public function withRule(ValidationRule $rule): self
54+
{
55+
return new self(...array_merge($this->rules, [$rule]));
56+
}
57+
}

0 commit comments

Comments
 (0)