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