Skip to content

Commit 9ed74d2

Browse files
Fix comparison of numeric jsonb columns (#22)
Since we use `->>` on `jsonb` fields we always get a string back. Comparisons such as `<` and `>` were done on string values which gives unexpected results. I have tried various other approaches that failed. - Casting everything to a `jsonb` doesn't work because you can't cast query params to `jsonb`. - Using a `CASE WHEN` with `jsonb_typeof` doesn't work because each `WHEN` of a `CASE WHEN` needs to return the same type. - There are also complications with calling `Convert` recursively for `$elemMatch` where you then don't know the column type anymore.
1 parent 947a271 commit 9ed74d2

File tree

5 files changed

+97
-28
lines changed

5 files changed

+97
-28
lines changed

examples/readme_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,6 @@ func ExampleNewConverter_readme() {
3333
fmt.Println(conditions)
3434
fmt.Printf("%#v\n", values)
3535
// Output:
36-
// ((("meta"->>'map' ~* $1) OR ("meta"->>'map' ~* $2)) AND ("meta"->>'password' = $3) AND (("meta"->>'playerCount' >= $4) AND ("meta"->>'playerCount' < $5)))
36+
// ((("meta"->>'map' ~* $1) OR ("meta"->>'map' ~* $2)) AND ("meta"->>'password' = $3) AND ((("meta"->>'playerCount')::numeric >= $4) AND (("meta"->>'playerCount')::numeric < $5)))
3737
// []interface {}{"aztec", "nuke", "", 2, 10}
3838
}

filter/converter.go

+45-25
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import (
1010
"sync"
1111
)
1212

13-
var basicOperatorMap = map[string]string{
14-
"$gt": ">",
15-
"$gte": ">=",
16-
"$lt": "<",
17-
"$lte": "<=",
13+
var numericOperatorMap = map[string]string{
14+
"$gt": ">",
15+
"$gte": ">=",
16+
"$lt": "<",
17+
"$lte": "<=",
18+
}
19+
20+
var textOperatorMap = map[string]string{
1821
"$eq": "=",
1922
"$ne": "!=",
2023
"$regex": "~*",
@@ -200,14 +203,7 @@ func (c *Converter) convertFilter(filter map[string]any, paramIndex int) (string
200203
values = append(values, v[operator])
201204
case "$exists":
202205
// $exists only works on jsonb columns, so we need to check if the key is in the JSONB data first.
203-
isNestedColumn := c.nestedColumn != ""
204-
for _, exemption := range c.nestedExemptions {
205-
if exemption == key {
206-
isNestedColumn = false
207-
break
208-
}
209-
}
210-
if !isNestedColumn {
206+
if !c.isNestedColumn(key) {
211207
// There is no way in Postgres to check if a column exists on a table.
212208
return "", nil, fmt.Errorf("$exists operator not supported on non-nested jsonb columns")
213209
}
@@ -217,20 +213,14 @@ func (c *Converter) convertFilter(filter map[string]any, paramIndex int) (string
217213
}
218214
inner = append(inner, fmt.Sprintf("(%sjsonb_path_match(%s, 'exists($.%s)'))", neg, c.nestedColumn, key))
219215
case "$elemMatch":
220-
// $elemMatch needs a different implementation depending on if the column is in JSONB or not.
221-
isNestedColumn := c.nestedColumn != ""
222-
for _, exemption := range c.nestedExemptions {
223-
if exemption == key {
224-
isNestedColumn = false
225-
break
226-
}
227-
}
228216
innerConditions, innerValues, err := c.convertFilter(map[string]any{c.placeholderName: v[operator]}, paramIndex)
229217
if err != nil {
230218
return "", nil, err
231219
}
232220
paramIndex += len(innerValues)
233-
if isNestedColumn {
221+
222+
// $elemMatch needs a different implementation depending on if the column is in JSONB or not.
223+
if c.isNestedColumn(key) {
234224
// This will for example become:
235225
//
236226
// EXISTS (SELECT 1 FROM jsonb_array_elements("meta"->'foo') AS __filter_placeholder WHERE ("__filter_placeholder"::text = $1))
@@ -247,11 +237,27 @@ func (c *Converter) convertFilter(filter map[string]any, paramIndex int) (string
247237
values = append(values, innerValues...)
248238
default:
249239
value := v[operator]
250-
op, ok := basicOperatorMap[operator]
240+
isNumericOperator := false
241+
op, ok := textOperatorMap[operator]
251242
if !ok {
252-
return "", nil, fmt.Errorf("unknown operator: %s", operator)
243+
op, ok = numericOperatorMap[operator]
244+
if !ok {
245+
return "", nil, fmt.Errorf("unknown operator: %s", operator)
246+
}
247+
isNumericOperator = true
248+
}
249+
250+
// Prevent cryptic errors like:
251+
// unexpected error: sql: converting argument $1 type: unsupported type []interface {}, a slice of interface
252+
if !isScalar(value) {
253+
return "", nil, fmt.Errorf("invalid comparison value (must be a primitive): %v", value)
254+
}
255+
256+
if isNumericOperator && isNumeric(value) && c.isNestedColumn(key) {
257+
inner = append(inner, fmt.Sprintf("((%s)::numeric %s $%d)", c.columnName(key), op, paramIndex))
258+
} else {
259+
inner = append(inner, fmt.Sprintf("(%s %s $%d)", c.columnName(key), op, paramIndex))
253260
}
254-
inner = append(inner, fmt.Sprintf("(%s %s $%d)", c.columnName(key), op, paramIndex))
255261
paramIndex++
256262
values = append(values, value)
257263
}
@@ -277,6 +283,8 @@ func (c *Converter) convertFilter(filter map[string]any, paramIndex int) (string
277283
conditions = append(conditions, fmt.Sprintf("(%s IS NULL)", c.columnName(key)))
278284
}
279285
default:
286+
// Prevent cryptic errors like:
287+
// unexpected error: sql: converting argument $1 type: unsupported type []interface {}, a slice of interface
280288
if !isScalar(value) {
281289
return "", nil, fmt.Errorf("invalid comparison value (must be a primitive): %v", value)
282290
}
@@ -308,3 +316,15 @@ func (c *Converter) columnName(column string) string {
308316
}
309317
return fmt.Sprintf(`%q->>'%s'`, c.nestedColumn, column)
310318
}
319+
320+
func (c *Converter) isNestedColumn(column string) bool {
321+
if c.nestedColumn == "" {
322+
return false
323+
}
324+
for _, exemption := range c.nestedExemptions {
325+
if exemption == column {
326+
return false
327+
}
328+
}
329+
return true
330+
}

filter/converter_test.go

+24
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,30 @@ func TestConverter_Convert(t *testing.T) {
358358
[]any{float64(18)},
359359
nil,
360360
},
361+
{
362+
"numeric comparison bug with jsonb column",
363+
filter.WithNestedJSONB("meta"),
364+
`{"foo": {"$gt": 0}}`,
365+
`(("meta"->>'foo')::numeric > $1)`,
366+
[]any{float64(0)},
367+
nil,
368+
},
369+
{
370+
"numeric comparison against null with jsonb column",
371+
filter.WithNestedJSONB("meta"),
372+
`{"foo": {"$gt": null}}`,
373+
`("meta"->>'foo' > $1)`,
374+
[]any{nil},
375+
nil,
376+
},
377+
{
378+
"compare with non scalar",
379+
nil,
380+
`{"name": {"$eq": [1, 2]}}`,
381+
``,
382+
nil,
383+
fmt.Errorf("invalid comparison value (must be a primitive): [1 2]"),
384+
},
361385
}
362386

363387
for _, tt := range tests {

filter/util.go

+7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
package filter
22

3+
func isNumeric(v any) bool {
4+
// json.Unmarshal returns float64 for all numbers
5+
// so we only need to check for float64.
6+
_, ok := v.(float64)
7+
return ok
8+
}
9+
310
func isScalar(v any) bool {
411
if v == nil {
512
return true

integration/postgres_test.go

+20-2
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,16 @@ func TestIntegration_BasicOperators(t *testing.T) {
286286
nil,
287287
},
288288
{
289-
`invalid value`,
289+
`invalid value type int`,
290290
`{"level": "town1"}`, // Level is an integer column, but the value is a string.
291291
nil,
292-
errors.New("pq: invalid input syntax for type integer: \"town1\""),
292+
errors.New(`pq: invalid input syntax for type integer: "town1"`),
293+
},
294+
{
295+
`invalid value type string`,
296+
`{"name": 123}`, // Name is a string column, but the value is an integer.
297+
[]int{},
298+
nil,
293299
},
294300
{
295301
`empty object`,
@@ -381,6 +387,18 @@ func TestIntegration_BasicOperators(t *testing.T) {
381387
[]int{3},
382388
nil,
383389
},
390+
{
391+
"$lt bug with jsonb column",
392+
`{"guild_id": {"$lt": 100}}`,
393+
[]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
394+
nil,
395+
},
396+
{
397+
"$lt with null and jsonb column",
398+
`{"guild_id": {"$lt": null}}`,
399+
[]int{},
400+
nil,
401+
},
384402
}
385403

386404
for _, tt := range tests {

0 commit comments

Comments
 (0)