Skip to content

refactor: scope extension-relation derivers to the ExtensionRegistry#220

Merged
nielspardon merged 2 commits into
substrait-io:mainfrom
nielspardon:refactor/scope-extension-relation-derivers
Jul 16, 2026
Merged

refactor: scope extension-relation derivers to the ExtensionRegistry#220
nielspardon merged 2 commits into
substrait-io:mainfrom
nielspardon:refactor/scope-extension-relation-derivers

Conversation

@nielspardon

Copy link
Copy Markdown
Member

Problem

User-defined extension relations registered their schema deriver in a process-global dict in type_inference (_extension_relation_derivers), while ExtensionRegistry.register_extension_relation also wrote a per-instance dict (self._extension_relations) that inference never read — dead state.

Consequences (from #206):

  • Two ExtensionRegistry instances silently shared one deriver map.
  • Last-writer-wins if two detail classes shared a type_url.
  • No isolation — state leaked across otherwise-independent registries and across tests.

Every other registry capability is instance-scoped; extension-relation derivation should be too.

Change

Store derivers only on the ExtensionRegistry and thread that registry through inference so _derive_extension_schema consults it, then drop the module-global dict and the module-level register_extension_relation shim.

  • ExtensionRegistry.register_extension_relation now writes only self._extension_relations; adds lookup_extension_relation(type_url).
  • infer_plan_schema / infer_rel_schema / infer_expression_type / infer_nested_type / infer_extended_expression_schema / _join_output_struct take a keyword-only registry=None and forward it down to _derive_extension_schema. When registry is None the behavior is unchanged (leaf/multi raise "no schema deriver"; single passes through), so the many non-extension call sites are unaffected.
  • Builder / DataFrame / expr call sites pass registry=registry — each is already inside a resolve(registry) closure, so chained extension-relation inference resolves against the right registry.

Behavior change

Registration is now scoped to the ExtensionRegistry instance. Code following the documented pattern (register on a registry, then build/infer through that same registry — as the DataFrame and builders do) is unaffected. Two independent registries no longer share deriver state.

Testing

  • New regression test test_deriver_registration_is_scoped_to_registry: a detail class registered on one registry does not derive on an independent one (and still derives on the one it was registered on).
  • _kinds helper now threads the registry (it re-infers a finished plan whose detail is an opaque Any).
  • Full suite: 512 passed, 30 skipped (unchanged); ruff format --check and ruff check clean.

Closes #206.

🤖 Generated with AI

User-defined extension relations registered their schema deriver in a
process-global dict in type_inference, while ExtensionRegistry also kept
a per-instance dict that inference never read (dead state). As a result
two registries silently shared one deriver map, registrations were
last-writer-wins across type_urls, and state leaked across otherwise
independent registries and across tests.

Store derivers only on the ExtensionRegistry and thread that registry
through inference so _derive_extension_schema consults it, then drop the
module-global dict and the module-level register_extension_relation shim.

- ExtensionRegistry.register_extension_relation now writes only
  self._extension_relations; adds lookup_extension_relation(type_url).
- infer_plan_schema / infer_rel_schema / infer_expression_type /
  infer_nested_type / infer_extended_expression_schema / _join_output_struct
  take a keyword-only registry=None and forward it to every recursive
  call and to _derive_extension_schema. When registry is None the
  behavior is unchanged (leaf/multi raise, single passes through).
- Builder / DataFrame / expr call sites pass registry=registry (each is
  already inside a resolve(registry) closure).
- test_extension_relations: _kinds threads the registry, plus a new
  test asserting a deriver registered on one registry does not derive
  on an independent one.

Closes substrait-io#206.
@nielspardon
nielspardon force-pushed the refactor/scope-extension-relation-derivers branch from 6018b21 to d95813b Compare July 16, 2026 13:13
tokoko pushed a commit that referenced this pull request Jul 16, 2026
## Problem

`type_inference.infer_rel_schema`'s `set` branch derived a `SetRel`'s
output schema from **`rel.set.inputs[0]` only** and never read
`rel.set.op`. Per the Substrait spec, set inputs share field *types* but
may differ in *nullability*, and the output nullability must be combined
across all inputs according to the operation. So for `UNION` /
`INTERSECTION`, the inferred nullability could be wrong (too strict)
when inputs disagree.

Reachable through this library's own builders: `plan.set(...)` and the
DataFrame `union` / `intersect` / `except_` verbs build a `SetRel` with
no stored schema, so a later `infer_plan_schema` over a union of two
sub-plans whose shared columns differ in nullability under-reported
nullability.

## Fix

Infer every input and combine each field's nullability per the
operation, matching the spec's [set-operation output-type
derivation](https://substrait.io/relations/logical_relations/#set-operation):

- **UNION_DISTINCT / UNION_ALL** — nullable if the field is nullable in
any input.
- **INTERSECTION_PRIMARY** — nullable only when the field is nullable in
the primary and in at least one secondary; required otherwise.
- **INTERSECTION_MULTISET / _ALL** — required if the field is required
in any input.
- **MINUS_PRIMARY / _PRIMARY_ALL / _MULTISET** (and unspecified) — same
as the primary.

Field *types* are taken from the primary input (the spec requires
identical field types across inputs); only the top-level nullability is
recombined, and `CopyFrom` preserves type parameters and nested element
types.

## Testing

- `test_inference_set_nullability` — parametrized over all 8 concrete
set ops using the **spec's own worked example** (three inputs, one
column per required/nullable combination) and asserting the spec's exact
output columns.
- `test_inference_set_nullability_preserves_field_types` — a `UNION_ALL`
over `decimal(10,2)` / `varchar(5)` / `list<string>` columns, asserting
the full `Type.Struct` so type parameters, nested element types, and
struct-level nullability are all verified.
- Full suite: **521 passed, 30 skipped**; `ruff format --check` and
`ruff check` clean.

An adversarial cross-check (independent re-derivation of all 9 `SetOp`
cases against the spec, plus edge-case and test-completeness passes)
confirmed the rules; a defensive guard was added so a field `Type` with
no `kind` set passes through unchanged rather than raising.

## Relationship to #206 / #220

Independent.
[#220](#220)
(registry scoping) also touches the `set` branch line to thread
`registry=registry`; whichever merges second is a trivial rebase (keep
both changes).

Closes #219.

🤖 Generated with AI

@tokoko tokoko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

…nsion-relation-derivers

# Conflicts:
#	src/substrait/type_inference.py
@nielspardon
nielspardon enabled auto-merge (squash) July 16, 2026 15:14
@nielspardon
nielspardon disabled auto-merge July 16, 2026 15:14
@nielspardon
nielspardon merged commit 0f65e67 into substrait-io:main Jul 16, 2026
19 checks passed
@nielspardon
nielspardon deleted the refactor/scope-extension-relation-derivers branch July 16, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scope extension-relation schema derivers to the ExtensionRegistry instead of a process-global

2 participants