Skip to content
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
102 changes: 56 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ INSTALLED_APPS = (
)
```

## Examples
## Basic example

```python
from django.db import models
Expand All @@ -35,17 +35,14 @@ class Customer(models.Model):
post_code = models.CharField(max_length=20)
is_preferred = models.BooleanField(default=False)

class Meta:
app_label = 'myapp'

class PreferredCustomer(pg.View):
projection = ['myapp.Customer.*',]
dependencies = ['myapp.OtherView',]
sql = """SELECT * FROM myapp_customer WHERE is_preferred = TRUE;"""

class PreferredCustomer(pg.View):
name = models.CharField(max_length=100)
post_code = models.CharField(max_length=20)

sql = """SELECT id, name, post_code FROM myapp_customer WHERE is_preferred IS TRUE"""

class Meta:
app_label = 'myapp'
db_table = 'myapp_preferredcustomer'
managed = False
```

Expand All @@ -59,33 +56,16 @@ CREATE VIEW myapp_preferredcustomer AS
SELECT * FROM myapp_customer WHERE is_preferred = TRUE;
```

To create all your views, run ``python manage.py sync_pgviews``

You can also specify field names, which will map onto fields in your View:

```python
from django_pgviews import view as pg


VIEW_SQL = """
SELECT name, post_code FROM myapp_customer WHERE is_preferred = TRUE
"""

To create this view, run `python manage.py migrate`, or `python manage.py sync_pgviews`.

class PreferredCustomer(pg.View):
name = models.CharField(max_length=100)
post_code = models.CharField(max_length=20)

sql = VIEW_SQL
```
Then you can query `PreferredCustomer` like any other model.

## Usage

To map onto a View, simply extend `pg_views.view.View`, assign SQL to the
`sql` argument, and define a `db_table`. You must _always_ set `managed = False`
on the `Meta` class.
To create a view, create a new class that subclasses `django_pgviews.view.View` instead of `models.Model`,
set `managed = False` on the `Meta` class, and define the `sql` class attribute with the definition of the view.

Views can be created in a number of ways:
Views can be created in two basic ways:

1. Define fields to map onto the VIEW output
2. Define a projection that describes the VIEW fields
Expand All @@ -98,20 +78,14 @@ Define the fields as you would with any Django Model:
from django_pgviews import view as pg


VIEW_SQL = """
SELECT name, post_code FROM myapp_customer WHERE is_preferred = TRUE
"""


class PreferredCustomer(pg.View):
name = models.CharField(max_length=100)
post_code = models.CharField(max_length=20)

sql = VIEW_SQL
sql = "SELECT id, name, post_code FROM myapp_customer WHERE is_preferred = TRUE"

class Meta:
managed = False
db_table = 'my_sql_view'
```

### Define Projection
Expand All @@ -124,30 +98,68 @@ from django_pgviews import view as pg


class PreferredCustomer(pg.View):
projection = ['myapp.Customer.*',]
projection = ['myapp.Customer.*']
sql = """SELECT * FROM myapp_customer WHERE is_preferred = TRUE;"""

class Meta:
db_table = 'my_sql_view'
managed = False
```

This will take all fields on `myapp.Customer` and apply them to `PreferredCustomer`

## Migrations

When you run `makemigrations`, `django-pgviews` will detect changes in views and create migrations to register new views and to drop renamed or removed views.

By default, when you run `migrate`, `django-pgviews` will create or update your views – you may turn this off with `MATERIALIZED_VIEWS_DISABLE_SYNC_ON_MIGRATE`, see below.

### Autodetector

If you use another library which updates the Django migration AutoDetector, if you want to keep full functionality, you need to subclass
the AutoDetector class to subclass from `django_pgviews.db.migrations.autodetector.PGViewsAutodetector` as well.

### Changing upstream fields

If you need to change a column which is used in a view definition, you may get an error like this:

```
django.db.utils.NotSupportedError: cannot alter type of a column used by a view or rule
DETAIL: rule _RETURN on materialized view some_view depends on column "some_column"
```

To handle this, you can use the migrations to drop the view before the migration gets applied, and then recreate it afterwards.

1. Add an empty migration to the app with your view (`python manage.py makemigrations --empty app_name`)
2. Add database operation to the migration
```python
migrations.SeparateDatabaseAndState(
database_operations=[
django_pgviews.db.migrations.operations.DeleteViewOperation(
name="SomeView", # CHANGEME
materialized=True, # CHANGEME
db_name="some_view", # CHANGEME
),
]
),
```
3. Make the migration changing the column depend on the migration dropping the view

When you then run migrations, the view will be dropped, the column will be changed, and the view will be recreated by the command after all migrations are applied.

## Features

### Configuration

`MATERIALIZED_VIEWS_DISABLE_SYNC_ON_MIGRATE`

When set to True, it skips running `sync_pgview` during migrations, which can be useful if you want to control the synchronization manually or avoid potential overhead during migrations. (default: False)
When set to True, it skips running `sync_pgviews` during migrations, which can be useful if you want to control the synchronization manually or avoid potential overhead during migrations. (default: False)
```
MATERIALIZED_VIEWS_DISABLE_SYNC_ON_MIGRATE = True
```

### Updating Views

Sometimes your models change, and you need your Database Views to reflect the new data.
Sometimes your models change, and you need your views to reflect the new data.
Updating the View logic is as simple as modifying the underlying SQL and running:

```
Expand Down Expand Up @@ -175,8 +187,6 @@ class PreferredCustomer(pg.View):
sql = """SELECT * FROM myapp_customer WHERE is_preferred = TRUE;"""

class Meta:
app_label = 'myapp'
db_table = 'myapp_preferredcustomer'
managed = False
```

Expand Down Expand Up @@ -254,7 +264,7 @@ def customer_saved(sender, action=None, instance=None, **kwargs):

As the materialized view isn't defined through the usual Django model fields, any indexes defined there won't be
created on the materialized view. Luckily Django provides a Meta option called `indexes` which can be used to add custom
indexes to models. `pg_views` supports defining indexes on materialized views using this option.
indexes to models. `django-pgviews` supports defining indexes on materialized views using this option.

In the following example, one index will be created, on the `name` column. The `db_index=True` on the field definition
for `post_code` will get ignored.
Expand Down
2 changes: 2 additions & 0 deletions django_pgviews/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def ready(self) -> None:
"""
from django.conf import settings

from .checks import validate_has_pgviews_autodetector # noqa

sync_enabled = getattr(settings, "MATERIALIZED_VIEWS_DISABLE_SYNC_ON_MIGRATE", False) is False

if sync_enabled:
Expand Down
41 changes: 41 additions & 0 deletions django_pgviews/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from typing import Any

from django.core.checks import CheckMessage, Tags, register
from django.core.checks import Warning as CheckWarning

from django_pgviews.db.migrations.autodetector import PGViewsAutodetector


@register(Tags.database) # type: ignore[not-callable]
def validate_has_pgviews_autodetector(**kwargs: Any) -> list[CheckMessage]:
from django.core.management import get_commands, load_command_class

commands = get_commands()

make_migrations = load_command_class(commands["makemigrations"], "makemigrations")
migrate = load_command_class(commands["migrate"], "migrate")

if not issubclass(
migrate.autodetector, # type: ignore[missing-attribute]
PGViewsAutodetector,
) or not issubclass(
make_migrations.autodetector, # type: ignore[missing-attribute]
PGViewsAutodetector,
):
return [
CheckWarning(
(
"If you don't use PGViewsAutodetector on your migrate and makemigrations commands, "
"django_pgviews will not detect and delete that views have been removed. "
"You are seeing this because you or some other dependency has overwritten the commands "
"from django_pgviews. "
),
hint=(
f"The makemigrations.Command.autodetector is {make_migrations.autodetector.__name__}, " # type: ignore[missing-attribute]
f"the migrate.Command.autodetector is {migrate.autodetector.__name__}." # type: ignore[missing-attribute]
),
id="django_pgviews.W001",
)
]

return []
Empty file.
92 changes: 92 additions & 0 deletions django_pgviews/db/migrations/autodetector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from django.apps import apps
from django.db.migrations.autodetector import MigrationAutodetector

from django_pgviews.db.migrations.operations import DeleteViewOperation, RegisterViewOperation, ViewState
from django_pgviews.view import View


class PGViewsAutodetector(MigrationAutodetector):
def populate_to_state_views(self):
"""
Ideally, we'd override ProjectState.from_apps() to do this, but
the makemigrations command does not expose a nice way to generate a ProjectState, so we need to hack it in.
"""
app_views = {}
for model in apps.get_models():
if not issubclass(model, View):
continue
view_state = ViewState.from_view(model)
app_views[(view_state.app_label, view_state.name_lower)] = view_state

self.to_state.views.update(app_views)

def _sort_migrations(self):
"""
Detects new/deleted views.

Ideally we'd override `_detect_changes`, but we need
1. Run after self.generated_operations is created
2. Run before self._sort_migrations()

This is probably the easiest way to do this.
"""
# ideally we'd subclass ProjectState but we can't
if not hasattr(self.from_state, "views"):
self.from_state.views = {}
if not hasattr(self.to_state, "views"):
self.to_state.views = {}

self.populate_to_state_views()

self.old_view_state = {}
self.new_view_state = {}

for (app_label, view_name), view_state in self.from_state.views.items():
self.old_view_state[(app_label, view_name)] = view_state
for (app_label, view_name), view_state in self.to_state.views.items():
self.new_view_state[(app_label, view_name)] = view_state

self.generate_deleted_views()
self.generate_created_views()

return super()._sort_migrations()

def generate_deleted_views(self):
new_keys = self.new_view_state.keys()
deleted_views = self.old_view_state.keys() - new_keys

for key in self.new_view_state.keys() & self.old_view_state.keys():
if self.new_view_state[key] != self.old_view_state[key]:
deleted_views.add(key)

for app_label, view_name in deleted_views:
view_state = self.from_state.views[app_label, view_name]

self.add_operation(
app_label,
DeleteViewOperation(
name=view_state.name,
materialized=view_state.materialized,
db_name=view_state.db_name,
),
)

def generate_created_views(self):
old_keys = self.old_view_state.keys()
added_views = self.new_view_state.keys() - old_keys

for key in self.new_view_state.keys() & self.old_view_state.keys():
if self.new_view_state[key] != self.old_view_state[key]:
added_views.add(key)

for app_label, view_name in added_views:
view_state = self.to_state.views[app_label, view_name]

self.add_operation(
app_label,
RegisterViewOperation(
name=view_state.name,
materialized=view_state.materialized,
db_name=view_state.db_name,
),
)
Loading