From 7e3656bef17b61aef6a5948b9e5a64c9422c8b80 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Oct 2025 16:18:19 +0530 Subject: [PATCH 01/20] added support for object type attribute --- src/Database/Adapter.php | 7 +++ src/Database/Adapter/MariaDB.php | 5 ++ src/Database/Adapter/Postgres.php | 77 ++++++++++++++++++++++++++++++- src/Database/Adapter/SQL.php | 9 ++++ src/Database/Adapter/SQLite.php | 5 ++ src/Database/Database.php | 44 ++++++++++++++++-- src/Database/Query.php | 8 ++++ 7 files changed, 150 insertions(+), 5 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 2332d9745..ff9a9360a 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1056,6 +1056,13 @@ abstract public function getSupportForBatchCreateAttributes(): bool; */ abstract public function getSupportForSpatialAttributes(): bool; + /** + * Are object (JSON) attributes supported? + * + * @return bool + */ + abstract public function getSupportForObject(): bool; + /** * Does the adapter support null values in spatial indexes? * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index e5dd89c5b..9ce2ab86e 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1849,6 +1849,11 @@ public function getSupportForSpatialAttributes(): bool return true; } + public function getSupportForObject(): bool + { + return false; + } + /** * Get Support for Null Values in Spatial Indexes * diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index d8cd83f4e..b24b97e58 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1562,6 +1562,64 @@ protected function handleSpatialQueries(Query $query, array &$binds, string $att } } + /** + * Handle JSONB queries + * + * @param Query $query + * @param array $binds + * @param string $attribute + * @param string $alias + * @param string $placeholder + * @return string + */ + protected function handleObjectQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string + { + switch ($query->getMethod()) { + case Query::TYPE_EQUAL: + $conditions = []; + foreach ($query->getValues() as $key => $value) { + if (is_array($value)) { + // JSONB containment operator @> + $binds[":{$placeholder}_{$key}"] = json_encode($value); + $conditions[] = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb"; + } else { + // Direct equality + $binds[":{$placeholder}_{$key}"] = json_encode($value); + $conditions[] = "{$alias}.{$attribute} = :{$placeholder}_{$key}::jsonb"; + } + } + return empty($conditions) ? '' : '(' . implode(' OR ', $conditions) . ')'; + + case Query::TYPE_CONTAINS: + $conditions = []; + foreach ($query->getValues() as $key => $value) { + if (is_array($value)) { + // For JSONB contains, we need to check if an array contains a specific element + // The JSONB containment operator @> checks if left contains right + // For array element containment: {"array": ["element"]} means array contains "element" + // For nested array containment: {"matrix": [[4,5,6]]} means matrix contains [4,5,6] + + if (count($value) === 1) { + $jsonKey = array_key_first($value); + $jsonValue = $value[$jsonKey]; + + // Always wrap the value in an array to represent "array contains this element" + // - For scalar: 'react' becomes ["react"] + // - For array: [4,5,6] becomes [[4,5,6]] + $value[$jsonKey] = [$jsonValue]; + } + + $binds[":{$placeholder}_{$key}"] = json_encode($value); + $conditions[] = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb"; + } + } + return empty($conditions) ? '' : '(' . implode(' OR ', $conditions) . ')'; + + default: + throw new DatabaseException('Query method ' . $query->getMethod() . ' not supported for object attributes'); + } + } + /** * Get SQL Condition * @@ -1585,6 +1643,10 @@ protected function getSQLCondition(Query $query, array &$binds): string return $this->handleSpatialQueries($query, $binds, $attribute, $alias, $placeholder); } + if ($query->isObjectAttribute()) { + return $this->handleObjectQueries($query, $binds, $attribute, $alias, $placeholder); + } + switch ($query->getMethod()) { case Query::TYPE_OR: case Query::TYPE_AND: @@ -1732,6 +1794,9 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool case Database::VAR_DATETIME: return 'TIMESTAMP(3)'; + case Database::TYPE_OBJECT: + return 'JSONB'; + // in all other DB engines, 4326 is the default SRID case Database::VAR_POINT: return 'GEOMETRY(POINT,' . Database::SRID . ')'; @@ -1743,7 +1808,7 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool return 'GEOMETRY(POLYGON,' . Database::SRID . ')'; default: - throw new DatabaseException('Unknown Type: ' . $type . '. Must be one of ' . Database::VAR_STRING . ', ' . Database::VAR_INTEGER . ', ' . Database::VAR_FLOAT . ', ' . Database::VAR_BOOLEAN . ', ' . Database::VAR_DATETIME . ', ' . Database::VAR_RELATIONSHIP . ', ' . Database::VAR_POINT . ', ' . Database::VAR_LINESTRING . ', ' . Database::VAR_POLYGON); + throw new DatabaseException('Unknown Type: ' . $type . '. Must be one of ' . Database::VAR_STRING . ', ' . Database::VAR_INTEGER . ', ' . Database::VAR_FLOAT . ', ' . Database::VAR_BOOLEAN . ', ' . Database::VAR_DATETIME . ', ' . Database::VAR_RELATIONSHIP . ', ' . Database::TYPE_OBJECT . ', ' . Database::VAR_POINT . ', ' . Database::VAR_LINESTRING . ', ' . Database::VAR_POLYGON); } } @@ -1951,6 +2016,16 @@ public function getSupportForSpatialAttributes(): bool return true; } + /** + * Are object (JSONB) attributes supported? + * + * @return bool + */ + public function getSupportForObject(): bool + { + return true; + } + /** * Does the adapter support null values in spatial indexes? * diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 84fae6ce7..337555c10 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1122,6 +1122,15 @@ public function getAttributeWidth(Document $collection): int $total += 7; break; + case Database::TYPE_OBJECT: + /** + * JSONB/JSON type + * Only the pointer contributes 20 bytes to the row size + * Data is stored externally + */ + $total += 20; + break; + case Database::VAR_POINT: $total += $this->getMaxPointSize(); break; diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index a892b6626..255655b1f 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -973,6 +973,11 @@ public function getSupportForSpatialAttributes(): bool return false; // SQLite doesn't have native spatial support } + public function getSupportForObject(): bool + { + return false; + } + public function getSupportForSpatialIndexNull(): bool { return false; // SQLite doesn't have native spatial support diff --git a/src/Database/Database.php b/src/Database/Database.php index 56a65e724..d50f0655c 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -44,6 +44,7 @@ class Database public const VAR_DATETIME = 'datetime'; public const VAR_ID = 'id'; public const VAR_OBJECT_ID = 'objectId'; + public const TYPE_OBJECT = 'object'; public const INT_MAX = 2147483647; public const BIG_INT_MAX = PHP_INT_MAX; @@ -1950,6 +1951,17 @@ private function validateAttribute( case self::VAR_DATETIME: case self::VAR_RELATIONSHIP: break; + case self::TYPE_OBJECT: + if (!$this->adapter->getSupportForObject()) { + throw new DatabaseException('Object attributes are not supported'); + } + if (!empty($size)) { + throw new DatabaseException('Size must be empty for object attributes'); + } + if (!empty($array)) { + throw new DatabaseException('Object attributes cannot be arrays'); + } + break; case self::VAR_POINT: case self::VAR_LINESTRING: case self::VAR_POLYGON: @@ -1965,7 +1977,7 @@ private function validateAttribute( } break; default: - throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP . ', ' . self::VAR_POINT . ', ' . self::VAR_LINESTRING . ', ' . self::VAR_POLYGON); + throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP . ', ' . self::TYPE_OBJECT . ', ' . self::VAR_POINT . ', ' . self::VAR_LINESTRING . ', ' . self::VAR_POLYGON); } // Only execute when $default is given @@ -2037,6 +2049,12 @@ protected function validateDefaultTypes(string $type, mixed $default): void throw new DatabaseException('Default value ' . $default . ' does not match given type ' . $type); } break; + case self::TYPE_OBJECT: + // Object types expect arrays as default values + if ($defaultType !== 'array') { + throw new DatabaseException('Default value for object type must be an array'); + } + break; case self::VAR_POINT: case self::VAR_LINESTRING: case self::VAR_POLYGON: @@ -2044,9 +2062,9 @@ protected function validateDefaultTypes(string $type, mixed $default): void if ($defaultType !== 'array') { throw new DatabaseException('Default value for spatial type ' . $type . ' must be an array'); } - // no break + break; default: - throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP . ', ' . self::VAR_POINT . ', ' . self::VAR_LINESTRING . ', ' . self::VAR_POLYGON); + throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP . ', ' . self::TYPE_OBJECT . ', ' . self::VAR_POINT . ', ' . self::VAR_LINESTRING . ', ' . self::VAR_POLYGON); } } @@ -2293,6 +2311,18 @@ public function updateAttribute(string $collection, string $id, ?string $type = } break; + case self::TYPE_OBJECT: + if (!$this->adapter->getSupportForObject()) { + throw new DatabaseException('Object attributes are not supported'); + } + if (!empty($size)) { + throw new DatabaseException('Size must be empty for object attributes'); + } + if (!empty($array)) { + throw new DatabaseException('Object attributes cannot be arrays'); + } + break; + case self::VAR_POINT: case self::VAR_LINESTRING: case self::VAR_POLYGON: @@ -2307,7 +2337,7 @@ public function updateAttribute(string $collection, string $id, ?string $type = } break; default: - throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP); + throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP . ', ' . self::TYPE_OBJECT); } /** Ensure required filters for the attribute are passed */ @@ -7287,6 +7317,12 @@ public function casting(Document $collection, Document $document): Document case self::VAR_FLOAT: $node = (float)$node; break; + case self::TYPE_OBJECT: + // Decode JSONB string to array + if (is_string($node)) { + $node = json_decode($node, true); + } + break; default: break; } diff --git a/src/Database/Query.php b/src/Database/Query.php index 47be58c12..87c76a010 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -960,6 +960,14 @@ public function isSpatialAttribute(): bool return in_array($this->attributeType, Database::SPATIAL_TYPES); } + /** + * @return bool + */ + public function isObjectAttribute(): bool + { + return $this->attributeType === Database::TYPE_OBJECT; + } + // Spatial query methods /** From 6edfe1af038394599e7ac6ffb4d19d27192d7bd1 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Oct 2025 16:18:34 +0530 Subject: [PATCH 02/20] updated validators --- src/Database/Validator/Query/Filter.php | 12 +++++++++++- src/Database/Validator/Structure.php | 9 +++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 5bc973f22..7f798ef84 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -147,6 +147,15 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s $validator = new Text(255, 0); // The query is always on uid break; + case Database::TYPE_OBJECT: + // For JSONB/object queries, value must be an array + if (!is_array($value)) { + $this->message = 'Query value for object type must be an array'; + return false; + } + // No further validation needed - JSONB accepts any valid array structure + continue 2; + case Database::VAR_POINT: case Database::VAR_LINESTRING: case Database::VAR_POLYGON: @@ -201,10 +210,11 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s !$array && in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS]) && $attributeSchema['type'] !== Database::VAR_STRING && + $attributeSchema['type'] !== Database::TYPE_OBJECT && !in_array($attributeSchema['type'], Database::SPATIAL_TYPES) ) { $queryType = $method === Query::TYPE_NOT_CONTAINS ? 'notContains' : 'contains'; - $this->message = 'Cannot query ' . $queryType . ' on attribute "' . $attribute . '" because it is not an array or string.'; + $this->message = 'Cannot query ' . $queryType . ' on attribute "' . $attribute . '" because it is not an array, string, or object.'; return false; } diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index cfb12fa3a..b98a07971 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -350,6 +350,15 @@ protected function checkForInvalidAttributeValues(array $structure, array $keys) ); break; + case Database::TYPE_OBJECT: + // For JSONB/object types, just validate it's an array (associative or list) + if (!is_array($value)) { + $this->message = 'Attribute "'.$key.'" has invalid type. Value must be an array for object type'; + return false; + } + // No additional validators needed - JSONB accepts any valid array structure + continue 2; // Skip to next attribute + case Database::VAR_POINT: case Database::VAR_LINESTRING: case Database::VAR_POLYGON: From 4483496e317aa81c7d3445e49427491d114e4cf4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Oct 2025 16:18:43 +0530 Subject: [PATCH 03/20] updated tests --- tests/e2e/Adapter/Scopes/AttributeTests.php | 2 +- tests/e2e/Adapter/Scopes/DocumentTests.php | 681 +++++++++++++++++++- 2 files changed, 680 insertions(+), 3 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index 89ab81a50..8cb22a42e 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -1523,7 +1523,7 @@ public function testArrayAttribute(): void ]); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - $this->assertEquals('Invalid query: Cannot query contains on attribute "age" because it is not an array or string.', $e->getMessage()); + $this->assertEquals('Invalid query: Cannot query contains on attribute "age" because it is not an array, string, or object.', $e->getMessage()); } $documents = $database->find($collection, [ diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 76c9231fc..484540955 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -1853,7 +1853,7 @@ public function testFindContains(): void ]); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - $this->assertEquals('Invalid query: Cannot query contains on attribute "price" because it is not an array or string.', $e->getMessage()); + $this->assertEquals('Invalid query: Cannot query contains on attribute "price" because it is not an array, string, or object.', $e->getMessage()); $this->assertTrue($e instanceof DatabaseException); } } @@ -3334,7 +3334,7 @@ public function testFindNotContains(): void ]); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - $this->assertEquals('Invalid query: Cannot query notContains on attribute "price" because it is not an array or string.', $e->getMessage()); + $this->assertEquals('Invalid query: Cannot query notContains on attribute "price" because it is not an array, string, or object.', $e->getMessage()); $this->assertTrue($e instanceof DatabaseException); } } @@ -6559,4 +6559,681 @@ public function testUpdateDocumentSuccessiveCallsDoNotResetDefaults(): void $database->deleteCollection($collectionId); } + + public function testObjectAttribute(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Skip test if adapter doesn't support JSONB + if (!$database->getAdapter()->getSupportForObject()) { + $this->markTestSkipped('Adapter does not support object attributes'); + return; + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Create object attribute + $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::TYPE_OBJECT, 0, false)); + + // Test 1: Create and read document with object attribute + $doc1 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc1', + '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], + 'meta' => [ + 'age' => 25, + 'skills' => ['react', 'node'], + 'user' => [ + 'info' => [ + 'country' => 'IN' + ] + ] + ] + ])); + + $this->assertIsArray($doc1->getAttribute('meta')); + $this->assertEquals(25, $doc1->getAttribute('meta')['age']); + $this->assertEquals(['react', 'node'], $doc1->getAttribute('meta')['skills']); + $this->assertEquals('IN', $doc1->getAttribute('meta')['user']['info']['country']); + + // Test 2: Query::equal with simple key-value pair + $results = $database->find($collectionId, [ + Query::equal('meta', [['age' => 25]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + + // Test 3: Query::equal with nested JSON + $results = $database->find($collectionId, [ + Query::equal('meta', [[ + 'user' => [ + 'info' => [ + 'country' => 'IN' + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + + // Test 4: Query::contains for array element + $results = $database->find($collectionId, [ + Query::contains('meta', [['skills' => 'react']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + + // Test 5: Create another document with different values + $doc2 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc2', + '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], + 'meta' => [ + 'age' => 30, + 'skills' => ['python', 'java'], + 'user' => [ + 'info' => [ + 'country' => 'US' + ] + ] + ] + ])); + + // Test 6: Query should return only doc1 + $results = $database->find($collectionId, [ + Query::equal('meta', [['age' => 25]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + + // Test 7: Query for doc2 + $results = $database->find($collectionId, [ + Query::equal('meta', [[ + 'user' => [ + 'info' => [ + 'country' => 'US' + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc2', $results[0]->getId()); + + // Test 8: Update document + $updatedDoc = $database->updateDocument($collectionId, 'doc1', new Document([ + '$id' => 'doc1', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'age' => 26, + 'skills' => ['react', 'node', 'typescript'], + 'user' => [ + 'info' => [ + 'country' => 'CA' + ] + ] + ] + ])); + + $this->assertEquals(26, $updatedDoc->getAttribute('meta')['age']); + $this->assertEquals(['react', 'node', 'typescript'], $updatedDoc->getAttribute('meta')['skills']); + $this->assertEquals('CA', $updatedDoc->getAttribute('meta')['user']['info']['country']); + + // Test 9: Query updated document + $results = $database->find($collectionId, [ + Query::equal('meta', [['age' => 26]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + + // Test 10: Query with multiple conditions using contains + $results = $database->find($collectionId, [ + Query::contains('meta', [['skills' => 'typescript']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + + // Test 11: Negative test - query that shouldn't match + $results = $database->find($collectionId, [ + Query::equal('meta', [['age' => 99]]) + ]); + $this->assertCount(0, $results); + + // Test 11a: Test getDocument by ID + $fetchedDoc = $database->getDocument($collectionId, 'doc1'); + $this->assertEquals('doc1', $fetchedDoc->getId()); + $this->assertIsArray($fetchedDoc->getAttribute('meta')); + $this->assertEquals(26, $fetchedDoc->getAttribute('meta')['age']); + $this->assertEquals(['react', 'node', 'typescript'], $fetchedDoc->getAttribute('meta')['skills']); + $this->assertEquals('CA', $fetchedDoc->getAttribute('meta')['user']['info']['country']); + + // Test 11b: Test Query::select to limit returned attributes + $results = $database->find($collectionId, [ + Query::select(['$id', 'meta']), + Query::equal('meta', [['age' => 26]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + $this->assertIsArray($results[0]->getAttribute('meta')); + $this->assertEquals(26, $results[0]->getAttribute('meta')['age']); + + // Test 11c: Test Query::select with only $id (exclude meta) + $results = $database->find($collectionId, [ + Query::select(['$id']), + Query::equal('meta', [['age' => 30]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc2', $results[0]->getId()); + // Meta should not be present when not selected + $this->assertEmpty($results[0]->getAttribute('meta')); + + // Test 12: Test with null value + $doc3 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc3', + '$permissions' => [Permission::read(Role::any())], + 'meta' => null + ])); + $this->assertNull($doc3->getAttribute('meta')); + + // Test 13: Test with empty object + $doc4 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc4', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [] + ])); + $this->assertIsArray($doc4->getAttribute('meta')); + $this->assertEmpty($doc4->getAttribute('meta')); + + // Test 14: Test deeply nested structure (5 levels) + $doc5 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc5', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => [ + 'level5' => 'deep_value' + ] + ] + ] + ] + ] + ])); + $this->assertEquals('deep_value', $doc5->getAttribute('meta')['level1']['level2']['level3']['level4']['level5']); + + // Test 15: Query deeply nested structure + $results = $database->find($collectionId, [ + Query::equal('meta', [[ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => [ + 'level5' => 'deep_value' + ] + ] + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc5', $results[0]->getId()); + + // Test 16: Query partial nested path + $results = $database->find($collectionId, [ + Query::equal('meta', [[ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => [ + 'level5' => 'deep_value' + ] + ] + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + + // Test 17: Test with mixed data types + $doc6 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc6', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'string' => 'text', + 'number' => 42, + 'float' => 3.14, + 'boolean' => true, + 'null_value' => null, + 'array' => [1, 2, 3], + 'object' => ['key' => 'value'] + ] + ])); + $this->assertEquals('text', $doc6->getAttribute('meta')['string']); + $this->assertEquals(42, $doc6->getAttribute('meta')['number']); + $this->assertEquals(3.14, $doc6->getAttribute('meta')['float']); + $this->assertTrue($doc6->getAttribute('meta')['boolean']); + $this->assertNull($doc6->getAttribute('meta')['null_value']); + + // Test 18: Query with boolean value + $results = $database->find($collectionId, [ + Query::equal('meta', [['boolean' => true]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc6', $results[0]->getId()); + + // Test 19: Query with numeric value + $results = $database->find($collectionId, [ + Query::equal('meta', [['number' => 42]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc6', $results[0]->getId()); + + // Test 20: Query with float value + $results = $database->find($collectionId, [ + Query::equal('meta', [['float' => 3.14]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc6', $results[0]->getId()); + + // Test 21: Test contains with multiple array elements + $doc7 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc7', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'tags' => ['php', 'javascript', 'python', 'go', 'rust'] + ] + ])); + $results = $database->find($collectionId, [ + Query::contains('meta', [['tags' => 'rust']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc7', $results[0]->getId()); + + // Test 22: Test contains with numeric array element + $doc8 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc8', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'scores' => [85, 90, 95, 100] + ] + ])); + $results = $database->find($collectionId, [ + Query::contains('meta', [['scores' => 95]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc8', $results[0]->getId()); + + // Test 23: Negative test - contains query that shouldn't match + $results = $database->find($collectionId, [ + Query::contains('meta', [['tags' => 'kotlin']]) + ]); + $this->assertCount(0, $results); + + // Test 24: Test complex nested array within object + $doc9 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc9', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'projects' => [ + [ + 'name' => 'Project A', + 'technologies' => ['react', 'node'], + 'active' => true + ], + [ + 'name' => 'Project B', + 'technologies' => ['vue', 'python'], + 'active' => false + ] + ], + 'company' => 'TechCorp' + ] + ])); + $this->assertIsArray($doc9->getAttribute('meta')['projects']); + $this->assertCount(2, $doc9->getAttribute('meta')['projects']); + $this->assertEquals('Project A', $doc9->getAttribute('meta')['projects'][0]['name']); + + // Test 25: Query using equal with nested key + $results = $database->find($collectionId, [ + Query::equal('meta', [['company' => 'TechCorp']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc9', $results[0]->getId()); + + // Test 25b: Query the entire array structure using equal + $results = $database->find($collectionId, [ + Query::equal('meta', [[ + 'projects' => [ + [ + 'name' => 'Project A', + 'technologies' => ['react', 'node'], + 'active' => true + ], + [ + 'name' => 'Project B', + 'technologies' => ['vue', 'python'], + 'active' => false + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc9', $results[0]->getId()); + + // Test 26: Test with special characters in values + $doc10 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc10', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'description' => 'Test with "quotes" and \'apostrophes\'', + 'emoji' => '🚀🎉', + 'symbols' => '@#$%^&*()' + ] + ])); + $this->assertEquals('Test with "quotes" and \'apostrophes\'', $doc10->getAttribute('meta')['description']); + $this->assertEquals('🚀🎉', $doc10->getAttribute('meta')['emoji']); + + // Test 27: Query with special characters + $results = $database->find($collectionId, [ + Query::equal('meta', [['emoji' => '🚀🎉']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc10', $results[0]->getId()); + + // Test 28: Test equal query with complete object match + $doc11 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc11', + '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], + 'meta' => [ + 'config' => [ + 'theme' => 'dark', + 'language' => 'en' + ] + ] + ])); + $results = $database->find($collectionId, [ + Query::equal('meta', [['config' => ['theme' => 'dark', 'language' => 'en']]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc11', $results[0]->getId()); + + // Test 29: Negative test - partial object match should still work (containment) + $results = $database->find($collectionId, [ + Query::equal('meta', [['config' => ['theme' => 'dark']]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc11', $results[0]->getId()); + + // Test 30: Test updating to empty object + $updatedDoc11 = $database->updateDocument($collectionId, 'doc11', new Document([ + '$id' => 'doc11', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [] + ])); + $this->assertIsArray($updatedDoc11->getAttribute('meta')); + $this->assertEmpty($updatedDoc11->getAttribute('meta')); + + // Test 31: Test with nested arrays of primitives + $doc12 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc12', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'matrix' => [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] + ] + ] + ])); + $this->assertIsArray($doc12->getAttribute('meta')['matrix']); + $this->assertEquals([1, 2, 3], $doc12->getAttribute('meta')['matrix'][0]); + + // Test 32: Contains query with nested array + $results = $database->find($collectionId, [ + Query::contains('meta', [['matrix' => [4, 5, 6]]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc12', $results[0]->getId()); + + // Test 33: Test getDocument with various documents + $fetchedDoc6 = $database->getDocument($collectionId, 'doc6'); + $this->assertEquals('doc6', $fetchedDoc6->getId()); + $this->assertEquals('text', $fetchedDoc6->getAttribute('meta')['string']); + $this->assertEquals(42, $fetchedDoc6->getAttribute('meta')['number']); + $this->assertTrue($fetchedDoc6->getAttribute('meta')['boolean']); + + $fetchedDoc10 = $database->getDocument($collectionId, 'doc10'); + $this->assertEquals('🚀🎉', $fetchedDoc10->getAttribute('meta')['emoji']); + $this->assertEquals('Test with "quotes" and \'apostrophes\'', $fetchedDoc10->getAttribute('meta')['description']); + + // Test 34: Test Query::select with complex nested structures + $results = $database->find($collectionId, [ + Query::select(['$id', '$permissions', 'meta']), + Query::equal('meta', [[ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => [ + 'level5' => 'deep_value' + ] + ] + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc5', $results[0]->getId()); + $this->assertEquals('deep_value', $results[0]->getAttribute('meta')['level1']['level2']['level3']['level4']['level5']); + + // Test 35: Test selecting multiple documents and verifying object attributes + $allDocs = $database->find($collectionId, [ + Query::select(['$id', 'meta']), + Query::limit(25) + ]); + $this->assertGreaterThan(10, count($allDocs)); + + // Verify that each document with meta has proper structure + foreach ($allDocs as $doc) { + $meta = $doc->getAttribute('meta'); + if ($meta !== null && $meta !== []) { + $this->assertIsArray($meta, "Document {$doc->getId()} should have array meta"); + } + } + + // Test 36: Test Query::select with only meta attribute + $results = $database->find($collectionId, [ + Query::select(['meta']), + Query::equal('meta', [['tags' => ['php', 'javascript', 'python', 'go', 'rust']]]) + ]); + $this->assertCount(1, $results); + $this->assertIsArray($results[0]->getAttribute('meta')); + $this->assertEquals(['php', 'javascript', 'python', 'go', 'rust'], $results[0]->getAttribute('meta')['tags']); + + // Clean up + $database->deleteCollection($collectionId); + } + + public function testObjectAttributeInvalidCases(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Skip test if adapter doesn't support JSONB + if (!$database->getAdapter()->getSupportForObject()) { + $this->markTestSkipped('Adapter does not support object attributes'); + return; + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Create object attribute + $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::TYPE_OBJECT, 0, false)); + + // Test 1: Try to create document with string instead of object (should fail) + $exceptionThrown = false; + try { + $database->createDocument($collectionId, new Document([ + '$id' => 'invalid1', + '$permissions' => [Permission::read(Role::any())], + 'meta' => 'this is a string not an object' + ])); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(StructureException::class, $e); + } + $this->assertTrue($exceptionThrown, 'Expected Structure exception for string value'); + + // Test 2: Try to create document with integer instead of object (should fail) + $exceptionThrown = false; + try { + $database->createDocument($collectionId, new Document([ + '$id' => 'invalid2', + '$permissions' => [Permission::read(Role::any())], + 'meta' => 12345 + ])); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(StructureException::class, $e); + } + $this->assertTrue($exceptionThrown, 'Expected Structure exception for integer value'); + + // Test 3: Try to create document with boolean instead of object (should fail) + $exceptionThrown = false; + try { + $database->createDocument($collectionId, new Document([ + '$id' => 'invalid3', + '$permissions' => [Permission::read(Role::any())], + 'meta' => true + ])); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(StructureException::class, $e); + } + $this->assertTrue($exceptionThrown, 'Expected Structure exception for boolean value'); + + // Test 4: Create valid document for query tests + $database->createDocument($collectionId, new Document([ + '$id' => 'valid1', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'name' => 'John', + 'age' => 30, + 'settings' => [ + 'notifications' => true, + 'theme' => 'dark' + ] + ] + ])); + + // Test 5: Query with non-matching nested structure + $results = $database->find($collectionId, [ + Query::equal('meta', [['settings' => ['notifications' => false]]]) + ]); + $this->assertCount(0, $results, 'Should not match when nested value differs'); + + // Test 6: Query with non-existent key + $results = $database->find($collectionId, [ + Query::equal('meta', [['nonexistent' => 'value']]) + ]); + $this->assertCount(0, $results, 'Should not match non-existent keys'); + + // Test 7: Contains query with non-matching array element + $database->createDocument($collectionId, new Document([ + '$id' => 'valid2', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'fruits' => ['apple', 'banana', 'orange'] + ] + ])); + $results = $database->find($collectionId, [ + Query::contains('meta', [['fruits' => 'grape']]) + ]); + $this->assertCount(0, $results, 'Should not match non-existent array element'); + + // Test 8: Test order preservation in nested objects + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'order_test', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'z_last' => 'value', + 'a_first' => 'value', + 'm_middle' => 'value' + ] + ])); + $meta = $doc->getAttribute('meta'); + $this->assertIsArray($meta); + // Note: JSON objects don't guarantee key order, but we can verify all keys exist + $this->assertArrayHasKey('z_last', $meta); + $this->assertArrayHasKey('a_first', $meta); + $this->assertArrayHasKey('m_middle', $meta); + + // Test 9: Test with very large nested structure + $largeStructure = []; + for ($i = 0; $i < 50; $i++) { + $largeStructure["key_$i"] = [ + 'id' => $i, + 'name' => "Item $i", + 'values' => range(1, 10) + ]; + } + $docLarge = $database->createDocument($collectionId, new Document([ + '$id' => 'large_structure', + '$permissions' => [Permission::read(Role::any())], + 'meta' => $largeStructure + ])); + $this->assertIsArray($docLarge->getAttribute('meta')); + $this->assertCount(50, $docLarge->getAttribute('meta')); + + // Test 10: Query within large structure + $results = $database->find($collectionId, [ + Query::equal('meta', [['key_25' => ['id' => 25, 'name' => 'Item 25', 'values' => range(1, 10)]]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('large_structure', $results[0]->getId()); + + // Test 11: Test getDocument with large structure + $fetchedLargeDoc = $database->getDocument($collectionId, 'large_structure'); + $this->assertEquals('large_structure', $fetchedLargeDoc->getId()); + $this->assertIsArray($fetchedLargeDoc->getAttribute('meta')); + $this->assertCount(50, $fetchedLargeDoc->getAttribute('meta')); + $this->assertEquals(25, $fetchedLargeDoc->getAttribute('meta')['key_25']['id']); + $this->assertEquals('Item 25', $fetchedLargeDoc->getAttribute('meta')['key_25']['name']); + + // Test 12: Test Query::select with valid document + $results = $database->find($collectionId, [ + Query::select(['$id', 'meta']), + Query::equal('meta', [['name' => 'John']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('valid1', $results[0]->getId()); + $this->assertIsArray($results[0]->getAttribute('meta')); + $this->assertEquals('John', $results[0]->getAttribute('meta')['name']); + $this->assertEquals(30, $results[0]->getAttribute('meta')['age']); + + // Test 13: Test getDocument returns proper structure + $fetchedValid1 = $database->getDocument($collectionId, 'valid1'); + $this->assertEquals('valid1', $fetchedValid1->getId()); + $this->assertIsArray($fetchedValid1->getAttribute('meta')); + $this->assertEquals('John', $fetchedValid1->getAttribute('meta')['name']); + $this->assertTrue($fetchedValid1->getAttribute('meta')['settings']['notifications']); + $this->assertEquals('dark', $fetchedValid1->getAttribute('meta')['settings']['theme']); + + // Test 14: Test Query::select excluding meta + $results = $database->find($collectionId, [ + Query::select(['$id', '$permissions']), + Query::equal('meta', [['fruits' => ['apple', 'banana', 'orange']]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('valid2', $results[0]->getId()); + // Meta should be empty when not selected + $this->assertEmpty($results[0]->getAttribute('meta')); + + // Test 15: Test getDocument with non-existent ID returns empty document + $nonExistent = $database->getDocument($collectionId, 'does_not_exist'); + $this->assertTrue($nonExistent->isEmpty()); + + // Clean up + $database->deleteCollection($collectionId); + } } From 10cf2bd65d87df6b6777368c56a2d016845c6b14 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Oct 2025 17:40:30 +0530 Subject: [PATCH 04/20] * added gin index * updated not operator --- src/Database/Adapter/Pool.php | 5 + src/Database/Adapter/Postgres.php | 61 +- src/Database/Database.php | 39 +- src/Database/Validator/Index.php | 52 +- tests/e2e/Adapter/Base.php | 2 + tests/e2e/Adapter/Scopes/DocumentTests.php | 677 ------------- .../Adapter/Scopes/ObjectAttributeTests.php | 913 ++++++++++++++++++ tests/unit/Validator/IndexTest.php | 87 ++ 8 files changed, 1125 insertions(+), 711 deletions(-) create mode 100644 tests/e2e/Adapter/Scopes/ObjectAttributeTests.php diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 799596d2d..619383789 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -568,4 +568,9 @@ public function decodePolygon(string $wkb): array { return $this->delegate(__FUNCTION__, \func_get_args()); } + + public function getSupportForObject(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index b24b97e58..6206262b4 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -860,14 +860,15 @@ public function createIndex(string $collection, string $id, string $type, array Database::INDEX_FULLTEXT => 'INDEX', Database::INDEX_UNIQUE => 'UNIQUE INDEX', Database::INDEX_SPATIAL => 'INDEX', - 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_GIN => 'INDEX', + 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_GIN), }; $key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\""; $attributes = \implode(', ', $attributes); - // Spatial indexes can't include _tenant because GIST indexes require all columns to have compatible operator classes - if ($this->sharedTables && $type !== Database::INDEX_FULLTEXT && $type !== Database::INDEX_SPATIAL) { + // Spatial and GIN indexes can't include _tenant because GIST/GIN indexes require all columns to have compatible operator classes + if ($this->sharedTables && $type !== Database::INDEX_FULLTEXT && $type !== Database::INDEX_SPATIAL && $type !== Database::INDEX_GIN) { // Add tenant as first index column for best performance $attributes = "_tenant, {$attributes}"; } @@ -879,6 +880,11 @@ public function createIndex(string $collection, string $id, string $type, array $sql .= " USING GIST"; } + // Add USING GIN for JSONB indexes + if ($type === Database::INDEX_GIN) { + $sql .= " USING GIN"; + } + $sql .= " ({$attributes});"; $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql); @@ -1576,44 +1582,41 @@ protected function handleObjectQueries(Query $query, array &$binds, string $attr { switch ($query->getMethod()) { case Query::TYPE_EQUAL: + case Query::TYPE_NOT_EQUAL: { + $isNot = $query->getMethod() === Query::TYPE_NOT_EQUAL; $conditions = []; foreach ($query->getValues() as $key => $value) { + $binds[":{$placeholder}_{$key}"] = json_encode($value); if (is_array($value)) { - // JSONB containment operator @> - $binds[":{$placeholder}_{$key}"] = json_encode($value); - $conditions[] = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb"; + $fragment = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb"; + $conditions[] = $isNot ? "NOT (" . $fragment . ")" : $fragment; } else { - // Direct equality - $binds[":{$placeholder}_{$key}"] = json_encode($value); - $conditions[] = "{$alias}.{$attribute} = :{$placeholder}_{$key}::jsonb"; + $fragment = "{$alias}.{$attribute} = :{$placeholder}_{$key}::jsonb"; + $conditions[] = $isNot ? "{$alias}.{$attribute} <> :{$placeholder}_{$key}::jsonb" : $fragment; } } - return empty($conditions) ? '' : '(' . implode(' OR ', $conditions) . ')'; + $separator = $isNot ? ' AND ' : ' OR '; + return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; + } case Query::TYPE_CONTAINS: + case Query::TYPE_NOT_CONTAINS: { + $isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS; $conditions = []; foreach ($query->getValues() as $key => $value) { - if (is_array($value)) { - // For JSONB contains, we need to check if an array contains a specific element - // The JSONB containment operator @> checks if left contains right - // For array element containment: {"array": ["element"]} means array contains "element" - // For nested array containment: {"matrix": [[4,5,6]]} means matrix contains [4,5,6] - - if (count($value) === 1) { - $jsonKey = array_key_first($value); - $jsonValue = $value[$jsonKey]; - - // Always wrap the value in an array to represent "array contains this element" - // - For scalar: 'react' becomes ["react"] - // - For array: [4,5,6] becomes [[4,5,6]] - $value[$jsonKey] = [$jsonValue]; - } - - $binds[":{$placeholder}_{$key}"] = json_encode($value); - $conditions[] = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb"; + if (count($value) === 1) { + $jsonKey = array_key_first($value); + $jsonValue = $value[$jsonKey]; + // wrap to represent array; eg: key -> [value] + $value[$jsonKey] = [$jsonValue]; } + $binds[":{$placeholder}_{$key}"] = json_encode($value); + $fragment = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb"; + $conditions[] = $isNot ? "NOT (" . $fragment . ")" : $fragment; } - return empty($conditions) ? '' : '(' . implode(' OR ', $conditions) . ')'; + $separator = $isNot ? ' AND ' : ' OR '; + return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; + } default: throw new DatabaseException('Query method ' . $query->getMethod() . ' not supported for object attributes'); diff --git a/src/Database/Database.php b/src/Database/Database.php index d50f0655c..22352400b 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -69,6 +69,7 @@ class Database public const INDEX_FULLTEXT = 'fulltext'; public const INDEX_UNIQUE = 'unique'; public const INDEX_SPATIAL = 'spatial'; + public const INDEX_GIN = 'gin'; public const ARRAY_INDEX_LENGTH = 255; // Relation Types @@ -1412,6 +1413,7 @@ public function createCollection(string $id, array $attributes = [], array $inde $this->adapter->getSupportForSpatialAttributes(), $this->adapter->getSupportForSpatialIndexNull(), $this->adapter->getSupportForSpatialIndexOrder(), + $this->adapter->getSupportForObject(), ); foreach ($indexes as $index) { if (!$validator->isValid($index)) { @@ -2027,7 +2029,7 @@ protected function validateDefaultTypes(string $type, mixed $default): void if ($defaultType === 'array') { // spatial types require the array itself - if (!in_array($type, Database::SPATIAL_TYPES)) { + if (!in_array($type, Database::SPATIAL_TYPES) && $type != Database::TYPE_OBJECT) { foreach ($default as $value) { $this->validateDefaultTypes($type, $value); } @@ -2051,10 +2053,11 @@ protected function validateDefaultTypes(string $type, mixed $default): void break; case self::TYPE_OBJECT: // Object types expect arrays as default values + var_dump($defaultType); if ($defaultType !== 'array') { throw new DatabaseException('Default value for object type must be an array'); } - break; + // no break case self::VAR_POINT: case self::VAR_LINESTRING: case self::VAR_POLYGON: @@ -2062,7 +2065,7 @@ protected function validateDefaultTypes(string $type, mixed $default): void if ($defaultType !== 'array') { throw new DatabaseException('Default value for spatial type ' . $type . ' must be an array'); } - break; + // no break default: throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP . ', ' . self::TYPE_OBJECT . ', ' . self::VAR_POINT . ', ' . self::VAR_LINESTRING . ', ' . self::VAR_POLYGON); } @@ -3315,8 +3318,14 @@ public function createIndex(string $collection, string $id, string $type, array } break; + case self::INDEX_GIN: + if (!$this->adapter->getSupportForObject()) { + throw new DatabaseException('GIN indexes are not supported'); + } + break; + default: - throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL); + 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_GIN); } /** @var array $collectionAttributes */ @@ -3373,6 +3382,27 @@ public function createIndex(string $collection, string $id, string $type, array } } + if ($type === self::INDEX_GIN) { + if (count($attributes) !== 1) { + throw new IndexException('GIN index can be created on a single object attribute'); + } + + foreach ($attributes as $attr) { + if (!isset($indexAttributesWithTypes[$attr])) { + throw new IndexException('Attribute "' . $attr . '" not found in collection'); + } + + $attributeType = $indexAttributesWithTypes[$attr]; + if ($attributeType !== self::TYPE_OBJECT) { + throw new IndexException('GIN index can only be created on object attributes. Attribute "' . $attr . '" is of type "' . $attributeType . '"'); + } + } + + if (!empty($orders)) { + throw new IndexException('GIN indexes do not support explicit orders. Remove the orders to create this index.'); + } + } + $index = new Document([ '$id' => ID::custom($id), 'key' => $id, @@ -3393,6 +3423,7 @@ public function createIndex(string $collection, string $id, string $type, array $this->adapter->getSupportForSpatialAttributes(), $this->adapter->getSupportForSpatialIndexNull(), $this->adapter->getSupportForSpatialIndexOrder(), + $this->adapter->getSupportForObject(), ); if (!$validator->isValid($index)) { throw new IndexException($validator->getDescription()); diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index bab80c173..d6f1235db 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -30,6 +30,8 @@ class Index extends Validator protected bool $spatialIndexOrderSupport; + protected bool $objectIndexSupport; + /** * @param array $attributes * @param int $maxLength @@ -38,9 +40,10 @@ class Index extends Validator * @param bool $spatialIndexSupport * @param bool $spatialIndexNullSupport * @param bool $spatialIndexOrderSupport + * @param bool $objectIndexSupport * @throws DatabaseException */ - public function __construct(array $attributes, int $maxLength, array $reservedKeys = [], bool $arrayIndexSupport = false, bool $spatialIndexSupport = false, bool $spatialIndexNullSupport = false, bool $spatialIndexOrderSupport = false) + public function __construct(array $attributes, int $maxLength, array $reservedKeys = [], bool $arrayIndexSupport = false, bool $spatialIndexSupport = false, bool $spatialIndexNullSupport = false, bool $spatialIndexOrderSupport = false, bool $objectIndexSupport = false) { $this->maxLength = $maxLength; $this->reservedKeys = $reservedKeys; @@ -48,6 +51,7 @@ public function __construct(array $attributes, int $maxLength, array $reservedKe $this->spatialIndexSupport = $spatialIndexSupport; $this->spatialIndexNullSupport = $spatialIndexNullSupport; $this->spatialIndexOrderSupport = $spatialIndexOrderSupport; + $this->objectIndexSupport = $objectIndexSupport; foreach ($attributes as $attribute) { $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); @@ -305,6 +309,10 @@ public function isValid($value): bool return false; } + if (!$this->checkGinIndex($value)) { + return false; + } + return true; } @@ -378,6 +386,48 @@ public function checkSpatialIndex(Document $index): bool } + return true; + } + + /** + * @param Document $index + * @return bool + */ + public function checkGinIndex(Document $index): bool + { + $type = $index->getAttribute('type'); + + $attributes = $index->getAttribute('attributes', []); + $orders = $index->getAttribute('orders', []); + + if ($type !== Database::INDEX_GIN) { + return true; + } + + if (!$this->objectIndexSupport) { + $this->message = 'GIN indexes are not supported'; + return false; + } + + if (count($attributes) !== 1) { + $this->message = 'GIN index can be created on a single object attribute'; + return false; + } + + if (!empty($orders)) { + $this->message = 'GIN indexes do not support explicit orders. Remove the orders to create this index.'; + return false; + } + + $attributeName = $attributes[0] ?? ''; + $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); + $attributeType = $attribute->getAttribute('type', ''); + + if ($attributeType !== Database::TYPE_OBJECT) { + $this->message = 'GIN index can only be created on object attributes. Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; + return false; + } + return true; } } diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 37ad7cce3..50711bc9b 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -8,6 +8,7 @@ use Tests\E2E\Adapter\Scopes\DocumentTests; use Tests\E2E\Adapter\Scopes\GeneralTests; use Tests\E2E\Adapter\Scopes\IndexTests; +use Tests\E2E\Adapter\Scopes\ObjectAttributeTests; use Tests\E2E\Adapter\Scopes\PermissionTests; use Tests\E2E\Adapter\Scopes\RelationshipTests; use Tests\E2E\Adapter\Scopes\SpatialTests; @@ -25,6 +26,7 @@ abstract class Base extends TestCase use PermissionTests; use RelationshipTests; use SpatialTests; + use ObjectAttributeTests; use GeneralTests; protected static string $namespace; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 484540955..7cba034b6 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -6559,681 +6559,4 @@ public function testUpdateDocumentSuccessiveCallsDoNotResetDefaults(): void $database->deleteCollection($collectionId); } - - public function testObjectAttribute(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - // Skip test if adapter doesn't support JSONB - if (!$database->getAdapter()->getSupportForObject()) { - $this->markTestSkipped('Adapter does not support object attributes'); - return; - } - - $collectionId = ID::unique(); - $database->createCollection($collectionId); - - // Create object attribute - $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::TYPE_OBJECT, 0, false)); - - // Test 1: Create and read document with object attribute - $doc1 = $database->createDocument($collectionId, new Document([ - '$id' => 'doc1', - '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], - 'meta' => [ - 'age' => 25, - 'skills' => ['react', 'node'], - 'user' => [ - 'info' => [ - 'country' => 'IN' - ] - ] - ] - ])); - - $this->assertIsArray($doc1->getAttribute('meta')); - $this->assertEquals(25, $doc1->getAttribute('meta')['age']); - $this->assertEquals(['react', 'node'], $doc1->getAttribute('meta')['skills']); - $this->assertEquals('IN', $doc1->getAttribute('meta')['user']['info']['country']); - - // Test 2: Query::equal with simple key-value pair - $results = $database->find($collectionId, [ - Query::equal('meta', [['age' => 25]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc1', $results[0]->getId()); - - // Test 3: Query::equal with nested JSON - $results = $database->find($collectionId, [ - Query::equal('meta', [[ - 'user' => [ - 'info' => [ - 'country' => 'IN' - ] - ] - ]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc1', $results[0]->getId()); - - // Test 4: Query::contains for array element - $results = $database->find($collectionId, [ - Query::contains('meta', [['skills' => 'react']]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc1', $results[0]->getId()); - - // Test 5: Create another document with different values - $doc2 = $database->createDocument($collectionId, new Document([ - '$id' => 'doc2', - '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], - 'meta' => [ - 'age' => 30, - 'skills' => ['python', 'java'], - 'user' => [ - 'info' => [ - 'country' => 'US' - ] - ] - ] - ])); - - // Test 6: Query should return only doc1 - $results = $database->find($collectionId, [ - Query::equal('meta', [['age' => 25]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc1', $results[0]->getId()); - - // Test 7: Query for doc2 - $results = $database->find($collectionId, [ - Query::equal('meta', [[ - 'user' => [ - 'info' => [ - 'country' => 'US' - ] - ] - ]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc2', $results[0]->getId()); - - // Test 8: Update document - $updatedDoc = $database->updateDocument($collectionId, 'doc1', new Document([ - '$id' => 'doc1', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [ - 'age' => 26, - 'skills' => ['react', 'node', 'typescript'], - 'user' => [ - 'info' => [ - 'country' => 'CA' - ] - ] - ] - ])); - - $this->assertEquals(26, $updatedDoc->getAttribute('meta')['age']); - $this->assertEquals(['react', 'node', 'typescript'], $updatedDoc->getAttribute('meta')['skills']); - $this->assertEquals('CA', $updatedDoc->getAttribute('meta')['user']['info']['country']); - - // Test 9: Query updated document - $results = $database->find($collectionId, [ - Query::equal('meta', [['age' => 26]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc1', $results[0]->getId()); - - // Test 10: Query with multiple conditions using contains - $results = $database->find($collectionId, [ - Query::contains('meta', [['skills' => 'typescript']]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc1', $results[0]->getId()); - - // Test 11: Negative test - query that shouldn't match - $results = $database->find($collectionId, [ - Query::equal('meta', [['age' => 99]]) - ]); - $this->assertCount(0, $results); - - // Test 11a: Test getDocument by ID - $fetchedDoc = $database->getDocument($collectionId, 'doc1'); - $this->assertEquals('doc1', $fetchedDoc->getId()); - $this->assertIsArray($fetchedDoc->getAttribute('meta')); - $this->assertEquals(26, $fetchedDoc->getAttribute('meta')['age']); - $this->assertEquals(['react', 'node', 'typescript'], $fetchedDoc->getAttribute('meta')['skills']); - $this->assertEquals('CA', $fetchedDoc->getAttribute('meta')['user']['info']['country']); - - // Test 11b: Test Query::select to limit returned attributes - $results = $database->find($collectionId, [ - Query::select(['$id', 'meta']), - Query::equal('meta', [['age' => 26]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc1', $results[0]->getId()); - $this->assertIsArray($results[0]->getAttribute('meta')); - $this->assertEquals(26, $results[0]->getAttribute('meta')['age']); - - // Test 11c: Test Query::select with only $id (exclude meta) - $results = $database->find($collectionId, [ - Query::select(['$id']), - Query::equal('meta', [['age' => 30]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc2', $results[0]->getId()); - // Meta should not be present when not selected - $this->assertEmpty($results[0]->getAttribute('meta')); - - // Test 12: Test with null value - $doc3 = $database->createDocument($collectionId, new Document([ - '$id' => 'doc3', - '$permissions' => [Permission::read(Role::any())], - 'meta' => null - ])); - $this->assertNull($doc3->getAttribute('meta')); - - // Test 13: Test with empty object - $doc4 = $database->createDocument($collectionId, new Document([ - '$id' => 'doc4', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [] - ])); - $this->assertIsArray($doc4->getAttribute('meta')); - $this->assertEmpty($doc4->getAttribute('meta')); - - // Test 14: Test deeply nested structure (5 levels) - $doc5 = $database->createDocument($collectionId, new Document([ - '$id' => 'doc5', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [ - 'level1' => [ - 'level2' => [ - 'level3' => [ - 'level4' => [ - 'level5' => 'deep_value' - ] - ] - ] - ] - ] - ])); - $this->assertEquals('deep_value', $doc5->getAttribute('meta')['level1']['level2']['level3']['level4']['level5']); - - // Test 15: Query deeply nested structure - $results = $database->find($collectionId, [ - Query::equal('meta', [[ - 'level1' => [ - 'level2' => [ - 'level3' => [ - 'level4' => [ - 'level5' => 'deep_value' - ] - ] - ] - ] - ]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc5', $results[0]->getId()); - - // Test 16: Query partial nested path - $results = $database->find($collectionId, [ - Query::equal('meta', [[ - 'level1' => [ - 'level2' => [ - 'level3' => [ - 'level4' => [ - 'level5' => 'deep_value' - ] - ] - ] - ] - ]]) - ]); - $this->assertCount(1, $results); - - // Test 17: Test with mixed data types - $doc6 = $database->createDocument($collectionId, new Document([ - '$id' => 'doc6', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [ - 'string' => 'text', - 'number' => 42, - 'float' => 3.14, - 'boolean' => true, - 'null_value' => null, - 'array' => [1, 2, 3], - 'object' => ['key' => 'value'] - ] - ])); - $this->assertEquals('text', $doc6->getAttribute('meta')['string']); - $this->assertEquals(42, $doc6->getAttribute('meta')['number']); - $this->assertEquals(3.14, $doc6->getAttribute('meta')['float']); - $this->assertTrue($doc6->getAttribute('meta')['boolean']); - $this->assertNull($doc6->getAttribute('meta')['null_value']); - - // Test 18: Query with boolean value - $results = $database->find($collectionId, [ - Query::equal('meta', [['boolean' => true]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc6', $results[0]->getId()); - - // Test 19: Query with numeric value - $results = $database->find($collectionId, [ - Query::equal('meta', [['number' => 42]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc6', $results[0]->getId()); - - // Test 20: Query with float value - $results = $database->find($collectionId, [ - Query::equal('meta', [['float' => 3.14]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc6', $results[0]->getId()); - - // Test 21: Test contains with multiple array elements - $doc7 = $database->createDocument($collectionId, new Document([ - '$id' => 'doc7', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [ - 'tags' => ['php', 'javascript', 'python', 'go', 'rust'] - ] - ])); - $results = $database->find($collectionId, [ - Query::contains('meta', [['tags' => 'rust']]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc7', $results[0]->getId()); - - // Test 22: Test contains with numeric array element - $doc8 = $database->createDocument($collectionId, new Document([ - '$id' => 'doc8', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [ - 'scores' => [85, 90, 95, 100] - ] - ])); - $results = $database->find($collectionId, [ - Query::contains('meta', [['scores' => 95]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc8', $results[0]->getId()); - - // Test 23: Negative test - contains query that shouldn't match - $results = $database->find($collectionId, [ - Query::contains('meta', [['tags' => 'kotlin']]) - ]); - $this->assertCount(0, $results); - - // Test 24: Test complex nested array within object - $doc9 = $database->createDocument($collectionId, new Document([ - '$id' => 'doc9', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [ - 'projects' => [ - [ - 'name' => 'Project A', - 'technologies' => ['react', 'node'], - 'active' => true - ], - [ - 'name' => 'Project B', - 'technologies' => ['vue', 'python'], - 'active' => false - ] - ], - 'company' => 'TechCorp' - ] - ])); - $this->assertIsArray($doc9->getAttribute('meta')['projects']); - $this->assertCount(2, $doc9->getAttribute('meta')['projects']); - $this->assertEquals('Project A', $doc9->getAttribute('meta')['projects'][0]['name']); - - // Test 25: Query using equal with nested key - $results = $database->find($collectionId, [ - Query::equal('meta', [['company' => 'TechCorp']]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc9', $results[0]->getId()); - - // Test 25b: Query the entire array structure using equal - $results = $database->find($collectionId, [ - Query::equal('meta', [[ - 'projects' => [ - [ - 'name' => 'Project A', - 'technologies' => ['react', 'node'], - 'active' => true - ], - [ - 'name' => 'Project B', - 'technologies' => ['vue', 'python'], - 'active' => false - ] - ] - ]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc9', $results[0]->getId()); - - // Test 26: Test with special characters in values - $doc10 = $database->createDocument($collectionId, new Document([ - '$id' => 'doc10', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [ - 'description' => 'Test with "quotes" and \'apostrophes\'', - 'emoji' => '🚀🎉', - 'symbols' => '@#$%^&*()' - ] - ])); - $this->assertEquals('Test with "quotes" and \'apostrophes\'', $doc10->getAttribute('meta')['description']); - $this->assertEquals('🚀🎉', $doc10->getAttribute('meta')['emoji']); - - // Test 27: Query with special characters - $results = $database->find($collectionId, [ - Query::equal('meta', [['emoji' => '🚀🎉']]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc10', $results[0]->getId()); - - // Test 28: Test equal query with complete object match - $doc11 = $database->createDocument($collectionId, new Document([ - '$id' => 'doc11', - '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], - 'meta' => [ - 'config' => [ - 'theme' => 'dark', - 'language' => 'en' - ] - ] - ])); - $results = $database->find($collectionId, [ - Query::equal('meta', [['config' => ['theme' => 'dark', 'language' => 'en']]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc11', $results[0]->getId()); - - // Test 29: Negative test - partial object match should still work (containment) - $results = $database->find($collectionId, [ - Query::equal('meta', [['config' => ['theme' => 'dark']]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc11', $results[0]->getId()); - - // Test 30: Test updating to empty object - $updatedDoc11 = $database->updateDocument($collectionId, 'doc11', new Document([ - '$id' => 'doc11', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [] - ])); - $this->assertIsArray($updatedDoc11->getAttribute('meta')); - $this->assertEmpty($updatedDoc11->getAttribute('meta')); - - // Test 31: Test with nested arrays of primitives - $doc12 = $database->createDocument($collectionId, new Document([ - '$id' => 'doc12', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [ - 'matrix' => [ - [1, 2, 3], - [4, 5, 6], - [7, 8, 9] - ] - ] - ])); - $this->assertIsArray($doc12->getAttribute('meta')['matrix']); - $this->assertEquals([1, 2, 3], $doc12->getAttribute('meta')['matrix'][0]); - - // Test 32: Contains query with nested array - $results = $database->find($collectionId, [ - Query::contains('meta', [['matrix' => [4, 5, 6]]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc12', $results[0]->getId()); - - // Test 33: Test getDocument with various documents - $fetchedDoc6 = $database->getDocument($collectionId, 'doc6'); - $this->assertEquals('doc6', $fetchedDoc6->getId()); - $this->assertEquals('text', $fetchedDoc6->getAttribute('meta')['string']); - $this->assertEquals(42, $fetchedDoc6->getAttribute('meta')['number']); - $this->assertTrue($fetchedDoc6->getAttribute('meta')['boolean']); - - $fetchedDoc10 = $database->getDocument($collectionId, 'doc10'); - $this->assertEquals('🚀🎉', $fetchedDoc10->getAttribute('meta')['emoji']); - $this->assertEquals('Test with "quotes" and \'apostrophes\'', $fetchedDoc10->getAttribute('meta')['description']); - - // Test 34: Test Query::select with complex nested structures - $results = $database->find($collectionId, [ - Query::select(['$id', '$permissions', 'meta']), - Query::equal('meta', [[ - 'level1' => [ - 'level2' => [ - 'level3' => [ - 'level4' => [ - 'level5' => 'deep_value' - ] - ] - ] - ] - ]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('doc5', $results[0]->getId()); - $this->assertEquals('deep_value', $results[0]->getAttribute('meta')['level1']['level2']['level3']['level4']['level5']); - - // Test 35: Test selecting multiple documents and verifying object attributes - $allDocs = $database->find($collectionId, [ - Query::select(['$id', 'meta']), - Query::limit(25) - ]); - $this->assertGreaterThan(10, count($allDocs)); - - // Verify that each document with meta has proper structure - foreach ($allDocs as $doc) { - $meta = $doc->getAttribute('meta'); - if ($meta !== null && $meta !== []) { - $this->assertIsArray($meta, "Document {$doc->getId()} should have array meta"); - } - } - - // Test 36: Test Query::select with only meta attribute - $results = $database->find($collectionId, [ - Query::select(['meta']), - Query::equal('meta', [['tags' => ['php', 'javascript', 'python', 'go', 'rust']]]) - ]); - $this->assertCount(1, $results); - $this->assertIsArray($results[0]->getAttribute('meta')); - $this->assertEquals(['php', 'javascript', 'python', 'go', 'rust'], $results[0]->getAttribute('meta')['tags']); - - // Clean up - $database->deleteCollection($collectionId); - } - - public function testObjectAttributeInvalidCases(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - // Skip test if adapter doesn't support JSONB - if (!$database->getAdapter()->getSupportForObject()) { - $this->markTestSkipped('Adapter does not support object attributes'); - return; - } - - $collectionId = ID::unique(); - $database->createCollection($collectionId); - - // Create object attribute - $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::TYPE_OBJECT, 0, false)); - - // Test 1: Try to create document with string instead of object (should fail) - $exceptionThrown = false; - try { - $database->createDocument($collectionId, new Document([ - '$id' => 'invalid1', - '$permissions' => [Permission::read(Role::any())], - 'meta' => 'this is a string not an object' - ])); - } catch (\Exception $e) { - $exceptionThrown = true; - $this->assertInstanceOf(StructureException::class, $e); - } - $this->assertTrue($exceptionThrown, 'Expected Structure exception for string value'); - - // Test 2: Try to create document with integer instead of object (should fail) - $exceptionThrown = false; - try { - $database->createDocument($collectionId, new Document([ - '$id' => 'invalid2', - '$permissions' => [Permission::read(Role::any())], - 'meta' => 12345 - ])); - } catch (\Exception $e) { - $exceptionThrown = true; - $this->assertInstanceOf(StructureException::class, $e); - } - $this->assertTrue($exceptionThrown, 'Expected Structure exception for integer value'); - - // Test 3: Try to create document with boolean instead of object (should fail) - $exceptionThrown = false; - try { - $database->createDocument($collectionId, new Document([ - '$id' => 'invalid3', - '$permissions' => [Permission::read(Role::any())], - 'meta' => true - ])); - } catch (\Exception $e) { - $exceptionThrown = true; - $this->assertInstanceOf(StructureException::class, $e); - } - $this->assertTrue($exceptionThrown, 'Expected Structure exception for boolean value'); - - // Test 4: Create valid document for query tests - $database->createDocument($collectionId, new Document([ - '$id' => 'valid1', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [ - 'name' => 'John', - 'age' => 30, - 'settings' => [ - 'notifications' => true, - 'theme' => 'dark' - ] - ] - ])); - - // Test 5: Query with non-matching nested structure - $results = $database->find($collectionId, [ - Query::equal('meta', [['settings' => ['notifications' => false]]]) - ]); - $this->assertCount(0, $results, 'Should not match when nested value differs'); - - // Test 6: Query with non-existent key - $results = $database->find($collectionId, [ - Query::equal('meta', [['nonexistent' => 'value']]) - ]); - $this->assertCount(0, $results, 'Should not match non-existent keys'); - - // Test 7: Contains query with non-matching array element - $database->createDocument($collectionId, new Document([ - '$id' => 'valid2', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [ - 'fruits' => ['apple', 'banana', 'orange'] - ] - ])); - $results = $database->find($collectionId, [ - Query::contains('meta', [['fruits' => 'grape']]) - ]); - $this->assertCount(0, $results, 'Should not match non-existent array element'); - - // Test 8: Test order preservation in nested objects - $doc = $database->createDocument($collectionId, new Document([ - '$id' => 'order_test', - '$permissions' => [Permission::read(Role::any())], - 'meta' => [ - 'z_last' => 'value', - 'a_first' => 'value', - 'm_middle' => 'value' - ] - ])); - $meta = $doc->getAttribute('meta'); - $this->assertIsArray($meta); - // Note: JSON objects don't guarantee key order, but we can verify all keys exist - $this->assertArrayHasKey('z_last', $meta); - $this->assertArrayHasKey('a_first', $meta); - $this->assertArrayHasKey('m_middle', $meta); - - // Test 9: Test with very large nested structure - $largeStructure = []; - for ($i = 0; $i < 50; $i++) { - $largeStructure["key_$i"] = [ - 'id' => $i, - 'name' => "Item $i", - 'values' => range(1, 10) - ]; - } - $docLarge = $database->createDocument($collectionId, new Document([ - '$id' => 'large_structure', - '$permissions' => [Permission::read(Role::any())], - 'meta' => $largeStructure - ])); - $this->assertIsArray($docLarge->getAttribute('meta')); - $this->assertCount(50, $docLarge->getAttribute('meta')); - - // Test 10: Query within large structure - $results = $database->find($collectionId, [ - Query::equal('meta', [['key_25' => ['id' => 25, 'name' => 'Item 25', 'values' => range(1, 10)]]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('large_structure', $results[0]->getId()); - - // Test 11: Test getDocument with large structure - $fetchedLargeDoc = $database->getDocument($collectionId, 'large_structure'); - $this->assertEquals('large_structure', $fetchedLargeDoc->getId()); - $this->assertIsArray($fetchedLargeDoc->getAttribute('meta')); - $this->assertCount(50, $fetchedLargeDoc->getAttribute('meta')); - $this->assertEquals(25, $fetchedLargeDoc->getAttribute('meta')['key_25']['id']); - $this->assertEquals('Item 25', $fetchedLargeDoc->getAttribute('meta')['key_25']['name']); - - // Test 12: Test Query::select with valid document - $results = $database->find($collectionId, [ - Query::select(['$id', 'meta']), - Query::equal('meta', [['name' => 'John']]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('valid1', $results[0]->getId()); - $this->assertIsArray($results[0]->getAttribute('meta')); - $this->assertEquals('John', $results[0]->getAttribute('meta')['name']); - $this->assertEquals(30, $results[0]->getAttribute('meta')['age']); - - // Test 13: Test getDocument returns proper structure - $fetchedValid1 = $database->getDocument($collectionId, 'valid1'); - $this->assertEquals('valid1', $fetchedValid1->getId()); - $this->assertIsArray($fetchedValid1->getAttribute('meta')); - $this->assertEquals('John', $fetchedValid1->getAttribute('meta')['name']); - $this->assertTrue($fetchedValid1->getAttribute('meta')['settings']['notifications']); - $this->assertEquals('dark', $fetchedValid1->getAttribute('meta')['settings']['theme']); - - // Test 14: Test Query::select excluding meta - $results = $database->find($collectionId, [ - Query::select(['$id', '$permissions']), - Query::equal('meta', [['fruits' => ['apple', 'banana', 'orange']]]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('valid2', $results[0]->getId()); - // Meta should be empty when not selected - $this->assertEmpty($results[0]->getAttribute('meta')); - - // Test 15: Test getDocument with non-existent ID returns empty document - $nonExistent = $database->getDocument($collectionId, 'does_not_exist'); - $this->assertTrue($nonExistent->isEmpty()); - - // Clean up - $database->deleteCollection($collectionId); - } } diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php new file mode 100644 index 000000000..b79a8c175 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -0,0 +1,913 @@ +getAdapter()->getSupportForObject()) { + $this->markTestSkipped('Adapter does not support object attributes'); + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Create object attribute + $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::TYPE_OBJECT, 0, false)); + + // Test 1: Create and read document with object attribute + $doc1 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc1', + '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], + 'meta' => [ + 'age' => 25, + 'skills' => ['react', 'node'], + 'user' => [ + 'info' => [ + 'country' => 'IN' + ] + ] + ] + ])); + + $this->assertIsArray($doc1->getAttribute('meta')); + $this->assertEquals(25, $doc1->getAttribute('meta')['age']); + $this->assertEquals(['react', 'node'], $doc1->getAttribute('meta')['skills']); + $this->assertEquals('IN', $doc1->getAttribute('meta')['user']['info']['country']); + + // Test 2: Query::equal with simple key-value pair + $results = $database->find($collectionId, [ + Query::equal('meta', [['age' => 25]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + + // Test 3: Query::equal with nested JSON + $results = $database->find($collectionId, [ + Query::equal('meta', [[ + 'user' => [ + 'info' => [ + 'country' => 'IN' + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + + // Test 4: Query::contains for array element + $results = $database->find($collectionId, [ + Query::contains('meta', [['skills' => 'react']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + + // Test 5: Create another document with different values + $doc2 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc2', + '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], + 'meta' => [ + 'age' => 30, + 'skills' => ['python', 'java'], + 'user' => [ + 'info' => [ + 'country' => 'US' + ] + ] + ] + ])); + + // Test 6: Query should return only doc1 + $results = $database->find($collectionId, [ + Query::equal('meta', [['age' => 25]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + + // Test 7: Query for doc2 + $results = $database->find($collectionId, [ + Query::equal('meta', [[ + 'user' => [ + 'info' => [ + 'country' => 'US' + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc2', $results[0]->getId()); + + // Test 8: Update document + $updatedDoc = $database->updateDocument($collectionId, 'doc1', new Document([ + '$id' => 'doc1', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'age' => 26, + 'skills' => ['react', 'node', 'typescript'], + 'user' => [ + 'info' => [ + 'country' => 'CA' + ] + ] + ] + ])); + + $this->assertEquals(26, $updatedDoc->getAttribute('meta')['age']); + $this->assertEquals(['react', 'node', 'typescript'], $updatedDoc->getAttribute('meta')['skills']); + $this->assertEquals('CA', $updatedDoc->getAttribute('meta')['user']['info']['country']); + + // Test 9: Query updated document + $results = $database->find($collectionId, [ + Query::equal('meta', [['age' => 26]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + + // Test 10: Query with multiple conditions using contains + $results = $database->find($collectionId, [ + Query::contains('meta', [['skills' => 'typescript']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + + // Test 11: Negative test - query that shouldn't match + $results = $database->find($collectionId, [ + Query::equal('meta', [['age' => 99]]) + ]); + $this->assertCount(0, $results); + + // Test 11d: notEqual on scalar inside object should exclude doc1 + $results = $database->find($collectionId, [ + Query::notEqual('meta', [['age' => 26]]) + ]); + // Should return doc2 only + $this->assertCount(1, $results); + $this->assertEquals('doc2', $results[0]->getId()); + + // Test 11e: notEqual on nested object should exclude doc1 + $results = $database->find($collectionId, [ + Query::notEqual('meta', [[ + 'user' => [ + 'info' => [ + 'country' => 'CA' + ] + ] + ]]) + ]); + // Should return doc2 only + $this->assertCount(1, $results); + $this->assertEquals('doc2', $results[0]->getId()); + + // Test 11a: Test getDocument by ID + $fetchedDoc = $database->getDocument($collectionId, 'doc1'); + $this->assertEquals('doc1', $fetchedDoc->getId()); + $this->assertIsArray($fetchedDoc->getAttribute('meta')); + $this->assertEquals(26, $fetchedDoc->getAttribute('meta')['age']); + $this->assertEquals(['react', 'node', 'typescript'], $fetchedDoc->getAttribute('meta')['skills']); + $this->assertEquals('CA', $fetchedDoc->getAttribute('meta')['user']['info']['country']); + + // Test 11b: Test Query::select to limit returned attributes + $results = $database->find($collectionId, [ + Query::select(['$id', 'meta']), + Query::equal('meta', [['age' => 26]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc1', $results[0]->getId()); + $this->assertIsArray($results[0]->getAttribute('meta')); + $this->assertEquals(26, $results[0]->getAttribute('meta')['age']); + + // Test 11c: Test Query::select with only $id (exclude meta) + $results = $database->find($collectionId, [ + Query::select(['$id']), + Query::equal('meta', [['age' => 30]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc2', $results[0]->getId()); + // Meta should not be present when not selected + $this->assertEmpty($results[0]->getAttribute('meta')); + + // Test 12: Test with null value + $doc3 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc3', + '$permissions' => [Permission::read(Role::any())], + 'meta' => null + ])); + $this->assertNull($doc3->getAttribute('meta')); + + // Test 13: Test with empty object + $doc4 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc4', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [] + ])); + $this->assertIsArray($doc4->getAttribute('meta')); + $this->assertEmpty($doc4->getAttribute('meta')); + + // Test 14: Test deeply nested structure (5 levels) + $doc5 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc5', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => [ + 'level5' => 'deep_value' + ] + ] + ] + ] + ] + ])); + $this->assertEquals('deep_value', $doc5->getAttribute('meta')['level1']['level2']['level3']['level4']['level5']); + + // Test 15: Query deeply nested structure + $results = $database->find($collectionId, [ + Query::equal('meta', [[ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => [ + 'level5' => 'deep_value' + ] + ] + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc5', $results[0]->getId()); + + // Test 16: Query partial nested path + $results = $database->find($collectionId, [ + Query::equal('meta', [[ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => [ + 'level5' => 'deep_value' + ] + ] + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + + // Test 17: Test with mixed data types + $doc6 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc6', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'string' => 'text', + 'number' => 42, + 'float' => 3.14, + 'boolean' => true, + 'null_value' => null, + 'array' => [1, 2, 3], + 'object' => ['key' => 'value'] + ] + ])); + $this->assertEquals('text', $doc6->getAttribute('meta')['string']); + $this->assertEquals(42, $doc6->getAttribute('meta')['number']); + $this->assertEquals(3.14, $doc6->getAttribute('meta')['float']); + $this->assertTrue($doc6->getAttribute('meta')['boolean']); + $this->assertNull($doc6->getAttribute('meta')['null_value']); + + // Test 18: Query with boolean value + $results = $database->find($collectionId, [ + Query::equal('meta', [['boolean' => true]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc6', $results[0]->getId()); + + // Test 19: Query with numeric value + $results = $database->find($collectionId, [ + Query::equal('meta', [['number' => 42]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc6', $results[0]->getId()); + + // Test 20: Query with float value + $results = $database->find($collectionId, [ + Query::equal('meta', [['float' => 3.14]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc6', $results[0]->getId()); + + // Test 21: Test contains with multiple array elements + $doc7 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc7', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'tags' => ['php', 'javascript', 'python', 'go', 'rust'] + ] + ])); + $results = $database->find($collectionId, [ + Query::contains('meta', [['tags' => 'rust']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc7', $results[0]->getId()); + + // Test 22: Test contains with numeric array element + $doc8 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc8', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'scores' => [85, 90, 95, 100] + ] + ])); + $results = $database->find($collectionId, [ + Query::contains('meta', [['scores' => 95]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc8', $results[0]->getId()); + + // Test 23: Negative test - contains query that shouldn't match + $results = $database->find($collectionId, [ + Query::contains('meta', [['tags' => 'kotlin']]) + ]); + $this->assertCount(0, $results); + + // Test 23b: notContains should exclude doc7 (which has 'rust') + $results = $database->find($collectionId, [ + Query::notContains('meta', [['tags' => 'rust']]) + ]); + // Should not include doc7; returns others (at least doc1, doc2, ...) + $this->assertGreaterThanOrEqual(1, count($results)); + foreach ($results as $doc) { + if ($doc->getId() === 'doc7') { + $this->fail('doc7 should not be returned by notContains for rust'); + } + } + + // Test 24: Test complex nested array within object + $doc9 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc9', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'projects' => [ + [ + 'name' => 'Project A', + 'technologies' => ['react', 'node'], + 'active' => true + ], + [ + 'name' => 'Project B', + 'technologies' => ['vue', 'python'], + 'active' => false + ] + ], + 'company' => 'TechCorp' + ] + ])); + $this->assertIsArray($doc9->getAttribute('meta')['projects']); + $this->assertCount(2, $doc9->getAttribute('meta')['projects']); + $this->assertEquals('Project A', $doc9->getAttribute('meta')['projects'][0]['name']); + + // Test 25: Query using equal with nested key + $results = $database->find($collectionId, [ + Query::equal('meta', [['company' => 'TechCorp']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc9', $results[0]->getId()); + + // Test 25b: Query the entire array structure using equal + $results = $database->find($collectionId, [ + Query::equal('meta', [[ + 'projects' => [ + [ + 'name' => 'Project A', + 'technologies' => ['react', 'node'], + 'active' => true + ], + [ + 'name' => 'Project B', + 'technologies' => ['vue', 'python'], + 'active' => false + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc9', $results[0]->getId()); + + // Test 26: Test with special characters in values + $doc10 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc10', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'description' => 'Test with "quotes" and \'apostrophes\'', + 'emoji' => '🚀🎉', + 'symbols' => '@#$%^&*()' + ] + ])); + $this->assertEquals('Test with "quotes" and \'apostrophes\'', $doc10->getAttribute('meta')['description']); + $this->assertEquals('🚀🎉', $doc10->getAttribute('meta')['emoji']); + + // Test 27: Query with special characters + $results = $database->find($collectionId, [ + Query::equal('meta', [['emoji' => '🚀🎉']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc10', $results[0]->getId()); + + // Test 28: Test equal query with complete object match + $doc11 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc11', + '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], + 'meta' => [ + 'config' => [ + 'theme' => 'dark', + 'language' => 'en' + ] + ] + ])); + $results = $database->find($collectionId, [ + Query::equal('meta', [['config' => ['theme' => 'dark', 'language' => 'en']]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc11', $results[0]->getId()); + + // Test 29: Negative test - partial object match should still work (containment) + $results = $database->find($collectionId, [ + Query::equal('meta', [['config' => ['theme' => 'dark']]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc11', $results[0]->getId()); + + // Test 30: Test updating to empty object + $updatedDoc11 = $database->updateDocument($collectionId, 'doc11', new Document([ + '$id' => 'doc11', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [] + ])); + $this->assertIsArray($updatedDoc11->getAttribute('meta')); + $this->assertEmpty($updatedDoc11->getAttribute('meta')); + + // Test 31: Test with nested arrays of primitives + $doc12 = $database->createDocument($collectionId, new Document([ + '$id' => 'doc12', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'matrix' => [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] + ] + ] + ])); + $this->assertIsArray($doc12->getAttribute('meta')['matrix']); + $this->assertEquals([1, 2, 3], $doc12->getAttribute('meta')['matrix'][0]); + + // Test 32: Contains query with nested array + $results = $database->find($collectionId, [ + Query::contains('meta', [['matrix' => [4, 5, 6]]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc12', $results[0]->getId()); + + // Test 33: Test getDocument with various documents + $fetchedDoc6 = $database->getDocument($collectionId, 'doc6'); + $this->assertEquals('doc6', $fetchedDoc6->getId()); + $this->assertEquals('text', $fetchedDoc6->getAttribute('meta')['string']); + $this->assertEquals(42, $fetchedDoc6->getAttribute('meta')['number']); + $this->assertTrue($fetchedDoc6->getAttribute('meta')['boolean']); + + $fetchedDoc10 = $database->getDocument($collectionId, 'doc10'); + $this->assertEquals('🚀🎉', $fetchedDoc10->getAttribute('meta')['emoji']); + $this->assertEquals('Test with "quotes" and \'apostrophes\'', $fetchedDoc10->getAttribute('meta')['description']); + + // Test 34: Test Query::select with complex nested structures + $results = $database->find($collectionId, [ + Query::select(['$id', '$permissions', 'meta']), + Query::equal('meta', [[ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => [ + 'level5' => 'deep_value' + ] + ] + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('doc5', $results[0]->getId()); + $this->assertEquals('deep_value', $results[0]->getAttribute('meta')['level1']['level2']['level3']['level4']['level5']); + + // Test 35: Test selecting multiple documents and verifying object attributes + $allDocs = $database->find($collectionId, [ + Query::select(['$id', 'meta']), + Query::limit(25) + ]); + $this->assertGreaterThan(10, count($allDocs)); + + // Verify that each document with meta has proper structure + foreach ($allDocs as $doc) { + $meta = $doc->getAttribute('meta'); + if ($meta !== null && $meta !== []) { + $this->assertIsArray($meta, "Document {$doc->getId()} should have array meta"); + } + } + + // Test 36: Test Query::select with only meta attribute + $results = $database->find($collectionId, [ + Query::select(['meta']), + Query::equal('meta', [['tags' => ['php', 'javascript', 'python', 'go', 'rust']]]) + ]); + $this->assertCount(1, $results); + $this->assertIsArray($results[0]->getAttribute('meta')); + $this->assertEquals(['php', 'javascript', 'python', 'go', 'rust'], $results[0]->getAttribute('meta')['tags']); + + // Clean up + $database->deleteCollection($collectionId); + } + + public function testObjectAttributeGinIndex(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Skip test if adapter doesn't support JSONB + if (!$database->getAdapter()->getSupportForObject()) { + $this->markTestSkipped('Adapter does not support object attributes'); + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Create object attribute + $this->assertEquals(true, $database->createAttribute($collectionId, 'data', Database::TYPE_OBJECT, 0, false)); + + // Test 1: Create GIN index on object attribute + $ginIndex = $database->createIndex($collectionId, 'idx_data_gin', Database::INDEX_GIN, ['data']); + $this->assertTrue($ginIndex); + + // Test 2: Create documents with JSONB data + $doc1 = $database->createDocument($collectionId, new Document([ + '$id' => 'gin1', + '$permissions' => [Permission::read(Role::any())], + 'data' => [ + 'tags' => ['php', 'javascript', 'python'], + 'config' => [ + 'env' => 'production', + 'debug' => false + ], + 'version' => '1.0.0' + ] + ])); + + $doc2 = $database->createDocument($collectionId, new Document([ + '$id' => 'gin2', + '$permissions' => [Permission::read(Role::any())], + 'data' => [ + 'tags' => ['java', 'kotlin', 'scala'], + 'config' => [ + 'env' => 'development', + 'debug' => true + ], + 'version' => '2.0.0' + ] + ])); + + // Test 3: Query with equal on indexed JSONB column + $results = $database->find($collectionId, [ + Query::equal('data', [['env' => 'production']]) + ]); + $this->assertCount(0, $results); // Note: GIN index doesn't make equal queries work differently + + // Test 4: Query with contains on indexed JSONB column + $results = $database->find($collectionId, [ + Query::contains('data', [['tags' => 'php']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('gin1', $results[0]->getId()); + + // Test 5: Verify GIN index improves performance for containment queries + $results = $database->find($collectionId, [ + Query::contains('data', [['tags' => 'kotlin']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('gin2', $results[0]->getId()); + + // Test 6: Try to create GIN index on non-object attribute (should fail) + $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 255, false); + + $exceptionThrown = false; + try { + $database->createIndex($collectionId, 'idx_name_gin', Database::INDEX_GIN, ['name']); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(IndexException::class, $e); + $this->assertStringContainsString('GIN index can only be created on object attributes', $e->getMessage()); + } + $this->assertTrue($exceptionThrown, 'Expected Index exception for GIN index on non-object attribute'); + + // Test 7: Try to create GIN index on multiple attributes (should fail) + $database->createAttribute($collectionId, 'metadata', Database::TYPE_OBJECT, 0, false); + + $exceptionThrown = false; + try { + $database->createIndex($collectionId, 'idx_multi_gin', Database::INDEX_GIN, ['data', 'metadata']); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(IndexException::class, $e); + $this->assertStringContainsString('GIN index can be created on a single object attribute', $e->getMessage()); + } + $this->assertTrue($exceptionThrown, 'Expected Index exception for GIN index on multiple attributes'); + + // Test 8: Try to create GIN index with orders (should fail) + $exceptionThrown = false; + try { + $database->createIndex($collectionId, 'idx_ordered_gin', Database::INDEX_GIN, ['metadata'], [], [Database::ORDER_ASC]); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(IndexException::class, $e); + $this->assertStringContainsString('GIN indexes do not support explicit orders', $e->getMessage()); + } + $this->assertTrue($exceptionThrown, 'Expected Index exception for GIN index with orders'); + + // Clean up + $database->deleteCollection($collectionId); + } + + public function testObjectAttributeInvalidCases(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Skip test if adapter doesn't support JSONB + if (!$database->getAdapter()->getSupportForObject()) { + $this->markTestSkipped('Adapter does not support object attributes'); + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Create object attribute + $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::TYPE_OBJECT, 0, false)); + + // Test 1: Try to create document with string instead of object (should fail) + $exceptionThrown = false; + try { + $database->createDocument($collectionId, new Document([ + '$id' => 'invalid1', + '$permissions' => [Permission::read(Role::any())], + 'meta' => 'this is a string not an object' + ])); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(StructureException::class, $e); + } + $this->assertTrue($exceptionThrown, 'Expected Structure exception for string value'); + + // Test 2: Try to create document with integer instead of object (should fail) + $exceptionThrown = false; + try { + $database->createDocument($collectionId, new Document([ + '$id' => 'invalid2', + '$permissions' => [Permission::read(Role::any())], + 'meta' => 12345 + ])); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(StructureException::class, $e); + } + $this->assertTrue($exceptionThrown, 'Expected Structure exception for integer value'); + + // Test 3: Try to create document with boolean instead of object (should fail) + $exceptionThrown = false; + try { + $database->createDocument($collectionId, new Document([ + '$id' => 'invalid3', + '$permissions' => [Permission::read(Role::any())], + 'meta' => true + ])); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(StructureException::class, $e); + } + $this->assertTrue($exceptionThrown, 'Expected Structure exception for boolean value'); + + // Test 4: Create valid document for query tests + $database->createDocument($collectionId, new Document([ + '$id' => 'valid1', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'name' => 'John', + 'age' => 30, + 'settings' => [ + 'notifications' => true, + 'theme' => 'dark' + ] + ] + ])); + + // Test 5: Query with non-matching nested structure + $results = $database->find($collectionId, [ + Query::equal('meta', [['settings' => ['notifications' => false]]]) + ]); + $this->assertCount(0, $results, 'Should not match when nested value differs'); + + // Test 6: Query with non-existent key + $results = $database->find($collectionId, [ + Query::equal('meta', [['nonexistent' => 'value']]) + ]); + $this->assertCount(0, $results, 'Should not match non-existent keys'); + + // Test 7: Contains query with non-matching array element + $database->createDocument($collectionId, new Document([ + '$id' => 'valid2', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'fruits' => ['apple', 'banana', 'orange'] + ] + ])); + $results = $database->find($collectionId, [ + Query::contains('meta', [['fruits' => 'grape']]) + ]); + $this->assertCount(0, $results, 'Should not match non-existent array element'); + + // Test 8: Test order preservation in nested objects + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'order_test', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'z_last' => 'value', + 'a_first' => 'value', + 'm_middle' => 'value' + ] + ])); + $meta = $doc->getAttribute('meta'); + $this->assertIsArray($meta); + // Note: JSON objects don't guarantee key order, but we can verify all keys exist + $this->assertArrayHasKey('z_last', $meta); + $this->assertArrayHasKey('a_first', $meta); + $this->assertArrayHasKey('m_middle', $meta); + + // Test 9: Test with very large nested structure + $largeStructure = []; + for ($i = 0; $i < 50; $i++) { + $largeStructure["key_$i"] = [ + 'id' => $i, + 'name' => "Item $i", + 'values' => range(1, 10) + ]; + } + $docLarge = $database->createDocument($collectionId, new Document([ + '$id' => 'large_structure', + '$permissions' => [Permission::read(Role::any())], + 'meta' => $largeStructure + ])); + $this->assertIsArray($docLarge->getAttribute('meta')); + $this->assertCount(50, $docLarge->getAttribute('meta')); + + // Test 10: Query within large structure + $results = $database->find($collectionId, [ + Query::equal('meta', [['key_25' => ['id' => 25, 'name' => 'Item 25', 'values' => range(1, 10)]]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('large_structure', $results[0]->getId()); + + // Test 11: Test getDocument with large structure + $fetchedLargeDoc = $database->getDocument($collectionId, 'large_structure'); + $this->assertEquals('large_structure', $fetchedLargeDoc->getId()); + $this->assertIsArray($fetchedLargeDoc->getAttribute('meta')); + $this->assertCount(50, $fetchedLargeDoc->getAttribute('meta')); + $this->assertEquals(25, $fetchedLargeDoc->getAttribute('meta')['key_25']['id']); + $this->assertEquals('Item 25', $fetchedLargeDoc->getAttribute('meta')['key_25']['name']); + + // Test 12: Test Query::select with valid document + $results = $database->find($collectionId, [ + Query::select(['$id', 'meta']), + Query::equal('meta', [['name' => 'John']]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('valid1', $results[0]->getId()); + $this->assertIsArray($results[0]->getAttribute('meta')); + $this->assertEquals('John', $results[0]->getAttribute('meta')['name']); + $this->assertEquals(30, $results[0]->getAttribute('meta')['age']); + + // Test 13: Test getDocument returns proper structure + $fetchedValid1 = $database->getDocument($collectionId, 'valid1'); + $this->assertEquals('valid1', $fetchedValid1->getId()); + $this->assertIsArray($fetchedValid1->getAttribute('meta')); + $this->assertEquals('John', $fetchedValid1->getAttribute('meta')['name']); + $this->assertTrue($fetchedValid1->getAttribute('meta')['settings']['notifications']); + $this->assertEquals('dark', $fetchedValid1->getAttribute('meta')['settings']['theme']); + + // Test 14: Test Query::select excluding meta + $results = $database->find($collectionId, [ + Query::select(['$id', '$permissions']), + Query::equal('meta', [['fruits' => ['apple', 'banana', 'orange']]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('valid2', $results[0]->getId()); + // Meta should be empty when not selected + $this->assertEmpty($results[0]->getAttribute('meta')); + + // Test 15: Test getDocument with non-existent ID returns empty document + $nonExistent = $database->getDocument($collectionId, 'does_not_exist'); + $this->assertTrue($nonExistent->isEmpty()); + + // Clean up + $database->deleteCollection($collectionId); + } + + public function testObjectAttributeDefaults(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Skip test if adapter doesn't support JSONB + if (!$database->getAdapter()->getSupportForObject()) { + $this->markTestSkipped('Adapter does not support object attributes'); + return; + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // 1) Default empty object + $this->assertEquals(true, $database->createAttribute($collectionId, 'metaDefaultEmpty', Database::TYPE_OBJECT, 0, false, [])); + + // 2) Default nested object + $defaultSettings = ['config' => ['theme' => 'light', 'lang' => 'en']]; + $this->assertEquals(true, $database->createAttribute($collectionId, 'settings', Database::TYPE_OBJECT, 0, false, $defaultSettings)); + + // 3) Required without default (should fail when missing) + $this->assertEquals(true, $database->createAttribute($collectionId, 'profile', Database::TYPE_OBJECT, 0, true, null)); + + // 4) Required with default (should auto-populate) + $this->assertEquals(true, $database->createAttribute($collectionId, 'profile2', Database::TYPE_OBJECT, 0, false, ['name' => 'anon'])); + + // 5) Explicit null default + $this->assertEquals(true, $database->createAttribute($collectionId, 'misc', Database::TYPE_OBJECT, 0, false, null)); + + // Create document missing all above attributes + $exceptionThrown = false; + try { + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'def1', + '$permissions' => [Permission::read(Role::any())], + ])); + // Should not reach here because 'profile' is required and missing + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(StructureException::class, $e); + } + $this->assertTrue($exceptionThrown, 'Expected Structure exception for missing required object attribute'); + + // Create document providing required 'profile' but omit others to test defaults + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'def2', + '$permissions' => [Permission::read(Role::any())], + 'profile' => ['name' => 'provided'], + ])); + + // metaDefaultEmpty should default to [] + $this->assertIsArray($doc->getAttribute('metaDefaultEmpty')); + $this->assertEmpty($doc->getAttribute('metaDefaultEmpty')); + + // settings should default to nested object + $this->assertIsArray($doc->getAttribute('settings')); + $this->assertEquals('light', $doc->getAttribute('settings')['config']['theme']); + $this->assertEquals('en', $doc->getAttribute('settings')['config']['lang']); + + // profile provided explicitly + $this->assertEquals('provided', $doc->getAttribute('profile')['name']); + + // profile2 required with default should be auto-populated + $this->assertIsArray($doc->getAttribute('profile2')); + $this->assertEquals('anon', $doc->getAttribute('profile2')['name']); + + // misc explicit null default remains null when omitted + $this->assertNull($doc->getAttribute('misc')); + + // Query defaults work + $results = $database->find($collectionId, [ + Query::equal('settings', [['config' => ['theme' => 'light']]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('def2', $results[0]->getId()); + + // Clean up + $database->deleteCollection($collectionId); + } +} diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index a2862830c..0a0e13ce7 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -238,6 +238,93 @@ public function testEmptyAttributes(): void $this->assertEquals('No attributes provided for index', $validator->getDescription()); } + /** + * @throws Exception + */ + public function testGinIndexValidation(): void + { + $collection = new Document([ + '$id' => ID::custom('test'), + 'name' => 'test', + 'attributes' => [ + new Document([ + '$id' => ID::custom('data'), + 'type' => Database::TYPE_OBJECT, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('name'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 255, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ]) + ], + 'indexes' => [] + ]); + + // Validator with objectIndexSupport enabled + $validator = new Index($collection->getAttribute('attributes'), 768, [], false, false, false, false, true); + + // Valid: GIN index on single TYPE_OBJECT attribute + $validIndex = new Document([ + '$id' => ID::custom('idx_gin_valid'), + 'type' => Database::INDEX_GIN, + 'attributes' => ['data'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertTrue($validator->isValid($validIndex)); + + // Invalid: GIN index on non-object attribute + $invalidIndexType = new Document([ + '$id' => ID::custom('idx_gin_invalid_type'), + 'type' => Database::INDEX_GIN, + 'attributes' => ['name'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertFalse($validator->isValid($invalidIndexType)); + $this->assertStringContainsString('GIN index can only be created on object attributes', $validator->getDescription()); + + // Invalid: GIN index on multiple attributes + $invalidIndexMulti = new Document([ + '$id' => ID::custom('idx_gin_multi'), + 'type' => Database::INDEX_GIN, + 'attributes' => ['data', 'name'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertFalse($validator->isValid($invalidIndexMulti)); + $this->assertStringContainsString('GIN index can be created on a single object attribute', $validator->getDescription()); + + // Invalid: GIN index with orders + $invalidIndexOrder = new Document([ + '$id' => ID::custom('idx_gin_order'), + 'type' => Database::INDEX_GIN, + 'attributes' => ['data'], + 'lengths' => [], + 'orders' => ['asc'], + ]); + $this->assertFalse($validator->isValid($invalidIndexOrder)); + $this->assertStringContainsString('GIN indexes do not support explicit orders', $validator->getDescription()); + + // Validator with objectIndexSupport disabled should reject GIN + $validatorNoSupport = new Index($collection->getAttribute('attributes'), 768, [], false, false, false, false, false); + $this->assertFalse($validatorNoSupport->isValid($validIndex)); + $this->assertEquals('GIN indexes are not supported', $validatorNoSupport->getDescription()); + } + /** * @throws Exception */ From 8cd5921ee6deaccc3af5a8d71f0cb87a9d94e1da Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Oct 2025 17:44:43 +0530 Subject: [PATCH 05/20] removed redundant return after skip in tests --- tests/e2e/Adapter/Scopes/ObjectAttributeTests.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index b79a8c175..3ff708184 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -838,7 +838,6 @@ public function testObjectAttributeDefaults(): void // Skip test if adapter doesn't support JSONB if (!$database->getAdapter()->getSupportForObject()) { $this->markTestSkipped('Adapter does not support object attributes'); - return; } $collectionId = ID::unique(); From 117af69a6346d337d71c2c69891341fabfe493ca Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Oct 2025 20:07:25 +0530 Subject: [PATCH 06/20] updated array handling for equal and contains in object --- src/Database/Adapter/Postgres.php | 19 ++++++++++--------- .../Adapter/Scopes/ObjectAttributeTests.php | 18 +++++++++++++++++- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 6206262b4..f4f36d2bc 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1587,13 +1587,8 @@ protected function handleObjectQueries(Query $query, array &$binds, string $attr $conditions = []; foreach ($query->getValues() as $key => $value) { $binds[":{$placeholder}_{$key}"] = json_encode($value); - if (is_array($value)) { - $fragment = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb"; - $conditions[] = $isNot ? "NOT (" . $fragment . ")" : $fragment; - } else { - $fragment = "{$alias}.{$attribute} = :{$placeholder}_{$key}::jsonb"; - $conditions[] = $isNot ? "{$alias}.{$attribute} <> :{$placeholder}_{$key}::jsonb" : $fragment; - } + $fragment = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb"; + $conditions[] = $isNot ? "NOT (" . $fragment . ")" : $fragment; } $separator = $isNot ? ' AND ' : ' OR '; return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; @@ -1607,8 +1602,14 @@ protected function handleObjectQueries(Query $query, array &$binds, string $attr if (count($value) === 1) { $jsonKey = array_key_first($value); $jsonValue = $value[$jsonKey]; - // wrap to represent array; eg: key -> [value] - $value[$jsonKey] = [$jsonValue]; + + // If scalar (e.g. "skills" => "typescript"), + // wrap it to express array containment: {"skills": ["typescript"]} + // If it's already an object/associative array (e.g. "config" => ["lang" => "en"]), + // keep as-is to express object containment. + if (!\is_array($jsonValue)) { + $value[$jsonKey] = [$jsonValue]; + } } $binds[":{$placeholder}_{$key}"] = json_encode($value); $fragment = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb"; diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index 3ff708184..4c661f30c 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -476,7 +476,7 @@ public function testObjectAttribute(): void // Test 32: Contains query with nested array $results = $database->find($collectionId, [ - Query::contains('meta', [['matrix' => [4, 5, 6]]]) + Query::contains('meta', [['matrix' => [[4, 5, 6]]]]) ]); $this->assertCount(1, $results); $this->assertEquals('doc12', $results[0]->getId()); @@ -826,6 +826,22 @@ public function testObjectAttributeInvalidCases(): void $nonExistent = $database->getDocument($collectionId, 'does_not_exist'); $this->assertTrue($nonExistent->isEmpty()); + // Test 16: with multiple json + $defaultSettings = ['config' => ['theme' => 'light', 'lang' => 'en']]; + $this->assertEquals(true, $database->createAttribute($collectionId, 'settings', Database::TYPE_OBJECT, 0, false, $defaultSettings)); + $database->createDocument($collectionId, new Document(['$permissions' => [Permission::read(Role::any())]])); + $database->createDocument($collectionId, new Document(['settings' => ['config' => ['theme' => 'dark', 'lang' => 'en']],'$permissions' => [Permission::read(Role::any())]])); + $results = $database->find($collectionId, [ + Query::equal('settings', [['config' => ['theme' => 'light']],['config' => ['theme' => 'dark']]]) + ]); + $this->assertCount(2, $results); + + $results = $database->find($collectionId, [ + // Containment: both documents have config.lang == 'en' + Query::contains('settings', [['config' => ['lang' => 'en']]]) + ]); + $this->assertCount(2, $results); + // Clean up $database->deleteCollection($collectionId); } From 979619f242cce9a393b1870b38e3009ea15630ec Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Oct 2025 20:33:25 +0530 Subject: [PATCH 07/20] fixed gin index issue --- 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 25c8caa50..eac52cdcb 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -893,7 +893,7 @@ public function createIndex(string $collection, string $id, string $type, array $key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\""; $attributes = \implode(', ', $attributes); - if ($this->sharedTables && \in_array($type, [Database::INDEX_KEY, Database::INDEX_UNIQUE, Database::INDEX_GIN])) { + if ($this->sharedTables && \in_array($type, [Database::INDEX_KEY, Database::INDEX_UNIQUE])) { // Add tenant as first index column for best performance $attributes = "_tenant, {$attributes}"; } From 6ba8558f60bb38953d83e18cc546b473473a1864 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Oct 2025 20:36:44 +0530 Subject: [PATCH 08/20] updated validating default types --- src/Database/Database.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 681c878c0..6cb266bcb 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -2149,13 +2149,6 @@ protected function validateDefaultTypes(string $type, mixed $default): void throw new DatabaseException('Default value ' . $default . ' does not match given type ' . $type); } break; - case self::TYPE_OBJECT: - // Object types expect arrays as default values - var_dump($defaultType); - if ($defaultType !== 'array') { - throw new DatabaseException('Default value for object type must be an array'); - } - // no break case self::VAR_VECTOR: // When validating individual vector components (from recursion), they should be numeric if ($defaultType !== 'double' && $defaultType !== 'integer') { From 739a4c3a521b83af9469c28ffea46411bf7f8f7e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Oct 2025 13:05:48 +0530 Subject: [PATCH 09/20] * added support method in the mongodb adapter * updated index unit tests --- src/Database/Adapter/Mongo.php | 5 +++++ tests/unit/Validator/IndexTest.php | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 82afb0eaa..8e4d2d5ef 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2788,6 +2788,11 @@ public function getSupportForBatchCreateAttributes(): bool return true; } + public function getSupportForObject(): bool + { + return false; + } + /** * Get current attribute count from collection document * diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index 30732bb4d..68e9f00b7 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -274,7 +274,7 @@ public function testGinIndexValidation(): void ]); // Validator with objectIndexSupport enabled - $validator = new Index($collection->getAttribute('attributes'), 768, [], false, false, false, false, true); + $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, true); // Valid: GIN index on single TYPE_OBJECT attribute $validIndex = new Document([ @@ -320,7 +320,7 @@ public function testGinIndexValidation(): void $this->assertStringContainsString('GIN indexes do not support explicit orders', $validator->getDescription()); // Validator with objectIndexSupport disabled should reject GIN - $validatorNoSupport = new Index($collection->getAttribute('attributes'), 768, [], false, false, false, false, false); + $validatorNoSupport = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, false); $this->assertFalse($validatorNoSupport->isValid($validIndex)); $this->assertEquals('GIN indexes are not supported', $validatorNoSupport->getDescription()); } From 2cb3d983390a79e668d7c392d953a78f60485bf6 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Oct 2025 13:50:57 +0530 Subject: [PATCH 10/20] renamed gin to object index to have a general term --- src/Database/Adapter/Postgres.php | 6 ++-- src/Database/Database.php | 17 +++++----- src/Database/Validator/Index.php | 21 ++++++------ .../Adapter/Scopes/ObjectAttributeTests.php | 32 +++++++++---------- tests/unit/Validator/IndexTest.php | 30 ++++++++--------- 5 files changed, 55 insertions(+), 51 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index eac52cdcb..a84903ee3 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -886,8 +886,8 @@ public function createIndex(string $collection, string $id, string $type, array Database::INDEX_HNSW_COSINE, Database::INDEX_HNSW_DOT => 'INDEX', Database::INDEX_UNIQUE => 'UNIQUE INDEX', - Database::INDEX_GIN => 'INDEX', - 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_GIN . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT), + Database::Index_Object => 'INDEX', + 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}\""; @@ -906,7 +906,7 @@ public function createIndex(string $collection, string $id, string $type, array Database::INDEX_HNSW_EUCLIDEAN => " USING HNSW ({$attributes} vector_l2_ops)", Database::INDEX_HNSW_COSINE => " USING HNSW ({$attributes} vector_cosine_ops)", Database::INDEX_HNSW_DOT => " USING HNSW ({$attributes} vector_ip_ops)", - Database::INDEX_GIN => " USING GIN ({$attributes})", + Database::Index_Object => " USING GIN ({$attributes})", default => " ({$attributes})", }; diff --git a/src/Database/Database.php b/src/Database/Database.php index 713327e54..50913dda4 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -73,7 +73,8 @@ class Database public const INDEX_FULLTEXT = 'fulltext'; public const INDEX_UNIQUE = 'unique'; public const INDEX_SPATIAL = 'spatial'; - public const INDEX_GIN = 'gin'; + // keeping + public const Index_Object = 'object'; public const INDEX_HNSW_EUCLIDEAN = 'hnsw_euclidean'; public const INDEX_HNSW_COSINE = 'hnsw_cosine'; public const INDEX_HNSW_DOT = 'hnsw_dot'; @@ -3489,14 +3490,14 @@ public function createIndex(string $collection, string $id, string $type, array } break; - case self::INDEX_GIN: + case self::Index_Object: if (!$this->adapter->getSupportForObject()) { - throw new DatabaseException('GIN indexes are not supported'); + throw new DatabaseException('Object indexes are not supported'); } break; 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_GIN . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT); + 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); } /** @var array $collectionAttributes */ @@ -3528,9 +3529,9 @@ public function createIndex(string $collection, string $id, string $type, array } } - if ($type === self::INDEX_GIN) { + if ($type === self::Index_Object) { if (count($attributes) !== 1) { - throw new IndexException('GIN index can be created on a single object attribute'); + throw new IndexException('Object index can be created on a single object attribute'); } foreach ($attributes as $attr) { @@ -3540,12 +3541,12 @@ public function createIndex(string $collection, string $id, string $type, array $attributeType = $indexAttributesWithTypes[$attr]; if ($attributeType !== self::TYPE_OBJECT) { - throw new IndexException('GIN index can only be created on object attributes. Attribute "' . $attr . '" is of type "' . $attributeType . '"'); + throw new IndexException('Object index can only be created on object attributes. Attribute "' . $attr . '" is of type "' . $attributeType . '"'); } } if (!empty($orders)) { - throw new IndexException('GIN indexes do not support explicit orders. Remove the orders to create this index.'); + throw new IndexException('Object indexes do not support explicit orders. Remove the orders to create this index.'); } } diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index cb8bd5193..4c923ad5e 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -28,7 +28,7 @@ class Index extends Validator * @param bool $supportForAttributes * @param bool $supportForMultipleFulltextIndexes * @param bool $supportForIdenticalIndexes - * @param bool $objectIndexSupport + * @param bool $supportForObjectIndexes * @throws DatabaseException */ public function __construct( @@ -43,7 +43,7 @@ public function __construct( protected bool $supportForAttributes = true, protected bool $supportForMultipleFulltextIndexes = true, protected bool $supportForIdenticalIndexes = true, - protected bool $objectIndexSupport = false + protected bool $supportForObjectIndexes = false ) { foreach ($attributes as $attribute) { $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); @@ -134,6 +134,9 @@ public function isValid($value): bool if (!$this->checkIdenticalIndexes($value)) { return false; } + if (!$this->checkObjectIndexes($value)) { + return false; + } return true; } @@ -536,29 +539,29 @@ public function checkIdenticalIndexes(Document $index): bool * @param Document $index * @return bool */ - public function checkGinIndex(Document $index): bool + public function checkObjectIndexes(Document $index): bool { $type = $index->getAttribute('type'); $attributes = $index->getAttribute('attributes', []); $orders = $index->getAttribute('orders', []); - if ($type !== Database::INDEX_GIN) { + if ($type !== Database::Index_Object) { return true; } - if (!$this->objectIndexSupport) { - $this->message = 'GIN indexes are not supported'; + if (!$this->supportForObjectIndexes) { + $this->message = 'Object indexes are not supported'; return false; } if (count($attributes) !== 1) { - $this->message = 'GIN index can be created on a single object attribute'; + $this->message = 'Object index can be created on a single object attribute'; return false; } if (!empty($orders)) { - $this->message = 'GIN indexes do not support explicit orders. Remove the orders to create this index.'; + $this->message = 'Object index do not support explicit orders. Remove the orders to create this index.'; return false; } @@ -567,7 +570,7 @@ public function checkGinIndex(Document $index): bool $attributeType = $attribute->getAttribute('type', ''); if ($attributeType !== Database::TYPE_OBJECT) { - $this->message = 'GIN index can only be created on object attributes. Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; + $this->message = 'Object index can only be created on object attributes. Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; return false; } diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index 4c661f30c..583b92b32 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -555,8 +555,8 @@ public function testObjectAttributeGinIndex(): void // Create object attribute $this->assertEquals(true, $database->createAttribute($collectionId, 'data', Database::TYPE_OBJECT, 0, false)); - // Test 1: Create GIN index on object attribute - $ginIndex = $database->createIndex($collectionId, 'idx_data_gin', Database::INDEX_GIN, ['data']); + // Test 1: Create Object index on object attribute + $ginIndex = $database->createIndex($collectionId, 'idx_data_gin', Database::Index_Object, ['data']); $this->assertTrue($ginIndex); // Test 2: Create documents with JSONB data @@ -590,7 +590,7 @@ public function testObjectAttributeGinIndex(): void $results = $database->find($collectionId, [ Query::equal('data', [['env' => 'production']]) ]); - $this->assertCount(0, $results); // Note: GIN index doesn't make equal queries work differently + $this->assertCount(0, $results); // Note: Object index doesn't make equal queries work differently // Test 4: Query with contains on indexed JSONB column $results = $database->find($collectionId, [ @@ -599,49 +599,49 @@ public function testObjectAttributeGinIndex(): void $this->assertCount(1, $results); $this->assertEquals('gin1', $results[0]->getId()); - // Test 5: Verify GIN index improves performance for containment queries + // Test 5: Verify Object index improves performance for containment queries $results = $database->find($collectionId, [ Query::contains('data', [['tags' => 'kotlin']]) ]); $this->assertCount(1, $results); $this->assertEquals('gin2', $results[0]->getId()); - // Test 6: Try to create GIN index on non-object attribute (should fail) + // Test 6: Try to create Object index on non-object attribute (should fail) $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 255, false); $exceptionThrown = false; try { - $database->createIndex($collectionId, 'idx_name_gin', Database::INDEX_GIN, ['name']); + $database->createIndex($collectionId, 'idx_name_gin', Database::Index_Object, ['name']); } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); - $this->assertStringContainsString('GIN index can only be created on object attributes', $e->getMessage()); + $this->assertStringContainsString('Object index can only be created on object attributes', $e->getMessage()); } - $this->assertTrue($exceptionThrown, 'Expected Index exception for GIN index on non-object attribute'); + $this->assertTrue($exceptionThrown, 'Expected Index exception for Object index on non-object attribute'); - // Test 7: Try to create GIN index on multiple attributes (should fail) + // Test 7: Try to create Object index on multiple attributes (should fail) $database->createAttribute($collectionId, 'metadata', Database::TYPE_OBJECT, 0, false); $exceptionThrown = false; try { - $database->createIndex($collectionId, 'idx_multi_gin', Database::INDEX_GIN, ['data', 'metadata']); + $database->createIndex($collectionId, 'idx_multi_gin', Database::Index_Object, ['data', 'metadata']); } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); - $this->assertStringContainsString('GIN index can be created on a single object attribute', $e->getMessage()); + $this->assertStringContainsString('Object index can be created on a single object attribute', $e->getMessage()); } - $this->assertTrue($exceptionThrown, 'Expected Index exception for GIN index on multiple attributes'); + $this->assertTrue($exceptionThrown, 'Expected Index exception for Object index on multiple attributes'); - // Test 8: Try to create GIN index with orders (should fail) + // Test 8: Try to create Object index with orders (should fail) $exceptionThrown = false; try { - $database->createIndex($collectionId, 'idx_ordered_gin', Database::INDEX_GIN, ['metadata'], [], [Database::ORDER_ASC]); + $database->createIndex($collectionId, 'idx_ordered_gin', Database::Index_Object, ['metadata'], [], [Database::ORDER_ASC]); } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); - $this->assertStringContainsString('GIN indexes do not support explicit orders', $e->getMessage()); + $this->assertStringContainsString('Object indexes do not support explicit orders', $e->getMessage()); } - $this->assertTrue($exceptionThrown, 'Expected Index exception for GIN index with orders'); + $this->assertTrue($exceptionThrown, 'Expected Index exception for Object index with orders'); // Clean up $database->deleteCollection($collectionId); diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index 68e9f00b7..9c43af793 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -273,56 +273,56 @@ public function testGinIndexValidation(): void 'indexes' => [] ]); - // Validator with objectIndexSupport enabled - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, true); + // Validator with supportForObjectIndexes enabled + $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, supportForObjectIndexes:true); - // Valid: GIN index on single TYPE_OBJECT attribute + // Valid: Object index on single TYPE_OBJECT attribute $validIndex = new Document([ '$id' => ID::custom('idx_gin_valid'), - 'type' => Database::INDEX_GIN, + 'type' => Database::Index_Object, 'attributes' => ['data'], 'lengths' => [], 'orders' => [], ]); $this->assertTrue($validator->isValid($validIndex)); - // Invalid: GIN index on non-object attribute + // Invalid: Object index on non-object attribute $invalidIndexType = new Document([ '$id' => ID::custom('idx_gin_invalid_type'), - 'type' => Database::INDEX_GIN, + 'type' => Database::Index_Object, 'attributes' => ['name'], 'lengths' => [], 'orders' => [], ]); $this->assertFalse($validator->isValid($invalidIndexType)); - $this->assertStringContainsString('GIN index can only be created on object attributes', $validator->getDescription()); + $this->assertStringContainsString('Object index can only be created on object attributes', $validator->getDescription()); - // Invalid: GIN index on multiple attributes + // Invalid: Object index on multiple attributes $invalidIndexMulti = new Document([ '$id' => ID::custom('idx_gin_multi'), - 'type' => Database::INDEX_GIN, + 'type' => Database::Index_Object, 'attributes' => ['data', 'name'], 'lengths' => [], 'orders' => [], ]); $this->assertFalse($validator->isValid($invalidIndexMulti)); - $this->assertStringContainsString('GIN index can be created on a single object attribute', $validator->getDescription()); + $this->assertStringContainsString('Object index can be created on a single object attribute', $validator->getDescription()); - // Invalid: GIN index with orders + // Invalid: Object index with orders $invalidIndexOrder = new Document([ '$id' => ID::custom('idx_gin_order'), - 'type' => Database::INDEX_GIN, + 'type' => Database::Index_Object, 'attributes' => ['data'], 'lengths' => [], 'orders' => ['asc'], ]); $this->assertFalse($validator->isValid($invalidIndexOrder)); - $this->assertStringContainsString('GIN indexes do not support explicit orders', $validator->getDescription()); + $this->assertStringContainsString('Object index do not support explicit orders', $validator->getDescription()); - // Validator with objectIndexSupport disabled should reject GIN + // Validator with supportForObjectIndexes disabled should reject GIN $validatorNoSupport = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, false); $this->assertFalse($validatorNoSupport->isValid($validIndex)); - $this->assertEquals('GIN indexes are not supported', $validatorNoSupport->getDescription()); + $this->assertEquals('Object indexes are not supported', $validatorNoSupport->getDescription()); } /** From e2768d9d35e0e31a12e4e63d48f6806651de0225 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Oct 2025 15:36:02 +0530 Subject: [PATCH 11/20] updated lock file --- composer.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.lock b/composer.lock index 1cd7dbf08..8ae3ed589 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ab8904fcdff6010338fe9e4d22c977bf", + "content-hash": "551ac82cdacc10d8a41f4ce450865610", "packages": [ { "name": "brick/math", From f5c0cfdc6d8993613ae03d22ebcd3b899fda63aa Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 31 Oct 2025 11:41:11 +0530 Subject: [PATCH 12/20] Refactor object type constants to use VAR_OBJECT for consistency across the database adapter and validator classes --- src/Database/Adapter/Postgres.php | 10 +++---- src/Database/Adapter/SQL.php | 2 +- src/Database/Database.php | 22 +++++++-------- src/Database/Query.php | 2 +- src/Database/Validator/Index.php | 6 ++-- src/Database/Validator/IndexDependency.php | 2 +- src/Database/Validator/Queries.php | 2 +- src/Database/Validator/Query/Base.php | 2 +- src/Database/Validator/Query/Filter.php | 4 +-- src/Database/Validator/Structure.php | 2 +- .../Adapter/Scopes/ObjectAttributeTests.php | 28 +++++++++---------- tests/unit/Validator/IndexTest.php | 12 ++++---- 12 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index a84903ee3..21779f93b 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -886,8 +886,8 @@ public function createIndex(string $collection, string $id, string $type, array Database::INDEX_HNSW_COSINE, Database::INDEX_HNSW_DOT => 'INDEX', Database::INDEX_UNIQUE => 'UNIQUE INDEX', - Database::Index_Object => 'INDEX', - 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), + Database::INDEX_OBJECT => 'INDEX', + 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}\""; @@ -906,7 +906,7 @@ public function createIndex(string $collection, string $id, string $type, array Database::INDEX_HNSW_EUCLIDEAN => " USING HNSW ({$attributes} vector_l2_ops)", Database::INDEX_HNSW_COSINE => " USING HNSW ({$attributes} vector_cosine_ops)", Database::INDEX_HNSW_DOT => " USING HNSW ({$attributes} vector_ip_ops)", - Database::Index_Object => " USING GIN ({$attributes})", + Database::INDEX_OBJECT => " USING GIN ({$attributes})", default => " ({$attributes})", }; @@ -1855,7 +1855,7 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool case Database::VAR_DATETIME: return 'TIMESTAMP(3)'; - case Database::TYPE_OBJECT: + case Database::VAR_OBJECT: return 'JSONB'; case Database::VAR_POINT: @@ -1871,7 +1871,7 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool return "VECTOR({$size})"; default: - throw new DatabaseException('Unknown Type: ' . $type . '. Must be one of ' . Database::VAR_STRING . ', ' . Database::VAR_INTEGER . ', ' . Database::VAR_FLOAT . ', ' . Database::VAR_BOOLEAN . ', ' . Database::VAR_DATETIME . ', ' . Database::VAR_RELATIONSHIP . ', ' . Database::TYPE_OBJECT . ', ' . Database::VAR_POINT . ', ' . Database::VAR_LINESTRING . ', ' . Database::VAR_POLYGON); + throw new DatabaseException('Unknown Type: ' . $type . '. Must be one of ' . Database::VAR_STRING . ', ' . Database::VAR_INTEGER . ', ' . Database::VAR_FLOAT . ', ' . Database::VAR_BOOLEAN . ', ' . Database::VAR_DATETIME . ', ' . Database::VAR_RELATIONSHIP . ', ' . Database::VAR_OBJECT . ', ' . Database::VAR_POINT . ', ' . Database::VAR_LINESTRING . ', ' . Database::VAR_POLYGON); } } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 51cdf38ac..556f1da1d 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1126,7 +1126,7 @@ public function getAttributeWidth(Document $collection): int $total += 7; break; - case Database::TYPE_OBJECT: + case Database::VAR_OBJECT: /** * JSONB/JSON type * Only the pointer contributes 20 bytes to the row size diff --git a/src/Database/Database.php b/src/Database/Database.php index 50913dda4..9999db0a7 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -48,7 +48,7 @@ class Database public const VAR_UUID7 = 'uuid7'; // object type - public const TYPE_OBJECT = 'object'; + public const VAR_OBJECT = 'object'; // Vector types public const VAR_VECTOR = 'vector'; @@ -74,7 +74,7 @@ class Database public const INDEX_UNIQUE = 'unique'; public const INDEX_SPATIAL = 'spatial'; // keeping - public const Index_Object = 'object'; + public const INDEX_OBJECT = 'object'; public const INDEX_HNSW_EUCLIDEAN = 'hnsw_euclidean'; public const INDEX_HNSW_COSINE = 'hnsw_cosine'; public const INDEX_HNSW_DOT = 'hnsw_dot'; @@ -2016,7 +2016,7 @@ private function validateAttribute( case self::VAR_DATETIME: case self::VAR_RELATIONSHIP: break; - case self::TYPE_OBJECT: + case self::VAR_OBJECT: if (!$this->adapter->getSupportForObject()) { throw new DatabaseException('Object attributes are not supported'); } @@ -2135,7 +2135,7 @@ protected function validateDefaultTypes(string $type, mixed $default): void if ($defaultType === 'array') { // Spatial types require the array itself - if (!in_array($type, Database::SPATIAL_TYPES) && $type != Database::TYPE_OBJECT) { + if (!in_array($type, Database::SPATIAL_TYPES) && $type != Database::VAR_OBJECT) { foreach ($default as $value) { $this->validateDefaultTypes($type, $value); } @@ -2432,7 +2432,7 @@ public function updateAttribute(string $collection, string $id, ?string $type = } break; - case self::TYPE_OBJECT: + case self::VAR_OBJECT: if (!$this->adapter->getSupportForObject()) { throw new DatabaseException('Object attributes are not supported'); } @@ -2491,7 +2491,7 @@ public function updateAttribute(string $collection, string $id, ?string $type = self::VAR_FLOAT, self::VAR_BOOLEAN, self::VAR_DATETIME, - self::VAR_RELATIONSHIP . ', ' . self::TYPE_OBJECT + self::VAR_RELATIONSHIP . ', ' . self::VAR_OBJECT ]; if ($this->adapter->getSupportForVectors()) { $supportedTypes[] = self::VAR_VECTOR; @@ -3490,14 +3490,14 @@ public function createIndex(string $collection, string $id, string $type, array } break; - case self::Index_Object: + case self::INDEX_OBJECT: if (!$this->adapter->getSupportForObject()) { throw new DatabaseException('Object indexes are not supported'); } break; 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); + 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); } /** @var array $collectionAttributes */ @@ -3529,7 +3529,7 @@ public function createIndex(string $collection, string $id, string $type, array } } - if ($type === self::Index_Object) { + if ($type === self::INDEX_OBJECT) { if (count($attributes) !== 1) { throw new IndexException('Object index can be created on a single object attribute'); } @@ -3540,7 +3540,7 @@ public function createIndex(string $collection, string $id, string $type, array } $attributeType = $indexAttributesWithTypes[$attr]; - if ($attributeType !== self::TYPE_OBJECT) { + if ($attributeType !== self::VAR_OBJECT) { throw new IndexException('Object index can only be created on object attributes. Attribute "' . $attr . '" is of type "' . $attributeType . '"'); } } @@ -7561,7 +7561,7 @@ public function casting(Document $collection, Document $document): Document case self::VAR_FLOAT: $node = (float)$node; break; - case self::TYPE_OBJECT: + case self::VAR_OBJECT: // Decode JSONB string to array if (is_string($node)) { $node = json_decode($node, true); diff --git a/src/Database/Query.php b/src/Database/Query.php index a6ba0b8bc..8656f5d96 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -982,7 +982,7 @@ public function isSpatialAttribute(): bool */ public function isObjectAttribute(): bool { - return $this->attributeType === Database::TYPE_OBJECT; + return $this->attributeType === Database::VAR_OBJECT; } // Spatial query methods diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index 4c923ad5e..422c07464 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -64,7 +64,7 @@ public function __construct( */ public function getType(): string { - return self::TYPE_OBJECT; + return self::VAR_OBJECT; } /** @@ -546,7 +546,7 @@ public function checkObjectIndexes(Document $index): bool $attributes = $index->getAttribute('attributes', []); $orders = $index->getAttribute('orders', []); - if ($type !== Database::Index_Object) { + if ($type !== Database::INDEX_OBJECT) { return true; } @@ -569,7 +569,7 @@ public function checkObjectIndexes(Document $index): bool $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); $attributeType = $attribute->getAttribute('type', ''); - if ($attributeType !== Database::TYPE_OBJECT) { + if ($attributeType !== Database::VAR_OBJECT) { $this->message = 'Object index can only be created on object attributes. Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; return false; } diff --git a/src/Database/Validator/IndexDependency.php b/src/Database/Validator/IndexDependency.php index 7e8453b83..408a21b66 100644 --- a/src/Database/Validator/IndexDependency.php +++ b/src/Database/Validator/IndexDependency.php @@ -80,6 +80,6 @@ public function isArray(): bool */ public function getType(): string { - return self::TYPE_OBJECT; + return self::VAR_OBJECT; } } diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 8066228e3..5d73f8a11 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -168,6 +168,6 @@ public function isArray(): bool */ public function getType(): string { - return self::TYPE_OBJECT; + return self::VAR_OBJECT; } } diff --git a/src/Database/Validator/Query/Base.php b/src/Database/Validator/Query/Base.php index a37fdd65a..8e3cf0aca 100644 --- a/src/Database/Validator/Query/Base.php +++ b/src/Database/Validator/Query/Base.php @@ -48,7 +48,7 @@ public function isArray(): bool */ public function getType(): string { - return self::TYPE_OBJECT; + return self::VAR_OBJECT; } /** diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index edf4a7434..177693328 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -162,7 +162,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s $validator = new Text(255, 0); // The query is always on uid break; - case Database::TYPE_OBJECT: + case Database::VAR_OBJECT: // For JSONB/object queries, value must be an array if (!is_array($value)) { $this->message = 'Query value for object type must be an array'; @@ -244,7 +244,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s !$array && in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS]) && $attributeSchema['type'] !== Database::VAR_STRING && - $attributeSchema['type'] !== Database::TYPE_OBJECT && + $attributeSchema['type'] !== Database::VAR_OBJECT && !in_array($attributeSchema['type'], Database::SPATIAL_TYPES) ) { $queryType = $method === Query::TYPE_NOT_CONTAINS ? 'notContains' : 'contains'; diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index 0fec03559..f9edf1700 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -358,7 +358,7 @@ protected function checkForInvalidAttributeValues(array $structure, array $keys) ); break; - case Database::TYPE_OBJECT: + case Database::VAR_OBJECT: // For JSONB/object types, just validate it's an array (associative or list) if (!is_array($value)) { $this->message = 'Attribute "'.$key.'" has invalid type. Value must be an array for object type'; diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index 583b92b32..50bc4bd92 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -27,7 +27,7 @@ public function testObjectAttribute(): void $database->createCollection($collectionId); // Create object attribute - $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::TYPE_OBJECT, 0, false)); + $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::VAR_OBJECT, 0, false)); // Test 1: Create and read document with object attribute $doc1 = $database->createDocument($collectionId, new Document([ @@ -553,10 +553,10 @@ public function testObjectAttributeGinIndex(): void $database->createCollection($collectionId); // Create object attribute - $this->assertEquals(true, $database->createAttribute($collectionId, 'data', Database::TYPE_OBJECT, 0, false)); + $this->assertEquals(true, $database->createAttribute($collectionId, 'data', Database::VAR_OBJECT, 0, false)); // Test 1: Create Object index on object attribute - $ginIndex = $database->createIndex($collectionId, 'idx_data_gin', Database::Index_Object, ['data']); + $ginIndex = $database->createIndex($collectionId, 'idx_data_gin', Database::INDEX_OBJECT, ['data']); $this->assertTrue($ginIndex); // Test 2: Create documents with JSONB data @@ -611,7 +611,7 @@ public function testObjectAttributeGinIndex(): void $exceptionThrown = false; try { - $database->createIndex($collectionId, 'idx_name_gin', Database::Index_Object, ['name']); + $database->createIndex($collectionId, 'idx_name_gin', Database::INDEX_OBJECT, ['name']); } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); @@ -620,11 +620,11 @@ public function testObjectAttributeGinIndex(): void $this->assertTrue($exceptionThrown, 'Expected Index exception for Object index on non-object attribute'); // Test 7: Try to create Object index on multiple attributes (should fail) - $database->createAttribute($collectionId, 'metadata', Database::TYPE_OBJECT, 0, false); + $database->createAttribute($collectionId, 'metadata', Database::VAR_OBJECT, 0, false); $exceptionThrown = false; try { - $database->createIndex($collectionId, 'idx_multi_gin', Database::Index_Object, ['data', 'metadata']); + $database->createIndex($collectionId, 'idx_multi_gin', Database::INDEX_OBJECT, ['data', 'metadata']); } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); @@ -635,7 +635,7 @@ public function testObjectAttributeGinIndex(): void // Test 8: Try to create Object index with orders (should fail) $exceptionThrown = false; try { - $database->createIndex($collectionId, 'idx_ordered_gin', Database::Index_Object, ['metadata'], [], [Database::ORDER_ASC]); + $database->createIndex($collectionId, 'idx_ordered_gin', Database::INDEX_OBJECT, ['metadata'], [], [Database::ORDER_ASC]); } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); @@ -661,7 +661,7 @@ public function testObjectAttributeInvalidCases(): void $database->createCollection($collectionId); // Create object attribute - $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::TYPE_OBJECT, 0, false)); + $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::VAR_OBJECT, 0, false)); // Test 1: Try to create document with string instead of object (should fail) $exceptionThrown = false; @@ -828,7 +828,7 @@ public function testObjectAttributeInvalidCases(): void // Test 16: with multiple json $defaultSettings = ['config' => ['theme' => 'light', 'lang' => 'en']]; - $this->assertEquals(true, $database->createAttribute($collectionId, 'settings', Database::TYPE_OBJECT, 0, false, $defaultSettings)); + $this->assertEquals(true, $database->createAttribute($collectionId, 'settings', Database::VAR_OBJECT, 0, false, $defaultSettings)); $database->createDocument($collectionId, new Document(['$permissions' => [Permission::read(Role::any())]])); $database->createDocument($collectionId, new Document(['settings' => ['config' => ['theme' => 'dark', 'lang' => 'en']],'$permissions' => [Permission::read(Role::any())]])); $results = $database->find($collectionId, [ @@ -860,20 +860,20 @@ public function testObjectAttributeDefaults(): void $database->createCollection($collectionId); // 1) Default empty object - $this->assertEquals(true, $database->createAttribute($collectionId, 'metaDefaultEmpty', Database::TYPE_OBJECT, 0, false, [])); + $this->assertEquals(true, $database->createAttribute($collectionId, 'metaDefaultEmpty', Database::VAR_OBJECT, 0, false, [])); // 2) Default nested object $defaultSettings = ['config' => ['theme' => 'light', 'lang' => 'en']]; - $this->assertEquals(true, $database->createAttribute($collectionId, 'settings', Database::TYPE_OBJECT, 0, false, $defaultSettings)); + $this->assertEquals(true, $database->createAttribute($collectionId, 'settings', Database::VAR_OBJECT, 0, false, $defaultSettings)); // 3) Required without default (should fail when missing) - $this->assertEquals(true, $database->createAttribute($collectionId, 'profile', Database::TYPE_OBJECT, 0, true, null)); + $this->assertEquals(true, $database->createAttribute($collectionId, 'profile', Database::VAR_OBJECT, 0, true, null)); // 4) Required with default (should auto-populate) - $this->assertEquals(true, $database->createAttribute($collectionId, 'profile2', Database::TYPE_OBJECT, 0, false, ['name' => 'anon'])); + $this->assertEquals(true, $database->createAttribute($collectionId, 'profile2', Database::VAR_OBJECT, 0, false, ['name' => 'anon'])); // 5) Explicit null default - $this->assertEquals(true, $database->createAttribute($collectionId, 'misc', Database::TYPE_OBJECT, 0, false, null)); + $this->assertEquals(true, $database->createAttribute($collectionId, 'misc', Database::VAR_OBJECT, 0, false, null)); // Create document missing all above attributes $exceptionThrown = false; diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index 9c43af793..1ab36f0a4 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -249,7 +249,7 @@ public function testGinIndexValidation(): void 'attributes' => [ new Document([ '$id' => ID::custom('data'), - 'type' => Database::TYPE_OBJECT, + 'type' => Database::VAR_OBJECT, 'format' => '', 'size' => 0, 'signed' => false, @@ -276,10 +276,10 @@ public function testGinIndexValidation(): void // Validator with supportForObjectIndexes enabled $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, supportForObjectIndexes:true); - // Valid: Object index on single TYPE_OBJECT attribute + // Valid: Object index on single VAR_OBJECT attribute $validIndex = new Document([ '$id' => ID::custom('idx_gin_valid'), - 'type' => Database::Index_Object, + 'type' => Database::INDEX_OBJECT, 'attributes' => ['data'], 'lengths' => [], 'orders' => [], @@ -289,7 +289,7 @@ public function testGinIndexValidation(): void // Invalid: Object index on non-object attribute $invalidIndexType = new Document([ '$id' => ID::custom('idx_gin_invalid_type'), - 'type' => Database::Index_Object, + 'type' => Database::INDEX_OBJECT, 'attributes' => ['name'], 'lengths' => [], 'orders' => [], @@ -300,7 +300,7 @@ public function testGinIndexValidation(): void // Invalid: Object index on multiple attributes $invalidIndexMulti = new Document([ '$id' => ID::custom('idx_gin_multi'), - 'type' => Database::Index_Object, + 'type' => Database::INDEX_OBJECT, 'attributes' => ['data', 'name'], 'lengths' => [], 'orders' => [], @@ -311,7 +311,7 @@ public function testGinIndexValidation(): void // Invalid: Object index with orders $invalidIndexOrder = new Document([ '$id' => ID::custom('idx_gin_order'), - 'type' => Database::Index_Object, + 'type' => Database::INDEX_OBJECT, 'attributes' => ['data'], 'lengths' => [], 'orders' => ['asc'], From 9a96110abbec4bd841f3eacc473471a37bebf9be Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 3 Nov 2025 10:42:58 +0530 Subject: [PATCH 13/20] added object validator test --- src/Database/Validator/Index.php | 2 +- src/Database/Validator/IndexDependency.php | 2 +- src/Database/Validator/ObjectValidator.php | 42 +++++++++++++ src/Database/Validator/Queries.php | 2 +- src/Database/Validator/Query/Base.php | 2 +- src/Database/Validator/Structure.php | 9 +-- tests/unit/Validator/ObjectTest.php | 70 ++++++++++++++++++++++ 7 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 src/Database/Validator/ObjectValidator.php create mode 100644 tests/unit/Validator/ObjectTest.php diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index 422c07464..33648feeb 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -64,7 +64,7 @@ public function __construct( */ public function getType(): string { - return self::VAR_OBJECT; + return self::TYPE_OBJECT; } /** diff --git a/src/Database/Validator/IndexDependency.php b/src/Database/Validator/IndexDependency.php index 408a21b66..7e8453b83 100644 --- a/src/Database/Validator/IndexDependency.php +++ b/src/Database/Validator/IndexDependency.php @@ -80,6 +80,6 @@ public function isArray(): bool */ public function getType(): string { - return self::VAR_OBJECT; + return self::TYPE_OBJECT; } } diff --git a/src/Database/Validator/ObjectValidator.php b/src/Database/Validator/ObjectValidator.php new file mode 100644 index 000000000..6788b722b --- /dev/null +++ b/src/Database/Validator/ObjectValidator.php @@ -0,0 +1,42 @@ +message = 'Attribute "'.$key.'" has invalid type. Value must be an array for object type'; - return false; - } - // No additional validators needed - JSONB accepts any valid array structure - continue 2; // Skip to next attribute + $validators[] = new ObjectValidator(); + break; case Database::VAR_POINT: case Database::VAR_LINESTRING: diff --git a/tests/unit/Validator/ObjectTest.php b/tests/unit/Validator/ObjectTest.php new file mode 100644 index 000000000..3cf50b026 --- /dev/null +++ b/tests/unit/Validator/ObjectTest.php @@ -0,0 +1,70 @@ +assertTrue($validator->isValid(['key' => 'value'])); + $this->assertTrue($validator->isValid([ + 'a' => [ + 'b' => [ + 'c' => 123 + ] + ] + ])); + + $this->assertTrue($validator->isValid([ + 'author' => 'Arnab', + 'metadata' => [ + 'rating' => 4.5, + 'info' => [ + 'category' => 'science' + ] + ] + ])); + + $this->assertTrue($validator->isValid([ + 'key1' => null, + 'key2' => ['nested' => null] + ])); + + $this->assertTrue($validator->isValid([ + 'meta' => (object)['x' => 1] + ])); + + $this->assertTrue($validator->isValid([ + 'a' => 1, + 2 => 'b' + ])); + + } + + public function testInvalidStructures(): void + { + $validator = new ObjectValidator(); + + $this->assertFalse($validator->isValid(['a', 'b', 'c'])); + + $this->assertFalse($validator->isValid('not an array')); + + $this->assertFalse($validator->isValid([ + 0 => 'value' + ])); + } + + public function testEmptyCases(): void + { + $validator = new ObjectValidator(); + + $this->assertTrue($validator->isValid([])); + + $this->assertFalse($validator->isValid('sldfjsdlfj')); + } +} From 8649acad73e89d5430358663f181b7534d369754 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 6 Nov 2025 14:07:31 +0530 Subject: [PATCH 14/20] update var_object to be a filter similar to other types --- src/Database/Database.php | 54 +++++++++++++++++----- src/Database/Validator/ObjectValidator.php | 9 +++- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 8a0fcd914..f1f59b52a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -68,6 +68,13 @@ class Database self::VAR_POLYGON ]; + // All types which requires filters + public const ATTRIBUTE_FILTER_TYPES = [ + ...self::SPATIAL_TYPES, + self::VAR_VECTOR, + self::VAR_OBJECT, + ]; + // Index Types public const INDEX_KEY = 'key'; public const INDEX_FULLTEXT = 'fulltext'; @@ -622,6 +629,35 @@ function (?string $value) { return is_array($decoded) ? $decoded : $value; } ); + + self::addFilter( + Database::VAR_OBJECT, + /** + * @param mixed $value + * @return mixed + */ + function (mixed $value) { + if (!\is_array($value)) { + return $value; + } + + return \json_encode($value); + }, + /** + * @param string|null $value + * @return array|null + */ + function (?string $value) { + if (is_null($value)) { + return null; + } + if (!is_string($value)) { + return $value; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : $value; + } + ); } /** @@ -1438,7 +1474,7 @@ public function delete(?string $database = null): bool public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document { foreach ($attributes as &$attribute) { - if (in_array($attribute['type'], Database::SPATIAL_TYPES) || $attribute['type'] === Database::VAR_VECTOR) { + if (in_array($attribute['type'], self::ATTRIBUTE_FILTER_TYPES)) { $existingFilters = $attribute['filters'] ?? []; if (!is_array($existingFilters)) { $existingFilters = [$existingFilters]; @@ -1811,11 +1847,8 @@ public function createAttribute(string $collection, string $id, string $type, in if ($collection->isEmpty()) { throw new NotFoundException('Collection not found'); } - if (in_array($type, Database::SPATIAL_TYPES)) { - $filters[] = $type; - $filters = array_unique($filters); - } - if ($type === Database::VAR_VECTOR) { + + if (in_array($type, self::ATTRIBUTE_FILTER_TYPES)) { $filters[] = $type; $filters = array_unique($filters); } @@ -2138,6 +2171,9 @@ private function validateAttribute( if ($this->adapter->getSupportForSpatialAttributes()) { \array_push($supportedTypes, ...self::SPATIAL_TYPES); } + if ($this->adapter->getSupportForObject()) { + $supportedTypes[] = self::VAR_OBJECT; + } throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes)); } @@ -7729,12 +7765,6 @@ public function casting(Document $collection, Document $document): Document case self::VAR_FLOAT: $node = (float)$node; break; - case self::VAR_OBJECT: - // Decode JSONB string to array - if (is_string($node)) { - $node = json_decode($node, true); - } - break; default: break; } diff --git a/src/Database/Validator/ObjectValidator.php b/src/Database/Validator/ObjectValidator.php index 6788b722b..d4524d901 100644 --- a/src/Database/Validator/ObjectValidator.php +++ b/src/Database/Validator/ObjectValidator.php @@ -21,7 +21,14 @@ public function getDescription(): string */ public function isValid(mixed $value): bool { - return empty($value) || is_array($value) && !array_is_list($value); + if (is_string($value)) { + // Check if it's valid JSON + json_decode($value); + return json_last_error() === JSON_ERROR_NONE; + } + + // Allow empty or associative arrays (non-list) + return empty($value) || (is_array($value) && !array_is_list($value)); } /** From cd4e0b53bd1f062d2117da5d563d86332496f235 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 6 Nov 2025 14:19:38 +0530 Subject: [PATCH 15/20] linting --- tests/e2e/Adapter/Base.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index b75427d33..0408cecab 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -8,8 +8,8 @@ use Tests\E2E\Adapter\Scopes\DocumentTests; use Tests\E2E\Adapter\Scopes\GeneralTests; use Tests\E2E\Adapter\Scopes\IndexTests; -use Tests\E2E\Adapter\Scopes\OperatorTests; use Tests\E2E\Adapter\Scopes\ObjectAttributeTests; +use Tests\E2E\Adapter\Scopes\OperatorTests; use Tests\E2E\Adapter\Scopes\PermissionTests; use Tests\E2E\Adapter\Scopes\RelationshipTests; use Tests\E2E\Adapter\Scopes\SchemalessTests; From 9a0cea65abbfb325506cb1d619db60dac7ee7e55 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 6 Nov 2025 14:32:12 +0530 Subject: [PATCH 16/20] added test to simulate a vector store --- .../Adapter/Scopes/ObjectAttributeTests.php | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index 50bc4bd92..25a33d58c 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -925,4 +925,160 @@ public function testObjectAttributeDefaults(): void // Clean up $database->deleteCollection($collectionId); } + + public function testMetadataWithVector(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Skip if adapter doesn't support either vectors or object attributes + if (!$database->getAdapter()->getSupportForVectors() || !$database->getAdapter()->getSupportForObject()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Attributes: 3D vector and nested metadata object + $database->createAttribute($collectionId, 'embedding', Database::VAR_VECTOR, 3, true); + $database->createAttribute($collectionId, 'metadata', Database::VAR_OBJECT, 0, false); + + // Seed documents + $docA = $database->createDocument($collectionId, new Document([ + '$id' => 'vecA', + '$permissions' => [Permission::read(Role::any())], + 'embedding' => [0.1, 0.2, 0.9], + 'metadata' => [ + 'profile' => [ + 'user' => [ + 'info' => [ + 'country' => 'IN', + 'score' => 100 + ] + ] + ], + 'tags' => ['ai', 'ml', 'db'], + 'settings' => [ + 'prefs' => [ + 'theme' => 'dark', + 'features' => [ + 'experimental' => true + ] + ] + ] + ] + ])); + + $docB = $database->createDocument($collectionId, new Document([ + '$id' => 'vecB', + '$permissions' => [Permission::read(Role::any())], + 'embedding' => [0.2, 0.9, 0.1], + 'metadata' => [ + 'profile' => [ + 'user' => [ + 'info' => [ + 'country' => 'US', + 'score' => 80 + ] + ] + ], + 'tags' => ['search', 'analytics'], + 'settings' => [ + 'prefs' => [ + 'theme' => 'light' + ] + ] + ] + ])); + + $docC = $database->createDocument($collectionId, new Document([ + '$id' => 'vecC', + '$permissions' => [Permission::read(Role::any())], + 'embedding' => [0.9, 0.1, 0.2], + 'metadata' => [ + 'profile' => [ + 'user' => [ + 'info' => [ + 'country' => 'CA', + 'score' => 60 + ] + ] + ], + 'tags' => ['ml', 'cv'], + 'settings' => [ + 'prefs' => [ + 'theme' => 'dark', + 'features' => [ + 'experimental' => false + ] + ] + ] + ] + ])); + + // 1) Vector similarity: closest to [0.0, 0.0, 1.0] should be vecA + $results = $database->find($collectionId, [ + Query::vectorCosine('embedding', [0.0, 0.0, 1.0]), + Query::limit(1) + ]); + $this->assertCount(1, $results); + $this->assertEquals('vecA', $results[0]->getId()); + + // 2) Complex nested metadata equal (partial object containment) + $results = $database->find($collectionId, [ + Query::equal('metadata', [[ + 'profile' => [ + 'user' => [ + 'info' => [ + 'country' => 'IN' + ] + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('vecA', $results[0]->getId()); + + // 3) Contains on nested array inside metadata + $results = $database->find($collectionId, [ + Query::contains('metadata', [[ + 'tags' => 'ml' + ]]) + ]); + $this->assertCount(2, $results); // vecA, vecC both have 'ml' in tags + + // 4) Combine vector query with nested metadata filters + $results = $database->find($collectionId, [ + Query::vectorEuclidean('embedding', [0.0, 1.0, 0.0]), + Query::equal('metadata', [[ + 'settings' => [ + 'prefs' => [ + 'theme' => 'light' + ] + ] + ]]), + Query::limit(1) + ]); + $this->assertCount(1, $results); + $this->assertEquals('vecB', $results[0]->getId()); + + // 5) Deep partial containment with boolean nested value + $results = $database->find($collectionId, [ + Query::equal('metadata', [[ + 'settings' => [ + 'prefs' => [ + 'features' => [ + 'experimental' => true + ] + ] + ] + ]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('vecA', $results[0]->getId()); + + // Cleanup + $database->deleteCollection($collectionId); + } } From fad8570f32652ab6b1d454234b4667b6f2aa9eca Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 11 Nov 2025 19:40:07 +0530 Subject: [PATCH 17/20] removed reduntant comment --- src/Database/Database.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 8c703ed37..5e6047cb9 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -80,7 +80,6 @@ class Database public const INDEX_FULLTEXT = 'fulltext'; public const INDEX_UNIQUE = 'unique'; public const INDEX_SPATIAL = 'spatial'; - // keeping public const INDEX_OBJECT = 'object'; public const INDEX_HNSW_EUCLIDEAN = 'hnsw_euclidean'; public const INDEX_HNSW_COSINE = 'hnsw_cosine'; From 29f4cfe9f0d6260add6ed8e49fb917958f6c3e64 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 11 Nov 2025 21:37:04 +0530 Subject: [PATCH 18/20] updated the semantics for not equal case --- src/Database/Query.php | 6 ++++- src/Database/Validator/Query/Filter.php | 8 ++----- .../Adapter/Scopes/ObjectAttributeTests.php | 23 +++++++++++++++---- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index 8656f5d96..60ec1d712 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -463,7 +463,11 @@ public static function equal(string $attribute, array $values): self */ public static function notEqual(string $attribute, string|int|float|bool|array $value): self { - return new self(self::TYPE_NOT_EQUAL, $attribute, is_array($value) ? $value : [$value]); + // maps or not an array + if ((is_array($value) && !array_is_list($value)) || !is_array($value)) { + $value = [$value]; + } + return new self(self::TYPE_NOT_EQUAL, $attribute, $value); } /** diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index c9f7b27ed..11053f14c 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -163,12 +163,8 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s break; case Database::VAR_OBJECT: - // For JSONB/object queries, value must be an array - if (!is_array($value)) { - $this->message = 'Query value for object type must be an array'; - return false; - } - // No further validation needed - JSONB accepts any valid array structure + // value for object can be of any type as its a hashmap + // eg; ['key'=>value'] continue 2; case Database::VAR_POINT: diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index 25a33d58c..7f2ca22fe 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -2,9 +2,11 @@ namespace Tests\E2E\Adapter\Scopes; +use Exception; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Index as IndexException; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; @@ -152,21 +154,31 @@ public function testObjectAttribute(): void // Test 11d: notEqual on scalar inside object should exclude doc1 $results = $database->find($collectionId, [ - Query::notEqual('meta', [['age' => 26]]) + Query::notEqual('meta', ['age' => 26]) ]); // Should return doc2 only $this->assertCount(1, $results); $this->assertEquals('doc2', $results[0]->getId()); + try { + // test -> not equal allows one value only + $results = $database->find($collectionId, [ + Query::notEqual('meta', [['age' => 26],['age' => 27]]) + ]); + $this->fail('No query thrown'); + } catch (Exception $e) { + $this->assertInstanceOf(QueryException::class, $e); + } + // Test 11e: notEqual on nested object should exclude doc1 $results = $database->find($collectionId, [ - Query::notEqual('meta', [[ + Query::notEqual('meta', [ 'user' => [ 'info' => [ 'country' => 'CA' ] ] - ]]) + ]) ]); // Should return doc2 only $this->assertCount(1, $results); @@ -588,9 +600,10 @@ public function testObjectAttributeGinIndex(): void // Test 3: Query with equal on indexed JSONB column $results = $database->find($collectionId, [ - Query::equal('data', [['env' => 'production']]) + Query::equal('data', [['config' => ['env' => 'production']]]) ]); - $this->assertCount(0, $results); // Note: Object index doesn't make equal queries work differently + $this->assertCount(1, $results); + $this->assertEquals('gin1', $results[0]->getId()); // Test 4: Query with contains on indexed JSONB column $results = $database->find($collectionId, [ From 5b34785070a8c824da20d3f62c480fed34b995cc Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 12 Nov 2025 13:24:23 +0530 Subject: [PATCH 19/20] index, attribute filters, typo updates --- src/Database/Database.php | 25 +++---------------- tests/e2e/Adapter/Scopes/AttributeTests.php | 15 ++++++++--- .../Adapter/Scopes/ObjectAttributeTests.php | 2 +- tests/e2e/Adapter/Scopes/VectorTests.php | 2 +- tests/unit/Validator/IndexTest.php | 2 +- 5 files changed, 17 insertions(+), 29 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 5e6047cb9..d8733488b 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -73,6 +73,7 @@ class Database ...self::SPATIAL_TYPES, self::VAR_VECTOR, self::VAR_OBJECT, + self::VAR_DATETIME ]; // Index Types @@ -2579,7 +2580,8 @@ public function updateAttribute(string $collection, string $id, ?string $type = self::VAR_FLOAT, self::VAR_BOOLEAN, self::VAR_DATETIME, - self::VAR_RELATIONSHIP . ', ' . self::VAR_OBJECT + self::VAR_RELATIONSHIP, + self::VAR_OBJECT ]; if ($this->adapter->getSupportForVectors()) { $supportedTypes[] = self::VAR_VECTOR; @@ -3617,27 +3619,6 @@ public function createIndex(string $collection, string $id, string $type, array } } - if ($type === self::INDEX_OBJECT) { - if (count($attributes) !== 1) { - throw new IndexException('Object index can be created on a single object attribute'); - } - - foreach ($attributes as $attr) { - if (!isset($indexAttributesWithTypes[$attr])) { - throw new IndexException('Attribute "' . $attr . '" not found in collection'); - } - - $attributeType = $indexAttributesWithTypes[$attr]; - if ($attributeType !== self::VAR_OBJECT) { - throw new IndexException('Object index can only be created on object attributes. Attribute "' . $attr . '" is of type "' . $attributeType . '"'); - } - } - - if (!empty($orders)) { - throw new IndexException('Object indexes do not support explicit orders. Remove the orders to create this index.'); - } - } - $index = new Document([ '$id' => ID::custom($id), 'key' => $id, diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index b141f56da..1fa91bd71 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -1750,16 +1750,23 @@ public function testCreateDatetime(): void } } - public function testCreateDateTimeAttributeFailure(): void + public function testCreateDatetimeAddingAutoFilter(): void { /** @var Database $database */ $database = static::getDatabase(); - $database->createCollection('datetime_fail'); + $database->createCollection('datetime_auto_filter'); - /** Test for FAILURE */ $this->expectException(Exception::class); - $database->createAttribute('datetime_fail', 'date_fail', Database::VAR_DATETIME, 0, false); + $database->createAttribute('datetime_auto', 'date_auto', Database::VAR_DATETIME, 0, false, filters:['json']); + $collection = $database->getCollection('datetime_auto_filter'); + $attribute = $collection->getAttributes()[0]; + $this->assertEquals([Database::VAR_DATETIME,'json'], $attribute['filters']); + $database->updateAttribute('datetime_auto', 'date_auto', Database::VAR_DATETIME, 0, false, filters:[]); + $collection = $database->getCollection('datetime_auto_filter'); + $attribute = $collection->getAttributes()[0]; + $this->assertEquals([Database::VAR_DATETIME,'json'], $attribute['filters']); + $database->deleteCollection('datetime_auto_filter'); } /** * @depends testCreateDeleteAttribute diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index 7f2ca22fe..2e9dc78f7 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -652,7 +652,7 @@ public function testObjectAttributeGinIndex(): void } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); - $this->assertStringContainsString('Object indexes do not support explicit orders', $e->getMessage()); + $this->assertStringContainsString('Object index do not support explicit orders', $e->getMessage()); } $this->assertTrue($exceptionThrown, 'Expected Index exception for Object index with orders'); diff --git a/tests/e2e/Adapter/Scopes/VectorTests.php b/tests/e2e/Adapter/Scopes/VectorTests.php index a99de4711..dd5cc7e76 100644 --- a/tests/e2e/Adapter/Scopes/VectorTests.php +++ b/tests/e2e/Adapter/Scopes/VectorTests.php @@ -2711,7 +2711,7 @@ public function testVectorQuerySum(): void $database->deleteCollection('vectorSum'); } - public function testVetorUpsert(): void + public function testVectorUpsert(): void { /** @var Database $database */ $database = static::getDatabase(); diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index 1ab36f0a4..608a65d2b 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -241,7 +241,7 @@ public function testEmptyAttributes(): void /** * @throws Exception */ - public function testGinIndexValidation(): void + public function testObjectIndexValidation(): void { $collection = new Document([ '$id' => ID::custom('test'), From 9a01de37c14059b15587a767d1920c8e3ec155a8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 12 Nov 2025 13:28:41 +0530 Subject: [PATCH 20/20] linting --- tests/e2e/Adapter/Scopes/AttributeTests.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index 1fa91bd71..92ebdb9a9 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -1760,11 +1760,11 @@ public function testCreateDatetimeAddingAutoFilter(): void $this->expectException(Exception::class); $database->createAttribute('datetime_auto', 'date_auto', Database::VAR_DATETIME, 0, false, filters:['json']); $collection = $database->getCollection('datetime_auto_filter'); - $attribute = $collection->getAttributes()[0]; + $attribute = $collection->getAttribute('attributes')[0]; $this->assertEquals([Database::VAR_DATETIME,'json'], $attribute['filters']); $database->updateAttribute('datetime_auto', 'date_auto', Database::VAR_DATETIME, 0, false, filters:[]); $collection = $database->getCollection('datetime_auto_filter'); - $attribute = $collection->getAttributes()[0]; + $attribute = $collection->getAttribute('attributes')[0]; $this->assertEquals([Database::VAR_DATETIME,'json'], $attribute['filters']); $database->deleteCollection('datetime_auto_filter'); }