Skip to content

Commit 66b85b4

Browse files
committed
feat: add strict mode to validate no extra fields
Add optional strict mode to throw exception on unknown fields in input data. This is useful for API validation where you want to ensure only expected fields are sent. Features: - Global static property Struct::$strictMode (default: false) - Works with field aliases (both property name and alias allowed) - Backward compatible (disabled by default) Usage: Struct::$strictMode = true; $struct = new MyStruct([ 'known_field' => 'value', 'unknown_field' => 'throws RuntimeException', ]); Test coverage: 10 new tests (76 total, 93.98% coverage)
1 parent 2e13ee4 commit 66b85b4

3 files changed

Lines changed: 270 additions & 0 deletions

File tree

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,40 @@ echo $user->age; // 30 (original unchanged)
313313
echo $updated->age; // 31 (new instance)
314314
```
315315

316+
### Strict Mode (Validate No Extra Fields)
317+
318+
```php
319+
use tommyknocker\struct\Struct;
320+
321+
// Enable strict mode globally
322+
Struct::$strictMode = true;
323+
324+
final class ApiRequest extends Struct
325+
{
326+
#[Field('string')]
327+
public readonly string $username;
328+
329+
#[Field('string')]
330+
public readonly string $email;
331+
}
332+
333+
// ✅ Valid - all fields are known
334+
$request = new ApiRequest([
335+
'username' => 'john',
336+
'email' => 'john@example.com',
337+
]);
338+
339+
// ❌ Throws RuntimeException: Unknown field: extra_field
340+
$request = new ApiRequest([
341+
'username' => 'john',
342+
'email' => 'john@example.com',
343+
'extra_field' => 'not allowed!',
344+
]);
345+
346+
// Disable strict mode (default behavior - extra fields ignored)
347+
Struct::$strictMode = false;
348+
```
349+
316350
### PSR-11 Container Integration
317351

318352
```php

src/Struct.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ abstract class Struct implements ArrayAccess, JsonSerializable
1818
{
1919
public static ?ContainerInterface $container = null;
2020

21+
/**
22+
* Enable strict mode to throw exception on unknown fields
23+
* @var bool
24+
*/
25+
public static bool $strictMode = false;
26+
2127
/**
2228
* Cache for reflection data to improve performance
2329
* @var array<string, array<ReflectionProperty>>
@@ -41,6 +47,45 @@ public function __construct(array $data)
4147
foreach (self::$reflectionCache[$className] as $property) {
4248
$this->assignProperty($property, $data);
4349
}
50+
51+
// Check for unknown fields in strict mode
52+
if (self::$strictMode) {
53+
$this->validateNoExtraFields($data);
54+
}
55+
}
56+
57+
/**
58+
* Validate that no extra fields are present in data
59+
* @param array<string, mixed> $data
60+
* @return void
61+
*/
62+
protected function validateNoExtraFields(array $data): void
63+
{
64+
$className = static::class;
65+
$allowedFields = [];
66+
67+
foreach (self::$reflectionCache[$className] as $property) {
68+
$attributes = $property->getAttributes(Field::class);
69+
if (empty($attributes)) {
70+
continue;
71+
}
72+
73+
/** @var Field $field */
74+
$field = $attributes[0]->newInstance();
75+
$name = $property->getName();
76+
77+
// Both property name and alias are allowed
78+
$allowedFields[$name] = true;
79+
if ($field->alias) {
80+
$allowedFields[$field->alias] = true;
81+
}
82+
}
83+
84+
foreach (array_keys($data) as $key) {
85+
if (!isset($allowedFields[$key])) {
86+
throw new RuntimeException("Unknown field: $key");
87+
}
88+
}
4489
}
4590

4691
/**

tests/StructStrictModeTest.php

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace tommyknocker\struct\tests;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use RuntimeException;
9+
use tommyknocker\struct\Field;
10+
use tommyknocker\struct\Struct;
11+
12+
final class SimpleStruct extends Struct
13+
{
14+
#[Field('string')]
15+
public readonly string $name;
16+
17+
#[Field('int')]
18+
public readonly int $age;
19+
}
20+
21+
final class StructWithAlias extends Struct
22+
{
23+
#[Field('string', alias: 'user_name')]
24+
public readonly string $name;
25+
26+
#[Field('int')]
27+
public readonly int $age;
28+
}
29+
30+
final class StructStrictModeTest extends TestCase
31+
{
32+
protected function setUp(): void
33+
{
34+
// Reset strict mode before each test
35+
Struct::$strictMode = false;
36+
}
37+
38+
protected function tearDown(): void
39+
{
40+
// Ensure strict mode is disabled after tests
41+
Struct::$strictMode = false;
42+
}
43+
44+
public function testNonStrictModeAllowsExtraFields(): void
45+
{
46+
Struct::$strictMode = false;
47+
48+
$struct = new SimpleStruct([
49+
'name' => 'John',
50+
'age' => 30,
51+
'extra_field' => 'should be ignored',
52+
'another_field' => 123,
53+
]);
54+
55+
$this->assertSame('John', $struct->name);
56+
$this->assertSame(30, $struct->age);
57+
}
58+
59+
public function testStrictModeThrowsOnExtraField(): void
60+
{
61+
Struct::$strictMode = true;
62+
63+
$this->expectException(RuntimeException::class);
64+
$this->expectExceptionMessage('Unknown field: extra_field');
65+
66+
new SimpleStruct([
67+
'name' => 'John',
68+
'age' => 30,
69+
'extra_field' => 'not allowed!',
70+
]);
71+
}
72+
73+
public function testStrictModeThrowsOnMultipleExtraFields(): void
74+
{
75+
Struct::$strictMode = true;
76+
77+
$this->expectException(RuntimeException::class);
78+
$this->expectExceptionMessage('Unknown field:');
79+
80+
new SimpleStruct([
81+
'name' => 'John',
82+
'age' => 30,
83+
'extra1' => 'value1',
84+
'extra2' => 'value2',
85+
]);
86+
}
87+
88+
public function testStrictModeAllowsValidFields(): void
89+
{
90+
Struct::$strictMode = true;
91+
92+
$struct = new SimpleStruct([
93+
'name' => 'John',
94+
'age' => 30,
95+
]);
96+
97+
$this->assertSame('John', $struct->name);
98+
$this->assertSame(30, $struct->age);
99+
}
100+
101+
public function testStrictModeWithAliasAllowsPropertyName(): void
102+
{
103+
Struct::$strictMode = true;
104+
105+
$struct = new StructWithAlias([
106+
'name' => 'John', // Using property name
107+
'age' => 30,
108+
]);
109+
110+
$this->assertSame('John', $struct->name);
111+
}
112+
113+
public function testStrictModeWithAliasAllowsAliasName(): void
114+
{
115+
Struct::$strictMode = true;
116+
117+
$struct = new StructWithAlias([
118+
'user_name' => 'John', // Using alias
119+
'age' => 30,
120+
]);
121+
122+
$this->assertSame('John', $struct->name);
123+
}
124+
125+
public function testStrictModeWithAliasThrowsOnUnknownField(): void
126+
{
127+
Struct::$strictMode = true;
128+
129+
$this->expectException(RuntimeException::class);
130+
$this->expectExceptionMessage('Unknown field: invalid_field');
131+
132+
new StructWithAlias([
133+
'user_name' => 'John',
134+
'age' => 30,
135+
'invalid_field' => 'not allowed',
136+
]);
137+
}
138+
139+
public function testStrictModeCanBeToggledBetweenInstances(): void
140+
{
141+
// First instance: strict mode off
142+
Struct::$strictMode = false;
143+
$struct1 = new SimpleStruct([
144+
'name' => 'John',
145+
'age' => 30,
146+
'extra' => 'ignored',
147+
]);
148+
$this->assertSame('John', $struct1->name);
149+
150+
// Second instance: strict mode on
151+
Struct::$strictMode = true;
152+
$this->expectException(RuntimeException::class);
153+
new SimpleStruct([
154+
'name' => 'Jane',
155+
'age' => 25,
156+
'extra' => 'will throw',
157+
]);
158+
}
159+
160+
public function testStrictModeWithNullableFields(): void
161+
{
162+
Struct::$strictMode = true;
163+
164+
$struct = new class (['name' => 'test', 'optional' => null]) extends Struct {
165+
#[Field('string')]
166+
public readonly string $name;
167+
168+
#[Field('string', nullable: true)]
169+
public readonly ?string $optional;
170+
};
171+
172+
$this->assertSame('test', $struct->name);
173+
$this->assertNull($struct->optional);
174+
}
175+
176+
public function testStrictModeWithDefaultValues(): void
177+
{
178+
Struct::$strictMode = true;
179+
180+
$struct = new class (['name' => 'test']) extends Struct {
181+
#[Field('string')]
182+
public readonly string $name;
183+
184+
#[Field('int', default: 42)]
185+
public readonly int $value;
186+
};
187+
188+
$this->assertSame('test', $struct->name);
189+
$this->assertSame(42, $struct->value);
190+
}
191+
}

0 commit comments

Comments
 (0)