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

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

28 Jul 2026 06:05AM UTC coverage: 98.735% (+0.001%) from 98.734%
30333648898

Pull #180

github

claude
Exclude internal bookkeeping properties from composition branch default helpers

setupBranchDefaultHelpers() iterated a composition branch's nested schema
properties without excluding internal ones, so an internal bookkeeping
property with a non-null default (eg. _skipNotProvidedPropertiesMap, added
whenever serialization is enabled) was misread as real branch data and
leaked into componentDefaultValueMap and propertyAccessors of the
generated _getModifiedValues_* helper, even though no getter is ever
generated for it.

Fixes #179.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QL8i9sBrFNCQv4WdGwxfup
Pull Request #180: Exclude internal bookkeeping properties from composition branch default helpers

2 of 2 new or added lines in 1 file covered. (100.0%)

6 existing lines in 3 files now uncovered.

7178 of 7270 relevant lines covered (98.73%)

573.98 hits per line

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

99.59
/src/Model/Validator/Factory/Composition/IfValidatorFactory.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\Model\Validator\Factory\Composition;
6

7
use PHPModelGenerator\Exception\Generic\DeniedPropertyException;
8
use PHPModelGenerator\Exception\SchemaException;
9
use PHPModelGenerator\Model\Property\BaseProperty;
10
use PHPModelGenerator\Model\Property\CompositionPropertyDecorator;
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;
16
use PHPModelGenerator\Model\Validator\ComposedPropertyValidator;
17
use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator;
18
use PHPModelGenerator\Model\Validator\PropertyValidator;
19
use PHPModelGenerator\Model\Validator\RequiredPropertyValidator;
20
use PHPModelGenerator\PropertyProcessor\Filter\CompositionCompatibilityChecker;
21
use PHPModelGenerator\PropertyProcessor\PropertyFactory;
22
use PHPModelGenerator\SchemaProcessor\SchemaProcessor;
23
use PHPModelGenerator\Utils\RenderHelper;
24
use PHPModelGenerator\Utils\TypeIntersection;
25

26
class IfValidatorFactory
27
    extends AbstractCompositionValidatorFactory
28
    implements ComposedPropertiesValidatorFactoryInterface
29
{
30
    /**
31
     * @throws SchemaException
32
     */
33
    public function modify(
2,613✔
34
        SchemaProcessor $schemaProcessor,
35
        Schema $schema,
36
        PropertyInterface $property,
37
        JsonSchema $propertySchema,
38
    ): void {
39
        if (!isset($propertySchema->getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) {
2,613✔
40
            return;
2,610✔
41
        }
42

43
        $json = $propertySchema->getJson();
126✔
44

45
        if (!isset($json['then']) && !isset($json['else'])) {
126✔
46
            throw new SchemaException(
1✔
47
                sprintf(
1✔
48
                    'Incomplete conditional composition for property %s in file %s',
1✔
49
                    $property->getName(),
1✔
50
                    $property->getJsonSchema()->getFile(),
1✔
51
                ),
1✔
52
                $property->getJsonSchema(),
1✔
53
            );
1✔
54
        }
55

56
        $json = $this->resolveBooleanBranches($json, $property, $schemaProcessor);
125✔
57

58
        if ($json === null) {
123✔
59
            return;
5✔
60
        }
61

62
        // Inherit the parent type into if/then/else sub-schemas before the filter check so
63
        // that sub-schemas that inherit 'object' are correctly recognised as object-typed.
64
        // Object-typed sub-schemas create nested schemas whose properties are processed
65
        // independently and are not subject to ComposedItem $value reset.
66
        $propertySchema = $this->inheritPropertyType($propertySchema->withJson($json));
118✔
67
        $json = $propertySchema->getJson();
118✔
68

69
        // Check for filter keywords in if/then/else sub-schemas after type inheritance.
70
        // TODO: filters inside if/then/else sub-schemas cannot be correctly applied
71
        // (ComposedItem.phptpl resets $value to $originalModelData after each branch).
72
        // Proper per-branch filter chaining is deferred to a follow-up topic.
73
        foreach (['if', 'then', 'else'] as $keyword) {
118✔
74
            if (
75
                isset($json[$keyword])
118✔
76
                && is_array($json[$keyword])
118✔
77
                && CompositionCompatibilityChecker::branchContainsFilter($json[$keyword])
118✔
78
            ) {
79
                throw new SchemaException(
1✔
80
                    sprintf(
1✔
81
                        'A filter keyword inside an if/then/else composition branch is not supported'
1✔
82
                            . ' for property %s in file %s (%s sub-schema).',
1✔
83
                        $property->getName(),
1✔
84
                        $property->getJsonSchema()->getFile(),
1✔
85
                        $keyword,
1✔
86
                    ),
1✔
87
                    $property->getJsonSchema(),
1✔
88
                );
1✔
89
            }
90
        }
91

92
        $propertyFactory = new PropertyFactory();
117✔
93

94
        $onlyForDefinedValues = !($property instanceof BaseProperty)
117✔
95
            && (!$property->isRequired()
117✔
96
                && $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed());
117✔
97

98
        /** @var array<string, CompositionPropertyDecorator|null> $properties */
99
        $properties = [];
117✔
100

101
        foreach (['if', 'then', 'else'] as $keyword) {
117✔
102
            if (!isset($json[$keyword])) {
117✔
103
                $properties[$keyword] = null;
50✔
104
                continue;
50✔
105
            }
106

107
            if ($json[$keyword] === false) {
117✔
108
                $properties[$keyword] = $this->createAlwaysFailingBranchProperty(
10✔
109
                    $schemaProcessor,
10✔
110
                    $schema,
10✔
111
                    $property,
10✔
112
                    $propertySchema,
10✔
113
                );
10✔
114
                continue;
10✔
115
            }
116

117
            $compositionSchema = $propertySchema->navigate($keyword);
117✔
118

119
            $compositionProperty = new CompositionPropertyDecorator(
117✔
120
                $property->getName(),
117✔
121
                $compositionSchema,
117✔
122
                $propertyFactory->create(
117✔
123
                    $schemaProcessor,
117✔
124
                    $schema,
117✔
125
                    $property->getName(),
117✔
126
                    $compositionSchema,
117✔
127
                    $property->isRequired(),
117✔
128
                ),
117✔
129
            );
117✔
130

131
            $compositionProperty->onResolve(static function () use ($compositionProperty): void {
117✔
132
                // A branch's own nested composition/conditional validator is never stripped here:
133
                // it is only ever attached to an untyped (bare) branch in the first place, since a
134
                // branch declaring "type": "object" instead gets its composition validated inside
135
                // its own generated nested class (AbstractCompositionValidatorFactory::shouldSkip()
136
                // blocks the composition-keyword factory from attaching a validator directly to
137
                // an object-typed branch property). Stripping it unconditionally would leave a
138
                // nested composition on a bare branch entirely unvalidated (issue #167).
139
                $compositionProperty->filterValidators(
117✔
140
                    static fn(Validator $validator): bool =>
117✔
141
                        !is_a($validator->getValidator(), RequiredPropertyValidator::class),
117✔
142
                );
117✔
143
            });
117✔
144

145
            $properties[$keyword] = $compositionProperty;
117✔
146
        }
147

148
        $this->applyIfThenElseTypeSemantics($property, $properties);
117✔
149

150
        $property->addValidator(
114✔
151
            (new ConditionalPropertyValidator(
114✔
152
                $schemaProcessor->getGeneratorConfiguration(),
114✔
153
                $property,
114✔
154
                array_values(array_filter($properties)),
114✔
155
                array_values(array_filter([$properties['then'], $properties['else']])),
114✔
156
                [
114✔
157
                    'ifProperty' => $properties['if'],
114✔
158
                    'thenProperty' => $properties['then'],
114✔
159
                    'elseProperty' => $properties['else'],
114✔
160
                    'schema' => $schema,
114✔
161
                    'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(),
114✔
162
                    'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()),
114✔
163
                    'onlyForDefinedValues' => $onlyForDefinedValues,
114✔
164
                ],
114✔
165
            ))->withJsonPointer($propertySchema->getPointer() . '/if'),
114✔
166
            100,
114✔
167
        );
114✔
168
    }
169

170
    /**
171
     * Create a composition branch that always fails, used for boolean `false` if/then/else branches.
172
     *
173
     * Unlike the allOf/anyOf/oneOf false-branch (which uses array_key_exists to guard absent
174
     * optional properties), here the outer ConditionalComposedItem template's onlyForDefinedValues
175
     * guard already prevents the entire conditional from running for absent properties. So the
176
     * branch itself just needs to always throw regardless of the value.
177
     */
178
    private function createAlwaysFailingBranchProperty(
10✔
179
        SchemaProcessor $schemaProcessor,
180
        Schema $schema,
181
        PropertyInterface $property,
182
        JsonSchema $propertySchema,
183
    ): CompositionPropertyDecorator {
184
        $propertyFactory = new PropertyFactory();
10✔
185
        $branchSchema = $propertySchema->withJson([]);
10✔
186

187
        $branchProperty = new CompositionPropertyDecorator(
10✔
188
            $property->getName(),
10✔
189
            $branchSchema,
10✔
190
            $propertyFactory->create(
10✔
191
                $schemaProcessor,
10✔
192
                $schema,
10✔
193
                $property->getName(),
10✔
194
                $branchSchema,
10✔
195
                $property->isRequired(),
10✔
196
            ),
10✔
197
        );
10✔
198

199
        $branchProperty->onResolve(function () use ($branchProperty): void {
10✔
200
            $branchProperty->filterValidators(
10✔
201
                static fn(Validator $validator): bool =>
10✔
202
                    !is_a($validator->getValidator(), RequiredPropertyValidator::class) &&
10✔
203
                    !is_a($validator->getValidator(), ComposedPropertyValidator::class),
10✔
204
            );
10✔
205
            $branchProperty->addValidator(
10✔
206
                new PropertyValidator(
10✔
207
                    $branchProperty,
10✔
208
                    'true',
10✔
209
                    DeniedPropertyException::class,
10✔
210
                ),
10✔
211
            );
10✔
212
        });
10✔
213

214
        return $branchProperty;
10✔
215
    }
216

217
    /**
218
     * Resolve boolean `if`/`then`/`else` branches into concrete schema arrays or return-null signals.
219
     *
220
     * Returns null when the entire if/then/else imposes no constraint and modify() should return
221
     * early. Returns the (possibly rewritten) $json array otherwise. Always-false branches are left
222
     * as boolean false values in the returned array so the foreach loop in modify() can handle them.
223
     *
224
     * @throws SchemaException
225
     */
226
    private function resolveBooleanBranches(
125✔
227
        array $json,
228
        PropertyInterface $property,
229
        SchemaProcessor $schemaProcessor,
230
    ): ?array {
231
        if (is_bool($json['if'])) {
125✔
232
            if ($json['if'] === false) {
21✔
233
                if (!isset($json['else'])) {
11✔
234
                    if (isset($json['then'])) {
1✔
235
                        $schemaProcessor->getGeneratorConfiguration()->getLogger()->warning(
1✔
236
                            "if: false for property '{property}' — then branch will never apply"
1✔
237
                                . " (condition never matches); no constraint generated.",
1✔
238
                            ['property' => $property->getName()],
1✔
239
                        );
1✔
240
                    }
241
                    return null;
1✔
242
                }
243

244
                if ($json['else'] === true) {
10✔
245
                    return null;
1✔
246
                }
247

248
                if ($json['else'] === false) {
9✔
249
                    $this->warnIfAlwaysFalse(
5✔
250
                        $schemaProcessor,
5✔
251
                        $property,
5✔
252
                        'if: false with else: false means the composition is always unsatisfiable',
5✔
253
                    );
5✔
254
                    // Rewrite as if: {} (always passes), then: false (always fails).
255
                    // The false then-branch is handled in the foreach loop below.
256
                    $json['if'] = [];
5✔
257
                    $json['then'] = false;
5✔
258
                    unset($json['else']);
5✔
259
                    return $json;
5✔
260
                }
261

262
                // Rewrite if: false, else: X as if: {}, then: X.
263
                // An empty if schema always passes so then always applies.
264
                // The ConditionalException will say "Condition: Valid" which is accurate
265
                // for if: {} but won't mention "else"; the message still correctly names
266
                // the failing branch constraint.
267
                $json['if'] = [];
4✔
268
                $json['then'] = $json['else'];
4✔
269
                unset($json['else']);
4✔
270

271
                return $json;
4✔
272
            }
273

274
            if (!isset($json['then'])) {
10✔
275
                if (isset($json['else'])) {
1✔
276
                    $schemaProcessor->getGeneratorConfiguration()->getLogger()->warning(
1✔
277
                        "if: true for property '{property}' — else branch will never apply"
1✔
278
                            . " (condition always matches); no constraint generated.",
1✔
279
                        ['property' => $property->getName()],
1✔
280
                    );
1✔
281
                }
282
                return null;
1✔
283
            }
284

285
            if ($json['then'] === true) {
9✔
UNCOV
286
                return null;
×
287
            }
288

289
            if ($json['then'] === false) {
9✔
290
                $this->warnIfAlwaysFalse(
5✔
291
                    $schemaProcessor,
5✔
292
                    $property,
5✔
293
                    'if: true with then: false means the composition is always unsatisfiable',
5✔
294
                );
5✔
295
            }
296

297
            // Rewrite if: true, then: Y as if: {}, then: Y (removing else — it never applies).
298
            // If then is false the false-branch is handled in the foreach loop below.
299
            $json['if'] = [];
9✔
300
            unset($json['else']);
9✔
301

302
            return $json;
9✔
303
        }
304

305
        if (isset($json['then']) && is_bool($json['then'])) {
104✔
306
            if ($json['then'] === false) {
2✔
307
                throw new SchemaException(
1✔
308
                    sprintf(
1✔
309
                        'then: false is unsatisfiable for property %s in file %s',
1✔
310
                        $property->getName(),
1✔
311
                        $property->getJsonSchema()->getFile(),
1✔
312
                    ),
1✔
313
                    $property->getJsonSchema(),
1✔
314
                );
1✔
315
            }
316

317
            unset($json['then']);
1✔
318
        }
319

320
        if (isset($json['else']) && is_bool($json['else'])) {
103✔
321
            if ($json['else'] === false) {
2✔
322
                throw new SchemaException(
1✔
323
                    sprintf(
1✔
324
                        'else: false is unsatisfiable for property %s in file %s',
1✔
325
                        $property->getName(),
1✔
326
                        $property->getJsonSchema()->getFile(),
1✔
327
                    ),
1✔
328
                    $property->getJsonSchema(),
1✔
329
                );
1✔
330
            }
331

332
            unset($json['else']);
1✔
333
        }
334

335
        if (!isset($json['then']) && !isset($json['else'])) {
102✔
336
            return null;
2✔
337
        }
338

339
        return $json;
100✔
340
    }
341

342
    /**
343
     * Apply type widening and conflict detection for property-level if/then/else.
344
     *
345
     * When both then and else branches are present, a coordinated callback fires after both
346
     * resolve to:
347
     * - Detect unsatisfiable schemas: if the parent property has a declared type that is
348
     *   incompatible (empty intersection) with BOTH then and else types, throw SchemaException.
349
     * - Widen the property type: when the parent property has no declared type, compute the
350
     *   union of then and else types (anyOf-like semantics) and apply it to the parent property.
351
     *
352
     * If else is absent, the absent branch accepts any value when `if` evaluates to false, so
353
     * the composition cannot constrain the type further; widening is skipped (property stays
354
     * mixed). Conflict detection also requires both branches to be present — a single conflicting
355
     * branch does not make the schema unsatisfiable.
356
     *
357
     * The parent type is captured at call time (before branch resolution) to avoid reading a
358
     * value that may have been mutated by concurrent onResolve side-effects.
359
     *
360
     * @param array<string, CompositionPropertyDecorator|null> $properties
361
     *
362
     * @throws SchemaException
363
     */
364
    private function applyIfThenElseTypeSemantics(
117✔
365
        PropertyInterface $property,
366
        array $properties,
367
    ): void {
368
        $thenProperty = $properties['then'];
117✔
369
        $elseProperty = $properties['else'];
117✔
370

371
        if ($thenProperty === null || $elseProperty === null) {
117✔
372
            // Absent branch = untyped (accepts any value on that path) — no type widening or
373
            // conflict detection possible without both branches.
374
            return;
50✔
375
        }
376

377
        // Capture before any branch-resolution callback can mutate the parent type.
378
        $originalParentType = $property->getType(true);
67✔
379

380
        $resolvedCount = 0;
67✔
381
        $onBothResolved = function () use (
67✔
382
            &$resolvedCount,
67✔
383
            $property,
67✔
384
            $thenProperty,
67✔
385
            $elseProperty,
67✔
386
            $originalParentType,
67✔
387
        ): void {
67✔
388
            $resolvedCount++;
67✔
389
            if ($resolvedCount < 2) {
67✔
390
                return;
67✔
391
            }
392

393
            // Object-level if/then/else creates nested schemas in the branches; type merging for
394
            // that case is owned by PropertyMerger. Skip here to avoid false conflict detection
395
            // (the parent 'object' type and branch generated-class types appear disjoint to
396
            // TypeIntersection::compute even though they are semantically compatible).
397
            if ($thenProperty->getNestedSchema() !== null || $elseProperty->getNestedSchema() !== null) {
67✔
398
                return;
48✔
399
            }
400

401
            if ($originalParentType !== null) {
19✔
402
                $this->detectIfThenElseConflict(
17✔
403
                    $property,
17✔
404
                    $originalParentType,
17✔
405
                    $thenProperty,
17✔
406
                    $elseProperty,
17✔
407
                );
17✔
408
                // Parent type constrains the property independently; widening is not applied.
409
                return;
14✔
410
            }
411

412
            // No parent type: widen to the union of then and else types (anyOf-like semantics).
413
            $this->transferPropertyType($property, [$thenProperty, $elseProperty], false);
2✔
414
        };
67✔
415

416
        $thenProperty->onResolve($onBothResolved);
67✔
417
        $elseProperty->onResolve($onBothResolved);
67✔
418
    }
419

420
    /**
421
     * Throw a SchemaException when the parent property's declared type is incompatible with
422
     * BOTH the then and the else branch types (empty intersection on both sides).
423
     *
424
     * A schema is unsatisfiable under this condition: no value can simultaneously satisfy the
425
     * parent type constraint and whichever composition branch fires. The schema is NOT
426
     * unsatisfiable when only one branch conflicts (values that satisfy the compatible branch
427
     * are still valid).
428
     *
429
     * @throws SchemaException
430
     */
431
    private function detectIfThenElseConflict(
17✔
432
        PropertyInterface $property,
433
        PropertyType $originalParentType,
434
        CompositionPropertyDecorator $thenProperty,
435
        CompositionPropertyDecorator $elseProperty,
436
    ): void {
437
        // isNullable() encodes the null part of a multi-type (e.g. ["string","null"]) separately
438
        // from getNames() — it is not present as a name string. Append 'null' so that a
439
        // null-typed branch is not incorrectly flagged as conflicting with a nullable parent.
440
        $parentNames = $originalParentType->isNullable()
17✔
441
            ? [...$originalParentType->getNames(), 'null']
2✔
442
            : $originalParentType->getNames();
15✔
443

444
        $thenTypes = $this->getBranchTypeNames($thenProperty);
17✔
445
        $elseTypes = $this->getBranchTypeNames($elseProperty);
17✔
446

447
        // A null return means the branch is truly untyped (no type keyword) — it accepts
448
        // any value and cannot conflict with the parent type.
449
        $thenConflicts = $thenTypes !== null
17✔
450
            && empty(TypeIntersection::compute($parentNames, $thenTypes));
17✔
451
        $elseConflicts = $elseTypes !== null
17✔
452
            && empty(TypeIntersection::compute($parentNames, $elseTypes));
17✔
453

454
        if ($thenConflicts || $elseConflicts) {
17✔
455
            throw new SchemaException(
3✔
456
                sprintf(
3✔
457
                    "Property '%s' has an if/then/else composition branch with a type incompatible"
3✔
458
                        . " with the property's declared type (file %s)."
3✔
459
                        . ' No value can satisfy both constraints.',
3✔
460
                    $property->getName(),
3✔
461
                    $property->getJsonSchema()->getFile(),
3✔
462
                ),
3✔
463
                $property->getJsonSchema(),
3✔
464
            );
3✔
465
        }
466
    }
467

468
    /**
469
     * Returns the full set of type names for a composition branch, or null when the branch
470
     * is truly untyped (no type keyword — accepts any value).
471
     *
472
     * NullModifier sets getType() to PHP null but adds a TypeHintDecorator containing 'null',
473
     * so we distinguish null-typed branches (effective types: ['null']) from truly untyped
474
     * branches (getType() also null, but no 'null' in the type hint).
475
     *
476
     * @return string[]|null null when the branch has no type constraint
477
     */
478
    private function getBranchTypeNames(CompositionPropertyDecorator $branch): ?array
17✔
479
    {
480
        $type = $branch->getType();
17✔
481

482
        if ($type !== null) {
17✔
483
            return $type->getNames();
16✔
484
        }
485

486
        // NullModifier-processed branch: getType() is PHP null but typeHint contains 'null'.
487
        if (str_contains($branch->getTypeHint(), 'null')) {
3✔
488
            return ['null'];
2✔
489
        }
490

491
        return null;
1✔
492
    }
493
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc