Skip to content

Commit 32b2bd9

Browse files
committed
Merge branch 'main' of github.com:utopia-php/database into joins9
# Conflicts: # src/Database/Database.php # src/Database/Query.php # src/Database/Validator/Queries.php # src/Database/Validator/Query/Filter.php
2 parents 063d068 + 5e49f32 commit 32b2bd9

17 files changed

Lines changed: 1807 additions & 852 deletions

File tree

src/Database/Adapter/MariaDB.php

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -927,7 +927,29 @@ public function createDocument(Document $collection, Document $document): Docume
927927
}
928928

929929
if (isset($stmtPermissions)) {
930-
$stmtPermissions->execute();
930+
try {
931+
$stmtPermissions->execute();
932+
} catch (PDOException $e) {
933+
$isOrphanedPermission = $e->getCode() === '23000'
934+
&& isset($e->errorInfo[1])
935+
&& $e->errorInfo[1] === 1062
936+
&& \str_contains($e->getMessage(), '_index1');
937+
938+
if (!$isOrphanedPermission) {
939+
throw $e;
940+
}
941+
942+
// Clean up orphaned permissions from a previous failed delete, then retry
943+
$sql = "DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid {$this->getTenantQuery($collection)}";
944+
$cleanup = $this->getPDO()->prepare($sql);
945+
$cleanup->bindValue(':_uid', $document->getId());
946+
if ($this->sharedTables) {
947+
$cleanup->bindValue(':_tenant', $document->getTenant());
948+
}
949+
$cleanup->execute();
950+
951+
$stmtPermissions->execute();
952+
}
931953
}
932954
} catch (PDOException $e) {
933955
throw $this->processException($e);
@@ -1605,7 +1627,14 @@ protected function getSQLCondition(Query $query, array &$binds): string
16051627
case Query::TYPE_IS_NOT_NULL:
16061628

16071629
return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}";
1630+
case Query::TYPE_CONTAINS_ALL:
1631+
if ($query->onArray()) {
1632+
$binds[":{$placeholder}_0"] = json_encode($query->getValues());
1633+
return "JSON_CONTAINS({$alias}.{$attribute}, :{$placeholder}_0)";
1634+
}
1635+
// no break
16081636
case Query::TYPE_CONTAINS:
1637+
case Query::TYPE_CONTAINS_ANY:
16091638
case Query::TYPE_NOT_CONTAINS:
16101639
if ($this->getSupportForJSONOverlaps() && $query->onArray()) {
16111640
$binds[":{$placeholder}_0"] = json_encode($query->getValues());
@@ -1629,7 +1658,7 @@ protected function getSQLCondition(Query $query, array &$binds): string
16291658
Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%',
16301659
Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value),
16311660
Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value),
1632-
Query::TYPE_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%',
1661+
Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%',
16331662
Query::TYPE_NOT_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%',
16341663
default => $value
16351664
};
@@ -1892,6 +1921,13 @@ protected function processException(PDOException $e): \Exception
18921921

18931922
// Duplicate row
18941923
if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) {
1924+
$message = $e->getMessage();
1925+
if (\str_contains($message, '_index1')) {
1926+
return new DuplicateException('Duplicate permissions for document', $e->getCode(), $e);
1927+
}
1928+
if (!\str_contains($message, '_uid')) {
1929+
return new DuplicateException('Document with the requested unique attributes already exists', $e->getCode(), $e);
1930+
}
18951931
return new DuplicateException('Document already exists', $e->getCode(), $e);
18961932
}
18971933

src/Database/Adapter/Mongo.php

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2676,6 +2676,22 @@ protected function buildFilters(array $queries, string $separator = '$and'): arr
26762676
*/
26772677
protected function buildFilter(Query $query): array
26782678
{
2679+
// Normalize extended ISO 8601 datetime strings in query values to UTCDateTime
2680+
// so they can be correctly compared against datetime fields stored in MongoDB.
2681+
if (!$this->getSupportForAttributes() || \in_array($query->getAttribute(), ['$createdAt', '$updatedAt'], true)) {
2682+
$values = $query->getValues();
2683+
foreach ($values as $k => $value) {
2684+
if (is_string($value) && $this->isExtendedISODatetime($value)) {
2685+
try {
2686+
$values[$k] = $this->toMongoDatetime($value);
2687+
} catch (\Throwable $th) {
2688+
// Leave value as-is if it cannot be parsed as a datetime
2689+
}
2690+
}
2691+
}
2692+
$query->setValues($values);
2693+
}
2694+
26792695
if ($query->getAttribute() === '$id') {
26802696
$query->setAttribute('_uid');
26812697
} elseif ($query->getAttribute() === '$sequence') {
@@ -2711,7 +2727,7 @@ protected function buildFilter(Query $query): array
27112727
};
27122728

27132729
$filter = [];
2714-
if ($query->isObjectAttribute() && !\str_contains($attribute, '.') && in_array($query->getMethod(), [Query::TYPE_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_NOT_EQUAL])) {
2730+
if ($query->isObjectAttribute() && !\str_contains($attribute, '.') && in_array($query->getMethod(), [Query::TYPE_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_CONTAINS_ALL, Query::TYPE_NOT_CONTAINS, Query::TYPE_NOT_EQUAL])) {
27152731
$this->handleObjectFilters($query, $filter);
27162732
return $filter;
27172733
}
@@ -2720,8 +2736,10 @@ protected function buildFilter(Query $query): array
27202736
$filter[$attribute]['$in'] = $value;
27212737
} elseif ($operator == '$ne' && \is_array($value)) {
27222738
$filter[$attribute]['$nin'] = $value;
2739+
} elseif ($operator == '$all') {
2740+
$filter[$attribute]['$all'] = $query->getValues();
27232741
} elseif ($operator == '$in') {
2724-
if ($query->getMethod() === Query::TYPE_CONTAINS && !$query->onArray()) {
2742+
if (in_array($query->getMethod(), [Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY]) && !$query->onArray()) {
27252743
// contains support array values
27262744
if (is_array($value)) {
27272745
$filter['$or'] = array_map(function ($val) use ($attribute) {
@@ -2798,6 +2816,8 @@ private function handleObjectFilters(Query $query, array &$filter): void
27982816
switch ($query->getMethod()) {
27992817

28002818
case Query::TYPE_CONTAINS:
2819+
case Query::TYPE_CONTAINS_ANY:
2820+
case Query::TYPE_CONTAINS_ALL:
28012821
case Query::TYPE_NOT_CONTAINS: {
28022822
$arrayValue = \is_array($queryValue) ? $queryValue : [$queryValue];
28032823
$operator = $isNot ? '$nin' : '$in';
@@ -2882,6 +2902,8 @@ protected function getQueryOperator(string $operator): string
28822902
Query::TYPE_GREATER => '$gt',
28832903
Query::TYPE_GREATER_EQUAL => '$gte',
28842904
Query::TYPE_CONTAINS => '$in',
2905+
Query::TYPE_CONTAINS_ANY => '$in',
2906+
Query::TYPE_CONTAINS_ALL => '$all',
28852907
Query::TYPE_NOT_CONTAINS => 'notContains',
28862908
Query::TYPE_SEARCH => '$search',
28872909
Query::TYPE_NOT_SEARCH => '$search',
@@ -3550,12 +3572,11 @@ protected function processException(\Throwable $e): \Throwable
35503572
}
35513573

35523574
// Duplicate key error
3553-
if ($e->getCode() === 11000) {
3554-
return new DuplicateException('Document already exists', $e->getCode(), $e);
3555-
}
3556-
3557-
// Duplicate key error for unique index
3558-
if ($e->getCode() === 11001) {
3575+
if ($e->getCode() === 11000 || $e->getCode() === 11001) {
3576+
$message = $e->getMessage();
3577+
if (!\str_contains($message, '_uid')) {
3578+
return new DuplicateException('Document with the requested unique attributes already exists', $e->getCode(), $e);
3579+
}
35593580
return new DuplicateException('Document already exists', $e->getCode(), $e);
35603581
}
35613582

src/Database/Adapter/Pool.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ class Pool extends Adapter
1717
*/
1818
protected UtopiaPool $pool;
1919

20+
/**
21+
* When a transaction is active, all delegate calls are routed through
22+
* this pinned adapter to ensure they run on the same connection.
23+
*/
24+
protected ?Adapter $pinnedAdapter = null;
25+
2026
/**
2127
* @param UtopiaPool<covariant Adapter> $pool The pool to use for connections. Must contain instances of Adapter.
2228
*/
@@ -37,6 +43,10 @@ public function __construct(UtopiaPool $pool)
3743
*/
3844
public function delegate(string $method, array $args): mixed
3945
{
46+
if ($this->pinnedAdapter !== null) {
47+
return $this->pinnedAdapter->{$method}(...$args);
48+
}
49+
4050
return $this->pool->use(function (Adapter $adapter) use ($method, $args) {
4151
// Run setters in case config changed since this connection was last used
4252
$adapter->setDatabase($this->getDatabase());
@@ -93,6 +103,52 @@ public function rollbackTransaction(): bool
93103
return $this->delegate(__FUNCTION__, \func_get_args());
94104
}
95105

106+
/**
107+
* Pin a single connection from the pool for the entire transaction lifecycle.
108+
* This prevents startTransaction(), the callback, and commitTransaction()
109+
* from running on different connections.
110+
*
111+
* @template T
112+
* @param callable(): T $callback
113+
* @return T
114+
* @throws \Throwable
115+
*/
116+
public function withTransaction(callable $callback): mixed
117+
{
118+
// If already inside a transaction, reuse the pinned adapter
119+
// so nested withTransaction calls use the same connection
120+
if ($this->pinnedAdapter !== null) {
121+
return $this->pinnedAdapter->withTransaction($callback);
122+
}
123+
124+
return $this->pool->use(function (Adapter $adapter) use ($callback) {
125+
$adapter->setDatabase($this->getDatabase());
126+
$adapter->setNamespace($this->getNamespace());
127+
$adapter->setSharedTables($this->getSharedTables());
128+
$adapter->setTenant($this->getTenant());
129+
$adapter->setAuthorization($this->authorization);
130+
131+
if ($this->getTimeout() > 0) {
132+
$adapter->setTimeout($this->getTimeout());
133+
}
134+
$adapter->resetDebug();
135+
foreach ($this->getDebug() as $key => $value) {
136+
$adapter->setDebug($key, $value);
137+
}
138+
$adapter->resetMetadata();
139+
foreach ($this->getMetadata() as $key => $value) {
140+
$adapter->setMetadata($key, $value);
141+
}
142+
143+
$this->pinnedAdapter = $adapter;
144+
try {
145+
return $adapter->withTransaction($callback);
146+
} finally {
147+
$this->pinnedAdapter = null;
148+
}
149+
});
150+
}
151+
96152
protected function quote(string $string): string
97153
{
98154
return $this->delegate(__FUNCTION__, \func_get_args());

src/Database/Adapter/Postgres.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1712,6 +1712,8 @@ protected function handleObjectQueries(Query $query, array &$binds, string $attr
17121712
}
17131713

17141714
case Query::TYPE_CONTAINS:
1715+
case Query::TYPE_CONTAINS_ANY:
1716+
case Query::TYPE_CONTAINS_ALL:
17151717
case Query::TYPE_NOT_CONTAINS: {
17161718
$isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS;
17171719
$conditions = [];
@@ -1822,7 +1824,15 @@ protected function getSQLCondition(Query $query, array &$binds): string
18221824
case Query::TYPE_IS_NOT_NULL:
18231825
return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}";
18241826

1827+
case Query::TYPE_CONTAINS_ALL:
1828+
if ($query->onArray()) {
1829+
// @> checks the array contains ALL specified values
1830+
$binds[":{$placeholder}_0"] = \json_encode($query->getValues());
1831+
return "{$alias}.{$attribute} @> :{$placeholder}_0::jsonb";
1832+
}
1833+
// no break
18251834
case Query::TYPE_CONTAINS:
1835+
case Query::TYPE_CONTAINS_ANY:
18261836
case Query::TYPE_NOT_CONTAINS:
18271837
if ($query->onArray()) {
18281838
$operator = '@>';
@@ -1844,7 +1854,7 @@ protected function getSQLCondition(Query $query, array &$binds): string
18441854
Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%',
18451855
Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value),
18461856
Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value),
1847-
Query::TYPE_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%',
1857+
Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%',
18481858
Query::TYPE_NOT_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%',
18491859
default => $value
18501860
};
@@ -2199,6 +2209,10 @@ protected function processException(PDOException $e): \Exception
21992209

22002210
// Duplicate row
22012211
if ($e->getCode() === '23505' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) {
2212+
$message = $e->getMessage();
2213+
if (!\str_contains($message, '_uid')) {
2214+
return new DuplicateException('Document with the requested unique attributes already exists', $e->getCode(), $e);
2215+
}
22022216
return new DuplicateException('Document already exists', $e->getCode(), $e);
22032217
}
22042218

src/Database/Adapter/SQL.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1806,6 +1806,8 @@ protected function getSQLOperator(string $method): string
18061806
case Query::TYPE_STARTS_WITH:
18071807
case Query::TYPE_ENDS_WITH:
18081808
case Query::TYPE_CONTAINS:
1809+
case Query::TYPE_CONTAINS_ANY:
1810+
case Query::TYPE_CONTAINS_ALL:
18091811
case Query::TYPE_NOT_STARTS_WITH:
18101812
case Query::TYPE_NOT_ENDS_WITH:
18111813
case Query::TYPE_NOT_CONTAINS:

src/Database/Adapter/SQLite.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,6 +1328,9 @@ protected function processException(PDOException $e): \Exception
13281328
stripos($message, 'unique') !== false ||
13291329
stripos($message, 'duplicate') !== false
13301330
) {
1331+
if (!\str_contains($message, '_uid')) {
1332+
return new DuplicateException('Document with the requested unique attributes already exists', $e->getCode(), $e);
1333+
}
13311334
return new DuplicateException('Document already exists', $e->getCode(), $e);
13321335
}
13331336
}

0 commit comments

Comments
 (0)