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
+55 -1
View File
@@ -419,7 +419,12 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// If we have computed columns/expressions but options.Columns is empty,
// populate it with all model columns first since computed columns are additions
if len(options.Columns) == 0 && (len(options.ComputedQL) > 0 || len(options.ComputedColumns) > 0) {
vectorSearchActive := options.VectorSearch != nil &&
options.VectorSearch.Column != "" && len(options.VectorSearch.Vector) > 0
if len(options.Columns) == 0 &&
(len(options.ComputedQL) > 0 || len(options.ComputedColumns) > 0 ||
(vectorSearchActive && options.VectorSearch.As != "")) {
logger.Debug("Populating options.Columns with all model columns since computed columns are additions")
options.Columns = reflection.GetSQLModelColumns(model)
}
@@ -472,6 +477,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 expand (Just expand to Preload for now)
for _, expand := range options.Expand {
logger.Debug("Applying expand: %s", expand.Relation)
@@ -2330,6 +2358,18 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
colName := h.qualifyColumnName(filter.Column, tableName)
return applyWhere(fmt.Sprintf("(%s IS NOT NULL AND %s != '')", colName, colName))
default:
if common.IsSpatialOperator(filter.Operator) {
if cond, sargs, ok := common.BuildSpatialCondition(rawQualifiedColumn, filter.Operator, filter.Value); ok {
return applyWhere(cond, sargs...)
}
return query
}
if common.IsVectorOperator(filter.Operator) {
if cond, vargs, ok := common.BuildVectorCondition(rawQualifiedColumn, filter.Operator, filter.Value); ok {
return applyWhere(cond, vargs...)
}
return query
}
logger.Warn("Unknown filter operator: %s, defaulting to equals", filter.Operator)
return applyWhere(fmt.Sprintf("%s = ?", qualifiedColumn), filter.Value)
}
@@ -2429,6 +2469,20 @@ func (h *Handler) buildFilterCondition(qualifiedColumn string, filter *common.Fi
colName := h.qualifyColumnName(filter.Column, tableName)
return fmt.Sprintf("(%s IS NOT NULL AND %s != '')", colName, colName), nil
default:
if common.IsSpatialOperator(filter.Operator) {
rawCol := h.qualifyColumnName(filter.Column, tableName)
if cond, sargs, ok := common.BuildSpatialCondition(rawCol, filter.Operator, filter.Value); ok {
return cond, sargs
}
return "", nil
}
if common.IsVectorOperator(filter.Operator) {
rawCol := h.qualifyColumnName(filter.Column, tableName)
if cond, vargs, ok := common.BuildVectorCondition(rawCol, filter.Operator, filter.Value); ok {
return cond, vargs
}
return "", nil
}
logger.Warn("Unknown filter operator: %s, defaulting to equals", filter.Operator)
return fmt.Sprintf("%s = ?", qualifiedColumn), []interface{}{filter.Value}
}
+101
View File
@@ -182,6 +182,22 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request, model interface{}) E
h.parseSearchOp(&options, key, decodedValue, "AND")
case strings.HasPrefix(key, "x-searchcols"):
options.SearchColumns = h.parseCommaSeparated(decodedValue)
case strings.HasPrefix(key, "x-spatialfilter-"):
h.parseGeoFilter(&options, key, "x-spatialfilter-", decodedValue)
case strings.HasPrefix(key, "x-vectorfilter-"):
h.parseGeoFilter(&options, key, "x-vectorfilter-", decodedValue)
// pgvector KNN search
case key == "x-vector-search-vector":
h.ensureVectorSearch(&options).Vector = parseFloat32List(decodedValue)
case key == "x-vector-search-as":
h.ensureVectorSearch(&options).As = decodedValue
case key == "x-vector-search-dir":
h.ensureVectorSearch(&options).Direction = decodedValue
case strings.HasPrefix(key, "x-vector-search-"):
vs := h.ensureVectorSearch(&options)
vs.Column = strings.TrimPrefix(key, "x-vector-search-")
vs.Metric = decodedValue
case strings.HasPrefix(key, "x-custom-sql-w"):
if options.CustomSQLWhere != "" {
options.CustomSQLWhere = fmt.Sprintf("%s AND (%s)", options.CustomSQLWhere, decodedValue)
@@ -309,6 +325,83 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request, model interface{}) E
return options
}
// ensureVectorSearch returns the options' VectorSearchOption, allocating it on
// first use.
func (h *Handler) ensureVectorSearch(options *ExtendedRequestOptions) *common.VectorSearchOption {
if options.VectorSearch == nil {
options.VectorSearch = &common.VectorSearchOption{}
}
return options.VectorSearch
}
// parseFloat32List parses a JSON array ("[1,2,3]") or comma-separated list into
// a []float32.
func parseFloat32List(value string) []float32 {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
var raw []float64
if err := json.Unmarshal([]byte(value), &raw); err == nil {
out := make([]float32, len(raw))
for i, f := range raw {
out[i] = float32(f)
}
return out
}
parts := strings.Split(strings.Trim(value, "[]"), ",")
out := make([]float32, 0, len(parts))
for _, p := range parts {
f, err := strconv.ParseFloat(strings.TrimSpace(p), 32)
if err != nil {
return nil
}
out = append(out, float32(f))
}
return out
}
// parseGeoFilter parses an x-spatialfilter-<col> / x-vectorfilter-<col> header.
// The value is a JSON object: {"op":"st_dwithin","geom":...,"distance":...} or
// {"op":"st_intersects","value":<geojson>}. An optional "logic":"or" controls
// how the filter combines with the previous one.
func (h *Handler) parseGeoFilter(options *ExtendedRequestOptions, key, prefix, value string) {
col := strings.TrimPrefix(key, prefix)
if col == "" || strings.TrimSpace(value) == "" {
return
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(value), &raw); err != nil {
logger.Warn("Invalid %s%s filter JSON: %v", prefix, col, err)
return
}
op, _ := raw["op"].(string)
if op == "" {
logger.Warn("%s%s filter missing \"op\"", prefix, col)
return
}
logicOp := "AND"
if lo, ok := raw["logic"].(string); ok && strings.EqualFold(lo, "or") {
logicOp = "OR"
}
var fv interface{}
if v, ok := raw["value"]; ok {
fv = v
} else {
delete(raw, "op")
delete(raw, "logic")
fv = raw
}
options.Filters = append(options.Filters, common.FilterOption{
Column: col,
Operator: op,
Value: fv,
LogicOperator: logicOp,
})
}
// parseSelectFields parses x-select-fields header
func (h *Handler) parseSelectFields(options *ExtendedRequestOptions, value string) {
if value == "" {
@@ -1365,6 +1458,14 @@ func (h *Handler) ValidateAndAdjustFilterForColumnType(filter *common.FilterOpti
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
}
// Never cast geometry/geography or pgvector columns to TEXT — spatial and
// vector operators need the native column type. Also bypass when the
// operator itself is spatial/vector (e.g. st_dwithin, l2_within).
if common.IsSpatialOperator(filter.Operator) || common.IsVectorOperator(filter.Operator) ||
reflection.IsSpatialColumn(model, filter.Column) || reflection.IsVectorColumn(model, filter.Column) {
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
}
colType := reflection.GetColumnTypeFromModel(model, filter.Column)
if colType == reflect.Invalid {
// Column not found in model, no casting needed
+124
View File
@@ -0,0 +1,124 @@
package restheadspec
import (
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
func TestBuildFilterCondition_Spatial(t *testing.T) {
h := &Handler{}
tests := []struct {
name string
filter common.FilterOption
wantCond string
wantCount int
}{
{
name: "st_dwithin",
filter: common.FilterOption{
Column: "geom",
Operator: "st_dwithin",
Value: map[string]interface{}{
"geom": "SRID=4326;POINT(0 0)",
"distance": 1000.0,
},
},
wantCond: "ST_DWithin(geom, ST_GeomFromEWKT(?), ?)",
wantCount: 2,
},
{
name: "st_intersects",
filter: common.FilterOption{
Column: "geom",
Operator: "st_intersects",
Value: "SRID=4326;POINT(0 0)",
},
wantCond: "ST_Intersects(geom, ST_GeomFromEWKT(?))",
wantCount: 1,
},
{
name: "l2_within",
filter: common.FilterOption{
Column: "embedding",
Operator: "l2_within",
Value: map[string]interface{}{
"vector": []interface{}{1.0, 2.0},
"distance": 0.3,
},
},
wantCond: "embedding <-> ? < ?",
wantCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := tt.filter
cond, args := h.buildFilterCondition(f.Column, &f, "")
if cond != tt.wantCond {
t.Errorf("cond = %q, want %q", cond, tt.wantCond)
}
if len(args) != tt.wantCount {
t.Errorf("args = %d, want %d", len(args), tt.wantCount)
}
})
}
}
func TestParseGeoFilter(t *testing.T) {
h := &Handler{}
options := &ExtendedRequestOptions{}
h.parseGeoFilter(options, "x-spatialfilter-geom", "x-spatialfilter-",
`{"op":"st_dwithin","geom":"SRID=4326;POINT(0 0)","distance":500}`)
if len(options.Filters) != 1 {
t.Fatalf("expected 1 filter, got %d", len(options.Filters))
}
f := options.Filters[0]
if f.Column != "geom" || f.Operator != "st_dwithin" {
t.Errorf("filter = %+v", f)
}
m, ok := f.Value.(map[string]interface{})
if !ok || m["distance"] != float64(500) {
t.Errorf("value = %v", f.Value)
}
if _, has := m["op"]; has {
t.Error("op should be stripped from value map")
}
}
func TestParseGeoFilter_ExplicitValue(t *testing.T) {
h := &Handler{}
options := &ExtendedRequestOptions{}
h.parseGeoFilter(options, "x-vectorfilter-embedding", "x-vectorfilter-",
`{"op":"cosine_within","logic":"or","value":{"vector":[1,2,3],"distance":0.2}}`)
if len(options.Filters) != 1 {
t.Fatalf("expected 1 filter, got %d", len(options.Filters))
}
f := options.Filters[0]
if f.Operator != "cosine_within" || f.LogicOperator != "OR" {
t.Errorf("filter = %+v", f)
}
if _, ok := f.Value.(map[string]interface{}); !ok {
t.Errorf("value type = %T", f.Value)
}
}
func TestParseFloat32List(t *testing.T) {
got := parseFloat32List("[1,2.5,3]")
if len(got) != 3 || got[1] != 2.5 {
t.Errorf("json array = %v", got)
}
got = parseFloat32List("1, 2, 3")
if len(got) != 3 || got[2] != 3 {
t.Errorf("csv = %v", got)
}
if parseFloat32List("") != nil {
t.Error("empty should be nil")
}
}