Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d4975ae
feat(gfql): plan connected join predicate pushdown
lmeyerov Jul 16, 2026
a9beb33
Merge remote-tracking branch 'origin/master' into perf/gfql-olap-spli…
lmeyerov Jul 16, 2026
ebb7113
fix(gfql): preserve lower literal comparison semantics
lmeyerov Jul 16, 2026
d152d05
fix(gfql): retain exact lower equality residuals
lmeyerov Jul 16, 2026
0ce47f1
test(gfql): use row semantics for unicode lower oracle
lmeyerov Jul 16, 2026
aa06536
test(gfql): cover mixed connected-join residual; drop unreachable una…
lmeyerov Jul 16, 2026
6831bb9
test(gfql): cover connected-join label predicate pushdown
lmeyerov Jul 16, 2026
8da7e07
fix(gfql): restore unary literal pushdown wrongly removed as dead code
lmeyerov Jul 16, 2026
55797c9
fix(gfql): only push connected-join filters that survive filter_dict
lmeyerov Jul 17, 2026
529674f
fix(gfql): stop connected-join pushdown from injecting predicates and…
lmeyerov Jul 17, 2026
bfde19b
fix(gfql): let column dtypes decide connected-join pushdown
lmeyerov Jul 17, 2026
68bde81
fix(gfql): mirror the live validator in the pushdown dtype gate
lmeyerov Jul 17, 2026
040c724
fix(gfql): classify non-pandas dtypes so the pushdown gate works on p…
lmeyerov Jul 17, 2026
3d41229
fix(gfql): fail closed on container dtypes in the pushdown gate
lmeyerov Jul 17, 2026
bf46ea7
fix(gfql): don't push onto an inline string property filter
lmeyerov Jul 17, 2026
29a4032
fix(gfql): check both sides of a connected-join filter merge
lmeyerov Jul 17, 2026
b53d871
fix(gfql): fail closed on dtypes that change class when the engine ma…
lmeyerov Jul 17, 2026
ba8c2cb
fix(gfql): classify the dtypes the executor will actually filter
lmeyerov Jul 17, 2026
ebd94da
fix(gfql): fail closed when the schema can't be read, and read frames…
lmeyerov Jul 17, 2026
6b0d2a4
fix(gfql): classify the converted frame, not an empty probe
lmeyerov Jul 17, 2026
a7f51a0
perf(gfql): read node dtypes only when pushdown asks for them
lmeyerov Jul 17, 2026
c48227a
test(gfql): pin that an unreadable schema fails closed
lmeyerov Jul 17, 2026
8761f67
fix(gfql): don't push onto a bool column the join will widen
lmeyerov Jul 17, 2026
666af9c
fix(gfql): don't push string ops onto object columns holding non-strings
lmeyerov Jul 17, 2026
dd47a5a
fix(gfql): mirror pandas' own .str rule instead of enumerating rejects
lmeyerov Jul 17, 2026
bec4060
test(gfql): pin the bytes carve-out in the string-op gate
lmeyerov Jul 17, 2026
d161d84
test(gfql): pin the floating and integer omit-set members
lmeyerov Jul 17, 2026
e93d549
fix(gfql): admit only object kinds that survive subsetting
lmeyerov Jul 17, 2026
9005a3f
test(gfql): pin the empty member of the string-op keep-set
lmeyerov Jul 17, 2026
77df4f7
fix(gfql): keep the aggregate's schema when a pushed filter empties a…
lmeyerov Jul 17, 2026
03d1f9d
Revert "fix(gfql): keep the aggregate's schema when a pushed filter e…
lmeyerov Jul 17, 2026
5a1dc46
fix(gfql): fail closed when an object column's values can't be read
lmeyerov Jul 17, 2026
045b3fe
fix(gfql): emit the full binding schema at 0 rows, then flow empties …
lmeyerov Jul 17, 2026
9e5c9c7
fix(gfql): carry source dtype when filling edge-alias columns at 0 rows
lmeyerov Jul 17, 2026
b790223
fix(gfql): widen downstream node int columns to float at 0 rows
lmeyerov Jul 17, 2026
0772083
fix(gfql): keep extension dtypes + widen bool at 0-row connected join
lmeyerov Jul 17, 2026
7f2e5d7
refactor(gfql): type + relocate connected-join pushdown internals
lmeyerov Jul 17, 2026
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
2 changes: 1 addition & 1 deletion bin/ci_cypher_surface_guard_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@
"max_properties": 0
}
},
"lowering_py_max_lines": 8779
"lowering_py_max_lines": 9237
}
120 changes: 118 additions & 2 deletions graphistry/compute/filter_by_dict.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from typing import Any, Dict, Optional, Tuple, Union
from typing import Any, Dict, Mapping, Optional, Tuple, Union, cast
import pandas as pd
from graphistry.Engine import EngineAbstract, df_to_engine, resolve_engine
from graphistry.util import setup_logger

from graphistry.Plottable import Plottable
from .predicates.ASTPredicate import ASTPredicate
from .typing import DataFrameT
from .typing import DataFrameT, DType, NodeDtypes


logger = setup_logger(__name__)
Expand Down Expand Up @@ -219,3 +219,119 @@ def filter_edges_by_dict(self: Plottable, filter_dict: Optional[dict] = None, en
"""
edges2 = filter_by_dict(self._edges, filter_dict, engine)
return self.edges(edges2)


class _LazyNodeDtypes(Mapping[str, DType]):
"""Node dtypes, materialized on first lookup.

Reading them costs a full engine conversion, and only connected-join pushdown ever asks --
a path most queries never reach. Computing eagerly charged every string query for a frame
it then discarded (~65ms per million polars rows). Stay a Mapping so callers are unchanged.
"""

def __init__(self, g: Plottable, engine: Union[EngineAbstract, str]) -> None:
self._g = g
self._engine = engine
self._resolved: Optional[NodeDtypes] = None

def _materialize(self) -> NodeDtypes:
if self._resolved is None:
self._resolved = _read_node_dtypes(self._g, self._engine)
return self._resolved

def __getitem__(self, key: str) -> DType:
return self._materialize()[key]

def __iter__(self) -> Any:
return iter(self._materialize())

def __len__(self) -> int:
return len(self._materialize())


def _node_dtypes_for_pushdown(
g: Plottable,
engine: Union[EngineAbstract, str] = EngineAbstract.AUTO,
) -> Optional[NodeDtypes]:
"""Node dtypes for the pushdown gate, or None when there is no graph to read."""
if g._nodes is None:
# No graph: the caller falls back to value-type rules, as a bare compile_cypher() does.
# Distinct from failing to read a graph we have, which must fail closed.
return None
return _LazyNodeDtypes(g, engine)


def _object_column_holds_non_strings(frame: DataFrameT, column: str, dtype: DType) -> bool:
"""Whether `column` is an object column whose values `.str` would reject.

`object` says nothing about contents: pandas stores ordinary strings that way, and also a
numeric column that acquired a `None`. String predicates are admitted on dtype alone -- by
this gate and by `filter_by_dict` alike -- but `.str` fails on the values, leaking a raw
AttributeError. Dropping the column leaves the gate with nothing to look up, so it fails
closed and the residual answers.

Keep only the kinds that stay valid under SUBSETTING. `StringMethods._validate` admits
{string, empty, bytes, mixed, mixed-integer}, but it runs on the frame it is handed -- and
the frame we inspect is the source, while the pushed filter runs on the join's candidate
subset. `string` and `empty` survive that (a subset of strings is string or empty), but
`mixed`/`mixed-integer` do not: drop the strings and they collapse to integer/floating/
boolean, which `.str` rejects. `bytes` is excluded separately -- it passes `_validate` yet
`str.contains` forbids it.

Mirroring a rule is not enough when we evaluate it against a different frame than the
executor does, so admit only what no subset can invalidate.
"""
import pandas as _pd

try:
is_object = bool(_pd.api.types.is_object_dtype(dtype))
except Exception:
return False
if not is_object:
return False
try:
inferred = _pd.api.types.infer_dtype(frame[column], skipna=True)
except Exception:
# It IS an object column and we cannot read its values, so we cannot tell whether
# `.str` would reject them. Omit it and let the residual answer, matching
# `_read_node_dtypes`, which returns {} for a schema it cannot read.
return True
return inferred not in {"string", "empty"}


def _read_node_dtypes(
g: Plottable,
engine: Union[EngineAbstract, str] = EngineAbstract.AUTO,
) -> NodeDtypes:
"""Column dtypes the executor will filter on, for the pushdown gate.

A pushed filter is schema-validated against the real column while the row residual
evaluates leniently, so the planner needs dtypes to avoid turning a correct empty result
into a type error. An unreadable schema yields an empty mapping, which fails every column
lookup and so falls back to the residual.
"""
nodes = g._nodes
if nodes is None:
return {}
try:
# Classify the frame the EXECUTOR will filter, not the one the caller handed in.
# `filter_by_dict` validates post-materialization dtypes, so judging the pre-conversion
# frame lets the two disagree (polars Decimal reads numeric here, object there).
#
# Convert the whole frame, not an empty probe: polars -> pandas is DATA-dependent, so
# `head(0)` reports a different class than the real conversion for a nullable Boolean
# (`bool` empty, `object` with a null in it). Identity for pandas; for other engines
# this costs a real conversion, which is why the caller defers it until a pushdown
# decision actually needs it.
probe = df_to_engine(nodes, resolve_engine(cast(Any, engine), nodes), warn=False)
# zip rather than `.items()`: pandas/cuDF expose a column-indexed Series, polars a list.
dtypes = {str(col): dtype for col, dtype in zip(list(probe.columns), list(probe.dtypes))}
return {
col: dtype
for col, dtype in dtypes.items()
if not _object_column_holds_non_strings(probe, col, dtype)
}
except Exception:
# We have a graph but could not read its schema, so we know nothing about any column.
# An empty mapping fails every column lookup, which fails closed to the residual.
return {}
Loading
Loading