Repository navigation
fix(declare): refuse a part reference that closes a master-part group cycle - #1620
Open
dimitri-yatsenko wants to merge 2 commits into
Open
dimitri-yatsenko wants to merge 2 commits into
dimitri-yatsenko wants to merge 2 commits into
Conversation
… cycle A part that references a table which already depends on the part's own master makes the master-part group depend on itself. topo_sort orders each master after every table its parts reference, so it could no longer order such tables, and every delete, drop, Diagram.trace and Schema.list_tables on the connection failed with networkx.NetworkXUnfeasible, including dropping the offending part. - Part._declare_check refuses such a reference before any DDL runs, with an error that names the part, the referenced table and the cycle. A new table has no children, so only a part's reference to a table outside its group can close a cycle, and this check refuses exactly those. - topo_sort raises a DataJointError that names an existing cycle and the part that closes it, and takes an optional set of tables to sort. Cascade and trace propagation, Diagram iteration and Schema.list_tables sort only their own tables, so an existing cycle affects only the operations that must order it, and the offending part can be dropped. - A part that does not inherit its master's primary key, unrenamed and non-nullable, through a reference to the master or a sibling part gets a warning at declaration. - declare() reports every foreign key through a new foreign_keys argument, which the _declare_check hook now receives.
A part is owned by its master when a foreign key in its primary key references the master, or another owned part of the same master; the reference may rename the attributes. Grouping parts by table name alone made topo_sort fail on schemas with no cycle (a part that does not reference its master but references a table downstream of it) and accept a cycle through a part that references its master only through a nullable or secondary foreign key. - owned_parts() reads ownership from the dependency graph through each edge's primary flag; is_owned() decides it for a part being declared. - topo_sort keeps only owned parts with their masters; other parts are sorted as separate tables. group_graph, group_ancestors and group_cycle use owned parts, so a reference to a part the master does not own counts as a reference outside the group. - Part._declare_check checks only owned parts for cycles and warns on a part that is not owned. The foreign-key records drop nullability. - The cascade's sort scope keeps following the name-based part-to-master lift of the current part_integrity engine.
dimitri-yatsenko
requested review from
MilagrosMarin,
lum-agilyti and
ttngu207
October 17, 2026 22:27
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.
Closes #1619.
Owned parts
This PR decides which parts belong to their master by a rule instead of by the table name.
A part is owned by its master when its primary key includes a reference to the master, either directly or through another part the master owns. The reference may rename the key attributes. For example:
-> masterin the primary key: owned.-> master.proj(session="session_id")in the primary key: owned.-> master.Channelin the primary key, whereChannelis owned: owned. This includes renamed references, such as the part-of-part case in Cascade may fail when Part table references another Part with renamed foreign keys #1429.-> masteror-> [nullable] masterbelow the---line, or no reference to the master at all: not owned.An owned part and its master form one group: a master row and its owned part rows are one entity. A part that is not owned is ordered as a separate table, like any other table that references the master.
What changes
Declaration refuses a group cycle.
Part._declare_checkruns after the definition is parsed and before any DDL. It finds out whether the new part is owned. For an owned part, it checks each reference to a table outside the group. If that table already depends on the master, the declaration is refused. Parts the master does not own count as outside the group. For the reproduction in the issue:When the referenced table is a part that the master does not own, the message says so and explains why.
topo_sortuses owned parts. Only owned parts are kept right after their masters, and a master is ordered after the tables its owned parts reference. Other parts are sorted as separate tables. An existing cycle now raises aDataJointErrorthat prints the cycle and names the part that closes it. It also says how to recover: drop that part withdrop(part_integrity='ignore')and declare it without the reference. Before, the error wasnetworkx.exception.NetworkXUnfeasible.Only the needed tables are sorted.
topo_sort(graph, nodes)takes an optional set of tables. It sorts those tables together with the tables on dependency paths between them, so their order still respects every dependency between them, and it returns only the requested tables. Ownership is judged on the whole graph. The callers now pass their own tables:Diagram: the seed's descendants or ancestors. Withpart_integrity="cascade", the cascade also includes the masters that its current engine reaches from their parts.Diagram.__iter__/__reversed__, used bydelete,dropandcounts():nodes_to_show.Schema.list_tables(): the schema's own tables.With an existing cycle, only the operations that must order it now fail. Operations on other tables keep working, and so does dropping the part that closes the cycle.
Warning for a part that is not owned. It is raised at declaration and says that DataJoint sorts the part as a separate table, and that from DataJoint 2.4 delete and trace will also treat it as one. It also says how to make the part owned.
Supporting changes
owned_parts(graph)reads ownership from the dependency graph. Each foreign key is its own edge, renamed ones included, with aprimaryflag that is set when all its attributes are in the referencing table's primary key. A part is owned when a primary edge comes from its master or from an owned part of the same master.dependencies.py:is_ownedhandles a part that is being declared.group_graphis the dependency graph with an edge from each owned part to its master.group_ancestorsandgroup_cyclework on that graph.declare(),prepare_declare()andcompile_foreign_key()take an optionalforeign_keyslist, filled with one(parent_table, attr_map)entry per foreign key.fk_attribute_mapcould not be used here: it keeps one parent per attribute, so a reference whose attributes are all shared with a later reference is missing from it. A new part is owned under the same rule as the graph uses: some reference to the master or to an owned part has all its attributes in the part's primary key._declare_checkhook receivesforeign_keysas a third argument. TheAutoPopulateoverride is updated to match.Dependencies.load_all_upstream()takes optional extra schemas to start from. The check passes the schemas of the referenced tables.Why the check is exact
With owned parts as groups and every other table on its own,
topo_sortsucceeds exactly when the groups do not depend on each other in a cycle. A new table has no children, so it cannot be on a cycle itself. A plain table, a master, or a part that is not owned adds no ordering constraint beyond its own references, so it can never close a cycle. That includes a master declared after a part of its own that does not reference it.An owned part adds one constraint: its master must come after each table outside the group that the part references. A cycle forms exactly when such a table X already depends on the master. "Depends on" follows references upward and, at a master, continues through the references of its owned parts.
group_cycletests exactly this.Changes for existing schemas
topo_sort: an owned part that references a part its master does not own, when that part depends on the master. An example is a part with-> [nullable] masteror a secondary-> master. Name-based grouping sorted such schemas, because the sibling reference stayed inside the group. Under the new rule the sibling is a separate table that depends on the master, so the owned part closes a cycle. Existing schemas of this shape now get the cycle error, with recovery instructions, for the operations that order those tables. This is the only shape that used to sort and no longer does.topo_sortresults, includinglist_tables()andDiagramiteration. They follow the tables they reference.test_upward_u3_nonprimary_master_fkdoes: its part references the master below---. The Cascade may fail when Part table references another Part with renamed foreign keys #1429 renamed part-of-part case is owned and does not warn.Why it is safe
topo_sortfail, which breaks delete, drop, trace andlist_tables(). The changes for existing schemas listed above are why this PR is labeledbreaking.part_integrity.Tests
tests/unit/test_group_cycle.py(no database):topo_sort;Two randomized checks run on random dependency graphs:
topo_sortsucceeds exactly when the graph with each master and its owned parts contracted to one node is acyclic, which is checked with an independent computation of ownership. When it succeeds, every table follows the tables it references, and each master is followed directly by its owned parts.group_cyclerefuses an owned part exactly when adding it would maketopo_sortfail, and no other declaration ever does.tests/integration/test_group_cycle.py(MySQL and PostgreSQL):list_tables()raise aDataJointErrorthat names the cycle and the part, and no rows are deleted. Unrelated tables keep working. Dropping the part and declaring it again without the reference restores the schema.Related suites (cascade, trace, diagram, declare, dependencies, schema, alter, foreign keys, virtual modules, autopopulate, unit): 670 passed, 2 skipped.
Full suite: 1218 passed, 14 skipped. On
masterthe suite gives 1158 passed and 14 skipped; the difference is the 60 new tests.Docs: the master-part specification (datajoint/datajoint-docs#299) can state the ownership rule and the cycle rule.