diff --git a/django_ormql/query.py b/django_ormql/query.py index 3c5ae92..b0e5505 100644 --- a/django_ormql/query.py +++ b/django_ormql/query.py @@ -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, @@ -55,6 +55,7 @@ class Tokenizer(Tokenizer): "!=": TokenType.NEQ, "||": TokenType.DPIPE, "->": TokenType.ARROW, + "->>": TokenType.DARROW, "ALL": TokenType.ALL, "AND": TokenType.AND, "ASC": TokenType.ASC, @@ -616,14 +617,21 @@ 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, ) @@ -631,14 +639,14 @@ def _resolve(e, parent_stack, depth): 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), ) diff --git a/doc/syntax.md b/doc/syntax.md index eb6e7fd..d42a208 100644 --- a/doc/syntax.md +++ b/doc/syntax.md @@ -59,7 +59,7 @@ column-ref: [column-ref.]column-name | | OUTER(column-ref) unary-operator: + | - | NOT -binary-operator: || | -> | +binary-operator: || | -> | ->> | + | - | * | / | % | <= | >= | < | > | = | == | <> | != | IS | IS NOT | @@ -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 @@ -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`. diff --git a/tests/conftest.py b/tests/conftest.py index bd75584..7142cc7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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", ) diff --git a/tests/test_jsonfield.py b/tests/test_jsonfield.py index effd8f4..63317c0 100644 --- a/tests/test_jsonfield.py +++ b/tests/test_jsonfield.py @@ -1,4 +1,5 @@ import pytest +from django.conf import settings @pytest.mark.django_db @@ -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"}}, - } + }, } } ] @@ -32,9 +35,22 @@ 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" """ @@ -42,6 +58,52 @@ def test_select_json_key(engine_t1): 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( @@ -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 @@ -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"}]