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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
<!-- Do Not Erase This Section - Used for tracking unreleased changes -->

### Added
- **GFQL Cypher `searchAny(entity, term[, opts])` cross-column search predicate + `g.search_nodes()`/`g.search_edges()` (viz-filter L2, native on all four engines)**: True where ANY of the entity's columns matches the term — the streamgl-viz inspector's table-search semantics as a composable WHERE predicate: OR across columns; case-insensitive substring by DEFAULT (case-folded, never regex — the common call avoids every engine regex limit); regex opt-in obeying the same per-engine decline rules as `=~`; dtype gate AS SEMANTICS (string columns always; integer columns iff the term is a numeric literal, per the inspector's `/^[0-9.-]+$/` gate; floats/dates/booleans reachable via the explicit `columns:` list). Options map `{caseSensitive, regex, columns}` is strict-validated (unknown keys error listing the valid ones); unbound aliases and missing explicit columns error clearly; null cells never match. Lowered like the pattern-predicate markers (a `search_any` row op + fresh marker column), so it composes through AND/OR/NOT and different node/edge terms coexist in one pipeline. Per-column matching reuses the parity-hardened `Contains` predicate on pandas/cuDF and a lowercase-fold/any_horizontal lowering on polars; oracle-pinned + 4-engine parity-or-NIE conformance cases. Python twins `g.search_nodes(term, columns=, case_sensitive=, regex=)` / `g.search_edges(...)` filter their own table and return a Plottable (polars-frame twins decline honestly for now — use the cypher op). Honest declines (NIE, use engine='pandas'): edge-alias searchAny on polars, and explicit columns beyond string/int/bool dtypes on polars AND cuDF (incl. floats: repr diverges across engines — dgx-probed).
- **GFQL Cypher `EXISTS { <pattern> }` pattern-existence subqueries (openCypher-standard), native on all four engines**: `WHERE EXISTS { (n)-[:R]->() }` and `WHERE NOT EXISTS { (n)--() }` now parse and run — the declarative prune-isolated building blocks for the streamgl-viz filter pipeline. An `EXISTS` body reuses the existing pattern-predicate lowering wholesale (`semi_apply_mark` / `anti_semi_apply` row ops), so pandas/cuDF worked immediately; the polars engine gains NATIVE lowerings for the semi-apply family (correlated key sets computed by the polars chain executor's named-flag columns; order-preserving `is_in` joins) plus `rows(binding_ops=...)` for the single-entity row table — previously all honest-NIE. Aliases introduced inside the braces are existentially scoped (`EXISTS { (n)--(m) }` allowed with `m` unbound outside — bare pattern predicates keep the conservative guard), inline property maps work, and the one supported inner `WHERE` form is endpoint inequality — `EXISTS { (n)--(m) WHERE m <> n }`, the drop-self-loop prune-isolated flavor (pandas/cuDF filter the correlated bindings; polars excludes self-loop edges, which is exactly the `m <> n` witness). Both prune flavors are oracle-pinned in the conformance matrix on a self-loop discriminator graph, 4-engine parity-or-NIE. Honest declines with clear errors: `EXISTS` in RETURN/WITH projections, general inner `WHERE`, multi-pattern bodies, full `MATCH..RETURN` subquery bodies, multi-alias correlation on polars.
- **GFQL polars execution config is Python-settable and live**: `set_cpu_streaming(bool)` and `set_gpu_executor('in-memory'|'streaming')` in `graphistry.compute.gfql.lazy` (plus the public `GPU_EXECUTORS` options and `GpuExecutor` type) set the CPU-streaming / GPU-executor knobs from Python. They resolve **Python override > environment variable > default**, read **live** per collect — previously these were env-only (`GFQL_POLARS_CPU_STREAMING` / `GFQL_POLARS_GPU_EXECUTOR`) and frozen at import, so neither a Python setting nor a post-import env change took effect. `None` resets a setter to env/default.
- **GFQL engine conversion honors the `validate`/`warn` convention**: `Engine.df_to_engine(df, engine, *, validate=, warn=)` threads the repo-wide `validate` (`'strict'`/`'strict-fast'`/`'autofix'`; `True`→strict, `False`→autofix) + `warn` protocol into the pandas→polars and pandas→cuDF converters. On a mixed-type object column that Arrow/polars/cuDF cannot represent, `strict` raises (`NotImplementedError` for polars, `ArrowConversionError` for cuDF) and `autofix` coerces the column to string and warns — the same convention as `plot()`/`upload()`. Each engine keeps its established default (polars `strict` = parity-or-raise; cuDF `autofix` = its shipped best-effort coercion, now `warn`-suppressible).
Expand Down
16 changes: 16 additions & 0 deletions docs/source/gfql/cypher.rst
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,22 @@ and ``RETURN`` expressions:
``size``, and conversions ``toInteger`` / ``toFloat`` / ``toString`` /
``toBoolean`` and ``coalesce``.
- Regex ``=~`` (see WHERE Forms above).
- ``searchAny(entity, term[, opts])`` — cross-column search predicate (WHERE
position; GFQL extension for the viz filter pipeline): True where ANY of the
entity's columns matches ``term``. Inspector semantics: OR across columns,
case-insensitive substring by default, regex opt-in; dtype-gated — string
columns always, integer columns iff the term is a numeric literal
(``/^[0-9.-]+$/``); floats/dates/booleans only via the explicit list. Options
map: ``{caseSensitive: true, regex: true, columns: ['name', ...]}`` (unknown
keys error, listing the valid ones). Composes with other WHERE predicates
through AND/OR/NOT; nodes and edges independently searchable with different
terms. Runs natively on all four engines for node aliases; an edge-alias
``searchAny(r, ...)`` declines honestly on polars pending multi-entity
binding-row support (use ``engine='pandas'``), and explicit non-string
columns beyond ints/bools likewise decline on polars and cuDF rather than
risk divergent stringification (float repr differs across engines). The regex path obeys the same
per-engine decline rules as ``=~``. Python twins:
:meth:`ComputeMixin.search_nodes` / :meth:`ComputeMixin.search_edges`.

``LIKE`` / ``ILIKE`` and ``BETWEEN`` are intentionally not provided — they are
not part of Cypher or GQL; use ``=~`` / ``CONTAINS`` / ``STARTS WITH`` and
Expand Down
49 changes: 49 additions & 0 deletions graphistry/compute/ComputeMixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,55 @@ def get_topological_levels(
out_df = safe_merge(g2_base._nodes, levels_df, on=g2_base._node, how='left')
return self.nodes(out_df)

def search_nodes(self, term, columns=None, case_sensitive=False, regex=False):
"""Keep nodes where ANY column matches ``term`` (viz-filter L2 inspector
semantics: OR across columns; case-insensitive substring default; regex
opt-in; string columns always, integer columns iff the term is a numeric
literal — floats/dates via explicit ``columns=`` on pandas ONLY: cuDF
declines them, its float/temporal stringification diverges from pandas).
pandas/cuDF native; polars frames raise NotImplementedError (use the
cypher ``search_any`` op).
"""
from graphistry.compute.gfql.search_any import search_any_mask
from graphistry.compute.exceptions import ErrorCode, GFQLValidationError
df = self._nodes
if df is None:
return self
if "polars" in type(df).__module__:
raise NotImplementedError(
"search_nodes is not yet native on polars frames; use the cypher "
"search_any op or engine='pandas'")
mask = search_any_mask(
df, term, case_sensitive=case_sensitive, regex=regex, columns=columns)
if mask is None:
raise GFQLValidationError(
ErrorCode.E108,
"search_nodes columns= includes a column absent from the nodes table",
field="columns", value=columns,
suggestion="List only columns present on the nodes table.")
return self.nodes(df[mask])

def search_edges(self, term, columns=None, case_sensitive=False, regex=False):
"""Keep edges where ANY column matches ``term`` — see :meth:`search_nodes`."""
from graphistry.compute.gfql.search_any import search_any_mask
from graphistry.compute.exceptions import ErrorCode, GFQLValidationError
df = self._edges
if df is None:
return self
if "polars" in type(df).__module__:
raise NotImplementedError(
"search_edges is not yet native on polars frames; use the cypher "
"search_any op or engine='pandas'")
mask = search_any_mask(
df, term, case_sensitive=case_sensitive, regex=regex, columns=columns)
if mask is None:
raise GFQLValidationError(
ErrorCode.E108,
"search_edges columns= includes a column absent from the edges table",
field="columns", value=columns,
suggestion="List only columns present on the edges table.")
return self.edges(df[mask])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DRY


def prune_self_edges(self):
return self.edges(self._edges[ self._edges[self._source] != self._edges[self._destination] ])

Expand Down
23 changes: 23 additions & 0 deletions graphistry/compute/ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -1680,6 +1680,29 @@ def anti_semi_apply(
return ASTCall("anti_semi_apply", params)


def search_any(
*,
alias: str,
term: str,
out_col: str,
case_sensitive: bool = False,
regex: bool = False,
columns: Optional[Sequence[str]] = None,
) -> ASTCall:
"""Annotate active rows with a cross-column search marker (viz-filter L2):
``out_col`` True where ANY of ``alias``'s columns matches ``term`` — OR across
columns, case-insensitive substring default, regex opt-in, dtype-gated (string
columns always; integer columns iff numeric-literal term)."""
params: Dict[str, Any] = {"alias": alias, "term": term, "out_col": out_col}
if case_sensitive:
params["case_sensitive"] = True
if regex:
params["regex"] = True
if columns is not None:
params["columns"] = list(columns)
return ASTCall("search_any", params)


def semi_apply_mark(
*,
binding_ops: List[Dict[str, Any]],
Expand Down
14 changes: 9 additions & 5 deletions graphistry/compute/gfql/call/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,12 +303,16 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En
) from error
if isinstance(error, GFQLTypeError):
raise error
if isinstance(error, NotImplementedError) and engine in (Engine.POLARS, Engine.POLARS_GPU):
if isinstance(error, NotImplementedError) and (
engine in (Engine.POLARS, Engine.POLARS_GPU) or is_row_pipeline_call(function)
):
# Honest engine-capability decline — propagate as-is so the DAG surface matches the chain
# surface's NotImplementedError. Sources: call_mode='strict' off-engine decline, and the
# polars-gpu GPU-or-error when cuDF is unavailable (_bridge_graph_for_offengine_call).
# Gated to the polars engines: a pandas/cudf NIE (e.g. fa2_layout requiring a GPU) must
# still fall through to the GFQLTypeError(E303) wrapper below, not leak as a bare NIE.
# surface's NotImplementedError. Sources: call_mode='strict' off-engine decline, the
# polars-gpu GPU-or-error when cuDF is unavailable (_bridge_graph_for_offengine_call),
# and row-pipeline kernels' per-engine declines on ANY engine (searchAny's cuDF
# regex/dtype gates — the parity-or-NIE contract needs them to SURFACE as NIE).
# Non-row-pipeline pandas/cudf NIEs (e.g. fa2_layout requiring a GPU) still fall
# through to the GFQLTypeError(E303) wrapper below, not leak as a bare NIE.
raise error
if error is not None:
raise GFQLTypeError(
Expand Down
15 changes: 15 additions & 0 deletions graphistry/compute/gfql/call/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,21 @@ def _semi_apply_mark_added_node_cols(params: Dict[str, object]) -> Set[str]:
description='Filter active rows by anti-semi joining against correlated binding rows',
),

'search_any': _safelist_entry(
{'alias', 'term', 'out_col', 'case_sensitive', 'regex', 'columns'},
required_params={'alias', 'term', 'out_col'},
param_validators={
'alias': is_non_empty_string,
'term': is_string,
'out_col': is_non_empty_string,
'case_sensitive': is_bool,
'regex': is_bool,
'columns': is_non_empty_list_of_strings,
},
description='Annotate active rows with a cross-column search marker (OR across columns)',
schema_effects=_schema_effects(adds_node_cols=_semi_apply_mark_added_node_cols),
),

'semi_apply_mark': _safelist_entry(
{'binding_ops', 'join_aliases', 'out_col', 'neq'},
required_params={'binding_ops', 'join_aliases', 'out_col'},
Expand Down
144 changes: 144 additions & 0 deletions graphistry/compute/gfql/cypher/lowering.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
order_by,
return_,
rows,
search_any,
semi_apply_mark,
serialize_binding_ops,
select,
Expand Down Expand Up @@ -56,6 +57,7 @@
from graphistry.compute.predicates.is_in import is_in
from graphistry.compute.predicates.logical import all_of
from graphistry.compute.predicates.str import contains as str_contains, endswith, fullmatch, never_match, startswith
from graphistry.compute.gfql.cypher.parser import _mask_quoted_backticked_and_commented_for_scan
from graphistry.compute.gfql.language_defs import GFQL_AGGREGATION_FUNCTIONS
from graphistry.compute.gfql.expr_parser import (
BinaryOp,
Expand Down Expand Up @@ -3843,6 +3845,138 @@ def _append_page_ops(
params=params,
)

_SEARCH_ANY_CALL_RE = re.compile(
r"\bsearchAny\s*\(\s*([A-Za-z_]\w*)\s*,\s*"
r"('(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\")"
r"\s*(?:,\s*\{([^{}]*)\})?\s*\)",
re.IGNORECASE,
)
_SEARCH_ANY_OPT_KEYS = {"casesensitive": "case_sensitive", "regex": "regex", "columns": "columns"}
_SEARCH_ANY_COLUMNS_RE = re.compile(
r"^\[\s*(?:'[^']*'|\"[^\"]*\")(?:\s*,\s*(?:'[^']*'|\"[^\"]*\"))*\s*\]$")
_SEARCH_ANY_COL_ITEM_RE = re.compile(r"'([^']*)'|\"([^\"]*)\"")


def _parse_search_any_opts(opts_text: str, *, line: int, column: int) -> Dict[str, Any]:
"""Parse searchAny's option map literal — strict: unknown keys error listing the
valid ones (persona pass: predictable options beat silent typo-tolerance)."""
out: Dict[str, Any] = {}
if not opts_text.strip():
return out
depth = 0
parts: List[str] = []
cur = ""
for ch in opts_text:
if ch in "[(":
depth += 1
elif ch in "])":
depth -= 1
if ch == "," and depth == 0:
parts.append(cur)
cur = ""
else:
cur += ch
parts.append(cur)
for part in parts:
m = re.match(r"\s*([A-Za-z_]\w*)\s*:\s*(.+?)\s*$", part)
key = m.group(1) if m else part.strip()
canon = _SEARCH_ANY_OPT_KEYS.get(key.lower()) if m else None
if m is None or canon is None:
raise _unsupported(
f"searchAny got an unsupported option {key!r}",
field="where",
value=opts_text,
line=line,
column=column,
)
val_text = m.group(2)
if canon in ("case_sensitive", "regex"):
low = val_text.lower()
if low not in ("true", "false"):
raise _unsupported(
f"searchAny option {key!r} must be true or false",
field="where", value=val_text, line=line, column=column,
)
out[canon] = low == "true"
else:
if not _SEARCH_ANY_COLUMNS_RE.match(val_text):
raise _unsupported(
"searchAny option 'columns' must be a list of string literals",
field="where", value=val_text, line=line, column=column,
)
out[canon] = [a or b for a, b in _SEARCH_ANY_COL_ITEM_RE.findall(val_text)]
return out


def _lift_search_any_from_row_where(
expr: ExpressionText,
*,
alias_targets: Mapping[str, ASTObject],
existing_cols: AbstractSet[str],
) -> Tuple[str, List[ASTCall]]:
"""viz-filter L2-b: rewrite each ``searchAny(alias, 'term'[, {opts}])`` in the
WHERE row-expression into a fresh boolean MARKER column + a ``search_any`` row
pre-filter (exactly the pattern-predicate marker mechanism), so the remaining
boolean expression composes through AND/OR/NOT unchanged.

Matches are found against a literal/comment-masked copy of the text so a
``searchAny(...)`` occurring INSIDE a string literal is data, not a call
(wave-1 B1: the bare ``.sub`` rewrote literal contents — silent wrong answer)."""
calls: List[ASTCall] = []
used: set = set(existing_cols)

def _sub(m: "re.Match[str]") -> str:
alias, term_lit, opts_text = m.group(1), m.group(2), m.group(3) or ""
if alias not in alias_targets:
raise _unsupported(
f"searchAny references alias {alias!r} not bound in the active MATCH scope",
field="where", value=alias,
line=expr.span.line, column=expr.span.column,
)
term = term_lit[1:-1].replace("\\'", "'").replace('\\"', '"')
opts = _parse_search_any_opts(
opts_text, line=expr.span.line, column=expr.span.column)
base = f"__gfql_search_any_{expr.span.line}_{expr.span.column}_{len(calls)}__"
out_col = _fresh_temp_name(used, base)
used.add(out_col)
calls.append(search_any(alias=alias, term=term, out_col=out_col, **opts))
return out_col

masked = _mask_quoted_backticked_and_commented_for_scan(expr.text)
parts: List[str] = []
last = 0
pos = 0
while True:
m = _SEARCH_ANY_CALL_RE.search(expr.text, pos)
if m is None:
break
if masked[m.start()] != expr.text[m.start()]:
# starts inside a string literal / comment: data, not a call — resume
# just past the START (not the end: an in-literal pseudo-match can span
# across quotes and swallow a real call inside its span; wave-2 S1)
pos = m.start() + 1
continue
parts.append(expr.text[last:m.start()])
parts.append(_sub(m))
last = m.end()
pos = m.end()
parts.append(expr.text[last:])
new_text = "".join(parts)
# Anything still spelled searchAny( outside literals is a form the lift can't
# parse (e.g. a $param term — the persona-pass signature is literal-only): fail
# loudly here instead of leaking an unknown function downstream (wave-1 I5).
masked_new = _mask_quoted_backticked_and_commented_for_scan(new_text)
if re.search(r"\bsearchAny\s*\(", masked_new, re.IGNORECASE):
raise _unsupported(
"searchAny requires a string-literal term and literal options, e.g. "
"searchAny(n, 'term', {caseSensitive: false}) — parameters ($p) and "
"expressions are not supported",
field="where", value=expr.text,
line=expr.span.line, column=expr.span.column,
)
return new_text, calls


def _append_match_row_where(
row_steps: List[ASTObject],
*,
Expand Down Expand Up @@ -6113,6 +6247,16 @@ def lower_match_query(
span=query.where.span if query.where is not None else merged_match.span,
)

if row_where is not None:
# viz-filter L2-b: lift searchAny(...) calls into search_any pre-filters +
# marker columns HERE (assembly level, like the pattern-predicate markers),
# so every LoweredCypherMatch consumer sees the lifted form.
lifted_text, search_calls = _lift_search_any_from_row_where(
row_where, alias_targets=alias_targets, existing_cols=frozenset())
if search_calls:
row_pre_filters.extend(search_calls)
row_where = ExpressionText(text=lifted_text, span=row_where.span)

return LoweredCypherMatch(
query=ops,
where=where_out,
Expand Down
Loading
Loading