Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add test suite for operators typechecking #17

Merged
merged 1 commit into from
May 7, 2024
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
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