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

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

03 Jun 2026 03:54PM UTC coverage: 19.622% (-2.4%) from 22.013%
26912150489

push

github

wol-soft
Add unevaluatedProperties runtime support

Adds the runtime exceptions thrown by the generated unevaluatedProperties
validator (UnevaluatedPropertiesException for the `false`-form,
InvalidUnevaluatedPropertiesException for the schema-form) and a
collectUnevaluatedKeys() method on CompositionEvaluationTrait that the
generated validators call.

The method computes the list of model-data keys not evaluated by any
local applicator or successful sibling composition branch, consulting
the typed slot values in _compositionEvaluations (null, true, string[],
or nested-schema instance) recorded by the composition tracking pass.

The generator skips emitting the validator when the same schema declares
a non-false additionalProperties, so this method does not need to handle
that shortcut at runtime.

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

0 of 40 new or added lines in 2 files covered. (0.0%)

35 existing lines in 1 file now uncovered.

166 of 846 relevant lines covered (19.62%)

0.66 hits per line

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

55.56
/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✔
UNCOV
140
            $modelData = array_merge(
×
141
                $this->_serializeAdditionalProperties($depth, $except, $emptyObjectsAsStdClass),
×
UNCOV
142
                $modelData,
×
UNCOV
143
            );
×
144
        }
145

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

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

153
        if ($emptyObjectsAsStdClass && empty($data)) {
1✔
UNCOV
154
            return new stdClass();
×
155
        }
156

157
        return $data;
1✔
158
    }
159

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

182
        $map = [];
1✔
183
        $hasSchemaNames = false;
1✔
184
        $fallbackMap = [];
1✔
185

186
        foreach ((new ReflectionClass($this))->getProperties() as $property) {
1✔
187
            // Static properties are never part of an instance's serialized state.
188
            if ($property->isStatic()) {
1✔
UNCOV
189
                continue;
×
190
            }
191

192
            $phpName = $property->getName();
1✔
193
            $schemaNameAttributes = $property->getAttributes(SchemaName::class);
1✔
194

195
            if ($schemaNameAttributes !== []) {
1✔
UNCOV
196
                $map[$phpName] = $schemaNameAttributes[0]->newInstance()->name;
×
UNCOV
197
                $hasSchemaNames = true;
×
198
            } elseif (!$hasSchemaNames) {
1✔
199
                // Accumulate fallback entries for non-generated classes. Exclude any property
200
                // explicitly marked with #[Internal].
201
                if ($property->getAttributes(Internal::class) === []) {
1✔
202
                    $fallbackMap[$phpName] = $phpName;
1✔
203
                }
204
            }
205
        }
206

207
        return self::$propertySchemaNames[static::class] = $hasSchemaNames ? $map : $fallbackMap;
1✔
208
    }
209

210
    /**
211
     * Function can be overwritten by classes using the trait to hook into serialization
212
     */
213
    protected function _resolveSerializationHook(array $data, int $depth, array $except): array
1✔
214
    {
215
        return $data;
1✔
216
    }
217

218
    /**
219
     * Serializes the model's additional-properties bag.
220
     *
221
     * Overridden in generated classes when a transforming filter must run before serialization.
222
     * Only called when property_exists($this, '_additionalProperties') is true.
223
     */
UNCOV
224
    protected function _serializeAdditionalProperties(int $depth, array $except, bool $emptyObjectsAsStdClass): array
×
225
    {
UNCOV
226
        return (array) $this->_getSerializedValue(
×
UNCOV
227
            $this->_additionalProperties,
×
UNCOV
228
            $depth,
×
UNCOV
229
            $except,
×
UNCOV
230
            $emptyObjectsAsStdClass,
×
UNCOV
231
        );
×
232
    }
233

234
    /**
235
     * Serializes the model's pattern-properties bags.
236
     *
237
     * Only called when property_exists($this, '_patternProperties') is true.
238
     */
UNCOV
239
    protected function _serializePatternProperties(int $depth, array $except): array
×
240
    {
UNCOV
241
        $serializedPatternProperties = [];
×
242

243
        foreach ($this->_patternProperties as $patternKey => $properties) {
×
UNCOV
244
            if ($customSerializer = $this->_getCustomSerializerMethod($patternKey)) {
×
UNCOV
245
                foreach ($this->{$customSerializer}() as $propertyKey => $value) {
×
UNCOV
246
                    $serializedPatternProperties[$propertyKey] = $this->_getSerializedValue($value, $depth, $except);
×
247
                }
UNCOV
248
                continue;
×
249
            }
250

UNCOV
251
            foreach ($properties as $propertyKey => $value) {
×
UNCOV
252
                $serializedPatternProperties[$propertyKey] = $this->_getSerializedValue($value, $depth, $except);
×
253
            }
254
        }
255

256
        return $serializedPatternProperties;
×
257
    }
258

259
    private function _getSerializedValue($value, int $depth, array $except, bool $emptyObjectsAsStdClass = false)
1✔
260
    {
261
        if (is_array($value)) {
1✔
262
            $subData = [];
1✔
263
            foreach ($value as $subKey => $element) {
1✔
264
                $subData[$subKey] = $this->_getSerializedValue(
1✔
265
                    $element,
1✔
266
                    $depth - 1,
1✔
267
                    $except,
1✔
268
                    $emptyObjectsAsStdClass,
1✔
269
                );
1✔
270
            }
271
            return $subData;
1✔
272
        }
273

274
        return $this->_evaluateAttribute($value, $depth, $except, $emptyObjectsAsStdClass);
1✔
275
    }
276

277
    private function _evaluateAttribute($attribute, int $depth, array $except, bool $emptyObjectsAsStdClass)
1✔
278
    {
279
        if (!is_object($attribute)) {
1✔
280
            return $attribute;
1✔
281
        }
282

283
        // Determine and cache the serialization capability of this concrete class.
284
        // The cache key is the class name only — capability is class-intrinsic.
285
        // Context-dependent decisions (depth, emptyObjectsAsStdClass) are handled
286
        // after the cache lookup so they never pollute the cached value.
287
        self::$objectSerializationCapability[$attribute::class] ??= match (true) {
288
            $attribute instanceof SerializationInterface => 'protocol',
1✔
UNCOV
289
            method_exists($attribute, 'jsonSerialize')   => 'jsonSerializable',
×
UNCOV
290
            method_exists($attribute, 'toArray')         => 'toArray',
×
UNCOV
291
            method_exists($attribute, '__toString')      => 'stringable',
×
UNCOV
292
            default                                      => 'plain',
×
293
        };
294

295
        // Depth-exhausted: return a string representation if possible, null otherwise.
296
        if ($depth <= 0) {
1✔
UNCOV
297
            return method_exists($attribute, '__toString') ? (string) $attribute : null;
×
298
        }
299

300
        $data = match (self::$objectSerializationCapability[$attribute::class]) {
1✔
301
            // Objects speaking our serialization protocol: propagate $except and $depth,
302
            // and choose the method that preserves the emptyObjectsAsStdClass semantics.
303
            'protocol' => $emptyObjectsAsStdClass
1✔
UNCOV
304
                ? $attribute->jsonSerialize($except)
×
305
                : $attribute->toArray($except, $depth),
1✔
306
            // Objects implementing JsonSerializable but not our protocol: we can pass
307
            // $except for the jsonSerialize branch only; no depth or $except for toArray
308
            // because we don't know the callee's signature.
UNCOV
309
            'jsonSerializable' => $emptyObjectsAsStdClass
×
UNCOV
310
                ? $attribute->jsonSerialize()
×
UNCOV
311
                : get_object_vars($attribute),
×
UNCOV
312
            'toArray' => $attribute->toArray(),
×
313
            // 'stringable' and 'plain' both fall through to get_object_vars; stringable
314
            // was handled by the depth-exhausted early return above.
UNCOV
315
            default => get_object_vars($attribute),
×
316
        };
317

318
        if ($data === [] && $emptyObjectsAsStdClass) {
1✔
UNCOV
319
            return new stdClass();
×
320
        }
321

322
        return $data;
1✔
323
    }
324

325
    private function _getCustomSerializerMethod(string $property)
1✔
326
    {
327
        // Cache key includes the concrete class so that subclasses with additional
328
        // _serialize*() methods are discovered independently of their parent class.
329
        // array_key_exists is used (not isset) because the cached "no serializer"
330
        // sentinel is false, and isset() returns false for null but true for false.
331
        $cacheKey = static::class . '::' . $property;
1✔
332
        if (array_key_exists($cacheKey, self::$customSerializer)) {
1✔
333
            return self::$customSerializer[$cacheKey];
1✔
334
        }
335

336
        $customSerializer = '_serialize' . ucfirst($property);
1✔
337
        if (!method_exists($this, $customSerializer)) {
1✔
338
            $customSerializer = false;
1✔
339
        }
340

341
        return self::$customSerializer[$cacheKey] = $customSerializer;
1✔
342
    }
343
}
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