diff --git a/postgres.dockerfile b/postgres.dockerfile index 0854120b6..f3ffe4821 100644 --- a/postgres.dockerfile +++ b/postgres.dockerfile @@ -4,4 +4,5 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends \ postgresql-16-postgis-3 \ postgresql-16-postgis-3-scripts \ + postgresql-16-pgvector \ && rm -rf /var/lib/apt/lists/* \ No newline at end of file diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 2332d9745..4283f2630 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1021,6 +1021,13 @@ abstract public function getSupportForGetConnectionId(): bool; */ abstract public function getSupportForUpserts(): bool; + /** + * Is vector type supported? + * + * @return bool + */ + abstract public function getSupportForVectors(): bool; + /** * Is Cache Fallback supported? * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index e5dd89c5b..ea2f4781f 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -139,7 +139,7 @@ public function createCollection(string $name, array $attributes = [], array $in $indexAttributes[$nested] = "`{$indexAttribute}`{$indexLength} {$indexOrder}"; if (!empty($hash[$indexAttribute]['array']) && $this->getSupportForCastIndexArray()) { - $indexAttributes[$nested] = '(CAST(`' . $indexAttribute . '` AS char(' . Database::ARRAY_INDEX_LENGTH . ') ARRAY))'; + $indexAttributes[$nested] = '(CAST(`' . $indexAttribute . '` AS char(' . Database::MAX_ARRAY_INDEX_LENGTH . ') ARRAY))'; } } @@ -746,7 +746,7 @@ public function createIndex(string $collection, string $id, string $type, array $attributes[$i] = "`{$attr}`{$length} {$order}"; if ($this->getSupportForCastIndexArray() && !empty($attribute['array'])) { - $attributes[$i] = '(CAST(`' . $attr . '` AS char(' . Database::ARRAY_INDEX_LENGTH . ') ARRAY))'; + $attributes[$i] = '(CAST(`' . $attr . '` AS char(' . Database::MAX_ARRAY_INDEX_LENGTH . ') ARRAY))'; } } @@ -1890,7 +1890,7 @@ public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bo public function getSpatialSQLType(string $type, bool $required): string { - $srid = Database::SRID; + $srid = Database::DEFAULT_SRID; $nullability = ''; if (!$this->getSupportForSpatialIndexNull()) { diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 70e15700e..22c561ff9 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -117,7 +117,7 @@ protected function handleDistanceSpatialQueries(Query $query, array &$binds, str } if ($useMeters) { - $attr = "ST_SRID({$alias}.{$attribute}, " . Database::SRID . ")"; + $attr = "ST_SRID({$alias}.{$attribute}, " . Database::DEFAULT_SRID . ")"; $geom = $this->getSpatialGeomFromText(":{$placeholder}_0", null); return "ST_Distance({$attr}, {$geom}, 'metre') {$operator} :{$placeholder}_1"; } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 799596d2d..929da8906 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -405,6 +405,11 @@ public function getSupportForUpserts(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForVectors(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function getSupportForCacheSkipOnFailure(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index d8cd83f4e..1b7b67fbd 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -147,15 +147,16 @@ public function create(string $name): bool ->prepare($sql) ->execute(); - // extension for supporting spatial types - $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS postgis;')->execute(); + // Enable extensions + $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS postgis')->execute(); + $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS vector')->execute(); $collation = " CREATE COLLATION IF NOT EXISTS utf8_ci_ai ( provider = icu, locale = 'und-u-ks-level1', deterministic = false - ); + ) "; $this->getPDO()->prepare($collation)->execute(); return $dbCreation; @@ -193,9 +194,6 @@ public function createCollection(string $name, array $attributes = [], array $in $namespace = $this->getNamespace(); $id = $this->filter($name); - /** @var array $attributeStrings */ - $attributeStrings = []; - /** @var array $attributeStrings */ $attributeStrings = []; foreach ($attributes as $attribute) { @@ -443,6 +441,16 @@ public function analyzeCollection(string $collection): bool */ public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool { + // Ensure pgvector extension is installed for vector types + if ($type === Database::VAR_VECTOR) { + if ($size <= 0) { + throw new DatabaseException('Vector dimensions must be a positive integer'); + } + if ($size > Database::MAX_VECTOR_DIMENSIONS) { + throw new DatabaseException('Vector dimensions cannot exceed ' . Database::MAX_VECTOR_DIMENSIONS); + } + } + $name = $this->filter($collection); $id = $this->filter($id); $type = $this->getSQLType($type, $size, $signed, $array, $required); @@ -543,7 +551,23 @@ public function updateAttribute(string $collection, string $id, string $type, in $name = $this->filter($collection); $id = $this->filter($id); $newKey = empty($newKey) ? null : $this->filter($newKey); - $type = $this->getSQLType($type, $size, $signed, $array, $required); + + if ($type === Database::VAR_VECTOR) { + if ($size <= 0) { + throw new DatabaseException('Vector dimensions must be a positive integer'); + } + if ($size > Database::MAX_VECTOR_DIMENSIONS) { + throw new DatabaseException('Vector dimensions cannot exceed ' . Database::MAX_VECTOR_DIMENSIONS); + } + } + + $type = $this->getSQLType( + $type, + $size, + $signed, + $array, + $required, + ); if ($type == 'TIMESTAMP(3)') { $type = "TIMESTAMP(3) without time zone USING TO_TIMESTAMP(\"$id\", 'YYYY-MM-DD HH24:MI:SS.MS')"; @@ -841,7 +865,6 @@ public function createIndex(string $collection, string $id, string $type, array $collection = $this->filter($collection); $id = $this->filter($id); - foreach ($attributes as $i => $attr) { $order = empty($orders[$i]) || Database::INDEX_FULLTEXT === $type ? '' : $orders[$i]; @@ -857,29 +880,33 @@ public function createIndex(string $collection, string $id, string $type, array $sqlType = match ($type) { Database::INDEX_KEY, - Database::INDEX_FULLTEXT => 'INDEX', + Database::INDEX_FULLTEXT, + Database::INDEX_SPATIAL, + Database::INDEX_HNSW_EUCLIDEAN, + Database::INDEX_HNSW_COSINE, + Database::INDEX_HNSW_DOT => '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), + 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), }; $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) { + if ($this->sharedTables && \in_array($type, [Database::INDEX_KEY, Database::INDEX_UNIQUE])) { // Add tenant as first index column for best performance $attributes = "_tenant, {$attributes}"; } $sql = "CREATE {$sqlType} {$key} ON {$this->getSQLTable($collection)}"; - // Add USING GIST for spatial indexes - if ($type === Database::INDEX_SPATIAL) { - $sql .= " USING GIST"; - } - - $sql .= " ({$attributes});"; + // Add USING clause for special index types + $sql .= match ($type) { + Database::INDEX_SPATIAL => " USING GIST ({$attributes})", + 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)", + default => " ({$attributes})", + }; $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql); @@ -1480,7 +1507,7 @@ protected function handleDistanceSpatialQueries(Query $query, array &$binds, str if ($meters) { $attr = "({$alias}.{$attribute}::geography)"; - $geom = "ST_SetSRID(" . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ", " . Database::SRID . ")::geography"; + $geom = "ST_SetSRID(" . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ", " . Database::DEFAULT_SRID . ")::geography"; return "ST_Distance({$attr}, {$geom}) {$operator} :{$placeholder}_1"; } @@ -1605,6 +1632,11 @@ protected function getSQLCondition(Query $query, array &$binds): string $binds[":{$placeholder}_0"] = $this->getFulltextValue($query->getValue()); return "NOT (to_tsvector(regexp_replace({$attribute}, '[^\w]+',' ','g')) @@ websearch_to_tsquery(:{$placeholder}_0))"; + case Query::TYPE_VECTOR_DOT: + case Query::TYPE_VECTOR_COSINE: + case Query::TYPE_VECTOR_EUCLIDEAN: + return ''; // Handled in ORDER BY clause + case Query::TYPE_BETWEEN: $binds[":{$placeholder}_0"] = $query->getValues()[0]; $binds[":{$placeholder}_1"] = $query->getValues()[1]; @@ -1623,8 +1655,6 @@ protected function getSQLCondition(Query $query, array &$binds): string case Query::TYPE_NOT_CONTAINS: if ($query->onArray()) { $operator = '@>'; - } else { - $operator = null; } // no break @@ -1665,6 +1695,37 @@ protected function getSQLCondition(Query $query, array &$binds): string } } + /** + * Get vector distance calculation for ORDER BY clause + * + * @param Query $query + * @param array $binds + * @param string $alias + * @return string|null + * @throws DatabaseException + */ + protected function getVectorDistanceOrder(Query $query, array &$binds, string $alias): ?string + { + $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); + + $attribute = $this->filter($query->getAttribute()); + $attribute = $this->quote($attribute); + $alias = $this->quote($alias); + $placeholder = ID::unique(); + + $values = $query->getValues(); + $vectorArray = $values[0] ?? []; + $vector = \json_encode(\array_map(\floatval(...), $vectorArray)); + $binds[":vector_{$placeholder}"] = $vector; + + return match ($query->getMethod()) { + Query::TYPE_VECTOR_DOT => "({$alias}.{$attribute} <#> :vector_{$placeholder}::vector)", + Query::TYPE_VECTOR_COSINE => "({$alias}.{$attribute} <=> :vector_{$placeholder}::vector)", + Query::TYPE_VECTOR_EUCLIDEAN => "({$alias}.{$attribute} <-> :vector_{$placeholder}::vector)", + default => null, + }; + } + /** * @param string $value * @return string @@ -1732,15 +1793,17 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool case Database::VAR_DATETIME: return 'TIMESTAMP(3)'; - // in all other DB engines, 4326 is the default SRID case Database::VAR_POINT: - return 'GEOMETRY(POINT,' . Database::SRID . ')'; + return 'GEOMETRY(POINT,' . Database::DEFAULT_SRID . ')'; case Database::VAR_LINESTRING: - return 'GEOMETRY(LINESTRING,' . Database::SRID . ')'; + return 'GEOMETRY(LINESTRING,' . Database::DEFAULT_SRID . ')'; case Database::VAR_POLYGON: - return 'GEOMETRY(POLYGON,' . Database::SRID . ')'; + return 'GEOMETRY(POLYGON,' . Database::DEFAULT_SRID . ')'; + + case Database::VAR_VECTOR: + 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); @@ -1889,6 +1952,16 @@ public function getSupportForUpserts(): bool return true; } + /** + * Is vector type supported? + * + * @return bool + */ + public function getSupportForVectors(): bool + { + return true; + } + /** * @return string */ diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 84fae6ce7..425dca260 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1130,6 +1130,11 @@ public function getAttributeWidth(Document $collection): int $total += 20; break; + case Database::VAR_VECTOR: + // Each dimension is typically 4 bytes (float32) + $total += ($attribute['size'] ?? 0) * 4; + break; + default: throw new DatabaseException('Unknown type: ' . $attribute['type']); } @@ -1483,7 +1488,7 @@ public function getSupportForBatchCreateAttributes(): bool } /** - * Is spatial attributes supported? + * Are spatial attributes supported? * * @return bool */ @@ -1522,6 +1527,16 @@ public function getSupportForSpatialAxisOrder(): bool return false; } + /** + * Is vector type supported? + * + * @return bool + */ + public function getSupportForVectors(): bool + { + return false; + } + /** * Generate ST_GeomFromText call with proper SRID and axis order support * @@ -1531,7 +1546,7 @@ public function getSupportForSpatialAxisOrder(): bool */ protected function getSpatialGeomFromText(string $wktPlaceholder, ?int $srid = null): string { - $srid = $srid ?? Database::SRID; + $srid = $srid ?? Database::DEFAULT_SRID; $geomFromText = "ST_GeomFromText({$wktPlaceholder}, {$srid}"; if ($this->getSupportForSpatialAxisOrder()) { @@ -1571,6 +1586,19 @@ abstract protected function getUpsertStatement( string $attribute = '', ): mixed; + /** + * Get vector distance calculation for ORDER BY clause + * + * @param Query $query + * @param array $binds + * @param string $alias + * @return string|null + */ + protected function getVectorDistanceOrder(Query $query, array &$binds, string $alias): ?string + { + return null; + } + /** * @param string $value * @return string @@ -1632,6 +1660,10 @@ protected function getSQLOperator(string $method): string case Query::TYPE_NOT_ENDS_WITH: case Query::TYPE_NOT_CONTAINS: return $this->getLikeOperator(); + case Query::TYPE_VECTOR_DOT: + case Query::TYPE_VECTOR_COSINE: + case Query::TYPE_VECTOR_EUCLIDEAN: + throw new DatabaseException('Vector queries are not supported by this database'); default: throw new DatabaseException('Unknown method: ' . $method); } @@ -2334,7 +2366,6 @@ protected function convertArrayToWKT(array $geometry): string public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array { $attributes = $collection->getAttribute('attributes', []); - $collection = $collection->getId(); $name = $this->filter($collection); $roles = Authorization::getRoles(); @@ -2345,12 +2376,25 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $queries = array_map(fn ($query) => clone $query, $queries); + // Extract vector queries for ORDER BY + $vectorQueries = []; + $otherQueries = []; + foreach ($queries as $query) { + if (in_array($query->getMethod(), Query::VECTOR_TYPES)) { + $vectorQueries[] = $query; + } else { + $otherQueries[] = $query; + } + } + + $queries = $otherQueries; + $cursorWhere = []; foreach ($orderAttributes as $i => $originalAttribute) { $orderType = $orderTypes[$i] ?? Database::ORDER_ASC; - // Handle random ordering specially + // Handle random ordering if ($orderType === Database::ORDER_RANDOM) { $orders[] = $this->getRandomOrder(); continue; @@ -2431,7 +2475,22 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 } $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; - $sqlOrder = 'ORDER BY ' . implode(', ', $orders); + + // Add vector distance calculations to ORDER BY + $vectorOrders = []; + foreach ($vectorQueries as $query) { + $vectorOrder = $this->getVectorDistanceOrder($query, $binds, $alias); + if ($vectorOrder) { + $vectorOrders[] = $vectorOrder; + } + } + + if (!empty($vectorOrders)) { + // Vector orders should come first for similarity search + $orders = \array_merge($vectorOrders, $orders); + } + + $sqlOrder = !empty($orders) ? 'ORDER BY ' . implode(', ', $orders) : ''; $sqlLimit = ''; if (! \is_null($limit)) { @@ -2446,7 +2505,6 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $selections = $this->getAttributeSelections($queries); - $sql = " SELECT {$this->getAttributeProjection($selections, $alias)} FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} diff --git a/src/Database/Database.php b/src/Database/Database.php index 56a65e724..512a6d828 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -36,22 +36,19 @@ class Database { - public const VAR_STRING = 'string'; // Simple Types + public const VAR_STRING = 'string'; public const VAR_INTEGER = 'integer'; public const VAR_FLOAT = 'double'; public const VAR_BOOLEAN = 'boolean'; public const VAR_DATETIME = 'datetime'; - public const VAR_ID = 'id'; - public const VAR_OBJECT_ID = 'objectId'; - public const INT_MAX = 2147483647; - public const BIG_INT_MAX = PHP_INT_MAX; - public const DOUBLE_MAX = PHP_FLOAT_MAX; + // ID types + public const VAR_ID = 'id'; + public const VAR_UUID = 'uuid'; - // Global SRID for geographic coordinates (WGS84) - public const SRID = 4326; - public const EARTH_RADIUS = 6371000; + // Vector types + public const VAR_VECTOR = 'vector'; // Relationship Types public const VAR_RELATIONSHIP = 'relationship'; @@ -61,14 +58,32 @@ class Database public const VAR_LINESTRING = 'linestring'; public const VAR_POLYGON = 'polygon'; - public const SPATIAL_TYPES = [self::VAR_POINT, self::VAR_LINESTRING, self::VAR_POLYGON]; + // All spatial types + public const SPATIAL_TYPES = [ + self::VAR_POINT, + self::VAR_LINESTRING, + self::VAR_POLYGON + ]; // Index Types public const INDEX_KEY = 'key'; public const INDEX_FULLTEXT = 'fulltext'; public const INDEX_UNIQUE = 'unique'; public const INDEX_SPATIAL = 'spatial'; - public const ARRAY_INDEX_LENGTH = 255; + public const INDEX_HNSW_EUCLIDEAN = 'hnsw_euclidean'; + public const INDEX_HNSW_COSINE = 'hnsw_cosine'; + public const INDEX_HNSW_DOT = 'hnsw_dot'; + + // Max limits + public const MAX_INT = 2147483647; + public const MAX_BIG_INT = PHP_INT_MAX; + public const MAX_DOUBLE = PHP_FLOAT_MAX; + public const MAX_VECTOR_DIMENSIONS = 16000; + public const MAX_ARRAY_INDEX_LENGTH = 255; + + // Global SRID for geographic coordinates (WGS84) + public const DEFAULT_SRID = 4326; + public const EARTH_RADIUS = 6371000; // Relation Types public const RELATION_ONE_TO_ONE = 'oneToOne'; @@ -91,7 +106,6 @@ class Database // Orders public const ORDER_ASC = 'ASC'; public const ORDER_DESC = 'DESC'; - public const ORDER_RANDOM = 'RANDOM'; // Permissions @@ -490,7 +504,7 @@ function (mixed $value) { return $value; } try { - return self::encodeSpatialData($value, Database::VAR_POINT); + return self::encodeSpatialData($value, Database::VAR_POINT); } catch (\Throwable) { return $value; } @@ -518,7 +532,7 @@ function (mixed $value) { return $value; } try { - return self::encodeSpatialData($value, Database::VAR_LINESTRING); + return self::encodeSpatialData($value, Database::VAR_LINESTRING); } catch (\Throwable) { return $value; } @@ -546,7 +560,7 @@ function (mixed $value) { return $value; } try { - return self::encodeSpatialData($value, Database::VAR_POLYGON); + return self::encodeSpatialData($value, Database::VAR_POLYGON); } catch (\Throwable) { return $value; } @@ -562,6 +576,43 @@ function (?string $value) { return $this->adapter->decodePolygon($value); } ); + + self::addFilter( + Database::VAR_VECTOR, + /** + * @param mixed $value + * @return mixed + */ + function (mixed $value) { + if (!\is_array($value)) { + return $value; + } + if (!\array_is_list($value)) { + return $value; + } + foreach ($value as $item) { + if (!\is_int($item) && !\is_float($item)) { + return $value; + } + } + + return \json_encode(\array_map(\floatval(...), $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; + } + ); } /** @@ -1328,7 +1379,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)) { + if (in_array($attribute['type'], Database::SPATIAL_TYPES) || $attribute['type'] === Database::VAR_VECTOR) { $existingFilters = $attribute['filters'] ?? []; if (!is_array($existingFilters)) { $existingFilters = [$existingFilters]; @@ -1379,7 +1430,7 @@ public function createCollection(string $id, array $attributes = [], array $inde $isArray = $collectionAttribute->getAttribute('array', false); if ($isArray) { if ($this->adapter->getMaxIndexLength() > 0) { - $lengths[$i] = self::ARRAY_INDEX_LENGTH; + $lengths[$i] = self::MAX_ARRAY_INDEX_LENGTH; } $orders[$i] = null; } @@ -1408,9 +1459,9 @@ public function createCollection(string $id, array $attributes = [], array $inde $this->adapter->getMaxIndexLength(), $this->adapter->getInternalIndexesKeys(), $this->adapter->getSupportForIndexArray(), - $this->adapter->getSupportForSpatialAttributes(), $this->adapter->getSupportForSpatialIndexNull(), $this->adapter->getSupportForSpatialIndexOrder(), + $this->adapter->getSupportForVectors(), ); foreach ($indexes as $index) { if (!$validator->isValid($index)) { @@ -1700,6 +1751,10 @@ public function createAttribute(string $collection, string $id, string $type, in $filters[] = $type; $filters = array_unique($filters); } + if ($type === Database::VAR_VECTOR) { + $filters[] = $type; + $filters = array_unique($filters); + } $attribute = $this->validateAttribute( $collection, @@ -1964,8 +2019,51 @@ private function validateAttribute( throw new DatabaseException('Spatial attributes cannot be arrays'); } break; + case self::VAR_VECTOR: + if (!$this->adapter->getSupportForVectors()) { + throw new DatabaseException('Vector types are not supported by the current database'); + } + if ($array) { + throw new DatabaseException('Vector type cannot be an array'); + } + if ($size <= 0) { + throw new DatabaseException('Vector dimensions must be a positive integer'); + } + if ($size > self::MAX_VECTOR_DIMENSIONS) { + throw new DatabaseException('Vector dimensions cannot exceed ' . self::MAX_VECTOR_DIMENSIONS); + } + + // Validate default value if provided + if ($default !== null) { + if (!is_array($default)) { + throw new DatabaseException('Vector default value must be an array'); + } + if (count($default) !== $size) { + throw new DatabaseException('Vector default value must have exactly ' . $size . ' elements'); + } + foreach ($default as $component) { + if (!is_numeric($component)) { + throw new DatabaseException('Vector default value must contain only numeric elements'); + } + } + } + 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); + $supportedTypes = [ + self::VAR_STRING, + self::VAR_INTEGER, + self::VAR_FLOAT, + self::VAR_BOOLEAN, + self::VAR_DATETIME, + self::VAR_RELATIONSHIP + ]; + if ($this->adapter->getSupportForVectors()) { + $supportedTypes[] = self::VAR_VECTOR; + } + if ($this->adapter->getSupportForSpatialAttributes()) { + \array_push($supportedTypes, ...self::SPATIAL_TYPES); + } + throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes)); } // Only execute when $default is given @@ -2014,7 +2112,7 @@ protected function validateDefaultTypes(string $type, mixed $default): void } if ($defaultType === 'array') { - // spatial types require the array itself + // Spatial types require the array itself if (!in_array($type, Database::SPATIAL_TYPES)) { foreach ($default as $value) { $this->validateDefaultTypes($type, $value); @@ -2037,16 +2135,28 @@ protected function validateDefaultTypes(string $type, mixed $default): void throw new DatabaseException('Default value ' . $default . ' does not match given type ' . $type); } break; - case self::VAR_POINT: - case self::VAR_LINESTRING: - case self::VAR_POLYGON: - // Spatial types expect arrays as default values - if ($defaultType !== 'array') { - throw new DatabaseException('Default value for spatial type ' . $type . ' must be an array'); + case self::VAR_VECTOR: + // When validating individual vector components (from recursion), they should be numeric + if ($defaultType !== 'double' && $defaultType !== 'integer') { + throw new DatabaseException('Vector components must be numeric values (float or integer)'); } - // 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); + $supportedTypes = [ + self::VAR_STRING, + self::VAR_INTEGER, + self::VAR_FLOAT, + self::VAR_BOOLEAN, + self::VAR_DATETIME, + self::VAR_RELATIONSHIP + ]; + if ($this->adapter->getSupportForVectors()) { + $supportedTypes[] = self::VAR_VECTOR; + } + if ($this->adapter->getSupportForSpatialAttributes()) { + \array_push($supportedTypes, ...self::SPATIAL_TYPES); + } + throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes)); } } @@ -2306,8 +2416,49 @@ public function updateAttribute(string $collection, string $id, ?string $type = throw new DatabaseException('Spatial attributes cannot be arrays'); } break; + case self::VAR_VECTOR: + if (!$this->adapter->getSupportForVectors()) { + throw new DatabaseException('Vector types are not supported by the current database'); + } + if ($array) { + throw new DatabaseException('Vector type cannot be an array'); + } + if ($size <= 0) { + throw new DatabaseException('Vector dimensions must be a positive integer'); + } + if ($size > self::MAX_VECTOR_DIMENSIONS) { + throw new DatabaseException('Vector dimensions cannot exceed ' . self::MAX_VECTOR_DIMENSIONS); + } + if ($default !== null) { + if (!\is_array($default)) { + throw new DatabaseException('Vector default value must be an array'); + } + if (\count($default) !== $size) { + throw new DatabaseException('Vector default value must have exactly ' . $size . ' elements'); + } + foreach ($default as $component) { + if (!\is_int($component) && !\is_float($component)) { + throw new DatabaseException('Vector default value must contain only numeric elements'); + } + } + } + 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); + $supportedTypes = [ + self::VAR_STRING, + self::VAR_INTEGER, + self::VAR_FLOAT, + self::VAR_BOOLEAN, + self::VAR_DATETIME, + self::VAR_RELATIONSHIP + ]; + if ($this->adapter->getSupportForVectors()) { + $supportedTypes[] = self::VAR_VECTOR; + } + if ($this->adapter->getSupportForSpatialAttributes()) { + \array_push($supportedTypes, ...self::SPATIAL_TYPES); + } + throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes)); } /** Ensure required filters for the attribute are passed */ @@ -2417,9 +2568,9 @@ public function updateAttribute(string $collection, string $id, ?string $type = $this->adapter->getMaxIndexLength(), $this->adapter->getInternalIndexesKeys(), $this->adapter->getSupportForIndexArray(), - $this->adapter->getSupportForSpatialAttributes(), $this->adapter->getSupportForSpatialIndexNull(), $this->adapter->getSupportForSpatialIndexOrder(), + $this->adapter->getSupportForVectors(), ); foreach ($indexes as $index) { @@ -3285,19 +3436,25 @@ public function createIndex(string $collection, string $id, string $type, array } break; + case Database::INDEX_HNSW_EUCLIDEAN: + case Database::INDEX_HNSW_COSINE: + case Database::INDEX_HNSW_DOT: + if (!$this->adapter->getSupportForVectors()) { + throw new DatabaseException('Vector 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_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT); } /** @var array $collectionAttributes */ $collectionAttributes = $collection->getAttribute('attributes', []); $indexAttributesWithTypes = []; - $indexAttributesRequired = []; foreach ($attributes as $i => $attr) { foreach ($collectionAttributes as $collectionAttribute) { if ($collectionAttribute->getAttribute('key') === $attr) { $indexAttributesWithTypes[$attr] = $collectionAttribute->getAttribute('type'); - $indexAttributesRequired[$attr] = $collectionAttribute->getAttribute('required', false); /** * mysql does not save length in collection when length = attributes size @@ -3311,7 +3468,7 @@ public function createIndex(string $collection, string $id, string $type, array $isArray = $collectionAttribute->getAttribute('array', false); if ($isArray) { if ($this->adapter->getMaxIndexLength() > 0) { - $lengths[$i] = self::ARRAY_INDEX_LENGTH; + $lengths[$i] = self::MAX_ARRAY_INDEX_LENGTH; } $orders[$i] = null; } @@ -3320,29 +3477,6 @@ public function createIndex(string $collection, string $id, string $type, array } } - // Validate spatial index constraints - if ($type === self::INDEX_SPATIAL) { - foreach ($attributes as $attr) { - if (!isset($indexAttributesWithTypes[$attr])) { - throw new IndexException('Attribute "' . $attr . '" not found in collection'); - } - - $attributeType = $indexAttributesWithTypes[$attr]; - if (!in_array($attributeType, [self::VAR_POINT, self::VAR_LINESTRING, self::VAR_POLYGON])) { - throw new IndexException('Spatial index can only be created on spatial attributes (point, linestring, polygon). Attribute "' . $attr . '" is of type "' . $attributeType . '"'); - } - } - - // Check spatial index null constraints for adapters that don't support null values - if (!$this->adapter->getSupportForSpatialIndexNull()) { - foreach ($attributes as $attr) { - if (!$indexAttributesRequired[$attr]) { - throw new IndexException('Spatial indexes do not allow null values. Mark the attribute "' . $attr . '" as required or create the index on a column with no null values.'); - } - } - } - } - $index = new Document([ '$id' => ID::custom($id), 'key' => $id, @@ -3360,9 +3494,9 @@ public function createIndex(string $collection, string $id, string $type, array $this->adapter->getMaxIndexLength(), $this->adapter->getInternalIndexesKeys(), $this->adapter->getSupportForIndexArray(), - $this->adapter->getSupportForSpatialAttributes(), $this->adapter->getSupportForSpatialIndexNull(), $this->adapter->getSupportForSpatialIndexOrder(), + $this->adapter->getSupportForVectors(), ); if (!$validator->isValid($index)) { throw new IndexException($validator->getDescription()); @@ -3464,7 +3598,7 @@ public function getDocument(string $collection, string $id, array $queries = [], $attributes = $collection->getAttribute('attributes', []); - $this->checkQueriesType($queries); + $this->checkQueryTypes($queries); if ($this->validate) { $validator = new DocumentValidator($attributes); @@ -4972,7 +5106,7 @@ public function updateDocuments( $attributes = $collection->getAttribute('attributes', []); $indexes = $collection->getAttribute('indexes', []); - $this->checkQueriesType($queries); + $this->checkQueryTypes($queries); if ($this->validate) { $validator = new DocumentsValidator( @@ -6511,7 +6645,7 @@ public function deleteDocuments( $attributes = $collection->getAttribute('attributes', []); $indexes = $collection->getAttribute('indexes', []); - $this->checkQueriesType($queries); + $this->checkQueryTypes($queries); if ($this->validate) { $validator = new DocumentsValidator( @@ -6708,7 +6842,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $attributes = $collection->getAttribute('attributes', []); $indexes = $collection->getAttribute('indexes', []); - $this->checkQueriesType($queries); + $this->checkQueryTypes($queries); if ($this->validate) { $validator = new DocumentsValidator( @@ -6931,7 +7065,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) $attributes = $collection->getAttribute('attributes', []); $indexes = $collection->getAttribute('indexes', []); - $this->checkQueriesType($queries); + $this->checkQueryTypes($queries); if ($this->validate) { $validator = new DocumentsValidator( @@ -6995,7 +7129,7 @@ public function sum(string $collection, string $attribute, array $queries = [], $attributes = $collection->getAttribute('attributes', []); $indexes = $collection->getAttribute('indexes', []); - $this->checkQueriesType($queries); + $this->checkQueryTypes($queries); if ($this->validate) { $validator = new DocumentsValidator( @@ -7185,8 +7319,7 @@ public function decode(Document $collection, Document $document, array $selectio $value = (is_null($value)) ? [] : $value; foreach ($value as $index => $node) { - - foreach (array_reverse($filters) as $filter) { + foreach (\array_reverse($filters) as $filter) { $node = $this->decodeAttribute($filter, $node, $document, $key); } $value[$index] = $node; @@ -7596,7 +7729,7 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a * @return void * @throws QueryException */ - private function checkQueriesType(array $queries): void + private function checkQueryTypes(array $queries): void { foreach ($queries as $query) { if (!$query instanceof Query) { @@ -7604,7 +7737,7 @@ private function checkQueriesType(array $queries): void } if ($query->isNested()) { - $this->checkQueriesType($query->getValues()); + $this->checkQueryTypes($query->getValues()); } } } diff --git a/src/Database/Query.php b/src/Database/Query.php index 47be58c12..c7f96deec 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -27,7 +27,7 @@ class Query public const TYPE_ENDS_WITH = 'endsWith'; public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; - // General spatial method constants (for spatial-only operations) + // Spatial methods public const TYPE_CROSSES = 'crosses'; public const TYPE_NOT_CROSSES = 'notCrosses'; public const TYPE_DISTANCE_EQUAL = 'distanceEqual'; @@ -41,6 +41,11 @@ class Query public const TYPE_TOUCHES = 'touches'; public const TYPE_NOT_TOUCHES = 'notTouches'; + // Vector query methods + public const TYPE_VECTOR_DOT = 'vectorDot'; + public const TYPE_VECTOR_COSINE = 'vectorCosine'; + public const TYPE_VECTOR_EUCLIDEAN = 'vectorEuclidean'; + public const TYPE_SELECT = 'select'; // Order methods @@ -91,6 +96,9 @@ class Query self::TYPE_NOT_OVERLAPS, self::TYPE_TOUCHES, self::TYPE_NOT_TOUCHES, + self::TYPE_VECTOR_DOT, + self::TYPE_VECTOR_COSINE, + self::TYPE_VECTOR_EUCLIDEAN, self::TYPE_SELECT, self::TYPE_ORDER_DESC, self::TYPE_ORDER_ASC, @@ -103,6 +111,12 @@ class Query self::TYPE_OR, ]; + public const VECTOR_TYPES = [ + self::TYPE_VECTOR_DOT, + self::TYPE_VECTOR_COSINE, + self::TYPE_VECTOR_EUCLIDEAN, + ]; + protected const LOGICAL_TYPES = [ self::TYPE_AND, self::TYPE_OR, @@ -277,7 +291,10 @@ public static function isMethod(string $value): bool self::TYPE_NOT_TOUCHES, self::TYPE_OR, self::TYPE_AND, - self::TYPE_SELECT => true, + self::TYPE_SELECT, + self::TYPE_VECTOR_DOT, + self::TYPE_VECTOR_COSINE, + self::TYPE_VECTOR_EUCLIDEAN => true, default => false, }; } @@ -857,7 +874,7 @@ public static function groupByType(array $queries): array break; case Query::TYPE_LIMIT: - // keep the 1st limit encountered and ignore the rest + // Keep the 1st limit encountered and ignore the rest if ($limit !== null) { break; } @@ -1113,4 +1130,40 @@ public static function notTouches(string $attribute, array $values): self { return new self(self::TYPE_NOT_TOUCHES, $attribute, [$values]); } + + /** + * Helper method to create Query with vectorDot method + * + * @param string $attribute + * @param array $vector + * @return Query + */ + public static function vectorDot(string $attribute, array $vector): self + { + return new self(self::TYPE_VECTOR_DOT, $attribute, [$vector]); + } + + /** + * Helper method to create Query with vectorCosine method + * + * @param string $attribute + * @param array $vector + * @return Query + */ + public static function vectorCosine(string $attribute, array $vector): self + { + return new self(self::TYPE_VECTOR_COSINE, $attribute, [$vector]); + } + + /** + * Helper method to create Query with vectorEuclidean method + * + * @param string $attribute + * @param array $vector + * @return Query + */ + public static function vectorEuclidean(string $attribute, array $vector): self + { + return new self(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]); + } } diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index bab80c173..7ee5f151b 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -10,45 +10,31 @@ class Index extends Validator { protected string $message = 'Invalid index'; - protected int $maxLength; /** * @var array $attributes */ protected array $attributes; - /** - * @var array $reservedKeys - */ - protected array $reservedKeys; - - protected bool $arrayIndexSupport; - - protected bool $spatialIndexSupport; - - protected bool $spatialIndexNullSupport; - - protected bool $spatialIndexOrderSupport; - /** * @param array $attributes * @param int $maxLength * @param array $reservedKeys - * @param bool $arrayIndexSupport - * @param bool $spatialIndexSupport - * @param bool $spatialIndexNullSupport - * @param bool $spatialIndexOrderSupport + * @param bool $supportForArrayIndexes + * @param bool $supportForSpatialIndexNull + * @param bool $supportForSpatialIndexOrder + * @param bool $supportForVectorIndexes * @throws DatabaseException */ - public function __construct(array $attributes, int $maxLength, array $reservedKeys = [], bool $arrayIndexSupport = false, bool $spatialIndexSupport = false, bool $spatialIndexNullSupport = false, bool $spatialIndexOrderSupport = false) - { - $this->maxLength = $maxLength; - $this->reservedKeys = $reservedKeys; - $this->arrayIndexSupport = $arrayIndexSupport; - $this->spatialIndexSupport = $spatialIndexSupport; - $this->spatialIndexNullSupport = $spatialIndexNullSupport; - $this->spatialIndexOrderSupport = $spatialIndexOrderSupport; - + public function __construct( + array $attributes, + protected int $maxLength, + protected array $reservedKeys = [], + protected bool $supportForArrayIndexes = false, + protected bool $supportForSpatialIndexNull = false, + protected bool $supportForSpatialIndexOrder = false, + protected bool $supportForVectorIndexes = false, + ) { foreach ($attributes as $attribute) { $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); $this->attributes[$key] = $attribute; @@ -169,16 +155,16 @@ public function checkArrayIndex(Document $index): bool $direction = $orders[$attributePosition] ?? ''; if (!empty($direction)) { - $this->message = 'Invalid index order "' . $direction . '" on array attribute "'. $attribute->getAttribute('key', '') .'"'; + $this->message = 'Invalid index order "' . $direction . '" on array attribute "' . $attribute->getAttribute('key', '') . '"'; return false; } - if ($this->arrayIndexSupport === false) { + if ($this->supportForArrayIndexes === false) { $this->message = 'Indexing an array attribute is not supported'; return false; } } elseif ($attribute->getAttribute('type') !== Database::VAR_STRING && !empty($lengths[$attributePosition])) { - $this->message = 'Cannot set a length on "'. $attribute->getAttribute('type') . '" attributes'; + $this->message = 'Cannot set a length on "' . $attribute->getAttribute('type') . '" attributes'; return false; } } @@ -225,8 +211,8 @@ public function checkIndexLength(Document $index): bool } if ($attribute->getAttribute('array', false)) { - $attributeSize = Database::ARRAY_INDEX_LENGTH; - $indexLength = Database::ARRAY_INDEX_LENGTH; + $attributeSize = Database::MAX_ARRAY_INDEX_LENGTH; + $indexLength = Database::MAX_ARRAY_INDEX_LENGTH; } if ($indexLength > $attributeSize) { @@ -263,6 +249,123 @@ public function checkReservedNames(Document $index): bool return true; } + /** + * @param Document $index + * @return bool + */ + public function checkSpatialIndex(Document $index): bool + { + $type = $index->getAttribute('type'); + + if ($type !== Database::INDEX_SPATIAL) { + return true; + } + + $attributes = $index->getAttribute('attributes', []); + $orders = $index->getAttribute('orders', []); + + if (\count($attributes) !== 1) { + $this->message = 'Spatial index must have exactly one attribute'; + return false; + } + + foreach ($attributes as $attributeName) { + $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); + $attributeType = $attribute->getAttribute('type', ''); + + if (!\in_array($attributeType, Database::SPATIAL_TYPES, true)) { + $this->message = 'Spatial index can only be created on spatial attributes (point, linestring, polygon). Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; + return false; + } + + $required = (bool)$attribute->getAttribute('required', false); + if (!$required && !$this->supportForSpatialIndexNull) { + $this->message = 'Spatial indexes do not allow null values. Mark the attribute "' . $attributeName . '" as required or create the index on a column with no null values.'; + return false; + } + } + + if (!empty($orders) && !$this->supportForSpatialIndexOrder) { + $this->message = 'Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'; + return false; + } + + return true; + } + + /** + * @param Document $index + * @return bool + */ + public function checkNonSpatialIndexOnSpatialAttribute(Document $index): bool + { + $type = $index->getAttribute('type'); + + // Skip check for spatial indexes + if ($type === Database::INDEX_SPATIAL) { + return true; + } + + $attributes = $index->getAttribute('attributes', []); + + foreach ($attributes as $attributeName) { + $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); + $attributeType = $attribute->getAttribute('type', ''); + + if (\in_array($attributeType, Database::SPATIAL_TYPES, true)) { + $this->message = 'Cannot create ' . $type . ' index on spatial attribute "' . $attributeName . '". Spatial attributes require spatial indexes.'; + return false; + } + } + + return true; + } + + /** + * @param Document $index + * @return bool + * @throws DatabaseException + */ + public function checkVectorIndex(Document $index): bool + { + $type = $index->getAttribute('type'); + + if ( + $type !== Database::INDEX_HNSW_DOT && + $type !== Database::INDEX_HNSW_COSINE && + $type !== Database::INDEX_HNSW_EUCLIDEAN + ) { + return true; + } + + if ($this->supportForVectorIndexes === false) { + $this->message = 'Vector indexes are not supported'; + return false; + } + + $attributes = $index->getAttribute('attributes', []); + + if (\count($attributes) !== 1) { + $this->message = 'Vector index must have exactly one attribute'; + return false; + } + + $attribute = $this->attributes[\strtolower($attributes[0])] ?? new Document(); + if ($attribute->getAttribute('type') !== Database::VAR_VECTOR) { + $this->message = 'Vector index can only be created on vector attributes'; + return false; + } + + $orders = $index->getAttribute('orders', []); + $lengths = $index->getAttribute('lengths', []); + if (!empty($orders) || \count(\array_filter($lengths)) > 0) { + $this->message = 'Vector indexes do not support orders or lengths'; + return false; + } + + return true; + } + /** * Is valid. * @@ -276,35 +379,33 @@ public function isValid($value): bool if (!$this->checkAttributesNotFound($value)) { return false; } - if (!$this->checkEmptyIndexAttributes($value)) { return false; } - if (!$this->checkDuplicatedAttributes($value)) { return false; } - if (!$this->checkFulltextIndexNonString($value)) { return false; } - if (!$this->checkArrayIndex($value)) { return false; } - if (!$this->checkIndexLength($value)) { return false; } - if (!$this->checkReservedNames($value)) { return false; } - if (!$this->checkSpatialIndex($value)) { return false; } - + if (!$this->checkNonSpatialIndexOnSpatialAttribute($value)) { + return false; + } + if (!$this->checkVectorIndex($value)) { + return false; + } return true; } @@ -331,53 +432,4 @@ public function getType(): string { return self::TYPE_OBJECT; } - - /** - * @param Document $index - * @return bool - */ - public function checkSpatialIndex(Document $index): bool - { - $type = $index->getAttribute('type'); - - $attributes = $index->getAttribute('attributes', []); - $orders = $index->getAttribute('orders', []); - - foreach ($attributes as $attributeName) { - $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); - $attributeType = $attribute->getAttribute('type', ''); - - if (!\in_array($attributeType, Database::SPATIAL_TYPES, true)) { - continue; - } - - if (!$this->spatialIndexSupport) { - $this->message = 'Spatial indexes are not supported'; - return false; - } - - if (count($attributes) !== 1) { - $this->message = 'Spatial index can be created on a single spatial attribute'; - return false; - } - - if ($type !== Database::INDEX_SPATIAL) { - $this->message = 'Spatial index can only be created on spatial attributes (point, linestring, polygon). Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; - return false; - } - $required = (bool) $attribute->getAttribute('required', false); - if (!$required && !$this->spatialIndexNullSupport) { - $this->message = 'Spatial indexes do not allow null values. Mark the attribute "' . $attributeName . '" as required or create the index on a column with no null values.'; - return false; - } - - if (!empty($orders) && !$this->spatialIndexOrderSupport) { - $this->message = 'Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'; - return false; - } - } - - - return true; - } } diff --git a/src/Database/Validator/IndexedQueries.php b/src/Database/Validator/IndexedQueries.php index 8e324b215..a24e0d21d 100644 --- a/src/Database/Validator/IndexedQueries.php +++ b/src/Database/Validator/IndexedQueries.php @@ -56,6 +56,29 @@ public function __construct(array $attributes = [], array $indexes = [], array $ parent::__construct($validators); } + /** + * Count vector queries across entire query tree + * + * @param array $queries + * @return int + */ + private function countVectorQueries(array $queries): int + { + $count = 0; + + foreach ($queries as $query) { + if (in_array($query->getMethod(), Query::VECTOR_TYPES)) { + $count++; + } + + if ($query->isNested()) { + $count += $this->countVectorQueries($query->getValues()); + } + } + + return $count; + } + /** * @param mixed $value * @return bool @@ -87,6 +110,12 @@ public function isValid($value): bool $queries[] = $query; } + $vectorQueryCount = $this->countVectorQueries($queries); + if ($vectorQueryCount > 1) { + $this->message = 'Cannot use multiple vector queries in a single request'; + return false; + } + $grouped = Query::groupByType($queries); $filters = $grouped['filters']; diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 727c9eed7..8066228e3 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -118,7 +118,10 @@ public function isValid($value): bool Query::TYPE_OVERLAPS, Query::TYPE_NOT_OVERLAPS, Query::TYPE_TOUCHES, - Query::TYPE_NOT_TOUCHES => Base::METHOD_TYPE_FILTER, + Query::TYPE_NOT_TOUCHES, + Query::TYPE_VECTOR_DOT, + Query::TYPE_VECTOR_COSINE, + Query::TYPE_VECTOR_EUCLIDEAN => Base::METHOD_TYPE_FILTER, default => '', }; diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 5bc973f22..dd50cec3c 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -156,6 +156,25 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s } continue 2; + case Database::VAR_VECTOR: + // For vector queries, validate that the value is an array of floats + if (!is_array($value)) { + $this->message = 'Vector query value must be an array'; + return false; + } + foreach ($value as $component) { + if (!is_numeric($component)) { + $this->message = 'Vector query value must contain only numeric values'; + return false; + } + } + // Check size match + $expectedSize = $attributeSchema['size'] ?? 0; + if (count($value) !== $expectedSize) { + $this->message = "Vector query value must have {$expectedSize} elements"; + return false; + } + continue 2; default: $this->message = 'Unknown Data type'; return false; @@ -216,6 +235,18 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s return false; } + // Vector queries can only be used on vector attributes (not arrays) + if (\in_array($method, Query::VECTOR_TYPES)) { + if ($attributeSchema['type'] !== Database::VAR_VECTOR) { + $this->message = 'Vector queries can only be used on vector attributes'; + return false; + } + if ($array) { + $this->message = 'Vector queries cannot be used on array attributes'; + return false; + } + } + return true; } @@ -302,6 +333,32 @@ public function isValid($value): bool case Query::TYPE_IS_NOT_NULL: return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); + case Query::TYPE_VECTOR_DOT: + case Query::TYPE_VECTOR_COSINE: + case Query::TYPE_VECTOR_EUCLIDEAN: + // Validate that the attribute is a vector type + if (!$this->isValidAttribute($attribute)) { + return false; + } + + // Handle dotted attributes (relationships) + $attributeKey = $attribute; + if (\str_contains($attributeKey, '.') && !isset($this->schema[$attributeKey])) { + $attributeKey = \explode('.', $attributeKey)[0]; + } + + $attributeSchema = $this->schema[$attributeKey]; + if ($attributeSchema['type'] !== Database::VAR_VECTOR) { + $this->message = 'Vector queries can only be used on vector attributes'; + return false; + } + + if (count($value->getValues()) != 1) { + $this->message = \ucfirst($method) . ' queries require exactly one vector value.'; + return false; + } + + return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); case Query::TYPE_OR: case Query::TYPE_AND: $filters = Query::groupByType($value->getValues())['filters']; diff --git a/src/Database/Validator/Sequence.php b/src/Database/Validator/Sequence.php index 305632727..e10aa4b43 100644 --- a/src/Database/Validator/Sequence.php +++ b/src/Database/Validator/Sequence.php @@ -46,12 +46,12 @@ public function isValid($value): bool } switch ($this->idAttributeType) { - case Database::VAR_OBJECT_ID: + case Database::VAR_UUID: return preg_match('/^[a-f0-9]{24}$/i', $value) === 1; case Database::VAR_INTEGER: $start = ($this->primary) ? 1 : 0; - $validator = new Range($start, Database::BIG_INT_MAX, Database::VAR_INTEGER); + $validator = new Range($start, Database::MAX_BIG_INT, Database::VAR_INTEGER); return $validator->isValid($value); default: diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index cfb12fa3a..18b105525 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -327,7 +327,7 @@ protected function checkForInvalidAttributeValues(array $structure, array $keys) case Database::VAR_INTEGER: // We need both Integer and Range because Range implicitly casts non-numeric values $validators[] = new Integer(); - $max = $size >= 8 ? Database::BIG_INT_MAX : Database::INT_MAX; + $max = $size >= 8 ? Database::MAX_BIG_INT : Database::MAX_INT; $min = $signed ? -$max : 0; $validators[] = new Range($min, $max, Database::VAR_INTEGER); break; @@ -335,8 +335,8 @@ protected function checkForInvalidAttributeValues(array $structure, array $keys) case Database::VAR_FLOAT: // We need both Float and Range because Range implicitly casts non-numeric values $validators[] = new FloatValidator(); - $min = $signed ? -Database::DOUBLE_MAX : 0; - $validators[] = new Range($min, Database::DOUBLE_MAX, Database::VAR_FLOAT); + $min = $signed ? -Database::MAX_DOUBLE : 0; + $validators[] = new Range($min, Database::MAX_DOUBLE, Database::VAR_FLOAT); break; case Database::VAR_BOOLEAN: @@ -356,6 +356,10 @@ protected function checkForInvalidAttributeValues(array $structure, array $keys) $validators[] = new Spatial($type); break; + case Database::VAR_VECTOR: + $validators[] = new Vector($attribute['size'] ?? 0); + break; + default: $this->message = 'Unknown attribute type "'.$type.'"'; return false; diff --git a/src/Database/Validator/Vector.php b/src/Database/Validator/Vector.php new file mode 100644 index 000000000..b81d0b3aa --- /dev/null +++ b/src/Database/Validator/Vector.php @@ -0,0 +1,96 @@ +size = $size; + } + + /** + * Get Description + * + * Returns validator description + * + * @return string + */ + public function getDescription(): string + { + return "Value must be an array of {$this->size} numeric values"; + } + + /** + * Is valid + * + * Validation will pass when $value is a valid vector array or JSON string + * + * @param mixed $value + * @return bool + */ + public function isValid(mixed $value): bool + { + if (is_string($value)) { + $decoded = json_decode($value, true); + if (!is_array($decoded)) { + return false; + } + $value = $decoded; + } + + if (!is_array($value)) { + return false; + } + + if (!\array_is_list($value)) { + return false; + } + + if (count($value) !== $this->size) { + return false; + } + + // Check that all values are int or float (not strings, booleans, null, arrays, objects) + foreach ($value as $component) { + if (!\is_int($component) && !\is_float($component)) { + return false; + } + } + + return true; + } + + /** + * Is array + * + * Function will return true if object is array. + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + * + * @return string + */ + public function getType(): string + { + return self::TYPE_ARRAY; + } +} diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 37ad7cce3..bb5f8d20c 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -11,6 +11,7 @@ use Tests\E2E\Adapter\Scopes\PermissionTests; use Tests\E2E\Adapter\Scopes\RelationshipTests; use Tests\E2E\Adapter\Scopes\SpatialTests; +use Tests\E2E\Adapter\Scopes\VectorTests; use Utopia\Database\Database; use Utopia\Database\Validator\Authorization; @@ -25,6 +26,7 @@ abstract class Base extends TestCase use PermissionTests; use RelationshipTests; use SpatialTests; + use VectorTests; use GeneralTests; protected static string $namespace; diff --git a/tests/e2e/Adapter/PostgresTest.php b/tests/e2e/Adapter/PostgresTest.php index 4e63eea81..9e5fb30f5 100644 --- a/tests/e2e/Adapter/PostgresTest.php +++ b/tests/e2e/Adapter/PostgresTest.php @@ -70,4 +70,5 @@ protected static function deleteIndex(string $collection, string $index): bool return true; } + } diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 5178a414d..35592417c 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -716,7 +716,7 @@ public function testCreateCollectionWithSchemaIndexes(): void if ($database->getAdapter()->getSupportForIndexArray()) { $this->assertEquals($collection->getAttribute('indexes')[2]['attributes'][0], 'cards'); - $this->assertEquals($collection->getAttribute('indexes')[2]['lengths'][0], Database::ARRAY_INDEX_LENGTH); + $this->assertEquals($collection->getAttribute('indexes')[2]['lengths'][0], Database::MAX_ARRAY_INDEX_LENGTH); $this->assertEquals($collection->getAttribute('indexes')[2]['orders'][0], null); } } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 76c9231fc..37495b7aa 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -83,10 +83,10 @@ public function testCreateDocument(): Document Permission::delete(Role::user(ID::custom('2x'))), ], 'string' => 'text📝', - 'integer_signed' => -Database::INT_MAX, - 'integer_unsigned' => Database::INT_MAX, - 'bigint_signed' => -Database::BIG_INT_MAX, - 'bigint_unsigned' => Database::BIG_INT_MAX, + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, 'float_signed' => -5.55, 'float_unsigned' => 5.55, 'boolean' => true, @@ -100,13 +100,13 @@ public function testCreateDocument(): Document $this->assertIsString($document->getAttribute('string')); $this->assertEquals('text📝', $document->getAttribute('string')); // Also makes sure an emoji is working $this->assertIsInt($document->getAttribute('integer_signed')); - $this->assertEquals(-Database::INT_MAX, $document->getAttribute('integer_signed')); + $this->assertEquals(-Database::MAX_INT, $document->getAttribute('integer_signed')); $this->assertIsInt($document->getAttribute('integer_unsigned')); - $this->assertEquals(Database::INT_MAX, $document->getAttribute('integer_unsigned')); + $this->assertEquals(Database::MAX_INT, $document->getAttribute('integer_unsigned')); $this->assertIsInt($document->getAttribute('bigint_signed')); - $this->assertEquals(-Database::BIG_INT_MAX, $document->getAttribute('bigint_signed')); + $this->assertEquals(-Database::MAX_BIG_INT, $document->getAttribute('bigint_signed')); $this->assertIsInt($document->getAttribute('bigint_signed')); - $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint_unsigned')); + $this->assertEquals(Database::MAX_BIG_INT, $document->getAttribute('bigint_unsigned')); $this->assertIsFloat($document->getAttribute('float_signed')); $this->assertEquals(-5.55, $document->getAttribute('float_signed')); $this->assertIsFloat($document->getAttribute('float_unsigned')); @@ -139,10 +139,10 @@ public function testCreateDocument(): Document Permission::delete(Role::user(ID::custom('2x'))), ], 'string' => 'text📝', - 'integer_signed' => -Database::INT_MAX, - 'integer_unsigned' => Database::INT_MAX, - 'bigint_signed' => -Database::BIG_INT_MAX, - 'bigint_unsigned' => Database::BIG_INT_MAX, + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, 'float_signed' => -5.55, 'float_unsigned' => 5.55, 'boolean' => true, @@ -156,13 +156,13 @@ public function testCreateDocument(): Document $this->assertIsString($manualIdDocument->getAttribute('string')); $this->assertEquals('text📝', $manualIdDocument->getAttribute('string')); // Also makes sure an emoji is working $this->assertIsInt($manualIdDocument->getAttribute('integer_signed')); - $this->assertEquals(-Database::INT_MAX, $manualIdDocument->getAttribute('integer_signed')); + $this->assertEquals(-Database::MAX_INT, $manualIdDocument->getAttribute('integer_signed')); $this->assertIsInt($manualIdDocument->getAttribute('integer_unsigned')); - $this->assertEquals(Database::INT_MAX, $manualIdDocument->getAttribute('integer_unsigned')); + $this->assertEquals(Database::MAX_INT, $manualIdDocument->getAttribute('integer_unsigned')); $this->assertIsInt($manualIdDocument->getAttribute('bigint_signed')); - $this->assertEquals(-Database::BIG_INT_MAX, $manualIdDocument->getAttribute('bigint_signed')); + $this->assertEquals(-Database::MAX_BIG_INT, $manualIdDocument->getAttribute('bigint_signed')); $this->assertIsInt($manualIdDocument->getAttribute('bigint_unsigned')); - $this->assertEquals(Database::BIG_INT_MAX, $manualIdDocument->getAttribute('bigint_unsigned')); + $this->assertEquals(Database::MAX_BIG_INT, $manualIdDocument->getAttribute('bigint_unsigned')); $this->assertIsFloat($manualIdDocument->getAttribute('float_signed')); $this->assertEquals(-5.55, $manualIdDocument->getAttribute('float_signed')); $this->assertIsFloat($manualIdDocument->getAttribute('float_unsigned')); @@ -182,13 +182,13 @@ public function testCreateDocument(): Document $this->assertIsString($manualIdDocument->getAttribute('string')); $this->assertEquals('text📝', $manualIdDocument->getAttribute('string')); // Also makes sure an emoji is working $this->assertIsInt($manualIdDocument->getAttribute('integer_signed')); - $this->assertEquals(-Database::INT_MAX, $manualIdDocument->getAttribute('integer_signed')); + $this->assertEquals(-Database::MAX_INT, $manualIdDocument->getAttribute('integer_signed')); $this->assertIsInt($manualIdDocument->getAttribute('integer_unsigned')); - $this->assertEquals(Database::INT_MAX, $manualIdDocument->getAttribute('integer_unsigned')); + $this->assertEquals(Database::MAX_INT, $manualIdDocument->getAttribute('integer_unsigned')); $this->assertIsInt($manualIdDocument->getAttribute('bigint_signed')); - $this->assertEquals(-Database::BIG_INT_MAX, $manualIdDocument->getAttribute('bigint_signed')); + $this->assertEquals(-Database::MAX_BIG_INT, $manualIdDocument->getAttribute('bigint_signed')); $this->assertIsInt($manualIdDocument->getAttribute('bigint_unsigned')); - $this->assertEquals(Database::BIG_INT_MAX, $manualIdDocument->getAttribute('bigint_unsigned')); + $this->assertEquals(Database::MAX_BIG_INT, $manualIdDocument->getAttribute('bigint_unsigned')); $this->assertIsFloat($manualIdDocument->getAttribute('float_signed')); $this->assertEquals(-5.55, $manualIdDocument->getAttribute('float_signed')); $this->assertIsFloat($manualIdDocument->getAttribute('float_unsigned')); @@ -386,7 +386,7 @@ public function testCreateDocuments(): void ], 'string' => 'text📝', 'integer' => 5, - 'bigint' => Database::BIG_INT_MAX, + 'bigint' => Database::MAX_BIG_INT, ]); } @@ -625,7 +625,7 @@ public function testUpsertDocuments(): void '$id' => 'first', 'string' => 'text📝', 'integer' => 5, - 'bigint' => Database::BIG_INT_MAX, + 'bigint' => Database::MAX_BIG_INT, '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -637,7 +637,7 @@ public function testUpsertDocuments(): void '$id' => 'second', 'string' => 'text📝', 'integer' => 5, - 'bigint' => Database::BIG_INT_MAX, + 'bigint' => Database::MAX_BIG_INT, '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -668,7 +668,7 @@ public function testUpsertDocuments(): void $this->assertIsInt($document->getAttribute('integer')); $this->assertEquals(5, $document->getAttribute('integer')); $this->assertIsInt($document->getAttribute('bigint')); - $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint')); + $this->assertEquals(Database::MAX_BIG_INT, $document->getAttribute('bigint')); } $documents = $database->find(__FUNCTION__); @@ -682,7 +682,7 @@ public function testUpsertDocuments(): void $this->assertIsInt($document->getAttribute('integer')); $this->assertEquals(5, $document->getAttribute('integer')); $this->assertIsInt($document->getAttribute('bigint')); - $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint')); + $this->assertEquals(Database::MAX_BIG_INT, $document->getAttribute('bigint')); } $documents[0]->setAttribute('string', 'new text📝'); @@ -705,7 +705,7 @@ public function testUpsertDocuments(): void $this->assertIsInt($document->getAttribute('integer')); $this->assertEquals(10, $document->getAttribute('integer')); $this->assertIsInt($document->getAttribute('bigint')); - $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint')); + $this->assertEquals(Database::MAX_BIG_INT, $document->getAttribute('bigint')); } $documents = $database->find(__FUNCTION__); @@ -720,7 +720,7 @@ public function testUpsertDocuments(): void $this->assertIsInt($document->getAttribute('integer')); $this->assertEquals(10, $document->getAttribute('integer')); $this->assertIsInt($document->getAttribute('bigint')); - $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint')); + $this->assertEquals(Database::MAX_BIG_INT, $document->getAttribute('bigint')); } } @@ -1333,7 +1333,7 @@ public function testGetDocument(Document $document): Document $this->assertIsString($document->getAttribute('string')); $this->assertEquals('text📝', $document->getAttribute('string')); $this->assertIsInt($document->getAttribute('integer_signed')); - $this->assertEquals(-Database::INT_MAX, $document->getAttribute('integer_signed')); + $this->assertEquals(-Database::MAX_INT, $document->getAttribute('integer_signed')); $this->assertIsFloat($document->getAttribute('float_signed')); $this->assertEquals(-5.55, $document->getAttribute('float_signed')); $this->assertIsFloat($document->getAttribute('float_unsigned')); @@ -1365,7 +1365,7 @@ public function testGetDocumentSelect(Document $document): Document $this->assertIsString($document->getAttribute('string')); $this->assertEquals('text📝', $document->getAttribute('string')); $this->assertIsInt($document->getAttribute('integer_signed')); - $this->assertEquals(-Database::INT_MAX, $document->getAttribute('integer_signed')); + $this->assertEquals(-Database::MAX_INT, $document->getAttribute('integer_signed')); $this->assertArrayNotHasKey('float', $document->getAttributes()); $this->assertArrayNotHasKey('boolean', $document->getAttributes()); $this->assertArrayNotHasKey('colors', $document->getAttributes()); @@ -4619,10 +4619,10 @@ public function testReadPermissionsSuccess(Document $document): Document Permission::delete(Role::any()), ], 'string' => 'text📝', - 'integer_signed' => -Database::INT_MAX, - 'integer_unsigned' => Database::INT_MAX, - 'bigint_signed' => -Database::BIG_INT_MAX, - 'bigint_unsigned' => Database::BIG_INT_MAX, + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, 'float_signed' => -5.55, 'float_unsigned' => 5.55, 'boolean' => true, @@ -4660,10 +4660,10 @@ public function testWritePermissionsSuccess(Document $document): void Permission::delete(Role::any()), ], 'string' => 'text📝', - 'integer_signed' => -Database::INT_MAX, - 'integer_unsigned' => Database::INT_MAX, - 'bigint_signed' => -Database::BIG_INT_MAX, - 'bigint_unsigned' => Database::BIG_INT_MAX, + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, 'float_signed' => -5.55, 'float_unsigned' => 5.55, 'boolean' => true, @@ -4692,10 +4692,10 @@ public function testWritePermissionsUpdateFailure(Document $document): Document Permission::delete(Role::any()), ], 'string' => 'text📝', - 'integer_signed' => -Database::INT_MAX, - 'integer_unsigned' => Database::INT_MAX, - 'bigint_signed' => -Database::BIG_INT_MAX, - 'bigint_unsigned' => Database::BIG_INT_MAX, + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, 'float_signed' => -5.55, 'float_unsigned' => 5.55, 'boolean' => true, @@ -4714,9 +4714,9 @@ public function testWritePermissionsUpdateFailure(Document $document): Document ], 'string' => 'text📝', 'integer_signed' => 6, - 'bigint_signed' => -Database::BIG_INT_MAX, - 'float_signed' => -Database::DOUBLE_MAX, - 'float_unsigned' => Database::DOUBLE_MAX, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'float_signed' => -Database::MAX_DOUBLE, + 'float_unsigned' => Database::MAX_DOUBLE, 'boolean' => true, 'colors' => ['pink', 'green', 'blue'], ])); diff --git a/tests/e2e/Adapter/Scopes/IndexTests.php b/tests/e2e/Adapter/Scopes/IndexTests.php index ac8b11da7..c516332f3 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -166,7 +166,10 @@ public function testIndexValidation(): void $attributes, $database->getAdapter()->getMaxIndexLength(), $database->getAdapter()->getInternalIndexesKeys(), - $database->getAdapter()->getSupportForIndexArray() + $database->getAdapter()->getSupportForIndexArray(), + $database->getAdapter()->getSupportForSpatialIndexNull(), + $database->getAdapter()->getSupportForSpatialIndexOrder(), + $database->getAdapter()->getSupportForVectors(), ); $errorMessage = 'Index length 701 is larger than the size for title1: 700"'; @@ -241,7 +244,10 @@ public function testIndexValidation(): void $attributes, $database->getAdapter()->getMaxIndexLength(), $database->getAdapter()->getInternalIndexesKeys(), - $database->getAdapter()->getSupportForIndexArray() + $database->getAdapter()->getSupportForIndexArray(), + $database->getAdapter()->getSupportForSpatialIndexNull(), + $database->getAdapter()->getSupportForSpatialIndexOrder(), + $database->getAdapter()->getSupportForVectors(), ); $errorMessage = 'Attribute "integer" cannot be part of a FULLTEXT index, must be of type string'; $this->assertFalse($validator->isValid($indexes[0])); diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index 50e14c90c..1196b2f53 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -218,10 +218,10 @@ public function testReadPermissionsFailure(): Document Permission::delete(Role::user('1')), ], 'string' => 'text📝', - 'integer_signed' => -Database::INT_MAX, - 'integer_unsigned' => Database::INT_MAX, - 'bigint_signed' => -Database::BIG_INT_MAX, - 'bigint_unsigned' => Database::BIG_INT_MAX, + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, 'float_signed' => -5.55, 'float_unsigned' => 5.55, 'boolean' => true, @@ -250,10 +250,10 @@ public function testNoChangeUpdateDocumentWithoutPermission(): Document Permission::read(Role::any()) ], 'string' => 'text📝', - 'integer_signed' => -Database::INT_MAX, - 'integer_unsigned' => Database::INT_MAX, - 'bigint_signed' => -Database::BIG_INT_MAX, - 'bigint_unsigned' => Database::BIG_INT_MAX, + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, 'float_signed' => -123456789.12346, 'float_unsigned' => 123456789.12346, 'boolean' => true, @@ -274,10 +274,10 @@ public function testNoChangeUpdateDocumentWithoutPermission(): Document '$id' => ID::unique(), '$permissions' => [], 'string' => 'text📝', - 'integer_signed' => -Database::INT_MAX, - 'integer_unsigned' => Database::INT_MAX, - 'bigint_signed' => -Database::BIG_INT_MAX, - 'bigint_unsigned' => Database::BIG_INT_MAX, + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, 'float_signed' => -123456789.12346, 'float_unsigned' => 123456789.12346, 'boolean' => true, diff --git a/tests/e2e/Adapter/Scopes/SpatialTests.php b/tests/e2e/Adapter/Scopes/SpatialTests.php index 31093e724..ec0e77252 100644 --- a/tests/e2e/Adapter/Scopes/SpatialTests.php +++ b/tests/e2e/Adapter/Scopes/SpatialTests.php @@ -21,7 +21,8 @@ public function testSpatialCollection(): void $database = static::getDatabase(); $collectionName = "test_spatial_Col"; if (!$database->getAdapter()->getSupportForSpatialAttributes()) { - $this->markTestSkipped('Adapter does not support spatial attributes'); + $this->expectNotToPerformAssertions(); + return; }; $attributes = [ new Document([ @@ -94,7 +95,8 @@ public function testSpatialTypeDocuments(): void /** @var Database $database */ $database = static::getDatabase(); if (!$database->getAdapter()->getSupportForSpatialAttributes()) { - $this->markTestSkipped('Adapter does not support spatial attributes'); + $this->expectNotToPerformAssertions(); + return; } $collectionName = 'test_spatial_doc_'; @@ -918,7 +920,8 @@ public function testComplexGeometricShapes(): void /** @var Database $database */ $database = static::getDatabase(); if (!$database->getAdapter()->getSupportForSpatialAttributes()) { - $this->markTestSkipped('Adapter does not support spatial attributes'); + $this->expectNotToPerformAssertions(); + return; } $collectionName = 'complex_shapes_'; @@ -1348,7 +1351,8 @@ public function testSpatialQueryCombinations(): void /** @var Database $database */ $database = static::getDatabase(); if (!$database->getAdapter()->getSupportForSpatialAttributes()) { - $this->markTestSkipped('Adapter does not support spatial attributes'); + $this->expectNotToPerformAssertions(); + return; } $collectionName = 'spatial_combinations_'; @@ -1478,7 +1482,8 @@ public function testSpatialBulkOperation(): void /** @var Database $database */ $database = static::getDatabase(); if (!$database->getAdapter()->getSupportForSpatialAttributes()) { - $this->markTestSkipped('Adapter does not support spatial attributes'); + $this->expectNotToPerformAssertions(); + return; } $collectionName = 'test_spatial_bulk_ops'; @@ -1780,7 +1785,8 @@ public function testSptialAggregation(): void /** @var Database $database */ $database = static::getDatabase(); if (!$database->getAdapter()->getSupportForSpatialAttributes()) { - $this->markTestSkipped('Adapter does not support spatial attributes'); + $this->expectNotToPerformAssertions(); + return; } $collectionName = 'spatial_agg_'; try { @@ -1867,7 +1873,8 @@ public function testUpdateSpatialAttributes(): void /** @var Database $database */ $database = static::getDatabase(); if (!$database->getAdapter()->getSupportForSpatialAttributes()) { - $this->markTestSkipped('Adapter does not support spatial attributes'); + $this->expectNotToPerformAssertions(); + return; } $collectionName = 'spatial_update_attrs_'; @@ -1953,7 +1960,8 @@ public function testSpatialAttributeDefaults(): void /** @var Database $database */ $database = static::getDatabase(); if (!$database->getAdapter()->getSupportForSpatialAttributes()) { - $this->markTestSkipped('Adapter does not support spatial attributes'); + $this->expectNotToPerformAssertions(); + return; } $collectionName = 'spatial_defaults_'; diff --git a/tests/e2e/Adapter/Scopes/VectorTests.php b/tests/e2e/Adapter/Scopes/VectorTests.php new file mode 100644 index 000000000..c0026b634 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/VectorTests.php @@ -0,0 +1,2627 @@ +getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Test that vector attributes can only be created on PostgreSQL + $database->createCollection('vectorCollection'); + + // Create a vector attribute with 3 dimensions + $database->createAttribute('vectorCollection', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create a vector attribute with 128 dimensions + $database->createAttribute('vectorCollection', 'large_embedding', Database::VAR_VECTOR, 128, false, null); + + // Verify the attributes were created + $collection = $database->getCollection('vectorCollection'); + $attributes = $collection->getAttribute('attributes'); + + $embeddingAttr = null; + $largeEmbeddingAttr = null; + + foreach ($attributes as $attr) { + if ($attr['key'] === 'embedding') { + $embeddingAttr = $attr; + } elseif ($attr['key'] === 'large_embedding') { + $largeEmbeddingAttr = $attr; + } + } + + $this->assertNotNull($embeddingAttr); + $this->assertNotNull($largeEmbeddingAttr); + $this->assertEquals(Database::VAR_VECTOR, $embeddingAttr['type']); + $this->assertEquals(3, $embeddingAttr['size']); + $this->assertEquals(Database::VAR_VECTOR, $largeEmbeddingAttr['type']); + $this->assertEquals(128, $largeEmbeddingAttr['size']); + + // Cleanup + $database->deleteCollection('vectorCollection'); + } + + public function testVectorInvalidDimensions(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorErrorCollection'); + + // Test invalid dimensions + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Vector dimensions must be a positive integer'); + $database->createAttribute('vectorErrorCollection', 'bad_embedding', Database::VAR_VECTOR, 0, true); + + // Cleanup + $database->deleteCollection('vectorErrorCollection'); + } + + public function testVectorTooManyDimensions(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorLimitCollection'); + + // Test too many dimensions (pgvector limit is 16000) + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Vector dimensions cannot exceed 16000'); + $database->createAttribute('vectorLimitCollection', 'huge_embedding', Database::VAR_VECTOR, 16001, true); + + // Cleanup + $database->deleteCollection('vectorLimitCollection'); + } + + public function testVectorDocuments(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorDocuments'); + $database->createAttribute('vectorDocuments', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('vectorDocuments', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create documents with vector data + $doc1 = $database->createDocument('vectorDocuments', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Document 1', + 'embedding' => [1.0, 0.0, 0.0] + ])); + + $doc2 = $database->createDocument('vectorDocuments', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Document 2', + 'embedding' => [0.0, 1.0, 0.0] + ])); + + $doc3 = $database->createDocument('vectorDocuments', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Document 3', + 'embedding' => [0.0, 0.0, 1.0] + ])); + + $this->assertNotEmpty($doc1->getId()); + $this->assertNotEmpty($doc2->getId()); + $this->assertNotEmpty($doc3->getId()); + + $this->assertEquals([1.0, 0.0, 0.0], $doc1->getAttribute('embedding')); + $this->assertEquals([0.0, 1.0, 0.0], $doc2->getAttribute('embedding')); + $this->assertEquals([0.0, 0.0, 1.0], $doc3->getAttribute('embedding')); + + // Cleanup + $database->deleteCollection('vectorDocuments'); + } + + public function testVectorQueries(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorQueries'); + $database->createAttribute('vectorQueries', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('vectorQueries', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create test documents with read permissions + $doc1 = $database->createDocument('vectorQueries', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Test 1', + 'embedding' => [1.0, 0.0, 0.0] + ])); + + $doc2 = $database->createDocument('vectorQueries', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Test 2', + 'embedding' => [0.0, 1.0, 0.0] + ])); + + $doc3 = $database->createDocument('vectorQueries', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Test 3', + 'embedding' => [0.5, 0.5, 0.0] + ])); + + // Verify documents were created + $this->assertNotEmpty($doc1->getId()); + $this->assertNotEmpty($doc2->getId()); + $this->assertNotEmpty($doc3->getId()); + + // Test without vector queries first + $allDocs = $database->find('vectorQueries'); + $this->assertCount(3, $allDocs, "Should have 3 documents in collection"); + + // Test vector dot product query + $results = $database->find('vectorQueries', [ + Query::vectorDot('embedding', [1.0, 0.0, 0.0]), + Query::orderAsc('$id') + ]); + + $this->assertCount(3, $results); + + // Test vector cosine distance query + $results = $database->find('vectorQueries', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::orderAsc('$id') + ]); + + $this->assertCount(3, $results); + + // Test vector euclidean distance query + $results = $database->find('vectorQueries', [ + Query::vectorEuclidean('embedding', [1.0, 0.0, 0.0]), + Query::orderAsc('$id') + ]); + + $this->assertCount(3, $results); + + // Test vector queries with limit - should return only top results + $results = $database->find('vectorQueries', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::limit(2) + ]); + + $this->assertCount(2, $results); + // The most similar vector should be the one closest to [1.0, 0.0, 0.0] + $this->assertEquals('Test 1', $results[0]->getAttribute('name')); + + // Test vector query with limit of 1 + $results = $database->find('vectorQueries', [ + Query::vectorDot('embedding', [0.0, 1.0, 0.0]), + Query::limit(1) + ]); + + $this->assertCount(1, $results); + $this->assertEquals('Test 2', $results[0]->getAttribute('name')); + + // Test vector query combined with other filters + $results = $database->find('vectorQueries', [ + Query::vectorCosine('embedding', [0.5, 0.5, 0.0]), + Query::notEqual('name', 'Test 1') + ]); + + $this->assertCount(2, $results); + // Should not contain Test 1 + foreach ($results as $result) { + $this->assertNotEquals('Test 1', $result->getAttribute('name')); + } + + // Test vector query with specific name filter + $results = $database->find('vectorQueries', [ + Query::vectorEuclidean('embedding', [0.7, 0.7, 0.0]), + Query::equal('name', ['Test 3']) + ]); + + $this->assertCount(1, $results); + $this->assertEquals('Test 3', $results[0]->getAttribute('name')); + + // Test vector query with offset - skip first result + $results = $database->find('vectorQueries', [ + Query::vectorDot('embedding', [0.5, 0.5, 0.0]), + Query::limit(2), + Query::offset(1) + ]); + + $this->assertCount(2, $results); + // Should skip the most similar result + + // Test empty result with impossible filter combination + $results = $database->find('vectorQueries', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::equal('name', ['Test 2']), + Query::equal('name', ['Test 3']) // Impossible condition + ]); + + $this->assertCount(0, $results); + + // Test vector query with secondary ordering + // Vector similarity takes precedence, name DESC is secondary + $results = $database->find('vectorQueries', [ + Query::vectorDot('embedding', [0.4, 0.6, 0.0]), + Query::orderDesc('name'), + Query::limit(2) + ]); + + $this->assertCount(2, $results); + // Results should be ordered primarily by vector similarity + // The vector [0.4, 0.6, 0.0] is most similar to Test 2 [0.0, 1.0, 0.0] + // and Test 3 [0.5, 0.5, 0.0] using dot product + // Test 2 dot product: 0.4*0.0 + 0.6*1.0 + 0.0*0.0 = 0.6 + // Test 3 dot product: 0.4*0.5 + 0.6*0.5 + 0.0*0.0 = 0.5 + // So Test 2 should come first (higher dot product with negative inner product operator) + $this->assertEquals('Test 2', $results[0]->getAttribute('name')); + + // Cleanup + $database->deleteCollection('vectorQueries'); + } + + public function testVectorQueryValidation(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorValidation'); + $database->createAttribute('vectorValidation', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createAttribute('vectorValidation', 'name', Database::VAR_STRING, 255, true); + + // Test that vector queries fail on non-vector attributes + $this->expectException(DatabaseException::class); + $database->find('vectorValidation', [ + Query::vectorDot('name', [1.0, 0.0, 0.0]) + ]); + + // Cleanup + $database->deleteCollection('vectorValidation'); + } + + public function testVectorIndexes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorIndexes'); + $database->createAttribute('vectorIndexes', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create different types of vector indexes + // Euclidean distance index (L2 distance) + $database->createIndex('vectorIndexes', 'embedding_euclidean', Database::INDEX_HNSW_EUCLIDEAN, ['embedding']); + + // Cosine distance index + $database->createIndex('vectorIndexes', 'embedding_cosine', Database::INDEX_HNSW_COSINE, ['embedding']); + + // Inner product (dot product) index + $database->createIndex('vectorIndexes', 'embedding_dot', Database::INDEX_HNSW_DOT, ['embedding']); + + // Verify indexes were created + $collection = $database->getCollection('vectorIndexes'); + $indexes = $collection->getAttribute('indexes'); + + $this->assertCount(3, $indexes); + + // Test that queries work with indexes + $database->createDocument('vectorIndexes', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1.0, 0.0, 0.0] + ])); + + $database->createDocument('vectorIndexes', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [0.0, 1.0, 0.0] + ])); + + // Query should use the appropriate index based on the operator + $results = $database->find('vectorIndexes', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::limit(1) + ]); + + $this->assertCount(1, $results); + + // Cleanup + $database->deleteCollection('vectorIndexes'); + } + + public function testVectorDimensionMismatch(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorDimMismatch'); + $database->createAttribute('vectorDimMismatch', 'embedding', Database::VAR_VECTOR, 3, true); + + // Test creating document with wrong dimension count + $this->expectException(DatabaseException::class); + $this->expectExceptionMessageMatches('/must be an array of 3 numeric values/'); + + $database->createDocument('vectorDimMismatch', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1.0, 0.0] // Only 2 dimensions, expects 3 + ])); + + // Cleanup + $database->deleteCollection('vectorDimMismatch'); + } + + public function testVectorWithInvalidDataTypes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorInvalidTypes'); + $database->createAttribute('vectorInvalidTypes', 'embedding', Database::VAR_VECTOR, 3, true); + + // Test with string values in vector + try { + $database->createDocument('vectorInvalidTypes', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => ['one', 'two', 'three'] + ])); + $this->fail('Should have thrown exception for non-numeric vector values'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric values', strtolower($e->getMessage())); + } + + // Test with mixed types + try { + $database->createDocument('vectorInvalidTypes', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1.0, 'two', 3.0] + ])); + $this->fail('Should have thrown exception for mixed type vector values'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric values', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorInvalidTypes'); + } + + public function testVectorWithNullAndEmpty(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorNullEmpty'); + $database->createAttribute('vectorNullEmpty', 'embedding', Database::VAR_VECTOR, 3, false); // Not required + + // Test with null vector (should work for non-required attribute) + $doc1 = $database->createDocument('vectorNullEmpty', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => null + ])); + + $this->assertNull($doc1->getAttribute('embedding')); + + // Test with empty array (should fail) + try { + $database->createDocument('vectorNullEmpty', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [] + ])); + $this->fail('Should have thrown exception for empty vector'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric values', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorNullEmpty'); + } + + public function testLargeVectors(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Test with maximum allowed dimensions (16000 for pgvector) + $database->createCollection('vectorLarge'); + $database->createAttribute('vectorLarge', 'embedding', Database::VAR_VECTOR, 1536, true); // Common embedding size + + // Create a large vector + $largeVector = array_fill(0, 1536, 0.1); + $largeVector[0] = 1.0; // Make first element different + + $doc = $database->createDocument('vectorLarge', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => $largeVector + ])); + + $this->assertCount(1536, $doc->getAttribute('embedding')); + $this->assertEquals(1.0, $doc->getAttribute('embedding')[0]); + + // Test vector search on large vectors + $searchVector = array_fill(0, 1536, 0.0); + $searchVector[0] = 1.0; + + $results = $database->find('vectorLarge', [ + Query::vectorCosine('embedding', $searchVector) + ]); + + $this->assertCount(1, $results); + + // Cleanup + $database->deleteCollection('vectorLarge'); + } + + public function testVectorUpdates(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorUpdates'); + $database->createAttribute('vectorUpdates', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create initial document + $doc = $database->createDocument('vectorUpdates', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()) + ], + 'embedding' => [1.0, 0.0, 0.0] + ])); + + $this->assertEquals([1.0, 0.0, 0.0], $doc->getAttribute('embedding')); + + // Update the vector + $updated = $database->updateDocument('vectorUpdates', $doc->getId(), new Document([ + 'embedding' => [0.0, 1.0, 0.0] + ])); + + $this->assertEquals([0.0, 1.0, 0.0], $updated->getAttribute('embedding')); + + // Test partial update (should replace entire vector) + $updated2 = $database->updateDocument('vectorUpdates', $doc->getId(), new Document([ + 'embedding' => [0.5, 0.5, 0.5] + ])); + + $this->assertEquals([0.5, 0.5, 0.5], $updated2->getAttribute('embedding')); + + // Cleanup + $database->deleteCollection('vectorUpdates'); + } + + public function testMultipleVectorAttributes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('multiVector'); + $database->createAttribute('multiVector', 'embedding1', Database::VAR_VECTOR, 3, true); + $database->createAttribute('multiVector', 'embedding2', Database::VAR_VECTOR, 5, true); + $database->createAttribute('multiVector', 'name', Database::VAR_STRING, 255, true); + + // Create documents with multiple vector attributes + $doc1 = $database->createDocument('multiVector', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Doc 1', + 'embedding1' => [1.0, 0.0, 0.0], + 'embedding2' => [1.0, 0.0, 0.0, 0.0, 0.0] + ])); + + $doc2 = $database->createDocument('multiVector', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Doc 2', + 'embedding1' => [0.0, 1.0, 0.0], + 'embedding2' => [0.0, 1.0, 0.0, 0.0, 0.0] + ])); + + // Query by first vector + $results = $database->find('multiVector', [ + Query::vectorCosine('embedding1', [1.0, 0.0, 0.0]) + ]); + + $this->assertCount(2, $results); + $this->assertEquals('Doc 1', $results[0]->getAttribute('name')); + + // Query by second vector + $results = $database->find('multiVector', [ + Query::vectorCosine('embedding2', [0.0, 1.0, 0.0, 0.0, 0.0]) + ]); + + $this->assertCount(2, $results); + $this->assertEquals('Doc 2', $results[0]->getAttribute('name')); + + // Cleanup + $database->deleteCollection('multiVector'); + } + + public function testVectorQueriesWithPagination(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorPagination'); + $database->createAttribute('vectorPagination', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createAttribute('vectorPagination', 'index', Database::VAR_INTEGER, 0, true); + + // Create 10 documents + for ($i = 0; $i < 10; $i++) { + $database->createDocument('vectorPagination', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'index' => $i, + 'embedding' => [ + cos($i * M_PI / 10), + sin($i * M_PI / 10), + 0.0 + ] + ])); + } + + // Test pagination with vector queries + $searchVector = [1.0, 0.0, 0.0]; + + // First page + $page1 = $database->find('vectorPagination', [ + Query::vectorCosine('embedding', $searchVector), + Query::limit(3), + Query::offset(0) + ]); + + $this->assertCount(3, $page1); + + // Second page + $page2 = $database->find('vectorPagination', [ + Query::vectorCosine('embedding', $searchVector), + Query::limit(3), + Query::offset(3) + ]); + + $this->assertCount(3, $page2); + + // Ensure different documents + $page1Ids = array_map(fn ($doc) => $doc->getId(), $page1); + $page2Ids = array_map(fn ($doc) => $doc->getId(), $page2); + $this->assertEmpty(array_intersect($page1Ids, $page2Ids)); + + // Test with cursor pagination + $firstBatch = $database->find('vectorPagination', [ + Query::vectorCosine('embedding', $searchVector), + Query::limit(5) + ]); + + $this->assertCount(5, $firstBatch); + + $lastDoc = $firstBatch[4]; + $nextBatch = $database->find('vectorPagination', [ + Query::vectorCosine('embedding', $searchVector), + Query::cursorAfter($lastDoc), + Query::limit(5) + ]); + + $this->assertCount(5, $nextBatch); + $this->assertNotEquals($lastDoc->getId(), $nextBatch[0]->getId()); + + // Cleanup + $database->deleteCollection('vectorPagination'); + } + + public function testCombinedVectorAndTextSearch(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorTextSearch'); + $database->createAttribute('vectorTextSearch', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('vectorTextSearch', 'category', Database::VAR_STRING, 50, true); + $database->createAttribute('vectorTextSearch', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create fulltext index for title + $database->createIndex('vectorTextSearch', 'title_fulltext', Database::INDEX_FULLTEXT, ['title']); + + // Create test documents + $docs = [ + ['title' => 'Machine Learning Basics', 'category' => 'AI', 'embedding' => [1.0, 0.0, 0.0]], + ['title' => 'Deep Learning Advanced', 'category' => 'AI', 'embedding' => [0.9, 0.1, 0.0]], + ['title' => 'Web Development Guide', 'category' => 'Web', 'embedding' => [0.0, 1.0, 0.0]], + ['title' => 'Database Design', 'category' => 'Data', 'embedding' => [0.0, 0.0, 1.0]], + ['title' => 'AI Ethics', 'category' => 'AI', 'embedding' => [0.8, 0.2, 0.0]], + ]; + + foreach ($docs as $doc) { + $database->createDocument('vectorTextSearch', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + ...$doc + ])); + } + + // Combine vector search with category filter + $results = $database->find('vectorTextSearch', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::equal('category', ['AI']), + Query::limit(2) + ]); + + $this->assertCount(2, $results); + $this->assertEquals('AI', $results[0]->getAttribute('category')); + $this->assertEquals('Machine Learning Basics', $results[0]->getAttribute('title')); + + // Combine vector search with text search + $results = $database->find('vectorTextSearch', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::search('title', 'Learning'), + Query::limit(5) + ]); + + $this->assertCount(2, $results); + foreach ($results as $result) { + $this->assertStringContainsString('Learning', $result->getAttribute('title')); + } + + // Complex query with multiple filters + $results = $database->find('vectorTextSearch', [ + Query::vectorEuclidean('embedding', [0.5, 0.5, 0.0]), + Query::notEqual('category', ['Web']), + Query::limit(3) + ]); + + $this->assertCount(3, $results); + foreach ($results as $result) { + $this->assertNotEquals('Web', $result->getAttribute('category')); + } + + // Cleanup + $database->deleteCollection('vectorTextSearch'); + } + + public function testVectorSpecialFloatValues(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorSpecialFloats'); + $database->createAttribute('vectorSpecialFloats', 'embedding', Database::VAR_VECTOR, 3, true); + + // Test with very small values (near zero) + $doc1 = $database->createDocument('vectorSpecialFloats', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1e-10, 1e-10, 1e-10] + ])); + + $this->assertNotNull($doc1->getId()); + + // Test with very large values + $doc2 = $database->createDocument('vectorSpecialFloats', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1e10, 1e10, 1e10] + ])); + + $this->assertNotNull($doc2->getId()); + + // Test with negative values + $doc3 = $database->createDocument('vectorSpecialFloats', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [-1.0, -0.5, -0.1] + ])); + + $this->assertNotNull($doc3->getId()); + + // Test with mixed sign values + $doc4 = $database->createDocument('vectorSpecialFloats', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [-1.0, 0.0, 1.0] + ])); + + $this->assertNotNull($doc4->getId()); + + // Query with negative vector + $results = $database->find('vectorSpecialFloats', [ + Query::vectorCosine('embedding', [-1.0, -1.0, -1.0]) + ]); + + $this->assertGreaterThan(0, count($results)); + + // Cleanup + $database->deleteCollection('vectorSpecialFloats'); + } + + public function testVectorIndexPerformance(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorPerf'); + $database->createAttribute('vectorPerf', 'embedding', Database::VAR_VECTOR, 128, true); + $database->createAttribute('vectorPerf', 'name', Database::VAR_STRING, 255, true); + + // Create documents + $numDocs = 100; + for ($i = 0; $i < $numDocs; $i++) { + $vector = []; + for ($j = 0; $j < 128; $j++) { + $vector[] = sin($i * $j * 0.01); + } + + $database->createDocument('vectorPerf', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => "Doc $i", + 'embedding' => $vector + ])); + } + + // Query without index + $searchVector = array_fill(0, 128, 0.5); + + $startTime = microtime(true); + $results1 = $database->find('vectorPerf', [ + Query::vectorCosine('embedding', $searchVector), + Query::limit(10) + ]); + $timeWithoutIndex = microtime(true) - $startTime; + + $this->assertCount(10, $results1); + + // Create HNSW index + $database->createIndex('vectorPerf', 'embedding_hnsw', Database::INDEX_HNSW_COSINE, ['embedding']); + + // Query with index (should be faster for larger datasets) + $startTime = microtime(true); + $results2 = $database->find('vectorPerf', [ + Query::vectorCosine('embedding', $searchVector), + Query::limit(10) + ]); + $timeWithIndex = microtime(true) - $startTime; + + $this->assertCount(10, $results2); + + // Results should be the same + $this->assertEquals( + array_map(fn ($d) => $d->getId(), $results1), + array_map(fn ($d) => $d->getId(), $results2) + ); + + // Cleanup + $database->deleteCollection('vectorPerf'); + } + + public function testVectorQueryValidationExtended(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorValidation2'); + $database->createAttribute('vectorValidation2', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createAttribute('vectorValidation2', 'text', Database::VAR_STRING, 255, true); + + $database->createDocument('vectorValidation2', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'text' => 'Test', + 'embedding' => [1.0, 0.0, 0.0] + ])); + + // Test vector query with wrong dimension count + try { + $database->find('vectorValidation2', [ + Query::vectorCosine('embedding', [1.0, 0.0]) // Wrong dimension + ]); + $this->fail('Should have thrown exception for dimension mismatch'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('elements', strtolower($e->getMessage())); + } + + // Test vector query on non-vector attribute + try { + $database->find('vectorValidation2', [ + Query::vectorCosine('text', [1.0, 0.0, 0.0]) + ]); + $this->fail('Should have thrown exception for non-vector attribute'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('vector', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorValidation2'); + } + + public function testVectorNormalization(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorNorm'); + $database->createAttribute('vectorNorm', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create documents with normalized and non-normalized vectors + $doc1 = $database->createDocument('vectorNorm', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1.0, 0.0, 0.0] // Already normalized + ])); + + $doc2 = $database->createDocument('vectorNorm', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [3.0, 4.0, 0.0] // Not normalized (magnitude = 5) + ])); + + // Cosine similarity should work regardless of normalization + $results = $database->find('vectorNorm', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + ]); + + $this->assertCount(2, $results); + + // For cosine similarity, [3, 4, 0] has similarity 3/5 = 0.6 with [1, 0, 0] + // So [1, 0, 0] should be first (similarity = 1.0) + $this->assertEquals([1.0, 0.0, 0.0], $results[0]->getAttribute('embedding')); + + // Cleanup + $database->deleteCollection('vectorNorm'); + } + + public function testVectorWithInfinityValues(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorInfinity'); + $database->createAttribute('vectorInfinity', 'embedding', Database::VAR_VECTOR, 3, true); + + // Test with INF value - should fail + try { + $database->createDocument('vectorInfinity', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [INF, 0.0, 0.0] + ])); + $this->fail('Should have thrown exception for INF value'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + + // Test with -INF value - should fail + try { + $database->createDocument('vectorInfinity', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [-INF, 0.0, 0.0] + ])); + $this->fail('Should have thrown exception for -INF value'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorInfinity'); + } + + public function testVectorWithNaNValues(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorNaN'); + $database->createAttribute('vectorNaN', 'embedding', Database::VAR_VECTOR, 3, true); + + // Test with NaN value - should fail + try { + $database->createDocument('vectorNaN', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [NAN, 0.0, 0.0] + ])); + $this->fail('Should have thrown exception for NaN value'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorNaN'); + } + + public function testVectorWithAssociativeArray(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorAssoc'); + $database->createAttribute('vectorAssoc', 'embedding', Database::VAR_VECTOR, 3, true); + + // Test with associative array - should fail + try { + $database->createDocument('vectorAssoc', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => ['x' => 1.0, 'y' => 0.0, 'z' => 0.0] + ])); + $this->fail('Should have thrown exception for associative array'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorAssoc'); + } + + public function testVectorWithSparseArray(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorSparse'); + $database->createAttribute('vectorSparse', 'embedding', Database::VAR_VECTOR, 3, true); + + // Test with sparse array (missing indexes) - should fail + try { + $vector = []; + $vector[0] = 1.0; + $vector[2] = 1.0; // Skip index 1 + $database->createDocument('vectorSparse', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => $vector + ])); + $this->fail('Should have thrown exception for sparse array'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorSparse'); + } + + public function testVectorWithNestedArrays(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorNested'); + $database->createAttribute('vectorNested', 'embedding', Database::VAR_VECTOR, 3, true); + + // Test with nested array - should fail + try { + $database->createDocument('vectorNested', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [[1.0], [0.0], [0.0]] + ])); + $this->fail('Should have thrown exception for nested array'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorNested'); + } + + public function testVectorWithBooleansInArray(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorBooleans'); + $database->createAttribute('vectorBooleans', 'embedding', Database::VAR_VECTOR, 3, true); + + // Test with boolean values - should fail + try { + $database->createDocument('vectorBooleans', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [true, false, true] + ])); + $this->fail('Should have thrown exception for boolean values'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorBooleans'); + } + + public function testVectorWithStringNumbers(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorStringNums'); + $database->createAttribute('vectorStringNums', 'embedding', Database::VAR_VECTOR, 3, true); + + // Test with numeric strings - should fail (strict validation) + try { + $database->createDocument('vectorStringNums', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => ['1.0', '2.0', '3.0'] + ])); + $this->fail('Should have thrown exception for string numbers'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + + // Test with strings containing spaces + try { + $database->createDocument('vectorStringNums', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [' 1.0 ', '2.0', '3.0'] + ])); + $this->fail('Should have thrown exception for string numbers with spaces'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorStringNums'); + } + + public function testVectorWithRelationships(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Create parent collection with vectors + $database->createCollection('vectorParent'); + $database->createAttribute('vectorParent', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('vectorParent', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create child collection + $database->createCollection('vectorChild'); + $database->createAttribute('vectorChild', 'title', Database::VAR_STRING, 255, true); + $database->createRelationship('vectorChild', 'vectorParent', Database::RELATION_MANY_TO_ONE, true, 'parent', 'children'); + + // Create parent documents with vectors + $parent1 = $database->createDocument('vectorParent', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Parent 1', + 'embedding' => [1.0, 0.0, 0.0] + ])); + + $parent2 = $database->createDocument('vectorParent', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Parent 2', + 'embedding' => [0.0, 1.0, 0.0] + ])); + + // Create child documents + $child1 = $database->createDocument('vectorChild', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'title' => 'Child 1', + 'parent' => $parent1->getId() + ])); + + $child2 = $database->createDocument('vectorChild', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'title' => 'Child 2', + 'parent' => $parent2->getId() + ])); + + // Query parents by vector similarity + $results = $database->find('vectorParent', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + ]); + + $this->assertCount(2, $results); + $this->assertEquals('Parent 1', $results[0]->getAttribute('name')); + + // Verify relationships are intact + $parent1Fetched = $database->getDocument('vectorParent', $parent1->getId()); + $children = $parent1Fetched->getAttribute('children'); + $this->assertCount(1, $children); + $this->assertEquals('Child 1', $children[0]->getAttribute('title')); + + // Query with vector and relationship filter combined + $results = $database->find('vectorParent', [ + Query::vectorCosine('embedding', [0.5, 0.5, 0.0]), + Query::equal('name', ['Parent 1']) + ]); + + $this->assertCount(1, $results); + + // Cleanup + $database->deleteCollection('vectorChild'); + $database->deleteCollection('vectorParent'); + } + + public function testVectorWithTwoWayRelationships(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Create two collections with two-way relationship and vectors + $database->createCollection('vectorAuthors'); + $database->createAttribute('vectorAuthors', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('vectorAuthors', 'embedding', Database::VAR_VECTOR, 3, true); + + $database->createCollection('vectorBooks'); + $database->createAttribute('vectorBooks', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('vectorBooks', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createRelationship('vectorBooks', 'vectorAuthors', Database::RELATION_MANY_TO_ONE, true, 'author', 'books'); + + // Create documents + $author = $database->createDocument('vectorAuthors', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Author 1', + 'embedding' => [1.0, 0.0, 0.0] + ])); + + $book1 = $database->createDocument('vectorBooks', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'title' => 'Book 1', + 'embedding' => [0.9, 0.1, 0.0], + 'author' => $author->getId() + ])); + + $book2 = $database->createDocument('vectorBooks', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'title' => 'Book 2', + 'embedding' => [0.8, 0.2, 0.0], + 'author' => $author->getId() + ])); + + // Query books by vector similarity + $results = $database->find('vectorBooks', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::limit(1) + ]); + + $this->assertCount(1, $results); + $this->assertEquals('Book 1', $results[0]->getAttribute('title')); + + // Query authors and verify relationship + $authorFetched = $database->getDocument('vectorAuthors', $author->getId()); + $books = $authorFetched->getAttribute('books'); + $this->assertCount(2, $books); + + // Cleanup + $database->deleteCollection('vectorBooks'); + $database->deleteCollection('vectorAuthors'); + } + + public function testVectorAllZeros(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorZeros'); + $database->createAttribute('vectorZeros', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create document with all-zeros vector + $doc = $database->createDocument('vectorZeros', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [0.0, 0.0, 0.0] + ])); + + $this->assertEquals([0.0, 0.0, 0.0], $doc->getAttribute('embedding')); + + // Create another document with non-zero vector + $doc2 = $database->createDocument('vectorZeros', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1.0, 0.0, 0.0] + ])); + + // Query with zero vector - cosine similarity should handle gracefully + $results = $database->find('vectorZeros', [ + Query::vectorCosine('embedding', [0.0, 0.0, 0.0]) + ]); + + // Should return documents, though similarity may be undefined + $this->assertGreaterThan(0, count($results)); + + // Query with non-zero vector against zero vectors + $results = $database->find('vectorZeros', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + ]); + + $this->assertCount(2, $results); + + // Cleanup + $database->deleteCollection('vectorZeros'); + } + + public function testVectorCosineSimilarityDivisionByZero(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorCosineZero'); + $database->createAttribute('vectorCosineZero', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create multiple documents with zero vectors + $database->createDocument('vectorCosineZero', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [0.0, 0.0, 0.0] + ])); + + $database->createDocument('vectorCosineZero', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [0.0, 0.0, 0.0] + ])); + + // Query with zero vector - should not cause division by zero error + $results = $database->find('vectorCosineZero', [ + Query::vectorCosine('embedding', [0.0, 0.0, 0.0]) + ]); + + // Should handle gracefully and return results + $this->assertCount(2, $results); + + // Cleanup + $database->deleteCollection('vectorCosineZero'); + } + + public function testDeleteVectorAttribute(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorDeleteAttr'); + $database->createAttribute('vectorDeleteAttr', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('vectorDeleteAttr', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create document with vector + $doc = $database->createDocument('vectorDeleteAttr', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Test', + 'embedding' => [1.0, 0.0, 0.0] + ])); + + $this->assertNotNull($doc->getAttribute('embedding')); + + // Delete the vector attribute + $result = $database->deleteAttribute('vectorDeleteAttr', 'embedding'); + $this->assertTrue($result); + + // Verify attribute is gone + $collection = $database->getCollection('vectorDeleteAttr'); + $attributes = $collection->getAttribute('attributes'); + foreach ($attributes as $attr) { + $this->assertNotEquals('embedding', $attr['key']); + } + + // Fetch document - should not have embedding anymore + $docFetched = $database->getDocument('vectorDeleteAttr', $doc->getId()); + $this->assertNull($docFetched->getAttribute('embedding', null)); + + // Cleanup + $database->deleteCollection('vectorDeleteAttr'); + } + + public function testDeleteAttributeWithVectorIndexes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorDeleteIndexedAttr'); + $database->createAttribute('vectorDeleteIndexedAttr', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create multiple indexes on the vector attribute + $database->createIndex('vectorDeleteIndexedAttr', 'idx1', Database::INDEX_HNSW_COSINE, ['embedding']); + $database->createIndex('vectorDeleteIndexedAttr', 'idx2', Database::INDEX_HNSW_EUCLIDEAN, ['embedding']); + + // Create document + $database->createDocument('vectorDeleteIndexedAttr', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1.0, 0.0, 0.0] + ])); + + // Delete the attribute - should also delete indexes + $result = $database->deleteAttribute('vectorDeleteIndexedAttr', 'embedding'); + $this->assertTrue($result); + + // Verify indexes are gone + $collection = $database->getCollection('vectorDeleteIndexedAttr'); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(0, $indexes); + + // Cleanup + $database->deleteCollection('vectorDeleteIndexedAttr'); + } + + public function testVectorSearchWithRestrictedPermissions(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Create documents with different permissions inside Authorization::skip + Authorization::skip(function () use ($database) { + $database->createCollection('vectorPermissions', [], [], [], true); + $database->createAttribute('vectorPermissions', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('vectorPermissions', 'embedding', Database::VAR_VECTOR, 3, true); + + $database->createDocument('vectorPermissions', new Document([ + '$permissions' => [ + Permission::read(Role::user('user1')) + ], + 'name' => 'Doc 1', + 'embedding' => [1.0, 0.0, 0.0] + ])); + + $database->createDocument('vectorPermissions', new Document([ + '$permissions' => [ + Permission::read(Role::user('user2')) + ], + 'name' => 'Doc 2', + 'embedding' => [0.9, 0.1, 0.0] + ])); + + $database->createDocument('vectorPermissions', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Doc 3', + 'embedding' => [0.8, 0.2, 0.0] + ])); + }); + + // Query as user1 - should only see doc1 and doc3 + Authorization::setRole(Role::user('user1')->toString()); + Authorization::setRole(Role::any()->toString()); + $results = $database->find('vectorPermissions', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + ]); + + $this->assertCount(2, $results); + $names = array_map(fn ($d) => $d->getAttribute('name'), $results); + $this->assertContains('Doc 1', $names); + $this->assertContains('Doc 3', $names); + $this->assertNotContains('Doc 2', $names); + + // Query as user2 - should only see doc2 and doc3 + Authorization::cleanRoles(); + Authorization::setRole(Role::user('user2')->toString()); + Authorization::setRole(Role::any()->toString()); + $results = $database->find('vectorPermissions', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + ]); + + $this->assertCount(2, $results); + $names = array_map(fn ($d) => $d->getAttribute('name'), $results); + $this->assertContains('Doc 2', $names); + $this->assertContains('Doc 3', $names); + $this->assertNotContains('Doc 1', $names); + + Authorization::cleanRoles(); + Authorization::setRole(Role::any()->toString()); + + // Cleanup + $database->deleteCollection('vectorPermissions'); + } + + public function testVectorPermissionFilteringAfterScoring(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorPermScoring'); + $database->createAttribute('vectorPermScoring', 'score', Database::VAR_INTEGER, 0, true); + $database->createAttribute('vectorPermScoring', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create 5 documents, top 3 by similarity have restricted access + for ($i = 0; $i < 5; $i++) { + $perms = $i < 3 + ? [Permission::read(Role::user('restricted'))] + : [Permission::read(Role::any())]; + + $database->createDocument('vectorPermScoring', new Document([ + '$permissions' => $perms, + 'score' => $i, + 'embedding' => [1.0 - ($i * 0.1), $i * 0.1, 0.0] + ])); + } + + // Query with limit 3 as any user - should skip restricted docs and return accessible ones + Authorization::setRole(Role::any()->toString()); + $results = $database->find('vectorPermScoring', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::limit(3) + ]); + + // Should only get the 2 accessible documents + $this->assertCount(2, $results); + foreach ($results as $doc) { + $this->assertGreaterThanOrEqual(3, $doc->getAttribute('score')); + } + + Authorization::cleanRoles(); + + // Cleanup + $database->deleteCollection('vectorPermScoring'); + } + + public function testVectorCursorBeforePagination(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorCursorBefore'); + $database->createAttribute('vectorCursorBefore', 'index', Database::VAR_INTEGER, 0, true); + $database->createAttribute('vectorCursorBefore', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create 10 documents + for ($i = 0; $i < 10; $i++) { + $database->createDocument('vectorCursorBefore', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'index' => $i, + 'embedding' => [1.0 - ($i * 0.05), $i * 0.05, 0.0] + ])); + } + + // Get first 5 results + $firstBatch = $database->find('vectorCursorBefore', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::limit(5) + ]); + + $this->assertCount(5, $firstBatch); + + // Get results before the 4th document (backward pagination) + $fourthDoc = $firstBatch[3]; + $beforeBatch = $database->find('vectorCursorBefore', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::cursorBefore($fourthDoc), + Query::limit(3) + ]); + + // Should get the 3 documents before the 4th one + $this->assertCount(3, $beforeBatch); + $beforeIds = array_map(fn ($d) => $d->getId(), $beforeBatch); + $this->assertNotContains($fourthDoc->getId(), $beforeIds); + + // Cleanup + $database->deleteCollection('vectorCursorBefore'); + } + + public function testVectorBackwardPagination(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorBackward'); + $database->createAttribute('vectorBackward', 'value', Database::VAR_INTEGER, 0, true); + $database->createAttribute('vectorBackward', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create documents + for ($i = 0; $i < 20; $i++) { + $database->createDocument('vectorBackward', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'value' => $i, + 'embedding' => [cos($i * 0.1), sin($i * 0.1), 0.0] + ])); + } + + // Get last batch + $allResults = $database->find('vectorBackward', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::limit(20) + ]); + + // Navigate backwards from the end + $lastDoc = $allResults[19]; + $backwardBatch = $database->find('vectorBackward', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::cursorBefore($lastDoc), + Query::limit(5) + ]); + + $this->assertCount(5, $backwardBatch); + + // Continue backward pagination + $firstOfBackward = $backwardBatch[0]; + $moreBackward = $database->find('vectorBackward', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::cursorBefore($firstOfBackward), + Query::limit(5) + ]); + + // Should get at least some results (may be less than 5 due to cursor position) + $this->assertGreaterThan(0, count($moreBackward)); + $this->assertLessThanOrEqual(5, count($moreBackward)); + + // Cleanup + $database->deleteCollection('vectorBackward'); + } + + public function testVectorDimensionUpdate(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorDimUpdate'); + $database->createAttribute('vectorDimUpdate', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create document + $doc = $database->createDocument('vectorDimUpdate', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1.0, 0.0, 0.0] + ])); + + $this->assertCount(3, $doc->getAttribute('embedding')); + + // Try to update attribute dimensions - should fail (immutable) + try { + $database->updateAttribute('vectorDimUpdate', 'embedding', Database::VAR_VECTOR, 5, true); + $this->fail('Should not allow changing vector dimensions'); + } catch (\Throwable $e) { + // Expected - dimension changes not allowed (either validation or database error) + $this->assertTrue(true); + } + + // Cleanup + $database->deleteCollection('vectorDimUpdate'); + } + + public function testVectorRequiredWithNullValue(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorRequiredNull'); + $database->createAttribute('vectorRequiredNull', 'embedding', Database::VAR_VECTOR, 3, true); // Required + + // Try to create document with null required vector - should fail + try { + $database->createDocument('vectorRequiredNull', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => null + ])); + $this->fail('Should have thrown exception for null required vector'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('required', strtolower($e->getMessage())); + } + + // Try to create document without vector attribute - should fail + try { + $database->createDocument('vectorRequiredNull', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ] + ])); + $this->fail('Should have thrown exception for missing required vector'); + } catch (DatabaseException $e) { + $this->assertTrue(true); + } + + // Cleanup + $database->deleteCollection('vectorRequiredNull'); + } + + public function testVectorConcurrentUpdates(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorConcurrent'); + $database->createAttribute('vectorConcurrent', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createAttribute('vectorConcurrent', 'version', Database::VAR_INTEGER, 0, true); + + // Create initial document + $doc = $database->createDocument('vectorConcurrent', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()) + ], + 'embedding' => [1.0, 0.0, 0.0], + 'version' => 1 + ])); + + // Simulate concurrent updates + $update1 = $database->updateDocument('vectorConcurrent', $doc->getId(), new Document([ + 'embedding' => [0.0, 1.0, 0.0], + 'version' => 2 + ])); + + $update2 = $database->updateDocument('vectorConcurrent', $doc->getId(), new Document([ + 'embedding' => [0.0, 0.0, 1.0], + 'version' => 3 + ])); + + // Last update should win + $final = $database->getDocument('vectorConcurrent', $doc->getId()); + $this->assertEquals([0.0, 0.0, 1.0], $final->getAttribute('embedding')); + $this->assertEquals(3, $final->getAttribute('version')); + + // Cleanup + $database->deleteCollection('vectorConcurrent'); + } + + public function testDeleteVectorIndexes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorDeleteIdx'); + $database->createAttribute('vectorDeleteIdx', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create index + $database->createIndex('vectorDeleteIdx', 'idx_cosine', Database::INDEX_HNSW_COSINE, ['embedding']); + + // Verify index exists + $collection = $database->getCollection('vectorDeleteIdx'); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(1, $indexes); + + // Create documents + $database->createDocument('vectorDeleteIdx', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1.0, 0.0, 0.0] + ])); + + // Delete index + $result = $database->deleteIndex('vectorDeleteIdx', 'idx_cosine'); + $this->assertTrue($result); + + // Verify index is gone + $collection = $database->getCollection('vectorDeleteIdx'); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(0, $indexes); + + // Queries should still work (without index optimization) + $results = $database->find('vectorDeleteIdx', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + ]); + + $this->assertCount(1, $results); + + // Cleanup + $database->deleteCollection('vectorDeleteIdx'); + } + + public function testMultipleVectorIndexes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorMultiIdx'); + $database->createAttribute('vectorMultiIdx', 'embedding1', Database::VAR_VECTOR, 3, true); + $database->createAttribute('vectorMultiIdx', 'embedding2', Database::VAR_VECTOR, 3, true); + + // Create multiple indexes on different vector attributes + $database->createIndex('vectorMultiIdx', 'idx1_cosine', Database::INDEX_HNSW_COSINE, ['embedding1']); + $database->createIndex('vectorMultiIdx', 'idx2_euclidean', Database::INDEX_HNSW_EUCLIDEAN, ['embedding2']); + + // Verify both indexes exist + $collection = $database->getCollection('vectorMultiIdx'); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(2, $indexes); + + // Create document + $database->createDocument('vectorMultiIdx', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding1' => [1.0, 0.0, 0.0], + 'embedding2' => [0.0, 1.0, 0.0] + ])); + + // Query using first index + $results = $database->find('vectorMultiIdx', [ + Query::vectorCosine('embedding1', [1.0, 0.0, 0.0]) + ]); + $this->assertCount(1, $results); + + // Query using second index + $results = $database->find('vectorMultiIdx', [ + Query::vectorEuclidean('embedding2', [0.0, 1.0, 0.0]) + ]); + $this->assertCount(1, $results); + + // Cleanup + $database->deleteCollection('vectorMultiIdx'); + } + + public function testVectorIndexCreationFailure(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorIdxFail'); + $database->createAttribute('vectorIdxFail', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createAttribute('vectorIdxFail', 'text', Database::VAR_STRING, 255, true); + + // Try to create vector index on non-vector attribute - should fail + try { + $database->createIndex('vectorIdxFail', 'bad_idx', Database::INDEX_HNSW_COSINE, ['text']); + $this->fail('Should not allow vector index on non-vector attribute'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('vector', strtolower($e->getMessage())); + } + + // Try to create duplicate index + $database->createIndex('vectorIdxFail', 'idx1', Database::INDEX_HNSW_COSINE, ['embedding']); + try { + $database->createIndex('vectorIdxFail', 'idx1', Database::INDEX_HNSW_COSINE, ['embedding']); + $this->fail('Should not allow duplicate index'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('index', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorIdxFail'); + } + + public function testVectorQueryWithoutIndex(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorNoIndex'); + $database->createAttribute('vectorNoIndex', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create documents without any index + $database->createDocument('vectorNoIndex', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1.0, 0.0, 0.0] + ])); + + $database->createDocument('vectorNoIndex', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [0.0, 1.0, 0.0] + ])); + + // Queries should still work (sequential scan) + $results = $database->find('vectorNoIndex', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + ]); + + $this->assertCount(2, $results); + + // Cleanup + $database->deleteCollection('vectorNoIndex'); + } + + public function testVectorQueryEmpty(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorEmptyQuery'); + $database->createAttribute('vectorEmptyQuery', 'embedding', Database::VAR_VECTOR, 3, true); + + // No documents in collection + $results = $database->find('vectorEmptyQuery', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + ]); + + $this->assertCount(0, $results); + + // Cleanup + $database->deleteCollection('vectorEmptyQuery'); + } + + public function testSingleDimensionVector(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorSingleDim'); + $database->createAttribute('vectorSingleDim', 'embedding', Database::VAR_VECTOR, 1, true); + + // Create documents with single-dimension vectors + $doc1 = $database->createDocument('vectorSingleDim', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1.0] + ])); + + $doc2 = $database->createDocument('vectorSingleDim', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [0.5] + ])); + + $this->assertEquals([1.0], $doc1->getAttribute('embedding')); + $this->assertEquals([0.5], $doc2->getAttribute('embedding')); + + // Query with single dimension + $results = $database->find('vectorSingleDim', [ + Query::vectorCosine('embedding', [1.0]) + ]); + + $this->assertCount(2, $results); + + // Cleanup + $database->deleteCollection('vectorSingleDim'); + } + + public function testVectorLongResultSet(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorLongResults'); + $database->createAttribute('vectorLongResults', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create 100 documents + for ($i = 0; $i < 100; $i++) { + $database->createDocument('vectorLongResults', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [ + sin($i * 0.1), + cos($i * 0.1), + sin($i * 0.05) + ] + ])); + } + + // Query all results + $results = $database->find('vectorLongResults', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::limit(100) + ]); + + $this->assertCount(100, $results); + + // Cleanup + $database->deleteCollection('vectorLongResults'); + } + + public function testMultipleVectorQueriesOnSameCollection(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorMultiQuery'); + $database->createAttribute('vectorMultiQuery', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create documents + for ($i = 0; $i < 10; $i++) { + $database->createDocument('vectorMultiQuery', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [ + cos($i * M_PI / 10), + sin($i * M_PI / 10), + 0.0 + ] + ])); + } + + // Execute multiple different vector queries + $results1 = $database->find('vectorMultiQuery', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::limit(5) + ]); + + $results2 = $database->find('vectorMultiQuery', [ + Query::vectorEuclidean('embedding', [0.0, 1.0, 0.0]), + Query::limit(5) + ]); + + $results3 = $database->find('vectorMultiQuery', [ + Query::vectorDot('embedding', [0.5, 0.5, 0.0]), + Query::limit(5) + ]); + + // All should return results + $this->assertCount(5, $results1); + $this->assertCount(5, $results2); + $this->assertCount(5, $results3); + + // Results should be different based on query vector + $this->assertNotEquals( + $results1[0]->getId(), + $results2[0]->getId() + ); + + // Cleanup + $database->deleteCollection('vectorMultiQuery'); + } + + public function testVectorNonNumericValidationE2E(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorNonNumeric'); + $database->createAttribute('vectorNonNumeric', 'embedding', Database::VAR_VECTOR, 3, true); + + // Test null value in array + try { + $database->createDocument('vectorNonNumeric', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1.0, null, 0.0] + ])); + $this->fail('Should reject null in vector array'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + + // Test object in array + try { + $database->createDocument('vectorNonNumeric', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1.0, (object)['x' => 1], 0.0] + ])); + $this->fail('Should reject object in vector array'); + } catch (\Throwable $e) { + $this->assertTrue(true); + } + + // Cleanup + $database->deleteCollection('vectorNonNumeric'); + } + + public function testVectorLargeValues(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorLargeVals'); + $database->createAttribute('vectorLargeVals', 'embedding', Database::VAR_VECTOR, 3, true); + + // Test with very large float values (but not INF) + $doc = $database->createDocument('vectorLargeVals', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => [1e38, -1e38, 1e37] + ])); + + $this->assertNotNull($doc->getId()); + + // Query should work + $results = $database->find('vectorLargeVals', [ + Query::vectorCosine('embedding', [1e38, -1e38, 1e37]) + ]); + + $this->assertCount(1, $results); + + // Cleanup + $database->deleteCollection('vectorLargeVals'); + } + + public function testVectorPrecisionLoss(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorPrecision'); + $database->createAttribute('vectorPrecision', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create vector with high precision values + $highPrecision = [0.123456789012345, 0.987654321098765, 0.555555555555555]; + $doc = $database->createDocument('vectorPrecision', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => $highPrecision + ])); + + // Retrieve and check precision (may have some loss) + $retrieved = $doc->getAttribute('embedding'); + $this->assertCount(3, $retrieved); + + // Values should be close to original (allowing for float precision) + for ($i = 0; $i < 3; $i++) { + $this->assertEqualsWithDelta($highPrecision[$i], $retrieved[$i], 0.0001); + } + + // Cleanup + $database->deleteCollection('vectorPrecision'); + } + + public function testVector16000DimensionsBoundary(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Test exactly 16000 dimensions (pgvector limit) + $database->createCollection('vector16000'); + $database->createAttribute('vector16000', 'embedding', Database::VAR_VECTOR, 16000, true); + + // Create a vector with exactly 16000 dimensions + $largeVector = array_fill(0, 16000, 0.1); + $largeVector[0] = 1.0; + + $doc = $database->createDocument('vector16000', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => $largeVector + ])); + + $this->assertCount(16000, $doc->getAttribute('embedding')); + + // Query should work + $searchVector = array_fill(0, 16000, 0.0); + $searchVector[0] = 1.0; + + $results = $database->find('vector16000', [ + Query::vectorCosine('embedding', $searchVector), + Query::limit(1) + ]); + + $this->assertCount(1, $results); + + // Cleanup + $database->deleteCollection('vector16000'); + } + + public function testVectorLargeDatasetIndexBuild(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorLargeDataset'); + $database->createAttribute('vectorLargeDataset', 'embedding', Database::VAR_VECTOR, 128, true); + + // Create 200 documents + for ($i = 0; $i < 200; $i++) { + $vector = []; + for ($j = 0; $j < 128; $j++) { + $vector[] = sin(($i + $j) * 0.01); + } + + $database->createDocument('vectorLargeDataset', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'embedding' => $vector + ])); + } + + // Create index on large dataset + $database->createIndex('vectorLargeDataset', 'idx_hnsw', Database::INDEX_HNSW_COSINE, ['embedding']); + + // Verify queries work + $searchVector = array_fill(0, 128, 0.5); + $results = $database->find('vectorLargeDataset', [ + Query::vectorCosine('embedding', $searchVector), + Query::limit(10) + ]); + + $this->assertCount(10, $results); + + // Cleanup + $database->deleteCollection('vectorLargeDataset'); + } + + public function testVectorFilterDisabled(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorFilterDisabled'); + $database->createAttribute('vectorFilterDisabled', 'status', Database::VAR_STRING, 50, true); + $database->createAttribute('vectorFilterDisabled', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create documents + $database->createDocument('vectorFilterDisabled', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'status' => 'active', + 'embedding' => [1.0, 0.0, 0.0] + ])); + + $database->createDocument('vectorFilterDisabled', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'status' => 'disabled', + 'embedding' => [0.9, 0.1, 0.0] + ])); + + $database->createDocument('vectorFilterDisabled', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'status' => 'active', + 'embedding' => [0.8, 0.2, 0.0] + ])); + + // Query with filter excluding disabled + $results = $database->find('vectorFilterDisabled', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::notEqual('status', ['disabled']) + ]); + + $this->assertCount(2, $results); + foreach ($results as $doc) { + $this->assertEquals('active', $doc->getAttribute('status')); + } + + // Cleanup + $database->deleteCollection('vectorFilterDisabled'); + } + + public function testVectorFilterOverride(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorFilterOverride'); + $database->createAttribute('vectorFilterOverride', 'category', Database::VAR_STRING, 50, true); + $database->createAttribute('vectorFilterOverride', 'priority', Database::VAR_INTEGER, 0, true); + $database->createAttribute('vectorFilterOverride', 'embedding', Database::VAR_VECTOR, 3, true); + + // Create documents + for ($i = 0; $i < 5; $i++) { + $database->createDocument('vectorFilterOverride', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'category' => $i < 3 ? 'A' : 'B', + 'priority' => $i, + 'embedding' => [1.0 - ($i * 0.1), $i * 0.1, 0.0] + ])); + } + + // Query with multiple filters + $results = $database->find('vectorFilterOverride', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::equal('category', ['A']), + Query::greaterThan('priority', 0), + Query::limit(2) + ]); + + // Should get category A documents with priority > 0 + $this->assertCount(2, $results); + foreach ($results as $doc) { + $this->assertEquals('A', $doc->getAttribute('category')); + $this->assertGreaterThan(0, $doc->getAttribute('priority')); + } + + // Cleanup + $database->deleteCollection('vectorFilterOverride'); + } + + public function testMultipleFiltersOnVectorAttribute(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorMultiFilters'); + $database->createAttribute('vectorMultiFilters', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('vectorMultiFilters', 'embedding1', Database::VAR_VECTOR, 3, true); + $database->createAttribute('vectorMultiFilters', 'embedding2', Database::VAR_VECTOR, 3, true); + + // Create documents + $database->createDocument('vectorMultiFilters', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Doc 1', + 'embedding1' => [1.0, 0.0, 0.0], + 'embedding2' => [0.0, 1.0, 0.0] + ])); + + // Try to use multiple vector queries - should reject + try { + $database->find('vectorMultiFilters', [ + Query::vectorCosine('embedding1', [1.0, 0.0, 0.0]), + Query::vectorCosine('embedding2', [0.0, 1.0, 0.0]) + ]); + $this->fail('Should not allow multiple vector queries'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('multiple vector queries', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorMultiFilters'); + } + + public function testVectorQueryInNestedQuery(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForVectors()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('vectorNested'); + $database->createAttribute('vectorNested', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('vectorNested', 'embedding1', Database::VAR_VECTOR, 3, true); + $database->createAttribute('vectorNested', 'embedding2', Database::VAR_VECTOR, 3, true); + + // Create document + $database->createDocument('vectorNested', new Document([ + '$permissions' => [ + Permission::read(Role::any()) + ], + 'name' => 'Doc 1', + 'embedding1' => [1.0, 0.0, 0.0], + 'embedding2' => [0.0, 1.0, 0.0] + ])); + + // Try to use vector query in nested OR clause with another vector query - should reject + try { + $database->find('vectorNested', [ + Query::vectorCosine('embedding1', [1.0, 0.0, 0.0]), + Query::or([ + Query::vectorCosine('embedding2', [0.0, 1.0, 0.0]), + Query::equal('name', ['Doc 1']) + ]) + ]); + $this->fail('Should not allow multiple vector queries across nested queries'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('multiple vector queries', strtolower($e->getMessage())); + } + + // Cleanup + $database->deleteCollection('vectorNested'); + } +} diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index b9b261dc0..0a142881b 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -49,6 +49,24 @@ public function testCreate(): void $this->assertEquals('score', $query->getAttribute()); $this->assertEquals(10, $query->getValues()[0]); + // Test vector queries + $vector = [0.1, 0.2, 0.3]; + + $query = Query::vectorDot('embedding', $vector); + $this->assertEquals(Query::TYPE_VECTOR_DOT, $query->getMethod()); + $this->assertEquals('embedding', $query->getAttribute()); + $this->assertEquals([$vector], $query->getValues()); + + $query = Query::vectorCosine('embedding', $vector); + $this->assertEquals(Query::TYPE_VECTOR_COSINE, $query->getMethod()); + $this->assertEquals('embedding', $query->getAttribute()); + $this->assertEquals([$vector], $query->getValues()); + + $query = Query::vectorEuclidean('embedding', $vector); + $this->assertEquals(Query::TYPE_VECTOR_EUCLIDEAN, $query->getMethod()); + $this->assertEquals('embedding', $query->getAttribute()); + $this->assertEquals([$vector], $query->getValues()); + $query = Query::search('search', 'John Doe'); $this->assertEquals(Query::TYPE_SEARCH, $query->getMethod()); diff --git a/tests/unit/Validator/StructureTest.php b/tests/unit/Validator/StructureTest.php index 68fa73bf8..3bb525008 100644 --- a/tests/unit/Validator/StructureTest.php +++ b/tests/unit/Validator/StructureTest.php @@ -748,7 +748,7 @@ public function testId(): void $validator = new Structure( new Document($this->collection), - Database::VAR_OBJECT_ID + Database::VAR_UUID ); $this->assertEquals(true, $validator->isValid(new Document([ diff --git a/tests/unit/Validator/VectorTest.php b/tests/unit/Validator/VectorTest.php new file mode 100644 index 000000000..be98d7ecf --- /dev/null +++ b/tests/unit/Validator/VectorTest.php @@ -0,0 +1,64 @@ +assertTrue($validator->isValid([1.0, 2.0, 3.0])); + $this->assertTrue($validator->isValid([0, 0, 0])); + $this->assertTrue($validator->isValid([-1.5, 0.0, 2.5])); + + // Test invalid vectors + $this->assertFalse($validator->isValid([1.0, 2.0])); // Wrong dimensions + $this->assertFalse($validator->isValid([1.0, 2.0, 3.0, 4.0])); // Wrong dimensions + $this->assertFalse($validator->isValid('not an array')); // Not an array + $this->assertFalse($validator->isValid(['1', '2', '3'])); // String numbers should fail + $this->assertFalse($validator->isValid([1.0, 'not numeric', 3.0])); // Non-numeric value + $this->assertFalse($validator->isValid([1.0, null, 3.0])); // Null value + $this->assertFalse($validator->isValid([])); // Empty array + $this->assertFalse($validator->isValid(['x' => 1.0, 'y' => 2.0, 'z' => 3.0])); // Associative array + $this->assertFalse($validator->isValid([1.0, true, 3.0])); // Boolean value + } + + public function testVectorWithDifferentDimensions(): void + { + $validator1 = new Vector(1); + $this->assertTrue($validator1->isValid([5.0])); + $this->assertFalse($validator1->isValid([1.0, 2.0])); + + $validator5 = new Vector(5); + $this->assertTrue($validator5->isValid([1.0, 2.0, 3.0, 4.0, 5.0])); + $this->assertFalse($validator5->isValid([1.0, 2.0, 3.0])); + + $validator128 = new Vector(128); + $vector128 = array_fill(0, 128, 1.0); + $this->assertTrue($validator128->isValid($vector128)); + + $vector127 = array_fill(0, 127, 1.0); + $this->assertFalse($validator128->isValid($vector127)); + } + + public function testVectorDescription(): void + { + $validator = new Vector(3); + $this->assertEquals('Value must be an array of 3 numeric values', $validator->getDescription()); + + $validator256 = new Vector(256); + $this->assertEquals('Value must be an array of 256 numeric values', $validator256->getDescription()); + } + + public function testVectorType(): void + { + $validator = new Vector(3); + $this->assertEquals('array', $validator->getType()); + $this->assertFalse($validator->isArray()); + } +}