Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
0172a2a
Add vector attribute support for PostgreSQL with pgvector extension
cursoragent Aug 15, 2025
5d5d118
Checkpoint before follow-up message
cursoragent Aug 15, 2025
2130be7
Add getSupportForVectors method to database adapters for vector support
cursoragent Aug 15, 2025
f13bb3b
Add vector support configuration and improve type validation
cursoragent Aug 15, 2025
3c6f328
Fix vector type default validation to prevent unnecessary recursion
cursoragent Aug 15, 2025
c8b33cc
Checkpoint before follow-up message
cursoragent Aug 15, 2025
04c05f6
Checkpoint before follow-up message
cursoragent Aug 15, 2025
98aacd5
Refactor vector validation to use 'size' instead of 'dimensions'
cursoragent Aug 15, 2025
755e6e8
Add vector type validation checks in Database class
cursoragent Aug 15, 2025
aa4798a
Add comprehensive vector query tests for PostgreSQL adapter
cursoragent Aug 15, 2025
bd9adb2
Merge remote-tracking branch 'origin/main' into feat/postgresql-vecto…
abnegate Sep 2, 2025
07bdc65
Ensure extension on createCollection
abnegate Sep 2, 2025
4ffcad9
Validate dimensions
abnegate Sep 2, 2025
8fc8ebf
Add HNSW index support
abnegate Sep 2, 2025
9af414f
Fix vector query
abnegate Sep 2, 2025
e2ec1cd
Update tests
abnegate Sep 2, 2025
dd2443e
Add ext for tests
abnegate Sep 2, 2025
4cbef8c
Fix queries
abnegate Sep 2, 2025
ad060b8
Fix query value mapping
abnegate Sep 2, 2025
a6561b3
Add byte counting
abnegate Sep 2, 2025
e8c8dde
Validate single attribute for indexes
abnegate Sep 2, 2025
f1d7daa
Add more tests
abnegate Sep 2, 2025
5752bd0
Fix decode
abnegate Sep 2, 2025
574e129
Fix lint
abnegate Sep 2, 2025
a33609c
Merge branch 'main' into feat/postgresql-vector-support
abnegate Sep 2, 2025
a7df34f
Fix test
abnegate Sep 2, 2025
89a33de
Fix tests
abnegate Sep 2, 2025
0a8fc09
Merge branch 'feat/postgresql-vector-support' of github.com:utopia-ph…
abnegate Sep 2, 2025
089c706
Fix tests
abnegate Sep 2, 2025
a402cb0
Fix tests
abnegate Sep 2, 2025
768fffb
Use const for max dims
abnegate Sep 3, 2025
952ddb7
Improve index validation
abnegate Sep 3, 2025
793be26
Fix tests
abnegate Sep 4, 2025
2aa62af
Merge branch 'main' into feat/postgresql-vector-support
abnegate Sep 4, 2025
aedec64
Update src/Database/Validator/Index.php
abnegate Sep 4, 2025
8912d5e
Validate default
abnegate Sep 4, 2025
3a4477e
Format
abnegate Sep 4, 2025
c2e3bea
Merge branch 'main' into feat/postgresql-vector-support
abnegate Sep 4, 2025
4310bfd
Merge remote-tracking branch 'origin/main' into feat/postgresql-vecto…
abnegate Oct 17, 2025
2a53c11
Check spatial attribute type in validator
abnegate Oct 17, 2025
2fc601a
Cleanup
abnegate Oct 17, 2025
2faf75a
Use filters instead of manual encode/decode
abnegate Oct 17, 2025
e8318df
Add more tests
abnegate Oct 17, 2025
08c1407
Update tests/e2e/Adapter/Scopes/VectorTests.php
abnegate Oct 17, 2025
afbf531
Fix tests
abnegate Oct 17, 2025
a05d4af
Merge branch 'feat/postgresql-vector-support' of github.com:utopia-ph…
abnegate Oct 17, 2025
b28102c
Fix test
abnegate Oct 17, 2025
50c040f
Fix validator
abnegate Oct 17, 2025
e16689d
Reject multiple vector queries
abnegate Oct 17, 2025
966b637
Stricter validation
abnegate Oct 17, 2025
fc907a3
Fix tests
abnegate Oct 17, 2025
ffe9b9a
Fix permission test
abnegate Oct 17, 2025
f02aa64
Simplify extension install
abnegate Oct 17, 2025
ed69345
Remove dead code
abnegate Oct 17, 2025
363cc8d
Simplify encode
abnegate Oct 17, 2025
b5a3c95
Simplify check
abnegate Oct 17, 2025
f06ef55
Update src/Database/Validator/Query/Filter.php
abnegate Oct 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions postgres.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
7 changes: 7 additions & 0 deletions src/Database/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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?
*
Expand Down
6 changes: 3 additions & 3 deletions src/Database/Adapter/MariaDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -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))';
}
}

Expand Down Expand Up @@ -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))';
}
}

Expand Down Expand Up @@ -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()) {
Expand Down
2 changes: 1 addition & 1 deletion src/Database/Adapter/MySQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
5 changes: 5 additions & 0 deletions src/Database/Adapter/Pool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
125 changes: 99 additions & 26 deletions src/Database/Adapter/Postgres.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -193,9 +194,6 @@ public function createCollection(string $name, array $attributes = [], array $in
$namespace = $this->getNamespace();
$id = $this->filter($name);

/** @var array<string> $attributeStrings */
$attributeStrings = [];

/** @var array<string> $attributeStrings */
$attributeStrings = [];
foreach ($attributes as $attribute) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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')";
Expand Down Expand Up @@ -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];

Expand All @@ -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);

Expand Down Expand Up @@ -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";
}

Expand Down Expand Up @@ -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];
Expand All @@ -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
Expand Down Expand Up @@ -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<string, mixed> $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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1889,6 +1952,16 @@ public function getSupportForUpserts(): bool
return true;
}

/**
* Is vector type supported?
*
* @return bool
*/
public function getSupportForVectors(): bool
{
return true;
}

/**
* @return string
*/
Expand Down
Loading