Skip to content

Commit 1b35138

Browse files
committed
Add local/weak object DBAL type
1 parent 448e308 commit 1b35138

File tree

4 files changed

+282
-1
lines changed

4 files changed

+282
-1
lines changed

bootstrap-types.php

+126
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,139 @@
44

55
namespace Atk4\Data\Types;
66

7+
use Atk4\Data\Exception;
78
use Doctrine\DBAL\Platforms\AbstractPlatform;
89
use Doctrine\DBAL\Types as DbalTypes;
910

1011
final class Types
1112
{
13+
public const LOCAL_OBJECT = 'atk4_local_object';
1214
public const MONEY = 'atk4_money';
1315
}
1416

17+
class LocalObjectHandle
18+
{
19+
private int $localUid;
20+
21+
/** @var \WeakReference<object> */
22+
private \WeakReference $weakValue;
23+
24+
private \Closure $destructFx;
25+
26+
public function __construct(int $localUid, object $value, \Closure $destructFx)
27+
{
28+
$this->localUid = $localUid;
29+
$this->weakValue = \WeakReference::create($value);
30+
$this->destructFx = $destructFx;
31+
}
32+
33+
public function __destruct()
34+
{
35+
($this->destructFx)($this);
36+
}
37+
38+
public function getLocalUid(): int
39+
{
40+
return $this->localUid;
41+
}
42+
43+
public function getValue(): ?object
44+
{
45+
return $this->weakValue->get();
46+
}
47+
}
48+
49+
/**
50+
* Type that allows to weak reference a local PHP object using a scalar string.
51+
*/
52+
class LocalObjectType extends DbalTypes\Type
53+
{
54+
private ?string $localUidPrefix = null;
55+
56+
private int $localUidCounter;
57+
58+
/** @var \WeakMap<object, LocalObjectHandle> */
59+
private \WeakMap $handles;
60+
/** @var array<int, \WeakReference<LocalObjectHandle>> */
61+
private array $handlesIndex;
62+
63+
protected function __clone()
64+
{
65+
// prevent clonning
66+
}
67+
68+
protected function init(): void
69+
{
70+
$this->localUidPrefix = hash('sha256', microtime(true) . random_bytes(64));
71+
$this->localUidCounter = 0;
72+
$this->handles = new \WeakMap();
73+
$this->handlesIndex = [];
74+
}
75+
76+
public function getName(): string
77+
{
78+
return Types::LOCAL_OBJECT;
79+
}
80+
81+
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string
82+
{
83+
return DbalTypes\Type::getType(DbalTypes\Types::STRING)->getSQLDeclaration($fieldDeclaration, $platform);
84+
}
85+
86+
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
87+
{
88+
if ($value === null) {
89+
return null;
90+
}
91+
92+
if ($this->localUidPrefix === null) {
93+
$this->init();
94+
}
95+
96+
$handle = $this->handles->offsetExists($value)
97+
? $this->handles->offsetGet($value)
98+
: null;
99+
100+
if ($handle === null) {
101+
$handle = new LocalObjectHandle(++$this->localUidCounter, $value, function (LocalObjectHandle $handle): void {
102+
unset($this->handlesIndex[$handle->getLocalUid()]);
103+
});
104+
$this->handles->offsetSet($value, $handle);
105+
$this->handlesIndex[$handle->getLocalUid()] = \WeakReference::create($handle);
106+
}
107+
108+
return $this->localUidPrefix . '-' . $handle->getLocalUid();
109+
}
110+
111+
public function convertToPHPValue($value, AbstractPlatform $platform): ?object
112+
{
113+
if ($value === null || trim($value) === '') {
114+
return null;
115+
}
116+
117+
$handleLocalUid = $this->localUidPrefix !== null && str_starts_with($value, $this->localUidPrefix . '-')
118+
? substr($value, strlen($this->localUidPrefix . '-'))
119+
: null;
120+
if ($handleLocalUid !== null && $handleLocalUid !== (string) (int) $handleLocalUid) {
121+
throw new Exception('Local object does not match the DBAL type instance');
122+
}
123+
$handle = $this->handlesIndex[(int) $handleLocalUid] ?? null;
124+
if ($handle !== null) {
125+
$handle = $handle->get();
126+
}
127+
if ($handle === null) {
128+
throw new Exception('Local object does no longer exist');
129+
}
130+
131+
return $handle->getValue();
132+
}
133+
134+
public function requiresSQLCommentHint(AbstractPlatform $platform): bool
135+
{
136+
return true;
137+
}
138+
}
139+
15140
class MoneyType extends DbalTypes\Type
16141
{
17142
public function getName(): string
@@ -46,4 +171,5 @@ public function requiresSQLCommentHint(AbstractPlatform $platform): bool
46171
}
47172
}
48173

174+
DbalTypes\Type::addType(Types::LOCAL_OBJECT, LocalObjectType::class);
49175
DbalTypes\Type::addType(Types::MONEY, MoneyType::class);

tests/LocalObjectTest.php

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Atk4\Data\Tests;
6+
7+
use Atk4\Data\Exception;
8+
use Atk4\Data\Model;
9+
use Atk4\Data\Schema\TestCase;
10+
use Atk4\Data\Types\LocalObjectType;
11+
use Doctrine\DBAL\Types as DbalTypes;
12+
13+
class LocalObjectTest extends TestCase
14+
{
15+
/**
16+
* @return \WeakMap<object, LocalObjectHandle>
17+
*/
18+
protected function getLocalObjectHandles(LocalObjectType $type = null): \WeakMap
19+
{
20+
if ($type === null) {
21+
$type = DbalTypes\Type::getType('atk4_local_object');
22+
}
23+
24+
$platform = $this->getDatabasePlatform();
25+
26+
return \Closure::bind(function () use ($type, $platform) {
27+
// make sure handles are initialized
28+
$type->convertToDatabaseValue(new \stdClass(), $platform);
29+
30+
TestCase::assertSame(count($type->handles), count($type->handlesIndex));
31+
32+
return $type->handles;
33+
}, null, LocalObjectType::class)();
34+
}
35+
36+
protected function setUp(): void
37+
{
38+
parent::setUp();
39+
40+
static::assertCount(0, $this->getLocalObjectHandles());
41+
}
42+
43+
protected function tearDown(): void
44+
{
45+
static::assertCount(0, $this->getLocalObjectHandles());
46+
47+
parent::tearDown();
48+
}
49+
50+
public function testTypeBasic(): void
51+
{
52+
$t1 = new LocalObjectType();
53+
$t2 = new LocalObjectType();
54+
$platform = $this->getDatabasePlatform();
55+
56+
$obj1 = new \stdClass();
57+
$obj2 = new \stdClass();
58+
59+
$v1 = $t1->convertToDatabaseValue($obj1, $platform);
60+
$v2 = $t1->convertToDatabaseValue($obj2, $platform);
61+
$v3 = $t2->convertToDatabaseValue($obj1, $platform);
62+
static::assertMatchesRegularExpression('~^\w+-\w+$~', $v1);
63+
static::assertNotSame($v1, $v2);
64+
static::assertNotSame($v1, $v3);
65+
66+
static::assertSame($obj1, $t1->convertToPHPValue($v1, $platform));
67+
static::assertSame($obj2, $t1->convertToPHPValue($v2, $platform));
68+
static::assertSame($obj1, $t2->convertToPHPValue($v3, $platform));
69+
70+
static::assertSame($v1, $t1->convertToDatabaseValue($obj1, $platform));
71+
static::assertSame($obj1, $t1->convertToPHPValue($v1, $platform));
72+
73+
static::assertCount(2, $this->getLocalObjectHandles($t1));
74+
static::assertCount(1, $this->getLocalObjectHandles($t2));
75+
$obj1WeakRef = \WeakReference::create($obj1);
76+
static::assertSame($obj1, $obj1WeakRef->get());
77+
unset($obj1);
78+
static::assertCount(1, $this->getLocalObjectHandles($t1));
79+
static::assertCount(0, $this->getLocalObjectHandles($t2));
80+
static::assertNull($obj1WeakRef->get());
81+
unset($obj2);
82+
static::assertCount(0, $this->getLocalObjectHandles($t1));
83+
}
84+
85+
public function testTypeCloneException(): void
86+
{
87+
$t = new LocalObjectType();
88+
89+
$this->expectException(\Error::class);
90+
clone $t;
91+
}
92+
93+
public function testTypeDifferentInstanceException(): void
94+
{
95+
$t1 = new LocalObjectType();
96+
$t2 = new LocalObjectType();
97+
$platform = $this->getDatabasePlatform();
98+
99+
$obj = new \stdClass();
100+
$v = $t1->convertToDatabaseValue($obj, $platform);
101+
102+
$t1->convertToPHPValue($v, $platform);
103+
104+
$this->expectException(Exception::class);
105+
$t2->convertToPHPValue($v, $platform);
106+
}
107+
108+
public function testTypeReleasedException(): void
109+
{
110+
$t = new LocalObjectType();
111+
$platform = $this->getDatabasePlatform();
112+
113+
$obj = new \stdClass();
114+
$v = $t->convertToDatabaseValue($obj, $platform);
115+
116+
$t->convertToPHPValue($v, $platform);
117+
118+
unset($obj);
119+
if (\PHP_MAJOR_VERSION < 8) { // force WeakMap polyfill housekeeping
120+
$this->getLocalObjectHandles($t);
121+
}
122+
123+
$this->expectException(Exception::class);
124+
$t->convertToPHPValue($v, $platform);
125+
}
126+
127+
public function testEntityKeepsReference(): void
128+
{
129+
$model = new Model($this->db, ['table' => 't']);
130+
$model->addField('v', ['type' => 'atk4_local_object']);
131+
$this->createMigrator($model)->create();
132+
133+
$entity = $model->createEntity();
134+
$obj = new \stdClass();
135+
$objWeakRef = \WeakReference::create($obj);
136+
$entity->set('v', $obj);
137+
unset($obj);
138+
139+
static::assertNotNull($objWeakRef->get());
140+
static::assertSame($objWeakRef->get(), $entity->get('v'));
141+
$entity->save();
142+
143+
unset($entity);
144+
145+
gc_collect_cycles();
146+
gc_collect_cycles();
147+
}
148+
}

tests/Schema/MigratorTest.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ protected function createDemoMigrator(string $table): Migrator
3030
->field('dt', ['type' => 'date'])
3131
->field('dttm', ['type' => 'datetime'])
3232
->field('fl', ['type' => 'float'])
33-
->field('mn', ['type' => 'atk4_money']);
33+
->field('mn', ['type' => 'atk4_money'])
34+
->field('lobj', ['type' => 'atk4_local_object']);
3435
}
3536

3637
public function testCreate(): void

tests/TypecastingTest.php

+6
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ public function testEmptyValues(): void
142142
'float' => '',
143143
'json' => '',
144144
'object' => '',
145+
'local-object' => '',
145146
],
146147
],
147148
];
@@ -161,6 +162,7 @@ public function testEmptyValues(): void
161162
$m->addField('float', ['type' => 'float']);
162163
$m->addField('json', ['type' => 'json']);
163164
$m->addField('object', ['type' => 'object']);
165+
$m->addField('local-object', ['type' => 'atk4_local_object']);
164166
$mm = $m->load(1);
165167

166168
// Only
@@ -175,8 +177,10 @@ public function testEmptyValues(): void
175177
static::assertNull($mm->get('float'));
176178
static::assertNull($mm->get('json'));
177179
static::assertNull($mm->get('object'));
180+
static::assertNull($mm->get('local-object'));
178181

179182
unset($row['id']);
183+
unset($row['local-object']);
180184
$mm->setMulti($row);
181185

182186
static::assertSame('', $mm->get('string'));
@@ -190,6 +194,7 @@ public function testEmptyValues(): void
190194
static::assertNull($mm->get('float'));
191195
static::assertNull($mm->get('json'));
192196
static::assertNull($mm->get('object'));
197+
static::assertNull($mm->get('local-object'));
193198
if (!$this->getDatabasePlatform() instanceof OraclePlatform) { // @TODO IMPORTANT we probably want to cast to string for Oracle on our own, so dirty array stay clean!
194199
static::assertSame([], $mm->getDirtyRef());
195200
}
@@ -212,6 +217,7 @@ public function testEmptyValues(): void
212217
'float' => null,
213218
'json' => null,
214219
'object' => null,
220+
'local-object' => null,
215221
];
216222

217223
static::{'assertEquals'}($dbData, $this->getDb());

0 commit comments

Comments
 (0)