Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
96 changes: 84 additions & 12 deletions src/giql/expander.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@
i.e. a ``(target, op)`` or ``(generic, op)`` expander is registered.

Otherwise it falls through to the legacy ``*_sql`` emitter on
:class:`giql.generators.base.BaseGIQLGenerator`. As of this issue **no operator
sets ``GIQL_EXPAND`` and the registry is empty, so the pass is a strict no-op**:
no node is touched and the emitted SQL is byte-identical. Each later migration PR
(epic #137 steps 4-9) registers a generic expander, flips one operator's
:class:`giql.generators.base.BaseGIQLGenerator`. The built-in expanders register
at import time via :mod:`giql.expanders`; the pass rewrites a node only when
``GIQL_EXPAND=True`` **and** an expander resolves for ``(active target, operator
type)``, and is a no-op for any operator that is unflagged or has no registered
expander. A migration PR registers an expander, flips one operator's
``GIQL_EXPAND`` flag, and deletes that operator's ``*_sql`` method.
"""

Expand Down Expand Up @@ -149,6 +150,14 @@ class OperatorExpander(Protocol):
a registered object satisfies it. A plain function is *not* an
``OperatorExpander`` (it has no ``expand`` method); register one by wrapping
it (see :func:`register`, which accepts either form).

An expander is **node-local**: ``expand(node, ctx) -> exp.Expression`` sees
one operator node and returns the expression that replaces it in place. It
cannot express a whole-query rewrite such as the INTERSECTS IEJoin fold,
which restructures the surrounding query (joins, CTEs) rather than a single
node. That fold is therefore deferred — it would need a separate
query-level mechanism — and is handled by the pre-pass join transformers, not
by an expander.
"""

def expand(self, node: exp.Expression, ctx: ExpansionContext) -> exp.Expression: ...
Expand Down Expand Up @@ -215,6 +224,17 @@ def register(
expander : OperatorExpander | ExpanderFn
The expander object or function. A later registration for the same
key replaces an earlier one (last-write-wins override).

Notes
-----
A *non-generic* ``(target, operator)`` entry is intended to also act as a
join-rewrite override for operators with a built-in whole-query join
rewrite (notably :class:`~giql.expressions.Intersects`, whose binned
equi-join / DuckDB IEJoin transformers run before expansion), letting a
per-target expander assume responsibility for that rewrite. That bypass
is intended for a future INTERSECTS consumer and is **not wired by any
caller yet** — no transformer consults :meth:`has_override` here (see
#141).
"""
self._expanders[(target, operator)] = _as_callable(expander)

Expand All @@ -223,6 +243,14 @@ def resolve(self, target: Target, operator: type) -> ExpanderFn | None:

Tries the exact ``(target, op)`` entry, then the
``(GenericTarget(), op)`` fallback, then ``None`` (legacy emitter).

A non-generic exact ``(target, op)`` entry is also intended to act as a
*join-rewrite override* for operators with a built-in whole-query join
rewrite (notably :class:`~giql.expressions.Intersects`). That override is
intended for a future INTERSECTS consumer and is **not wired by any
caller yet** — resolution does not itself bypass the built-in
binned / IEJoin transformers (see :meth:`register`, :meth:`has_override`,
and #141).
"""
fn = self._expanders.get((target, operator))
if fn is not None:
Expand All @@ -233,6 +261,23 @@ def resolve(self, target: Target, operator: type) -> ExpanderFn | None:
return fn
return None

def has_override(self, target: Target, operator: type) -> bool:
"""Whether an exact non-generic ``(target, operator)`` entry is registered.

Returns ``True`` only when *target* is not :class:`~giql.targets.GenericTarget`
and an exact ``(target, operator)`` entry is registered; the portable
``(GenericTarget(), operator)`` fallback is *not* an override and does not
count here.

Such an entry is intended to mark a target-specific override that
supersedes built-in handling (e.g. taking responsibility for the
whole-query join rewrite the built-in transformers would otherwise
perform). That mechanism is intended for a future INTERSECTS consumer and
is **not wired by any caller yet** — no transformer consults this method
in the current pipeline (see #141).
"""
return target != GenericTarget() and (target, operator) in self._expanders

def unregister(self, target: Target, operator: type) -> None:
"""Drop the ``(target, operator)`` entry if present.

Expand All @@ -253,6 +298,31 @@ def clear(self) -> None:
"""
self._expanders.clear()

def snapshot(self) -> dict[tuple[Target, type], ExpanderFn]:
"""Return a shallow copy of the current registrations.

The save half of a save/restore seam that supports test
baseline-isolation: capture the baseline with this and hand it back to
:meth:`restore` afterward, so the built-in expanders registered at import
survive an isolating fixture that would otherwise :meth:`clear` them
permanently.

The returned dict is a fresh mapping (mutating it does not affect the
registry), keyed by the same ``(target, operator)`` tuples.
"""
return dict(self._expanders)

def restore(self, snapshot: dict[tuple[Target, type], ExpanderFn]) -> None:
"""Replace all registrations with those captured by :meth:`snapshot`.

The restore half of the save/restore seam that supports test
baseline-isolation. Drops every current entry and re-installs exactly the
*snapshot* contents, so a fixture can return the registry to a previously
captured baseline regardless of what its body registered or cleared.
"""
self._expanders.clear()
self._expanders.update(snapshot)

def __contains__(self, key: tuple[Target, type]) -> bool:
"""Whether an *exact* ``(target, operator)`` entry is registered.

Expand Down Expand Up @@ -280,8 +350,9 @@ def __bool__(self) -> bool:


#: The process-wide registry the :func:`register` decorator writes to and the
#: :class:`ExpandOperators` pass reads from. Empty as of this issue, so the pass
#: is a strict no-op.
#: :class:`ExpandOperators` pass reads from. The built-in expanders register into
#: it at import time via :mod:`giql.expanders`; the pass rewrites a node only when
#: an expander resolves here (and the operator is flagged ``GIQL_EXPAND``).
REGISTRY = ExpanderRegistry()


Expand Down Expand Up @@ -380,9 +451,10 @@ class sets ``GIQL_EXPAND = True`` *and* the registry resolves an expander for
``(target, operator type)`` through its fallback chain; otherwise the node is
left untouched and the legacy ``*_sql`` emitter handles it.

The pass mutates and returns *expression* in place. **With no operator
flagged and an empty registry it is a strict no-op** and the emitted SQL is
byte-identical, so the existing suite is the migration oracle.
The pass mutates and returns *expression* in place. It touches only nodes
whose operator is flagged ``GIQL_EXPAND`` and resolves an expander; for every
other operator it is a no-op, leaving the emitted SQL byte-identical, so the
existing suite is the migration oracle.

Parameters
----------
Expand All @@ -399,9 +471,9 @@ class sets ``GIQL_EXPAND = True`` *and* the registry resolves an expander for
Returns
-------
exp.Expression
The same *expression*, with opted-in operator nodes replaced by their
target-specific expansions (none, while every flag is off / the registry
is empty).
The same *expression*, with each opted-in operator node that resolves an
expander replaced by its target-specific expansion; nodes that are
unflagged or resolve no expander are left untouched.
"""
reg = registry if registry is not None else REGISTRY
operators = _giql_operators()
Expand Down
37 changes: 37 additions & 0 deletions src/giql/expanders/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Built-in operator expanders for epic #137.

Importing this package registers every built-in expander as a side effect:
each submodule decorates its expander(s) with ``@register(...)`` at import
time, and this package imports all of them. The import is wired once (in
:mod:`giql.transpile`) so the process-wide ``REGISTRY`` is populated before the
first transpile.

New operator modules are picked up automatically: drop a ``<operator>.py`` into
this package and it is imported here without editing this file.

Modules whose name starts with ``_`` are skipped (private helpers, not
expanders). Submodules import in :func:`pkgutil.iter_modules` order, which sets
last-write-wins resolution-order precedence for overlapping registrations; an
import error here aborts the whole package import by design (a broken built-in
expander must not be silently skipped).
"""

from __future__ import annotations

import importlib
import pkgutil

from giql.expander import REGISTRY

for _module_info in pkgutil.iter_modules(__path__):
if _module_info.name.startswith("_"):
continue
importlib.import_module(f"{__name__}.{_module_info.name}")

# Fail loudly if discovery registered nothing. Under zipimport or PEP-420
# namespace-package layouts ``pkgutil.iter_modules`` can yield no submodules,
# silently leaving the registry unpopulated; assert at least one expander landed
# so zero-discovery surfaces here rather than as a mystery legacy-path
# fallthrough. The check is branch-agnostic: it names no one operator, so it
# holds in every wave-3 worktree regardless of which expanders ship.
assert len(REGISTRY) > 0, "giql.expanders auto-discovery registered no expanders"
Loading
Loading