Repository navigation
feat(diagram): Diagram.expand on a traversal plan - #1637
Open
dimitri-yatsenko wants to merge 3 commits into
Open
dimitri-yatsenko wants to merge 3 commits into
dimitri-yatsenko wants to merge 3 commits into
Conversation
The Hypothesis strategies that draw model schemas, databases, seeds and conditions move from test_expand_spec.py to expand_strategies.py, so that the planner's tests can draw from them too. No test changes.
Add datajoint.expand, the planner behind Diagram.expand (diagram.md, "Traversal specification", Computation): - TraversalSchema, a schema description built from connection.dependencies and the headings: tables, primary keys, nullable attributes, foreign keys with their attr_map, and each master's owned parts with their owning attributes (A1). - The plan: Seed, Literal, Restrict, Lift, Reexpand and Or nodes that name the restrictions they use by handle. plan_expand makes one pass over the group quotient for down and up (both is the up pass seeded with the down pass), copies a condition only under R1's rule, relabels literal keys under the Seeds rule, and marks the groups whose foreign keys link instances to iterate. - Two evaluators: RowEvaluator on rows held in memory, and QueryCompiler, which compiles each node to a FreeTable with one condition and reads rows only to find the master keys of a group that iterates. tests/unit/test_expand_plan.py (L0') checks the plan against the reference model: the description agrees with the model, the plan evaluated in memory selects D(S), U(S) and U(D(S)) for every seed kind, plan structure for the catalog shapes, and a delete simulation. The simulation leaves DB - D(S) when no lift is evaluated at delete time, and when the nodes the spec says to materialize are materialized; with the plan's own flags it is a strict xfail until delete-time materialization lands.
Diagram.expand(seed, direction) returns a diagram whose nodes are the table-level reach of the seed and whose d[T], counts() and iteration give each table's rows as a restricted query expression, compiled from the plan. It loads the schemas reachable in the direction of travel, refuses projections, joins, aggregations and unions as seeds, and replaces a dj.Top seed by its keys. cascade() and trace() are unchanged. tests/integration/test_expand_planner.py checks S1, S3, S7, G1, G4, G7 and K1, K3-K6 of the shape catalog on MySQL and PostgreSQL.
dimitri-yatsenko
requested review from
MilagrosMarin,
lum-agilyti and
ttngu207
October 18, 2026 00:36
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1621 (the reference model). This PR adds the planner behind
Diagram.expand, the classmethod itself, and tests that compare the planner with the reference model.cascade(),trace()andTable.delete()are unchanged; they move onto the planner in later PRs.The normative definitions are the "Traversal specification" section of
diagram.mdand the Traversal Shape Catalog in datajoint/datajoint-docs#249.What it adds
src/datajoint/expand.pyworks in three steps, as the specification's "Computation" section describes them.TraversalSchema. It is plain data: tables with their primary keys, attributes and nullable attributes, foreign keys with theirattr_map, and each master's owned parts with their owning attributes (A1).TraversalSchema.from_dependenciesbuilds it fromconnection.dependenciesand the table headings, which give nullability and attribute order. The tests build it by hand from the reference model.plan_expand(schema, seed, direction). It builds aPlan, an intermediate form whose nodes areSeed,Literal,Restrict,Lift,ReexpandandOr. A node names the restrictions it uses by a handle, the index of another node. The planner reads no rows.QueryCompilerandcompile_planturn a plan into one restrictedFreeTableper table.RowEvaluatorevaluates a plan on rows held in memory, in the reference model's format.Diagram.expand(seed, direction="down")returns a diagram whose nodes are the seed's table-level reach. On it,d[T]returns tableTrestricted to its rows (by class, instance, full table name or class name),counts()counts the rows, and iteration yields each node's restricted table in the plan's topological order. The rest of the expanded-diagram contract comes later: copying,+, the operators that raise, andprune().How it maps to the specification
TraversalSchema._find_owned_parts: parts are taken parents first; an unrenamed-> masterwins, and otherwise the first owning reference. From a database, foreign keys are ordered by the heading positions of their attributes, because the catalog does not record definition order.TraversalSchema.reach.graph_reachfinds a superset in the dependency graph, which decides the headings to load._plan_pass: Q in topological order fordownand in reverse order forup.bothis theuppass seeded with every table's rows from thedownpass, so it givesU(D(S)).Restrict(child, parent, attr_map), orRestrict(..., copy=True)when the condition names only attributes of an unrenamed foreign key with no nullable column. Literal keys are relabeled under the Seeds rule.Restrict(parent, child, reversed attr_map). Keys read from the child itself that hold every column of the foreign key are relabeled.Lift(master, part rows, master key -> owning attributes)andReexpand(part, master rows, owning attributes -> master key).Or.TraversalSchema.links(master)finds the foreign keys inside a group that do not map owning attributes onto owning attributes. For such a group the plan holds aGroupStepwith aLiteral(keys=None)for the master keys and astepacross those foreign keys. The evaluator repeats the step until no new key appears; it reads rows only here.seed_from_expression: one table with any restriction. Projections, joins, aggregations and unions raiseDataJointError. Adj.Topseed becomes aLiteralof the keys it selects. A seed's other conditions stay symbolic; a compiled condition that contains a subquery is never copied.Restrict,LiftorReexpandcompiles to(a1, a2) IN (SELECT s1, s2 FROM <source>), which is whatT & source.proj(a1="s1", a2="s2")compiles to. Matching by position also works where.proj()cannot: it keeps the source's other key attributes out of the match (S7 upward), and it allows a rename to a name the source already has (S5 upward). A seed's compiled conditions are appended as they are, without being compiled again.load_dependenciesloads the downstream closure (down), the upstream closure (up), or the upstream closure of the downstream closure (both), starting from the activated schemas and the seed's schema.Tests
tests/unit/test_expand_plan.py, with no database:Dependenciesgraph, with either quoting.D(S),U(S)andU(D(S)). Every seed kind is drawn: a condition, literal key dicts, keys read from the seed table, and no restriction. Copies are evaluated as copies, by applying the copied condition to the child's rows, so the copy rule is tested and not assumed. The seed kinds give the same rows (K3).downplan as a cascade would: leaves first, each table's restriction evaluated on the database as it is at that moment, andON DELETE RESTRICTemulated. It leavesDB − D(S)in two cases: when no lift is evaluated at delete time, and when the nodes that "Materialization happens only at delete time" names are materialized first (every lift, and the seed unless it is literal keys). With the plan's own materialization flags, which a later PR adds, the delete property is a strictxfail. The xfail pins G1, G7 and a K1 seed that reads its descendant (Cascade delete: a restriction referencing an earlier-deleted (downstream) table is silently invalidated by reverse-topological delete order — materialize (independent of MySQL 1093) #1496).datajoint.dependencies.owned_parts(fix(declare): refuse a part reference that closes a master-part group cycle #1620) finds the same owned parts. It is skipped until fix(declare): refuse a part reference that closes a master-part group cycle #1620 merges, so the two PRs can merge in either order.tests/integration/test_expand_planner.py, on MySQL and PostgreSQL: S1, S3, S7, G1, G4, G7 and K1, K3–K6 return the rows the catalog states in every table, in each direction the catalog lists. Refused seeds and unknown directions raise. The full catalog, the differential tests on random schemas and the delete tests follow.Harness strength. Each of these planner mutations fails between 4 and 13 tests in
test_expand_plan.py(ciprofile):Questions for the specification
For datajoint/datajoint-docs#249:
QueryExpressionkeeps only compiled conditions. The copy rule is stated for conditions (a dict's keys, a query expression's heading). This PR reads a seed's attributes fromrestriction_attributesand never copies a condition that contains a subquery. For the same reason, K6's warning cannot be raised:restrict()drops a dict that names no attribute before the seed is seen.Restrict(copy=True), and a copied seed condition is the seed's compiled SQL applied to the child. Is that the intended reading?.proj(...), keep the source's primary key. Should the text say "projected onto the foreign key's attributes" (or "onto the owning attributes")?dj.Topseed is read before delete time, like the keys of a group that iterates. Should "Materialization happens only at delete time" name it too?Next
cascade()andtrace()becomeexpand()with theirpart_integritymodes, and the old propagation code is removed.enforceas a row-level check before deleting (Cascade delete: a restriction referencing an earlier-deleted (downstream) table is silently invalidated by reverse-topological delete order — materialize (independent of MySQL 1093) #1496).trace()asexpand(..., "up"), andself.upstreambuilt from the parents of the row amake()will insert (Diagram.trace: descend through master-part boundaries (Merge-table upstream tracing) #1481).+, operators that raise,prune()), and the deprecation ofrestrict()(Diagram.restrict: apply Part conditions through the Master to preserve master-part compositional integrity #1501).