@@ -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