├── .gitignore
├── src
├── Exception
│ ├── AbstractException.php
│ └── InvalidArgumentException.php
├── Transformable
│ ├── TransformableState.php
│ ├── TransformerMethod.php
│ ├── Mapping
│ │ └── Driver
│ │ │ ├── Attribute.php
│ │ │ └── Annotation.php
│ ├── Transformer
│ │ ├── TransformerInterface.php
│ │ ├── PhpHashTransformer.php
│ │ ├── AbstractHmacTransformer.php
│ │ ├── PhpHmacTransformer.php
│ │ ├── LaminasCryptHashTransformer.php
│ │ ├── LaminasCryptHmacTransformer.php
│ │ ├── AbstractHashTransformer.php
│ │ ├── LaminasCryptSymmetricTransformer.php
│ │ ├── TransformerPool.php
│ │ ├── DefuseCryptoEncryptKeyTransformer.php
│ │ └── HaliteSymmetricTransformer.php
│ └── TransformableSubscriber.php
└── Mapping
│ └── Transformable.php
├── Makefile
├── Dockerfile
├── doc
└── transformable
│ ├── attributes.rst
│ ├── annotations.rst
│ ├── libsodium-halite.rst
│ └── exampe-symfony.rst
├── tests
├── bootstrap.php
├── Transformable
│ ├── Transformer
│ │ ├── PhpHmacTransformerTest.php
│ │ ├── LaminasCryptHmacTransformerTest.php
│ │ ├── PhpHashTransformerTest.php
│ │ ├── TransformerPoolTest.php
│ │ ├── LaminasCryptHashTransformerTest.php
│ │ ├── HaliteSymmetricTransformerTest.php
│ │ ├── LaminasCryptSymmetricTransformerTest.php
│ │ └── DefuseCryptoEncryptKeyTransformerTest.php
│ ├── Fixture
│ │ └── Test.php
│ ├── TransformableSubscriberTest.php
│ └── TransformableTest.php
└── Tool
│ └── BaseTestCaseORM.php
├── phpunit.xml
├── LICENSE
├── .github
└── workflows
│ └── continuous-integration.yml
├── composer.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor/
2 | /composer.lock
3 | .coverage
4 |
5 | .phpunit.result.cache
6 | .phpunit.cache
7 | .idea
8 |
--------------------------------------------------------------------------------
/src/Exception/AbstractException.php:
--------------------------------------------------------------------------------
1 | getAlgorithm(), $value, $this->getBinary());
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/doc/transformable/attributes.rst:
--------------------------------------------------------------------------------
1 | Annotations
2 | ===========
3 |
4 | .. code-block:: php
5 |
6 | ')]
18 | protected $bar;
19 |
--------------------------------------------------------------------------------
/src/Transformable/Transformer/AbstractHmacTransformer.php:
--------------------------------------------------------------------------------
1 | key;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/Transformable/Transformer/PhpHmacTransformer.php:
--------------------------------------------------------------------------------
1 | getAlgorithm(), $value, $this->getKey(), $this->getBinary());
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/Transformable/Transformer/LaminasCryptHashTransformer.php:
--------------------------------------------------------------------------------
1 | getAlgorithm(), $value, $this->getBinary());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/doc/transformable/annotations.rst:
--------------------------------------------------------------------------------
1 | Annotations
2 | ===========
3 |
4 | .. code-block:: php
5 |
6 | ")
21 | */
22 | protected $bar;
23 |
--------------------------------------------------------------------------------
/src/Transformable/Transformer/LaminasCryptHmacTransformer.php:
--------------------------------------------------------------------------------
1 | getKey(), $this->algorithm, $value, $this->getBinary());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Mapping/Transformable.php:
--------------------------------------------------------------------------------
1 | name = $data['name'] ?? $name;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/tests/bootstrap.php:
--------------------------------------------------------------------------------
1 | add('Tool', __DIR__ . '/../vendor/gedmo/doctrine-extensions/tests/Gedmo/Tool');
13 | $loader->add('Transformable\\Fixture', __DIR__ . '/src');
14 |
15 | $reader = new AnnotationReader();
16 | $reader = new PsrCachedReader($reader, new ArrayAdapterAlias());
17 | $_ENV['annotation_reader'] = $reader;
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
15 | ./
16 |
17 |
18 | ./tests
19 | ./vendor
20 |
21 |
22 |
23 |
24 | ./tests/Transformable/
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/doc/transformable/libsodium-halite.rst:
--------------------------------------------------------------------------------
1 | Libsodium & Halite
2 | ===========
3 |
4 | PHP 7+ comes with libsodium included as `sodium`
5 | Halite (Halite is a high-level cryptography interface) https://github.com/paragonie/halite
6 |
7 | When you configure the LibsodiumCryptHashTransformer you need to generate an index key:
8 | You would use the hash transformer for blind indexes (searchable versions of encrypted fields).
9 |
10 | High entropy or low entropy?
11 | - If we have high-entropy input we can just use shared-key authentication (crypto_auth, it uses HMAC).
12 | - If we have low-entropy input eg. SSN, phone numbers, emails. We must use a password hashing function (Argon2).
13 | Our second key becomes a salt.
14 |
15 | source: https://www.youtube.com/watch?v=Q2xGy3AGGSo&t=475s
16 |
17 | For the HaliteSymmetricTransformer you need to generate an encryption key:
18 |
19 | .. code-block:: php
20 |
21 | setOptions($options);
14 | }
15 |
16 | protected function setOptions(array $options)
17 | {
18 | if (array_key_exists('algorithm', $options)) {
19 | $this->algorithm = $options['algorithm'];
20 | }
21 |
22 | if (array_key_exists('binary', $options)) {
23 | $this->binary = $options['binary'];
24 | }
25 | }
26 |
27 | public function getAlgorithm(): string
28 | {
29 | return $this->algorithm;
30 | }
31 |
32 | public function getBinary(): bool
33 | {
34 | return $this->binary;
35 | }
36 |
37 | public abstract function transform(?string $value): string|bool;
38 |
39 | public function reverseTransform(?string $value): string|null
40 | {
41 | return $value;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/tests/Transformable/Transformer/PhpHmacTransformerTest.php:
--------------------------------------------------------------------------------
1 | transformer = new PhpHmacTransformer(self::KEY, [
22 | 'algorithm' => self::ALGORITHM,
23 | 'binary' => self::BINARY
24 | ]);
25 | }
26 |
27 | public function testTransform(): void
28 | {
29 | $this->assertEquals(
30 | hash_hmac(self::ALGORITHM, self::VALUE, self::KEY, self::BINARY),
31 | $this->transformer->transform(self::VALUE)
32 | );
33 | }
34 |
35 | public function testReverseTransform(): void
36 | {
37 | $this->assertEquals(self::VALUE, $this->transformer->reverseTransform(self::VALUE));
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/Transformable/Transformer/LaminasCryptHmacTransformerTest.php:
--------------------------------------------------------------------------------
1 | transformer = new LaminasCryptHmacTransformer(self::KEY, [
22 | 'algorithm' => self::ALGORITHM,
23 | 'binary' => self::BINARY
24 | ]);
25 | }
26 |
27 | public function testTransform(): void
28 | {
29 | $this->assertEquals(
30 | Hmac::compute(self::KEY, self::ALGORITHM, self::VALUE, self::BINARY),
31 | $this->transformer->transform(self::VALUE)
32 | );
33 | }
34 |
35 | public function testReverseTransform(): void
36 | {
37 | $this->assertEquals(self::VALUE, $this->transformer->reverseTransform(self::VALUE));
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Transformable/Transformer/LaminasCryptSymmetricTransformer.php:
--------------------------------------------------------------------------------
1 | crypt = BlockCipher::factory('openssl', $options['encryption_options'] ?? ['algo' => $this->defaultAlgo]);
16 | $this->crypt->setKey($key);
17 | $this->crypt->setBinaryOutput(true);
18 |
19 | $this->setOptions($options);
20 | }
21 |
22 | protected function setOptions(array $options)
23 | {
24 | if (array_key_exists('binary', $options)) {
25 | $this->crypt->setBinaryOutput((bool)$options['binary']);
26 | }
27 | }
28 |
29 | public function getBinary(): bool
30 | {
31 | return $this->crypt->getBinaryOutput();
32 | }
33 |
34 | public function transform(?string $value): string|bool
35 | {
36 | if (empty($value)) {
37 | return false;
38 | }
39 |
40 | return $this->crypt->encrypt($value);
41 | }
42 |
43 | public function reverseTransform(?string $value): bool|string|null
44 | {
45 | if ($value === null) {
46 | return null;
47 | }
48 |
49 | return $this->crypt->decrypt($value);
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/tests/Transformable/Fixture/Test.php:
--------------------------------------------------------------------------------
1 | id;
44 | }
45 |
46 | public function getValue(): ?string
47 | {
48 | return $this->value;
49 | }
50 |
51 | public function setValue(?string $value): void
52 | {
53 | $this->value = $value;
54 | }
55 |
56 | public function isUpdated(): bool
57 | {
58 | return $this->updated;
59 | }
60 |
61 | public function setUpdated(bool $updated): void
62 | {
63 | $this->updated = $updated;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/tests/Transformable/TransformableSubscriberTest.php:
--------------------------------------------------------------------------------
1 | shouldReceive('transform')->andReturn(self::VALUE);
20 | $transformer->shouldReceive('reverseTransform')->andReturn(self::VALUE);
21 |
22 | $transformerPool = m::mock('MediaMonks\Doctrine\Transformable\Transformer\TransformerPool');
23 | $transformerPool->shouldReceive('get')->andReturn($transformer);
24 |
25 | $this->transformableSubscriber = new TransformableSubscriber($transformerPool);
26 | }
27 |
28 | public function testGetSubscribedEvents(): void
29 | {
30 | $subscribedEvents = $this->transformableSubscriber->getSubscribedEvents();
31 |
32 | $this->assertContains(Events::onFlush, $subscribedEvents);
33 | $this->assertContains(Events::postPersist, $subscribedEvents);
34 | $this->assertContains(Events::postLoad, $subscribedEvents);
35 | $this->assertContains(Events::postUpdate, $subscribedEvents);
36 | }
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/src/Transformable/Transformer/TransformerPool.php:
--------------------------------------------------------------------------------
1 | offsetGet($name);
19 | }
20 |
21 | /**
22 | * @throws Exception
23 | */
24 | public function set(string $name, TransformerInterface $transformer): TransformerPool
25 | {
26 | $this->offsetSet($name, $transformer);
27 | return $this;
28 | }
29 |
30 | public function offsetExists(mixed $name): bool
31 | {
32 | return array_key_exists($name, $this->transformers);
33 | }
34 |
35 | /**
36 | * @throws Exception
37 | */
38 | public function offsetGet(mixed $name): ?TransformerInterface
39 | {
40 | if (!$this->offsetExists($name)) {
41 | throw new InvalidArgumentException(sprintf('Transformer with name "%s" is not set', $name));
42 | }
43 |
44 | return $this->transformers[$name];
45 | }
46 |
47 | /**
48 | * @throws Exception
49 | */
50 | public function offsetSet(mixed $key, mixed $transformer): void
51 | {
52 | $this->transformers[$key] = $transformer;
53 | }
54 |
55 | public function offsetUnset(mixed $name): void
56 | {
57 | if ($this->offsetExists($name)) {
58 | unset($this->transformers[$name]);
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/.github/workflows/continuous-integration.yml:
--------------------------------------------------------------------------------
1 | name: "CI"
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - master
8 |
9 | env:
10 | COMPOSER_ROOT_VERSION: "1.99.99"
11 |
12 | jobs:
13 | coverage:
14 | name: "Coverage"
15 | runs-on: "ubuntu-latest"
16 | steps:
17 | - uses: "actions/checkout@v3"
18 | - uses: "shivammathur/setup-php@v2"
19 | with:
20 | php-version: "latest"
21 | coverage: "pcov"
22 | ini-values: "memory_limit=-1, zend.assertions=1, error_reporting=-1, display_errors=On"
23 | tools: "composer"
24 | - name: "Prepare for tests"
25 | run: "mkdir -p build/logs"
26 | - uses: "ramsey/composer-install@v2"
27 | - name: "Run unit tests"
28 | run: "./vendor/bin/phpunit --colors=always --coverage-clover build/logs/clover.xml --coverage-text"
29 | - name: "Publish coverage report to Codecov"
30 | uses: "codecov/codecov-action@v3"
31 |
32 | unit-tests:
33 | name: "Unit Tests"
34 | runs-on: "ubuntu-latest"
35 | strategy:
36 | fail-fast: false
37 | matrix:
38 | php-version: [ "8.1", "8.2" ]
39 | steps:
40 | - uses: "actions/checkout@v3"
41 | - uses: "shivammathur/setup-php@v2"
42 | with:
43 | php-version: "${{ matrix.php-version }}"
44 | coverage: "none"
45 | ini-values: "memory_limit=-1, zend.assertions=1, error_reporting=-1, display_errors=On"
46 | tools: "composer"
47 | - name: "Prepare for tests"
48 | run: "mkdir -p build/logs"
49 | - uses: "ramsey/composer-install@v2"
50 | - name: "Run unit tests"
51 | run: "./vendor/bin/phpunit --colors=always"
--------------------------------------------------------------------------------
/tests/Transformable/Transformer/PhpHashTransformerTest.php:
--------------------------------------------------------------------------------
1 | transformer = new PhpHashTransformer(['algorithm' => self::ALGORITHM, 'binary' => false]);
21 | }
22 |
23 | public function testChangeAlgorithm(): void
24 | {
25 | $transformer = new PhpHashTransformer(['algorithm' => self::ALGORITHM_ALTERNATIVE]);
26 | $this->assertEquals(self::ALGORITHM_ALTERNATIVE, $transformer->getAlgorithm());
27 | }
28 |
29 | public function testBinaryDefaultEnabled(): void
30 | {
31 | $transformer = new PhpHashTransformer();
32 | $this->assertTrue($transformer->getBinary());
33 | }
34 |
35 | public function testDisableBinary(): void
36 | {
37 | $transformer = new PhpHashTransformer(['binary' => false]);
38 | $this->assertFalse($transformer->getBinary());
39 | }
40 |
41 | public function testTransformHex(): void
42 | {
43 | $this->assertEquals(hash(self::ALGORITHM, self::VALUE_HEX), $this->transformer->transform(self::VALUE_HEX));
44 | }
45 |
46 | public function testReverseTransformHex(): void
47 | {
48 | $this->assertEquals(self::VALUE_HEX, $this->transformer->reverseTransform(self::VALUE_HEX));
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/tests/Transformable/Transformer/TransformerPoolTest.php:
--------------------------------------------------------------------------------
1 | transformerPool = new TransformerPool();
20 | $this->transformer = m::mock(TransformerInterface::class);
21 |
22 | try {
23 | $this->transformerPool->set($this->transformerKey, $this->transformer);
24 | } catch (Throwable $e) {
25 | $this->fail($e->getMessage());
26 | }
27 | }
28 |
29 | public function testSetGet(): void
30 | {
31 | $this->assertEquals($this->transformer, $this->transformerPool->get($this->transformerKey));
32 | }
33 |
34 | public function testExists(): void
35 | {
36 | $this->assertTrue($this->transformerPool->offsetExists($this->transformerKey));
37 | }
38 |
39 | public function testUnset(): void
40 | {
41 | unset($this->transformerPool[$this->transformerKey]);
42 | $this->assertFalse($this->transformerPool->offsetExists($this->transformerKey));
43 | }
44 |
45 | public function testInvalidArgumentExceptionThrownOnNonExistingTransformer(): void
46 | {
47 | $this->expectException('MediaMonks\Doctrine\Exception\InvalidArgumentException');
48 | $this->transformerPool->get('non_existing_transformer');
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Transformable/Mapping/Driver/Annotation.php:
--------------------------------------------------------------------------------
1 |
13 | */
14 | class Annotation extends AbstractAnnotationDriver
15 | {
16 | const TRANSFORMABLE = Transformable::class;
17 |
18 | /**
19 | * {@inheritDoc}
20 | */
21 | public function readExtendedMetadata($meta, array &$config): void
22 | {
23 | $class = $this->getMetaReflectionClass($meta);
24 | foreach ($class->getProperties() as $property) {
25 | if ($this->isInherited($meta, $property)) {
26 | continue;
27 | }
28 |
29 | if ($transformable = $this->reader->getPropertyAnnotation($property, self::TRANSFORMABLE)) {
30 | $config['transformable'][] = $this->getConfig($property, $transformable);
31 | }
32 | }
33 | }
34 |
35 | protected function isInherited(ClassMetadata $meta, ReflectionProperty $property): bool
36 | {
37 | return ($meta->isMappedSuperclass && !$property->isPrivate()
38 | || $meta->isInheritedField($property->name)
39 | || isset($meta->associationMappings[$property->name]['inherited'])
40 | );
41 | }
42 |
43 | #[ArrayShape(['field' => "string", 'name' => "string"])]
44 | protected function getConfig(ReflectionProperty $property, Transformable $transformable): array
45 | {
46 | return [
47 | 'field' => $property->getName(),
48 | 'name' => $transformable->name
49 | ];
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/tests/Transformable/Transformer/LaminasCryptHashTransformerTest.php:
--------------------------------------------------------------------------------
1 | transformer = new LaminasCryptHashTransformer(['algorithm' => self::ALGORITHM, 'binary' => false]);
21 | }
22 |
23 | public function testChangeAlgorithm(): void
24 | {
25 | $transformer = new LaminasCryptHashTransformer(['algorithm' => self::ALGORITHM_ALTERNATIVE]);
26 | $this->assertEquals(self::ALGORITHM_ALTERNATIVE, $transformer->getAlgorithm());
27 | }
28 |
29 | public function testBinaryDefaultEnabled(): void
30 | {
31 | $transformer = new LaminasCryptHashTransformer();
32 | $this->assertTrue($transformer->getBinary());
33 | }
34 |
35 | public function testDisableBinary(): void
36 | {
37 | $transformer = new LaminasCryptHashTransformer(['binary' => false]);
38 | $this->assertFalse($transformer->getBinary());
39 | }
40 |
41 | public function testTransformHex(): void
42 | {
43 | $this->assertEquals(Hash::compute($this->transformer->getAlgorithm(), self::VALUE_HEX, $this->transformer->getBinary()), $this->transformer->transform(self::VALUE_HEX));
44 | }
45 |
46 | public function testReverseTransformHex(): void
47 | {
48 | $this->assertEquals(self::VALUE_HEX, $this->transformer->reverseTransform(self::VALUE_HEX));
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Transformable/Transformer/DefuseCryptoEncryptKeyTransformer.php:
--------------------------------------------------------------------------------
1 | setOptions($options);
15 | }
16 |
17 | protected function setOptions(array $options)
18 | {
19 | if (array_key_exists('binary', $options)) {
20 | $this->binary = $options['binary'];
21 | }
22 | }
23 |
24 | /**
25 | * @throws \Defuse\Crypto\Exception\BadFormatException
26 | * @throws \Defuse\Crypto\Exception\EnvironmentIsBrokenException
27 | */
28 | public function getKey(): Key
29 | {
30 | return Key::loadFromAsciiSafeString($this->encryptionKey);
31 | }
32 |
33 | public function getBinary(): bool
34 | {
35 | return $this->binary;
36 | }
37 |
38 | /**
39 | * @throws \Defuse\Crypto\Exception\BadFormatException
40 | * @throws \Defuse\Crypto\Exception\EnvironmentIsBrokenException
41 | */
42 | public function transform(?string $value): string
43 | {
44 | if (empty($value)) {
45 | return false;
46 | }
47 |
48 | return Crypto::encrypt($value, $this->getKey(), $this->getBinary());
49 | }
50 |
51 | /**
52 | * @throws \Defuse\Crypto\Exception\BadFormatException
53 | * @throws \Defuse\Crypto\Exception\EnvironmentIsBrokenException
54 | * @throws \Defuse\Crypto\Exception\WrongKeyOrModifiedCiphertextException
55 | */
56 | public function reverseTransform(?string $value): string|null
57 | {
58 | if (empty($value)) {
59 | return null;
60 | }
61 |
62 | return Crypto::decrypt($value, $this->getKey(), $this->getBinary());
63 | }
64 |
65 | }
66 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mediamonks/doctrine-extensions",
3 | "type": "library",
4 | "description": "Doctrine2 behavioral extensions which allows to transform (encrypt, decrypt, hash) your data automatically",
5 | "keywords": [
6 | "doctrine",
7 | "extensions",
8 | "transform",
9 | "transformable",
10 | "encrypt",
11 | "decrypt",
12 | "encryptable",
13 | "hash",
14 | "hashable",
15 | "hmac",
16 | "crypt",
17 | "cryptable"
18 | ],
19 | "homepage": "https://www.mediamonks.com/",
20 | "license": "MIT",
21 | "authors": [
22 | {
23 | "name": "Robert Slootjes",
24 | "email": "robert@mediamonks.com"
25 | },
26 | {
27 | "name": "Bas Bloembergen",
28 | "email": "basb@mediamonks.com"
29 | },
30 | {
31 | "name": "Edwin Luijten",
32 | "email": "edwin@mediamonks.com"
33 | }
34 | ],
35 | "require": {
36 | "php": ">=8.1",
37 | "gedmo/doctrine-extensions": "^3.0"
38 | },
39 | "require-dev": {
40 | "defuse/php-encryption": "^2.0",
41 | "laminas/laminas-crypt": "^3.10",
42 | "phpunit/phpunit": "^9.5",
43 | "paragonie/halite": "^v5.0",
44 | "mockery/mockery": "^1.4",
45 | "doctrine/orm": ">=2.5",
46 | "doctrine/common": ">=2.5"
47 | },
48 | "suggest": {
49 | "doctrine/orm": "to use the extensions with the ORM",
50 | "defuse/php-encryption": "to use the Defuse Crypto transformers",
51 | "laminas/laminas-crypt": "to use the Zend Crypt transformers",
52 | "paragonie/halite": "to use the Libsodium Crypto transformers"
53 | },
54 | "autoload": {
55 | "psr-4": {
56 | "MediaMonks\\Doctrine\\": "src"
57 | }
58 | },
59 | "autoload-dev": {
60 | "psr-4": {
61 | "MediaMonks\\Doctrine\\Tests\\": "tests"
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/tests/Transformable/Transformer/HaliteSymmetricTransformerTest.php:
--------------------------------------------------------------------------------
1 | markTestSkipped('Libsodium not installed');
19 | }
20 | KeyFactory::save(
21 | KeyFactory::generateEncryptionKey(),
22 | self::ENCRYPTION_KEY_PATH
23 | );
24 | }
25 |
26 | protected function tearDown(): void
27 | {
28 | unlink(self::ENCRYPTION_KEY_PATH);
29 | }
30 |
31 | protected function getTransformerHex(): HaliteSymmetricTransformer
32 | {
33 | try {
34 | return new HaliteSymmetricTransformer(self::ENCRYPTION_KEY_PATH, ['binary' => false]);
35 | } catch (Throwable $e) {
36 | $this->fail($e->getMessage());
37 | }
38 | }
39 |
40 | protected function getTransformerBinary(): HaliteSymmetricTransformer
41 | {
42 | return new HaliteSymmetricTransformer(self::ENCRYPTION_KEY_PATH);
43 | }
44 |
45 | public function testBinaryDefaultEnabled(): void
46 | {
47 | $transformer = new HaliteSymmetricTransformer(self::ENCRYPTION_KEY_PATH);
48 | $this->assertTrue($transformer->getBinary());
49 | }
50 |
51 | public function testTransformHex(): void
52 | {
53 | try {
54 | $x = $this->getTransformerHex()->transform(self::VALUE_TO_ENCRYPT);
55 | $y = $this->getTransformerHex()->reverseTransform($x);
56 | } catch (Throwable $e) {
57 | $this->fail($e->getMessage());
58 | }
59 | $this->assertEquals(self::VALUE_TO_ENCRYPT, $y);
60 | }
61 |
62 | public function testTransformNullValue(): void
63 | {
64 | try {
65 | $x = $this->getTransformerHex()->transform(null);
66 | $y = $this->getTransformerHex()->reverseTransform($x);
67 | } catch (Throwable $e) {
68 | $this->fail($e->getMessage());
69 | }
70 | $this->assertEquals(null, $y);
71 | }
72 |
73 | public function testTransformBinary(): void
74 | {
75 | try {
76 | $x = $this->getTransformerBinary()->transform(self::VALUE_TO_ENCRYPT);
77 | $y = $this->getTransformerBinary()->reverseTransform($x);
78 | } catch (Throwable $e) {
79 | $this->fail($e->getMessage());
80 | }
81 | $this->assertEquals(self::VALUE_TO_ENCRYPT, $y);
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/tests/Transformable/Transformer/LaminasCryptSymmetricTransformerTest.php:
--------------------------------------------------------------------------------
1 | false]);
17 | }
18 |
19 | protected function getTransformerBinary(): LaminasCryptSymmetricTransformer
20 | {
21 | return new LaminasCryptSymmetricTransformer(self::KEY);
22 | }
23 |
24 | public function testBinaryDefaultEnabled(): void
25 | {
26 | $this->assertTrue($this->getTransformerBinary()->getBinary());
27 | }
28 |
29 | public function testDisableBinary(): void
30 | {
31 | $this->assertFalse($this->getTransformerHex()->getBinary());
32 | }
33 |
34 | public function testTransformHex(): void
35 | {
36 | $encrypted = $this->getTransformerHex()->transform(self::VALUE_HEX);
37 | $this->assertEquals(self::VALUE_HEX, $this->getTransformerHex()->reverseTransform($encrypted));
38 | }
39 |
40 | public function testReverseTransformHex(): void
41 | {
42 | $encrypted = $this->getTransformerHex()->transform(self::VALUE_HEX);
43 | $this->assertEquals(self::VALUE_HEX, $this->getTransformerHex()->reverseTransform($encrypted));
44 | }
45 |
46 | public function testTransformReverseTransformHex(): void
47 | {
48 | $transformer = $this->getTransformerHex();
49 | $this->assertEquals(self::VALUE_HEX, $transformer->reverseTransform($transformer->transform(self::VALUE_HEX)));
50 | }
51 |
52 | public function testTransformBinary(): void
53 | {
54 | $encrypted = $this->getTransformerBinary()->transform(self::VALUE_BINARY);
55 | $this->assertEquals(hex2bin(bin2hex(self::VALUE_BINARY)), $this->getTransformerBinary()->reverseTransform($encrypted));
56 | }
57 |
58 | public function testReverseTransformBinary(): void
59 | {
60 | $encrypted = $this->getTransformerBinary()->transform(self::VALUE_BINARY);
61 | $this->assertEquals(self::VALUE_BINARY, $this->getTransformerBinary()->reverseTransform($encrypted));
62 | }
63 |
64 | public function testTransformReverseTransformBinary(): void
65 | {
66 | $this->assertEquals(self::VALUE_BINARY, $this->getTransformerBinary()->reverseTransform($this->getTransformerBinary()->transform(self::VALUE_BINARY)));
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/doc/transformable/exampe-symfony.rst:
--------------------------------------------------------------------------------
1 | Example Symfony
2 | ===============
3 |
4 | Configure services:
5 |
6 | .. code-block:: yaml
7 |
8 | # app/config/parameters.yml
9 | parameters:
10 | defuse_encryption_key: def000008728e..........3672cb0efd
11 | laminas_encryption_key: a_key_stronger_than_this_example
12 | hmac_key: a_key_stronger_than_this_example
13 |
14 | services:
15 | mediamonks.doctrine.transformable.transformer.defuse_encrypt_key:
16 | class: MediaMonks\Doctrine\Transformable\Transformer\DefuseCryptoEncryptKeyTransformer
17 | arguments: ["@defuse_encryption_key"]
18 |
19 | mediamonks.doctrine.transformable.transformer.laminas_crypt_symmetric:
20 | class: MediaMonks\Doctrine\Transformable\Transformer\LaminasCryptSymmetricTransformer
21 | arguments: ["%laminas_encryption_key%"]
22 |
23 | mediamonks.doctrine.transformable.transformer.laminas_crypt_hash:
24 | class: MediaMonks\Doctrine\Transformable\Transformer\LaminasCryptHashTransformer
25 |
26 | mediamonks.doctrine.transformable.transformer.laminas_crypt_hmac:
27 | class: MediaMonks\Doctrine\Transformable\Transformer\LaminasCryptHmacTransformer
28 | arguments: ["%hmac_key%"]
29 |
30 | mediamonks.doctrine.transformable.transformer_pool:
31 | class: MediaMonks\Doctrine\Transformable\Transformer\TransformerPool
32 | calls:
33 | - [set, ['defuse_encrypt_key', "@mediamonks.doctrine.transformable.transformer.defuse_encrypt_key"]]
34 | - [set, ['laminas_encrypt', "@mediamonks.doctrine.transformable.transformer.laminas_crypt_symmetric"]]
35 | - [set, ['laminas_hash', "@mediamonks.doctrine.transformable.transformer.laminas_crypt_hash"]]
36 | - [set, ['laminas_hmac', "@mediamonks.doctrine.transformable.transformer.laminas_crypt_hmac"]]
37 |
38 | doctrine.transformable.subscriber:
39 | class: MediaMonks\Doctrine\Transformable\TransformableSubscriber
40 | arguments: [@mediamonks.doctrine.transformable.transformer_pool]
41 | tags:
42 | - { name: doctrine.event_subscriber, priority: 100}
43 |
44 | Configure entity:
45 |
46 | .. code-block:: php
47 |
48 | encryptionKey = KeyFactory::loadEncryptionKey($encryptionKey);
27 |
28 | $this->setOptions($options);
29 | }
30 |
31 | protected function setOptions(array $options)
32 | {
33 | if (array_key_exists('binary', $options)) {
34 | $this->binary = $options['binary'];
35 | }
36 | }
37 |
38 | public function getBinary(): bool
39 | {
40 | return $this->binary;
41 | }
42 |
43 | /**
44 | * @throws \ParagonIE\Halite\Alerts\CannotPerformOperation
45 | * @throws \ParagonIE\Halite\Alerts\InvalidDigestLength
46 | * @throws \ParagonIE\Halite\Alerts\InvalidMessage
47 | * @throws \ParagonIE\Halite\Alerts\InvalidType
48 | * @throws \SodiumException
49 | */
50 | public function transform(?string $value): string|bool
51 | {
52 | if (empty($value)) {
53 | return false;
54 | }
55 |
56 | if ($this->binary) {
57 | $value = \Sodium\bin2hex($value);
58 | }
59 |
60 | if (Halite::VERSION > self::HALITE_LEGACY_VERSION) {
61 | $value = new HiddenString($value);
62 | }
63 |
64 | return Crypto::encrypt($value, $this->encryptionKey);
65 | }
66 |
67 | /**
68 | * @throws \ParagonIE\Halite\Alerts\CannotPerformOperation
69 | * @throws \ParagonIE\Halite\Alerts\InvalidDigestLength
70 | * @throws \ParagonIE\Halite\Alerts\InvalidMessage
71 | * @throws \ParagonIE\Halite\Alerts\InvalidSignature
72 | * @throws \ParagonIE\Halite\Alerts\InvalidType
73 | * @throws \SodiumException
74 | */
75 | public function reverseTransform(?string $value): string|null
76 | {
77 | if (empty($value)) {
78 | return null;
79 | }
80 |
81 | $decryptedValue = Crypto::decrypt($value, $this->encryptionKey);
82 |
83 | if (Halite::VERSION > self::HALITE_LEGACY_VERSION) {
84 | $decryptedValue = $decryptedValue->getString();
85 | }
86 |
87 | if (!$this->binary) {
88 | return $decryptedValue;
89 | }
90 |
91 | return \Sodium\hex2bin($decryptedValue);
92 | }
93 | }
94 |
95 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | [](https://github.com/mediamonks/doctrine-extensions/actions)
3 | [](https://codecov.io/gh/mediamonks/doctrine-extensions)
4 | [](https://packagist.org/packages/mediamonks/doctrine-extensions)
5 | [](https://packagist.org/packages/mediamonks/doctrine-extensions)
6 | [](https://packagist.org/packages/mediamonks/doctrine-extensions)
7 | [](https://packagist.org/packages/mediamonks/doctrine-extensions)
8 |
9 | # MediaMonks Doctrine2 behavioral extensions
10 |
11 | These extensions add more functionality to Doctrine2.
12 |
13 | > Breaking changes!
14 | All Zend transformers are now renamed to Laminas.
15 | YAML support has been removed
16 |
17 | > New features!
18 | Attribute support
19 |
20 | ## Transformable
21 |
22 | This extension uses transform and reverseTransform methods to convert data to and from the database. This can for example be used to encrypt a field when it's sent to the database and it will be decrypted when it is retrieved from the database.
23 |
24 | The field's value will only be transformed when the value changed which also makes it possible to implement only a transform function for one way transformations like hashing.
25 |
26 | Currently, these adapters are provided in order of recommendation:
27 |
28 | - HaliteSymmetricTransformer - Encrypt/decrypts the value
29 | - DefuseCryptoEncryptKeyTransformer - Encrypt/decrypts the value
30 | - PhpHashTransformer - Hashes the value
31 | - PhpHmacTransformer - Hashes the value with a key
32 | - LaminasCryptHashTransformer - Hashes the value
33 | - LaminasCryptHmacTransformer - Hashes the value with a key
34 | - LaminasCryptSymmetricTransformer - Encrypts/decrypts the value using openssl (Mcrypt is deprecated), with aes as default algorithm
35 |
36 | You can easily create your own transformers by implementing the [TransformableInterface](src/Transformable/Transformer/TransformerInterface.php)
37 |
38 | ## System Requirements
39 |
40 | You need:
41 |
42 | - **PHP >= 8.1**
43 |
44 | To use the library.
45 |
46 | ## Install
47 |
48 | Install this package by using Composer.
49 |
50 | ```
51 | $ composer require mediamonks/doctrine-extensions
52 | ```
53 |
54 | ## Security
55 |
56 | If you discover any security related issues, please email devmonk@mediamonks.com instead of using the issue tracker.
57 |
58 | # Documentation
59 |
60 | Please refer to the files in the [/doc](/doc) folder.
61 |
62 | # Credits
63 |
64 | This package was inspired by/uses code from [gedmo/doctrine-extensions](https://packagist.org/packages/gedmo/doctrine-extensions).
65 |
66 | ## License
67 |
68 | The MIT License (MIT). Please see [License File](LICENSE) for more information.
69 |
--------------------------------------------------------------------------------
/tests/Transformable/Transformer/DefuseCryptoEncryptKeyTransformerTest.php:
--------------------------------------------------------------------------------
1 | transformer = new DefuseCryptoEncryptKeyTransformer(self::KEY);
25 | }
26 |
27 | protected function getTransformerHex(): DefuseCryptoEncryptKeyTransformer
28 | {
29 | $mock = Mockery::mock('alias:Defuse\Crypto\Crypto');
30 | $mock->shouldReceive('encrypt')->andReturn(self::VALUE_HEX_ENCRYPTED);
31 | $mock->shouldReceive('decrypt')->andReturn(self::VALUE_HEX);
32 |
33 | return new DefuseCryptoEncryptKeyTransformer(self::KEY, ['binary' => false]);
34 | }
35 |
36 | protected function getTransformerBinary(): DefuseCryptoEncryptKeyTransformer
37 | {
38 | $mock = Mockery::mock('alias:Defuse\Crypto\Crypto');
39 | $mock->shouldReceive('encrypt')->andReturn(self::VALUE_BINARY_ENCRYPTED);
40 | $mock->shouldReceive('decrypt')->andReturn(self::VALUE_BINARY);
41 |
42 | return new DefuseCryptoEncryptKeyTransformer(self::KEY);
43 | }
44 |
45 | protected function tearDown(): void
46 | {
47 | Mockery::close();
48 |
49 | parent::tearDown();
50 | }
51 |
52 | public function testBinaryDefaultEnabled(): void
53 | {
54 | $transformer = new DefuseCryptoEncryptKeyTransformer(self::KEY);
55 | $this->assertTrue($transformer->getBinary());
56 | }
57 |
58 | public function testDisableBinary(): void
59 | {
60 | $transformer = new DefuseCryptoEncryptKeyTransformer(self::KEY, ['binary' => false]);
61 | $this->assertFalse($transformer->getBinary());
62 | }
63 |
64 | public function testTransformHex(): void
65 | {
66 | $this->assertEquals(self::VALUE_HEX_ENCRYPTED, $this->getTransformerHex()->transform(self::VALUE_HEX));
67 | }
68 |
69 | public function testReverseTransformHex(): void
70 | {
71 | $this->assertEquals(self::VALUE_HEX, $this->getTransformerHex()->reverseTransform(self::VALUE_HEX_ENCRYPTED));
72 | }
73 |
74 | public function testTransformBinary(): void
75 | {
76 | $this->assertEquals(self::VALUE_BINARY_ENCRYPTED, $this->getTransformerBinary()->transform(self::VALUE_BINARY));
77 | }
78 |
79 | public function testReverseTransformBinary(): void
80 | {
81 | $this->assertEquals(self::VALUE_BINARY, $this->getTransformerBinary()->reverseTransform(self::VALUE_BINARY_ENCRYPTED));
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/tests/Tool/BaseTestCaseORM.php:
--------------------------------------------------------------------------------
1 | http://www.gediminasm.org
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace MediaMonks\Doctrine\Tests\Tool;
13 |
14 | use Doctrine\Common\Annotations\AnnotationReader;
15 | use Doctrine\Common\EventManager;
16 | use Doctrine\DBAL\DriverManager;
17 | use Doctrine\DBAL\Logging\Middleware;
18 | use Doctrine\ORM\Configuration;
19 | use Doctrine\ORM\EntityManager;
20 | use Doctrine\ORM\Mapping\Driver\AnnotationDriver;
21 | use Doctrine\ORM\Mapping\Driver\AttributeDriver;
22 | use Doctrine\ORM\Tools\SchemaTool;
23 | use Doctrine\Persistence\Mapping\Driver\MappingDriver;
24 | use PHPUnit\Framework\TestCase;
25 | use Psr\Log\LoggerInterface;
26 | use Throwable;
27 |
28 | /**
29 | * Base test case contains common mock objects
30 | * and functionality among all extensions using
31 | * ORM object manager
32 | *
33 | * @author Gediminas Morkevicius
34 | */
35 | abstract class BaseTestCaseORM extends TestCase
36 | {
37 | protected ?EntityManager $em;
38 | protected LoggerInterface $queryLogger;
39 |
40 | protected function setUp(): void
41 | {
42 | $this->queryLogger = $this->createMock(LoggerInterface::class);
43 | }
44 |
45 | /**
46 | * EntityManager mock object together with
47 | * annotation mapping driver and pdo_sqlite
48 | * database in memory
49 | */
50 | protected function getDefaultMockSqliteEntityManager(EventManager $evm = null, bool $annotations = false): EntityManager
51 | {
52 | try {
53 | $conn = DriverManager::getConnection([
54 | 'driver' => 'pdo_sqlite',
55 | 'memory' => true,
56 | ]);
57 | $config = !$annotations ? $this->getDefaultConfiguration() : $this->getDefaultConfiguration($annotations);
58 | $em = new EntityManager($conn, $config, $evm ?: $this->getEventManager());
59 | $schema = array_map(static function ($class) use ($em) {
60 | return $em->getClassMetadata($class);
61 | }, $this->getUsedEntityFixtures());
62 |
63 | $schemaTool = new SchemaTool($em);
64 | $schemaTool->dropSchema([]);
65 | $schemaTool->createSchema($schema);
66 | $this->em = $em;
67 | } catch (Throwable $e) {
68 | $this->fail($e->getMessage());
69 | }
70 |
71 | return $this->em;
72 | }
73 |
74 |
75 | /**
76 | * Creates default mapping driver
77 | */
78 | protected function getMetadataDriverImplementation(bool $annotations): MappingDriver
79 | {
80 | if (!$annotations) {
81 | return new AttributeDriver([]);
82 | }
83 |
84 | return new AnnotationDriver(new AnnotationReader());
85 | }
86 |
87 | /**
88 | * Get a list of used fixture classes
89 | *
90 | * @phpstan-return list
91 | */
92 | abstract protected function getUsedEntityFixtures(): array;
93 |
94 | protected function getDefaultConfiguration(bool $annotations = false): Configuration
95 | {
96 | $config = new Configuration();
97 | $config->setProxyDir(TESTS_TEMP_DIR);
98 | $config->setProxyNamespace('Proxy');
99 | $config->setMetadataDriverImpl($this->getMetadataDriverImplementation($annotations));
100 |
101 | // TODO: Remove the "if" check when dropping support of doctrine/dbal 2.
102 | if (class_exists(Middleware::class)) {
103 | $config->setMiddlewares([
104 | new Middleware($this->queryLogger),
105 | ]);
106 | }
107 |
108 | return $config;
109 | }
110 |
111 | /**
112 | * @return bool|array
113 | */
114 | protected function fetchAssociative(string $query, array $params = [], array $types = []): array|bool
115 | {
116 | try {
117 | return $this->em->getConnection()->fetchAssociative($query, $params, $types);
118 | } catch (Throwable $e) {
119 | $this->fail($e->getMessage());
120 | }
121 | }
122 |
123 | protected function find($className, $id, $lockMode = null, $lockVersion = null): ?object
124 | {
125 | try {
126 | return $this->em->find($className, $id, $lockMode, $lockVersion);
127 | } catch (Throwable $e) {
128 | $this->fail($e->getMessage());
129 | }
130 | }
131 |
132 | protected function insert($table, array $data, array $types = []): int|string
133 | {
134 | try {
135 | return $this->em->getConnection()->insert($table, $data, $types);
136 | } catch (Throwable $e) {
137 | $this->fail($e->getMessage());
138 | }
139 | }
140 |
141 | protected function persistAndFlush(object $entity): void
142 | {
143 | try {
144 | $this->em->persist($entity);
145 | $this->em->flush();
146 | } catch (Throwable $e) {
147 | $this->fail($e->getMessage());
148 | }
149 | }
150 |
151 | protected function clear(): void
152 | {
153 | try {
154 | $this->em->clear();
155 | } catch (Throwable $e) {
156 | $this->fail($e->getMessage());
157 | }
158 | }
159 |
160 | protected function flush(): void
161 | {
162 | try {
163 | $this->em->flush();
164 | } catch (Throwable $e) {
165 | $this->fail($e->getMessage());
166 | }
167 | }
168 |
169 | protected function getEventManager(): EventManager
170 | {
171 | return new EventManager();
172 | }
173 | }
--------------------------------------------------------------------------------
/src/Transformable/TransformableSubscriber.php:
--------------------------------------------------------------------------------
1 |
23 | * @author Bas Bloembergen
24 | * @author Marco Brotas
25 | */
26 | class TransformableSubscriber extends MappedEventSubscriber
27 | {
28 | const TRANSFORMABLE = 'transformable';
29 |
30 | protected array $entityFieldValues = [];
31 |
32 | public function __construct(protected TransformerPool $transformerPool)
33 | {
34 | parent::__construct();
35 | }
36 |
37 | public function getSubscribedEvents(): array
38 | {
39 | return [
40 | Events::loadClassMetadata,
41 |
42 | Events::postLoad,
43 | Events::onFlush,
44 |
45 | Events::postPersist,
46 | Events::postUpdate,
47 | ];
48 | }
49 |
50 | public function loadClassMetadata(EventArgs $eventArguments): void
51 | {
52 | $eventAdapter = $this->getEventAdapter($eventArguments);
53 | $this->loadMetadataForObjectClass($eventAdapter->getObjectManager(), $eventArguments->getClassMetadata());
54 | }
55 |
56 | /**
57 | * @throws Exception
58 | */
59 | public function postLoad(PostLoadEventArgs $eventArguments): void
60 | {
61 | $this->reverseTransform($eventArguments, $eventArguments->getObject());
62 | }
63 |
64 | /**
65 | * @throws Exception
66 | */
67 | public function onFlush(OnFlushEventArgs $eventArguments): void
68 | {
69 | $this->transform($eventArguments);
70 | }
71 |
72 | /**
73 | * @throws Exception
74 | */
75 | public function postPersist(PostPersistEventArgs $eventArguments): void
76 | {
77 | $this->reverseTransform($eventArguments, $eventArguments->getObject());
78 | }
79 |
80 | /**
81 | * @throws Exception
82 | */
83 | public function postUpdate(PostUpdateEventArgs $eventArguments): void
84 | {
85 | $this->reverseTransform($eventArguments, $eventArguments->getObject());
86 | }
87 |
88 | /**
89 | * @throws Exception
90 | */
91 | protected function transform(EventArgs $eventArguments): void
92 | {
93 | $eventAdapter = $this->getEventAdapter($eventArguments);
94 | $objectManager = $eventAdapter->getObjectManager();
95 | $unitOfWork = $objectManager->getUnitOfWork();
96 |
97 | foreach ($eventAdapter->getScheduledObjectInsertions($unitOfWork) as $entity) {
98 | $this->handle($objectManager, $unitOfWork, $entity, TransformerMethod::TRANSFORM);
99 | }
100 |
101 | foreach ($eventAdapter->getScheduledObjectUpdates($unitOfWork) as $entity) {
102 | $this->handle($objectManager, $unitOfWork, $entity, TransformerMethod::TRANSFORM);
103 | }
104 | }
105 |
106 | /**
107 | * @throws Exception
108 | */
109 | protected function reverseTransform(EventArgs $eventArguments, object $entity): void
110 | {
111 | $eventAdapter = $this->getEventAdapter($eventArguments);
112 | $objectManager = $eventAdapter->getObjectManager();
113 | $unitOfWork = $objectManager->getUnitOfWork();
114 |
115 | $this->handle($objectManager, $unitOfWork, $entity, TransformerMethod::REVERSE_TRANSFORM);
116 | }
117 |
118 | /**
119 | * @throws Exception
120 | */
121 | protected function handle(EntityManagerInterface|ObjectManager $objectManager, UnitOfWork $unitOfWork, object $entity, TransformerMethod $method): void
122 | {
123 | $meta = $objectManager->getClassMetadata(get_class($entity));
124 | $config = $this->getConfiguration($objectManager, $meta->name);
125 | $transformableConfig = $config[self::TRANSFORMABLE] ?? [];
126 | if (!empty($transformableConfig)) {
127 | foreach ($transformableConfig as $column) {
128 | $this->handleField($entity, $method, $column, $meta, $unitOfWork);
129 | }
130 | }
131 | }
132 |
133 | /*------------------------------------------------------------------*/
134 |
135 | /**
136 | * @throws Exception
137 | */
138 | protected function handleField(object $entity, TransformerMethod $method, array $column, ClassMetadata $meta, UnitOfWork $unitOfWork): void
139 | {
140 | $field = $column['field'];
141 | $transformer = $this->getTransformer($column['name']);
142 | $oid = spl_object_id($entity);
143 |
144 | $reflectionProperty = $meta->getReflectionProperty($field);
145 | $originalValue = $this->getEntityValue($reflectionProperty, $entity);
146 |
147 | $newValue = $this->getNewValue($oid, $field, $transformer, $method, $originalValue);
148 | $reflectionProperty->setValue($entity, $newValue);
149 |
150 | // replace the uow original data with the reverse transformed, to avoid detecting useless changes.
151 | $unitOfWork->setOriginalEntityProperty($oid, $field, $newValue);
152 |
153 | // correct uow change set
154 | $changeSet = &$unitOfWork->getEntityChangeSet($entity);
155 | if (isset($changeSet[$field])) {
156 | if ($newValue === $changeSet[$field][0] && $newValue !== null) {
157 | unset($changeSet[$field]);
158 | if (empty($changeSet)) $unitOfWork->clearEntityChangeSet($oid);
159 | } else {
160 | $changeSet[$field][1] = $newValue;
161 | }
162 | }
163 |
164 | if ($method === TransformerMethod::REVERSE_TRANSFORM) {
165 | $this->storeOriginalFieldData($oid, $field, $originalValue, $newValue);
166 | }
167 | }
168 |
169 | protected function getEntityValue(ReflectionProperty $reflectionProperty, object $entity): string|null
170 | {
171 | $value = $reflectionProperty->getValue($entity);
172 |
173 | if (is_resource($value)) {
174 | $value = stream_get_contents($value);
175 | }
176 |
177 | return $value;
178 | }
179 |
180 | /**
181 | * @throws Exception
182 | */
183 | protected function getNewValue(string $oid, string $field, TransformerInterface $transformer, TransformerMethod $method, mixed $value): mixed
184 | {
185 | if ($method === TransformerMethod::TRANSFORM
186 | && $this->getEntityFieldValue($oid, $field, TransformableState::PLAIN) === $value) {
187 | return $this->getEntityFieldValue($oid, $field, TransformableState::TRANSFORMED);
188 | }
189 |
190 | return $this->performTransformerOperation($transformer, $method, $value);
191 | }
192 |
193 | /**
194 | * @throws Exception
195 | */
196 | protected function performTransformerOperation(TransformerInterface $transformer, TransformerMethod $method, mixed $originalValue): mixed
197 | {
198 | if ($originalValue === null) return null;
199 |
200 | return match ($method) {
201 | TransformerMethod::TRANSFORM => $transformer->transform($originalValue),
202 | TransformerMethod::REVERSE_TRANSFORM => $transformer->reverseTransform($originalValue)
203 | };
204 | }
205 |
206 | protected function getEntityFieldValue(string $oid, string $field, TransformableState $state): mixed
207 | {
208 | if (!isset($this->entityFieldValues[$oid][$field])) {
209 | return null;
210 | }
211 |
212 | return $this->entityFieldValues[$oid][$field][$state->value];
213 | }
214 |
215 | protected function storeOriginalFieldData(string $oid, string $field, mixed $transformed, mixed $plain): void
216 | {
217 | $this->entityFieldValues[$oid][$field] = [
218 | TransformableState::TRANSFORMED->value => $transformed,
219 | TransformableState::PLAIN->value => $plain,
220 | ];
221 | }
222 |
223 | /**
224 | * @throws Exception
225 | */
226 | protected function getTransformer(string $name): TransformerInterface
227 | {
228 | return $this->transformerPool->get($name);
229 | }
230 |
231 | /**
232 | * {@inheritDoc}
233 | */
234 | protected function getNamespace(): string
235 | {
236 | return __NAMESPACE__;
237 | }
238 | }
239 |
--------------------------------------------------------------------------------
/tests/Transformable/TransformableTest.php:
--------------------------------------------------------------------------------
1 | addEventSubscriber($this->getSubscriber($transformer));
28 | $this->em = $this->getDefaultMockSqliteEntityManager($evm, $annotations);
29 | }
30 |
31 | protected function getSubscriber($transformer = null): TransformableSubscriber
32 | {
33 | if (is_null($transformer)) {
34 | $transformer = $this->getDefaultTransformer();
35 | }
36 |
37 | $transformerPool = m::mock(TransformerPool::class);
38 | $transformerPool->shouldReceive('get')->andReturn($transformer);
39 |
40 | return new TransformableSubscriber($transformerPool);
41 | }
42 |
43 | protected function getDefaultTransformer(): TransformerInterface
44 | {
45 | $transformer = m::mock('MediaMonks\Doctrine\Transformable\Transformer\NoopTransformer', TransformerInterface::class);
46 | $transformer->shouldReceive('transform')->andReturn(self::VALUE_TRANSFORMED);
47 | $transformer->shouldReceive('reverseTransform')->andReturn(self::VALUE);
48 |
49 | return $transformer;
50 | }
51 |
52 | public function testGetSubscribedEvents(): void
53 | {
54 | $this->setUpEntityManager();
55 |
56 | $subscribedEvents = $this->getSubscriber()->getSubscribedEvents();
57 |
58 | $this->assertContains(Events::loadClassMetadata, $subscribedEvents);
59 | $this->assertContains(Events::onFlush, $subscribedEvents);
60 | $this->assertContains(Events::postPersist, $subscribedEvents);
61 | $this->assertContains(Events::postLoad, $subscribedEvents);
62 | $this->assertContains(Events::postUpdate, $subscribedEvents);
63 | }
64 |
65 | public function testTransformedValueIsStored(): void
66 | {
67 | $this->setUpEntityManager();
68 |
69 | $test = new Test();
70 | $test->setValue(self::VALUE);
71 |
72 | $this->persistAndFlush($test);
73 |
74 | $dbRow = $this->fetchAssociative('SELECT * FROM tests WHERE id = ?', [$test->getId()]);
75 |
76 | $this->assertEquals(self::VALUE_TRANSFORMED, $dbRow['value']);
77 | $this->assertEquals(self::VALUE, $test->getValue());
78 |
79 | $this->clear();
80 |
81 | $test = $this->find(Test::class, 1);
82 | $this->assertEquals(self::VALUE, $test->getValue());
83 | }
84 |
85 | public function testAnnotationTransformedValueIsStored(): void
86 | {
87 | $this->setUpEntityManager(null, true);
88 |
89 | $test = new Test();
90 | $test->setValue(self::VALUE);
91 |
92 | $this->persistAndFlush($test);
93 |
94 | $dbRow = $this->fetchAssociative('SELECT * FROM tests WHERE id = ?', [$test->getId()]);
95 |
96 | $this->assertEquals(self::VALUE_TRANSFORMED, $dbRow['value']);
97 | $this->assertEquals(self::VALUE, $test->getValue());
98 |
99 | $this->clear();
100 |
101 | $test = $this->find(Test::class, 1);
102 | $this->assertEquals(self::VALUE, $test->getValue());
103 | }
104 |
105 | public function testSupportsNull(): void
106 | {
107 | $this->setUpEntityManager();
108 |
109 | $test = new Test();
110 |
111 | $this->persistAndFlush($test);
112 |
113 | $dbRow = $this->fetchAssociative('SELECT * FROM tests WHERE id = ?', [$test->getId()]);
114 |
115 | $this->assertEquals(null, $dbRow['value']);
116 | $this->assertNull($test->getValue());
117 | }
118 |
119 | public function testAnnotationSupportsNull(): void
120 | {
121 | $this->setUpEntityManager(null, true);
122 |
123 | $test = new Test();
124 |
125 | $this->persistAndFlush($test);
126 |
127 | $dbRow = $this->fetchAssociative('SELECT * FROM tests WHERE id = ?', [$test->getId()]);
128 |
129 | $this->assertEquals(null, $dbRow['value']);
130 | $this->assertNull($test->getValue());
131 | }
132 |
133 | public function testTransformAfterUpdate(): void
134 | {
135 | $transformer = m::mock('MediaMonks\Doctrine\Transformable\Transformer\NoopTransformer', TransformerInterface::class);
136 | $transformer->shouldReceive('transform')->andReturn(self::VALUE_TRANSFORMED, self::VALUE_2_TRANSFORMED);
137 | $transformer->shouldReceive('reverseTransform')->andReturn(self::VALUE, self::VALUE_2);
138 |
139 | $this->setUpEntityManager($transformer);
140 |
141 | $test = new Test();
142 | $test->setValue(self::VALUE);
143 |
144 | $this->persistAndFlush($test);
145 |
146 | $test->setValue(self::VALUE_2);
147 | $this->flush();
148 |
149 | $dbRow = $this->fetchAssociative('SELECT * FROM tests WHERE id = ?', [$test->getId()]);
150 |
151 | $this->assertEquals(self::VALUE_2_TRANSFORMED, $dbRow['value']);
152 | }
153 |
154 | public function testAnnotationTransformAfterUpdate(): void
155 | {
156 | $transformer = m::mock('MediaMonks\Doctrine\Transformable\Transformer\NoopTransformer', TransformerInterface::class);
157 | $transformer->shouldReceive('transform')->andReturn(self::VALUE_TRANSFORMED, self::VALUE_2_TRANSFORMED);
158 | $transformer->shouldReceive('reverseTransform')->andReturn(self::VALUE, self::VALUE_2);
159 |
160 | $this->setUpEntityManager($transformer, true);
161 |
162 | $test = new Test();
163 | $test->setValue(self::VALUE);
164 |
165 | $this->persistAndFlush($test);
166 |
167 | $test->setValue(self::VALUE_2);
168 | $this->flush();
169 |
170 | $dbRow = $this->fetchAssociative('SELECT * FROM tests WHERE id = ?', [$test->getId()]);
171 |
172 | $this->assertEquals(self::VALUE_2_TRANSFORMED, $dbRow['value']);
173 | }
174 |
175 | public function testReverseTransformOfAlreadyPresentValue(): void
176 | {
177 | $this->setUpEntityManager();
178 |
179 | $this->insert('tests', ['id' => 1, 'value' => self::VALUE_TRANSFORMED, 'updated' => 0]);
180 |
181 | $test = $this->find(Test::class, 1);
182 | $this->assertEquals(self::VALUE, $test->getValue());
183 | }
184 |
185 | public function testAnnotationReverseTransformOfAlreadyPresentValue(): void
186 | {
187 | $this->setUpEntityManager(null, true);
188 |
189 | $this->insert('tests', ['id' => 1, 'value' => self::VALUE_TRANSFORMED, 'updated' => 0]);
190 |
191 | $test = $this->find(Test::class, 1);
192 | $this->assertEquals(self::VALUE, $test->getValue());
193 | }
194 |
195 | public function testNotTransformingAnUnchangedValueTwice(): void
196 | {
197 | $this->setUpEntityManager();
198 |
199 | $test = new Test();
200 | $test->setValue(self::VALUE);
201 |
202 | $this->persistAndFlush($test);
203 |
204 | $dbRow = $this->fetchAssociative('SELECT * FROM tests WHERE id = ?', [$test->getId()]);
205 | $this->assertEquals(self::VALUE_TRANSFORMED, $dbRow['value']);
206 | $this->assertEquals(self::VALUE, $test->getValue());
207 |
208 | $test->setUpdated(true);
209 | $this->flush();
210 |
211 | $dbRow = $this->fetchAssociative('SELECT * FROM tests WHERE id = ?', [$test->getId()]);
212 | $this->assertEquals(self::VALUE_TRANSFORMED, $dbRow['value']);
213 | $this->assertEquals(self::VALUE, $test->getValue());
214 | }
215 |
216 | public function testAnnotationNotTransformingAnUnchangedValueTwice(): void
217 | {
218 | $this->setUpEntityManager(null, true);
219 |
220 | $test = new Test();
221 | $test->setValue(self::VALUE);
222 |
223 | $this->persistAndFlush($test);
224 |
225 | $dbRow = $this->fetchAssociative('SELECT * FROM tests WHERE id = ?', [$test->getId()]);
226 | $this->assertEquals(self::VALUE_TRANSFORMED, $dbRow['value']);
227 | $this->assertEquals(self::VALUE, $test->getValue());
228 |
229 | $test->setUpdated(true);
230 | $this->flush();
231 |
232 | $dbRow = $this->fetchAssociative('SELECT * FROM tests WHERE id = ?', [$test->getId()]);
233 | $this->assertEquals(self::VALUE_TRANSFORMED, $dbRow['value']);
234 | $this->assertEquals(self::VALUE, $test->getValue());
235 | }
236 |
237 | protected function getUsedEntityFixtures(): array
238 | {
239 | return [
240 | Test::class,
241 | ];
242 | }
243 | }
244 |
--------------------------------------------------------------------------------