File: plan.py

package info (click to toggle)
python-django-test-migrations 1.5.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 436 kB
  • sloc: python: 1,479; makefile: 26
file content (129 lines) | stat: -rw-r--r-- 4,488 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
from django.apps import apps
from django.db import DEFAULT_DB_ALIAS, connections
from django.db.migrations import Migration
from django.db.migrations.graph import Node
from django.db.migrations.loader import MigrationLoader

from django_test_migrations.exceptions import MigrationNotInPlan
from django_test_migrations.types import MigrationPlan, MigrationTarget


def all_migrations(
    database: str = DEFAULT_DB_ALIAS,
    app_names: list[str] | None = None,
) -> list[Node]:
    """
    Returns the sorted list of migrations nodes.

    The order is the same as when migrations are applied.

    When you might need this function?
    When you are testing the migration order.

    For example, imagine that you have a direct dependency:
    ``main_app.0002_migration`` and ``other_app.0001_initial``
    where ``other_app.0001_initial`` relies on the model or field
    introduced in ``main_app.0002_migration``.

    You can use ``dependencies`` field
    to ensure that everything works correctly.

    But, sometimes migrations are squashed,
    sometimes they are renamed, refactored, and moved.

    It would be better to have a test that will ensure
    that ``other_app.0001_initial`` comes after ``main_app.0002_migration``.
    And everything works as expected.
    """
    loader = MigrationLoader(connections[database])

    if app_names:
        _validate_app_names(app_names)
        targets = [
            key for key in loader.graph.leaf_nodes() if key[0] in app_names
        ]
    else:
        targets = loader.graph.leaf_nodes()
    return _generate_plan(targets, loader)


def nodes_to_tuples(nodes: list[Node]) -> list[tuple[str, str]]:
    """Utility function to transform nodes to tuples."""
    return [(node[0], node[1]) for node in nodes]


def _validate_app_names(app_names: list[str]) -> None:
    """
    Validates the provided app names.

    Raises ```LookupError`` when incorrect app names are provided.
    """
    for app_name in app_names:
        apps.get_app_config(app_name)


def _generate_plan(
    targets: list[Node],
    loader: MigrationLoader,
) -> list[Node]:
    plan = []
    seen: set[Node] = set()

    # Generate the plan
    for target in targets:
        for migration in loader.graph.forwards_plan(target):
            if migration not in seen:
                node = loader.graph.node_map[migration]
                plan.append(node)
                seen.add(migration)
    return plan


def truncate_plan(
    targets: list[MigrationTarget],
    plan: MigrationPlan,
) -> MigrationPlan:
    """Truncate migrations ``plan`` up to ``targets``.

    This method is used mainly to truncate full/clean migrations plan
    to get as broad plan as possible.
    By "broad" plan we mean plan with all targets migrations included
    as well as all older migrations not related with targets.
    "Broad" plan is needed mostly to make ``Migrator`` API developers'
    friendly, just to not force developers to include migrations targets
    for all other models they want to use in test (e.g. to setup some
    model instances) in ``migrate_from``.
    Such plan will also produce database state really similar to state
    from our production environment just before new migrations are applied.
    Migrations plan for targets generated by Django's
    ``MigrationExecutor.migration_plan`` is minimum plan needed to apply
    targets migrations, it includes only migrations targets with all its
    dependencies, so it doesn't fit to our approach, that's why following
    function is needed.

    """
    if not targets or not plan:
        return plan

    target_max_index = max(_get_index(target, plan) for target in targets)
    return plan[: (target_max_index + 1)]


def _get_index(target: MigrationTarget, plan: MigrationPlan) -> int:
    try:
        index = next(
            index
            for index, (migration, _) in enumerate(plan)
            if _filter_predicate(target, migration)
        )
    except StopIteration:
        raise MigrationNotInPlan(target) from None
    return index - (target[1] is None)


def _filter_predicate(target: MigrationTarget, migration: Migration) -> bool:
    # when ``None`` passed as migration name then initial migration from
    # target's app will be chosen and handled properly in ``_get_index``
    # so in final all target app migrations will be excluded from plan
    index = 2 - (target[1] is None)
    return (migration.app_label, migration.name)[:index] == target[:index]