Skip to content

feat: add support for table column mapping #45

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -118,7 +118,7 @@ Example
usage:

``` Go
type User {
type User struct {
FullName string
}

Expand All @@ -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
Expand Down
82 changes: 62 additions & 20 deletions coverage/coverage.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,15 @@

<option value="file0">github.com/hashicorp/mql/common.go (100.0%)</option>

<option value="file1">github.com/hashicorp/mql/expr.go (100.0%)</option>
<option value="file1">github.com/hashicorp/mql/expr.go (97.9%)</option>

<option value="file2">github.com/hashicorp/mql/lex.go (100.0%)</option>

<option value="file3">github.com/hashicorp/mql/mql.go (98.2%)</option>

<option value="file4">github.com/hashicorp/mql/options.go (100.0%)</option>

<option value="file5">github.com/hashicorp/mql/parser.go (93.4%)</option>
<option value="file5">github.com/hashicorp/mql/parser.go (93.6%)</option>

<option value="file6">github.com/hashicorp/mql/stack.go (100.0%)</option>

Expand Down Expand Up @@ -224,6 +224,16 @@
if err != nil </span><span class="cov8" title="1">{
return nil, fmt.Errorf("%s: %q in %s: %w", op, *e.value, e.String(), ErrInvalidParameter)
}</span>

<span class="cov8" title="1">opts, err := getOpts(opt...)
if err != nil </span><span class="cov0" title="0">{
return nil, fmt.Errorf("%s: %w", op, err)
}</span>
<span class="cov8" title="1">if n, ok := opts.withTableColumnMap[columnName]; ok </span><span class="cov8" title="1">{
// override our column name with the mapped column name
columnName = n
}</span>

<span class="cov8" title="1">if validator.typ == "time" </span><span class="cov8" title="1">{
columnName = fmt.Sprintf("%s::date", columnName)
}</span>
Expand Down Expand Up @@ -251,9 +261,7 @@
func newLogicalOp(s string) (logicalOp, error) <span class="cov8" title="1">{
const op = "newLogicalOp"
switch logicalOp(s) </span>{
case
andOp,
orOp:<span class="cov8" title="1">
case andOp, orOp:<span class="cov8" title="1">
return logicalOp(s), nil</span>
default:<span class="cov8" title="1">
return "", fmt.Errorf("%s: %w %q", op, ErrInvalidLogicalOp, s)</span>
Expand Down Expand Up @@ -758,9 +766,9 @@
if err != nil </span><span class="cov8" title="1">{
return nil, fmt.Errorf("%s: %w", op, err)
}</span>
<span class="cov8" title="1">switch </span>{
case opts.withValidateConvertColumn == v.column &amp;&amp; !isNil(opts.withValidateConvertFn):<span class="cov8" title="1">
return opts.withValidateConvertFn(v.column, v.comparisonOp, v.value)</span>
<span class="cov8" title="1">switch validateConvertFn, ok := opts.withValidateConvertFns[v.column]; </span>{
case ok &amp;&amp; !isNil(validateConvertFn):<span class="cov8" title="1">
return validateConvertFn(v.column, v.comparisonOp, v.value)</span>
default:<span class="cov8" title="1">
columnName := strings.ToLower(v.column)
if n, ok := opts.withColumnMap[columnName]; ok </span><span class="cov8" title="1">{
Expand Down Expand Up @@ -812,20 +820,22 @@
)

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
type Option func(*options) error

func getDefaultOptions() options <span class="cov8" title="1">{
return options{
withColumnMap: make(map[string]string),
withColumnMap: make(map[string]string),
withValidateConvertFns: make(map[string]ValidateConvertFunc),
withTableColumnMap: make(map[string]string),
}
}</span>

Expand All @@ -848,8 +858,8 @@
}</span>
}

// 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 <span class="cov8" title="1">{
return func(o *options) error </span><span class="cov8" title="1">{
if !isNil(m) </span><span class="cov8" title="1">{
Expand All @@ -871,8 +881,10 @@
return func(o *options) error </span><span class="cov8" title="1">{
switch </span>{
case fieldName != "" &amp;&amp; !isNil(fn):<span class="cov8" title="1">
o.withValidateConvertFn = fn
o.withValidateConvertColumn = fieldName</span>
if _, exists := o.withValidateConvertFns[fieldName]; exists </span><span class="cov8" title="1">{
return fmt.Errorf("%s: duplicated convert: %w", op, ErrInvalidParameter)
}</span>
<span class="cov8" title="1">o.withValidateConvertFns[fieldName] = fn</span>
case fieldName == "" &amp;&amp; !isNil(fn):<span class="cov8" title="1">
return fmt.Errorf("%s: missing field name: %w", op, ErrInvalidParameter)</span>
case fieldName != "" &amp;&amp; isNil(fn):<span class="cov8" title="1">
Expand Down Expand Up @@ -902,6 +914,28 @@
return nil
}</span>
}

// 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-&gt;&gt;'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-&gt;&gt;'language'="blah"`
//
// The field names in the keys to the map should always be lower case.
func WithTableColumnMap(m map[string]string) Option <span class="cov8" title="1">{
return func(o *options) error </span><span class="cov8" title="1">{
if !isNil(m) </span><span class="cov8" title="1">{
o.withTableColumnMap = m
}</span>
<span class="cov8" title="1">return nil</span>
}
}
</pre>

<pre class="file" id="file5" style="display: none">// Copyright (c) HashiCorp, Inc.
Expand Down Expand Up @@ -969,7 +1003,15 @@
return nil, fmt.Errorf("%s: %w before right side expression in: %q", op, ErrMissingLogicalOp, p.raw)</span>
// finally, assign the right expr
case logicExpr.rightExpr == nil:<span class="cov8" title="1">
logicExpr.rightExpr = e.leftExpr
if e.rightExpr != nil </span><span class="cov8" title="1">{
// if e.rightExpr isn't nil, then we've got a complete
// expr (left + op + right) and we need to assign this to
// our rightExpr
logicExpr.rightExpr = e
break TkLoop</span>
}
// otherwise, we need to assign the left side of e
<span class="cov8" title="1">logicExpr.rightExpr = e.leftExpr
break TkLoop</span>
}
case stringToken, numberToken, symbolToken:<span class="cov8" title="1">
Expand Down
1 change: 1 addition & 0 deletions coverage/coverage.log
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
1692791808,98.2
1694895660,98.3
1695066606,98.4
1739128381,98.2
2 changes: 1 addition & 1 deletion coverage/coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions expr.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ 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)
}

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)
}
Expand Down
9 changes: 9 additions & 0 deletions expr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,13 @@ func Test_defaultValidateConvert(t *testing.T) {
assert.ErrorIs(t, err, ErrInvalidParameter)
assert.ErrorContains(t, err, "missing validator type")
})
t.Run("success-with-table-override", func(t *testing.T) {
e, err := defaultValidateConvert("name", EqualOp, pointer("alice"), validator{fn: fValidators["name"].fn, typ: "default"},
WithTableColumnMap(map[string]string{"name": "users.name"}))
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]")
})
}
13 changes: 13 additions & 0 deletions mql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 26 additions & 2 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +23,7 @@ func getDefaultOptions() options {
return options{
withColumnMap: make(map[string]string),
withValidateConvertFns: make(map[string]ValidateConvertFunc),
withTableColumnMap: make(map[string]string),
}
}

Expand All @@ -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) {
Expand Down Expand Up @@ -100,3 +102,25 @@ 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
//
// 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
}
}
Loading