mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-08 14:26:28 +00:00
fix(filters): qualify model columns to avoid ambiguity with joins
Qualify plain model columns with the main table alias in resolvespec filters so preload joins no longer cause SQLSTATE 42702, and accept <table>.<column> from clients in resolvespec and restheadspec instead of silently dropping the filter. Add join/preload tests for resolvespec, restheadspec and funcspec.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
||||
)
|
||||
|
||||
var rePlainIdent = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
// MainTableAlias returns the alias the main table gets in the SELECT:
|
||||
// the model's TableAlias() when provided, otherwise the bare table name.
|
||||
func MainTableAlias(model interface{}, tableName string) string {
|
||||
if p, ok := model.(TableAliasProvider); ok {
|
||||
if a := p.TableAlias(); a != "" {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return reflection.ExtractTableNameOnly(tableName)
|
||||
}
|
||||
|
||||
func isModelSQLColumn(model interface{}, column string) bool {
|
||||
for _, c := range reflection.GetSQLModelColumns(model) {
|
||||
if strings.EqualFold(c, column) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// QualifyModelColumn returns "alias"."column" when column is a plain identifier
|
||||
// that exists on the model, so it stays unambiguous once joins are added.
|
||||
// Anything else (expressions, JSON paths, other tables' columns) is returned unchanged.
|
||||
func QualifyModelColumn(model interface{}, alias, column string) string {
|
||||
if alias == "" || model == nil || !rePlainIdent.MatchString(column) || !isModelSQLColumn(model, column) {
|
||||
return column
|
||||
}
|
||||
return QuoteIdent(alias) + "." + QuoteIdent(column)
|
||||
}
|
||||
|
||||
// StripMainTablePrefix turns "<prefix>.<column>" into "<column>" when prefix is
|
||||
// one of the main table's names/aliases and column exists on the model.
|
||||
// Any other input is returned unchanged.
|
||||
func StripMainTablePrefix(model interface{}, column string, prefixes ...string) string {
|
||||
idx := strings.Index(column, ".")
|
||||
if idx <= 0 || model == nil {
|
||||
return column
|
||||
}
|
||||
prefix := strings.Trim(column[:idx], `"`)
|
||||
col := strings.Trim(column[idx+1:], `"`)
|
||||
if !rePlainIdent.MatchString(prefix) || !rePlainIdent.MatchString(col) {
|
||||
return column
|
||||
}
|
||||
for _, p := range prefixes {
|
||||
if p != "" && strings.EqualFold(p, prefix) && isModelSQLColumn(model, col) {
|
||||
return col
|
||||
}
|
||||
}
|
||||
return column
|
||||
}
|
||||
|
||||
// StripMainTablePrefixFromFilters applies StripMainTablePrefix to every filter column.
|
||||
func StripMainTablePrefixFromFilters(model interface{}, filters []FilterOption, prefixes ...string) {
|
||||
for i := range filters {
|
||||
filters[i].Column = StripMainTablePrefix(model, filters[i].Column, prefixes...)
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeMainTableFilters rewrites "<main table or alias>.<column>" filter
|
||||
// columns on opts to the bare model column (on a copy, never the caller's
|
||||
// slice) so the column validator keeps them.
|
||||
func NormalizeMainTableFilters(model interface{}, tableName string, opts *RequestOptions) {
|
||||
opts.Filters = append([]FilterOption(nil), opts.Filters...)
|
||||
StripMainTablePrefixFromFilters(model, opts.Filters,
|
||||
MainTableAlias(model, tableName), reflection.ExtractTableNameOnly(tableName))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
type qualifyModel struct {
|
||||
ID int64 `bun:"id,pk"`
|
||||
Name string `bun:"name"`
|
||||
}
|
||||
|
||||
func TestQualifyModelColumn(t *testing.T) {
|
||||
m := qualifyModel{}
|
||||
cases := []struct{ alias, col, want string }{
|
||||
{"t", "name", `"t"."name"`},
|
||||
{"t", "NAME", `"t"."NAME"`},
|
||||
{"t", "missing", "missing"},
|
||||
{"t", "data->>'x'", "data->>'x'"},
|
||||
{"t", "rel.name", "rel.name"},
|
||||
{"", "name", "name"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := QualifyModelColumn(m, c.alias, c.col); got != c.want {
|
||||
t.Errorf("Qualify(%q,%q) = %q, want %q", c.alias, c.col, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := QualifyModelColumn(nil, "t", "name"); got != "name" {
|
||||
t.Errorf("nil model: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripMainTablePrefix(t *testing.T) {
|
||||
m := qualifyModel{}
|
||||
cases := []struct{ col, want string }{
|
||||
{"province_state.name", "name"},
|
||||
{`"province_state"."name"`, "name"},
|
||||
{"PROVINCE_STATE.name", "name"},
|
||||
{"rel_rid_country.name", "rel_rid_country.name"},
|
||||
{"province_state.missing", "province_state.missing"},
|
||||
{"name", "name"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := StripMainTablePrefix(m, c.col, "province_state"); got != c.want {
|
||||
t.Errorf("Strip(%q) = %q, want %q", c.col, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripMainTablePrefix_ValidatorKeepsFilter(t *testing.T) {
|
||||
m := qualifyModel{}
|
||||
filters := []FilterOption{{Column: "province_state.name", Operator: "eq", Value: "x"}}
|
||||
StripMainTablePrefixFromFilters(m, filters, "province_state")
|
||||
out := NewColumnValidator(m).FilterRequestOptions(RequestOptions{Filters: filters})
|
||||
if len(out.Filters) != 1 || out.Filters[0].Column != "name" {
|
||||
t.Fatalf("filters = %+v", out.Filters)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user