├── .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 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 |
Django VersionDjango-PGView Version
1.4 and downUnsupported
1.50.0.1
1.60.0.3
1.70.0.4
1.90.1.0
1.100.2.0
2.20.6.0
3.00.6.0
3.10.6.1
3.20.7.1
4.00.8.1
4.10.8.4
4.20.9.2
5.00.9.4
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 | --------------------------------------------------------------------------------