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

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

07 Jul 2026 09:31AM UTC coverage: 98.076%. First build
28856540538

Pull #157

github

wol-soft
Warn and skip on unevaluatedProperties dead cells + edge-case tests

UnevaluatedPropertiesValidatorFactory now consolidates all four
sibling shapes that leave the unevaluatedProperties bucket
permanently empty into a single deadCodeReason() helper:

- additionalProperties: true — every extra flows to the model but
  the accumulator does not credit it, so the validator would still
  fire and defeat the intent of `additionalProperties: true`.
- additionalProperties: {schema} — every extra is claimed and
  validated by additionalProperties; the unevaluated set is empty.
- additionalProperties: false — every extra is rejected before the
  post-composition phase, so the unevaluated validator never runs.
- denyAdditionalProperties() generator flag with additionalProperties
  absent — synthesises the same false shape at configuration time.

Each cell emits a distinct warning via the existing echo channel
so build-output greps can identify the specific dead shape, and
skips validator emission entirely.

Tests added:

- Object-side dead-code data provider covering all four shapes plus
  the denyAdditionalProperties() variant. Rows pass the
  GeneratorConfiguration directly (setOutputEnabled(true) on the
  base, chained with setDenyAdditionalProperties(true) on the deny
  row) so the test signature drops the closure/fallback boilerplate.
- testAdditionalFalseRejectsExtrasEvenWhenUnevaluatedSchemaWouldAccept
  proves the factory suppressed the validator: an extra that would
  satisfy the unevaluated integer schema is still rejected because
  additionalProperties: false runs first.
- testContradictoryInnerSchemaThrowsSchemaExceptionPointingAtFile
  pins that an unevaluatedProperties schema with contradictory allOf
  types surfaces via the existing "conflicting types" SchemaException
  path, with the file identifier preserved.
- testPropertyNamesRejectionPrecedesUnevaluated data-provider variant
  covering both direct-exception and error-collection modes. The
  re... (continued)
Pull Request #157: Unevaluated properties

712 of 774 new or added lines in 38 files covered. (91.99%)

7237 of 7379 relevant lines covered (98.08%)

563.49 hits per line

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

94.59
/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\SchemaProcessor\PostProcessor\Internal;
6

7
use PHPModelGenerator\Model\GeneratorConfiguration;
8
use PHPModelGenerator\Model\MethodInterface;
9
use PHPModelGenerator\Model\Property\CompositionPropertyDecorator;
10
use PHPModelGenerator\Model\Property\Property;
11
use PHPModelGenerator\Model\Property\PropertyInterface;
12
use PHPModelGenerator\Model\Property\PropertyType;
13
use PHPModelGenerator\Model\Schema;
14
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
15
use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator;
16
use PHPModelGenerator\Model\Validator\ArrayContainsValidator;
17
use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator;
18
use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor;
19
use PHPModelGenerator\Traits\CompositionEvaluationTrait;
20

21
/**
22
 * Detects whether unevaluatedProperties or unevaluatedItems is reachable from a schema and,
23
 * when it is, activates evaluation tracking on that schema's composition validators.
24
 *
25
 * Activation emits the _compositionEvaluations cache field on the generated class and marks
26
 * each composition validator with per-branch slot writes. When neither keyword is reachable
27
 * anywhere in the schema graph, this post processor is a complete no-op.
28
 *
29
 * Cross-state revalidation under mutation (setter + populate) is *not* handled here. It lives
30
 * in Model.phptpl and Populate.phptpl as a direct call to _executePostCompositionValidators
31
 * against a candidate state — see the templates for details. The unevaluatedProperties
32
 * validator participates in that pass simply by being registered as a post-composition
33
 * validator on the schema; this post processor's only responsibility is the activation-side
34
 * work that makes the cache available to it.
35
 *
36
 * Known limitation: when a cross-state check fails in direct-exception mode, composition
37
 * validation that ran during the setter's per-property _validate* phase may have updated
38
 * _propertyValidationState / _compositionEvaluations with state that was never committed.
39
 * The cache may briefly hold "would-be" entries until the next mutation invalidates them.
40
 * This is a pre-existing concern with composition revalidation, not specific to the
41
 * unevaluatedProperties feature, and self-corrects the next time an affecting property
42
 * changes.
43
 */
44
class UnevaluatedPropertiesPostProcessor extends PostProcessor
45
{
46
    /**
47
     * Monotonically increasing counter scoped to a single ($schema, $property) activation
48
     * pass. Reset per property at the start of its walk so each property's compositions
49
     * receive `<propertyName>_0`, `<propertyName>_1`, ... slot keys. Tracked on the
50
     * instance so the recursive activation helpers do not need to thread a by-reference
51
     * parameter through every call.
52
     */
53
    private int $slotKeyCounter = 0;
54

55
    /**
56
     * Object hashes of composition validators already activated in the current property
57
     * walk. Required (not defensive) — a self-referencing schema such as
58
     * `{type: array, allOf: [{$ref: "#"}], unevaluatedItems: false}` produces a composition
59
     * validator whose composed property's wrapped property carries the same composition
60
     * validator instance. Without this short-circuit, `activateArrayComposition()` would
61
     * recurse indefinitely.
62
     *
63
     * @var array<string, true>
64
     */
65
    private array $activatedCompositions = [];
66

67
    public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void
2,583✔
68
    {
69
        $seen = [];
2,583✔
70

71
        if (!$this->needsActivation($schema, $seen)) {
2,583✔
72
            return;
2,519✔
73
        }
74

75
        // A schema with a non-false `unevaluatedProperties` validator tracks each key the
76
        // validator successfully evaluates in `_evaluatedPropertyKeys`. That field is read
77
        // by `_getEvaluatedProperties()` on nested branch classes so an enclosing
78
        // `unevaluatedProperties` sees those keys as evaluated.
79
        $this->addEvaluatedPropertyKeysField($schema);
96✔
80

81
        // The nested branch schemas may already be queued — adding the method here, before any
82
        // render() runs, ensures every branch class carries _getEvaluatedProperties() regardless
83
        // of whether the outer or inner schema was processed first. RenderQueue::execute runs
84
        // process() over every job before render() begins.
85
        $this->addGetEvaluatedPropertiesToNestedBranchSchemas($schema);
96✔
86

87
        // The trait carries collectUnevaluatedKeys(), which every generated unevaluatedProperties
88
        // validator calls regardless of whether the schema also has composition validators. It
89
        // must therefore be attached on every activation-triggering schema, not only on ones that
90
        // declare allOf/anyOf/oneOf/if-then-else.
91
        $schema->addTrait(CompositionEvaluationTrait::class);
96✔
92

93
        $this->activateSchemaLevelTracking($schema);
96✔
94
        $this->activateArrayPropertyTracking($schema);
96✔
95
    }
96

97
    /**
98
     * Enables evaluation tracking on every schema-level composition validator and declares
99
     * the `_compositionEvaluations` cache field if any composition was activated.
100
     *
101
     * Each activated branch writes its success bit and the property names it claimed into
102
     * `_compositionEvaluations[$validatorIndex][$componentIndex]`. The unevaluatedProperties
103
     * validator reads those slots via the trait's `collectUnevaluatedKeys`. Schemas without
104
     * any composition skip the field declaration — the trait's reads use `?? []`, so the
105
     * absence is safe.
106
     */
107
    private function activateSchemaLevelTracking(Schema $schema): void
96✔
108
    {
109
        $activated = false;
96✔
110
        foreach ($schema->getBaseValidators() as $baseValidator) {
96✔
111
            if ($baseValidator instanceof AbstractComposedPropertyValidator) {
49✔
112
                $baseValidator->enableEvaluationTracking();
34✔
113
                $activated = true;
34✔
114
            }
115
        }
116

117
        if (!$activated) {
96✔
118
            return;
65✔
119
        }
120

121
        $schema->addProperty(
34✔
122
            (new Property(
34✔
123
                'compositionEvaluations',
34✔
124
                new PropertyType('array'),
34✔
125
                new JsonSchema(__FILE__, []),
34✔
126
            ))
34✔
127
                ->setInternal(true)
34✔
128
                ->setDefaultValue([]),
34✔
129
        );
34✔
130
    }
131

132
    /**
133
     * Walks array properties carrying `unevaluatedItems`, activates evaluation tracking on
134
     * any composition validator attached to such a property, and declares the two transient
135
     * array-side fields when their respective write sites are reachable:
136
     *   - `_compositionAnnotated` — only when at least one property-level composition was
137
     *     activated. The composition template wholesale-overwrites the property's slot at
138
     *     the end of every chain run.
139
     *   - `_evaluatedItemIndices` — whenever at least one array property carries
140
     *     `unevaluatedItems`. The unevaluatedItems template writes a `[propertyName =>
141
     *     [index => true]]` entry after each successful per-index validation.
142
     */
143
    private function activateArrayPropertyTracking(Schema $schema): void
96✔
144
    {
145
        $unevaluatedItemsPresent = false;
96✔
146
        $compositionActivated = false;
96✔
147

148
        foreach ($schema->getProperties() as $schemaProperty) {
96✔
149
            $propertyJson = $schemaProperty->getJsonSchema()->getJson();
94✔
150

151
            if (!array_key_exists('unevaluatedItems', $propertyJson)) {
94✔
152
                continue;
71✔
153
            }
154

155
            // The unevaluatedItems factory is registered on the `array` type only, so a
156
            // property whose type cannot hold an array never produces an unevaluatedItems
157
            // validator at runtime. Activating compositions in that case would write
158
            // `_compositionAnnotated` slots that nobody reads — wasted state.
159
            $typeNames = $schemaProperty->getType()?->getNames() ?? [];
25✔
160
            if ($typeNames !== [] && !in_array('array', $typeNames, true)) {
25✔
NEW
161
                continue;
×
162
            }
163

164
            $unevaluatedItemsPresent = true;
25✔
165

166
            $this->slotKeyCounter = 0;
25✔
167
            $this->activatedCompositions = [];
25✔
168

169
            foreach ($schemaProperty->getOrderedValidators() as $validator) {
25✔
170
                if ($validator instanceof AbstractComposedPropertyValidator) {
25✔
171
                    $this->activateArrayComposition($validator, $schemaProperty);
13✔
172
                    $compositionActivated = true;
13✔
173
                }
174
            }
175
        }
176

177
        if ($compositionActivated) {
96✔
178
            // Transient bridge between property-level array compositions and a sibling
179
            // unevaluatedItems validator. Wholesale-overwritten per chain run; never registered
180
            // with the rollback registry, never snapshotted across setter calls.
181
            $schema->addProperty(
13✔
182
                (new Property(
13✔
183
                    'compositionAnnotated',
13✔
184
                    new PropertyType('array'),
13✔
185
                    new JsonSchema(__FILE__, []),
13✔
186
                ))
13✔
187
                    ->setInternal(true)
13✔
188
                    ->setDefaultValue([]),
13✔
189
            );
13✔
190
        }
191

192
        if ($unevaluatedItemsPresent) {
96✔
193
            // Per-array-property index map of indices the property's UnevaluatedItems validator
194
            // successfully evaluated, shaped as [propertyName => [index => true]]. Inner and outer
195
            // UnevaluatedItems validators share the same instance field — there are no nested
196
            // array classes to cross. Transient: every chain run overwrites it; never registered
197
            // with the rollback registry, never snapshotted across setter calls.
198
            $schema->addProperty(
25✔
199
                (new Property(
25✔
200
                    'evaluatedItemIndices',
25✔
201
                    new PropertyType('array'),
25✔
202
                    new JsonSchema(__FILE__, []),
25✔
203
                ))
25✔
204
                    ->setInternal(true)
25✔
205
                    ->setDefaultValue([]),
25✔
206
            );
25✔
207
        }
208
    }
209

210
    /**
211
     * Enable evaluation tracking on a property-level composition validator on an array
212
     * property and recurse into its branches. The slot-key counter on the post-processor
213
     * instance is shared across the recursion so an outer composition and a nested one
214
     * inside one of its branches receive monotonically increasing keys (e.g. `tags_0` for
215
     * the outer allOf, `tags_1` for an inner oneOf). Within each branch, any ArrayContains
216
     * validator gets its trackBranchMatches flag set so the contains template exports per-
217
     * index match results to the surrounding composition body.
218
     *
219
     * The instance-level `$activatedCompositions` set guards against `$ref`-induced cycles
220
     * in the composition graph; activating the same validator twice would double-emit slot
221
     * writes and confuse the rebuild.
222
     */
223
    private function activateArrayComposition(
13✔
224
        AbstractComposedPropertyValidator $compositionValidator,
225
        PropertyInterface $parentProperty,
226
    ): void {
227
        $compositionHash = spl_object_hash($compositionValidator);
13✔
228
        if (isset($this->activatedCompositions[$compositionHash])) {
13✔
NEW
229
            return;
×
230
        }
231
        $this->activatedCompositions[$compositionHash] = true;
13✔
232

233
        $compositionValidator->enableEvaluationTracking();
13✔
234
        $compositionValidator->setSlotKey($parentProperty->getName() . '_' . $this->slotKeyCounter++);
13✔
235

236
        foreach ($compositionValidator->getComposedProperties() as $composedProperty) {
13✔
237
            $this->activateValidatorsInBranch($composedProperty, $parentProperty);
13✔
238
        }
239
    }
240

241
    private function activateValidatorsInBranch(
13✔
242
        CompositionPropertyDecorator $composedProperty,
243
        PropertyInterface $parentProperty,
244
    ): void {
245
        // Iterate the wrapped property's validators directly. The decorator's
246
        // getOrderedValidators() returns fresh withProperty() clones every call, so a
247
        // mutation on those clones would be invisible at render time.
248
        foreach ($composedProperty->getWrappedProperty()->getOrderedValidators() as $validator) {
13✔
249
            if ($validator instanceof AbstractComposedPropertyValidator) {
13✔
NEW
250
                $this->activateArrayComposition($validator, $parentProperty);
×
NEW
251
                continue;
×
252
            }
253

254
            if ($validator instanceof ArrayContainsValidator) {
13✔
255
                $validator->setTrackBranchMatches(true);
3✔
256
            }
257
        }
258
    }
259

260
    /**
261
     * Adds the `_evaluatedPropertyKeys` collection field to a schema that carries the
262
     * schema-form `UnevaluatedPropertiesValidator`. Nested branch schemas reach this code
263
     * through their own `process()` call (RenderQueue calls each job's processors), so no
264
     * recursion across branches is required here.
265
     */
266
    private function addEvaluatedPropertyKeysField(Schema $schema): void
96✔
267
    {
268
        foreach ($schema->getPostCompositionValidators() as $postCompositionValidator) {
96✔
269
            if ($postCompositionValidator instanceof UnevaluatedPropertiesValidator) {
60✔
270
                $schema->addProperty(
18✔
271
                    (new Property(
18✔
272
                        'evaluatedPropertyKeys',
18✔
273
                        new PropertyType('array'),
18✔
274
                        new JsonSchema(__FILE__, []),
18✔
275
                    ))
18✔
276
                        ->setInternal(true)
18✔
277
                        ->setDefaultValue([]),
18✔
278
                );
18✔
279

280
                return;
18✔
281
            }
282
        }
283
    }
284

285
    /**
286
     * For each composition branch that produces a nested object class, adds an internal
287
     * `_getEvaluatedProperties()` method the enclosing schema's unevaluatedProperties
288
     * validator queries to learn which keys the nested class evaluated.
289
     *
290
     * The method name uses an underscore prefix so it cannot collide with a user-declared
291
     * schema property called `evaluatedProperties` (whose generated getter would be
292
     * `getEvaluatedProperties()` without the underscore) and so its internal-only role is
293
     * visible at the call site.
294
     */
295
    private function addGetEvaluatedPropertiesToNestedBranchSchemas(Schema $schema): void
96✔
296
    {
297
        foreach ($schema->getBaseValidators() as $baseValidator) {
96✔
298
            if (!$baseValidator instanceof AbstractComposedPropertyValidator) {
49✔
299
                continue;
16✔
300
            }
301

302
            foreach ($baseValidator->getComposedProperties() as $composedProperty) {
34✔
303
                $nestedSchema = $composedProperty->getNestedSchema();
33✔
304

305
                if ($nestedSchema === null || $nestedSchema->hasMethod('_getEvaluatedProperties')) {
33✔
NEW
306
                    continue;
×
307
                }
308

309
                $nestedSchema->addMethod(
33✔
310
                    '_getEvaluatedProperties',
33✔
311
                    $this->buildGetEvaluatedPropertiesMethod($nestedSchema),
33✔
312
                );
33✔
313
            }
314
        }
315
    }
316

317
    /**
318
     * Builds a MethodInterface that emits `_getEvaluatedProperties()` for the given nested
319
     * schema. The method returns the union of declared property names present in the
320
     * instance's raw model data and the keys recorded in `_evaluatedPropertyKeys` (populated
321
     * by the nested schema's own unevaluatedProperties validator).
322
     */
323
    private function buildGetEvaluatedPropertiesMethod(Schema $nestedSchema): MethodInterface
33✔
324
    {
325
        return new class ($nestedSchema) implements MethodInterface {
33✔
326
            public function __construct(private readonly Schema $nestedSchema)
327
            {
328
            }
33✔
329

330
            public function getCode(): string
331
            {
332
                $declaredPropertyNames = array_values(array_map(
33✔
333
                    static fn(PropertyInterface $property): string => $property->getName(),
33✔
334
                    array_filter(
33✔
335
                        $this->nestedSchema->getProperties(),
33✔
336
                        static fn(PropertyInterface $property): bool => !$property->isInternal(),
33✔
337
                    ),
33✔
338
                ));
33✔
339

340
                return sprintf(
33✔
341
                    '
33✔
342
                    #[Internal]
343
                    public function _getEvaluatedProperties(): array
344
                    {
345
                        $evaluated = [];
346
                        foreach (%s as $propName) {
347
                            if (array_key_exists($propName, $this->_rawModelDataInput)) {
348
                                $evaluated[$propName] = true;
349
                            }
350
                        }
351
                        // Keys evaluated by this schema\'s own unevaluatedProperties: {schema}
352
                        // validator are tracked so an enclosing schema can see them.
353
                        if (property_exists($this, "_evaluatedPropertyKeys")) {
354
                            foreach ($this->_evaluatedPropertyKeys as $propName => $_) {
355
                                $evaluated[$propName] = true;
356
                            }
357
                        }
358
                        return array_keys($evaluated);
359
                    }',
33✔
360
                    var_export($declaredPropertyNames, true),
33✔
361
                );
33✔
362
            }
363
        };
33✔
364
    }
365

366
    /**
367
     * Returns true when the schema or any reachable subschema contains unevaluatedProperties
368
     * or unevaluatedItems, meaning composition validators on this schema must emit the
369
     * _compositionEvaluations cache.
370
     *
371
     * Uses a $seen map keyed by file+pointer to break reference cycles.
372
     */
373
    private function needsActivation(Schema $schema, array &$seen): bool
2,583✔
374
    {
375
        $schemaKey = $schema->getJsonSchema()->getFile() . '#' . $schema->getJsonSchema()->getPointer();
2,583✔
376

377
        if (array_key_exists($schemaKey, $seen)) {
2,583✔
378
            return $seen[$schemaKey];
40✔
379
        }
380

381
        // Mark false initially so cycles terminate without infinite recursion.
382
        $seen[$schemaKey] = false;
2,583✔
383

384
        $json = $schema->getJsonSchema()->getJson();
2,583✔
385

386
        if (array_key_exists('unevaluatedProperties', $json) || array_key_exists('unevaluatedItems', $json)) {
2,583✔
387
            return $seen[$schemaKey] = true;
73✔
388
        }
389

390
        // Check each composition branch: both the branch-level JSON and any nested schema it produces.
391
        foreach ($schema->getBaseValidators() as $baseValidator) {
2,543✔
392
            if (!$baseValidator instanceof AbstractComposedPropertyValidator) {
756✔
393
                continue;
423✔
394
            }
395

396
            foreach ($baseValidator->getComposedProperties() as $composedProperty) {
402✔
397
                $branchJson = $composedProperty->getBranchSchema()->getJson();
401✔
398

399
                if (
400
                    array_key_exists('unevaluatedProperties', $branchJson)
401✔
401
                    || array_key_exists('unevaluatedItems', $branchJson)
401✔
402
                ) {
NEW
403
                    return $seen[$schemaKey] = true;
×
404
                }
405

406
                $nestedSchema = $composedProperty->getNestedSchema();
401✔
407

408
                if ($nestedSchema !== null && $this->needsActivation($nestedSchema, $seen)) {
401✔
NEW
409
                    return $seen[$schemaKey] = true;
×
410
                }
411
            }
412
        }
413

414
        // Check property-level nested schemas (e.g. an object-typed property with its own composition).
415
        // Array properties never carry a nested schema — their unevaluatedItems lives in the
416
        // property's own JSON, so check that before the getNestedSchema() recursion. Without
417
        // this, a schema like {type: object, properties: {tags: {type: array, unevaluatedItems: false}}}
418
        // would miss activation entirely.
419
        foreach ($schema->getProperties() as $schemaProperty) {
2,543✔
420
            $propertyJson = $schemaProperty->getJsonSchema()->getJson();
2,473✔
421

422
            if (
423
                array_key_exists('unevaluatedProperties', $propertyJson)
2,473✔
424
                || array_key_exists('unevaluatedItems', $propertyJson)
2,473✔
425
            ) {
426
                return $seen[$schemaKey] = true;
26✔
427
            }
428

429
            $nestedSchema = $schemaProperty->getNestedSchema();
2,449✔
430

431
            if ($nestedSchema !== null && $this->needsActivation($nestedSchema, $seen)) {
2,449✔
NEW
432
                return $seen[$schemaKey] = true;
×
433
            }
434
        }
435

436
        return $seen[$schemaKey] = false;
2,519✔
437
    }
438
}
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