Skip to content

Commit 68e1272

Browse files
authored
Add support for nested operators inside arrays (#25)
1 parent f60caf6 commit 68e1272

File tree

12 files changed

+90
-41
lines changed

12 files changed

+90
-41
lines changed

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
project = "python-jsonlogic"
1414
copyright = "2024, Victorien"
1515
author = "Victorien"
16-
release = "0.1"
16+
release = "0.0.1"
1717

1818
# -- General configuration ---------------------------------------------------
1919
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

docs/source/usage/evaluation.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ using the utility :func:`~jsonlogic.evaluation.evaluate` function:
1313
expr = JSONLogicExpression.from_json({">": [{"var": "my_int"}, 2]})
1414
1515
root_op = expr.as_operator_tree(operator_registry)
16-
assert isinstance(root_op, Operator)
1716
1817
return_value = evaluate(
1918
root_op,

docs/source/usage/index.rst

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,6 @@ receives two arguments:
7474
- :paramref:`~jsonlogic.core.Operator.from_expression.arguments`: The list of arguments for this operator.
7575
This can either be another :class:`~jsonlogic.core.Operator` or a :data:`~jsonlogic.typing.JSONLogicPrimitive`.
7676

77-
Note that because this method is defined recursively, the retun type annotation is the union
78-
of :class:`~jsonlogic.core.Operator` and :data:`~jsonlogic.typing.JSONLogicPrimitive`. Using
79-
an :keyword:`assert` statement or :func:`~typing.cast` call might help your type checker.
80-
8177
.. warning::
8278

8379
Each operator is responsible for checking the provided arguments. For example, the ``GreaterThan`` operator

docs/source/usage/resolving_variables.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ the expression will evaluate to :json:`1`. However, this dot-like notation can b
4343
"some.var": 2
4444
}
4545
46-
For this reason, an alternative format is proposed, based on the JSON Pointer standard (:rfc:`6901`). The following expressions:
46+
For this reason, an alternative format is proposed, based on the JSON Pointer standard (:rfc:`6901`).
4747

48-
with the following data:
48+
With the following data:
4949

5050
.. code-block:: json
5151
@@ -71,7 +71,7 @@ this is how the references will evaluate:
7171
Variables scopes
7272
----------------
7373

74-
The original `JsonLogic`_ format implicitly uses the notion scope in the implementation
74+
The original `JsonLogic`_ format implicitly uses the notion of a scope in the implementation
7575
of some operators such as `map <https://jsonlogic.com/operations.html#map-reduce-and-filter>`_:
7676

7777
.. code-block:: json

docs/source/usage/typechecking.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ can be used:
2222
expr = JSONLogicExpression.from_json({">": [{"var": "my_int"}, 2]})
2323
2424
root_op = expr.as_operator_tree(operator_registry)
25-
assert isinstance(root_op, Operator)
2625
2726
root_type, diagnostics = typecheck(
2827
root_op,

src/jsonlogic/core.py

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from ._compat import Self, TypeAlias
1212
from .json_schema.types import AnyType, JSONSchemaType
13-
from .typing import JSON, JSONLogicPrimitive, OperatorArgument
13+
from .typing import JSON, JSONLogicPrimitive, JSONObject, OperatorArgument
1414

1515
if TYPE_CHECKING:
1616
# This is a hack to make Pylance think `TypeAlias` comes from `typing`
@@ -62,27 +62,35 @@ def __init__(self, message: str, /) -> None:
6262
self.message = message
6363

6464

65-
NormalizedExpression: TypeAlias = "dict[str, list[JSONLogicExpression]]"
65+
ExprArgument: TypeAlias = "JSONLogicPrimitive | JSONLogicExpression | list[ExprArgument]"
66+
67+
NormalizedExpression: TypeAlias = "dict[str, list[ExprArgument]]"
6668

6769

6870
@dataclass
6971
class JSONLogicExpression:
7072
"""A parsed and normalized JSON Logic expression.
7173
72-
A JSON Logic expression can be:
73-
74-
- a single item dictionary, mapping the operator key to another :class:`JSONLogicExpression`,
75-
- a :data:`~jsonlogic.typing.JSONLogicPrimitive`.
74+
The underlying structure of an expression is a single item dictionary,
75+
mapping the operator key to a list of arguments.
7676
7777
All JSON Logic expressions should be instantiated using the :meth:`from_json` constructor::
7878
79-
expr = JSONLogicExpression.from_json(...)
79+
expr = JSONLogicExpression.from_json({"op": ...})
8080
"""
8181

82-
expression: JSONLogicPrimitive | NormalizedExpression
82+
expression: NormalizedExpression
83+
84+
@classmethod
85+
def _parse_impl(cls, json: JSON) -> ExprArgument:
86+
if isinstance(json, dict):
87+
return cls.from_json(json)
88+
if isinstance(json, list):
89+
return [cls._parse_impl(s) for s in json]
90+
return json
8391

8492
@classmethod
85-
def from_json(cls, json: JSON) -> Self: # TODO disallow list? TODO fix type errors
93+
def from_json(cls, json: JSONObject) -> Self:
8694
"""Build a JSON Logic expression from JSON data.
8795
8896
Operator arguments are recursively normalized to a :class:`list`::
@@ -91,30 +99,34 @@ def from_json(cls, json: JSON) -> Self: # TODO disallow list? TODO fix type err
9199
assert expr.expression == {"var": ["varname"]}
92100
"""
93101
if not isinstance(json, dict):
94-
return cls(expression=json) # type: ignore
102+
raise ValueError("The root node of the expression must be a dict")
95103

96104
operator, op_args = next(iter(json.items()))
97105
if not isinstance(op_args, list):
98106
op_args = [op_args]
99107

100-
sub_expressions = [cls.from_json(op_arg) for op_arg in op_args]
108+
return cls({operator: [cls._parse_impl(arg) for arg in op_args]})
101109

102-
return cls({operator: sub_expressions}) # type: ignore
110+
def _as_op_impl(self, op_arg: ExprArgument, operator_registry: OperatorRegistry) -> OperatorArgument:
111+
if isinstance(op_arg, JSONLogicExpression):
112+
return op_arg.as_operator_tree(operator_registry)
113+
if isinstance(op_arg, list):
114+
return [self._as_op_impl(sub_arg, operator_registry) for sub_arg in op_arg]
115+
return op_arg
103116

104-
def as_operator_tree(self, operator_registry: OperatorRegistry) -> JSONLogicPrimitive | Operator:
117+
def as_operator_tree(self, operator_registry: OperatorRegistry) -> Operator:
105118
"""Return a recursive tree of operators, using the provided registry as a reference.
106119
107120
Args:
108121
operator_registry: The registry to use to resolve operator IDs.
109122
110123
Returns:
111-
The current expression if it is a :data:`~jsonlogic.typing.JSONLogicPrimitive`
112-
or an :class:`Operator` instance.
124+
An :class:`Operator` instance.
113125
"""
114126
if not isinstance(self.expression, dict):
115127
return self.expression
116128

117129
op_id, op_args = next(iter(self.expression.items()))
118130
OperatorCls = operator_registry.get(op_id)
119131

120-
return OperatorCls.from_expression(op_id, [op_arg.as_operator_tree(operator_registry) for op_arg in op_args])
132+
return OperatorCls.from_expression(op_id, [self._as_op_impl(op_arg, operator_registry) for op_arg in op_args])

src/jsonlogic/evaluation/utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,6 @@ def get_value(obj: OperatorArgument, context: EvaluationContext) -> Any:
5757
"""
5858
if isinstance(obj, Operator):
5959
return obj.evaluate(context)
60+
if isinstance(obj, list):
61+
return [get_value(sub_obj, context) for sub_obj in obj]
6062
return _cast_value(obj, context.settings.literal_casts)

src/jsonlogic/typechecking/utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from jsonlogic.core import Operator
66
from jsonlogic.json_schema import from_value
7-
from jsonlogic.json_schema.types import JSONSchemaType
7+
from jsonlogic.json_schema.types import ArrayType, JSONSchemaType, UnionType
88
from jsonlogic.typing import OperatorArgument
99

1010
from .diagnostics import Diagnostic
@@ -44,4 +44,6 @@ def get_type(obj: OperatorArgument, context: TypecheckContext) -> JSONSchemaType
4444
"""
4545
if isinstance(obj, Operator):
4646
return obj.typecheck(context)
47+
if isinstance(obj, list):
48+
return ArrayType(UnionType(*(get_type(sub_obj, context) for sub_obj in obj)))
4749
return from_value(obj, context.settings.literal_casts)

src/jsonlogic/typing.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,31 @@
1919
JSONArray: TypeAlias = "list[JSON]"
2020
JSON: TypeAlias = "JSONPrimitive | JSONArray | JSONObject"
2121

22-
JSONLogicPrimitive: TypeAlias = "JSONPrimitive | list[JSONPrimitive]"
23-
"""A JSON Logic primitive is defined as either a JSON primitive or a list of JSON primitives.
22+
JSONLogicPrimitive: TypeAlias = "JSONPrimitive | list[JSONLogicPrimitive]"
23+
"""A JSON Logic primitive is recursively defined as either a JSON primitive or a list of JSON Logic primitives.
2424
2525
Such primitives are only considered when dealing with operator arguments:
2626
27-
.. code-block:: javascript
27+
.. code-block:: json
2828
2929
{
3030
"op": [
3131
"a string", // A valid primitive (in this case a JSON primitive)
32-
["a list"] // A list of JSON primitives
32+
["a list"], // A list of JSON primitives
33+
[1, [2, 3]]
3334
]
3435
}
3536
"""
3637

37-
OperatorArgument: TypeAlias = "JSONLogicPrimitive | Operator"
38-
"""A valid operator argument, either a JSON Logic primitive or an operator.
38+
OperatorArgument: TypeAlias = "Operator | JSONLogicPrimitive | list[OperatorArgument]"
39+
"""An operator argument is recursively defined a JSON Logic primitive, an operator or a list of operator arguments.
3940
40-
.. code-block:: javascript
41+
.. code-block:: json
4142
4243
{
4344
"op": [
44-
{"nested_op": ...}, // A nested operator
45+
{"nested_op": "..."}, // A nested operator
46+
[1, {"other_op": "..."}],
4547
["a list"] // A JSON Logic primitive
4648
]
4749
}

tests/operators/test_evaluate.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,7 @@ def test_map() -> None:
184184
assert rv == [3.0, 4.0]
185185

186186

187-
@pytest.mark.xfail(reason="Arrays are currently considered as JSON Logic primitives.")
188-
def test_map_op_in_values():
187+
def test_map_op_in_values() -> None:
189188
op = as_op({"map": [["2000-01-01", {"var": "/my_date"}], {">": [{"var": ""}, "1970-01-01"]}]})
190189

191190
rv = evaluate(
@@ -198,6 +197,23 @@ def test_map_op_in_values():
198197
assert rv == [True, False]
199198

200199

200+
def test_nested_map() -> None:
201+
op = as_op(
202+
{
203+
"map": [
204+
[[1, 2], [3, {"var": "/my_number"}]],
205+
{"var": ""},
206+
],
207+
}
208+
)
209+
210+
rv = evaluate(
211+
op, data={"my_number": 4}, data_schema={"type": "object", "properties": {"my_number": {"type": "integer"}}}
212+
)
213+
214+
assert rv == [[1, 2], [3, 4]]
215+
216+
201217
def test_map_root_reference() -> None:
202218
# The `/@1` reference should resolve to the "" attribute of the top level schema,
203219
# meaning the variables of the `map` operators are meaningless.

0 commit comments

Comments
 (0)