Compare commits

..

2 Commits

Author SHA1 Message Date
Hein
aef1f96c10 fix(db): cast columns to text for LIKE/ILIKE queries
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -29m15s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -29m7s
Build , Vet Test, and Lint / Build (push) Successful in -32m31s
Build , Vet Test, and Lint / Lint Code (push) Successful in -31m40s
Tests / Integration Tests (push) Failing after -33m31s
Tests / Unit Tests (push) Successful in -31m4s
2026-04-13 14:05:17 +02:00
Hein
354ed2a8dc feat(db): add fallback metric entity handling for unknown targets
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -30m5s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -29m35s
Build , Vet Test, and Lint / Lint Code (push) Successful in -29m19s
Build , Vet Test, and Lint / Build (push) Successful in -29m43s
Tests / Integration Tests (push) Failing after -30m34s
Tests / Unit Tests (push) Successful in -30m8s
* implement fallbackMetricEntityFromQuery for query sanitization
* add tests for fallback metric entity and sanitization logic
2026-04-10 16:00:22 +02:00
11 changed files with 221 additions and 29 deletions

View File

@@ -10,6 +10,8 @@ import (
"github.com/bitechdev/ResolveSpec/pkg/reflection" "github.com/bitechdev/ResolveSpec/pkg/reflection"
) )
const maxMetricFallbackEntityLength = 120
func recordQueryMetrics(enabled bool, operation, schema, entity, table string, startedAt time.Time, err error) { func recordQueryMetrics(enabled bool, operation, schema, entity, table string, startedAt time.Time, err error) {
if !enabled { if !enabled {
return return
@@ -136,7 +138,7 @@ func metricTargetFromRawQuery(query, driverName string) (operation, schema, enti
operation = normalizeMetricOperation(firstQueryKeyword(query)) operation = normalizeMetricOperation(firstQueryKeyword(query))
tableRef := tableFromRawQuery(query, operation) tableRef := tableFromRawQuery(query, operation)
if tableRef == "" { if tableRef == "" {
return operation, "", "unknown", "unknown" return operation, "", fallbackMetricEntityFromQuery(query), "unknown"
} }
schema, table = parseTableName(tableRef, driverName) schema, table = parseTableName(tableRef, driverName)
@@ -144,6 +146,133 @@ func metricTargetFromRawQuery(query, driverName string) (operation, schema, enti
return operation, schema, entity, table return operation, schema, entity, table
} }
func fallbackMetricEntityFromQuery(query string) string {
query = sanitizeMetricQueryShape(query)
if query == "" {
return "unknown"
}
if len(query) > maxMetricFallbackEntityLength {
return query[:maxMetricFallbackEntityLength-3] + "..."
}
return query
}
func sanitizeMetricQueryShape(query string) string {
query = strings.TrimSpace(query)
if query == "" {
return ""
}
var out strings.Builder
for i := 0; i < len(query); {
if query[i] == '\'' {
out.WriteByte('?')
i++
for i < len(query) {
if query[i] == '\'' {
if i+1 < len(query) && query[i+1] == '\'' {
i += 2
continue
}
i++
break
}
i++
}
continue
}
if query[i] == '?' {
out.WriteByte('?')
i++
continue
}
if query[i] == '$' && i+1 < len(query) && isASCIIDigit(query[i+1]) {
out.WriteByte('?')
i++
for i < len(query) && isASCIIDigit(query[i]) {
i++
}
continue
}
if query[i] == ':' && (i == 0 || query[i-1] != ':') && i+1 < len(query) && isIdentifierStart(query[i+1]) {
out.WriteByte('?')
i++
for i < len(query) && isIdentifierPart(query[i]) {
i++
}
continue
}
if query[i] == '@' && (i == 0 || query[i-1] != '@') && i+1 < len(query) && isIdentifierStart(query[i+1]) {
out.WriteByte('?')
i++
for i < len(query) && isIdentifierPart(query[i]) {
i++
}
continue
}
if startsNumericLiteral(query, i) {
out.WriteByte('?')
i++
for i < len(query) && (isASCIIDigit(query[i]) || query[i] == '.') {
i++
}
continue
}
out.WriteByte(query[i])
i++
}
return strings.Join(strings.Fields(out.String()), " ")
}
func startsNumericLiteral(query string, idx int) bool {
if idx >= len(query) {
return false
}
start := idx
if query[idx] == '-' {
if idx+1 >= len(query) || !isASCIIDigit(query[idx+1]) {
return false
}
start++
}
if !isASCIIDigit(query[start]) {
return false
}
if idx > 0 && isIdentifierPart(query[idx-1]) {
return false
}
if start+1 < len(query) && query[start] == '0' && (query[start+1] == 'x' || query[start+1] == 'X') {
return false
}
return true
}
func isASCIIDigit(ch byte) bool {
return ch >= '0' && ch <= '9'
}
func isIdentifierStart(ch byte) bool {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_'
}
func isIdentifierPart(ch byte) bool {
return isIdentifierStart(ch) || isASCIIDigit(ch)
}
func firstQueryKeyword(query string) string { func firstQueryKeyword(query string) string {
query = strings.TrimSpace(query) query = strings.TrimSpace(query)
if query == "" { if query == "" {

View File

@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -268,6 +269,47 @@ func TestPgSQLAdapterRawExecRecordsMetric(t *testing.T) {
assert.Equal(t, "orders", calls[0].table) assert.Equal(t, "orders", calls[0].table)
} }
func TestPgSQLAdapterRawExecUsesSQLAsEntityWhenTargetUnknown(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
provider := &capturingMetricsProvider{}
prev := metrics.GetProvider()
metrics.SetProvider(provider)
defer metrics.SetProvider(prev)
query := `select core.c_setuserid($1)`
mock.ExpectExec(`select core\.c_setuserid\(\$1\)`).
WithArgs(42).
WillReturnResult(sqlmock.NewResult(0, 1))
adapter := NewPgSQLAdapter(db)
_, err = adapter.Exec(context.Background(), query, 42)
require.NoError(t, err)
calls := provider.snapshot()
require.Len(t, calls, 1)
assert.Equal(t, "SELECT", calls[0].operation)
assert.Equal(t, "default", calls[0].schema)
assert.Equal(t, "select core.c_setuserid(?)", calls[0].entity)
assert.Equal(t, "unknown", calls[0].table)
}
func TestFallbackMetricEntityFromQuerySanitizesAndTruncates(t *testing.T) {
entity := fallbackMetricEntityFromQuery(" \n SELECT some_function(1, 'abc', $2, ?, :name, @p1, true, null) \t ")
assert.Equal(t, "SELECT some_function(?, ?, ?, ?, ?, ?, true, null)", entity)
entity = fallbackMetricEntityFromQuery("SELECT price::numeric, id FROM logs WHERE code = -42")
assert.Equal(t, "SELECT price::numeric, id FROM logs WHERE code = ?", entity)
longQuery := "SELECT " + strings.Repeat("x", maxMetricFallbackEntityLength)
entity = fallbackMetricEntityFromQuery(longQuery)
assert.Len(t, entity, maxMetricFallbackEntityLength)
assert.True(t, strings.HasSuffix(entity, "..."))
}
func TestBunAdapterRecordsEntityAndTableMetrics(t *testing.T) { func TestBunAdapterRecordsEntityAndTableMetrics(t *testing.T) {
sqldb, err := sql.Open(sqliteshim.ShimName, "file::memory:?cache=shared") sqldb, err := sql.Open(sqliteshim.ShimName, "file::memory:?cache=shared")
require.NoError(t, err) require.NoError(t, err)

View File

@@ -739,7 +739,7 @@ func (h *Handler) mergeQueryParams(r *http.Request, sqlquery string, variables m
colval = strings.ReplaceAll(colval, "\\", "\\\\") colval = strings.ReplaceAll(colval, "\\", "\\\\")
colval = strings.ReplaceAll(colval, "'", "''") colval = strings.ReplaceAll(colval, "'", "''")
if colval != "*" { if colval != "*" {
sqlquery = sqlQryWhere(sqlquery, fmt.Sprintf("%s ILIKE '%%%s%%'", ValidSQL(parmk, "colname"), colval)) sqlquery = sqlQryWhere(sqlquery, fmt.Sprintf("CAST(%s AS TEXT) ILIKE '%%%s%%'", ValidSQL(parmk, "colname"), colval))
} }
} else if val == "" || val == "0" { } else if val == "" || val == "0" {
// For empty/zero values, treat as literal 0 or empty string with quotes // For empty/zero values, treat as literal 0 or empty string with quotes
@@ -806,7 +806,7 @@ func (h *Handler) mergeHeaderParams(r *http.Request, sqlquery string, variables
colname := strings.ReplaceAll(k, "x-searchfilter-", "") colname := strings.ReplaceAll(k, "x-searchfilter-", "")
sval := strings.ReplaceAll(val, "'", "") sval := strings.ReplaceAll(val, "'", "")
if sval != "" { if sval != "" {
sqlquery = sqlQryWhere(sqlquery, fmt.Sprintf("%s ILIKE '%%%s%%'", ValidSQL(colname, "colname"), ValidSQL(sval, "colvalue"))) sqlquery = sqlQryWhere(sqlquery, fmt.Sprintf("CAST(%s AS TEXT) ILIKE '%%%s%%'", ValidSQL(colname, "colname"), ValidSQL(sval, "colvalue")))
} }
} }

View File

@@ -259,7 +259,7 @@ func (h *Handler) ApplyFilters(sqlQuery string, params *RequestParameters) strin
for colName, value := range params.SearchFilters { for colName, value := range params.SearchFilters {
sval := strings.ReplaceAll(value, "'", "") sval := strings.ReplaceAll(value, "'", "")
if sval != "" { if sval != "" {
condition := fmt.Sprintf("%s ILIKE '%%%s%%'", ValidSQL(colName, "colname"), ValidSQL(sval, "colvalue")) condition := fmt.Sprintf("CAST(%s AS TEXT) ILIKE '%%%s%%'", ValidSQL(colName, "colname"), ValidSQL(sval, "colvalue"))
sqlQuery = sqlQryWhere(sqlQuery, condition) sqlQuery = sqlQryWhere(sqlQuery, condition)
logger.Debug("Applied search filter: %s", condition) logger.Debug("Applied search filter: %s", condition)
} }
@@ -307,11 +307,11 @@ func (h *Handler) buildFilterCondition(colName string, op FilterOperator) string
switch operator { switch operator {
case "contains", "contain", "like": case "contains", "contain", "like":
return fmt.Sprintf("%s ILIKE '%%%s%%'", safCol, ValidSQL(value, "colvalue")) return fmt.Sprintf("CAST(%s AS TEXT) ILIKE '%%%s%%'", safCol, ValidSQL(value, "colvalue"))
case "beginswith", "startswith": case "beginswith", "startswith":
return fmt.Sprintf("%s ILIKE '%s%%'", safCol, ValidSQL(value, "colvalue")) return fmt.Sprintf("CAST(%s AS TEXT) ILIKE '%s%%'", safCol, ValidSQL(value, "colvalue"))
case "endswith": case "endswith":
return fmt.Sprintf("%s ILIKE '%%%s'", safCol, ValidSQL(value, "colvalue")) return fmt.Sprintf("CAST(%s AS TEXT) ILIKE '%%%s'", safCol, ValidSQL(value, "colvalue"))
case "equals", "eq", "=": case "equals", "eq", "=":
if IsNumeric(value) { if IsNumeric(value) {
return fmt.Sprintf("%s = %s", safCol, ValidSQL(value, "colvalue")) return fmt.Sprintf("%s = %s", safCol, ValidSQL(value, "colvalue"))

View File

@@ -274,7 +274,7 @@ func TestBuildFilterCondition(t *testing.T) {
Value: "test", Value: "test",
Logic: "AND", Logic: "AND",
}, },
expected: "description ILIKE '%test%'", expected: "CAST(description AS TEXT) ILIKE '%test%'",
}, },
{ {
name: "Starts with operator", name: "Starts with operator",
@@ -284,7 +284,7 @@ func TestBuildFilterCondition(t *testing.T) {
Value: "john", Value: "john",
Logic: "AND", Logic: "AND",
}, },
expected: "name ILIKE 'john%'", expected: "CAST(name AS TEXT) ILIKE 'john%'",
}, },
{ {
name: "Ends with operator", name: "Ends with operator",
@@ -294,7 +294,7 @@ func TestBuildFilterCondition(t *testing.T) {
Value: "@example.com", Value: "@example.com",
Logic: "AND", Logic: "AND",
}, },
expected: "email ILIKE '%@example.com'", expected: "CAST(email AS TEXT) ILIKE '%@example.com'",
}, },
{ {
name: "Between operator", name: "Between operator",

View File

@@ -702,8 +702,13 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
if hookCtx.Options != nil { if hookCtx.Options != nil {
// Apply filters // Apply filters
for _, filter := range hookCtx.Options.Filters { for _, filter := range hookCtx.Options.Filters {
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)
} else {
query = query.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) query = query.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
} }
}
// Apply sorting // Apply sorting
for _, sort := range hookCtx.Options.Sort { for _, sort := range hookCtx.Options.Sort {
@@ -743,9 +748,14 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName) countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
if hookCtx.Options != nil { if hookCtx.Options != nil {
for _, filter := range hookCtx.Options.Filters { for _, filter := range hookCtx.Options.Filters {
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)
} else {
countQuery = countQuery.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) countQuery = countQuery.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
} }
} }
}
count, _ := countQuery.Count(hookCtx.Context) count, _ := countQuery.Count(hookCtx.Context)
metadata["total"] = count metadata["total"] = count
metadata["count"] = reflection.Len(hookCtx.ModelPtr) metadata["count"] = reflection.Len(hookCtx.ModelPtr)

View File

@@ -735,9 +735,9 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (condition st
case "lte", "<=": case "lte", "<=":
return fmt.Sprintf("%s <= ?", filter.Column), []interface{}{filter.Value} return fmt.Sprintf("%s <= ?", filter.Column), []interface{}{filter.Value}
case "like": case "like":
return fmt.Sprintf("%s LIKE ?", filter.Column), []interface{}{filter.Value} return fmt.Sprintf("CAST(%s AS TEXT) LIKE ?", filter.Column), []interface{}{filter.Value}
case "ilike": case "ilike":
return fmt.Sprintf("%s ILIKE ?", filter.Column), []interface{}{filter.Value} return fmt.Sprintf("CAST(%s AS TEXT) ILIKE ?", filter.Column), []interface{}{filter.Value}
case "in": case "in":
condition, args := common.BuildInCondition(filter.Column, filter.Value) condition, args := common.BuildInCondition(filter.Column, filter.Value)
return condition, args return condition, args

View File

@@ -54,7 +54,7 @@ func TestBuildFilterCondition(t *testing.T) {
Operator: "like", Operator: "like",
Value: "%@example.com", Value: "%@example.com",
}, },
expectedCondition: "email LIKE ?", expectedCondition: "CAST(email AS TEXT) LIKE ?",
expectedArgsCount: 1, expectedArgsCount: 1,
}, },
} }

View File

@@ -1545,10 +1545,10 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionStr
condition = fmt.Sprintf("%s <= ?", filter.Column) condition = fmt.Sprintf("%s <= ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "like": case "like":
condition = fmt.Sprintf("%s LIKE ?", filter.Column) condition = fmt.Sprintf("CAST(%s AS TEXT) LIKE ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "ilike": case "ilike":
condition = fmt.Sprintf("%s ILIKE ?", filter.Column) condition = fmt.Sprintf("CAST(%s AS TEXT) ILIKE ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "in": case "in":
condition, args = common.BuildInCondition(filter.Column, filter.Value) condition, args = common.BuildInCondition(filter.Column, filter.Value)
@@ -1589,10 +1589,10 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
condition = fmt.Sprintf("%s <= ?", filter.Column) condition = fmt.Sprintf("%s <= ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "like": case "like":
condition = fmt.Sprintf("%s LIKE ?", filter.Column) condition = fmt.Sprintf("CAST(%s AS TEXT) LIKE ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "ilike": case "ilike":
condition = fmt.Sprintf("%s ILIKE ?", filter.Column) condition = fmt.Sprintf("CAST(%s AS TEXT) ILIKE ?", filter.Column)
args = []interface{}{filter.Value} args = []interface{}{filter.Value}
case "in": case "in":
condition, args = common.BuildInCondition(filter.Column, filter.Value) condition, args = common.BuildInCondition(filter.Column, filter.Value)

View File

@@ -2118,11 +2118,12 @@ func (h *Handler) qualifyColumnName(columnName, fullTableName string) string {
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption, tableName string, needsCast bool, logicOp string) common.SelectQuery { func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption, tableName string, needsCast bool, logicOp string) common.SelectQuery {
// Qualify the column name with table name if not already qualified // Qualify the column name with table name if not already qualified
qualifiedColumn := h.qualifyColumnName(filter.Column, tableName) rawQualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
qualifiedColumn := rawQualifiedColumn
// Apply casting to text if needed for non-numeric columns or non-numeric values // Apply casting to text if needed for non-numeric columns or non-numeric values
if needsCast { if needsCast {
qualifiedColumn = fmt.Sprintf("CAST(%s AS TEXT)", qualifiedColumn) qualifiedColumn = fmt.Sprintf("CAST(%s AS TEXT)", rawQualifiedColumn)
} }
// Helper function to apply the correct Where method based on logic operator // Helper function to apply the correct Where method based on logic operator
@@ -2147,11 +2148,11 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
case "lte", "less_than_equals", "le": case "lte", "less_than_equals", "le":
return applyWhere(fmt.Sprintf("%s <= ?", qualifiedColumn), filter.Value) return applyWhere(fmt.Sprintf("%s <= ?", qualifiedColumn), filter.Value)
case "like": case "like":
return applyWhere(fmt.Sprintf("%s LIKE ?", qualifiedColumn), filter.Value) // 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)
case "ilike": case "ilike":
// Use ILIKE for case-insensitive search (PostgreSQL) // Always cast to TEXT for LIKE/ILIKE to support date/time/timestamp columns
// Column is already cast to TEXT if needed return applyWhere(fmt.Sprintf("CAST(%s AS TEXT) ILIKE ?", rawQualifiedColumn), filter.Value)
return applyWhere(fmt.Sprintf("%s ILIKE ?", qualifiedColumn), filter.Value)
case "in": case "in":
cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value) cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
if cond == "" { if cond == "" {
@@ -2203,11 +2204,16 @@ func (h *Handler) applyOrFilterGroup(query common.SelectQuery, filters []*common
for i, filter := range filters { for i, filter := range filters {
// Qualify the column name with table name if not already qualified // Qualify the column name with table name if not already qualified
qualifiedColumn := h.qualifyColumnName(filter.Column, tableName) rawQualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
qualifiedColumn := rawQualifiedColumn
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)
} else if castInfo[i].NeedsCast {
// Apply casting to text if needed for non-numeric columns or non-numeric values // Apply casting to text if needed for non-numeric columns or non-numeric values
if castInfo[i].NeedsCast { qualifiedColumn = fmt.Sprintf("CAST(%s AS TEXT)", rawQualifiedColumn)
qualifiedColumn = fmt.Sprintf("CAST(%s AS TEXT)", qualifiedColumn)
} }
// Build the condition based on operator // Build the condition based on operator

View File

@@ -807,6 +807,11 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionStr
cond, args := common.BuildInCondition(filter.Column, filter.Value) cond, args := common.BuildInCondition(filter.Column, filter.Value)
return cond, args return cond, args
} }
op := strings.ToLower(filter.Operator)
if op == "like" || op == "ilike" {
operatorSQL := h.getOperatorSQL(filter.Operator)
return fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, operatorSQL), []interface{}{filter.Value}
}
operatorSQL := h.getOperatorSQL(filter.Operator) operatorSQL := h.getOperatorSQL(filter.Operator)
return fmt.Sprintf("%s %s ?", filter.Column, operatorSQL), []interface{}{filter.Value} return fmt.Sprintf("%s %s ?", filter.Column, operatorSQL), []interface{}{filter.Value}
} }