diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 8fc62aebb..62a8eb7fe 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1072,6 +1072,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 0cc86e6c5..2876139f7 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2135,6 +2135,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/Mongo.php b/src/Database/Adapter/Mongo.php index 32a0faee6..009ad1f7c 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2790,6 +2790,11 @@ public function getSupportForBatchCreateAttributes(): bool return true; } + public function getSupportForObject(): bool + { + return false; + } + /** * Get current attribute count from collection document * diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index e5d4b746f..76c98e8b2 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -585,6 +585,11 @@ public function decodePolygon(string $wkb): array return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForObject(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function castingBefore(Document $collection, Document $document): Document { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 6828f6324..72e49cc07 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -889,7 +889,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', - 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_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}\""; @@ -908,6 +909,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})", default => " ({$attributes})", }; @@ -1656,6 +1658,62 @@ 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: + 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); + $fragment = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb"; + $conditions[] = $isNot ? "NOT (" . $fragment . ")" : $fragment; + } + $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 (count($value) === 1) { + $jsonKey = array_key_first($value); + $jsonValue = $value[$jsonKey]; + + // 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"; + $conditions[] = $isNot ? "NOT (" . $fragment . ")" : $fragment; + } + $separator = $isNot ? ' AND ' : ' OR '; + return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; + } + + default: + throw new DatabaseException('Query method ' . $query->getMethod() . ' not supported for object attributes'); + } + } + /** * Get SQL Condition * @@ -1679,6 +1737,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: @@ -1860,6 +1922,9 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool case Database::VAR_DATETIME: return 'TIMESTAMP(3)'; + case Database::VAR_OBJECT: + return 'JSONB'; + case Database::VAR_POINT: return 'GEOMETRY(POINT,' . Database::DEFAULT_SRID . ')'; @@ -1873,7 +1938,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::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); } } @@ -2106,6 +2171,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 c9c4970e4..4bd0bb653 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1175,6 +1175,15 @@ public function getAttributeWidth(Document $collection): int $total += 7; break; + case Database::VAR_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 1260ccca0..a3d31db68 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1008,6 +1008,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 25ab6ea2f..b2bd088e1 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -47,6 +47,9 @@ class Database public const VAR_ID = 'id'; public const VAR_UUID7 = 'uuid7'; + // object type + public const VAR_OBJECT = 'object'; + // Vector types public const VAR_VECTOR = 'vector'; @@ -65,11 +68,20 @@ class Database self::VAR_POLYGON ]; + // All types which requires filters + public const ATTRIBUTE_FILTER_TYPES = [ + ...self::SPATIAL_TYPES, + self::VAR_VECTOR, + self::VAR_OBJECT, + self::VAR_DATETIME + ]; + // Index Types public const INDEX_KEY = 'key'; public const INDEX_FULLTEXT = 'fulltext'; public const INDEX_UNIQUE = 'unique'; public const INDEX_SPATIAL = 'spatial'; + 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'; @@ -623,6 +635,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; + } + ); } /** @@ -1512,7 +1553,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]; @@ -1599,6 +1640,7 @@ public function createCollection(string $id, array $attributes = [], array $inde $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), + $this->adapter->getSupportForObject(), ); foreach ($indexes as $index) { if (!$validator->isValid($index)) { @@ -1884,11 +1926,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); } @@ -2142,6 +2181,17 @@ private function validateAttribute( case self::VAR_DATETIME: case self::VAR_RELATIONSHIP: break; + case self::VAR_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: @@ -2200,6 +2250,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)); } @@ -2250,7 +2303,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::VAR_OBJECT) { foreach ($default as $value) { $this->validateDefaultTypes($type, $value); } @@ -2547,6 +2600,18 @@ public function updateAttribute(string $collection, string $id, ?string $type = } break; + case self::VAR_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: @@ -2594,7 +2659,8 @@ public function updateAttribute(string $collection, string $id, ?string $type = self::VAR_FLOAT, self::VAR_BOOLEAN, self::VAR_DATETIME, - self::VAR_RELATIONSHIP + self::VAR_RELATIONSHIP, + self::VAR_OBJECT ]; if ($this->adapter->getSupportForVectors()) { $supportedTypes[] = self::VAR_VECTOR; @@ -2719,6 +2785,7 @@ public function updateAttribute(string $collection, string $id, ?string $type = $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), + $this->adapter->getSupportForObject() ); foreach ($indexes as $index) { @@ -3592,8 +3659,14 @@ public function createIndex(string $collection, string $id, string $type, array } break; + 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_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 */ @@ -3648,6 +3721,7 @@ public function createIndex(string $collection, string $id, string $type, array $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), + $this->adapter->getSupportForObject(), ); if (!$validator->isValid($index)) { throw new IndexException($validator->getDescription()); diff --git a/src/Database/Query.php b/src/Database/Query.php index c7f96deec..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); } /** @@ -977,6 +981,14 @@ public function isSpatialAttribute(): bool return in_array($this->attributeType, Database::SPATIAL_TYPES); } + /** + * @return bool + */ + public function isObjectAttribute(): bool + { + return $this->attributeType === Database::VAR_OBJECT; + } + // Spatial query methods /** diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index a272befb8..33648feeb 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -28,6 +28,7 @@ class Index extends Validator * @param bool $supportForAttributes * @param bool $supportForMultipleFulltextIndexes * @param bool $supportForIdenticalIndexes + * @param bool $supportForObjectIndexes * @throws DatabaseException */ public function __construct( @@ -42,6 +43,7 @@ public function __construct( protected bool $supportForAttributes = true, protected bool $supportForMultipleFulltextIndexes = true, protected bool $supportForIdenticalIndexes = true, + protected bool $supportForObjectIndexes = false ) { foreach ($attributes as $attribute) { $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); @@ -132,6 +134,9 @@ public function isValid($value): bool if (!$this->checkIdenticalIndexes($value)) { return false; } + if (!$this->checkObjectIndexes($value)) { + return false; + } return true; } @@ -529,4 +534,46 @@ public function checkIdenticalIndexes(Document $index): bool return true; } + + /** + * @param Document $index + * @return bool + */ + public function checkObjectIndexes(Document $index): bool + { + $type = $index->getAttribute('type'); + + $attributes = $index->getAttribute('attributes', []); + $orders = $index->getAttribute('orders', []); + + if ($type !== Database::INDEX_OBJECT) { + return true; + } + + if (!$this->supportForObjectIndexes) { + $this->message = 'Object indexes are not supported'; + return false; + } + + if (count($attributes) !== 1) { + $this->message = 'Object index can be created on a single object attribute'; + return false; + } + + if (!empty($orders)) { + $this->message = 'Object index 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::VAR_OBJECT) { + $this->message = 'Object index can only be created on object attributes. Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; + return false; + } + + return true; + } } diff --git a/src/Database/Validator/ObjectValidator.php b/src/Database/Validator/ObjectValidator.php new file mode 100644 index 000000000..d4524d901 --- /dev/null +++ b/src/Database/Validator/ObjectValidator.php @@ -0,0 +1,49 @@ +value'] + continue 2; + case Database::VAR_POINT: case Database::VAR_LINESTRING: case Database::VAR_POLYGON: @@ -235,10 +240,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::VAR_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 5c6005359..74eadcf96 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -373,6 +373,10 @@ protected function checkForInvalidAttributeValues(array $structure, array $keys) ); break; + case Database::VAR_OBJECT: + $validators[] = new ObjectValidator(); + break; + case Database::VAR_POINT: case Database::VAR_LINESTRING: case Database::VAR_POLYGON: diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 3643d8d60..b6b585784 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -9,6 +9,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\OperatorTests; use Tests\E2E\Adapter\Scopes\PermissionTests; use Tests\E2E\Adapter\Scopes\RelationshipTests; @@ -32,6 +33,7 @@ abstract class Base extends TestCase use RelationshipTests; use SpatialTests; use SchemalessTests; + use ObjectAttributeTests; use VectorTests; use GeneralTests; diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index 8add6b1d8..92ebdb9a9 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -1566,7 +1566,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, [ @@ -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->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->getAttribute('attributes')[0]; + $this->assertEquals([Database::VAR_DATETIME,'json'], $attribute['filters']); + $database->deleteCollection('datetime_auto_filter'); } /** * @depends testCreateDeleteAttribute diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 867c39d86..656b47255 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -1890,7 +1890,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); } } @@ -3371,7 +3371,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); } } diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php new file mode 100644 index 000000000..2e9dc78f7 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -0,0 +1,1097 @@ +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::VAR_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()); + + 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', [ + '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::VAR_OBJECT, 0, false)); + + // 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 + $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', [['config' => ['env' => 'production']]]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('gin1', $results[0]->getId()); + + // 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 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 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_OBJECT, ['name']); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(IndexException::class, $e); + $this->assertStringContainsString('Object index can only be created on object attributes', $e->getMessage()); + } + $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::VAR_OBJECT, 0, false); + + $exceptionThrown = false; + try { + $database->createIndex($collectionId, 'idx_multi_gin', Database::INDEX_OBJECT, ['data', 'metadata']); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(IndexException::class, $e); + $this->assertStringContainsString('Object index can be created on a single object attribute', $e->getMessage()); + } + $this->assertTrue($exceptionThrown, 'Expected Index exception for Object index on multiple attributes'); + + // 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]); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(IndexException::class, $e); + $this->assertStringContainsString('Object index do not support explicit orders', $e->getMessage()); + } + $this->assertTrue($exceptionThrown, 'Expected Index exception for Object 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::VAR_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()); + + // Test 16: with multiple json + $defaultSettings = ['config' => ['theme' => 'light', 'lang' => 'en']]; + $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, [ + 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); + } + + 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'); + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // 1) Default empty object + $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::VAR_OBJECT, 0, false, $defaultSettings)); + + // 3) Required without default (should fail when missing) + $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::VAR_OBJECT, 0, false, ['name' => 'anon'])); + + // 5) Explicit null default + $this->assertEquals(true, $database->createAttribute($collectionId, 'misc', Database::VAR_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); + } + + 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); + } +} 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 987297ae2..608a65d2b 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 testObjectIndexValidation(): void + { + $collection = new Document([ + '$id' => ID::custom('test'), + 'name' => 'test', + 'attributes' => [ + new Document([ + '$id' => ID::custom('data'), + 'type' => Database::VAR_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 supportForObjectIndexes enabled + $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, supportForObjectIndexes:true); + + // Valid: Object index on single VAR_OBJECT attribute + $validIndex = new Document([ + '$id' => ID::custom('idx_gin_valid'), + 'type' => Database::INDEX_OBJECT, + 'attributes' => ['data'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertTrue($validator->isValid($validIndex)); + + // Invalid: Object index on non-object attribute + $invalidIndexType = new Document([ + '$id' => ID::custom('idx_gin_invalid_type'), + 'type' => Database::INDEX_OBJECT, + 'attributes' => ['name'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertFalse($validator->isValid($invalidIndexType)); + $this->assertStringContainsString('Object index can only be created on object attributes', $validator->getDescription()); + + // Invalid: Object index on multiple attributes + $invalidIndexMulti = new Document([ + '$id' => ID::custom('idx_gin_multi'), + 'type' => Database::INDEX_OBJECT, + 'attributes' => ['data', 'name'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertFalse($validator->isValid($invalidIndexMulti)); + $this->assertStringContainsString('Object index can be created on a single object attribute', $validator->getDescription()); + + // Invalid: Object index with orders + $invalidIndexOrder = new Document([ + '$id' => ID::custom('idx_gin_order'), + 'type' => Database::INDEX_OBJECT, + 'attributes' => ['data'], + 'lengths' => [], + 'orders' => ['asc'], + ]); + $this->assertFalse($validator->isValid($invalidIndexOrder)); + $this->assertStringContainsString('Object index do not support explicit orders', $validator->getDescription()); + + // 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('Object indexes are not supported', $validatorNoSupport->getDescription()); + } + /** * @throws Exception */ 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')); + } +}