diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1239129..812d637 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: branches: - master +env: + PY_COLORS: 1 + jobs: linters: runs-on: ubuntu-latest diff --git a/django_db_views/db_view.py b/django_db_views/db_view.py index e638dce..c9a2075 100644 --- a/django_db_views/db_view.py +++ b/django_db_views/db_view.py @@ -9,17 +9,20 @@ class DBViewModelBase(ModelBase): def __new__(cls, *args, **kwargs): new_class = super().__new__(cls, *args, **kwargs) - assert new_class._meta.managed is False, "For DB View managed must be se to false" + assert ( + new_class._meta.managed is False + ), "For DB View managed must be se to false" DBViewsRegistry[new_class._meta.db_table] = new_class return new_class class DBView(models.Model, metaclass=DBViewModelBase): """ - Children should define: - view_definition - define the view, can be callable or attribute (string) - view definition can be per db engine. + Children should define: + view_definition - define the view, can be callable or attribute (string) + view definition can be per db engine. """ + view_definition: Union[Callable, str, dict] class Meta: @@ -40,6 +43,8 @@ def refresh(cls, using=None, concurrently=False): using = using or DEFAULT_DB_ALIAS with connections[using].cursor() as cursor: if concurrently: - cursor.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY %s;" % cls._meta.db_table) + cursor.execute( + "REFRESH MATERIALIZED VIEW CONCURRENTLY %s;" % cls._meta.db_table + ) else: cursor.execute("REFRESH MATERIALIZED VIEW %s;" % cls._meta.db_table) diff --git a/django_db_views/migration_functions.py b/django_db_views/migration_functions.py index edd2e4c..ef86582 100644 --- a/django_db_views/migration_functions.py +++ b/django_db_views/migration_functions.py @@ -16,18 +16,33 @@ def __init__(self, view_definition: str, table_name: str, engine=None): class ForwardViewMigrationBase(ViewMigration): def __call__(self, apps, schema_editor): if self.view_definition: - if self.view_engine is None or self.view_engine == schema_editor.connection.settings_dict['ENGINE']: + if ( + self.view_engine is None + or self.view_engine == schema_editor.connection.settings_dict["ENGINE"] + ): schema_editor.execute(self.DROP_COMMAND_TEMPLATE % self.table_name) - schema_editor.execute(self.CREATE_COMMAND_TEMPLATE % (self.table_name, self.view_definition)) + schema_editor.execute( + self.CREATE_COMMAND_TEMPLATE + % (self.table_name, self.view_definition) + ) class BackwardViewMigrationBase(ViewMigration): def __call__(self, apps, schema_editor): - if self.view_engine is None or self.view_engine == schema_editor.connection.settings_dict['ENGINE']: + if ( + self.view_engine is None + or self.view_engine == schema_editor.connection.settings_dict["ENGINE"] + ): schema_editor.execute(self.DROP_COMMAND_TEMPLATE % self.table_name) if self.view_definition: - if self.view_engine is None or self.view_engine == schema_editor.connection.settings_dict['ENGINE']: - schema_editor.execute(self.CREATE_COMMAND_TEMPLATE % (self.table_name, self.view_definition)) + if ( + self.view_engine is None + or self.view_engine == schema_editor.connection.settings_dict["ENGINE"] + ): + schema_editor.execute( + self.CREATE_COMMAND_TEMPLATE + % (self.table_name, self.view_definition) + ) @deconstructible @@ -62,7 +77,10 @@ def __init__(self, table_name: str, engine=None): self.view_engine = engine def __call__(self, apps, schema_editor): - if self.view_engine is None or self.view_engine == schema_editor.connection.settings_dict['ENGINE']: + if ( + self.view_engine is None + or self.view_engine == schema_editor.connection.settings_dict["ENGINE"] + ): schema_editor.execute(self.DROP_COMMAND_TEMPLATE % self.table_name) diff --git a/django_db_views/operations.py b/django_db_views/operations.py index 0cde6ee..5babe1b 100644 --- a/django_db_views/operations.py +++ b/django_db_views/operations.py @@ -3,7 +3,10 @@ from django_db_views.context_manager import VIEW_MIGRATION_CONTEXT from django_db_views.db_view import DBView, DBMaterializedView -from django_db_views.migration_functions import ForwardMaterializedViewMigration, ForwardViewMigration +from django_db_views.migration_functions import ( + ForwardMaterializedViewMigration, + ForwardViewMigration, +) def get_table_engine_name_hash(table_name, engine): @@ -12,13 +15,14 @@ def get_table_engine_name_hash(table_name, engine): class DBViewModelState(ModelState): def __init__( - self, *args, - # Not required cus migrate also load state using clone method that do not provide required by us fields. - view_engine: str = None, - view_definition: str = None, - table_name: str = None, - base_class=None, - **kwargs + self, + *args, + # Not required cus migrate also load state using clone method that do not provide required by us fields. + view_engine: str = None, + view_definition: str = None, + table_name: str = None, + base_class=None, + **kwargs, ): super().__init__(*args, **kwargs) if VIEW_MIGRATION_CONTEXT["is_view_migration"]: @@ -29,7 +33,6 @@ def __init__( class ViewRunPython(operations.RunPython): - def state_forwards(self, app_label, state): if VIEW_MIGRATION_CONTEXT["is_view_migration"]: if isinstance(self.code, ForwardMaterializedViewMigration): @@ -42,7 +45,9 @@ def state_forwards(self, app_label, state): DBViewModelState( app_label, # Hash table_name_engine_name to add state model per migration, which are added per engine. - get_table_engine_name_hash(self.code.table_name, self.code.view_engine), + get_table_engine_name_hash( + self.code.table_name, self.code.view_engine + ), list(), dict(), # we do not use django bases (they initialize model using that, and broke ViewRegistry), @@ -52,13 +57,15 @@ def state_forwards(self, app_label, state): view_engine=self.code.view_engine, view_definition=self.code.view_definition, base_class=model, - table_name=self.code.table_name + table_name=self.code.table_name, ) ) class ViewDropRunPython(operations.RunPython): - def state_forwards(self, app_label, state): if VIEW_MIGRATION_CONTEXT["is_view_migration"]: - state.remove_model(app_label, get_table_engine_name_hash(self.code.table_name, self.code.view_engine)) + state.remove_model( + app_label, + get_table_engine_name_hash(self.code.table_name, self.code.view_engine), + ) diff --git a/tests/asserts_utils.py b/tests/asserts_utils.py index 00b5cc9..8e1ead5 100644 --- a/tests/asserts_utils.py +++ b/tests/asserts_utils.py @@ -1,14 +1,16 @@ from django.db import connections -def is_table_exists(table_name: str, using: str = 'default') -> bool: +def is_table_exists(table_name: str, using: str = "default") -> bool: with connections[using].cursor() as cursor: return table_name in connections[using].introspection.table_names(cursor) -def is_view_exists(view_name: str, using: str = 'default') -> bool: +def is_view_exists(view_name: str, using: str = "default") -> bool: with connections[using].cursor() as cursor: views = [ - table.name for table in connections[using].introspection.get_table_list(cursor) if table.type == 'v' + table.name + for table in connections[using].introspection.get_table_list(cursor) + if table.type == "v" ] return view_name in views diff --git a/tests/decorators.py b/tests/decorators.py index 9097349..179d151 100644 --- a/tests/decorators.py +++ b/tests/decorators.py @@ -8,5 +8,11 @@ def decorated_test(*args, **kwargs): try: test_function(*args, **kwargs) finally: - call_command("migrate", "test_app", "zero", database=kwargs.get("database", "default")) + call_command( + "migrate", + "test_app", + "zero", + database=kwargs.get("database", "default"), + ) + return decorated_test diff --git a/tests/dynamic_models_fixtures.py b/tests/dynamic_models_fixtures.py index 6c5617c..a703942 100644 --- a/tests/dynamic_models_fixtures.py +++ b/tests/dynamic_models_fixtures.py @@ -1,23 +1,30 @@ import pytest from django.db import models -from tests.test_app.models import QuestionTemplate, SimpleViewWithoutDependenciesTemplate, ChoiceTemplate, \ - RawViewQuestionStatTemplate, QueryViewQuestionStatTemplate, MultipleDBRawViewTemplate, \ - MultipleDBQueryViewQuestionStatTemplate, SimpleMaterializedViewWithoutDependenciesTemplate, \ - SimpleMaterializedViewWithIndexTemplate, SecondSimpleViewWithoutDependenciesTemplate +from tests.test_app.models import ( + QuestionTemplate, + SimpleViewWithoutDependenciesTemplate, + ChoiceTemplate, + RawViewQuestionStatTemplate, + QueryViewQuestionStatTemplate, + MultipleDBRawViewTemplate, + MultipleDBQueryViewQuestionStatTemplate, + SimpleMaterializedViewWithoutDependenciesTemplate, + SimpleMaterializedViewWithIndexTemplate, + SecondSimpleViewWithoutDependenciesTemplate, +) def define_model(template_class, parent): attributes = get_declared_class_attributes(template_class) - attrs = { - **attributes, - '__module__': 'tests.test_app.models' - } + attrs = {**attributes, "__module__": "tests.test_app.models"} return type(template_class.__name__.replace("Template", ""), (parent,), attrs) def get_declared_class_attributes(cls) -> dict: - return {key: value for key, value in cls.__dict__.items() if not key.startswith('__')} + return { + key: value for key, value in cls.__dict__.items() if not key.startswith("__") + } @pytest.fixture @@ -33,46 +40,56 @@ def Choice(): @pytest.fixture def SimpleViewWithoutDependencies(): from django_db_views.db_view import DBView + return define_model(SimpleViewWithoutDependenciesTemplate, DBView) @pytest.fixture def SecondSimpleViewWithoutDependencies(): from django_db_views.db_view import DBView + return define_model(SecondSimpleViewWithoutDependenciesTemplate, DBView) @pytest.fixture def RawViewQuestionStat(): from django_db_views.db_view import DBView + return define_model(RawViewQuestionStatTemplate, DBView) @pytest.fixture def QueryViewQuestionStat(): from django_db_views.db_view import DBView + return define_model(QueryViewQuestionStatTemplate, DBView) @pytest.fixture def MultipleDBRawView(): from django_db_views.db_view import DBView + return define_model(MultipleDBRawViewTemplate, DBView) @pytest.fixture def MultipleDBQueryViewQuestionStat(): from django_db_views.db_view import DBView + return define_model(MultipleDBQueryViewQuestionStatTemplate, DBView) @pytest.fixture def SimpleMaterializedViewWithoutDependencies(): from django_db_views.db_view import DBMaterializedView - return define_model(SimpleMaterializedViewWithoutDependenciesTemplate, DBMaterializedView) + + return define_model( + SimpleMaterializedViewWithoutDependenciesTemplate, DBMaterializedView + ) @pytest.fixture def SimpleMaterializedViewWithIndex(): from django_db_views.db_view import DBMaterializedView + return define_model(SimpleMaterializedViewWithIndexTemplate, DBMaterializedView) diff --git a/tests/fixturies.py b/tests/fixturies.py index c382f6d..31db9c6 100644 --- a/tests/fixturies.py +++ b/tests/fixturies.py @@ -4,37 +4,39 @@ from django.apps import apps -@pytest.fixture(autouse=True, scope='function') +@pytest.fixture(autouse=True, scope="function") def temp_migrations_dir(settings, tmpdir, mocker): migrations_dir = tmpdir.mkdir("migrations") - init_file = (migrations_dir / "__init__.py") + init_file = migrations_dir / "__init__.py" init_file.write_text("", encoding="utf-8") - mocker.patch("django.db.migrations.writer.MigrationWriter.basedir", migrations_dir.strpath) + mocker.patch( + "django.db.migrations.writer.MigrationWriter.basedir", migrations_dir.strpath + ) def new_module_import(module_name): - if module_name == 'tests.test_app.migrations': + if module_name == "tests.test_app.migrations": spec = importlib.util.spec_from_file_location(module_name, init_file) return importlib.util.module_from_spec(spec) - elif 'tests.test_app.migrations' in module_name: - spec = importlib.util.spec_from_file_location(module_name, migrations_dir / f"{module_name.split('.')[-1]}.py") + elif "tests.test_app.migrations" in module_name: + spec = importlib.util.spec_from_file_location( + module_name, migrations_dir / f"{module_name.split('.')[-1]}.py" + ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module return importlib.import_module(module_name) - mocker.patch( - "django.db.migrations.loader.import_module", - new_module_import - ) + + mocker.patch("django.db.migrations.loader.import_module", new_module_import) return migrations_dir -@pytest.fixture(autouse=True, scope='function') +@pytest.fixture(autouse=True, scope="function") def dynamic_models_cleanup(): try: yield None finally: # We delete all dynamically created models - test_app_models = list(apps.all_models['test_app'].keys()) + test_app_models = list(apps.all_models["test_app"].keys()) for model_name in test_app_models: - del apps.all_models['test_app'][model_name] + del apps.all_models["test_app"][model_name] apps.clear_cache() diff --git a/tests/test_app/models.py b/tests/test_app/models.py index 044b2cb..7a8f0b2 100644 --- a/tests/test_app/models.py +++ b/tests/test_app/models.py @@ -10,7 +10,9 @@ class QuestionTemplate: class ChoiceTemplate: - question = models.ForeignKey("Question", on_delete=models.CASCADE, related_name='choices') + question = models.ForeignKey( + "Question", on_delete=models.CASCADE, related_name="choices" + ) text = models.CharField(max_length=200) votes = models.IntegerField(default=0) @@ -42,7 +44,10 @@ class SecondSimpleViewWithoutDependenciesTemplate: class RawViewQuestionStatTemplate: question = models.ForeignKey( - "Question", on_delete=models.DO_NOTHING, related_name="question", primary_key=True + "Question", + on_delete=models.DO_NOTHING, + related_name="question", + primary_key=True, ) total_choices = models.IntegerField() @@ -63,16 +68,20 @@ class Meta: class QueryViewQuestionStatTemplate: question = models.ForeignKey( - "Question", on_delete=models.DO_NOTHING, related_name="question", primary_key=True + "Question", + on_delete=models.DO_NOTHING, + related_name="question", + primary_key=True, ) total_choices = models.IntegerField() @staticmethod def view_definition(): return str( - apps.get_model( - 'test_app', 'Question' - ).objects.values("id").annotate(total_choices=models.Count("choices"), question_id=F("id")).query + apps.get_model("test_app", "Question") + .objects.values("id") + .annotate(total_choices=models.Count("choices"), question_id=F("id")) + .query ) class Meta: @@ -109,16 +118,16 @@ class MultipleDBQueryViewQuestionStatTemplate: @staticmethod def view_definition(): - queryset = apps.get_model( - 'test_app', 'Question' - ).objects.values("id").annotate(total_choices=models.Count("choices"), question_id=F("id")) + queryset = ( + apps.get_model("test_app", "Question") + .objects.values("id") + .annotate(total_choices=models.Count("choices"), question_id=F("id")) + ) return { "django.db.backends.mysql": str( queryset.query.get_compiler("mysql").as_sql()[0] ), - "django.db.backends.postgresql": str( - queryset.query - ) + "django.db.backends.postgresql": str(queryset.query), } class Meta: @@ -152,6 +161,4 @@ class Meta: managed = False db_table = "simple_materialized_view_without_dependencies" # only django 3.2 + - indexes = [ - Index(fields=['current_date_time']) - ] + indexes = [Index(fields=["current_date_time"])] diff --git a/tests/test_backward_compatibility.py b/tests/test_backward_compatibility.py index 9fe4a99..8510b41 100644 --- a/tests/test_backward_compatibility.py +++ b/tests/test_backward_compatibility.py @@ -6,9 +6,9 @@ @pytest.fixture(autouse=True) def backward_compatibility_test_app_settings(settings): - settings.INSTALLED_APPS += ['tests.backward_compatibility_test_app'] + settings.INSTALLED_APPS += ["tests.backward_compatibility_test_app"] yield settings - settings.INSTALLED_APPS.remove('tests.backward_compatibility_test_app') + settings.INSTALLED_APPS.remove("tests.backward_compatibility_test_app") @pytest.mark.django_db() @@ -17,12 +17,12 @@ def test_engine_support_backward_compatibility_migration(): """Ensures that the initial migration works.""" assert not is_view_exists("view_for_backward_compatibility_check") call_command( - "migrate", app_label="backward_compatibility_test_app", - migration_name="0-0-9_to_0-0-10_added_engine_support" + "migrate", + app_label="backward_compatibility_test_app", + migration_name="0-0-9_to_0-0-10_added_engine_support", ) assert is_view_exists("view_for_backward_compatibility_check") call_command( - "migrate", app_label="backward_compatibility_test_app", - migration_name="zero" + "migrate", app_label="backward_compatibility_test_app", migration_name="zero" ) assert not is_view_exists("view_for_backward_compatibility_check") diff --git a/tests/test_materialized_views.py b/tests/test_materialized_views.py index 67ef4a2..8e004df 100644 --- a/tests/test_materialized_views.py +++ b/tests/test_materialized_views.py @@ -3,25 +3,31 @@ from tests.asserts_utils import is_view_exists from tests.decorators import roll_back_schema -from tests.fixturies import temp_migrations_dir, dynamic_models_cleanup # noqa +from tests.fixturies import dynamic_models_cleanup # noqa @pytest.mark.django_db(transaction=True) @roll_back_schema def test_materialized_db_view_based_on_raw_sql_without_dependencies( - temp_migrations_dir, SimpleMaterializedViewWithoutDependencies + temp_migrations_dir, SimpleMaterializedViewWithoutDependencies ): call_command("makeviewmigrations", "test_app") assert (temp_migrations_dir / "0001_initial.py").exists() call_command("migrate", "test_app") assert is_view_exists(SimpleMaterializedViewWithoutDependencies._meta.db_table) assert SimpleMaterializedViewWithoutDependencies.objects.all().count() == 1 - current_date_time_from_view_call_1 = SimpleMaterializedViewWithoutDependencies.objects.get().current_date_time - current_date_time_from_view_call_2 = SimpleMaterializedViewWithoutDependencies.objects.get().current_date_time + current_date_time_from_view_call_1 = ( + SimpleMaterializedViewWithoutDependencies.objects.get().current_date_time + ) + current_date_time_from_view_call_2 = ( + SimpleMaterializedViewWithoutDependencies.objects.get().current_date_time + ) assert current_date_time_from_view_call_1 == current_date_time_from_view_call_2 # regular refresh SimpleMaterializedViewWithoutDependencies.refresh() - current_date_time_from_view_call_3 = SimpleMaterializedViewWithoutDependencies.objects.get().current_date_time + current_date_time_from_view_call_3 = ( + SimpleMaterializedViewWithoutDependencies.objects.get().current_date_time + ) assert current_date_time_from_view_call_1 != current_date_time_from_view_call_3 # backward migration call_command("migrate", "test_app", "zero") @@ -32,23 +38,31 @@ def test_materialized_db_view_based_on_raw_sql_without_dependencies( @pytest.mark.django_db(transaction=True) @roll_back_schema def test_materialized_db_view_based_on_raw_sql_with_indexes( - temp_migrations_dir, SimpleMaterializedViewWithIndex + temp_migrations_dir, SimpleMaterializedViewWithIndex ): call_command("makeviewmigrations", "test_app") assert (temp_migrations_dir / "0001_initial.py").exists() call_command("migrate", "test_app") assert is_view_exists(SimpleMaterializedViewWithIndex._meta.db_table) assert SimpleMaterializedViewWithIndex.objects.all().count() == 1 - current_date_time_from_view_call_1 = SimpleMaterializedViewWithIndex.objects.get().current_date_time - current_date_time_from_view_call_2 = SimpleMaterializedViewWithIndex.objects.get().current_date_time + current_date_time_from_view_call_1 = ( + SimpleMaterializedViewWithIndex.objects.get().current_date_time + ) + current_date_time_from_view_call_2 = ( + SimpleMaterializedViewWithIndex.objects.get().current_date_time + ) assert current_date_time_from_view_call_1 == current_date_time_from_view_call_2 # regular refresh SimpleMaterializedViewWithIndex.refresh() - current_date_time_from_view_call_3 = SimpleMaterializedViewWithIndex.objects.get().current_date_time + current_date_time_from_view_call_3 = ( + SimpleMaterializedViewWithIndex.objects.get().current_date_time + ) assert current_date_time_from_view_call_1 != current_date_time_from_view_call_3 # regular refresh concurrently SimpleMaterializedViewWithIndex.refresh(concurrently=True) - current_date_time_from_view_call_4 = SimpleMaterializedViewWithIndex.objects.get().current_date_time + current_date_time_from_view_call_4 = ( + SimpleMaterializedViewWithIndex.objects.get().current_date_time + ) assert current_date_time_from_view_call_3 != current_date_time_from_view_call_4 # backward migration call_command("migrate", "test_app", "zero")