fix(restheadspec,resolvespec): stop casting SqlNull-wrapped and citext columns to TEXT in filters

reflect.Type.Kind() on spectypes.SqlNull[T] wrappers (SqlInt16/32/64,
SqlFloat64, SqlBool, SqlString, and embedders like SqlTimeStamp) always
reports reflect.Struct, never the wrapped T. ValidateAndAdjustFilterForColumnType
treated those as "complex" columns and forced CAST(col AS TEXT) on eq/gt/lt
filters, e.g. CAST(atdetail.rid_parent AS TEXT) = '90446096', which can't
use the index on rid_parent.

Add spectypes.UnwrapKind to see through SqlNull wrappers to the underlying
Kind, and use it in GetColumnTypeFromModel so numeric/string SqlNull columns
are recognized correctly and compared natively.

Also stop unconditionally casting to TEXT for LIKE/ILIKE and add
reflection.IsCitextColumn: citext columns are already case-insensitive, so
casting them to TEXT flips to case-sensitive matching and defeats a citext
index.
This commit is contained in:
Hein
2026-09-15 11:18:13 +02:00
parent 1885ce016b
commit 6bd6a6f164
8 changed files with 253 additions and 21 deletions
+4 -3
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
)
type PrimaryKeyNameProvider interface {
@@ -728,19 +729,19 @@ func GetColumnTypeFromModel(model interface{}, colName string) reflect.Kind {
// Parse JSON tag (format: "name,omitempty")
parts := strings.Split(jsonTag, ",")
if parts[0] == sourceColName {
return field.Type.Kind()
return spectypes.UnwrapKind(field.Type)
}
}
// Check field name (case-insensitive)
if strings.EqualFold(field.Name, sourceColName) {
return field.Type.Kind()
return spectypes.UnwrapKind(field.Type)
}
// Check snake_case conversion
snakeCaseName := ToSnakeCase(field.Name)
if snakeCaseName == sourceColName {
return field.Type.Kind()
return spectypes.UnwrapKind(field.Type)
}
}
+18
View File
@@ -3,6 +3,8 @@ package reflection
import (
"reflect"
"testing"
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
)
// Test models for GORM
@@ -1047,6 +1049,22 @@ func TestGetColumnTypeFromModel(t *testing.T) {
}
}
// SqlNull-wrapped columns (e.g. nullable bigint foreign keys) must report the
// wrapped value's Kind, not reflect.Struct, so numeric eq/gt/lt filters don't
// get an unnecessary CAST(... AS TEXT) that defeats the column's index.
type SqlNullFKModel struct {
RidParent spectypes.SqlInt64 `bun:"rid_parent" json:"rid_parent"`
}
func TestGetColumnTypeFromModel_SqlNullWrapper(t *testing.T) {
model := SqlNullFKModel{RidParent: spectypes.NewSqlInt64(90446096)}
result := GetColumnTypeFromModel(model, "rid_parent")
if result != reflect.Int64 {
t.Errorf("GetColumnTypeFromModel(rid_parent) = %v, want %v (SqlInt64 must unwrap to its numeric Kind)", result, reflect.Int64)
}
}
// ============= Tests for relation functions =============
// Models for relation testing
+26 -8
View File
@@ -145,8 +145,16 @@ func IsJSONColumn(model interface{}, colName string) bool {
// tagDeclaresJSON reports whether an ORM struct tag declares a json/jsonb column
// type, e.g. `bun:"meta,type:jsonb"` or `gorm:"column:meta;type:json"`.
func tagDeclaresJSON(tag string) bool {
return columnTypeTagValue(tag) == "json" || strings.HasPrefix(columnTypeTagValue(tag), "json(") ||
columnTypeTagValue(tag) == "jsonb" || strings.HasPrefix(columnTypeTagValue(tag), "jsonb(")
}
// columnTypeTagValue extracts the lower-cased value of a `type:` entry from a
// bun or gorm struct tag, e.g. `bun:"name,type:citext"` -> "citext". Returns ""
// if the tag carries no `type:` entry.
func columnTypeTagValue(tag string) string {
if tag == "" {
return false
return ""
}
for _, part := range strings.FieldsFunc(tag, func(r rune) bool {
return r == ',' || r == ';' || r == ' '
@@ -155,12 +163,22 @@ func tagDeclaresJSON(tag string) bool {
if !found {
continue
}
value = strings.ToLower(strings.TrimSpace(value))
// Match "json" and "jsonb", including parametrised forms just in case.
if value == "json" || value == "jsonb" ||
strings.HasPrefix(value, "json(") || strings.HasPrefix(value, "jsonb(") {
return true
}
return strings.ToLower(strings.TrimSpace(value))
}
return false
return ""
}
// IsCitextColumn reports whether colName carries an explicit `type:citext`
// bun/gorm tag. citext columns must never be CAST(... AS TEXT) for comparisons:
// that swaps in case-sensitive text semantics and defeats any citext index.
func IsCitextColumn(model interface{}, colName string) bool {
f, ok := getColumnStructField(model, colName)
if !ok {
return false
}
tagVal := columnTypeTagValue(f.Tag.Get("bun"))
if tagVal == "" {
tagVal = columnTypeTagValue(f.Tag.Get("gorm"))
}
return tagVal == "citext"
}