Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain-models/plain/models/backends/base/creation.py: 42%

110 statements  

« prev     ^ index     » next       coverage.py v7.6.9, created at 2024-12-23 11:16 -0600

1import os 

2import sys 

3 

4from plain.packages import packages 

5from plain.runtime import settings 

6 

7# The prefix to put on the default database name when creating 

8# the test database. 

9TEST_DATABASE_PREFIX = "test_" 

10 

11 

12class BaseDatabaseCreation: 

13 """ 

14 Encapsulate backend-specific differences pertaining to creation and 

15 destruction of the test database. 

16 """ 

17 

18 def __init__(self, connection): 

19 self.connection = connection 

20 

21 def _nodb_cursor(self): 

22 return self.connection._nodb_cursor() 

23 

24 def log(self, msg): 

25 sys.stderr.write(msg + os.linesep) 

26 

27 def create_test_db( 

28 self, verbosity=1, autoclobber=False, serialize=True, keepdb=False 

29 ): 

30 """ 

31 Create a test database, prompting the user for confirmation if the 

32 database already exists. Return the name of the test database created. 

33 """ 

34 from plain.models.cli import migrate 

35 

36 test_database_name = self._get_test_db_name() 

37 

38 if verbosity >= 1: 

39 action = "Creating" 

40 if keepdb: 

41 action = "Using existing" 

42 

43 self.log( 

44 f"{action} test database for alias {self._get_database_display_str(verbosity, test_database_name)}..." 

45 ) 

46 

47 # We could skip this call if keepdb is True, but we instead 

48 # give it the keepdb param. This is to handle the case 

49 # where the test DB doesn't exist, in which case we need to 

50 # create it, then just not destroy it. If we instead skip 

51 # this, we will get an exception. 

52 self._create_test_db(verbosity, autoclobber, keepdb) 

53 

54 self.connection.close() 

55 settings.DATABASES[self.connection.alias]["NAME"] = test_database_name 

56 self.connection.settings_dict["NAME"] = test_database_name 

57 

58 try: 

59 if self.connection.settings_dict["TEST"]["MIGRATE"] is False: 

60 # Disable migrations for all packages. 

61 for app in packages.get_package_configs(): 

62 app._old_migrations_module = app.migrations_module 

63 app.migrations_module = None 

64 # We report migrate messages at one level lower than that 

65 # requested. This ensures we don't get flooded with messages during 

66 # testing (unless you really ask to be flooded). 

67 migrate.callback( 

68 package_label=None, 

69 migration_name=None, 

70 no_input=True, 

71 database=self.connection.alias, 

72 fake=False, 

73 fake_initial=False, 

74 plan=False, 

75 check_unapplied=False, 

76 run_syncdb=True, 

77 prune=False, 

78 verbosity=max(verbosity - 1, 0), 

79 ) 

80 finally: 

81 if self.connection.settings_dict["TEST"]["MIGRATE"] is False: 

82 for app in packages.get_package_configs(): 

83 app.migrations_module = app._old_migrations_module 

84 del app._old_migrations_module 

85 

86 # We then serialize the current state of the database into a string 

87 # and store it on the connection. This slightly horrific process is so people 

88 # who are testing on databases without transactions or who are using 

89 # a TransactionTestCase still get a clean database on every test run. 

90 # if serialize: 

91 # self.connection._test_serialized_contents = self.serialize_db_to_string() 

92 

93 # Ensure a connection for the side effect of initializing the test database. 

94 self.connection.ensure_connection() 

95 

96 return test_database_name 

97 

98 def set_as_test_mirror(self, primary_settings_dict): 

99 """ 

100 Set this database up to be used in testing as a mirror of a primary 

101 database whose settings are given. 

102 """ 

103 self.connection.settings_dict["NAME"] = primary_settings_dict["NAME"] 

104 

105 # def serialize_db_to_string(self): 

106 # """ 

107 # Serialize all data in the database into a JSON string. 

108 # Designed only for test runner usage; will not handle large 

109 # amounts of data. 

110 # """ 

111 

112 # # Iteratively return every object for all models to serialize. 

113 # def get_objects(): 

114 # from plain.models.migrations.loader import MigrationLoader 

115 

116 # loader = MigrationLoader(self.connection) 

117 # for package_config in packages.get_package_configs(): 

118 # if ( 

119 # package_config.models_module is not None 

120 # and package_config.label in loader.migrated_packages 

121 # ): 

122 # for model in package_config.get_models(): 

123 # if model._meta.can_migrate( 

124 # self.connection 

125 # ) and router.allow_migrate_model(self.connection.alias, model): 

126 # queryset = model._base_manager.using( 

127 # self.connection.alias, 

128 # ).order_by(model._meta.pk.name) 

129 # yield from queryset.iterator() 

130 

131 # # Serialize to a string 

132 # out = StringIO() 

133 # serializers.serialize("json", get_objects(), indent=None, stream=out) 

134 # return out.getvalue() 

135 

136 # def deserialize_db_from_string(self, data): 

137 # """ 

138 # Reload the database with data from a string generated by 

139 # the serialize_db_to_string() method. 

140 # """ 

141 # data = StringIO(data) 

142 # table_names = set() 

143 # # Load data in a transaction to handle forward references and cycles. 

144 # with atomic(using=self.connection.alias): 

145 # # Disable constraint checks, because some databases (MySQL) doesn't 

146 # # support deferred checks. 

147 # with self.connection.constraint_checks_disabled(): 

148 # for obj in serializers.deserialize( 

149 # "json", data, using=self.connection.alias 

150 # ): 

151 # obj.save() 

152 # table_names.add(obj.object.__class__._meta.db_table) 

153 # # Manually check for any invalid keys that might have been added, 

154 # # because constraint checks were disabled. 

155 # self.connection.check_constraints(table_names=table_names) 

156 

157 def _get_database_display_str(self, verbosity, database_name): 

158 """ 

159 Return display string for a database for use in various actions. 

160 """ 

161 return "'{}'{}".format( 

162 self.connection.alias, 

163 (f" ('{database_name}')") if verbosity >= 2 else "", 

164 ) 

165 

166 def _get_test_db_name(self): 

167 """ 

168 Internal implementation - return the name of the test DB that will be 

169 created. Only useful when called from create_test_db() and 

170 _create_test_db() and when no external munging is done with the 'NAME' 

171 settings. 

172 """ 

173 if self.connection.settings_dict["TEST"]["NAME"]: 

174 return self.connection.settings_dict["TEST"]["NAME"] 

175 return TEST_DATABASE_PREFIX + self.connection.settings_dict["NAME"] 

176 

177 def _execute_create_test_db(self, cursor, parameters, keepdb=False): 

178 cursor.execute("CREATE DATABASE {dbname} {suffix}".format(**parameters)) 

179 

180 def _create_test_db(self, verbosity, autoclobber, keepdb=False): 

181 """ 

182 Internal implementation - create the test db tables. 

183 """ 

184 test_database_name = self._get_test_db_name() 

185 test_db_params = { 

186 "dbname": self.connection.ops.quote_name(test_database_name), 

187 "suffix": self.sql_table_creation_suffix(), 

188 } 

189 # Create the test database and connect to it. 

190 with self._nodb_cursor() as cursor: 

191 try: 

192 self._execute_create_test_db(cursor, test_db_params, keepdb) 

193 except Exception as e: 

194 # if we want to keep the db, then no need to do any of the below, 

195 # just return and skip it all. 

196 if keepdb: 

197 return test_database_name 

198 

199 self.log(f"Got an error creating the test database: {e}") 

200 if not autoclobber: 

201 confirm = input( 

202 "Type 'yes' if you would like to try deleting the test " 

203 f"database '{test_database_name}', or 'no' to cancel: " 

204 ) 

205 if autoclobber or confirm == "yes": 

206 try: 

207 if verbosity >= 1: 

208 self.log( 

209 "Destroying old test database for alias {}...".format( 

210 self._get_database_display_str( 

211 verbosity, test_database_name 

212 ), 

213 ) 

214 ) 

215 cursor.execute( 

216 "DROP DATABASE {dbname}".format(**test_db_params) 

217 ) 

218 self._execute_create_test_db(cursor, test_db_params, keepdb) 

219 except Exception as e: 

220 self.log(f"Got an error recreating the test database: {e}") 

221 sys.exit(2) 

222 else: 

223 self.log("Tests cancelled.") 

224 sys.exit(1) 

225 

226 return test_database_name 

227 

228 def clone_test_db(self, suffix, verbosity=1, autoclobber=False, keepdb=False): 

229 """ 

230 Clone a test database. 

231 """ 

232 source_database_name = self.connection.settings_dict["NAME"] 

233 

234 if verbosity >= 1: 

235 action = "Cloning test database" 

236 if keepdb: 

237 action = "Using existing clone" 

238 self.log( 

239 f"{action} for alias {self._get_database_display_str(verbosity, source_database_name)}..." 

240 ) 

241 

242 # We could skip this call if keepdb is True, but we instead 

243 # give it the keepdb param. See create_test_db for details. 

244 self._clone_test_db(suffix, verbosity, keepdb) 

245 

246 def get_test_db_clone_settings(self, suffix): 

247 """ 

248 Return a modified connection settings dict for the n-th clone of a DB. 

249 """ 

250 # When this function is called, the test database has been created 

251 # already and its name has been copied to settings_dict['NAME'] so 

252 # we don't need to call _get_test_db_name. 

253 orig_settings_dict = self.connection.settings_dict 

254 return { 

255 **orig_settings_dict, 

256 "NAME": "{}_{}".format(orig_settings_dict["NAME"], suffix), 

257 } 

258 

259 def _clone_test_db(self, suffix, verbosity, keepdb=False): 

260 """ 

261 Internal implementation - duplicate the test db tables. 

262 """ 

263 raise NotImplementedError( 

264 "The database backend doesn't support cloning databases. " 

265 "Disable the option to run tests in parallel processes." 

266 ) 

267 

268 def destroy_test_db( 

269 self, old_database_name=None, verbosity=1, keepdb=False, suffix=None 

270 ): 

271 """ 

272 Destroy a test database, prompting the user for confirmation if the 

273 database already exists. 

274 """ 

275 self.connection.close() 

276 if suffix is None: 

277 test_database_name = self.connection.settings_dict["NAME"] 

278 else: 

279 test_database_name = self.get_test_db_clone_settings(suffix)["NAME"] 

280 

281 if verbosity >= 1: 

282 action = "Destroying" 

283 if keepdb: 

284 action = "Preserving" 

285 self.log( 

286 f"{action} test database for alias {self._get_database_display_str(verbosity, test_database_name)}..." 

287 ) 

288 

289 # if we want to preserve the database 

290 # skip the actual destroying piece. 

291 if not keepdb: 

292 self._destroy_test_db(test_database_name, verbosity) 

293 

294 # Restore the original database name 

295 if old_database_name is not None: 

296 settings.DATABASES[self.connection.alias]["NAME"] = old_database_name 

297 self.connection.settings_dict["NAME"] = old_database_name 

298 

299 def _destroy_test_db(self, test_database_name, verbosity): 

300 """ 

301 Internal implementation - remove the test db tables. 

302 """ 

303 # Remove the test database to clean up after 

304 # ourselves. Connect to the previous database (not the test database) 

305 # to do so, because it's not allowed to delete a database while being 

306 # connected to it. 

307 with self._nodb_cursor() as cursor: 

308 cursor.execute( 

309 f"DROP DATABASE {self.connection.ops.quote_name(test_database_name)}" 

310 ) 

311 

312 def sql_table_creation_suffix(self): 

313 """ 

314 SQL to append to the end of the test table creation statements. 

315 """ 

316 return "" 

317 

318 def test_db_signature(self): 

319 """ 

320 Return a tuple with elements of self.connection.settings_dict (a 

321 DATABASES setting value) that uniquely identify a database 

322 accordingly to the RDBMS particularities. 

323 """ 

324 settings_dict = self.connection.settings_dict 

325 return ( 

326 settings_dict["HOST"], 

327 settings_dict["PORT"], 

328 settings_dict["ENGINE"], 

329 self._get_test_db_name(), 

330 ) 

331 

332 def setup_worker_connection(self, _worker_id): 

333 settings_dict = self.get_test_db_clone_settings(str(_worker_id)) 

334 # connection.settings_dict must be updated in place for changes to be 

335 # reflected in plain.models.connections. If the following line assigned 

336 # connection.settings_dict = settings_dict, new threads would connect 

337 # to the default database instead of the appropriate clone. 

338 self.connection.settings_dict.update(settings_dict) 

339 self.connection.close()