diff --git a/Annotations/AnonymizedEntity.php b/Annotations/AnonymizedEntity.php
new file mode 100644
index 0000000..6a78135
--- /dev/null
+++ b/Annotations/AnonymizedEntity.php
@@ -0,0 +1,82 @@
+
+ */
+final class AnonymizedEntity
+{
+ public const ACTION_ANONYMIZE = 'anonymize';
+ public const ACTION_TRUNCATE = 'truncate';
+ private const ACTION_CHOICES = [self::ACTION_ANONYMIZE, self::ACTION_TRUNCATE];
+
+ /**
+ * @var string
+ */
+ private $action = self::ACTION_ANONYMIZE;
+
+ /**
+ * Add where sql condition on which not apply anonymization.
+ *
+ * @var string|null
+ */
+ private $exceptWhereClause;
+
+ public function __construct(iterable $options)
+ {
+ foreach ($options as $key => $value) {
+ if (!property_exists($this, $key)) {
+ throw new \InvalidArgumentException(sprintf('Option "%s" does not exist', $key));
+ }
+
+ $this->$key = $value;
+ }
+
+ $this->validateAction();
+ }
+
+ public function getAction(): string
+ {
+ return $this->action;
+ }
+
+ public function getExceptWhereClause(): ?string
+ {
+ return $this->exceptWhereClause;
+ }
+
+ public function isTruncateAction(): bool
+ {
+ return static::ACTION_TRUNCATE === $this->action;
+ }
+
+ public function isAnonymizeAction(): bool
+ {
+ return static::ACTION_ANONYMIZE === $this->action;
+ }
+
+ private function validateAction(): void
+ {
+ if (!\in_array($this->action, static::ACTION_CHOICES, true)) {
+ throw new \InvalidArgumentException(sprintf('Action "%s" is not allowed. Allowed actions are: %s',
+ $this->action, implode(', ', static::ACTION_CHOICES)));
+ }
+ }
+}
diff --git a/Annotations/AnonymizedProperty.php b/Annotations/AnonymizedProperty.php
new file mode 100644
index 0000000..4a57e82
--- /dev/null
+++ b/Annotations/AnonymizedProperty.php
@@ -0,0 +1,136 @@
+
+ */
+final class AnonymizedProperty
+{
+ public const TYPE_STATIC = 'static';
+ public const TYPE_COMPOSED = 'composed';
+ public const TYPE_EXPRESSION = 'expression';
+ private const TYPE_CHOICES = [self::TYPE_STATIC, self::TYPE_COMPOSED, self::TYPE_EXPRESSION];
+
+ /**
+ * @var mixed|null
+ */
+ private $value;
+
+ /**
+ * Can be of type static (fixed value) or composed (mix of static & existing field value).
+ *
+ * @var string
+ */
+ private $type = self::TYPE_STATIC;
+
+ /**
+ * @var string
+ */
+ private $fieldName;
+
+ /**
+ * @var string
+ */
+ private $columnName;
+
+ public function __construct(iterable $options)
+ {
+ foreach ($options as $key => $value) {
+ if (!property_exists($this, $key)) {
+ throw new \InvalidArgumentException(sprintf('Option "%s" does not exist', $key));
+ }
+
+ $this->$key = $value;
+ }
+
+ $this->validateType();
+ }
+
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ public function getType(): string
+ {
+ return $this->type;
+ }
+
+ public function getFieldName(): string
+ {
+ return $this->fieldName;
+ }
+
+ public function setFieldName(string $fieldName): self
+ {
+ $this->fieldName = $fieldName;
+
+ return $this;
+ }
+
+ public function getColumnName(): string
+ {
+ return $this->columnName;
+ }
+
+ public function setColumnName(string $columnName): self
+ {
+ $this->columnName = $columnName;
+
+ return $this;
+ }
+
+ public function isStatic(): bool
+ {
+ return static::TYPE_STATIC === $this->type;
+ }
+
+ public function isComposed(): bool
+ {
+ return static::TYPE_COMPOSED === $this->type;
+ }
+
+ public function isExpression(): bool
+ {
+ return static::TYPE_EXPRESSION === $this->type;
+ }
+
+ public function extractComposedFieldFromValue(): string
+ {
+ preg_match('/<(\w*)>/', $this->value, $matches);
+
+ return $matches[1] ?? '';
+ }
+
+ public function explodeComposedFieldValue(): array
+ {
+ preg_match('/(.*)<(\w*)>(.*)/', $this->value, $matches);
+
+ return $matches ?? [];
+ }
+
+ private function validateType(): void
+ {
+ if (!\in_array($this->type, static::TYPE_CHOICES, true)) {
+ throw new \InvalidArgumentException(sprintf('Type "%s" is not allowed. Allowed types are: %s',
+ $this->type, implode(', ', static::TYPE_CHOICES)));
+ }
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63e797a..b77eb8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ master
* Drop support for PHP 7.1
* Add PHP 7.4 in CI
* Upgrade PhpUnit to 8
+* Add command to anonymize database through annotation configuration
v1.1.0
------
diff --git a/Command/AnonymizeDataCommand.php b/Command/AnonymizeDataCommand.php
new file mode 100644
index 0000000..47faa70
--- /dev/null
+++ b/Command/AnonymizeDataCommand.php
@@ -0,0 +1,127 @@
+
+ */
+final class AnonymizeDataCommand extends Command
+{
+ /**
+ * {@inheritdoc}
+ */
+ protected static $defaultName = 'ekino-data-protection:anonymize';
+
+ protected $anonymizedMetadataBuilder;
+
+ protected $anonymizedMetadataValidator;
+
+ protected $anonymizedQueryBuilder;
+
+ public function __construct(
+ AnonymizedMetadataBuilder $anonymizedMetadataBuilder,
+ AnonymizedMetadataValidator $anonymizedMetadataValidator,
+ AnonymizedQueryBuilder $anonymizedQueryBuilder
+ )
+ {
+ parent::__construct();
+
+ $this->anonymizedMetadataBuilder = $anonymizedMetadataBuilder;
+ $this->anonymizedMetadataValidator = $anonymizedMetadataValidator;
+ $this->anonymizedQueryBuilder = $anonymizedQueryBuilder;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function configure(): void
+ {
+ $this->setDescription('Anonymize database based on entities annotations')
+ ->addOption('force', null, InputOption::VALUE_NONE, 'Set this parameter to execute this action')
+ ->setHelp('Usage: `bin/console ekino-data-protection:anonymize`')
+ ;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $output->writeln(sprintf('Anonymization starts'));
+
+ $anonymizedMetadatas = $this->anonymizedMetadataBuilder->build();
+ $queries = [];
+
+ foreach ($anonymizedMetadatas as $anonymizedMetadata) {
+ $this->anonymizedMetadataValidator->validate($anonymizedMetadata);
+ $queries[] = $this->anonymizedQueryBuilder->buildQuery($anonymizedMetadata);
+ }
+
+ if (!$input->getOption('force')) {
+ $output->writeln('ATTENTION: This operation should not be executed in a production environment.');
+ $output->writeln('');
+ $output->writeln('Would annoymize your database according to your configuration.');
+ $output->writeln('Please run the operation with --force to execute');
+ $output->writeln('Some data will be lost/anonymized!');
+
+ $this->displayQueries($queries, $output);
+
+ return 0;
+ }
+
+ $question = 'Are you sure you wish to continue & anonymize your database? (y/n)';
+
+ if (! $this->canExecute($question, $input, $output)) {
+ $output->writeln('Anonymization cancelled!');
+
+ return 1;
+ }
+
+ $this->displayQueries($queries, $output);
+ // @todo execute queries
+ $output->writeln(sprintf('Anonymization ends'));
+
+ return 0;
+ }
+
+ private function displayQueries(array $queries, OutputInterface $output): void
+ {
+ $output->writeln('Following queries have been built and will be executed:');
+
+ foreach ($queries as $query) {
+ $output->writeln(sprintf('%s', $query));
+ }
+ }
+
+ private function askConfirmation(string $question, InputInterface $input, OutputInterface $output): bool
+ {
+ return $this->getHelper('question')->ask($input, $output, new ConfirmationQuestion($question));
+ }
+
+ private function canExecute(string $question, InputInterface $input, OutputInterface $output ): bool
+ {
+ return ! $input->isInteractive() || $this->askConfirmation($question, $input, $output);
+ }
+}
diff --git a/Meta/AnonymizedMetadata.php b/Meta/AnonymizedMetadata.php
new file mode 100644
index 0000000..c13be5c
--- /dev/null
+++ b/Meta/AnonymizedMetadata.php
@@ -0,0 +1,81 @@
+
+ */
+final class AnonymizedMetadata
+{
+ /**
+ * @var ClassMetadata
+ */
+ private $classMetadata;
+
+ /**
+ * @var AnonymizedEntity
+ */
+ private $anonymizedEntity;
+
+ /**
+ * @var AnonymizedProperty[]
+ */
+ private $anonymizedProperties = [];
+
+ public function getClassMetadata(): ClassMetadata
+ {
+ return $this->classMetadata;
+ }
+
+ public function setClassMetadata(ClassMetadata $classMetadata): self
+ {
+ $this->classMetadata = $classMetadata;
+
+ return $this;
+ }
+
+ public function getAnonymizedEntity(): AnonymizedEntity
+ {
+ return $this->anonymizedEntity;
+ }
+
+ public function setAnonymizedEntity(AnonymizedEntity $anonymizedEntity): self
+ {
+ $this->anonymizedEntity = $anonymizedEntity;
+
+ return $this;
+ }
+
+ public function getAnonymizedProperties(): array
+ {
+ return $this->anonymizedProperties;
+ }
+
+ /**
+ * @param AnonymizedProperty[] $anonymizedProperties
+ */
+ public function setAnonymizedProperties(array $anonymizedProperties): self
+ {
+ $this->anonymizedProperties = $anonymizedProperties;
+
+ return $this;
+ }
+}
diff --git a/Meta/AnonymizedMetadataBuilder.php b/Meta/AnonymizedMetadataBuilder.php
new file mode 100644
index 0000000..32d6850
--- /dev/null
+++ b/Meta/AnonymizedMetadataBuilder.php
@@ -0,0 +1,111 @@
+
+ */
+final class AnonymizedMetadataBuilder
+{
+ private $entityManager;
+
+ protected $annotationReader;
+
+ public function __construct(EntityManagerInterface $entityManager, Reader $annotationReader)
+ {
+ $this->entityManager = $entityManager;
+ $this->annotationReader = $annotationReader;
+ }
+
+ /**
+ * return \Generator
+ */
+ public function build(): \Generator
+ {
+ $anonymizedMetadatas = [];
+ /** @var ClassMetadata[] $classMetadatas */
+ $classMetadatas = $this->entityManager->getMetadataFactory()->getAllMetadata();
+
+ foreach ($classMetadatas as $classMetadata) {
+ $anonymizedEntity = $this->buildAnonymizedEntityAnnotations($classMetadata);
+ $anonymizedProperties = $this->buildAnonymizedPropertiesAnnotations($classMetadata);
+
+ if (!$anonymizedEntity && !empty($anonymizedProperties)) {
+ throw AnnotationException::creationError(
+ sprintf('You tried to anonymize a property without specifying it at class level in %s.
+ You should add @AnonymizedEntity() in class phpdoc', $classMetadata->getName()));
+ }
+
+ if ($anonymizedEntity) {
+ yield $this->buildAnonymizedMetadata(
+ $classMetadata, $anonymizedEntity, $anonymizedProperties
+ );
+ }
+ }
+ }
+
+ private function buildAnonymizedMetadata(
+ ClassMetadata $classMetadata,
+ AnonymizedEntity $anonymizedEntity,
+ array $anonymizedProperties): AnonymizedMetadata
+ {
+ return (new AnonymizedMetadata())
+ ->setClassMetadata($classMetadata)
+ ->setAnonymizedEntity($anonymizedEntity)
+ ->setAnonymizedProperties($anonymizedProperties);
+ }
+
+ private function buildAnonymizedPropertiesAnnotations(ClassMetadata $classMetadata): array
+ {
+ $anonymizedProperties = [];
+ $properties = $classMetadata->getFieldNames();
+
+ foreach ($properties as $property) {
+ /** @var AnonymizedProperty|null $anonymizedProperty */
+ $anonymizedProperty = $this->annotationReader->getPropertyAnnotation(
+ new \ReflectionProperty($classMetadata->getName(), $property),
+ AnonymizedProperty::class
+ );
+
+ if (\is_null($anonymizedProperty)) {
+ continue;
+ }
+
+ $anonymizedProperty->setFieldName($property)->setColumnName($classMetadata->getColumnName($property));
+ $anonymizedProperties[] = $anonymizedProperty;
+ }
+
+ return $anonymizedProperties;
+ }
+
+ private function buildAnonymizedEntityAnnotations(ClassMetadata $classMetadata): ?AnonymizedEntity
+ {
+ /** @var AnonymizedEntity|null $anonymizedEntity */
+ $anonymizedEntity = $this->annotationReader->getClassAnnotation(
+ new \ReflectionClass($classMetadata->getName()), AnonymizedEntity::class
+ );
+
+ return $anonymizedEntity;
+ }
+}
diff --git a/Meta/AnonymizedMetadataValidator.php b/Meta/AnonymizedMetadataValidator.php
new file mode 100644
index 0000000..991100c
--- /dev/null
+++ b/Meta/AnonymizedMetadataValidator.php
@@ -0,0 +1,135 @@
+
+ */
+final class AnonymizedMetadataValidator
+{
+ public function validate(AnonymizedMetadata $anonymizedMetadata): void
+ {
+ $this->anonymizedPropertiesMustBeEmptyIfAnonymizedEntityActionIsTruncate($anonymizedMetadata);
+
+ $anonymizedProperties = $anonymizedMetadata->getAnonymizedProperties();
+ $classMetadata = $anonymizedMetadata->getClassMetadata();
+
+ foreach ($anonymizedProperties as $anonymizedProperty) {
+ $this->propertyMustBeNullableIfStaticValueIsNull($anonymizedProperty, $classMetadata);
+ $this->propertyMustExistsInComposedExpression($anonymizedProperty, $classMetadata);
+ $this->composedFieldForUniquePropertyMustBeUnique($anonymizedProperty, $classMetadata);
+ $this->anonymizedPropertyMustBeComposedIfFieldHasUniqueIndex($anonymizedProperty, $classMetadata);
+ $this->associationPropertyMustNotHaveAnonymizedProperty($anonymizedProperty, $classMetadata);
+ }
+ }
+
+ private function anonymizedPropertiesMustBeEmptyIfAnonymizedEntityActionIsTruncate(
+ AnonymizedMetadata $anonymizedMetadata): void
+ {
+ $anonymizedEntity = $anonymizedMetadata->getAnonymizedEntity();
+
+ if ($anonymizedEntity->isTruncateAction()
+ && !empty($anonymizedMetadata->getAnonymizedProperties())) {
+ throw AnnotationException::creationError(
+ sprintf('If %s action is set at class level, it can\'t have property annotation in %s',
+ AnonymizedEntity::ACTION_TRUNCATE, $anonymizedMetadata->getClassMetadata()->getName()));
+ }
+ }
+
+ private function anonymizedPropertyMustBeComposedIfFieldHasUniqueIndex(
+ AnonymizedProperty $anonymizedProperty,
+ ClassMetadata $classMetadata): void
+ {
+ if ($classMetadata->isUniqueField($anonymizedProperty->getFieldName()) && !$anonymizedProperty->isComposed()) {
+ throw AnnotationException::creationError(
+ sprintf('If property is unique (%s), AnonymzedProperty must be of type %s in %s',
+ $anonymizedProperty->getFieldName(), AnonymizedProperty::TYPE_COMPOSED, $classMetadata->getName()));
+ }
+ }
+
+ private function propertyMustExistsInComposedExpression(
+ AnonymizedProperty $anonymizedProperty,
+ ClassMetadata $classMetadata): void
+ {
+ if (!$anonymizedProperty->isComposed()) {
+ return;
+ }
+
+ $value = $anonymizedProperty->getValue();
+ $composedField = $anonymizedProperty->extractComposedFieldFromValue();
+
+ if (empty($composedField)) {
+ throw AnnotationException::creationError(
+ sprintf('No composed field specified in composed expression of %s property in %s',
+ $anonymizedProperty->getFieldName(), $classMetadata->getName()));
+ }
+
+ if (!\in_array($composedField, $classMetadata->getFieldNames())) {
+ throw AnnotationException::creationError(
+ sprintf('Property %s specified in composed expression of %s does not exists in %s',
+ $composedField, $anonymizedProperty->getFieldName(), $classMetadata->getName()));
+ }
+ }
+
+ private function composedFieldForUniquePropertyMustBeUnique(
+ AnonymizedProperty $anonymizedProperty,
+ ClassMetadata $classMetadata): void
+ {
+ if (!$anonymizedProperty->isComposed()) {
+ return;
+ }
+
+ $composedField = $anonymizedProperty->extractComposedFieldFromValue();
+
+ if ($classMetadata->isUniqueField($anonymizedProperty->getFieldName())
+ && !$classMetadata->isUniqueField($composedField)) {
+ throw AnnotationException::creationError(
+ sprintf('If property is unique (%s), composed field %s must be unique to avoid duplicate potential value in %s',
+ $anonymizedProperty->getFieldName(), $composedField, $classMetadata->getName()));
+ }
+ }
+
+ private function propertyMustBeNullableIfStaticValueIsNull(
+ AnonymizedProperty $anonymizedProperty,
+ ClassMetadata $classMetadata): void
+ {
+ if ($anonymizedProperty->isStatic()
+ && \is_null($anonymizedProperty->getValue())
+ && !$classMetadata->isNullable($anonymizedProperty->getFieldName())) {
+ throw AnnotationException::creationError(
+ sprintf('Property %s is supposed to be anonymized to null value but is not nullable in %s',
+ $anonymizedProperty->getFieldName(), $classMetadata->getName()));
+ }
+ }
+
+ private function associationPropertyMustNotHaveAnonymizedProperty(
+ AnonymizedProperty $anonymizedProperty,
+ ClassMetadata $classMetadata): void
+ {
+ if (\in_array($anonymizedProperty->getFieldName(), $classMetadata->getAssociationNames())) {
+ throw AnnotationException::creationError(
+ sprintf('Anonymization of associations (%s) is not supported in %s',
+ $anonymizedProperty->getFieldName(), $classMetadata->getName()));
+ }
+ }
+}
diff --git a/QueryBuilder/AnonymizedQueryBuilder.php b/QueryBuilder/AnonymizedQueryBuilder.php
new file mode 100644
index 0000000..5eff383
--- /dev/null
+++ b/QueryBuilder/AnonymizedQueryBuilder.php
@@ -0,0 +1,78 @@
+
+ */
+final class AnonymizedQueryBuilder
+{
+ public function buildQuery(AnonymizedMetadata $anonymizedMetadata): string
+ {
+ $query = '';
+
+ if ($anonymizedMetadata->getAnonymizedEntity()->isTruncateAction()) {
+ $query = $this->buildTruncateQuery($anonymizedMetadata);
+ } elseif ($anonymizedMetadata->getAnonymizedEntity()->isAnonymizeAction()) {
+ $query = $this->buildAnonymizeQuery($anonymizedMetadata);
+ }
+
+ $exceptWhereClause = $anonymizedMetadata->getAnonymizedEntity()->getExceptWhereClause();
+
+ return $exceptWhereClause ? sprintf('%s WHERE %s', $query, $exceptWhereClause) : $query;
+ }
+
+ private function buildTruncateQuery(AnonymizedMetadata $anonymizedMetadata): string
+ {
+ return sprintf('DELETE FROM %s', $anonymizedMetadata->getClassMetadata()->getTableName());
+ }
+
+ private function buildAnonymizeQuery(AnonymizedMetadata $anonymizedMetadata): string
+ {
+ $setters = [];
+ $anonymizedProperties = $anonymizedMetadata->getAnonymizedProperties();
+
+ foreach ($anonymizedProperties as $property => $anonymizedProperty) {
+ $setters[] = $this->buildPropertySetterQueryPart($anonymizedProperty);
+ }
+
+ return sprintf('UPDATE %s SET %s',
+ $anonymizedMetadata->getClassMetadata()->getTableName(), implode(', ', array_filter($setters)));
+ }
+
+ private function buildPropertySetterQueryPart(AnonymizedProperty $anonymizedProperty): string
+ {
+ if ($anonymizedProperty->isComposed()) {
+ $composedFieldParts = $anonymizedProperty->explodeComposedFieldValue();
+
+ return sprintf('%s = concat(concat("%s", %s), "%s")', $anonymizedProperty->getColumnName(),
+ $composedFieldParts[1], $composedFieldParts[2],$composedFieldParts[3]);
+ }
+
+ if ($anonymizedProperty->isStatic() || $anonymizedProperty->isExpression()) {
+ $propertyValue = $anonymizedProperty->isStatic() ?
+ sprintf('"%s"', $anonymizedProperty->getValue()) : $anonymizedProperty->getValue();
+
+ return sprintf('%s = %s', $anonymizedProperty->getColumnName(), $propertyValue);
+ }
+
+ throw new \LogicException('"%s" type is not expected as this point to generate a valid query.');
+ }
+}
diff --git a/README.md b/README.md
index c58bfb0..3640497 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,51 @@ To encrypt a text, run the following command:
`bin/console ekino-data-protection:encrypt myText`, optionally with `--secret mySecret` and/or `--method myCipher`
+## Anonymize your database
+
+This bundle provides an easy way to anonymize your database. This feature can be useful when you get down some
+production database to your staged environments for example and you care about who access to real client/user data
+according to GDPR reglementation.
+
+Efficiency is also very important when you need to anonymize big volumetry. This bundle aims to generate at most one
+query per table.
+
+### Annotation configuration
+
+This bundle provides 2 annotations:
+
+`@AnonyizedEntity()` to specify which entity should be considered to be anonymized
+
+- action option: anonymize(default)|truncate
+- exceptWhereClause option: to keep some data unchanged, will be added as WHERE clause at the end of generated query
+
+
+`@AnonyizedProperty()` to specify which entity field should be considered to be anonymized
+
+- type option: static(default)|composed|expression
+
+ - static type: same value will be applied everywhere
+ - composed type: a string composed of an another `` (ex : test-@test.com)
+ - expression type: a valid expression where another field can be used or not (ex : CONCAT(FLOOR(1 + (RAND() * 1000)), id))
+
+- value option: string representing static|composed|expression format
+
+### Process
+
+The command for anonymizing will :
+- gather anonymizing configuration by reading annotation and build AnonymizedMetadata
+- validate AnonymizedMetadata (see `Ekino\DataProtectionBundle\Meta\AnonymizedMetadataValidator`)
+- build queries
+- execute queries
+
+### Usage
+
+To anonymize your database, run the following command:
+
+`bin/console ekino-data-protection:anonymize` will display the queries that have been generated
+`bin/console ekino-data-protection:anonymize --force` will display & execute the queries that have been generated
+
+
[1]: https://php.net/manual/en/function.openssl-get-cipher-methods.php
[2]: https://github.com/Seldaek/monolog
[3]: https://github.com/sonata-project/SonataAdminBundle
diff --git a/Resources/config/services.xml b/Resources/config/services.xml
index 1d6f5d2..1e2a371 100644
--- a/Resources/config/services.xml
+++ b/Resources/config/services.xml
@@ -31,6 +31,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/Annotations/AnonymizedEntityTest.php b/Tests/Annotations/AnonymizedEntityTest.php
new file mode 100644
index 0000000..975a321
--- /dev/null
+++ b/Tests/Annotations/AnonymizedEntityTest.php
@@ -0,0 +1,58 @@
+
+ */
+class AnonymizedEntityTest extends TestCase
+{
+ public function testAnonymizedEntityWithInvalidProperty(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Option "foo" does not exist');
+
+ new AnonymizedEntity([
+ 'foo' => 'bar',
+ ]);
+ }
+
+ public function testAnonymizedEntityWithInvalidAction(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Action "foo" is not allowed. Allowed actions are: anonymize, truncate');
+
+ new AnonymizedEntity([
+ 'action' => 'foo',
+ ]);
+ }
+
+ public function testWithValidConfiguration(): void
+ {
+ $anonymizedEntity = new AnonymizedEntity([
+ 'action' => AnonymizedEntity::ACTION_TRUNCATE,
+ 'exceptWhereClause' => 'roles NOT LIKE %foo%',
+ ]);
+
+ $this->assertTrue($anonymizedEntity->isTruncateAction());
+ $this->assertFalse($anonymizedEntity->isAnonymizeAction());
+ $this->assertSame($anonymizedEntity->getExceptWhereClause(), 'roles NOT LIKE %foo%');
+ $this->assertSame($anonymizedEntity->getAction(), AnonymizedEntity::ACTION_TRUNCATE);
+ }
+}
diff --git a/Tests/Annotations/AnonymizedPropertyTest.php b/Tests/Annotations/AnonymizedPropertyTest.php
new file mode 100644
index 0000000..f332138
--- /dev/null
+++ b/Tests/Annotations/AnonymizedPropertyTest.php
@@ -0,0 +1,59 @@
+
+ */
+class AnonymizedPropertyTest extends TestCase
+{
+ public function testAnonymizedEntityWithInvalidProperty(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Option "foo" does not exist');
+
+ new AnonymizedProperty([
+ 'foo' => 'bar',
+ ]);
+ }
+
+ public function testAnonymizedEntityWithInvalidType(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Type "foo" is not allowed. Allowed types are: static, composed, expression');
+
+ new AnonymizedProperty([
+ 'type' => 'foo',
+ ]);
+ }
+
+ public function testWithValidConfiguration(): void
+ {
+ $anonymizedProperty = new AnonymizedProperty([
+ 'type' => AnonymizedProperty::TYPE_COMPOSED,
+ 'value' => 'test-',
+ ]);
+
+ $this->assertTrue($anonymizedProperty->isComposed());
+ $this->assertFalse($anonymizedProperty->isStatic());
+ $this->assertFalse($anonymizedProperty->isExpression());
+ $this->assertSame('id', $anonymizedProperty->extractComposedFieldFromValue());
+ $this->assertSame(['test-', 'test-', 'id', ''], $anonymizedProperty->explodeComposedFieldValue());
+ }
+}
diff --git a/Tests/Entity/AnonymizedPropertiesAndTruncateAnonymizedEntityAction.php b/Tests/Entity/AnonymizedPropertiesAndTruncateAnonymizedEntityAction.php
new file mode 100644
index 0000000..c360c15
--- /dev/null
+++ b/Tests/Entity/AnonymizedPropertiesAndTruncateAnonymizedEntityAction.php
@@ -0,0 +1,47 @@
+
+ */
+class AnonymizedPropertiesAndTruncateAnonymizedEntityAction implements ClassMetadataProviderInterface
+{
+ /**
+ * @var string
+ * @AnonymizedProperty()
+ */
+ private $bar;
+
+ public static function getFieldMappings(): array
+ {
+ return ['bar' => ['fieldName' => 'bar']];
+ }
+
+ public static function getFieldNames(): array
+ {
+ return ['bar' => 'bar'];
+ }
+
+ public static function getAssociationMappings(): array
+ {
+ return [];
+ }
+}
diff --git a/Tests/Entity/AnonymizedPropertyNullOnNotNullableProperty.php b/Tests/Entity/AnonymizedPropertyNullOnNotNullableProperty.php
new file mode 100644
index 0000000..31f6590
--- /dev/null
+++ b/Tests/Entity/AnonymizedPropertyNullOnNotNullableProperty.php
@@ -0,0 +1,47 @@
+
+ */
+class AnonymizedPropertyNullOnNotNullableProperty implements ClassMetadataProviderInterface
+{
+ /**
+ * @var string
+ * @AnonymizedProperty()
+ */
+ private $bar;
+
+ public static function getFieldMappings(): array
+ {
+ return ['bar' => ['fieldName' => 'bar', 'nullable' => false]];
+ }
+
+ public static function getFieldNames(): array
+ {
+ return ['bar' => 'bar'];
+ }
+
+ public static function getAssociationMappings(): array
+ {
+ return ['bar' => 'bar'];
+ }
+}
diff --git a/Tests/Entity/AnonymizedPropertyOnAssociationField.php b/Tests/Entity/AnonymizedPropertyOnAssociationField.php
new file mode 100644
index 0000000..fbc0cac
--- /dev/null
+++ b/Tests/Entity/AnonymizedPropertyOnAssociationField.php
@@ -0,0 +1,47 @@
+
+ */
+class AnonymizedPropertyOnAssociationField implements ClassMetadataProviderInterface
+{
+ /**
+ * @var Foo
+ * @AnonymizedProperty(value="foo")
+ */
+ private $bar;
+
+ public static function getFieldMappings(): array
+ {
+ return ['bar' => ['fieldName' => 'bar']];
+ }
+
+ public static function getFieldNames(): array
+ {
+ return ['bar' => 'bar'];
+ }
+
+ public static function getAssociationMappings(): array
+ {
+ return ['bar' => 'bar'];
+ }
+}
diff --git a/Tests/Entity/AnonymizedPropertyWithoutAnonymizedEntity.php b/Tests/Entity/AnonymizedPropertyWithoutAnonymizedEntity.php
new file mode 100644
index 0000000..a6b3e09
--- /dev/null
+++ b/Tests/Entity/AnonymizedPropertyWithoutAnonymizedEntity.php
@@ -0,0 +1,45 @@
+
+ */
+class AnonymizedPropertyWithoutAnonymizedEntity implements ClassMetadataProviderInterface
+{
+ /**
+ * @var string
+ * @AnonymizedProperty()
+ */
+ private $bar;
+
+ public static function getFieldMappings(): array
+ {
+ return ['bar' => ['fieldName' => 'bar']];
+ }
+
+ public static function getFieldNames(): array
+ {
+ return ['bar' => 'bar'];
+ }
+
+ public static function getAssociationMappings(): array
+ {
+ return [];
+ }
+}
diff --git a/Tests/Entity/ClassMetadataProviderInterface.php b/Tests/Entity/ClassMetadataProviderInterface.php
new file mode 100644
index 0000000..ca4c3a1
--- /dev/null
+++ b/Tests/Entity/ClassMetadataProviderInterface.php
@@ -0,0 +1,29 @@
+
+ */
+interface ClassMetadataProviderInterface
+{
+ public static function getFieldMappings(): array;
+
+ public static function getFieldNames(): array;
+
+ public static function getAssociationMappings(): array;
+}
diff --git a/Tests/Entity/ClassMetadataProviderTrait.php b/Tests/Entity/ClassMetadataProviderTrait.php
new file mode 100644
index 0000000..b408cb5
--- /dev/null
+++ b/Tests/Entity/ClassMetadataProviderTrait.php
@@ -0,0 +1,41 @@
+
+ */
+trait ClassMetadataProviderTrait
+{
+ private function getClassMetadata(string $className): ClassMetadata
+ {
+ if (!is_a($className, ClassMetadataProviderInterface::class, true)) {
+ throw new \InvalidArgumentException(sprintf('Class %s should be an instance of %s', $className,
+ ClassMetadataProviderInterface::class));
+ }
+
+ $classMetadata = new ClassMetadata($className);
+ $classMetadata->fieldMappings = $className::getFieldMappings();
+ $classMetadata->fieldNames = $className::getFieldNames();
+ $classMetadata->associationMappings = $className::getAssociationMappings();
+ $classMetadata->table = ['name' => strtolower((new \ReflectionClass($className))->getShortName())];
+
+ return $classMetadata;
+ }
+}
diff --git a/Tests/Entity/ComposedAnonymizedPropertyWithNotUniqueField.php b/Tests/Entity/ComposedAnonymizedPropertyWithNotUniqueField.php
new file mode 100644
index 0000000..44ed38a
--- /dev/null
+++ b/Tests/Entity/ComposedAnonymizedPropertyWithNotUniqueField.php
@@ -0,0 +1,54 @@
+
+ */
+class ComposedAnonymizedPropertyWithNotUniqueField implements ClassMetadataProviderInterface
+{
+ /**
+ * @var string
+ * @AnonymizedProperty(type="composed", value="test-")
+ */
+ private $bar;
+
+ /**
+ * @var string
+ */
+ private $foo;
+
+ public static function getFieldMappings(): array
+ {
+ return [
+ 'bar' => ['fieldName' => 'bar', 'unique' => true],
+ 'foo' => ['fieldName' => 'foo', 'unique' => false]];
+ }
+
+ public static function getFieldNames(): array
+ {
+ return ['bar' => 'bar', 'foo' => 'foo'];
+ }
+
+ public static function getAssociationMappings(): array
+ {
+ return [];
+ }
+}
diff --git a/Tests/Entity/ComposedAnonymizedPropertyWithUnknownField.php b/Tests/Entity/ComposedAnonymizedPropertyWithUnknownField.php
new file mode 100644
index 0000000..ab37cda
--- /dev/null
+++ b/Tests/Entity/ComposedAnonymizedPropertyWithUnknownField.php
@@ -0,0 +1,47 @@
+
+ */
+class ComposedAnonymizedPropertyWithUnknownField implements ClassMetadataProviderInterface
+{
+ /**
+ * @var string
+ * @AnonymizedProperty(type="composed", value="bar--baz")
+ */
+ private $bar;
+
+ public static function getFieldMappings(): array
+ {
+ return ['bar' => ['fieldName' => 'bar']];
+ }
+
+ public static function getFieldNames(): array
+ {
+ return ['bar' => 'bar'];
+ }
+
+ public static function getAssociationMappings(): array
+ {
+ return [];
+ }
+}
diff --git a/Tests/Entity/ComposedAnonymizedPropertyWithoutComposedField.php b/Tests/Entity/ComposedAnonymizedPropertyWithoutComposedField.php
new file mode 100644
index 0000000..0790365
--- /dev/null
+++ b/Tests/Entity/ComposedAnonymizedPropertyWithoutComposedField.php
@@ -0,0 +1,47 @@
+
+ */
+class ComposedAnonymizedPropertyWithoutComposedField implements ClassMetadataProviderInterface
+{
+ /**
+ * @var string
+ * @AnonymizedProperty(type="composed", value="foo")
+ */
+ private $bar;
+
+ public static function getFieldMappings(): array
+ {
+ return ['bar' => ['fieldName' => 'bar']];
+ }
+
+ public static function getFieldNames(): array
+ {
+ return ['bar' => 'bar'];
+ }
+
+ public static function getAssociationMappings(): array
+ {
+ return [];
+ }
+}
diff --git a/Tests/Entity/Foo.php b/Tests/Entity/Foo.php
new file mode 100644
index 0000000..e2e560c
--- /dev/null
+++ b/Tests/Entity/Foo.php
@@ -0,0 +1,52 @@
+
+ */
+class Foo implements ClassMetadataProviderInterface
+{
+ /**
+ * @var string
+ * @AnonymizedProperty(value="lorem")
+ */
+ private $bar;
+
+ /**
+ * @var string
+ */
+ private $baz;
+
+ public static function getFieldMappings(): array
+ {
+ return ['bar' => ['fieldName' => 'bar'], 'baz' => ['fieldName' => 'baz']];
+ }
+
+ public static function getFieldNames(): array
+ {
+ return ['bar' => 'bar', 'baz' => 'baz'];
+ }
+
+ public static function getAssociationMappings(): array
+ {
+ return [];
+ }
+}
diff --git a/Tests/Entity/UniqueFieldWithoutComposedAnonymizedProperty.php b/Tests/Entity/UniqueFieldWithoutComposedAnonymizedProperty.php
new file mode 100644
index 0000000..4bce3db
--- /dev/null
+++ b/Tests/Entity/UniqueFieldWithoutComposedAnonymizedProperty.php
@@ -0,0 +1,49 @@
+
+ */
+class UniqueFieldWithoutComposedAnonymizedProperty implements ClassMetadataProviderInterface
+{
+ /**
+ * @var string
+ * @AnonymizedProperty(type="static", value="foo")
+ */
+ private $bar;
+
+ public static function getFieldMappings(): array
+ {
+ return [
+ 'bar' => ['fieldName' => 'bar', 'unique' => true]
+ ];
+ }
+
+ public static function getFieldNames(): array
+ {
+ return ['bar' => 'bar'];
+ }
+
+ public static function getAssociationMappings(): array
+ {
+ return [];
+ }
+}
diff --git a/Tests/Entity/ValidAnonymizedEntity.php b/Tests/Entity/ValidAnonymizedEntity.php
new file mode 100644
index 0000000..4e5acfc
--- /dev/null
+++ b/Tests/Entity/ValidAnonymizedEntity.php
@@ -0,0 +1,69 @@
+
+ */
+class ValidAnonymizedEntity implements ClassMetadataProviderInterface
+{
+ /**
+ * @var int
+ */
+ private $id;
+
+ /**
+ * @var string
+ * @AnonymizedProperty(type="static", value="lorem")
+ */
+ private $bar;
+
+ /**
+ * @var string
+ * @AnonymizedProperty(type="composed", value="lorem-")
+ */
+ private $baz;
+
+ /**
+ * @var string
+ * @AnonymizedProperty(type="expression", value="CONCAT(FLOOR(1 + (RAND() * 1000)), id)")
+ */
+ public $foo;
+
+ public static function getFieldMappings(): array
+ {
+ return [
+ 'id' => ['fieldName' => 'id', 'unique' => true],
+ 'bar' => ['fieldName' => 'bar'],
+ 'baz' => ['fieldName' => 'baz', 'unique' => true],
+ 'foo' => ['fieldName' => 'foo'],
+ ];
+ }
+
+ public static function getFieldNames(): array
+ {
+ return ['id' => 'id', 'bar' => 'bar', 'baz' => 'baz', 'foo' => 'foo'];
+ }
+
+ public static function getAssociationMappings(): array
+ {
+ return [];
+ }
+}
diff --git a/Tests/Entity/ValidTruncateAnonymizedEntity.php b/Tests/Entity/ValidTruncateAnonymizedEntity.php
new file mode 100644
index 0000000..0c05b7a
--- /dev/null
+++ b/Tests/Entity/ValidTruncateAnonymizedEntity.php
@@ -0,0 +1,45 @@
+
+ */
+class ValidTruncateAnonymizedEntity implements ClassMetadataProviderInterface
+{
+ /**
+ * @var string
+ */
+ private $bar;
+
+ public static function getFieldMappings(): array
+ {
+ return ['bar' => ['fieldName' => 'bar']];
+ }
+
+ public static function getFieldNames(): array
+ {
+ return ['bar' => 'bar'];
+ }
+
+ public static function getAssociationMappings(): array
+ {
+ return [];
+ }
+}
diff --git a/Tests/Meta/AnonymizedMetadataBuilderTest.php b/Tests/Meta/AnonymizedMetadataBuilderTest.php
new file mode 100644
index 0000000..5dad3ca
--- /dev/null
+++ b/Tests/Meta/AnonymizedMetadataBuilderTest.php
@@ -0,0 +1,102 @@
+
+ */
+class AnonymizedMetadataBuilderTest extends TestCase
+{
+ use ClassMetadataProviderTrait;
+
+ /**
+ * @var AnonymizedMetadataBuilder
+ */
+ private $anonymizedMetadataBuilder;
+
+ /**
+ * @var EntityManager|MockObject
+ */
+ private $entityManager;
+
+ /**
+ * @var AnnotationReader
+ */
+ private $annotationReader;
+
+ /**
+ * @var ClassMetadataFactory|MockObject
+ */
+ private $classMetadataFactory;
+
+ protected function setUp(): void
+ {
+ $this->entityManager = $this->createMock(EntityManager::class);
+ $this->annotationReader = new AnnotationReader();
+ $this->classMetadataFactory = $this->createMock(ClassMetadataFactory::class);
+ $this->entityManager->expects($this->once())->method('getMetadataFactory')
+ ->willReturn($this->classMetadataFactory);
+ $this->anonymizedMetadataBuilder = new AnonymizedMetadataBuilder($this->entityManager, $this->annotationReader);
+ }
+
+ public function testBuildWithoutAnonymizedEntity(): void
+ {
+ $this->expectException(AnnotationException::class);
+ $this->expectExceptionMessage(
+ '[Creation Error] You tried to anonymize a property without specifying it at class level');
+
+ $classMetadata = $this->getClassMetadata(AnonymizedPropertyWithoutAnonymizedEntity::class);
+ $this->classMetadataFactory->expects($this->once())->method('getAllMetadata')->willReturn([$classMetadata]);
+
+ $this->anonymizedMetadataBuilder->build()->current();
+ }
+
+ public function testBuild(): void
+ {
+ $classMetadata = $this->getClassMetadata(Foo::class);
+ $this->classMetadataFactory->expects($this->once())->method('getAllMetadata')->willReturn([$classMetadata]);
+
+ /** @var \Generator $anonymizedMetadatas */
+ $anonymizedMetadatas = $this->anonymizedMetadataBuilder->build();
+ foreach ($anonymizedMetadatas as $anonymizedMetadata) {
+ $this->assertNotNull($anonymizedMetadata->getAnonymizedEntity());
+ $this->assertInstanceOf(ClassMetadata::class, $anonymizedMetadata->getClassMetadata());
+
+ /** @var AnonymizedProperty[] $anonymizedProperties */
+ $anonymizedProperties = $anonymizedMetadata->getAnonymizedProperties();
+ $this->assertCount(1, $anonymizedProperties);
+ $anonymizedProperty = $anonymizedProperties[0];
+ $this->assertSame('lorem', $anonymizedProperty->getValue());
+ $this->assertSame(AnonymizedProperty::TYPE_STATIC, $anonymizedProperty->getType());
+ $this->assertSame('bar', $anonymizedProperty->getColumnName());
+ $this->assertSame('bar', $anonymizedProperty->getFieldName());
+ }
+ }
+}
diff --git a/Tests/Meta/AnonymizedMetadataProviderTrait.php b/Tests/Meta/AnonymizedMetadataProviderTrait.php
new file mode 100644
index 0000000..deda31b
--- /dev/null
+++ b/Tests/Meta/AnonymizedMetadataProviderTrait.php
@@ -0,0 +1,49 @@
+
+ */
+trait AnonymizedMetadataProviderTrait
+{
+ use ClassMetadataProviderTrait;
+
+ private function getAnonymizedMetadata(string $className): AnonymizedMetadata
+ {
+ $classMetadata = $this->getClassMetadata($className);
+ $entityManager = $this->createMock(EntityManager::class);
+ $annotationReader = new AnnotationReader();
+ $classMetadataFactory = $this->createMock(ClassMetadataFactory::class);
+ $classMetadataFactory->expects($this->once())->method('getAllMetadata')
+ ->willReturn([$classMetadata]);
+ $entityManager->expects($this->once())->method('getMetadataFactory')
+ ->willReturn($classMetadataFactory);
+ $anonymizedMetadataBuilder = new AnonymizedMetadataBuilder($entityManager, $annotationReader);
+
+ $anonymizedMetadatas = $anonymizedMetadataBuilder->build();
+
+ return $anonymizedMetadatas->current();
+ }
+}
diff --git a/Tests/Meta/AnonymizedMetadataValidatorTest.php b/Tests/Meta/AnonymizedMetadataValidatorTest.php
new file mode 100644
index 0000000..7aebcad
--- /dev/null
+++ b/Tests/Meta/AnonymizedMetadataValidatorTest.php
@@ -0,0 +1,108 @@
+
+ */
+class AnonymizedMetadataValidatorTest extends TestCase
+{
+ use AnonymizedMetadataProviderTrait;
+
+ /**
+ * @var AnonymizedMetadataValidator
+ */
+ private $anonymizedMetadataValidator;
+
+ protected function setUp(): void
+ {
+ $this->anonymizedMetadataValidator = new AnonymizedMetadataValidator();
+ }
+
+ /**
+ * @dataProvider getInvalidConfigirations
+ */
+ public function testWithInvalidConfiguration(string $exceptionClass, string $exceptionMessage, string $entityName): void
+ {
+ $this->expectException($exceptionClass);
+ $this->expectExceptionMessage($exceptionMessage);
+
+ $this->anonymizedMetadataValidator->validate($this->getAnonymizedMetadata($entityName));
+ }
+
+ public function getInvalidConfigirations(): \Generator
+ {
+ yield 'testValidateWithAnonymizedPropertiesAndMissingAnonymizedEntity' => [
+ AnnotationException::class,
+ '[Creation Error] You tried to anonymize a property without specifying it at class level',
+ AnonymizedPropertyWithoutAnonymizedEntity::class
+ ];
+ yield 'testValidateWithAnonymizedPropertiesAndTruncateAnonymizedEntityAction' => [
+ AnnotationException::class,
+ sprintf('[Creation Error] If truncate action is set at class level, it can\'t have property annotation in %s',
+ AnonymizedPropertiesAndTruncateAnonymizedEntityAction::class),
+ AnonymizedPropertiesAndTruncateAnonymizedEntityAction::class
+ ];
+ yield 'testValidateWithAnonymizedPropertyOnAssociationField' => [
+ AnnotationException::class,
+ sprintf('[Creation Error] Anonymization of associations (bar) is not supported in %s',
+ AnonymizedPropertyOnAssociationField::class),
+ AnonymizedPropertyOnAssociationField::class
+ ];
+ yield 'testValidatePropertyIsNullableOnNullValue' => [
+ AnnotationException::class,
+ sprintf('[Creation Error] Property bar is supposed to be anonymized to null value but is not nullable in %s',
+ AnonymizedPropertyNullOnNotNullableProperty::class),
+ AnonymizedPropertyNullOnNotNullableProperty::class
+ ];
+ yield 'testValidateComposedAnonymizedPropertyWithoutComposedField' => [
+ AnnotationException::class,
+ sprintf('[Creation Error] No composed field specified in composed expression of bar property in %s',
+ ComposedAnonymizedPropertyWithoutComposedField::class),
+ ComposedAnonymizedPropertyWithoutComposedField::class
+ ];
+ yield 'testValidateComposedAnonymizedPropertyWithUnknownField' => [
+ AnnotationException::class,
+ sprintf('[Creation Error] Property foo specified in composed expression of bar does not exists in %s',
+ ComposedAnonymizedPropertyWithUnknownField::class),
+ ComposedAnonymizedPropertyWithUnknownField::class
+ ];
+ yield 'testValidateComposedAnonymizedPropertyWithNotUniqueField' => [
+ AnnotationException::class,
+ sprintf('[Creation Error] If property is unique (bar), composed field foo must be unique to avoid duplicate potential value in %s',
+ ComposedAnonymizedPropertyWithNotUniqueField::class),
+ ComposedAnonymizedPropertyWithNotUniqueField::class
+ ];
+ yield 'testValidateUniqueFieldWithoutComposedAnonymizedProperty' => [
+ AnnotationException::class,
+ sprintf('[Creation Error] If property is unique (bar), AnonymzedProperty must be of type composed in %s',
+ UniqueFieldWithoutComposedAnonymizedProperty::class),
+ UniqueFieldWithoutComposedAnonymizedProperty::class
+ ];
+ }
+}
diff --git a/Tests/QueryBuilder/AnonymizedQueryBuilderTest.php b/Tests/QueryBuilder/AnonymizedQueryBuilderTest.php
new file mode 100644
index 0000000..e7a5cb0
--- /dev/null
+++ b/Tests/QueryBuilder/AnonymizedQueryBuilderTest.php
@@ -0,0 +1,67 @@
+
+ */
+class AnonymizedQueryBuilderTest extends TestCase
+{
+ use AnonymizedMetadataProviderTrait;
+
+ /**
+ * @var AnonymizedQueryBuilder
+ */
+ private $anonymizedQueryBuilder;
+
+ protected function setUp(): void
+ {
+ $this->anonymizedQueryBuilder = new AnonymizedQueryBuilder();
+ }
+
+ /**
+ * @dataProvider getAnonymizedMetadatas
+ */
+ public function testBuildQuery(string $entityName, string $expectedQuery): void
+ {
+ $this->assertSame($expectedQuery,
+ $this->anonymizedQueryBuilder->buildQuery($this->getAnonymizedMetadata($entityName))
+ );
+ }
+
+ public function getAnonymizedMetadatas(): \Generator
+ {
+ yield 'Foo' => [
+ Foo::class,
+ 'UPDATE foo SET bar = "lorem"'
+ ];
+ yield 'ValidTruncateAnonymizedEntity' => [
+ ValidTruncateAnonymizedEntity::class,
+ 'DELETE FROM validtruncateanonymizedentity'
+ ];
+ yield 'ValidAnonymizedEntity' => [
+ ValidAnonymizedEntity::class,
+ 'UPDATE validanonymizedentity SET bar = "lorem", baz = concat(concat("lorem-", id), ""), foo = CONCAT(FLOOR(1 + (RAND() * 1000)), id) WHERE foo NOT LIKE \'%bar%\''
+ ];
+ }
+}
diff --git a/composer.json b/composer.json
index c2991a1..7e3c23a 100644
--- a/composer.json
+++ b/composer.json
@@ -16,6 +16,7 @@
"php": "^7.2",
"ext-json": "*",
"ext-openssl": "*",
+ "doctrine/orm": "^2.6",
"monolog/monolog": "~1.24|~2.0",
"symfony/config": "~3.3|~4.4",
"symfony/console": "~3.3|~4.4",