Skip to content

Commit

Permalink
Add test suite for operators typechecking (#17)
Browse files Browse the repository at this point in the history
While doing so, fixed a few issues:
Fixed reference parsing when trying to resolve the empty pointers
(i.e. `""`). Note that dot references does not support such of format
as they are ambiguous.
Fixed `DataStack` crash when no initial data is provided.
Fixed some diagnostic messages in operators typechecking.
Fixed construction of some operators.
  • Loading branch information
Viicos authored May 7, 2024
1 parent eba1d22 commit 4e00a96
Show file tree
Hide file tree
Showing 6 changed files with 302 additions and 18 deletions.
38 changes: 29 additions & 9 deletions src/jsonlogic/operators/operators.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@
from jsonlogic.core import JSONLogicSyntaxError, Operator
from jsonlogic.evaluation import EvaluationContext
from jsonlogic.json_schema import as_json_schema, from_json_schema
from jsonlogic.json_schema.types import AnyType, ArrayType, BinaryOp, BooleanType, JSONSchemaType, UnsupportedOperation
from jsonlogic.json_schema.types import (
AnyType,
ArrayType,
BinaryOp,
BooleanType,
JSONSchemaType,
StringType,
UnsupportedOperation,
)
from jsonlogic.resolving import Unresolvable
from jsonlogic.typing import OperatorArgument
from jsonlogic.utils import UNSET, UnsetType
Expand Down Expand Up @@ -37,7 +45,11 @@ def from_expression(cls, operator: str, arguments: list[OperatorArgument]) -> Se

def typecheck(self, context: TypecheckContext) -> JSONSchemaType:
if isinstance(self.variable_path, Operator):
self.variable_path.typecheck(context)
var_type = self.variable_path.typecheck(context)
if not isinstance(var_type, StringType):
context.add_diagnostic(
f"The first argument must be of type string, got {var_type.name}", "argument_type", self
)
return AnyType()

default_value_type: JSONSchemaType | None
Expand All @@ -51,15 +63,15 @@ def typecheck(self, context: TypecheckContext) -> JSONSchemaType:
except Unresolvable:
if default_value_type is None:
context.add_diagnostic(
f"{self.variable_path} is unresolvable and no fallback value is provided",
f"{self.variable_path!r} is unresolvable and no fallback value is provided",
"unresolvable_variable",
self,
)
return AnyType()

# We emit a diagnostic but as a warning, as this will not fail at runtime
context.add_diagnostic(
f"{self.variable_path} is unresolvable", "unresolvable_variable", self, type="warning"
f"{self.variable_path!r} is unresolvable", "unresolvable_variable", self, type="warning"
)
return default_value_type
else:
Expand Down Expand Up @@ -114,15 +126,21 @@ class If(Operator):

@classmethod
def from_expression(cls, operator: str, arguments: list[OperatorArgument]) -> Self:
if len(arguments) <= 2:
raise JSONLogicSyntaxError(f"{operator!r} expects at least 3 arguments, got {len(arguments)}")
if len(arguments) % 2 == 0:
raise JSONLogicSyntaxError(f"{operator!r} expects an odd number of arguments, got {len(arguments)}")
return cls(operator=operator, if_elses=list(zip(arguments[::2], arguments[1::2])), leading_else=arguments[-1])

def typecheck(self, context: TypecheckContext) -> JSONSchemaType:
for i, (cond, _) in enumerate(self.if_elses, start=1):
cond_type = get_type(cond, context)
if not isinstance(cond_type, BooleanType):
context.add_diagnostic(f"Condition {i} should be a boolean", "argument_type", self)
try:
# TODO It might be that unary_op("bool") does not return
# `BooleanType`, altough it wouldn't make much sense
cond_type.unary_op("bool")
except UnsupportedOperation:
context.add_diagnostic(f"Condition {i} should support boolean evaluation", "argument_type", self)

return functools.reduce(operator.or_, (get_type(rv, context) for _, rv in self.if_elses)) | get_type(
self.leading_else, context
Expand Down Expand Up @@ -213,7 +231,7 @@ class Plus(Operator):

@classmethod
def from_expression(cls, operator: str, arguments: list[OperatorArgument]) -> Self:
# Having a unary + is a bit useless, but we might not error here in the future
# TODO Having a unary + is a bit useless, but we might not error here in the future
if not len(arguments) >= 2:
raise JSONLogicSyntaxError(f"{operator!r} expects at least two arguments, got {len(arguments)}")
return cls(operator=operator, arguments=arguments)
Expand Down Expand Up @@ -287,15 +305,17 @@ class Map(Operator):

@classmethod
def from_expression(cls, operator: str, arguments: list[OperatorArgument]) -> Self:
if len(arguments) not in {1, 2}:
if len(arguments) != 2:
raise JSONLogicSyntaxError(f"{operator!r} expects two arguments, got {len(arguments)}")

return cls(operator=operator, vars=arguments[0], func=arguments[1])

def typecheck(self, context: TypecheckContext) -> JSONSchemaType:
vars_type = get_type(self.vars, context)
if not isinstance(vars_type, ArrayType):
context.add_diagnostic(f"The first argument must be of type array, got {vars_type}", "argument_type", self)
context.add_diagnostic(
f"The first argument must be of type array, got {vars_type.name}", "argument_type", self
)
return AnyType()

vars_type = cast(ArrayType[JSONSchemaType], vars_type)
Expand Down
13 changes: 8 additions & 5 deletions src/jsonlogic/resolving.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,21 @@ class DotReferenceParser(BaseReferenceParser):
def __call__(self, reference: str) -> tuple[ParsedReference, int]:
reference, scope = self.parse_scope(reference)

return ParsedReference(reference, reference.split(".")), scope
return ParsedReference(reference, [] if reference == "" else reference.split(".")), scope


class PointerReferenceParser(BaseReferenceParser):
"""A reference parser able to parse JSON Pointer references, as specified by :rfc:`6901`."""

def __call__(self, reference: str) -> tuple[ParsedReference, int]:
reference, scope = self.parse_scope(reference)
segments = [
segment.replace("~2", "@").replace("~1", "/").replace("~0", "~")
for segment in unquote(reference[1:]).split("/")
]
if reference == "":
segments = []
else:
segments = [
segment.replace("~2", "@").replace("~1", "/").replace("~0", "~")
for segment in unquote(reference[1:]).split("/")
]

return ParsedReference(reference, segments), scope

Expand Down
3 changes: 2 additions & 1 deletion src/jsonlogic/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ def __repr__(self) -> str:

class DataStack(Generic[DataT]):
def __init__(self, root_data: DataT | UnsetType = UNSET) -> None:
self._stack = []
if root_data is not UNSET:
self._stack = [root_data]
self._stack.append(root_data)

@property
def tail(self) -> DataT:
Expand Down
3 changes: 3 additions & 0 deletions tests/operators/test_from_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ def test_if() -> None:
assert if_.if_elses == [(1, 2), (3, 4)]
assert if_.leading_else == 5

with pytest.raises(JSONLogicSyntaxError, match="'if' expects at least 3 arguments, got 1"):
If.from_expression("if", [1])

with pytest.raises(JSONLogicSyntaxError, match="'if' expects an odd number of arguments, got 6"):
If.from_expression("if", [1, 2, 3, 4, 5, 6])

Expand Down
235 changes: 235 additions & 0 deletions tests/operators/test_typecheck.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, cast

from jsonlogic._compat import Self
from jsonlogic.core import JSONLogicExpression, Operator
from jsonlogic.json_schema.types import (
AnyType,
ArrayType,
BooleanType,
DateType,
IntegerType,
JSONSchemaType,
NumberType,
StringType,
UnionType,
)
from jsonlogic.operators import operator_registry as base_operator_registry
from jsonlogic.typechecking import TypecheckContext, typecheck
from jsonlogic.typing import OperatorArgument


@dataclass
class ReturnsOp(Operator):
"""A test operator returning the provided type during typechecking."""

return_type: JSONSchemaType

@classmethod
def from_expression(cls, operator: str, arguments: list[OperatorArgument]) -> Self:
assert len(arguments) == 1
return cls(
operator=operator,
return_type=arguments[0], # type: ignore
)

def typecheck(self, context: TypecheckContext) -> JSONSchemaType:
return self.return_type


operator_registry = base_operator_registry.copy(extend={"returns": ReturnsOp})


def as_op(json_logic: dict[str, Any]) -> Operator:
expr = JSONLogicExpression.from_json(json_logic)
return cast(Operator, expr.as_operator_tree(operator_registry))


def test_var_dynamic_variable_path() -> None:
op_no_diag = as_op({"var": {"returns": StringType()}})
rt, diagnostics = typecheck(op_no_diag, {})

assert rt == AnyType()
assert diagnostics == []

op_diag = as_op({"var": {"returns": BooleanType()}})
rt, diagnostics = typecheck(op_diag, {})

assert rt == AnyType()
diag = diagnostics[0]
assert diag.category == "argument_type"
assert diag.message == "The first argument must be of type string, got boolean"


def test_var_unresolvable_no_default() -> None:
op = as_op({"var": "/some_var"})
rt, diagnostics = typecheck(op, {})

assert rt == AnyType()
diag = diagnostics[0]

assert diag.category == "unresolvable_variable"
assert diag.message == "'/some_var' is unresolvable and no fallback value is provided"


def test_var_unresolvable_default() -> None:
op = as_op({"var": ["/some_var", 1]})
rt, diagnostics = typecheck(op, {})

assert rt == IntegerType()
diag = diagnostics[0]

assert diag.category == "unresolvable_variable"
assert diag.message == "'/some_var' is unresolvable"
assert diag.type == "warning"


def test_var_resolvable_no_default() -> None:
op = as_op({"var": "/some_var"})
rt, diagnostics = typecheck(op, {"type": "object", "properties": {"some_var": {"type": "string"}}})

assert rt == StringType()
assert diagnostics == []


def test_var_resolvable_default() -> None:
op = as_op({"var": ["/some_var", 1]})
rt, diagnostics = typecheck(op, {"type": "object", "properties": {"some_var": {"type": "string"}}})

assert rt == UnionType(StringType(), IntegerType())
assert diagnostics == []


def test_equality_op() -> None:
# Note: this test is relevant for the `Equal` and `NotEqual` operator classes.
op_1 = as_op({"==": [1, "test"]})
rt, diagnostics = typecheck(op_1, {})

assert rt == BooleanType()
assert diagnostics == []

op_2 = as_op({"==": [{"var": "a"}, {"var": "b"}]})
rt, diagnostics = typecheck(op_2, {})

assert rt == BooleanType()
# Should contain diagnostics from the var op:
assert len(diagnostics) == 2


def test_if_bad_condition() -> None:
# `DateType` does not support boolean evaluation
returns_date = {"returns": DateType()}
op = as_op({"if": [returns_date, "some_value", "other_value"]})
_, diagnostics = typecheck(op, {})
diag = diagnostics[0]

assert diag.category == "argument_type"
assert diag.message == "Condition 1 should support boolean evaluation"

op = as_op({"if": [returns_date, "some_value", returns_date, "other_value", "another_value"]})
_, diagnostics = typecheck(op, {})
diag_1, diag_2 = diagnostics

assert diag_1.message == "Condition 1 should support boolean evaluation"
assert diag_2.message == "Condition 2 should support boolean evaluation"


def test_if() -> None:
op = as_op({"if": [1, "string_val", 2, 2.0, "other_string"]})
rt, diagnostics = typecheck(op, {})

assert rt == UnionType(StringType(), NumberType())
assert diagnostics == []


def test_binary_op() -> None:
# Note: this test is relevant for all the binary operator classes.
op = as_op({">": [2, {"returns": NumberType()}]})
rt, diagnostics = typecheck(op, {})

assert rt == BooleanType()
assert diagnostics == []

op_fail = as_op({">=": [2, {"returns": DateType()}]})
rt, diagnostics = typecheck(op_fail, {})
diag = diagnostics[0]

assert rt == AnyType()
assert diag.category == "operator"
assert diag.message == 'Operator ">=" not supported for types integer and date'


def test_plus() -> None:
op_two_operands = as_op({"+": ["a", "b"]})
rt, diagnostics = typecheck(op_two_operands, {})
diag = diagnostics[0]

assert rt == AnyType()
assert diag.category == "operator"
assert diag.message == 'Operator "+" not supported for types string and string'

op_three_operands = as_op({"+": ["a", 1, 2]})
rt, diagnostics = typecheck(op_three_operands, {})
diag = diagnostics[0]

assert rt == AnyType()
assert diag.category == "operator"
assert diag.message == 'Operator "+" not supported for types string (argument 1) and integer (argument 2)'

op_ok = as_op({"+": [1, 2, 3, 4]})
rt, diagnostics = typecheck(op_ok, {})

assert rt == IntegerType()
assert diagnostics == []


def test_minus() -> None:
op_unary = as_op({"-": {"returns": DateType()}})
rt, diagnostics = typecheck(op_unary, {})
diag = diagnostics[0]

assert rt == AnyType()
assert diag.category == "operator"
assert diag.message == 'Operator "-" not supported for type date'

op_binary = as_op({"-": [{"returns": DateType()}, {"returns": IntegerType()}]})
rt, diagnostics = typecheck(op_binary, {})
diag = diagnostics[0]

assert rt == AnyType()
assert diag.category == "operator"
assert diag.message == 'Operator "-" not supported for type date and integer'

op_ok = as_op({"-": [1, 1]})
rt, diagnostics = typecheck(op_ok, {})

assert rt == IntegerType()
assert diagnostics == []


def test_map() -> None:
op_bad_vars = as_op({"map": [1, "irrelevant"]})
rt, diagnostics = typecheck(op_bad_vars, {})
diag = diagnostics[0]

assert rt == AnyType()
assert diag.category == "argument_type"
assert diag.message == "The first argument must be of type array, got integer"

op = as_op({"map": [[1, 2], {"+": [{"var": ""}, 2.0]}]})
rt, diagnostics = typecheck(op, {})

assert rt == ArrayType(NumberType())
assert diagnostics == []


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.
op = as_op({"map": [["some", "strings"], {"+": [{"var": "/@1"}, 2.0]}]})
rt, diagnostics = typecheck(op, {"type": "object", "properties": {"": {"type": "integer"}}})

assert rt == ArrayType(NumberType())
assert diagnostics == []
Loading

0 comments on commit 4e00a96

Please sign in to comment.