Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add GenericTargetFactory and related configuration #33

Merged
merged 7 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 33 additions & 33 deletions docs/usage.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
## Usage

After the bundle is activated, you can directly use it by implementing a factory and a populator for your target and
source types.
After the bundle is activated, you can directly use it by implementing populators for your target and source types.

Imagine your source type is `User`:

Expand Down Expand Up @@ -41,31 +40,6 @@ Separation of Concerns.

You should use the Converter-and-Populator-pattern. But how?!

Implement the following three artifacts:

### Factory

Implement a comfortable factory for your target type:

```php
use Neusta\ConverterBundle\Converter\Context\GenericContext;
use Neusta\ConverterBundle\TargetFactory;

/**
* @implements TargetFactory<Person, GenericContext>
*/
class PersonFactory implements TargetFactory
{
public function create(?object $ctx = null): Person
{
return new Person();
}
}
```

Skip thinking about the converter context at the moment. It will help you...
maybe not now but in a few weeks. You will see.

### Populators

Implement one or several populators:
Expand All @@ -91,14 +65,37 @@ As you can see, implementation here is quite simple - just concatenation of two
But however transformation will become more and more complex, it should be done in a testable,
separated Populator or in several of them.

Skip thinking about the converter context at the moment. It will help you...
maybe not now but in a few weeks. You will see.

### Configuration

To put things together, register the factory and populator as services:
First register the populator as a service:

```yaml
# config/services.yaml
services:
YourNamespace\PersonNamePopulator: ~
```

Then declare the following converter in your package config:

```yaml
# config/packages/neusta_converter.yaml
neusta_converter:
converter:
person.converter:
target: YourNamespace\Person
populators:
- YourNamespace\PersonNamePopulator
# additional populators may follow
```

To put things together, register the populator as services:

```yaml
# config/services.yaml
services:
YourNamespace\PersonFactory: ~
YourNamespace\PersonNamePopulator: ~
```

Expand All @@ -109,7 +106,7 @@ And then declare the following converter in your package config:
neusta_converter:
converter:
person.converter:
target_factory: YourNamespace\PersonFactory
target: YourNamespace\Person
populators:
- YourNamespace\PersonNamePopulator
# additional populators may follow
Expand All @@ -118,6 +115,9 @@ neusta_converter:
> Note: You can use a custom implementation of the `Converter` interface via the `converter` keyword.
> Its constructor must contain the two parameters `TargetFactory $factory` and `array $populators`.

> Note: You can use a custom implementation of the `TargetTypeFactory` interface via the `target_factory` keyword,
> if you have special needs when creating the target object.

#### Mapping properties

If you just want to map a single property from the source to the target without transforming it in between, you don't
Expand All @@ -131,7 +131,7 @@ You can use it in your converter config via the `properties` keyword:
neusta_converter:
converter:
person.converter:
# ...
target: YourNamespace\Person
properties:
email: ~
phoneNumber: phone
Expand Down Expand Up @@ -159,7 +159,7 @@ neusta_converter:
converter:
person.converter:
properties:
# ...
target: YourNamespace\Person
phoneNumber:
source: phone
default: '0123456789'
Expand All @@ -181,7 +181,7 @@ You can use it in your converter config via the `context` keyword:
neusta_converter:
converter:
person.converter:
# ...
target: YourNamespace\Person
context:
group: ~
locale: language
Expand Down
17 changes: 15 additions & 2 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,15 @@ private function addConverterSection(ArrayNodeDefinition $rootNode): void
->info('Class name of the "Converter" implementation')
->defaultValue(GenericConverter::class)
->end()
->scalarNode('target')
->info('Class name of the target')
->validate()
->ifTrue(fn ($v) => !class_exists($v))
->thenInvalid('The target type %s does not exist.')
->end()
->end()
->scalarNode('target_factory')
->info('Service id of the "TargetFactory"')
->isRequired()
->cannotBeEmpty()
->end()
->arrayNode('populators')
->info('Service ids of the "Populator"s')
Expand Down Expand Up @@ -82,6 +87,14 @@ private function addConverterSection(ArrayNodeDefinition $rootNode): void
->prototype('scalar')->end()
->end()
->end()
->validate()
->ifTrue(fn (array $c) => !isset($c['target']) && !isset($c['target_factory']))
->thenInvalid('Either "target" or "target_factory" must be defined.')
->end()
->validate()
->ifTrue(fn (array $c) => isset($c['target'], $c['target_factory']))
->thenInvalid('Either "target" or "target_factory" must be defined, but not both.')
->end()
->validate()
->ifTrue(fn (array $c) => empty($c['populators']) && empty($c['properties']) && empty($c['context']))
->thenInvalid('At least one "populator", "property" or "context" must be defined.')
Expand Down
9 changes: 8 additions & 1 deletion src/DependencyInjection/NeustaConverterExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Neusta\ConverterBundle\Populator\ContextMappingPopulator;
use Neusta\ConverterBundle\Populator\ConvertingPopulator;
use Neusta\ConverterBundle\Populator\PropertyMappingPopulator;
use Neusta\ConverterBundle\Target\GenericTargetFactory;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
Expand Down Expand Up @@ -41,6 +42,12 @@ public function loadInternal(array $mergedConfig, ContainerBuilder $container):
*/
private function registerConverterConfiguration(string $id, array $config, ContainerBuilder $container): void
{
$targetFactoryId = $config['target_factory'] ?? "{$id}.target_factory";
if (!isset($config['target_factory'])) {
$container->register($targetFactoryId, GenericTargetFactory::class)
->setArgument('$type', $config['target']);
}

foreach ($config['properties'] ?? [] as $targetProperty => $sourceConfig) {
$skipNull = false;
if (str_ends_with($targetProperty, '?')) {
Expand Down Expand Up @@ -74,7 +81,7 @@ private function registerConverterConfiguration(string $id, array $config, Conta
$container->register($id, $config['converter'])
->setPublic(true)
->setArguments([
'$factory' => new Reference($config['target_factory']),
'$factory' => new Reference($targetFactoryId),
'$populators' => array_map(
static fn (string $populator) => new Reference($populator),
$config['populators'],
Expand Down
49 changes: 49 additions & 0 deletions src/Target/GenericTargetFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Neusta\ConverterBundle\Target;

use Neusta\ConverterBundle\TargetFactory;

/**
* @template T of object
*
* @implements TargetFactory<T, object|null>
*/
final class GenericTargetFactory implements TargetFactory
{
/** @var \ReflectionClass<T> */
private \ReflectionClass $type;

/**
* @param class-string<T> $type
*
* @throws \ReflectionException
* @throws \InvalidArgumentException
*/
public function __construct(string $type)
{
$this->type = new \ReflectionClass($type);

if (!$this->type->isInstantiable()) {
throw new \InvalidArgumentException(sprintf('Target class "%s" is not instantiable.', $type));
}

if ($this->type->getConstructor()?->getNumberOfRequiredParameters()) {
throw new \InvalidArgumentException(sprintf('Target class "%s" has required constructor parameters.', $type));
}
}

/**
* @throws \LogicException
*/
public function create(?object $ctx = null): object
{
try {
return $this->type->newInstance();
} catch (\ReflectionException $e) {
throw new \LogicException(sprintf('Cannot create new instance of "%s" because: %s', $this->type->getName(), $e->getMessage()), 0, $e);
}
}
}
74 changes: 74 additions & 0 deletions tests/DependencyInjection/NeustaConverterExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
use Neusta\ConverterBundle\Populator\ContextMappingPopulator;
use Neusta\ConverterBundle\Populator\ConvertingPopulator;
use Neusta\ConverterBundle\Populator\PropertyMappingPopulator;
use Neusta\ConverterBundle\Target\GenericTargetFactory;
use Neusta\ConverterBundle\Tests\Fixtures\Model\Target\Factory\PersonFactory;
use Neusta\ConverterBundle\Tests\Fixtures\Model\Target\Person;
use Neusta\ConverterBundle\Tests\Fixtures\Populator\PersonNamePopulator;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\TypedReference;

Expand Down Expand Up @@ -45,6 +48,77 @@ public function test_with_generic_converter(): void
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar', '$populators', [new Reference(PersonNamePopulator::class)]);
}

public function test_with_generic_target_factory(): void
{
$this->load([
'converter' => [
'foobar' => [
'target' => Person::class,
'populators' => [
PersonNamePopulator::class,
],
],
],
]);

// converter
$this->assertContainerBuilderHasPublicService('foobar', GenericConverter::class);
$this->assertContainerBuilderHasService('foobar.target_factory', GenericTargetFactory::class);
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar', '$factory', new Reference('foobar.target_factory'));
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.target_factory', '$type', Person::class);
}

public function test_with_generic_target_factory_for_unknown_type(): void
{
$this->expectException(InvalidConfigurationException::class);
$this->expectExceptionMessage('The target type "UnknownClass" does not exist.');

$this->load([
'converter' => [
'foobar' => [
'target' => 'UnknownClass',
'populators' => [
PersonNamePopulator::class,
],
],
],
]);
}

public function test_without_target_and_target_factory(): void
{
$this->expectException(InvalidConfigurationException::class);
$this->expectExceptionMessage('Either "target" or "target_factory" must be defined.');

$this->load([
'converter' => [
'foobar' => [
'populators' => [
PersonNamePopulator::class,
],
],
],
]);
}

public function test_with_target_and_target_factory(): void
{
$this->expectException(InvalidConfigurationException::class);
$this->expectExceptionMessage('Either "target" or "target_factory" must be defined, but not both.');

$this->load([
'converter' => [
'foobar' => [
'target' => Person::class,
'target_factory' => PersonFactory::class,
'populators' => [
PersonNamePopulator::class,
],
],
],
]);
}

public function test_with_mapped_properties(): void
{
$this->load([
Expand Down
4 changes: 2 additions & 2 deletions tests/Fixtures/Config/person.yaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
neusta_converter:
converter:
test.person.converter:
converter: Neusta\ConverterBundle\Converter\GenericConverter
target_factory: Neusta\ConverterBundle\Tests\Fixtures\Model\Target\Factory\PersonFactory
target: Neusta\ConverterBundle\Tests\Fixtures\Model\Target\Person
context:
group: ~ # same property name
locale: language # different property names
populators:
- Neusta\ConverterBundle\Tests\Fixtures\Populator\PersonNamePopulator

test.person.converter.extended:
converter: Neusta\ConverterBundle\Converter\GenericConverter
target_factory: Neusta\ConverterBundle\Tests\Fixtures\Model\Target\Factory\PersonWithDefaultsFactory
properties:
fullName:
Expand Down