Repository: Viicos/jsonlogic

Issue:
Add support for operators inside arrays
As shown in the failing test, `["2000-01-01", {"var": "/my_date"}]` is treated as is, as a literal.

https://github.com/Viicos/jsonlogic/blob/d96289027c5f28ed703a55c4b276d3bab954fb2d/tests/operators/test_typecheck.py#L235-L245

Discussion:

Patch:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index f46a4aa..8a0d020 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -13,7 +13,7 @@ sys.path.insert(0, str(Path(__file__).parents[2] / "src"))
 project = "python-jsonlogic"
 copyright = "2024, Victorien"
 author = "Victorien"
-release = "0.1"
+release = "0.0.1"
 
 # -- General configuration ---------------------------------------------------
 # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
diff --git a/docs/source/usage/evaluation.rst b/docs/source/usage/evaluation.rst
index 2e05568..78465ee 100644
--- a/docs/source/usage/evaluation.rst
+++ b/docs/source/usage/evaluation.rst
@@ -13,7 +13,6 @@ using the utility :func:`~jsonlogic.evaluation.evaluate` function:
     expr = JSONLogicExpression.from_json({">": [{"var": "my_int"}, 2]})
 
     root_op = expr.as_operator_tree(operator_registry)
-    assert isinstance(root_op, Operator)
 
     return_value = evaluate(
         root_op,
diff --git a/docs/source/usage/index.rst b/docs/source/usage/index.rst
index ecfe560..23b2c0f 100644
--- a/docs/source/usage/index.rst
+++ b/docs/source/usage/index.rst
@@ -74,10 +74,6 @@ receives two arguments:
 - :paramref:`~jsonlogic.core.Operator.from_expression.arguments`: The list of arguments for this operator.
   This can either be another :class:`~jsonlogic.core.Operator` or a :data:`~jsonlogic.typing.JSONLogicPrimitive`.
 
-Note that because this method is defined recursively, the retun type annotation is the union
-of :class:`~jsonlogic.core.Operator` and :data:`~jsonlogic.typing.JSONLogicPrimitive`. Using
-an :keyword:`assert` statement or :func:`~typing.cast` call might help your type checker.
-
 .. warning::
 
     Each operator is responsible for checking the provided arguments. For example, the ``GreaterThan`` operator
diff --git a/docs/source/usage/resolving_variables.rst b/docs/source/usage/resolving_variables.rst
index c8a1942..0ffc80b 100644
--- a/docs/source/usage/resolving_variables.rst
+++ b/docs/source/usage/resolving_variables.rst
@@ -43,9 +43,9 @@ the expression will evaluate to :json:`1`. However, this dot-like notation can b
           "some.var": 2
       }
 
-For this reason, an alternative format is proposed, based on the JSON Pointer standard (:rfc:`6901`). The following expressions:
+For this reason, an alternative format is proposed, based on the JSON Pointer standard (:rfc:`6901`).
 
-with the following data:
+With the following data:
 
 .. code-block:: json
 
@@ -71,7 +71,7 @@ this is how the references will evaluate:
 Variables scopes
 ----------------
 
-The original `JsonLogic`_ format implicitly uses the notion scope in the implementation
+The original `JsonLogic`_ format implicitly uses the notion of a scope in the implementation
 of some operators such as `map <https://jsonlogic.com/operations.html#map-reduce-and-filter>`_:
 
 .. code-block:: json
diff --git a/docs/source/usage/typechecking.rst b/docs/source/usage/typechecking.rst
index 4401807..714cdad 100644
--- a/docs/source/usage/typechecking.rst
+++ b/docs/source/usage/typechecking.rst
@@ -22,7 +22,6 @@ can be used:
     expr = JSONLogicExpression.from_json({">": [{"var": "my_int"}, 2]})
 
     root_op = expr.as_operator_tree(operator_registry)
-    assert isinstance(root_op, Operator)
 
     root_type, diagnostics = typecheck(
         root_op,
diff --git a/src/jsonlogic/core.py b/src/jsonlogic/core.py
index f283a1e..a018f55 100644
--- a/src/jsonlogic/core.py
+++ b/src/jsonlogic/core.py
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any
 
 from ._compat import Self, TypeAlias
 from .json_schema.types import AnyType, JSONSchemaType
-from .typing import JSON, JSONLogicPrimitive, OperatorArgument
+from .typing import JSON, JSONLogicPrimitive, JSONObject, OperatorArgument
 
 if TYPE_CHECKING:
     # This is a hack to make Pylance think `TypeAlias` comes from `typing`
@@ -62,27 +62,35 @@ class JSONLogicSyntaxError(Exception):
         self.message = message
 
 
-NormalizedExpression: TypeAlias = "dict[str, list[JSONLogicExpression]]"
+ExprArgument: TypeAlias = "JSONLogicPrimitive | JSONLogicExpression | list[ExprArgument]"
+
+NormalizedExpression: TypeAlias = "dict[str, list[ExprArgument]]"
 
 
 @dataclass
 class JSONLogicExpression:
     """A parsed and normalized JSON Logic expression.
 
-    A JSON Logic expression can be:
-
-    - a single item dictionary, mapping the operator key to another :class:`JSONLogicExpression`,
-    - a :data:`~jsonlogic.typing.JSONLogicPrimitive`.
+    The underlying structure of an expression is a single item dictionary,
+    mapping the operator key to a list of arguments.
 
     All JSON Logic expressions should be instantiated using the :meth:`from_json` constructor::
 
-        expr = JSONLogicExpression.from_json(...)
+        expr = JSONLogicExpression.from_json({"op": ...})
     """
 
-    expression: JSONLogicPrimitive | NormalizedExpression
+    expression: NormalizedExpression
+
+    @classmethod
+    def _parse_impl(cls, json: JSON) -> ExprArgument:
+        if isinstance(json, dict):
+            return cls.from_json(json)
+        if isinstance(json, list):
+            return [cls._parse_impl(s) for s in json]
+        return json
 
     @classmethod
-    def from_json(cls, json: JSON) -> Self:  # TODO disallow list? TODO fix type errors
+    def from_json(cls, json: JSONObject) -> Self:
         """Build a JSON Logic expression from JSON data.
 
         Operator arguments are recursively normalized to a :class:`list`::
@@ -91,25 +99,29 @@ class JSONLogicExpression:
             assert expr.expression == {"var": ["varname"]}
         """
         if not isinstance(json, dict):
-            return cls(expression=json)  # type: ignore
+            raise ValueError("The root node of the expression must be a dict")
 
         operator, op_args = next(iter(json.items()))
         if not isinstance(op_args, list):
             op_args = [op_args]
 
-        sub_expressions = [cls.from_json(op_arg) for op_arg in op_args]
+        return cls({operator: [cls._parse_impl(arg) for arg in op_args]})
 
-        return cls({operator: sub_expressions})  # type: ignore
+    def _as_op_impl(self, op_arg: ExprArgument, operator_registry: OperatorRegistry) -> OperatorArgument:
+        if isinstance(op_arg, JSONLogicExpression):
+            return op_arg.as_operator_tree(operator_registry)
+        if isinstance(op_arg, list):
+            return [self._as_op_impl(sub_arg, operator_registry) for sub_arg in op_arg]
+        return op_arg
 
-    def as_operator_tree(self, operator_registry: OperatorRegistry) -> JSONLogicPrimitive | Operator:
+    def as_operator_tree(self, operator_registry: OperatorRegistry) -> Operator:
         """Return a recursive tree of operators, using the provided registry as a reference.
 
         Args:
             operator_registry: The registry to use to resolve operator IDs.
 
         Returns:
-            The current expression if it is a :data:`~jsonlogic.typing.JSONLogicPrimitive`
-            or an :class:`Operator` instance.
+            An :class:`Operator` instance.
         """
         if not isinstance(self.expression, dict):
             return self.expression
@@ -117,4 +129,4 @@ class JSONLogicExpression:
         op_id, op_args = next(iter(self.expression.items()))
         OperatorCls = operator_registry.get(op_id)
 
-        return OperatorCls.from_expression(op_id, [op_arg.as_operator_tree(operator_registry) for op_arg in op_args])
+        return OperatorCls.from_expression(op_id, [self._as_op_impl(op_arg, operator_registry) for op_arg in op_args])
diff --git a/src/jsonlogic/evaluation/utils.py b/src/jsonlogic/evaluation/utils.py
index 9a65536..f6d216d 100644
--- a/src/jsonlogic/evaluation/utils.py
+++ b/src/jsonlogic/evaluation/utils.py
@@ -57,4 +57,6 @@ def get_value(obj: OperatorArgument, context: EvaluationContext) -> Any:
     """
     if isinstance(obj, Operator):
         return obj.evaluate(context)
+    if isinstance(obj, list):
+        return [get_value(sub_obj, context) for sub_obj in obj]
     return _cast_value(obj, context.settings.literal_casts)
diff --git a/src/jsonlogic/typechecking/utils.py b/src/jsonlogic/typechecking/utils.py
index 8d6a6c2..dcd8f30 100644
--- a/src/jsonlogic/typechecking/utils.py
+++ b/src/jsonlogic/typechecking/utils.py
@@ -4,7 +4,7 @@ from typing import Any
 
 from jsonlogic.core import Operator
 from jsonlogic.json_schema import from_value
-from jsonlogic.json_schema.types import JSONSchemaType
+from jsonlogic.json_schema.types import ArrayType, JSONSchemaType, UnionType
 from jsonlogic.typing import OperatorArgument
 
 from .diagnostics import Diagnostic
@@ -44,4 +44,6 @@ def get_type(obj: OperatorArgument, context: TypecheckContext) -> JSONSchemaType
     """
     if isinstance(obj, Operator):
         return obj.typecheck(context)
+    if isinstance(obj, list):
+        return ArrayType(UnionType(*(get_type(sub_obj, context) for sub_obj in obj)))
     return from_value(obj, context.settings.literal_casts)
diff --git a/src/jsonlogic/typing.py b/src/jsonlogic/typing.py
index 5d6a9d6..ca95556 100644
--- a/src/jsonlogic/typing.py
+++ b/src/jsonlogic/typing.py
@@ -19,29 +19,31 @@ JSONObject: TypeAlias = "dict[str, JSON]"
 JSONArray: TypeAlias = "list[JSON]"
 JSON: TypeAlias = "JSONPrimitive | JSONArray | JSONObject"
 
-JSONLogicPrimitive: TypeAlias = "JSONPrimitive | list[JSONPrimitive]"
-"""A JSON Logic primitive is defined as either a JSON primitive or a list of JSON primitives.
+JSONLogicPrimitive: TypeAlias = "JSONPrimitive | list[JSONLogicPrimitive]"
+"""A JSON Logic primitive is recursively defined as either a JSON primitive or a list of JSON Logic primitives.
 
 Such primitives are only considered when dealing with operator arguments:
 
-.. code-block:: javascript
+.. code-block:: json
 
     {
         "op": [
             "a string", // A valid primitive (in this case a JSON primitive)
-            ["a list"] // A list of JSON primitives
+            ["a list"], // A list of JSON primitives
+            [1, [2, 3]]
         ]
     }
 """
 
-OperatorArgument: TypeAlias = "JSONLogicPrimitive | Operator"
-"""A valid operator argument, either a JSON Logic primitive or an operator.
+OperatorArgument: TypeAlias = "Operator | JSONLogicPrimitive | list[OperatorArgument]"
+"""An operator argument is recursively defined a JSON Logic primitive, an operator or a list of operator arguments.
 
-.. code-block:: javascript
+.. code-block:: json
 
     {
         "op": [
-            {"nested_op": ...}, // A nested operator
+            {"nested_op": "..."}, // A nested operator
+            [1, {"other_op": "..."}],
             ["a list"] // A JSON Logic primitive
         ]
     }


Test patch:
diff --git a/tests/operators/test_evaluate.py b/tests/operators/test_evaluate.py
index 5495da9..1b8ab75 100644
--- a/tests/operators/test_evaluate.py
+++ b/tests/operators/test_evaluate.py
@@ -184,8 +184,7 @@ def test_map() -> None:
     assert rv == [3.0, 4.0]
 
 
-@pytest.mark.xfail(reason="Arrays are currently considered as JSON Logic primitives.")
-def test_map_op_in_values():
+def test_map_op_in_values() -> None:
     op = as_op({"map": [["2000-01-01", {"var": "/my_date"}], {">": [{"var": ""}, "1970-01-01"]}]})
 
     rv = evaluate(
@@ -198,6 +197,23 @@ def test_map_op_in_values():
     assert rv == [True, False]
 
 
+def test_nested_map() -> None:
+    op = as_op(
+        {
+            "map": [
+                [[1, 2], [3, {"var": "/my_number"}]],
+                {"var": ""},
+            ],
+        }
+    )
+
+    rv = evaluate(
+        op, data={"my_number": 4}, data_schema={"type": "object", "properties": {"my_number": {"type": "integer"}}}
+    )
+
+    assert rv == [[1, 2], [3, 4]]
+
+
 def test_map_root_reference() -> None:
     # The `/@1` reference should resolve to the "" attribute of the top level schema,
     # meaning the variables of the `map` operators are meaningless.
diff --git a/tests/operators/test_typecheck.py b/tests/operators/test_typecheck.py
index 617b319..ed5d3fa 100644
--- a/tests/operators/test_typecheck.py
+++ b/tests/operators/test_typecheck.py
@@ -4,8 +4,6 @@ from dataclasses import dataclass
 from datetime import date
 from typing import Any, cast
 
-import pytest
-
 from jsonlogic._compat import Self
 from jsonlogic.core import JSONLogicExpression, Operator
 from jsonlogic.evaluation import EvaluationContext
@@ -232,8 +230,7 @@ def test_map() -> None:
     assert diagnostics == []
 
 
-@pytest.mark.xfail(reason="Arrays are currently considered as JSON Logic primitives.")
-def test_map_op_in_values():
+def test_map_op_in_values() -> None:
     op = as_op({"map": [["2000-01-01", {"var": "/my_date"}], {">": [{"var": ""}, "1970-01-01"]}]})
 
     rt, _ = typecheck(
@@ -245,6 +242,21 @@ def test_map_op_in_values():
     assert rt == ArrayType(BooleanType())
 
 
+def test_nested_map() -> None:
+    op = as_op(
+        {
+            "map": [
+                [[1, 2], [3, {"var": "/my_number"}]],
+                {"var": ""},
+            ],
+        }
+    )
+
+    rt, _ = typecheck(op, data_schema={"type": "object", "properties": {"my_number": {"type": "integer"}}})
+
+    assert rt == ArrayType(ArrayType(IntegerType()))
+
+
 def test_map_root_reference() -> None:
     # The `/@1` reference should resolve to the "" attribute of the top level schema,
     # meaning the variables of the `map` operators are meaningless.
diff --git a/tests/test_json_logic_expression.py b/tests/test_json_logic_expression.py
new file mode 100644
index 0000000..74d6271
--- /dev/null
+++ b/tests/test_json_logic_expression.py
@@ -0,0 +1,9 @@
+from jsonlogic import JSONLogicExpression
+
+
+def test_from_json() -> None:
+    expr = JSONLogicExpression.from_json({"op": [1, {"op": 2}, [3, [4, 5]], [6, [{"op": 7}]]]})
+
+    assert expr == JSONLogicExpression(
+        {"op": [1, JSONLogicExpression({"op": [2]}), [3, [4, 5]], [6, [JSONLogicExpression({"op": [7]})]]]}
+    )