├── .github
├── dependabot.yml
└── workflows
│ ├── build.yml
│ ├── check.yml
│ └── pre_commit.yml
├── .gitignore
├── .pre-commit-config.yaml
├── MANIFEST.in
├── README.md
├── UNLICENSE
├── django_pgviews
├── __init__.py
├── apps.py
├── compat.py
├── db
│ ├── __init__.py
│ └── sql
│ │ ├── __init__.py
│ │ ├── compiler.py
│ │ └── query.py
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ ├── clear_pgviews.py
│ │ ├── refresh_pgviews.py
│ │ └── sync_pgviews.py
├── models.py
├── signals.py
└── view.py
├── doc
├── Makefile
├── bitstrings.rst
├── conf.py
├── index.rst
└── views.rst
├── pyproject.toml
├── tests
├── __init__.py
├── manage.py
└── test_project
│ ├── __init__.py
│ ├── multidbtest
│ ├── __init__.py
│ ├── models.py
│ └── tests.py
│ ├── routers.py
│ ├── schemadbtest
│ ├── __init__.py
│ ├── models.py
│ └── tests.py
│ ├── settings
│ ├── __init__.py
│ ├── base.py
│ └── ci.py
│ ├── urls.py
│ ├── viewtest
│ ├── __init__.py
│ ├── models.py
│ └── tests.py
│ └── wsgi.py
└── tox.ini
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | tags:
7 | - v*
8 | jobs:
9 | build_wheels:
10 | name: Build
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - name: Install Poetry
15 | uses: snok/install-poetry@v1.4.1
16 | - name: Build
17 | run: poetry build
18 | - uses: actions/upload-artifact@v4
19 | with:
20 | path: ./dist/*
21 |
22 | upload_pypi:
23 | name: Upload to PyPI
24 | needs: [build_wheels]
25 | runs-on: ubuntu-latest
26 | environment: pypi
27 | permissions:
28 | id-token: write
29 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
30 | steps:
31 | - uses: actions/download-artifact@v4
32 | with:
33 | name: artifact
34 | path: dist
35 |
36 | - uses: pypa/gh-action-pypi-publish@release/v1
37 | with:
38 | skip-existing: true
39 |
--------------------------------------------------------------------------------
/.github/workflows/check.yml:
--------------------------------------------------------------------------------
1 | name: check
2 | on:
3 | workflow_dispatch:
4 | pull_request:
5 | push:
6 | branches:
7 | - main
8 |
9 | jobs:
10 | test:
11 | name: test ${{ matrix.py }} on postgres ${{ matrix.postgres-version }}
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | fail-fast: false
16 | matrix:
17 | py:
18 | - "3.12"
19 | - "3.11"
20 | - "3.10"
21 | - "3.9"
22 | - "3.8"
23 | postgres-version:
24 | # - 12 - 5.1 doesn't support 12 anymore, let's just stop running tests for it
25 | - 13
26 | - 14
27 | - 15
28 | - 16
29 | steps:
30 | - name: Setup python for test ${{ matrix.py }}
31 | uses: actions/setup-python@v5
32 | with:
33 | python-version: ${{ matrix.py }}
34 | - uses: actions/checkout@v4
35 | - name: Create database
36 | run: |
37 | # maps the container port to localhost
38 | docker run --name db -p 5432:5432 -d -e POSTGRES_PASSWORD=postgres postgres:${{ matrix.postgres-version }}
39 | sleep 10 # wait for server to initialize
40 |
41 | - name: Install tox-gh
42 | run: python -m pip install tox-gh
43 | - name: Run test suite
44 | run: tox
45 |
--------------------------------------------------------------------------------
/.github/workflows/pre_commit.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - main
5 | pull_request:
6 | branches:
7 | - main
8 | workflow_dispatch:
9 |
10 | name: Pre-commit
11 |
12 | jobs:
13 | pre_commit:
14 | name: Pre-commit
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v4
18 | - uses: actions/setup-python@v5
19 | with:
20 | python-version: '3.9'
21 | - uses: pre-commit/action@v3.0.1
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.py[co]
2 |
3 | # Virtualenvs for testing
4 | .venv
5 | venv/
6 | .direnv
7 | .envrc
8 |
9 | # Temporary testing directories
10 | test__*
11 |
12 | # Packages
13 | *.egg
14 | *.egg-info
15 | dist
16 | build
17 | eggs
18 | parts
19 | bin
20 | var
21 | sdist
22 | develop-eggs
23 | .installed.cfg
24 | include
25 | lib
26 | local
27 |
28 | # Sphinx docs
29 | doc/_build
30 |
31 | # Installer logs
32 | pip-log.txt
33 |
34 | # Unit test / coverage reports
35 | .coverage
36 | .tox
37 |
38 | #Translations
39 | *.mo
40 |
41 | #Mr Developer
42 | .mr.developer.cfg
43 |
44 | # Compiled
45 | *.pyc
46 | README.rst
47 |
48 | # Temporary files
49 | *~
50 | .*.swp
51 |
52 | .idea
53 | pip-selfcheck.json
54 | tags
55 | pip-wheel-metadata
56 |
57 | poetry.lock
58 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | default_language_version:
2 | python: python3.9
3 | repos:
4 | - repo: https://github.com/astral-sh/ruff-pre-commit
5 | rev: 'v0.11.8'
6 | hooks:
7 | - id: ruff
8 | args: [ "--fix" ]
9 | - id: ruff-format
10 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xelixdev/django-pgviews-redux/7aafbbc56331a252506c6f48d3b22b9f5cc6c519/MANIFEST.in
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SQL Views for Postgres
2 |
3 | Adds first-class support for [PostgreSQL Views][pg-views] in the Django ORM.
4 | Fork of the original [django-pgviews][django-pgviews] by [mypebble][mypebble] with support for Django 3.2+.
5 |
6 | [pg-views]: http://www.postgresql.org/docs/9.1/static/sql-createview.html
7 | [django-pgviews]: https://github.com/mypebble/django-pgviews
8 | [mypebble]: https://github.com/mypebble
9 |
10 | ## Installation
11 |
12 | Install via pip:
13 |
14 | pip install django-pgviews-redux
15 |
16 | Add to installed applications in settings.py:
17 |
18 | ```python
19 | INSTALLED_APPS = (
20 | # ...
21 | 'django_pgviews',
22 | )
23 | ```
24 |
25 | ## Examples
26 |
27 | ```python
28 | from django.db import models
29 |
30 | from django_pgviews import view as pg
31 |
32 |
33 | class Customer(models.Model):
34 | name = models.CharField(max_length=100)
35 | post_code = models.CharField(max_length=20)
36 | is_preferred = models.BooleanField(default=False)
37 |
38 | class Meta:
39 | app_label = 'myapp'
40 |
41 | class PreferredCustomer(pg.View):
42 | projection = ['myapp.Customer.*',]
43 | dependencies = ['myapp.OtherView',]
44 | sql = """SELECT * FROM myapp_customer WHERE is_preferred = TRUE;"""
45 |
46 | class Meta:
47 | app_label = 'myapp'
48 | db_table = 'myapp_preferredcustomer'
49 | managed = False
50 | ```
51 |
52 | **NOTE** It is important that we include the `managed = False` in the `Meta` so
53 | Django 1.7 migrations don't attempt to create DB tables for this view.
54 |
55 | The SQL produced by this might look like:
56 |
57 | ```postgresql
58 | CREATE VIEW myapp_preferredcustomer AS
59 | SELECT * FROM myapp_customer WHERE is_preferred = TRUE;
60 | ```
61 |
62 | To create all your views, run ``python manage.py sync_pgviews``
63 |
64 | You can also specify field names, which will map onto fields in your View:
65 |
66 | ```python
67 | from django_pgviews import view as pg
68 |
69 |
70 | VIEW_SQL = """
71 | SELECT name, post_code FROM myapp_customer WHERE is_preferred = TRUE
72 | """
73 |
74 |
75 | class PreferredCustomer(pg.View):
76 | name = models.CharField(max_length=100)
77 | post_code = models.CharField(max_length=20)
78 |
79 | sql = VIEW_SQL
80 | ```
81 |
82 | ## Usage
83 |
84 | To map onto a View, simply extend `pg_views.view.View`, assign SQL to the
85 | `sql` argument and define a `db_table`. You must _always_ set `managed = False`
86 | on the `Meta` class.
87 |
88 | Views can be created in a number of ways:
89 |
90 | 1. Define fields to map onto the VIEW output
91 | 2. Define a projection that describes the VIEW fields
92 |
93 | ### Define Fields
94 |
95 | Define the fields as you would with any Django Model:
96 |
97 | ```python
98 | from django_pgviews import view as pg
99 |
100 |
101 | VIEW_SQL = """
102 | SELECT name, post_code FROM myapp_customer WHERE is_preferred = TRUE
103 | """
104 |
105 |
106 | class PreferredCustomer(pg.View):
107 | name = models.CharField(max_length=100)
108 | post_code = models.CharField(max_length=20)
109 |
110 | sql = VIEW_SQL
111 |
112 | class Meta:
113 | managed = False
114 | db_table = 'my_sql_view'
115 | ```
116 |
117 | ### Define Projection
118 |
119 | `django-pgviews` can take a projection to figure out what fields it needs to
120 | map onto for a view. To use this, set the `projection` attribute:
121 |
122 | ```python
123 | from django_pgviews import view as pg
124 |
125 |
126 | class PreferredCustomer(pg.View):
127 | projection = ['myapp.Customer.*',]
128 | sql = """SELECT * FROM myapp_customer WHERE is_preferred = TRUE;"""
129 |
130 | class Meta:
131 | db_table = 'my_sql_view'
132 | managed = False
133 | ```
134 |
135 | This will take all fields on `myapp.Customer` and apply them to
136 | `PreferredCustomer`
137 |
138 | ## Features
139 |
140 | ### Configuration
141 | `MATERIALIZED_VIEWS_DISABLE_SYNC_ON_MIGRATE`
142 |
143 | 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)
144 | ```
145 | MATERIALIZED_VIEWS_DISABLE_SYNC_ON_MIGRATE = True
146 | ```
147 |
148 | ### Updating Views
149 |
150 | Sometimes your models change and you need your Database Views to reflect the new
151 | data. Updating the View logic is as simple as modifying the underlying SQL and
152 | running:
153 |
154 | ```
155 | python manage.py sync_pgviews --force
156 | ```
157 |
158 | This will forcibly update any views that conflict with your new SQL.
159 |
160 | ### Dependencies
161 |
162 | You can specify other views you depend on. This ensures the other views are
163 | installed beforehand. Using dependencies also ensures that your views get
164 | refreshed correctly when using `sync_pgviews --force`.
165 |
166 | **Note:** Views are synced after the Django application has migrated and adding
167 | models to the dependency list will cause syncing to fail.
168 |
169 | Example:
170 |
171 | ```python
172 | from django_pgviews import view as pg
173 |
174 | class PreferredCustomer(pg.View):
175 | dependencies = ['myapp.OtherView',]
176 | sql = """SELECT * FROM myapp_customer WHERE is_preferred = TRUE;"""
177 |
178 | class Meta:
179 | app_label = 'myapp'
180 | db_table = 'myapp_preferredcustomer'
181 | managed = False
182 | ```
183 |
184 | ### Materialized Views
185 |
186 | Postgres 9.3 and up supports [materialized views](http://www.postgresql.org/docs/current/static/sql-creatematerializedview.html)
187 | which allow you to cache the results of views, potentially allowing them
188 | to load faster.
189 |
190 | However, you do need to manually refresh the view. To do this automatically,
191 | you can attach [signals](https://docs.djangoproject.com/en/1.8/ref/signals/)
192 | and call the refresh function.
193 |
194 | Example:
195 |
196 | ```python
197 | from django_pgviews import view as pg
198 |
199 |
200 | VIEW_SQL = """
201 | SELECT name, post_code FROM myapp_customer WHERE is_preferred = TRUE
202 | """
203 |
204 | class Customer(models.Model):
205 | name = models.CharField(max_length=100)
206 | post_code = models.CharField(max_length=20)
207 | is_preferred = models.BooleanField(default=True)
208 |
209 |
210 | class PreferredCustomer(pg.MaterializedView):
211 | name = models.CharField(max_length=100)
212 | post_code = models.CharField(max_length=20)
213 |
214 | sql = VIEW_SQL
215 |
216 |
217 | @receiver(post_save, sender=Customer)
218 | def customer_saved(sender, action=None, instance=None, **kwargs):
219 | PreferredCustomer.refresh()
220 | ```
221 |
222 | #### Concurrent refresh
223 |
224 | Postgres 9.4 and up allow materialized views to be refreshed concurrently, without blocking reads, as long as a
225 | unique index exists on the materialized view. To enable concurrent refresh, specify the name of a column that can be
226 | used as a unique index on the materialized view. Unique index can be defined on more than one column of a materialized
227 | view. Once enabled, passing `concurrently=True` to the model's refresh method will result in postgres performing the
228 | refresh concurrently. (Note that the refresh method itself blocks until the refresh is complete; concurrent refresh is
229 | most useful when materialized views are updated in another process or thread.)
230 |
231 | Example:
232 |
233 | ```python
234 | from django_pgviews import view as pg
235 |
236 |
237 | VIEW_SQL = """
238 | SELECT id, name, post_code FROM myapp_customer WHERE is_preferred = TRUE
239 | """
240 |
241 | class PreferredCustomer(pg.MaterializedView):
242 | concurrent_index = 'id, post_code'
243 | sql = VIEW_SQL
244 |
245 | name = models.CharField(max_length=100)
246 | post_code = models.CharField(max_length=20)
247 |
248 |
249 | @receiver(post_save, sender=Customer)
250 | def customer_saved(sender, action=None, instance=None, **kwargs):
251 | PreferredCustomer.refresh(concurrently=True)
252 | ```
253 |
254 | #### Indexes
255 |
256 | As the materialized view isn't defined through the usual Django model fields, any indexes defined there won't be
257 | created on the materialized view. Luckily Django provides a Meta option called `indexes` which can be used to add custom
258 | indexes to models. `pg_views` supports defining indexes on materialized views using this option.
259 |
260 | In the following example, one index will be created, on the `name` column. The `db_index=True` on the field definition
261 | for `post_code` will get ignored.
262 |
263 | ```python
264 | from django_pgviews import view as pg
265 |
266 |
267 | VIEW_SQL = """
268 | SELECT id, name, post_code FROM myapp_customer WHERE is_preferred = TRUE
269 | """
270 |
271 | class PreferredCustomer(pg.MaterializedView):
272 | sql = VIEW_SQL
273 |
274 | name = models.CharField(max_length=100)
275 | post_code = models.CharField(max_length=20, db_index=True)
276 |
277 | class Meta:
278 | managed = False # don't forget this, otherwise Django will think it's a regular model
279 | indexes = [
280 | models.Index(fields=["name"]),
281 | ]
282 | ```
283 |
284 | #### WITH NO DATA
285 |
286 | Materialized views can be created either with or without data. By default, they are created with data, however
287 | `pg_views` supports creating materialized views without data, by defining `with_data = False` for the
288 | `pg.MaterializedView` class. Such views then do not support querying until the first
289 | refresh (raising `django.db.utils.OperationalError`).
290 |
291 | Example:
292 |
293 | ```python
294 | from django_pgviews import view as pg
295 |
296 | class PreferredCustomer(pg.MaterializedView):
297 | concurrent_index = 'id, post_code'
298 | sql = """
299 | SELECT id, name, post_code FROM myapp_customer WHERE is_preferred = TRUE
300 | """
301 | with_data = False
302 |
303 | name = models.CharField(max_length=100)
304 | post_code = models.CharField(max_length=20)
305 | ```
306 |
307 | #### Conditional materialized views recreate
308 |
309 | Since all materialized views are recreated on running `migrate`, it can lead to obsolete recreations even if there
310 | were no changes to the definition of the view. To prevent this, version 0.7.0 and higher contain a feature which
311 | checks existing materialized view definition in the database (if the mat. view exists at all) and compares the
312 | definition with the one currently defined in your `pg.MaterializedView` subclass. If the definition matches
313 | exactly, the re-create of materialized view is skipped.
314 |
315 | This feature is enabled by setting the `MATERIALIZED_VIEWS_CHECK_SQL_CHANGED` in your Django settings to `True`,
316 | which enables the feature when running `migrate`. The command `sync_pgviews` uses this setting as well,
317 | however it also has switches `--enable-materialized-views-check-sql-changed` and
318 | `--disable-materialized-views-check-sql-changed` which override this setting for that command.
319 |
320 | This feature also takes into account indexes. When a view is deemed not needing recreating, the process will still
321 | check the indexes on the table and delete any extra indexes and create any missing indexes. This reconciliation
322 | is done through the index name, so if you use custom names for your indexes, it might happen that it won't get updated
323 | on change of the content but not the name.
324 |
325 | ### Schemas
326 |
327 | By default, the views will get created in the schema of the database, this is usually `public`.
328 | The package supports the database defining the schema in the settings by using
329 | options (`"OPTIONS": {"options": "-c search_path=custom_schema"}`).
330 |
331 | The package `django-tenants` is supported as well, if used.
332 |
333 | It is possible to define the schema explicitly for a view, if different from the default schema of the database, like
334 | this:
335 |
336 | ```python
337 | from django_pgviews import view as pg
338 |
339 |
340 | class PreferredCustomer(pg.View):
341 | sql = """SELECT * FROM myapp_customer WHERE is_preferred = TRUE;"""
342 |
343 | class Meta:
344 | db_table = 'my_custom_schema.preferredcustomer'
345 | managed = False
346 | ```
347 |
348 | ### Dynamic View SQL
349 |
350 | If you need a dynamic view SQL (for example if it needs a value from settings in it), you can override the `run_sql`
351 | classmethod on the view to return the SQL. The method should return a namedtuple `ViewSQL`, which contains the query
352 | and potentially the params to `cursor.execute` call. Params should be either None or a list of parameters for the query.
353 |
354 | ```python
355 | from django.conf import settings
356 | from django_pgviews import view as pg
357 |
358 |
359 | class PreferredCustomer(pg.View):
360 | @classmethod
361 | def get_sql(cls):
362 | return pg.ViewSQL(
363 | """SELECT * FROM myapp_customer WHERE is_preferred = TRUE and created_at >= %s;""",
364 | [settings.MIN_PREFERRED_CUSTOMER_CREATED_AT]
365 | )
366 |
367 | class Meta:
368 | db_table = 'preferredcustomer'
369 | managed = False
370 | ```
371 |
372 | ### Sync Listeners
373 |
374 | django-pgviews 0.5.0 adds the ability to listen to when a `post_sync` event has
375 | occurred.
376 |
377 | #### `view_synced`
378 |
379 | Fired every time a VIEW is synchronised with the database.
380 |
381 | Provides args:
382 | * `sender` - View Class
383 | * `update` - Whether the view to be updated
384 | * `force` - Whether `force` was passed
385 | * `status` - The result of creating the view e.g. `EXISTS`, `FORCE_REQUIRED`
386 | * `has_changed` - Whether the view had to change
387 |
388 | #### `all_views_synced`
389 |
390 | Sent after all Postgres VIEWs are synchronised.
391 |
392 | Provides args:
393 | * `sender` - Always `None`
394 |
395 |
396 | ### Multiple databases
397 |
398 | django-pgviews can use multiple databases. Similar to Django's `migrate`
399 | management command, our commands (`clear_pgviews`, `refresh_pgviews`,
400 | `sync_pgviews`) operate on one database at a time. You can specify which
401 | database to synchronize by providing the `--database` option. For example:
402 |
403 | ```shell
404 | python manage.py sync_pgviews # uses default db
405 | python manage.py sync_pgviews --database=myotherdb
406 | ```
407 |
408 | Unless using custom routers, django-pgviews will sync all views to the specified
409 | database. If you want to interact with multiple databases automatically, you'll
410 | need to take some additional steps. Please refer to Django's [Automatic database
411 | routing](https://docs.djangoproject.com/en/3.2/topics/db/multi-db/#automatic-database-routing)
412 | to pin views to specific databases.
413 |
414 |
415 | ## Django Compatibility
416 |
417 |
418 |
419 |
420 | Django Version |
421 | Django-PGView Version |
422 |
423 |
424 |
425 |
426 | 1.4 and down |
427 | Unsupported |
428 |
429 |
430 | 1.5 |
431 | 0.0.1 |
432 |
433 |
434 | 1.6 |
435 | 0.0.3 |
436 |
437 |
438 | 1.7 |
439 | 0.0.4 |
440 |
441 |
442 | 1.9 |
443 | 0.1.0 |
444 |
445 |
446 | 1.10 |
447 | 0.2.0 |
448 |
449 |
450 | 2.2 |
451 | 0.6.0 |
452 |
453 |
454 | 3.0 |
455 | 0.6.0 |
456 |
457 |
458 | 3.1 |
459 | 0.6.1 |
460 |
461 | 3.2 |
462 | 0.7.1 |
463 |
464 |
465 | 4.0 |
466 | 0.8.1 |
467 |
468 |
469 | 4.1 |
470 | 0.8.4 |
471 |
472 |
473 | 4.2 |
474 | 0.9.2 |
475 |
476 |
477 | 5.0 |
478 | 0.9.4 |
479 |
480 |
481 |
482 |
483 | ## Python 3 Support
484 |
485 | Django PGViews Redux only officially supports Python 3.7+, it might work on 3.6, but there's no guarantees.
486 |
--------------------------------------------------------------------------------
/UNLICENSE:
--------------------------------------------------------------------------------
1 | This is free and unencumbered software released into the public domain.
2 |
3 | Anyone is free to copy, modify, publish, use, compile, sell, or
4 | distribute this software, either in source code form or as a compiled
5 | binary, for any purpose, commercial or non-commercial, and by any
6 | means.
7 |
8 | In jurisdictions that recognize copyright laws, the author or authors
9 | of this software dedicate any and all copyright interest in the
10 | software to the public domain. We make this dedication for the benefit
11 | of the public at large and to the detriment of our heirs and
12 | successors. We intend this dedication to be an overt act of
13 | relinquishment in perpetuity of all present and future rights to this
14 | software under copyright law.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
24 | For more information, please refer to
25 |
--------------------------------------------------------------------------------
/django_pgviews/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xelixdev/django-pgviews-redux/7aafbbc56331a252506c6f48d3b22b9f5cc6c519/django_pgviews/__init__.py
--------------------------------------------------------------------------------
/django_pgviews/apps.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from django import apps
4 | from django.db.models import signals
5 |
6 | logger = logging.getLogger("django_pgviews.sync_pgviews")
7 |
8 |
9 | class ViewConfig(apps.AppConfig):
10 | """
11 | The base configuration for Django PGViews. We use this to setup our
12 | post_migrate signal handlers.
13 | """
14 |
15 | counter = 0
16 | name = "django_pgviews"
17 | verbose_name = "Django Postgres Views"
18 |
19 | def sync_pgviews(self, sender, app_config, using, **kwargs):
20 | """
21 | Forcibly sync the views.
22 | """
23 | self.counter = self.counter + 1
24 | total = len([a for a in apps.apps.get_app_configs() if a.models_module is not None])
25 |
26 | if self.counter == total:
27 | logger.info("All applications have migrated, time to sync")
28 | # Import here otherwise Django doesn't start properly
29 | # (models in app init are not allowed)
30 | from django.conf import settings
31 |
32 | from .models import ViewSyncer
33 |
34 | vs = ViewSyncer()
35 | vs.run(
36 | force=True,
37 | update=True,
38 | materialized_views_check_sql_changed=getattr(settings, "MATERIALIZED_VIEWS_CHECK_SQL_CHANGED", False),
39 | using=using,
40 | )
41 | self.counter = 0
42 |
43 | def ready(self):
44 | """
45 | Find and setup the apps to set the post_migrate hooks for.
46 | """
47 | from django.conf import settings
48 |
49 | sync_enabled = getattr(settings, "MATERIALIZED_VIEWS_DISABLE_SYNC_ON_MIGRATE", False) is False
50 |
51 | if sync_enabled:
52 | signals.post_migrate.connect(self.sync_pgviews)
53 |
--------------------------------------------------------------------------------
/django_pgviews/compat.py:
--------------------------------------------------------------------------------
1 | __all__ = ["ProgrammingError"]
2 |
3 | try:
4 | from psycopg import ProgrammingError
5 | except ImportError:
6 | from psycopg2 import ProgrammingError
7 |
--------------------------------------------------------------------------------
/django_pgviews/db/__init__.py:
--------------------------------------------------------------------------------
1 | def get_fields_by_name(model_cls, *field_names):
2 | """Return a dict of `models.Field` instances for named fields.
3 |
4 | Supports wildcard fetches using `'*'`.
5 |
6 | >>> get_fields_by_name(User, 'username', 'password')
7 | {'username': ,
8 | 'password': }
9 |
10 | >>> get_fields_by_name(User, '*')
11 | {'username': ,
12 | ...,
13 | 'date_joined': }
14 | """
15 | if "*" in field_names:
16 | return {field.name: field for field in model_cls._meta.fields}
17 | return {field_name: model_cls._meta.get_field(field_name) for field_name in field_names}
18 |
--------------------------------------------------------------------------------
/django_pgviews/db/sql/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xelixdev/django-pgviews-redux/7aafbbc56331a252506c6f48d3b22b9f5cc6c519/django_pgviews/db/sql/__init__.py
--------------------------------------------------------------------------------
/django_pgviews/db/sql/compiler.py:
--------------------------------------------------------------------------------
1 | from django.db.models.sql import compiler
2 |
3 |
4 | class NonQuotingCompiler(compiler.SQLCompiler):
5 | """
6 | Compiler for functions/statements that doesn't quote the db_table attribute.
7 | """
8 |
9 | def quote_name_unless_alias(self, name):
10 | """
11 | Don't quote the name.
12 | """
13 | if name in self.quote_cache:
14 | return self.quote_cache[name]
15 |
16 | self.quote_cache[name] = name
17 | return name
18 |
19 | def as_sql(self, *args, **kwargs):
20 | """
21 | Messy hack to create some table aliases for us.
22 | """
23 | self.query.table_map[self.query.model._meta.db_table] = [""]
24 | return super().as_sql(*args, **kwargs)
25 |
--------------------------------------------------------------------------------
/django_pgviews/db/sql/query.py:
--------------------------------------------------------------------------------
1 | from django.db import connections
2 | from django.db.models.sql import query
3 |
4 | from django_pgviews.db.sql import compiler
5 |
6 |
7 | class NonQuotingQuery(query.Query):
8 | """
9 | Query class that uses the NonQuotingCompiler.
10 | """
11 |
12 | def get_compiler(self, using=None, connection=None):
13 | """
14 | Get the NonQuotingCompiler object.
15 | """
16 | if using is None and connection is None:
17 | raise ValueError("Need either using or connection")
18 | if using:
19 | connection = connections[using]
20 |
21 | for _alias, annotation in self.annotation_select.items():
22 | connection.ops.check_expression_support(annotation)
23 |
24 | return compiler.NonQuotingCompiler(self, connection, using)
25 |
--------------------------------------------------------------------------------
/django_pgviews/management/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xelixdev/django-pgviews-redux/7aafbbc56331a252506c6f48d3b22b9f5cc6c519/django_pgviews/management/__init__.py
--------------------------------------------------------------------------------
/django_pgviews/management/commands/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xelixdev/django-pgviews-redux/7aafbbc56331a252506c6f48d3b22b9f5cc6c519/django_pgviews/management/commands/__init__.py
--------------------------------------------------------------------------------
/django_pgviews/management/commands/clear_pgviews.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from django.apps import apps
4 | from django.core.management.base import BaseCommand
5 | from django.db import DEFAULT_DB_ALIAS
6 |
7 | from django_pgviews.view import MaterializedView, View, clear_view
8 |
9 | logger = logging.getLogger("django_pgviews.sync_pgviews")
10 |
11 |
12 | class Command(BaseCommand):
13 | help = """Clear Postgres views. Use this before running a migration"""
14 |
15 | def add_arguments(self, parser):
16 | parser.add_argument(
17 | "--database",
18 | default=DEFAULT_DB_ALIAS,
19 | help='Nominates a database to synchronize. Defaults to the "default" database.',
20 | )
21 |
22 | def handle(self, database, **options):
23 | for view_cls in apps.get_models():
24 | if not (isinstance(view_cls, type) and issubclass(view_cls, View) and hasattr(view_cls, "sql")):
25 | continue
26 | python_name = f"{view_cls._meta.app_label}.{view_cls.__name__}"
27 | connection = view_cls.get_view_connection(using=database, restricted_mode=True)
28 | if not connection:
29 | continue
30 | status = clear_view(
31 | connection, view_cls._meta.db_table, materialized=isinstance(view_cls(), MaterializedView)
32 | )
33 | if status == "DROPPED":
34 | msg = "dropped"
35 | else:
36 | msg = "not dropped"
37 | logger.info("%s (%s): %s", python_name, view_cls._meta.db_table, msg)
38 |
--------------------------------------------------------------------------------
/django_pgviews/management/commands/refresh_pgviews.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from django.db import DEFAULT_DB_ALIAS
3 |
4 | from django_pgviews.models import ViewRefresher
5 |
6 |
7 | class Command(BaseCommand):
8 | help = """Refresh materialized Postgres views for all installed apps."""
9 |
10 | def add_arguments(self, parser):
11 | parser.add_argument(
12 | "-C",
13 | "--concurrently",
14 | action="store_true",
15 | dest="concurrently",
16 | help="Refresh concurrently if the materialized view supports it",
17 | )
18 | parser.add_argument(
19 | "--database",
20 | default=DEFAULT_DB_ALIAS,
21 | help='Nominates a database to synchronize. Defaults to the "default" database.',
22 | )
23 |
24 | def handle(self, concurrently, database, **options):
25 | ViewRefresher().run(concurrently, using=database)
26 |
--------------------------------------------------------------------------------
/django_pgviews/management/commands/sync_pgviews.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.core.management.base import BaseCommand
3 | from django.db import DEFAULT_DB_ALIAS
4 |
5 | from django_pgviews.models import ViewSyncer
6 |
7 |
8 | class Command(BaseCommand):
9 | help = """Create/update Postgres views for all installed apps."""
10 |
11 | def add_arguments(self, parser):
12 | parser.add_argument(
13 | "--no-update",
14 | action="store_false",
15 | dest="update",
16 | default=True,
17 | help="""Don't update existing views, only create new ones.""",
18 | )
19 | parser.add_argument(
20 | "--force",
21 | action="store_true",
22 | dest="force",
23 | default=False,
24 | help="Force replacement of pre-existing views where breaking changes have been made to the schema.",
25 | )
26 | parser.add_argument(
27 | "-E",
28 | "--enable-materialized-views-check-sql-changed",
29 | action="store_true",
30 | dest="materialized_views_check_sql_changed",
31 | default=None,
32 | help=(
33 | "Before recreating materialized view, check the SQL has changed compared to the currently active "
34 | "materialized view in the database, if there is one, and only re-create the materialized view "
35 | "if the SQL is different. By default uses django setting MATERIALIZED_VIEWS_CHECK_SQL_CHANGED."
36 | ),
37 | )
38 | parser.add_argument(
39 | "-D",
40 | "--disable-materialized-views-check-sql-changed",
41 | action="store_false",
42 | dest="materialized_views_check_sql_changed",
43 | default=None,
44 | help=(
45 | "Before recreating materialized view, check the SQL has changed compared to the currently active "
46 | "materialized view in the database, if there is one, and only re-create the materialized view "
47 | "if the SQL is different. By default uses django setting MATERIALIZED_VIEWS_CHECK_SQL_CHANGED."
48 | ),
49 | )
50 | parser.add_argument(
51 | "--database",
52 | default=DEFAULT_DB_ALIAS,
53 | help='Nominates a database to synchronize. Defaults to the "default" database.',
54 | )
55 |
56 | def handle(self, force, update, materialized_views_check_sql_changed, database, **options):
57 | vs = ViewSyncer()
58 |
59 | if materialized_views_check_sql_changed is None:
60 | materialized_views_check_sql_changed = getattr(settings, "MATERIALIZED_VIEWS_CHECK_SQL_CHANGED", False)
61 |
62 | vs.run(force, update, using=database, materialized_views_check_sql_changed=materialized_views_check_sql_changed)
63 |
--------------------------------------------------------------------------------
/django_pgviews/models.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from django.apps import apps
4 |
5 | from django_pgviews.signals import all_views_synced, view_synced
6 | from django_pgviews.view import MaterializedView, View, create_materialized_view, create_view
7 |
8 | logger = logging.getLogger("django_pgviews.sync_pgviews")
9 | exists_logger = logging.getLogger("django_pgviews.sync_pgviews.exists")
10 |
11 |
12 | class RunBacklog:
13 | def __init__(self) -> None:
14 | super().__init__()
15 | self.finished = []
16 |
17 | def run(self, **kwargs):
18 | self.finished = []
19 | backlog = []
20 | for view_cls in apps.get_models():
21 | if not (isinstance(view_cls, type) and issubclass(view_cls, View) and hasattr(view_cls, "sql")):
22 | continue
23 | backlog.append(view_cls)
24 | loop = 0
25 | while len(backlog) > 0 and loop < 10:
26 | loop += 1
27 | backlog = self.run_backlog(backlog, **kwargs)
28 |
29 | if loop >= 10:
30 | logger.warning("pgviews dependencies hit limit. Check if your model dependencies are correct")
31 | return False
32 |
33 | return True
34 |
35 | def run_backlog(self, backlog, **kwargs):
36 | raise NotImplementedError
37 |
38 |
39 | class ViewSyncer(RunBacklog):
40 | def run(self, force, update, using, materialized_views_check_sql_changed=False, **options):
41 | if super().run(
42 | force=force,
43 | update=update,
44 | using=using,
45 | materialized_views_check_sql_changed=materialized_views_check_sql_changed,
46 | ):
47 | all_views_synced.send(sender=None, using=using)
48 |
49 | def run_backlog(self, backlog, *, force, update, using, materialized_views_check_sql_changed, **kwargs):
50 | """Installs the list of models given from the previous backlog
51 |
52 | If the correct dependent views have not been installed, the view
53 | will be added to the backlog.
54 |
55 | Eventually we get to a point where all dependencies are sorted.
56 | """
57 | new_backlog = []
58 | for view_cls in backlog:
59 | skip = False
60 | name = f"{view_cls._meta.app_label}.{view_cls.__name__}"
61 | for dep in view_cls._dependencies:
62 | if dep not in self.finished:
63 | skip = True
64 | break
65 |
66 | try:
67 | connection = view_cls.get_view_connection(using=using, restricted_mode=True)
68 | if not connection:
69 | logger.info("Skipping pgview %s (migrations not allowed on %s)", name, using)
70 | continue # Skip
71 |
72 | if skip is True:
73 | new_backlog.append(view_cls)
74 | logger.info("Putting pgview at back of queue: %s", name)
75 | continue # Skip
76 |
77 | if isinstance(view_cls(), MaterializedView):
78 | status = create_materialized_view(
79 | connection, view_cls, check_sql_changed=materialized_views_check_sql_changed
80 | )
81 | else:
82 | status = create_view(
83 | connection,
84 | view_cls._meta.db_table,
85 | view_cls.get_sql(),
86 | update=update,
87 | force=force,
88 | )
89 |
90 | view_synced.send(
91 | sender=view_cls,
92 | update=update,
93 | force=force,
94 | status=status,
95 | has_changed=status not in ("EXISTS", "FORCE_REQUIRED"),
96 | using=using,
97 | )
98 | self.finished.append(name)
99 | except Exception as exc:
100 | exc.view_cls = view_cls
101 | exc.python_name = name
102 | raise
103 | else:
104 | use_logger = logger
105 |
106 | if status == "CREATED":
107 | msg = "created"
108 | elif status == "UPDATED":
109 | msg = "updated"
110 | elif status == "EXISTS":
111 | msg = "already exists, skipping"
112 | use_logger = exists_logger
113 | elif status == "FORCED":
114 | msg = "forced overwrite of existing schema"
115 | elif status == "FORCE_REQUIRED":
116 | msg = "exists with incompatible schema, --force required to update"
117 | else:
118 | msg = status
119 |
120 | use_logger.info("pgview %s %s", name, msg)
121 | return new_backlog
122 |
123 |
124 | class ViewRefresher(RunBacklog):
125 | def run(self, concurrently, using, **kwargs):
126 | return super().run(concurrently=concurrently, using=using, **kwargs)
127 |
128 | def run_backlog(self, backlog, *, concurrently, using, **kwargs):
129 | new_backlog = []
130 | for view_cls in backlog:
131 | skip = False
132 | name = f"{view_cls._meta.app_label}.{view_cls.__name__}"
133 | for dep in view_cls._dependencies:
134 | if dep not in self.finished:
135 | skip = True
136 | break
137 |
138 | if skip is True:
139 | new_backlog.append(view_cls)
140 | logger.info("Putting pgview at back of queue: %s", name)
141 | continue # Skip
142 |
143 | # Don't refresh views not associated with this database
144 | connection = view_cls.get_view_connection(using=using, restricted_mode=True)
145 | if not connection:
146 | continue
147 |
148 | if issubclass(view_cls, MaterializedView):
149 | view_cls.refresh(concurrently=concurrently)
150 | logger.info("pgview %s refreshed", name)
151 |
152 | self.finished.append(name)
153 |
154 | return new_backlog
155 |
--------------------------------------------------------------------------------
/django_pgviews/signals.py:
--------------------------------------------------------------------------------
1 | from django.dispatch import Signal
2 |
3 | view_synced = Signal()
4 | all_views_synced = Signal()
5 |
--------------------------------------------------------------------------------
/django_pgviews/view.py:
--------------------------------------------------------------------------------
1 | """Helpers to access Postgres views from the Django ORM."""
2 |
3 | import collections
4 | import copy
5 | import logging
6 | import re
7 |
8 | from django.apps import apps
9 | from django.core import exceptions
10 | from django.db import connections, models, router, transaction
11 | from django.db.backends.postgresql.schema import DatabaseSchemaEditor
12 | from django.db.backends.utils import truncate_name
13 | from django.db.models.query import QuerySet
14 |
15 | from django_pgviews.compat import ProgrammingError
16 | from django_pgviews.db import get_fields_by_name
17 |
18 | FIELD_SPEC_REGEX = r"^([A-Za-z_][A-Za-z0-9_]*)\." r"([A-Za-z_][A-Za-z0-9_]*)\." r"(\*|(?:[A-Za-z_][A-Za-z0-9_]*))$"
19 | FIELD_SPEC_RE = re.compile(FIELD_SPEC_REGEX)
20 |
21 | ViewSQL = collections.namedtuple("ViewSQL", "query,params")
22 |
23 | logger = logging.getLogger("django_pgviews.view")
24 |
25 |
26 | def hasfield(model_cls, field_name):
27 | """
28 | Like `hasattr()`, but for model fields.
29 |
30 | >>> from django.contrib.auth.models import User
31 | >>> hasfield(User, 'password')
32 | True
33 | >>> hasfield(User, 'foobarbaz')
34 | False
35 | """
36 | try:
37 | model_cls._meta.get_field(field_name)
38 | return True
39 | except exceptions.FieldDoesNotExist:
40 | return False
41 |
42 |
43 | # Projections of models fields onto views which have been deferred due to
44 | # model import and loading dependencies.
45 | # Format: (app_label, model_name): {view_cls: [field_name, ...]}
46 | _DEFERRED_PROJECTIONS = collections.defaultdict(lambda: collections.defaultdict(list))
47 |
48 |
49 | def realize_deferred_projections(sender, *args, **kwargs):
50 | """Project any fields which were deferred pending model preparation."""
51 | app_label = sender._meta.app_label
52 | model_name = sender.__name__.lower()
53 | pending = _DEFERRED_PROJECTIONS.pop((app_label, model_name), {})
54 | for view_cls, field_names in pending.items():
55 | field_instances = get_fields_by_name(sender, *field_names)
56 | for name, field in field_instances.items():
57 | # Only assign the field if the view does not already have an
58 | # attribute or explicitly-defined field with that name.
59 | if hasattr(view_cls, name) or hasfield(view_cls, name):
60 | continue
61 | copy.copy(field).contribute_to_class(view_cls, name)
62 |
63 |
64 | models.signals.class_prepared.connect(realize_deferred_projections)
65 |
66 |
67 | def _schema_and_name(connection, view_name):
68 | if "." in view_name:
69 | return view_name.split(".", 1)
70 | else:
71 | try:
72 | schema_name = connection.schema_name
73 | except AttributeError:
74 | schema_name = None
75 |
76 | return schema_name, view_name
77 |
78 |
79 | def _create_mat_view(cursor, view_name, query, params, with_data):
80 | """
81 | Creates a materialized view using a specific cursor, name and definition.
82 | """
83 | cursor.execute(
84 | "CREATE MATERIALIZED VIEW {} AS {} {};".format(view_name, query, "WITH DATA" if with_data else "WITH NO DATA"),
85 | params,
86 | )
87 |
88 |
89 | def _drop_mat_view(cursor, view_name):
90 | """
91 | Drops a materialized view using a specific cursor.
92 | """
93 | cursor.execute(f"DROP MATERIALIZED VIEW IF EXISTS {view_name} CASCADE;")
94 |
95 |
96 | def _concurrent_index_name(view_name, concurrent_index):
97 | # replace . with _ in view_name in case the table is in a schema
98 | return view_name.replace(".", "_") + "_" + "_".join([s.strip() for s in concurrent_index.split(",")]) + "_index"
99 |
100 |
101 | def _create_concurrent_index(cursor, view_name, concurrent_index):
102 | cursor.execute(
103 | f"CREATE UNIQUE INDEX {_concurrent_index_name(view_name, concurrent_index)} ON {view_name} ({concurrent_index})"
104 | )
105 |
106 |
107 | class CustomSchemaEditor(DatabaseSchemaEditor):
108 | def _create_index_sql(self, *args, **kwargs):
109 | """
110 | Override to handle indexes in custom schemas, when the schema is explicitly set.
111 | """
112 | statement = super()._create_index_sql(*args, **kwargs)
113 |
114 | model = args[0]
115 |
116 | if "." in model._meta.db_table: # by default the table it's quoted, but we need it non-quoted
117 | statement.parts["table"] = model._meta.db_table
118 |
119 | return statement
120 |
121 |
122 | def _make_where(**kwargs):
123 | where_fragments = []
124 | params = []
125 |
126 | for key, value in kwargs.items():
127 | if value is None:
128 | # skip key if value is not specified
129 | continue
130 |
131 | if isinstance(value, (list, tuple)):
132 | in_fragment = ", ".join("%s" for _ in range(len(value)))
133 | where_fragments.append(f"{key} IN ({in_fragment})")
134 | params.extend(list(value))
135 | else:
136 | where_fragments.append(f"{key} = %s")
137 | params.append(value)
138 | where_fragment = " AND ".join(where_fragments)
139 | return where_fragment, params
140 |
141 |
142 | def _ensure_indexes(connection, cursor, view_cls, schema_name_log):
143 | """
144 | This function gets called when a materialized view is deemed not needing a re-create. That is however only a part
145 | of the story, since that checks just the SQL of the view itself. The second part is the indexes.
146 | This function gets the current indexes on the materialized view and reconciles them with the indexes that
147 | should be in the view, dropping extra ones and creating new ones.
148 | """
149 | view_name = view_cls._meta.db_table
150 | concurrent_index = view_cls._concurrent_index
151 | indexes = view_cls._meta.indexes
152 | vschema, vname = _schema_and_name(connection, view_name)
153 |
154 | where_fragment, params = _make_where(schemaname=vschema, tablename=vname)
155 | cursor.execute(f"SELECT indexname FROM pg_indexes WHERE {where_fragment}", params)
156 |
157 | existing_indexes = {x[0] for x in cursor.fetchall()}
158 | required_indexes = {x.name for x in indexes}
159 |
160 | if view_cls._concurrent_index is not None:
161 | concurrent_index_name = _concurrent_index_name(view_name, concurrent_index)
162 | required_indexes.add(concurrent_index_name)
163 | else:
164 | concurrent_index_name = None
165 |
166 | for index_name in existing_indexes - required_indexes:
167 | if vschema:
168 | full_index_name = f"{vschema}.{index_name}"
169 | else:
170 | full_index_name = index_name
171 | cursor.execute(f"DROP INDEX {full_index_name}")
172 | logger.info("pgview dropped index %s on view %s (%s)", index_name, view_name, schema_name_log)
173 |
174 | schema_editor: DatabaseSchemaEditor = CustomSchemaEditor(connection)
175 |
176 | for index_name in required_indexes - existing_indexes:
177 | if index_name == concurrent_index_name:
178 | _create_concurrent_index(cursor, view_name, concurrent_index)
179 | logger.info("pgview created concurrent index on view %s (%s)", view_name, schema_name_log)
180 | else:
181 | for index in indexes:
182 | if index.name == index_name:
183 | schema_editor.add_index(view_cls, index)
184 | logger.info("pgview created index %s on view %s (%s)", index.name, view_name, schema_name_log)
185 | break
186 |
187 |
188 | @transaction.atomic()
189 | def create_materialized_view(connection, view_cls, check_sql_changed=False):
190 | """
191 | Create a materialized view on a connection.
192 |
193 | Returns one of statuses EXISTS, UPDATED, CREATED.
194 |
195 | If with_data = False, then the materialized view will get created without data.
196 |
197 | If check_sql_changed = True, then the process will first check if there is a materialized view in the database
198 | already with the same SQL, if there is, it will not do anything. Otherwise the materialized view gets dropped
199 | and recreated.
200 | """
201 | view_name = view_cls._meta.db_table
202 | view_query = view_cls.get_sql()
203 | concurrent_index = view_cls._concurrent_index
204 |
205 | try:
206 | schema_name = connection.schema_name
207 | schema_name_log = f"schema {schema_name}"
208 | except AttributeError:
209 | schema_name_log = "default schema"
210 |
211 | vschema, vname = _schema_and_name(connection, view_name)
212 |
213 | cursor_wrapper = connection.cursor()
214 | cursor = cursor_wrapper.cursor
215 |
216 | where_fragment, params = _make_where(schemaname=vschema, matviewname=vname)
217 |
218 | try:
219 | cursor.execute(
220 | f"SELECT COUNT(*) FROM pg_matviews WHERE {where_fragment};",
221 | params,
222 | )
223 | view_exists = cursor.fetchone()[0] > 0
224 |
225 | query = view_query.query.strip()
226 | if query.endswith(";"):
227 | query = query[:-1]
228 |
229 | if check_sql_changed and view_exists:
230 | temp_viewname = truncate_name(view_name + "_temp", length=63)
231 | _, temp_vname = _schema_and_name(connection, temp_viewname)
232 |
233 | _drop_mat_view(cursor, temp_viewname)
234 | _create_mat_view(cursor, temp_viewname, query, view_query.params, with_data=False)
235 |
236 | definitions_where, definitions_params = _make_where(schemaname=vschema, matviewname=[vname, temp_vname])
237 | cursor.execute(
238 | f"SELECT definition FROM pg_matviews WHERE {definitions_where};",
239 | definitions_params,
240 | )
241 | definitions = cursor.fetchall()
242 |
243 | _drop_mat_view(cursor, temp_viewname)
244 |
245 | if definitions[0] == definitions[1]:
246 | _ensure_indexes(connection, cursor, view_cls, schema_name_log)
247 | return "EXISTS"
248 |
249 | if view_exists:
250 | _drop_mat_view(cursor, view_name)
251 | logger.info("pgview dropped materialized view %s (%s)", view_name, schema_name_log)
252 |
253 | _create_mat_view(cursor, view_name, query, view_query.params, with_data=view_cls.with_data)
254 | logger.info("pgview created materialized view %s (%s)", view_name, schema_name_log)
255 |
256 | if concurrent_index is not None:
257 | _create_concurrent_index(cursor, view_name, concurrent_index)
258 | logger.info("pgview created concurrent index on view %s (%s)", view_name, schema_name_log)
259 |
260 | if view_cls._meta.indexes:
261 | schema_editor = CustomSchemaEditor(connection)
262 |
263 | for index in view_cls._meta.indexes:
264 | schema_editor.add_index(view_cls, index)
265 | logger.info("pgview created index %s on view %s (%s)", index.name, view_name, schema_name_log)
266 |
267 | if view_exists:
268 | return "UPDATED"
269 |
270 | return "CREATED"
271 | finally:
272 | cursor_wrapper.close()
273 |
274 |
275 | @transaction.atomic()
276 | def create_view(connection, view_name, view_query: ViewSQL, update=True, force=False):
277 | """
278 | Create a named view on a connection.
279 |
280 | Returns True if a new view was created (or an existing one updated), or
281 | False if nothing was done.
282 |
283 | If ``update`` is True (default), attempt to update an existing view. If the
284 | existing view's schema is incompatible with the new definition, ``force``
285 | (default: False) controls whether or not to drop the old view and create
286 | the new one.
287 | """
288 |
289 | vschema, vname = _schema_and_name(connection, view_name)
290 |
291 | cursor_wrapper = connection.cursor()
292 | cursor = cursor_wrapper.cursor
293 | try:
294 | force_required = False
295 | # Determine if view already exists.
296 | view_exists_where, view_exists_params = _make_where(table_schema=vschema, table_name=vname)
297 | cursor.execute(
298 | f"SELECT COUNT(*) FROM information_schema.views WHERE {view_exists_where};",
299 | view_exists_params,
300 | )
301 | view_exists = cursor.fetchone()[0] > 0
302 | if view_exists and not update:
303 | return "EXISTS"
304 | elif view_exists:
305 | # Detect schema conflict by copying the original view, attempting to
306 | # update this copy, and detecting errors.
307 | cursor.execute(f"CREATE TEMPORARY VIEW check_conflict AS SELECT * FROM {view_name};")
308 | try:
309 | with transaction.atomic():
310 | cursor.execute(
311 | f"CREATE OR REPLACE TEMPORARY VIEW check_conflict AS {view_query.query};",
312 | view_query.params,
313 | )
314 | except ProgrammingError:
315 | force_required = True
316 | finally:
317 | cursor.execute("DROP VIEW IF EXISTS check_conflict;")
318 |
319 | if not force_required:
320 | cursor.execute(f"CREATE OR REPLACE VIEW {view_name} AS {view_query.query};", view_query.params)
321 | ret = view_exists and "UPDATED" or "CREATED"
322 | elif force:
323 | cursor.execute(f"DROP VIEW IF EXISTS {view_name} CASCADE;")
324 | cursor.execute(f"CREATE VIEW {view_name} AS {view_query.query};", view_query.params)
325 | ret = "FORCED"
326 | else:
327 | ret = "FORCE_REQUIRED"
328 |
329 | return ret
330 | finally:
331 | cursor_wrapper.close()
332 |
333 |
334 | def clear_view(connection, view_name, materialized=False):
335 | """
336 | Remove a named view on connection.
337 | """
338 | cursor_wrapper = connection.cursor()
339 | cursor = cursor_wrapper.cursor
340 | try:
341 | if materialized:
342 | cursor.execute(f"DROP MATERIALIZED VIEW IF EXISTS {view_name} CASCADE")
343 | else:
344 | cursor.execute(f"DROP VIEW IF EXISTS {view_name} CASCADE")
345 | finally:
346 | cursor_wrapper.close()
347 | return "DROPPED"
348 |
349 |
350 | class ViewMeta(models.base.ModelBase):
351 | def __new__(cls, name, bases, attrs):
352 | """
353 | Deal with all of the meta attributes, removing any Django does not want
354 | """
355 | # Get attributes before Django
356 | dependencies = attrs.pop("dependencies", [])
357 | projection = attrs.pop("projection", [])
358 | concurrent_index = attrs.pop("concurrent_index", None)
359 |
360 | # Get projection
361 | deferred_projections = []
362 | for field_name in projection:
363 | if isinstance(field_name, models.Field):
364 | attrs[field_name.name] = copy.copy(field_name)
365 | elif isinstance(field_name, str):
366 | match = FIELD_SPEC_RE.match(field_name)
367 | if not match:
368 | raise TypeError(f"Unrecognized field specifier: {field_name!r}")
369 | deferred_projections.append(match.groups())
370 | else:
371 | raise TypeError(f"Unrecognized field specifier: {field_name!r}")
372 |
373 | view_cls = models.base.ModelBase.__new__(cls, name, bases, attrs)
374 |
375 | # Get dependencies
376 | view_cls._dependencies = dependencies
377 | # Materialized views can have an index allowing concurrent refresh
378 | view_cls._concurrent_index = concurrent_index
379 | for app_label, model_name, field_name in deferred_projections:
380 | model_spec = (app_label, model_name.lower())
381 |
382 | _DEFERRED_PROJECTIONS[model_spec][view_cls].append(field_name)
383 | _realise_projections(app_label, model_name)
384 |
385 | return view_cls
386 |
387 | def add_to_class(self, name, value):
388 | if name == "_base_manager":
389 | return
390 | super().add_to_class(name, value)
391 |
392 |
393 | class BaseManagerMeta:
394 | base_manager_name = "objects"
395 |
396 |
397 | class View(models.Model, metaclass=ViewMeta):
398 | """
399 | Helper for exposing Postgres views as Django models.
400 | """
401 |
402 | _deferred = False
403 | sql = None
404 |
405 | @classmethod
406 | def get_sql(cls):
407 | return ViewSQL(cls.sql, None)
408 |
409 | @classmethod
410 | def get_view_connection(cls, using, restricted_mode: bool = True):
411 | """
412 | Returns connection for "using" database.
413 | Operates in two modes, regular mode and restricted mode.
414 | In regular mode just returns the connection.
415 | In restricted mode, returns None if migrations are not allowed (via router) to indicate view should not be
416 | used on the specified database.
417 |
418 | Overwrite this method in subclass to customize, if needed.
419 | """
420 | if not restricted_mode or router.allow_migrate(using, cls._meta.app_label):
421 | return connections[using]
422 | return None
423 |
424 | class Meta:
425 | abstract = True
426 | managed = False
427 |
428 |
429 | def _realise_projections(app_label, model_name):
430 | """
431 | Checks whether the model has been loaded and runs
432 | realise_deferred_projections() if it has.
433 | """
434 | try:
435 | model_cls = apps.get_model(app_label, model_name)
436 | except exceptions.AppRegistryNotReady:
437 | return
438 | if model_cls is not None:
439 | realize_deferred_projections(model_cls)
440 |
441 |
442 | class ReadOnlyViewQuerySet(QuerySet):
443 | def _raw_delete(self, *args, **kwargs):
444 | return 0
445 |
446 | def delete(self):
447 | raise NotImplementedError("Not allowed")
448 |
449 | def update(self, **kwargs):
450 | raise NotImplementedError("Not allowed")
451 |
452 | def _update(self, values):
453 | raise NotImplementedError("Not allowed")
454 |
455 | def create(self, **kwargs):
456 | raise NotImplementedError("Not allowed")
457 |
458 | def update_or_create(self, defaults=None, **kwargs):
459 | raise NotImplementedError("Not allowed")
460 |
461 | def bulk_create(self, objs, batch_size=None):
462 | raise NotImplementedError("Not allowed")
463 |
464 |
465 | class ReadOnlyViewManager(models.Manager):
466 | def get_queryset(self):
467 | return ReadOnlyViewQuerySet(self.model, using=self._db)
468 |
469 |
470 | class ReadOnlyView(View):
471 | """View which cannot be altered"""
472 |
473 | _base_manager = ReadOnlyViewManager()
474 | objects = ReadOnlyViewManager()
475 |
476 | class Meta(BaseManagerMeta):
477 | abstract = True
478 | managed = False
479 |
480 |
481 | class MaterializedView(View):
482 | """A materialized view.
483 | More information:
484 | http://www.postgresql.org/docs/current/static/sql-creatematerializedview.html
485 | """
486 |
487 | with_data = True
488 |
489 | @classmethod
490 | def refresh(cls, concurrently=False):
491 | conn = cls.get_view_connection(using=router.db_for_write(cls), restricted_mode=False)
492 | if not conn:
493 | logger.warning("Failed to find connection to refresh %s", cls)
494 | return
495 | cursor_wrapper = conn.cursor()
496 | cursor = cursor_wrapper.cursor
497 | try:
498 | if cls._concurrent_index is not None and concurrently:
499 | cursor.execute(f"REFRESH MATERIALIZED VIEW CONCURRENTLY {cls._meta.db_table}")
500 | else:
501 | cursor.execute(f"REFRESH MATERIALIZED VIEW {cls._meta.db_table}")
502 | finally:
503 | cursor_wrapper.close()
504 |
505 | class Meta:
506 | abstract = True
507 | managed = False
508 |
509 |
510 | class ReadOnlyMaterializedView(MaterializedView):
511 | """Read-only version of the materialized view"""
512 |
513 | _base_manager = ReadOnlyViewManager()
514 | objects = ReadOnlyViewManager()
515 |
516 | class Meta(BaseManagerMeta):
517 | abstract = True
518 | managed = False
519 |
--------------------------------------------------------------------------------
/doc/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = PYTHONPATH=..:../test_project DJANGO_SETTINGS_MODULE=test_project.settings sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # Internal variables.
11 | PAPEROPT_a4 = -D latex_paper_size=a4
12 | PAPEROPT_letter = -D latex_paper_size=letter
13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
14 | # the i18n builder cannot share the environment and doctrees with the others
15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
16 |
17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
18 |
19 | help:
20 | @echo "Please use \`make ' where is one of"
21 | @echo " html to make standalone HTML files"
22 | @echo " dirhtml to make HTML files named index.html in directories"
23 | @echo " singlehtml to make a single large HTML file"
24 | @echo " pickle to make pickle files"
25 | @echo " json to make JSON files"
26 | @echo " htmlhelp to make HTML files and a HTML help project"
27 | @echo " qthelp to make HTML files and a qthelp project"
28 | @echo " devhelp to make HTML files and a Devhelp project"
29 | @echo " epub to make an epub"
30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
31 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
32 | @echo " text to make text files"
33 | @echo " man to make manual pages"
34 | @echo " texinfo to make Texinfo files"
35 | @echo " info to make Texinfo files and run them through makeinfo"
36 | @echo " gettext to make PO message catalogs"
37 | @echo " changes to make an overview of all changed/added/deprecated items"
38 | @echo " linkcheck to check all external links for integrity"
39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
40 |
41 | clean:
42 | -rm -rf $(BUILDDIR)/*
43 |
44 | html:
45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
46 | @echo
47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
48 |
49 | dirhtml:
50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
51 | @echo
52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
53 |
54 | singlehtml:
55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
56 | @echo
57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
58 |
59 | pickle:
60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
61 | @echo
62 | @echo "Build finished; now you can process the pickle files."
63 |
64 | json:
65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
66 | @echo
67 | @echo "Build finished; now you can process the JSON files."
68 |
69 | htmlhelp:
70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
71 | @echo
72 | @echo "Build finished; now you can run HTML Help Workshop with the" \
73 | ".hhp project file in $(BUILDDIR)/htmlhelp."
74 |
75 | qthelp:
76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
77 | @echo
78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django_postgres.qhcp"
81 | @echo "To view the help file:"
82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django_postgres.qhc"
83 |
84 | devhelp:
85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
86 | @echo
87 | @echo "Build finished."
88 | @echo "To view the help file:"
89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django_postgres"
90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django_postgres"
91 | @echo "# devhelp"
92 |
93 | epub:
94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
95 | @echo
96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
97 |
98 | latex:
99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
100 | @echo
101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
103 | "(use \`make latexpdf' here to do that automatically)."
104 |
105 | latexpdf:
106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
107 | @echo "Running LaTeX files through pdflatex..."
108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
110 |
111 | text:
112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
113 | @echo
114 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
115 |
116 | man:
117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
118 | @echo
119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
120 |
121 | texinfo:
122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
123 | @echo
124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
125 | @echo "Run \`make' in that directory to run these through makeinfo" \
126 | "(use \`make info' here to do that automatically)."
127 |
128 | info:
129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
130 | @echo "Running Texinfo files through makeinfo..."
131 | make -C $(BUILDDIR)/texinfo info
132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
133 |
134 | gettext:
135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
136 | @echo
137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
138 |
139 | changes:
140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
141 | @echo
142 | @echo "The overview file is in $(BUILDDIR)/changes."
143 |
144 | linkcheck:
145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
146 | @echo
147 | @echo "Link check complete; look for any errors in the above output " \
148 | "or in $(BUILDDIR)/linkcheck/output.txt."
149 |
150 | doctest:
151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
152 | @echo "Testing of doctests in the sources finished, look at the " \
153 | "results in $(BUILDDIR)/doctest/output.txt."
154 |
--------------------------------------------------------------------------------
/doc/bitstrings.rst:
--------------------------------------------------------------------------------
1 | ===========
2 | Bit Strings
3 | ===========
4 |
5 | Postgres has a `bit string`_ type, which is exposed by django-postgres as
6 | :class:`~django_postgres.BitStringField` and the
7 | :class:`~django_postgres.BitStringExpression` helper (aliased as
8 | ``django_postgres.B``). The representation of bit strings in Python is handled
9 | by the `python-bitstring`_ library (a dependency of ``django-postgres``).
10 |
11 | .. _bit string: http://www.postgresql.org/docs/9.1/static/arrays.html
12 | .. _python-bitstring: http://packages.python.org/bitstring
13 |
14 |
15 | Quickstart
16 | ==========
17 |
18 | Given the following ``models.py``::
19 |
20 | from django.db import models
21 | import django_postgres
22 |
23 | class BloomFilter(models.Model):
24 | name = models.CharField(max_length=100)
25 | bitmap = django_postgres.BitStringField(max_length=8)
26 |
27 | You can create objects with bit strings, and update them like so::
28 |
29 | >>> from django_postgres import Bits
30 | >>> from models import BloomFilter
31 |
32 | >>> bloom = BloomFilter.objects.create(name='test')
33 | INSERT INTO myapp_bloomfilter
34 | (name, bitmap) VALUES ('test', B'00000000')
35 | RETURNING myapp_bloomfilter.id;
36 |
37 | >>> print bloom.bitmap
38 | Bits('0x00')
39 | >>> bloom.bitmap |= Bits(bin='00100000')
40 | >>> print bloom.bitmap
41 | Bits('0x20')
42 |
43 | >>> bloom.save(force_update=True)
44 | UPDATE myapp_bloomfilter SET bitmap = B'00100000'
45 | WHERE myapp_bloomfilter.id = 1;
46 |
47 | Several query lookups are defined for filtering on bit strings. Standard
48 | equality::
49 |
50 | >>> BloomFilter.objects.filter(bitmap='00100000')
51 | SELECT * FROM myapp_bloomfilter WHERE bitmap = B'00100000';
52 |
53 | You can also test against bitwise comparison operators (``and``, ``or`` and
54 | ``xor``). The SQL produced is slightly convoluted, due to the few functions
55 | provided by Postgres::
56 |
57 | >>> BloomFilter.objects.filter(bitmap__and='00010000')
58 | SELECT * FROM myapp_bloomfilter WHERE position(B'1' IN bitmap & B'00010000') > 0
59 | >>> BloomFilter.objects.filter(bitmap__or='00010000')
60 | SELECT * FROM myapp_bloomfilter WHERE position(B'1' IN bitmap | B'00010000') > 0
61 | >>> BloomFilter.objects.filter(bitmap__xor='00010000')
62 | SELECT * FROM myapp_bloomfilter WHERE position(B'1' IN bitmap # B'00010000') > 0
63 |
64 | Finally, you can also test the zero-ness of left- and right-shifted bit
65 | strings::
66 |
67 | >>> BloomFilter.objects.filter(bitmap__lshift=3)
68 | SELECT * FROM myapp_bloomfilter WHERE position(B'1' IN bitmap << 3) > 0
69 | >>> BloomFilter.objects.filter(bitmap__rshift=3)
70 | SELECT * FROM myapp_bloomfilter WHERE position(B'1' IN bitmap >> 3) > 0
71 |
72 |
73 | Bit String Fields
74 | =================
75 |
76 | .. class:: django_postgres.BitStringField(max_length=1[, varying=False, ...])
77 |
78 | A bit string field, represented by the Postgres ``BIT`` or ``VARBIT`` types.
79 |
80 | :param max_length:
81 | The length (in bits) of this field.
82 | :param varying:
83 | Use a ``VARBIT`` instead of ``BIT``. Not recommended; it may cause strange
84 | querying behavior or length mismatch errors.
85 |
86 | If ``varying`` is True and ``max_length`` is ``None``, a ``VARBIT`` of
87 | unlimited length will be created.
88 |
89 | The default value of a :class:`BitStringField` is chosen as follows:
90 |
91 | * If a ``default`` kwarg is provided, that value is used.
92 | * Otherwise, if ``null=True``, the default value is ``None``.
93 | * Otherwise, if the field is not a ``VARBIT``, it defaults to an all-zero
94 | bit string of ``max_length`` (remember, the default length is 1).
95 | * Finally, all other cases will default to a single ``0``.
96 |
97 | All other parameters (``db_column``, ``help_text``, etc.) behave as standard
98 | for a Django field.
99 |
100 |
101 | Bit String Expressions
102 | ======================
103 |
104 | It's useful to be able to atomically modify bit strings in the database, in a
105 | manner similar to Django's `F-expressions `_.
106 | For this reason, :class:`~django_postgres.BitStringExpression` is provided,
107 | and aliased as ``django_postgres.B`` for convenience.
108 |
109 | Here's a short example::
110 |
111 | >>> from django_postgres import B
112 | >>> BloomFilter.objects.filter(id=1).update(bitmap=B('bitmap') | '00001000')
113 | UPDATE myapp_bloomfilter SET bitmap = bitmap | B'00001000'
114 | WHERE myapp_bloomfilter.id = 1;
115 | >>> bloom = BloomFilter.objects.get(id=1)
116 | >>> print bloom.bitmap
117 | Bits('0x28')
118 |
119 | .. class:: django_postgres.BitStringExpression(field_name)
120 |
121 | The following operators are supported:
122 |
123 | - Concatenation (``+``)
124 | - Bitwise AND (``&``)
125 | - Bitwise OR (``|``)
126 | - Bitwise XOR (``^``)
127 | - (Unary) bitwise NOT (``~``)
128 | - Bitwise left-shift (``<<``)
129 | - Bitwise right-shift (``>>``)
130 |
--------------------------------------------------------------------------------
/doc/conf.py:
--------------------------------------------------------------------------------
1 | #
2 | # django-postgres documentation build configuration file, created by
3 | # sphinx-quickstart on Sun Aug 19 05:34:54 2012.
4 | #
5 | # This file is execfile()d with the current directory set to its containing dir.
6 | #
7 | # Note that not all possible configuration values are present in this
8 | # autogenerated file.
9 | #
10 | # All configuration values have a default; values that are commented out
11 | # serve to show the default.
12 |
13 |
14 | # If extensions (or modules to document with autodoc) are in another directory,
15 | # add these directories to sys.path here. If the directory is relative to the
16 | # documentation root, use os.path.abspath to make it absolute, like shown here.
17 | # sys.path.insert(0, os.path.abspath('.'))
18 |
19 | # -- General configuration -----------------------------------------------------
20 |
21 | # If your documentation needs a minimal Sphinx version, state it here.
22 | # needs_sphinx = '1.0'
23 |
24 | # Add any Sphinx extension module names here, as strings. They can be extensions
25 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
26 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"]
27 |
28 | # Add any paths that contain templates here, relative to this directory.
29 | templates_path = ["_templates"]
30 |
31 | # The suffix of source filenames.
32 | source_suffix = ".rst"
33 |
34 | # The encoding of source files.
35 | # source_encoding = 'utf-8-sig'
36 |
37 | # The master toctree document.
38 | master_doc = "index"
39 |
40 | # General information about the project.
41 | project = "django-postgres"
42 | copyright = "Public Domain"
43 |
44 | # The version info for the project you're documenting, acts as replacement for
45 | # |version| and |release|, also used in various other places throughout the
46 | # built documents.
47 | #
48 | # The short X.Y version.
49 | version = "0.0.1"
50 | # The full version, including alpha/beta/rc tags.
51 | release = "0.0.1"
52 |
53 | # The language for content autogenerated by Sphinx. Refer to documentation
54 | # for a list of supported languages.
55 | # language = None
56 |
57 | # There are two options for replacing |today|: either, you set today to some
58 | # non-false value, then it is used:
59 | # today = ''
60 | # Else, today_fmt is used as the format for a strftime call.
61 | # today_fmt = '%B %d, %Y'
62 |
63 | # List of patterns, relative to source directory, that match files and
64 | # directories to ignore when looking for source files.
65 | exclude_patterns = ["_build"]
66 |
67 | # The reST default role (used for this markup: `text`) to use for all documents.
68 | # default_role = None
69 |
70 | # If true, '()' will be appended to :func: etc. cross-reference text.
71 | # add_function_parentheses = True
72 |
73 | # If true, the current module name will be prepended to all description
74 | # unit titles (such as .. function::).
75 | # add_module_names = True
76 |
77 | # If true, sectionauthor and moduleauthor directives will be shown in the
78 | # output. They are ignored by default.
79 | # show_authors = False
80 |
81 | # The name of the Pygments (syntax highlighting) style to use.
82 | pygments_style = "sphinx"
83 |
84 | # A list of ignored prefixes for module index sorting.
85 | # modindex_common_prefix = []
86 |
87 |
88 | # -- Options for HTML output ---------------------------------------------------
89 |
90 | # The theme to use for HTML and HTML Help pages. See the documentation for
91 | # a list of builtin themes.
92 | html_theme = "default"
93 |
94 | # Theme options are theme-specific and customize the look and feel of a theme
95 | # further. For a list of options available for each theme, see the
96 | # documentation.
97 | # html_theme_options = {}
98 |
99 | # Add any paths that contain custom themes here, relative to this directory.
100 | # html_theme_path = []
101 |
102 | # The name for this set of Sphinx documents. If None, it defaults to
103 | # " v documentation".
104 | # html_title = None
105 |
106 | # A shorter title for the navigation bar. Default is the same as html_title.
107 | # html_short_title = None
108 |
109 | # The name of an image file (relative to this directory) to place at the top
110 | # of the sidebar.
111 | # html_logo = None
112 |
113 | # The name of an image file (within the static path) to use as favicon of the
114 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
115 | # pixels large.
116 | # html_favicon = None
117 |
118 | # Add any paths that contain custom static files (such as style sheets) here,
119 | # relative to this directory. They are copied after the builtin static files,
120 | # so a file named "default.css" will overwrite the builtin "default.css".
121 | html_static_path = ["_static"]
122 |
123 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
124 | # using the given strftime format.
125 | # html_last_updated_fmt = '%b %d, %Y'
126 |
127 | # If true, SmartyPants will be used to convert quotes and dashes to
128 | # typographically correct entities.
129 | # html_use_smartypants = True
130 |
131 | # Custom sidebar templates, maps document names to template names.
132 | # html_sidebars = {}
133 |
134 | # Additional templates that should be rendered to pages, maps page names to
135 | # template names.
136 | # html_additional_pages = {}
137 |
138 | # If false, no module index is generated.
139 | # html_domain_indices = True
140 |
141 | # If false, no index is generated.
142 | # html_use_index = True
143 |
144 | # If true, the index is split into individual pages for each letter.
145 | # html_split_index = False
146 |
147 | # If true, links to the reST sources are added to the pages.
148 | # html_show_sourcelink = True
149 |
150 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
151 | # html_show_sphinx = True
152 |
153 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
154 | html_show_copyright = False
155 |
156 | # If true, an OpenSearch description file will be output, and all pages will
157 | # contain a tag referring to it. The value of this option must be the
158 | # base URL from which the finished HTML is served.
159 | # html_use_opensearch = ''
160 |
161 | # This is the file name suffix for HTML files (e.g. ".xhtml").
162 | # html_file_suffix = None
163 |
164 | # Output file base name for HTML help builder.
165 | htmlhelp_basename = "django_postgresdoc"
166 |
167 |
168 | # -- Options for LaTeX output --------------------------------------------------
169 |
170 | latex_elements = {
171 | # The paper size ('letterpaper' or 'a4paper').
172 | #'papersize': 'letterpaper',
173 | # The font size ('10pt', '11pt' or '12pt').
174 | #'pointsize': '10pt',
175 | # Additional stuff for the LaTeX preamble.
176 | #'preamble': '',
177 | }
178 |
179 | # Grouping the document tree into LaTeX files. List of tuples
180 | # (source start file, target name, title, author, documentclass [howto/manual]).
181 | latex_documents = [("index", "django-postgres.tex", "django\\-postgres Documentation", "Author", "manual")]
182 |
183 | # The name of an image file (relative to this directory) to place at the top of
184 | # the title page.
185 | # latex_logo = None
186 |
187 | # For "manual" documents, if this is true, then toplevel headings are parts,
188 | # not chapters.
189 | # latex_use_parts = False
190 |
191 | # If true, show page references after internal links.
192 | # latex_show_pagerefs = False
193 |
194 | # If true, show URL addresses after external links.
195 | # latex_show_urls = False
196 |
197 | # Documents to append as an appendix to all manuals.
198 | # latex_appendices = []
199 |
200 | # If false, no module index is generated.
201 | # latex_domain_indices = True
202 |
203 |
204 | # -- Options for manual page output --------------------------------------------
205 |
206 | # One entry per manual page. List of tuples
207 | # (source start file, name, description, authors, manual section).
208 | man_pages = [("index", "django-postgres", "django-postgres Documentation", ["Author"], 1)]
209 |
210 | # If true, show URL addresses after external links.
211 | # man_show_urls = False
212 |
213 |
214 | # -- Options for Texinfo output ------------------------------------------------
215 |
216 | # Grouping the document tree into Texinfo files. List of tuples
217 | # (source start file, target name, title, author,
218 | # dir menu entry, description, category)
219 | texinfo_documents = [
220 | (
221 | "index",
222 | "django-postgres",
223 | "django-postgres Documentation",
224 | "Author",
225 | "django-postgres",
226 | "One line description of project.",
227 | "Miscellaneous",
228 | )
229 | ]
230 |
231 | # Documents to append as an appendix to all manuals.
232 | # texinfo_appendices = []
233 |
234 | # If false, no module index is generated.
235 | # texinfo_domain_indices = True
236 |
237 | # How to display URL addresses: 'footnote', 'no', or 'inline'.
238 | # texinfo_show_urls = 'footnote'
239 |
240 |
241 | # -- Options for Epub output ---------------------------------------------------
242 |
243 | # Bibliographic Dublin Core info.
244 | epub_title = "django-postgres"
245 | epub_author = "Author"
246 | epub_publisher = "Author"
247 | epub_copyright = "2012, Author"
248 |
249 | # The language of the text. It defaults to the language option
250 | # or en if the language is not set.
251 | # epub_language = ''
252 |
253 | # The scheme of the identifier. Typical schemes are ISBN or URL.
254 | # epub_scheme = ''
255 |
256 | # The unique identifier of the text. This can be a ISBN number
257 | # or the project homepage.
258 | # epub_identifier = ''
259 |
260 | # A unique identification for the text.
261 | # epub_uid = ''
262 |
263 | # A tuple containing the cover image and cover page html template filenames.
264 | # epub_cover = ()
265 |
266 | # HTML files that should be inserted before the pages created by sphinx.
267 | # The format is a list of tuples containing the path and title.
268 | # epub_pre_files = []
269 |
270 | # HTML files shat should be inserted after the pages created by sphinx.
271 | # The format is a list of tuples containing the path and title.
272 | # epub_post_files = []
273 |
274 | # A list of files that should not be packed into the epub file.
275 | # epub_exclude_files = []
276 |
277 | # The depth of the table of contents in toc.ncx.
278 | # epub_tocdepth = 3
279 |
280 | # Allow duplicate toc entries.
281 | # epub_tocdup = True
282 |
--------------------------------------------------------------------------------
/doc/index.rst:
--------------------------------------------------------------------------------
1 | ===============
2 | django-postgres
3 | ===============
4 |
5 | Adds first-class support for `PostgreSQL `_
6 | features to the Django ORM.
7 |
8 | Planned features include:
9 |
10 | - `Arrays `_
11 | - `Enums `_
12 | - `Bit Strings `_
13 | - `Constraints `_
14 | - `Triggers `_
15 | - `Domains `_
16 | - `Composite Types `_
17 | - `Views `_
18 |
19 | Obviously this is quite a large project, but I think it would provide a huge
20 | amount of value to Django developers.
21 |
22 |
23 | Why?
24 | ====
25 |
26 | PostgreSQL is an excellent data store, with a host of useful and
27 | efficiently-implemented features. Unfortunately these features are not exposed
28 | through Django's ORM, primarily because the framework has to support several
29 | SQL backends and so can only provide a set of features common to all of them.
30 |
31 | The features made available here replace some of the following practices:
32 |
33 | - Manual denormalization on ``save()`` (such that model saves may result in
34 | three or more separate queries).
35 | - Sequences represented by a one-to-many, with an ``order`` integer field.
36 | - Complex types represented by JSON in a text field.
37 |
38 |
39 | Contents
40 | ========
41 |
42 | This is a WIP, so the following list may grow and change over time.
43 |
44 | .. toctree::
45 | :maxdepth: 4
46 |
47 | views
48 | bitstrings
49 |
50 |
51 | Indices and tables
52 | ==================
53 |
54 | * :ref:`genindex`
55 | * :ref:`modindex`
56 | * :ref:`search`
57 |
58 |
--------------------------------------------------------------------------------
/doc/views.rst:
--------------------------------------------------------------------------------
1 | =====
2 | Views
3 | =====
4 |
5 | For more info on Postgres views, see the `official Postgres docs
6 | `_. Effectively,
7 | views are named queries which can be accessed as if they were regular database
8 | tables.
9 |
10 | Quickstart
11 | ==========
12 |
13 | Given the following view in SQL:
14 |
15 | .. code-block:: sql
16 |
17 | CREATE OR REPLACE VIEW myapp_viewname AS
18 | SELECT * FROM myapp_table WHERE condition;
19 |
20 | You can create this view by just subclassing :class:`django_postgres.View`. In
21 | ``myapp/models.py``::
22 |
23 | import django_postgres
24 |
25 | class ViewName(django_postgres.View):
26 | projection = ['myapp.Table.*']
27 | sql = """SELECT * FROM myapp_table WHERE condition"""
28 |
29 | :class:`View`
30 | =============
31 |
32 | .. class:: django_postgres.View
33 |
34 | Inherit from this class to define and interact with your database views.
35 |
36 | You need to either define the field types manually (using standard Django
37 | model fields), or use :attr:`projection` to copy field definitions from other
38 | models.
39 |
40 | .. attribute:: sql
41 |
42 | The SQL for this view (typically a ``SELECT`` query). This attribute is
43 | optional, but if present, the view will be created on ``sync_pgviews``
44 | (which is probably what you want).
45 |
46 | .. attribute:: projection
47 |
48 | A list of field specifiers which will be automatically copied to this view.
49 | If your view directly presents fields from another table, you can
50 | effectively 'import' those here, like so::
51 |
52 | projection = ['auth.User.username', 'auth.User.password',
53 | 'admin.LogEntry.change_message']
54 |
55 | If your view represents a subset of rows in another table (but the same
56 | columns), you might want to import all the fields from that table, like
57 | so::
58 |
59 | projection = ['myapp.Table.*']
60 |
61 | Of course you can mix wildcards with normal field specifiers::
62 |
63 | projection = ['myapp.Table.*', 'auth.User.username', 'auth.User.email']
64 |
65 |
66 | Primary Keys
67 | ============
68 |
69 | Django requires exactly one field on any relation (view, table, etc.) to be a
70 | primary key. By default it will add an ``id`` field to your view, and this will
71 | work fine if you're using a wildcard projection from another model. If not, you
72 | should do one of three things. Project an ``id`` field from a model with a one-to-one
73 | relationship::
74 |
75 | class SimpleUser(django_postgres.View):
76 | projection = ['auth.User.id', 'auth.User.username', 'auth.User.password']
77 | sql = """SELECT id, username, password, FROM auth_user;"""
78 |
79 | Explicitly define a field on your view with ``primary_key=True``::
80 |
81 | class SimpleUser(django_postgres.View):
82 | projection = ['auth.User.password']
83 | sql = """SELECT username, password, FROM auth_user;"""
84 | # max_length doesn't matter here, but Django needs something.
85 | username = models.CharField(max_length=1, primary_key=True)
86 |
87 | Or add an ``id`` column to your view's SQL query (this example uses
88 | `window functions `_)::
89 |
90 | class SimpleUser(django_postgres.View):
91 | projection = ['auth.User.username', 'auth.User.password']
92 | sql = """SELECT username, password, row_number() OVER () AS id
93 | FROM auth_user;"""
94 |
95 |
96 | Creating the Views
97 | ==================
98 |
99 | Creating the views is simple. Just run the ``sync_pgviews`` command::
100 |
101 | $ ./manage.py sync_pgviews
102 | Creating views for django.contrib.auth.models
103 | Creating views for django.contrib.contenttypes.models
104 | Creating views for myapp.models
105 | myapp.models.Superusers (myapp_superusers): created
106 | myapp.models.SimpleUser (myapp_simpleuser): created
107 | myapp.models.Staffness (myapp_staffness): created
108 |
109 | Migrations
110 | ==========
111 |
112 | Views play well with South migrations. If a migration modifies the underlying
113 | table(s) that a view depends on so as to break the view, that view will be
114 | silently deleted by Postgres. For this reason, it's important to run
115 | ``sync_pgviews`` after ``migrate`` to ensure any required tables have been
116 | created/updated.
117 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "django-pgviews-redux"
3 | version = "0.11.0"
4 | description = "Create and manage Postgres SQL Views in Django"
5 | authors = ["Mikuláš Poul "]
6 | readme = "README.md"
7 | packages = [{include = "django_pgviews"}]
8 | classifiers = [
9 | "Development Status :: 5 - Production/Stable",
10 | "Programming Language :: Python",
11 | "Programming Language :: Python :: 3",
12 | "Programming Language :: Python :: 3.8",
13 | "Programming Language :: Python :: 3.9",
14 | "Programming Language :: Python :: 3.10",
15 | "Programming Language :: Python :: 3.11",
16 | "Programming Language :: Python :: 3.12",
17 | "Framework :: Django",
18 | "Framework :: Django :: 4.2",
19 | "Framework :: Django :: 5.0",
20 | "Framework :: Django :: 5.1",
21 | "License :: Public Domain",
22 | "License :: OSI Approved :: The Unlicense (Unlicense)",
23 | ]
24 | include = ["UNLICENSE"]
25 | repository = "https://github.com/xelixdev/django-pgviews-redux"
26 | keywords = ["django", "views", "materialized views", "postgres"]
27 |
28 | [build-system]
29 | requires = ["poetry-core"]
30 | build-backend = "poetry.core.masonry.api"
31 |
32 | [tool.poetry.dependencies]
33 | python = ">=3.6"
34 |
35 | [tool.ruff]
36 | line-length = 120
37 | target-version = "py38"
38 |
39 | [tool.ruff.lint]
40 | select = [
41 | # https://github.com/charliermarsh/ruff#pyflakes-f
42 | "F",
43 | # https://github.com/charliermarsh/ruff#pycodestyle-e-w
44 | "E",
45 | "W",
46 | # https://github.com/charliermarsh/ruff#isort-i
47 | "I",
48 | # https://github.com/charliermarsh/ruff#pep8-naming-n
49 | "N",
50 | # https://github.com/charliermarsh/ruff#pyupgrade-up
51 | "UP",
52 | # https://github.com/charliermarsh/ruff#flake8-bugbear-b
53 | "B",
54 | # https://github.com/charliermarsh/ruff#flake8-comprehensions-c4
55 | "C4",
56 | # https://github.com/charliermarsh/ruff#flake8-debugger-t10
57 | "T10",
58 | # https://github.com/charliermarsh/ruff#flake8-pie-pie
59 | "PIE",
60 | # https://github.com/charliermarsh/ruff#flake8-return-ret
61 | "RET",
62 | # https://github.com/charliermarsh/ruff#flake8-simplify-sim
63 | "SIM",
64 | ]
65 |
66 | # Never enforce...
67 | ignore = [
68 | "E501", # line length violations
69 | "PT004", # missing-fixture-name-underscore
70 | "SIM108", # use-ternary-operator
71 | "RET505", # superfluous-else-return
72 | "RET506", # superfluous-else-raise
73 | "RET507", # superfluous-else-continue
74 | "RET508", # superfluous-else-break
75 | "B027", # empty-method-without-abstract-decorator
76 | ]
77 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xelixdev/django-pgviews-redux/7aafbbc56331a252506c6f48d3b22b9f5cc6c519/tests/__init__.py
--------------------------------------------------------------------------------
/tests/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import sys
4 |
5 | if __name__ == "__main__":
6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings")
7 |
8 | from django.core.management import execute_from_command_line
9 |
10 | execute_from_command_line(sys.argv)
11 |
--------------------------------------------------------------------------------
/tests/test_project/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xelixdev/django-pgviews-redux/7aafbbc56331a252506c6f48d3b22b9f5cc6c519/tests/test_project/__init__.py
--------------------------------------------------------------------------------
/tests/test_project/multidbtest/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xelixdev/django-pgviews-redux/7aafbbc56331a252506c6f48d3b22b9f5cc6c519/tests/test_project/multidbtest/__init__.py
--------------------------------------------------------------------------------
/tests/test_project/multidbtest/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | from django_pgviews import view
4 |
5 |
6 | class Observation(models.Model):
7 | date = models.DateField()
8 | temperature = models.IntegerField()
9 |
10 |
11 | VIEW_SQL = """
12 | WITH summary AS (
13 | SELECT
14 | date_trunc('month', date) AS date,
15 | count(*)
16 | FROM multidbtest_observation
17 | GROUP BY 1
18 | ORDER BY date
19 | ) SELECT
20 | ROW_NUMBER() OVER () AS id,
21 | date,
22 | count
23 | FROM summary;
24 | """
25 |
26 |
27 | class MonthlyObservation(view.ReadOnlyMaterializedView):
28 | sql = VIEW_SQL
29 | date = models.DateField()
30 | count = models.IntegerField()
31 |
--------------------------------------------------------------------------------
/tests/test_project/multidbtest/tests.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 |
3 | from django.core.management import call_command
4 | from django.db import DEFAULT_DB_ALIAS, connections
5 | from django.dispatch import receiver
6 | from django.test import TestCase
7 |
8 | from django_pgviews.signals import view_synced
9 |
10 | from ..viewtest.models import RelatedView
11 | from .models import MonthlyObservation, Observation
12 |
13 |
14 | class WeatherPinnedViewConnectionTest(TestCase):
15 | """Weather views should only return weather_db when pinned."""
16 |
17 | def test_weather_view_using_weather_db(self):
18 | self.assertEqual(MonthlyObservation.get_view_connection(using="weather_db"), connections["weather_db"])
19 |
20 | def test_weather_view_using_default_db(self):
21 | self.assertIsNone(MonthlyObservation.get_view_connection(using=DEFAULT_DB_ALIAS))
22 |
23 | def test_other_app_view_using_weather_db(self):
24 | self.assertIsNone(RelatedView.get_view_connection(using="weather_db"))
25 |
26 | def test_other_app_view_using_default_db(self):
27 | self.assertEqual(RelatedView.get_view_connection(using=DEFAULT_DB_ALIAS), connections["default"])
28 |
29 |
30 | class WeatherPinnedRefreshViewTest(TestCase):
31 | """View.refresh() should automatically select the appropriate database."""
32 |
33 | databases = {DEFAULT_DB_ALIAS, "weather_db"}
34 |
35 | def test_pre_refresh(self):
36 | Observation.objects.create(date=dt.date(2022, 1, 1), temperature=10)
37 | Observation.objects.create(date=dt.date(2022, 1, 3), temperature=20)
38 | self.assertEqual(MonthlyObservation.objects.count(), 0)
39 |
40 | def test_refresh(self):
41 | Observation.objects.create(date=dt.date(2022, 1, 1), temperature=10)
42 | Observation.objects.create(date=dt.date(2022, 1, 3), temperature=20)
43 | MonthlyObservation.refresh()
44 | self.assertEqual(MonthlyObservation.objects.count(), 1)
45 |
46 |
47 | class WeatherPinnedMigrateTest(TestCase):
48 | """Ensure views are only sync'd against the correct database on migrate."""
49 |
50 | databases = {DEFAULT_DB_ALIAS, "weather_db"}
51 |
52 | def test_default(self):
53 | synced_views = []
54 |
55 | @receiver(view_synced)
56 | def on_view_synced(sender, **kwargs):
57 | synced_views.append(sender)
58 |
59 | call_command("migrate", database=DEFAULT_DB_ALIAS)
60 | self.assertNotIn(MonthlyObservation, synced_views)
61 | self.assertIn(RelatedView, synced_views)
62 |
63 | def test_weather_db(self):
64 | synced_views = []
65 |
66 | @receiver(view_synced)
67 | def on_view_synced(sender, **kwargs):
68 | synced_views.append(sender)
69 |
70 | call_command("migrate", database="weather_db")
71 | self.assertIn(MonthlyObservation, synced_views)
72 | self.assertNotIn(RelatedView, synced_views)
73 |
74 |
75 | class WeatherPinnedSyncPGViewsTest(TestCase):
76 | """Ensure views are only sync'd against the correct database with sync_pgviews."""
77 |
78 | databases = {DEFAULT_DB_ALIAS, "weather_db"}
79 |
80 | def test_default(self):
81 | synced_views = []
82 |
83 | @receiver(view_synced)
84 | def on_view_synced(sender, **kwargs):
85 | synced_views.append(sender)
86 |
87 | call_command("sync_pgviews", database=DEFAULT_DB_ALIAS)
88 | self.assertNotIn(MonthlyObservation, synced_views)
89 | self.assertIn(RelatedView, synced_views)
90 |
91 | def test_weather_db(self):
92 | synced_views = []
93 |
94 | @receiver(view_synced)
95 | def on_view_synced(sender, **kwargs):
96 | synced_views.append(sender)
97 |
98 | call_command("sync_pgviews", database="weather_db")
99 | self.assertIn(MonthlyObservation, synced_views)
100 | self.assertNotIn(RelatedView, synced_views)
101 |
102 |
103 | class WeatherPinnedRefreshPGViewsTest(TestCase):
104 | """Ensure views are only refreshed on each database using refresh_pgviews"""
105 |
106 | databases = {DEFAULT_DB_ALIAS, "weather_db"}
107 |
108 | def test_default(self):
109 | Observation.objects.create(date=dt.date(2022, 1, 1), temperature=10)
110 | call_command("refresh_pgviews", database=DEFAULT_DB_ALIAS)
111 | self.assertEqual(MonthlyObservation.objects.count(), 0)
112 |
113 | def test_weather_db(self):
114 | Observation.objects.create(date=dt.date(2022, 1, 1), temperature=10)
115 | call_command("refresh_pgviews", database="weather_db")
116 | self.assertEqual(MonthlyObservation.objects.count(), 1)
117 |
--------------------------------------------------------------------------------
/tests/test_project/routers.py:
--------------------------------------------------------------------------------
1 | class DBRouter:
2 | """
3 | A router to control all database operations on models and views in the
4 | multidbtest application.
5 | """
6 |
7 | def db_for_read(self, model, **hints):
8 | """
9 | Attempts to read multidbtest models go to weather_db.
10 | """
11 | if model._meta.app_label == "multidbtest":
12 | return "weather_db"
13 | if model._meta.app_label == "schemadbtest":
14 | return "schema_db"
15 | return "default"
16 |
17 | def db_for_write(self, model, **hints):
18 | """
19 | Attempts to write multidbtest models go to weather_db.
20 | """
21 | if model._meta.app_label == "multidbtest":
22 | return "weather_db"
23 | if model._meta.app_label == "schemadbtest":
24 | return "schema_db"
25 | return "default"
26 |
27 | def allow_relation(self, obj1, obj2, **hints):
28 | """
29 | Allow relations if a model in the multidbtest app is involved.
30 | """
31 | if obj1._meta.app_label in {"multidbtest", "schemadbtest"} or obj2._meta.app_label in {
32 | "multidbtest",
33 | "schemadbtest",
34 | }:
35 | return True
36 | return None
37 |
38 | def allow_migrate(self, db, app_label, model_name=None, **hints):
39 | """
40 | Make sure the multidbtest models only appear in the weather_db database.
41 | """
42 | if app_label == "multidbtest":
43 | return db == "weather_db"
44 | elif app_label == "schemadbtest":
45 | return db == "schema_db"
46 | else:
47 | return db == "default"
48 |
--------------------------------------------------------------------------------
/tests/test_project/schemadbtest/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xelixdev/django-pgviews-redux/7aafbbc56331a252506c6f48d3b22b9f5cc6c519/tests/test_project/schemadbtest/__init__.py
--------------------------------------------------------------------------------
/tests/test_project/schemadbtest/models.py:
--------------------------------------------------------------------------------
1 | from django.db import connections, models
2 | from django.db.models import signals
3 | from django.dispatch import receiver
4 |
5 | from django_pgviews import view
6 |
7 |
8 | class SchemaObservation(models.Model):
9 | date = models.DateField()
10 | temperature = models.IntegerField()
11 |
12 |
13 | VIEW_SQL = """
14 | WITH summary AS (
15 | SELECT
16 | date_trunc('month', date) AS date,
17 | count(*)
18 | FROM schemadbtest_schemaobservation
19 | GROUP BY 1
20 | ORDER BY date
21 | ) SELECT
22 | ROW_NUMBER() OVER () AS id,
23 | date,
24 | count
25 | FROM summary;
26 | """
27 |
28 |
29 | class SchemaMonthlyObservationView(view.View):
30 | sql = VIEW_SQL
31 | date = models.DateField()
32 | count = models.IntegerField()
33 |
34 |
35 | class SchemaMonthlyObservationMaterializedView(view.MaterializedView):
36 | sql = VIEW_SQL
37 | date = models.DateField()
38 | count = models.IntegerField()
39 |
40 | class Meta:
41 | managed = False
42 | indexes = [models.Index(fields=["date"])]
43 |
44 |
45 | @receiver(signals.pre_migrate)
46 | def create_test_schema(sender, app_config, using, **kwargs):
47 | command = "CREATE SCHEMA IF NOT EXISTS {};".format("other")
48 | with connections[using].cursor() as cursor:
49 | cursor.execute(command)
50 |
--------------------------------------------------------------------------------
/tests/test_project/schemadbtest/tests.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | from contextlib import closing
3 |
4 | from django.core.management import call_command
5 | from django.db import DEFAULT_DB_ALIAS, connections
6 | from django.dispatch import receiver
7 | from django.test import TestCase
8 |
9 | from django_pgviews.signals import view_synced
10 |
11 | from ..viewtest.models import RelatedView
12 | from ..viewtest.tests import get_list_of_indexes
13 | from .models import SchemaMonthlyObservationMaterializedView, SchemaMonthlyObservationView, SchemaObservation
14 |
15 |
16 | class WeatherPinnedViewConnectionTest(TestCase):
17 | """Weather views should only return schema_db when pinned."""
18 |
19 | def test_schema_view_using_schema_db(self):
20 | self.assertEqual(SchemaMonthlyObservationView.get_view_connection(using="schema_db"), connections["schema_db"])
21 |
22 | def test_schema_view_using_default_db(self):
23 | self.assertIsNone(SchemaMonthlyObservationView.get_view_connection(using=DEFAULT_DB_ALIAS))
24 |
25 | def test_schema_materialized_view_using_schema_db(self):
26 | self.assertEqual(
27 | SchemaMonthlyObservationMaterializedView.get_view_connection(using="schema_db"), connections["schema_db"]
28 | )
29 |
30 | def test_schema_materialized_view_using_default_db(self):
31 | self.assertIsNone(SchemaMonthlyObservationMaterializedView.get_view_connection(using=DEFAULT_DB_ALIAS))
32 |
33 | def test_other_app_view_using_schema_db(self):
34 | self.assertIsNone(RelatedView.get_view_connection(using="schema_db"))
35 |
36 | def test_other_app_view_using_default_db(self):
37 | self.assertEqual(RelatedView.get_view_connection(using=DEFAULT_DB_ALIAS), connections["default"])
38 |
39 |
40 | class SchemaTest(TestCase):
41 | """View.refresh() should automatically select the appropriate schema."""
42 |
43 | databases = {DEFAULT_DB_ALIAS, "schema_db"}
44 |
45 | def test_schemas(self):
46 | with closing(connections["schema_db"].cursor()) as cur:
47 | cur.execute("""SELECT schemaname FROM pg_tables WHERE tablename LIKE 'schemadbtest_schemaobservation';""")
48 |
49 | res = cur.fetchone()
50 | self.assertIsNotNone(res, "Can't find table schemadbtest_schemaobservation;")
51 |
52 | (schemaname,) = res
53 | self.assertEqual(schemaname, "other")
54 |
55 | cur.execute(
56 | """SELECT schemaname FROM pg_views WHERE viewname LIKE 'schemadbtest_schemamonthlyobservationview';"""
57 | )
58 |
59 | res = cur.fetchone()
60 | self.assertIsNotNone(res, "Can't find schemadbtest_schemamonthlyobservationview;")
61 |
62 | (schemaname,) = res
63 | self.assertEqual(schemaname, "other")
64 |
65 | cur.execute(
66 | """SELECT schemaname FROM pg_matviews WHERE matviewname LIKE 'schemadbtest_schemamonthlyobservationmaterializedview';"""
67 | )
68 |
69 | res = cur.fetchone()
70 | self.assertIsNotNone(res, "Can't find schemadbtest_schemamonthlyobservationmaterializedview.")
71 |
72 | (schemaname,) = res
73 | self.assertEqual(schemaname, "other")
74 |
75 | indexes = get_list_of_indexes(cur, SchemaMonthlyObservationMaterializedView)
76 | self.assertEqual(indexes, {"schemadbtes_date_9985f7_idx"})
77 |
78 | def test_view(self):
79 | SchemaObservation.objects.create(date=dt.date(2022, 1, 1), temperature=10)
80 | SchemaObservation.objects.create(date=dt.date(2022, 1, 3), temperature=20)
81 | self.assertEqual(SchemaMonthlyObservationView.objects.count(), 1)
82 |
83 | def test_mat_view_pre_refresh(self):
84 | SchemaObservation.objects.create(date=dt.date(2022, 1, 1), temperature=10)
85 | SchemaObservation.objects.create(date=dt.date(2022, 1, 3), temperature=20)
86 | self.assertEqual(SchemaMonthlyObservationMaterializedView.objects.count(), 0)
87 |
88 | def test_mat_view_refresh(self):
89 | SchemaObservation.objects.create(date=dt.date(2022, 1, 1), temperature=10)
90 | SchemaObservation.objects.create(date=dt.date(2022, 1, 3), temperature=20)
91 | SchemaMonthlyObservationMaterializedView.refresh()
92 | self.assertEqual(SchemaMonthlyObservationMaterializedView.objects.count(), 1)
93 |
94 | def test_view_exists_on_sync(self):
95 | synced = []
96 |
97 | @receiver(view_synced)
98 | def on_view_synced(sender, **kwargs):
99 | synced.append(sender)
100 | if sender == SchemaMonthlyObservationView:
101 | self.assertEqual(
102 | dict(
103 | {"status": "EXISTS", "has_changed": False},
104 | update=False,
105 | force=False,
106 | signal=view_synced,
107 | using="schema_db",
108 | ),
109 | kwargs,
110 | )
111 | if sender == SchemaMonthlyObservationMaterializedView:
112 | self.assertEqual(
113 | dict(
114 | {"status": "UPDATED", "has_changed": True},
115 | update=False,
116 | force=False,
117 | signal=view_synced,
118 | using="schema_db",
119 | ),
120 | kwargs,
121 | )
122 |
123 | call_command("sync_pgviews", database="schema_db", update=False)
124 |
125 | self.assertIn(SchemaMonthlyObservationView, synced)
126 | self.assertIn(SchemaMonthlyObservationMaterializedView, synced)
127 |
128 | def test_sync_pgviews_materialized_views_check_sql_changed(self):
129 | self.assertEqual(SchemaObservation.objects.count(), 0, "Test started with non-empty SchemaObservation")
130 | self.assertEqual(
131 | SchemaMonthlyObservationMaterializedView.objects.count(), 0, "Test started with non-empty mat view"
132 | )
133 |
134 | SchemaObservation.objects.create(date=dt.date(2022, 1, 1), temperature=10)
135 |
136 | # test regular behaviour, the mat view got recreated
137 | call_command("sync_pgviews", database="schema_db", update=False) # uses default django setting, False
138 | self.assertEqual(SchemaMonthlyObservationMaterializedView.objects.count(), 1)
139 |
140 | # the mat view did not get recreated because the model hasn't changed
141 | SchemaObservation.objects.create(date=dt.date(2022, 2, 3), temperature=20)
142 | call_command("sync_pgviews", database="schema_db", update=False, materialized_views_check_sql_changed=True)
143 | self.assertEqual(SchemaMonthlyObservationMaterializedView.objects.count(), 1)
144 |
145 | # the mat view got recreated because the mat view SQL has changed
146 |
147 | # let's pretend the mat view in the DB is ordered by name, while the defined on models isn't
148 | with connections["schema_db"].cursor() as cursor:
149 | cursor.execute("DROP MATERIALIZED VIEW schemadbtest_schemamonthlyobservationmaterializedview CASCADE;")
150 | cursor.execute(
151 | """
152 | CREATE MATERIALIZED VIEW schemadbtest_schemamonthlyobservationmaterializedview as
153 | WITH summary AS (
154 | SELECT
155 | date_trunc('day', date) AS date,
156 | count(*)
157 | FROM schemadbtest_schemaobservation
158 | GROUP BY 1
159 | ORDER BY date
160 | ) SELECT
161 | ROW_NUMBER() OVER () AS id,
162 | date,
163 | count
164 | FROM summary;
165 | """
166 | )
167 |
168 | call_command("sync_pgviews", update=False, materialized_views_check_sql_changed=True)
169 | self.assertEqual(SchemaMonthlyObservationMaterializedView.objects.count(), 2)
170 |
171 | def test_migrate_materialized_views_check_sql_changed_default(self):
172 | self.assertEqual(SchemaObservation.objects.count(), 0, "Test started with non-empty SchemaObservation")
173 | self.assertEqual(
174 | SchemaMonthlyObservationMaterializedView.objects.count(), 0, "Test started with non-empty mat view"
175 | )
176 |
177 | SchemaObservation.objects.create(date=dt.date(2022, 1, 1), temperature=10)
178 |
179 | call_command("migrate", database="schema_db")
180 |
181 | self.assertEqual(SchemaMonthlyObservationMaterializedView.objects.count(), 1)
182 |
--------------------------------------------------------------------------------
/tests/test_project/settings/__init__.py:
--------------------------------------------------------------------------------
1 | from .base import * # noqa: F403
2 |
--------------------------------------------------------------------------------
/tests/test_project/settings/base.py:
--------------------------------------------------------------------------------
1 | # Django settings for test_project project.
2 |
3 | DEBUG = True
4 | TEMPLATE_DEBUG = DEBUG
5 |
6 | ADMINS = (
7 | # ('Your Name', 'your_email@example.com'),
8 | )
9 |
10 | TEST_RUNNER = "django.test.runner.DiscoverRunner"
11 |
12 | MANAGERS = ADMINS
13 |
14 | DATABASES = {
15 | "default": {
16 | "ENGINE": "django.db.backends.postgresql",
17 | "NAME": "django_pgviews",
18 | "USER": "postgres",
19 | "PASSWORD": "postgres",
20 | "HOST": "",
21 | "PORT": "",
22 | },
23 | "weather_db": {
24 | "ENGINE": "django.db.backends.postgresql",
25 | "NAME": "postgres_weatherdb",
26 | "USER": "django_pgviews",
27 | "PASSWORD": "postgres",
28 | "HOST": "",
29 | "PORT": "",
30 | },
31 | "schema_db": {
32 | "ENGINE": "django.db.backends.postgresql",
33 | "NAME": "postgres_schemadb",
34 | "OPTIONS": {"options": "-c search_path=other"},
35 | "USER": "django_pgviews",
36 | "PASSWORD": "postgres",
37 | "HOST": "",
38 | "PORT": "",
39 | },
40 | }
41 | DATABASE_ROUTERS = ["test_project.routers.DBRouter"]
42 |
43 |
44 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
45 |
46 | # Hosts/domain names that are valid for this site; required if DEBUG is False
47 | # See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts
48 | ALLOWED_HOSTS = []
49 |
50 | # Local time zone for this installation. Choices can be found here:
51 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
52 | # although not all choices may be available on all operating systems.
53 | # In a Windows environment this must be set to your system time zone.
54 | TIME_ZONE = "America/Chicago"
55 |
56 | # Language code for this installation. All choices can be found here:
57 | # http://www.i18nguy.com/unicode/language-identifiers.html
58 | LANGUAGE_CODE = "en-us"
59 |
60 | SITE_ID = 1
61 |
62 | # If you set this to False, Django will make some optimizations so as not
63 | # to load the internationalization machinery.
64 | USE_I18N = True
65 |
66 | # If you set this to False, Django will not format dates, numbers and
67 | # calendars according to the current locale.
68 | USE_L10N = True
69 |
70 | # If you set this to False, Django will not use timezone-aware datetimes.
71 | USE_TZ = True
72 |
73 | # Absolute filesystem path to the directory that will hold user-uploaded files.
74 | # Example: "/var/www/example.com/media/"
75 | MEDIA_ROOT = ""
76 |
77 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a
78 | # trailing slash.
79 | # Examples: "http://example.com/media/", "http://media.example.com/"
80 | MEDIA_URL = ""
81 |
82 | # Absolute path to the directory static files should be collected to.
83 | # Don't put anything in this directory yourself; store your static files
84 | # in apps' "static/" subdirectories and in STATICFILES_DIRS.
85 | # Example: "/var/www/example.com/static/"
86 | STATIC_ROOT = ""
87 |
88 | # URL prefix for static files.
89 | # Example: "http://example.com/static/", "http://static.example.com/"
90 | STATIC_URL = "/static/"
91 |
92 | # Additional locations of static files
93 | STATICFILES_DIRS = (
94 | # Put strings here, like "/home/html/static" or "C:/www/django/static".
95 | # Always use forward slashes, even on Windows.
96 | # Don't forget to use absolute paths, not relative paths.
97 | )
98 |
99 | # List of finder classes that know how to find static files in
100 | # various locations.
101 | STATICFILES_FINDERS = (
102 | "django.contrib.staticfiles.finders.FileSystemFinder",
103 | "django.contrib.staticfiles.finders.AppDirectoriesFinder",
104 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder',
105 | )
106 |
107 | # Make this unique, and don't share it with anybody.
108 | SECRET_KEY = "z)9k+gxz%8pyppdd6%76t(z+c2wg=*%@nn#r((-#iv+cjj8=l="
109 |
110 | # List of callables that know how to import templates from various sources.
111 | TEMPLATE_LOADERS = (
112 | "django.template.loaders.filesystem.Loader",
113 | "django.template.loaders.app_directories.Loader",
114 | # 'django.template.loaders.eggs.Loader',
115 | )
116 |
117 | MIDDLEWARE_CLASSES = (
118 | "django.middleware.common.CommonMiddleware",
119 | "django.contrib.sessions.middleware.SessionMiddleware",
120 | "django.middleware.csrf.CsrfViewMiddleware",
121 | "django.contrib.auth.middleware.AuthenticationMiddleware",
122 | "django.contrib.messages.middleware.MessageMiddleware",
123 | # Uncomment the next line for simple clickjacking protection:
124 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
125 | )
126 |
127 | ROOT_URLCONF = "test_project.urls"
128 |
129 | # Python dotted path to the WSGI application used by Django's runserver.
130 | WSGI_APPLICATION = "test_project.wsgi.application"
131 |
132 | TEMPLATE_DIRS = (
133 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
134 | # Always use forward slashes, even on Windows.
135 | # Don't forget to use absolute paths, not relative paths.
136 | )
137 |
138 | INSTALLED_APPS = (
139 | "django.contrib.auth",
140 | "django.contrib.contenttypes",
141 | "django.contrib.sessions",
142 | "django.contrib.sites",
143 | "django.contrib.messages",
144 | "django.contrib.staticfiles",
145 | # Uncomment the next line to enable the admin:
146 | # 'django.contrib.admin',
147 | # Uncomment the next line to enable admin documentation:
148 | # 'django.contrib.admindocs',
149 | "django_pgviews",
150 | "test_project.viewtest",
151 | "test_project.multidbtest",
152 | "test_project.schemadbtest",
153 | )
154 |
155 | # A sample logging configuration. The only tangible logging
156 | # performed by this configuration is to send an email to
157 | # the site admins on every HTTP 500 error when DEBUG=False.
158 | # See http://docs.djangoproject.com/en/dev/topics/logging for
159 | # more details on how to customize your logging configuration.
160 | LOGGING = {
161 | "version": 1,
162 | "disable_existing_loggers": False,
163 | "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}},
164 | "handlers": {
165 | "mail_admins": {
166 | "level": "ERROR",
167 | "filters": ["require_debug_false"],
168 | "class": "django.utils.log.AdminEmailHandler",
169 | },
170 | "console": {"class": "logging.StreamHandler"},
171 | },
172 | "loggers": {
173 | "django.request": {"handlers": ["mail_admins"], "level": "ERROR", "propagate": True},
174 | "django_pgviews": {"level": "INFO", "handlers": ["console"]},
175 | },
176 | }
177 |
178 | MATERIALIZED_VIEWS_SYNC_DISABLED = False
179 |
--------------------------------------------------------------------------------
/tests/test_project/settings/ci.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from .base import * # noqa: F403
4 |
5 | DATABASES = {
6 | "default": {
7 | "ENGINE": "django.db.backends.postgresql",
8 | "NAME": os.environ.get("DB_NAME", "circle_test"),
9 | "USER": os.environ.get("DB_USER", "postgres"),
10 | "PASSWORD": os.environ.get("DB_PASSWORD", "postgres"),
11 | "HOST": "localhost",
12 | "PORT": "5432",
13 | },
14 | "weather_db": {
15 | "ENGINE": "django.db.backends.postgresql",
16 | "NAME": os.environ.get("DB_NAME_WEATHER", "weatherdb"),
17 | "USER": os.environ.get("DB_USER", "postgres"),
18 | "PASSWORD": os.environ.get("DB_PASSWORD", "postgres"),
19 | "HOST": "localhost",
20 | "PORT": "5432",
21 | },
22 | "schema_db": {
23 | "ENGINE": "django.db.backends.postgresql",
24 | "NAME": os.environ.get("DB_NAME_WEATHER", "schemadb"),
25 | "USER": os.environ.get("DB_USER", "postgres"),
26 | "PASSWORD": os.environ.get("DB_PASSWORD", "postgres"),
27 | "HOST": "localhost",
28 | "PORT": "5432",
29 | "OPTIONS": {"options": "-c search_path=other"},
30 | },
31 | }
32 |
--------------------------------------------------------------------------------
/tests/test_project/urls.py:
--------------------------------------------------------------------------------
1 | urlpatterns = []
2 |
--------------------------------------------------------------------------------
/tests/test_project/viewtest/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xelixdev/django-pgviews-redux/7aafbbc56331a252506c6f48d3b22b9f5cc6c519/tests/test_project/viewtest/__init__.py
--------------------------------------------------------------------------------
/tests/test_project/viewtest/models.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 |
3 | from django.db import connections, models
4 | from django.db.models import signals
5 | from django.dispatch import receiver
6 | from django.utils import timezone
7 |
8 | from django_pgviews import view
9 |
10 |
11 | class TestModel(models.Model):
12 | """
13 | Test model with some basic data for running migrate tests against.
14 | """
15 |
16 | name = models.CharField(max_length=100)
17 |
18 |
19 | class Superusers(view.View):
20 | projection = ["auth.User.*"]
21 | sql = """SELECT * FROM auth_user WHERE is_superuser = TRUE;"""
22 |
23 |
24 | class LatestSuperusers(view.View): # concept doesn't make much sense, but it will get the job done
25 | projection = ["auth.User.*"]
26 |
27 | @classmethod
28 | def get_sql(cls):
29 | return view.ViewSQL(
30 | """SELECT * FROM auth_user WHERE is_superuser = TRUE and date_joined >= %s;""",
31 | [timezone.now() - timedelta(days=5)],
32 | )
33 |
34 |
35 | class SimpleUser(view.View):
36 | projection = ["auth.User.username", "auth.User.password"]
37 | # The row_number() window function is needed so that Django sees some kind
38 | # of 'id' field. We could also grab the one from `auth.User`, but this
39 | # seemed like more fun :)
40 | sql = """SELECT username, password, row_number() OVER () AS id FROM auth_user;"""
41 |
42 |
43 | class RelatedView(view.ReadOnlyView):
44 | sql = """SELECT id AS model_id, id FROM viewtest_testmodel"""
45 | model = models.ForeignKey(TestModel, on_delete=models.CASCADE)
46 |
47 |
48 | class MaterializedRelatedView(view.ReadOnlyMaterializedView):
49 | sql = """SELECT id AS model_id, id FROM viewtest_testmodel"""
50 | model = models.ForeignKey(TestModel, on_delete=models.DO_NOTHING)
51 |
52 |
53 | class DependantView(view.ReadOnlyView):
54 | dependencies = ("viewtest.RelatedView",)
55 | sql = """SELECT model_id from viewtest_relatedview;"""
56 |
57 |
58 | class DependantMaterializedView(view.ReadOnlyMaterializedView):
59 | dependencies = ("viewtest.MaterializedRelatedView",)
60 | sql = """SELECT model_id from viewtest_materializedrelatedview;"""
61 |
62 |
63 | class MaterializedRelatedViewWithIndex(view.ReadOnlyMaterializedView):
64 | concurrent_index = "id"
65 | sql = """SELECT id AS model_id, id FROM viewtest_testmodel"""
66 | model = models.ForeignKey(TestModel, on_delete=models.DO_NOTHING)
67 |
68 | class Meta:
69 | managed = False
70 | indexes = [models.Index(fields=["model"])]
71 |
72 |
73 | class CustomSchemaMaterializedRelatedViewWithIndex(view.ReadOnlyMaterializedView):
74 | concurrent_index = "id"
75 | sql = """SELECT id AS model_id, id FROM viewtest_testmodel"""
76 | model = models.ForeignKey(TestModel, on_delete=models.DO_NOTHING)
77 |
78 | class Meta:
79 | db_table = "test_schema.my_custom_view_with_index"
80 | managed = False
81 | indexes = [models.Index(fields=["model"])]
82 |
83 |
84 | class CustomSchemaView(view.ReadOnlyView):
85 | sql = """SELECT id AS model_id, id FROM viewtest_testmodel"""
86 | model = models.ForeignKey(TestModel, on_delete=models.DO_NOTHING)
87 |
88 | class Meta:
89 | managed = False
90 | db_table = "test_schema.my_custom_view"
91 |
92 |
93 | class MaterializedRelatedViewWithNoData(view.ReadOnlyMaterializedView):
94 | sql = """SELECT id AS model_id, id FROM viewtest_testmodel"""
95 | model = models.ForeignKey(TestModel, on_delete=models.DO_NOTHING)
96 | with_data = False
97 |
98 |
99 | class MaterializedRelatedViewWithReallyReallyReallyReallyReallyReallyLongName(view.ReadOnlyMaterializedView):
100 | sql = """SELECT id AS model_id, id FROM viewtest_testmodel"""
101 | model = models.ForeignKey(TestModel, on_delete=models.DO_NOTHING)
102 |
103 |
104 | @receiver(signals.post_migrate)
105 | def create_test_schema(sender, app_config, using, **kwargs):
106 | command = "CREATE SCHEMA IF NOT EXISTS {};".format("test_schema")
107 | with connections[using].cursor() as cursor:
108 | cursor.execute(command)
109 |
--------------------------------------------------------------------------------
/tests/test_project/viewtest/tests.py:
--------------------------------------------------------------------------------
1 | """Test Django PGViews."""
2 |
3 | from contextlib import closing
4 | from datetime import timedelta
5 |
6 | from django.apps import apps
7 | from django.conf import settings
8 | from django.contrib import auth
9 | from django.contrib.auth.models import User
10 | from django.core.management import call_command
11 | from django.db import DEFAULT_DB_ALIAS, connection
12 | from django.db.models.signals import post_migrate
13 | from django.db.utils import DatabaseError, OperationalError
14 | from django.dispatch import receiver
15 | from django.test import TestCase, override_settings
16 | from django.utils import timezone
17 |
18 | from django_pgviews.signals import all_views_synced, view_synced
19 | from django_pgviews.view import _make_where, _schema_and_name
20 |
21 | from . import models
22 | from .models import LatestSuperusers
23 |
24 | try:
25 | from psycopg.errors import UndefinedTable
26 | except ImportError:
27 | from psycopg2.errors import UndefinedTable
28 |
29 |
30 | def get_list_of_indexes(cursor, cls):
31 | schema, table = _schema_and_name(cursor.connection, cls._meta.db_table)
32 | where_fragment, params = _make_where(tablename=table, schemaname=schema)
33 | cursor.execute(f"SELECT indexname FROM pg_indexes WHERE {where_fragment}", params)
34 | return {x[0] for x in cursor.fetchall()}
35 |
36 |
37 | class ViewTestCase(TestCase):
38 | """
39 | Run the tests to ensure the post_migrate hooks were called.
40 | """
41 |
42 | def test_views_have_been_created(self):
43 | """
44 | Look at the PG View table to ensure views were created.
45 | """
46 | with closing(connection.cursor()) as cur:
47 | cur.execute("""SELECT COUNT(*) FROM pg_views WHERE viewname LIKE 'viewtest_%';""")
48 |
49 | (count,) = cur.fetchone()
50 | self.assertEqual(count, 5)
51 |
52 | cur.execute("""SELECT COUNT(*) FROM pg_matviews WHERE matviewname LIKE 'viewtest_%';""")
53 |
54 | (count,) = cur.fetchone()
55 | self.assertEqual(count, 5)
56 |
57 | cur.execute("""SELECT COUNT(*) FROM information_schema.views WHERE table_schema = 'test_schema';""")
58 |
59 | (count,) = cur.fetchone()
60 | self.assertEqual(count, 1)
61 |
62 | def test_clear_views(self):
63 | """
64 | Check the PG View table to see that the views were removed.
65 | """
66 | call_command(
67 | "clear_pgviews",
68 | *[],
69 | )
70 | with closing(connection.cursor()) as cur:
71 | cur.execute("""SELECT COUNT(*) FROM pg_views WHERE viewname LIKE 'viewtest_%';""")
72 |
73 | (count,) = cur.fetchone()
74 | self.assertEqual(count, 0)
75 |
76 | cur.execute("""SELECT COUNT(*) FROM information_schema.views WHERE table_schema = 'test_schema';""")
77 |
78 | (count,) = cur.fetchone()
79 | self.assertEqual(count, 0)
80 |
81 | def test_wildcard_projection(self):
82 | """
83 | Wildcard projections take all fields from a projected model.
84 | """
85 | foo_user = auth.models.User.objects.create(username="foo", is_superuser=True)
86 | foo_user.set_password("blah")
87 | foo_user.save()
88 |
89 | foo_superuser = models.Superusers.objects.get(username="foo")
90 |
91 | self.assertEqual(foo_user.id, foo_superuser.id)
92 | self.assertEqual(foo_user.password, foo_superuser.password)
93 |
94 | def test_limited_projection(self):
95 | """
96 | A limited projection only creates the projected fields.
97 | """
98 | foo_user = auth.models.User.objects.create(username="foo", is_superuser=True)
99 | foo_user.set_password("blah")
100 | foo_user.save()
101 |
102 | foo_simple = models.SimpleUser.objects.get(username="foo")
103 |
104 | self.assertEqual(foo_simple.username, foo_user.username)
105 | self.assertEqual(foo_simple.password, foo_user.password)
106 | self.assertFalse(getattr(foo_simple, "date_joined", False))
107 |
108 | def test_related_delete(self):
109 | """
110 | Test views do not interfere with deleting the models
111 | """
112 | test_model = models.TestModel()
113 | test_model.name = "Bob"
114 | test_model.save()
115 | test_model.delete()
116 |
117 | def test_materialized_view(self):
118 | """
119 | Test a materialized view works correctly
120 | """
121 | self.assertEqual(
122 | models.MaterializedRelatedView.objects.count(), 0, "Materialized view should not have anything"
123 | )
124 |
125 | test_model = models.TestModel()
126 | test_model.name = "Bob"
127 | test_model.save()
128 |
129 | self.assertEqual(
130 | models.MaterializedRelatedView.objects.count(), 0, "Materialized view should not have anything"
131 | )
132 |
133 | models.MaterializedRelatedView.refresh()
134 |
135 | self.assertEqual(models.MaterializedRelatedView.objects.count(), 1, "Materialized view should have updated")
136 |
137 | models.MaterializedRelatedViewWithIndex.refresh(concurrently=True)
138 |
139 | self.assertEqual(
140 | models.MaterializedRelatedViewWithIndex.objects.count(),
141 | 1,
142 | "Materialized view should have updated concurrently",
143 | )
144 |
145 | def test_refresh_missing(self):
146 | with connection.cursor() as cursor:
147 | cursor.execute("DROP MATERIALIZED VIEW viewtest_materializedrelatedview CASCADE;")
148 |
149 | with self.assertRaises(UndefinedTable):
150 | models.MaterializedRelatedView.refresh()
151 |
152 | def test_materialized_view_indexes(self):
153 | with connection.cursor() as cursor:
154 | orig_indexes = get_list_of_indexes(cursor, models.MaterializedRelatedViewWithIndex)
155 | self.assertIn("viewtest_materializedrelatedviewwithindex_id_index", orig_indexes)
156 | self.assertEqual(len(orig_indexes), 2)
157 |
158 | # drop current indexes, add some random ones which will get deleted
159 | for index_name in orig_indexes:
160 | cursor.execute(f"DROP INDEX {index_name}")
161 |
162 | cursor.execute(
163 | "CREATE UNIQUE INDEX viewtest_materializedrelatedviewwithindex_concurrent_idx "
164 | "ON viewtest_materializedrelatedviewwithindex (id)"
165 | )
166 | cursor.execute(
167 | "CREATE INDEX viewtest_materializedrelatedviewwithindex_some_idx "
168 | "ON viewtest_materializedrelatedviewwithindex (model_id)"
169 | )
170 |
171 | call_command("sync_pgviews", materialized_views_check_sql_changed=True)
172 |
173 | with connection.cursor() as cursor:
174 | new_indexes = get_list_of_indexes(cursor, models.MaterializedRelatedViewWithIndex)
175 |
176 | self.assertEqual(new_indexes, orig_indexes)
177 |
178 | def test_materialized_view_schema_indexes(self):
179 | with connection.cursor() as cursor:
180 | orig_indexes = get_list_of_indexes(cursor, models.CustomSchemaMaterializedRelatedViewWithIndex)
181 |
182 | self.assertEqual(len(orig_indexes), 2)
183 | self.assertIn("test_schema_my_custom_view_with_index_id_index", orig_indexes)
184 |
185 | # drop current indexes, add some random ones which will get deleted
186 | for index_name in orig_indexes:
187 | cursor.execute(f"DROP INDEX test_schema.{index_name}")
188 |
189 | cursor.execute(
190 | "CREATE UNIQUE INDEX my_custom_view_with_index_concurrent_idx "
191 | "ON test_schema.my_custom_view_with_index (id)"
192 | )
193 | cursor.execute(
194 | "CREATE INDEX my_custom_view_with_index_some_idx ON test_schema.my_custom_view_with_index (model_id)"
195 | )
196 |
197 | call_command("sync_pgviews", materialized_views_check_sql_changed=True)
198 |
199 | with connection.cursor() as cursor:
200 | new_indexes = get_list_of_indexes(cursor, models.CustomSchemaMaterializedRelatedViewWithIndex)
201 |
202 | self.assertEqual(new_indexes, orig_indexes)
203 |
204 | def test_materialized_view_with_no_data(self):
205 | """
206 | Test a materialized view with no data works correctly
207 | """
208 | with self.assertRaises(OperationalError):
209 | models.MaterializedRelatedViewWithNoData.objects.count()
210 |
211 | def test_materialized_view_with_no_data_after_refresh(self):
212 | models.TestModel.objects.create(name="Bob")
213 |
214 | models.MaterializedRelatedViewWithNoData.refresh()
215 |
216 | self.assertEqual(
217 | models.MaterializedRelatedViewWithNoData.objects.count(), 1, "Materialized view should have updated"
218 | )
219 |
220 | def test_signals(self):
221 | expected = {
222 | models.MaterializedRelatedView: {"status": "UPDATED", "has_changed": True},
223 | models.Superusers: {"status": "EXISTS", "has_changed": False},
224 | }
225 | synced_views = []
226 | all_views_were_synced = [False]
227 |
228 | @receiver(view_synced)
229 | def on_view_synced(sender, **kwargs):
230 | synced_views.append(sender)
231 | if sender in expected:
232 | expected_kwargs = expected.pop(sender)
233 | self.assertEqual(
234 | dict(expected_kwargs, update=False, force=False, signal=view_synced, using=DEFAULT_DB_ALIAS), kwargs
235 | )
236 |
237 | @receiver(all_views_synced)
238 | def on_all_views_synced(sender, **kwargs):
239 | all_views_were_synced[0] = True
240 |
241 | call_command("sync_pgviews", update=False)
242 |
243 | # All views went through syncing
244 | self.assertEqual(all_views_were_synced[0], True)
245 | self.assertFalse(expected)
246 | self.assertEqual(len(synced_views), 12)
247 |
248 | def test_get_sql(self):
249 | User.objects.create(username="old", is_superuser=True, date_joined=timezone.now() - timedelta(days=10))
250 | User.objects.create(username="new", is_superuser=True, date_joined=timezone.now() - timedelta(days=1))
251 |
252 | call_command("sync_pgviews", update=False)
253 |
254 | self.assertEqual(LatestSuperusers.objects.count(), 1)
255 |
256 | def test_sync_pgviews_materialized_views_check_sql_changed(self):
257 | self.assertEqual(models.TestModel.objects.count(), 0, "Test started with non-empty TestModel")
258 | self.assertEqual(models.MaterializedRelatedView.objects.count(), 0, "Test started with non-empty mat view")
259 |
260 | models.TestModel.objects.create(name="Test")
261 |
262 | # test regular behaviour, the mat view got recreated
263 | call_command("sync_pgviews", update=False) # uses default django setting, False
264 | self.assertEqual(models.MaterializedRelatedView.objects.count(), 1)
265 |
266 | # the mat view did not get recreated because the model hasn't changed
267 | models.TestModel.objects.create(name="Test 2")
268 | call_command("sync_pgviews", update=False, materialized_views_check_sql_changed=True)
269 | self.assertEqual(models.MaterializedRelatedView.objects.count(), 1)
270 |
271 | # the mat view got recreated because the mat view SQL has changed
272 |
273 | # let's pretend the mat view in the DB is ordered by name, while the defined on models isn't
274 | with connection.cursor() as cursor:
275 | cursor.execute("DROP MATERIALIZED VIEW viewtest_materializedrelatedview CASCADE;")
276 | cursor.execute(
277 | """
278 | CREATE MATERIALIZED VIEW viewtest_materializedrelatedview as
279 | SELECT id AS model_id, id FROM viewtest_testmodel ORDER BY name;
280 | """
281 | )
282 |
283 | call_command("sync_pgviews", update=False, materialized_views_check_sql_changed=True)
284 | self.assertEqual(models.MaterializedRelatedView.objects.count(), 2)
285 |
286 | def test_migrate_materialized_views_check_sql_changed_default(self):
287 | self.assertEqual(models.TestModel.objects.count(), 0, "Test started with non-empty TestModel")
288 | self.assertEqual(models.MaterializedRelatedView.objects.count(), 0, "Test started with non-empty mat view")
289 |
290 | models.TestModel.objects.create(name="Test")
291 |
292 | call_command("migrate")
293 |
294 | self.assertEqual(models.MaterializedRelatedView.objects.count(), 1)
295 |
296 | def test_refresh_pgviews(self):
297 | models.TestModel.objects.create(name="Test")
298 |
299 | call_command("refresh_pgviews")
300 |
301 | self.assertEqual(models.MaterializedRelatedView.objects.count(), 1)
302 | self.assertEqual(models.DependantView.objects.count(), 1)
303 | self.assertEqual(models.DependantMaterializedView.objects.count(), 1)
304 | self.assertEqual(models.MaterializedRelatedViewWithIndex.objects.count(), 1)
305 | self.assertEqual(models.MaterializedRelatedViewWithNoData.objects.count(), 1)
306 |
307 | models.TestModel.objects.create(name="Test 2")
308 |
309 | call_command("refresh_pgviews", concurrently=True)
310 |
311 | self.assertEqual(models.MaterializedRelatedView.objects.count(), 2)
312 | self.assertEqual(models.DependantView.objects.count(), 2)
313 | self.assertEqual(models.DependantMaterializedView.objects.count(), 2)
314 | self.assertEqual(models.MaterializedRelatedViewWithIndex.objects.count(), 2)
315 | self.assertEqual(models.MaterializedRelatedViewWithNoData.objects.count(), 2)
316 |
317 |
318 | class TestMaterializedViewsCheckSQLSettings(TestCase):
319 | def setUp(self):
320 | settings.MATERIALIZED_VIEWS_CHECK_SQL_CHANGED = True
321 |
322 | def test_migrate_materialized_views_check_sql_set_to_true(self):
323 | self.assertEqual(models.TestModel.objects.count(), 0)
324 | self.assertEqual(models.MaterializedRelatedView.objects.count(), 0)
325 |
326 | models.TestModel.objects.create(name="Test")
327 | call_command("migrate")
328 | self.assertEqual(models.MaterializedRelatedView.objects.count(), 0)
329 |
330 | # let's pretend the mat view in the DB is ordered by name, while the defined on models isn't
331 | with connection.cursor() as cursor:
332 | cursor.execute("DROP MATERIALIZED VIEW viewtest_materializedrelatedview CASCADE;")
333 | cursor.execute(
334 | """
335 | CREATE MATERIALIZED VIEW viewtest_materializedrelatedview as
336 | SELECT id AS model_id, id FROM viewtest_testmodel ORDER BY name;
337 | """
338 | )
339 |
340 | # which means that when the sync is triggered here, the mat view will get updated
341 | call_command("migrate")
342 | self.assertEqual(models.MaterializedRelatedView.objects.count(), 1)
343 |
344 | def tearDown(self):
345 | settings.MATERIALIZED_VIEWS_CHECK_SQL_CHANGED = False
346 |
347 |
348 | class DependantViewTestCase(TestCase):
349 | def test_sync_depending_views(self):
350 | """
351 | Test the sync_pgviews command for views that depend on other views.
352 |
353 | This test drops `viewtest_dependantview` and its dependencies
354 | and recreates them manually, thereby simulating an old state
355 | of the views in the db before changes to the view model's sql is made.
356 | Then we sync the views again and verify that everything was updated.
357 | """
358 |
359 | with closing(connection.cursor()) as cur:
360 | cur.execute("DROP VIEW viewtest_relatedview CASCADE;")
361 |
362 | cur.execute("""CREATE VIEW viewtest_relatedview as SELECT id AS model_id, name FROM viewtest_testmodel;""")
363 |
364 | cur.execute("""CREATE VIEW viewtest_dependantview as SELECT name from viewtest_relatedview;""")
365 |
366 | cur.execute("""SELECT name from viewtest_relatedview;""")
367 | cur.execute("""SELECT name from viewtest_dependantview;""")
368 |
369 | call_command("sync_pgviews", "--force")
370 |
371 | with closing(connection.cursor()) as cur:
372 | cur.execute("""SELECT COUNT(*) FROM pg_views WHERE viewname LIKE 'viewtest_%';""")
373 |
374 | (count,) = cur.fetchone()
375 | self.assertEqual(count, 5)
376 |
377 | with self.assertRaises(DatabaseError):
378 | cur.execute("""SELECT name from viewtest_relatedview;""")
379 |
380 | with self.assertRaises(DatabaseError):
381 | cur.execute("""SELECT name from viewtest_dependantview;""")
382 |
383 | def test_sync_depending_materialized_views(self):
384 | """
385 | Refresh views that depend on materialized views.
386 | """
387 | with closing(connection.cursor()) as cur:
388 | cur.execute(
389 | """DROP MATERIALIZED VIEW viewtest_materializedrelatedview
390 | CASCADE;"""
391 | )
392 |
393 | cur.execute(
394 | """CREATE MATERIALIZED VIEW viewtest_materializedrelatedview as
395 | SELECT id AS model_id, name FROM viewtest_testmodel;"""
396 | )
397 |
398 | cur.execute(
399 | """CREATE MATERIALIZED VIEW viewtest_dependantmaterializedview
400 | as SELECT name from viewtest_materializedrelatedview;"""
401 | )
402 | cur.execute("""SELECT name from viewtest_materializedrelatedview;""")
403 | cur.execute("""SELECT name from viewtest_dependantmaterializedview;""")
404 |
405 | call_command("sync_pgviews", "--force")
406 |
407 | with closing(connection.cursor()) as cur:
408 | cur.execute("""SELECT COUNT(*) FROM pg_views WHERE viewname LIKE 'viewtest_%';""")
409 |
410 | (count,) = cur.fetchone()
411 | self.assertEqual(count, 5)
412 |
413 | with self.assertRaises(DatabaseError):
414 | cur.execute("""SELECT name from viewtest_dependantmaterializedview;""")
415 |
416 | with self.assertRaises(DatabaseError):
417 | cur.execute("""SELECT name from viewtest_materializedrelatedview; """)
418 |
419 | with self.assertRaises(DatabaseError):
420 | cur.execute("""SELECT name from viewtest_dependantmaterializedview;""")
421 |
422 |
423 | class MakeWhereTestCase(TestCase):
424 | def test_with_schema(self):
425 | where_fragment, params = _make_where(schemaname="test_schema", tablename="test_tablename")
426 | self.assertEqual(where_fragment, "schemaname = %s AND tablename = %s")
427 | self.assertEqual(params, ["test_schema", "test_tablename"])
428 |
429 | def test_no_schema(self):
430 | where_fragment, params = _make_where(schemaname=None, tablename="test_tablename")
431 | self.assertEqual(where_fragment, "tablename = %s")
432 | self.assertEqual(params, ["test_tablename"])
433 |
434 | def test_with_schema_list(self):
435 | where_fragment, params = _make_where(schemaname="test_schema", tablename=["test_tablename1", "test_tablename2"])
436 | self.assertEqual(where_fragment, "schemaname = %s AND tablename IN (%s, %s)")
437 | self.assertEqual(params, ["test_schema", "test_tablename1", "test_tablename2"])
438 |
439 | def test_no_schema_list(self):
440 | where_fragment, params = _make_where(schemaname=None, tablename=["test_tablename1", "test_tablename2"])
441 | self.assertEqual(where_fragment, "tablename IN (%s, %s)")
442 | self.assertEqual(params, ["test_tablename1", "test_tablename2"])
443 |
444 |
445 | class TestMaterializedViewSyncDisabledSettings(TestCase):
446 | def setUp(self):
447 | """
448 | NOTE: By default, Django runs and registers signals with default values during
449 | test execution. To address this, we store the original receivers and settings,
450 | then restore them in tearDown to avoid affecting other tests.
451 | """
452 |
453 | # Store original receivers and settings
454 | self._original_receivers = list(post_migrate.receivers)
455 | self._original_config = apps.get_app_config("django_pgviews").counter
456 |
457 | # Clear existing signal receivers
458 | post_migrate.receivers.clear()
459 |
460 | # Get the app config and reset counter
461 | config = apps.get_app_config("django_pgviews")
462 | config.counter = 0
463 |
464 | # Reload app config with new settings
465 | with override_settings(MATERIALIZED_VIEWS_DISABLE_SYNC_ON_MIGRATE=True):
466 | config.ready()
467 |
468 | # Drop the view if it exists
469 | with connection.cursor() as cursor:
470 | cursor.execute("DROP MATERIALIZED VIEW IF EXISTS viewtest_materializedrelatedview CASCADE;")
471 |
472 | def tearDown(self):
473 | """Restore original signal receivers and app config state"""
474 |
475 | post_migrate.receivers.clear()
476 | post_migrate.receivers.extend(self._original_receivers)
477 | apps.get_app_config("django_pgviews").counter = self._original_config
478 |
479 | def test_migrate_materialized_views_sync_disabled(self):
480 | self.assertEqual(models.TestModel.objects.count(), 0)
481 |
482 | models.TestModel.objects.create(name="Test")
483 |
484 | call_command("migrate") # migrate is not running sync_pgviews
485 | with connection.cursor() as cursor:
486 | cursor.execute(
487 | "SELECT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'viewtest_materializedrelatedview');"
488 | )
489 | exists = cursor.fetchone()[0]
490 | self.assertFalse(exists, "Materialized view viewtest_materializedrelatedview should not exist.")
491 |
492 | call_command("sync_pgviews") # explicitly run sync_pgviews
493 | with connection.cursor() as cursor:
494 | cursor.execute(
495 | "SELECT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'viewtest_materializedrelatedview');"
496 | )
497 | exists = cursor.fetchone()[0]
498 | self.assertTrue(exists, "Materialized view viewtest_materializedrelatedview should exist.")
499 |
--------------------------------------------------------------------------------
/tests/test_project/wsgi.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings")
4 |
5 | from django.core.wsgi import get_wsgi_application # noqa: E402
6 |
7 | application = get_wsgi_application()
8 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist =
3 | py{38,39,310,311}-dj{42}-pg{2,3}
4 | py{310,311,312}-dj{50,51}-pg{2,3}
5 |
6 | [gh]
7 | python =
8 | "3.8" = py38
9 | "3.9" = py39
10 | "3.10" = py310
11 | "3.11" = py311
12 | "3.12" = py312
13 |
14 | [testenv]
15 | usedevelop = true
16 | setenv =
17 | DJANGO_SETTINGS_MODULE = test_project.settings.ci
18 | changedir = {toxinidir}/tests
19 | deps=
20 | pg2: psycopg2>2.9
21 | pg3: psycopg>3.1
22 | dj42: https://github.com/django/django/archive/stable/4.2.x.tar.gz#egg=django
23 | dj50: https://github.com/django/django/archive/stable/5.0.x.tar.gz#egg=django
24 | dj51: https://github.com/django/django/archive/stable/5.1.x.tar.gz#egg=django
25 | commands=
26 | python manage.py test {posargs:test_project.viewtest test_project.multidbtest test_project.schemadbtest} -v2
27 | passenv =
28 | DB_NAME
29 | DB_USER
30 | DB_PASSWORD
31 |
--------------------------------------------------------------------------------