Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions src/Database/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ abstract class Adapter

protected bool $alterLocks = false;

protected bool $skipDuplicates = false;
protected OnDuplicate $onDuplicate = OnDuplicate::Fail;

/**
* @var array<string, mixed>
Expand Down Expand Up @@ -395,23 +395,23 @@ public function inTransaction(): bool
}

/**
* Run a callback with skipDuplicates enabled.
* Duplicate key errors during createDocuments() will be silently skipped
* instead of thrown. Nestable — saves and restores previous state.
* Run a callback scoped to a specific OnDuplicate mode. Create-style
* operations (createDocument, createCollection, createAttribute, createIndex)
* dispatch on this mode. Nestable — saves and restores previous state.
*
* @template T
* @param callable(): T $callback
* @return T
*/
public function skipDuplicates(callable $callback): mixed
public function withOnDuplicate(OnDuplicate $mode, callable $callback): mixed
{
$previous = $this->skipDuplicates;
$this->skipDuplicates = true;
$previous = $this->onDuplicate;
$this->onDuplicate = $mode;

try {
return $callback();
} finally {
$this->skipDuplicates = $previous;
$this->onDuplicate = $previous;
}
}

Expand Down
30 changes: 30 additions & 0 deletions src/Database/Adapter/MariaDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Utopia\Database\Exception\Timeout as TimeoutException;
use Utopia\Database\Exception\Truncate as TruncateException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\OnDuplicate;
use Utopia\Database\Operator;
use Utopia\Database\Query;

Expand Down Expand Up @@ -2376,4 +2377,33 @@ public function getSupportForTTLIndexes(): bool
{
return false;
}

/**
* MariaDB/MySQL Upsert: append `ON DUPLICATE KEY UPDATE col = VALUES(col), ...`
* so the INSERT replaces the matched row's columns instead of throwing.
*
* @param array<string> $columns
*/
protected function getInsertSuffix(string $table, array $columns = []): string
{
if ($this->onDuplicate !== OnDuplicate::Upsert || empty($columns)) {
return '';
}

$assignments = [];
foreach ($columns as $col) {
// Skip the primary unique key (_uid) — no need to "update" what matched.
// Safe to skip _id too since it's auto-increment in INSERT and untouched on update.
if (\in_array($col, ['`_uid`', '`_id`'], true)) {
continue;
}
$assignments[] = "{$col} = VALUES({$col})";
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

if (empty($assignments)) {
return '';
}

return 'ON DUPLICATE KEY UPDATE ' . \implode(', ', $assignments);
}
Comment on lines +2381 to +2408
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all callers and check column formatting
rg -nP --type=php -C3 '\bgetInsertSuffix\s*\('

# Look at how columns are assembled at call sites
ast-grep --pattern $'$_->getInsertSuffix($$$)'

# Verify there are existing assertions locking _createdAt immutability across upsert
rg -nP --type=php -C2 '_createdAt' tests | rg -i 'upsert|on.?duplicate'

Repository: utopia-php/database

Length of output: 2686


🏁 Script executed:

#!/bin/bash
# 1. Get full Postgres::getInsertSuffix() implementation
echo "=== Postgres::getInsertSuffix() ==="
sed -n '2389,2420p' src/Database/Adapter/Postgres.php

# 2. Get getUpsertStatement() implementation at line 1296
echo -e "\n=== MariaDB::getUpsertStatement() ==="
sed -n '1296,1350p' src/Database/Adapter/MariaDB.php

# 3. Check how columnList is formatted when passed to getInsertSuffix
echo -e "\n=== columnList assembly in SQL.php ==="
sed -n '2620,2640p' src/Database/Adapter/SQL.php

# 4. Search for _createdAt exclusion patterns in upsert
echo -e "\n=== _createdAt patterns in MariaDB ==="
rg -n '_createdAt' src/Database/Adapter/MariaDB.php -C2 | head -50

Repository: utopia-php/database

Length of output: 5517


🏁 Script executed:

#!/bin/bash
# Search for test files that verify _createdAt behavior in upserts
echo "=== Test files checking _createdAt immutability ==="
fd -e '.php' tests | xargs grep -l '_createdAt' | head -10

# Check for specific upsert+_createdAt tests
echo -e "\n=== Tests with upsert and _createdAt ==="
rg -l 'upsert' tests --type=php | xargs rg '_createdAt' -C3 | head -100

Repository: utopia-php/database

Length of output: 156


🏁 Script executed:

#!/bin/bash
# Find all callers of getUpsertStatement
echo "=== Callers of getUpsertStatement() ==="
rg -n 'getUpsertStatement' src/Database --type=php -B2 -A2

# Check if getInsertSuffix was recently added
echo -e "\n=== Recent additions to getInsertSuffix ==="
git log --oneline -p -- src/Database/Adapter/MariaDB.php | grep -A20 'getInsertSuffix' | head -60

Repository: utopia-php/database

Length of output: 3247


🏁 Script executed:

#!/bin/bash
# Verify when batch inserts with OnDuplicate::Upsert are actually used
echo "=== OnDuplicate::Upsert usage ==="
rg -n 'OnDuplicate::Upsert' src/Database --type=php -B3 -A3 | head -100

# Check if there are high-level upsert methods in Database class
echo -e "\n=== Database class upsert methods ==="
rg -n 'def.*upsert|function.*upsert' src/Database/Database.php --type=php -i

Repository: utopia-php/database

Length of output: 2284


getInsertSuffix overwrites _createdAt and _tenant on Upsert — diverges from the existing upsert path.

The new batch insert path via getInsertSuffix() skips only `_uid` and `_id`, so the generated SQL will include:

ON DUPLICATE KEY UPDATE `_createdAt` = VALUES(`_createdAt`), `_tenant` = VALUES(`_tenant`), ...

Issues:

  1. _createdAt regression. The existing getUpsertStatement() (line 1296) intentionally excludes _createdAt so the original creation timestamp is preserved on collision. The new batch path silently overwrites it with the incoming value — user-visible data loss for anyone relying on _createdAt being immutable across upserts.
  2. _tenant inconsistency. Postgres::getInsertSuffix() skips _uid, _id, and _tenant, but this MariaDB variant includes _tenant in updates. In shared-tables mode the unique key is (_uid, _tenant), so this assignment is a no-op on conflict but misaligns with the other adapter.

Align the skip list with getUpsertStatement() to exclude both _createdAt and _tenant:

🛡️ Proposed fix
     protected function getInsertSuffix(string $table, array $columns = []): string
     {
         if ($this->onDuplicate !== OnDuplicate::Upsert || empty($columns)) {
             return '';
         }

         $assignments = [];
         foreach ($columns as $col) {
-            // Skip the primary unique key (_uid) — no need to "update" what matched.
-            // Safe to skip _id too since it's auto-increment in INSERT and untouched on update.
-            if (\in_array($col, ['`_uid`', '`_id`'], true)) {
+            // Skip the conflict key (_uid), the surrogate PK (_id), the tenant
+            // discriminator (_tenant), and _createdAt — the original creation
+            // timestamp must be preserved on an upsert collision.
+            if (\in_array($col, ['`_uid`', '`_id`', '`_tenant`', '`_createdAt`'], true)) {
                 continue;
             }
             $assignments[] = "{$col} = VALUES({$col})";
         }

         if (empty($assignments)) {
             return '';
         }

         return 'ON DUPLICATE KEY UPDATE ' . \implode(', ', $assignments);
     }
🧰 Tools
🪛 PHPMD (2.15.0)

[warning] 2398-2398: Avoid unused parameters such as '$table'. (undefined)

(UnusedFormalParameter)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/MariaDB.php` around lines 2392 - 2419, The
getInsertSuffix method currently only skips '`_uid`' and '`_id`', causing
`_createdAt` and `_tenant` to be overwritten on upsert; update getInsertSuffix
(used when $this->onDuplicate === OnDuplicate::Upsert) to also skip
'`_createdAt`' and '`_tenant`'—aligning its skip list with getUpsertStatement()
and Postgres::getInsertSuffix()—so the foreach over $columns does not add
assignments for these four columns and thus the returned 'ON DUPLICATE KEY
UPDATE ...' string omits `_createdAt` and `_tenant`.

}
44 changes: 26 additions & 18 deletions src/Database/Adapter/Mongo.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Utopia\Database\Exception\Timeout as TimeoutException;
use Utopia\Database\Exception\Transaction as TransactionException;
use Utopia\Database\Exception\Type as TypeException;
use Utopia\Database\OnDuplicate;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Mongo\Client;
Expand Down Expand Up @@ -123,7 +124,8 @@ public function withTransaction(callable $callback): mixed
}

// upsert + $setOnInsert hits WriteConflict (E112) under txn snapshot isolation.
if ($this->skipDuplicates) {
// Both Skip and Upsert modes use the no-transaction path.
if ($this->onDuplicate !== OnDuplicate::Fail) {
return $callback();
}

Expand Down Expand Up @@ -429,10 +431,12 @@ public function createCollection(string $name, array $attributes = [], array $in
{
$id = $this->getNamespace() . '_' . $this->filter($name);

// In shared-tables mode or for metadata, the physical collection may
// already exist for another tenant. Return early to avoid a
// "Collection Exists" exception from the client.
if (!$this->inTransaction && ($this->getSharedTables() || $name === Database::METADATA) && $this->exists($this->getNamespace(), $name)) {
// In shared-tables mode or for metadata the physical collection may
// already exist. Return early to avoid "Collection Exists" from the
// client.
$tolerateExisting = $this->getSharedTables() || $name === Database::METADATA;

if (!$this->inTransaction && $tolerateExisting && $this->exists($this->getNamespace(), $name)) {
return true;
}

Expand All @@ -443,14 +447,15 @@ public function createCollection(string $name, array $attributes = [], array $in
} catch (MongoException $e) {
$e = $this->processException($e);
if ($e instanceof DuplicateException) {
// Keep existing shared-tables/metadata behavior — no-op there.
return true;
Comment on lines 449 to 451
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve Fail semantics for collection duplicates.

After processException(), a code-48 collection collision becomes DuplicateException, but Line 457 still returns true even when $tolerateExisting is false. That makes default OnDuplicate::Fail silently idempotent and bypasses the duplicate path mentioned below at Lines 459-467.

🐛 Proposed fix
             if ($e instanceof DuplicateException) {
                 // Also tolerate client-reported duplicates in Skip/Upsert mode.
                 if ($tolerateExisting) {
                     return true;
                 }
-                // Keep existing shared-tables/metadata behavior — no-op there.
-                return true;
+                throw $e;
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/Mongo.php` around lines 451 - 457, The
DuplicateException branch in processException() currently returns true
unconditionally, which makes OnDuplicate::Fail behave like idempotent success;
change the logic in the DuplicateException handling inside Mongo.php so it
returns true only when $tolerateExisting is true or when the target is
explicitly a shared-tables/metadata case (preserve the "no-op" behavior for
shared-tables/metadata), and otherwise do not return early so the subsequent
OnDuplicate::Fail handling runs (i.e., let the code fall through to the
duplicate-path at the bottom instead of returning true).

}
// Client throws code-0 "Collection Exists" when its pre-check
// finds the collection. In shared-tables/metadata context this
// is a no-op; otherwise re-throw as DuplicateException so
// Database::createCollection() can run orphan reconciliation.
// finds the collection. Tolerated contexts no-op; otherwise re-throw
// as DuplicateException so Database::createCollection() can run
// orphan reconciliation.
if ($e->getCode() === 0 && stripos($e->getMessage(), 'Collection Exists') !== false) {
if ($this->getSharedTables() || $name === Database::METADATA) {
if ($tolerateExisting) {
return true;
}
throw new DuplicateException('Collection already exists', $e->getCode(), $e);
Expand Down Expand Up @@ -1497,30 +1502,33 @@ public function createDocuments(Document $collection, array $documents): array
$records[] = $record;
}

// insertMany aborts the txn on any duplicate; upsert + $setOnInsert no-ops instead.
if ($this->skipDuplicates) {
// insertMany aborts the txn on any duplicate; Mongo's upsert path handles
// both Skip ($setOnInsert: insert-only no-op) and Upsert ($set: overwrite).
if ($this->onDuplicate !== OnDuplicate::Fail) {
if (empty($records)) {
return [];
}

$operator = $this->onDuplicate === OnDuplicate::Upsert ? '$set' : '$setOnInsert';

$operations = [];
foreach ($records as $record) {
$filter = ['_uid' => $record['_uid'] ?? ''];
if ($this->sharedTables) {
$filter['_tenant'] = $record['_tenant'] ?? $this->getTenant();
}

// Filter fields can't reappear in $setOnInsert (mongo path-conflict error).
$setOnInsert = $record;
unset($setOnInsert['_uid'], $setOnInsert['_tenant']);
// Filter fields can't reappear in $setOnInsert/$set (mongo path-conflict error).
$payload = $record;
unset($payload['_uid'], $payload['_tenant']);
Comment on lines +1521 to +1523
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 _id not stripped from $set payload on Upsert

Every call to $set in this adapter (updateDocument, upsertDocument) explicitly does unset($record['_id']) before building the update — MongoDB treats _id as an immutable field and rejects $set: {_id: …} on an existing document. The new bulk-write Upsert path only unsets _uid and _tenant, leaving _id (set from $record['_id'] = $sequence or from the $_ substitution of $id) in the $set payload. For any existing document this will throw "Mod on _id not allowed" at the driver level.

The _id must be removed from the $set payload and moved into $setOnInsert so that new documents get their ID on insert while existing documents are not touched:

$payload = $record;
unset($payload['_uid'], $payload['_tenant']);
$idValue = $payload['_id'] ?? null;
unset($payload['_id']);

$update = [$operator => $payload];
if ($operator === '$set' && $idValue !== null) {
    $update['$setOnInsert'] = ['_id' => $idValue];
}

$operations[] = [
    'filter' => $filter,
    'update' => $update,
];


if (empty($setOnInsert)) {
if (empty($payload)) {
continue;
}

$operations[] = [
'filter' => $filter,
'update' => ['$setOnInsert' => $setOnInsert],
'update' => [$operator => $payload],
Comment on lines +1521 to +1531
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

MongoDB update $set _id immutable field and $setOnInsert _id upsert

💡 Result:

In MongoDB, the _id field is immutable and cannot be modified once a document exists. Attempting to use $set on _id in an update operation (with or without upsert) will fail with an error like "Performing an update on the path '_id' would modify the immutable field '_id'". For upsert operations (upsert: true), the _id is determined by the query filter if it specifies _id. For example: db.collection.updateOne( { _id: ObjectId("...") }, { $set: { field: "value" } }, { upsert: true } ) If no document matches, MongoDB inserts a new document using the _id from the query filter, applying the update operators (like $set). $setOnInsert operators are also applied only on insert. Do not include _id in $set or $setOnInsert in the update document, as MongoDB will reject attempts to set _id via update operators even with $setOnInsert (historical behavior confirmed in sources, fixed in later versions but still not recommended). Instead, specify _id in the query filter for controlled upserts. Example of correct upsert without setting _id in update: db.collection.updateOne( { _id: 123 }, { $set: { status: "active" }, $setOnInsert: { createdAt: new Date } }, { upsert: true } ) This sets _id: 123 on insert from the query, applies both $set and $setOnInsert on insert, or only $set on update. If the query doesn't specify _id, MongoDB auto-generates ObjectId for _id on insert.

Citations:


🏁 Script executed:

# Find and examine the upsert operation context in Mongo.php around lines 1527-1537
cd src/Database/Adapter && head -1560 Mongo.php | tail -50

Repository: utopia-php/database

Length of output: 1846


🏁 Script executed:

# Get broader context of the upsert method to understand filter and payload construction
cd src/Database/Adapter && sed -n '1480,1560p' Mongo.php

Repository: utopia-php/database

Length of output: 2845


Strip _id from Mongo $set upserts and specify it in the filter instead.

In OnDuplicate::Upsert, $operator is $set, which will fail because MongoDB rejects any attempt to set _id via the $set operator. MongoDB's _id field is immutable—it cannot be modified after document creation, and it also cannot be set via $setOnInsert. Instead, _id must be specified in the query filter. For new inserts, MongoDB uses the _id from the filter; for existing documents, only $set fields outside of _id are applied.

🔧 Suggested approach

Include _id in the filter when constructing the upsert operation, and exclude it from the $set payload. For example:

                 $filter = ['_uid' => $record['_uid'] ?? ''];
+                if (\array_key_exists('_id', $record)) {
+                    $filter['_id'] = $record['_id'];
+                }
                 if ($this->sharedTables) {
                     $filter['_tenant'] = $record['_tenant'] ?? $this->getTenant();
                 }
 
                 // Filter fields can't reappear in $setOnInsert/$set (mongo path-conflict error).
                 $payload = $record;
-                unset($payload['_uid'], $payload['_tenant']);
+                unset($payload['_uid'], $payload['_tenant'], $payload['_id']);

This way, _id is supplied via the filter (where MongoDB expects it for upserts), not via the update operators.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/Mongo.php` around lines 1527 - 1537, In
OnDuplicate::Upsert where $operator is $set, remove any '_id' from the $payload
before adding the update and, if an '_id' existed in the original $record,
inject it into the $filter so the upsert supplies _id via the query filter
rather than via $set/$setOnInsert; update the code that builds $operations (the
['filter'=> $filter, 'update'=> [$operator => $payload]] entry) to ensure '_id'
is only present in $filter and never in the $payload, preserving the existing
unset of '_uid' and '_tenant' and skipping when $payload becomes empty.

];
}

Expand Down Expand Up @@ -3590,8 +3598,8 @@ protected function processException(\Throwable $e): \Throwable
return new DuplicateException('Collection already exists', $e->getCode(), $e);
}

// Index already exists
if ($e->getCode() === 85) {
// Index already exists (85 = IndexOptionsConflict, 86 = IndexKeySpecsConflict)
if ($e->getCode() === 85 || $e->getCode() === 86) {
return new DuplicateException('Index already exists', $e->getCode(), $e);
}

Expand Down
16 changes: 10 additions & 6 deletions src/Database/Adapter/Pool.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception as DatabaseException;
use Utopia\Database\OnDuplicate;
use Utopia\Database\Validator\Authorization;
use Utopia\Pools\Pool as UtopiaPool;

Expand Down Expand Up @@ -43,8 +44,9 @@ public function __construct(UtopiaPool $pool)
public function delegate(string $method, array $args): mixed
{
if ($this->pinnedAdapter !== null) {
if ($this->skipDuplicates) {
return $this->pinnedAdapter->skipDuplicates(
if ($this->onDuplicate !== OnDuplicate::Fail) {
return $this->pinnedAdapter->withOnDuplicate(
$this->onDuplicate,
fn () => $this->pinnedAdapter->{$method}(...$args)
);
}
Expand All @@ -71,8 +73,9 @@ public function delegate(string $method, array $args): mixed
$adapter->setMetadata($key, $value);
}

if ($this->skipDuplicates) {
return $adapter->skipDuplicates(
if ($this->onDuplicate !== OnDuplicate::Fail) {
return $adapter->withOnDuplicate(
$this->onDuplicate,
fn () => $adapter->{$method}(...$args)
);
}
Expand Down Expand Up @@ -156,8 +159,9 @@ public function withTransaction(callable $callback): mixed

$this->pinnedAdapter = $adapter;
try {
if ($this->skipDuplicates) {
return $adapter->skipDuplicates(
if ($this->onDuplicate !== OnDuplicate::Fail) {
return $adapter->withOnDuplicate(
$this->onDuplicate,
fn () => $adapter->withTransaction($callback)
);
}
Expand Down
54 changes: 44 additions & 10 deletions src/Database/Adapter/Postgres.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Utopia\Database\Exception\Transaction as TransactionException;
use Utopia\Database\Exception\Truncate as TruncateException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\OnDuplicate;
use Utopia\Database\Operator;
use Utopia\Database\Query;

Expand Down Expand Up @@ -241,6 +242,7 @@ public function createCollection(string $name, array $attributes = [], array $in
}

$sqlTenant = $this->sharedTables ? '_tenant INTEGER DEFAULT NULL,' : '';

$collection = "
CREATE TABLE {$this->getSQLTable($id)} (
_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
Expand Down Expand Up @@ -291,19 +293,19 @@ public function createCollection(string $name, array $attributes = [], array $in
$uniquePermissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_ukey");
$permissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_permission");
$permissions .= "
CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\"
CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\"
ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_document,_type,_permission);
CREATE INDEX \"{$permissionIndex}\"
ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_permission,_type);
CREATE INDEX \"{$permissionIndex}\"
ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_permission,_type);
";
} else {
$uniquePermissionIndex = $this->getShortKey("{$namespace}_{$id}_ukey");
$permissionIndex = $this->getShortKey("{$namespace}_{$id}_permission");
$permissions .= "
CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\"
CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\"
ON {$this->getSQLTable($id . '_perms')} USING btree (_document COLLATE utf8_ci_ai,_type,_permission);
CREATE INDEX \"{$permissionIndex}\"
ON {$this->getSQLTable($id . '_perms')} USING btree (_permission,_type);
CREATE INDEX \"{$permissionIndex}\"
ON {$this->getSQLTable($id . '_perms')} USING btree (_permission,_type);
";
}

Expand Down Expand Up @@ -2352,23 +2354,55 @@ public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool

protected function getInsertKeyword(): string
{
// Postgres doesn't have INSERT IGNORE — Skip/Upsert are realized via
// the ON CONFLICT suffix (see getInsertSuffix).
return 'INSERT INTO';
}

protected function getInsertSuffix(string $table): string
/**
* Postgres permissions insert uses ON CONFLICT DO NOTHING via suffix; the
* keyword stays plain INSERT INTO. Override MariaDB's INSERT IGNORE (no-op
* in MySQL dialect, a syntax error in Postgres).
*/
protected function getInsertPermissionsKeyword(): string
{
return 'INSERT INTO';
}

/**
* @param array<string> $columns
*/
protected function getInsertSuffix(string $table, array $columns = []): string
{
if (!$this->skipDuplicates) {
if ($this->onDuplicate === OnDuplicate::Fail) {
return '';
}

$conflictTarget = $this->sharedTables ? '("_uid", "_tenant")' : '("_uid")';

return "ON CONFLICT {$conflictTarget} DO NOTHING";
if ($this->onDuplicate === OnDuplicate::Skip) {
return "ON CONFLICT {$conflictTarget} DO NOTHING";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Upsert: DO UPDATE SET col = EXCLUDED.col for every column except the conflict key.
$assignments = [];
foreach ($columns as $col) {
if (\in_array($col, ['"_uid"', '"_id"', '"_tenant"'], true)) {
continue;
}
$assignments[] = "{$col} = EXCLUDED.{$col}";
}

if (empty($assignments)) {
return "ON CONFLICT {$conflictTarget} DO NOTHING";
}

return "ON CONFLICT {$conflictTarget} DO UPDATE SET " . \implode(', ', $assignments);
}
Comment on lines +2387 to 2401
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm how SQL::createDocuments passes the columns list into getInsertSuffix
# (expecting quoted identifiers like `"_uid"` to match the exclusion set),
# and check how other adapters (MariaDB) exclude _createdAt on Upsert.
rg -nP -C5 'getInsertSuffix\s*\(' --type=php
rg -nP -C3 "'_createdAt'|\"_createdAt\"" src/Database/Adapter --type=php

Repository: utopia-php/database

Length of output: 19136


🏁 Script executed:

# Extract the full getInsertSuffix() implementation in Postgres.php
sed -n '2375,2410p' src/Database/Adapter/Postgres.php

# Check MariaDB's implementation for comparison
sed -n '2387,2420p' src/Database/Adapter/MariaDB.php

# Search for getInsertPermissionsSuffix() to understand permissions handling
rg -nP 'getInsertPermissionsSuffix' --type=php -A5

Repository: utopia-php/database

Length of output: 3171


🏁 Script executed:

# Get full getInsertPermissionsSuffix() implementations
sed -n '2403,2430p' src/Database/Adapter/Postgres.php
echo "---"
rg -nP 'getInsertPermissionsSuffix' src/Database/Adapter/MariaDB.php -A15

# Check how columns list is built in createDocuments (search for where $columnList is created)
sed -n '2550,2600p' src/Database/Adapter/SQL.php | head -60

# Verify what columns are included in the upsert
rg -nP '\$columnList' src/Database/Adapter/SQL.php -B2 -A2

Repository: utopia-php/database

Length of output: 3518


Upsert incorrectly updates _createdAt — conflicts with updateDocument() semantics.

In getInsertSuffix(), the assignment loop excludes only "_uid", "_id", "_tenant", but not "_createdAt". This means on a conflict, the incoming document's _createdAt will overwrite the original creation timestamp. However, in updateDocument() at line 1428, the code explicitly excludes _createdAt from updates, confirming this column should be immutable on both paths.

Additionally, the _permissions column will be updated via DO UPDATE SET in the main table, but getInsertPermissionsSuffix() uses DO NOTHING for the permissions table, causing the two to diverge on upsert.

🛠 Proposed fix
-        // Upsert: DO UPDATE SET col = EXCLUDED.col for every column except the conflict key.
+        // Upsert: DO UPDATE SET col = EXCLUDED.col for every column except the
+        // conflict/identity/immutable columns.
         $assignments = [];
         foreach ($columns as $col) {
-            if (\in_array($col, ['"_uid"', '"_id"', '"_tenant"'], true)) {
+            if (\in_array($col, ['"_uid"', '"_id"', '"_tenant"', '"_createdAt"'], true)) {
                 continue;
             }
             $assignments[] = "{$col} = EXCLUDED.{$col}";
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/Postgres.php` around lines 2387 - 2401,
getInsertSuffix() currently allows _createdAt and _permissions to be updated on
conflict, which conflicts with updateDocument() and the permissions table
behavior; modify getInsertSuffix() (the assignment loop that currently excludes
['"_uid"','"_id"','"_tenant"']) to also exclude '"_createdAt"' and
'"_permissions"' so those columns are not included in the DO UPDATE SET list,
and leave getInsertPermissionsSuffix() as DO NOTHING so both main-table upserts
and the permissions-table upserts treat creation timestamp and permissions as
immutable on conflict.


protected function getInsertPermissionsSuffix(): string
{
if (!$this->skipDuplicates) {
if ($this->onDuplicate === OnDuplicate::Fail) {
return '';
}

Expand Down
Loading
Loading