Skip to content
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
18 changes: 13 additions & 5 deletions django_ormql/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
functions,
lookups,
)
from django.db.models.fields.json import KeyTransform
from django.db.models.fields.json import KeyTextTransform, KeyTransform
from django.db.models.functions import Cast
from sqlglot import (
Dialect,
Expand Down Expand Up @@ -55,6 +55,7 @@ class Tokenizer(Tokenizer):
"!=": TokenType.NEQ,
"||": TokenType.DPIPE,
"->": TokenType.ARROW,
"->>": TokenType.DARROW,
"ALL": TokenType.ALL,
"AND": TokenType.AND,
"ASC": TokenType.ASC,
Expand Down Expand Up @@ -616,29 +617,36 @@ def _resolve(e, parent_stack, depth):
if expression.name not in self.placeholders:
raise QueryError(f"Placeholder '{expression.name}' not filled")
return Value(self.placeholders[expression.name])
elif isinstance(expression, expressions.JSONExtract):
elif isinstance(
expression, (expressions.JSONExtract, expressions.JSONExtractScalar)
):
django_transform = (
KeyTransform
if isinstance(expression, expressions.JSONExtract)
else KeyTextTransform
)
if isinstance(expression.expression, expressions.JSONPath):
k = self._expression_to_django(expression.this, **kwargs)
for pathel in expression.expression.expressions:
if isinstance(pathel, expressions.JSONPathRoot):
pass
elif isinstance(pathel, expressions.JSONPathKey):
k = KeyTransform(
k = django_transform(
pathel.this,
k,
)
else:
raise QueryNotSupported("Advanced JSON path is not supported")
return k
elif isinstance(expression.expression, expressions.Literal):
return KeyTransform(
return django_transform(
expression.expression.this,
self._expression_to_django(expression.this, **kwargs),
)
elif isinstance(expression.expression, expressions.Column) or isinstance(
expression.expression, expressions.Identifier
):
return KeyTransform(
return django_transform(
expression.expression.this.this,
self._expression_to_django(expression.this, **kwargs),
)
Expand Down
12 changes: 9 additions & 3 deletions doc/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ column-ref: [column-ref.]column-name | | OUTER(column-ref)

unary-operator: + | - | NOT

binary-operator: || | -> |
binary-operator: || | -> | ->> |
+ | - | * | / | % |
<= | >= | < | > |
= | == | <> | != | IS | IS NOT |
Expand All @@ -78,7 +78,7 @@ type-name: BOOL[EAN] | [BIG]INT | DECIMAL | FLOAT | DOUBLE | JSONB | TEXT | TIME
- The result of comparisons between different types or with NULL depends on the underlying database.
- Casting behaviour depends on the underlying database.
- Math operations involving different numeric types are always casted to either decimal or float, i.e. `10 / 3` will not return `3`, but always `3.3333...`.
- `||` is a concatenation operator and `->` a JSON traversal operator.
- `||` is a concatenation operator and `->`/`->>` a JSON traversal operator.

## Supported functions

Expand Down Expand Up @@ -169,7 +169,13 @@ ORDER BY paid DESC
### JSON traversal

```
SELECT address->city->state AS state
SELECT address->city->>state AS state
FROM customers
WHERE name = "CA"
```

Note that `->` will always return another JSON object, even if that is later processed to a native object in the output.
Therefore (on strongly typed databases), `WHERE address->city->state = "CA"` will not return any results, since the
string `"CA"` is not the same as the JSON object `"CA"`. Hence, for comparisons or other in-SQL operations, you need to
use `WHERE address->city->>state = "CA"` to perform text extraction. `->>` always extracts text, never other types, even
though you can cast them with `->>value::bool`.
4 changes: 3 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,12 @@ def dataset1():
tenant=t1,
name="CA",
address={
"business": True,
"quality": 23,
"city": {
"name": "Heidelberg",
"state": {"code": "BW", "country": {"code": "DE"}},
}
},
},
email="ca1@example.com",
)
Expand Down
129 changes: 127 additions & 2 deletions tests/test_jsonfield.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from django.conf import settings


@pytest.mark.django_db
Expand All @@ -13,10 +14,12 @@ def test_select_json(engine_t1):
assert list(res) == [
{
"address": {
"business": True,
"quality": 23,
"city": {
"name": "Heidelberg",
"state": {"code": "BW", "country": {"code": "DE"}},
}
},
}
}
]
Expand All @@ -32,16 +35,75 @@ def test_select_json_key(engine_t1):
"""
)
assert list(res) == [{"state": {"code": "BW", "country": {"code": "DE"}}}]

res = engine_t1.query(
"""
SELECT address->city->>state AS state
FROM customers
WHERE name = "CA"
"""
)
assert (
list(res)[0]["state"].replace(" ", "")
== '{"code":"BW","country":{"code":"DE"}}'
)

res = engine_t1.query(
"""
SELECT address->city->state->code AS state
SELECT address->city->state->>code AS state
FROM customers
WHERE name = "CA"
"""
)
assert list(res) == [{"state": "BW"}]


@pytest.mark.django_db
def test_select_json_key_scalar_to_string(engine_t1):
if "sqlite" in settings.DATABASES["default"]["ENGINE"]:
pytest.skip("Not supported on SQLite")
res = engine_t1.query(
"""
SELECT address->>business AS business
FROM customers
WHERE name = "CA"
"""
)
assert list(res) == [{"business": "true"}]

res = engine_t1.query(
"""
SELECT address->>quality AS quality
FROM customers
WHERE name = "CA"
"""
)
assert list(res) == [{"quality": "23"}]


@pytest.mark.django_db
def test_select_json_key_strongly_typed(engine_t1):
if "sqlite" in settings.DATABASES["default"]["ENGINE"]:
pytest.skip("Not supported on SQLite")
res = engine_t1.query(
"""
SELECT address->business AS business
FROM customers
WHERE name = "CA"
"""
)
assert list(res) == [{"business": True}]

res = engine_t1.query(
"""
SELECT address->quality AS quality
FROM customers
WHERE name = "CA"
"""
)
assert list(res) == [{"quality": 23}]


@pytest.mark.django_db
def test_select_json_key_string(engine_t1):
res = engine_t1.query(
Expand All @@ -52,6 +114,7 @@ def test_select_json_key_string(engine_t1):
"""
)
assert list(res) == [{"state": {"code": "BW", "country": {"code": "DE"}}}]

res = engine_t1.query(
"""
SELECT address->"city.state"->"code" AS state
Expand All @@ -60,3 +123,65 @@ def test_select_json_key_string(engine_t1):
"""
)
assert list(res) == [{"state": "BW"}]


@pytest.mark.django_db
def test_select_json_key_in_where(engine_t1):
res = engine_t1.query(
"""
SELECT address->city->state AS state
FROM customers
WHERE address->city->>name = "Heidelberg"
"""
)
assert list(res) == [{"state": {"code": "BW", "country": {"code": "DE"}}}]

res = engine_t1.query(
"""
SELECT address->city->state->>code AS state
FROM customers
WHERE address->quality::int = 23
"""
)
assert list(res) == [{"state": "BW"}]

res = engine_t1.query(
"""
SELECT address->city->state->>code AS state
FROM customers
WHERE address->>business = 'true'
"""
)
assert list(res) == [{"state": "BW"}]


@pytest.mark.django_db
def test_select_json_key_in_where_strongly_typed(engine_t1):
if "sqlite" in settings.DATABASES["default"]["ENGINE"]:
pytest.skip("Not supported on SQLite")
res = engine_t1.query(
"""
SELECT address->city->state AS state
FROM customers
WHERE address->city->name = '"Heidelberg"'
"""
)
assert list(res) == [{"state": {"code": "BW", "country": {"code": "DE"}}}]

res = engine_t1.query(
"""
SELECT address->city->state->>code AS state
FROM customers
WHERE address->>quality = '23'
"""
)
assert list(res) == [{"state": "BW"}]

res = engine_t1.query(
"""
SELECT address->city->state->>code AS state
FROM customers
WHERE address->business::bool = true
"""
)
assert list(res) == [{"state": "BW"}]
Loading