diff --git a/src/jsonata/functions.py b/src/jsonata/functions.py index fa158a0..262ee21 100644 --- a/src/jsonata/functions.py +++ b/src/jsonata/functions.py @@ -28,6 +28,7 @@ import decimal import functools import inspect +import itertools import json import math import random @@ -36,7 +37,7 @@ import unicodedata import urllib.parse from dataclasses import dataclass -from typing import Any, AnyStr, Mapping, NoReturn, Optional, Sequence, Callable, Type, Union +from typing import Any, AnyStr, Mapping, NoReturn, Optional, Sequence, Callable, Iterator, Type, Union from jsonata import datetimeutils, jexception, parser, utils from jsonata.regex_engine import CompiledPattern @@ -515,8 +516,7 @@ class RegexpMatch: # @returns {object} - structure that represents the match(es) # @staticmethod - def evaluate_matcher(matcher: CompiledPattern, string: Optional[str]) -> list[RegexpMatch]: - res = [] + def evaluate_matcher(matcher: CompiledPattern, string: Optional[str]) -> Iterator[RegexpMatch]: matches = matcher.finditer(string) for m in matches: groups = [] @@ -528,8 +528,7 @@ def evaluate_matcher(matcher: CompiledPattern, string: Optional[str]) -> list[Re rm = Functions.RegexpMatch(m.group(), m.start(), groups) rm.groups = groups - res.append(rm) - return res + yield rm # # Tests if the str contains the token @@ -555,7 +554,7 @@ def contains(string: Optional[str], token: Union[None, str, CompiledPattern]) -> # if (dbg) System.out.println("match = "+matches) # result = (typeof matches !== 'undefined') # throw new Error("regexp not impl"); //result = false - result = bool(matches) + result = next(matches, None) is not None else: raise RuntimeError("unknown type to match: " + str(token)) @@ -583,16 +582,13 @@ def match_(string: Optional[str], regex: Optional[CompiledPattern], limit: Optio result = utils.Utils.create_sequence() matches = Functions.evaluate_matcher(regex, string) - max = sys.maxsize if limit is not None: - max = limit + matches = itertools.islice(matches, math.ceil(limit)) - for i, rm in enumerate(matches): + for rm in matches: m = {"match": rm.match, "index": rm.index, "groups": rm.groups} # Convert to JSON map: result.append(m) - if i >= max: - break return result # diff --git a/src/jsonata/jsonata.py b/src/jsonata/jsonata.py index 8248db4..1092b98 100644 --- a/src/jsonata/jsonata.py +++ b/src/jsonata/jsonata.py @@ -1643,8 +1643,7 @@ def partial_apply_procedure(self, proc: Optional[parser.Parser.Symbol], args: Se for param in proc.arguments: # proc.arguments.forEach(Object (param, index) { arg = args[index] if index < len(args) else None - if (arg is None) or ( - isinstance(arg, parser.Parser.Symbol) and ("operator" == arg.type and "?" == arg.value)): + if isinstance(arg, parser.Parser.Symbol) and ("operator" == arg.type and "?" == arg.value): unbound_args.append(param) else: env.bind(str(param.value), arg) @@ -1671,17 +1670,10 @@ def partial_apply_native_function(self, native: Optional[JFunction], args: Seque # var body = "function($a,$c) { $substring($a,0,$c) }" - sig_args = [] - part_args = [] - i = 0 - while i < native.get_number_of_args(): - arg_name = "$" + chr(ord('a') + i) - sig_args.append(arg_name) - if i >= len(args) or args[i] is None: - part_args.append(arg_name) - else: - part_args.append(args[i]) - i += 1 + # leave out trailing args that weren't supplied, so the native is called + # without them and optional params aren't validated against undefined + nargs = min(len(args), native.get_number_of_args()) + sig_args = ["$" + chr(ord('a') + i) for i in range(nargs)] body = "function(" + ", ".join(sig_args) + "){" body += "$" + native.function_name + "(" + ", ".join(sig_args) + ") }" diff --git a/tests/string_test.py b/tests/string_test.py index aa4028a..7f20608 100644 --- a/tests/string_test.py +++ b/tests/string_test.py @@ -1,4 +1,5 @@ import jsonata +from jsonata.regex_engine import default_regex_engine # @@ -145,3 +146,62 @@ def test_trim(self): assert jsonata.Jsonata("$trim(\" \")").evaluate(None) == "" assert jsonata.Jsonata("$trim(\"\")").evaluate(None) == "" assert jsonata.Jsonata("$trim(notthere)").evaluate(None) is None + + def test_match_limit(self): + res = jsonata.Jsonata("$match('ababab', /ab/)").evaluate(None) + assert res is not None + assert [m["index"] for m in res] == [0, 2, 4] + assert jsonata.Jsonata("$match('ababab', /ab/, 0)").evaluate(None) is None + assert jsonata.Jsonata("$match('ababab', /ab/, 1)").evaluate(None) == {"match": "ab", "index": 0, "groups": []} + + res = jsonata.Jsonata("$match('ababab', /ab/, 2)").evaluate(None) + assert res is not None + assert [m["index"] for m in res] == [0, 2] + + res = jsonata.Jsonata("$match('ababab', /ab/, 5)").evaluate(None) + assert res is not None + assert [m["index"] for m in res] == [0, 2, 4] + + def test_match_fractional_limit(self): + res = jsonata.Jsonata("$match('ababab', /ab/, 4/2)").evaluate(None) + assert res is not None + assert [m["index"] for m in res] == [0, 2] + + # Like jsonata-js, matches are returned while the count is less than the limit + res = jsonata.Jsonata("$match('ababab', /ab/, 1.5)").evaluate(None) + assert res is not None + assert [m["index"] for m in res] == [0, 2] + + def test_match_limit_stops_searching(self): + searched = [] + + class CountingPattern: + def __init__(self, pattern): + self._pattern = pattern + + def __getattr__(self, name): + return getattr(self._pattern, name) + + def finditer(self, string): + for m in self._pattern.finditer(string): + searched.append(m.start()) + yield m + + def counting_regex_engine(pattern, flags): + return CountingPattern(default_regex_engine(pattern, flags)) + + # The search stops once the limit is reached + for limit, expected in [(0, []), (1, [0]), (2, [0, 2])]: + searched.clear() + jsonata.Jsonata(f"$match('ababab', /ab/, {limit})", counting_regex_engine).evaluate(None) + assert searched == expected + + def test_partial_application_with_map(self): + # Only ? placeholders are unbound, so $map doesn't pass the index as a trailing argument + assert jsonata.Jsonata("$map(['abc', 'abc'], $substring(?, 1))").evaluate(None) == ["bc", "bc"] + assert jsonata.Jsonata("$map(['ab', 'ab'], $match(?, /ab/)).match").evaluate(None) == ["ab", "ab"] + + def test_partial_application_optional_args(self): + # Trailing args that weren't supplied aren't passed to the native function + assert jsonata.Jsonata("$sort(?)([3, 1, 2])").evaluate(None) == [1, 2, 3] + assert jsonata.Jsonata("$map([[3, 1, 2], [2, 1]], $sort(?))").evaluate(None) == [[1, 2, 3], [1, 2]]