Skip to content

Commit 0c4daf6

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

File tree

5 files changed

+296
-1
lines changed

5 files changed

+296
-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);

src/Model.php

+1
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,7 @@ public function loadAny()
13261326
public function reload()
13271327
{
13281328
$id = $this->getId();
1329+
$data = $this->getDataRef(); // keep weakly persisted objects referenced
13291330
$this->unload();
13301331

13311332
$res = $this->_load(true, false, $id);

tests/LocalObjectTest.php

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

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)