Skip to content

Commit e2a422f

Browse files
author
Kirill Nesmeyanov
committed
Add basic discriminator map support
1 parent cb193e5 commit e2a422f

File tree

6 files changed

+235
-4
lines changed

6 files changed

+235
-4
lines changed

src/Mapping/DiscriminatorMap.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypeLang\Mapper\Mapping;
6+
7+
/**
8+
* ```
9+
* #[DiscriminatorMap(field: 'type', map: [
10+
* 'admin' => Admin::class,
11+
* 'moderator' => Moderator::class,
12+
* 'user' => User::class,
13+
* 'any' => 'array<array-key, string>'
14+
* ])]
15+
* abstract class Account {}
16+
*
17+
* final class Admin extends Account {}
18+
* final class Moderator extends Account {}
19+
* final class User extends Account {}
20+
* ```
21+
*/
22+
#[\Attribute(\Attribute::TARGET_CLASS)]
23+
class DiscriminatorMap
24+
{
25+
public function __construct(
26+
/**
27+
* @var non-empty-string
28+
*/
29+
public readonly string $field,
30+
/**
31+
* @var array<non-empty-string, non-empty-string>
32+
*/
33+
public readonly array $map,
34+
) {}
35+
}

src/Mapping/Driver/AttributeDriver.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
use TypeLang\Mapper\Exception\Definition\PropertyTypeNotFoundException;
1010
use TypeLang\Mapper\Exception\Definition\TypeNotFoundException;
1111
use TypeLang\Mapper\Exception\Environment\ComposerPackageRequiredException;
12+
use TypeLang\Mapper\Mapping\DiscriminatorMap;
1213
use TypeLang\Mapper\Mapping\MapName;
1314
use TypeLang\Mapper\Mapping\MapType;
1415
use TypeLang\Mapper\Mapping\Metadata\ClassMetadata;
16+
use TypeLang\Mapper\Mapping\Metadata\DiscriminatorMapMetadata;
1517
use TypeLang\Mapper\Mapping\Metadata\ExpressionMetadata;
1618
use TypeLang\Mapper\Mapping\Metadata\TypeMetadata;
1719
use TypeLang\Mapper\Mapping\NormalizeAsArray;
@@ -124,6 +126,19 @@ protected function load(
124126
));
125127
}
126128
}
129+
130+
// -----------------------------------------------------------------
131+
// Apply discriminator logic
132+
// -----------------------------------------------------------------
133+
134+
$attribute = $this->findClassAttribute($reflection, DiscriminatorMap::class);
135+
136+
if ($attribute !== null) {
137+
$class->setDiscriminator(new DiscriminatorMapMetadata(
138+
field: $attribute->field,
139+
map: $attribute->map,
140+
));
141+
}
127142
}
128143

129144
/**

src/Mapping/Metadata/ClassMetadata.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,28 @@ final class ClassMetadata extends Metadata
3232
*/
3333
private ?bool $normalizeAsArray = null;
3434

35+
/**
36+
* Contains a {@see DiscriminatorMapMetadata} instance in case of class-like
37+
* contains a discriminator map.
38+
*/
39+
private ?DiscriminatorMapMetadata $discriminator;
40+
3541
/**
3642
* @param class-string<T> $name
3743
* @param iterable<array-key, PropertyMetadata> $properties
3844
*/
3945
public function __construct(
4046
private readonly string $name,
4147
iterable $properties = [],
48+
?DiscriminatorMapMetadata $discriminator = null,
4249
?int $createdAt = null,
4350
) {
4451
parent::__construct($createdAt);
4552

4653
foreach ($properties as $property) {
4754
$this->addProperty($property);
4855
}
56+
$this->discriminator = $discriminator;
4957
}
5058

5159
/**
@@ -183,4 +191,38 @@ public function getProperties(): array
183191
{
184192
return \array_values($this->properties);
185193
}
194+
195+
/**
196+
* Returns {@see DiscriminatorMapMetadata} information about a class
197+
* discriminator map, or returns {@see null} if no such metadata has been
198+
* registered in the {@see ClassMetadata} instance.
199+
*
200+
* @api
201+
*/
202+
public function findDiscriminator(): ?DiscriminatorMapMetadata
203+
{
204+
return $this->discriminator;
205+
}
206+
207+
/**
208+
* Returns {@see true} if the {@see DiscriminatorMapMetadata} information
209+
* was registered in the {@see ClassMetadata} and {@see false} otherwise.
210+
*
211+
* @api
212+
*/
213+
public function hasDiscriminator(): bool
214+
{
215+
return $this->discriminator !== null;
216+
}
217+
218+
/**
219+
* Updates {@see DiscriminatorMapMetadata} information about a class
220+
* discriminator map.
221+
*
222+
* @api
223+
*/
224+
public function setDiscriminator(?DiscriminatorMapMetadata $discriminator): void
225+
{
226+
$this->discriminator = $discriminator;
227+
}
186228
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypeLang\Mapper\Mapping\Metadata;
6+
7+
/**
8+
* Represents an abstraction over general information about a class.
9+
*/
10+
final class DiscriminatorMapMetadata extends Metadata
11+
{
12+
public function __construct(
13+
/**
14+
* @var non-empty-string
15+
*/
16+
private readonly string $field,
17+
/**
18+
* @var array<non-empty-string, non-empty-string>
19+
*/
20+
private array $map = [],
21+
?int $createdAt = null,
22+
) {
23+
parent::__construct($createdAt);
24+
}
25+
26+
/**
27+
* Returns class for the passed value of the defined {@see $field}.
28+
*
29+
* @return non-empty-string|null
30+
*/
31+
public function findType(string $fieldValue): ?string
32+
{
33+
return $this->map[$fieldValue] ?? null;
34+
}
35+
36+
/**
37+
* Returns {@see true} in case of the passed value of the
38+
* defined {@see $field} is mapped on class.
39+
*/
40+
public function hasType(string $fieldValue): bool
41+
{
42+
return $this->findType($fieldValue) !== null;
43+
}
44+
45+
/**
46+
* Returns class mapping.
47+
*
48+
* @return array<non-empty-string, non-empty-string>
49+
*/
50+
public function getMapping(): array
51+
{
52+
return $this->map;
53+
}
54+
55+
/**
56+
* Returns discriminator field name.
57+
*
58+
* @api
59+
*
60+
* @return non-empty-string
61+
*/
62+
public function getField(): string
63+
{
64+
return $this->field;
65+
}
66+
}

src/Type/Builder/ClassTypeBuilder.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace TypeLang\Mapper\Type\Builder;
66

7+
use TypeLang\Mapper\Exception\Definition\InternalTypeException;
78
use TypeLang\Mapper\Mapping\Driver\DriverInterface;
89
use TypeLang\Mapper\Mapping\Driver\ReflectionDriver;
910
use TypeLang\Mapper\Runtime\Parser\TypeParserInterface;
@@ -49,7 +50,11 @@ public function isSupported(TypeStatement $statement): bool
4950

5051
$reflection = new \ReflectionClass($name);
5152

52-
return $reflection->isInstantiable();
53+
return $reflection->isInstantiable()
54+
// Allow abstract classes for discriminators
55+
|| $reflection->isAbstract()
56+
// Allow interfaces for discriminators
57+
|| $reflection->isInterface();
5358
}
5459

5560
public function build(
@@ -63,12 +68,30 @@ public function build(
6368
/** @var class-string<T> $class */
6469
$class = $statement->name->toString();
6570

71+
$reflection = new \ReflectionClass($class);
72+
6673
$metadata = $this->driver->getClassMetadata(
67-
class: new \ReflectionClass($class),
74+
class: $reflection,
6875
types: $types,
6976
parser: $parser,
7077
);
7178

79+
$discriminator = $metadata->findDiscriminator();
80+
81+
if ($discriminator === null && !$reflection->isInstantiable()) {
82+
throw InternalTypeException::becauseInternalTypeErrorOccurs(
83+
type: $statement,
84+
message: \vsprintf('%s "%s" expects a discriminator map to be able to be created', [
85+
match (true) {
86+
$reflection->isAbstract() => 'Non-creatable abstract class',
87+
$reflection->isInterface() => 'Non-creatable interface',
88+
default => 'Unknown non-instantiable type',
89+
},
90+
$reflection->getName(),
91+
]),
92+
);
93+
}
94+
7295
return new ClassType(
7396
metadata: $metadata,
7497
accessor: $this->accessor,

src/Type/ClassType/ClassTypeDenormalizer.php

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44

55
namespace TypeLang\Mapper\Type\ClassType;
66

7+
use TypeLang\Mapper\Exception\Definition\TypeNotFoundException;
78
use TypeLang\Mapper\Exception\Mapping\FieldExceptionInterface;
89
use TypeLang\Mapper\Exception\Mapping\InvalidFieldTypeValueException;
910
use TypeLang\Mapper\Exception\Mapping\InvalidValueMappingException;
1011
use TypeLang\Mapper\Exception\Mapping\MappingExceptionInterface;
1112
use TypeLang\Mapper\Exception\Mapping\MissingFieldTypeException;
1213
use TypeLang\Mapper\Exception\Mapping\MissingFieldValueException;
14+
use TypeLang\Mapper\Exception\Mapping\RuntimeExceptionInterface;
1315
use TypeLang\Mapper\Mapping\Metadata\ClassMetadata;
16+
use TypeLang\Mapper\Mapping\Metadata\DiscriminatorMapMetadata;
1417
use TypeLang\Mapper\Runtime\Context;
1518
use TypeLang\Mapper\Runtime\Path\Entry\ObjectEntry;
1619
use TypeLang\Mapper\Runtime\Path\Entry\ObjectPropertyEntry;
@@ -38,9 +41,14 @@ public function match(mixed $value, Context $context): bool
3841
}
3942

4043
/**
41-
* @return T
44+
* @return T|mixed
45+
* @throws InvalidValueMappingException
46+
* @throws MissingFieldValueException
47+
* @throws RuntimeExceptionInterface
48+
* @throws TypeNotFoundException
49+
* @throws \Throwable
4250
*/
43-
public function cast(mixed $value, Context $context): object
51+
public function cast(mixed $value, Context $context): mixed
4452
{
4553
if (\is_object($value)) {
4654
$value = (array) $value;
@@ -54,6 +62,12 @@ public function cast(mixed $value, Context $context): object
5462
);
5563
}
5664

65+
$discriminator = $this->metadata->findDiscriminator();
66+
67+
if ($discriminator !== null) {
68+
return $this->castOverDiscriminator($discriminator, $value, $context);
69+
}
70+
5771
$entrance = $context->enter($value, new ObjectEntry($this->metadata->getName()));
5872

5973
$instance = $this->instantiator->instantiate($this->metadata);
@@ -63,6 +77,42 @@ public function cast(mixed $value, Context $context): object
6377
return $instance;
6478
}
6579

80+
/**
81+
* @param array<array-key, mixed> $value
82+
* @throws MissingFieldValueException
83+
* @throws \Throwable
84+
* @throws TypeNotFoundException
85+
* @throws RuntimeExceptionInterface
86+
*/
87+
private function castOverDiscriminator(DiscriminatorMapMetadata $map, array $value, Context $context): mixed
88+
{
89+
$discriminatorValue = $value[$map->getField()] ?? null;
90+
91+
// Invalid discriminator field type
92+
if (!\is_string($discriminatorValue)) {
93+
throw MissingFieldValueException::createFromContext(
94+
expected: $this->metadata->getTypeStatement($context),
95+
field: $map->getField(),
96+
context: $context,
97+
);
98+
}
99+
100+
$discriminatorDefinition = $map->findType($discriminatorValue);
101+
102+
// Invalid discriminator type
103+
if ($discriminatorDefinition === null) {
104+
throw MissingFieldValueException::createFromContext(
105+
expected: $this->metadata->getTypeStatement($context),
106+
field: $map->getField(),
107+
context: $context,
108+
);
109+
}
110+
111+
$discriminatorType = $context->getTypeByDefinition($discriminatorDefinition);
112+
113+
return $discriminatorType->cast($value, $context);
114+
}
115+
66116
/**
67117
* @param array<array-key, mixed> $value
68118
*

0 commit comments

Comments
 (0)