From 5a835043415e90842f22668dad8a935d0eb07cd4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 17 Nov 2025 18:37:11 +0530 Subject: [PATCH 01/10] added hashing for the key in postgres if more than 63 --- src/Database/Adapter/Postgres.php | 87 ++++++++++++++---- tests/e2e/Adapter/Scopes/CollectionTests.php | 97 ++++++++++++++++++++ 2 files changed, 166 insertions(+), 18 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 72e49cc07..aa1127f9b 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -30,6 +30,7 @@ */ class Postgres extends SQL { + const MAX_IDENTIFIER_NAME = 63; /** * @inheritDoc */ @@ -244,17 +245,24 @@ public function createCollection(string $name, array $attributes = [], array $in "; if ($this->sharedTables) { + $uidIndex = $this->getIndexKey("{$namespace}_{$this->tenant}_{$id}_uid"); + $createdIndex = $this->getIndexKey("{$namespace}_{$this->tenant}_{$id}_created"); + $updatedIndex = $this->getIndexKey("{$namespace}_{$this->tenant}_{$id}_updated"); + $tenantIdIndex = $this->getIndexKey("{$namespace}_{$this->tenant}_{$id}_tenant_id"); $collection .= " - CREATE UNIQUE INDEX \"{$namespace}_{$this->tenant}_{$id}_uid\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai, \"_tenant\"); - CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_created\" ON {$this->getSQLTable($id)} (_tenant, \"_createdAt\"); - CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_updated\" ON {$this->getSQLTable($id)} (_tenant, \"_updatedAt\"); - CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_tenant_id\" ON {$this->getSQLTable($id)} (_tenant, _id); + CREATE UNIQUE INDEX \"{$uidIndex}\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai, \"_tenant\"); + CREATE INDEX \"{$createdIndex}\" ON {$this->getSQLTable($id)} (_tenant, \"_createdAt\"); + CREATE INDEX \"{$updatedIndex}\" ON {$this->getSQLTable($id)} (_tenant, \"_updatedAt\"); + CREATE INDEX \"{$tenantIdIndex}\" ON {$this->getSQLTable($id)} (_tenant, _id); "; } else { + $uidIndex = $this->getIndexKey("{$namespace}_{$id}_uid"); + $createdIndex = $this->getIndexKey("{$namespace}_{$id}_created"); + $updatedIndex = $this->getIndexKey("{$namespace}_{$id}_updated"); $collection .= " - CREATE UNIQUE INDEX \"{$namespace}_{$id}_uid\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai); - CREATE INDEX \"{$namespace}_{$id}_created\" ON {$this->getSQLTable($id)} (\"_createdAt\"); - CREATE INDEX \"{$namespace}_{$id}_updated\" ON {$this->getSQLTable($id)} (\"_updatedAt\"); + CREATE UNIQUE INDEX \"{$uidIndex}\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai); + CREATE INDEX \"{$createdIndex}\" ON {$this->getSQLTable($id)} (\"_createdAt\"); + CREATE INDEX \"{$updatedIndex}\" ON {$this->getSQLTable($id)} (\"_updatedAt\"); "; } @@ -271,17 +279,21 @@ public function createCollection(string $name, array $attributes = [], array $in "; if ($this->sharedTables) { + $uniquePermissionIndex = $this->getIndexKey("{$namespace}_{$this->tenant}_{$id}_ukey"); + $permissionIndex = $this->getIndexKey("{$namespace}_{$this->tenant}_{$id}_permission"); $permissions .= " - CREATE UNIQUE INDEX \"{$namespace}_{$this->tenant}_{$id}_ukey\" + CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_document,_type,_permission); - CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_permission\" + CREATE INDEX \"{$permissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_permission,_type); "; } else { + $uniquePermissionIndex = $this->getIndexKey("{$namespace}_{$id}_ukey"); + $permissionIndex = $this->getIndexKey("{$namespace}_{$id}_permission"); $permissions .= " - CREATE UNIQUE INDEX \"{$namespace}_{$id}_ukey\" + CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_document COLLATE utf8_ci_ai,_type,_permission); - CREATE INDEX \"{$namespace}_{$id}_permission\" + CREATE INDEX \"{$permissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_permission,_type); "; } @@ -893,7 +905,7 @@ public function createIndex(string $collection, string $id, string $type, array 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), }; - $key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\""; + $keyName = $this->getIndexKey("{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}"); $attributes = \implode(', ', $attributes); if ($this->sharedTables && \in_array($type, [Database::INDEX_KEY, Database::INDEX_UNIQUE])) { @@ -901,7 +913,7 @@ public function createIndex(string $collection, string $id, string $type, array $attributes = "_tenant, {$attributes}"; } - $sql = "CREATE {$sqlType} {$key} ON {$this->getSQLTable($collection)}"; + $sql = "CREATE {$sqlType} \"{$keyName}\" ON {$this->getSQLTable($collection)}"; // Add USING clause for special index types $sql .= match ($type) { @@ -936,9 +948,9 @@ public function deleteIndex(string $collection, string $id): bool $id = $this->filter($id); $schemaName = $this->getDatabase(); - $key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\""; + $keyName = $this->getIndexKey("{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}"); - $sql = "DROP INDEX IF EXISTS \"{$schemaName}\".{$key}"; + $sql = "DROP INDEX IF EXISTS \"{$schemaName}\".\"{$keyName}\""; $sql = $this->trigger(Database::EVENT_INDEX_DELETE, $sql); return $this->execute($this->getPDO() @@ -961,10 +973,11 @@ public function renameIndex(string $collection, string $old, string $new): bool $namespace = $this->getNamespace(); $old = $this->filter($old); $new = $this->filter($new); - $oldIndexName = "{$this->tenant}_{$collection}_{$old}"; - $newIndexName = "{$namespace}_{$this->tenant}_{$collection}_{$new}"; + $schema = $this->getDatabase(); + $oldIndexName = $this->getIndexKey("{$namespace}_{$this->tenant}_{$collection}_{$old}"); + $newIndexName = $this->getIndexKey("{$namespace}_{$this->tenant}_{$collection}_{$new}"); - $sql = "ALTER INDEX {$this->getSQLTable($oldIndexName)} RENAME TO \"{$newIndexName}\""; + $sql = "ALTER INDEX \"{$schema}\".\"{$oldIndexName}\" RENAME TO \"{$newIndexName}\""; $sql = $this->trigger(Database::EVENT_INDEX_RENAME, $sql); return $this->execute($this->getPDO() @@ -2738,4 +2751,42 @@ protected function bindOperatorParams(\PDOStatement|PDOStatementProxy $stmt, Ope break; } } + + /** + * Ensure index key length stays within PostgreSQL's 63 character limit. + * + * @param string $key + * @return string + */ + protected function getIndexKey(string $key): string + { + if (\strlen($key) <= self::MAX_IDENTIFIER_NAME) { + return $key; + } + + $suffix = ''; + $separatorPosition = strrpos($key, '_'); + if ($separatorPosition !== false) { + $suffix = substr($key, $separatorPosition + 1); + } + + $hash = hash('crc32b', $key); + + if ($suffix !== '') { + $hashedKey = "{$hash}_{$suffix}"; + if (\strlen($hashedKey) <= self::MAX_IDENTIFIER_NAME) { + return $hashedKey; + } + } + + return substr($hash, 0, self::MAX_IDENTIFIER_NAME); + } + + protected function getSQLTable(string $name): string + { + $table = "{$this->getNamespace()}_{$this->filter($name)}"; + $table = $this->getIndexKey($table); + + return "{$this->quote($this->getDatabase())}.{$this->quote($table)}"; + } } diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 2f94ff09c..f6212fe44 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -1582,4 +1582,101 @@ public function testSetGlobalCollection(): void $this->assertEmpty($db->getGlobalCollections()); } + + public function testCreateCollectionWithLongId(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collection = '019a91aa-58cd-708d-a55c-5f7725ef937a'; + + $attributes = [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 256, + 'required' => true, + 'array' => false, + ]), + new Document([ + '$id' => 'age', + 'type' => Database::VAR_INTEGER, + 'size' => 0, + 'required' => false, + 'array' => false, + ]), + new Document([ + '$id' => 'isActive', + 'type' => Database::VAR_BOOLEAN, + 'size' => 0, + 'required' => false, + 'array' => false, + ]), + ]; + + $indexes = [ + new Document([ + '$id' => ID::custom('idx_name'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['name'], + 'lengths' => [128], + 'orders' => ['ASC'], + ]), + new Document([ + '$id' => ID::custom('idx_name_age'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['name', 'age'], + 'lengths' => [128, null], + 'orders' => ['ASC', 'DESC'], + ]), + ]; + + $collectionDocument = $database->createCollection( + $collection, + $attributes, + $indexes, + permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ); + + $this->assertEquals($collection, $collectionDocument->getId()); + $this->assertCount(3, $collectionDocument->getAttribute('attributes')); + $this->assertCount(2, $collectionDocument->getAttribute('indexes')); + + $document = $database->createDocument($collection, new Document([ + '$id' => 'longIdDoc', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'LongId Test', + 'age' => 42, + 'isActive' => true, + ])); + + $this->assertEquals('longIdDoc', $document->getId()); + $this->assertEquals('LongId Test', $document->getAttribute('name')); + $this->assertEquals(42, $document->getAttribute('age')); + $this->assertTrue($document->getAttribute('isActive')); + + $found = $database->find($collection, [ + Query::equal('name', ['LongId Test']), + ]); + + $this->assertCount(1, $found); + $this->assertEquals('longIdDoc', $found[0]->getId()); + + $fetched = $database->getDocument($collection, 'longIdDoc'); + $this->assertEquals('LongId Test', $fetched->getAttribute('name')); + + $this->assertTrue($database->deleteCollection($collection)); + } } From 1ee6436314222b9523362bbfbc26cbf7ccfe817c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 17 Nov 2025 18:41:50 +0530 Subject: [PATCH 02/10] linting --- src/Database/Adapter/Postgres.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index aa1127f9b..1b2689ad6 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -30,7 +30,7 @@ */ class Postgres extends SQL { - const MAX_IDENTIFIER_NAME = 63; + public const MAX_IDENTIFIER_NAME = 63; /** * @inheritDoc */ From 68d47bf64e8e0f98c4f9cfe37050f81a81130422 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 18 Nov 2025 10:12:01 +0530 Subject: [PATCH 03/10] changed hashing to use md5 instead of crc for collision issues --- src/Database/Adapter/Postgres.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 1b2689ad6..7497fe693 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2770,7 +2770,7 @@ protected function getIndexKey(string $key): string $suffix = substr($key, $separatorPosition + 1); } - $hash = hash('crc32b', $key); + $hash = md5($key); if ($suffix !== '') { $hashedKey = "{$hash}_{$suffix}"; From ef4429ec8027be61aa25b7d24b7c85e775a280d8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 18 Nov 2025 12:02:20 +0530 Subject: [PATCH 04/10] renamed getIndexKey to getShortKey --- src/Database/Adapter/Postgres.php | 34 +++++++++++++++---------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 7497fe693..86da09a58 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -245,10 +245,10 @@ public function createCollection(string $name, array $attributes = [], array $in "; if ($this->sharedTables) { - $uidIndex = $this->getIndexKey("{$namespace}_{$this->tenant}_{$id}_uid"); - $createdIndex = $this->getIndexKey("{$namespace}_{$this->tenant}_{$id}_created"); - $updatedIndex = $this->getIndexKey("{$namespace}_{$this->tenant}_{$id}_updated"); - $tenantIdIndex = $this->getIndexKey("{$namespace}_{$this->tenant}_{$id}_tenant_id"); + $uidIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_uid"); + $createdIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_created"); + $updatedIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_updated"); + $tenantIdIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_tenant_id"); $collection .= " CREATE UNIQUE INDEX \"{$uidIndex}\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai, \"_tenant\"); CREATE INDEX \"{$createdIndex}\" ON {$this->getSQLTable($id)} (_tenant, \"_createdAt\"); @@ -256,9 +256,9 @@ public function createCollection(string $name, array $attributes = [], array $in CREATE INDEX \"{$tenantIdIndex}\" ON {$this->getSQLTable($id)} (_tenant, _id); "; } else { - $uidIndex = $this->getIndexKey("{$namespace}_{$id}_uid"); - $createdIndex = $this->getIndexKey("{$namespace}_{$id}_created"); - $updatedIndex = $this->getIndexKey("{$namespace}_{$id}_updated"); + $uidIndex = $this->getShortKey("{$namespace}_{$id}_uid"); + $createdIndex = $this->getShortKey("{$namespace}_{$id}_created"); + $updatedIndex = $this->getShortKey("{$namespace}_{$id}_updated"); $collection .= " CREATE UNIQUE INDEX \"{$uidIndex}\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai); CREATE INDEX \"{$createdIndex}\" ON {$this->getSQLTable($id)} (\"_createdAt\"); @@ -279,8 +279,8 @@ public function createCollection(string $name, array $attributes = [], array $in "; if ($this->sharedTables) { - $uniquePermissionIndex = $this->getIndexKey("{$namespace}_{$this->tenant}_{$id}_ukey"); - $permissionIndex = $this->getIndexKey("{$namespace}_{$this->tenant}_{$id}_permission"); + $uniquePermissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_ukey"); + $permissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_permission"); $permissions .= " CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_document,_type,_permission); @@ -288,8 +288,8 @@ public function createCollection(string $name, array $attributes = [], array $in ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_permission,_type); "; } else { - $uniquePermissionIndex = $this->getIndexKey("{$namespace}_{$id}_ukey"); - $permissionIndex = $this->getIndexKey("{$namespace}_{$id}_permission"); + $uniquePermissionIndex = $this->getShortKey("{$namespace}_{$id}_ukey"); + $permissionIndex = $this->getShortKey("{$namespace}_{$id}_permission"); $permissions .= " CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_document COLLATE utf8_ci_ai,_type,_permission); @@ -905,7 +905,7 @@ public function createIndex(string $collection, string $id, string $type, array 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), }; - $keyName = $this->getIndexKey("{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}"); + $keyName = $this->getShortKey("{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}"); $attributes = \implode(', ', $attributes); if ($this->sharedTables && \in_array($type, [Database::INDEX_KEY, Database::INDEX_UNIQUE])) { @@ -948,7 +948,7 @@ public function deleteIndex(string $collection, string $id): bool $id = $this->filter($id); $schemaName = $this->getDatabase(); - $keyName = $this->getIndexKey("{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}"); + $keyName = $this->getShortKey("{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}"); $sql = "DROP INDEX IF EXISTS \"{$schemaName}\".\"{$keyName}\""; $sql = $this->trigger(Database::EVENT_INDEX_DELETE, $sql); @@ -974,8 +974,8 @@ public function renameIndex(string $collection, string $old, string $new): bool $old = $this->filter($old); $new = $this->filter($new); $schema = $this->getDatabase(); - $oldIndexName = $this->getIndexKey("{$namespace}_{$this->tenant}_{$collection}_{$old}"); - $newIndexName = $this->getIndexKey("{$namespace}_{$this->tenant}_{$collection}_{$new}"); + $oldIndexName = $this->getShortKey("{$namespace}_{$this->tenant}_{$collection}_{$old}"); + $newIndexName = $this->getShortKey("{$namespace}_{$this->tenant}_{$collection}_{$new}"); $sql = "ALTER INDEX \"{$schema}\".\"{$oldIndexName}\" RENAME TO \"{$newIndexName}\""; $sql = $this->trigger(Database::EVENT_INDEX_RENAME, $sql); @@ -2758,7 +2758,7 @@ protected function bindOperatorParams(\PDOStatement|PDOStatementProxy $stmt, Ope * @param string $key * @return string */ - protected function getIndexKey(string $key): string + protected function getShortKey(string $key): string { if (\strlen($key) <= self::MAX_IDENTIFIER_NAME) { return $key; @@ -2785,7 +2785,7 @@ protected function getIndexKey(string $key): string protected function getSQLTable(string $name): string { $table = "{$this->getNamespace()}_{$this->filter($name)}"; - $table = $this->getIndexKey($table); + $table = $this->getShortKey($table); return "{$this->quote($this->getDatabase())}.{$this->quote($table)}"; } From 737eee60e70d3d4c44442da0e1d8a945870ed3c9 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 5 Dec 2025 21:47:49 +1300 Subject: [PATCH 05/10] Add operator support for relationship arrays --- src/Database/Database.php | 80 ++++++++ src/Database/Validator/Operator.php | 130 +++++++++++- .../Scopes/Relationships/ManyToManyTests.php | 157 +++++++++++++++ .../Scopes/Relationships/OneToManyTests.php | 187 ++++++++++++++++++ .../Scopes/Relationships/OneToOneTests.php | 76 +++++++ 5 files changed, 623 insertions(+), 7 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index c4eda9e11..83b7b6b66 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5205,6 +5205,11 @@ public function updateDocument(string $collection, string $id, Document $documen break; } + if (Operator::isOperator($value)) { + $shouldUpdate = true; + break; + } + if (!\is_array($value) || !\array_is_list($value)) { throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, ' . \gettype($value) . ' given.'); } @@ -5610,6 +5615,24 @@ private function updateDocumentRelationships(Document $collection, Document $old $twoWayKey = (string)$relationship['options']['twoWayKey']; $side = (string)$relationship['options']['side']; + if (Operator::isOperator($value)) { + $operator = $value; + if ($operator->isArrayOperation()) { + $existingIds = []; + if (\is_array($oldValue)) { + $existingIds = \array_map(function ($item) { + if ($item instanceof Document) { + return $item->getId(); + } + return $item; + }, $oldValue); + } + + $value = $this->applyRelationshipOperator($operator, $existingIds); + $document->setAttribute($key, $value); + } + } + if ($oldValue == $value) { if ( ($relationType === Database::RELATION_ONE_TO_ONE @@ -5969,6 +5992,63 @@ private function getJunctionCollection(Document $collection, Document $relatedCo : '_' . $relatedCollection->getSequence() . '_' . $collection->getSequence(); } + /** + * Apply an operator to a relationship array of IDs + * + * @param Operator $operator + * @param array $existingIds + * @return array + */ + private function applyRelationshipOperator(Operator $operator, array $existingIds): array + { + $method = $operator->getMethod(); + $values = $operator->getValues(); + + // Extract IDs from operator values (could be strings or Documents) + $valueIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $values)); + + switch ($method) { + case Operator::TYPE_ARRAY_APPEND: + return \array_values(\array_merge($existingIds, $valueIds)); + + case Operator::TYPE_ARRAY_PREPEND: + return \array_values(\array_merge($valueIds, $existingIds)); + + case Operator::TYPE_ARRAY_INSERT: + $index = $values[0] ?? 0; + $item = $values[1] ?? null; + $itemId = $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null); + if ($itemId !== null) { + \array_splice($existingIds, $index, 0, [$itemId]); + } + return \array_values($existingIds); + + case Operator::TYPE_ARRAY_REMOVE: + $toRemove = $values[0] ?? null; + if (\is_array($toRemove)) { + $toRemoveIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $toRemove)); + return \array_values(\array_diff($existingIds, $toRemoveIds)); + } + $toRemoveId = $toRemove instanceof Document ? $toRemove->getId() : (\is_string($toRemove) ? $toRemove : null); + if ($toRemoveId !== null) { + return \array_values(\array_diff($existingIds, [$toRemoveId])); + } + return $existingIds; + + case Operator::TYPE_ARRAY_UNIQUE: + return \array_values(\array_unique($existingIds)); + + case Operator::TYPE_ARRAY_INTERSECT: + return \array_values(\array_intersect($existingIds, $valueIds)); + + case Operator::TYPE_ARRAY_DIFF: + return \array_values(\array_diff($existingIds, $valueIds)); + + default: + return $existingIds; + } + } + /** * Create or update a document. * diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index e43ebf26c..842a4861e 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -36,6 +36,50 @@ public function __construct(Document $collection, ?Document $currentDocument = n } } + /** + * Check if a value is a valid relationship reference (string ID or Document) + * + * @param mixed $item + * @return bool + */ + private function isValidRelationshipValue(mixed $item): bool + { + return \is_string($item) || $item instanceof Document; + } + + /** + * Check if a relationship attribute represents a "many" side (returns array of documents) + * + * @param Document|array $attribute + * @return bool + */ + private function isRelationshipArray(Document|array $attribute): bool + { + $options = $attribute instanceof Document + ? $attribute->getAttribute('options', []) + : ($attribute['options'] ?? []); + + $relationType = $options['relationType'] ?? ''; + $side = $options['side'] ?? ''; + + // Many-to-many is always an array on both sides + if ($relationType === Database::RELATION_MANY_TO_MANY) { + return true; + } + + // One-to-many: array on parent side, single on child side + if ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT) { + return true; + } + + // Many-to-one: array on child side, single on parent side + if ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_CHILD) { + return true; + } + + return false; + } + /** * Get Description * @@ -165,7 +209,19 @@ private function validateOperatorForAttribute( break; case DatabaseOperator::TYPE_ARRAY_APPEND: case DatabaseOperator::TYPE_ARRAY_PREPEND: - if (!$isArray) { + // For relationships, check if it's a "many" side + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + foreach ($values as $item) { + if (!$this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } @@ -182,14 +238,24 @@ private function validateOperatorForAttribute( break; case DatabaseOperator::TYPE_ARRAY_UNIQUE: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } break; case DatabaseOperator::TYPE_ARRAY_INSERT: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } @@ -206,6 +272,14 @@ private function validateOperatorForAttribute( } $insertValue = $values[1]; + + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isValidRelationshipValue($insertValue)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + if ($type === Database::VAR_INTEGER && \is_numeric($insertValue)) { if ($insertValue > Database::MAX_INT || $insertValue < Database::MIN_INT) { $this->message = "Cannot apply {$method} operator: array items must be between " . Database::MIN_INT . " and " . Database::MAX_INT; @@ -228,7 +302,19 @@ private function validateOperatorForAttribute( break; case DatabaseOperator::TYPE_ARRAY_REMOVE: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + $toValidate = \is_array($values[0]) ? $values[0] : $values; + foreach ($toValidate as $item) { + if (!$this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } @@ -240,7 +326,12 @@ private function validateOperatorForAttribute( break; case DatabaseOperator::TYPE_ARRAY_INTERSECT: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + } elseif (!$isArray) { $this->message = "Cannot use {$method} operator on non-array attribute '{$operator->getAttribute()}'"; return false; } @@ -250,16 +341,41 @@ private function validateOperatorForAttribute( return false; } + if ($type === Database::VAR_RELATIONSHIP) { + foreach ($values as $item) { + if (!$this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + } + break; case DatabaseOperator::TYPE_ARRAY_DIFF: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + foreach ($values as $item) { + if (!$this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + } elseif (!$isArray) { $this->message = "Cannot use {$method} operator on non-array attribute '{$operator->getAttribute()}'"; return false; } break; case DatabaseOperator::TYPE_ARRAY_FILTER: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php index f49b51de2..a3f4f5564 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php @@ -2093,4 +2093,161 @@ public function testPartialUpdateManyToManyWithStringIdsAndDocuments(): void $database->deleteCollection('tags'); $database->deleteCollection('articles'); } + + public function testManyToManyRelationshipWithArrayOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Cleanup any leftover collections from previous runs + try { + $database->deleteCollection('library'); + } catch (\Throwable $e) { + } + try { + $database->deleteCollection('book'); + } catch (\Throwable $e) { + } + + $database->createCollection('library'); + $database->createCollection('book'); + + $database->createAttribute('library', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('book', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'library', + relatedCollection: 'book', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'books', + twoWayKey: 'libraries' + ); + + // Create some books + $book1 = $database->createDocument('book', new Document([ + '$id' => 'book1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Book 1', + ])); + + $book2 = $database->createDocument('book', new Document([ + '$id' => 'book2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Book 2', + ])); + + $book3 = $database->createDocument('book', new Document([ + '$id' => 'book3', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Book 3', + ])); + + $book4 = $database->createDocument('book', new Document([ + '$id' => 'book4', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Book 4', + ])); + + // Create library with one book + $library = $database->createDocument('library', new Document([ + '$id' => 'library1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Library 1', + 'books' => ['book1'], + ])); + + $this->assertCount(1, $library->getAttribute('books')); + $this->assertEquals('book1', $library->getAttribute('books')[0]->getId()); + + // Test arrayAppend - add a single book + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayAppend(['book2']), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(2, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertContains('book2', $bookIds); + + // Test arrayAppend - add multiple books + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayAppend(['book3', 'book4']), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(4, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertContains('book2', $bookIds); + $this->assertContains('book3', $bookIds); + $this->assertContains('book4', $bookIds); + + // Test arrayRemove - remove a single book + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayRemove('book2'), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(3, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertNotContains('book2', $bookIds); + $this->assertContains('book3', $bookIds); + $this->assertContains('book4', $bookIds); + + // Test arrayRemove - remove multiple books at once + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayRemove(['book3', 'book4']), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(1, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertNotContains('book3', $bookIds); + $this->assertNotContains('book4', $bookIds); + + // Test arrayPrepend - add books + // Note: Order is not guaranteed for many-to-many relationships as they use junction tables + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayPrepend(['book2']), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(2, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertContains('book2', $bookIds); + + // Cleanup + $database->deleteCollection('library'); + $database->deleteCollection('book'); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php index 5b4df0d6d..bb4aa7650 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php @@ -2676,4 +2676,191 @@ public function testPartialUpdateWithStringIdsVsDocuments(): void $database->deleteCollection('libraries'); $database->deleteCollection('books_lib'); } + + public function testOneToManyRelationshipWithArrayOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Cleanup any leftover collections from previous runs + try { + $database->deleteCollection('author'); + } catch (\Throwable $e) { + } + try { + $database->deleteCollection('article'); + } catch (\Throwable $e) { + } + + $database->createCollection('author'); + $database->createCollection('article'); + + $database->createAttribute('author', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('article', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'author', + relatedCollection: 'article', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'articles', + twoWayKey: 'author' + ); + + // Create some articles + $article1 = $database->createDocument('article', new Document([ + '$id' => 'article1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Article 1', + ])); + + $article2 = $database->createDocument('article', new Document([ + '$id' => 'article2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Article 2', + ])); + + $article3 = $database->createDocument('article', new Document([ + '$id' => 'article3', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Article 3', + ])); + + // Create author with one article + $database->createDocument('author', new Document([ + '$id' => 'author1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Author 1', + 'articles' => ['article1'], + ])); + + // Fetch the document to get relationships (needed for Mirror which may not return relationships on create) + $author = $database->getDocument('author', 'author1'); + $this->assertCount(1, $author->getAttribute('articles')); + $this->assertEquals('article1', $author->getAttribute('articles')[0]->getId()); + + // Test arrayAppend - add articles + $author = $database->updateDocument('author', 'author1', new Document([ + 'articles' => \Utopia\Database\Operator::arrayAppend(['article2']), + ])); + + $author = $database->getDocument('author', 'author1'); + $this->assertCount(2, $author->getAttribute('articles')); + $articleIds = \array_map(fn ($article) => $article->getId(), $author->getAttribute('articles')); + $this->assertContains('article1', $articleIds); + $this->assertContains('article2', $articleIds); + + // Test arrayRemove - remove an article + $author = $database->updateDocument('author', 'author1', new Document([ + 'articles' => \Utopia\Database\Operator::arrayRemove('article1'), + ])); + + $author = $database->getDocument('author', 'author1'); + $this->assertCount(1, $author->getAttribute('articles')); + $articleIds = \array_map(fn ($article) => $article->getId(), $author->getAttribute('articles')); + $this->assertNotContains('article1', $articleIds); + $this->assertContains('article2', $articleIds); + + // Cleanup + $database->deleteCollection('author'); + $database->deleteCollection('article'); + } + + public function testOneToManyChildSideRejectsArrayOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Cleanup any leftover collections from previous runs + try { + $database->deleteCollection('parent_o2m'); + } catch (\Throwable $e) { + } + try { + $database->deleteCollection('child_o2m'); + } catch (\Throwable $e) { + } + + $database->createCollection('parent_o2m'); + $database->createCollection('child_o2m'); + + $database->createAttribute('parent_o2m', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('child_o2m', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'parent_o2m', + relatedCollection: 'child_o2m', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'children', + twoWayKey: 'parent' + ); + + // Create a parent + $database->createDocument('parent_o2m', new Document([ + '$id' => 'parent1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Parent 1', + ])); + + // Create child with parent + $database->createDocument('child_o2m', new Document([ + '$id' => 'child1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Child 1', + 'parent' => 'parent1', + ])); + + // Array operators should fail on child side (single-value "parent" relationship) + try { + $database->updateDocument('child_o2m', 'child1', new Document([ + 'parent' => \Utopia\Database\Operator::arrayAppend(['parent2']), + ])); + $this->fail('Expected exception for array operator on child side of one-to-many relationship'); + } catch (\Utopia\Database\Exception\Structure $e) { + $this->assertStringContainsString('single-value relationship', $e->getMessage()); + } + + // Cleanup + $database->deleteCollection('parent_o2m'); + $database->deleteCollection('child_o2m'); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php index 66da1c750..160fca576 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php @@ -2597,4 +2597,80 @@ public function testPartialUpdateOneToOneWithoutRelationshipField(): void $database->deleteCollection('cities_strict'); $database->deleteCollection('mayors_strict'); } + + public function testOneToOneRelationshipRejectsArrayOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Cleanup any leftover collections from previous runs + try { + $database->deleteCollection('user_o2o'); + } catch (\Throwable $e) { + } + try { + $database->deleteCollection('profile_o2o'); + } catch (\Throwable $e) { + } + + $database->createCollection('user_o2o'); + $database->createCollection('profile_o2o'); + + $database->createAttribute('user_o2o', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('profile_o2o', 'bio', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'user_o2o', + relatedCollection: 'profile_o2o', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'profile', + twoWayKey: 'user' + ); + + // Create a profile + $database->createDocument('profile_o2o', new Document([ + '$id' => 'profile1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'bio' => 'Test bio', + ])); + + // Create user with profile + $database->createDocument('user_o2o', new Document([ + '$id' => 'user1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'User 1', + 'profile' => 'profile1', + ])); + + // Array operators should fail on one-to-one relationships + try { + $database->updateDocument('user_o2o', 'user1', new Document([ + 'profile' => \Utopia\Database\Operator::arrayAppend(['profile2']), + ])); + $this->fail('Expected exception for array operator on one-to-one relationship'); + } catch (\Utopia\Database\Exception\Structure $e) { + $this->assertStringContainsString('single-value relationship', $e->getMessage()); + } + + // Cleanup + $database->deleteCollection('user_o2o'); + $database->deleteCollection('profile_o2o'); + } } From 9417bcbd5ac5a01f936bfdc65cccb3833a94dfa6 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 16 Dec 2025 21:46:34 +1300 Subject: [PATCH 06/10] Update limit message --- src/Database/Database.php | 6 +++--- tests/e2e/Adapter/Scopes/AttributeTests.php | 14 ++++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 83b7b6b66..d5595df38 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -2712,7 +2712,7 @@ public function updateAttribute(string $collection, string $id, ?string $type = $this->adapter->getDocumentSizeLimit() > 0 && $this->adapter->getAttributeWidth($collectionDoc) >= $this->adapter->getDocumentSizeLimit() ) { - throw new LimitException('Row width limit reached. Cannot update attribute.'); + throw new LimitException('Row width limit reached. Cannot update attribute. Current row width is ' . $this->adapter->getAttributeWidth($collectionDoc) . ' bytes but the maximum is ' . $this->adapter->getDocumentSizeLimit() . ' bytes. Reduce the size of existing attributes or remove some attributes to free up space.'); } if (in_array($type, self::SPATIAL_TYPES, true) && !$this->adapter->getSupportForSpatialIndexNull()) { @@ -2829,14 +2829,14 @@ public function checkAttribute(Document $collection, Document $attribute): bool $this->adapter->getLimitForAttributes() > 0 && $this->adapter->getCountOfAttributes($collection) > $this->adapter->getLimitForAttributes() ) { - throw new LimitException('Column limit reached. Cannot create new attribute.'); + 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.'); } if ( $this->adapter->getDocumentSizeLimit() > 0 && $this->adapter->getAttributeWidth($collection) >= $this->adapter->getDocumentSizeLimit() ) { - throw new LimitException('Row width limit reached. Cannot create new attribute.'); + 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.'); } return true; diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index 92ebdb9a9..c428f3f01 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -957,7 +957,8 @@ public function testExceptionAttributeLimit(): void $this->fail('Failed to throw exception'); } catch (\Throwable $e) { $this->assertInstanceOf(LimitException::class, $e); - $this->assertEquals('Column limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Column limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Remove some attributes to free up space.', $e->getMessage()); } try { @@ -965,7 +966,8 @@ public function testExceptionAttributeLimit(): void $this->fail('Failed to throw exception'); } catch (\Throwable $e) { $this->assertInstanceOf(LimitException::class, $e); - $this->assertEquals('Column limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Column limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Remove some attributes to free up space.', $e->getMessage()); } } @@ -1035,7 +1037,9 @@ public function testExceptionWidthLimit(): void $this->fail('Failed to throw exception'); } catch (\Exception $e) { $this->assertInstanceOf(LimitException::class, $e); - $this->assertEquals('Row width limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Row width limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('bytes but the maximum is 65535 bytes', $e->getMessage()); + $this->assertStringContainsString('Reduce the size of existing attributes or remove some attributes to free up space.', $e->getMessage()); } try { @@ -1043,7 +1047,9 @@ public function testExceptionWidthLimit(): void $this->fail('Failed to throw exception'); } catch (\Throwable $e) { $this->assertInstanceOf(LimitException::class, $e); - $this->assertEquals('Row width limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Row width limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('bytes but the maximum is 65535 bytes', $e->getMessage()); + $this->assertStringContainsString('Reduce the size of existing attributes or remove some attributes to free up space.', $e->getMessage()); } } From 5e0008eb19b75de893aa8553bc96496b5feaf6c7 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 17:42:55 +0530 Subject: [PATCH 07/10] Add support for exists and notExists query types in Mongo --- src/Database/Adapter/Mongo.php | 7 + src/Database/Adapter/SQL.php | 3 + src/Database/Query.php | 28 +++- src/Database/Validator/Queries.php | 4 +- src/Database/Validator/Query/Filter.php | 10 +- tests/e2e/Adapter/Scopes/SchemalessTests.php | 127 +++++++++++++++++++ 6 files changed, 176 insertions(+), 3 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 009ad1f7c..f8ff6edf0 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -42,6 +42,7 @@ class Mongo extends Adapter '$regex', '$not', '$nor', + '$exists', ]; protected Client $client; @@ -2373,6 +2374,8 @@ protected function buildFilter(Query $query): array $value = match ($query->getMethod()) { Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL => null, + Query::TYPE_EXISTS => true, + Query::TYPE_NOT_EXISTS => false, default => $this->getQueryValue( $query->getMethod(), count($query->getValues()) > 1 @@ -2434,6 +2437,8 @@ protected function buildFilter(Query $query): array $filter[$attribute] = ['$not' => $this->createSafeRegex($value, '^%s')]; } elseif ($operator === '$regex' && $query->getMethod() === Query::TYPE_NOT_ENDS_WITH) { $filter[$attribute] = ['$not' => $this->createSafeRegex($value, '%s$')]; + } elseif ($operator === '$exists') { + $filter[$attribute][$operator] = $value; } else { $filter[$attribute][$operator] = $value; } @@ -2472,6 +2477,8 @@ protected function getQueryOperator(string $operator): string Query::TYPE_NOT_ENDS_WITH => '$regex', Query::TYPE_OR => '$or', Query::TYPE_AND => '$and', + Query::TYPE_EXISTS, + Query::TYPE_NOT_EXISTS => '$exists', 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), }; } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 4bd0bb653..dfd1565ba 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1798,6 +1798,9 @@ protected function getSQLOperator(string $method): string case Query::TYPE_VECTOR_COSINE: case Query::TYPE_VECTOR_EUCLIDEAN: throw new DatabaseException('Vector queries are not supported by this database'); + case Query::TYPE_EXISTS: + case Query::TYPE_NOT_EXISTS: + throw new DatabaseException('Exists queries are not supported by this database'); default: throw new DatabaseException('Unknown method: ' . $method); } diff --git a/src/Database/Query.php b/src/Database/Query.php index 60ec1d712..d265d7bf3 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -26,6 +26,8 @@ class Query public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; public const TYPE_ENDS_WITH = 'endsWith'; public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; + public const TYPE_EXISTS = 'exists'; + public const TYPE_NOT_EXISTS = 'notExists'; // Spatial methods public const TYPE_CROSSES = 'crosses'; @@ -294,7 +296,9 @@ public static function isMethod(string $value): bool self::TYPE_SELECT, self::TYPE_VECTOR_DOT, self::TYPE_VECTOR_COSINE, - self::TYPE_VECTOR_EUCLIDEAN => true, + self::TYPE_VECTOR_EUCLIDEAN, + self::TYPE_EXISTS, + self::TYPE_NOT_EXISTS => true, default => false, }; } @@ -1178,4 +1182,26 @@ public static function vectorEuclidean(string $attribute, array $vector): self { return new self(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]); } + + /** + * Helper method to create Query with exists method + * + * @param string $attribute + * @return Query + */ + public static function exists(string $attribute): self + { + return new self(self::TYPE_EXISTS, $attribute); + } + + /** + * Helper method to create Query with notExists method + * + * @param string $attribute + * @return Query + */ + public static function notExists(string $attribute): self + { + return new self(self::TYPE_NOT_EXISTS, $attribute); + } } diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 8066228e3..22017692a 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -121,7 +121,9 @@ public function isValid($value): bool Query::TYPE_NOT_TOUCHES, Query::TYPE_VECTOR_DOT, Query::TYPE_VECTOR_COSINE, - Query::TYPE_VECTOR_EUCLIDEAN => Base::METHOD_TYPE_FILTER, + Query::TYPE_VECTOR_EUCLIDEAN, + Query::TYPE_EXISTS, + Query::TYPE_NOT_EXISTS => Base::METHOD_TYPE_FILTER, default => '', }; diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 11053f14c..7d6fcd4d0 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -91,6 +91,12 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s $attribute = \explode('.', $attribute)[0]; } + // exists and notExists queries don't require values, just attribute validation + if (in_array($method, [Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS])) { + // Validate attribute (handles encrypted attributes, schemaless mode, etc.) + return $this->isValidAttribute($attribute); + } + if (!$this->supportForAttributes && !isset($this->schema[$attribute])) { // First check maxValuesCount guard for any IN-style value arrays if (count($values) > $this->maxValuesCount) { @@ -250,7 +256,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s if ( $array && - !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL]) + !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL, Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS]) ) { $this->message = 'Cannot query '. $method .' on attribute "' . $attribute . '" because it is an array.'; return false; @@ -352,6 +358,8 @@ public function isValid($value): bool case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: + case Query::TYPE_EXISTS: + case Query::TYPE_NOT_EXISTS: return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); case Query::TYPE_VECTOR_DOT: diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index ee0985682..16bd434a6 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1155,4 +1155,131 @@ public function testSchemalessDates(): void $database->deleteCollection($col); } + + public function testSchemalessExists(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid('schemaless_exists'); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + // Create documents with and without the 'optionalField' attribute + $docs = [ + new Document(['$id' => 'doc1', '$permissions' => $permissions, 'optionalField' => 'value1', 'name' => 'doc1']), + new Document(['$id' => 'doc2', '$permissions' => $permissions, 'optionalField' => 'value2', 'name' => 'doc2']), + new Document(['$id' => 'doc3', '$permissions' => $permissions, 'name' => 'doc3']), // no optionalField + new Document(['$id' => 'doc4', '$permissions' => $permissions, 'optionalField' => null, 'name' => 'doc4']), // exists but null + new Document(['$id' => 'doc5', '$permissions' => $permissions, 'name' => 'doc5']), // no optionalField + ]; + $this->assertEquals(5, $database->createDocuments($colName, $docs)); + + // Test exists - should return documents where optionalField exists (even if null) + $documents = $database->find($colName, [ + Query::exists('optionalField'), + ]); + + $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 + $ids = array_map(fn ($doc) => $doc->getId(), $documents); + $this->assertContains('doc1', $ids); + $this->assertContains('doc2', $ids); + $this->assertContains('doc4', $ids); + + // Verify that doc4 is included even though optionalField is null + $doc4 = array_filter($documents, fn ($doc) => $doc->getId() === 'doc4'); + $this->assertCount(1, $doc4); + $doc4Array = array_values($doc4); + $this->assertTrue(array_key_exists('optionalField', $doc4Array[0]->getAttributes())); + + // Test exists with another attribute + $documents = $database->find($colName, [ + Query::exists('name'), + ]); + $this->assertEquals(5, count($documents)); // All documents have 'name' + + // Test exists with non-existent attribute + $documents = $database->find($colName, [ + Query::exists('nonExistentField'), + ]); + $this->assertEquals(0, count($documents)); + + $database->deleteCollection($colName); + } + + public function testSchemalessNotExists(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid('schemaless_not_exists'); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + // Create documents with and without the 'optionalField' attribute + $docs = [ + new Document(['$id' => 'doc1', '$permissions' => $permissions, 'optionalField' => 'value1', 'name' => 'doc1']), + new Document(['$id' => 'doc2', '$permissions' => $permissions, 'optionalField' => 'value2', 'name' => 'doc2']), + new Document(['$id' => 'doc3', '$permissions' => $permissions, 'name' => 'doc3']), // no optionalField + new Document(['$id' => 'doc4', '$permissions' => $permissions, 'optionalField' => null, 'name' => 'doc4']), // exists but null + new Document(['$id' => 'doc5', '$permissions' => $permissions, 'name' => 'doc5']), // no optionalField + ]; + $this->assertEquals(5, $database->createDocuments($colName, $docs)); + + // Test notExists - should return documents where optionalField does not exist + $documents = $database->find($colName, [ + Query::notExists('optionalField'), + ]); + + $this->assertEquals(2, count($documents)); // doc3, doc5 + $ids = array_map(fn ($doc) => $doc->getId(), $documents); + $this->assertContains('doc3', $ids); + $this->assertContains('doc5', $ids); + + // Verify that doc4 is NOT included (it exists even though null) + $this->assertNotContains('doc4', $ids); + + // Test notExists with another attribute + $documents = $database->find($colName, [ + Query::notExists('name'), + ]); + $this->assertEquals(0, count($documents)); // All documents have 'name' + + // Test notExists with non-existent attribute + $documents = $database->find($colName, [ + Query::notExists('nonExistentField'), + ]); + $this->assertEquals(5, count($documents)); // All documents don't have this field + + // Test combination of exists and notExists + $documents = $database->find($colName, [ + Query::exists('name'), + Query::notExists('optionalField'), + ]); + $this->assertEquals(2, count($documents)); // doc3, doc5 + + $database->deleteCollection($colName); + } } From 086052dfe12ca317e6f109258ee1b149bdc954eb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 18:29:55 +0530 Subject: [PATCH 08/10] added missing types to the query types --- src/Database/Query.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Database/Query.php b/src/Database/Query.php index d265d7bf3..e3b4d95d0 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -101,6 +101,8 @@ class Query self::TYPE_VECTOR_DOT, self::TYPE_VECTOR_COSINE, self::TYPE_VECTOR_EUCLIDEAN, + self::TYPE_EXISTS, + self::TYPE_NOT_EXISTS, self::TYPE_SELECT, self::TYPE_ORDER_DESC, self::TYPE_ORDER_ASC, From b73282899275bdc4c9d23fdb160507e7614a5b83 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 7 Jan 2026 14:12:56 +0530 Subject: [PATCH 09/10] updated exists and not exists method --- src/Database/Adapter/Mongo.php | 4 +- src/Database/Query.php | 12 +-- src/Database/Validator/Query/Filter.php | 4 +- tests/e2e/Adapter/Scopes/SchemalessTests.php | 104 ++++++++++++++++++- 4 files changed, 111 insertions(+), 13 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index f8ff6edf0..18554f87c 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2438,7 +2438,9 @@ protected function buildFilter(Query $query): array } elseif ($operator === '$regex' && $query->getMethod() === Query::TYPE_NOT_ENDS_WITH) { $filter[$attribute] = ['$not' => $this->createSafeRegex($value, '%s$')]; } elseif ($operator === '$exists') { - $filter[$attribute][$operator] = $value; + foreach ($query->getValues() as $attribute) { + $filter['$or'][] = [$attribute => [$operator => $value]]; + } } else { $filter[$attribute][$operator] = $value; } diff --git a/src/Database/Query.php b/src/Database/Query.php index e3b4d95d0..f5e8f6420 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -1188,22 +1188,22 @@ public static function vectorEuclidean(string $attribute, array $vector): self /** * Helper method to create Query with exists method * - * @param string $attribute + * @param array $attribute * @return Query */ - public static function exists(string $attribute): self + public static function exists(array $attributes): self { - return new self(self::TYPE_EXISTS, $attribute); + return new self(self::TYPE_EXISTS, '', $attributes); } /** * Helper method to create Query with notExists method * - * @param string $attribute + * @param string|int|float|bool|array $attribute * @return Query */ - public static function notExists(string $attribute): self + public static function notExists(string|int|float|bool|array $attribute): self { - return new self(self::TYPE_NOT_EXISTS, $attribute); + return new self(self::TYPE_NOT_EXISTS, '', is_array($attribute) ? $attribute : [$attribute]); } } diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 7d6fcd4d0..e62fc3913 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -312,6 +312,8 @@ public function isValid($value): bool case Query::TYPE_EQUAL: case Query::TYPE_CONTAINS: case Query::TYPE_NOT_CONTAINS: + case Query::TYPE_EXISTS: + case Query::TYPE_NOT_EXISTS: if ($this->isEmpty($value->getValues())) { $this->message = \ucfirst($method) . ' queries require at least one value.'; return false; @@ -358,8 +360,6 @@ public function isValid($value): bool case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: - case Query::TYPE_EXISTS: - case Query::TYPE_NOT_EXISTS: return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); case Query::TYPE_VECTOR_DOT: diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 16bd434a6..87c35af0e 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1188,7 +1188,7 @@ public function testSchemalessExists(): void // Test exists - should return documents where optionalField exists (even if null) $documents = $database->find($colName, [ - Query::exists('optionalField'), + Query::exists(['optionalField']), ]); $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 @@ -1205,13 +1205,68 @@ public function testSchemalessExists(): void // Test exists with another attribute $documents = $database->find($colName, [ - Query::exists('name'), + Query::exists(['name']), ]); $this->assertEquals(5, count($documents)); // All documents have 'name' // Test exists with non-existent attribute $documents = $database->find($colName, [ - Query::exists('nonExistentField'), + Query::exists(['nonExistentField']), + ]); + $this->assertEquals(0, count($documents)); + + // Multiple attributes in a single exists query (OR semantics) + $documents = $database->find($colName, [ + Query::exists(['optionalField', 'name']), + ]); + // All documents have "name", some also have "optionalField" + $this->assertEquals(5, count($documents)); + + // Multiple attributes where only one exists on some documents + $documents = $database->find($colName, [ + Query::exists(['optionalField', 'nonExistentField']), + ]); + // Only documents where optionalField exists should be returned + $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 + + // Multiple attributes where none exist should return empty + $documents = $database->find($colName, [ + Query::exists(['nonExistentField', 'alsoMissing']), + ]); + $this->assertEquals(0, count($documents)); + + // Multiple attributes including one present on all docs still returns all (OR) + $documents = $database->find($colName, [ + Query::exists(['name', 'nonExistentField', 'alsoMissing']), + ]); + $this->assertEquals(5, count($documents)); + + // Multiple exists queries (AND semantics) + $documents = $database->find($colName, [ + Query::exists(['optionalField']), + Query::exists(['name']), + ]); + // Documents must have both attributes + $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 + + // Nested OR with exists (optionalField OR nonExistentField) AND name + $documents = $database->find($colName, [ + Query::and([ + Query::or([ + Query::exists(['optionalField']), + Query::exists(['nonExistentField']), + ]), + Query::exists(['name']), + ]), + ]); + $this->assertEquals(3, count($documents)); // doc1, doc2, doc4 + + // Nested OR with only missing attributes should yield empty + $documents = $database->find($colName, [ + Query::or([ + Query::exists(['nonExistentField']), + Query::exists(['alsoMissing']), + ]), ]); $this->assertEquals(0, count($documents)); @@ -1273,13 +1328,54 @@ public function testSchemalessNotExists(): void ]); $this->assertEquals(5, count($documents)); // All documents don't have this field + // Multiple attributes in a single notExists query (OR semantics) - both missing + $documents = $database->find($colName, [ + Query::notExists(['nonExistentField', 'alsoMissing']), + ]); + $this->assertEquals(5, count($documents)); + + // Multiple attributes (OR) where only some documents miss one of them + $documents = $database->find($colName, [ + Query::notExists(['name', 'optionalField']), + ]); + $this->assertEquals(2, count($documents)); // doc3, doc5 + + // Multiple notExists queries (AND semantics) - must miss both + $documents = $database->find($colName, [ + Query::notExists(['optionalField']), + Query::notExists(['nonExistentField']), + ]); + $this->assertEquals(2, count($documents)); // doc3, doc5 + // Test combination of exists and notExists $documents = $database->find($colName, [ - Query::exists('name'), + Query::exists(['name']), Query::notExists('optionalField'), ]); $this->assertEquals(2, count($documents)); // doc3, doc5 + // Nested OR/AND with notExists: (notExists optionalField OR notExists nonExistent) AND name + $documents = $database->find($colName, [ + Query::and([ + Query::or([ + Query::notExists(['optionalField']), + Query::notExists(['nonExistentField']), + ]), + Query::exists(['name']), + ]), + ]); + // notExists(nonExistentField) matches all docs, so OR is always true; AND with name returns all + $this->assertEquals(5, count($documents)); // all docs match due to nonExistentField + + // Nested OR with notExists where all attributes exist => empty + $documents = $database->find($colName, [ + Query::or([ + Query::notExists(['name']), + Query::notExists(['optionalField']), + ]), + ]); + $this->assertEquals(2, count($documents)); // only ones missing optionalField (doc3, doc5) + $database->deleteCollection($colName); } } From aa280279f5dd734ea3185cdcaaffec654391f5b8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 7 Jan 2026 14:18:44 +0530 Subject: [PATCH 10/10] linting --- src/Database/Query.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index f5e8f6420..e8ccdcaa3 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -1188,7 +1188,7 @@ public static function vectorEuclidean(string $attribute, array $vector): self /** * Helper method to create Query with exists method * - * @param array $attribute + * @param array $attributes * @return Query */ public static function exists(array $attributes): self