Skip to content

Commit c2117a4

Browse files
committed
init django materialized view app
0 parents  commit c2117a4

31 files changed

+2085
-0
lines changed

.github/workflows/default.yml

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Python CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
13+
services:
14+
postgres:
15+
image: postgres
16+
env:
17+
POSTGRES_USER: postgres
18+
POSTGRES_PASSWORD: postgres
19+
POSTGRES_DB: postgres
20+
ports:
21+
- 5432:5432
22+
options: >-
23+
--health-cmd pg_isready
24+
--health-interval 10s
25+
--health-timeout 5s
26+
--health-retries 5
27+
28+
steps:
29+
- uses: actions/checkout@v2
30+
with:
31+
fetch-depth: 0
32+
33+
- name: Set up Python
34+
uses: actions/setup-python@v2
35+
with:
36+
python-version: 3.9
37+
38+
- name: Install Dependencies
39+
run: |
40+
pip install poetry
41+
poetry config virtualenvs.in-project true
42+
poetry run python -m ensurepip --upgrade
43+
poetry install --no-interaction
44+
45+
- name: Run Tests
46+
run: |
47+
poetry run coverage run -m pytest
48+
49+
- name: Generate Report
50+
run: |
51+
poetry run coverage xml
52+
poetry run coverage report
53+
54+
- name: Upload coverage reports to Codecov
55+
uses: codecov/codecov-action@v3

.github/workflows/release.yml

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- '*'
7+
8+
jobs:
9+
build:
10+
if: github.repository == 'muehlemann-popp/django-materialized-view'
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- uses: actions/checkout@v2
15+
with:
16+
fetch-depth: 0
17+
18+
- name: Set up Python
19+
uses: actions/setup-python@v2
20+
with:
21+
python-version: 3.9
22+
23+
- name: Install dependencies
24+
run: |
25+
python -m pip install -U pip
26+
python -m pip install -U setuptools twine wheel
27+
28+
- name: Build package
29+
run: |
30+
python setup.py --version
31+
python setup.py sdist --format=gztar bdist_wheel
32+
twine check dist/*

.gitignore

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*.egg-info
2+
/__pycache__
3+
.pytest_cache
4+
*.pyc
5+
/reports
6+
/.coverage
7+
/build
8+
.idea
9+
dist

LICENSE

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Copyright 2021 Mühlemann & Popp Online Media AG
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4+
5+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6+
7+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

MANIFEST.in

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
include README.md
2+
include LICENSE
3+
recursive-include docs *
4+
5+
recursive-exclude * __pycache__
6+
recursive-exclude * *.py[co]
7+
recursive-exclude * *.orig

README.md

Whitespace-only changes.

django_materialized_view/__init__.py

Whitespace-only changes.

django_materialized_view/apps.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class DjangoMaterializedViewAppConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "django_materialized_view"
+176
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import datetime
2+
import logging
3+
import time
4+
from typing import Callable, Dict, Optional, Union
5+
6+
from django.conf import settings
7+
from django.db import DEFAULT_DB_ALIAS, connections, models
8+
from django.db.models import QuerySet
9+
from django.db.models.base import ModelBase
10+
11+
__all__ = [
12+
"MaterializedViewModel",
13+
"DBViewsRegistry",
14+
]
15+
16+
from django_materialized_view.models import MaterializedViewRefreshLog
17+
18+
logger = logging.getLogger(__name__)
19+
20+
DBViewsRegistry: Dict[str, "MaterializedViewModel"] = {}
21+
22+
23+
class DBViewModelBase(ModelBase):
24+
def __new__(mcs, *args, **kwargs):
25+
new_class = super().__new__(mcs, *args, **kwargs)
26+
assert new_class._meta.managed is False, "For DB View managed must be se to false" # noqa
27+
if new_class._meta.abstract is False: # noqa
28+
DBViewsRegistry[new_class._meta.db_table] = new_class # noqa
29+
return new_class
30+
31+
32+
class DBMaterializedView(models.Model, metaclass=DBViewModelBase):
33+
"""
34+
Children should define:
35+
view_definition - define the view, can be callable or attribute (string)
36+
"""
37+
38+
view_definition: Union[Callable, str, dict]
39+
40+
class Meta:
41+
managed = False
42+
abstract = True
43+
44+
@classmethod
45+
def refresh(cls, using: Optional[str] = None, concurrently: bool = False):
46+
"""
47+
concurrently option requires an index and postgres db
48+
"""
49+
using = using or DEFAULT_DB_ALIAS
50+
with connections[using].cursor() as cursor:
51+
if concurrently:
52+
cursor.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY %s;" % cls._meta.db_table)
53+
else:
54+
cursor.execute("REFRESH MATERIALIZED VIEW %s;" % cls._meta.db_table)
55+
56+
57+
class MaterializedViewModel(DBMaterializedView):
58+
"""
59+
1. create class and inherit from `MaterializedViewModel`
60+
EXAMPLE:
61+
----------------------------------------------------------------
62+
from django.db import models
63+
from core.models.materialized_view import MaterializedViewModel
64+
65+
class JiraTimeestimateChangelog(MaterializedViewModel):
66+
create_pkey_index = True # if you need add uniq field as a primary kay and create indexes
67+
68+
class Meta:
69+
managed = False
70+
71+
# if create_pkey_index = True you must identify primary_key=True
72+
item = models.OneToOneField("jira.Item", on_delete=models.DO_NOTHING, primary_key=True, db_column="id")
73+
from_seconds = models.IntegerField()
74+
to_seconds = models.IntegerField()
75+
type = models.CharField(max_length=255)
76+
----------------------------------------------------------------
77+
78+
2. create materialized view query .sql file
79+
1. run migrate_with_views command for getting your new sql file name and path
80+
./manage.py migrate_with_views
81+
2. you will get file path in your console
82+
[Errno 2] No such file or directory:
83+
'..../models/materialized_view/sql_files/jiradetailedstatusitem.sql'
84+
- please create SQL file and put it to this directory
85+
3. create file on suggested path with suggested name
86+
4. run again `./manage.py migrate_with_views` -
87+
this command will run default migrate command and apply materialized views
88+
89+
3. add your materialized view to update cron job
90+
- `JiraTimeestimateChangelog.refresh()`
91+
"""
92+
93+
create_pkey_index = False
94+
95+
class Meta:
96+
managed = False
97+
abstract = True
98+
99+
@classmethod
100+
def refresh(cls, using: Optional[str] = None, concurrently: bool = True) -> None:
101+
log = MaterializedViewRefreshLog(
102+
view_name=cls.get_tablename(),
103+
)
104+
try:
105+
start_time = time.monotonic()
106+
super().refresh(using=using, concurrently=concurrently)
107+
end_time = time.monotonic()
108+
log.duration = datetime.timedelta(seconds=end_time - start_time)
109+
except Exception: # noqa
110+
log.failed = True
111+
logging.exception(f"failed to refresh materialized view {cls.get_tablename()}")
112+
log.save()
113+
114+
@classmethod
115+
def view_definition(cls):
116+
return cls.__get_query()
117+
118+
@classmethod
119+
def __create_index_for_primary_key(cls) -> str:
120+
try:
121+
if not cls.create_pkey_index:
122+
return ""
123+
if cls._meta.pk.db_column:
124+
primary_key_field = cls._meta.pk.db_column
125+
else:
126+
primary_key_field = cls._meta.pk.attname
127+
return f"CREATE UNIQUE INDEX {cls.get_tablename()}_pkey ON {cls.get_tablename()} ({primary_key_field})"
128+
except Exception as exc:
129+
print(exc)
130+
exit(-1)
131+
132+
@classmethod
133+
def __get_sql_file_path(cls) -> str:
134+
return (
135+
f"{settings.SRC_DIR}/{cls.__get_app_label()}"
136+
f"/models/materialized_views/sql_files/{cls.__get_class_name()}.sql"
137+
)
138+
139+
@staticmethod
140+
def get_query_from_queryset() -> QuerySet:
141+
"""
142+
redefine this method if you need create materialized view based on queryset instead sql file
143+
example:
144+
@staticmethod
145+
def get_query_from_queryset() -> QuerySet:
146+
return User.objects.all()
147+
"""
148+
pass
149+
150+
@classmethod
151+
def __get_query(cls) -> str:
152+
queryset = cls.get_query_from_queryset()
153+
154+
if isinstance(queryset, QuerySet):
155+
sql_query = f"{queryset.query}; {cls.__create_index_for_primary_key()}"
156+
return sql_query
157+
try:
158+
with open(cls.__get_sql_file_path(), "r") as sql_file:
159+
sql_query = f"{sql_file.read()}; {cls.__create_index_for_primary_key()}"
160+
except FileNotFoundError as exc:
161+
raise FileNotFoundError(f"{exc}, - please create SQL file and put it to this directory")
162+
return sql_query
163+
164+
@classmethod
165+
def __get_class_name(cls) -> str:
166+
return cls.__name__.casefold()
167+
168+
@classmethod
169+
def __get_app_label(cls) -> str:
170+
return cls._meta.app_label
171+
172+
@classmethod
173+
def get_tablename(cls) -> str:
174+
return f"{cls.__get_app_label()}_{cls.__get_class_name()}"
175+
176+

django_materialized_view/management/__init__.py

Whitespace-only changes.

django_materialized_view/management/commands/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import re
2+
3+
from django.core.management import call_command
4+
from django.core.management.base import BaseCommand
5+
from django.db import NotSupportedError
6+
7+
from django_materialized_view.processor import MaterializedViewsProcessor
8+
9+
10+
def extract_mv_name(line):
11+
"""
12+
>>> extract_mv_name('rule _RETURN on materialized view some_materialized_name depends on column')
13+
'some_materialized_name'
14+
"""
15+
pattern = re.compile(r"rule _RETURN on materialized view (\w+) depends on column")
16+
match = pattern.search(line)
17+
if match:
18+
return match.group(1)
19+
else:
20+
return None
21+
22+
23+
class Command(BaseCommand):
24+
help = "Applies migrations if need removes materialized views and then recreates them"
25+
views_to_be_recreated = set() # type: ignore
26+
view_processor = MaterializedViewsProcessor()
27+
28+
def add_arguments(self, parser):
29+
parser.add_argument("args", nargs="*")
30+
31+
def handle(self, *args, **kwargs):
32+
try:
33+
call_command("migrate", args)
34+
except NotSupportedError as exc:
35+
print() # need for new line
36+
mv_name = extract_mv_name(exc.args[0])
37+
if not mv_name:
38+
raise exc
39+
self.views_to_be_recreated.add(mv_name)
40+
sorted_views = self.view_processor.get_view_list_sorted_by_dependencies(self.views_to_be_recreated)
41+
self.views_to_be_recreated.update(sorted_views)
42+
for view in self.views_to_be_recreated:
43+
self.view_processor.add_view_to_be_deleted(view)
44+
self.view_processor.delete_views()
45+
self.handle(*args, **kwargs)
46+
self.view_processor.process_materialized_views()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 4.1.3 on 2023-02-15 15:07
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
initial = True
9+
10+
dependencies = [
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='MaterializedViewRefreshLog',
16+
fields=[
17+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18+
('updated_at', models.DateTimeField(auto_now_add=True, db_index=True)),
19+
('duration', models.DurationField(null=True)),
20+
('failed', models.BooleanField(default=False)),
21+
('view_name', models.CharField(max_length=255)),
22+
],
23+
),
24+
migrations.CreateModel(
25+
name='MaterializedViewMigrations',
26+
fields=[
27+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
28+
('applied', models.DateTimeField(auto_now_add=True)),
29+
('app', models.CharField(max_length=255)),
30+
('view_name', models.CharField(max_length=255)),
31+
('hash', models.CharField(max_length=255)),
32+
('deleted', models.BooleanField(default=False)),
33+
],
34+
),
35+
migrations.AddConstraint(
36+
model_name='materializedviewmigrations',
37+
constraint=models.UniqueConstraint(condition=models.Q(('deleted', False)), fields=('app', 'view_name'), name='one_active_view_per_model'),
38+
),
39+
]

django_materialized_view/migrations/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)