From 8d64666abc7f52bf049be92a7e8c248018cb5047 Mon Sep 17 00:00:00 2001 From: Doug Clark Date: Sat, 8 Feb 2025 00:45:31 -0600 Subject: [PATCH 1/3] feat: add support for table column mapping --- expr.go | 8 +++++++- expr_test.go | 20 +++++++++++++++----- mql.go | 2 +- mql_test.go | 13 +++++++++++++ options.go | 17 +++++++++++++++-- 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/expr.go b/expr.go index 1e4943f..0ef7771 100644 --- a/expr.go +++ b/expr.go @@ -77,7 +77,7 @@ func (e *comparisonExpr) isComplete() bool { // defaultValidateConvert will validate the comparison expr value, and then convert the // expr to its SQL equivalence. -func defaultValidateConvert(columnName string, comparisonOp ComparisonOp, columnValue *string, validator validator, opt ...Option) (*WhereClause, error) { +func defaultValidateConvert(columnName string, comparisonOp ComparisonOp, columnValue *string, validator validator, opts options) (*WhereClause, error) { const op = "mql.(comparisonExpr).convertToSql" switch { case columnName == "": @@ -103,6 +103,12 @@ func defaultValidateConvert(columnName string, comparisonOp ComparisonOp, column if err != nil { return nil, fmt.Errorf("%s: %q in %s: %w", op, *e.value, e.String(), ErrInvalidParameter) } + newCol, ok := opts.withTableColumnMap[columnName] + if ok { + // override our column name with the mapped column name + columnName = newCol + } + if validator.typ == "time" { columnName = fmt.Sprintf("%s::date", columnName) } diff --git a/expr_test.go b/expr_test.go index b16e365..937dd37 100644 --- a/expr_test.go +++ b/expr_test.go @@ -93,39 +93,49 @@ func Test_defaultValidateConvert(t *testing.T) { t.Parallel() fValidators, err := fieldValidators(reflect.ValueOf(testModel{})) require.NoError(t, err) + opts := getDefaultOptions() t.Run("missing-column", func(t *testing.T) { - e, err := defaultValidateConvert("", EqualOp, pointer("alice"), fValidators["name"]) + e, err := defaultValidateConvert("", EqualOp, pointer("alice"), fValidators["name"], opts) require.Error(t, err) assert.Empty(t, e) assert.ErrorIs(t, err, ErrMissingColumn) assert.ErrorContains(t, err, "missing column") }) t.Run("missing-comparison-op", func(t *testing.T) { - e, err := defaultValidateConvert("name", "", pointer("alice"), fValidators["name"]) + e, err := defaultValidateConvert("name", "", pointer("alice"), fValidators["name"], opts) require.Error(t, err) assert.Empty(t, e) assert.ErrorIs(t, err, ErrMissingComparisonOp) assert.ErrorContains(t, err, "missing comparison operator") }) t.Run("missing-value", func(t *testing.T) { - e, err := defaultValidateConvert("name", EqualOp, nil, fValidators["name"]) + e, err := defaultValidateConvert("name", EqualOp, nil, fValidators["name"], opts) require.Error(t, err) assert.Empty(t, e) assert.ErrorIs(t, err, ErrMissingComparisonValue) assert.ErrorContains(t, err, "missing comparison value") }) t.Run("missing-validator-func", func(t *testing.T) { - e, err := defaultValidateConvert("name", EqualOp, pointer("alice"), validator{typ: "string"}) + e, err := defaultValidateConvert("name", EqualOp, pointer("alice"), validator{typ: "string"}, opts) require.Error(t, err) assert.Empty(t, e) assert.ErrorIs(t, err, ErrInvalidParameter) assert.ErrorContains(t, err, "missing validator function") }) t.Run("missing-validator-typ", func(t *testing.T) { - e, err := defaultValidateConvert("name", EqualOp, pointer("alice"), validator{fn: fValidators["name"].fn}) + e, err := defaultValidateConvert("name", EqualOp, pointer("alice"), validator{fn: fValidators["name"].fn}, opts) require.Error(t, err) assert.Empty(t, e) assert.ErrorIs(t, err, ErrInvalidParameter) assert.ErrorContains(t, err, "missing validator type") }) + t.Run("success-with-table-override", func(t *testing.T) { + opts.withTableColumnMap["name"] = "users.name" + e, err := defaultValidateConvert("name", EqualOp, pointer("alice"), validator{fn: fValidators["name"].fn, typ: "default"}, opts) + assert.Empty(t, err) + assert.NotEmpty(t, e) + assert.Equal(t, "users.name=?", e.Condition, "condition") + assert.Len(t, e.Args, 1, "args") + assert.Equal(t, "alice", e.Args[0], "args[0]") + }) } diff --git a/mql.go b/mql.go index aa8154a..7abeea0 100644 --- a/mql.go +++ b/mql.go @@ -87,7 +87,7 @@ func exprToWhereClause(e expr, fValidators map[string]validator, opt ...Option) } return nil, fmt.Errorf("%s: %w %q %s", op, ErrInvalidColumn, columnName, cols) } - w, err := defaultValidateConvert(columnName, v.comparisonOp, v.value, validator, opt...) + w, err := defaultValidateConvert(columnName, v.comparisonOp, v.value, validator, opts) if err != nil { return nil, fmt.Errorf("%s: %w", op, err) } diff --git a/mql_test.go b/mql_test.go index 98d0f0c..3f8b024 100644 --- a/mql_test.go +++ b/mql_test.go @@ -306,6 +306,19 @@ func TestParse(t *testing.T) { wantErrIs: mql.ErrInvalidParameter, wantErrContains: "missing ConvertToSqlFunc: invalid parameter", }, + { + name: "success-with-table-column-map", + query: "custom_name=\"alice\"", + model: testModel{}, + opts: []mql.Option{ + mql.WithColumnMap(map[string]string{"custom_name": "name"}), + mql.WithTableColumnMap(map[string]string{"name": "users.custom->>'name'"}), + }, + want: &mql.WhereClause{ + Condition: "users.custom->>'name'=?", + Args: []any{"alice"}, + }, + }, } for _, tc := range tests { tc := tc diff --git a/options.go b/options.go index 54d2bdb..827dc63 100644 --- a/options.go +++ b/options.go @@ -13,6 +13,7 @@ type options struct { withValidateConvertFns map[string]ValidateConvertFunc withIgnoredFields []string withPgPlaceholder bool + withTableColumnMap map[string]string // map of model field names to their table.column name } // Option - how options are passed as args @@ -22,6 +23,7 @@ func getDefaultOptions() options { return options{ withColumnMap: make(map[string]string), withValidateConvertFns: make(map[string]ValidateConvertFunc), + withTableColumnMap: make(map[string]string), } } @@ -44,8 +46,8 @@ func withSkipWhitespace() Option { } } -// WithColumnMap provides an optional map of columns from a column in the user -// provided query to a column in the database model +// WithColumnMap provides an optional map of columns from the user +// provided query to a field in the given model func WithColumnMap(m map[string]string) Option { return func(o *options) error { if !isNil(m) { @@ -100,3 +102,14 @@ func WithPgPlaceholders() Option { return nil } } + +// WithTableColumnMap provides an optional map of columns from the +// model to the table.column name in the generated where clause +func WithTableColumnMap(m map[string]string) Option { + return func(o *options) error { + if !isNil(m) { + o.withTableColumnMap = m + } + return nil + } +} From 88c174cae77b777625af9942fc6a31ae671f2e4c Mon Sep 17 00:00:00 2001 From: Doug Clark Date: Sat, 8 Feb 2025 18:37:30 -0600 Subject: [PATCH 2/3] add changelog, update coverage, revert function sig, add doc --- CHANGELOG.md | 1 + README.md | 36 +++++++++++++++++-- coverage/coverage.html | 82 +++++++++++++++++++++++++++++++----------- coverage/coverage.svg | 2 +- expr.go | 12 ++++--- expr_test.go | 15 ++++---- mql.go | 2 +- options.go | 11 ++++++ 8 files changed, 125 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32bb673..1ebb562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Canonical reference for changes, improvements, and bugfixes for mql. ## Next +* feat: add support for table column mapping by @dlclark in [[PNR](https://github.com/hashicorp/mql/pull/45)] ## 0.1.4 (2024/05/14) diff --git a/README.md b/README.md index 33dc3d6..9ec470e 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Example: -### Mapping column names +### Mapping field names You can also provide an optional map from query column identifiers to model field names via @@ -118,7 +118,7 @@ Example usage: ``` Go -type User { +type User struct { FullName string } @@ -137,6 +137,38 @@ if err != nil { } ``` +### Mapping column names +You can also provide an optional map from model field names to output column +names via +[WithTableColumnMap(...)](https://pkg.go.dev/github.com/hashicorp/mql#WithTableColumnMap) +if needed. + +Example +[WithTableColumnMap(...)](https://pkg.go.dev/github.com/hashicorp/mql#WithTableColumnMap) +usage: + +``` Go +type User struct { + FullName string +} + +// map the field name FullName to column "u.fullname" +tableColumnMap := map[string]string{ + "fullname": "u.fullname", +} + +w, err := mql.Parse( + `FullName="alice"`, + User{}, + mql.WithTableColumnMap(tableColumnMap)) + +if err != nil { + return nil, err +} + +fmt.Print(w.Condition) // prints u.fullname=? +``` + ### Ignoring fields If your model (Go struct) has fields you don't want users searching then you can diff --git a/coverage/coverage.html b/coverage/coverage.html index 4e99bbf..a26e45b 100644 --- a/coverage/coverage.html +++ b/coverage/coverage.html @@ -57,7 +57,7 @@ - + @@ -65,7 +65,7 @@ - + @@ -224,6 +224,16 @@ if err != nil { return nil, fmt.Errorf("%s: %q in %s: %w", op, *e.value, e.String(), ErrInvalidParameter) } + + opts, err := getOpts(opt...) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + if n, ok := opts.withTableColumnMap[columnName]; ok { + // override our column name with the mapped column name + columnName = n + } + if validator.typ == "time" { columnName = fmt.Sprintf("%s::date", columnName) } @@ -251,9 +261,7 @@ func newLogicalOp(s string) (logicalOp, error) { const op = "newLogicalOp" switch logicalOp(s) { - case - andOp, - orOp: + case andOp, orOp: return logicalOp(s), nil default: return "", fmt.Errorf("%s: %w %q", op, ErrInvalidLogicalOp, s) @@ -758,9 +766,9 @@ if err != nil { return nil, fmt.Errorf("%s: %w", op, err) } - switch { - case opts.withValidateConvertColumn == v.column && !isNil(opts.withValidateConvertFn): - return opts.withValidateConvertFn(v.column, v.comparisonOp, v.value) + switch validateConvertFn, ok := opts.withValidateConvertFns[v.column]; { + case ok && !isNil(validateConvertFn): + return validateConvertFn(v.column, v.comparisonOp, v.value) default: columnName := strings.ToLower(v.column) if n, ok := opts.withColumnMap[columnName]; ok { @@ -812,12 +820,12 @@ ) type options struct { - withSkipWhitespace bool - withColumnMap map[string]string - withValidateConvertFn ValidateConvertFunc - withValidateConvertColumn string - withIgnoredFields []string - withPgPlaceholder bool + withSkipWhitespace bool + withColumnMap map[string]string + withValidateConvertFns map[string]ValidateConvertFunc + withIgnoredFields []string + withPgPlaceholder bool + withTableColumnMap map[string]string // map of model field names to their table.column name } // Option - how options are passed as args @@ -825,7 +833,9 @@ func getDefaultOptions() options { return options{ - withColumnMap: make(map[string]string), + withColumnMap: make(map[string]string), + withValidateConvertFns: make(map[string]ValidateConvertFunc), + withTableColumnMap: make(map[string]string), } } @@ -848,8 +858,8 @@ } } -// WithColumnMap provides an optional map of columns from a column in the user -// provided query to a column in the database model +// WithColumnMap provides an optional map of columns from the user +// provided query to a field in the given model func WithColumnMap(m map[string]string) Option { return func(o *options) error { if !isNil(m) { @@ -871,8 +881,10 @@ return func(o *options) error { switch { case fieldName != "" && !isNil(fn): - o.withValidateConvertFn = fn - o.withValidateConvertColumn = fieldName + if _, exists := o.withValidateConvertFns[fieldName]; exists { + return fmt.Errorf("%s: duplicated convert: %w", op, ErrInvalidParameter) + } + o.withValidateConvertFns[fieldName] = fn case fieldName == "" && !isNil(fn): return fmt.Errorf("%s: missing field name: %w", op, ErrInvalidParameter) case fieldName != "" && isNil(fn): @@ -902,6 +914,28 @@ return nil } } + +// WithTableColumnMap provides an optional map of columns from the +// model to the table.column name in the generated where clause +// +// For example, if you need to map the language field name to something +// more complex in your SQL statement then you can use this map: +// +// WithTableColumnMap(map[string]string{"language":"preferences->>'language'"}) +// +// In the example above we're mapping "language" field to a json field in +// the "preferences" column. A user can say `language="blah"` and the +// mql-created SQL where clause will contain `preferences->>'language'="blah"` +// +// The field names in the keys to the map should always be lower case. +func WithTableColumnMap(m map[string]string) Option { + return func(o *options) error { + if !isNil(m) { + o.withTableColumnMap = m + } + return nil + } +}