Skip to content

Commit 6e6f825

Browse files
committed
Relationships: route to UpdateInPlace, delete dead drop-mirror code
Relationships can't take the DropAndRecreate path cleanly: - utopia's deleteAttribute throws for VAR_RELATIONSHIP - Two-way drops cascade to the partner table's physical column and destroy its row data (partner's rows migrated earlier in the run won't get re-migrated) Route relationships through SchemaAction::UpdateInPlace instead: - canDrop: !$isRelationship on resolveSchemaAction - New updateRelationshipInPlace() helper calls $dbForDatabases->updateRelationship() for the fields utopia supports (newTwoWayKey, twoWay, onDelete) + updateDocument() on Appwrite's application-level doc - relationType changes can't be updated in place and would silently diverge utopia's internal metadata from Appwrite's doc; throw a clear error asking the caller to drop+recreate manually Each side of a two-way is authoritative for its own Appwrite-level doc. utopia's updateRelationship cascades internal metadata on both sides in one call and is idempotent for same-target writes, so neither side's pass stomps the other. Dead-code cleanup: delete dropTwoWayMirror() and the relationship branch of dropAttributeForRecreate — both were only reachable from paths that no longer fire. Simplifies dropAttributeForRecreate signature (drops unused $type and $relatedTable params). Fix dropOrphanAttribute for relationship orphans: deleteAttribute throws for VAR_RELATIONSHIP, so use deleteRelationship which cascades both sides' physical columns + utopia metadata. Clean the two Appwrite-level docs separately. All 34 unit tests pass, pint + phpstan L3 clean.
1 parent 791dbb1 commit 6e6f825

1 file changed

Lines changed: 102 additions & 105 deletions

File tree

src/Migration/Destinations/Appwrite.php

Lines changed: 102 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -104,18 +104,6 @@ class Appwrite extends Destination
104104
*/
105105
private array $orphansByTable = [];
106106

107-
/**
108-
* Set of two-way relationship pair identities already reconciled during
109-
* this run. Keyed by a canonical pair-key independent of which side is
110-
* being processed. Used by createField to short-circuit the partner's
111-
* second pass — the first side's action already reconciled both sides
112-
* (Tolerate no-ops, DropAndRecreate drops + recreates both via
113-
* dropTwoWayMirror + createRelationship).
114-
*
115-
* @var array<string, true>
116-
*/
117-
private array $processedTwoWayPairs = [];
118-
119107
/**
120108
* @param string $project
121109
* @param string $endpoint
@@ -757,19 +745,11 @@ protected function createField(Column|Attribute $resource): bool
757745

758746
$this->trackOrphanCandidate($database, $table, 'attributeKeys', $resource->getKey(), $dbForDatabases);
759747

760-
// Two-way relationships have mirrored metadata + columns on both
761-
// tables. Processing one side already reconciles both (Tolerate
762-
// no-ops, DropAndRecreate drops + recreates both via
763-
// dropTwoWayMirror + createRelationship). When the partner side
764-
// comes through, skip it — re-processing would either double-drop
765-
// (destroying row data on tables whose rows already migrated) or
766-
// waste work. The first side always wins; subsequent partner is
767-
// idempotent from the orchestrator's perspective.
768-
$twoWayPairKey = $this->twoWayPairKey($database, $table, $resource, $type);
769-
if ($twoWayPairKey !== null && isset($this->processedTwoWayPairs[$twoWayPairKey])) {
770-
$resource->setStatus(Resource::STATUS_SKIPPED, 'Two-way partner already reconciled');
771-
return false;
772-
}
748+
// Relationships route to UpdateInPlace, not DropAndRecreate:
749+
// utopia's deleteAttribute throws for VAR_RELATIONSHIP, and
750+
// two-way drops would cascade to the partner table's physical
751+
// column. Non-relationship attrs keep DropAndRecreate.
752+
$isRelationship = $type === UtopiaDatabase::VAR_RELATIONSHIP;
773753

774754
$attributeMetaId = $database->getSequence() . '_' . $table->getSequence() . '_' . $resource->getKey();
775755
if ($this->onDuplicate !== OnDuplicate::Fail) {
@@ -778,28 +758,31 @@ protected function createField(Column|Attribute $resource): bool
778758
!$existingAttr->isEmpty(),
779759
$updatedAt,
780760
$existingAttr->getUpdatedAt(),
781-
canDrop: true,
761+
canDrop: !$isRelationship,
782762
);
783763

784764
if ($action === SchemaAction::Tolerate) {
785765
$this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $table->getId());
786766
$dbForDatabases->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $table->getSequence());
787767
$resource->setStatus(Resource::STATUS_SKIPPED, 'Already exists on destination');
788-
if ($twoWayPairKey !== null) {
789-
$this->processedTwoWayPairs[$twoWayPairKey] = true;
790-
}
791768
return false;
792769
}
793770

794-
if ($action === SchemaAction::DropAndRecreate) {
795-
$this->dropAttributeForRecreate(
771+
if ($action === SchemaAction::UpdateInPlace) {
772+
$this->updateRelationshipInPlace(
796773
$database,
797774
$table,
798775
$resource,
799776
$type,
800-
$relatedTable ?? null,
777+
$updatedAt,
778+
$existingAttr,
801779
$dbForDatabases,
802780
);
781+
return true;
782+
}
783+
784+
if ($action === SchemaAction::DropAndRecreate) {
785+
$this->dropAttributeForRecreate($database, $table, $resource, $dbForDatabases);
803786
}
804787
}
805788

@@ -969,10 +952,6 @@ protected function createField(Column|Attribute $resource): bool
969952
$this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $table->getId());
970953
$dbForDatabases->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $table->getSequence());
971954

972-
if ($twoWayPairKey !== null) {
973-
$this->processedTwoWayPairs[$twoWayPairKey] = true;
974-
}
975-
976955
return true;
977956
}
978957

@@ -1301,18 +1280,16 @@ protected function createRecord(Row $resource, bool $isLast): bool
13011280
}
13021281

13031282
/**
1304-
* Drop an attribute (metadata doc + physical column) so it can be recreated.
1305-
*
1306-
* Two-way relationships require mirroring: createField writes a second
1307-
* `attributes` document on the related table keyed by twoWayKey. Dropping
1308-
* only the parent leaves that child doc dangling and the recreate collides.
1283+
* Drop a non-relationship attribute (metadata doc + physical column) so
1284+
* it can be recreated. Not used for relationships — those route through
1285+
* UpdateInPlace since utopia's deleteAttribute throws for VAR_RELATIONSHIP
1286+
* and dropping a relationship column would cascade data loss to the
1287+
* partner table's rows.
13091288
*/
13101289
private function dropAttributeForRecreate(
13111290
UtopiaDocument $database,
13121291
UtopiaDocument $table,
13131292
Column|Attribute $resource,
1314-
string $type,
1315-
?UtopiaDocument $relatedTable,
13161293
UtopiaDatabase $dbForDatabases,
13171294
): void {
13181295
$collectionId = 'database_' . $database->getSequence() . '_collection_' . $table->getSequence();
@@ -1322,40 +1299,79 @@ private function dropAttributeForRecreate(
13221299
$this->dbForProject->deleteDocument('attributes', $attributeMetaId);
13231300
$this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $table->getId());
13241301
$dbForDatabases->purgeCachedCollection($collectionId);
1325-
1326-
if ($type !== UtopiaDatabase::VAR_RELATIONSHIP || $relatedTable === null) {
1327-
return;
1328-
}
1329-
$options = $resource->getOptions();
1330-
if (empty($options['twoWay'])) {
1331-
return;
1332-
}
1333-
$twoWayKey = (string) ($options['twoWayKey'] ?? '');
1334-
if ($twoWayKey === '') {
1335-
return;
1336-
}
1337-
1338-
$this->dropTwoWayMirror($database, $relatedTable, $twoWayKey, $dbForDatabases);
13391302
}
13401303

13411304
/**
1342-
* Drop the child-side `attributes` metadata doc + physical column that
1343-
* createField writes for two-way relationships. Shared by the DropAndRecreate
1344-
* path (pre-fetched $relatedTable) and the orphan cleanup path.
1305+
* Reconcile a relationship attribute's metadata on destination without
1306+
* dropping its physical column — keeps row data intact.
1307+
*
1308+
* Two layers must stay in sync: utopia-php/database's internal
1309+
* `_metadata` collection (used for row validation) and Appwrite's
1310+
* application-level `attributes` doc (used by the Appwrite API). Each
1311+
* side's own pass is authoritative for its own Appwrite-level doc;
1312+
* utopia's updateRelationship cascades internal metadata on both sides
1313+
* in one call and is idempotent across passes for same-target writes.
1314+
*
1315+
* relationType changes aren't supported by updateRelationship (they'd
1316+
* require a physical schema change). Fail fast rather than silently
1317+
* diverge utopia's view from Appwrite's view.
13451318
*/
1346-
private function dropTwoWayMirror(
1319+
private function updateRelationshipInPlace(
13471320
UtopiaDocument $database,
1348-
UtopiaDocument $relatedTable,
1349-
string $twoWayKey,
1321+
UtopiaDocument $table,
1322+
Column|Attribute $resource,
1323+
string $type,
1324+
string $updatedAt,
1325+
UtopiaDocument $existingAttr,
13501326
UtopiaDatabase $dbForDatabases,
13511327
): void {
1352-
$childCollectionId = 'database_' . $database->getSequence() . '_collection_' . $relatedTable->getSequence();
1353-
$childMetaId = $database->getSequence() . '_' . $relatedTable->getSequence() . '_' . $twoWayKey;
1328+
$collectionId = 'database_' . $database->getSequence() . '_collection_' . $table->getSequence();
1329+
$sourceOptions = $resource->getOptions();
1330+
$destOptions = $existingAttr->getAttribute('options', []);
13541331

1355-
$this->tolerateMissing(fn () => $this->dbForProject->deleteDocument('attributes', $childMetaId));
1356-
$this->tolerateMissing(fn () => $dbForDatabases->deleteAttribute($childCollectionId, $twoWayKey));
1357-
$this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $relatedTable->getId());
1358-
$dbForDatabases->purgeCachedCollection($childCollectionId);
1332+
if (
1333+
isset($sourceOptions['relationType'], $destOptions['relationType'])
1334+
&& $sourceOptions['relationType'] !== $destOptions['relationType']
1335+
) {
1336+
throw new Exception(
1337+
resourceName: $resource->getName(),
1338+
resourceGroup: $resource->getGroup(),
1339+
resourceId: $resource->getId(),
1340+
message: 'Changing relationType on a migrated relationship is not supported; drop and recreate the relationship on the destination manually before re-running the migration.',
1341+
);
1342+
}
1343+
1344+
$dbForDatabases->updateRelationship(
1345+
collection: $collectionId,
1346+
id: $resource->getKey(),
1347+
newTwoWayKey: ($sourceOptions['twoWayKey'] ?? null) !== ($destOptions['twoWayKey'] ?? null)
1348+
? (string) ($sourceOptions['twoWayKey'] ?? '')
1349+
: null,
1350+
twoWay: ($sourceOptions['twoWay'] ?? null) !== ($destOptions['twoWay'] ?? null)
1351+
? (bool) ($sourceOptions['twoWay'] ?? false)
1352+
: null,
1353+
onDelete: ($sourceOptions['onDelete'] ?? null) !== ($destOptions['onDelete'] ?? null)
1354+
? (string) ($sourceOptions['onDelete'] ?? '')
1355+
: null,
1356+
);
1357+
1358+
$this->dbForProject->updateDocument('attributes', $existingAttr->getId(), new UtopiaDocument([
1359+
'key' => $resource->getKey(),
1360+
'type' => $type,
1361+
'size' => $resource->getSize(),
1362+
'required' => $resource->isRequired(),
1363+
'signed' => $resource->isSigned(),
1364+
'default' => $resource->getDefault(),
1365+
'array' => $resource->isArray(),
1366+
'format' => $resource->getFormat(),
1367+
'formatOptions' => $resource->getFormatOptions(),
1368+
'filters' => $resource->getFilters(),
1369+
'options' => $sourceOptions,
1370+
'$updatedAt' => $updatedAt,
1371+
]));
1372+
1373+
$this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $table->getId());
1374+
$dbForDatabases->purgeCachedCollection($collectionId);
13591375
}
13601376

13611377
/**
@@ -1368,38 +1384,6 @@ private function tableIdentity(UtopiaDocument $database, UtopiaDocument $table):
13681384
return $database->getSequence() . ':' . $table->getSequence();
13691385
}
13701386

1371-
/**
1372-
* Canonical identity for a two-way relationship pair — same string
1373-
* regardless of which side (parent or child) is being processed.
1374-
* Returns null when the resource isn't a two-way relationship (no
1375-
* pair-level tracking needed).
1376-
*/
1377-
private function twoWayPairKey(
1378-
UtopiaDocument $database,
1379-
UtopiaDocument $table,
1380-
Column|Attribute $resource,
1381-
string $type,
1382-
): ?string {
1383-
if ($type !== UtopiaDatabase::VAR_RELATIONSHIP) {
1384-
return null;
1385-
}
1386-
$options = $resource->getOptions();
1387-
if (empty($options['twoWay'])) {
1388-
return null;
1389-
}
1390-
$twoWayKey = (string) ($options['twoWayKey'] ?? '');
1391-
$relatedTableId = (string) ($options['relatedCollection'] ?? '');
1392-
if ($twoWayKey === '' || $relatedTableId === '') {
1393-
return null;
1394-
}
1395-
1396-
$thisSide = $table->getId() . '::' . $resource->getKey();
1397-
$partnerSide = $relatedTableId . '::' . $twoWayKey;
1398-
$pair = [$thisSide, $partnerSide];
1399-
\sort($pair);
1400-
return $database->getSequence() . '@' . \implode('<->', $pair);
1401-
}
1402-
14031387
/**
14041388
* Records that $key was seen for this (database, table) during the run.
14051389
* $kind selects the tracker bucket ('attributeKeys' or 'indexKeys'). Only
@@ -1516,6 +1500,12 @@ private function dropOrphansByKind(
15161500
* Drop an orphan attribute (metadata doc + physical column). Uses the
15171501
* destination's own metadata document as the source of truth for type
15181502
* and relationship options, since there's no source resource to consult.
1503+
*
1504+
* Relationships use deleteRelationship (which cascades to both sides's
1505+
* physical columns + utopia's internal metadata) rather than
1506+
* deleteAttribute (which throws for relationship types). Appwrite's
1507+
* application-level `attributes` metadata docs on both sides are
1508+
* cleaned separately.
15191509
*/
15201510
private function dropOrphanAttribute(
15211511
UtopiaDocument $database,
@@ -1528,7 +1518,11 @@ private function dropOrphanAttribute(
15281518
$options = $attrDoc->getAttribute('options', []);
15291519
$collectionId = 'database_' . $database->getSequence() . '_collection_' . $table->getSequence();
15301520

1531-
$this->tolerateMissing(fn () => $dbForDatabases->deleteAttribute($collectionId, $key));
1521+
if ($type === UtopiaDatabase::VAR_RELATIONSHIP) {
1522+
$this->tolerateMissing(fn () => $dbForDatabases->deleteRelationship($collectionId, $key));
1523+
} else {
1524+
$this->tolerateMissing(fn () => $dbForDatabases->deleteAttribute($collectionId, $key));
1525+
}
15321526
$this->tolerateMissing(fn () => $this->dbForProject->deleteDocument('attributes', $attrDoc->getId()));
15331527
$this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $table->getId());
15341528
$dbForDatabases->purgeCachedCollection($collectionId);
@@ -1546,12 +1540,15 @@ private function dropOrphanAttribute(
15461540
return;
15471541
}
15481542

1549-
$childCollectionId = 'database_' . $database->getSequence() . '_collection_' . $relatedTable->getSequence();
1543+
// Physical column on the related table was already dropped by
1544+
// deleteRelationship above. Only the Appwrite-level metadata doc
1545+
// remains to clean up.
15501546
$childMetaId = $database->getSequence() . '_' . $relatedTable->getSequence() . '_' . $twoWayKey;
15511547
$this->tolerateMissing(fn () => $this->dbForProject->deleteDocument('attributes', $childMetaId));
1552-
$this->tolerateMissing(fn () => $dbForDatabases->deleteAttribute($childCollectionId, $twoWayKey));
15531548
$this->dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $relatedTable->getId());
1554-
$dbForDatabases->purgeCachedCollection($childCollectionId);
1549+
$dbForDatabases->purgeCachedCollection(
1550+
'database_' . $database->getSequence() . '_collection_' . $relatedTable->getSequence(),
1551+
);
15551552
}
15561553

15571554
private function dropOrphanIndex(

0 commit comments

Comments
 (0)