Funktionierender Prototyp des Serious Games zur Vermittlung von Wissen zu Software-Engineering-Arbeitsmodellen.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

loader.py 16KB

1 year ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import pkgutil
  2. import sys
  3. from importlib import import_module, reload
  4. from django.apps import apps
  5. from django.conf import settings
  6. from django.db.migrations.graph import MigrationGraph
  7. from django.db.migrations.recorder import MigrationRecorder
  8. from .exceptions import (
  9. AmbiguityError,
  10. BadMigrationError,
  11. InconsistentMigrationHistory,
  12. NodeNotFoundError,
  13. )
  14. MIGRATIONS_MODULE_NAME = "migrations"
  15. class MigrationLoader:
  16. """
  17. Load migration files from disk and their status from the database.
  18. Migration files are expected to live in the "migrations" directory of
  19. an app. Their names are entirely unimportant from a code perspective,
  20. but will probably follow the 1234_name.py convention.
  21. On initialization, this class will scan those directories, and open and
  22. read the Python files, looking for a class called Migration, which should
  23. inherit from django.db.migrations.Migration. See
  24. django.db.migrations.migration for what that looks like.
  25. Some migrations will be marked as "replacing" another set of migrations.
  26. These are loaded into a separate set of migrations away from the main ones.
  27. If all the migrations they replace are either unapplied or missing from
  28. disk, then they are injected into the main set, replacing the named migrations.
  29. Any dependency pointers to the replaced migrations are re-pointed to the
  30. new migration.
  31. This does mean that this class MUST also talk to the database as well as
  32. to disk, but this is probably fine. We're already not just operating
  33. in memory.
  34. """
  35. def __init__(
  36. self,
  37. connection,
  38. load=True,
  39. ignore_no_migrations=False,
  40. replace_migrations=True,
  41. ):
  42. self.connection = connection
  43. self.disk_migrations = None
  44. self.applied_migrations = None
  45. self.ignore_no_migrations = ignore_no_migrations
  46. self.replace_migrations = replace_migrations
  47. if load:
  48. self.build_graph()
  49. @classmethod
  50. def migrations_module(cls, app_label):
  51. """
  52. Return the path to the migrations module for the specified app_label
  53. and a boolean indicating if the module is specified in
  54. settings.MIGRATION_MODULE.
  55. """
  56. if app_label in settings.MIGRATION_MODULES:
  57. return settings.MIGRATION_MODULES[app_label], True
  58. else:
  59. app_package_name = apps.get_app_config(app_label).name
  60. return "%s.%s" % (app_package_name, MIGRATIONS_MODULE_NAME), False
  61. def load_disk(self):
  62. """Load the migrations from all INSTALLED_APPS from disk."""
  63. self.disk_migrations = {}
  64. self.unmigrated_apps = set()
  65. self.migrated_apps = set()
  66. for app_config in apps.get_app_configs():
  67. # Get the migrations module directory
  68. module_name, explicit = self.migrations_module(app_config.label)
  69. if module_name is None:
  70. self.unmigrated_apps.add(app_config.label)
  71. continue
  72. was_loaded = module_name in sys.modules
  73. try:
  74. module = import_module(module_name)
  75. except ModuleNotFoundError as e:
  76. if (explicit and self.ignore_no_migrations) or (
  77. not explicit and MIGRATIONS_MODULE_NAME in e.name.split(".")
  78. ):
  79. self.unmigrated_apps.add(app_config.label)
  80. continue
  81. raise
  82. else:
  83. # Module is not a package (e.g. migrations.py).
  84. if not hasattr(module, "__path__"):
  85. self.unmigrated_apps.add(app_config.label)
  86. continue
  87. # Empty directories are namespaces. Namespace packages have no
  88. # __file__ and don't use a list for __path__. See
  89. # https://docs.python.org/3/reference/import.html#namespace-packages
  90. if getattr(module, "__file__", None) is None and not isinstance(
  91. module.__path__, list
  92. ):
  93. self.unmigrated_apps.add(app_config.label)
  94. continue
  95. # Force a reload if it's already loaded (tests need this)
  96. if was_loaded:
  97. reload(module)
  98. self.migrated_apps.add(app_config.label)
  99. migration_names = {
  100. name
  101. for _, name, is_pkg in pkgutil.iter_modules(module.__path__)
  102. if not is_pkg and name[0] not in "_~"
  103. }
  104. # Load migrations
  105. for migration_name in migration_names:
  106. migration_path = "%s.%s" % (module_name, migration_name)
  107. try:
  108. migration_module = import_module(migration_path)
  109. except ImportError as e:
  110. if "bad magic number" in str(e):
  111. raise ImportError(
  112. "Couldn't import %r as it appears to be a stale "
  113. ".pyc file." % migration_path
  114. ) from e
  115. else:
  116. raise
  117. if not hasattr(migration_module, "Migration"):
  118. raise BadMigrationError(
  119. "Migration %s in app %s has no Migration class"
  120. % (migration_name, app_config.label)
  121. )
  122. self.disk_migrations[
  123. app_config.label, migration_name
  124. ] = migration_module.Migration(
  125. migration_name,
  126. app_config.label,
  127. )
  128. def get_migration(self, app_label, name_prefix):
  129. """Return the named migration or raise NodeNotFoundError."""
  130. return self.graph.nodes[app_label, name_prefix]
  131. def get_migration_by_prefix(self, app_label, name_prefix):
  132. """
  133. Return the migration(s) which match the given app label and name_prefix.
  134. """
  135. # Do the search
  136. results = []
  137. for migration_app_label, migration_name in self.disk_migrations:
  138. if migration_app_label == app_label and migration_name.startswith(
  139. name_prefix
  140. ):
  141. results.append((migration_app_label, migration_name))
  142. if len(results) > 1:
  143. raise AmbiguityError(
  144. "There is more than one migration for '%s' with the prefix '%s'"
  145. % (app_label, name_prefix)
  146. )
  147. elif not results:
  148. raise KeyError(
  149. f"There is no migration for '{app_label}' with the prefix "
  150. f"'{name_prefix}'"
  151. )
  152. else:
  153. return self.disk_migrations[results[0]]
  154. def check_key(self, key, current_app):
  155. if (key[1] != "__first__" and key[1] != "__latest__") or key in self.graph:
  156. return key
  157. # Special-case __first__, which means "the first migration" for
  158. # migrated apps, and is ignored for unmigrated apps. It allows
  159. # makemigrations to declare dependencies on apps before they even have
  160. # migrations.
  161. if key[0] == current_app:
  162. # Ignore __first__ references to the same app (#22325)
  163. return
  164. if key[0] in self.unmigrated_apps:
  165. # This app isn't migrated, but something depends on it.
  166. # The models will get auto-added into the state, though
  167. # so we're fine.
  168. return
  169. if key[0] in self.migrated_apps:
  170. try:
  171. if key[1] == "__first__":
  172. return self.graph.root_nodes(key[0])[0]
  173. else: # "__latest__"
  174. return self.graph.leaf_nodes(key[0])[0]
  175. except IndexError:
  176. if self.ignore_no_migrations:
  177. return None
  178. else:
  179. raise ValueError(
  180. "Dependency on app with no migrations: %s" % key[0]
  181. )
  182. raise ValueError("Dependency on unknown app: %s" % key[0])
  183. def add_internal_dependencies(self, key, migration):
  184. """
  185. Internal dependencies need to be added first to ensure `__first__`
  186. dependencies find the correct root node.
  187. """
  188. for parent in migration.dependencies:
  189. # Ignore __first__ references to the same app.
  190. if parent[0] == key[0] and parent[1] != "__first__":
  191. self.graph.add_dependency(migration, key, parent, skip_validation=True)
  192. def add_external_dependencies(self, key, migration):
  193. for parent in migration.dependencies:
  194. # Skip internal dependencies
  195. if key[0] == parent[0]:
  196. continue
  197. parent = self.check_key(parent, key[0])
  198. if parent is not None:
  199. self.graph.add_dependency(migration, key, parent, skip_validation=True)
  200. for child in migration.run_before:
  201. child = self.check_key(child, key[0])
  202. if child is not None:
  203. self.graph.add_dependency(migration, child, key, skip_validation=True)
  204. def build_graph(self):
  205. """
  206. Build a migration dependency graph using both the disk and database.
  207. You'll need to rebuild the graph if you apply migrations. This isn't
  208. usually a problem as generally migration stuff runs in a one-shot process.
  209. """
  210. # Load disk data
  211. self.load_disk()
  212. # Load database data
  213. if self.connection is None:
  214. self.applied_migrations = {}
  215. else:
  216. recorder = MigrationRecorder(self.connection)
  217. self.applied_migrations = recorder.applied_migrations()
  218. # To start, populate the migration graph with nodes for ALL migrations
  219. # and their dependencies. Also make note of replacing migrations at this step.
  220. self.graph = MigrationGraph()
  221. self.replacements = {}
  222. for key, migration in self.disk_migrations.items():
  223. self.graph.add_node(key, migration)
  224. # Replacing migrations.
  225. if migration.replaces:
  226. self.replacements[key] = migration
  227. for key, migration in self.disk_migrations.items():
  228. # Internal (same app) dependencies.
  229. self.add_internal_dependencies(key, migration)
  230. # Add external dependencies now that the internal ones have been resolved.
  231. for key, migration in self.disk_migrations.items():
  232. self.add_external_dependencies(key, migration)
  233. # Carry out replacements where possible and if enabled.
  234. if self.replace_migrations:
  235. for key, migration in self.replacements.items():
  236. # Get applied status of each of this migration's replacement
  237. # targets.
  238. applied_statuses = [
  239. (target in self.applied_migrations) for target in migration.replaces
  240. ]
  241. # The replacing migration is only marked as applied if all of
  242. # its replacement targets are.
  243. if all(applied_statuses):
  244. self.applied_migrations[key] = migration
  245. else:
  246. self.applied_migrations.pop(key, None)
  247. # A replacing migration can be used if either all or none of
  248. # its replacement targets have been applied.
  249. if all(applied_statuses) or (not any(applied_statuses)):
  250. self.graph.remove_replaced_nodes(key, migration.replaces)
  251. else:
  252. # This replacing migration cannot be used because it is
  253. # partially applied. Remove it from the graph and remap
  254. # dependencies to it (#25945).
  255. self.graph.remove_replacement_node(key, migration.replaces)
  256. # Ensure the graph is consistent.
  257. try:
  258. self.graph.validate_consistency()
  259. except NodeNotFoundError as exc:
  260. # Check if the missing node could have been replaced by any squash
  261. # migration but wasn't because the squash migration was partially
  262. # applied before. In that case raise a more understandable exception
  263. # (#23556).
  264. # Get reverse replacements.
  265. reverse_replacements = {}
  266. for key, migration in self.replacements.items():
  267. for replaced in migration.replaces:
  268. reverse_replacements.setdefault(replaced, set()).add(key)
  269. # Try to reraise exception with more detail.
  270. if exc.node in reverse_replacements:
  271. candidates = reverse_replacements.get(exc.node, set())
  272. is_replaced = any(
  273. candidate in self.graph.nodes for candidate in candidates
  274. )
  275. if not is_replaced:
  276. tries = ", ".join("%s.%s" % c for c in candidates)
  277. raise NodeNotFoundError(
  278. "Migration {0} depends on nonexistent node ('{1}', '{2}'). "
  279. "Django tried to replace migration {1}.{2} with any of [{3}] "
  280. "but wasn't able to because some of the replaced migrations "
  281. "are already applied.".format(
  282. exc.origin, exc.node[0], exc.node[1], tries
  283. ),
  284. exc.node,
  285. ) from exc
  286. raise
  287. self.graph.ensure_not_cyclic()
  288. def check_consistent_history(self, connection):
  289. """
  290. Raise InconsistentMigrationHistory if any applied migrations have
  291. unapplied dependencies.
  292. """
  293. recorder = MigrationRecorder(connection)
  294. applied = recorder.applied_migrations()
  295. for migration in applied:
  296. # If the migration is unknown, skip it.
  297. if migration not in self.graph.nodes:
  298. continue
  299. for parent in self.graph.node_map[migration].parents:
  300. if parent not in applied:
  301. # Skip unapplied squashed migrations that have all of their
  302. # `replaces` applied.
  303. if parent in self.replacements:
  304. if all(
  305. m in applied for m in self.replacements[parent].replaces
  306. ):
  307. continue
  308. raise InconsistentMigrationHistory(
  309. "Migration {}.{} is applied before its dependency "
  310. "{}.{} on database '{}'.".format(
  311. migration[0],
  312. migration[1],
  313. parent[0],
  314. parent[1],
  315. connection.alias,
  316. )
  317. )
  318. def detect_conflicts(self):
  319. """
  320. Look through the loaded graph and detect any conflicts - apps
  321. with more than one leaf migration. Return a dict of the app labels
  322. that conflict with the migration names that conflict.
  323. """
  324. seen_apps = {}
  325. conflicting_apps = set()
  326. for app_label, migration_name in self.graph.leaf_nodes():
  327. if app_label in seen_apps:
  328. conflicting_apps.add(app_label)
  329. seen_apps.setdefault(app_label, set()).add(migration_name)
  330. return {
  331. app_label: sorted(seen_apps[app_label]) for app_label in conflicting_apps
  332. }
  333. def project_state(self, nodes=None, at_end=True):
  334. """
  335. Return a ProjectState object representing the most recent state
  336. that the loaded migrations represent.
  337. See graph.make_state() for the meaning of "nodes" and "at_end".
  338. """
  339. return self.graph.make_state(
  340. nodes=nodes, at_end=at_end, real_apps=self.unmigrated_apps
  341. )
  342. def collect_sql(self, plan):
  343. """
  344. Take a migration plan and return a list of collected SQL statements
  345. that represent the best-efforts version of that plan.
  346. """
  347. statements = []
  348. state = None
  349. for migration, backwards in plan:
  350. with self.connection.schema_editor(
  351. collect_sql=True, atomic=migration.atomic
  352. ) as schema_editor:
  353. if state is None:
  354. state = self.project_state(
  355. (migration.app_label, migration.name), at_end=False
  356. )
  357. if not backwards:
  358. state = migration.apply(state, schema_editor, collect_sql=True)
  359. else:
  360. state = migration.unapply(state, schema_editor, collect_sql=True)
  361. statements.extend(schema_editor.collected_sql)
  362. return statements