Skip to content
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
3 changes: 3 additions & 0 deletions aws_json_term_matcher/grammar.lark
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ expression: "(" expression ")"
| comparison

comparison: entity COMPARATOR value
| entity NOT_EXISTS -> not_exists
COMPARATOR: "=" | "!=" | ">" | ">=" | "<" | "<="

entity: "$" selection
Expand All @@ -21,6 +22,8 @@ SCIENTIFIC: SIGNED_INT "e" SIGNED_INT | SIGNED_FLOAT "e" SIGNED_INT
IP: INT "." INT "." INT "." INT
WILDCARD_IP: INT "." "*" | INT "." INT "." "*" | INT "." INT "." INT "." "*"

NOT_EXISTS: /(?i:NOT\s+EXISTS?)/

NAME: /[a-zA-Z_][a-zA-Z0-9_-]*/
INT: /[0-9]+/
FLOAT: /[0-9]+\.[0-9]+/
Expand Down
98 changes: 58 additions & 40 deletions aws_json_term_matcher/matcher.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import os

from lark import Lark, Transformer, v_args, Tree, Token
from lark import Lark, Token, Transformer, Tree, v_args
from lark.exceptions import UnexpectedCharacters, UnexpectedInput, UnexpectedToken

from aws_json_term_matcher.exceptions import ParsingError, MatchingError
from aws_json_term_matcher.exceptions import MatchingError, ParsingError

MISSING = object()


def extract_boolean(node):
Expand All @@ -16,6 +18,8 @@ def extract_boolean(node):
Returns:
The boolean value once found.
"""
if isinstance(node, bool):
return node
while hasattr(node, "children") and node.children:
node = node.children[0]
if isinstance(node, bool):
Expand Down Expand Up @@ -63,60 +67,74 @@ def and_op(self, left, right):
def or_op(self, left: Tree, right: Tree):
return extract_boolean(left) or extract_boolean(right)

def not_exists(self, entity, _op=None):
entity_value = self.resolve_entity(entity)
return entity_value is MISSING

def comparison(self, entity, comparator, value):
entity_value = self.resolve_entity(entity)
if entity_value is MISSING:
return False
result = self.compare(entity_value, comparator, value)
return result

def resolve_entity(self, entity: Tree):
# Extract the entity from the dictionary based on selection rules
# This would resolve $.attribute or $[index] kind of paths in the dictionary
keys = []
# in this case the three only is composed of branch with just one branch
# entity -> selection -> attribute access -> "NAME"
path = []

def _resolve(node):
if node.data == "attribute_access":
# Handles attributes like $.attributeName or $["attributeName"]
child = node.children[0]
if child.type == "NAME":
keys.append(child.value) # Regular attribute
elif child.type == "ESCAPED_STRING":
keys.append(
child.value.strip('"')
) # Attribute accessed like ["attr"]

elif node.data == "index_access":
index = node.children[0].value
keys.append(index)

elif node.data == "selection":
# Keep recursing through the selection (attributes or indices)
for child in node.children:
_resolve(child)
elif node.data == "entity":
for child in node.children:
_resolve(child)
if isinstance(node, Tree):
if node.data == "attribute_access":
# Handles attributes like $.attributeName or $["attributeName"]
child = node.children[0]
if child.type == "NAME":
path.append(("attr", child.value))
elif child.type == "ESCAPED_STRING":
path.append(("attr", child.value.strip("\"'")))

elif node.data == "index_access":
index = int(node.children[0].value)
path.append(("index", index))

else:
for child in node.children:
_resolve(child)

# Start traversing the entity tree to build the keys
_resolve(entity)

value = self.data
try:
for key in keys:
if key.isdigit():
value = value[int(key)]
current = self.data
for access_type, key in path:
if access_type == "attr":
if isinstance(current, dict) and key in current:
current = current[key]
else:
value = value.get(key, None)
return value
except IndexError:
return None
return MISSING
elif access_type == "index":
if isinstance(current, (list, tuple)):
if 0 <= key < len(current):
current = current[key]
else:
return MISSING
elif isinstance(current, dict):
if key in current:
current = current[key]
elif str(key) in current:
current = current[str(key)]
else:
return MISSING
else:
return MISSING

return current

def compare(self, entity_value, comparator, value):
comparator_value = comparator.value

if isinstance(value, IpRange):
return value.ip_is_in_range(entity_value)
in_range = value.ip_is_in_range(entity_value)
return not in_range if comparator_value == "!=" else in_range

if comparator_value == "=":
if value == "*" and entity_value is not None:
Expand All @@ -125,13 +143,13 @@ def compare(self, entity_value, comparator, value):
elif comparator_value == "!=":
return entity_value != value
elif comparator_value == ">":
return entity_value > value
return entity_value > value if entity_value is not None else False
elif comparator_value == ">=":
return entity_value >= value
return entity_value >= value if entity_value is not None else False
elif comparator_value == "<":
return entity_value < value
return entity_value < value if entity_value is not None else False
elif comparator_value == "<=":
return entity_value <= value
return entity_value <= value if entity_value is not None else False
return False

def value(self, value: Token):
Expand Down
44 changes: 40 additions & 4 deletions tests/test_matcher.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import json

import pytest

from aws_json_term_matcher.matcher import match

EXAMPLE_JSON_EVENT = json.loads(
"""
EXAMPLE_JSON_EVENT = json.loads("""
{
"eventType": "UpdateTrail",
"bandwidth": 80,
Expand All @@ -21,8 +22,7 @@
"arn:aws:states:us-east-1:111222333444:stateMachine:OrderProcessorWorkflow"
]
}
"""
)
""")


filters = [
Expand Down Expand Up @@ -66,9 +66,45 @@
),
# Special cases
('{$.eventType = "*"}', True),
# NOT EXISTS / NOT EXIST
("{ $.SomeOtherObject NOT EXISTS }", True),
("{ $.SomeOtherObject NOT EXIST }", True),
("{ $.SomeOtherObject not exists }", True),
("{ $.SomeOtherObject not exist }", True),
("{ $.eventType NOT EXISTS }", False),
("{ $.eventType NOT EXIST }", False),
('{ $["eventType"] NOT EXISTS }', False),
('{ $["non-existent"] NOT EXISTS }', True),
("{ $.number[0] NOT EXISTS }", False),
("{ $.number[4] NOT EXISTS }", True),
("{ $.non_existent.nested NOT EXISTS }", True),
('{($.SomeOtherObject NOT EXISTS) && ($.eventType = "UpdateTrail")}', True),
('{($.SomeOtherObject NOT EXISTS) && ($.eventType != "UpdateTrail")}', False),
("{ $.SomeOtherObject NOT EXISTS || $.bandwidth = 999 }", True),
("{ $.eventType NOT EXISTS || $.bandwidth = 999 }", False),
]


@pytest.mark.parametrize("filter_def, result", filters)
def test_matcher(filter_def, result):
assert match(EXAMPLE_JSON_EVENT, filter_def) == result


def test_not_exists_with_null_and_nested():
data = {
"nullable": None,
"nested": {"present": "value", "null_child": None},
"empty_list": [],
}
# An attribute with value null (None) exists, so NOT EXISTS is False
assert match(data, "{ $.nullable NOT EXISTS }") is False
assert match(data, "{ $.nested.null_child NOT EXISTS }") is False
# An attribute that does not exist
assert match(data, "{ $.missing NOT EXISTS }") is True
assert match(data, "{ $.missing NOT EXIST }") is True
assert match(data, "{ $.nested.missing NOT EXISTS }") is True
assert match(data, "{ $.missing.deeply.nested NOT EXISTS }") is True
assert match(data, "{ $.empty_list[0] NOT EXISTS }") is True
# Non-existent attribute with comparison returns False without error
assert match(data, '{ $.missing = "value" }') is False
assert match(data, '{ $.missing.nested = "value" }') is False
15 changes: 14 additions & 1 deletion tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from aws_json_term_matcher.exceptions import ParsingError
from aws_json_term_matcher.matcher import parse_filter


# This test case a
test_cases = [
# simple case
Expand All @@ -27,6 +26,16 @@
'{($.detail-type ="ShopUnavailable") && (($.resources[1] = "arn:aws:states:us-east-1:111222333444:execution:OrderProcessorWorkflow:d57d4769-72fd") || ($.resources[0] = "arn:aws:states:us-east-1:111222333444:stateMachine:OrderProcessorWorkflow"))}',
'{ $.number[0][1]["test"].test = 1e-3 }',
'{ ($.detail-type = "ShopUnavailable") && (($.resources[1] = "arn:aws:states:us-east-1:111222333444:execution:OrderProcessorWorkflow:d57d4769-72fd") || ($.resources[0] = "arn:aws:states:us-east-1:111222333444:execution:OrderProcessorWorkflow:d57d4769-72fd"))}',
# NOT EXISTS / NOT EXIST
"{ $.SomeOtherObject NOT EXISTS }",
"{ $.SomeOtherObject NOT EXIST }",
"{ $.SomeOtherObject not exists }",
"{ $.SomeOtherObject not exist }",
"{ $.someObject.someField NOT EXISTS }",
'{ $["someObject"]["someField"] NOT EXISTS }',
"{ $.arrayKey[0] NOT EXISTS }",
'{( $.SomeOtherObject NOT EXISTS) && ($.eventType = "UpdateTrail") }',
'{ $.SomeOtherObject NOT EXISTS || $.eventType = "UpdateTrail" }',
]


Expand All @@ -52,6 +61,10 @@ def test_parse_filter(filter_definition):
"{($.attribute = 1 }",
"{$.attribute = 1) }",
"{($.attribute = 1) && () }",
"{ $.attribute NOT }",
"{ $.attribute EXISTS }",
"{ NOT EXISTS }",
"{ $.attribute NOT EXISTS extra }",
]


Expand Down