Skip to content

Commit 783193d

Browse files
authored
Merge pull request #778 from utopia-php/chore-sync-3.x
2 parents 414dbde + 27d36f4 commit 783193d

File tree

14 files changed

+1077
-34
lines changed

14 files changed

+1077
-34
lines changed

src/Database/Adapter/Mongo.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class Mongo extends Adapter
4242
'$regex',
4343
'$not',
4444
'$nor',
45+
'$exists',
4546
];
4647

4748
protected Client $client;
@@ -2373,6 +2374,8 @@ protected function buildFilter(Query $query): array
23732374
$value = match ($query->getMethod()) {
23742375
Query::TYPE_IS_NULL,
23752376
Query::TYPE_IS_NOT_NULL => null,
2377+
Query::TYPE_EXISTS => true,
2378+
Query::TYPE_NOT_EXISTS => false,
23762379
default => $this->getQueryValue(
23772380
$query->getMethod(),
23782381
count($query->getValues()) > 1
@@ -2434,6 +2437,10 @@ protected function buildFilter(Query $query): array
24342437
$filter[$attribute] = ['$not' => $this->createSafeRegex($value, '^%s')];
24352438
} elseif ($operator === '$regex' && $query->getMethod() === Query::TYPE_NOT_ENDS_WITH) {
24362439
$filter[$attribute] = ['$not' => $this->createSafeRegex($value, '%s$')];
2440+
} elseif ($operator === '$exists') {
2441+
foreach ($query->getValues() as $attribute) {
2442+
$filter['$or'][] = [$attribute => [$operator => $value]];
2443+
}
24372444
} else {
24382445
$filter[$attribute][$operator] = $value;
24392446
}
@@ -2472,6 +2479,8 @@ protected function getQueryOperator(string $operator): string
24722479
Query::TYPE_NOT_ENDS_WITH => '$regex',
24732480
Query::TYPE_OR => '$or',
24742481
Query::TYPE_AND => '$and',
2482+
Query::TYPE_EXISTS,
2483+
Query::TYPE_NOT_EXISTS => '$exists',
24752484
default => throw new DatabaseException('Unknown operator:' . $operator . '. Must be one of ' . Query::TYPE_EQUAL . ', ' . Query::TYPE_NOT_EQUAL . ', ' . Query::TYPE_LESSER . ', ' . Query::TYPE_LESSER_EQUAL . ', ' . Query::TYPE_GREATER . ', ' . Query::TYPE_GREATER_EQUAL . ', ' . Query::TYPE_IS_NULL . ', ' . Query::TYPE_IS_NOT_NULL . ', ' . Query::TYPE_BETWEEN . ', ' . Query::TYPE_NOT_BETWEEN . ', ' . Query::TYPE_STARTS_WITH . ', ' . Query::TYPE_NOT_STARTS_WITH . ', ' . Query::TYPE_ENDS_WITH . ', ' . Query::TYPE_NOT_ENDS_WITH . ', ' . Query::TYPE_CONTAINS . ', ' . Query::TYPE_NOT_CONTAINS . ', ' . Query::TYPE_SEARCH . ', ' . Query::TYPE_NOT_SEARCH . ', ' . Query::TYPE_SELECT),
24762485
};
24772486
}

src/Database/Adapter/Postgres.php

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
*/
3131
class Postgres extends SQL
3232
{
33+
public const MAX_IDENTIFIER_NAME = 63;
3334
/**
3435
* @inheritDoc
3536
*/
@@ -244,17 +245,24 @@ public function createCollection(string $name, array $attributes = [], array $in
244245
";
245246

246247
if ($this->sharedTables) {
248+
$uidIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_uid");
249+
$createdIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_created");
250+
$updatedIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_updated");
251+
$tenantIdIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_tenant_id");
247252
$collection .= "
248-
CREATE UNIQUE INDEX \"{$namespace}_{$this->tenant}_{$id}_uid\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai, \"_tenant\");
249-
CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_created\" ON {$this->getSQLTable($id)} (_tenant, \"_createdAt\");
250-
CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_updated\" ON {$this->getSQLTable($id)} (_tenant, \"_updatedAt\");
251-
CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_tenant_id\" ON {$this->getSQLTable($id)} (_tenant, _id);
253+
CREATE UNIQUE INDEX \"{$uidIndex}\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai, \"_tenant\");
254+
CREATE INDEX \"{$createdIndex}\" ON {$this->getSQLTable($id)} (_tenant, \"_createdAt\");
255+
CREATE INDEX \"{$updatedIndex}\" ON {$this->getSQLTable($id)} (_tenant, \"_updatedAt\");
256+
CREATE INDEX \"{$tenantIdIndex}\" ON {$this->getSQLTable($id)} (_tenant, _id);
252257
";
253258
} else {
259+
$uidIndex = $this->getShortKey("{$namespace}_{$id}_uid");
260+
$createdIndex = $this->getShortKey("{$namespace}_{$id}_created");
261+
$updatedIndex = $this->getShortKey("{$namespace}_{$id}_updated");
254262
$collection .= "
255-
CREATE UNIQUE INDEX \"{$namespace}_{$id}_uid\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai);
256-
CREATE INDEX \"{$namespace}_{$id}_created\" ON {$this->getSQLTable($id)} (\"_createdAt\");
257-
CREATE INDEX \"{$namespace}_{$id}_updated\" ON {$this->getSQLTable($id)} (\"_updatedAt\");
263+
CREATE UNIQUE INDEX \"{$uidIndex}\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai);
264+
CREATE INDEX \"{$createdIndex}\" ON {$this->getSQLTable($id)} (\"_createdAt\");
265+
CREATE INDEX \"{$updatedIndex}\" ON {$this->getSQLTable($id)} (\"_updatedAt\");
258266
";
259267
}
260268

@@ -271,17 +279,21 @@ public function createCollection(string $name, array $attributes = [], array $in
271279
";
272280

273281
if ($this->sharedTables) {
282+
$uniquePermissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_ukey");
283+
$permissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_permission");
274284
$permissions .= "
275-
CREATE UNIQUE INDEX \"{$namespace}_{$this->tenant}_{$id}_ukey\"
285+
CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\"
276286
ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_document,_type,_permission);
277-
CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_permission\"
287+
CREATE INDEX \"{$permissionIndex}\"
278288
ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_permission,_type);
279289
";
280290
} else {
291+
$uniquePermissionIndex = $this->getShortKey("{$namespace}_{$id}_ukey");
292+
$permissionIndex = $this->getShortKey("{$namespace}_{$id}_permission");
281293
$permissions .= "
282-
CREATE UNIQUE INDEX \"{$namespace}_{$id}_ukey\"
294+
CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\"
283295
ON {$this->getSQLTable($id . '_perms')} USING btree (_document COLLATE utf8_ci_ai,_type,_permission);
284-
CREATE INDEX \"{$namespace}_{$id}_permission\"
296+
CREATE INDEX \"{$permissionIndex}\"
285297
ON {$this->getSQLTable($id . '_perms')} USING btree (_permission,_type);
286298
";
287299
}
@@ -893,15 +905,15 @@ public function createIndex(string $collection, string $id, string $type, array
893905
default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL . ', ' . Database::INDEX_OBJECT . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT),
894906
};
895907

896-
$key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\"";
908+
$keyName = $this->getShortKey("{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}");
897909
$attributes = \implode(', ', $attributes);
898910

899911
if ($this->sharedTables && \in_array($type, [Database::INDEX_KEY, Database::INDEX_UNIQUE])) {
900912
// Add tenant as first index column for best performance
901913
$attributes = "_tenant, {$attributes}";
902914
}
903915

904-
$sql = "CREATE {$sqlType} {$key} ON {$this->getSQLTable($collection)}";
916+
$sql = "CREATE {$sqlType} \"{$keyName}\" ON {$this->getSQLTable($collection)}";
905917

906918
// Add USING clause for special index types
907919
$sql .= match ($type) {
@@ -936,9 +948,9 @@ public function deleteIndex(string $collection, string $id): bool
936948
$id = $this->filter($id);
937949
$schemaName = $this->getDatabase();
938950

939-
$key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\"";
951+
$keyName = $this->getShortKey("{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}");
940952

941-
$sql = "DROP INDEX IF EXISTS \"{$schemaName}\".{$key}";
953+
$sql = "DROP INDEX IF EXISTS \"{$schemaName}\".\"{$keyName}\"";
942954
$sql = $this->trigger(Database::EVENT_INDEX_DELETE, $sql);
943955

944956
return $this->execute($this->getPDO()
@@ -961,10 +973,11 @@ public function renameIndex(string $collection, string $old, string $new): bool
961973
$namespace = $this->getNamespace();
962974
$old = $this->filter($old);
963975
$new = $this->filter($new);
964-
$oldIndexName = "{$this->tenant}_{$collection}_{$old}";
965-
$newIndexName = "{$namespace}_{$this->tenant}_{$collection}_{$new}";
976+
$schema = $this->getDatabase();
977+
$oldIndexName = $this->getShortKey("{$namespace}_{$this->tenant}_{$collection}_{$old}");
978+
$newIndexName = $this->getShortKey("{$namespace}_{$this->tenant}_{$collection}_{$new}");
966979

967-
$sql = "ALTER INDEX {$this->getSQLTable($oldIndexName)} RENAME TO \"{$newIndexName}\"";
980+
$sql = "ALTER INDEX \"{$schema}\".\"{$oldIndexName}\" RENAME TO \"{$newIndexName}\"";
968981
$sql = $this->trigger(Database::EVENT_INDEX_RENAME, $sql);
969982

970983
return $this->execute($this->getPDO()
@@ -2743,4 +2756,42 @@ public function getSupportNonUtfCharacters(): bool
27432756
{
27442757
return false;
27452758
}
2759+
2760+
/**
2761+
* Ensure index key length stays within PostgreSQL's 63 character limit.
2762+
*
2763+
* @param string $key
2764+
* @return string
2765+
*/
2766+
protected function getShortKey(string $key): string
2767+
{
2768+
if (\strlen($key) <= self::MAX_IDENTIFIER_NAME) {
2769+
return $key;
2770+
}
2771+
2772+
$suffix = '';
2773+
$separatorPosition = strrpos($key, '_');
2774+
if ($separatorPosition !== false) {
2775+
$suffix = substr($key, $separatorPosition + 1);
2776+
}
2777+
2778+
$hash = md5($key);
2779+
2780+
if ($suffix !== '') {
2781+
$hashedKey = "{$hash}_{$suffix}";
2782+
if (\strlen($hashedKey) <= self::MAX_IDENTIFIER_NAME) {
2783+
return $hashedKey;
2784+
}
2785+
}
2786+
2787+
return substr($hash, 0, self::MAX_IDENTIFIER_NAME);
2788+
}
2789+
2790+
protected function getSQLTable(string $name): string
2791+
{
2792+
$table = "{$this->getNamespace()}_{$this->filter($name)}";
2793+
$table = $this->getShortKey($table);
2794+
2795+
return "{$this->quote($this->getDatabase())}.{$this->quote($table)}";
2796+
}
27462797
}

src/Database/Adapter/SQL.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,6 +1797,9 @@ protected function getSQLOperator(string $method): string
17971797
case Query::TYPE_VECTOR_COSINE:
17981798
case Query::TYPE_VECTOR_EUCLIDEAN:
17991799
throw new DatabaseException('Vector queries are not supported by this database');
1800+
case Query::TYPE_EXISTS:
1801+
case Query::TYPE_NOT_EXISTS:
1802+
throw new DatabaseException('Exists queries are not supported by this database');
18001803
default:
18011804
throw new DatabaseException('Unknown method: ' . $method);
18021805
}

src/Database/Database.php

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3003,14 +3003,14 @@ public function checkAttribute(Document $collection, Document $attribute): bool
30033003
$this->adapter->getLimitForAttributes() > 0 &&
30043004
$this->adapter->getCountOfAttributes($collection) > $this->adapter->getLimitForAttributes()
30053005
) {
3006-
throw new LimitException('Column limit reached. Cannot create new attribute.');
3006+
throw new LimitException('Column limit reached. Cannot create new attribute. Current attribute count is ' . $this->adapter->getCountOfAttributes($collection) . ' but the maximum is ' . $this->adapter->getLimitForAttributes() . '. Remove some attributes to free up space.');
30073007
}
30083008

30093009
if (
30103010
$this->adapter->getDocumentSizeLimit() > 0 &&
30113011
$this->adapter->getAttributeWidth($collection) >= $this->adapter->getDocumentSizeLimit()
30123012
) {
3013-
throw new LimitException('Row width limit reached. Cannot create new attribute.');
3013+
throw new LimitException('Row width limit reached. Cannot create new attribute. Current row width is ' . $this->adapter->getAttributeWidth($collection) . ' bytes but the maximum is ' . $this->adapter->getDocumentSizeLimit() . ' bytes. Reduce the size of existing attributes or remove some attributes to free up space.');
30143014
}
30153015

30163016
return true;
@@ -5671,6 +5671,11 @@ public function updateDocument(string $collection, string $id, Document $documen
56715671
break;
56725672
}
56735673

5674+
if (Operator::isOperator($value)) {
5675+
$shouldUpdate = true;
5676+
break;
5677+
}
5678+
56745679
if (!\is_array($value) || !\array_is_list($value)) {
56755680
throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, ' . \gettype($value) . ' given.');
56765681
}
@@ -6079,6 +6084,24 @@ private function updateDocumentRelationships(Document $collection, Document $old
60796084
$twoWayKey = (string)$relationship['options']['twoWayKey'];
60806085
$side = (string)$relationship['options']['side'];
60816086

6087+
if (Operator::isOperator($value)) {
6088+
$operator = $value;
6089+
if ($operator->isArrayOperation()) {
6090+
$existingIds = [];
6091+
if (\is_array($oldValue)) {
6092+
$existingIds = \array_map(function ($item) {
6093+
if ($item instanceof Document) {
6094+
return $item->getId();
6095+
}
6096+
return $item;
6097+
}, $oldValue);
6098+
}
6099+
6100+
$value = $this->applyRelationshipOperator($operator, $existingIds);
6101+
$document->setAttribute($key, $value);
6102+
}
6103+
}
6104+
60826105
if ($oldValue == $value) {
60836106
if (
60846107
($relationType === Database::RELATION_ONE_TO_ONE
@@ -6438,6 +6461,63 @@ private function getJunctionCollection(Document $collection, Document $relatedCo
64386461
: '_' . $relatedCollection->getSequence() . '_' . $collection->getSequence();
64396462
}
64406463

6464+
/**
6465+
* Apply an operator to a relationship array of IDs
6466+
*
6467+
* @param Operator $operator
6468+
* @param array<string> $existingIds
6469+
* @return array<string|Document>
6470+
*/
6471+
private function applyRelationshipOperator(Operator $operator, array $existingIds): array
6472+
{
6473+
$method = $operator->getMethod();
6474+
$values = $operator->getValues();
6475+
6476+
// Extract IDs from operator values (could be strings or Documents)
6477+
$valueIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $values));
6478+
6479+
switch ($method) {
6480+
case Operator::TYPE_ARRAY_APPEND:
6481+
return \array_values(\array_merge($existingIds, $valueIds));
6482+
6483+
case Operator::TYPE_ARRAY_PREPEND:
6484+
return \array_values(\array_merge($valueIds, $existingIds));
6485+
6486+
case Operator::TYPE_ARRAY_INSERT:
6487+
$index = $values[0] ?? 0;
6488+
$item = $values[1] ?? null;
6489+
$itemId = $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null);
6490+
if ($itemId !== null) {
6491+
\array_splice($existingIds, $index, 0, [$itemId]);
6492+
}
6493+
return \array_values($existingIds);
6494+
6495+
case Operator::TYPE_ARRAY_REMOVE:
6496+
$toRemove = $values[0] ?? null;
6497+
if (\is_array($toRemove)) {
6498+
$toRemoveIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $toRemove));
6499+
return \array_values(\array_diff($existingIds, $toRemoveIds));
6500+
}
6501+
$toRemoveId = $toRemove instanceof Document ? $toRemove->getId() : (\is_string($toRemove) ? $toRemove : null);
6502+
if ($toRemoveId !== null) {
6503+
return \array_values(\array_diff($existingIds, [$toRemoveId]));
6504+
}
6505+
return $existingIds;
6506+
6507+
case Operator::TYPE_ARRAY_UNIQUE:
6508+
return \array_values(\array_unique($existingIds));
6509+
6510+
case Operator::TYPE_ARRAY_INTERSECT:
6511+
return \array_values(\array_intersect($existingIds, $valueIds));
6512+
6513+
case Operator::TYPE_ARRAY_DIFF:
6514+
return \array_values(\array_diff($existingIds, $valueIds));
6515+
6516+
default:
6517+
return $existingIds;
6518+
}
6519+
}
6520+
64416521
/**
64426522
* Create or update a document.
64436523
*

src/Database/Query.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class Query
2626
public const TYPE_NOT_STARTS_WITH = 'notStartsWith';
2727
public const TYPE_ENDS_WITH = 'endsWith';
2828
public const TYPE_NOT_ENDS_WITH = 'notEndsWith';
29+
public const TYPE_EXISTS = 'exists';
30+
public const TYPE_NOT_EXISTS = 'notExists';
2931

3032
// Spatial methods
3133
public const TYPE_CROSSES = 'crosses';
@@ -99,6 +101,8 @@ class Query
99101
self::TYPE_VECTOR_DOT,
100102
self::TYPE_VECTOR_COSINE,
101103
self::TYPE_VECTOR_EUCLIDEAN,
104+
self::TYPE_EXISTS,
105+
self::TYPE_NOT_EXISTS,
102106
self::TYPE_SELECT,
103107
self::TYPE_ORDER_DESC,
104108
self::TYPE_ORDER_ASC,
@@ -294,7 +298,9 @@ public static function isMethod(string $value): bool
294298
self::TYPE_SELECT,
295299
self::TYPE_VECTOR_DOT,
296300
self::TYPE_VECTOR_COSINE,
297-
self::TYPE_VECTOR_EUCLIDEAN => true,
301+
self::TYPE_VECTOR_EUCLIDEAN,
302+
self::TYPE_EXISTS,
303+
self::TYPE_NOT_EXISTS => true,
298304
default => false,
299305
};
300306
}
@@ -1178,4 +1184,26 @@ public static function vectorEuclidean(string $attribute, array $vector): self
11781184
{
11791185
return new self(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]);
11801186
}
1187+
1188+
/**
1189+
* Helper method to create Query with exists method
1190+
*
1191+
* @param array<string> $attributes
1192+
* @return Query
1193+
*/
1194+
public static function exists(array $attributes): self
1195+
{
1196+
return new self(self::TYPE_EXISTS, '', $attributes);
1197+
}
1198+
1199+
/**
1200+
* Helper method to create Query with notExists method
1201+
*
1202+
* @param string|int|float|bool|array<mixed,mixed> $attribute
1203+
* @return Query
1204+
*/
1205+
public static function notExists(string|int|float|bool|array $attribute): self
1206+
{
1207+
return new self(self::TYPE_NOT_EXISTS, '', is_array($attribute) ? $attribute : [$attribute]);
1208+
}
11811209
}

0 commit comments

Comments
 (0)