mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-08-30 04:52:35 +00:00
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:
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user