Skip to content

Commit

Permalink
feat: add support for type aliases (#118)
Browse files Browse the repository at this point in the history
* Working prototype

* Style fixes

* Add arg type to config validator callback

* Inject type mapper as optional service

* Add tests

* Test without type mapper service

* With and without type mapper

* Assert service exists vs not

* Apply linter diff

* Add doc

* Rename config key to type_map

* Define type mapper service in xml

* Make type mapper final

* Do not use deprecated static test container

* Use forward compatible test container access

* Fix condition for removing enum normalizer

* Address deprecation error

* lint

* Add test for custom type mapper

* lint

* test compatibility w earlier versions

* Use legacy password hashing in MySQL for old php versions

* Add doc, drop symfony param, resolve wrong var naming

* doc fixes

* Fix mysql in workflow, run on 8.2, no fail-fast
  • Loading branch information
TamasSzigeti authored Feb 23, 2023
1 parent 9373f11 commit 9ad0d28
Show file tree
Hide file tree
Showing 16 changed files with 458 additions and 11 deletions.
31 changes: 25 additions & 6 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
name: "tests"

on: ["pull_request", "push"]
on:
pull_request: ~
push:
branches: [main]

jobs:
run:
Expand All @@ -14,19 +17,35 @@ jobs:
- "7.4"
- "8.0"
- "8.1"
- "8.2"
dependencies:
- highest
include:
- php-version: "8.1"
dependencies: lowest
- php-version: "8.2"
dependencies: lowest
fail-fast: false

services:
database:
image: mysql:8
ports:
- 3306:3306
options: --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 10
env:
MYSQL_ROOT_PASSWORD: odmroot
MYSQL_DATABASE: odm
MYSQL_USER: odm
MYSQL_PASSWORD: odm

steps:
- name: Start MySQL
- name: Use legacy password hashing in MySQL
if: ${{ contains(fromJson('["7.1", "7.2", "7.3"]'), matrix.php-version) }}
env:
MYSQL_PWD: root
MYSQL_PWD: odmroot
run: |
sudo systemctl start mysql.service
mysql -uroot -e "create database odm";
mysql -h127.0.0.1 -uroot -e "ALTER USER odm IDENTIFIED WITH mysql_native_password BY 'odm'"
- name: Start PostgreSQL
run: |
Expand Down Expand Up @@ -54,7 +73,7 @@ jobs:

- name: Run tests (MySQL)
env:
DATABASE_URL: mysql://root:root@localhost/odm?serverVersion=8.0
DATABASE_URL: mysql://odm:[email protected]/odm?serverVersion=8.0
run: php vendor/bin/simple-phpunit

- name: Run tests (PostgreSQL)
Expand Down
63 changes: 62 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,67 @@ $foo = $entityManager->find(Foo::class, $foo->getId());
var_dump($foo->misc); // Same as what we set earlier
```

### Using type aliases

Using custom type aliases as `#type` rather than FQCNs has a couple of benefits:
- In case you move or rename your document classes, you can just update your type map without migrating database content
- For applications that might store millions of records with JSON documents, this can also save some storage space

You can introduce type aliases at any point in time. Already persisted JSON documents with class names will still get deserialized correctly.

#### Using Symfony

In order to use type aliases, add the bundle configuration, e.g. in `config/packages/doctrine_json_odm.yaml`:

```yaml
dunglas_doctrine_json_odm:
type_map:
foo: App\Something\Foo
bar: App\SomethingElse\Bar
```
With this, `Foo` objects will be serialized as:

```json
{ "#type": "foo", "someProperty": "someValue" }
```

Another option is to use your own custom type mapper implementing `Dunglas\DoctrineJsonOdm\TypeMapperInterface`. For this, just override the service definition:

```yaml
services:
dunglas_doctrine_json_odm.type_mapper: '@App\Something\MyFancyTypeMapper'
```

#### Without Symfony

When instantiating `Dunglas\DoctrineJsonOdm\Serializer`, you need to pass an extra argument that implements `Dunglas\DoctrineJsonOdm\TypeMapperInterface`.

For using the built-in type mapper:

```php
// …
use Dunglas\DoctrineJsonOdm\Serializer;
use Dunglas\DoctrineJsonOdm\TypeMapper;
use App\Something\Foo;
use App\SomethingElse\Bar;
// For using the built-in type mapper:
$typeMapper = new TypeMapper([
'foo' => Foo::class,
'bar' => Bar::class,
]);
// Or implement TypeMapperInterface with your own class:
$typeMapper = new MyTypeMapper();
// Then pass it into the Serializer constructor
Type::getType('json_document')->setSerializer(
new Serializer([new ArrayDenormalizer(), new ObjectNormalizer()], [new JsonEncoder()], $typeMapper)
);
```


### Limitations when updating nested properties

Due to how Doctrine works, it will not detect changes to nested objects or properties.
Expand Down Expand Up @@ -247,7 +308,7 @@ As a side note: If you happen to use [Autowiring](https://symfony.com/doc/curren

**When the namespace of a used entity changes**

Because we store the `#type` along with the data in the database, you have to migrate the already existing data in your database to reflect the new namespace.
For classes without [type aliases](#using-type-aliases), because we store the `#type` along with the data in the database, you have to migrate the already existing data in your database to reflect the new namespace.

Example: If we have a project that we migrate from `AppBundle` to `App`, we have the namespace `AppBundle/Entity/Bar` in our database which has to become `App/Entity/Bar` instead.

Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"symfony/serializer": "^4.4 || ^5.4 || ^6.0"
},
"require-dev": {
"doctrine/annotations": "^1.0",
"doctrine/doctrine-bundle": "^1.12.13 || ^2.2",
"doctrine/dbal": "^2.7 || ^3.3",
"symfony/finder": "^4.4 || ^5.4 || ^6.0",
Expand Down
43 changes: 43 additions & 0 deletions src/Bundle/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/*
* (c) Kévin Dunglas <[email protected]>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace Dunglas\DoctrineJsonOdm\Bundle\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

final class Configuration implements ConfigurationInterface
{
/**
* @return TreeBuilder
*/
public function getConfigTreeBuilder()
{
$treeBuilder = new TreeBuilder('dunglas_doctrine_json_odm');

$treeBuilder->getRootNode()
->children()
->arrayNode('type_map')
->defaultValue([])
->useAttributeAsKey('type')
->scalarPrototype()
->cannotBeEmpty()
->validate()
->ifTrue(static function (string $v): bool {
return !class_exists($v);
})
->thenInvalid('Use fully qualified classnames as type values')
->end()
->end()
->end()
->end();

return $treeBuilder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,16 @@ public function load(array $configs, ContainerBuilder $container): void
$loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.xml');

if (!class_exists(BackedEnumNormalizer::class) || !class_exists(\BackedEnum::class)) {
if (PHP_VERSION_ID < 80100 || !class_exists(BackedEnumNormalizer::class)) {
$container->removeDefinition('dunglas_doctrine_json_odm.normalizer.backed_enum');
}

$config = $this->processConfiguration(new Configuration(), $configs);

if ($config['type_map'] ?? []) {
$container->getDefinition('dunglas_doctrine_json_odm.type_mapper')->addArgument($config['type_map']);
} else {
$container->removeDefinition('dunglas_doctrine_json_odm.type_mapper');
}
}
}
4 changes: 4 additions & 0 deletions src/Bundle/Resources/config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

<service id="dunglas_doctrine_json_odm.normalizer.backed_enum" class="Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer" public="false" />

<service id="dunglas_doctrine_json_odm.type_mapper" class="Dunglas\DoctrineJsonOdm\TypeMapper" public="false" />

<service id="dunglas_doctrine_json_odm.serializer" class="Dunglas\DoctrineJsonOdm\Serializer" public="true">
<argument type="collection">
<argument type="service" id="dunglas_doctrine_json_odm.normalizer.backed_enum" on-invalid="ignore" />
Expand All @@ -27,6 +29,8 @@
<argument type="collection">
<argument type="service" id="serializer.encoder.json" />
</argument>

<argument type="service" id="dunglas_doctrine_json_odm.type_mapper" on-invalid="ignore" />
</service>
</services>
</container>
34 changes: 33 additions & 1 deletion src/SerializerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,34 @@

namespace Dunglas\DoctrineJsonOdm;

use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Component\Serializer\Encoder\EncoderInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

/**
* @internal
*
* @author Kévin Dunglas <[email protected]>
*/
trait SerializerTrait
{
/**
* @var TypeMapperInterface|null
*/
private $typeMapper;

/**
* @param (NormalizerInterface|DenormalizerInterface)[] $normalizers
* @param (EncoderInterface|DecoderInterface)[] $encoders
*/
public function __construct(array $normalizers = [], array $encoders = [], ?TypeMapperInterface $typeMapper = null)
{
parent::__construct($normalizers, $encoders);

$this->typeMapper = $typeMapper;
}

/**
* @param mixed $data
* @param string|null $format
Expand All @@ -27,7 +48,13 @@ public function normalize($data, $format = null, array $context = [])
$normalizedData = parent::normalize($data, $format, $context);

if (\is_object($data)) {
$typeData = [self::KEY_TYPE => \get_class($data)];
$typeName = \get_class($data);

if ($this->typeMapper) {
$typeName = $this->typeMapper->getTypeByClass($typeName);
}

$typeData = [self::KEY_TYPE => $typeName];
$valueData = is_scalar($normalizedData) ? [self::KEY_SCALAR => $normalizedData] : $normalizedData;
$normalizedData = array_merge($typeData, $valueData);
}
Expand All @@ -44,6 +71,11 @@ public function denormalize($data, $type, $format = null, array $context = [])
{
if (\is_array($data) && (isset($data[self::KEY_TYPE]))) {
$keyType = $data[self::KEY_TYPE];

if ($this->typeMapper) {
$keyType = $this->typeMapper->getClassByType($keyType);
}

unset($data[self::KEY_TYPE]);

$data = $data[self::KEY_SCALAR] ?? $data;
Expand Down
55 changes: 55 additions & 0 deletions src/TypeMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/*
* (c) Kévin Dunglas <[email protected]>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace Dunglas\DoctrineJsonOdm;

/**
* Allows using string constants in place of class names.
*/
final class TypeMapper implements TypeMapperInterface
{
/**
* @var array<string, class-string>
*/
private $typeToClass;

/**
* @var array<class-string, string>
*/
private $classToType;

/**
* @param array<class-string, string> $typeToClass
*/
public function __construct(array $typeToClass)
{
$this->typeToClass = $typeToClass;
$this->classToType = array_flip($typeToClass);
}

/**
* Falls back to class name itself.
*
* @param class-string $class
*/
public function getTypeByClass(string $class): string
{
return $this->classToType[$class] ?? $class;
}

/**
* Falls back to type name itself – it might as well be a class.
*
* @return class-string
*/
public function getClassByType(string $type): string
{
return $this->typeToClass[$type] ?? $type;
}
}
30 changes: 30 additions & 0 deletions src/TypeMapperInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* (c) Kévin Dunglas <[email protected]>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace Dunglas\DoctrineJsonOdm;

/**
* Allows using string constants in place of class names.
*/
interface TypeMapperInterface
{
/**
* Resolve type name from class.
*
* @param class-string $class
*/
public function getTypeByClass(string $class): string;

/**
* Resolve class from type name.
*
* @return class-string
*/
public function getClassByType(string $type): string;
}
1 change: 0 additions & 1 deletion tests/Fixtures/AppKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa
'test' => null,
]);

$db = getenv('DB');
$container->loadFromExtension('doctrine', [
'dbal' => [
'url' => '%env(resolve:DATABASE_URL)%',
Expand Down
Loading

0 comments on commit 9ad0d28

Please sign in to comment.