|
24 | 24 | order_by, |
25 | 25 | return_, |
26 | 26 | rows, |
| 27 | + search_any, |
27 | 28 | semi_apply_mark, |
28 | 29 | serialize_binding_ops, |
29 | 30 | select, |
@@ -3843,6 +3844,103 @@ def _append_page_ops( |
3843 | 3844 | params=params, |
3844 | 3845 | ) |
3845 | 3846 |
|
| 3847 | +_SEARCH_ANY_CALL_RE = re.compile( |
| 3848 | + r"\bsearchAny\s*\(\s*([A-Za-z_]\w*)\s*,\s*" |
| 3849 | + r"('(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\")" |
| 3850 | + r"\s*(?:,\s*\{([^{}]*)\})?\s*\)", |
| 3851 | + re.IGNORECASE, |
| 3852 | +) |
| 3853 | +_SEARCH_ANY_OPT_KEYS = {"casesensitive": "case_sensitive", "regex": "regex", "columns": "columns"} |
| 3854 | +_SEARCH_ANY_COLUMNS_RE = re.compile( |
| 3855 | + r"^\[\s*(?:'[^']*'|\"[^\"]*\")(?:\s*,\s*(?:'[^']*'|\"[^\"]*\"))*\s*\]$") |
| 3856 | +_SEARCH_ANY_COL_ITEM_RE = re.compile(r"'([^']*)'|\"([^\"]*)\"") |
| 3857 | + |
| 3858 | + |
| 3859 | +def _parse_search_any_opts(opts_text: str, *, line: int, column: int) -> Dict[str, Any]: |
| 3860 | + """Parse searchAny's option map literal — strict: unknown keys error listing the |
| 3861 | + valid ones (persona pass: predictable options beat silent typo-tolerance).""" |
| 3862 | + out: Dict[str, Any] = {} |
| 3863 | + if not opts_text.strip(): |
| 3864 | + return out |
| 3865 | + depth = 0 |
| 3866 | + parts: List[str] = [] |
| 3867 | + cur = "" |
| 3868 | + for ch in opts_text: |
| 3869 | + if ch in "[(": |
| 3870 | + depth += 1 |
| 3871 | + elif ch in "])": |
| 3872 | + depth -= 1 |
| 3873 | + if ch == "," and depth == 0: |
| 3874 | + parts.append(cur) |
| 3875 | + cur = "" |
| 3876 | + else: |
| 3877 | + cur += ch |
| 3878 | + parts.append(cur) |
| 3879 | + for part in parts: |
| 3880 | + m = re.match(r"\s*([A-Za-z_]\w*)\s*:\s*(.+?)\s*$", part) |
| 3881 | + key = m.group(1) if m else part.strip() |
| 3882 | + canon = _SEARCH_ANY_OPT_KEYS.get(key.lower()) if m else None |
| 3883 | + if m is None or canon is None: |
| 3884 | + raise _unsupported( |
| 3885 | + f"searchAny got an unsupported option {key!r}", |
| 3886 | + field="where", |
| 3887 | + value=opts_text, |
| 3888 | + line=line, |
| 3889 | + column=column, |
| 3890 | + ) |
| 3891 | + val_text = m.group(2) |
| 3892 | + if canon in ("case_sensitive", "regex"): |
| 3893 | + low = val_text.lower() |
| 3894 | + if low not in ("true", "false"): |
| 3895 | + raise _unsupported( |
| 3896 | + f"searchAny option {key!r} must be true or false", |
| 3897 | + field="where", value=val_text, line=line, column=column, |
| 3898 | + ) |
| 3899 | + out[canon] = low == "true" |
| 3900 | + else: |
| 3901 | + if not _SEARCH_ANY_COLUMNS_RE.match(val_text): |
| 3902 | + raise _unsupported( |
| 3903 | + "searchAny option 'columns' must be a list of string literals", |
| 3904 | + field="where", value=val_text, line=line, column=column, |
| 3905 | + ) |
| 3906 | + out[canon] = [a or b for a, b in _SEARCH_ANY_COL_ITEM_RE.findall(val_text)] |
| 3907 | + return out |
| 3908 | + |
| 3909 | + |
| 3910 | +def _lift_search_any_from_row_where( |
| 3911 | + expr: ExpressionText, |
| 3912 | + *, |
| 3913 | + alias_targets: Mapping[str, ASTObject], |
| 3914 | + existing_cols: AbstractSet[str], |
| 3915 | +) -> Tuple[str, List[ASTCall]]: |
| 3916 | + """viz-filter L2-b: rewrite each ``searchAny(alias, 'term'[, {opts}])`` in the |
| 3917 | + WHERE row-expression into a fresh boolean MARKER column + a ``search_any`` row |
| 3918 | + pre-filter (exactly the pattern-predicate marker mechanism), so the remaining |
| 3919 | + boolean expression composes through AND/OR/NOT unchanged.""" |
| 3920 | + calls: List[ASTCall] = [] |
| 3921 | + used: set = set(existing_cols) |
| 3922 | + |
| 3923 | + def _sub(m: "re.Match[str]") -> str: |
| 3924 | + alias, term_lit, opts_text = m.group(1), m.group(2), m.group(3) or "" |
| 3925 | + if alias not in alias_targets: |
| 3926 | + raise _unsupported( |
| 3927 | + f"searchAny references alias {alias!r} not bound in the active MATCH scope", |
| 3928 | + field="where", value=alias, |
| 3929 | + line=expr.span.line, column=expr.span.column, |
| 3930 | + ) |
| 3931 | + term = term_lit[1:-1].replace("\\'", "'").replace('\\"', '"') |
| 3932 | + opts = _parse_search_any_opts( |
| 3933 | + opts_text, line=expr.span.line, column=expr.span.column) |
| 3934 | + base = f"__gfql_search_any_{expr.span.line}_{expr.span.column}_{len(calls)}__" |
| 3935 | + out_col = _fresh_temp_name(used, base) |
| 3936 | + used.add(out_col) |
| 3937 | + calls.append(search_any(alias=alias, term=term, out_col=out_col, **opts)) |
| 3938 | + return out_col |
| 3939 | + |
| 3940 | + new_text = _SEARCH_ANY_CALL_RE.sub(_sub, expr.text) |
| 3941 | + return new_text, calls |
| 3942 | + |
| 3943 | + |
3846 | 3944 | def _append_match_row_where( |
3847 | 3945 | row_steps: List[ASTObject], |
3848 | 3946 | *, |
@@ -6113,6 +6211,16 @@ def lower_match_query( |
6113 | 6211 | span=query.where.span if query.where is not None else merged_match.span, |
6114 | 6212 | ) |
6115 | 6213 |
|
| 6214 | + if row_where is not None: |
| 6215 | + # viz-filter L2-b: lift searchAny(...) calls into search_any pre-filters + |
| 6216 | + # marker columns HERE (assembly level, like the pattern-predicate markers), |
| 6217 | + # so every LoweredCypherMatch consumer sees the lifted form. |
| 6218 | + lifted_text, search_calls = _lift_search_any_from_row_where( |
| 6219 | + row_where, alias_targets=alias_targets, existing_cols=frozenset()) |
| 6220 | + if search_calls: |
| 6221 | + row_pre_filters.extend(search_calls) |
| 6222 | + row_where = ExpressionText(text=lifted_text, span=row_where.span) |
| 6223 | + |
6116 | 6224 | return LoweredCypherMatch( |
6117 | 6225 | query=ops, |
6118 | 6226 | where=where_out, |
|
0 commit comments