diff --git a/aws_json_term_matcher/grammar.lark b/aws_json_term_matcher/grammar.lark index 319437c..c0d0f48 100644 --- a/aws_json_term_matcher/grammar.lark +++ b/aws_json_term_matcher/grammar.lark @@ -6,6 +6,7 @@ expression: "(" expression ")" | comparison comparison: entity COMPARATOR value + | entity NOT_EXISTS -> not_exists COMPARATOR: "=" | "!=" | ">" | ">=" | "<" | "<=" entity: "$" selection @@ -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]+/ diff --git a/aws_json_term_matcher/matcher.py b/aws_json_term_matcher/matcher.py index 788d4c8..aaed659 100644 --- a/aws_json_term_matcher/matcher.py +++ b/aws_json_term_matcher/matcher.py @@ -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): @@ -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): @@ -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: @@ -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): diff --git a/tests/test_matcher.py b/tests/test_matcher.py index 92ad956..973d0c5 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -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, @@ -21,8 +22,7 @@ "arn:aws:states:us-east-1:111222333444:stateMachine:OrderProcessorWorkflow" ] } -""" -) +""") filters = [ @@ -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 diff --git a/tests/test_parser.py b/tests/test_parser.py index 7af3d62..495e9e5 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -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 @@ -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" }', ] @@ -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 }", ]