Skip to content

Allow using type names in place of class names #118

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

Merged
merged 25 commits into from
Feb 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9d6e5e2
Working prototype
TamasSzigeti Sep 1, 2022
751719a
Style fixes
TamasSzigeti Sep 1, 2022
3514c64
Add arg type to config validator callback
TamasSzigeti Oct 17, 2022
ed629aa
Inject type mapper as optional service
TamasSzigeti Oct 17, 2022
343bcc0
Add tests
TamasSzigeti Oct 17, 2022
b338d56
Test without type mapper service
TamasSzigeti Oct 17, 2022
a299103
With and without type mapper
TamasSzigeti Oct 17, 2022
488b3d5
Assert service exists vs not
TamasSzigeti Oct 17, 2022
22f2ce8
Apply linter diff
TamasSzigeti Oct 17, 2022
144fcc9
Add doc
TamasSzigeti Oct 17, 2022
35c2633
Rename config key to type_map
TamasSzigeti Oct 17, 2022
91caa7b
Define type mapper service in xml
TamasSzigeti Oct 17, 2022
bb1ac96
Make type mapper final
TamasSzigeti Oct 17, 2022
ea36396
Do not use deprecated static test container
TamasSzigeti Oct 17, 2022
b22e8fa
Use forward compatible test container access
TamasSzigeti Oct 17, 2022
49bb617
Fix condition for removing enum normalizer
TamasSzigeti Oct 17, 2022
672c7a9
Address deprecation error
TamasSzigeti Oct 17, 2022
0fba0e3
lint
TamasSzigeti Oct 17, 2022
d01cf41
Add test for custom type mapper
TamasSzigeti Oct 17, 2022
2721cc2
lint
TamasSzigeti Oct 17, 2022
1d74beb
test compatibility w earlier versions
TamasSzigeti Oct 18, 2022
fdf4cc6
Use legacy password hashing in MySQL for old php versions
TamasSzigeti Oct 18, 2022
333a2f3
Add doc, drop symfony param, resolve wrong var naming
TamasSzigeti Feb 10, 2023
469d843
doc fixes
TamasSzigeti Feb 18, 2023
558fa73
Fix mysql in workflow, run on 8.2, no fail-fast
TamasSzigeti Feb 18, 2023
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
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