Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Fixed
- Migrations now honor ``db_constraint=False`` on ``ForeignKeyField``/``ManyToManyField`` and emit the column (and through table) without a ``FOREIGN KEY`` constraint, matching the runtime schema generator. (#2226)
- Fix ``field__not=None`` filter returning NULL rows instead of non-NULL rows. (#2353)
- ``Case``/``When`` and aggregate ``_filter`` expressions now keep the joins for related fields they reference, so e.g. ``When(tournament__name="a", then=1)`` no longer fails with a missing table. (#2354)
- ``MigrationGraph.leaf_nodes()``/``root_nodes()`` with an app label no longer treat an edge from another app as making that app leafless or rootless. ``migrate``/``plan`` for ``app.__latest__``/``app.__first__`` planned nothing (or rolled nothing back) when the app's newest migration was depended on from another app, or its first migration depended on another app. (#2305)

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.

Use #2307 instead of #2305


1.1.9
-----
Expand Down
33 changes: 33 additions & 0 deletions tests/migrations/test_migration_graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Tests for app-scoped `MigrationGraph.leaf_nodes()` / `root_nodes()`."""

from __future__ import annotations

from tortoise.migrations.graph import MigrationGraph, MigrationKey
from tortoise.migrations.migration import Migration


def _build_cross_app_graph() -> MigrationGraph:
graph = MigrationGraph()
a1 = MigrationKey("a", "0001_initial")
a2 = MigrationKey("a", "0002_second")
b1 = MigrationKey("b", "0001_initial")
for key in (a1, a2, b1):
graph.add_node(key, Migration(key.name, key.app_label))
graph.add_dependency(a2, a2, a1) # a.0002 depends on a.0001
graph.add_dependency(b1, b1, a2) # b.0001 depends on a.0002 (cross-app edge)
return graph


def test_leaf_and_root_nodes_ignore_cross_app_edges() -> None:
graph = _build_cross_app_graph()
# a.0002 is depended on by b.0001, but that edge is not an `a` migration, so `a` still
# has a leaf; the same holds for the root of `b`.
assert graph.leaf_nodes("a") == [MigrationKey("a", "0002_second")]
assert graph.root_nodes("b") == [MigrationKey("b", "0001_initial")]


def test_leaf_and_root_nodes_without_app_label_keep_any_app_edges() -> None:
graph = _build_cross_app_graph()
# The global call still counts edges from every app.
assert graph.leaf_nodes() == [MigrationKey("b", "0001_initial")]
assert graph.root_nodes() == [MigrationKey("a", "0001_initial")]
35 changes: 24 additions & 11 deletions tortoise/migrations/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing import Literal

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.

typing is part of the standard library and is already imported on line 5, so there is no need to put it under TYPE_CHECKING.


from tortoise.migrations.migration import Migration


Expand Down Expand Up @@ -110,21 +112,32 @@ def validate_consistency(self) -> None:
if isinstance(node, DummyNode):
node.raise_error()

def root_nodes(self, app_label: str | None = None) -> list[MigrationKey]:
nodes = [
def _filter_boundary_keys(
self, attr: Literal["parents", "children"], app_label: str | None
) -> list[MigrationKey]:
nodes = self.node_map.values()
if app_label is None:
return sorted([node.key for node in nodes if not getattr(node, attr)])
keys = [
node.key
for node in self.node_map.values()
if not node.parents and (app_label is None or node.key.app_label == app_label)
for node in nodes
if node.key.app_label == app_label
and not self._has_related_node(getattr(node, attr), app_label)
]
return sorted(nodes)
return sorted(keys)

def root_nodes(self, app_label: str | None = None) -> list[MigrationKey]:
return self._filter_boundary_keys("parents", app_label)

def leaf_nodes(self, app_label: str | None = None) -> list[MigrationKey]:
nodes = [
node.key
for node in self.node_map.values()
if not node.children and (app_label is None or node.key.app_label == app_label)
]
return sorted(nodes)
return self._filter_boundary_keys("children", app_label)

@staticmethod
def _has_related_node(related: set[Node], app_label: str | None) -> bool:

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.

How about:

    @staticmethod
    def _has_related_node(related: set[Node], app_label: str | None) -> bool:
        # For a single-app request only same-app edges decide whether that app has a root or
        # a leaf: an app whose newest migration is depended on from another app, or whose
        # first migration depends on another app, still has its own target.
        return any(node.key.app_label == app_label for node in related)

    def _filter_boundary_keys(
        self, attr: Literal["parents", "children"], app_label: str | None
    ) -> list[MigrationKey]:
        nodes = self.node_map.values()
        if app_label is None:
            return sorted([node.key for node in nodes if not getattr(node, attr)])
        keys = [
            node.key
            for node in nodes
            if node.key.app_label == app_label
            and not self._has_related_node(node.parents, app_label)
        ]
        return sorted(keys)

    def root_nodes(self, app_label: str | None = None) -> list[MigrationKey]:
        return self._filter_boundary_keys("parents", app_label)

    def leaf_nodes(self, app_label: str | None = None) -> list[MigrationKey]:
        return self._filter_boundary_keys("children", app_label)

@inchang-ing inchang-ing Oct 16, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good call — adopted. _filter_boundary_keys now takes the attr and handles the global case once up front, and _has_related_node is a plain same-app check without the app_label is None branch. One correction to the sketch: it hardcoded node.parents, so leaf_nodes() would have kept using parents; it uses getattr(node, attr) now. Rebased onto current develop while at it (the changelog was the only conflict).

# For a single-app request only same-app edges decide whether that app has a root or
# a leaf: an app whose newest migration is depended on from another app, or whose
# first migration depends on another app, still has its own target.
return any(node.key.app_label == app_label for node in related)

def forwards_plan(self, target: MigrationKey) -> list[MigrationKey]:
if target not in self.nodes:
Expand Down
Loading