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+ }
0 commit comments