From 010f21eaf51636b776b5520318505cce226c4efa Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 30 May 2025 22:28:34 +1200 Subject: [PATCH 1/5] Fix attribute ordering --- src/Database/Adapter/MariaDB.php | 2 ++ tests/e2e/Adapter/Scopes/DocumentTests.php | 32 ++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index d210dc16b..8f9170fb7 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1350,6 +1350,8 @@ public function createOrUpdateDocuments( = $document->getTenant(); } + \ksort($attributes); + $columns = []; foreach (\array_keys($attributes) as $key => $attr) { /** diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 30fcbda9e..a4393ea0a 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -672,6 +672,38 @@ public function testUpsertDocumentsAttributeMismatch(): void $this->assertEquals(null, $existingDocument->getAttribute('last')); $this->assertEquals('second', $newDocument->getAttribute('first')); $this->assertEquals('last', $newDocument->getAttribute('last')); + + $doc3 = new Document([ + '$id' => 'third', + 'last' => 'last', + 'first' => 'third', + ]); + + $doc4 = new Document([ + '$id' => 'fourth', + 'first' => 'fourth', + 'last' => 'last', + ]); + + // Ensure mismatch of attribute orders is allowed + $docs = $database->createOrUpdateDocuments(__FUNCTION__, [ + $doc3, + $doc4 + ]); + + $this->assertEquals(2, $docs); + $this->assertEquals('third', $doc3->getAttribute('first')); + $this->assertEquals('last', $doc3->getAttribute('last')); + $this->assertEquals('fourth', $doc4->getAttribute('first')); + $this->assertEquals('last', $doc4->getAttribute('last')); + + $doc3 = $database->getDocument(__FUNCTION__, 'third'); + $doc4 = $database->getDocument(__FUNCTION__, 'fourth'); + + $this->assertEquals('third', $doc3->getAttribute('first')); + $this->assertEquals('last', $doc3->getAttribute('last')); + $this->assertEquals('fourth', $doc4->getAttribute('first')); + $this->assertEquals('last', $doc4->getAttribute('last')); } public function testUpsertDocumentsNoop(): void From 8db7f375c4b79f51e26ade0dacd43125671be08e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 30 May 2025 22:39:49 +1200 Subject: [PATCH 2/5] Add postgres upsert impl --- src/Database/Adapter/MariaDB.php | 1 + src/Database/Adapter/Postgres.php | 215 +++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 3 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 8f9170fb7..90b5999a7 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1350,6 +1350,7 @@ public function createOrUpdateDocuments( = $document->getTenant(); } + // Enforce deterministic order \ksort($attributes); $columns = []; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 8678afcaa..3560333da 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1400,9 +1400,218 @@ public function updateDocument(string $collection, string $id, Document $documen * @param string $attribute * @param array $changes * @return array + * @throws DatabaseException */ - public function createOrUpdateDocuments(string $collection, string $attribute, array $changes): array - { + public function createOrUpdateDocuments( + string $collection, + string $attribute, + array $changes + ): array { + if (empty($changes)) { + return $changes; + } + + try { + $name = $this->filter($collection); + $attribute = $this->filter($attribute); + + $attributes = []; + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + $documentIds = []; + $documentTenants = []; + + foreach ($changes as $change) { + $document = $change->getNew(); + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = \json_encode($document->getPermissions()); + + if (!empty($document->getInternalId())) { + $attributes['_id'] = $document->getInternalId(); + } else { + $documentIds[] = $document->getId(); + } + + if ($this->sharedTables) { + $attributes['_tenant'] + = $documentTenants[] + = $document->getTenant(); + } + + // Enforce deterministic order + \ksort($attributes); + + $columns = []; + foreach (\array_keys($attributes) as $key => $attr) { + $columns[$key] = $this->quote($this->filter($attr)); + } + $columns = '(' . \implode(', ', $columns) . ')'; + + /** Value list for this row */ + $bindKeys = []; + foreach ($attributes as $attrValue) { + if (\is_array($attrValue)) { + $attrValue = \json_encode($attrValue); + } + $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + $bindValues[$bindKey] = $attrValue; + $bindIndex++; + } + + $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + } + + $getUpdateClause = function (string $attribute, bool $increment = false): string { + $attribute = $this->quote($this->filter($attribute)); + + $newValue = $increment + ? "{$attribute} + EXCLUDED.{$attribute}" + : "EXCLUDED.{$attribute}"; + + // tenant-aware overwrite guard + if ($this->sharedTables) { + return "{$attribute} = CASE + WHEN _tenant = EXCLUDED._tenant + THEN {$newValue} + ELSE {$attribute} + END"; + } + + return "{$attribute} = {$newValue}"; + }; + + if (!empty($attribute)) { + $updateColumns = [ + $getUpdateClause($attribute, increment: true), + $getUpdateClause('_updatedAt'), + ]; + } else { + $updateColumns = []; + foreach (\array_keys($attributes) as $attr) { + $updateColumns[] = $getUpdateClause($this->filter($attr)); + } + } + + // Conflict target: _uid, plus _tenant if shared tables + $conflictTarget = $this->sharedTables + ? '(_uid, _tenant)' + : '(_uid)'; + + $sql = " + INSERT INTO {$this->getSQLTable($name)} {$columns} + VALUES " . \implode(', ', $batchKeys) . " + ON CONFLICT {$conflictTarget} DO UPDATE SET + " . \implode(', ', $updateColumns); + + $stmt = $this->getPDO()->prepare($sql); + + foreach ($bindValues as $key => $binding) { + $stmt->bindValue($key, $binding, $this->getPDOType($binding)); + } + + $stmt->execute(); + $stmt->closeCursor(); + + $removeQueries = []; + $removeBindValues = []; + $addQueries = []; + $addBindValues = []; + + foreach ($changes as $index => $change) { + $old = $change->getOld(); + $document = $change->getNew(); + + $current = []; + foreach (Database::PERMISSIONS as $type) { + $current[$type] = $old->getPermissionsByType($type); + } + + // Calculate removals + foreach (Database::PERMISSIONS as $type) { + $toRemove = \array_diff($current[$type], $document->getPermissionsByType($type)); + if (!empty($toRemove)) { + $removeQueries[] = "( + _document = :_uid_{$index} + {$this->getTenantQuery($collection, tenantCount: \count($toRemove))} + AND _type = '{$type}' + AND _permission IN (" . \implode(',', \array_map(fn ($i) => ":remove_{$type}_{$index}_{$i}", \array_keys($toRemove))) . ") + )"; + $removeBindValues[":_uid_{$index}"] = $document->getId(); + $removeBindValues[":_tenant_{$index}"] = $document->getTenant(); + foreach ($toRemove as $i => $perm) { + $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $perm; + } + } + } + + // Calculate additions + foreach (Database::PERMISSIONS as $type) { + $toAdd = \array_diff($document->getPermissionsByType($type), $current[$type]); + + foreach ($toAdd as $i => $permission) { + $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; + + if ($this->sharedTables) { + $addQuery .= ", :_tenant_{$index}"; + } + + $addQuery .= ")"; + $addQueries[] = $addQuery; + $addBindValues[":_uid_{$index}"] = $document->getId(); + $addBindValues[":add_{$type}_{$index}_{$i}"] = $permission; + + if ($this->sharedTables) { + $addBindValues[":_tenant_{$index}"] = $document->getTenant(); + } + } + } + } + + // Execute permission removals + if (!empty($removeQueries)) { + $removeQuery = \implode(' OR ', $removeQueries); + $stmtRemovePermissions = $this->getPDO()->prepare("DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE {$removeQuery}"); + foreach ($removeBindValues as $key => $value) { + $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); + } + $stmtRemovePermissions->execute(); + } + + // Execute permission additions + if (!empty($addQueries)) { + $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission"; + if ($this->sharedTables) { + $sqlAddPermissions .= ", _tenant"; + } + $sqlAddPermissions .= ") VALUES " . \implode(', ', $addQueries); + $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); + foreach ($addBindValues as $key => $value) { + $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); + } + $stmtAddPermissions->execute(); + } + + $internalIds = $this->getInternalIds( + $collection, + $documentIds, + $documentTenants + ); + + foreach ($changes as $change) { + if (isset($internalIds[$change->getNew()->getId()])) { + $change->getNew()->setAttribute('$internalId', $internalIds[$change->getNew()->getId()]); + } + } + } catch (\PDOException $e) { + throw $this->processException($e); + } + return \array_map(fn ($change) => $change->getNew(), $changes); } @@ -2125,7 +2334,7 @@ public function getSupportForSchemaAttributes(): bool public function getSupportForUpserts(): bool { - return false; + return true; } /** From 6e85fb6fc39ecf86f93b1a0a3622f11359cfc097 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 30 May 2025 22:40:46 +1200 Subject: [PATCH 3/5] Revert "Add postgres upsert impl" This reverts commit 8db7f375c4b79f51e26ade0dacd43125671be08e. --- src/Database/Adapter/MariaDB.php | 1 - src/Database/Adapter/Postgres.php | 215 +----------------------------- 2 files changed, 3 insertions(+), 213 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 90b5999a7..8f9170fb7 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1350,7 +1350,6 @@ public function createOrUpdateDocuments( = $document->getTenant(); } - // Enforce deterministic order \ksort($attributes); $columns = []; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 3560333da..8678afcaa 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1400,218 +1400,9 @@ public function updateDocument(string $collection, string $id, Document $documen * @param string $attribute * @param array $changes * @return array - * @throws DatabaseException */ - public function createOrUpdateDocuments( - string $collection, - string $attribute, - array $changes - ): array { - if (empty($changes)) { - return $changes; - } - - try { - $name = $this->filter($collection); - $attribute = $this->filter($attribute); - - $attributes = []; - $bindIndex = 0; - $batchKeys = []; - $bindValues = []; - $documentIds = []; - $documentTenants = []; - - foreach ($changes as $change) { - $document = $change->getNew(); - $attributes = $document->getAttributes(); - $attributes['_uid'] = $document->getId(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = \json_encode($document->getPermissions()); - - if (!empty($document->getInternalId())) { - $attributes['_id'] = $document->getInternalId(); - } else { - $documentIds[] = $document->getId(); - } - - if ($this->sharedTables) { - $attributes['_tenant'] - = $documentTenants[] - = $document->getTenant(); - } - - // Enforce deterministic order - \ksort($attributes); - - $columns = []; - foreach (\array_keys($attributes) as $key => $attr) { - $columns[$key] = $this->quote($this->filter($attr)); - } - $columns = '(' . \implode(', ', $columns) . ')'; - - /** Value list for this row */ - $bindKeys = []; - foreach ($attributes as $attrValue) { - if (\is_array($attrValue)) { - $attrValue = \json_encode($attrValue); - } - $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = ':' . $bindKey; - $bindValues[$bindKey] = $attrValue; - $bindIndex++; - } - - $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; - } - - $getUpdateClause = function (string $attribute, bool $increment = false): string { - $attribute = $this->quote($this->filter($attribute)); - - $newValue = $increment - ? "{$attribute} + EXCLUDED.{$attribute}" - : "EXCLUDED.{$attribute}"; - - // tenant-aware overwrite guard - if ($this->sharedTables) { - return "{$attribute} = CASE - WHEN _tenant = EXCLUDED._tenant - THEN {$newValue} - ELSE {$attribute} - END"; - } - - return "{$attribute} = {$newValue}"; - }; - - if (!empty($attribute)) { - $updateColumns = [ - $getUpdateClause($attribute, increment: true), - $getUpdateClause('_updatedAt'), - ]; - } else { - $updateColumns = []; - foreach (\array_keys($attributes) as $attr) { - $updateColumns[] = $getUpdateClause($this->filter($attr)); - } - } - - // Conflict target: _uid, plus _tenant if shared tables - $conflictTarget = $this->sharedTables - ? '(_uid, _tenant)' - : '(_uid)'; - - $sql = " - INSERT INTO {$this->getSQLTable($name)} {$columns} - VALUES " . \implode(', ', $batchKeys) . " - ON CONFLICT {$conflictTarget} DO UPDATE SET - " . \implode(', ', $updateColumns); - - $stmt = $this->getPDO()->prepare($sql); - - foreach ($bindValues as $key => $binding) { - $stmt->bindValue($key, $binding, $this->getPDOType($binding)); - } - - $stmt->execute(); - $stmt->closeCursor(); - - $removeQueries = []; - $removeBindValues = []; - $addQueries = []; - $addBindValues = []; - - foreach ($changes as $index => $change) { - $old = $change->getOld(); - $document = $change->getNew(); - - $current = []; - foreach (Database::PERMISSIONS as $type) { - $current[$type] = $old->getPermissionsByType($type); - } - - // Calculate removals - foreach (Database::PERMISSIONS as $type) { - $toRemove = \array_diff($current[$type], $document->getPermissionsByType($type)); - if (!empty($toRemove)) { - $removeQueries[] = "( - _document = :_uid_{$index} - {$this->getTenantQuery($collection, tenantCount: \count($toRemove))} - AND _type = '{$type}' - AND _permission IN (" . \implode(',', \array_map(fn ($i) => ":remove_{$type}_{$index}_{$i}", \array_keys($toRemove))) . ") - )"; - $removeBindValues[":_uid_{$index}"] = $document->getId(); - $removeBindValues[":_tenant_{$index}"] = $document->getTenant(); - foreach ($toRemove as $i => $perm) { - $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $perm; - } - } - } - - // Calculate additions - foreach (Database::PERMISSIONS as $type) { - $toAdd = \array_diff($document->getPermissionsByType($type), $current[$type]); - - foreach ($toAdd as $i => $permission) { - $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; - - if ($this->sharedTables) { - $addQuery .= ", :_tenant_{$index}"; - } - - $addQuery .= ")"; - $addQueries[] = $addQuery; - $addBindValues[":_uid_{$index}"] = $document->getId(); - $addBindValues[":add_{$type}_{$index}_{$i}"] = $permission; - - if ($this->sharedTables) { - $addBindValues[":_tenant_{$index}"] = $document->getTenant(); - } - } - } - } - - // Execute permission removals - if (!empty($removeQueries)) { - $removeQuery = \implode(' OR ', $removeQueries); - $stmtRemovePermissions = $this->getPDO()->prepare("DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE {$removeQuery}"); - foreach ($removeBindValues as $key => $value) { - $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); - } - $stmtRemovePermissions->execute(); - } - - // Execute permission additions - if (!empty($addQueries)) { - $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission"; - if ($this->sharedTables) { - $sqlAddPermissions .= ", _tenant"; - } - $sqlAddPermissions .= ") VALUES " . \implode(', ', $addQueries); - $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); - foreach ($addBindValues as $key => $value) { - $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); - } - $stmtAddPermissions->execute(); - } - - $internalIds = $this->getInternalIds( - $collection, - $documentIds, - $documentTenants - ); - - foreach ($changes as $change) { - if (isset($internalIds[$change->getNew()->getId()])) { - $change->getNew()->setAttribute('$internalId', $internalIds[$change->getNew()->getId()]); - } - } - } catch (\PDOException $e) { - throw $this->processException($e); - } - + public function createOrUpdateDocuments(string $collection, string $attribute, array $changes): array + { return \array_map(fn ($change) => $change->getNew(), $changes); } @@ -2334,7 +2125,7 @@ public function getSupportForSchemaAttributes(): bool public function getSupportForUpserts(): bool { - return true; + return false; } /** From a98c36912df73996ff2eaf9b698f027c449579d8 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 30 May 2025 22:40:51 +1200 Subject: [PATCH 4/5] Reapply "Add postgres upsert impl" This reverts commit 6e85fb6fc39ecf86f93b1a0a3622f11359cfc097. --- src/Database/Adapter/MariaDB.php | 1 + src/Database/Adapter/Postgres.php | 215 +++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 3 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 8f9170fb7..90b5999a7 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1350,6 +1350,7 @@ public function createOrUpdateDocuments( = $document->getTenant(); } + // Enforce deterministic order \ksort($attributes); $columns = []; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 8678afcaa..3560333da 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1400,9 +1400,218 @@ public function updateDocument(string $collection, string $id, Document $documen * @param string $attribute * @param array $changes * @return array + * @throws DatabaseException */ - public function createOrUpdateDocuments(string $collection, string $attribute, array $changes): array - { + public function createOrUpdateDocuments( + string $collection, + string $attribute, + array $changes + ): array { + if (empty($changes)) { + return $changes; + } + + try { + $name = $this->filter($collection); + $attribute = $this->filter($attribute); + + $attributes = []; + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + $documentIds = []; + $documentTenants = []; + + foreach ($changes as $change) { + $document = $change->getNew(); + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = \json_encode($document->getPermissions()); + + if (!empty($document->getInternalId())) { + $attributes['_id'] = $document->getInternalId(); + } else { + $documentIds[] = $document->getId(); + } + + if ($this->sharedTables) { + $attributes['_tenant'] + = $documentTenants[] + = $document->getTenant(); + } + + // Enforce deterministic order + \ksort($attributes); + + $columns = []; + foreach (\array_keys($attributes) as $key => $attr) { + $columns[$key] = $this->quote($this->filter($attr)); + } + $columns = '(' . \implode(', ', $columns) . ')'; + + /** Value list for this row */ + $bindKeys = []; + foreach ($attributes as $attrValue) { + if (\is_array($attrValue)) { + $attrValue = \json_encode($attrValue); + } + $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + $bindValues[$bindKey] = $attrValue; + $bindIndex++; + } + + $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + } + + $getUpdateClause = function (string $attribute, bool $increment = false): string { + $attribute = $this->quote($this->filter($attribute)); + + $newValue = $increment + ? "{$attribute} + EXCLUDED.{$attribute}" + : "EXCLUDED.{$attribute}"; + + // tenant-aware overwrite guard + if ($this->sharedTables) { + return "{$attribute} = CASE + WHEN _tenant = EXCLUDED._tenant + THEN {$newValue} + ELSE {$attribute} + END"; + } + + return "{$attribute} = {$newValue}"; + }; + + if (!empty($attribute)) { + $updateColumns = [ + $getUpdateClause($attribute, increment: true), + $getUpdateClause('_updatedAt'), + ]; + } else { + $updateColumns = []; + foreach (\array_keys($attributes) as $attr) { + $updateColumns[] = $getUpdateClause($this->filter($attr)); + } + } + + // Conflict target: _uid, plus _tenant if shared tables + $conflictTarget = $this->sharedTables + ? '(_uid, _tenant)' + : '(_uid)'; + + $sql = " + INSERT INTO {$this->getSQLTable($name)} {$columns} + VALUES " . \implode(', ', $batchKeys) . " + ON CONFLICT {$conflictTarget} DO UPDATE SET + " . \implode(', ', $updateColumns); + + $stmt = $this->getPDO()->prepare($sql); + + foreach ($bindValues as $key => $binding) { + $stmt->bindValue($key, $binding, $this->getPDOType($binding)); + } + + $stmt->execute(); + $stmt->closeCursor(); + + $removeQueries = []; + $removeBindValues = []; + $addQueries = []; + $addBindValues = []; + + foreach ($changes as $index => $change) { + $old = $change->getOld(); + $document = $change->getNew(); + + $current = []; + foreach (Database::PERMISSIONS as $type) { + $current[$type] = $old->getPermissionsByType($type); + } + + // Calculate removals + foreach (Database::PERMISSIONS as $type) { + $toRemove = \array_diff($current[$type], $document->getPermissionsByType($type)); + if (!empty($toRemove)) { + $removeQueries[] = "( + _document = :_uid_{$index} + {$this->getTenantQuery($collection, tenantCount: \count($toRemove))} + AND _type = '{$type}' + AND _permission IN (" . \implode(',', \array_map(fn ($i) => ":remove_{$type}_{$index}_{$i}", \array_keys($toRemove))) . ") + )"; + $removeBindValues[":_uid_{$index}"] = $document->getId(); + $removeBindValues[":_tenant_{$index}"] = $document->getTenant(); + foreach ($toRemove as $i => $perm) { + $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $perm; + } + } + } + + // Calculate additions + foreach (Database::PERMISSIONS as $type) { + $toAdd = \array_diff($document->getPermissionsByType($type), $current[$type]); + + foreach ($toAdd as $i => $permission) { + $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; + + if ($this->sharedTables) { + $addQuery .= ", :_tenant_{$index}"; + } + + $addQuery .= ")"; + $addQueries[] = $addQuery; + $addBindValues[":_uid_{$index}"] = $document->getId(); + $addBindValues[":add_{$type}_{$index}_{$i}"] = $permission; + + if ($this->sharedTables) { + $addBindValues[":_tenant_{$index}"] = $document->getTenant(); + } + } + } + } + + // Execute permission removals + if (!empty($removeQueries)) { + $removeQuery = \implode(' OR ', $removeQueries); + $stmtRemovePermissions = $this->getPDO()->prepare("DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE {$removeQuery}"); + foreach ($removeBindValues as $key => $value) { + $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); + } + $stmtRemovePermissions->execute(); + } + + // Execute permission additions + if (!empty($addQueries)) { + $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission"; + if ($this->sharedTables) { + $sqlAddPermissions .= ", _tenant"; + } + $sqlAddPermissions .= ") VALUES " . \implode(', ', $addQueries); + $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); + foreach ($addBindValues as $key => $value) { + $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); + } + $stmtAddPermissions->execute(); + } + + $internalIds = $this->getInternalIds( + $collection, + $documentIds, + $documentTenants + ); + + foreach ($changes as $change) { + if (isset($internalIds[$change->getNew()->getId()])) { + $change->getNew()->setAttribute('$internalId', $internalIds[$change->getNew()->getId()]); + } + } + } catch (\PDOException $e) { + throw $this->processException($e); + } + return \array_map(fn ($change) => $change->getNew(), $changes); } @@ -2125,7 +2334,7 @@ public function getSupportForSchemaAttributes(): bool public function getSupportForUpserts(): bool { - return false; + return true; } /** From 71efc8cf70916eb77ee8bfe134c66fbfa3085950 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 30 May 2025 22:55:22 +1200 Subject: [PATCH 5/5] Revert "Reapply "Add postgres upsert impl"" This reverts commit a98c36912df73996ff2eaf9b698f027c449579d8. --- src/Database/Adapter/MariaDB.php | 1 - src/Database/Adapter/Postgres.php | 215 +----------------------------- 2 files changed, 3 insertions(+), 213 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 90b5999a7..8f9170fb7 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1350,7 +1350,6 @@ public function createOrUpdateDocuments( = $document->getTenant(); } - // Enforce deterministic order \ksort($attributes); $columns = []; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 3560333da..8678afcaa 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1400,218 +1400,9 @@ public function updateDocument(string $collection, string $id, Document $documen * @param string $attribute * @param array $changes * @return array - * @throws DatabaseException */ - public function createOrUpdateDocuments( - string $collection, - string $attribute, - array $changes - ): array { - if (empty($changes)) { - return $changes; - } - - try { - $name = $this->filter($collection); - $attribute = $this->filter($attribute); - - $attributes = []; - $bindIndex = 0; - $batchKeys = []; - $bindValues = []; - $documentIds = []; - $documentTenants = []; - - foreach ($changes as $change) { - $document = $change->getNew(); - $attributes = $document->getAttributes(); - $attributes['_uid'] = $document->getId(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = \json_encode($document->getPermissions()); - - if (!empty($document->getInternalId())) { - $attributes['_id'] = $document->getInternalId(); - } else { - $documentIds[] = $document->getId(); - } - - if ($this->sharedTables) { - $attributes['_tenant'] - = $documentTenants[] - = $document->getTenant(); - } - - // Enforce deterministic order - \ksort($attributes); - - $columns = []; - foreach (\array_keys($attributes) as $key => $attr) { - $columns[$key] = $this->quote($this->filter($attr)); - } - $columns = '(' . \implode(', ', $columns) . ')'; - - /** Value list for this row */ - $bindKeys = []; - foreach ($attributes as $attrValue) { - if (\is_array($attrValue)) { - $attrValue = \json_encode($attrValue); - } - $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = ':' . $bindKey; - $bindValues[$bindKey] = $attrValue; - $bindIndex++; - } - - $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; - } - - $getUpdateClause = function (string $attribute, bool $increment = false): string { - $attribute = $this->quote($this->filter($attribute)); - - $newValue = $increment - ? "{$attribute} + EXCLUDED.{$attribute}" - : "EXCLUDED.{$attribute}"; - - // tenant-aware overwrite guard - if ($this->sharedTables) { - return "{$attribute} = CASE - WHEN _tenant = EXCLUDED._tenant - THEN {$newValue} - ELSE {$attribute} - END"; - } - - return "{$attribute} = {$newValue}"; - }; - - if (!empty($attribute)) { - $updateColumns = [ - $getUpdateClause($attribute, increment: true), - $getUpdateClause('_updatedAt'), - ]; - } else { - $updateColumns = []; - foreach (\array_keys($attributes) as $attr) { - $updateColumns[] = $getUpdateClause($this->filter($attr)); - } - } - - // Conflict target: _uid, plus _tenant if shared tables - $conflictTarget = $this->sharedTables - ? '(_uid, _tenant)' - : '(_uid)'; - - $sql = " - INSERT INTO {$this->getSQLTable($name)} {$columns} - VALUES " . \implode(', ', $batchKeys) . " - ON CONFLICT {$conflictTarget} DO UPDATE SET - " . \implode(', ', $updateColumns); - - $stmt = $this->getPDO()->prepare($sql); - - foreach ($bindValues as $key => $binding) { - $stmt->bindValue($key, $binding, $this->getPDOType($binding)); - } - - $stmt->execute(); - $stmt->closeCursor(); - - $removeQueries = []; - $removeBindValues = []; - $addQueries = []; - $addBindValues = []; - - foreach ($changes as $index => $change) { - $old = $change->getOld(); - $document = $change->getNew(); - - $current = []; - foreach (Database::PERMISSIONS as $type) { - $current[$type] = $old->getPermissionsByType($type); - } - - // Calculate removals - foreach (Database::PERMISSIONS as $type) { - $toRemove = \array_diff($current[$type], $document->getPermissionsByType($type)); - if (!empty($toRemove)) { - $removeQueries[] = "( - _document = :_uid_{$index} - {$this->getTenantQuery($collection, tenantCount: \count($toRemove))} - AND _type = '{$type}' - AND _permission IN (" . \implode(',', \array_map(fn ($i) => ":remove_{$type}_{$index}_{$i}", \array_keys($toRemove))) . ") - )"; - $removeBindValues[":_uid_{$index}"] = $document->getId(); - $removeBindValues[":_tenant_{$index}"] = $document->getTenant(); - foreach ($toRemove as $i => $perm) { - $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $perm; - } - } - } - - // Calculate additions - foreach (Database::PERMISSIONS as $type) { - $toAdd = \array_diff($document->getPermissionsByType($type), $current[$type]); - - foreach ($toAdd as $i => $permission) { - $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; - - if ($this->sharedTables) { - $addQuery .= ", :_tenant_{$index}"; - } - - $addQuery .= ")"; - $addQueries[] = $addQuery; - $addBindValues[":_uid_{$index}"] = $document->getId(); - $addBindValues[":add_{$type}_{$index}_{$i}"] = $permission; - - if ($this->sharedTables) { - $addBindValues[":_tenant_{$index}"] = $document->getTenant(); - } - } - } - } - - // Execute permission removals - if (!empty($removeQueries)) { - $removeQuery = \implode(' OR ', $removeQueries); - $stmtRemovePermissions = $this->getPDO()->prepare("DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE {$removeQuery}"); - foreach ($removeBindValues as $key => $value) { - $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); - } - $stmtRemovePermissions->execute(); - } - - // Execute permission additions - if (!empty($addQueries)) { - $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission"; - if ($this->sharedTables) { - $sqlAddPermissions .= ", _tenant"; - } - $sqlAddPermissions .= ") VALUES " . \implode(', ', $addQueries); - $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); - foreach ($addBindValues as $key => $value) { - $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); - } - $stmtAddPermissions->execute(); - } - - $internalIds = $this->getInternalIds( - $collection, - $documentIds, - $documentTenants - ); - - foreach ($changes as $change) { - if (isset($internalIds[$change->getNew()->getId()])) { - $change->getNew()->setAttribute('$internalId', $internalIds[$change->getNew()->getId()]); - } - } - } catch (\PDOException $e) { - throw $this->processException($e); - } - + public function createOrUpdateDocuments(string $collection, string $attribute, array $changes): array + { return \array_map(fn ($change) => $change->getNew(), $changes); } @@ -2334,7 +2125,7 @@ public function getSupportForSchemaAttributes(): bool public function getSupportForUpserts(): bool { - return true; + return false; } /**