|
23 | 23 | from graphistry.Engine import is_polars_df as _is_polars_df |
24 | 24 |
|
25 | 25 |
|
| 26 | +def _series_is_series_like(s: Any) -> bool: |
| 27 | + """True for a pandas/cuDF Series (``.dropna``) or a polars Series (module check). |
| 28 | +
|
| 29 | + The reentry executor recovers carried node identities from a projection-meta ``ids`` Series |
| 30 | + that is pandas under ``engine='pandas'`` and polars under ``engine='polars'``; both are valid.""" |
| 31 | + return hasattr(s, "dropna") or _is_polars_df(s) |
| 32 | + |
| 33 | + |
| 34 | +def _series_not_null_mask(s: Any) -> Any: |
| 35 | + """Non-null boolean mask, engine-aware (polars ``is_not_null`` vs pandas ``notna``).""" |
| 36 | + if _is_polars_df(s): |
| 37 | + return s.is_not_null() |
| 38 | + return s.notna() |
| 39 | + |
| 40 | + |
| 41 | +def _series_filter(s: Any, mask: Any) -> Any: |
| 42 | + """Filter a Series by a boolean mask, engine-aware, dropping the old index (pandas).""" |
| 43 | + if _is_polars_df(s): |
| 44 | + return s.filter(mask) |
| 45 | + return s[mask].reset_index(drop=True) |
| 46 | + |
| 47 | + |
| 48 | +def _frame_filter(df: Any, mask: Any) -> Any: |
| 49 | + """Filter a DataFrame's rows by a boolean mask, engine-aware, dropping the old index.""" |
| 50 | + if _is_polars_df(df): |
| 51 | + return df.filter(mask) |
| 52 | + return df.loc[mask].reset_index(drop=True) |
| 53 | + |
| 54 | + |
| 55 | +def _ordered_left_join(left: Any, right: Any, *, on: str) -> Any: |
| 56 | + """Left join preserving ``left`` row order, engine-aware. Polars ``.merge`` does not exist; |
| 57 | + ``safe_merge`` (pandas/cuDF ``.merge``) cannot run on polars frames, so branch to |
| 58 | + ``.join(..., maintain_order='left')`` which pins the WITH-row ordering the reentry seed needs. |
| 59 | +
|
| 60 | + Under ``engine='polars'`` the carried ids come from the natively-projected (polars) prefix |
| 61 | + while the base node table can still be pandas (the base graph is converted lazily), so align |
| 62 | + ``right`` onto ``left``'s engine before the polars join.""" |
| 63 | + if _is_polars_df(left): |
| 64 | + from graphistry.Engine import Engine, df_to_engine |
| 65 | + if not _is_polars_df(right): |
| 66 | + right = df_to_engine(right, Engine.POLARS) |
| 67 | + return left.join(right, on=on, how="left", maintain_order="left") |
| 68 | + return safe_merge(left, right, on=on, how="left") |
| 69 | + |
| 70 | + |
26 | 71 | def _reentry_row(prefix_rows: Any, row_index: int) -> Any: |
27 | 72 | """One prefix row as a col->scalar mapping, engine-aware (``row[col]`` works for |
28 | 73 | both the pandas Series and the polars named-row dict).""" |
@@ -274,7 +319,7 @@ def compiled_query_reentry_state( |
274 | 319 | ) |
275 | 320 | ids = meta["ids"] |
276 | 321 | id_column = meta["id_column"] |
277 | | - if not hasattr(ids, "dropna"): |
| 322 | + if not _series_is_series_like(ids): |
278 | 323 | raise reentry_validation_error( |
279 | 324 | "Cypher MATCH after WITH could not recover carried node identities from the prefix stage", |
280 | 325 | value=output_name, |
@@ -306,6 +351,17 @@ def compiled_query_reentry_state( |
306 | 351 | value=output_name, |
307 | 352 | suggestion=REENTRY_SCALAR_SUGGESTION, |
308 | 353 | ) |
| 354 | + if _is_polars_df(carried_ids) or _is_polars_df(aligned_prefix_rows) or _is_polars_df(base_nodes): |
| 355 | + # Whole-row re-entry that ALSO carries scalar WITH columns (e.g. `WITH a, a.val AS av |
| 356 | + # MATCH (a)-...`) threads hidden ``__cypher_reentry_*`` payload columns through the |
| 357 | + # binding pipeline; that carry path is pandas-only so far. The seed-only whole-row case |
| 358 | + # (no carried scalars) returned above. Decline honestly rather than run the pandas-only |
| 359 | + # payload merge on polars frames (parity-or-NIE; no silent bridge). |
| 360 | + raise NotImplementedError( |
| 361 | + "polars engine does not yet natively support Cypher WITH -> MATCH re-entry that carries " |
| 362 | + "scalar WITH columns into the trailing MATCH; use engine='pandas' for this query " |
| 363 | + "(no pandas fallback; parity-or-error by design)" |
| 364 | + ) |
309 | 365 | duplicate_mask = carried_ids.duplicated() |
310 | 366 | if bool(duplicate_mask.any()) if hasattr(duplicate_mask, "any") else False: |
311 | 367 | raise reentry_validation_error( |
@@ -488,18 +544,18 @@ def aligned_reentry_rows( |
488 | 544 | value=output_name, |
489 | 545 | suggestion="Retry with a direct whole-row carry through WITH or inspect intermediate row-shaping before MATCH re-entry.", |
490 | 546 | ) |
491 | | - if not hasattr(ids, "notna"): |
| 547 | + if not _series_is_series_like(ids): |
492 | 548 | raise reentry_validation_error( |
493 | 549 | "Cypher MATCH after WITH could not align carried node identities from the prefix stage", |
494 | 550 | value=output_name, |
495 | 551 | suggestion=REENTRY_WHOLE_ROW_SUGGESTION, |
496 | 552 | ) |
497 | 553 |
|
498 | | - non_null_mask = cast(SeriesT, ids.notna()) |
499 | | - carried_ids = cast(SeriesT, ids[non_null_mask].reset_index(drop=True)) |
| 554 | + non_null_mask = _series_not_null_mask(ids) |
| 555 | + carried_ids = cast(SeriesT, _series_filter(ids, non_null_mask)) |
500 | 556 | if prefix_rows is None: |
501 | 557 | return carried_ids, None |
502 | | - return carried_ids, cast(DataFrameT, prefix_rows.loc[non_null_mask].reset_index(drop=True)) |
| 558 | + return carried_ids, cast(DataFrameT, _frame_filter(prefix_rows, non_null_mask)) |
503 | 559 |
|
504 | 560 |
|
505 | 561 | def reentry_carry_payload( |
@@ -533,4 +589,4 @@ def ordered_reentry_start_nodes( |
533 | 589 | id_column: str, |
534 | 590 | ) -> DataFrameT: |
535 | 591 | # MATCH re-entry must preserve the WITH row order, not the base node-table order. |
536 | | - return cast(DataFrameT, safe_merge(carried_node_ids, node_rows, on=id_column, how="left")) |
| 592 | + return cast(DataFrameT, _ordered_left_join(carried_node_ids, node_rows, on=id_column)) |
0 commit comments