diff --git a/django/db/backends/sqlite3/creation.py b/django/db/backends/sqlite3/creation.py index d57bf9ee1f..3586d83ca5 100644 --- a/django/db/backends/sqlite3/creation.py +++ b/django/db/backends/sqlite3/creation.py @@ -56,8 +56,28 @@ class DatabaseCreation(BaseDatabaseCreation): source_database_name = orig_settings_dict["NAME"] or ":memory:" if not self.is_in_memory_db(source_database_name): - root, ext = os.path.splitext(source_database_name) - return {**orig_settings_dict, "NAME": f"{root}_{suffix}{ext}"} + if source_database_name.startswith("file:"): + if "?" in source_database_name: + base, query = source_database_name.split("?", 1) + return { + **orig_settings_dict, + "NAME": f"{base}_{suffix}?{query}", + } + else: + return { + **orig_settings_dict, + "NAME": f"{source_database_name}_{suffix}", + } + else: + root, ext = os.path.splitext(source_database_name) + clone_name = f"{root}_{suffix}{ext}" + clone_dir = os.path.dirname(clone_name) + if clone_dir: + os.makedirs(clone_dir, exist_ok=True) + return { + **orig_settings_dict, + "NAME": clone_name, + } start_method = multiprocessing.get_start_method() if start_method == "fork": @@ -93,6 +113,10 @@ class DatabaseCreation(BaseDatabaseCreation): except Exception as e: self.log("Got an error deleting the old test database: %s" % e) sys.exit(2) + target_dir = os.path.dirname(str(target_database_name)) + if target_dir: + os.makedirs(target_dir, exist_ok=True) + try: shutil.copy(source_database_name, target_database_name) except Exception as e: @@ -102,7 +126,15 @@ class DatabaseCreation(BaseDatabaseCreation): # Forkserver and spawn require migrating to disk which will be # re-opened in setup_worker_connection. elif multiprocessing.get_start_method() in {"forkserver", "spawn"}: - ondisk_db = sqlite3.connect(target_database_name, uri=True) + target_dir = os.path.dirname(str(target_database_name)) + if target_dir: + os.makedirs(target_dir, exist_ok=True) + # Open on-disk database using a proper URI. + if str(target_database_name).startswith("file:"): + ondisk_uri = str(target_database_name) + else: + ondisk_uri = f"file:{target_database_name}" + ondisk_db = sqlite3.connect(ondisk_uri, uri=True) self.connection.connection.backup(ondisk_db) ondisk_db.close() @@ -142,9 +174,17 @@ class DatabaseCreation(BaseDatabaseCreation): connection_str = ( f"file:memorydb_{alias}_{_worker_id}?mode=memory&cache=shared" ) - source_db = self.connection.Database.connect( - f"file:{alias}_{_worker_id}.sqlite3?mode=ro", uri=True - ) + source_name = str(settings_dict["NAME"]) + if source_name.startswith("file:"): + # Ensure read-only mode is set on URI. + source_uri = ( + f"{source_name}&mode=ro" + if "?" in source_name + else f"{source_name}?mode=ro" + ) + else: + source_uri = f"file:{source_name}?mode=ro" + source_db = self.connection.Database.connect(source_uri, uri=True) target_db = sqlite3.connect(connection_str, uri=True) source_db.backup(target_db) source_db.close() diff --git a/tests/backends/sqlite/test_parallel.py b/tests/backends/sqlite/test_parallel.py new file mode 100644 index 0000000000..bd00c143e7 --- /dev/null +++ b/tests/backends/sqlite/test_parallel.py @@ -0,0 +1,51 @@ +import shutil +import tempfile +import unittest +from pathlib import Path + +from django.db import connection, connections +from django.test import TestCase + + +@unittest.skipUnless(connection.vendor == "sqlite", "SQLite tests") +class SQLiteParallelCloneTests(TestCase): + """ + Tests that cloned SQLite test databases respect the original + directory when running tests in parallel. + """ + + def setUp(self): + self.test_db_dir = Path(tempfile.mkdtemp()) / "db" / "default" + self.test_db_dir.mkdir(parents=True, exist_ok=True) + + self.original_settings = None + + self.addCleanup( + lambda: shutil.rmtree(self.test_db_dir.parent.parent, ignore_errors=True) + ) + + def tearDown(self): + if self.original_settings is not None: + conn = connections["default"] + conn.settings_dict["NAME"] = self.original_settings["NAME"] + conn.settings_dict["TEST"]["NAME"] = self.original_settings["TEST"]["NAME"] + + def test_clone_respects_directory(self): + conn = connections["default"] + self.original_settings = { + "NAME": conn.settings_dict["NAME"], + "TEST": {"NAME": conn.settings_dict["TEST"]["NAME"]}, + } + + conn.close() + + db_path = str(self.test_db_dir / "db.sqlite3") + conn.settings_dict["NAME"] = db_path + conn.settings_dict["TEST"]["NAME"] = str(self.test_db_dir / "test_db.sqlite3") + + clone = conn.creation.get_test_db_clone_settings(1) + + clone_path = Path(clone["NAME"]) + self.assertIn("db", clone_path.parts) + self.assertIn("default", clone_path.parts) + self.assertTrue(clone_path.name.startswith("db_1"))