Coverage for /Users/davegaeddert/Development/dropseed/plain/plain-models/plain/models/migrations/migration.py: 38%

102 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-16 22:03 -0500

1import re 

2 

3from plain.models.migrations.utils import get_migration_name_timestamp 

4from plain.models.transaction import atomic 

5 

6from .exceptions import IrreversibleError 

7 

8 

9class Migration: 

10 """ 

11 The base class for all migrations. 

12 

13 Migration files will import this from plain.models.migrations.Migration 

14 and subclass it as a class called Migration. It will have one or more 

15 of the following attributes: 

16 

17 - operations: A list of Operation instances, probably from 

18 plain.models.migrations.operations 

19 - dependencies: A list of tuples of (app_path, migration_name) 

20 - run_before: A list of tuples of (app_path, migration_name) 

21 - replaces: A list of migration_names 

22 

23 Note that all migrations come out of migrations and into the Loader or 

24 Graph as instances, having been initialized with their app label and name. 

25 """ 

26 

27 # Operations to apply during this migration, in order. 

28 operations = [] 

29 

30 # Other migrations that should be run before this migration. 

31 # Should be a list of (app, migration_name). 

32 dependencies = [] 

33 

34 # Other migrations that should be run after this one (i.e. have 

35 # this migration added to their dependencies). Useful to make third-party 

36 # packages' migrations run after your AUTH_USER replacement, for example. 

37 run_before = [] 

38 

39 # Migration names in this app that this migration replaces. If this is 

40 # non-empty, this migration will only be applied if all these migrations 

41 # are not applied. 

42 replaces = [] 

43 

44 # Is this an initial migration? Initial migrations are skipped on 

45 # --fake-initial if the table or fields already exist. If None, check if 

46 # the migration has any dependencies to determine if there are dependencies 

47 # to tell if db introspection needs to be done. If True, always perform 

48 # introspection. If False, never perform introspection. 

49 initial = None 

50 

51 # Whether to wrap the whole migration in a transaction. Only has an effect 

52 # on database backends which support transactional DDL. 

53 atomic = True 

54 

55 def __init__(self, name, package_label): 

56 self.name = name 

57 self.package_label = package_label 

58 # Copy dependencies & other attrs as we might mutate them at runtime 

59 self.operations = list(self.__class__.operations) 

60 self.dependencies = list(self.__class__.dependencies) 

61 self.run_before = list(self.__class__.run_before) 

62 self.replaces = list(self.__class__.replaces) 

63 

64 def __eq__(self, other): 

65 return ( 

66 isinstance(other, Migration) 

67 and self.name == other.name 

68 and self.package_label == other.package_label 

69 ) 

70 

71 def __repr__(self): 

72 return f"<Migration {self.package_label}.{self.name}>" 

73 

74 def __str__(self): 

75 return f"{self.package_label}.{self.name}" 

76 

77 def __hash__(self): 

78 return hash(f"{self.package_label}.{self.name}") 

79 

80 def mutate_state(self, project_state, preserve=True): 

81 """ 

82 Take a ProjectState and return a new one with the migration's 

83 operations applied to it. Preserve the original object state by 

84 default and return a mutated state from a copy. 

85 """ 

86 new_state = project_state 

87 if preserve: 

88 new_state = project_state.clone() 

89 

90 for operation in self.operations: 

91 operation.state_forwards(self.package_label, new_state) 

92 return new_state 

93 

94 def apply(self, project_state, schema_editor, collect_sql=False): 

95 """ 

96 Take a project_state representing all migrations prior to this one 

97 and a schema_editor for a live database and apply the migration 

98 in a forwards order. 

99 

100 Return the resulting project state for efficient reuse by following 

101 Migrations. 

102 """ 

103 for operation in self.operations: 

104 # If this operation cannot be represented as SQL, place a comment 

105 # there instead 

106 if collect_sql: 

107 schema_editor.collected_sql.append("--") 

108 schema_editor.collected_sql.append("-- %s" % operation.describe()) 

109 schema_editor.collected_sql.append("--") 

110 if not operation.reduces_to_sql: 

111 schema_editor.collected_sql.append( 

112 "-- THIS OPERATION CANNOT BE WRITTEN AS SQL" 

113 ) 

114 continue 

115 collected_sql_before = len(schema_editor.collected_sql) 

116 # Save the state before the operation has run 

117 old_state = project_state.clone() 

118 operation.state_forwards(self.package_label, project_state) 

119 # Run the operation 

120 atomic_operation = operation.atomic or ( 

121 self.atomic and operation.atomic is not False 

122 ) 

123 if not schema_editor.atomic_migration and atomic_operation: 

124 # Force a transaction on a non-transactional-DDL backend or an 

125 # atomic operation inside a non-atomic migration. 

126 with atomic(schema_editor.connection.alias): 

127 operation.database_forwards( 

128 self.package_label, schema_editor, old_state, project_state 

129 ) 

130 else: 

131 # Normal behaviour 

132 operation.database_forwards( 

133 self.package_label, schema_editor, old_state, project_state 

134 ) 

135 if collect_sql and collected_sql_before == len(schema_editor.collected_sql): 

136 schema_editor.collected_sql.append("-- (no-op)") 

137 return project_state 

138 

139 def unapply(self, project_state, schema_editor, collect_sql=False): 

140 """ 

141 Take a project_state representing all migrations prior to this one 

142 and a schema_editor for a live database and apply the migration 

143 in a reverse order. 

144 

145 The backwards migration process consists of two phases: 

146 

147 1. The intermediate states from right before the first until right 

148 after the last operation inside this migration are preserved. 

149 2. The operations are applied in reverse order using the states 

150 recorded in step 1. 

151 """ 

152 # Construct all the intermediate states we need for a reverse migration 

153 to_run = [] 

154 new_state = project_state 

155 # Phase 1 

156 for operation in self.operations: 

157 # If it's irreversible, error out 

158 if not operation.reversible: 

159 raise IrreversibleError( 

160 f"Operation {operation} in {self} is not reversible" 

161 ) 

162 # Preserve new state from previous run to not tamper the same state 

163 # over all operations 

164 new_state = new_state.clone() 

165 old_state = new_state.clone() 

166 operation.state_forwards(self.package_label, new_state) 

167 to_run.insert(0, (operation, old_state, new_state)) 

168 

169 # Phase 2 

170 for operation, to_state, from_state in to_run: 

171 if collect_sql: 

172 schema_editor.collected_sql.append("--") 

173 schema_editor.collected_sql.append("-- %s" % operation.describe()) 

174 schema_editor.collected_sql.append("--") 

175 if not operation.reduces_to_sql: 

176 schema_editor.collected_sql.append( 

177 "-- THIS OPERATION CANNOT BE WRITTEN AS SQL" 

178 ) 

179 continue 

180 collected_sql_before = len(schema_editor.collected_sql) 

181 atomic_operation = operation.atomic or ( 

182 self.atomic and operation.atomic is not False 

183 ) 

184 if not schema_editor.atomic_migration and atomic_operation: 

185 # Force a transaction on a non-transactional-DDL backend or an 

186 # atomic operation inside a non-atomic migration. 

187 with atomic(schema_editor.connection.alias): 

188 operation.database_backwards( 

189 self.package_label, schema_editor, from_state, to_state 

190 ) 

191 else: 

192 # Normal behaviour 

193 operation.database_backwards( 

194 self.package_label, schema_editor, from_state, to_state 

195 ) 

196 if collect_sql and collected_sql_before == len(schema_editor.collected_sql): 

197 schema_editor.collected_sql.append("-- (no-op)") 

198 return project_state 

199 

200 def suggest_name(self): 

201 """ 

202 Suggest a name for the operations this migration might represent. Names 

203 are not guaranteed to be unique, but put some effort into the fallback 

204 name to avoid VCS conflicts if possible. 

205 """ 

206 if self.initial: 

207 return "initial" 

208 

209 raw_fragments = [op.migration_name_fragment for op in self.operations] 

210 fragments = [re.sub(r"\W+", "_", name) for name in raw_fragments if name] 

211 

212 if not fragments or len(fragments) != len(self.operations): 

213 return "auto_%s" % get_migration_name_timestamp() 

214 

215 name = fragments[0] 

216 for fragment in fragments[1:]: 

217 new_name = f"{name}_{fragment}" 

218 if len(new_name) > 52: 

219 name = f"{name}_and_more" 

220 break 

221 name = new_name 

222 return name 

223 

224 

225class SwappableTuple(tuple): 

226 """ 

227 Subclass of tuple so Plain can tell this was originally a swappable 

228 dependency when it reads the migration file. 

229 """ 

230 

231 def __new__(cls, value, setting): 

232 self = tuple.__new__(cls, value) 

233 self.setting = setting 

234 return self 

235 

236 

237def swappable_dependency(value): 

238 """Turn a setting value into a dependency.""" 

239 return SwappableTuple((value.split(".", 1)[0], "__first__"), value)