diff --git a/lib/Doctrine/ConnectionFactory.php b/lib/Doctrine/ConnectionFactory.php index 4584c3a8d1..cfd126b648 100644 --- a/lib/Doctrine/ConnectionFactory.php +++ b/lib/Doctrine/ConnectionFactory.php @@ -3,8 +3,11 @@ namespace MailPoet\Doctrine; use MailPoet\Config\Env; +use MailPoet\Doctrine\Types\JsonOrSerializedType; +use MailPoet\Doctrine\Types\JsonType; use MailPoetVendor\Doctrine\DBAL\DriverManager; use MailPoetVendor\Doctrine\DBAL\Platforms\MySqlPlatform; +use MailPoetVendor\Doctrine\DBAL\Types\Type; use PDO; class ConnectionFactory { @@ -13,6 +16,11 @@ class ConnectionFactory { private $min_wait_timeout = 60; + private $types = [ + JsonType::NAME => JsonType::class, + JsonOrSerializedType::NAME => JsonOrSerializedType::class, + ]; + function createConnection() { $platform_class = self::PLATFORM_CLASS; $connection_params = [ @@ -36,6 +44,7 @@ class ConnectionFactory { $connection_params['port'] = Env::$db_port; } + $this->setupTypes(); return DriverManager::getConnection($connection_params); } @@ -56,4 +65,14 @@ class ConnectionFactory { PDO::MYSQL_ATTR_INIT_COMMAND => 'SET ' . implode(', ', $driver_options), ]; } + + private function setupTypes() { + foreach ($this->types as $name => $class) { + if (Type::hasType($name)) { + Type::overrideType($name, $class); + } else { + Type::addType($name, $class); + } + } + } } diff --git a/lib/Doctrine/Types/JsonOrSerializedType.php b/lib/Doctrine/Types/JsonOrSerializedType.php new file mode 100644 index 0000000000..91294492a2 --- /dev/null +++ b/lib/Doctrine/Types/JsonOrSerializedType.php @@ -0,0 +1,28 @@ +getJsonTypeDeclarationSQL($fieldDeclaration); + } + + function convertToDatabaseValue($value, AbstractPlatform $platform) { + if ($value === null) { + return null; + } + + $flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + if (defined('JSON_PRESERVE_ZERO_FRACTION')) { + $flags |= JSON_PRESERVE_ZERO_FRACTION; // phpcs:ignore + } + + $encoded = json_encode($value, $flags); + $this->handleErrors(); + return $encoded; + } + + function convertToPHPValue($value, AbstractPlatform $platform) { + if ($value === null) { + return null; + } + + if (is_resource($value)) { + $value = stream_get_contents($value); + } + + $decoded = json_decode($value, true); + $this->handleErrors(); + return $decoded; + } + + function getName() { + return self::NAME; + } + + function requiresSQLCommentHint(AbstractPlatform $platform) { + return !$platform->hasNativeJsonType(); + } + + private function handleErrors() { + $error = json_last_error(); + if ($error !== JSON_ERROR_NONE) { + throw new \RuntimeException(json_last_error_msg(), $error); + } + } +} diff --git a/tests/integration/Doctrine/ConnectionFactoryTest.php b/tests/integration/Doctrine/ConnectionFactoryTest.php index 7957ee335b..c5ae3b3cd0 100644 --- a/tests/integration/Doctrine/ConnectionFactoryTest.php +++ b/tests/integration/Doctrine/ConnectionFactoryTest.php @@ -5,8 +5,11 @@ namespace MailPoet\Test\Config; use MailPoet\Config\Env; use MailPoet\Doctrine\ConnectionFactory; use MailPoet\Doctrine\SerializableConnection; +use MailPoet\Doctrine\Types\JsonOrSerializedType; +use MailPoet\Doctrine\Types\JsonType; use MailPoetVendor\Doctrine\DBAL\Driver\PDOMySql; use MailPoetVendor\Doctrine\DBAL\Platforms\MySqlPlatform; +use MailPoetVendor\Doctrine\DBAL\Types\Type; use PDO; class ConnectionFactoryTest extends \MailPoetTest { @@ -36,6 +39,9 @@ class ConnectionFactoryTest extends \MailPoetTest { expect($connection->getPassword())->equals(Env::$db_password); expect($connection->getParams()['charset'])->equals(Env::$db_charset); expect($connection->getDatabase())->equals(Env::$db_name); + + expect(Type::getType(JsonType::NAME))->isInstanceOf(JsonType::class); + expect(Type::getType(JsonOrSerializedType::NAME))->isInstanceOf(JsonOrSerializedType::class); } function testItIgnoresEmptyCharset() { diff --git a/tests/integration/Doctrine/Types/JsonEntity.php b/tests/integration/Doctrine/Types/JsonEntity.php new file mode 100644 index 0000000000..f45ff27fd4 --- /dev/null +++ b/tests/integration/Doctrine/Types/JsonEntity.php @@ -0,0 +1,64 @@ +id; + } + + /** + * @return array|null + */ + public function getJsonData() { + return $this->json_data; + } + + /** + * @param array|null $json_data + */ + public function setJsonData($json_data) { + $this->json_data = $json_data; + } + + /** + * @return array|null + */ + public function getJsonOrSerializedData() { + return $this->json_or_serialized_data; + } + + /** + * @param array|null $json_or_serialized_data + */ + public function setJsonOrSerializedData($json_or_serialized_data) { + $this->json_or_serialized_data = $json_or_serialized_data; + } +} diff --git a/tests/integration/Doctrine/Types/JsonTypesTest.php b/tests/integration/Doctrine/Types/JsonTypesTest.php new file mode 100644 index 0000000000..401e8279b6 --- /dev/null +++ b/tests/integration/Doctrine/Types/JsonTypesTest.php @@ -0,0 +1,164 @@ + [ + 'a' => 'string', + 'b' => 10, + 'c' => true, + 'd' => null, + ], + ]; + + function _before() { + $this->wp = new WPFunctions(); + $this->entity_manager = $this->createEntityManager(); + $this->table_name = $this->entity_manager->getClassMetadata(JsonEntity::class)->getTableName(); + $this->connection->executeUpdate("DROP TABLE IF EXISTS $this->table_name"); + $this->connection->executeUpdate(" + CREATE TABLE $this->table_name ( + id int(11) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY, + json_data longtext NULL, + json_or_serialized_data longtext NULL + ) + "); + } + + function testItSavesJsonData() { + $entity = new JsonEntity(); + $entity->setJsonData($this->test_data); + $entity->setJsonOrSerializedData($this->test_data); + $this->entity_manager->persist($entity); + $this->entity_manager->flush(); + + $saved_data = $this->connection->executeQuery("SELECT * FROM $this->table_name")->fetch(); + expect($saved_data['json_data'])->same(json_encode($this->test_data)); + expect($saved_data['json_or_serialized_data'])->same(json_encode($this->test_data)); + } + + function testItLoadsJsonData() { + $this->connection->executeUpdate( + "INSERT INTO $this->table_name (id, json_data, json_or_serialized_data) VALUES (?, ?, ?)", + [ + 1, + json_encode($this->test_data), + json_encode($this->test_data), + ] + ); + + $entity = $this->entity_manager->find(JsonEntity::class, 1); + expect($entity->getJsonData())->same($this->test_data); + expect($entity->getJsonOrSerializedData())->same($this->test_data); + } + + function testItLoadsSerializedData() { + $this->connection->executeUpdate( + "INSERT INTO $this->table_name (id, json_or_serialized_data) VALUES (?, ?)", + [ + 1, + serialize($this->test_data), + ] + ); + + $entity = $this->entity_manager->find(JsonEntity::class, 1); + expect($entity->getJsonData())->null(); + expect($entity->getJsonOrSerializedData())->same($this->test_data); + } + + function testItSavesNullData() { + $entity = new JsonEntity(); + $entity->setJsonData(null); + $entity->setJsonOrSerializedData(null); + $this->entity_manager->persist($entity); + $this->entity_manager->flush(); + + $saved_data = $this->connection->executeQuery("SELECT * FROM $this->table_name")->fetch(); + expect($saved_data['json_data'])->null(); + expect($saved_data['json_or_serialized_data'])->null(); + } + + function testItLoadsNullData() { + $this->connection->executeUpdate( + "INSERT INTO $this->table_name (id, json_data, json_or_serialized_data) VALUES (?, ?, ?)", + [ + 1, + null, + null, + ] + ); + + $entity = $this->entity_manager->find(JsonEntity::class, 1); + expect($entity->getJsonData())->null(); + expect($entity->getJsonOrSerializedData())->null(); + } + + function testItDoesNotSaveInvalidData() { + $entity = new JsonEntity(); + $entity->setJsonData("\xB1\x31"); // invalid unicode sequence + $this->entity_manager->persist($entity); + + $exception = null; + try { + $this->entity_manager->flush(); + } catch (Exception $e) { + $exception = $e; + } + expect($exception)->isInstanceOf(RuntimeException::class); + } + + function testItDoesNotLoadInvalidData() { + $this->connection->executeUpdate( + "INSERT INTO $this->table_name (id, json_data) VALUES (?, ?)", + [ + 1, + '{', // invalid JSON + ] + ); + + $exception = null; + try { + $this->entity_manager->find(JsonEntity::class, 1); + } catch (Exception $e) { + $exception = $e; + } + expect($exception)->isInstanceOf(RuntimeException::class); + } + + function _after() { + parent::_after(); + $this->connection->executeUpdate("DROP TABLE IF EXISTS $this->table_name"); + } + + private function createEntityManager() { + $configuration_factory = new ConfigurationFactory(); + $configuration = $configuration_factory->createConfiguration(); + + $metadata_driver = $configuration->newDefaultAnnotationDriver([__DIR__]); + $configuration->setMetadataDriverImpl($metadata_driver); + $configuration->setMetadataCacheImpl(new ArrayCache()); + + $timestamp_listener = new TimestampListener($this->wp); + $entity_manager_factory = new EntityManagerFactory($this->connection, $configuration, $timestamp_listener); + return $entity_manager_factory->createEntityManager(); + } +}