Skip to content

Commit

Permalink
feat: split mapper flexible mode in three distinct modes
Browse files Browse the repository at this point in the history
The flexible mode has been deprecated in favor of three modes with
distinct roles.

1. **The flexible casting**

   Changes the behaviours explained below:

   ```php
   $flexibleMapper = (new \CuyZ\Valinor\MapperBuilder())
       ->enableFlexibleCasting()
       ->mapper();

   // ---
   // Scalar types will accept non-strict values; for instance an
   // integer type will accept any valid numeric value like the
   // *string* "42".

   $flexibleMapper->map('int', '42');
   // => 42

   // ---
   // List type will accept non-incremental keys.

   $flexibleMapper->map('list<int>', ['foo' => 42, 'bar' => 1337]);
   // => [0 => 42, 1 => 1338]

   // ---
   // If a value is missing in a source for a node that accepts `null`,
   // the node will be filled with `null`.

   $flexibleMapper->map(
       'array{foo: string, bar: null|string}',
       ['foo' => 'foo'] // `bar` is missing
   );
   // => ['foo' => 'foo', 'bar' => null]

   // ---
   // Array and list types will convert `null` or missing values to an
   // empty array.

   $flexibleMapper->map(
       'array{foo: string, bar: array<string>}',
       ['foo' => 'foo'] // `bar` is missing
   );
   // => ['foo' => 'foo', 'bar' => []]
   ```

2. **The superfluous keys**

   Superfluous keys in source arrays will be allowed, preventing errors
   when a value is not bound to any object property/parameter or shaped
   array element.

   ```php
   (new \CuyZ\Valinor\MapperBuilder())
       ->allowSuperfluousKeys()
       ->mapper()
       ->map(
           'array{foo: string, bar: int}',
           [
               'foo' => 'foo',
               'bar' => 42,
               'baz' => 1337.404, // `baz` will be ignored
           ]
       );
   ```

3. **The permissive types**

   Allows permissive types `mixed` and `object` to be used during
   mapping.

   ```php
   (new \CuyZ\Valinor\MapperBuilder())
       ->allowPermissiveTypes()
       ->mapper()
       ->map(
           'array{foo: string, bar: mixed}',
           [
               'foo' => 'foo',
               'bar' => 42, // Could be any value
           ]
       );
   ```
  • Loading branch information
romm authored Nov 8, 2022
1 parent 93f898c commit 549e5fe
Show file tree
Hide file tree
Showing 19 changed files with 829 additions and 294 deletions.
98 changes: 80 additions & 18 deletions docs/pages/mapping/type-strictness.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,96 @@ Array keys that are not bound to any node are forbidden. Mapping an array
`bar` will fail, because `baz` is superfluous. The same rule applies for
shaped arrays.

When mapping to a list, the given array must have sequential integer keys
starting at 0; if any gap or invalid key is found it will fail, like for
instance trying to map `['foo' => 'foo', 'bar' => 'bar']` to `list<string>`.

Types that are too permissive are not permitted — if the mapper encounters a
type like `mixed`, `object` or `array` it will fail because those types are not
precise enough.

## Flexible mode
---

If these limitations are too restrictive, the mapper can be made more flexible
to disable one or several rule(s) declared above.

## Enabling flexible casting

This setting changes the behaviours explained below:

```php
$flexibleMapper = (new \CuyZ\Valinor\MapperBuilder())
->enableFlexibleCasting()
->mapper();

// ---
// Scalar types will accept non-strict values; for instance an integer
// type will accept any valid numeric value like the *string* "42".

$flexibleMapper->map('int', '42');
// => 42

// ---
// List type will accept non-incremental keys.

$flexibleMapper->map('list<int>', ['foo' => 42, 'bar' => 1337]);
// => [0 => 42, 1 => 1338]

If the limitations are too restrictive, the mapper can be made more flexible to
disable all strict rules declared above and enable value casting when possible.
// ---
// If a value is missing in a source for a node that accepts `null`, the
// node will be filled with `null`.

$flexibleMapper->map(
'array{foo: string, bar: null|string}',
['foo' => 'foo'] // `bar` is missing
);
// => ['foo' => 'foo', 'bar' => null]

// ---
// Array and list types will convert `null` or missing values to an empty
// array.

$flexibleMapper->map(
'array{foo: string, bar: array<string>}',
['foo' => 'foo'] // `bar` is missing
);
// => ['foo' => 'foo', 'bar' => []]
```

## Allowing superfluous keys

With this setting enabled, superfluous keys in source arrays will be allowed,
preventing errors when a value is not bound to any object property/parameter or
shaped array element.

```php
(new \CuyZ\Valinor\MapperBuilder())
->flexible()
->allowSuperfluousKeys()
->mapper()
->map('array{foo: int, bar: bool}', [
'foo' => '42', // The value will be cast from `string` to `int`
'bar' => 'true', // The value will be cast from `string` to `bool`
'baz' => '…', // Will be ignored
]);
->map(
'array{foo: string, bar: int}',
[
'foo' => 'foo',
'bar' => 42,
'baz' => 1337.404, // `baz` will be ignored
]
);
```

## When should flexible mode be enabled?
## Allowing permissive types

When using this library for a provider application — for instance an API
endpoint that can be called with a JSON payload — it is recommended to use the
strict mode. This ensures that the consumers of the API provide the exact
awaited data structure, and prevents unknown values to be passed.
This setting allows permissive types `mixed` and `object` to be used during
mapping.

When using this library as a consumer of an external source, it can make sense
to enable the flexible mode. This allows for instance to convert string numeric
values to integers or to ignore data that is present in the source but not
needed in the application.
```php
(new \CuyZ\Valinor\MapperBuilder())
->allowPermissiveTypes()
->mapper()
->map(
'array{foo: string, bar: mixed}',
[
'foo' => 'foo',
'bar' => 42, // Could be any value
]
);
```
19 changes: 10 additions & 9 deletions src/Library/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,34 +87,35 @@ public function __construct(Settings $settings)
ShellVisitor::class => fn () => new AttributeShellVisitor(),

NodeBuilder::class => function () use ($settings) {
$listNodeBuilder = new ListNodeBuilder($settings->flexible);
$arrayNodeBuilder = new ArrayNodeBuilder($settings->flexible);
$listNodeBuilder = new ListNodeBuilder($settings->enableFlexibleCasting);
$arrayNodeBuilder = new ArrayNodeBuilder($settings->enableFlexibleCasting);

$builder = new CasterNodeBuilder([
ListType::class => $listNodeBuilder,
NonEmptyListType::class => $listNodeBuilder,
ArrayType::class => $arrayNodeBuilder,
NonEmptyArrayType::class => $arrayNodeBuilder,
IterableType::class => $arrayNodeBuilder,
ShapedArrayType::class => new ShapedArrayNodeBuilder($settings->flexible),
ScalarType::class => new ScalarNodeBuilder($settings->flexible),
ShapedArrayType::class => new ShapedArrayNodeBuilder($settings->allowSuperfluousKeys),
ScalarType::class => new ScalarNodeBuilder($settings->enableFlexibleCasting),
]);

$builder = new UnionNodeBuilder($builder, $settings->flexible);
$builder = new UnionNodeBuilder($builder, $settings->enableFlexibleCasting);

$builder = new ClassNodeBuilder(
$builder,
$this->get(ClassDefinitionRepository::class),
$this->get(ObjectBuilderFactory::class),
$settings->flexible
$settings->enableFlexibleCasting,
$settings->allowSuperfluousKeys
);

$builder = new InterfaceNodeBuilder(
$builder,
$this->get(ObjectImplementations::class),
$this->get(ClassDefinitionRepository::class),
$this->get(ObjectBuilderFactory::class),
$settings->flexible
$settings->enableFlexibleCasting
);

$builder = new CasterProxyNodeBuilder($builder);
Expand All @@ -126,7 +127,7 @@ public function __construct(Settings $settings)
$settings->valueModifier
)
);
$builder = new StrictNodeBuilder($builder, $settings->flexible);
$builder = new StrictNodeBuilder($builder, $settings->allowPermissiveTypes, $settings->enableFlexibleCasting);
$builder = new ShellVisitorNodeBuilder($builder, $this->get(ShellVisitor::class));

return new ErrorCatcherNodeBuilder($builder, $settings->exceptionFilter);
Expand All @@ -152,7 +153,7 @@ public function __construct(Settings $settings)
$factory = new AttributeObjectBuilderFactory($factory);
$factory = new CollisionObjectBuilderFactory($factory);

if (! $settings->flexible) {
if (! $settings->allowPermissiveTypes) {
$factory = new StrictTypesObjectBuilderFactory($factory);
}

Expand Down
6 changes: 5 additions & 1 deletion src/Library/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ final class Settings
/** @var CacheInterface<mixed> */
public CacheInterface $cache;

public bool $flexible = false;
public bool $enableFlexibleCasting = false;

public bool $allowSuperfluousKeys = false;

public bool $allowPermissiveTypes = false;

/** @var callable(Throwable): ErrorMessage */
public $exceptionFilter;
Expand Down
17 changes: 5 additions & 12 deletions src/Mapper/Object/FilledArguments.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,15 @@ final class FilledArguments implements IteratorAggregate

private Arguments $arguments;

private bool $flexible;

private function __construct(Arguments $arguments, Shell $shell, bool $flexible)
private function __construct(Arguments $arguments, Shell $shell)
{
$this->arguments = $arguments;
$this->flexible = $flexible;
$this->hasValue = $shell->hasValue();
}

public static function forInterface(Arguments $arguments, Shell $shell, bool $flexible): self
public static function forInterface(Arguments $arguments, Shell $shell): self
{
$self = new self($arguments, $shell, $flexible);
$self = new self($arguments, $shell);

if ($self->hasValue) {
if (count($arguments) > 0) {
Expand All @@ -50,9 +47,9 @@ public static function forInterface(Arguments $arguments, Shell $shell, bool $fl
return $self;
}

public static function forClass(Arguments $arguments, Shell $shell, bool $flexible): self
public static function forClass(Arguments $arguments, Shell $shell): self
{
$self = new self($arguments, $shell, $flexible);
$self = new self($arguments, $shell);

if ($self->hasValue) {
$self->value = $self->transform($shell->value());
Expand Down Expand Up @@ -109,10 +106,6 @@ private function transform($source): array
}
}

if ($argumentsCount === 0 && $this->flexible && ! $isArray) {
return [];
}

if (! $isArray) {
throw new SourceIsNotAnArray($source, $this->arguments);
}
Expand Down
8 changes: 4 additions & 4 deletions src/Mapper/Tree/Builder/ArrayNodeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@
/** @internal */
final class ArrayNodeBuilder implements NodeBuilder
{
private bool $flexible;
private bool $enableFlexibleCasting;

public function __construct(bool $flexible)
public function __construct(bool $enableFlexibleCasting)
{
$this->flexible = $flexible;
$this->enableFlexibleCasting = $enableFlexibleCasting;
}

public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
Expand All @@ -32,7 +32,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode

assert($type instanceof ArrayType || $type instanceof NonEmptyArrayType || $type instanceof IterableType);

if (null === $value && $this->flexible) {
if ($this->enableFlexibleCasting && $value === null) {
return TreeNode::branch($shell, [], []);
}

Expand Down
22 changes: 15 additions & 7 deletions src/Mapper/Tree/Builder/ClassNodeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,22 @@ final class ClassNodeBuilder implements NodeBuilder

private ObjectBuilderFactory $objectBuilderFactory;

private bool $flexible;
private bool $enableFlexibleCasting;

private bool $allowSuperfluousKeys;

public function __construct(
NodeBuilder $delegate,
ClassDefinitionRepository $classDefinitionRepository,
ObjectBuilderFactory $objectBuilderFactory,
bool $flexible
bool $enableFlexibleCasting,
bool $allowSuperfluousKeys
) {
$this->delegate = $delegate;
$this->classDefinitionRepository = $classDefinitionRepository;
$this->objectBuilderFactory = $objectBuilderFactory;
$this->flexible = $flexible;
$this->enableFlexibleCasting = $enableFlexibleCasting;
$this->allowSuperfluousKeys = $allowSuperfluousKeys;
}

public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
Expand All @@ -48,8 +52,12 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
return $this->delegate->build($shell, $rootBuilder);
}

if ($this->enableFlexibleCasting && $shell->value() === null) {
$shell = $shell->withValue([]);
}

$builder = $this->builder($shell, ...$classTypes);
$arguments = FilledArguments::forClass($builder->describeArguments(), $shell, $this->flexible);
$arguments = FilledArguments::forClass($builder->describeArguments(), $shell);

$children = [];

Expand All @@ -71,8 +79,8 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode

$node = TreeNode::branch($shell, $object, $children);

if (! $this->flexible) {
$node = $this->checkForUnexpectedKeys($arguments, $node);
if (! $this->allowSuperfluousKeys) {
$node = $this->checkForSuperfluousKeys($arguments, $node);
}

return $node;
Expand Down Expand Up @@ -125,7 +133,7 @@ private function buildObject(ObjectBuilder $builder, array $children): ?object
return $builder->build($arguments);
}

private function checkForUnexpectedKeys(FilledArguments $arguments, TreeNode $node): TreeNode
private function checkForSuperfluousKeys(FilledArguments $arguments, TreeNode $node): TreeNode
{
$superfluousKeys = $arguments->superfluousKeys();

Expand Down
12 changes: 8 additions & 4 deletions src/Mapper/Tree/Builder/InterfaceNodeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,20 @@ final class InterfaceNodeBuilder implements NodeBuilder

private ObjectBuilderFactory $objectBuilderFactory;

private bool $flexible;
private bool $enableFlexibleCasting;

public function __construct(
NodeBuilder $delegate,
ObjectImplementations $implementations,
ClassDefinitionRepository $classDefinitionRepository,
ObjectBuilderFactory $objectBuilderFactory,
bool $flexible
bool $enableFlexibleCasting
) {
$this->delegate = $delegate;
$this->implementations = $implementations;
$this->classDefinitionRepository = $classDefinitionRepository;
$this->objectBuilderFactory = $objectBuilderFactory;
$this->flexible = $flexible;
$this->enableFlexibleCasting = $enableFlexibleCasting;
}

public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
Expand All @@ -51,11 +51,15 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
return $this->delegate->build($shell, $rootBuilder);
}

if ($this->enableFlexibleCasting && $shell->value() === null) {
$shell = $shell->withValue([]);
}

$interfaceName = $type->className();

$function = $this->implementations->function($interfaceName);
$arguments = Arguments::fromParameters($function->parameters());
$arguments = FilledArguments::forInterface($arguments, $shell, $this->flexible);
$arguments = FilledArguments::forInterface($arguments, $shell);

$children = $this->children($shell, $arguments, $rootBuilder);
$values = [];
Expand Down
10 changes: 5 additions & 5 deletions src/Mapper/Tree/Builder/ListNodeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
/** @internal */
final class ListNodeBuilder implements NodeBuilder
{
private bool $flexible;
private bool $enableFlexibleCasting;

public function __construct(bool $flexible)
public function __construct(bool $enableFlexibleCasting)
{
$this->flexible = $flexible;
$this->enableFlexibleCasting = $enableFlexibleCasting;
}

public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode
Expand All @@ -31,7 +31,7 @@ public function build(Shell $shell, RootNodeBuilder $rootBuilder): TreeNode

assert($type instanceof ListType || $type instanceof NonEmptyListType);

if (null === $value && $this->flexible) {
if ($this->enableFlexibleCasting && $value === null) {
return TreeNode::branch($shell, [], []);
}

Expand All @@ -58,7 +58,7 @@ private function children(CompositeTraversableType $type, Shell $shell, RootNode
$children = [];

foreach ($values as $key => $value) {
if ($this->flexible || $key === $expected) {
if ($this->enableFlexibleCasting || $key === $expected) {
$child = $shell->child((string)$expected, $subType);
$children[$expected] = $rootBuilder->build($child->withValue($value));
} else {
Expand Down
Loading

0 comments on commit 549e5fe

Please sign in to comment.