Skip to content

Commit d793ffb

Browse files
feat: union/intersect types (#5470)
* fix(metadata): handle union/intersect types * review * try to move SchemaFactory onto SchemaPropertyMetadataFactory * complete property schema on SchemaFactory * Apply suggestions from code review Co-authored-by: Antoine Bluchet <[email protected]> * fix: review * fix: cs * fix: phpunit * fix: cs * fix: tests about maker * fix: JsonSchema::SchemaFactory * fix: behat tests * fix deprec * tests --------- Co-authored-by: Antoine Bluchet <[email protected]>
1 parent 6babb3d commit d793ffb

File tree

52 files changed

+1548
-484
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+1548
-484
lines changed
+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
Feature: Union/Intersect types
2+
3+
Scenario Outline: Create a resource with union type
4+
When I add "Content-Type" header equal to "application/ld+json"
5+
And I add "Accept" header equal to "application/ld+json"
6+
And I send a "POST" request to "/issue-5452/books" with body:
7+
"""
8+
{
9+
"number": <number>,
10+
"isbn": "978-3-16-148410-0"
11+
}
12+
"""
13+
Then the response status code should be 201
14+
And the response should be in JSON
15+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
16+
And the JSON should be valid according to this schema:
17+
"""
18+
{
19+
"type": "object",
20+
"properties": {
21+
"@type": {
22+
"type": "string",
23+
"pattern": "^Book$"
24+
},
25+
"@context": {
26+
"type": "string",
27+
"pattern": "^/contexts/Book$"
28+
},
29+
"@id": {
30+
"type": "string",
31+
"pattern": "^/.well-known/genid/.+$"
32+
},
33+
"number": {
34+
"type": "<type>"
35+
},
36+
"isbn": {
37+
"type": "string",
38+
"pattern": "^978-3-16-148410-0$"
39+
}
40+
},
41+
"required": [
42+
"@type",
43+
"@context",
44+
"@id",
45+
"number",
46+
"isbn"
47+
]
48+
}
49+
"""
50+
Examples:
51+
| number | type |
52+
| "1" | string |
53+
| 1 | integer |
54+
55+
Scenario: Create a resource with valid intersect type
56+
When I add "Content-Type" header equal to "application/ld+json"
57+
And I send a "POST" request to "/issue-5452/books" with body:
58+
"""
59+
{
60+
"number": 1,
61+
"isbn": "978-3-16-148410-0",
62+
"author": "/issue-5452/authors/1"
63+
}
64+
"""
65+
Then the response status code should be 201
66+
And the response should be in JSON
67+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
68+
And the JSON should be valid according to this schema:
69+
"""
70+
{
71+
"type": "object",
72+
"properties": {
73+
"@type": {
74+
"type": "string",
75+
"pattern": "^Book$"
76+
},
77+
"@context": {
78+
"type": "string",
79+
"pattern": "^/contexts/Book$"
80+
},
81+
"@id": {
82+
"type": "string",
83+
"pattern": "^/.well-known/genid/.+$"
84+
},
85+
"number": {
86+
"type": "integer"
87+
},
88+
"isbn": {
89+
"type": "string",
90+
"pattern": "^978-3-16-148410-0$"
91+
},
92+
"author": {
93+
"type": "string",
94+
"pattern": "^/issue-5452/authors/1$"
95+
}
96+
},
97+
"required": [
98+
"@type",
99+
"@context",
100+
"@id",
101+
"number",
102+
"isbn",
103+
"author"
104+
]
105+
}
106+
"""
107+
108+
Scenario: Create a resource with invalid intersect type
109+
When I add "Content-Type" header equal to "application/ld+json"
110+
And I send a "POST" request to "/issue-5452/books" with body:
111+
"""
112+
{
113+
"number": 1,
114+
"isbn": "978-3-16-148410-0",
115+
"library": "/issue-5452/libraries/1"
116+
}
117+
"""
118+
Then the response status code should be 400
119+
And the response should be in JSON
120+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
121+
And the JSON node "hydra:description" should be equal to 'Could not denormalize object of type "ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\ActivableInterface", no supporting normalizer found.'

features/openapi/docs.feature

+12-10
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,19 @@ Feature: Documentation support
8686
{
8787
"default": "male",
8888
"example": "male",
89-
"type": "string",
89+
"type": ["string", "null"],
9090
"enum": [
9191
"male",
9292
"female",
9393
null
94-
],
95-
"nullable": true
94+
]
9695
}
9796
"""
9897
And the "playMode" property exists for the OpenAPI class "VideoGame"
9998
And the "playMode" property for the OpenAPI class "VideoGame" should be equal to:
10099
"""
101100
{
101+
"owl:maxCardinality": 1,
102102
"type": "string",
103103
"format": "iri-reference"
104104
}
@@ -238,8 +238,7 @@ Feature: Documentation support
238238
"type": "string"
239239
},
240240
"property": {
241-
"type": "string",
242-
"nullable": true
241+
"type": ["string", "null"]
243242
},
244243
"required": {
245244
"type": "boolean"
@@ -310,12 +309,15 @@ Feature: Documentation support
310309
And the "resourceRelated" property for the OpenAPI class "Resource" should be equal to:
311310
"""
312311
{
313-
"readOnly":true,
314-
"anyOf":[
312+
"owl:maxCardinality": 1,
313+
"readOnly": true,
314+
"anyOf": [
315+
{
316+
"$ref": "#/components/schemas/ResourceRelated"
317+
},
315318
{
316-
"$ref":"#/components/schemas/ResourceRelated"
319+
"type": "null"
317320
}
318-
],
319-
"nullable":true
321+
]
320322
}
321323
"""

src/Elasticsearch/Filter/AbstractFilter.php

+47-23
Original file line numberDiff line numberDiff line change
@@ -93,46 +93,70 @@ protected function getMetadata(string $resourceClass, string $property): array
9393
return $noop;
9494
}
9595

96-
$type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
96+
$types = $propertyMetadata->getBuiltinTypes();
9797

98-
if (null === $type) {
98+
if (null === $types) {
9999
return $noop;
100100
}
101101

102102
++$index;
103-
$builtinType = $type->getBuiltinType();
104103

105-
if (Type::BUILTIN_TYPE_OBJECT !== $builtinType && Type::BUILTIN_TYPE_ARRAY !== $builtinType) {
106-
if ($totalProperties === $index) {
107-
break;
104+
// check each type before deciding if it's noop or not
105+
// e.g: maybe the first type is noop, but the second is valid
106+
$isNoop = false;
107+
108+
foreach ($types as $type) {
109+
$builtinType = $type->getBuiltinType();
110+
111+
if (Type::BUILTIN_TYPE_OBJECT !== $builtinType && Type::BUILTIN_TYPE_ARRAY !== $builtinType) {
112+
if ($totalProperties === $index) {
113+
break 2;
114+
}
115+
116+
$isNoop = true;
117+
118+
continue;
108119
}
109120

110-
return $noop;
111-
}
121+
if ($type->isCollection() && null === $type = $type->getCollectionValueTypes()[0] ?? null) {
122+
$isNoop = true;
112123

113-
if ($type->isCollection() && null === $type = $type->getCollectionValueTypes()[0] ?? null) {
114-
return $noop;
115-
}
124+
continue;
125+
}
126+
127+
if (Type::BUILTIN_TYPE_ARRAY === $builtinType && Type::BUILTIN_TYPE_OBJECT !== $type->getBuiltinType()) {
128+
if ($totalProperties === $index) {
129+
break 2;
130+
}
131+
132+
$isNoop = true;
116133

117-
if (Type::BUILTIN_TYPE_ARRAY === $builtinType && Type::BUILTIN_TYPE_OBJECT !== $type->getBuiltinType()) {
118-
if ($totalProperties === $index) {
119-
break;
134+
continue;
120135
}
121136

122-
return $noop;
123-
}
137+
if (null === $className = $type->getClassName()) {
138+
$isNoop = true;
124139

125-
if (null === $className = $type->getClassName()) {
126-
return $noop;
140+
continue;
141+
}
142+
143+
if ($isResourceClass = $this->resourceClassResolver->isResourceClass($className)) {
144+
$currentResourceClass = $className;
145+
} elseif ($totalProperties !== $index) {
146+
$isNoop = true;
147+
148+
continue;
149+
}
150+
151+
$hasAssociation = $totalProperties === $index && $isResourceClass;
152+
$isNoop = false;
153+
154+
break;
127155
}
128156

129-
if ($isResourceClass = $this->resourceClassResolver->isResourceClass($className)) {
130-
$currentResourceClass = $className;
131-
} elseif ($totalProperties !== $index) {
157+
if ($isNoop) {
132158
return $noop;
133159
}
134-
135-
$hasAssociation = $totalProperties === $index && $isResourceClass;
136160
}
137161

138162
return [$type, $hasAssociation, $currentResourceClass, $currentProperty];

src/Elasticsearch/Util/FieldDatatypeTrait.php

+21-24
Original file line numberDiff line numberDiff line change
@@ -59,30 +59,27 @@ private function getNestedFieldPath(string $resourceClass, string $property): ?s
5959
return null;
6060
}
6161

62-
// TODO: 3.0 allow multiple types
63-
$type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
64-
65-
if (null === $type) {
66-
return null;
67-
}
68-
69-
if (
70-
Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType()
71-
&& null !== ($nextResourceClass = $type->getClassName())
72-
&& $this->resourceClassResolver->isResourceClass($nextResourceClass)
73-
) {
74-
$nestedPath = $this->getNestedFieldPath($nextResourceClass, implode('.', $properties));
75-
76-
return null === $nestedPath ? $nestedPath : "$currentProperty.$nestedPath";
77-
}
78-
79-
if (
80-
null !== ($type = $type->getCollectionValueTypes()[0] ?? null)
81-
&& Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType()
82-
&& null !== ($className = $type->getClassName())
83-
&& $this->resourceClassResolver->isResourceClass($className)
84-
) {
85-
return $currentProperty;
62+
$types = $propertyMetadata->getBuiltinTypes() ?? [];
63+
64+
foreach ($types as $type) {
65+
if (
66+
Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType()
67+
&& null !== ($nextResourceClass = $type->getClassName())
68+
&& $this->resourceClassResolver->isResourceClass($nextResourceClass)
69+
) {
70+
$nestedPath = $this->getNestedFieldPath($nextResourceClass, implode('.', $properties));
71+
72+
return null === $nestedPath ? $nestedPath : "$currentProperty.$nestedPath";
73+
}
74+
75+
if (
76+
null !== ($type = $type->getCollectionValueTypes()[0] ?? null)
77+
&& Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType()
78+
&& null !== ($className = $type->getClassName())
79+
&& $this->resourceClassResolver->isResourceClass($className)
80+
) {
81+
return $currentProperty;
82+
}
8683
}
8784

8885
return null;

src/GraphQl/Type/FieldsBuilder.php

+9-3
Original file line numberDiff line numberDiff line change
@@ -213,17 +213,23 @@ public function getResourceObjectTypeFields(?string $resourceClass, Operation $o
213213
'denormalization_groups' => $operation->getDenormalizationContext()['groups'] ?? null,
214214
];
215215
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $context);
216+
$propertyTypes = $propertyMetadata->getBuiltinTypes();
216217

217218
if (
218-
null === ($propertyType = $propertyMetadata->getBuiltinTypes()[0] ?? null)
219+
!$propertyTypes
219220
|| (!$input && false === $propertyMetadata->isReadable())
220221
|| ($input && $operation instanceof Mutation && false === $propertyMetadata->isWritable())
221222
) {
222223
continue;
223224
}
224225

225-
if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) {
226-
$fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration;
226+
// guess union/intersect types: check each type until finding a valid one
227+
foreach ($propertyTypes as $propertyType) {
228+
if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) {
229+
$fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration;
230+
// stop at the first valid type
231+
break;
232+
}
227233
}
228234
}
229235
}

0 commit comments

Comments
 (0)