Skip to content

Commit b8e383c

Browse files
abnegateclaude
andcommitted
feat(memory): support relationships
Flip getSupportForRelationships() to true and implement createRelationship, updateRelationship, and deleteRelationship by registering relationship fields on the in-memory attribute list. Reads now surface registered relationship keys as null when unpopulated, matching MariaDB's `ADD COLUMN ... DEFAULT NULL` semantics so the wrapper can populate, traverse, and remove relationships consistently across drivers. updateDocument now applies a sparse merge against the existing row instead of replacing it wholesale, so the wrapper's pattern of removing unchanged relationship attributes before update no longer clobbers stored FKs. resolveAttributeValue tries the fully-filtered column name before falling back to dot-path traversal so relationship keys whose names contain dots (e.g. `\$symbols_coll.ection3`) match the row's flattened column. Cascade and metadata orchestration continue to live in the Database wrapper; the adapter only mutates the underlying row maps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 024ddf0 commit b8e383c

1 file changed

Lines changed: 262 additions & 11 deletions

File tree

src/Database/Adapter/Memory.php

Lines changed: 262 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -414,17 +414,238 @@ public function renameAttribute(string $collection, string $old, string $new): b
414414

415415
public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool
416416
{
417-
throw new DatabaseException('Relationships are not implemented in the Memory adapter');
417+
// Memory stores documents as flexible maps, so the relationship "column"
418+
// is registered on the attribute list rather than added as a physical
419+
// schema column. The registration ensures that reads always surface
420+
// the relationship key (as null when unpopulated) — matching MariaDB,
421+
// which selects the column even when no rows have a value.
422+
// The M2M junction collection itself is created by the wrapper through
423+
// the standard createCollection path.
424+
switch ($type) {
425+
case Database::RELATION_ONE_TO_ONE:
426+
$this->registerRelationshipField($collection, $id);
427+
if ($twoWay) {
428+
$this->registerRelationshipField($relatedCollection, $twoWayKey);
429+
}
430+
break;
431+
case Database::RELATION_ONE_TO_MANY:
432+
$this->registerRelationshipField($relatedCollection, $twoWayKey);
433+
break;
434+
case Database::RELATION_MANY_TO_ONE:
435+
$this->registerRelationshipField($collection, $id);
436+
break;
437+
case Database::RELATION_MANY_TO_MANY:
438+
// Junction columns live on the junction collection, which is
439+
// created with explicit attributes by the wrapper.
440+
break;
441+
default:
442+
throw new DatabaseException('Invalid relationship type');
443+
}
444+
445+
return true;
418446
}
419447

420448
public function updateRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side, ?string $newKey = null, ?string $newTwoWayKey = null): bool
421449
{
422-
throw new DatabaseException('Relationships are not implemented in the Memory adapter');
450+
$key = $this->filter($key);
451+
$twoWayKey = $this->filter($twoWayKey);
452+
$newKey = $newKey !== null ? $this->filter($newKey) : null;
453+
$newTwoWayKey = $newTwoWayKey !== null ? $this->filter($newTwoWayKey) : null;
454+
455+
switch ($type) {
456+
case Database::RELATION_ONE_TO_ONE:
457+
if ($newKey !== null && $newKey !== $key) {
458+
$this->renameDocumentField($collection, $key, $newKey);
459+
}
460+
if ($twoWay && $newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) {
461+
$this->renameDocumentField($relatedCollection, $twoWayKey, $newTwoWayKey);
462+
}
463+
break;
464+
case Database::RELATION_ONE_TO_MANY:
465+
if ($side === Database::RELATION_SIDE_PARENT) {
466+
if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) {
467+
$this->renameDocumentField($relatedCollection, $twoWayKey, $newTwoWayKey);
468+
}
469+
} else {
470+
if ($newKey !== null && $newKey !== $key) {
471+
$this->renameDocumentField($collection, $key, $newKey);
472+
}
473+
}
474+
break;
475+
case Database::RELATION_MANY_TO_ONE:
476+
if ($side === Database::RELATION_SIDE_CHILD) {
477+
if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) {
478+
$this->renameDocumentField($relatedCollection, $twoWayKey, $newTwoWayKey);
479+
}
480+
} else {
481+
if ($newKey !== null && $newKey !== $key) {
482+
$this->renameDocumentField($collection, $key, $newKey);
483+
}
484+
}
485+
break;
486+
case Database::RELATION_MANY_TO_MANY:
487+
$junction = $this->resolveJunctionCollection($collection, $relatedCollection, $side);
488+
if ($junction !== null) {
489+
if ($newKey !== null && $newKey !== $key) {
490+
$this->renameDocumentField($junction, $key, $newKey);
491+
}
492+
if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) {
493+
$this->renameDocumentField($junction, $twoWayKey, $newTwoWayKey);
494+
}
495+
}
496+
break;
497+
default:
498+
throw new DatabaseException('Invalid relationship type');
499+
}
500+
501+
return true;
423502
}
424503

425504
public function deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side): bool
426505
{
427-
throw new DatabaseException('Relationships are not implemented in the Memory adapter');
506+
$key = $this->filter($key);
507+
$twoWayKey = $this->filter($twoWayKey);
508+
509+
switch ($type) {
510+
case Database::RELATION_ONE_TO_ONE:
511+
if ($side === Database::RELATION_SIDE_PARENT) {
512+
$this->dropDocumentField($collection, $key);
513+
if ($twoWay) {
514+
$this->dropDocumentField($relatedCollection, $twoWayKey);
515+
}
516+
} else {
517+
$this->dropDocumentField($relatedCollection, $twoWayKey);
518+
if ($twoWay) {
519+
$this->dropDocumentField($collection, $key);
520+
}
521+
}
522+
break;
523+
case Database::RELATION_ONE_TO_MANY:
524+
if ($side === Database::RELATION_SIDE_PARENT) {
525+
$this->dropDocumentField($relatedCollection, $twoWayKey);
526+
} else {
527+
$this->dropDocumentField($collection, $key);
528+
}
529+
break;
530+
case Database::RELATION_MANY_TO_ONE:
531+
if ($side === Database::RELATION_SIDE_PARENT) {
532+
$this->dropDocumentField($collection, $key);
533+
} else {
534+
$this->dropDocumentField($relatedCollection, $twoWayKey);
535+
}
536+
break;
537+
case Database::RELATION_MANY_TO_MANY:
538+
// Junction collection is dropped by the wrapper via cleanupCollection.
539+
break;
540+
default:
541+
throw new DatabaseException('Invalid relationship type');
542+
}
543+
544+
return true;
545+
}
546+
547+
/**
548+
* Register a relationship field on the collection's attribute list so
549+
* subsequent reads materialise the field (as null) even when no document
550+
* has been written to it. Mirrors MariaDB's `ADD COLUMN ... DEFAULT NULL`.
551+
*/
552+
protected function registerRelationshipField(string $collection, string $field): void
553+
{
554+
$key = $this->key($collection);
555+
if (! isset($this->data[$key])) {
556+
return;
557+
}
558+
$field = $this->filter($field);
559+
$this->data[$key]['attributes'][$field] = [
560+
'type' => Database::VAR_RELATIONSHIP,
561+
'size' => 0,
562+
'signed' => true,
563+
'array' => false,
564+
'required' => false,
565+
];
566+
}
567+
568+
/**
569+
* Unregister a relationship field from the collection's attribute list.
570+
*/
571+
protected function unregisterRelationshipField(string $collection, string $field): void
572+
{
573+
$key = $this->key($collection);
574+
if (! isset($this->data[$key])) {
575+
return;
576+
}
577+
unset($this->data[$key]['attributes'][$this->filter($field)]);
578+
}
579+
580+
/**
581+
* Rename a field across every document in a collection, preserving null
582+
* entries so subsequent reads that join on the new key still resolve.
583+
*/
584+
protected function renameDocumentField(string $collection, string $oldKey, string $newKey): void
585+
{
586+
$key = $this->key($collection);
587+
if (! isset($this->data[$key])) {
588+
return;
589+
}
590+
if (isset($this->data[$key]['attributes'][$oldKey])) {
591+
$this->data[$key]['attributes'][$newKey] = $this->data[$key]['attributes'][$oldKey];
592+
unset($this->data[$key]['attributes'][$oldKey]);
593+
}
594+
foreach ($this->data[$key]['documents'] as $storageKey => $document) {
595+
if (! \array_key_exists($oldKey, $document)) {
596+
continue;
597+
}
598+
$document[$newKey] = $document[$oldKey];
599+
unset($document[$oldKey]);
600+
$this->data[$key]['documents'][$storageKey] = $document;
601+
}
602+
}
603+
604+
/**
605+
* Remove a field from every document in a collection.
606+
*/
607+
protected function dropDocumentField(string $collection, string $field): void
608+
{
609+
$key = $this->key($collection);
610+
if (! isset($this->data[$key])) {
611+
return;
612+
}
613+
unset($this->data[$key]['attributes'][$field]);
614+
foreach ($this->data[$key]['documents'] as $storageKey => $document) {
615+
if (\array_key_exists($field, $document)) {
616+
unset($document[$field]);
617+
$this->data[$key]['documents'][$storageKey] = $document;
618+
}
619+
}
620+
}
621+
622+
/**
623+
* Resolve the junction collection name for a many-to-many relationship.
624+
* Mirrors Database::getJunctionCollection — the junction is named after
625+
* the parent/child sequence pair.
626+
*/
627+
protected function resolveJunctionCollection(string $collection, string $relatedCollection, string $side): ?string
628+
{
629+
$metadataKey = $this->key(Database::METADATA);
630+
if (! isset($this->data[$metadataKey])) {
631+
return null;
632+
}
633+
634+
$collectionDoc = $this->locateDocument($metadataKey, Database::METADATA, $collection);
635+
$relatedDoc = $this->locateDocument($metadataKey, Database::METADATA, $relatedCollection);
636+
if ($collectionDoc === null || $relatedDoc === null) {
637+
return null;
638+
}
639+
640+
$collectionSequence = $collectionDoc[1]['_id'] ?? null;
641+
$relatedSequence = $relatedDoc[1]['_id'] ?? null;
642+
if ($collectionSequence === null || $relatedSequence === null) {
643+
return null;
644+
}
645+
646+
return $side === Database::RELATION_SIDE_PARENT
647+
? '_'.$collectionSequence.'_'.$relatedSequence
648+
: '_'.$relatedSequence.'_'.$collectionSequence;
428649
}
429650

430651
public function renameIndex(string $collection, string $old, string $new): bool
@@ -513,7 +734,7 @@ public function getDocument(Document $collection, string $id, array $queries = [
513734
return new Document([]);
514735
}
515736

516-
$row = $this->rowToDocument($located[1]);
737+
$row = $this->rowToDocument($located[1], null, $key);
517738

518739
// Apply Query::select projection — drop user attributes that were not
519740
// requested but always retain the document internals ($id, $sequence,
@@ -666,7 +887,13 @@ public function updateDocument(Document $collection, string $id, Document $docum
666887

667888
$this->enforceUniqueIndexes($key, $document, $id);
668889

669-
$row = $this->documentToRow($document);
890+
$update = $this->documentToRow($document);
891+
892+
// Sparse update — MariaDB's UPDATE only sets columns present in the
893+
// document; absent columns retain their previous values. The wrapper
894+
// relies on this for relationship updates, where it removes
895+
// unchanged relationship keys before calling the adapter.
896+
$row = \array_merge($existing, $update);
670897
$row['_id'] = $existing['_id'];
671898
if ($this->sharedTables && \array_key_exists('_tenant', $existing)) {
672899
// Preserve the row's stored tenant — MariaDB's UPDATE statements
@@ -899,7 +1126,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
8991126
$selections = $this->extractSelections($queries);
9001127
$results = [];
9011128
foreach ($rows as $row) {
902-
$results[] = new Document($this->rowToDocument($row, $selections));
1129+
$results[] = new Document($this->rowToDocument($row, $selections, $key));
9031130
}
9041131

9051132
if ($cursorDirection === Database::CURSOR_BEFORE) {
@@ -1134,7 +1361,7 @@ public function getSupportForTimeouts(): bool
11341361

11351362
public function getSupportForRelationships(): bool
11361363
{
1137-
return false;
1364+
return true;
11381365
}
11391366

11401367
public function getSupportForUpdateLock(): bool
@@ -1467,7 +1694,7 @@ protected function documentToRow(Document $document): array
14671694
* @param array<int, string>|null $selections
14681695
* @return array<string, mixed>
14691696
*/
1470-
protected function rowToDocument(array $row, ?array $selections = null): array
1697+
protected function rowToDocument(array $row, ?array $selections = null, ?string $storageKey = null): array
14711698
{
14721699
$allowed = null;
14731700
if ($selections !== null && $selections !== [] && ! \in_array('*', $selections, true)) {
@@ -1506,6 +1733,22 @@ protected function rowToDocument(array $row, ?array $selections = null): array
15061733
}
15071734
}
15081735

1736+
// Surface registered relationship fields as null when missing — mirrors
1737+
// MariaDB selecting a `DEFAULT NULL` column even when no row has set it.
1738+
if ($storageKey !== null && isset($this->data[$storageKey]['attributes'])) {
1739+
foreach ($this->data[$storageKey]['attributes'] as $attributeId => $definition) {
1740+
if (($definition['type'] ?? null) !== Database::VAR_RELATIONSHIP) {
1741+
continue;
1742+
}
1743+
if ($allowed !== null && ! isset($allowed[$attributeId])) {
1744+
continue;
1745+
}
1746+
if (! \array_key_exists($attributeId, $document)) {
1747+
$document[$attributeId] = null;
1748+
}
1749+
}
1750+
}
1751+
15091752
return $document;
15101753
}
15111754

@@ -2104,10 +2347,18 @@ protected function resolveDocumentValue(Document $document, string $attribute):
21042347
*/
21052348
protected function resolveAttributeValue(array $row, string $attribute): mixed
21062349
{
2350+
// MariaDB strips non-alphanumeric chars from column names before any
2351+
// SELECT, so an attribute like `$symbols_coll.ection3` becomes a flat
2352+
// `symbols_collection3` column. Mirror that: if the fully-filtered name
2353+
// exists as a column on the row, return it directly without splitting
2354+
// on dots — only fall back to nested object path traversal when the
2355+
// flat lookup misses.
2356+
$flatColumn = $this->mapAttribute($attribute);
2357+
if (\array_key_exists($flatColumn, $row)) {
2358+
return $row[$flatColumn];
2359+
}
21072360
if (! \str_contains($attribute, '.')) {
2108-
$column = $this->mapAttribute($attribute);
2109-
2110-
return \array_key_exists($column, $row) ? $row[$column] : null;
2361+
return null;
21112362
}
21122363

21132364
[$head, $rest] = \explode('.', $attribute, 2);

0 commit comments

Comments
 (0)