Skip to content

Commit f737d78

Browse files
feat: repeatable attributes support (#394)
Co-authored-by: Alan Poulain <[email protected]>
1 parent 9aa7f65 commit f737d78

File tree

14 files changed

+121
-36
lines changed

14 files changed

+121
-36
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 5.1.0
4+
5+
* feat: repeatable attributes support
6+
37
## 5.0.0
48

59
* feat: add OpenAPI support

phpstan.neon

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ parameters:
1818
required: ?boolean,
1919
unique: boolean,
2020
embedded: boolean,
21-
attributes: array<string, (int|bool|null|string|string[]|string[][]|\Nette\PhpGenerator\Literal)[]|null>
21+
attributes: list<array<string, (int|bool|null|string|string[]|string[][]|\Nette\PhpGenerator\Literal)[]|null>>
2222
}
2323
'''
2424
TypeConfiguration: '''
@@ -28,7 +28,7 @@ parameters:
2828
abstract: ?boolean,
2929
embeddable: boolean,
3030
namespaces: array{class: ?string, interface: ?string},
31-
attributes: array<string, (int|bool|null|string|string[]|string[][]|\Nette\PhpGenerator\Literal)[]|null>,
31+
attributes: list<array<string, (int|bool|null|string|string[]|string[][]|\Nette\PhpGenerator\Literal)[]|null>>,
3232
parent: false|string,
3333
guessFrom: string,
3434
operations: array<string, ?array<string, string|int|bool|string[]|null>>,
@@ -38,7 +38,7 @@ parameters:
3838
'''
3939
Configuration: '''
4040
array{
41-
vocabularies: array{uri: string, format: string, allTypes: ?boolean, attributes: array<string, (int|bool|null|string|string[]|string[][]|\Nette\PhpGenerator\Literal)[]|null>}[],
41+
vocabularies: array{uri: string, format: string, allTypes: ?boolean, attributes: list<array<string, (int|bool|null|string|string[]|string[][]|\Nette\PhpGenerator\Literal)[]|null>>}[],
4242
vocabularyNamespace: string,
4343
relations: array{uris: string[], defaultCardinality: string},
4444
debug: boolean,
@@ -53,7 +53,7 @@ parameters:
5353
useCollection: boolean,
5454
resolveTargetEntityConfigPath: ?string,
5555
resolveTargetEntityConfigType: 'XML'|'yaml',
56-
inheritanceAttributes: array<string, (int|bool|null|string|string[]|string[][]|\Nette\PhpGenerator\Literal)[]>,
56+
inheritanceAttributes: list<array<string, (int|bool|null|string|string[]|string[][]|\Nette\PhpGenerator\Literal)[]|null>>,
5757
inheritanceType: 'JOINED'|'SINGLE_TABLE'|'SINGLE_COLLECTION'|'TABLE_PER_CLASS'|'COLLECTION_PER_CLASS'|'NONE',
5858
maxIdentifierLength: integer
5959
},

src/AttributeGenerator/ConfigurationAttributeGenerator.php

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,44 @@ final class ConfigurationAttributeGenerator extends AbstractAttributeGenerator
2626
*/
2727
public function generateClassAttributes(Class_ $class): array
2828
{
29-
$typeConfig = $this->config['types'][$class->name()] ?? null;
30-
$vocabConfig = null;
29+
$typeAttributes = $this->config['types'][$class->name()]['attributes'] ?? [[]];
30+
$vocabAttributes = [[]];
3131
if ($class instanceof SchemaClass) {
32-
$vocabConfig = $this->config['vocabularies'][$class->resource()->getGraph()->getUri()] ?? null;
32+
$vocabAttributes = $this->config['vocabularies'][$class->resource()->getGraph()->getUri()]['attributes'] ?? [[]];
3333
}
3434

35+
$getAttributesNames = static fn (array $config) => $config === [[]]
36+
? []
37+
: array_unique(array_map(fn (array $v) => array_keys($v)[0], $config));
38+
$typeAttributesNames = $getAttributesNames($typeAttributes);
39+
$vocabAttributesNames = $getAttributesNames($vocabAttributes);
40+
41+
$getAttribute = static fn (string $name, array $args) => new Attribute(
42+
$name,
43+
$args + [
44+
// An attribute from a vocabulary cannot be appended if a same one has not
45+
// previously been generated or if the same one is not mergeable.
46+
// It allows vocabulary attributes configuration to only merge the attributes args.
47+
'alwaysGenerate' => !\in_array($name, $vocabAttributesNames, true) ||
48+
\in_array($name, $typeAttributesNames, true),
49+
// Custom explicitly configured attributes is not mergeable with next one
50+
// but treated as repeated if given more than once.
51+
'mergeable' => false,
52+
]
53+
);
54+
3555
$attributes = [];
36-
$configAttributes = array_merge($vocabConfig['attributes'] ?? [], $typeConfig['attributes'] ?? []);
37-
foreach ($configAttributes as $attributeName => $attributeArgs) {
38-
$attributes[] = new Attribute($attributeName, ($attributeArgs ?? []) + ['alwaysGenerate' => !isset($vocabConfig['attributes'][$attributeName]) || isset($typeConfig['attributes'][$attributeName])]);
56+
foreach ($vocabAttributes as $configAttributes) {
57+
foreach ($configAttributes as $attributeName => $attributeArgs) {
58+
if (!\in_array($attributeName, $typeAttributesNames, true)) {
59+
$attributes[] = $getAttribute($attributeName, $attributeArgs ?? []);
60+
}
61+
}
62+
}
63+
foreach ($typeAttributes as $configAttributes) {
64+
foreach ($configAttributes as $attributeName => $attributeArgs) {
65+
$attributes[] = $getAttribute($attributeName, $attributeArgs ?? []);
66+
}
3967
}
4068

4169
return $attributes;
@@ -47,11 +75,16 @@ public function generateClassAttributes(Class_ $class): array
4775
public function generatePropertyAttributes(Property $property, string $className): array
4876
{
4977
$typeConfig = $this->config['types'][$className] ?? null;
50-
$propertyConfig = $typeConfig['properties'][$property->name()] ?? null;
78+
$propertyAttributes = $typeConfig['properties'][$property->name()]['attributes'] ?? [[]];
5179

5280
$attributes = [];
53-
foreach ($propertyConfig['attributes'] ?? [] as $attributeName => $attributeArgs) {
54-
$attributes[] = new Attribute($attributeName, $attributeArgs ?? []);
81+
foreach ($propertyAttributes as $configAttributes) {
82+
foreach ($configAttributes as $attributeName => $attributeArgs) {
83+
$attributes[] = new Attribute(
84+
$attributeName,
85+
($attributeArgs ?? []) + ['mergeable' => false]
86+
);
87+
}
5588
}
5689

5790
return $attributes;

src/AttributeGenerator/DoctrineMongoDBAttributeGenerator.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ public function generateClassAttributes(Class_ $class): array
4040

4141
$attributes = [];
4242
if ($class->hasChild && ($inheritanceAttributes = $this->config['doctrine']['inheritanceAttributes'])) {
43-
foreach ($inheritanceAttributes as $attributeName => $attributeArgs) {
44-
$attributes[] = new Attribute($attributeName, $attributeArgs);
43+
foreach ($inheritanceAttributes as $configAttributes) {
44+
foreach ($configAttributes as $attributeName => $attributeArgs) {
45+
$attributes[] = new Attribute($attributeName, $attributeArgs ?? []);
46+
}
4547
}
4648
} elseif ($class->isAbstract) {
4749
$attributes[] = new Attribute('MongoDB\MappedSuperclass');

src/AttributeGenerator/DoctrineOrmAttributeGenerator.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@ public function generateClassAttributes(Class_ $class): array
4848

4949
$attributes = [];
5050
if ($class->hasChild && ($inheritanceAttributes = $this->config['doctrine']['inheritanceAttributes'])) {
51-
foreach ($inheritanceAttributes as $attributeName => $attributeArgs) {
52-
$attributes[] = new Attribute($attributeName, $attributeArgs);
51+
foreach ($inheritanceAttributes as $configAttributes) {
52+
foreach ($configAttributes as $attributeName => $attributeArgs) {
53+
$attributes[] = new Attribute($attributeName, $attributeArgs ?? []);
54+
}
5355
}
5456
} elseif ($class->isAbstract) {
5557
$attributes[] = new Attribute('ORM\MappedSuperclass');

src/Model/AddAttributeTrait.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,19 @@ trait AddAttributeTrait
1818
public function addAttribute(Attribute $attribute): self
1919
{
2020
if (!\in_array($attribute, $this->attributes, true)) {
21-
if (!$this->getAttributeWithName($attribute->name())) {
21+
$previousAttribute = $this->getAttributeWithName($attribute->name());
22+
if (!$previousAttribute || !$previousAttribute->mergeable) {
2223
if ($attribute->append) {
2324
$this->attributes[] = $attribute;
2425
}
2526
} else {
2627
$this->attributes = array_map(
2728
fn (Attribute $attr) => $attr->name() === $attribute->name()
28-
? new Attribute($attr->name(), array_merge($attr->args(), $attribute->args()))
29+
? new Attribute($attr->name(), array_merge(
30+
$attr->args(),
31+
$attribute->args(),
32+
['mergeable' => $attribute->mergeable]
33+
))
2934
: $attr,
3035
$this->attributes
3136
);

src/Model/Attribute.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,20 @@ final class Attribute
2525
/** @var (int|bool|null|string|string[]|string[][]|\Nette\PhpGenerator\Literal|\Nette\PhpGenerator\Literal[])[] */
2626
private array $args;
2727

28+
/**
29+
* If this attribute can be appended if a same one has not previously been generated or if the same one is not mergeable?
30+
*
31+
* @see \ApiPlatform\SchemaGenerator\Model\AddAttributeTrait
32+
*/
2833
public bool $append = true;
2934

35+
/**
36+
* If this attribute mergeable with the next one?
37+
*
38+
* @see \ApiPlatform\SchemaGenerator\Model\AddAttributeTrait
39+
*/
40+
public bool $mergeable = true;
41+
3042
/**
3143
* @param (int|bool|null|string|string[]|string[][]|\Nette\PhpGenerator\Literal|\Nette\PhpGenerator\Literal[])[] $args
3244
*/
@@ -35,7 +47,9 @@ public function __construct(string $name, array $args = [])
3547
$this->name = $name;
3648

3749
$this->append = (bool) ($args['alwaysGenerate'] ?? true);
38-
unset($args['alwaysGenerate']);
50+
$this->mergeable = (bool) ($args['mergeable'] ?? true);
51+
52+
unset($args['alwaysGenerate'], $args['mergeable']);
3953

4054
$this->args = $args;
4155
}

src/SchemaGeneratorConfiguration.php

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,11 @@ public function getConfigTreeBuilder(): TreeBuilder
4949
$namespacePrefix = $this->defaultPrefix ?? 'App\\';
5050

5151
/* @see https://yaml.org/type/omap.html */
52-
$transformOmap = fn (array $nodeConfig) => !empty(array_filter(
53-
$nodeConfig,
54-
fn ($v, $k) => \is_int($k) && \is_array($v) && 1 === \count($v) && \is_string(array_keys($v)[0]),
55-
\ARRAY_FILTER_USE_BOTH
56-
))
57-
? array_reduce(array_values($nodeConfig), fn (array $map, array $v) => $map + $v, [])
58-
: $nodeConfig;
52+
$transformOmap = fn (array $nodeConfig) => array_map(
53+
fn ($v, $k) => \is_int($k) ? $v : [$k => $v],
54+
array_values($nodeConfig),
55+
array_keys($nodeConfig)
56+
);
5957

6058
// @phpstan-ignore-next-line node is not null
6159
$attributesNode = fn () => (new NodeBuilder())

tests/AttributeGenerator/ConfigurationAttributeGeneratorTest.php

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,18 @@ public function provideGenerateClassAttributesCases(): \Generator
4545

4646
yield 'type configuration' => [
4747
$class,
48-
['types' => ['Foo' => ['attributes' => ['ApiResource' => ['routePrefix' => '/prefix']]]]],
49-
[new Attribute('ApiResource', ['routePrefix' => '/prefix'])],
48+
['types' => ['Foo' => ['attributes' => [['ApiResource' => ['routePrefix' => '/prefix']]]]]],
49+
[new Attribute('ApiResource', ['routePrefix' => '/prefix', 'mergeable' => false])],
5050
];
5151

5252
$class = new SchemaClass('Foo', new RdfResource('https://schema.org/Foo', new RdfGraph(SchemaGeneratorConfiguration::SCHEMA_ORG_URI)));
5353
$expectedAttribute = new Attribute('ApiResource', ['routePrefix' => '/prefix']);
5454
$expectedAttribute->append = false;
55+
$expectedAttribute->mergeable = false;
5556

5657
yield 'vocab configuration' => [
5758
$class,
58-
['vocabularies' => [SchemaGeneratorConfiguration::SCHEMA_ORG_URI => ['attributes' => ['ApiResource' => ['routePrefix' => '/prefix']]]]],
59+
['vocabularies' => [SchemaGeneratorConfiguration::SCHEMA_ORG_URI => ['attributes' => [['ApiResource' => ['routePrefix' => '/prefix']]]]]],
5960
[$expectedAttribute],
6061
];
6162

@@ -64,10 +65,10 @@ public function provideGenerateClassAttributesCases(): \Generator
6465
yield 'vocab and type configuration' => [
6566
$class,
6667
[
67-
'vocabularies' => [SchemaGeneratorConfiguration::SCHEMA_ORG_URI => ['attributes' => ['ApiResource' => ['routePrefix' => '/prefix']]]],
68-
'types' => ['Foo' => ['attributes' => ['ApiResource' => ['security' => "is_granted('ROLE_USER')"]]]],
68+
'vocabularies' => [SchemaGeneratorConfiguration::SCHEMA_ORG_URI => ['attributes' => [['ApiResource' => ['routePrefix' => '/prefix']]]]],
69+
'types' => ['Foo' => ['attributes' => [['ApiResource' => ['security' => "is_granted('ROLE_USER')"]]]]],
6970
],
70-
[new Attribute('ApiResource', ['security' => "is_granted('ROLE_USER')"])],
71+
[new Attribute('ApiResource', ['security' => "is_granted('ROLE_USER')", 'mergeable' => false])],
7172
];
7273
}
7374

@@ -89,8 +90,8 @@ public function provideGeneratePropertyAttributesCases(): \Generator
8990

9091
yield 'type configuration' => [
9192
$property,
92-
['types' => ['Res' => ['properties' => ['prop' => ['attributes' => ['ApiResource' => ['security' => "is_granted('ROLE_USER')"]]]]]]],
93-
[new Attribute('ApiResource', ['security' => "is_granted('ROLE_USER')"])],
93+
['types' => ['Res' => ['properties' => ['prop' => ['attributes' => [['ApiResource' => ['security' => "is_granted('ROLE_USER')"]]]]]]]],
94+
[new Attribute('ApiResource', ['security' => "is_granted('ROLE_USER')", 'mergeable' => false])],
9495
];
9596
}
9697

tests/Command/GenerateCommandTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ public function testCustomAttributes(): void
102102
*/
103103
#[ORM\Entity]
104104
#[ApiResource(types: ['https://schema.org/Book'], routePrefix: '/library')]
105+
#[ORM\UniqueConstraint(name: 'isbn', columns: ['isbn'])]
106+
#[ORM\UniqueConstraint(name: 'title', columns: ['title'])]
105107
#[MyAttribute]
106108
class Book
107109
{
@@ -115,6 +117,17 @@ class Book
115117
, $book);
116118
$this->assertStringContainsString(<<<'PHP'
117119
#[ORM\OrderBy(name: 'ASC')]
120+
PHP
121+
, $book);
122+
// Generated attribute could be merged with next one that is a configured one.
123+
$this->assertStringContainsString(<<<'PHP'
124+
#[ORM\InverseJoinColumn(nullable: false, unique: true, name: 'first_join_column')]
125+
PHP
126+
, $book);
127+
// Configured attribute could not be merged with next one and it is treated
128+
// as repeated.
129+
$this->assertStringContainsString(<<<'PHP'
130+
#[ORM\InverseJoinColumn(name: 'second_join_column')]
118131
PHP
119132
, $book);
120133
}

0 commit comments

Comments
 (0)