Skip to content

Commit ef42830

Browse files
lmeyerovclaude
andauthored
fix(gfql/row): resolve bare relationship alias in row expressions (#1072) (#1249)
* fix(gfql/row): resolve bare relationship alias in row expressions (#1072) Bare edge aliases in select / where_rows / order_by row expressions raised "unsupported token in row expression" because _gfql_resolve_token only had a node-id fallback for the bare-alias case. Add an edge fallback: prefer the edge-id column, otherwise render the relationship as a Cypher-style [:TYPE {props}] string, mirroring what cypher RETURN <relAlias> already produces via result_postprocess. Direct GFQL chains and cypher-lowered chains now see the same column shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cypher): support multi-alias carry through WITH before MATCH re-entry (#1071) Pre-rewrite the prefix WITH stage in _compile_bounded_reentry_query so secondary whole-row aliases are demoted to scalar property carries (S.X AS __cypher_reentry_<S>_<X>__) and downstream S.X references compose with the existing single-whole-row machinery (#1047/#1068). RETURN of a secondary whole-row alias, and re-binding a secondary alias as a node variable in the trailing MATCH, remain unsupported with precise errors. Closes #1071. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gfql): unify relationship alias rendering and harden row resolution (#1072) * fix(gfql/cypher): close wave-3 alias resolution and reentry where gaps (#1072) * docs(changelog): add #1072 entry for relationship alias in row expressions Records the user-facing fix and amplifications for #1072 under ## [Development] → ### Fixed: bare edge-alias resolution in row expressions, shared vectorized renderer in row/entity_props.py, edge-alias gating via _gfql_rows_edge_aliases, string-escape / float / empty-type rendering normalization, and re-entry WHERE secondary-alias predicate rewriting via synthesized text. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(gfql/row): add cuDF smoke test for bare relationship alias rendering Wave 4 finding T2 (IMPORTANT): TestRelationshipAliasInRowExpression had 14 new tests in PR #1249 but zero cuDF coverage on the new edge-alias rendering path, despite Wave 2 vectorization being specifically motivated by cuDF support (Wave 1 finding #4). Adds test_select_bare_relationship_alias_renders_on_cudf_when_available following the sibling pattern at test_row_pipeline_vectorized_cudf_when_available (pytest.importorskip("cudf")). Asserts: (a) result keeps cuDF backend, (b) bare-alias rendering produces "[:WORKS_AT {workFrom: 2010}]", (c) select/return cell-for-cell parity holds on cuDF. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(gfql/row): collapse duplicate ValueError in _gfql_resolve_token Wave 4 finding D1 (SUGGESTION): the conditional at pipeline.py:2310 and the raise at pipeline.py:2312 produce identical errors. After the node-id branch (lines 2306-2309) returns, the only remaining outcome is "unsupported token", regardless of edge_aliases membership — both branches raised the same f-string. Collapsed to a single raise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 80d8084 commit ef42830

7 files changed

Lines changed: 653 additions & 75 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
5252
- **GFQL / IR refactor**: Consolidated the `("input", "left", "right", "subquery")` child-slot tuple that was duplicated across 5 sites (IR verifier, physical planner, two rewrite passes, one test helper) into a single `CHILD_SLOTS` constant and `iter_children` helper in `graphistry/compute/gfql/ir/logical_plan.py`. Adds 8 regression tests including: identity-preservation for `UnnestApply` and `PredicatePushdownPass` when no descendant is rewritten (the `rewritten_child is not child` guard is load-bearing for tier-2 fixed-point convergence), asymmetric identity preservation across `Join` when only one branch is rewritten, and a reflective `typing.get_type_hints` check that any future `LogicalPlan` subclass adding a new child slot must update `CHILD_SLOTS` (#1196, #1199).
5353

5454
### Fixed
55+
- **GFQL / row pipeline — relationship alias in row expressions (#1072)**: Bare edge-alias tokens in row expressions (`select([("rel", "workAt")])`, `WHERE rel IS NOT NULL`, `RETURN rel`) no longer raise `unsupported token in row expression: '<alias>'`. `_gfql_resolve_token` in `graphistry/compute/gfql/row/pipeline.py` extends the bare-alias branch with edge-alias resolution: prefer `{alias}.{_edge}` when present, otherwise render `[:TYPE {prop: value, ...}]` via a new shared vectorized renderer in `graphistry/compute/gfql/row/entity_props.py` reused by `cypher/result_postprocess.py::_format_edge_entities` and the row-pipeline path. Edge-alias classification is gated on `_gfql_rows_edge_aliases` metadata propagated through row-table adapters so node-alias frames missing a node-id column are not misrendered as edges. Renderer normalizes string escaping (`\\`, `'`), float formatting (strips trailing `.0+`), and treats blank/whitespace `type` as absent (`[{...}]` instead of `[: {...}]`). Re-entry `WHERE` secondary-alias property predicates (`MATCH ... WITH a, b ... MATCH ... WHERE b.x = ...`) are now rewritten when the predicate has no `expr_tree` by synthesizing WHERE expression text from structured predicates and clearing consumed predicates in `cypher/lowering.py` to avoid stale dual representation. Carries upstream prerequisites #1071 (multi-alias carry through `WITH` before MATCH re-entry) and #1236 (OR/XOR-around-pattern WHERE lowering) needed for current TCK contract behavior. Adds regressions in `test_row_pipeline_ops.py` covering string escaping, float normalization, null-only properties, missing/empty `type`, multi-edge rows, `select`/`return` parity, node-alias misclassification guard, and bare-alias `id` parity; adds `test_lowering.py` regressions for re-entry WHERE rewrites on secondary aliases and OR/XOR-around carried+trailing alias property paths (#1072).
5556
- **GFQL / comparison predicates**: Mixed-type scalar comparisons in Cypher `WHERE` execution (`>`, `<`, `>=`, `<=`) now preserve null-safe filter semantics across pandas and cuDF backends instead of failing whole-series evaluation on incomparable rows. Comparable rows keep backend-native ordering; incomparable/null rows evaluate non-matching (`False`) (#1219, #1223).
5657
- **GFQL / predicate pushdown**: Fixed a silent `\b` regex bug in `_refs_for_segment` (`graphistry/compute/gfql/passes/predicate_pushdown.py`). The rf-string `rf"\\b..."` produced a literal backslash-b sequence, not a word-boundary assertion, so per-conjunct alias detection never matched and always fell back to the full original reference set — widening reference sets for every split conjunct and preventing some safe-pushable conjuncts from being recognized. Tests passed because the fallback is a superset. Also consolidated two duplicate "split WHERE body on top-level AND" implementations (one in `parser.py`, one in `predicate_pushdown.py`) into a shared helper `graphistry/compute/gfql/expr_split.py::split_top_level_and` with strictly quote/bracket/paren/backtick-aware splitting. Adds 20 direct unit tests for the shared helper and one regression test locking the `\b` fix (#1195, #1198).
5758
- **GFQL / Cypher binder**: Replaced fragile regex-based WHERE label narrowing fallback in `_apply_where_label_narrowing` with AST-derived narrowing. `generic_where_clause` now lifts AND-joined bare label predicates (`WHERE n:Admin AND n:Active`) to structured `WhereClause.predicates` using the existing quote/bracket/paren/backtick-aware `_split_top_level_and_terms` helper; string-literal false-matches (e.g. `WHERE n.name = 'n:Admin'` incorrectly narrowing alias `n`) are closed by `fullmatch` anchoring. Removes `_WHERE_LABEL_RE` and `_WHERE_NON_CONJUNCTIVE_RE` from `binder.py`. Adds 10 targeted tests covering single/double/triple AND, multi-alias, multi-label-per-alias, lowercase `and`, XOR/OR/NOT conservative non-narrowing, mixed label+property all-or-nothing, and string-literal false-positive guards (#1125, #1193).

graphistry/compute/gfql/cypher/lowering.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5874,9 +5874,24 @@ def _where_clause_expr_text(where: WhereClause) -> Optional[ExpressionText]:
58745874
derived from the same ``_span_from_meta(meta)`` of the
58755875
``generic_where_clause`` rule) to preserve error-position semantics.
58765876
"""
5877-
if where.expr_tree is None:
5878-
return None
5879-
return ExpressionText(text=boolean_expr_to_text(where.expr_tree), span=where.span)
5877+
if where.expr_tree is not None:
5878+
return ExpressionText(text=boolean_expr_to_text(where.expr_tree), span=where.span)
5879+
5880+
# Structured-predicate WHERE clauses can still require downstream rewrite
5881+
# (for example, multi-alias re-entry secondary carry demotion). Synthesize
5882+
# equivalent row-expression text when all predicates are row-renderable.
5883+
if where.predicates:
5884+
predicate_texts: List[str] = []
5885+
for predicate in where.predicates:
5886+
if not isinstance(predicate, WherePredicate):
5887+
return None
5888+
row_text = _row_where_predicate_text(predicate)
5889+
if row_text is None:
5890+
return None
5891+
predicate_texts.append(row_text)
5892+
if predicate_texts:
5893+
return ExpressionText(text=" and ".join(predicate_texts), span=where.span)
5894+
return None
58805895

58815896

58825897
def _rewrite_where_clause_and_resync(
@@ -5907,7 +5922,10 @@ def _rewrite_where_clause_and_resync(
59075922
atom_text=rewritten.text,
59085923
atom_span=rewritten.span,
59095924
)
5910-
return replace(where, expr_tree=new_tree)
5925+
rewritten_where = replace(where, expr_tree=new_tree)
5926+
if where.expr_tree is None and where.predicates:
5927+
rewritten_where = replace(rewritten_where, predicates=())
5928+
return rewritten_where
59115929

59125930

59135931
def _extract_relationship_type_where(

graphistry/compute/gfql/cypher/result_postprocess.py

Lines changed: 14 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@
1616
LOCALTIME_CALL_TEXT_RE,
1717
TIME_CALL_TEXT_RE,
1818
)
19-
from graphistry.compute.gfql.row.entity_props import edge_property_columns, node_property_columns
19+
from graphistry.compute.gfql.row.entity_props import (
20+
append_property_segments as shared_append_property_segments,
21+
edge_property_columns,
22+
format_edge_entity_text as shared_format_edge_entity_text,
23+
node_property_columns,
24+
render_scalar_value_text as shared_render_scalar_value_text,
25+
)
2026
from graphistry.compute.gfql.row.pipeline import _RowPipelineAdapter
2127

2228

@@ -270,61 +276,15 @@ def _format(series: SeriesT) -> SeriesT:
270276

271277

272278
def _render_scalar_value_text(df: DataFrameT, alias_col: str, series: SeriesT) -> SeriesT:
273-
text = _object_text(cast(SeriesT, series.astype(str)))
274-
dtype_txt = str(getattr(series, "dtype", "")).lower()
275-
if "bool" in dtype_txt and hasattr(text, "str"):
276-
return cast(SeriesT, text.str.lower())
277-
if "float" in dtype_txt and hasattr(text, "str"):
278-
return _normalize_scientific_numeric_text(text)
279-
if any(token in dtype_txt for token in ("int", "double", "decimal")):
280-
return text
281-
if hasattr(text, "str"):
282-
stripped = cast(SeriesT, text.str.strip())
283-
non_null = cast(SeriesT, ~_is_null_mask(series))
284-
list_like = cast(SeriesT, series_str_match(stripped, r"^\[.*\]$", na=False))
285-
if _all_non_null_match(list_like, non_null):
286-
return stripped
287-
map_like = cast(SeriesT, series_str_match(stripped, r"^\{.*\}$", na=False))
288-
if _all_non_null_match(map_like, non_null):
289-
return stripped
290-
temporal = _normalize_temporal_constructor_series(df, alias_col, series, text, quoted=True)
291-
if temporal is not None:
292-
return temporal
293-
non_null = cast(SeriesT, ~_is_null_mask(series))
294-
bool_like = cast(SeriesT, series_str_match(text, r"^(True|False)$", na=False))
295-
num_like = cast(SeriesT, series_str_match(text, r"^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$", na=False))
296-
if hasattr(bool_like, "where") and bool(bool_like.where(non_null, True).all()):
297-
return cast(SeriesT, text.str.lower())
298-
if hasattr(num_like, "where") and bool(num_like.where(non_null, True).all()):
299-
return _normalize_scientific_numeric_text(text)
300-
out = _quote_text_series(df, alias_col, text)
301-
out = cast(SeriesT, out.where(~bool_like, text.str.lower()))
302-
out = cast(SeriesT, out.where(~num_like, text.str.replace(r"\.0+$", "", regex=True)))
303-
out = cast(SeriesT, out.where(~list_like, stripped))
304-
out = cast(SeriesT, out.where(~map_like, stripped))
305-
return out
306-
if hasattr(text, "str"):
307-
return _quote_text_series(df, alias_col, text)
308-
return cast(SeriesT, _const_text(df, alias_col, "'") + text + "'")
279+
return shared_render_scalar_value_text(df, alias_col, series)
309280

310281

311282
def _append_property_segments(
312283
df: DataFrameT,
313284
alias_col: str,
314285
property_columns: Sequence[str],
315286
) -> tuple[SeriesT, SeriesT]:
316-
text = _empty_text(df, alias_col)
317-
has_props = _false_mask(df, alias_col)
318-
for col in property_columns:
319-
series = cast(SeriesT, df[col])
320-
include = cast(SeriesT, ~_is_null_mask(series))
321-
value_text = _render_scalar_value_text(df, alias_col, series)
322-
segment = _const_text(df, alias_col, f"{col}: ") + value_text
323-
prefix = cast(SeriesT, _const_text(df, alias_col, ", ").where(has_props & include, ""))
324-
append = cast(SeriesT, (prefix + segment).where(include, ""))
325-
text = cast(SeriesT, text + append)
326-
has_props = cast(SeriesT, has_props | include)
327-
return text, has_props
287+
return shared_append_property_segments(df, alias_col, property_columns)
328288

329289

330290
def _node_label_text(df: DataFrameT, alias_col: str) -> SeriesT:
@@ -377,25 +337,13 @@ def _format_node_entities(df: DataFrameT, projection: ResultProjectionPlan) -> S
377337

378338
def _format_edge_entities(df: DataFrameT, projection: ResultProjectionPlan) -> SeriesT:
379339
alias_col = projection.alias
380-
if "type" in df.columns:
381-
type_series = cast(SeriesT, df["type"])
382-
type_text = _object_text(cast(SeriesT, type_series.astype(str)))
383-
type_part = cast(SeriesT, (_const_text(df, alias_col, ":") + type_text).where(~_is_null_mask(type_series), ""))
384-
else:
385-
type_part = _empty_text(df, alias_col)
386-
prop_text, has_props = _append_property_segments(
340+
return shared_format_edge_entity_text(
387341
df,
388-
alias_col,
389-
_edge_property_columns(df, alias_col, projection.exclude_columns),
390-
)
391-
type_present = cast(SeriesT, type_part != "")
392-
prop_block = cast(SeriesT, _const_text(df, alias_col, "{") + prop_text + "}")
393-
prop_suffix = cast(
394-
SeriesT,
395-
(_const_text(df, alias_col, " ").where(has_props & type_present, "") + prop_block).where(has_props, ""),
342+
alias_col=alias_col,
343+
property_columns=_edge_property_columns(df, alias_col, projection.exclude_columns),
344+
type_col="type",
345+
nullify_missing_alias_rows=True,
396346
)
397-
rendered = cast(SeriesT, _const_text(df, alias_col, "[") + type_part + prop_suffix + "]")
398-
return _nullify_missing_alias_rows(df, alias_col, rendered)
399347

400348

401349
def _project_property_column(

0 commit comments

Comments
 (0)