diff --git a/graphistry/compute/gfql_unified.py b/graphistry/compute/gfql_unified.py index d0a6f0603e..2b1fbe49aa 100644 --- a/graphistry/compute/gfql_unified.py +++ b/graphistry/compute/gfql_unified.py @@ -1815,6 +1815,24 @@ def gfql(self: Plottable, :returns: Resulting Plottable :rtype: Plottable """ + # engine inference: resolve_engine(AUTO) maps polars frames to PANDAS (polars predates + # Engine.POLARS there), silently bridging polars-frame graphs onto the generic pandas path + # (~13x slower on cypher point queries, pandas frames out). Route AUTO to the native polars + # engine instead; an honest NIE (unsupported shape) falls back to the legacy AUTO path, which + # is allowed here because the user did not pin an engine. + if ( + (engine == EngineAbstract.AUTO or engine == EngineAbstract.AUTO.value) + and is_polars_df(self._edges) and (self._nodes is None or is_polars_df(self._nodes)) + ): + try: + return gfql( + self, query, engine=Engine.POLARS.value, output=output, policy=policy, + where=where, language=language, params=params, validate=validate, + shortest_path_backend=shortest_path_backend, + ) + except NotImplementedError: + logger.debug('AUTO polars-native attempt declined; falling back to generic path') + context = ExecutionContext() if policy and context.policy_depth >= 1: diff --git a/graphistry/tests/compute/gfql/cypher/test_lowering.py b/graphistry/tests/compute/gfql/cypher/test_lowering.py index 093c727fcc..9de105e86f 100644 --- a/graphistry/tests/compute/gfql/cypher/test_lowering.py +++ b/graphistry/tests/compute/gfql/cypher/test_lowering.py @@ -15997,7 +15997,7 @@ def test_connected_join_polars_nullable_columns_match_master(predicate: str, exp query = f"MATCH (p)-[]->(x), (p)-[]->(y) WHERE {predicate} RETURN count(p) AS n" out = g.gfql(query)._nodes - records = out.to_dict(orient="records") if hasattr(out, "to_dict") else out.to_dicts() + records = out.to_dicts() if hasattr(out, "to_dicts") else out.to_dict(orient="records") assert records == expected @@ -16027,7 +16027,7 @@ def test_arrow_table_nodes_match_master_results() -> None: def run(predicate: str) -> Any: query = f"MATCH (p)-[]->(x), (p)-[]->(y) WHERE {predicate} RETURN count(p) AS n" out = g.gfql(query)._nodes - return out.to_dict(orient="records") if hasattr(out, "to_dict") else out.to_dicts() + return out.to_dicts() if hasattr(out, "to_dicts") else out.to_dict(orient="records") assert run("p.name = 5") == [] assert run("p.name = 'ann'") == [{"n": 4}] @@ -16084,7 +16084,7 @@ def test_connected_join_polars_decimal_matches_master(predicate: str, expected: query = f"MATCH (p)-->(a), (p)-->(b) WHERE {predicate} RETURN count(p) AS n" result = g.gfql(query)._nodes - records = result.to_dict(orient="records") if hasattr(result, "to_dict") else result.to_dicts() + records = result.to_dicts() if hasattr(result, "to_dicts") else result.to_dict(orient="records") assert records == expected @@ -16099,7 +16099,7 @@ def test_connected_join_polars_backed_graph_matches_pandas_results() -> None: g_polars = graphistry.nodes(pl.from_pandas(nodes), "id").edges(pl.from_pandas(edges), "s", "d") result = g_polars.gfql(query)._nodes - records = result.to_dict(orient="records") if hasattr(result, "to_dict") else result.to_dicts() + records = result.to_dicts() if hasattr(result, "to_dicts") else result.to_dict(orient="records") assert records == [] diff --git a/graphistry/tests/compute/gfql/test_engine_polars_cypher_conformance.py b/graphistry/tests/compute/gfql/test_engine_polars_cypher_conformance.py index d1a8c5433d..1940740065 100644 --- a/graphistry/tests/compute/gfql/test_engine_polars_cypher_conformance.py +++ b/graphistry/tests/compute/gfql/test_engine_polars_cypher_conformance.py @@ -496,3 +496,33 @@ def test_bool_modulo_declines_like_pandas(): # bool + int still computes in parity (not over-declined) got = g.gfql("MATCH (n) RETURN n.flag + 2 AS r", engine="polars")._nodes["r"].to_list() assert got == [3, 2, 3] + + +class TestAutoEngineRoutesPolarsNative: + """engine=auto on polars-frame graphs must run the native polars path (frames in = frames + out), not the silent pandas bridge (~13x on point queries); polars-NIE shapes still answer + via the legacy AUTO fallback since the user did not pin an engine.""" + + def _graph(self): + nodes = pl.DataFrame({"id": [0, 1, 2, 3], "label__Person": [True] * 4}) + edges = pl.DataFrame({"s": [0, 1, 2], "d": [1, 2, 3], "type": ["KNOWS"] * 3}) + return graphistry.nodes(nodes, "id").edges(edges, "s", "d") + + def test_auto_returns_polars_frames_and_matches_explicit(self): + g = self._graph() + q = "MATCH (a:Person {id: 0})-[:KNOWS]->(b) RETURN b.id AS bid ORDER BY bid" + r_auto = g.gfql(q)._nodes + r_expl = g.gfql(q, engine="polars")._nodes + assert "polars" in type(r_auto).__module__, "auto must not bridge polars graphs to pandas" + from polars.testing import assert_frame_equal + assert_frame_equal(r_auto, r_expl) + + def test_auto_falls_back_on_polars_nie(self): + g = self._graph() + q = ("MATCH p = shortestPath((a:Person {id: 0})-[:KNOWS*]-(b:Person {id: 3})) " + "RETURN length(p) AS l") + with pytest.raises(NotImplementedError): + g.gfql(q, engine="polars") # pinned engine: honest NIE + out = g.gfql(q)._nodes # auto: answers via fallback + rows = out.to_dict("records") if hasattr(out, "to_dict") else out.to_pandas().to_dict("records") + assert rows == [{"l": 3}]