Skip to content

Commit a8c95e4

Browse files
Add $field operator to compare fields (#23)
Currently you can only compare fields to constants. This new operator allows you to compare fields with fields. For example `playerCount < maxPlayers` This is an exception to the mongodb syntax as mongodb doesn't support this without `$expr` which we don't support because it's not JSON compatible. --------- Co-authored-by: Koen Bollen <[email protected]>
1 parent 9ed74d2 commit a8c95e4

File tree

5 files changed

+139
-14
lines changed

5 files changed

+139
-14
lines changed

README.md

+17-3
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ _It's designed to be simple, secure, and free of dependencies._
77
When filtering data based on user-generated inputs, you need a syntax that's both intuitive and reliable. MongoDB's query filter is an excellent choice because it's simple, widely understood, and battle-tested in real-world applications. Although this package doesn't interact with MongoDB, it uses the same syntax to simplify filtering.
88

99
### Supported Features:
10-
- Basics: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$regex`
11-
- Logical operators: `$and`, `$or`
12-
- Array operators: `$in`
10+
- Basics: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$regex`, `$exists`
11+
- Logical operators: `$and`, `$or`, `$not`, `$nor`
12+
- Array operators: `$in`, `$nin`, `$elemMatch`
13+
- Field comparison: `$field` (see [#difference-with-mongodb](#difference-with-mongodb))
1314

1415
This package is intended for use with PostgreSQL drivers like [github.com/lib/pq](https://github.com/lib/pq) and [github.com/jackc/pgx](https://github.com/jackc/pgx). However, it can work with any driver that supports the database/sql package.
1516

@@ -92,6 +93,19 @@ values := []any{"aztec", "nuke", "", 2, 10}
9293
(given "customdata" is configured with `filter.WithNestedJSONB("customdata", "password", "playerCount")`)
9394

9495

96+
## Difference with MongoDB
97+
98+
- The MongoDB query filters don't have the option to compare fields with each other. This package adds the `$field` operator to compare fields with each other.
99+
For example:
100+
```json5
101+
{
102+
"playerCount": { "$lt": { "$field": "maxPlayers" } }
103+
}
104+
```
105+
106+
- Some comparisons have limitations.`>`, `>=`, `<` and `<=` only work on non-jsob fields if they are numeric.
107+
108+
95109
## Contributing
96110

97111
If you have a feature request or discovered a bug, we'd love to hear from you! Please open an issue or submit a pull request. This project adheres to the [Poki Vulnerability Disclosure Policy](https://poki.com/en/c/vulnerability-disclosure-policy).

filter/converter.go

+39-10
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,13 @@ func (c *Converter) convertFilter(filter map[string]any, paramIndex int) (string
235235
inner = append(inner, fmt.Sprintf("EXISTS (SELECT 1 FROM unnest(%s) AS %s WHERE %s)", c.columnName(key), c.placeholderName, innerConditions))
236236
}
237237
values = append(values, innerValues...)
238+
case "$field":
239+
vv, ok := v[operator].(string)
240+
if !ok {
241+
return "", nil, fmt.Errorf("invalid value for $field operator (must be string): %v", v[operator])
242+
}
243+
244+
inner = append(inner, fmt.Sprintf("(%s = %s)", c.columnName(key), c.columnName(vv)))
238245
default:
239246
value := v[operator]
240247
isNumericOperator := false
@@ -247,19 +254,41 @@ func (c *Converter) convertFilter(filter map[string]any, paramIndex int) (string
247254
isNumericOperator = true
248255
}
249256

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-
}
257+
// If the value is a map with a $field key, we need to compare the column to another column.
258+
if vv, ok := value.(map[string]any); ok {
259+
field, ok := vv["$field"].(string)
260+
if !ok || len(vv) > 1 {
261+
return "", nil, fmt.Errorf("invalid value for %s operator (must be object with $field key only): %v", operator, value)
262+
}
263+
264+
left := c.columnName(key)
265+
right := c.columnName(field)
255266

256-
if isNumericOperator && isNumeric(value) && c.isNestedColumn(key) {
257-
inner = append(inner, fmt.Sprintf("((%s)::numeric %s $%d)", c.columnName(key), op, paramIndex))
267+
if isNumericOperator {
268+
if c.isNestedColumn(key) {
269+
left = fmt.Sprintf("(%s)::numeric", left)
270+
}
271+
if c.isNestedColumn(field) {
272+
right = fmt.Sprintf("(%s)::numeric", right)
273+
}
274+
}
275+
276+
inner = append(inner, fmt.Sprintf("(%s %s %s)", left, op, right))
258277
} else {
259-
inner = append(inner, fmt.Sprintf("(%s %s $%d)", c.columnName(key), op, paramIndex))
278+
// Prevent cryptic errors like:
279+
// unexpected error: sql: converting argument $1 type: unsupported type []interface {}, a slice of interface
280+
if !isScalar(value) {
281+
return "", nil, fmt.Errorf("invalid comparison value (must be a primitive): %v", value)
282+
}
283+
284+
if isNumericOperator && isNumeric(value) && c.isNestedColumn(key) {
285+
inner = append(inner, fmt.Sprintf("((%s)::numeric %s $%d)", c.columnName(key), op, paramIndex))
286+
} else {
287+
inner = append(inner, fmt.Sprintf("(%s %s $%d)", c.columnName(key), op, paramIndex))
288+
}
289+
paramIndex++
290+
values = append(values, value)
260291
}
261-
paramIndex++
262-
values = append(values, value)
263292
}
264293
}
265294
innerResult := strings.Join(inner, " AND ")

filter/converter_test.go

+40
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,46 @@ func TestConverter_Convert(t *testing.T) {
382382
nil,
383383
fmt.Errorf("invalid comparison value (must be a primitive): [1 2]"),
384384
},
385+
{
386+
"compare two fields",
387+
nil,
388+
`{"playerCount": {"$lt": {"$field": "maxPlayers"}}}`,
389+
`("playerCount" < "maxPlayers")`,
390+
nil,
391+
nil,
392+
},
393+
{
394+
"compare two jsonb fields",
395+
filter.WithNestedJSONB("meta"),
396+
`{"foo": {"$eq": {"$field": "bar"}}}`,
397+
`("meta"->>'foo' = "meta"->>'bar')`,
398+
nil,
399+
nil,
400+
},
401+
{
402+
"compare two jsonb fields with numeric comparison",
403+
filter.WithNestedJSONB("meta"),
404+
`{"foo": {"$lt": {"$field": "bar"}}}`,
405+
`(("meta"->>'foo')::numeric < ("meta"->>'bar')::numeric)`,
406+
nil,
407+
nil,
408+
},
409+
{
410+
"compare two fields with simple expression",
411+
filter.WithNestedJSONB("meta", "foo"),
412+
`{"foo": {"$field": "bar"}}`,
413+
`("foo" = "meta"->>'bar')`,
414+
nil,
415+
nil,
416+
},
417+
{
418+
"compare with invalid object",
419+
nil,
420+
`{"name": {"$eq": {"foo": "bar"}}}`,
421+
``,
422+
nil,
423+
fmt.Errorf("invalid value for $eq operator (must be object with $field key only): map[foo:bar]"),
424+
},
385425
}
386426

387427
for _, tt := range tests {

integration/postgres_test.go

+42
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,48 @@ func TestIntegration_BasicOperators(t *testing.T) {
399399
[]int{},
400400
nil,
401401
},
402+
{
403+
"string order comparison",
404+
`{"pet": {"$lt": "dog"}}`,
405+
[]int{2, 4, 6, 8},
406+
nil,
407+
},
408+
{
409+
"compare two fields",
410+
`{"level": {"$lt": { "$field": "guild_id" }}}`,
411+
[]int{1},
412+
nil,
413+
},
414+
{
415+
"compare two string fields",
416+
`{"name": {"$field": "pet"}}`,
417+
[]int{},
418+
nil,
419+
},
420+
{
421+
"compare two string fields with jsonb",
422+
`{"pet": {"$field": "class"}}`,
423+
[]int{3},
424+
nil,
425+
},
426+
{
427+
// This converts to: ("level" = "metadata"->>'guild_id')
428+
// This currently doesn't work, because we don't know the type of the columns.
429+
// 'level' is an integer column, 'guild_id' is a jsonb column which always gets converted to a string.
430+
"compare two numeric fields",
431+
`{"level": {"$field": "guild_id"}}`,
432+
nil,
433+
errors.New(`pq: operator does not exist: integer = text`),
434+
},
435+
{
436+
// This converts to: (("metadata"->>'pet')::numeric < "class")
437+
// This currently doesn't work, because we always convert < etc to a numeric comparison.
438+
// We don't know the type of the columns, so we can't convert it to a string comparison.
439+
"string order comparison with two fields",
440+
`{"pet": {"$lt": {"$field": "class"}}}`,
441+
nil,
442+
errors.New(`pq: operator does not exist: numeric < text`),
443+
},
402444
}
403445

404446
for _, tt := range tests {

integration/setup_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ func createPlayersTable(t *testing.T, db *sql.DB) {
127127
("id", "name", "metadata", "level", "class", "mount", "items", "parents") VALUES
128128
(1, 'Alice', '{"guild_id": 20, "pet": "dog" }', 10, 'warrior', 'horse', '{}', '{40, 60}'),
129129
(2, 'Bob', '{"guild_id": 20, "pet": "cat", "keys": [1, 3] }', 20, 'mage', 'horse', '{}', '{20, 30}'),
130-
(3, 'Charlie', '{"guild_id": 30, "pet": "dog", "keys": [4, 6] }', 30, 'rogue', NULL, '{}', '{30, 50}'),
130+
(3, 'Charlie', '{"guild_id": 30, "pet": "dog", "keys": [4, 6] }', 30, 'dog', NULL, '{}', '{30, 50}'),
131131
(4, 'David', '{"guild_id": 30, "pet": "cat" }', 40, 'warrior', NULL, '{}', '{}'),
132132
(5, 'Eve', '{"guild_id": 40, "pet": "dog", "hats": ["helmet"]}', 50, 'mage', 'griffon', '{"staff", "cloak"}', '{}'),
133133
(6, 'Frank', '{"guild_id": 40, "pet": "cat", "hats": ["cap"] }', 60, 'rogue', 'griffon', '{"dagger"}', '{}'),

0 commit comments

Comments
 (0)