Compare commits

..
6 Commits
Author SHA1 Message Date
Hein 6de9be0ae7 chore(proxy): remove outdated proxy documentation 2026-09-17 11:09:14 +02:00
Hein 82e923b16e fix(quickproxy): use http.NotFoundHandler per golangci-lint gocritic 2026-09-17 11:08:12 +02:00
Hein 9a664593f0 feat(quickproxy): add reverse-proxy-with-static-fallback package
Adds pkg/server/quickproxy: longest-prefix rule matching over
net/http/httputil.ReverseProxy, falling back to a caller-supplied
handler when the upstream is unreachable or returns 404. Any other
upstream response streams through unchanged. All HTTP methods are
proxied, with a configurable global dial/response-header timeout
(quickproxy.WithTimeout, default 10s).

GoCore-side wiring (config field, webserver2/proxy.go, server.go
route ordering) is tracked separately in that repo.
2026-09-17 11:07:53 +02:00
Hein 6e3124e4e0 fix(restheadspec): prevent casting numeric values in ILIKE filters
Tests / Unit Tests (push) Failing after 5s
Tests / Integration Tests (push) Failing after 24s
Build , Vet Test, and Lint / Build (push) Successful in 54s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 1m6s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m38s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m47s
2026-09-15 13:55:11 +02:00
Hein d5de48011b fix(websocketspec,mqttspec,resolvemcp): stop casting citext columns to TEXT for LIKE/ILIKE
Same class of bug as the restheadspec/resolvespec fix: these handlers
unconditionally rendered CAST(col AS TEXT) LIKE/ILIKE for every column,
which flips a citext column to case-sensitive matching and defeats a
citext index. Thread the model through to buildFilterCondition/applyFilters
so reflection.IsCitextColumn can skip the cast for citext columns.

resolvemcp's eq/neq/gt/lt paths never cast (they never had the
restheadspec-style reflect.Kind cast heuristic), so this only touches
LIKE/ILIKE. funcspec is unaffected: it has no Go struct model to check
against (colname/value come straight from SQL function parameters).
2026-09-15 11:26:28 +02:00
Hein 6bd6a6f164 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.
2026-09-15 11:18:13 +02:00
13 changed files with 758 additions and 33 deletions
+14 -2
View File
@@ -720,7 +720,13 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
}
op := strings.ToLower(filter.Operator)
if op == "like" || op == "ilike" {
query = query.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
// citext columns are already case-insensitive; casting to TEXT would
// switch to case-sensitive matching and defeat a citext index.
if reflection.IsCitextColumn(hookCtx.Model, filter.Column) {
query = query.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
} else {
query = query.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
}
} else {
query = query.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
}
@@ -786,7 +792,13 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
}
op := strings.ToLower(filter.Operator)
if op == "like" || op == "ilike" {
countQuery = countQuery.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
// citext columns are already case-insensitive; casting to TEXT would
// switch to case-sensitive matching and defeat a citext index.
if reflection.IsCitextColumn(hookCtx.Model, filter.Column) {
countQuery = countQuery.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
} else {
countQuery = countQuery.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
}
} else {
countQuery = countQuery.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
}
+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"
}
+19 -10
View File
@@ -269,7 +269,7 @@ func (h *Handler) executeRead(ctx context.Context, schema, entity, id string, op
}
// Filters
query = h.applyFilters(query, options.Filters)
query = h.applyFilters(query, options.Filters, model)
// Custom operators
for _, customOp := range options.CustomOperators {
@@ -751,8 +751,10 @@ func (h *Handler) executeDelete(ctx context.Context, schema, entity, id string)
return recordToDelete, nil
}
// applyFilters applies all filters with OR grouping logic.
func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
// applyFilters applies all filters with OR grouping logic. model, when
// non-nil, lets citext columns be recognised so LIKE/ILIKE compares them
// natively instead of casting to TEXT (which would defeat a citext index).
func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption, model interface{}) common.SelectQuery {
if len(filters) == 0 {
return query
}
@@ -768,10 +770,10 @@ func (h *Handler) applyFilters(query common.SelectQuery, filters []common.Filter
orGroup = append(orGroup, filters[j])
j++
}
query = h.applyFilterGroup(query, orGroup)
query = h.applyFilterGroup(query, orGroup, model)
i = j
} else {
condition, args := h.buildFilterCondition(filters[i])
condition, args := h.buildFilterCondition(filters[i], model)
if condition != "" {
query = query.Where(condition, args...)
}
@@ -782,12 +784,12 @@ func (h *Handler) applyFilters(query common.SelectQuery, filters []common.Filter
return query
}
func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption, model interface{}) common.SelectQuery {
var conditions []string
var args []interface{}
for _, filter := range filters {
condition, filterArgs := h.buildFilterCondition(filter)
condition, filterArgs := h.buildFilterCondition(filter, model)
if condition != "" {
conditions = append(conditions, condition)
args = append(args, filterArgs...)
@@ -803,7 +805,14 @@ func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.Fi
return query.Where("("+strings.Join(conditions, " OR ")+")", args...)
}
func (h *Handler) buildFilterCondition(filter common.FilterOption) (condition string, args []interface{}) {
func (h *Handler) buildFilterCondition(filter common.FilterOption, model interface{}) (condition string, args []interface{}) {
// citext columns are already case-insensitive; casting to TEXT would
// switch to case-sensitive matching and defeat a citext index.
likeColumn := filter.Column
if !reflection.IsCitextColumn(model, filter.Column) {
likeColumn = fmt.Sprintf("CAST(%s AS TEXT)", filter.Column)
}
switch filter.Operator {
case "eq", "=":
return fmt.Sprintf("%s = ?", filter.Column), []interface{}{filter.Value}
@@ -818,9 +827,9 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (condition st
case "lte", "<=":
return fmt.Sprintf("%s <= ?", filter.Column), []interface{}{filter.Value}
case "like":
return fmt.Sprintf("CAST(%s AS TEXT) LIKE ?", filter.Column), []interface{}{filter.Value}
return fmt.Sprintf("%s LIKE ?", likeColumn), []interface{}{filter.Value}
case "ilike":
return fmt.Sprintf("CAST(%s AS TEXT) ILIKE ?", filter.Column), []interface{}{filter.Value}
return fmt.Sprintf("%s ILIKE ?", likeColumn), []interface{}{filter.Value}
case "in":
condition, args := common.BuildInCondition(filter.Column, filter.Value)
return condition, args
+16 -4
View File
@@ -1937,10 +1937,10 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption, model interfa
condition = fmt.Sprintf("%s <= ?", filter.Column)
args = []interface{}{filter.Value}
case "like":
condition = fmt.Sprintf("CAST(%s AS TEXT) LIKE ?", filter.Column)
condition = fmt.Sprintf("%s LIKE ?", likeColumn(filter.Column, model))
args = []interface{}{filter.Value}
case "ilike":
condition = fmt.Sprintf("CAST(%s AS TEXT) ILIKE ?", filter.Column)
condition = fmt.Sprintf("%s ILIKE ?", likeColumn(filter.Column, model))
args = []interface{}{filter.Value}
case "in":
condition, args = common.BuildInCondition(filter.Column, filter.Value)
@@ -1973,6 +1973,18 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption, model interfa
return condition, args
}
// likeColumn returns the column expression to use for LIKE/ILIKE. citext
// columns are compared natively — they're already case-insensitive, and
// CAST(... AS TEXT) would switch to case-sensitive matching and defeat a
// citext index. Every other column is cast to TEXT so LIKE/ILIKE also works
// against date/time/timestamp and numeric columns.
func likeColumn(column string, model interface{}) string {
if reflection.IsCitextColumn(model, column) {
return column
}
return fmt.Sprintf("CAST(%s AS TEXT)", column)
}
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption, model interface{}) common.SelectQuery {
// Determine which method to use based on LogicOperator
useOrLogic := strings.EqualFold(filter.LogicOperator, "OR")
@@ -2007,10 +2019,10 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
condition = fmt.Sprintf("%s <= ?", filter.Column)
args = []interface{}{filter.Value}
case "like":
condition = fmt.Sprintf("CAST(%s AS TEXT) LIKE ?", filter.Column)
condition = fmt.Sprintf("%s LIKE ?", likeColumn(filter.Column, model))
args = []interface{}{filter.Value}
case "ilike":
condition = fmt.Sprintf("CAST(%s AS TEXT) ILIKE ?", filter.Column)
condition = fmt.Sprintf("%s ILIKE ?", likeColumn(filter.Column, model))
args = []interface{}{filter.Value}
case "in":
condition, args = common.BuildInCondition(filter.Column, filter.Value)
+175
View File
@@ -0,0 +1,175 @@
package restheadspec
import (
"reflect"
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
)
// atdetailModel mirrors the real-world model that triggered this regression:
// rid_parent is a nullable bigint foreign key, backed by spectypes.SqlInt64
// (a SqlNull[int64] alias). An eq filter on it was being rendered as
// CAST(atdetail.rid_parent AS TEXT) = '90446096', which can't use the index
// on rid_parent. Name is a citext column, which must never be cast to TEXT
// either (that would switch to case-sensitive matching and lose its index).
type atdetailModel struct {
RidParent spectypes.SqlInt64 `json:"rid_parent" bun:"rid_parent"`
Name string `json:"name" bun:"name,type:citext"`
}
func TestValidateAndAdjustFilterForColumnType_SqlNullNumeric(t *testing.T) {
h := &Handler{}
model := atdetailModel{}
filter := &common.FilterOption{Column: "rid_parent", Operator: "eq", Value: "90446096"}
info := h.ValidateAndAdjustFilterForColumnType(filter, model)
if info.NeedsCast {
t.Fatalf("expected NeedsCast=false for a numeric SqlInt64 column with a numeric value, got true")
}
if !info.IsNumericType {
t.Fatalf("expected IsNumericType=true for a SqlInt64 column")
}
if v, ok := filter.Value.(int64); !ok || v != 90446096 {
t.Fatalf("expected filter.Value to be converted to int64(90446096), got %#v", filter.Value)
}
}
func TestApplyFilter_SqlNullNumeric_NoCastKeepsIndexUsable(t *testing.T) {
h := &Handler{}
model := atdetailModel{}
filter := common.FilterOption{Column: "rid_parent", Operator: "eq", Value: "90446096"}
castInfo := h.ValidateAndAdjustFilterForColumnType(&filter, model)
q := &jsonCapQuery{}
h.applyFilter(q, filter, "public.atdetail", castInfo.NeedsCast, "AND", model)
c := q.only(t)
const want = "atdetail.rid_parent = ?"
if c.query != want {
t.Fatalf("query = %q, want %q (must not CAST a numeric column to TEXT)", c.query, want)
}
if !reflect.DeepEqual(c.args, []interface{}{int64(90446096)}) {
t.Fatalf("args = %#v", c.args)
}
}
// TestFieldFilterHeader_SqlNullNumeric_EndToEnd reproduces the exact reported
// regression: a request carrying the header
//
// x-fieldfilter-rid_parent: 90446096
//
// against a model whose rid_parent field is a nullable bigint (spectypes.SqlInt64).
// Before the fix, this parsed to a filter that got CAST(atdetail.rid_parent AS TEXT) = '90446096',
// making the query unable to use the index on rid_parent. It must now parse to
// a native "atdetail.rid_parent = ?" comparison with an int64 argument.
func TestFieldFilterHeader_SqlNullNumeric_EndToEnd(t *testing.T) {
h := NewHandler(nil, nil)
model := atdetailModel{}
req := &MockRequest{
headers: map[string]string{
"x-fieldfilter-rid_parent": "90446096",
},
queryParams: map[string]string{},
}
options := h.parseOptionsFromHeaders(req, model)
if len(options.Filters) != 1 {
t.Fatalf("expected 1 filter parsed from x-fieldfilter-rid_parent, got %d: %+v", len(options.Filters), options.Filters)
}
filter := options.Filters[0]
if filter.Column != "rid_parent" || filter.Operator != "eq" {
t.Fatalf("unexpected parsed filter: %+v", filter)
}
if filter.Value != "90446096" {
t.Fatalf("expected raw header string value before type validation, got %#v", filter.Value)
}
// This is the exact step that decided whether to CAST: ValidateAndAdjustFilterForColumnType
// used to see reflect.Struct for the SqlInt64-wrapped column and cast to TEXT.
castInfo := h.ValidateAndAdjustFilterForColumnType(&filter, model)
if castInfo.NeedsCast {
t.Fatalf("regression: numeric SqlInt64 column x-fieldfilter-rid_parent got NeedsCast=true, " +
"which renders CAST(atdetail.rid_parent AS TEXT) = '90446096' and defeats the column's index")
}
q := &jsonCapQuery{}
h.applyFilter(q, filter, "public.atdetail", castInfo.NeedsCast, filter.LogicOperator, model)
c := q.only(t)
const want = "atdetail.rid_parent = ?"
if c.query != want {
t.Fatalf("SQL condition = %q, want %q (no CAST, so the rid_parent index can still be used)", c.query, want)
}
if !reflect.DeepEqual(c.args, []interface{}{int64(90446096)}) {
t.Fatalf("args = %#v, want [int64(90446096)]", c.args)
}
}
func TestApplyFilter_Citext_NeverCastForEqOrIlike(t *testing.T) {
h := &Handler{}
model := atdetailModel{}
t.Run("eq", func(t *testing.T) {
filter := common.FilterOption{Column: "name", Operator: "eq", Value: "Acme"}
castInfo := h.ValidateAndAdjustFilterForColumnType(&filter, model)
if castInfo.NeedsCast {
t.Fatalf("citext column must never need a CAST")
}
q := &jsonCapQuery{}
h.applyFilter(q, filter, "public.atdetail", castInfo.NeedsCast, "AND", model)
if c := q.only(t); c.query != "atdetail.name = ?" {
t.Fatalf("query = %q", c.query)
}
})
t.Run("ilike", func(t *testing.T) {
filter := common.FilterOption{Column: "name", Operator: "ilike", Value: "%acme%"}
q := &jsonCapQuery{}
h.applyFilter(q, filter, "public.atdetail", false, "AND", model)
if c := q.only(t); c.query != "atdetail.name ILIKE ?" {
t.Fatalf("query = %q, want no CAST for a citext column", c.query)
}
})
}
// TestValidateAndAdjustFilterForColumnType_NumericColumn_Ilike reproduces a
// global "search all columns" request (x-searchor-contains-<col> per column,
// e.g. the X-Filter-All style OR group) landing an ILIKE filter with a
// '%...%'-wrapped numeric-looking value on a numeric column such as
// rid_parent. Before the fix, ValidateAndAdjustFilterForColumnType trimmed
// the '%' wildcards, saw a numeric string, and rewrote filter.Value to an
// int64 -- so applyFilter's CAST(col AS TEXT) ILIKE ? bound an integer
// argument instead of the wildcard string, and Postgres rejected it with
// "operator does not exist: text ~~* integer".
func TestValidateAndAdjustFilterForColumnType_NumericColumn_Ilike(t *testing.T) {
h := &Handler{}
model := atdetailModel{}
filter := &common.FilterOption{Column: "rid_parent", Operator: "ilike", Value: "%345346346%"}
info := h.ValidateAndAdjustFilterForColumnType(filter, model)
if !info.NeedsCast {
t.Fatalf("expected NeedsCast=true so the numeric column is cast to TEXT for ILIKE")
}
if filter.Value != "%345346346%" {
t.Fatalf("ILIKE must keep the wildcard-wrapped string value untouched, got %#v", filter.Value)
}
q := &jsonCapQuery{}
h.applyFilter(q, *filter, "public.atdetail", info.NeedsCast, "OR", model)
c := q.only(t)
const want = "CAST(atdetail.rid_parent AS TEXT) ILIKE ?"
if c.query != want {
t.Fatalf("query = %q, want %q", c.query, want)
}
if !reflect.DeepEqual(c.args, []interface{}{"%345346346%"}) {
t.Fatalf("args = %#v, want [\"%%345346346%%\"]", c.args)
}
}
+20 -6
View File
@@ -2325,6 +2325,13 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
qualifiedColumn = fmt.Sprintf("CAST(%s AS TEXT)", rawQualifiedColumn)
}
// citext columns already compare case-insensitively; casting to TEXT for
// LIKE/ILIKE would switch to case-sensitive matching and defeat a citext index.
likeColumn := rawQualifiedColumn
if !reflection.IsCitextColumn(model, filter.Column) {
likeColumn = fmt.Sprintf("CAST(%s AS TEXT)", rawQualifiedColumn)
}
switch strings.ToLower(filter.Operator) {
case "eq", "equals":
return applyWhere(fmt.Sprintf("%s = ?", qualifiedColumn), filter.Value)
@@ -2339,11 +2346,14 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
case "lte", "less_than_equals", "le":
return applyWhere(fmt.Sprintf("%s <= ?", qualifiedColumn), filter.Value)
case "like":
// Always cast to TEXT for LIKE/ILIKE to support date/time/timestamp columns
return applyWhere(fmt.Sprintf("CAST(%s AS TEXT) LIKE ?", rawQualifiedColumn), filter.Value)
// Cast to TEXT for LIKE to support date/time/timestamp columns; citext
// columns are compared natively (see likeColumn above).
return applyWhere(fmt.Sprintf("%s LIKE ?", likeColumn), filter.Value)
case "ilike":
// Always cast to TEXT for LIKE/ILIKE to support date/time/timestamp columns
return applyWhere(fmt.Sprintf("CAST(%s AS TEXT) ILIKE ?", rawQualifiedColumn), filter.Value)
// Cast to TEXT for ILIKE to support date/time/timestamp columns; citext
// columns are compared natively (see likeColumn above) since citext is
// already case-insensitive.
return applyWhere(fmt.Sprintf("%s ILIKE ?", likeColumn), filter.Value)
case "in":
cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
if cond == "" {
@@ -2421,8 +2431,12 @@ func (h *Handler) applyOrFilterGroup(query common.SelectQuery, filters []*common
op := strings.ToLower(filter.Operator)
if op == "like" || op == "ilike" {
// Always cast to TEXT for LIKE/ILIKE to support date/time/timestamp columns
qualifiedColumn = fmt.Sprintf("CAST(%s AS TEXT)", rawQualifiedColumn)
// Cast to TEXT for LIKE/ILIKE to support date/time/timestamp columns.
// citext columns are left native: they're already case-insensitive and
// casting would defeat a citext index.
if !reflection.IsCitextColumn(model, filter.Column) {
qualifiedColumn = fmt.Sprintf("CAST(%s AS TEXT)", rawQualifiedColumn)
}
} else if castInfo[i].NeedsCast {
// Apply casting to text if needed for non-numeric columns or non-numeric values
qualifiedColumn = fmt.Sprintf("CAST(%s AS TEXT)", rawQualifiedColumn)
+18
View File
@@ -1466,6 +1466,12 @@ func (h *Handler) ValidateAndAdjustFilterForColumnType(filter *common.FilterOpti
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
}
// Never cast citext columns to TEXT: CAST(col AS TEXT) swaps in case-sensitive
// comparison semantics and prevents PostgreSQL from using a citext index.
if reflection.IsCitextColumn(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
@@ -1473,6 +1479,18 @@ func (h *Handler) ValidateAndAdjustFilterForColumnType(filter *common.FilterOpti
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
}
// LIKE/ILIKE always compare against text, wildcards and all. Never coerce
// the value to the column's native numeric/bool/time type here: doing so
// strips the '%' wildcards and hands the driver a non-string argument,
// which fails with "operator does not exist: text ~~* integer" once the
// column is cast to TEXT below.
if op := strings.ToLower(filter.Operator); op == "like" || op == "ilike" {
if reflection.IsStringType(colType) {
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
}
return ColumnCastInfo{NeedsCast: true, IsNumericType: reflection.IsNumericType(colType)}
}
// Check if the input value is numeric
valueIsNumeric := false
if strVal, ok := filter.Value.(string); ok {
+184
View File
@@ -0,0 +1,184 @@
// Package quickproxy provides a small reverse-proxy layer that tries a set
// of configured upstream targets first, and falls back to a caller-supplied
// http.Handler (typically static file serving) when the upstream is
// unreachable or returns 404.
package quickproxy
import (
"errors"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sort"
"strings"
"time"
)
// Rule maps a URL path prefix to an upstream target.
// A Rule with URLPrefix "/" acts as a catch-all passthrough.
type Rule struct {
// URLPrefix is the URL path prefix this rule matches. Must start with "/".
URLPrefix string
// Target is the upstream base URL, e.g. "http://localhost:3000".
// The incoming request path and query are forwarded unchanged; only the
// scheme and host are rewritten to Target's.
Target string
}
// DefaultTimeout is the dial and response-header timeout applied to
// upstream requests when no WithTimeout option is given. It does not limit
// response body streaming.
const DefaultTimeout = 10 * time.Second
// Option configures a Service.
type Option func(*options)
type options struct {
timeout time.Duration
}
// WithTimeout sets the dial and response-header timeout used when
// connecting to upstream targets. It does not limit response body
// streaming, so it won't interrupt long-lived downloads or SSE/WebSocket
// connections once established.
func WithTimeout(d time.Duration) Option {
return func(o *options) { o.timeout = d }
}
// compiledRule pairs a Rule with its ready-to-use reverse proxy.
type compiledRule struct {
prefix string
proxy *httputil.ReverseProxy
}
// Service holds a compiled set of proxy rules and performs longest-prefix
// matching against them. A Service is safe for concurrent use once
// returned from NewService; Handler must be called once per Service to
// wire up the fallback handler before the returned http.Handler is served.
type Service struct {
rules []compiledRule // sorted by descending prefix length
}
// errUpstreamNotFound is a sentinel error returned from ModifyResponse to
// make ReverseProxy invoke ErrorHandler (our fallback path) instead of
// writing the upstream's 404 to the client. Nothing has been written to
// the ResponseWriter yet when this happens.
var errUpstreamNotFound = errors.New("quickproxy: upstream returned 404")
// NewService compiles the given rules into a Service. Rules are matched by
// longest URLPrefix, so a catch-all "/" rule can coexist with more specific
// rules such as "/api".
func NewService(rules []Rule, opts ...Option) (*Service, error) {
if len(rules) == 0 {
return nil, fmt.Errorf("quickproxy: no rules configured")
}
cfg := options{timeout: DefaultTimeout}
for _, opt := range opts {
opt(&cfg)
}
seen := make(map[string]bool, len(rules))
compiled := make([]compiledRule, 0, len(rules))
for _, r := range rules {
if !strings.HasPrefix(r.URLPrefix, "/") {
return nil, fmt.Errorf("quickproxy: rule prefix %q must start with /", r.URLPrefix)
}
if seen[r.URLPrefix] {
return nil, fmt.Errorf("quickproxy: duplicate rule prefix %q", r.URLPrefix)
}
seen[r.URLPrefix] = true
target, err := url.Parse(r.Target)
if err != nil || target.Scheme == "" || target.Host == "" {
return nil, fmt.Errorf("quickproxy: invalid target %q for prefix %q", r.Target, r.URLPrefix)
}
compiled = append(compiled, compiledRule{
prefix: r.URLPrefix,
proxy: newReverseProxy(target, cfg.timeout),
})
}
// Longest prefix first, so the first match in Handler is always the
// most specific one.
sort.Slice(compiled, func(i, j int) bool {
return len(compiled[i].prefix) > len(compiled[j].prefix)
})
return &Service{rules: compiled}, nil
}
func newReverseProxy(target *url.URL, timeout time.Duration) *httputil.ReverseProxy {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: timeout,
}).DialContext,
ResponseHeaderTimeout: timeout,
}
return &httputil.ReverseProxy{
Transport: transport,
Director: func(req *http.Request) {
originalHost := req.Host
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.Host = target.Host
if originalHost != "" {
req.Header.Set("X-Forwarded-Host", originalHost)
}
},
ModifyResponse: func(resp *http.Response) error {
if resp.StatusCode == http.StatusNotFound {
return errUpstreamNotFound
}
return nil
},
}
}
// Handler returns an http.Handler that tries the configured proxy rules
// first (longest-prefix match), and calls fallback when no rule matches,
// the upstream is unreachable, or the upstream returns 404. Any other
// upstream response (2xx, other 4xx, 5xx) is streamed through to the
// client unchanged.
//
// Handler wires up ErrorHandler on the Service's compiled rules, so it
// should be called once per Service, before the returned http.Handler
// starts serving requests.
func (s *Service) Handler(fallback http.Handler) http.Handler {
if fallback == nil {
fallback = http.NotFoundHandler()
}
for i := range s.rules {
s.rules[i].proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, _ error) {
fallback.ServeHTTP(w, r)
}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rule := s.match(r.URL.Path)
if rule == nil {
fallback.ServeHTTP(w, r)
return
}
rule.proxy.ServeHTTP(w, r)
})
}
// match returns the longest-prefix rule matching path, or nil if none match.
func (s *Service) match(path string) *compiledRule {
for i := range s.rules {
if strings.HasPrefix(path, s.rules[i].prefix) {
return &s.rules[i]
}
}
return nil
}
+235
View File
@@ -0,0 +1,235 @@
package quickproxy
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNewService_Validation(t *testing.T) {
tests := []struct {
name string
rules []Rule
wantErr bool
}{
{"no rules", nil, true},
{"empty rules", []Rule{}, true},
{"bad prefix", []Rule{{URLPrefix: "api", Target: "http://localhost:1"}}, true},
{"bad target", []Rule{{URLPrefix: "/api", Target: "not-a-url"}}, true},
{"missing host", []Rule{{URLPrefix: "/api", Target: "http://"}}, true},
{"duplicate prefix", []Rule{
{URLPrefix: "/api", Target: "http://localhost:1"},
{URLPrefix: "/api", Target: "http://localhost:2"},
}, true},
{"valid", []Rule{{URLPrefix: "/api", Target: "http://localhost:1"}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewService(tt.rules)
if (err != nil) != tt.wantErr {
t.Fatalf("NewService() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func fallbackHandler(body string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
})
}
func TestHandler_ProxiesSuccessResponse(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("upstream:" + r.URL.Path))
}))
defer upstream.Close()
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: upstream.URL}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
req := httptest.NewRequest(http.MethodGet, "/api/widgets", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "upstream:/api/widgets" {
t.Fatalf("body = %q", got)
}
}
func TestHandler_404FallsBack(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("upstream not found"))
}))
defer upstream.Close()
svc, err := NewService([]Rule{{URLPrefix: "/", Target: upstream.URL}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/missing.html", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
}
func TestHandler_UnreachableUpstreamFallsBack(t *testing.T) {
// A closed listener address: nothing is listening, so dialing fails.
unreachable := "http://127.0.0.1:1"
svc, err := NewService([]Rule{{URLPrefix: "/", Target: unreachable}}, WithTimeout(500*time.Millisecond))
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/anything", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
}
func TestHandler_NonNotFoundErrorsPassThrough(t *testing.T) {
codes := []int{http.StatusOK, http.StatusForbidden, http.StatusBadRequest, http.StatusInternalServerError}
for _, code := range codes {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(code)
_, _ = w.Write([]byte("upstream response"))
}))
svc, err := NewService([]Rule{{URLPrefix: "/", Target: upstream.URL}})
if err != nil {
upstream.Close()
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/x", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != code {
t.Errorf("status for upstream code %d = %d, want %d", code, rr.Code, code)
}
if got := rr.Body.String(); got != "upstream response" {
t.Errorf("body for upstream code %d = %q, want passthrough", code, got)
}
upstream.Close()
}
}
func TestHandler_LongestPrefixMatch(t *testing.T) {
specific := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("specific"))
}))
defer specific.Close()
general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("general"))
}))
defer general.Close()
svc, err := NewService([]Rule{
{URLPrefix: "/", Target: general.URL},
{URLPrefix: "/api/v1", Target: specific.URL},
})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
for path, want := range map[string]string{
"/api/v1/thing": "specific",
"/api/other": "general",
"/anything": "general",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != want {
t.Errorf("path %s: body = %q, want %q", path, got, want)
}
}
}
func TestHandler_NoMatchFallsBack(t *testing.T) {
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: "http://127.0.0.1:1"}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/other", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
}
func TestHandler_AllMethodsProxied(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(r.Method + ":" + string(body)))
}))
defer upstream.Close()
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: upstream.URL}})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete}
for _, method := range methods {
req := httptest.NewRequest(method, "/api/widgets", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
want := method + ":"
if got := rr.Body.String(); got != want {
t.Errorf("method %s: body = %q, want %q", method, got, want)
}
}
}
+24
View File
@@ -97,3 +97,27 @@ func IsJSONType(t reflect.Type) bool {
n, ok := SQLTypeName(t)
return ok && (n == "jsonb" || n == "json")
}
// UnwrapKind returns the reflect.Kind to use when reasoning about a column's
// comparability (numeric vs. string vs. other) for filter building. Plain Go
// types return their own Kind unchanged. spectypes.SqlNull[T] wrappers (and
// types that embed one, such as SqlTimeStamp/SqlDate/SqlTime) always report
// reflect.Struct for their own Kind even when T is an int64 or string, which
// would otherwise make numeric/text columns look "complex" and force an
// unnecessary CAST(... AS TEXT) that defeats native column indexes. For those
// wrappers, UnwrapKind returns the Kind of the wrapped value T instead.
func UnwrapKind(t reflect.Type) reflect.Kind {
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil {
return reflect.Invalid
}
if t.Kind() != reflect.Struct || t.PkgPath() != pkgPath {
return t.Kind()
}
if f, ok := t.FieldByName("Val"); ok {
return f.Type.Kind()
}
return t.Kind()
}
+5
View File
@@ -863,6 +863,11 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption, model interfa
op := strings.ToLower(filter.Operator)
if op == "like" || op == "ilike" {
operatorSQL := h.getOperatorSQL(filter.Operator)
// citext columns are already case-insensitive; casting to TEXT would
// switch to case-sensitive matching and defeat a citext index.
if reflection.IsCitextColumn(model, filter.Column) {
return fmt.Sprintf("%s %s ?", filter.Column, operatorSQL), []interface{}{filter.Value}
}
return fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, operatorSQL), []interface{}{filter.Value}
}
operatorSQL := h.getOperatorSQL(filter.Operator)