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
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-23 11:16 -0600
1import os
2import sys
4from plain.packages import packages
5from plain.runtime import settings
7# The prefix to put on the default database name when creating
8# the test database.
9TEST_DATABASE_PREFIX = "test_"
12class BaseDatabaseCreation:
13 """
14 Encapsulate backend-specific differences pertaining to creation and
15 destruction of the test database.
16 """
18 def __init__(self, connection):
19 self.connection = connection
21 def _nodb_cursor(self):
22 return self.connection._nodb_cursor()
24 def log(self, msg):
25 sys.stderr.write(msg + os.linesep)
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
36 test_database_name = self._get_test_db_name()
38 if verbosity >= 1:
39 action = "Creating"
40 if keepdb:
41 action = "Using existing"
43 self.log(
44 f"{action} test database for alias {self._get_database_display_str(verbosity, test_database_name)}..."
45 )
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)
54 self.connection.close()
55 settings.DATABASES[self.connection.alias]["NAME"] = test_database_name
56 self.connection.settings_dict["NAME"] = test_database_name
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
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()
93 # Ensure a connection for the side effect of initializing the test database.
94 self.connection.ensure_connection()
96 return test_database_name
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"]
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 # """
112 # # Iteratively return every object for all models to serialize.
113 # def get_objects():
114 # from plain.models.migrations.loader import MigrationLoader
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()
131 # # Serialize to a string
132 # out = StringIO()
133 # serializers.serialize("json", get_objects(), indent=None, stream=out)
134 # return out.getvalue()
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)
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 )
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"]
177 def _execute_create_test_db(self, cursor, parameters, keepdb=False):
178 cursor.execute("CREATE DATABASE {dbname} {suffix}".format(**parameters))
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
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)
226 return test_database_name
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"]
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 )
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)
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 }
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 )
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"]
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 )
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)
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
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 )
312 def sql_table_creation_suffix(self):
313 """
314 SQL to append to the end of the test table creation statements.
315 """
316 return ""
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 )
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()