diff --git a/composer.json b/composer.json index 72959eefc9..a40fab54f9 100644 --- a/composer.json +++ b/composer.json @@ -23,8 +23,8 @@ "ext-openssl": "*", "ext-pdo": "*", "beberlei/assert": "^3.3.3", - "composer/package-versions-deprecated": "^1.11.99.5", - "doctrine/dbal": "^3.10.3", + "composer/package-versions-deprecated": "^1.11", + "doctrine/dbal": "^4.3.3", "doctrine/doctrine-bundle": "^2.13.3", "doctrine/doctrine-migrations-bundle": "^3.6.0", "doctrine/orm": "^3.5.7", diff --git a/composer.lock b/composer.lock index 72821a2cd4..bda66556df 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2b913d2a4625f2d66b389914be2fae66", + "content-hash": "94a8ddb3d35a9b901b313ae95ec0df5d", "packages": [ { "name": "beberlei/assert", @@ -465,48 +465,40 @@ }, { "name": "doctrine/dbal", - "version": "3.10.3", + "version": "4.4.1", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "65edaca19a752730f290ec2fb89d593cb40afb43" + "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/65edaca19a752730f290ec2fb89d593cb40afb43", - "reference": "65edaca19a752730f290ec2fb89d593cb40afb43", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", + "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", "shasum": "" }, "require": { - "composer-runtime-api": "^2", - "doctrine/deprecations": "^0.5.3|^1", - "doctrine/event-manager": "^1|^2", - "php": "^7.4 || ^8.0", + "doctrine/deprecations": "^1.1.5", + "php": "^8.2", "psr/cache": "^1|^2|^3", "psr/log": "^1|^2|^3" }, - "conflict": { - "doctrine/cache": "< 1.11" - }, "require-dev": { - "doctrine/cache": "^1.11|^2.0", "doctrine/coding-standard": "14.0.0", "fig/log-test": "^1", - "jetbrains/phpstorm-stubs": "2023.1", + "jetbrains/phpstorm-stubs": "2023.2", "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "2.0.7", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "9.6.29", + "phpunit/phpunit": "11.5.23", "slevomat/coding-standard": "8.24.0", "squizlabs/php_codesniffer": "4.0.0", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/console": "^4.4|^5.4|^6.0|^7.0" + "symfony/cache": "^6.3.8|^7.0|^8.0", + "symfony/console": "^5.4|^6.3|^7.0|^8.0" }, "suggest": { "symfony/console": "For helpful console commands such as SQL execution and import of files." }, - "bin": [ - "bin/doctrine-dbal" - ], "type": "library", "autoload": { "psr-4": { @@ -559,7 +551,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.10.3" + "source": "https://github.com/doctrine/dbal/tree/4.4.1" }, "funding": [ { @@ -575,33 +567,33 @@ "type": "tidelift" } ], - "time": "2025-10-09T09:05:12+00:00" + "time": "2025-12-04T10:11:03+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -621,9 +613,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/doctrine-bundle", @@ -2771,16 +2763,16 @@ }, { "name": "robrichards/xmlseclibs", - "version": "3.1.3", + "version": "3.1.4", "source": { "type": "git", "url": "https://github.com/robrichards/xmlseclibs.git", - "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07" + "reference": "bc87389224c6de95802b505e5265b0ec2c5bcdbd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/2bdfd742624d739dfadbd415f00181b4a77aaf07", - "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/bc87389224c6de95802b505e5265b0ec2c5bcdbd", + "reference": "bc87389224c6de95802b505e5265b0ec2c5bcdbd", "shasum": "" }, "require": { @@ -2807,9 +2799,9 @@ ], "support": { "issues": "https://github.com/robrichards/xmlseclibs/issues", - "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.3" + "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.4" }, - "time": "2024-11-20T21:13:56+00:00" + "time": "2025-12-08T11:57:53+00:00" }, { "name": "sensio/framework-extra-bundle", diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 57cbc24adf..1425a8eea9 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -23,6 +23,10 @@ doctrine: engineblock_collab_person_uuid: OpenConext\EngineBlockBundle\Doctrine\Type\CollabPersonUuidType engineblock_metadata_coins: OpenConext\EngineBlockBundle\Doctrine\Type\MetadataCoinType engineblock_metadata_mdui: OpenConext\EngineBlockBundle\Doctrine\Type\MetadataMduiType + + array: OpenConext\EngineBlockBundle\Doctrine\Type\SerializedArrayType + object: OpenConext\EngineBlockBundle\Doctrine\Type\SerializedObjectType + orm: auto_generate_proxy_classes: "%kernel.debug%" proxy_dir: '%kernel.cache_dir%/doctrine/orm/Proxies' diff --git a/src/OpenConext/EngineBlock/Metadata/Entity/AbstractRole.php b/src/OpenConext/EngineBlock/Metadata/Entity/AbstractRole.php index 05187a3253..a682751fac 100644 --- a/src/OpenConext/EngineBlock/Metadata/Entity/AbstractRole.php +++ b/src/OpenConext/EngineBlock/Metadata/Entity/AbstractRole.php @@ -18,7 +18,6 @@ namespace OpenConext\EngineBlock\Metadata\Entity; -use DateTime; use Doctrine\ORM\Mapping as ORM; use OpenConext\EngineBlock\Metadata\Coins; use OpenConext\EngineBlock\Metadata\ContactPerson; @@ -28,12 +27,17 @@ use OpenConext\EngineBlock\Metadata\Organization; use OpenConext\EngineBlock\Metadata\Service; use OpenConext\EngineBlock\Metadata\X509\X509Certificate; +use OpenConext\EngineBlockBundle\Doctrine\Type\SerializedArrayType; +use OpenConext\EngineBlockBundle\Doctrine\Type\SerializedObjectType; use RuntimeException; use SAML2\Constants; /** * Abstract base class for configuration entities. * + * Note: This baseclass is extended by IdentityProvider and ServiceProvider. Both entities are stored in a single table. + * This means all columns defined in both subclasses are nullable by default, even if you pass 'nullable: false'. + * * @package OpenConext\EngineBlock\Metadata\Entity * @SuppressWarnings(PHPMD.TooManyFields) * @@ -134,25 +138,25 @@ abstract class AbstractRole * @var Logo * @deprecated Will be removed in favour of using the Mdui value object, use the getter for this field instead */ - #[ORM\Column(name: 'logo', type: \Doctrine\DBAL\Types\Types::OBJECT)] + #[ORM\Column(name: 'logo', type: SerializedObjectType::NAME)] public $logo; /** * @var Organization */ - #[ORM\Column(name: 'organization_nl_name', type: \Doctrine\DBAL\Types\Types::OBJECT, nullable: true, length: 65535)] + #[ORM\Column(name: 'organization_nl_name', type: SerializedObjectType::NAME, length: 65535, nullable: true)] public $organizationNl; /** * @var Organization */ - #[ORM\Column(name: 'organization_en_name', type: \Doctrine\DBAL\Types\Types::OBJECT, nullable: true, length: 65535)] + #[ORM\Column(name: 'organization_en_name', type: SerializedObjectType::NAME, length: 65535, nullable: true)] public $organizationEn; /** * @var Organization */ - #[ORM\Column(name: 'organization_pt_name', type: \Doctrine\DBAL\Types\Types::OBJECT, nullable: true, length: 65535)] + #[ORM\Column(name: 'organization_pt_name', type: SerializedObjectType::NAME, length: 65535, nullable: true)] public $organizationPt; /** @@ -179,7 +183,7 @@ abstract class AbstractRole /** * @var X509Certificate[] */ - #[ORM\Column(name: 'certificates', type: \Doctrine\DBAL\Types\Types::ARRAY, length: 65535)] + #[ORM\Column(name: 'certificates', type: SerializedArrayType::NAME, length: 65535)] public $certificates = array(); /** @@ -191,7 +195,7 @@ abstract class AbstractRole /** * @var ContactPerson[] */ - #[ORM\Column(name: 'contact_persons', type: \Doctrine\DBAL\Types\Types::ARRAY, length: 65535)] + #[ORM\Column(name: 'contact_persons', type: SerializedArrayType::NAME, length: 65535)] public $contactPersons; /** @@ -203,13 +207,13 @@ abstract class AbstractRole /** * @var string[] */ - #[ORM\Column(name: 'name_id_formats', type: \Doctrine\DBAL\Types\Types::ARRAY, length: 65535)] + #[ORM\Column(name: 'name_id_formats', type: SerializedArrayType::NAME, length: 65535)] public $supportedNameIdFormats; /** * @var Service */ - #[ORM\Column(name: 'single_logout_service', type: \Doctrine\DBAL\Types\Types::OBJECT, nullable: true, length: 65535)] + #[ORM\Column(name: 'single_logout_service', type: SerializedObjectType::NAME, length: 65535, nullable: true)] public $singleLogoutService; /** diff --git a/src/OpenConext/EngineBlock/Metadata/Entity/IdentityProvider.php b/src/OpenConext/EngineBlock/Metadata/Entity/IdentityProvider.php index cb5ed5daca..70d8dd8abe 100644 --- a/src/OpenConext/EngineBlock/Metadata/Entity/IdentityProvider.php +++ b/src/OpenConext/EngineBlock/Metadata/Entity/IdentityProvider.php @@ -32,6 +32,7 @@ use OpenConext\EngineBlock\Metadata\ShibMdScope; use OpenConext\EngineBlock\Metadata\Service; use OpenConext\EngineBlock\Metadata\StepupConnections; +use OpenConext\EngineBlockBundle\Doctrine\Type\SerializedArrayType; use RobRichards\XMLSecLibs\XMLSecurityKey; use SAML2\Constants; @@ -71,7 +72,7 @@ class IdentityProvider extends AbstractRole /** * @var Service[] */ - #[ORM\Column(name: 'single_sign_on_services', type: \Doctrine\DBAL\Types\Types::ARRAY, length: 65535)] + #[ORM\Column(name: 'single_sign_on_services', type: SerializedArrayType::NAME, length: 65535)] public $singleSignOnServices = array(); /** @@ -83,7 +84,7 @@ class IdentityProvider extends AbstractRole /** * @var ShibMdScope[] */ - #[ORM\Column(name: 'shib_md_scopes', type: \Doctrine\DBAL\Types\Types::ARRAY, length: 65535)] + #[ORM\Column(name: 'shib_md_scopes', type: SerializedArrayType::NAME, length: 65535)] public $shibMdScopes = array(); /** diff --git a/src/OpenConext/EngineBlock/Metadata/Entity/ServiceProvider.php b/src/OpenConext/EngineBlock/Metadata/Entity/ServiceProvider.php index e78e2ed3a5..8212317932 100644 --- a/src/OpenConext/EngineBlock/Metadata/Entity/ServiceProvider.php +++ b/src/OpenConext/EngineBlock/Metadata/Entity/ServiceProvider.php @@ -29,6 +29,7 @@ use OpenConext\EngineBlock\Metadata\RequestedAttribute; use OpenConext\EngineBlock\Metadata\IndexedService; use OpenConext\EngineBlock\Metadata\Service; +use OpenConext\EngineBlockBundle\Doctrine\Type\SerializedArrayType; use RobRichards\XMLSecLibs\XMLSecurityKey; use SAML2\Constants; @@ -48,19 +49,19 @@ class ServiceProvider extends AbstractRole /** * @var null|AttributeReleasePolicy */ - #[ORM\Column(name: 'attribute_release_policy', type: \Doctrine\DBAL\Types\Types::ARRAY, length: 65535)] + #[ORM\Column(name: 'attribute_release_policy', type: SerializedArrayType::NAME, length: 65535)] public $attributeReleasePolicy; /** * @var IndexedService[] */ - #[ORM\Column(name: 'assertion_consumer_services', type: \Doctrine\DBAL\Types\Types::ARRAY, length: 65535)] + #[ORM\Column(name: 'assertion_consumer_services', type: SerializedArrayType::NAME, length: 65535)] public $assertionConsumerServices; /** * @var string[] */ - #[ORM\Column(name: 'allowed_idp_entity_ids', type: \Doctrine\DBAL\Types\Types::ARRAY, length: 6777215)] + #[ORM\Column(name: 'allowed_idp_entity_ids', type: SerializedArrayType::NAME, length: 6777215)] public $allowedIdpEntityIds; /** @@ -72,7 +73,7 @@ class ServiceProvider extends AbstractRole /** * @var null|RequestedAttribute[] */ - #[ORM\Column(name: 'requested_attributes', type: \Doctrine\DBAL\Types\Types::ARRAY, length: 65535)] + #[ORM\Column(name: 'requested_attributes', type: SerializedArrayType::NAME, length: 65535)] public $requestedAttributes; /** diff --git a/src/OpenConext/EngineBlockBundle/Doctrine/Type/SerializedArrayType.php b/src/OpenConext/EngineBlockBundle/Doctrine/Type/SerializedArrayType.php new file mode 100644 index 0000000000..e2f5e52d2c --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Doctrine/Type/SerializedArrayType.php @@ -0,0 +1,82 @@ +getClobTypeDeclarationSQL($column); + } + + public function convertToDatabaseValue($value, AbstractPlatform $platform): string + { + return serialize($value); + } + + public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mixed + { + if ($value === null) { + return null; + } + + $value = is_resource($value) ? stream_get_contents($value) : $value; + + set_error_handler(function (int $code, string $message) use ($value): bool { + if ($code === E_DEPRECATED || $code === E_USER_DEPRECATED) { + return false; + } + + throw ValueNotConvertible::new($value, $this->getName(), $message); + }); + + try { + return unserialize($value); + } finally { + restore_error_handler(); + } + } + + public function getName(): string + { + return self::NAME; + } +} diff --git a/src/OpenConext/EngineBlockBundle/Doctrine/Type/SerializedObjectType.php b/src/OpenConext/EngineBlockBundle/Doctrine/Type/SerializedObjectType.php new file mode 100644 index 0000000000..ebd2560913 --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Doctrine/Type/SerializedObjectType.php @@ -0,0 +1,82 @@ +getClobTypeDeclarationSQL($column); + } + + public function convertToDatabaseValue($value, AbstractPlatform $platform): string + { + return serialize($value); + } + + public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mixed + { + if ($value === null) { + return null; + } + + $value = is_resource($value) ? stream_get_contents($value) : $value; + + set_error_handler(function (int $code, string $message) use ($value): bool { + if ($code === E_DEPRECATED || $code === E_USER_DEPRECATED) { + return false; + } + + throw ValueNotConvertible::new($value, $this->getName(), $message); + }); + + try { + return unserialize($value); + } finally { + restore_error_handler(); + } + } + + public function getName(): string + { + return self::NAME; + } +} diff --git a/tests/unit/OpenConext/EngineBlockBundle/Doctrine/Type/SerializedArrayTypeTest.php b/tests/unit/OpenConext/EngineBlockBundle/Doctrine/Type/SerializedArrayTypeTest.php new file mode 100644 index 0000000000..d0cad5610d --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockBundle/Doctrine/Type/SerializedArrayTypeTest.php @@ -0,0 +1,107 @@ +platform = new MySQLPlatform(); + } + + #[Group('EngineBlockBundle')] + #[Group('Doctrine')] + #[Test] + public function null_is_serialized_to_a_string_not_sql_null(): void + { + $type = Type::getType(SerializedArrayType::NAME); + + $result = $type->convertToDatabaseValue(null, $this->platform); + + // Mirrors DBAL 3 ArrayType: null is serialize()d to 'N;', never SQL NULL. + $this->assertSame(serialize(null), $result); + $this->assertNotNull($result); + } + + #[Group('EngineBlockBundle')] + #[Group('Doctrine')] + #[Test] + public function an_array_round_trips_correctly(): void + { + $type = Type::getType(SerializedArrayType::NAME); + $input = ['foo' => 'bar', 'baz' => [1, 2, 3]]; + + $dbValue = $type->convertToDatabaseValue($input, $this->platform); + $phpValue = $type->convertToPHPValue($dbValue, $this->platform); + + $this->assertSame($input, $phpValue); + } + + #[Group('EngineBlockBundle')] + #[Group('Doctrine')] + #[Test] + public function null_from_database_converts_to_null(): void + { + $type = Type::getType(SerializedArrayType::NAME); + + $result = $type->convertToPHPValue(null, $this->platform); + + $this->assertNull($result); + } + + #[Group('EngineBlockBundle')] + #[Group('Doctrine')] + #[Test] + public function serialized_null_from_database_converts_to_null(): void + { + $type = Type::getType(SerializedArrayType::NAME); + + // 'N;' is what old code stored when $logo / $certificates was null + $result = $type->convertToPHPValue(serialize(null), $this->platform); + + $this->assertNull($result); + } + + #[Group('EngineBlockBundle')] + #[Group('Doctrine')] + #[Test] + public function corrupted_database_value_throws_a_conversion_exception(): void + { + $type = Type::getType(SerializedArrayType::NAME); + + $this->expectException(ConversionException::class); + $type->convertToPHPValue('this is not valid serialized data }{', $this->platform); + } +} diff --git a/tests/unit/OpenConext/EngineBlockBundle/Doctrine/Type/SerializedObjectTypeTest.php b/tests/unit/OpenConext/EngineBlockBundle/Doctrine/Type/SerializedObjectTypeTest.php new file mode 100644 index 0000000000..3952f828ea --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockBundle/Doctrine/Type/SerializedObjectTypeTest.php @@ -0,0 +1,110 @@ +platform = new MySQLPlatform(); + } + + #[Group('EngineBlockBundle')] + #[Group('Doctrine')] + #[Test] + public function null_is_serialized_to_a_string_not_sql_null(): void + { + $type = Type::getType(SerializedObjectType::NAME); + + $result = $type->convertToDatabaseValue(null, $this->platform); + + // Mirrors DBAL 3 ObjectType: null is serialize()d to 'N;', never SQL NULL. + $this->assertSame(serialize(null), $result); + $this->assertNotNull($result); + } + + #[Group('EngineBlockBundle')] + #[Group('Doctrine')] + #[Test] + public function an_object_round_trips_correctly(): void + { + $type = Type::getType(SerializedObjectType::NAME); + $input = new stdClass(); + $input->foo = 'bar'; + $input->list = [1, 2, 3]; + + $dbValue = $type->convertToDatabaseValue($input, $this->platform); + $phpValue = $type->convertToPHPValue($dbValue, $this->platform); + + $this->assertEquals($input, $phpValue); + } + + #[Group('EngineBlockBundle')] + #[Group('Doctrine')] + #[Test] + public function null_from_database_converts_to_null(): void + { + $type = Type::getType(SerializedObjectType::NAME); + + $result = $type->convertToPHPValue(null, $this->platform); + + $this->assertNull($result); + } + + #[Group('EngineBlockBundle')] + #[Group('Doctrine')] + #[Test] + public function serialized_null_from_database_converts_to_null(): void + { + $type = Type::getType(SerializedObjectType::NAME); + + // 'N;' is what old code stored when an object field (e.g. logo) was null + $result = $type->convertToPHPValue(serialize(null), $this->platform); + + $this->assertNull($result); + } + + #[Group('EngineBlockBundle')] + #[Group('Doctrine')] + #[Test] + public function corrupted_database_value_throws_a_conversion_exception(): void + { + $type = Type::getType(SerializedObjectType::NAME); + + $this->expectException(ConversionException::class); + $type->convertToPHPValue('this is not valid serialized data }{', $this->platform); + } +}