Skip to content

Add initial experimental multi database support #930

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions docs/database.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,21 +64,25 @@ select using an argument to the ``django_db`` mark::
Tests requiring multiple databases
----------------------------------

.. caution::

This support is **experimental** and is subject to change without
deprecation. We are still figuring out the best way to expose this
functionality. If you are using this successfully or unsuccessfully,
`let us know <https://github.com/pytest-dev/pytest-django/issues/924>`_!

``pytest-django`` has experimental support for multi-database configurations.
Currently ``pytest-django`` does not specifically support Django's
multi-database support.
multi-database support, using the ``databases`` argument to the
:py:func:`django_db <pytest.mark.django_db>` mark::

You can however use normal :class:`~django.test.TestCase` instances to use its
:ref:`django:topics-testing-advanced-multidb` support.
In particular, if your database is configured for replication, be sure to read
about :ref:`django:topics-testing-primaryreplica`.
@pytest.mark.django_db(databases=['default', 'other'])
def test_spam():
assert MyModel.objects.using('other').count() == 0

If you have any ideas about the best API to support multiple databases
directly in ``pytest-django`` please get in touch, we are interested
in eventually supporting this but unsure about simply following
Django's approach.
For details see :py:attr:`django.test.TransactionTestCase.databases` and
:py:attr:`django.test.TestCase.databases`.

See `pull request 431 <https://github.com/pytest-dev/pytest-django/pull/431>`_
for an idea/discussion to approach this.

``--reuse-db`` - reuse the testing database between test runs
--------------------------------------------------------------
Expand Down
19 changes: 18 additions & 1 deletion docs/helpers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Markers
``pytest.mark.django_db`` - request database access
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. py:function:: pytest.mark.django_db([transaction=False, reset_sequences=False])
.. py:function:: pytest.mark.django_db([transaction=False, reset_sequences=False, databases=None])

This is used to mark a test function as requiring the database. It
will ensure the database is set up correctly for the test. Each test
Expand Down Expand Up @@ -54,6 +54,23 @@ Markers
effect. Please be aware that not all databases support this feature.
For details see :py:attr:`django.test.TransactionTestCase.reset_sequences`.


:type databases: Union[Iterable[str], str, None]
:param databases:
.. caution::

This argument is **experimental** and is subject to change without
deprecation. We are still figuring out the best way to expose this
functionality. If you are using this successfully or unsuccessfully,
`let us know <https://github.com/pytest-dev/pytest-django/issues/924>`_!

The ``databases`` argument defines which databases in a multi-database
configuration will be set up and may be used by the test. Defaults to
only the ``default`` database. The special value ``"__all__"`` may be use
to specify all configured databases.
For details see :py:attr:`django.test.TransactionTestCase.databases` and
:py:attr:`django.test.TestCase.databases`.

.. note::

If you want access to the Django database inside a *fixture*, this marker may
Expand Down
13 changes: 12 additions & 1 deletion pytest_django/fixtures.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""All pytest-django fixtures"""
from typing import Any, Generator, List
from typing import Any, Generator, Iterable, List, Optional, Tuple, Union
import os
from contextlib import contextmanager
from functools import partial
Expand All @@ -12,8 +12,13 @@

TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Literal

import django

_DjangoDbDatabases = Optional[Union["Literal['__all__']", Iterable[str]]]
_DjangoDb = Tuple[bool, bool, _DjangoDbDatabases]


__all__ = [
"django_db_setup",
Expand Down Expand Up @@ -142,6 +147,10 @@ def _django_db_fixture_helper(
# Do nothing, we get called with transactional=True, too.
return

_databases = getattr(
request.node, "_pytest_django_databases", None,
) # type: Optional[_DjangoDbDatabases]

django_db_blocker.unblock()
request.addfinalizer(django_db_blocker.restore)

Expand All @@ -158,6 +167,8 @@ def _django_db_fixture_helper(
class PytestDjangoTestCase(test_case_class): # type: ignore[misc,valid-type]
if transactional and _reset_sequences:
reset_sequences = True
if _databases is not None:
databases = _databases

PytestDjangoTestCase.setUpClass()
request.addfinalizer(PytestDjangoTestCase.tearDownClass)
Expand Down
27 changes: 18 additions & 9 deletions pytest_django/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@

import django

from .fixtures import _DjangoDb, _DjangoDbDatabases


SETTINGS_MODULE_ENV = "DJANGO_SETTINGS_MODULE"
CONFIGURATION_ENV = "DJANGO_CONFIGURATION"
Expand Down Expand Up @@ -262,12 +264,14 @@ def pytest_load_initial_conftests(
# Register the marks
early_config.addinivalue_line(
"markers",
"django_db(transaction=False, reset_sequences=False): "
"django_db(transaction=False, reset_sequences=False, databases=None): "
"Mark the test as using the Django test database. "
"The *transaction* argument allows you to use real transactions "
"in the test like Django's TransactionTestCase. "
"The *reset_sequences* argument resets database sequences before "
"the test.",
"the test. "
"The *databases* argument sets which database aliases the test "
"uses (by default, only 'default'). Use '__all__' for all databases.",
)
early_config.addinivalue_line(
"markers",
Expand Down Expand Up @@ -452,7 +456,11 @@ def _django_db_marker(request) -> None:
"""
marker = request.node.get_closest_marker("django_db")
if marker:
transaction, reset_sequences = validate_django_db(marker)
transaction, reset_sequences, databases = validate_django_db(marker)

# TODO: Use pytest Store (item.store) once that's stable.
request.node._pytest_django_databases = databases

if reset_sequences:
request.getfixturevalue("django_db_reset_sequences")
elif transaction:
Expand Down Expand Up @@ -727,21 +735,22 @@ def restore(self) -> None:
_blocking_manager = _DatabaseBlocker()


def validate_django_db(marker) -> Tuple[bool, bool]:
def validate_django_db(marker) -> "_DjangoDb":
"""Validate the django_db marker.

It checks the signature and creates the ``transaction`` and
``reset_sequences`` attributes on the marker which will have the
correct values.
It checks the signature and creates the ``transaction``,
``reset_sequences`` and ``databases`` attributes on the marker
which will have the correct values.

A sequence reset is only allowed when combined with a transaction.
"""

def apifun(
transaction: bool = False,
reset_sequences: bool = False,
) -> Tuple[bool, bool]:
return transaction, reset_sequences
databases: "_DjangoDbDatabases" = None,
) -> "_DjangoDb":
return transaction, reset_sequences, databases

return apifun(*marker.args, **marker.kwargs)

Expand Down
17 changes: 16 additions & 1 deletion pytest_django_test/app/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,20 @@ class Migration(migrations.Migration):
),
("name", models.CharField(max_length=100)),
],
)
),
migrations.CreateModel(
name="SecondItem",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
],
),
]
6 changes: 6 additions & 0 deletions pytest_django_test/app/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
from django.db import models


# Routed to database "main".
class Item(models.Model):
name = models.CharField(max_length=100) # type: str


# Routed to database "second".
class SecondItem(models.Model):
name = models.CharField(max_length=100) # type: str
3 changes: 3 additions & 0 deletions pytest_django_test/db_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
# An explicit test db name was given, is that as the base name
TEST_DB_NAME = "{}_inner".format(TEST_DB_NAME)

SECOND_DB_NAME = DB_NAME + '_second' if DB_NAME is not None else None
SECOND_TEST_DB_NAME = TEST_DB_NAME + '_second' if DB_NAME is not None else None


def get_db_engine():
return _settings["ENGINE"].split(".")[-1]
Expand Down
14 changes: 14 additions & 0 deletions pytest_django_test/db_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class DbRouter:
def db_for_read(self, model, **hints):
if model._meta.app_label == 'app' and model._meta.model_name == 'seconditem':
return 'second'
return None

def db_for_write(self, model, **hints):
if model._meta.app_label == 'app' and model._meta.model_name == 'seconditem':
return 'second'
return None

def allow_migrate(self, db, app_label, model_name=None, **hints):
if app_label == 'app' and model_name == 'seconditem':
return db == 'second'
2 changes: 2 additions & 0 deletions pytest_django_test/settings_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@
"OPTIONS": {},
}
]

DATABASE_ROUTERS = ['pytest_django_test.db_router.DbRouter']
33 changes: 32 additions & 1 deletion pytest_django_test/settings_mysql_innodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,38 @@
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": "pytest_django_should_never_get_accessed",
"NAME": "pytest_django_tests_default",
"USER": environ.get("TEST_DB_USER", "root"),
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
"HOST": environ.get("TEST_DB_HOST", "localhost"),
"OPTIONS": {
"init_command": "SET default_storage_engine=InnoDB",
"charset": "utf8mb4",
},
"TEST": {
"CHARSET": "utf8mb4",
"COLLATION": "utf8mb4_unicode_ci",
},
},
"replica": {
"ENGINE": "django.db.backends.mysql",
"NAME": "pytest_django_tests_replica",
"USER": environ.get("TEST_DB_USER", "root"),
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
"HOST": environ.get("TEST_DB_HOST", "localhost"),
"OPTIONS": {
"init_command": "SET default_storage_engine=InnoDB",
"charset": "utf8mb4",
},
"TEST": {
"MIRROR": "default",
"CHARSET": "utf8mb4",
"COLLATION": "utf8mb4_unicode_ci",
},
},
"second": {
"ENGINE": "django.db.backends.mysql",
"NAME": "pytest_django_tests_second",
"USER": environ.get("TEST_DB_USER", "root"),
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
"HOST": environ.get("TEST_DB_HOST", "localhost"),
Expand Down
33 changes: 32 additions & 1 deletion pytest_django_test/settings_mysql_myisam.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,38 @@
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": "pytest_django_should_never_get_accessed",
"NAME": "pytest_django_tests_default",
"USER": environ.get("TEST_DB_USER", "root"),
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
"HOST": environ.get("TEST_DB_HOST", "localhost"),
"OPTIONS": {
"init_command": "SET default_storage_engine=MyISAM",
"charset": "utf8mb4",
},
"TEST": {
"CHARSET": "utf8mb4",
"COLLATION": "utf8mb4_unicode_ci",
},
},
"replica": {
"ENGINE": "django.db.backends.mysql",
"NAME": "pytest_django_tests_replica",
"USER": environ.get("TEST_DB_USER", "root"),
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
"HOST": environ.get("TEST_DB_HOST", "localhost"),
"OPTIONS": {
"init_command": "SET default_storage_engine=MyISAM",
"charset": "utf8mb4",
},
"TEST": {
"MIRROR": "default",
"CHARSET": "utf8mb4",
"COLLATION": "utf8mb4_unicode_ci",
},
},
"second": {
"ENGINE": "django.db.backends.mysql",
"NAME": "pytest_django_tests_second",
"USER": environ.get("TEST_DB_USER", "root"),
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
"HOST": environ.get("TEST_DB_HOST", "localhost"),
Expand Down
19 changes: 18 additions & 1 deletion pytest_django_test/settings_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,24 @@
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "pytest_django_should_never_get_accessed",
"NAME": "pytest_django_tests_default",
"USER": environ.get("TEST_DB_USER", ""),
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
"HOST": environ.get("TEST_DB_HOST", ""),
},
"replica": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "pytest_django_tests_replica",
"USER": environ.get("TEST_DB_USER", ""),
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
"HOST": environ.get("TEST_DB_HOST", ""),
"TEST": {
"MIRROR": "default",
},
},
"second": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "pytest_django_tests_second",
"USER": environ.get("TEST_DB_USER", ""),
"PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
"HOST": environ.get("TEST_DB_HOST", ""),
Expand Down
14 changes: 13 additions & 1 deletion pytest_django_test/settings_sqlite.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
from .settings_base import * # noqa: F401 F403


DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "/should_not_be_accessed",
}
},
"replica": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "/should_not_be_accessed",
"TEST": {
"MIRROR": "default",
},
},
"second": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "/should_not_be_accessed",
},
}
Loading