13
13
14
14
use Symfony \AI \Platform \Contract \JsonSchema \Attribute \With ;
15
15
use Symfony \AI \Platform \Exception \InvalidArgumentException ;
16
+ use Symfony \Component \Serializer \Attribute \DiscriminatorMap ;
16
17
use Symfony \Component \TypeInfo \Type ;
17
18
use Symfony \Component \TypeInfo \Type \BackedEnumType ;
18
19
use Symfony \Component \TypeInfo \Type \BuiltinType ;
19
20
use Symfony \Component \TypeInfo \Type \CollectionType ;
20
21
use Symfony \Component \TypeInfo \Type \NullableType ;
21
22
use Symfony \Component \TypeInfo \Type \ObjectType ;
23
+ use Symfony \Component \TypeInfo \Type \UnionType ;
22
24
use Symfony \Component \TypeInfo \TypeIdentifier ;
23
25
use Symfony \Component \TypeInfo \TypeResolver \TypeResolver ;
24
26
47
49
* minProperties?: int,
48
50
* maxProperties?: int,
49
51
* dependentRequired?: bool,
52
+ * anyOf?: list<mixed>,
50
53
* }>,
51
54
* required: list<string>,
52
55
* additionalProperties: false,
@@ -110,7 +113,10 @@ private function convertTypes(array $elements): ?array
110
113
$ schema = $ this ->getTypeSchema ($ type );
111
114
112
115
if ($ type ->isNullable ()) {
113
- $ schema ['type ' ] = [$ schema ['type ' ], 'null ' ];
116
+ // anyOf already contains the null variant when applicable; do nothing
117
+ if (!isset ($ schema ['anyOf ' ])) {
118
+ $ schema ['type ' ] = [$ schema ['type ' ], 'null ' ];
119
+ }
114
120
} elseif (!($ element instanceof \ReflectionParameter && $ element ->isOptional ())) {
115
121
$ result ['required ' ][] = $ name ;
116
122
}
@@ -151,6 +157,21 @@ private function getTypeSchema(Type $type): array
151
157
}
152
158
}
153
159
160
+ if ($ type instanceof UnionType) {
161
+ // Do not handle nullables as a union but directly return the wrapped type schema
162
+ if (2 === \count ($ type ->getTypes ()) && $ type ->isNullable () && $ type instanceof NullableType) {
163
+ return $ this ->getTypeSchema ($ type ->getWrappedType ());
164
+ }
165
+
166
+ $ variants = [];
167
+
168
+ foreach ($ type ->getTypes () as $ variant ) {
169
+ $ variants [] = $ this ->getTypeSchema ($ variant );
170
+ }
171
+
172
+ return ['anyOf ' => $ variants ];
173
+ }
174
+
154
175
switch (true ) {
155
176
case $ type ->isIdentifiedBy (TypeIdentifier::INT ):
156
177
return ['type ' => 'integer ' ];
@@ -168,6 +189,22 @@ private function getTypeSchema(Type $type): array
168
189
if ($ collectionValueType ->isIdentifiedBy (TypeIdentifier::OBJECT )) {
169
190
\assert ($ collectionValueType instanceof ObjectType);
170
191
192
+ // Check for the DiscriminatorMap attribute to handle polymorphic arrays
193
+ $ discriminatorMapping = $ this ->findDiscriminatorMapping ($ collectionValueType ->getClassName ());
194
+ if ($ discriminatorMapping ) {
195
+ $ discriminators = [];
196
+ foreach ($ discriminatorMapping as $ _ => $ discriminator ) {
197
+ $ discriminators [] = $ this ->buildProperties ($ discriminator );
198
+ }
199
+
200
+ return [
201
+ 'type ' => 'array ' ,
202
+ 'items ' => [
203
+ 'anyOf ' => $ discriminators ,
204
+ ],
205
+ ];
206
+ }
207
+
171
208
return [
172
209
'type ' => 'array ' ,
173
210
'items ' => $ this ->buildProperties ($ collectionValueType ->getClassName ()),
@@ -195,6 +232,8 @@ private function getTypeSchema(Type $type): array
195
232
}
196
233
197
234
// no break
235
+ case $ type ->isIdentifiedBy (TypeIdentifier::NULL ):
236
+ return ['type ' => 'null ' ];
198
237
case $ type ->isIdentifiedBy (TypeIdentifier::STRING ):
199
238
default :
200
239
// Fallback to string for any unhandled types
@@ -233,4 +272,34 @@ private function buildEnumSchema(string $enumClassName): array
233
272
'enum ' => $ values ,
234
273
];
235
274
}
275
+
276
+ /**
277
+ * @param class-string $className
278
+ *
279
+ * @return array<string, class-string>|null
280
+ *
281
+ * @throws \ReflectionException
282
+ */
283
+ private function findDiscriminatorMapping (string $ className ): ?array
284
+ {
285
+ /** @var \ReflectionAttribute<DiscriminatorMap>[] $attributes */
286
+ $ attributes = (new \ReflectionClass ($ className ))->getAttributes (DiscriminatorMap::class);
287
+ $ result = \count ($ attributes ) > 0 ? $ attributes [array_key_first ($ attributes )]->newInstance () : null ;
288
+
289
+ if (!$ result ) {
290
+ return null ;
291
+ }
292
+
293
+ /**
294
+ * In the 8.* release of symfony/serializer DiscriminatorMap removes the getMapping() method in favor of property access.
295
+ * This satisfies the project's pipeline that builds against both < and >= 8.* release.
296
+ * This logic can be removed once the project builds against >= 8.* only.
297
+ *
298
+ * @see https://github.com/symfony/ai/pull/585#issuecomment-3303631346
299
+ */
300
+ $ reflectionProperty = new \ReflectionProperty ($ result , 'mapping ' );
301
+ $ reflectionProperty ->setAccessible (true );
302
+
303
+ return $ reflectionProperty ->getValue ($ result );
304
+ }
236
305
}
0 commit comments