• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wol-soft / php-json-schema-model-generator / 28628957811

02 Jul 2026 11:52PM UTC coverage: 98.749% (-0.003%) from 98.752%
28628957811

Pull #160

github

wol-soft
Add JSON Pointer (RFC 6901) stamping on all ValidationExceptions

Every ValidationException now carries a getJsonPointer() method (provided
by the production library) that identifies the schema keyword that rejected
the value (e.g. /properties/age/minimum, /patternProperties/^S~0~1).

Changes in the generator:
- All validator factories call ->withJsonPointer() on every validator they
  construct, threading the pointer from the JsonSchema pointer registry
- AbstractPropertyValidator gains withJsonPointer() / getJsonPointer();
  withJsonPointer() forwards resolution to the clone so the validator
  resolution chain works correctly for recursive $ref schemas
- FilterValidator overrides withJsonPointer() to propagate the pointer to
  its embedded filterValueValidator
- MediaStringModifier, EnumPostProcessor, FilterValidatorFactory, and
  ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor all pass
  the appropriate pointer through FilterProcessor::process()
- AdditionalPropertiesAccessorPostProcessor resolves the primary pointer
  from the synthesised #[JsonPointer] attribute on each property
- PhpAttribute gains getArguments() to support the above at generation time
- Documentation updated; all existing pointer-assertion tests are merged
  into their sibling validation tests rather than being standalone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pull Request #160: Add JSON Pointer (RFC 6901) stamping on all ValidationExceptions

168 of 169 new or added lines in 30 files covered. (99.41%)

4 existing lines in 1 file now uncovered.

6473 of 6555 relevant lines covered (98.75%)

570.3 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.4
/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\SchemaProcessor\PostProcessor;
6

7
use PHPModelGenerator\Accessor\AdditionalPropertiesAccessor;
8
use PHPModelGenerator\Accessor\ImmutableAdditionalPropertiesAccessor;
9
use PHPModelGenerator\Attributes\JsonPointer;
10
use PHPModelGenerator\Exception\FileSystemException;
11
use PHPModelGenerator\Exception\Object\MinPropertiesException;
12
use PHPModelGenerator\Exception\Object\RegularPropertyAsAdditionalPropertyException;
13
use PHPModelGenerator\Exception\SchemaException;
14
use PHPModelGenerator\Model\GeneratorConfiguration;
15
use PHPModelGenerator\Model\Property\Property;
16
use PHPModelGenerator\Model\Property\PropertyInterface;
17
use PHPModelGenerator\Model\Property\PropertyType;
18
use PHPModelGenerator\Model\Schema;
19
use PHPModelGenerator\Model\Validator\AdditionalPropertiesValidator;
20
use PHPModelGenerator\Model\Validator\PropertyValidator;
21
use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookResolver;
22
use PHPModelGenerator\SchemaProcessor\PostProcessor\Internal\AdditionalPropertiesPostProcessor;
23
use PHPModelGenerator\SchemaProcessor\PostProcessor\Internal\SerializationPostProcessor;
24
use PHPModelGenerator\Utils\RenderHelper;
25
use ReflectionClass;
26

27
class AdditionalPropertiesAccessorPostProcessor extends PostProcessor
28
{
29
    use CompanionGeneratorTrait;
30

31
    /**
32
     * @param bool $addForModelsWithoutAdditionalPropertiesDefinition By default, the accessor is added only to schemas
33
     *             that declare an additionalProperties constraint. Set to true to also add it to schemas that don't.
34
     */
35
    public function __construct(private readonly bool $addForModelsWithoutAdditionalPropertiesDefinition = false)
39✔
36
    {}
39✔
37

38
    /**
39
     * Add the additionalProperties() accessor method to the provided schema.
40
     *
41
     * @throws SchemaException
42
     */
43
    public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void
39✔
44
    {
45
        $json = $schema->getJsonSchema()->getJson();
39✔
46

47
        if (
48
            (!$this->addForModelsWithoutAdditionalPropertiesDefinition && !isset($json['additionalProperties']))
39✔
49
            || (isset($json['additionalProperties']) && $json['additionalProperties'] === false)
37✔
50
            || (!isset($json['additionalProperties']) && $generatorConfiguration->denyAdditionalProperties())
39✔
51
        ) {
52
            return;
13✔
53
        }
54

55
        $validationProperty = null;
34✔
56
        foreach ($schema->getBaseValidators() as $validator) {
34✔
57
            if (is_a($validator, AdditionalPropertiesValidator::class)) {
31✔
58
                $validationProperty = $validator->getValidationProperty();
22✔
59
            }
60
        }
61

62
        // When the flag is set and no additionalProperties is defined in the schema, ensure the infrastructure exists.
63
        if ($this->addForModelsWithoutAdditionalPropertiesDefinition && !isset($json['additionalProperties'])) {
34✔
64
            (new AdditionalPropertiesPostProcessor())->addAdditionalPropertiesCollectionProperty($schema);
13✔
65
        }
66
        if (
67
            $generatorConfiguration->hasSerializationEnabled() &&
34✔
68
            $this->addForModelsWithoutAdditionalPropertiesDefinition &&
34✔
69
            !isset($json['additionalProperties'])
34✔
70
        ) {
71
            (new SerializationPostProcessor())->addAdditionalPropertiesTransformingFilterSerializer(
4✔
72
                $schema,
4✔
73
                $generatorConfiguration,
4✔
74
            );
4✔
75
        }
76

77
        $hasCompanion = $validationProperty && $validationProperty->getType();
34✔
78
        $isImmutable = $generatorConfiguration->isImmutable();
34✔
79

80
        $schema->addProperty(
34✔
81
            (new Property(
34✔
82
                'additionalPropertiesAccessor',
34✔
83
                null,
34✔
84
                $schema->getJsonSchema(),
34✔
85
                'Cached accessor instance for additional properties',
34✔
86
            ))
34✔
87
                ->setDefaultValue('null', true)
34✔
88
                ->setInternal(true),
34✔
89
        );
34✔
90

91
        $this->addAccessorMethod($schema, $generatorConfiguration, $hasCompanion);
34✔
92

93
        if (!$isImmutable) {
34✔
94
            $this->addSetAdditionalPropertyMethod($schema, $generatorConfiguration, $validationProperty);
21✔
95
            $this->addRemoveAdditionalPropertyMethod($schema, $generatorConfiguration);
21✔
96
        }
97

98
        if ($hasCompanion) {
34✔
99
            $this->pendingCompanions[] = [
22✔
100
                'schema' => $schema,
22✔
101
                'generatorConfiguration' => $generatorConfiguration,
22✔
102
                'validationProperty' => $validationProperty,
22✔
103
            ];
22✔
104
        }
105
    }
106

107
    /**
108
     * @throws FileSystemException
109
     */
110
    protected function renderCompanionFromEntry(array $entry): void
22✔
111
    {
112
        $this->renderCompanionClass($entry['schema'], $entry['generatorConfiguration'], $entry['validationProperty']);
22✔
113
    }
114

115
    private function addAccessorMethod(
34✔
116
        Schema $schema,
117
        GeneratorConfiguration $generatorConfiguration,
118
        bool $hasCompanion,
119
    ): void {
120
        $isImmutable = $generatorConfiguration->isImmutable();
34✔
121

122
        if (!$hasCompanion) {
34✔
123
            $schema->addUsedClass($isImmutable
13✔
124
                ? ImmutableAdditionalPropertiesAccessor::class
3✔
125
                : AdditionalPropertiesAccessor::class);
13✔
126
        }
127

128
        $accessorType = $hasCompanion
34✔
129
            ? $schema->getClassName() . 'AdditionalProperties'
22✔
130
            : (new ReflectionClass($isImmutable
13✔
131
                ? ImmutableAdditionalPropertiesAccessor::class
3✔
132
                : AdditionalPropertiesAccessor::class))->getShortName();
13✔
133

134
        $schema->addMethod(
34✔
135
            'additionalProperties',
34✔
136
            new RenderedMethod(
34✔
137
                $schema,
34✔
138
                $generatorConfiguration,
34✔
139
                'AdditionalProperties/AdditionalPropertiesAccessorMethod.phptpl',
34✔
140
                [
34✔
141
                    'accessorType' => $accessorType,
34✔
142
                    'immutable' => $isImmutable,
34✔
143
                ],
34✔
144
            )
34✔
145
        );
34✔
146
    }
147

148
    private function addSetAdditionalPropertyMethod(
21✔
149
        Schema $schema,
150
        GeneratorConfiguration $generatorConfiguration,
151
        ?PropertyInterface $validationProperty,
152
    ): void {
153
        $nonInternalProperties = array_filter(
21✔
154
            $schema->getProperties(),
21✔
155
            static fn(PropertyInterface $property): bool => !$property->isInternal(),
21✔
156
        );
21✔
157

158
        $objectProperties = array_map(
21✔
159
            static fn(PropertyInterface $property): string => $property->getName(),
21✔
160
            $nonInternalProperties,
21✔
161
        );
21✔
162

163
        $objectPropertyPointers = array_combine(
21✔
164
            array_map(static fn(PropertyInterface $property): string => $property->getName(), $nonInternalProperties),
21✔
165
            array_map(
21✔
166
                static fn(PropertyInterface $property): string => self::resolvePrimaryJsonPointer($property),
21✔
167
                $nonInternalProperties,
21✔
168
            ),
21✔
169
        );
21✔
170

171
        $hasObjectProperties = $objectProperties !== [];
21✔
172
        if ($hasObjectProperties) {
21✔
173
            $schema->addUsedClass(RegularPropertyAsAdditionalPropertyException::class);
17✔
174
        }
175

176
        $schema->addMethod(
21✔
177
            '_setAdditionalProperty',
21✔
178
            new RenderedMethod(
21✔
179
                $schema,
21✔
180
                $generatorConfiguration,
21✔
181
                'AdditionalProperties/SetAdditionalProperty.phptpl',
21✔
182
                [
21✔
183
                    'validationProperty' => $validationProperty,
21✔
184
                    'hasObjectProperties' => $hasObjectProperties,
21✔
185
                    'objectProperties' => RenderHelper::varExportArray($objectProperties),
21✔
186
                    'objectPropertyPointers' => RenderHelper::varExportArray($objectPropertyPointers),
21✔
187
                    'schemaHookResolver' => new SchemaHookResolver($schema),
21✔
188
                ],
21✔
189
            )
21✔
190
        );
21✔
191
    }
192

193
    /**
194
     * Resolve the pointer to report when a property name collides with a regular property.
195
     *
196
     * A property merged from multiple composition branches (e.g. declared in both the root
197
     * properties block and an allOf branch) carries one #[JsonPointer] attribute per defining
198
     * location, synthesized by PropertyAttributeSynthesizer. Reading that attribute data ties
199
     * this pointer to the same single source of truth used for the generated #[JsonPointer]
200
     * attributes, instead of independently recomputing it from
201
     * $property->getJsonSchema()->getPointer() — a second computation that would silently
202
     * diverge from the attribute data if PropertyMerger's choice of JsonSchema ever changes.
203
     *
204
     * RegularPropertyAsAdditionalPropertyException carries a single pointer, so when a property
205
     * has multiple declaration sites only the first (root-preferred) one is reported — knowing
206
     * any one true location is sufficient to explain the name collision.
207
     */
208
    private static function resolvePrimaryJsonPointer(PropertyInterface $property): string
17✔
209
    {
210
        foreach ($property->getAttributes() as $attribute) {
17✔
211
            if ($attribute->getFqcn() === JsonPointer::class) {
17✔
212
                return (string) $attribute->getArguments()[0];
17✔
213
            }
214
        }
215

NEW
216
        return $property->getJsonSchema()->getPointer();
×
217
    }
218

219
    /**
220
     * @throws SchemaException
221
     */
222
    private function addRemoveAdditionalPropertyMethod(
21✔
223
        Schema $schema,
224
        GeneratorConfiguration $generatorConfiguration,
225
    ): void {
226
        $minPropertyValidator = null;
21✔
227
        $json = $schema->getJsonSchema()->getJson();
21✔
228
        if (isset($json['minProperties'])) {
21✔
229
            $minPropertyValidator = (new PropertyValidator(
9✔
230
                new Property($schema->getClassName(), null, $schema->getJsonSchema()),
9✔
231
                sprintf(
9✔
232
                    '%s < %d',
9✔
233
                    'count($this->_rawModelDataInput) - 1',
9✔
234
                    $json['minProperties'],
9✔
235
                ),
9✔
236
                MinPropertiesException::class,
9✔
237
                [$json['minProperties']],
9✔
238
            ))->withJsonPointer($schema->getJsonSchema()->getPointer() . '/minProperties');
9✔
239
        }
240

241
        $schema->addMethod(
21✔
242
            '_removeAdditionalProperty',
21✔
243
            new RenderedMethod(
21✔
244
                $schema,
21✔
245
                $generatorConfiguration,
21✔
246
                'AdditionalProperties/RemoveAdditionalProperty.phptpl',
21✔
247
                ['minPropertyValidator' => $minPropertyValidator],
21✔
248
            )
21✔
249
        );
21✔
250
    }
251

252
    /**
253
     * Render and write the typed companion class for the given schema.
254
     *
255
     * @throws FileSystemException
256
     */
257
    private function renderCompanionClass(
22✔
258
        Schema $schema,
259
        GeneratorConfiguration $generatorConfiguration,
260
        PropertyInterface $validationProperty,
261
    ): void {
262
        $renderHelper = new RenderHelper($generatorConfiguration);
22✔
263
        $isImmutable = $generatorConfiguration->isImmutable();
22✔
264
        $companionClassName = $schema->getClassName() . 'AdditionalProperties';
22✔
265

266
        $namespace = $this->resolveCompanionNamespace($schema, $generatorConfiguration);
22✔
267

268
        // Nullable clone for get() — the property might not exist, so null is always possible.
269
        $nullableProperty = (clone $validationProperty)->setType(
22✔
270
            $validationProperty->getType(),
22✔
271
            new PropertyType($validationProperty->getType(true)->getNames(), true),
22✔
272
        );
22✔
273

274
        $use = RenderHelper::filterClassImports(
22✔
275
            $isImmutable ? [] : ['Closure'],
22✔
276
            $namespace,
22✔
277
        );
22✔
278

279
        $this->writeAndRequireCompanionFile(
22✔
280
            $schema,
22✔
281
            $companionClassName,
22✔
282
            $namespace,
22✔
283
            join(DIRECTORY_SEPARATOR, ['Companion', 'AdditionalPropertiesCompanion.phptpl']),
22✔
284
            [
22✔
285
                'namespace' => $namespace,
22✔
286
                'use' => $use,
22✔
287
                'companionClassName' => $companionClassName,
22✔
288
                'immutable' => $isImmutable,
22✔
289
                'getReturnType' => $renderHelper->getTypeHintAnnotation($nullableProperty, true),
22✔
290
                'getNullablePhpType' => $renderHelper->getType($nullableProperty, true),
22✔
291
                'getAllReturnAnnotation' => $this->buildGetAllReturnAnnotation(
22✔
292
                    $renderHelper->getTypeHintAnnotation($validationProperty, true),
22✔
293
                ),
22✔
294
                'setParameterType' => $renderHelper->getType($validationProperty),
22✔
295
                'setParameterAnnotation' => $renderHelper->getTypeHintAnnotation($validationProperty),
22✔
296
            ],
22✔
297
        );
22✔
298
    }
299

300
    private function buildGetAllReturnAnnotation(string $typeAnnotation): string
22✔
301
    {
302
        // Wrap union types in parentheses so that e.g. 'DateTime|null' becomes '(DateTime|null)[]',
303
        // not 'DateTime[]|null[]' — the latter means "an array of nulls" to static analysers.
304
        if (str_contains($typeAnnotation, '|')) {
22✔
305
            return '(' . $typeAnnotation . ')[]';
4✔
306
        }
307

308
        return $typeAnnotation . '[]';
18✔
309
    }
310
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc