Skip to content

Implement $elemMatch #19

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 1 commit into from
Jun 15, 2024
Merged
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
43 changes: 42 additions & 1 deletion filter/converter.go
Original file line number Diff line number Diff line change
@@ -19,14 +19,20 @@ var basicOperatorMap = map[string]string{
"$regex": "~*",
}

// DefaultPlaceholderName is the default placeholder name used in the generated SQL query.
// This name should not be used in the database or any JSONB column. It can be changed using
// the WithPlaceholderName option.
const DefaultPlaceholderName = "__filter_placeholder"

type Converter struct {
nestedColumn string
nestedExemptions []string
arrayDriver func(a any) interface {
driver.Valuer
sql.Scanner
}
emptyCondition string
emptyCondition string
placeholderName string
}

// NewConverter creates a new Converter with optional nested JSONB field mapping.
@@ -41,6 +47,9 @@ func NewConverter(options ...Option) *Converter {
option(converter)
}
}
if converter.placeholderName == "" {
converter.placeholderName = DefaultPlaceholderName
}
return converter
}

@@ -197,6 +206,35 @@ func (c *Converter) convertFilter(filter map[string]any, paramIndex int) (string
neg = "NOT "
}
inner = append(inner, fmt.Sprintf("(%sjsonb_path_match(%s, 'exists($.%s)'))", neg, c.nestedColumn, key))
case "$elemMatch":
// $elemMatch needs a different implementation depending on if the column is in JSONB or not.
isNestedColumn := c.nestedColumn != ""
for _, exemption := range c.nestedExemptions {
if exemption == key {
isNestedColumn = false
break
}
}
innerConditions, innerValues, err := c.convertFilter(map[string]any{c.placeholderName: v[operator]}, paramIndex)
if err != nil {
return "", nil, err
}
paramIndex += len(innerValues)
if isNestedColumn {
// This will for example become:
//
// EXISTS (SELECT 1 FROM jsonb_array_elements("meta"->'foo') AS __filter_placeholder WHERE ("__filter_placeholder"::text = $1))
//
// We can't use c.columnName here because we need `->` to get the jsonb value instead of `->>` which gets the text value.
inner = append(inner, fmt.Sprintf("EXISTS (SELECT 1 FROM jsonb_array_elements(%q->'%s') AS %s WHERE %s)", c.nestedColumn, key, c.placeholderName, innerConditions))
} else {
// This will for example become:
//
// EXISTS (SELECT 1 FROM unnest("foo") AS __filter_placeholder WHERE ("__filter_placeholder"::text = $1))
//
inner = append(inner, fmt.Sprintf("EXISTS (SELECT 1 FROM unnest(%s) AS %s WHERE %s)", c.columnName(key), c.placeholderName, innerConditions))
}
values = append(values, innerValues...)
default:
value := v[operator]
op, ok := basicOperatorMap[operator]
@@ -247,6 +285,9 @@ func (c *Converter) convertFilter(filter map[string]any, paramIndex int) (string
}

func (c *Converter) columnName(column string) string {
if column == c.placeholderName {
return fmt.Sprintf(`%q::text`, column)
}
if c.nestedColumn == "" {
return fmt.Sprintf("%q", column)
}
32 changes: 32 additions & 0 deletions filter/converter_test.go
Original file line number Diff line number Diff line change
@@ -304,6 +304,38 @@ func TestConverter_Convert(t *testing.T) {
nil,
nil,
},
{
"sql injection",
nil,
`{"\"bla = 1 --": 1}`,
``,
nil,
fmt.Errorf("invalid column name: \"bla = 1 --"),
},
{
"$elemMatch on normal column",
nil,
`{"name": {"$elemMatch": {"$eq": "John"}}}`,
`EXISTS (SELECT 1 FROM unnest("name") AS __filter_placeholder WHERE ("__filter_placeholder"::text = $1))`,
[]any{"John"},
nil,
},
{
"$elemMatch on jsonb column",
filter.WithNestedJSONB("meta"),
`{"name": {"$elemMatch": {"$eq": "John"}}}`,
`EXISTS (SELECT 1 FROM jsonb_array_elements("meta"->'name') AS __filter_placeholder WHERE ("__filter_placeholder"::text = $1))`,
[]any{"John"},
nil,
},
{
"$elemMatch with $gt",
filter.WithPlaceholderName("__placeholder"),
`{"age": {"$elemMatch": {"$gt": 18}}}`,
`EXISTS (SELECT 1 FROM unnest("age") AS __placeholder WHERE ("__placeholder"::text > $1))`,
[]any{float64(18)},
nil,
},
}

for _, tt := range tests {
9 changes: 9 additions & 0 deletions filter/options.go
Original file line number Diff line number Diff line change
@@ -49,3 +49,12 @@ func WithEmptyCondition(condition string) Option {
c.emptyCondition = condition
}
}

// WithPlaceholderName is an option to specify the placeholder name that will be
// used in the generated SQL query. This name should not be used in the database
// or any JSONB column.
func WithPlaceholderName(name string) Option {
return func(c *Converter) {
c.placeholderName = name
}
}
2 changes: 2 additions & 0 deletions fuzz/fuzz_test.go
Original file line number Diff line number Diff line change
@@ -42,6 +42,8 @@ func FuzzConverter(f *testing.F) {
`{"name": {"$not": {"$eq": "John"}}}`,
`{"name": null}`,
`{"name": {"$exists": false}}`,
`{"name": {"$elemMatch": {"$eq": "John"}}}`,
`{"age": {"$elemMatch": {"$gt": 18}}}`,
}
for _, tc := range tcs {
f.Add(tc, true)
24 changes: 24 additions & 0 deletions integration/postgres_test.go
Original file line number Diff line number Diff line change
@@ -357,6 +357,30 @@ func TestIntegration_BasicOperators(t *testing.T) {
[]int{1, 5, 6, 7, 8, 9, 10},
nil,
},
{
"$elemMatch on normal column",
`{"items": {"$elemMatch": {"$regex": "a"}}}`,
[]int{5, 6},
nil,
},
{
"$elemMatch on jsonb column",
`{"hats": {"$elemMatch": {"$regex": "a"}}}`,
[]int{6},
nil,
},
{
"$elemMatch with a numeric column",
`{"parents": {"$elemMatch": {"$gt": 40, "$lt": 60}}}`,
[]int{3},
nil,
},
{
"$elemMatch with numeric jsonb column",
`{"keys": {"$elemMatch": {"$gt": 5}}}`,
[]int{3},
nil,
},
}

for _, tt := range tests {