From ef463a12b99dd77e1b8361c4f6d1458d4bcd15fc Mon Sep 17 00:00:00 2001 From: Codex Worker Date: Sun, 25 Oct 2026 17:15:52 -0700 Subject: [PATCH 1/6] fix(gfql): validate statically invalid conversion operands --- graphistry/compute/gfql/cypher/lowering.py | 108 +++++++++--------- .../compute/gfql/test_unary_op_surface.py | 50 ++++++++ 2 files changed, 106 insertions(+), 52 deletions(-) diff --git a/graphistry/compute/gfql/cypher/lowering.py b/graphistry/compute/gfql/cypher/lowering.py index 0d02cf9440..3017713bda 100644 --- a/graphistry/compute/gfql/cypher/lowering.py +++ b/graphistry/compute/gfql/cypher/lowering.py @@ -1009,39 +1009,29 @@ def _raise_invalid(op_name: str) -> None: ) -def _list_item_invalid_for_numeric_conversion( - item: ExprNode, +def _conversion_operand_statically_invalid( + function_name: str, + operand: ExprNode, *, alias_targets: Optional[Mapping[str, ASTObject]], - allow_boolean: bool, ) -> bool: - if isinstance(item, ExprLiteral): - if isinstance(item.value, (list, dict)): - return True - return isinstance(item.value, bool) and not allow_boolean - if isinstance(item, (ListLiteral, MapLiteral)): + name = function_name.lower() + if isinstance(operand, (ListLiteral, MapLiteral)): return True - if isinstance(item, Identifier): - return alias_targets is not None and item.name in alias_targets - if isinstance(item, FunctionCall): - return item.name in {"__node_entity__", "__edge_entity__"} + if isinstance(operand, ExprLiteral): + if isinstance(operand.value, (list, dict)): + return True + return name == "tofloat" and isinstance(operand.value, bool) + if isinstance(operand, Identifier): + return alias_targets is not None and operand.name in alias_targets + if isinstance(operand, FunctionCall): + return operand.name in {"__node_entity__", "__edge_entity__"} return False -def _list_item_invalid_for_tostring( - item: ExprNode, - *, - alias_targets: Optional[Mapping[str, ASTObject]], -) -> bool: - if isinstance(item, ExprLiteral): - return isinstance(item.value, (list, dict)) - if isinstance(item, (ListLiteral, MapLiteral)): - return True - if isinstance(item, Identifier): - return alias_targets is not None and item.name in alias_targets - if isinstance(item, FunctionCall): - return item.name in {"__node_entity__", "__edge_entity__"} - return False +_CYPHER_CONVERSION_FUNCTIONS = frozenset( + {"tointeger", "tofloat", "toboolean", "tostring"} +) def _contains_aggregate_call(node: ExprNode) -> bool: @@ -1096,37 +1086,51 @@ def _enter(current: ExprNode) -> None: _raise("Cypher list indexing requires integer keys in the local compiler") if isinstance(current, MapLiteral) and _contains_aggregate_call(current): _raise("Cypher aggregate expressions inside map literals are not supported in the local compiler yet") + if ( + isinstance(current, FunctionCall) + and current.name.lower() in _CYPHER_CONVERSION_FUNCTIONS + and len(current.args) == 1 + and _conversion_operand_statically_invalid( + current.name, + current.args[0], + alias_targets=alias_targets, + ) + ): + _raise( + f"Cypher {current.name}() cannot consume the statically invalid operand type" + ) if isinstance(current, ListComprehension): if current.predicate is not None and _contains_aggregate_call(current.predicate): _raise("Cypher list comprehensions cannot contain aggregate functions in the local compiler") if current.projection is not None and _contains_aggregate_call(current.projection): _raise("Cypher list comprehensions cannot contain aggregate functions in the local compiler") - if ( - isinstance(current.projection, FunctionCall) - and len(current.projection.args) == 1 - and isinstance(current.projection.args[0], Identifier) - and current.projection.args[0].name == current.var - and isinstance(current.source, ListLiteral) - ): - if current.projection.name.lower() in {"tofloat", "tointeger"}: - if any( - _list_item_invalid_for_numeric_conversion( - item, - alias_targets=alias_targets, - allow_boolean=current.projection.name.lower() == "tointeger", - ) - for item in current.source.items - ): - _raise( - "Cypher toFloat()/toInteger() cannot consume the current " - "list-comprehension item types in the local compiler" - ) - if current.projection.name == "tostring": - if any( - _list_item_invalid_for_tostring(item, alias_targets=alias_targets) - for item in current.source.items - ): - _raise("Cypher toString() cannot consume the current list-comprehension item types in the local compiler") + if current.projection is not None and isinstance(current.source, ListLiteral): + projection_nodes: List[ExprNode] = [] + walk_expr_nodes(current.projection, enter=projection_nodes.append) + for projection_call in projection_nodes: + if not isinstance(projection_call, FunctionCall) or projection_call.name.lower() not in _CYPHER_CONVERSION_FUNCTIONS: + continue + for conversion_arg in projection_call.args: + arg_consumes_var = False + + def _mark_arg_consumption(arg_node: ExprNode) -> None: + nonlocal arg_consumes_var + if isinstance(arg_node, Identifier) and arg_node.name == current.var: + arg_consumes_var = True + + walk_expr_nodes(conversion_arg, enter=_mark_arg_consumption) + if arg_consumes_var and any( + _conversion_operand_statically_invalid( + projection_call.name, + item, + alias_targets=alias_targets, + ) + for item in current.source.items + ): + _raise( + f"Cypher {projection_call.name}() cannot consume the current " + "list-comprehension item type in the local compiler" + ) walk_expr_nodes(node, enter=_enter) diff --git a/graphistry/tests/compute/gfql/test_unary_op_surface.py b/graphistry/tests/compute/gfql/test_unary_op_surface.py index 28f65b6ff7..77398f65eb 100644 --- a/graphistry/tests/compute/gfql/test_unary_op_surface.py +++ b/graphistry/tests/compute/gfql/test_unary_op_surface.py @@ -564,3 +564,53 @@ def test_numeric_comprehension_invalid_consumed_items_still_raise(engine): with pytest.raises(GFQLValidationError) as error: _graph(engine).gfql(f"MATCH (n) RETURN {expression} AS v", engine=engine) assert error.value.code == ErrorCode.E108 + + +@pytest.mark.parametrize("expression", [ + "toInteger([])", + "toInteger({a: 1})", + "toFloat([])", + "toFloat({a: 1})", + "toFloat(true)", + "toBoolean([])", + "toBoolean({a: 1})", + "toString([])", + "toString({a: 1})", +]) +def test_conversion_rejects_statically_invalid_direct_literals(expression): + """Validation happens before an engine sees a provably unsupported conversion.""" + with pytest.raises(GFQLValidationError) as error: + graphistry.nodes(pd.DataFrame({"id": ["a"]})).gfql( + f"MATCH (n) RETURN {expression} AS v", language="cypher" + ) + assert error.value.code == ErrorCode.E108 + + +@pytest.mark.parametrize("function", ["toInteger", "toFloat", "toBoolean", "toString"]) +def test_conversion_rejects_statically_invalid_bound_parameters(function): + """Compile-time parameter folding participates in the same static type check.""" + with pytest.raises(GFQLValidationError) as error: + graphistry.nodes(pd.DataFrame({"id": ["a"]})).gfql( + f"MATCH (n) RETURN {function}($value) AS v", + params={"value": []}, + language="cypher", + ) + assert error.value.code == ErrorCode.E108 + + +@pytest.mark.parametrize("engine", ENGINES) +@pytest.mark.parametrize("expression,expected", [ + ("toInteger('41')", 41), + ("toFloat(2)", 2.0), + ("toBoolean(true)", True), + ("toString(2)", "2"), +]) +def test_conversion_valid_literal_executes_across_engines(engine, expression, expected): + """Each conversion keeps at least one statically valid scalar execution path.""" + assert _values(engine, expression) == [expected] * 4 + + +@pytest.mark.parametrize("engine", ["pandas"]) +def test_conversion_unconsumed_invalid_comprehension_items_compile(engine): + """The source item is not an operand when the projection ignores it.""" + assert _values(engine, "[x IN [1, []] | toString('x')]") == [["x", "x"]] * 4 From 6cda4d8bcffb8b670933b287d9377256a02c5345 Mon Sep 17 00:00:00 2001 From: Codex Worker Date: Sun, 25 Oct 2026 17:23:04 -0700 Subject: [PATCH 2/6] style(gfql): fit conversion validator line guard --- graphistry/compute/gfql/cypher/lowering.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/graphistry/compute/gfql/cypher/lowering.py b/graphistry/compute/gfql/cypher/lowering.py index 3017713bda..2f6bf6cf36 100644 --- a/graphistry/compute/gfql/cypher/lowering.py +++ b/graphistry/compute/gfql/cypher/lowering.py @@ -1029,9 +1029,7 @@ def _conversion_operand_statically_invalid( return False -_CYPHER_CONVERSION_FUNCTIONS = frozenset( - {"tointeger", "tofloat", "toboolean", "tostring"} -) +_CYPHER_CONVERSION_FUNCTIONS = frozenset({"tointeger", "tofloat", "toboolean", "tostring"}) def _contains_aggregate_call(node: ExprNode) -> bool: @@ -1096,9 +1094,7 @@ def _enter(current: ExprNode) -> None: alias_targets=alias_targets, ) ): - _raise( - f"Cypher {current.name}() cannot consume the statically invalid operand type" - ) + _raise(f"Cypher {current.name}() cannot consume the statically invalid operand type") if isinstance(current, ListComprehension): if current.predicate is not None and _contains_aggregate_call(current.predicate): _raise("Cypher list comprehensions cannot contain aggregate functions in the local compiler") From 86a2f384812a361e70ad6ba3e5d0c724bd3753d7 Mon Sep 17 00:00:00 2001 From: Codex Worker Date: Sun, 25 Oct 2026 17:36:19 -0700 Subject: [PATCH 3/6] fix(gfql): respect lexical binding in conversion validation --- CHANGELOG.md | 2 + graphistry/compute/gfql/cypher/lowering.py | 68 +++++++++---------- .../compute/gfql/test_unary_op_surface.py | 16 +++++ 3 files changed, 52 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e7bbd77e5..557df1fa6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed +- **GFQL Cypher conversion operand validation**: statically invalid conversion operands, such as lists, maps, and graph entities, are rejected at compile time before the engine sees them. Dynamic operands, null propagation, and list-comprehension lexical shadowing keep their existing behavior. + - **GFQL remote Let/DAG compatibility**: `gfql_remote` no longer substitutes an empty `gfql_operations` array for a Let/DAG query, so unsupported older servers receive `gfql_query` and reject it explicitly instead of silently running an identity program. - **GFQL temporal scalar filters**: an empty timestamp or duration string no longer silently becomes a `NaT` bound; it raises the same typed `GFQLSchemaError` E302 as an unparseable bound. Naive bounds are handled as `Timestamp` values before timezone localization, and optional `pytz` exception discovery is confined to aware-column localization. - **GFQL Cypher null-equality conjunctions**: comparisons such as `n.v = null` now preserve three-valued `UNKNOWN` when combined with `AND`, instead of being replaced as if no predicate existed. `NeverMatch` predicates now round-trip through GFQL JSON, and raw JSON `filter_dict` null matching is unchanged. diff --git a/graphistry/compute/gfql/cypher/lowering.py b/graphistry/compute/gfql/cypher/lowering.py index 2f6bf6cf36..dbb2bf043f 100644 --- a/graphistry/compute/gfql/cypher/lowering.py +++ b/graphistry/compute/gfql/cypher/lowering.py @@ -97,6 +97,7 @@ _rebuild_expr_node, collect_identifiers, parse_expr, + iter_expr_children, walk_expr_nodes, is_expr_node, ) @@ -1014,6 +1015,7 @@ def _conversion_operand_statically_invalid( operand: ExprNode, *, alias_targets: Optional[Mapping[str, ASTObject]], + ignored_aliases: AbstractSet[str] = frozenset(), ) -> bool: name = function_name.lower() if isinstance(operand, (ListLiteral, MapLiteral)): @@ -1023,7 +1025,7 @@ def _conversion_operand_statically_invalid( return True return name == "tofloat" and isinstance(operand.value, bool) if isinstance(operand, Identifier): - return alias_targets is not None and operand.name in alias_targets + return alias_targets is not None and operand.name in alias_targets and operand.name not in ignored_aliases if isinstance(operand, FunctionCall): return operand.name in {"__node_entity__", "__edge_entity__"} return False @@ -1031,6 +1033,9 @@ def _conversion_operand_statically_invalid( _CYPHER_CONVERSION_FUNCTIONS = frozenset({"tointeger", "tofloat", "toboolean", "tostring"}) +def _expr_references(node: ExprNode, name: str) -> bool: + return isinstance(node, Identifier) and node.name == name or any(_expr_references(child, name) for child in iter_expr_children(node)) + def _contains_aggregate_call(node: ExprNode) -> bool: found = False @@ -1084,6 +1089,14 @@ def _enter(current: ExprNode) -> None: _raise("Cypher list indexing requires integer keys in the local compiler") if isinstance(current, MapLiteral) and _contains_aggregate_call(current): _raise("Cypher aggregate expressions inside map literals are not supported in the local compiler yet") + if isinstance(current, ListComprehension): + if current.predicate is not None and _contains_aggregate_call(current.predicate): + _raise("Cypher list comprehensions cannot contain aggregate functions in the local compiler") + if current.projection is not None and _contains_aggregate_call(current.projection): + _raise("Cypher list comprehensions cannot contain aggregate functions in the local compiler") + walk_expr_nodes(node, enter=_enter) + + def _validate_conversion_scope(current: ExprNode, bound_aliases: AbstractSet[str]) -> None: if ( isinstance(current, FunctionCall) and current.name.lower() in _CYPHER_CONVERSION_FUNCTIONS @@ -1092,43 +1105,30 @@ def _enter(current: ExprNode) -> None: current.name, current.args[0], alias_targets=alias_targets, + ignored_aliases=bound_aliases, ) ): _raise(f"Cypher {current.name}() cannot consume the statically invalid operand type") - if isinstance(current, ListComprehension): - if current.predicate is not None and _contains_aggregate_call(current.predicate): - _raise("Cypher list comprehensions cannot contain aggregate functions in the local compiler") - if current.projection is not None and _contains_aggregate_call(current.projection): - _raise("Cypher list comprehensions cannot contain aggregate functions in the local compiler") - if current.projection is not None and isinstance(current.source, ListLiteral): - projection_nodes: List[ExprNode] = [] - walk_expr_nodes(current.projection, enter=projection_nodes.append) - for projection_call in projection_nodes: - if not isinstance(projection_call, FunctionCall) or projection_call.name.lower() not in _CYPHER_CONVERSION_FUNCTIONS: - continue - for conversion_arg in projection_call.args: - arg_consumes_var = False - - def _mark_arg_consumption(arg_node: ExprNode) -> None: - nonlocal arg_consumes_var - if isinstance(arg_node, Identifier) and arg_node.name == current.var: - arg_consumes_var = True - - walk_expr_nodes(conversion_arg, enter=_mark_arg_consumption) - if arg_consumes_var and any( - _conversion_operand_statically_invalid( - projection_call.name, - item, - alias_targets=alias_targets, - ) - for item in current.source.items - ): - _raise( - f"Cypher {projection_call.name}() cannot consume the current " - "list-comprehension item type in the local compiler" - ) + if isinstance(current, (ListComprehension, QuantifierExpr)): + nested_aliases = bound_aliases | {current.var} + if isinstance(current, ListComprehension) and isinstance(current.source, ListLiteral): + projection = current.projection + projection_call = projection if isinstance(projection, FunctionCall) else None + if ( + projection_call is not None + and projection_call.name.lower() in _CYPHER_CONVERSION_FUNCTIONS + and len(projection_call.args) == 1 + and _expr_references(projection_call.args[0], current.var) + and any(_conversion_operand_statically_invalid(projection_call.name, item, alias_targets=alias_targets) for item in current.source.items) + ): + _raise(f"Cypher {projection_call.name}() cannot consume the current list-comprehension item type") + for child in iter_expr_children(current): + _validate_conversion_scope(child, nested_aliases) + else: + for child in iter_expr_children(current): + _validate_conversion_scope(child, bound_aliases) - walk_expr_nodes(node, enter=_enter) + _validate_conversion_scope(node, frozenset()) def _validate_with_projection_aliasing(stage: ProjectionStage) -> None: diff --git a/graphistry/tests/compute/gfql/test_unary_op_surface.py b/graphistry/tests/compute/gfql/test_unary_op_surface.py index 77398f65eb..5be5df0ade 100644 --- a/graphistry/tests/compute/gfql/test_unary_op_surface.py +++ b/graphistry/tests/compute/gfql/test_unary_op_surface.py @@ -614,3 +614,19 @@ def test_conversion_valid_literal_executes_across_engines(engine, expression, ex def test_conversion_unconsumed_invalid_comprehension_items_compile(engine): """The source item is not an operand when the projection ignores it.""" assert _values(engine, "[x IN [1, []] | toString('x')]") == [["x", "x"]] * 4 + + +@pytest.mark.parametrize("engine", ["pandas"]) +@pytest.mark.parametrize("expression,expected", [ + ("[n IN [1] | toInteger(n)]", [[1]]), + ("[n IN ['x'] | toString(n)]", [["x"]]), + ("[x IN [1, []] | [x IN [1] | toInteger(x)]]", [[[1], [1]]]), +]) +def test_conversion_respects_comprehension_lexical_binding(engine, expression, expected): + """Comprehension variables shadow graph aliases; graph aliases remain rejected.""" + assert _values(engine, expression) == expected * 4 + with pytest.raises(GFQLValidationError) as error: + _graph(engine).gfql( + "MATCH (n) RETURN [x IN [1] | toInteger(n)] AS v", engine=engine + ) + assert error.value.code == ErrorCode.E108 From 4f0b88ffd4b78a85e1f3b21cc78fc7afc9cb89e7 Mon Sep 17 00:00:00 2001 From: Codex Worker Date: Sun, 25 Oct 2026 18:04:48 -0700 Subject: [PATCH 4/6] fix: scope conversion item validation lexically --- graphistry/compute/gfql/cypher/lowering.py | 57 +++++++++++++------ .../compute/gfql/test_unary_op_surface.py | 22 +++++++ 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/graphistry/compute/gfql/cypher/lowering.py b/graphistry/compute/gfql/cypher/lowering.py index dbb2bf043f..93687ecc9c 100644 --- a/graphistry/compute/gfql/cypher/lowering.py +++ b/graphistry/compute/gfql/cypher/lowering.py @@ -1016,6 +1016,7 @@ def _conversion_operand_statically_invalid( *, alias_targets: Optional[Mapping[str, ASTObject]], ignored_aliases: AbstractSet[str] = frozenset(), + invalid_aliases: AbstractSet[Tuple[str, str]] = frozenset(), ) -> bool: name = function_name.lower() if isinstance(operand, (ListLiteral, MapLiteral)): @@ -1025,6 +1026,8 @@ def _conversion_operand_statically_invalid( return True return name == "tofloat" and isinstance(operand.value, bool) if isinstance(operand, Identifier): + if (operand.name, name) in invalid_aliases: + return True return alias_targets is not None and operand.name in alias_targets and operand.name not in ignored_aliases if isinstance(operand, FunctionCall): return operand.name in {"__node_entity__", "__edge_entity__"} @@ -1033,8 +1036,23 @@ def _conversion_operand_statically_invalid( _CYPHER_CONVERSION_FUNCTIONS = frozenset({"tointeger", "tofloat", "toboolean", "tostring"}) -def _expr_references(node: ExprNode, name: str) -> bool: - return isinstance(node, Identifier) and node.name == name or any(_expr_references(child, name) for child in iter_expr_children(node)) +def _expr_references(node: ExprNode, name: str, ignored_aliases: AbstractSet[str] = frozenset()) -> bool: + if isinstance(node, Identifier): + return node.name == name and node.name not in ignored_aliases + if isinstance(node, ListComprehension): + if _expr_references(node.source, name, ignored_aliases): + return True + body_aliases = ignored_aliases | {node.var} + body = (node.predicate, node.projection) + return any( + child is not None and _expr_references(child, name, body_aliases) + for child in body + ) + if isinstance(node, QuantifierExpr): + if _expr_references(node.source, name, ignored_aliases): + return True + return _expr_references(node.predicate, name, ignored_aliases | {node.var}) + return any(_expr_references(child, name, ignored_aliases) for child in iter_expr_children(node)) def _contains_aggregate_call(node: ExprNode) -> bool: @@ -1096,7 +1114,7 @@ def _enter(current: ExprNode) -> None: _raise("Cypher list comprehensions cannot contain aggregate functions in the local compiler") walk_expr_nodes(node, enter=_enter) - def _validate_conversion_scope(current: ExprNode, bound_aliases: AbstractSet[str]) -> None: + def _validate_conversion_scope(current: ExprNode, bound_aliases: AbstractSet[str], invalid_aliases: AbstractSet[Tuple[str, str]]) -> None: if ( isinstance(current, FunctionCall) and current.name.lower() in _CYPHER_CONVERSION_FUNCTIONS @@ -1106,29 +1124,36 @@ def _validate_conversion_scope(current: ExprNode, bound_aliases: AbstractSet[str current.args[0], alias_targets=alias_targets, ignored_aliases=bound_aliases, + invalid_aliases=invalid_aliases, ) ): _raise(f"Cypher {current.name}() cannot consume the statically invalid operand type") if isinstance(current, (ListComprehension, QuantifierExpr)): nested_aliases = bound_aliases | {current.var} + nested_invalid_aliases = { + (variable, conversion_function) + for variable, conversion_function in invalid_aliases + if variable != current.var + } if isinstance(current, ListComprehension) and isinstance(current.source, ListLiteral): - projection = current.projection - projection_call = projection if isinstance(projection, FunctionCall) else None - if ( - projection_call is not None - and projection_call.name.lower() in _CYPHER_CONVERSION_FUNCTIONS - and len(projection_call.args) == 1 - and _expr_references(projection_call.args[0], current.var) - and any(_conversion_operand_statically_invalid(projection_call.name, item, alias_targets=alias_targets) for item in current.source.items) - ): - _raise(f"Cypher {projection_call.name}() cannot consume the current list-comprehension item type") + for function_name in _CYPHER_CONVERSION_FUNCTIONS: + if any( + _conversion_operand_statically_invalid( + function_name, + item, + alias_targets=alias_targets, + ignored_aliases=bound_aliases, + ) + for item in current.source.items + ): + nested_invalid_aliases = nested_invalid_aliases | {(current.var, function_name)} for child in iter_expr_children(current): - _validate_conversion_scope(child, nested_aliases) + _validate_conversion_scope(child, nested_aliases, nested_invalid_aliases) else: for child in iter_expr_children(current): - _validate_conversion_scope(child, bound_aliases) + _validate_conversion_scope(child, bound_aliases, invalid_aliases) - _validate_conversion_scope(node, frozenset()) + _validate_conversion_scope(node, frozenset(), frozenset()) def _validate_with_projection_aliasing(stage: ProjectionStage) -> None: diff --git a/graphistry/tests/compute/gfql/test_unary_op_surface.py b/graphistry/tests/compute/gfql/test_unary_op_surface.py index 5be5df0ade..d89fe880bc 100644 --- a/graphistry/tests/compute/gfql/test_unary_op_surface.py +++ b/graphistry/tests/compute/gfql/test_unary_op_surface.py @@ -630,3 +630,25 @@ def test_conversion_respects_comprehension_lexical_binding(engine, expression, e "MATCH (n) RETURN [x IN [1] | toInteger(n)] AS v", engine=engine ) assert error.value.code == ErrorCode.E108 + + +@pytest.mark.parametrize("engine", ["pandas"]) +def test_conversion_ignores_shadowed_outer_item_in_nested_argument(engine): + """An inner comprehension binding hides the outer item, even in nested arguments.""" + expression = "[x IN [1, []] | toString([x IN [1] | x])]" + assert _values(engine, expression) == [["[1]", "[1]"]] * 4 + + +@pytest.mark.parametrize("engine", ["pandas"]) +@pytest.mark.parametrize("function,expected", [ + ("toInteger", 1), + ("toFloat", 1.0), + ("toBoolean", True), + ("toString", "1"), +]) +def test_conversion_binding_is_lexical_for_nested_variables(engine, function, expected): + """A differently named nested binding leaves the outer comprehension item visible.""" + expression = f"[x IN [1, []] | [y IN ['1'] | {function}(x)]]" + with pytest.raises(GFQLValidationError) as error: + _graph(engine).gfql(f"MATCH (n) RETURN {expression} AS v", engine=engine) + assert error.value.code == ErrorCode.E108 From d9b823204de1448a6bbec47b09abeb9eafa05c6a Mon Sep 17 00:00:00 2001 From: Codex Worker Date: Sun, 25 Oct 2026 18:13:11 -0700 Subject: [PATCH 5/6] fix: preserve lowering surface guard --- graphistry/compute/gfql/cypher/lowering.py | 42 +++++----------------- 1 file changed, 8 insertions(+), 34 deletions(-) diff --git a/graphistry/compute/gfql/cypher/lowering.py b/graphistry/compute/gfql/cypher/lowering.py index 93687ecc9c..bf76786d64 100644 --- a/graphistry/compute/gfql/cypher/lowering.py +++ b/graphistry/compute/gfql/cypher/lowering.py @@ -1022,9 +1022,7 @@ def _conversion_operand_statically_invalid( if isinstance(operand, (ListLiteral, MapLiteral)): return True if isinstance(operand, ExprLiteral): - if isinstance(operand.value, (list, dict)): - return True - return name == "tofloat" and isinstance(operand.value, bool) + return isinstance(operand.value, (list, dict)) or (name == "tofloat" and isinstance(operand.value, bool)) if isinstance(operand, Identifier): if (operand.name, name) in invalid_aliases: return True @@ -1036,25 +1034,6 @@ def _conversion_operand_statically_invalid( _CYPHER_CONVERSION_FUNCTIONS = frozenset({"tointeger", "tofloat", "toboolean", "tostring"}) -def _expr_references(node: ExprNode, name: str, ignored_aliases: AbstractSet[str] = frozenset()) -> bool: - if isinstance(node, Identifier): - return node.name == name and node.name not in ignored_aliases - if isinstance(node, ListComprehension): - if _expr_references(node.source, name, ignored_aliases): - return True - body_aliases = ignored_aliases | {node.var} - body = (node.predicate, node.projection) - return any( - child is not None and _expr_references(child, name, body_aliases) - for child in body - ) - if isinstance(node, QuantifierExpr): - if _expr_references(node.source, name, ignored_aliases): - return True - return _expr_references(node.predicate, name, ignored_aliases | {node.var}) - return any(_expr_references(child, name, ignored_aliases) for child in iter_expr_children(node)) - - def _contains_aggregate_call(node: ExprNode) -> bool: found = False def _enter(current: ExprNode) -> None: @@ -1130,23 +1109,18 @@ def _validate_conversion_scope(current: ExprNode, bound_aliases: AbstractSet[str _raise(f"Cypher {current.name}() cannot consume the statically invalid operand type") if isinstance(current, (ListComprehension, QuantifierExpr)): nested_aliases = bound_aliases | {current.var} - nested_invalid_aliases = { - (variable, conversion_function) - for variable, conversion_function in invalid_aliases - if variable != current.var - } + nested_invalid_aliases = {(alias, function) for alias, function in invalid_aliases if alias != current.var} if isinstance(current, ListComprehension) and isinstance(current.source, ListLiteral): - for function_name in _CYPHER_CONVERSION_FUNCTIONS: + nested_invalid_aliases |= { + (current.var, function_name) + for function_name in _CYPHER_CONVERSION_FUNCTIONS if any( _conversion_operand_statically_invalid( - function_name, - item, - alias_targets=alias_targets, - ignored_aliases=bound_aliases, + function_name, item, alias_targets=alias_targets, ignored_aliases=bound_aliases ) for item in current.source.items - ): - nested_invalid_aliases = nested_invalid_aliases | {(current.var, function_name)} + ) + } for child in iter_expr_children(current): _validate_conversion_scope(child, nested_aliases, nested_invalid_aliases) else: From 1bd503099aa602d9ff5fdcbf7b9c83b343a7216d Mon Sep 17 00:00:00 2001 From: Codex Worker Date: Sun, 25 Oct 2026 18:22:16 -0700 Subject: [PATCH 6/6] fix: scope comprehension conversion validation --- graphistry/compute/gfql/cypher/lowering.py | 3 ++- .../compute/gfql/test_unary_op_surface.py | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/graphistry/compute/gfql/cypher/lowering.py b/graphistry/compute/gfql/cypher/lowering.py index bf76786d64..3f8c371b7d 100644 --- a/graphistry/compute/gfql/cypher/lowering.py +++ b/graphistry/compute/gfql/cypher/lowering.py @@ -1108,6 +1108,7 @@ def _validate_conversion_scope(current: ExprNode, bound_aliases: AbstractSet[str ): _raise(f"Cypher {current.name}() cannot consume the statically invalid operand type") if isinstance(current, (ListComprehension, QuantifierExpr)): + _validate_conversion_scope(current.source, bound_aliases, invalid_aliases) nested_aliases = bound_aliases | {current.var} nested_invalid_aliases = {(alias, function) for alias, function in invalid_aliases if alias != current.var} if isinstance(current, ListComprehension) and isinstance(current.source, ListLiteral): @@ -1121,7 +1122,7 @@ def _validate_conversion_scope(current: ExprNode, bound_aliases: AbstractSet[str for item in current.source.items ) } - for child in iter_expr_children(current): + for child in (current.predicate,) if isinstance(current, QuantifierExpr) else cast(Tuple[ExprNode, ...], tuple(filter(None, (current.predicate, current.projection)))): _validate_conversion_scope(child, nested_aliases, nested_invalid_aliases) else: for child in iter_expr_children(current): diff --git a/graphistry/tests/compute/gfql/test_unary_op_surface.py b/graphistry/tests/compute/gfql/test_unary_op_surface.py index d89fe880bc..8aedb3c509 100644 --- a/graphistry/tests/compute/gfql/test_unary_op_surface.py +++ b/graphistry/tests/compute/gfql/test_unary_op_surface.py @@ -639,6 +639,33 @@ def test_conversion_ignores_shadowed_outer_item_in_nested_argument(engine): assert _values(engine, expression) == [["[1]", "[1]"]] * 4 +@pytest.mark.parametrize("engine", ["pandas"]) +def test_conversion_uses_outer_scope_for_nested_source(engine): + """A nested source is evaluated before its binding shadows the outer item.""" + for variable in ["x", "y"]: + expression = f"[x IN [1, []] | [{variable} IN [toInteger(x)] | {variable}]]" + with pytest.raises(GFQLValidationError) as error: + _graph(engine).gfql(f"MATCH (n) RETURN {expression} AS v", engine=engine) + assert error.value.code == ErrorCode.E108 + + +@pytest.mark.parametrize("engine", ["pandas"]) +def test_conversion_uses_outer_scope_for_nested_quantifier_source(engine): + """A quantifier source is also evaluated before its binding is introduced.""" + for variable in ["x", "y"]: + expression = f"[x IN [1, []] | any({variable} IN [toInteger(x)] WHERE {variable})]" + with pytest.raises(GFQLValidationError) as error: + _graph(engine).gfql(f"MATCH (n) RETURN {expression} AS v", engine=engine) + assert error.value.code == ErrorCode.E108 + + +@pytest.mark.parametrize("engine", ["pandas"]) +@pytest.mark.parametrize("source", ["[1]", "['1']"]) +def test_conversion_shadowed_source_still_runs_valid_body(engine, source): + """Binding a valid nested source preserves the shadowed comprehension body.""" + assert _values(engine, f"[x IN [1, []] | [x IN {source} | toInteger(x)]]") == [[[1], [1]]] * 4 + + @pytest.mark.parametrize("engine", ["pandas"]) @pytest.mark.parametrize("function,expected", [ ("toInteger", 1),