Skip to content

Commit c9a98de

Browse files
authored
issue #160 - fix doctrine/orm joined inheritance anonymization (#164)
* issue #160 - fix doctrine/orm joined inheritance anonymization
1 parent d329a5c commit c9a98de

File tree

10 files changed

+202
-23
lines changed

10 files changed

+202
-23
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
## Next
44

55
* [feature] ⭐️ Add Doctrine DBAL 4.0 compatibility (#140).
6+
* [feature] ⭐️ Add Doctrine ORM 3.0 compatibility as a side effect of Doctrine DBAL 4.0 support (#140).
67
* [feature] ⭐️ Anonymization - Add Doctrine Embeddables support (#105).
7-
* [feature] ⭐️ As a side effect, Doctrine ORM 3.0 should now work (#140).
8+
* [feature] ⭐️ Anonymization - Add Doctrine entity joined inheritance support (#160)
9+
* [feature] ⭐️ Anonymization - Finalized and improved IBAN/BIC anonymizer (#4)
10+
* [fix] Restored MySQL 5.7 support (#124)
811
* [internal] Remove `doctrine/dbal` dependency from all code except the database session registry (#142).
912
* [internal] Introduce `DatabaseSessionRegistry` as single entry point for plugging-in database (#142).
1013
* [internal] Use `makinacorpus/query-builder` schema manager for DDL alteration (#140).
1114
* [internal] Raise `makinacorpus/query-builder` dependency to version 1.5.5 (#140).
15+
* [internal] Many improvements in local/CI `./dev.sh` test script.
1216

1317
## 1.1.0
1418

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default defineConfig({
7373
{ text: 'Custom Anonymizers', link: '/anonymization/custom-anonymizers' },
7474
{ text: 'Anonymization command', link: '/anonymization/command' },
7575
{ text: 'GDPR-friendly workflow', link: '/anonymization/workflow' },
76+
{ text: 'Doctrine and inheritance', link: '/anonymization/doctrine-inheritance' },
7677
{ text: 'Performance', link: '/anonymization/performance' },
7778
{ text: 'Internals', link: '/anonymization/internals' },
7879
]

docs/content/anonymization/custom-anonymizers.md

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,7 @@ use MakinaCorpus\DbToolsBundle\Attribute\AsAnonymizer;
7474
class MyEnumAnonymizer extends AbstractEnumAnonymizer
7575
{
7676
77-
/**
78-
* {@inheritdoc}
79-
*/
77+
#[\Override]
8078
protected function getSample(): array
8179
{
8280
// Generate here your sample.
@@ -108,9 +106,7 @@ use MakinaCorpus\DbToolsBundle\Attribute\AsAnonymizer;
108106
)]
109107
class MyMulticolumnAnonymizer extends AbstractMultipleColumnAnonymizer
110108
{
111-
/**
112-
* @inheritdoc
113-
*/
109+
#[\Override]
114110
protected function getColumnNames(): array
115111
{
116112
// Declare here name fo each part of your multicolumn
@@ -122,9 +118,7 @@ class MyMulticolumnAnonymizer extends AbstractMultipleColumnAnonymizer
122118
];
123119
}
124120
125-
/**
126-
* {@inheritdoc}
127-
*/
121+
#[\Override]
128122
protected function getSample(): array
129123
{
130124
// Generate here your sample.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Doctrine ORM and entity inheritance
2+
3+
Doctrine ORM allows various complex inheritance scenarios, support of those scenarios
4+
is usable in some case, but partial or non working in others.
5+
6+
## Embeddables
7+
8+
Embeddables are not quite inheritance, but composition instead. This use case is
9+
fully supported since all properties from the nested entity live in the entity
10+
that uses it table.
11+
12+
When the anonymizators builds the SQL query for anonymization, all columns are
13+
in the same table and run all at once.
14+
15+
## Joined inheritance
16+
17+
Joined inheritance has a very basic testing scenario, and should work flawlessly
18+
in most cases. When doing joined inheritance, the parent entity table exists
19+
separatly from the concrete entity implementation.
20+
21+
Anonymizator will hence build up two different SQL queries for anonymizing and
22+
live with it, one for the child class, and the other for the parent class.
23+
24+
Yes, it has a few drawbacks explained below.
25+
26+
:::warning
27+
**You cannot use a multi-column anonymizator on columns from the parent entity and columns of the child entity at the same time.**
28+
29+
By design, this API prevents this, and there will never be any work around.
30+
:::
31+
32+
:::warning
33+
**When anonymizing parent entity columns, the SQL query will not restrict to the a certain child type.**
34+
35+
This means that all entities in the discriminator map will be anonymized at once,
36+
none will be filtered out.
37+
:::
38+
39+
## Other inheritance types
40+
41+
Other inheritances types have not be tested yet, but may work.
42+
43+
:::tip
44+
The underlaying code handling inheritance in the anonymizator attribute lookup
45+
is generic enough so other inheritance types may actually work gracefully and
46+
transparently.
47+
48+
Testing it into your project is easy, [reporting an issue](https://github.com/makinacorpus/DbToolsBundle/issues)
49+
is as well! We are here to help you whenever a problem arise.
50+
:::

docs/content/anonymization/essentials.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ If Doctrine ORM is enabled, the *DbToolsBundle* will automatically look for attr
2121
If you want to use YAML configuration, look at the [Bundle Configuration
2222
section](../configuration#anonymization) to see how to configure it.
2323

24+
:::info
25+
All anonymizers can be configured via attributes on Doctrine ORM entities, but inheritance
26+
is not fully supported yet, [please read this page](doctrine-inheritance) for more information.
27+
:::
28+
2429
The anonymization is based on *Anonymizers*. An *Anonymizer* represents a way to anonymize a column (or
2530
multiple columns in certain cases). For example, you will use the EmailAnonymizer to anonymize a column that
2631
represents an email address.

docs/content/anonymization/internals.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ Each anonymizer adds its own `SET` statements to the query, and a few `JOIN` cla
88

99
## PHP query builder
1010

11-
Generating this kind of queries is quiet complexe and mostly impossible without a complete
11+
Generating this kind of queries is quite complex and mostly impossible without a complete
1212
and robust SQL query builder.
1313

1414
Our first thought was to use the [DBAL query builder](https://www.doctrine-project.org/projects/doctrine-dbal/en/4.0/reference/query-builder.html#sql-query-builder).
1515
But, while it is very robust, it lacks many features. Features such as
1616
update with join, which are essential for our use case.
1717

18-
Instead, we decided to re-use one of our tool: the [makinacorpus/query-builder-bundle](https://github.com/makinacorpus/query-builder-bundle) package.
18+
Instead, we decided to re-use one of our tool: [makinacorpus/php-query-builder](https://github.com/makinacorpus/query-builder-bundle).
1919

2020
This query builder lets you write SQL queries using a concise and easy to read fluent PHP API
2121
and [implements a lot of features](https://php-query-builder.readthedocs.io/en/stable/introduction/features.html).

src/Anonymization/Config/Loader/AttributesLoader.php

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ public function load(AnonymizationConfig $config): void
2828
return;
2929
}
3030

31-
$metadatas = $entityManager->getMetadataFactory()->getAllMetadata();
31+
$metadataFactory = $entityManager->getMetadataFactory();
32+
$metadatas = $metadataFactory->getAllMetadata();
3233

3334
foreach ($metadatas as $metadata) {
3435
\assert($metadata instanceof ClassMetadata);
@@ -51,11 +52,13 @@ public function load(AnonymizationConfig $config): void
5152

5253
$reflexionClass = $metadata->getReflectionClass();
5354
if ($attributes = $reflexionClass->getAttributes(Anonymize::class)) {
55+
// There can only be one of those attributes, foreach() is
56+
// required because of reflection API signature.
5457
foreach ($attributes as $key => $attribute) {
5558
$anonymization = $attribute->newInstance();
5659
$config->add(new AnonymizerConfig(
5760
$metadata->getTableName(),
58-
// For a anonymization setted on table, we give an arbitrary name
61+
// For a anonymization setted on table, we give an arbitrary name.
5962
$anonymization->type . '_' . $key,
6063
$anonymization->type,
6164
new Options($anonymization->options),
@@ -64,11 +67,11 @@ public function load(AnonymizationConfig $config): void
6467
}
6568

6669
foreach ($metadata->fieldMappings as $fieldName => $fieldValues) {
67-
// Field name with dot are part of Embeddables
70+
// Field name with dot are part of Embeddables.
6871
if (\str_contains($fieldName, '.')) {
6972
if (\key_exists($fieldValues['originalClass'], $embeddedClassesConfig)) {
7073
$embeddedClassConfig = $embeddedClassesConfig[$fieldValues['originalClass']];
71-
if(\key_exists($fieldValues['originalField'], $embeddedClassConfig)) {
74+
if (\key_exists($fieldValues['originalField'], $embeddedClassConfig)) {
7275
$propertyConfig = $embeddedClassConfig[$fieldValues['originalField']];
7376
$config->add(new AnonymizerConfig(
7477
$metadata->getTableName(),
@@ -81,12 +84,29 @@ public function load(AnonymizationConfig $config): void
8184
continue;
8285
}
8386

84-
$reflexionProperty = $reflexionClass->getProperty($fieldName);
85-
if ($attributes = $reflexionProperty->getAttributes(Anonymize::class)) {
87+
$columnName = $metadata->getColumnName($fieldName);
88+
if ($metadata->isInheritedField($fieldName)) {
89+
$fieldMapping = $metadata->getFieldMapping($fieldName);
90+
// @phpstan-ignore-next-line
91+
if (\is_array($fieldMapping)) {
92+
// Code for doctrine/orm:^2.0.
93+
$ownerClass = $fieldMapping['inherited'];
94+
} else {
95+
// Code for doctrine/orm:^3.0.
96+
$ownerClass = $fieldMapping->inherited;
97+
}
98+
$parentMetadata = $metadataFactory->getMetadataFor($ownerClass);
99+
\assert($parentMetadata instanceof ClassMetadata);
100+
$tableName = $parentMetadata->getTableName();
101+
} else {
102+
$tableName = $metadata->getTableName();
103+
}
104+
105+
if ($attributes = $metadata->getReflectionProperty($fieldName)->getAttributes(Anonymize::class)) {
86106
$anonymization = $attributes[0]->newInstance();
87107
$config->add(new AnonymizerConfig(
88-
$metadata->getTableName(),
89-
$metadata->getColumnName($fieldName),
108+
$tableName,
109+
$columnName,
90110
$anonymization->type,
91111
new Options($anonymization->options),
92112
));
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MakinaCorpus\DbToolsBundle\Tests\Resources\Loader;
6+
7+
use Doctrine\ORM\Mapping as ORM;
8+
use MakinaCorpus\DbToolsBundle\Attribute\Anonymize;
9+
10+
#[ORM\Entity()]
11+
#[ORM\Table(name: 'test_joined_child')]
12+
class TestJoinedChild extends TestJoinedParent
13+
{
14+
#[Anonymize(type: 'constant', options: ['value' => 'https://fr.wikipedia.org/wiki/Wikip%C3%A9dia:Accueil_principal#/media/Fichier:Myotis_crypticus_-_Manuel_Ruedi.jpg'])]
15+
#[ORM\Column(length: 255, nullable: true)]
16+
private ?string $url = null;
17+
18+
#[Anonymize(type: 'constant', options: ['value' => 'https://fr.wikipedia.org/wiki/Wikip%C3%A9dia:Accueil_principal#/media/Fichier:Myotis_crypticus_-_Manuel_Ruedi.jpg'])]
19+
#[ORM\Column(length: 255, nullable: true)]
20+
private ?string $thumbnail_url = null;
21+
22+
public function getUrl(): ?string
23+
{
24+
return $this->url;
25+
}
26+
27+
public function getThumbnailUrl(): ?string
28+
{
29+
return $this->thumbnail_url;
30+
}
31+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MakinaCorpus\DbToolsBundle\Tests\Resources\Loader;
6+
7+
use Doctrine\ORM\Mapping as ORM;
8+
use MakinaCorpus\DbToolsBundle\Attribute\Anonymize;
9+
10+
#[ORM\Entity()]
11+
#[ORM\Table(name: 'test_joined_parent')]
12+
#[ORM\InheritanceType('JOINED')]
13+
#[ORM\DiscriminatorColumn(name: 'type', type: 'string')]
14+
#[ORM\DiscriminatorMap([
15+
'child' => TestJoinedChild::class,
16+
])]
17+
class TestJoinedParent
18+
{
19+
#[ORM\Id]
20+
#[ORM\GeneratedValue(strategy: "NONE")]
21+
#[ORM\Column]
22+
private ?int $id = null;
23+
24+
#[ORM\Column(length: 180, unique: true)]
25+
#[Anonymize(type:'email', options: ['domain' => 'toto.com'])]
26+
private ?string $email = null;
27+
28+
public function getId(): ?int
29+
{
30+
return $this->id;
31+
}
32+
33+
public function getEmail(): ?string
34+
{
35+
return $this->email;
36+
}
37+
}

tests/Unit/Anonymization/Configuration/AttributeLoaderTest.php

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@
1212
use MakinaCorpus\DbToolsBundle\Anonymization\Config\AnonymizationConfig;
1313
use MakinaCorpus\DbToolsBundle\Anonymization\Config\AnonymizerConfig;
1414
use MakinaCorpus\DbToolsBundle\Anonymization\Config\Loader\AttributesLoader;
15-
use MakinaCorpus\DbToolsBundle\Tests\Resources\Loader\TestEntity;
1615
use MakinaCorpus\DbToolsBundle\Test\UnitTestCase;
17-
use MakinaCorpus\DbToolsBundle\Tests\Resources\Loader\TestEmbeddableEntity;
16+
use MakinaCorpus\DbToolsBundle\Tests\Resources\Loader\TestEntity;
1817
use MakinaCorpus\DbToolsBundle\Tests\Resources\Loader\TestEntityWithEmbedded;
18+
use MakinaCorpus\DbToolsBundle\Tests\Resources\Loader\TestJoinedChild;
1919

2020
class AttributeLoaderTest extends UnitTestCase
2121
{
@@ -68,6 +68,43 @@ public function testLoadOk(): void
6868
self::assertSame('country', $testTableConfig['address_0']->options->get('country'));
6969
}
7070

71+
public function testLoadWithJoinedInheritance(): void
72+
{
73+
$classMetadataFactory = $this->getClassMetadataFactory();
74+
$classMetadataFactory->getMetadataFor(TestJoinedChild::class);
75+
76+
$entityManager = $this->createMock(EntityManagerInterface::class);
77+
$entityManager
78+
->expects($this->exactly(1))
79+
->method('getMetadataFactory')
80+
->willReturn($classMetadataFactory)
81+
;
82+
83+
$entityManagerProvider = $this->createMock(EntityManagerProvider::class);
84+
$entityManagerProvider
85+
->expects($this->exactly(1))
86+
->method('getManager')
87+
->willReturn($entityManager)
88+
;
89+
90+
// We load configuration for the 'default' connection.
91+
$config = new AnonymizationConfig('default');
92+
(new AttributesLoader($entityManagerProvider))->load($config);
93+
94+
// Then we validate what's in it:
95+
$testTableConfig = $config->getTableConfig('test_joined_child');
96+
self::assertCount(2, $testTableConfig);
97+
self::assertInstanceOf(AnonymizerConfig::class, $testTableConfig['url']);
98+
self::assertSame('constant', $testTableConfig['url']->anonymizer);
99+
self::assertInstanceOf(AnonymizerConfig::class, $testTableConfig['thumbnail_url']);
100+
self::assertSame('constant', $testTableConfig['thumbnail_url']->anonymizer);
101+
102+
$testTableConfig = $config->getTableConfig('test_joined_parent');
103+
self::assertCount(1, $testTableConfig);
104+
self::assertInstanceOf(AnonymizerConfig::class, $testTableConfig['email']);
105+
self::assertSame('email', $testTableConfig['email']->anonymizer);
106+
}
107+
71108
public function testLoadWithEmbeddedOk(): void
72109
{
73110
$classMetadataFactory = $this->getClassMetadataFactory();
@@ -87,7 +124,7 @@ public function testLoadWithEmbeddedOk(): void
87124
->willReturn($entityManager)
88125
;
89126

90-
// We try to load configuration for the 'default' connection.
127+
// We load configuration for the 'default' connection.
91128
$config = new AnonymizationConfig('default');
92129
(new AttributesLoader($entityManagerProvider))->load($config);
93130

0 commit comments

Comments
 (0)