diff --git a/bootstrap-types.php b/bootstrap-types.php index 9c67835a9..18d9ecbc4 100644 --- a/bootstrap-types.php +++ b/bootstrap-types.php @@ -2,48 +2,12 @@ declare(strict_types=1); -namespace Atk4\Data\Types; +namespace Atk4\Data\Bootstrap; -use Doctrine\DBAL\Platforms\AbstractPlatform; +use Atk4\Data\Type\LocalObjectType; +use Atk4\Data\Type\MoneyType; +use Atk4\Data\Type\Types; use Doctrine\DBAL\Types as DbalTypes; -final class Types -{ - public const MONEY = 'atk4_money'; -} - -class MoneyType extends DbalTypes\Type -{ - public function getName(): string - { - return Types::MONEY; - } - - public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string - { - return DbalTypes\Type::getType(DbalTypes\Types::FLOAT)->getSQLDeclaration($fieldDeclaration, $platform); - } - - public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string - { - if ($value === null || trim((string) $value) === '') { - return null; - } - - return (string) round((float) $value, 4); - } - - public function convertToPHPValue($value, AbstractPlatform $platform): ?float - { - $v = $this->convertToDatabaseValue($value, $platform); - - return $v === null ? null : (float) $v; - } - - public function requiresSQLCommentHint(AbstractPlatform $platform): bool - { - return true; - } -} - +DbalTypes\Type::addType(Types::LOCAL_OBJECT, LocalObjectType::class); DbalTypes\Type::addType(Types::MONEY, MoneyType::class); diff --git a/src/Model.php b/src/Model.php index fbf44ba8a..6f6bb681c 100644 --- a/src/Model.php +++ b/src/Model.php @@ -1049,7 +1049,7 @@ public function setOrder($field, string $direction = 'asc') } } else { // format "field" => direction - $this->setOrder($k, $v); // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/7924 + $this->setOrder($k, $v); } } @@ -1326,6 +1326,7 @@ public function loadAny() public function reload() { $id = $this->getId(); + $data = $this->getDataRef(); // keep weakly persisted objects referenced $this->unload(); $res = $this->_load(true, false, $id); diff --git a/src/Persistence/Sql/Connection.php b/src/Persistence/Sql/Connection.php index 29b8bd9d4..f76df8717 100644 --- a/src/Persistence/Sql/Connection.php +++ b/src/Persistence/Sql/Connection.php @@ -88,10 +88,10 @@ public static function normalizeDsn($dsn, $user = null, $password = null) $dsn['dsn'] = str_replace('-', '_', $parsed['scheme']) . ':'; unset($parsed['scheme']); foreach ($parsed as $k => $v) { - if ($k === 'pass') { + if ($k === 'pass') { // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/8638 unset($parsed[$k]); $k = 'password'; - } elseif ($k === 'path') { + } elseif ($k === 'path') { // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/8638 unset($parsed[$k]); $k = 'dbname'; $v = preg_replace('~^/~', '', $v); @@ -215,7 +215,7 @@ private static function getDriverNameFromDbalDriverConnection(DbalDriverConnecti $driver = $connection->getNativeConnection(); if ($driver instanceof \PDO) { - return 'pdo_' . $driver->getAttribute(\PDO::ATTR_DRIVER_NAME); // @phpstan-ignore-line + return 'pdo_' . $driver->getAttribute(\PDO::ATTR_DRIVER_NAME); } elseif ($driver instanceof \mysqli) { return 'mysqli'; } elseif (is_resource($driver) && get_resource_type($driver) === 'oci8 connection') { diff --git a/src/Type/LocalObjectHandle.php b/src/Type/LocalObjectHandle.php new file mode 100644 index 000000000..a25c69c72 --- /dev/null +++ b/src/Type/LocalObjectHandle.php @@ -0,0 +1,37 @@ + */ + private \WeakReference $weakValue; + + private \Closure $destructFx; + + public function __construct(int $localUid, object $value, \Closure $destructFx) + { + $this->localUid = $localUid; + $this->weakValue = \WeakReference::create($value); + $this->destructFx = $destructFx; + } + + public function __destruct() + { + ($this->destructFx)($this); + } + + public function getLocalUid(): int + { + return $this->localUid; + } + + public function getValue(): ?object + { + return $this->weakValue->get(); + } +} diff --git a/src/Type/LocalObjectType.php b/src/Type/LocalObjectType.php new file mode 100644 index 000000000..a51d48802 --- /dev/null +++ b/src/Type/LocalObjectType.php @@ -0,0 +1,115 @@ + */ + private \WeakMap $handles; + /** @var array> */ + private array $handlesIndex; + + protected function __clone() + { + // prevent clonning + } + + protected function init(): void + { + $this->instanceUid = hash('sha256', microtime(true) . random_bytes(64)); + $this->localUidCounter = 0; + $this->handles = new \WeakMap(); + $this->handlesIndex = []; + } + + public function getName(): string + { + return Types::LOCAL_OBJECT; + } + + public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string + { + return DbalTypes\Type::getType(DbalTypes\Types::STRING)->getSQLDeclaration($fieldDeclaration, $platform); + } + + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + if ($value === null) { + return null; + } + + if ($this->instanceUid === null) { + $this->init(); + } + + $handle = $this->handles->offsetExists($value) + ? $this->handles->offsetGet($value) + : null; + + if ($handle === null) { + $handle = new LocalObjectHandle(++$this->localUidCounter, $value, function (LocalObjectHandle $handle): void { + unset($this->handlesIndex[$handle->getLocalUid()]); + }); + $this->handles->offsetSet($value, $handle); + $this->handlesIndex[$handle->getLocalUid()] = \WeakReference::create($handle); + } + + $className = get_debug_type($value); + if (strlen($className) > 160) { // keep result below 255 bytes + $className = mb_strcut($className, 0, 80) + . '...' + . mb_strcut(substr($className, strlen(mb_strcut($className, 0, 80))), -80); + } + + return $className . '-' . $this->instanceUid . '-' . $handle->getLocalUid(); + } + + public function convertToPHPValue($value, AbstractPlatform $platform): ?object + { + if ($value === null || trim($value) === '') { + return null; + } + + $valueExploded = explode('-', $value, 3); + if (count($valueExploded) !== 3 + || $valueExploded[1] !== $this->instanceUid + || $valueExploded[2] !== (string) (int) $valueExploded[2] + ) { + throw new Exception('Local object does not match the DBAL type instance'); + } + $handle = $this->handlesIndex[(int) $valueExploded[2]] ?? null; + if ($handle !== null) { + $handle = $handle->get(); + } + $res = $handle !== null ? $handle->getValue() : null; + if ($res === null) { + throw new Exception('Local object does no longer exist'); + } + + return $res; + } + + public function requiresSQLCommentHint(AbstractPlatform $platform): bool + { + return true; + } +} diff --git a/src/Type/MoneyType.php b/src/Type/MoneyType.php new file mode 100644 index 000000000..d3be1304a --- /dev/null +++ b/src/Type/MoneyType.php @@ -0,0 +1,42 @@ +getSQLDeclaration($fieldDeclaration, $platform); + } + + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + if ($value === null || trim((string) $value) === '') { + return null; + } + + return (string) round((float) $value, 4); + } + + public function convertToPHPValue($value, AbstractPlatform $platform): ?float + { + $v = $this->convertToDatabaseValue($value, $platform); + + return $v === null ? null : (float) $v; + } + + public function requiresSQLCommentHint(AbstractPlatform $platform): bool + { + return true; + } +} diff --git a/src/Type/Types.php b/src/Type/Types.php new file mode 100644 index 000000000..ca7d0db3e --- /dev/null +++ b/src/Type/Types.php @@ -0,0 +1,11 @@ + + */ + protected function getLocalObjectHandles(LocalObjectType $type = null): \WeakMap + { + if ($type === null) { + /** @var LocalObjectType */ + $type = DbalTypes\Type::getType('atk4_local_object'); + } + + $platform = $this->getDatabasePlatform(); + + return \Closure::bind(function () use ($type, $platform) { + // make sure handles are initialized + $type->convertToDatabaseValue(new \stdClass(), $platform); + + TestCase::assertSame(count($type->handles), count($type->handlesIndex)); + + return $type->handles; + }, null, LocalObjectType::class)(); + } + + protected function setUp(): void + { + parent::setUp(); + + static::assertCount(0, $this->getLocalObjectHandles()); + } + + protected function tearDown(): void + { + static::assertCount(0, $this->getLocalObjectHandles()); + + parent::tearDown(); + } + + public function testTypeBasic(): void + { + $t1 = new LocalObjectType(); + $t2 = new LocalObjectType(); + $platform = $this->getDatabasePlatform(); + + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + + $v1 = $t1->convertToDatabaseValue($obj1, $platform); + $v2 = $t1->convertToDatabaseValue($obj2, $platform); + $v3 = $t2->convertToDatabaseValue($obj1, $platform); + static::assertMatchesRegularExpression('~^stdClass-\w+-\w+$~', $v1); + static::assertNotSame($v1, $v2); + static::assertNotSame($v1, $v3); + + static::assertSame($obj1, $t1->convertToPHPValue($v1, $platform)); + static::assertSame($obj2, $t1->convertToPHPValue($v2, $platform)); + static::assertSame($obj1, $t2->convertToPHPValue($v3, $platform)); + + static::assertSame($v1, $t1->convertToDatabaseValue($obj1, $platform)); + static::assertSame($obj1, $t1->convertToPHPValue($v1, $platform)); + + static::assertCount(2, $this->getLocalObjectHandles($t1)); + static::assertCount(1, $this->getLocalObjectHandles($t2)); + $obj1WeakRef = \WeakReference::create($obj1); + static::assertSame($obj1, $obj1WeakRef->get()); + unset($obj1); + static::assertCount(1, $this->getLocalObjectHandles($t1)); + static::assertCount(0, $this->getLocalObjectHandles($t2)); + static::assertNull($obj1WeakRef->get()); + unset($obj2); + static::assertCount(0, $this->getLocalObjectHandles($t1)); + + $obj3 = new \stdClass(); + $v4 = $t1->convertToDatabaseValue($obj3, $platform); + static::assertNotNull($v4); + static::assertNotSame($v4, $v1); + static::assertNotSame($v4, $v2); + static::assertNotSame($v4, $v3); + } + + public function testTypeCloneException(): void + { + $t = new LocalObjectType(); + + $this->expectException(\Error::class); + clone $t; + } + + public function testTypeDifferentInstanceException(): void + { + $t1 = new LocalObjectType(); + $t2 = new LocalObjectType(); + $platform = $this->getDatabasePlatform(); + + $obj = new \stdClass(); + $v = $t1->convertToDatabaseValue($obj, $platform); + + $t1->convertToPHPValue($v, $platform); + + $this->expectException(Exception::class); + $t2->convertToPHPValue($v, $platform); + } + + public function testTypeReleasedException(): void + { + $t = new LocalObjectType(); + $platform = $this->getDatabasePlatform(); + + $obj = new \stdClass(); + $v = $t->convertToDatabaseValue($obj, $platform); + + $t->convertToPHPValue($v, $platform); + + unset($obj); + if (\PHP_MAJOR_VERSION < 8) { // force WeakMap polyfill housekeeping + $this->getLocalObjectHandles($t); + } + + $this->expectException(Exception::class); + $t->convertToPHPValue($v, $platform); + } + + public function testEntityKeepsReference(): void + { + $model = new Model($this->db, ['table' => 't']); + $model->addField('v', ['type' => 'atk4_local_object']); + $this->createMigrator($model)->create(); + + $entity = $model->createEntity(); + $obj = new \stdClass(); + $objWeakRef = \WeakReference::create($obj); + $entity->set('v', $obj); + unset($obj); + static::assertNotNull($objWeakRef->get()); + static::assertSame($objWeakRef->get(), $entity->get('v')); + + $entity->save(); + static::assertNotNull($objWeakRef->get()); + static::assertSame($objWeakRef->get(), $entity->get('v')); + + $entity->reload(); + static::assertNotNull($objWeakRef->get()); + static::assertSame($objWeakRef->get(), $entity->get('v')); + + $entity2 = $model->load($entity->getId()); + $entity->unload(); + static::assertNotNull($objWeakRef->get()); + static::assertNull($entity->get('v')); + static::assertSame($objWeakRef->get(), $entity2->get('v')); + + $entity2->unload(); + static::assertNull($objWeakRef->get()); + static::assertNull($entity2->get('v')); + } + + public function testDatabaseValueLengthIsLimited(): void + { + $t = new LocalObjectType(); + $platform = $this->getDatabasePlatform(); + + $obj1 = new LocalObjectDummyClassWithLongNameAWithLongNameBWithLongNameCWithLongNameDWithLongNameEWithLongNameFWithLongNameGWithLongNameHWithLongNameIWithLongNameJWithLongNameKWithLongNameL(); + $obj2 = new class() extends LocalObjectDummyClassWithLongNameAWithLongNameBWithLongNameCWithLongNameDWithLongNameEWithLongNameFWithLongNameGWithLongNameHWithLongNameIWithLongNameJWithLongNameKWithLongNameL {}; + + $v1 = $t->convertToDatabaseValue($obj1, $platform); + $v2 = $t->convertToDatabaseValue($obj2, $platform); + + static::assertSame($obj1, $t->convertToPHPValue($v1, $platform)); + static::assertSame($obj2, $t->convertToPHPValue($v2, $platform)); + + static::assertLessThan(250, strlen($v1)); + static::assertLessThan(250, strlen($v2)); + + static::assertSame('Atk4\Data\Tests\LocalObjectDummyClassWithLongNameAWithLongNameBWithLongNameCWith...eFWithLongNameGWithLongNameHWithLongNameIWithLongNameJWithLongNameKWithLongNameL', explode('-', $v1)[0]); + static::assertSame('Atk4\Data\Tests\LocalObjectDummyClassWithLongNameAWithLongNameBWithLongNameCWith...NameGWithLongNameHWithLongNameIWithLongNameJWithLongNameKWithLongNameL@anonymous', explode('-', $v2)[0]); + } +} + +class LocalObjectDummyClassWithLongNameAWithLongNameBWithLongNameCWithLongNameDWithLongNameEWithLongNameFWithLongNameGWithLongNameHWithLongNameIWithLongNameJWithLongNameKWithLongNameL +{ +} diff --git a/tests/Schema/MigratorTest.php b/tests/Schema/MigratorTest.php index ed8f434ac..c5202ac97 100644 --- a/tests/Schema/MigratorTest.php +++ b/tests/Schema/MigratorTest.php @@ -30,7 +30,8 @@ protected function createDemoMigrator(string $table): Migrator ->field('dt', ['type' => 'date']) ->field('dttm', ['type' => 'datetime']) ->field('fl', ['type' => 'float']) - ->field('mn', ['type' => 'atk4_money']); + ->field('mn', ['type' => 'atk4_money']) + ->field('lobj', ['type' => 'atk4_local_object']); } public function testCreate(): void diff --git a/tests/TypecastingTest.php b/tests/TypecastingTest.php index a4b17c00b..7b86c3420 100644 --- a/tests/TypecastingTest.php +++ b/tests/TypecastingTest.php @@ -142,6 +142,7 @@ public function testEmptyValues(): void 'float' => '', 'json' => '', 'object' => '', + 'local-object' => '', ], ], ]; @@ -161,6 +162,7 @@ public function testEmptyValues(): void $m->addField('float', ['type' => 'float']); $m->addField('json', ['type' => 'json']); $m->addField('object', ['type' => 'object']); + $m->addField('local-object', ['type' => 'atk4_local_object']); $mm = $m->load(1); // Only @@ -175,8 +177,10 @@ public function testEmptyValues(): void static::assertNull($mm->get('float')); static::assertNull($mm->get('json')); static::assertNull($mm->get('object')); + static::assertNull($mm->get('local-object')); unset($row['id']); + unset($row['local-object']); $mm->setMulti($row); static::assertSame('', $mm->get('string')); @@ -190,6 +194,7 @@ public function testEmptyValues(): void static::assertNull($mm->get('float')); static::assertNull($mm->get('json')); static::assertNull($mm->get('object')); + static::assertNull($mm->get('local-object')); if (!$this->getDatabasePlatform() instanceof OraclePlatform) { // @TODO IMPORTANT we probably want to cast to string for Oracle on our own, so dirty array stay clean! static::assertSame([], $mm->getDirtyRef()); } @@ -212,6 +217,7 @@ public function testEmptyValues(): void 'float' => null, 'json' => null, 'object' => null, + 'local-object' => null, ]; static::{'assertEquals'}($dbData, $this->getDb());