feat(spectypes): add support for PostGIS and pgvector types

* Implement custom types: SqlGeometry, SqlGeography, SqlHalfVector, SqlSparseVector, SqlBitVector
* Add spatial filter operators and vector similarity operators
* Include metadata and OpenAPI reporting for geometry/vector column types
* Create tests for EWKB and WKT conversions
This commit is contained in:
2026-08-29 21:27:42 +02:00
parent 798bb47e71
commit f259df1258
22 changed files with 2886 additions and 9 deletions
+37
View File
@@ -87,6 +87,43 @@ func TestBuildFilterCondition(t *testing.T) {
expectedCondition: "",
expectedArgsCount: 0,
},
{
name: "st_dwithin spatial operator",
filter: common.FilterOption{
Column: "geom",
Operator: "st_dwithin",
Value: map[string]any{
"geom": "SRID=4326;POINT(0 0)",
"distance": 1000.0,
},
},
expectedCondition: "ST_DWithin(geom, ST_GeomFromEWKT(?), ?)",
expectedArgsCount: 2,
},
{
name: "st_intersects spatial operator",
filter: common.FilterOption{
Column: "geom",
Operator: "st_intersects",
Value: "SRID=4326;POLYGON((0 0,1 0,1 1,0 1,0 0))",
LogicOperator: "",
},
expectedCondition: "ST_Intersects(geom, ST_GeomFromEWKT(?))",
expectedArgsCount: 1,
},
{
name: "l2_within vector operator",
filter: common.FilterOption{
Column: "embedding",
Operator: "l2_within",
Value: map[string]any{
"vector": []any{1.0, 2.0, 3.0},
"distance": 0.5,
},
},
expectedCondition: "embedding <-> ? < ?",
expectedArgsCount: 2,
},
}
for _, tt := range tests {
+73 -5
View File
@@ -16,6 +16,7 @@ import (
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
)
// FallbackHandler is a function that handles requests when no model is found
@@ -332,8 +333,13 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
query = query.Table(tableName)
}
if len(options.Columns) == 0 && (len(options.ComputedColumns) > 0) {
logger.Debug("Populating options.Columns with all model columns since computed columns are additions")
vectorSearchActive := options.VectorSearch != nil &&
options.VectorSearch.Column != "" && len(options.VectorSearch.Vector) > 0
if len(options.Columns) == 0 &&
(len(options.ComputedColumns) > 0 ||
(vectorSearchActive && options.VectorSearch.As != "")) {
logger.Debug("Populating options.Columns with all model columns since computed/vector columns are additions")
options.Columns = reflection.GetSQLModelColumns(model)
}
@@ -352,6 +358,29 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
}
}
// pgvector KNN search: order by distance to the query vector and,
// optionally, return that distance as an extra column. Postgres only.
if vectorSearchActive {
vs := options.VectorSearch
op := common.VectorOperator(vs.Metric)
lit, litErr := common.VectorLiteral(vs.Vector)
if litErr != nil {
logger.Error("Invalid vector search vector: %v", litErr)
statusCode, errCode, errMsg = http.StatusBadRequest, "invalid_vector_search", "Invalid vector search vector"
return litErr
}
col := common.QuoteIdent(vs.Column)
dir := "ASC"
if strings.EqualFold(vs.Direction, "desc") {
dir = "DESC"
}
if vs.As != "" {
query = query.ColumnExpr(fmt.Sprintf("(%s %s ?) AS %s", col, op, common.QuoteIdent(vs.As)), lit)
}
query = query.OrderExpr(fmt.Sprintf("%s %s ? %s", col, op, dir), lit)
logger.Debug("Applying vector search on %s (%s)", vs.Column, op)
}
// Apply preloading
if len(options.Preload) > 0 {
var err error
@@ -1909,7 +1938,21 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionStr
return "", nil
}
default:
return "", nil
if common.IsSpatialOperator(filter.Operator) {
q, a, ok := common.BuildSpatialCondition(filter.Column, filter.Operator, filter.Value)
if !ok {
return "", nil
}
condition, args = q, a
} else if common.IsVectorOperator(filter.Operator) {
q, a, ok := common.BuildVectorCondition(filter.Column, filter.Operator, filter.Value)
if !ok {
return "", nil
}
condition, args = q, a
} else {
return "", nil
}
}
return condition, args
@@ -1958,7 +2001,21 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
return query
}
default:
return query
if common.IsSpatialOperator(filter.Operator) {
q, a, ok := common.BuildSpatialCondition(filter.Column, filter.Operator, filter.Value)
if !ok {
return query
}
condition, args = q, a
} else if common.IsVectorOperator(filter.Operator) {
q, a, ok := common.BuildVectorCondition(filter.Column, filter.Operator, filter.Value)
if !ok {
return query
}
condition, args = q, a
} else {
return query
}
}
// Apply filter with appropriate logic operator
@@ -2094,9 +2151,20 @@ func (h *Handler) generateMetadata(schema, entity string, model interface{}) *co
continue
}
colTypeStr := getColumnType(columnField)
// Fill the gap for spectypes wrappers whose Go representation (struct or
// slice) has no obvious SQL mapping — PostGIS geometry/geography and
// pgvector vector/halfvec/sparsevec/bit. A gorm `type:` tag still wins
// (dimensioned types like vector(1536)).
if colTypeStr == "unknown" && !strings.Contains(field.Tag.Get("gorm"), "type:") {
if n, ok := spectypes.SQLTypeName(field.Type); ok {
colTypeStr = n
}
}
column := common.Column{
Name: jsonName,
Type: getColumnType(columnField),
Type: colTypeStr,
IsNullable: isSQLType || isNullable(field),
IsPrimary: strings.Contains(gormTag, "primaryKey"),
IsUnique: strings.Contains(gormTag, "unique") || strings.Contains(gormTag, "uniqueIndex"),