Skip to content

Commit

Permalink
Add support for replacing materialized views to the schema editor
Browse files Browse the repository at this point in the history
  • Loading branch information
Photonios committed Nov 3, 2019
1 parent da0591f commit 3a13758
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 1 deletion.
21 changes: 21 additions & 0 deletions psqlextra/backend/introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,24 @@ def get_partition_key(self, cursor, table_name: str) -> List[str]:

cursor.execute(sql, (table_name,))
return [row[0] for row in cursor.fetchall()]

def get_constraints(self, cursor, table_name: str):
"""Retrieve any constraints or keys (unique, pk, fk, check, index)
across one or more columns.
Also retrieve the definition of expression-based indexes.
"""

constraints = super().get_constraints(cursor, table_name)

# standard Django implementation does not return the definition
# for indexes, only for constraints, let's patch that up
cursor.execute(
"SELECT indexname, indexdef FROM pg_indexes WHERE tablename = %s",
(table_name,),
)
for index, definition in cursor.fetchall():
if constraints[index].get("definition") is None:
constraints[index]["definition"] = definition

return constraints
40 changes: 39 additions & 1 deletion psqlextra/backend/schema.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
from typing import Any, List
from unittest import mock

from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.core.exceptions import (
FieldDoesNotExist,
ImproperlyConfigured,
SuspiciousOperation,
)
from django.db import transaction
from django.db.models import Field, Model

from psqlextra.type_assertions import is_sql_with_params
from psqlextra.types import PostgresPartitioningMethod

from . import base_impl
from .introspection import PostgresIntrospection
from .side_effects import (
HStoreRequiredSchemaEditorSideEffect,
HStoreUniqueSchemaEditorSideEffect,
Expand Down Expand Up @@ -53,6 +59,7 @@ def __init__(self, connection, collect_sql=False, atomic=True):
side_effect.quote_name = self.quote_name

self.deferred_sql = []
self.introspection = PostgresIntrospection(self.connection)

def create_model(self, model: Model) -> None:
"""Creates a new model."""
Expand Down Expand Up @@ -108,6 +115,37 @@ def create_materialized_view_model(self, model: Model) -> None:

self._create_view_model(self.sql_create_materialized_view, model)

def replace_materialized_view_model(self, model: Model) -> None:
"""Replaces a materialized view with a newer version.
This is used to alter the backing query of a materialized view.
Replacing a materialized view is a lot trickier than a normal view.
For normal views we can use `CREATE OR REPLACE VIEW`, but for
materialized views, we have to create the new view, copy all
indexes and constraints and drop the old one.
This operation is atomic as it runs in a transaction.
"""

with self.connection.cursor() as cursor:
constraints = self.introspection.get_constraints(
cursor, model._meta.db_table
)

with transaction.atomic():
self.delete_materialized_view_model(model)
self.create_materialized_view_model(model)

for constraint_name, constraint_options in constraints.items():
if not constraint_options["definition"]:
raise SuspiciousOperation(
"Table %s has a constraint '%s' that no definition could be generated for",
(model._meta.db_tabel, constraint_name),
)

self.execute(constraint_options["definition"])

def delete_materialized_view_model(self, model: Model) -> None:
"""Deletes a materialized view model."""

Expand Down
9 changes: 9 additions & 0 deletions tests/db_introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,12 @@ def get_partitions(table_name: str):
with connection.cursor() as cursor:
introspection = connection.introspection
return introspection.get_partitions(cursor, table_name)


def get_constraints(table_name: str):
"""Gets a complete list of constraints and indexes for the specified
table."""

with connection.cursor() as cursor:
introspection = connection.introspection
return introspection.get_constraints(cursor, table_name)
42 changes: 42 additions & 0 deletions tests/test_schema_editor_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,45 @@ def test_schema_editor_create_delete_materialized_view():

# make sure it was actually deleted
assert model._meta.db_table not in db_introspection.table_names(True)


def test_schema_editor_replace_materialized_view():
"""Tests whether creating a materialized view and then replacing it with
another one (thus changing the backing query) works as expected."""

underlying_model = get_fake_model({"name": models.TextField()})

model = define_fake_materialized_view_model(
{"name": models.TextField()},
{"query": underlying_model.objects.filter(name="test1")},
{"indexes": [models.Index(fields=["name"])]},
)

underlying_model.objects.create(name="test1")
underlying_model.objects.create(name="test2")

schema_editor = PostgresSchemaEditor(connection)
schema_editor.create_materialized_view_model(model)

for index in model._meta.indexes:
schema_editor.add_index(model, index)

constraints_before = db_introspection.get_constraints(model._meta.db_table)

objs = list(model.objects.all())
assert len(objs) == 1
assert objs[0].name == "test1"

model._view_meta.query = underlying_model.objects.filter(
name="test2"
).query.sql_with_params()
schema_editor.replace_materialized_view_model(model)

objs = list(model.objects.all())
assert len(objs) == 1
assert objs[0].name == "test2"

# make sure all indexes/constraints still exists because
# replacing a materialized view involves re-creating it
constraints_after = db_introspection.get_constraints(model._meta.db_table)
assert constraints_after == constraints_before

0 comments on commit 3a13758

Please sign in to comment.