├── .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 | [![Build Status](https://img.shields.io/github/actions/workflow/status/mediamonks/doctrine-extensions/continuous-integration.yml?label=CI&logo=github&style=flat-square)](https://github.com/mediamonks/doctrine-extensions/actions) 3 | [![Code Coverage](https://img.shields.io/codecov/c/gh/mediamonks/doctrine-extensions?label=codecov&logo=codecov&style=flat-square)](https://codecov.io/gh/mediamonks/doctrine-extensions) 4 | [![Total Downloads](https://poser.pugx.org/mediamonks/doctrine-extensions/downloads)](https://packagist.org/packages/mediamonks/doctrine-extensions) 5 | [![Latest Stable Version](https://poser.pugx.org/mediamonks/doctrine-extensions/v/stable)](https://packagist.org/packages/mediamonks/doctrine-extensions) 6 | [![Latest Unstable Version](https://poser.pugx.org/mediamonks/doctrine-extensions/v/unstable)](https://packagist.org/packages/mediamonks/doctrine-extensions) 7 | [![License](https://poser.pugx.org/mediamonks/doctrine-extensions/license)](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 | --------------------------------------------------------------------------------