Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
60f1ff1
Add ignore param to createDocuments for silent duplicate handling
premtsd-code Apr 9, 2026
bee42d4
Add e2e tests for createDocuments ignore mode
premtsd-code Apr 9, 2026
93a9136
Fix Mongo adapter ignore mode: pass ignoreDuplicates to client and fi…
premtsd-code Apr 10, 2026
2906dda
Revert "Fix Mongo adapter ignore mode: pass ignoreDuplicates to clien…
premtsd-code Apr 10, 2026
63d9902
Replace ignore param with skipDuplicates scope guard
premtsd-code Apr 10, 2026
b0a8392
Push skipDuplicates scope guard down to Adapter layer
premtsd-code Apr 12, 2026
d8f647c
Refactor: extract helpers and collapse conditional wraps
premtsd-code Apr 12, 2026
c88c6ac
fix: guard buildUidTenantLookup against empty UID set
premtsd-code Apr 12, 2026
16849fe
Add e2e tests for skipDuplicates edge cases
premtsd-code Apr 13, 2026
c6d566e
SQL adapter: remove redundant skipDuplicates pre-filter
premtsd-code Apr 13, 2026
fd37b69
Mongo adapter: use upsertWithCounts for race-safe accurate counts
premtsd-code Apr 13, 2026
a24358c
Merge remote-tracking branch 'origin/main' into csv-import-upsert-v2
premtsd-code Apr 13, 2026
3b783af
Revert "SQL adapter: remove redundant skipDuplicates pre-filter"
premtsd-code Apr 13, 2026
3a483f2
Mirror: forward only docs the source actually inserted
premtsd-code Apr 13, 2026
eb99cf1
skipDuplicates: simplify by moving pre-filter to orchestrator only
premtsd-code Apr 13, 2026
89e4cf8
skipDuplicates: drop deferred-relationships, inline pre-filter, tight…
premtsd-code Apr 13, 2026
41704eb
Address Jake's review on PR #852
premtsd-code Apr 13, 2026
e9e5e76
Mirror::createDocuments: bound skipDuplicates capture to O($batchSize)
premtsd-code Apr 14, 2026
ae929db
Restore per-tenant grouping for batch existing-doc lookups in tenantP…
premtsd-code Apr 14, 2026
431d378
skipDuplicates: drop pre-filter, rely on adapter-level dedup
premtsd-code Apr 14, 2026
0fd7c33
skipDuplicates: drop intra-batch dedup, trim verbose test comments
premtsd-code Apr 14, 2026
9baaa04
Group skipDuplicates / getInsert* into their logical clusters
premtsd-code Apr 15, 2026
3c85013
Mirror::createDocuments: re-fetch source after skipDuplicates insert
premtsd-code Apr 15, 2026
934ec04
Mirror::createDocuments: pre-filter existing ids + unify skip/non-ski…
premtsd-code Apr 15, 2026
fa0e373
Chunk id-lookup queries to respect RELATION_QUERY_CHUNK_SIZE
premtsd-code Apr 15, 2026
52b189b
Chunk id lookups by \$this->maxQueryValues, not RELATION_QUERY_CHUNK_…
premtsd-code Apr 15, 2026
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
23 changes: 23 additions & 0 deletions src/Database/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ abstract class Adapter

protected bool $alterLocks = false;

protected bool $skipDuplicates = false;

/**
* @var array<string, mixed>
*/
Expand Down Expand Up @@ -392,6 +394,27 @@ public function inTransaction(): bool
return $this->inTransaction > 0;
}

/**
* Run a callback with skipDuplicates enabled.
* Duplicate key errors during createDocuments() will be silently skipped
* instead of thrown. Nestable — saves and restores previous state.
*
* @template T
* @param callable(): T $callback
* @return T
*/
public function skipDuplicates(callable $callback): mixed
{
$previous = $this->skipDuplicates;
$this->skipDuplicates = true;

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

/**
* @template T
* @param callable(): T $callback
Expand Down
41 changes: 41 additions & 0 deletions src/Database/Adapter/Mongo.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ public function withTransaction(callable $callback): mixed
return $callback();
}

// upsert + $setOnInsert hits WriteConflict (E112) under txn snapshot isolation.
if ($this->skipDuplicates) {
return $callback();
}

try {
$this->startTransaction();
$result = $callback();
Expand Down Expand Up @@ -1492,6 +1497,42 @@ 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) {
if (empty($records)) {
return [];
}

$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']);

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

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

try {
$this->client->upsert($name, $operations, $options);
} catch (MongoException $e) {
throw $this->processException($e);
}

return $documents;
Comment thread
abnegate marked this conversation as resolved.
}

try {
$documents = $this->client->insertMany($name, $records, $options);
} catch (MongoException $e) {
Expand Down
15 changes: 15 additions & 0 deletions src/Database/Adapter/Pool.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ public function __construct(UtopiaPool $pool)
public function delegate(string $method, array $args): mixed
{
if ($this->pinnedAdapter !== null) {
if ($this->skipDuplicates) {
return $this->pinnedAdapter->skipDuplicates(
fn () => $this->pinnedAdapter->{$method}(...$args)
);
}
return $this->pinnedAdapter->{$method}(...$args);
}

Expand All @@ -66,6 +71,11 @@ public function delegate(string $method, array $args): mixed
$adapter->setMetadata($key, $value);
}

if ($this->skipDuplicates) {
return $adapter->skipDuplicates(
fn () => $adapter->{$method}(...$args)
);
}
return $adapter->{$method}(...$args);
});
}
Expand Down Expand Up @@ -146,6 +156,11 @@ public function withTransaction(callable $callback): mixed

$this->pinnedAdapter = $adapter;
try {
if ($this->skipDuplicates) {
return $adapter->skipDuplicates(
fn () => $adapter->withTransaction($callback)
);
}
return $adapter->withTransaction($callback);
} finally {
$this->pinnedAdapter = null;
Expand Down
29 changes: 29 additions & 0 deletions src/Database/Adapter/Postgres.php
Original file line number Diff line number Diff line change
Expand Up @@ -2350,6 +2350,35 @@ public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool
return false;
}

protected function getInsertKeyword(): string
{
return 'INSERT INTO';
}

protected function getInsertSuffix(string $table): string
{
if (!$this->skipDuplicates) {
return '';
}

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

return "ON CONFLICT {$conflictTarget} DO NOTHING";
}

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

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

return "ON CONFLICT {$conflictTarget} DO NOTHING";
}

public function decodePoint(string $wkb): array
{
if (str_starts_with(strtoupper($wkb), 'POINT(')) {
Expand Down
36 changes: 33 additions & 3 deletions src/Database/Adapter/SQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,33 @@ public function getSupportForHostname(): bool
return true;
}

/**
* Returns the INSERT keyword, optionally with IGNORE for duplicate handling.
* Override in adapter subclasses for DB-specific syntax.
*/
protected function getInsertKeyword(): string
{
return $this->skipDuplicates ? 'INSERT IGNORE INTO' : 'INSERT INTO';
}

/**
* Returns a suffix appended after VALUES clause for duplicate handling.
* Override in adapter subclasses (e.g., Postgres uses ON CONFLICT DO NOTHING).
*/
protected function getInsertSuffix(string $table): string
{
return '';
}

/**
* Returns a suffix for the permissions INSERT statement when ignoring duplicates.
* Override in adapter subclasses for DB-specific syntax.
*/
protected function getInsertPermissionsSuffix(): string
{
return '';
}

/**
* Get current attribute count from collection document
*
Expand Down Expand Up @@ -2476,6 +2503,7 @@ public function createDocuments(Document $collection, array $documents): array
if (empty($documents)) {
return $documents;
}

$spatialAttributes = $this->getSpatialAttributes($collection);
$collection = $collection->getId();
try {
Expand Down Expand Up @@ -2573,8 +2601,9 @@ public function createDocuments(Document $collection, array $documents): array
$batchKeys = \implode(', ', $batchKeys);

$stmt = $this->getPDO()->prepare("
INSERT INTO {$this->getSQLTable($name)} {$columns}
{$this->getInsertKeyword()} {$this->getSQLTable($name)} {$columns}
VALUES {$batchKeys}
{$this->getInsertSuffix($name)}
");

foreach ($bindValues as $key => $value) {
Expand All @@ -2588,8 +2617,9 @@ public function createDocuments(Document $collection, array $documents): array
$permissions = \implode(', ', $permissions);

$sqlPermissions = "
INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn})
VALUES {$permissions};
{$this->getInsertKeyword()} {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn})
VALUES {$permissions}
{$this->getInsertPermissionsSuffix()}
";

$stmtPermissions = $this->getPDO()->prepare($sqlPermissions);
Expand Down
5 changes: 5 additions & 0 deletions src/Database/Adapter/SQLite.php
Original file line number Diff line number Diff line change
Expand Up @@ -1936,4 +1936,9 @@ public function getSupportForTTLIndexes(): bool
{
return false;
}

protected function getInsertKeyword(): string
{
return $this->skipDuplicates ? 'INSERT OR IGNORE INTO' : 'INSERT INTO';
}
}
96 changes: 81 additions & 15 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,8 @@ class Database

protected bool $preserveDates = false;

protected bool $skipDuplicates = false;

protected bool $preserveSequence = false;

protected int $maxQueryValues = 5000;
Expand Down Expand Up @@ -842,6 +844,29 @@ public function skipRelationshipsExistCheck(callable $callback): mixed
}
}

public function skipDuplicates(callable $callback): mixed
{
$previous = $this->skipDuplicates;
$this->skipDuplicates = true;

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

/**
* Build a tenant-aware identity key for a document.
* Returns "<tenant>:<id>" in tenant-per-document shared-table mode, otherwise just the id.
*/
private function tenantKey(Document $document): string
{
return ($this->adapter->getSharedTables() && $this->adapter->getTenantPerDocument())
? $document->getTenant() . ':' . $document->getId()
: $document->getId();
}

/**
* Trigger callback for events
*
Expand Down Expand Up @@ -5700,9 +5725,11 @@ public function createDocuments(
}

foreach (\array_chunk($documents, $batchSize) as $chunk) {
$batch = $this->withTransaction(function () use ($collection, $chunk) {
return $this->adapter->createDocuments($collection, $chunk);
});
$insert = fn () => $this->withTransaction(fn () => $this->adapter->createDocuments($collection, $chunk));
// Set adapter flag before withTransaction so Mongo can opt out of a real txn.
$batch = $this->skipDuplicates
? $this->adapter->skipDuplicates($insert)
: $insert();

$batch = $this->adapter->getSequences($collection->getId(), $batch);

Expand Down Expand Up @@ -7116,18 +7143,57 @@ public function upsertDocumentsWithIncrease(
$created = 0;
$updated = 0;
$seenIds = [];
foreach ($documents as $key => $document) {
if ($this->getSharedTables() && $this->getTenantPerDocument()) {
$old = $this->authorization->skip(fn () => $this->withTenant($document->getTenant(), fn () => $this->silent(fn () => $this->getDocument(
$collection->getId(),
$document->getId(),
))));
} else {
$old = $this->authorization->skip(fn () => $this->silent(fn () => $this->getDocument(
$collection->getId(),
$document->getId(),
)));

// Batch-fetch existing documents in one query instead of N individual getDocument() calls.
// tenantPerDocument: group ids by tenant and run one find() per tenant under withTenant,
// so cross-tenant batches (e.g. StatsUsage worker) don't get silently scoped to the
// session tenant and miss rows belonging to other tenants.
$existingDocs = [];

if ($this->getSharedTables() && $this->getTenantPerDocument()) {
$idsByTenant = [];
foreach ($documents as $doc) {
if ($doc->getId() !== '') {
$idsByTenant[$doc->getTenant()][] = $doc->getId();
}
}
foreach ($idsByTenant as $tenant => $tenantIds) {
$tenantIds = \array_values(\array_unique($tenantIds));
foreach (\array_chunk($tenantIds, \max(1, $this->maxQueryValues)) as $chunk) {
$found = $this->authorization->skip(fn () => $this->withTenant($tenant, fn () => $this->silent(
fn () => $this->find($collection->getId(), [
Query::equal('$id', $chunk),
Query::limit(PHP_INT_MAX),
])
)));
foreach ($found as $doc) {
$existingDocs[$tenant . ':' . $doc->getId()] = $doc;
}
}
}
} else {
$docIds = \array_values(\array_unique(\array_filter(
\array_map(fn (Document $doc) => $doc->getId(), $documents),
fn ($id) => $id !== ''
)));

if (!empty($docIds)) {
foreach (\array_chunk($docIds, \max(1, $this->maxQueryValues)) as $chunk) {
$existing = $this->authorization->skip(fn () => $this->silent(
fn () => $this->find($collection->getId(), [
Query::equal('$id', $chunk),
Query::limit(PHP_INT_MAX),
])
));
foreach ($existing as $doc) {
$existingDocs[$this->tenantKey($doc)] = $doc;
}
}
}
}

foreach ($documents as $key => $document) {
$old = $existingDocs[$this->tenantKey($document)] ?? new Document();

// Extract operators early to avoid comparison issues
$documentArray = $document->getArrayCopy();
Expand Down Expand Up @@ -7294,7 +7360,7 @@ public function upsertDocumentsWithIncrease(
$document = $this->silent(fn () => $this->createDocumentRelationships($collection, $document));
}

$seenIds[] = $document->getId();
$seenIds[] = $this->tenantKey($document);
$old = $this->adapter->castingBefore($collection, $old);
$document = $this->adapter->castingBefore($collection, $document);

Expand Down
Loading
Loading