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

wol-soft / php-json-schema-model-generator-production / 27720209155

17 Jun 2026 09:14PM UTC coverage: 18.913% (-0.7%) from 19.622%
27720209155

push

github

wol-soft
Add unevaluatedProperties accessors and per-key pattern validity

Introduce UnevaluatedPropertiesAccessor and ImmutableUnevaluatedPropertiesAccessor
plus RegularPropertyAsUnevaluatedPropertyException, modelled on the existing
additionalProperties pair. SerializableTrait gains _serializeUnevaluatedProperties
so the new _unevaluatedProperties bucket flattens back into toArray() / toJSON()
output the same way the additional and pattern buckets do.

Fix CompositionEvaluationTrait::collectUnevaluatedKeys to honour per-key
patternProperties validity (decision 0.5 from the unevaluated-properties plan).
A pattern-matched key is now credited as evaluated only when its stored value
in _patternProperties equals the current modelData value; a mismatch means the
last validation attempt failed and was rolled back by PatternProperties.phptpl,
so the key counts as unevaluated. Without this, an unevaluatedProperties:false
sibling miscredits a pattern-matched key whose value failed integer validation
and never fires for the orphan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

1 of 40 new or added lines in 5 files covered. (2.5%)

167 of 883 relevant lines covered (18.91%)

0.63 hits per line

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

52.59
/src/Traits/SerializableTrait.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\Traits;
6

7
use PHPModelGenerator\Attributes\Internal;
8
use PHPModelGenerator\Attributes\SchemaName;
9
use PHPModelGenerator\Interfaces\SerializationInterface;
10
use ReflectionClass;
11
use stdClass;
12

13
/**
14
 * Provide methods to serialize generated models
15
 *
16
 * Trait SerializableTrait
17
 *
18
 * @package PHPModelGenerator\Traits
19
 */
20
trait SerializableTrait
21
{
22
    /**
23
     * Maps concrete class names to their php-name => schema-name property maps.
24
     * Populated once per class via reflection.
25
     *
26
     * @var array<class-string, array<string, string>>
27
     */
28
    #[Internal]
29
    private static array $propertySchemaNames = [];
30

31
    /**
32
     * Maps concrete class names to their serialization capability string.
33
     * Populated once per encountered nested-object class.
34
     *
35
     * @var array<class-string, string>
36
     */
37
    #[Internal]
38
    private static array $objectSerializationCapability = [];
39

40
    /** @var array<string, string|false> */
41
    #[Internal]
42
    private static array $customSerializer = [];
43

44
    /**
45
     * Get a JSON representation of the current state
46
     *
47
     * @param array $except provide a list of properties which shouldn't be contained in the resulting JSON.
48
     *                      eg. if you want to return an user model and don't want the password to be included
49
     * @param int $options  Bitmask for json_encode
50
     * @param int $depth    the maximum level of object nesting. Must be greater than 0
51
     *
52
     * @return string|false
53
     */
54
    public function toJSON(array $except = [], int $options = 0, int $depth = 512)
×
55
    {
56
        if ($depth < 1) {
×
57
            return false;
×
58
        }
59

60
        return json_encode($this->_getValues($depth, $except, true), $options, $depth);
×
61
    }
62

63
    /**
64
     * Return a JSON serializable representation of the current state
65
     */
66
    #[\ReturnTypeWillChange]
67
    public function jsonSerialize(array $except = [])
×
68
    {
69
        return $this->_getValues(512, $except, true);
×
70
    }
71

72
    /**
73
     * Get an array representation of the current state
74
     *
75
     * @param array $except provide a list of properties which shouldn't be contained in the resulting JSON.
76
     *                      eg. if you want to return an user model and don't want the password to be included
77
     * @param int $depth    the maximum level of object nesting. Must be greater than 0
78
     *
79
     * @return array|false
80
     */
81
    public function toArray(array $except = [], int $depth = 512)
1✔
82
    {
83
        if ($depth < 1) {
1✔
84
            return false;
×
85
        }
86

87
        return $this->_getValues($depth, $except, false);
1✔
88
    }
89

90
    /**
91
     * Get a representation of the current state
92
     *
93
     * @param array $except                provide a list of properties which shouldn't be contained in the resulting
94
     *                                     JSON. eg. if you want to return an user model and don't want the password
95
     *                                     to be included
96
     * @param int $depth                   the maximum level of object nesting. Must be greater than 0
97
     * @param bool $emptyObjectsAsStdClass If set to true, the wrapping data structure for empty objects will be an
98
     *                                     stdClass. Array otherwise
99
     *
100
     * @return array|stdClass
101
     */
102
    private function _getValues(int $depth, array $except, bool $emptyObjectsAsStdClass)
1✔
103
    {
104
        $depth--;
1✔
105
        $modelData = [];
1✔
106

107
        $localExcept = $except;
1✔
108
        if (isset($this->_skipNotProvidedPropertiesMap, $this->_rawModelDataInput)) {
1✔
109
            $localExcept = array_merge(
×
110
                $localExcept,
×
111
                array_diff($this->_skipNotProvidedPropertiesMap, array_keys($this->_rawModelDataInput))
×
112
            );
×
113
        }
114

115
        foreach ($this->_getPropertySchemaNames() as $phpName => $schemaName) {
1✔
116
            if (in_array($schemaName, $localExcept, true)) {
1✔
117
                continue;
1✔
118
            }
119

120
            if ($customSerializer = $this->_getCustomSerializerMethod($phpName)) {
1✔
121
                $modelData[$schemaName] = $this->_getSerializedValue(
×
122
                    $this->{$customSerializer}(),
×
123
                    $depth,
×
124
                    $except,
×
125
                    $emptyObjectsAsStdClass,
×
126
                );
×
127
                continue;
×
128
            }
129

130
            $modelData[$schemaName] = $this->_getSerializedValue(
1✔
131
                $this->{$phpName},
1✔
132
                $depth,
1✔
133
                $except,
1✔
134
                $emptyObjectsAsStdClass,
1✔
135
            );
1✔
136
        }
137

138
        // Additional properties are merged before tagged properties so that tagged properties take precedence.
139
        if (property_exists($this, '_additionalProperties')) {
1✔
140
            $modelData = array_merge(
×
141
                $this->_serializeAdditionalProperties($depth, $except, $emptyObjectsAsStdClass),
×
142
                $modelData,
×
143
            );
×
144
        }
145

146
        // Pattern properties fill any remaining keys not already present (tagged properties take precedence).
147
        if (property_exists($this, '_patternProperties')) {
1✔
148
            $modelData += $this->_serializePatternProperties($depth, $except);
×
149
        }
150

151
        // Unevaluated properties are a catch-all for keys not claimed by any of the above. The
152
        // buckets are non-overlapping by construction (the runtime accumulator rebuild credits
153
        // properties / patternProperties / additionalProperties / composition branches before
154
        // unevaluatedProperties fires) so the merge order is purely cosmetic.
155
        if (property_exists($this, '_unevaluatedProperties')) {
1✔
NEW
156
            $modelData += $this->_serializeUnevaluatedProperties($depth, $except, $emptyObjectsAsStdClass);
×
157
        }
158

159
        $data = $this->_resolveSerializationHook($modelData, $depth, $except);
1✔
160

161
        if ($emptyObjectsAsStdClass && empty($data)) {
1✔
162
            return new stdClass();
×
163
        }
164

165
        return $data;
1✔
166
    }
167

168
    /**
169
     * Build and cache the php-name => schema-name map for this concrete class in a single
170
     * reflection pass.
171
     *
172
     * For generated model classes every schema-derived property carries a #[SchemaName]
173
     * attribute; only those properties are included, keyed by their original JSON Schema name.
174
     *
175
     * For hand-written classes that include this trait (e.g. the exception hierarchy) no
176
     * #[SchemaName] attributes are present. In that case the method falls back to the legacy
177
     * behaviour and uses the PHP property name as both the key and the output key, preserving
178
     * backward-compatible serialization for those classes.
179
     *
180
     * Static properties and properties marked with #[Internal] are never serialized.
181
     *
182
     * @return array<string, string>
183
     */
184
    private function _getPropertySchemaNames(): array
1✔
185
    {
186
        if (isset(self::$propertySchemaNames[static::class])) {
1✔
187
            return self::$propertySchemaNames[static::class];
1✔
188
        }
189

190
        $map = [];
1✔
191
        $hasSchemaNames = false;
1✔
192
        $fallbackMap = [];
1✔
193

194
        foreach ((new ReflectionClass($this))->getProperties() as $property) {
1✔
195
            // Static properties are never part of an instance's serialized state.
196
            if ($property->isStatic()) {
1✔
197
                continue;
×
198
            }
199

200
            $phpName = $property->getName();
1✔
201
            $schemaNameAttributes = $property->getAttributes(SchemaName::class);
1✔
202

203
            if ($schemaNameAttributes !== []) {
1✔
204
                $map[$phpName] = $schemaNameAttributes[0]->newInstance()->name;
×
205
                $hasSchemaNames = true;
×
206
            } elseif (!$hasSchemaNames) {
1✔
207
                // Accumulate fallback entries for non-generated classes. Exclude any property
208
                // explicitly marked with #[Internal].
209
                if ($property->getAttributes(Internal::class) === []) {
1✔
210
                    $fallbackMap[$phpName] = $phpName;
1✔
211
                }
212
            }
213
        }
214

215
        return self::$propertySchemaNames[static::class] = $hasSchemaNames ? $map : $fallbackMap;
1✔
216
    }
217

218
    /**
219
     * Function can be overwritten by classes using the trait to hook into serialization
220
     */
221
    protected function _resolveSerializationHook(array $data, int $depth, array $except): array
1✔
222
    {
223
        return $data;
1✔
224
    }
225

226
    /**
227
     * Serializes the model's additional-properties bag.
228
     *
229
     * Overridden in generated classes when a transforming filter must run before serialization.
230
     * Only called when property_exists($this, '_additionalProperties') is true.
231
     */
232
    protected function _serializeAdditionalProperties(int $depth, array $except, bool $emptyObjectsAsStdClass): array
×
233
    {
234
        return (array) $this->_getSerializedValue(
×
235
            $this->_additionalProperties,
×
236
            $depth,
×
237
            $except,
×
238
            $emptyObjectsAsStdClass,
×
239
        );
×
240
    }
241

242
    /**
243
     * Serializes the model's unevaluated-properties bag.
244
     *
245
     * Overridden in generated classes when a transforming filter must run before serialization.
246
     * Only called when property_exists($this, '_unevaluatedProperties') is true.
247
     */
NEW
248
    protected function _serializeUnevaluatedProperties(int $depth, array $except, bool $emptyObjectsAsStdClass): array
×
249
    {
NEW
250
        return (array) $this->_getSerializedValue(
×
NEW
251
            $this->_unevaluatedProperties,
×
NEW
252
            $depth,
×
NEW
253
            $except,
×
NEW
254
            $emptyObjectsAsStdClass,
×
NEW
255
        );
×
256
    }
257

258
    /**
259
     * Serializes the model's pattern-properties bags.
260
     *
261
     * Only called when property_exists($this, '_patternProperties') is true.
262
     */
263
    protected function _serializePatternProperties(int $depth, array $except): array
×
264
    {
265
        $serializedPatternProperties = [];
×
266

267
        foreach ($this->_patternProperties as $patternKey => $properties) {
×
268
            if ($customSerializer = $this->_getCustomSerializerMethod($patternKey)) {
×
269
                foreach ($this->{$customSerializer}() as $propertyKey => $value) {
×
270
                    $serializedPatternProperties[$propertyKey] = $this->_getSerializedValue($value, $depth, $except);
×
271
                }
272
                continue;
×
273
            }
274

275
            foreach ($properties as $propertyKey => $value) {
×
276
                $serializedPatternProperties[$propertyKey] = $this->_getSerializedValue($value, $depth, $except);
×
277
            }
278
        }
279

280
        return $serializedPatternProperties;
×
281
    }
282

283
    private function _getSerializedValue($value, int $depth, array $except, bool $emptyObjectsAsStdClass = false)
1✔
284
    {
285
        if (is_array($value)) {
1✔
286
            $subData = [];
1✔
287
            foreach ($value as $subKey => $element) {
1✔
288
                $subData[$subKey] = $this->_getSerializedValue(
1✔
289
                    $element,
1✔
290
                    $depth - 1,
1✔
291
                    $except,
1✔
292
                    $emptyObjectsAsStdClass,
1✔
293
                );
1✔
294
            }
295
            return $subData;
1✔
296
        }
297

298
        return $this->_evaluateAttribute($value, $depth, $except, $emptyObjectsAsStdClass);
1✔
299
    }
300

301
    private function _evaluateAttribute($attribute, int $depth, array $except, bool $emptyObjectsAsStdClass)
1✔
302
    {
303
        if (!is_object($attribute)) {
1✔
304
            return $attribute;
1✔
305
        }
306

307
        // Determine and cache the serialization capability of this concrete class.
308
        // The cache key is the class name only — capability is class-intrinsic.
309
        // Context-dependent decisions (depth, emptyObjectsAsStdClass) are handled
310
        // after the cache lookup so they never pollute the cached value.
311
        self::$objectSerializationCapability[$attribute::class] ??= match (true) {
312
            $attribute instanceof SerializationInterface => 'protocol',
1✔
313
            method_exists($attribute, 'jsonSerialize')   => 'jsonSerializable',
×
314
            method_exists($attribute, 'toArray')         => 'toArray',
×
315
            method_exists($attribute, '__toString')      => 'stringable',
×
316
            default                                      => 'plain',
×
317
        };
318

319
        // Depth-exhausted: return a string representation if possible, null otherwise.
320
        if ($depth <= 0) {
1✔
321
            return method_exists($attribute, '__toString') ? (string) $attribute : null;
×
322
        }
323

324
        $data = match (self::$objectSerializationCapability[$attribute::class]) {
1✔
325
            // Objects speaking our serialization protocol: propagate $except and $depth,
326
            // and choose the method that preserves the emptyObjectsAsStdClass semantics.
327
            'protocol' => $emptyObjectsAsStdClass
1✔
328
                ? $attribute->jsonSerialize($except)
×
329
                : $attribute->toArray($except, $depth),
1✔
330
            // Objects implementing JsonSerializable but not our protocol: we can pass
331
            // $except for the jsonSerialize branch only; no depth or $except for toArray
332
            // because we don't know the callee's signature.
333
            'jsonSerializable' => $emptyObjectsAsStdClass
×
334
                ? $attribute->jsonSerialize()
×
335
                : get_object_vars($attribute),
×
336
            'toArray' => $attribute->toArray(),
×
337
            // 'stringable' and 'plain' both fall through to get_object_vars; stringable
338
            // was handled by the depth-exhausted early return above.
339
            default => get_object_vars($attribute),
×
340
        };
341

342
        if ($data === [] && $emptyObjectsAsStdClass) {
1✔
343
            return new stdClass();
×
344
        }
345

346
        return $data;
1✔
347
    }
348

349
    private function _getCustomSerializerMethod(string $property)
1✔
350
    {
351
        // Cache key includes the concrete class so that subclasses with additional
352
        // _serialize*() methods are discovered independently of their parent class.
353
        // array_key_exists is used (not isset) because the cached "no serializer"
354
        // sentinel is false, and isset() returns false for null but true for false.
355
        $cacheKey = static::class . '::' . $property;
1✔
356
        if (array_key_exists($cacheKey, self::$customSerializer)) {
1✔
357
            return self::$customSerializer[$cacheKey];
1✔
358
        }
359

360
        $customSerializer = '_serialize' . ucfirst($property);
1✔
361
        if (!method_exists($this, $customSerializer)) {
1✔
362
            $customSerializer = false;
1✔
363
        }
364

365
        return self::$customSerializer[$cacheKey] = $customSerializer;
1✔
366
    }
367
}
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