+2
-2
src/Generator/ClassGenerator.php
+2
-2
src/Generator/ClassGenerator.php
···
42
42
?MethodGenerator $methodGenerator = null,
43
43
?DocBlockGenerator $docBlockGenerator = null
44
44
) {
45
-
$this->naming = $naming ?? new NamingConverter;
45
+
$this->naming = $naming ?? new NamingConverter();
46
46
$this->typeMapper = $typeMapper ?? new TypeMapper($this->naming);
47
-
$this->renderer = $renderer ?? new StubRenderer;
47
+
$this->renderer = $renderer ?? new StubRenderer();
48
48
$this->methodGenerator = $methodGenerator ?? new MethodGenerator($this->naming, $this->typeMapper, $this->renderer);
49
49
$this->docBlockGenerator = $docBlockGenerator ?? new DocBlockGenerator($this->typeMapper);
50
50
}
+2
-2
src/Generator/ConstructorGenerator.php
+2
-2
src/Generator/ConstructorGenerator.php
···
21
21
?PropertyGenerator $propertyGenerator = null,
22
22
?StubRenderer $renderer = null
23
23
) {
24
-
$this->propertyGenerator = $propertyGenerator ?? new PropertyGenerator;
25
-
$this->renderer = $renderer ?? new StubRenderer;
24
+
$this->propertyGenerator = $propertyGenerator ?? new PropertyGenerator();
25
+
$this->renderer = $renderer ?? new StubRenderer();
26
26
}
27
27
28
28
/**
+1
-1
src/Generator/DocBlockGenerator.php
+1
-1
src/Generator/DocBlockGenerator.php
+2
-2
src/Generator/MethodGenerator.php
+2
-2
src/Generator/MethodGenerator.php
···
35
35
?StubRenderer $renderer = null,
36
36
?ModelMapper $modelMapper = null
37
37
) {
38
-
$this->naming = $naming ?? new NamingConverter;
38
+
$this->naming = $naming ?? new NamingConverter();
39
39
$this->typeMapper = $typeMapper ?? new TypeMapper($this->naming);
40
-
$this->renderer = $renderer ?? new StubRenderer;
40
+
$this->renderer = $renderer ?? new StubRenderer();
41
41
$this->modelMapper = $modelMapper ?? new ModelMapper($this->naming, $this->typeMapper);
42
42
}
43
43
+1
-1
src/Generator/ModelMapper.php
+1
-1
src/Generator/ModelMapper.php
···
19
19
*/
20
20
public function __construct(?NamingConverter $naming = null, ?TypeMapper $typeMapper = null)
21
21
{
22
-
$this->naming = $naming ?? new NamingConverter;
22
+
$this->naming = $naming ?? new NamingConverter();
23
23
$this->typeMapper = $typeMapper ?? new TypeMapper($this->naming);
24
24
}
25
25
+2
-2
src/Generator/PropertyGenerator.php
+2
-2
src/Generator/PropertyGenerator.php
···
19
19
*/
20
20
public function __construct(?TypeMapper $typeMapper = null, ?StubRenderer $renderer = null)
21
21
{
22
-
$this->typeMapper = $typeMapper ?? new TypeMapper;
23
-
$this->renderer = $renderer ?? new StubRenderer;
22
+
$this->typeMapper = $typeMapper ?? new TypeMapper();
23
+
$this->renderer = $renderer ?? new StubRenderer();
24
24
}
25
25
26
26
/**
+1
-1
src/Generator/TypeMapper.php
+1
-1
src/Generator/TypeMapper.php
+446
src/Validation/Validator.php
+446
src/Validation/Validator.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\Schema\Validation;
4
+
5
+
use SocialDept\Schema\Contracts\LexiconValidator as LexiconValidatorContract;
6
+
use SocialDept\Schema\Data\LexiconDocument;
7
+
use SocialDept\Schema\Exceptions\RecordValidationException;
8
+
use SocialDept\Schema\Exceptions\SchemaValidationException;
9
+
use SocialDept\Schema\Parser\SchemaLoader;
10
+
use SocialDept\Schema\Parser\TypeParser;
11
+
12
+
class Validator implements LexiconValidatorContract
13
+
{
14
+
/**
15
+
* Validation mode constants.
16
+
*/
17
+
public const MODE_STRICT = 'strict';
18
+
19
+
public const MODE_OPTIMISTIC = 'optimistic';
20
+
21
+
public const MODE_LENIENT = 'lenient';
22
+
23
+
/**
24
+
* Schema loader for loading lexicon documents.
25
+
*/
26
+
protected SchemaLoader $schemaLoader;
27
+
28
+
/**
29
+
* Type parser for parsing and resolving types.
30
+
*/
31
+
protected TypeParser $typeParser;
32
+
33
+
/**
34
+
* Validation mode.
35
+
*/
36
+
protected string $mode = self::MODE_STRICT;
37
+
38
+
/**
39
+
* Collected validation errors.
40
+
*
41
+
* @var array<string, array<string>>
42
+
*/
43
+
protected array $errors = [];
44
+
45
+
/**
46
+
* Create a new Validator.
47
+
*/
48
+
public function __construct(
49
+
SchemaLoader $schemaLoader,
50
+
?TypeParser $typeParser = null
51
+
) {
52
+
$this->schemaLoader = $schemaLoader;
53
+
$this->typeParser = $typeParser ?? new TypeParser(schemaLoader: $schemaLoader);
54
+
}
55
+
56
+
/**
57
+
* Validate data against Lexicon schema.
58
+
*/
59
+
public function validate(array $data, LexiconDocument $schema): bool
60
+
{
61
+
$this->errors = [];
62
+
63
+
try {
64
+
$this->validateData($data, $schema);
65
+
66
+
return empty($this->errors);
67
+
} catch (RecordValidationException|SchemaValidationException) {
68
+
return false;
69
+
}
70
+
}
71
+
72
+
/**
73
+
* Validate and return errors.
74
+
*
75
+
* @return array<string, array<string>>
76
+
*/
77
+
public function validateWithErrors(array $data, LexiconDocument $schema): array
78
+
{
79
+
$this->errors = [];
80
+
81
+
try {
82
+
$this->validateData($data, $schema);
83
+
84
+
return $this->errors;
85
+
} catch (RecordValidationException $e) {
86
+
return ['_root' => [$e->getMessage()]];
87
+
} catch (SchemaValidationException $e) {
88
+
return ['_schema' => [$e->getMessage()]];
89
+
}
90
+
}
91
+
92
+
/**
93
+
* Validate a specific field.
94
+
*/
95
+
public function validateField(mixed $value, string $field, LexiconDocument $schema): bool
96
+
{
97
+
$this->errors = [];
98
+
99
+
try {
100
+
$mainDef = $schema->getMainDefinition();
101
+
102
+
if ($mainDef === null) {
103
+
return false;
104
+
}
105
+
106
+
$properties = $this->extractProperties($mainDef);
107
+
108
+
if (! isset($properties[$field])) {
109
+
if ($this->mode === self::MODE_STRICT) {
110
+
$this->addError($field, "Field '{$field}' is not defined in schema");
111
+
112
+
return false;
113
+
}
114
+
115
+
return true;
116
+
}
117
+
118
+
$this->validateProperty($value, $field, $properties[$field], $schema);
119
+
120
+
return empty($this->errors);
121
+
} catch (RecordValidationException) {
122
+
return false;
123
+
}
124
+
}
125
+
126
+
/**
127
+
* Set validation mode (strict, optimistic, lenient).
128
+
*/
129
+
public function setMode(string $mode): void
130
+
{
131
+
if (! in_array($mode, [self::MODE_STRICT, self::MODE_OPTIMISTIC, self::MODE_LENIENT])) {
132
+
throw new \InvalidArgumentException("Invalid validation mode: {$mode}");
133
+
}
134
+
135
+
$this->mode = $mode;
136
+
}
137
+
138
+
/**
139
+
* Get current validation mode.
140
+
*/
141
+
public function getMode(): string
142
+
{
143
+
return $this->mode;
144
+
}
145
+
146
+
/**
147
+
* Validate data against schema.
148
+
*/
149
+
protected function validateData(array $data, LexiconDocument $schema): void
150
+
{
151
+
$mainDef = $schema->getMainDefinition();
152
+
153
+
if ($mainDef === null) {
154
+
throw SchemaValidationException::invalidStructure(
155
+
$schema->getNsid(),
156
+
['Missing main definition']
157
+
);
158
+
}
159
+
160
+
$type = $mainDef['type'] ?? null;
161
+
162
+
// Only validate if it's a record or object type
163
+
if ($type !== 'record' && $type !== 'object') {
164
+
throw SchemaValidationException::invalidStructure(
165
+
$schema->getNsid(),
166
+
['Schema must be a record or object type, got: ' . ($type ?? 'unknown')]
167
+
);
168
+
}
169
+
170
+
$properties = $this->extractProperties($mainDef);
171
+
$required = $this->extractRequired($mainDef);
172
+
173
+
// Validate required fields
174
+
$this->validateRequired($data, $required);
175
+
176
+
// Validate defined properties
177
+
foreach ($properties as $name => $propDef) {
178
+
if (array_key_exists($name, $data)) {
179
+
$this->validateProperty($data[$name], $name, $propDef, $schema);
180
+
}
181
+
}
182
+
183
+
// Check for unknown fields
184
+
if ($this->mode === self::MODE_STRICT) {
185
+
$this->validateNoUnknownFields($data, array_keys($properties));
186
+
}
187
+
}
188
+
189
+
/**
190
+
* Extract properties from definition.
191
+
*
192
+
* @param array<string, mixed> $definition
193
+
* @return array<string, array<string, mixed>>
194
+
*/
195
+
protected function extractProperties(array $definition): array
196
+
{
197
+
// Handle record type
198
+
if (isset($definition['record']) && is_array($definition['record'])) {
199
+
return $definition['record']['properties'] ?? [];
200
+
}
201
+
202
+
// Handle object type
203
+
if ($definition['type'] === 'object' || isset($definition['properties'])) {
204
+
return $definition['properties'] ?? [];
205
+
}
206
+
207
+
return [];
208
+
}
209
+
210
+
/**
211
+
* Extract required fields from definition.
212
+
*
213
+
* @param array<string, mixed> $definition
214
+
* @return array<string>
215
+
*/
216
+
protected function extractRequired(array $definition): array
217
+
{
218
+
// Handle record type
219
+
if (isset($definition['record']) && is_array($definition['record'])) {
220
+
return $definition['record']['required'] ?? [];
221
+
}
222
+
223
+
// Handle object type
224
+
return $definition['required'] ?? [];
225
+
}
226
+
227
+
/**
228
+
* Validate required fields are present.
229
+
*
230
+
* @param array<string> $required
231
+
*/
232
+
protected function validateRequired(array $data, array $required): void
233
+
{
234
+
if ($this->mode === self::MODE_LENIENT) {
235
+
return;
236
+
}
237
+
238
+
foreach ($required as $field) {
239
+
if (! array_key_exists($field, $data)) {
240
+
$this->addError($field, "Required field '{$field}' is missing");
241
+
}
242
+
}
243
+
}
244
+
245
+
/**
246
+
* Validate a single property.
247
+
*
248
+
* @param array<string, mixed> $propDef
249
+
*/
250
+
protected function validateProperty(mixed $value, string $name, array $propDef, LexiconDocument $schema): void
251
+
{
252
+
try {
253
+
$type = $propDef['type'] ?? 'unknown';
254
+
255
+
// Basic type validation
256
+
$this->validateType($value, $type, $name);
257
+
258
+
// Constraint validation (skip in lenient mode)
259
+
if ($this->mode !== self::MODE_LENIENT) {
260
+
$this->validateConstraints($value, $propDef, $name);
261
+
}
262
+
263
+
// Nested object validation
264
+
if ($type === 'object' && is_array($value)) {
265
+
$this->validateNestedObject($value, $propDef, $name, $schema);
266
+
}
267
+
268
+
// Array validation
269
+
if ($type === 'array' && is_array($value)) {
270
+
$this->validateArray($value, $propDef, $name, $schema);
271
+
}
272
+
} catch (RecordValidationException $e) {
273
+
$this->addError($name, $e->getMessage());
274
+
}
275
+
}
276
+
277
+
/**
278
+
* Validate value type.
279
+
*/
280
+
protected function validateType(mixed $value, string $expectedType, string $fieldName): void
281
+
{
282
+
$actualType = gettype($value);
283
+
284
+
$valid = match ($expectedType) {
285
+
'string' => is_string($value),
286
+
'integer' => is_int($value),
287
+
'boolean' => is_bool($value),
288
+
'number' => is_numeric($value),
289
+
'object' => is_array($value),
290
+
'array' => is_array($value),
291
+
'null' => is_null($value),
292
+
default => true, // Unknown types pass in optimistic/lenient modes
293
+
};
294
+
295
+
if (! $valid) {
296
+
$this->addError($fieldName, "Expected type '{$expectedType}', got '{$actualType}'");
297
+
}
298
+
}
299
+
300
+
/**
301
+
* Validate field constraints.
302
+
*
303
+
* @param array<string, mixed> $propDef
304
+
*/
305
+
protected function validateConstraints(mixed $value, array $propDef, string $fieldName): void
306
+
{
307
+
// String length constraints
308
+
if (is_string($value)) {
309
+
if (isset($propDef['maxLength']) && strlen($value) > $propDef['maxLength']) {
310
+
$this->addError($fieldName, "String exceeds maximum length of {$propDef['maxLength']}");
311
+
}
312
+
313
+
if (isset($propDef['minLength']) && strlen($value) < $propDef['minLength']) {
314
+
$this->addError($fieldName, "String is shorter than minimum length of {$propDef['minLength']}");
315
+
}
316
+
317
+
if (isset($propDef['maxGraphemes']) && grapheme_strlen($value) > $propDef['maxGraphemes']) {
318
+
$this->addError($fieldName, "String exceeds maximum graphemes of {$propDef['maxGraphemes']}");
319
+
}
320
+
321
+
if (isset($propDef['minGraphemes']) && grapheme_strlen($value) < $propDef['minGraphemes']) {
322
+
$this->addError($fieldName, "String has fewer than minimum graphemes of {$propDef['minGraphemes']}");
323
+
}
324
+
}
325
+
326
+
// Number constraints
327
+
if (is_numeric($value)) {
328
+
if (isset($propDef['maximum']) && $value > $propDef['maximum']) {
329
+
$this->addError($fieldName, "Value exceeds maximum of {$propDef['maximum']}");
330
+
}
331
+
332
+
if (isset($propDef['minimum']) && $value < $propDef['minimum']) {
333
+
$this->addError($fieldName, "Value is less than minimum of {$propDef['minimum']}");
334
+
}
335
+
}
336
+
337
+
// Array constraints
338
+
if (is_array($value)) {
339
+
$count = count($value);
340
+
341
+
if (isset($propDef['maxItems']) && $count > $propDef['maxItems']) {
342
+
$this->addError($fieldName, "Array exceeds maximum items of {$propDef['maxItems']}");
343
+
}
344
+
345
+
if (isset($propDef['minItems']) && $count < $propDef['minItems']) {
346
+
$this->addError($fieldName, "Array has fewer than minimum items of {$propDef['minItems']}");
347
+
}
348
+
}
349
+
350
+
// Enum constraint
351
+
if (isset($propDef['enum']) && ! in_array($value, $propDef['enum'], true)) {
352
+
$allowedValues = implode(', ', $propDef['enum']);
353
+
$this->addError($fieldName, "Value must be one of: {$allowedValues}");
354
+
}
355
+
356
+
// Const constraint
357
+
if (isset($propDef['const']) && $value !== $propDef['const']) {
358
+
$expectedValue = json_encode($propDef['const']);
359
+
$this->addError($fieldName, "Value must be {$expectedValue}");
360
+
}
361
+
}
362
+
363
+
/**
364
+
* Validate nested object.
365
+
*
366
+
* @param array<string, mixed> $propDef
367
+
*/
368
+
protected function validateNestedObject(array $value, array $propDef, string $fieldName, LexiconDocument $schema): void
369
+
{
370
+
$nestedProperties = $propDef['properties'] ?? [];
371
+
$nestedRequired = $propDef['required'] ?? [];
372
+
373
+
// Create temporary document for nested validation
374
+
$nestedDoc = new LexiconDocument(
375
+
lexicon: 1,
376
+
id: $schema->id,
377
+
defs: ['main' => [
378
+
'type' => 'object',
379
+
'properties' => $nestedProperties,
380
+
'required' => $nestedRequired,
381
+
]],
382
+
description: null,
383
+
source: null,
384
+
raw: []
385
+
);
386
+
387
+
$originalErrors = $this->errors;
388
+
$this->errors = [];
389
+
390
+
$this->validateData($value, $nestedDoc);
391
+
392
+
// Prefix nested errors with field name
393
+
foreach ($this->errors as $nestedField => $messages) {
394
+
foreach ($messages as $message) {
395
+
$this->addError("{$fieldName}.{$nestedField}", $message);
396
+
}
397
+
}
398
+
399
+
$this->errors = array_merge($originalErrors, $this->errors);
400
+
}
401
+
402
+
/**
403
+
* Validate array items.
404
+
*
405
+
* @param array<string, mixed> $propDef
406
+
*/
407
+
protected function validateArray(array $value, array $propDef, string $fieldName, LexiconDocument $schema): void
408
+
{
409
+
if (! isset($propDef['items'])) {
410
+
return;
411
+
}
412
+
413
+
$itemDef = $propDef['items'];
414
+
415
+
foreach ($value as $index => $item) {
416
+
$itemFieldName = "{$fieldName}[{$index}]";
417
+
$this->validateProperty($item, $itemFieldName, $itemDef, $schema);
418
+
}
419
+
}
420
+
421
+
/**
422
+
* Validate no unknown fields are present.
423
+
*
424
+
* @param array<string> $allowedFields
425
+
*/
426
+
protected function validateNoUnknownFields(array $data, array $allowedFields): void
427
+
{
428
+
foreach (array_keys($data) as $field) {
429
+
if (! in_array($field, $allowedFields)) {
430
+
$this->addError($field, "Unknown field '{$field}' is not allowed");
431
+
}
432
+
}
433
+
}
434
+
435
+
/**
436
+
* Add a validation error.
437
+
*/
438
+
protected function addError(string $field, string $message): void
439
+
{
440
+
if (! isset($this->errors[$field])) {
441
+
$this->errors[$field] = [];
442
+
}
443
+
444
+
$this->errors[$field][] = $message;
445
+
}
446
+
}
+1
-1
tests/Unit/Generator/ClassGeneratorTest.php
+1
-1
tests/Unit/Generator/ClassGeneratorTest.php
+1
-1
tests/Unit/Generator/ConstructorGeneratorTest.php
+1
-1
tests/Unit/Generator/ConstructorGeneratorTest.php
+1
-1
tests/Unit/Generator/DocBlockGeneratorTest.php
+1
-1
tests/Unit/Generator/DocBlockGeneratorTest.php
+1
-1
tests/Unit/Generator/MethodGeneratorTest.php
+1
-1
tests/Unit/Generator/MethodGeneratorTest.php
+2
-1
tests/Unit/Generator/ModelMapperTest.php
+2
-1
tests/Unit/Generator/ModelMapperTest.php
···
13
13
{
14
14
parent::setUp();
15
15
16
-
$this->mapper = new ModelMapper;
16
+
$this->mapper = new ModelMapper();
17
17
}
18
18
19
19
public function test_it_generates_to_model_body_for_simple_properties(): void
···
281
281
foreach ($lines as $line) {
282
282
if (str_contains($line, 'last:')) {
283
283
$lastPropertyLine = $line;
284
+
284
285
break;
285
286
}
286
287
}
+1
-1
tests/Unit/Generator/PropertyGeneratorTest.php
+1
-1
tests/Unit/Generator/PropertyGeneratorTest.php
+577
tests/Unit/Validation/ValidatorTest.php
+577
tests/Unit/Validation/ValidatorTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\Schema\Tests\Unit\Validation;
4
+
5
+
use Orchestra\Testbench\TestCase;
6
+
use SocialDept\Schema\Data\LexiconDocument;
7
+
use SocialDept\Schema\Parser\Nsid;
8
+
use SocialDept\Schema\Parser\SchemaLoader;
9
+
use SocialDept\Schema\Validation\Validator;
10
+
11
+
class ValidatorTest extends TestCase
12
+
{
13
+
protected Validator $validator;
14
+
15
+
protected SchemaLoader $loader;
16
+
17
+
protected function setUp(): void
18
+
{
19
+
parent::setUp();
20
+
21
+
$fixturesPath = __DIR__.'/../../fixtures';
22
+
$this->loader = new SchemaLoader([$fixturesPath], false);
23
+
$this->validator = new Validator($this->loader);
24
+
}
25
+
26
+
public function test_it_validates_valid_data(): void
27
+
{
28
+
$document = $this->createDocument([
29
+
'type' => 'record',
30
+
'record' => [
31
+
'type' => 'object',
32
+
'required' => ['name'],
33
+
'properties' => [
34
+
'name' => ['type' => 'string'],
35
+
'age' => ['type' => 'integer'],
36
+
],
37
+
],
38
+
]);
39
+
40
+
$data = ['name' => 'John', 'age' => 30];
41
+
42
+
$this->assertTrue($this->validator->validate($data, $document));
43
+
}
44
+
45
+
public function test_it_rejects_missing_required_field(): void
46
+
{
47
+
$document = $this->createDocument([
48
+
'type' => 'record',
49
+
'record' => [
50
+
'type' => 'object',
51
+
'required' => ['name', 'email'],
52
+
'properties' => [
53
+
'name' => ['type' => 'string'],
54
+
'email' => ['type' => 'string'],
55
+
],
56
+
],
57
+
]);
58
+
59
+
$data = ['name' => 'John'];
60
+
61
+
$this->assertFalse($this->validator->validate($data, $document));
62
+
63
+
$errors = $this->validator->validateWithErrors($data, $document);
64
+
$this->assertArrayHasKey('email', $errors);
65
+
$this->assertStringContainsString('Required', $errors['email'][0]);
66
+
}
67
+
68
+
public function test_it_validates_type_mismatch(): void
69
+
{
70
+
$document = $this->createDocument([
71
+
'type' => 'record',
72
+
'record' => [
73
+
'type' => 'object',
74
+
'required' => ['age'],
75
+
'properties' => [
76
+
'age' => ['type' => 'integer'],
77
+
],
78
+
],
79
+
]);
80
+
81
+
$data = ['age' => 'not a number'];
82
+
83
+
$this->assertFalse($this->validator->validate($data, $document));
84
+
85
+
$errors = $this->validator->validateWithErrors($data, $document);
86
+
$this->assertArrayHasKey('age', $errors);
87
+
}
88
+
89
+
public function test_it_validates_string_max_length(): void
90
+
{
91
+
$document = $this->createDocument([
92
+
'type' => 'record',
93
+
'record' => [
94
+
'type' => 'object',
95
+
'required' => ['text'],
96
+
'properties' => [
97
+
'text' => [
98
+
'type' => 'string',
99
+
'maxLength' => 10,
100
+
],
101
+
],
102
+
],
103
+
]);
104
+
105
+
$data = ['text' => 'This is way too long'];
106
+
107
+
$this->assertFalse($this->validator->validate($data, $document));
108
+
109
+
$errors = $this->validator->validateWithErrors($data, $document);
110
+
$this->assertArrayHasKey('text', $errors);
111
+
$this->assertStringContainsString('maximum length', $errors['text'][0]);
112
+
}
113
+
114
+
public function test_it_validates_string_min_length(): void
115
+
{
116
+
$document = $this->createDocument([
117
+
'type' => 'record',
118
+
'record' => [
119
+
'type' => 'object',
120
+
'required' => ['text'],
121
+
'properties' => [
122
+
'text' => [
123
+
'type' => 'string',
124
+
'minLength' => 5,
125
+
],
126
+
],
127
+
],
128
+
]);
129
+
130
+
$data = ['text' => 'Hi'];
131
+
132
+
$this->assertFalse($this->validator->validate($data, $document));
133
+
134
+
$errors = $this->validator->validateWithErrors($data, $document);
135
+
$this->assertArrayHasKey('text', $errors);
136
+
$this->assertStringContainsString('minimum length', $errors['text'][0]);
137
+
}
138
+
139
+
public function test_it_validates_grapheme_constraints(): void
140
+
{
141
+
$document = $this->createDocument([
142
+
'type' => 'record',
143
+
'record' => [
144
+
'type' => 'object',
145
+
'required' => ['text'],
146
+
'properties' => [
147
+
'text' => [
148
+
'type' => 'string',
149
+
'maxGraphemes' => 5,
150
+
],
151
+
],
152
+
],
153
+
]);
154
+
155
+
$data = ['text' => '😀😁😂😃😄😅']; // 6 graphemes
156
+
157
+
$this->assertFalse($this->validator->validate($data, $document));
158
+
}
159
+
160
+
public function test_it_validates_number_maximum(): void
161
+
{
162
+
$document = $this->createDocument([
163
+
'type' => 'record',
164
+
'record' => [
165
+
'type' => 'object',
166
+
'required' => ['count'],
167
+
'properties' => [
168
+
'count' => [
169
+
'type' => 'integer',
170
+
'maximum' => 100,
171
+
],
172
+
],
173
+
],
174
+
]);
175
+
176
+
$data = ['count' => 150];
177
+
178
+
$this->assertFalse($this->validator->validate($data, $document));
179
+
180
+
$errors = $this->validator->validateWithErrors($data, $document);
181
+
$this->assertArrayHasKey('count', $errors);
182
+
$this->assertStringContainsString('maximum', $errors['count'][0]);
183
+
}
184
+
185
+
public function test_it_validates_number_minimum(): void
186
+
{
187
+
$document = $this->createDocument([
188
+
'type' => 'record',
189
+
'record' => [
190
+
'type' => 'object',
191
+
'required' => ['count'],
192
+
'properties' => [
193
+
'count' => [
194
+
'type' => 'integer',
195
+
'minimum' => 10,
196
+
],
197
+
],
198
+
],
199
+
]);
200
+
201
+
$data = ['count' => 5];
202
+
203
+
$this->assertFalse($this->validator->validate($data, $document));
204
+
205
+
$errors = $this->validator->validateWithErrors($data, $document);
206
+
$this->assertArrayHasKey('count', $errors);
207
+
$this->assertStringContainsString('minimum', $errors['count'][0]);
208
+
}
209
+
210
+
public function test_it_validates_array_max_items(): void
211
+
{
212
+
$document = $this->createDocument([
213
+
'type' => 'record',
214
+
'record' => [
215
+
'type' => 'object',
216
+
'required' => ['items'],
217
+
'properties' => [
218
+
'items' => [
219
+
'type' => 'array',
220
+
'maxItems' => 3,
221
+
'items' => ['type' => 'string'],
222
+
],
223
+
],
224
+
],
225
+
]);
226
+
227
+
$data = ['items' => ['a', 'b', 'c', 'd']];
228
+
229
+
$this->assertFalse($this->validator->validate($data, $document));
230
+
231
+
$errors = $this->validator->validateWithErrors($data, $document);
232
+
$this->assertArrayHasKey('items', $errors);
233
+
$this->assertStringContainsString('maximum items', $errors['items'][0]);
234
+
}
235
+
236
+
public function test_it_validates_array_min_items(): void
237
+
{
238
+
$document = $this->createDocument([
239
+
'type' => 'record',
240
+
'record' => [
241
+
'type' => 'object',
242
+
'required' => ['items'],
243
+
'properties' => [
244
+
'items' => [
245
+
'type' => 'array',
246
+
'minItems' => 2,
247
+
'items' => ['type' => 'string'],
248
+
],
249
+
],
250
+
],
251
+
]);
252
+
253
+
$data = ['items' => ['a']];
254
+
255
+
$this->assertFalse($this->validator->validate($data, $document));
256
+
257
+
$errors = $this->validator->validateWithErrors($data, $document);
258
+
$this->assertArrayHasKey('items', $errors);
259
+
$this->assertStringContainsString('minimum items', $errors['items'][0]);
260
+
}
261
+
262
+
public function test_it_validates_enum_constraint(): void
263
+
{
264
+
$document = $this->createDocument([
265
+
'type' => 'record',
266
+
'record' => [
267
+
'type' => 'object',
268
+
'required' => ['status'],
269
+
'properties' => [
270
+
'status' => [
271
+
'type' => 'string',
272
+
'enum' => ['active', 'inactive', 'pending'],
273
+
],
274
+
],
275
+
],
276
+
]);
277
+
278
+
$data = ['status' => 'unknown'];
279
+
280
+
$this->assertFalse($this->validator->validate($data, $document));
281
+
282
+
$errors = $this->validator->validateWithErrors($data, $document);
283
+
$this->assertArrayHasKey('status', $errors);
284
+
$this->assertStringContainsString('one of:', $errors['status'][0]);
285
+
}
286
+
287
+
public function test_it_validates_const_constraint(): void
288
+
{
289
+
$document = $this->createDocument([
290
+
'type' => 'record',
291
+
'record' => [
292
+
'type' => 'object',
293
+
'required' => ['type'],
294
+
'properties' => [
295
+
'type' => [
296
+
'type' => 'string',
297
+
'const' => 'post',
298
+
],
299
+
],
300
+
],
301
+
]);
302
+
303
+
$data = ['type' => 'comment'];
304
+
305
+
$this->assertFalse($this->validator->validate($data, $document));
306
+
307
+
$errors = $this->validator->validateWithErrors($data, $document);
308
+
$this->assertArrayHasKey('type', $errors);
309
+
}
310
+
311
+
public function test_it_validates_nested_objects(): void
312
+
{
313
+
$document = $this->createDocument([
314
+
'type' => 'record',
315
+
'record' => [
316
+
'type' => 'object',
317
+
'required' => ['author'],
318
+
'properties' => [
319
+
'author' => [
320
+
'type' => 'object',
321
+
'required' => ['name'],
322
+
'properties' => [
323
+
'name' => ['type' => 'string'],
324
+
'email' => ['type' => 'string'],
325
+
],
326
+
],
327
+
],
328
+
],
329
+
]);
330
+
331
+
$data = ['author' => ['email' => 'john@example.com']];
332
+
333
+
$this->assertFalse($this->validator->validate($data, $document));
334
+
335
+
$errors = $this->validator->validateWithErrors($data, $document);
336
+
$this->assertArrayHasKey('author.name', $errors);
337
+
}
338
+
339
+
public function test_it_validates_array_items(): void
340
+
{
341
+
$document = $this->createDocument([
342
+
'type' => 'record',
343
+
'record' => [
344
+
'type' => 'object',
345
+
'required' => ['tags'],
346
+
'properties' => [
347
+
'tags' => [
348
+
'type' => 'array',
349
+
'items' => [
350
+
'type' => 'string',
351
+
'maxLength' => 10,
352
+
],
353
+
],
354
+
],
355
+
],
356
+
]);
357
+
358
+
$data = ['tags' => ['short', 'this is way too long']];
359
+
360
+
$this->assertFalse($this->validator->validate($data, $document));
361
+
362
+
$errors = $this->validator->validateWithErrors($data, $document);
363
+
$this->assertArrayHasKey('tags[1]', $errors);
364
+
}
365
+
366
+
public function test_strict_mode_rejects_unknown_fields(): void
367
+
{
368
+
$document = $this->createDocument([
369
+
'type' => 'record',
370
+
'record' => [
371
+
'type' => 'object',
372
+
'required' => ['name'],
373
+
'properties' => [
374
+
'name' => ['type' => 'string'],
375
+
],
376
+
],
377
+
]);
378
+
379
+
$data = ['name' => 'John', 'unknown' => 'value'];
380
+
381
+
$this->validator->setMode(Validator::MODE_STRICT);
382
+
$this->assertFalse($this->validator->validate($data, $document));
383
+
384
+
$errors = $this->validator->validateWithErrors($data, $document);
385
+
$this->assertArrayHasKey('unknown', $errors);
386
+
}
387
+
388
+
public function test_optimistic_mode_allows_unknown_fields(): void
389
+
{
390
+
$document = $this->createDocument([
391
+
'type' => 'record',
392
+
'record' => [
393
+
'type' => 'object',
394
+
'required' => ['name'],
395
+
'properties' => [
396
+
'name' => ['type' => 'string'],
397
+
],
398
+
],
399
+
]);
400
+
401
+
$data = ['name' => 'John', 'unknown' => 'value'];
402
+
403
+
$this->validator->setMode(Validator::MODE_OPTIMISTIC);
404
+
$this->assertTrue($this->validator->validate($data, $document));
405
+
}
406
+
407
+
public function test_lenient_mode_skips_required_validation(): void
408
+
{
409
+
$document = $this->createDocument([
410
+
'type' => 'record',
411
+
'record' => [
412
+
'type' => 'object',
413
+
'required' => ['name', 'email'],
414
+
'properties' => [
415
+
'name' => ['type' => 'string'],
416
+
'email' => ['type' => 'string'],
417
+
],
418
+
],
419
+
]);
420
+
421
+
$data = ['name' => 'John'];
422
+
423
+
$this->validator->setMode(Validator::MODE_LENIENT);
424
+
$this->assertTrue($this->validator->validate($data, $document));
425
+
}
426
+
427
+
public function test_lenient_mode_skips_constraint_validation(): void
428
+
{
429
+
$document = $this->createDocument([
430
+
'type' => 'record',
431
+
'record' => [
432
+
'type' => 'object',
433
+
'required' => ['text'],
434
+
'properties' => [
435
+
'text' => [
436
+
'type' => 'string',
437
+
'maxLength' => 5,
438
+
],
439
+
],
440
+
],
441
+
]);
442
+
443
+
$data = ['text' => 'This is way too long'];
444
+
445
+
$this->validator->setMode(Validator::MODE_LENIENT);
446
+
$this->assertTrue($this->validator->validate($data, $document));
447
+
}
448
+
449
+
public function test_it_validates_specific_field(): void
450
+
{
451
+
$document = $this->createDocument([
452
+
'type' => 'record',
453
+
'record' => [
454
+
'type' => 'object',
455
+
'required' => ['name'],
456
+
'properties' => [
457
+
'name' => [
458
+
'type' => 'string',
459
+
'maxLength' => 50,
460
+
],
461
+
'age' => ['type' => 'integer'],
462
+
],
463
+
],
464
+
]);
465
+
466
+
$this->assertTrue($this->validator->validateField('John', 'name', $document));
467
+
$this->assertFalse($this->validator->validateField('not a number', 'age', $document));
468
+
}
469
+
470
+
public function test_it_validates_field_constraints(): void
471
+
{
472
+
$document = $this->createDocument([
473
+
'type' => 'record',
474
+
'record' => [
475
+
'type' => 'object',
476
+
'required' => ['name'],
477
+
'properties' => [
478
+
'name' => [
479
+
'type' => 'string',
480
+
'maxLength' => 5,
481
+
],
482
+
],
483
+
],
484
+
]);
485
+
486
+
$this->assertFalse($this->validator->validateField('John Doe', 'name', $document));
487
+
}
488
+
489
+
public function test_it_rejects_invalid_validation_mode(): void
490
+
{
491
+
$this->expectException(\InvalidArgumentException::class);
492
+
493
+
$this->validator->setMode('invalid');
494
+
}
495
+
496
+
public function test_it_returns_current_mode(): void
497
+
{
498
+
$this->assertEquals(Validator::MODE_STRICT, $this->validator->getMode());
499
+
500
+
$this->validator->setMode(Validator::MODE_LENIENT);
501
+
$this->assertEquals(Validator::MODE_LENIENT, $this->validator->getMode());
502
+
}
503
+
504
+
public function test_it_returns_empty_errors_for_valid_data(): void
505
+
{
506
+
$document = $this->createDocument([
507
+
'type' => 'record',
508
+
'record' => [
509
+
'type' => 'object',
510
+
'required' => ['name'],
511
+
'properties' => [
512
+
'name' => ['type' => 'string'],
513
+
],
514
+
],
515
+
]);
516
+
517
+
$data = ['name' => 'John'];
518
+
519
+
$errors = $this->validator->validateWithErrors($data, $document);
520
+
$this->assertEmpty($errors);
521
+
}
522
+
523
+
public function test_it_validates_object_type_definition(): void
524
+
{
525
+
$document = $this->createDocument([
526
+
'type' => 'object',
527
+
'required' => ['name'],
528
+
'properties' => [
529
+
'name' => ['type' => 'string'],
530
+
],
531
+
]);
532
+
533
+
$data = ['name' => 'John'];
534
+
535
+
$this->assertTrue($this->validator->validate($data, $document));
536
+
}
537
+
538
+
public function test_it_validates_multiple_errors(): void
539
+
{
540
+
$document = $this->createDocument([
541
+
'type' => 'record',
542
+
'record' => [
543
+
'type' => 'object',
544
+
'required' => ['name', 'age', 'email'],
545
+
'properties' => [
546
+
'name' => ['type' => 'string'],
547
+
'age' => ['type' => 'integer'],
548
+
'email' => ['type' => 'string'],
549
+
],
550
+
],
551
+
]);
552
+
553
+
$data = ['name' => 'John'];
554
+
555
+
$errors = $this->validator->validateWithErrors($data, $document);
556
+
$this->assertCount(2, $errors); // Missing age and email
557
+
$this->assertArrayHasKey('age', $errors);
558
+
$this->assertArrayHasKey('email', $errors);
559
+
}
560
+
561
+
/**
562
+
* Helper to create a test document.
563
+
*
564
+
* @param array<string, mixed> $mainDef
565
+
*/
566
+
protected function createDocument(array $mainDef): LexiconDocument
567
+
{
568
+
return new LexiconDocument(
569
+
lexicon: 1,
570
+
id: Nsid::parse('com.example.test'),
571
+
defs: ['main' => $mainDef],
572
+
description: null,
573
+
source: null,
574
+
raw: []
575
+
);
576
+
}
577
+
}