Skip to content

Commit 841baf5

Browse files
Implement $elemMatch
1 parent bbadf91 commit 841baf5

File tree

4 files changed

+87
-0
lines changed

4 files changed

+87
-0
lines changed

filter/converter.go

+37
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ var BasicOperatorMap = map[string]string{
1919
"$regex": "~*",
2020
}
2121

22+
// ReservedColumnName is a reserved column name used internally for nested $elemMatch queries.
23+
// This column name should not be used in the database or any JSONB column.
24+
// You can set this to a different value as long as it's a valid Postgres identifier.
25+
var ReservedColumnName = "__placeholder"
26+
2227
type Converter struct {
2328
nestedColumn string
2429
nestedExemptions []string
@@ -150,6 +155,35 @@ func (c *Converter) convertFilter(filter map[string]any, paramIndex int) (string
150155
v[operator] = c.arrayDriver(v[operator])
151156
}
152157
values = append(values, v[operator])
158+
case "$elemMatch":
159+
// $elemMatch needs a different implementation depending on if the column is in JSONB or not.
160+
isNestedColumn := c.nestedColumn != ""
161+
for _, exemption := range c.nestedExemptions {
162+
if exemption == key {
163+
isNestedColumn = false
164+
break
165+
}
166+
}
167+
innerConditions, innerValues, err := c.convertFilter(map[string]any{ReservedColumnName: v[operator]}, paramIndex)
168+
if err != nil {
169+
return "", nil, err
170+
}
171+
paramIndex += len(innerValues)
172+
if isNestedColumn {
173+
// This will for example become:
174+
//
175+
// EXISTS (SELECT 1 FROM jsonb_array_elements("meta"->'foo') AS __placeholder WHERE ("__placeholder"::text = $1))
176+
//
177+
// We can't use c.columnName here because we need `->` to get the jsonb value instead of `->>` which gets the text value.
178+
inner = append(inner, fmt.Sprintf("EXISTS (SELECT 1 FROM jsonb_array_elements(%q->'%s') AS %s WHERE %s)", c.nestedColumn, key, ReservedColumnName, innerConditions))
179+
} else {
180+
// This will for example become:
181+
//
182+
// EXISTS (SELECT 1 FROM unnest("foo") AS __placeholder WHERE ("__placeholder"::text = $1))
183+
//
184+
inner = append(inner, fmt.Sprintf("EXISTS (SELECT 1 FROM unnest(%s) AS %s WHERE %s)", c.columnName(key), ReservedColumnName, innerConditions))
185+
}
186+
values = append(values, innerValues...)
153187
default:
154188
value := v[operator]
155189
op, ok := BasicOperatorMap[operator]
@@ -182,6 +216,9 @@ func (c *Converter) convertFilter(filter map[string]any, paramIndex int) (string
182216
}
183217

184218
func (c *Converter) columnName(column string) string {
219+
if column == ReservedColumnName {
220+
return fmt.Sprintf(`%q::text`, column)
221+
}
185222
if c.nestedColumn == "" {
186223
return fmt.Sprintf("%q", column)
187224
}

filter/converter_test.go

+24
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,30 @@ func TestConverter_Convert(t *testing.T) {
208208
nil,
209209
fmt.Errorf("empty objects not allowed"),
210210
},
211+
{
212+
"$elemMatch on normal column",
213+
nil,
214+
`{"name": {"$elemMatch": {"$eq": "John"}}}`,
215+
`EXISTS (SELECT 1 FROM unnest("name") AS __placeholder WHERE ("__placeholder"::text = $1))`,
216+
[]any{"John"},
217+
nil,
218+
},
219+
{
220+
"$elemMatch on jsonb column",
221+
filter.WithNestedJSONB("meta"),
222+
`{"name": {"$elemMatch": {"$eq": "John"}}}`,
223+
`EXISTS (SELECT 1 FROM jsonb_array_elements("meta"->'name') AS __placeholder WHERE ("__placeholder"::text = $1))`,
224+
[]any{"John"},
225+
nil,
226+
},
227+
{
228+
"$elemMatch with $gt",
229+
nil,
230+
`{"age": {"$elemMatch": {"$gt": 18}}}`,
231+
`EXISTS (SELECT 1 FROM unnest("age") AS __placeholder WHERE ("__placeholder"::text > $1))`,
232+
[]any{float64(18)},
233+
nil,
234+
},
211235
}
212236

213237
for _, tt := range tests {

fuzz/fuzz_test.go

+2
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ func FuzzConverter(f *testing.F) {
3333
`{"name": {}}`,
3434
`{"$or": []}`,
3535
`{"status": {"$in": []}}`,
36+
`{"name": {"$elemMatch": {"$eq": "John"}}}`,
37+
`{"age": {"$elemMatch": {"$gt": 18}}}`,
3638
}
3739
for _, tc := range tcs {
3840
f.Add(tc)

integration/postgres_test.go

+24
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,30 @@ func TestIntegration_BasicOperators(t *testing.T) {
297297
[]int{},
298298
nil,
299299
},
300+
{
301+
"$elemMatch on normal column",
302+
`{"items": {"$elemMatch": {"$regex": "a"}}}`,
303+
[]int{5, 6},
304+
nil,
305+
},
306+
{
307+
"$elemMatch on jsonb column",
308+
`{"hats": {"$elemMatch": {"$regex": "a"}}}`,
309+
[]int{6},
310+
nil,
311+
},
312+
{
313+
"$elemMatch with a numeric column",
314+
`{"parents": {"$elemMatch": {"$gt": 40, "$lt": 60}}}`,
315+
[]int{3},
316+
nil,
317+
},
318+
{
319+
"$elemMatch with numeric jsonb column",
320+
`{"keys": {"$elemMatch": {"$gt": 5}}}`,
321+
[]int{3},
322+
nil,
323+
},
300324
}
301325

302326
for _, tt := range tests {

0 commit comments

Comments
 (0)