├── .env_template ├── .github └── workflows │ ├── dockerhub.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE.txt ├── README.md ├── docker ├── Postgres10.Dockerfile ├── Postgres11.Dockerfile ├── Postgres12.Dockerfile ├── Postgres13.Dockerfile └── docker-compose.yml ├── manage.py ├── pyproject.toml ├── pytest.ini ├── requirements.txt ├── requirements_dev.txt ├── requirements_lint.txt ├── requirements_test.txt ├── setup.cfg ├── setup.py ├── src ├── __init__.py └── django_plpy │ ├── __init__.py │ ├── builder.py │ ├── installer.py │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── checkenv.py │ │ └── syncfunctions.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── settings.py │ └── utils.py ├── tests ├── __init__.py ├── books │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_alter_book_name.py │ │ ├── 0003_book_stock_days_left.py │ │ ├── 0004_alter_book_stock_days_left.py │ │ └── __init__.py │ └── models.py ├── conftest.py ├── testapp │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── tests.py └── tox.ini /.env_template: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://db_triggers:db_triggers@localhost/db_triggers 2 | SECRET_KEY=1234657 3 | 4 | # this is only needed if database is on a different host 5 | #PLPY_ENV_PATHS=/env, 6 | #PLPY_PROJECT_PATH=/app 7 | -------------------------------------------------------------------------------- /.github/workflows/dockerhub.yml: -------------------------------------------------------------------------------- 1 | name: Postgres PL/Python 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | push_to_registry: 9 | name: Push Docker image to Docker Hub 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | postgres-version: [10, 11, 12, 13] 14 | steps: 15 | - name: Check out the repo 16 | uses: actions/checkout@v2 17 | 18 | - name: Log in to Docker Hub 19 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 20 | with: 21 | username: ${{ secrets.DOCKER_USERNAME }} 22 | password: ${{ secrets.DOCKER_PASSWORD }} 23 | 24 | - name: Extract metadata (tags, labels) for Docker 25 | id: meta 26 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 27 | with: 28 | images: thorinschiffer/postgres-plpython 29 | 30 | - name: Build and push Docker image for Postgres-${{ matrix.postgres-version }} 31 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 32 | with: 33 | context: . 34 | push: true 35 | tags: thorinschiffer/postgres-plpython:${{ matrix.postgres-version }} 36 | labels: ${{ steps.meta.outputs.labels }} 37 | file: docker/Postgres${{ matrix.postgres-version }}.Dockerfile 38 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | - uses: pre-commit/action@v2.0.3 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | build: 10 | name: Test P${{ matrix.python-version }}, Dj${{ matrix.django-version-number }}, PSQL${{matrix.postgres-version}} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: [3.6, 3.7, 3.8, 3.9] 16 | postgres-version: [10, 11, 12, 13] 17 | django-version-number: [2, 3] 18 | include: 19 | - django-version-number: 2 20 | django-version: Django==2.2 21 | - django-version-number: 3 22 | django-version: Django>=3.2,<4.0 23 | services: 24 | postgres: 25 | image: thorinschiffer/postgres-plpython:${{ matrix.postgres-version }} 26 | env: 27 | POSTGRES_PASSWORD: postgres 28 | ports: 29 | - 5432:5432 30 | volumes: 31 | - ${{github.workspace}}:/app 32 | steps: 33 | - uses: actions/checkout@v2 34 | - name: Set up Python ${{ matrix.python-version }} 35 | uses: actions/setup-python@v2 36 | with: 37 | python-version: ${{ matrix.python-version }} 38 | cache: pip 39 | - name: Install Dependencies 40 | run: | 41 | python -m pip install --upgrade pip 42 | pip install -r requirements_test.txt 43 | pip install -U "${{matrix.django-version}}" 44 | ls /opt/hostedtoolcache/Python/ 45 | cp -R /opt/hostedtoolcache/Python/${{ matrix.python-version }}*/x64/lib/python${{ matrix.python-version }}/site-packages/ ${{github.workspace}}/venv/ 46 | - name: Download code climate client 47 | run: | 48 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 49 | chmod +x ./cc-test-reporter 50 | GIT_BRANCH=$GITHUB_REF GIT_COMMIT_SHA=$GITHUB_SHA ./cc-test-reporter before-build 51 | 52 | - name: Run Tests 53 | env: 54 | DATABASE_URL: postgres://postgres:postgres@localhost/postgres 55 | PLPY_ENV_PATHS: /app/venv,/app/src/ 56 | PLPY_PROJECT_PATH: /app 57 | run: | 58 | pip install -e . 59 | pytest --junitxml=test-results/junit.xml --cov-report=xml --cov=. . 60 | - name: Report coverage 61 | uses: paambaati/codeclimate-action@v3.0.0 62 | env: 63 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 64 | if: matrix.python-version == 3.7 && matrix.postgres-version == 13 && matrix.django-version-number == 3 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary and binary files 2 | *~ 3 | *.py[cod] 4 | *.so 5 | *.cfg 6 | !.isort.cfg 7 | !setup.cfg 8 | *.orig 9 | *.log 10 | *.pot 11 | __pycache__/* 12 | .cache/* 13 | .*.swp 14 | */.ipynb_checkpoints/* 15 | .DS_Store 16 | 17 | # Project files 18 | .ropeproject 19 | .project 20 | .pydevproject 21 | .settings 22 | .idea 23 | .vscode 24 | tags 25 | 26 | # Package files 27 | *.egg 28 | *.eggs/ 29 | .installed.cfg 30 | *.egg-info 31 | 32 | # Unittest and coverage 33 | htmlcov/* 34 | .coverage 35 | .coverage.* 36 | .tox 37 | junit*.xml 38 | coverage.xml 39 | .pytest_cache/ 40 | 41 | # Build and docs folder/files 42 | build/* 43 | dist/* 44 | sdist/* 45 | docs/api/* 46 | docs/_rst/* 47 | docs/_build/* 48 | cover/* 49 | MANIFEST 50 | 51 | # Per-project virtualenvs 52 | .venv*/ 53 | .conda*/ 54 | {} 55 | 56 | # Django 57 | /*.sqlite3 58 | 59 | # dotenv 60 | **.env 61 | 62 | # pyenv 63 | .python-version 64 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: .*?/migrations/.*? 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v2.0.0 # Use the ref you want to point at 5 | hooks: 6 | - id: flake8 7 | entry: pflake8 8 | additional_dependencies: [flake8-tidy-imports, flake8-mutable, pyproject-flake8] 9 | args: [--count, --show-source] 10 | - id: trailing-whitespace 11 | - id: end-of-file-fixer 12 | - id: check-merge-conflict 13 | - id: check-yaml 14 | - repo: https://github.com/asottile/pyupgrade 15 | rev: v2.29.1 16 | hooks: 17 | - id: pyupgrade 18 | args: [--py36-plus] 19 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 20 | rev: v2.2.0 21 | hooks: 22 | - id: pretty-format-yaml 23 | args: [--autofix, --indent, '2'] 24 | - repo: https://github.com/ambv/black 25 | rev: 21.11b1 26 | hooks: 27 | - id: black 28 | - repo: https://github.com/PyCQA/pydocstyle 29 | rev: 6.1.1 30 | hooks: 31 | - id: pydocstyle 32 | files: ^src/ 33 | args: 34 | - --ignore=D104, D100, D2, D4 35 | - --count 36 | - repo: https://github.com/hadialqattan/pycln 37 | rev: v1.1.0 38 | hooks: 39 | - id: pycln 40 | args: [--config=pyproject.toml] 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Thorin Schiffer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | Django utilities for Postgres PL/Python 4 | 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/8fe31e70125f34ad5328/maintainability)](https://codeclimate.com/github/eviltnan/django-plpy/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/8fe31e70125f34ad5328/test\_coverage)](https://codeclimate.com/github/eviltnan/django-plpy/test\_coverage) [![test](https://github.com/eviltnan/django-plpy/actions/workflows/test.yml/badge.svg)](https://github.com/eviltnan/django-plpy/actions/workflows/test.yml) [![lint](https://github.com/eviltnan/django-plpy/actions/workflows/lint.yml/badge.svg)](https://github.com/eviltnan/django-plpy/actions/workflows/lint.yml) [![PyPI version](https://badge.fury.io/py/django-plpy.svg)](https://badge.fury.io/py/django-plpy) 6 | 7 | ### What is django-plpy 8 | 9 | PostgreSQL's PL/Python plugin allows you to write stored procedures in Python. Django-plpy provides utilities and commands for using python functions from your project within Django ORM and more. 10 | 11 | ### Requirements 12 | 13 | Django 2.2 or later, Python 3.6 or higher, Postgres 10 or higher. 14 | 15 | #### Installation 16 | 17 | PL/Python, therefore django-plpy requires Postgres plugin for plpython3u. Most of the distributions provide it in their repositories, here is how you install it on Ubuntu: 18 | 19 | ``` 20 | apt-get -y install postgresql-plpython3-10 21 | ``` 22 | 23 | Mind the PostgreSQL version at the end. 24 | 25 | Install django-plpy with pip 26 | 27 | ``` 28 | pip install django-plpy 29 | ``` 30 | 31 | Add it to INSTALLED\_APPS 32 | 33 | ```python 34 | INSTALLED_APPS = [ 35 | ..., 36 | "django_plpy", 37 | ..., 38 | ] 39 | ``` 40 | 41 | Migrate 42 | 43 | ``` 44 | ./manage.py migrate 45 | ``` 46 | 47 | Check if your local python environment is compatible with your Postgres python interpreter. 48 | 49 | ```shell 50 | ./manage.py checkenv 51 | ``` 52 | 53 | Django-plpy is ready to be used. 54 | 55 | ### Features 56 | 57 | #### Installing of python functions 58 | 59 | The main workflow for bringing python functions to the database is to decorate them with `@plpython` and call manage.py command `syncfunctions` to install them. Full annotation is needed for the proper arguments mapping to the corresponding Postgres type function signature. 60 | 61 | Imagine a function like this: 62 | 63 | ```python 64 | from django_plpy.installer import plfunction 65 | 66 | 67 | @plfunction 68 | def pl_max(a: int, b: int) -> int: 69 | if a > b: 70 | return a 71 | return b 72 | ``` 73 | 74 | Finding a maximum of two values. @plfunction decorator registers it for installation, if any function with that name already exists it will be overwritten. Call `syncfunctions` command to install it into the database: 75 | 76 | ``` 77 | ./manage.py syncfunctions 78 | ``` 79 | 80 | #### Python functions in SQL queries 81 | 82 | ```python 83 | from django.db import connection 84 | 85 | with connection.cursor() as cursor: 86 | cursor.execute("select pl_max(10, 20)") 87 | row = cursor.fetchone() 88 | assert row[0] == 20 89 | ``` 90 | 91 | #### Python functions in annotations 92 | 93 | ```python 94 | from django.db.models import F, Func 95 | from tests.books.models import Book 96 | 97 | Book.objects.annotate( 98 | max_value=Func(F("amount_sold"), F("amount_stock"), function="pl_max") 99 | ) 100 | ``` 101 | 102 | #### Using python functions for custom ORM lookups 103 | 104 | ```python 105 | from django_plpy.installer import plfunction 106 | from django.db.models import Transform 107 | from django.db.models import IntegerField 108 | from tests.books.models import Book 109 | 110 | 111 | @plfunction 112 | def plsquare(a: int) -> int: 113 | return a * a 114 | 115 | 116 | class PySquare(Transform): 117 | lookup_name = "plsquare" 118 | function = "plsquare" 119 | 120 | 121 | IntegerField.register_lookup(PySquare) 122 | assert Book.objects.filter(amount_stock__plsquare=400).exists() 123 | ``` 124 | 125 | #### Installing of python triggers 126 | 127 | Triggers are a very mighty mechanism, django-plpy allows you to easily mark a python function as a trigger, so some logic from your project is directly associated with the data changing events in the database. 128 | 129 | Here is an example of a python trigger using the `@pltrigger` decorator. 130 | 131 | ```python 132 | from django_plpy.installer import pltrigger 133 | 134 | 135 | @pltrigger(event="INSERT", when="BEFORE", table="books_book") 136 | def pl_trigger(td, plpy): 137 | # mind triggers don't return anything 138 | td["new"]["name"] = td["new"]["name"] + "test" 139 | td["new"]["amount_sold"] = plpy.execute("SELECT count(*) FROM books_book")[0][ 140 | "count" 141 | ] 142 | ``` 143 | 144 | #### Using Django models in triggers 145 | 146 | The parameters of `@pltrigger` decorator declare the trigger parameters like event the trigger will bind to and table name. You can replace `table_name` with a model name, the table name will looked up automatically: 147 | 148 | ```python 149 | from django_plpy.installer import pltrigger 150 | from django.db.models import Model, CharField, IntegerField 151 | 152 | 153 | class Book(Model): 154 | name = CharField(max_length=10) 155 | amount_stock = IntegerField(default=20) 156 | amount_sold = IntegerField(default=10) 157 | 158 | 159 | @pltrigger(event="INSERT", when="BEFORE", model=Book) 160 | def pl_update_amount(new: Book, old: Book, td, plpy): 161 | # don't use save method here, it will kill the database because of recursion 162 | new.amount_stock += 10 163 | ``` 164 | 165 | Read more about plpy triggers in the official Postgres documentation: https://www.postgresql.org/docs/13/plpython-database.html. 166 | 167 | Using Django models in triggers comes at a price, read about the details of implementation below. 168 | 169 | #### Bulk operations and triggers, migrations 170 | 171 | Python triggers are fully featured Postgres triggers, meaning they will be created for every row, unlike Django signals. So if you define a trigger with event="UPDATE" and call a bulk update on a model, the trigger will be called for all affected by the operation: 172 | 173 | ```python 174 | from django_plpy.installer import pltrigger 175 | from tests.books.models import Book 176 | 177 | 178 | @pltrigger(event="UPDATE", when="BEFORE", model=Book) 179 | def pl_update_amount(new: Book, old: Book, td, plpy): 180 | # don't use save method here, it will kill the database because of recursion 181 | new.amount_stock += 10 182 | ``` 183 | 184 | Update results a trigger call on every line: 185 | 186 | ```python 187 | from tests.books.models import Book 188 | 189 | Book.objects.values('amount_stock') 190 | # 191 | 192 | Book.objects.all().update(name="test") 193 | # 3 194 | 195 | Book.objects.values('amount_stock') 196 | # 197 | ``` 198 | 199 | Unlike the code of Django models or signals, triggers will also be called while migrations. 200 | 201 | #### Turning Django signals to python triggers 202 | 203 | Although Django signals are neither asynchronous nor have any ability to be executed in another thread or process, many developers mistakenly expect them to behave this way. Often it leads to a callback hell and complex execution flow. Django signals implement a dispatcher-receiver pattern and only make an impression of asynchronous execution. 204 | 205 | With django-plpy, you can quickly turn your signals into triggers and make them truly asynchronous. 206 | 207 | Before: 208 | 209 | ```python 210 | from django.dispatch import receiver 211 | from django.db.models.signals import post_save 212 | from django.contrib.auth.models import User 213 | 214 | 215 | @receiver(post_save, sender=User) 216 | def send_mail(sender, instance, **kwargs): 217 | instance.send_mail() 218 | ``` 219 | 220 | After: 221 | 222 | ```python 223 | from django_plpy.installer import pltrigger 224 | from django.contrib.auth.models import User 225 | 226 | 227 | @pltrigger(event="INSERT", when="AFTER", model=User) 228 | def pl_send_mail(new: User, old: User, td, plpy): 229 | new.send_mail() 230 | ``` 231 | 232 | #### Manage.py commands 233 | 234 | `syncfunctions` installs functions and triggers decorated with `@plfunction` and `@pltrigger` to the database. 235 | 236 | ```shell 237 | (venv) thorin@thorin-N141CU:~/PycharmProjects/django-plpy$ ./manage.py syncfunctions 238 | Synced 4 functions and 1 triggers 239 | ``` 240 | 241 | `checkenv` checks if your local python and database's python versions are compatible. 242 | 243 | ```shell 244 | (venv) thorin@thorin-N141CU:~/PycharmProjects/django-plpy$ ./manage.py checkenv 245 | Database's Python version: 3.7.3 246 | Minor versions match, local version: 3.7.12. Django-plpy Django ORM can be used in triggers. 247 | ``` 248 | 249 | If your local python and database's python versions have different minor releases, psycopg won't work, so Django ORM cannot be used in triggers. This is what you will see in this case: 250 | 251 | ```shell 252 | (venv) thorin@thorin-N141CU:~/PycharmProjects/django-plpy$ ./manage.py checkenv 253 | Database's Python version: 3.6.9 254 | Postgres python and this python's versions don't match, local version: 3.7.12.Django-plpy Django ORM cannot be used in triggers. 255 | ``` 256 | 257 | ### Under the hood 258 | 259 | #### Supported argument types 260 | 261 | Currently, supported types are: 262 | 263 | ``` 264 | int: "integer", 265 | str: "varchar", 266 | Dict[str, str]: "JSONB", 267 | List[str]: "varchar[]", 268 | List[int]: "int[]", 269 | bool: "boolean", 270 | float: "real", 271 | ``` 272 | 273 | #### Using Django in PL functions and triggers 274 | 275 | While installing with, `syncfunctions` the source code of the function will be copied to a corresponding stored procedure and installed in Postgres. This makes your local context not available to the functions, which means that no models or libraries can be used within the transferred functions. 276 | 277 | To solve this problem, you need to set up your python project and environment within a Postgres python interpreter. Django-plpy supports the following two scenarios of how you use your database. 278 | 279 | **Database and application are on the same host** 280 | 281 | Rarely used nowadays, but still out there, this scenario is the simplest for the environment sharing. Django-plpy creates stored procedures and transfers the necessary configuration to the database: 282 | 283 | * secrets and database access credentials 284 | * path to the python env (defaults to `distutils.sysconfig.get_python_lib()`, for more config see below) 285 | * loads Django applications the way manage.py does it 286 | 287 | **Database is in a separate docker container** 288 | 289 | A more common production scenario is that the database is on a separate docker container. 290 | 291 | **Couple of words about docker and plpython or django-plpy** 292 | 293 | The official Postgres image doesn't support plpython plugin out of the box, so if you want to use plpython as such you would need to create your image or use one of those provided by the maintainer of this package (thorinschiffer/postgres-plpython). 294 | 295 | All the images provide python 3.7 because Postgres uses the default python environment from the OS the image is based on and 3.7 is the standard for Debian Buster. 296 | 297 | **Using django-plpy with dockerized Postgres** 298 | 299 | To make the code available to the Postgres python interpreter, it has to somehow appear within the docker container. You can either provision the image with it while building if you decided to write your docker image / dockerfile, or you can share the code using volumes. 300 | 301 | Once the code and environment exist somewhere within the Docker container, django-plpy can be told to use them: So if your environment lives under `/env` (copy site-packages folder to this path) and your app within `/app`, add following settings to your `settings.py` 302 | 303 | ```python 304 | PLPY_ENV_PATHS = ["/env"] 305 | PLPY_PROJECT_PATH = "/app" 306 | ``` 307 | 308 | #### AWS RDS and other managed databases 309 | 310 | In the times of Saas there databases are rarely connected in a docker image but much 311 | more frequently in a managed database like AWS RDS. 312 | Django-plpy can be only install simple functions and triggers in such instances, because 313 | there is no access to the database's filesystem in such setup. 314 | 315 | Besides that some managed databases won't give you superuser rights, meaning installing 316 | extensions in such a scenario will be troublesome. 317 | 318 | 319 | #### How the code is installed 320 | 321 | Django-plpy copies the function code, wraps it in a PL/Python stored procedure or trigger 322 | and then installs it with `manage.py syncfunctions`. 323 | If you use Django models, database has to have access to your project file and virtualenv (see above), 324 | or if you create your own database docker image, it has to be provisioned correspondingly. 325 | This scenario seems quite exotic, and you do it on your own risk. 326 | 327 | #### Troubleshooting 328 | 329 | If you see `Error loading psycopg2 module: No module named 'psycopg2._psycopg'`, your local python and db's versions don't match. 330 | Check your python versions with `manage.py checkenv` 331 | 332 | If you see this: 333 | 334 | ``` 335 | django.db.utils.ProgrammingError: language "plpython3u" does not exist 336 | HINT: Use CREATE LANGUAGE to load the language into the database. 337 | ``` 338 | 339 | you haven't migrated: `manage.py migrate` 340 | #### Caveats 341 | 342 | Custom functions and triggers are not a part of Django ORM and can get out of hand, name collisions are not checked explicitely for now. 343 | 344 | For now enabling orm stores the os.environ in json in plaintext in the trigger function text, so this feature is 345 | considered quite experimental. 346 | 347 | There were no real performance tests made, so this is for you to find out. 348 | Currently only python3.7 is supported with Django ORM, as it is a current version in the Debian based python images, 349 | that are default. There is an opportunity to build an image with a custom python verson and postgres version, but 350 | it seems a total overkill assuming all the problems with Django ORM, see above. 351 | 352 | Prepend database functions with plpy_ prefix to be sure they are not executed locally. 353 | Django-plpy won't remove any triggers, you will have to take care of the stale triggers yourself. 354 | 355 | ### Installation for development 356 | 357 | Install project locally: `pip install -e .` 358 | 359 | Django-plpy [django-environ](https://github.com/joke2k/django-environ) for passing the necessary env over dotenv, database for creds in particular. See .env\_template for possible env variables. 360 | 361 | ### Changelog 362 | 363 | #### 0.1.0 Initial release 364 | 365 | - pl functions 366 | - pl triggers 367 | -------------------------------------------------------------------------------- /docker/Postgres10.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:10-buster 2 | 3 | RUN apt-get update && apt-get -y install postgresql-plpython3-10 4 | 5 | RUN apt-get clean && \ 6 | rm -rf /var/cache/apt/* /var/lib/apt/lists/* 7 | 8 | ENTRYPOINT ["/docker-entrypoint.sh"] 9 | 10 | EXPOSE 5432 11 | CMD ["postgres"] 12 | -------------------------------------------------------------------------------- /docker/Postgres11.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:11-buster 2 | 3 | RUN apt-get update && apt-get -y install postgresql-plpython3-11 4 | 5 | RUN apt-get clean && \ 6 | rm -rf /var/cache/apt/* /var/lib/apt/lists/* 7 | 8 | ENTRYPOINT ["docker-entrypoint.sh"] 9 | 10 | EXPOSE 5432 11 | CMD ["postgres"] 12 | -------------------------------------------------------------------------------- /docker/Postgres12.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:12 2 | 3 | RUN apt-get update && apt-get -y install postgresql-plpython3-12 4 | 5 | RUN apt-get clean && \ 6 | rm -rf /var/cache/apt/* /var/lib/apt/lists/* 7 | 8 | ENTRYPOINT ["docker-entrypoint.sh"] 9 | 10 | EXPOSE 5432 11 | CMD ["postgres"] 12 | -------------------------------------------------------------------------------- /docker/Postgres13.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:13 2 | 3 | RUN apt-get update && apt-get -y install postgresql-plpython3-13 4 | 5 | RUN apt-get clean && \ 6 | rm -rf /var/cache/apt/* /var/lib/apt/lists/* 7 | 8 | ENTRYPOINT ["docker-entrypoint.sh"] 9 | 10 | EXPOSE 5432 11 | CMD ["postgres"] 12 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | postgres: 4 | image: thorinschiffer/postgres-plpython:13 5 | volumes: 6 | - app:/app 7 | - python_env:/env 8 | ports: 9 | - 8432:5432 10 | environment: 11 | - POSTGRES_DB=postgres 12 | - POSTGRES_USER=postgres 13 | - POSTGRES_PASSWORD=postgres 14 | volumes: 15 | app: 16 | driver: local 17 | driver_opts: 18 | type: none 19 | o: bind 20 | device: ../ 21 | python_env: 22 | driver: local 23 | driver_opts: 24 | type: none 25 | o: bind 26 | device: ../venv/lib/python3.7/site-packages 27 | -------------------------------------------------------------------------------- /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", "tests.testapp.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | # AVOID CHANGING REQUIRES: IT WILL BE UPDATED BY PYSCAFFOLD! 3 | requires = ["setuptools>=46.1.0", "setuptools_scm[toml]>=5", "wheel"] 4 | build-backend = "setuptools.build_meta" 5 | 6 | [tool.setuptools_scm] 7 | # See configuration details in https://github.com/pypa/setuptools_scm 8 | version_scheme = "post-release" 9 | local_scheme= "no-local-version" 10 | 11 | [tool.pycln] 12 | all = true 13 | 14 | [tool.flake8] 15 | exclude = ".git,__pycache__,docs/source/conf.py,old,build,dist,*/migrations/*,*/settings/*,*.wsgi" 16 | max-complexity = 10 17 | max-line-length = 120 18 | 19 | [tool.coverage.paths] 20 | source = ["src", "*/site-packages"] 21 | 22 | [tool.coverage.run] 23 | branch = true 24 | source = ["django_plpy"] 25 | omit = ["*/tests/*", "*/migrations/*", "*/urls.py", "*/settings.py", "*/asgi.py", "manage.py", "setup.py"] 26 | 27 | [tool.coverage.report] 28 | # Regexes for lines to exclude from consideration 29 | exclude_lines = [ 30 | "pragma: no cover", 31 | "def __repr__", 32 | "raise AssertionError", 33 | "raise NotImplementedError", 34 | "if 0:", 35 | "if __name__ == .__main__.:", 36 | ] 37 | skip_covered = true 38 | sort = "Miss" 39 | ignore_errors = true 40 | skip_empty = true 41 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.testapp.settings 3 | python_files = tests.py test_*.py *_tests.py 4 | env = 5 | SECRET_KEY=1234567 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django 2 | psycopg2-binary 3 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements_test.txt 2 | ipython 3 | -------------------------------------------------------------------------------- /requirements_lint.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pre-commit 3 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pytest-cov 3 | pytest-django 4 | pytest-env 5 | pytest-randomly 6 | django-environ 7 | tox 8 | tox-pyenv 9 | django-extensions 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # This file is used to configure your project. 2 | # Read more about the various options under: 3 | # http://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files 4 | 5 | [metadata] 6 | name = django-plpy 7 | description = Django utilities for Postgres PL/Python. 8 | author = Thorin Schiffer 9 | author_email = thorin@schiffer.pro 10 | license = MIT 11 | long_description = file: README.md 12 | long_description_content_type = text/markdown; charset=UTF-8; variant=GFM 13 | url = https://github.com/eviltnan/django-plpy 14 | # Add here related links, for example: 15 | project_urls = 16 | Documentation = https://github.com/eviltnan/django-plpy 17 | Source = https://github.com/eviltnan/django-plpy 18 | Twitter = https://twitter.com/SchifferThorin 19 | 20 | # Change if running only on Windows, Mac or Linux (comma-separated) 21 | platforms = any 22 | 23 | # Add here all kinds of additional classifiers as defined under 24 | # https://pypi.python.org/pypi?%3Aaction=list_classifiers 25 | classifiers = 26 | Development Status :: 5 - Production/Stable 27 | Programming Language :: Python 28 | Framework :: Django :: 2.0 29 | Framework :: Django :: 3.0 30 | 31 | 32 | [options] 33 | zip_safe = False 34 | packages = find_namespace: 35 | include_package_data = True 36 | package_dir = 37 | =src 38 | 39 | # Require a min/specific Python version (comma-separated conditions) 40 | # python_requires = >=3.8 41 | 42 | # Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0. 43 | # Version specifiers like >=2.2,<3.0 avoid problems due to API changes in 44 | # new major versions. This works if the required packages follow Semantic Versioning. 45 | # For more information, check out https://semver.org/. 46 | install_requires = 47 | importlib-metadata; python_version>"3.6" 48 | django 49 | psycopg2-binary 50 | 51 | [options.packages.find] 52 | where = src 53 | exclude = 54 | tests 55 | 56 | [options.extras_require] 57 | # Add here additional requirements for extra features, to install with: 58 | # `pip install django-plpy[PDF]` like: 59 | # PDF = ReportLab; RXP 60 | 61 | # Add here test requirements (semicolon/line-separated) 62 | testing = 63 | setuptools 64 | pytest 65 | pytest-cov 66 | django-environ 67 | tox 68 | django-extensions 69 | 70 | [options.entry_points] 71 | # Add here console scripts like: 72 | # console_scripts = 73 | # script_name = django_plpy.module:function 74 | # For example: 75 | # console_scripts = 76 | # fibonacci = django_plpy.skeleton:run 77 | # And any other entry points, for example: 78 | # pyscaffold.cli = 79 | # awesome = pyscaffoldext.awesome.extension:AwesomeExtension 80 | 81 | [tool:pytest] 82 | # Specify command line options as you would do when invoking pytest directly. 83 | # e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml 84 | # in order to write a coverage file that can be read by Jenkins. 85 | # CAUTION: --cov flags may prohibit setting breakpoints while debugging. 86 | # Comment those flags to avoid this py.test issue. 87 | addopts = 88 | --cov django_plpy --cov-report term-missing 89 | --verbose 90 | norecursedirs = 91 | dist 92 | build 93 | .tox 94 | testpaths = tests 95 | # Use pytest markers to select/deselect specific tests 96 | # markers = 97 | # slow: mark tests as slow (deselect with '-m "not slow"') 98 | # system: mark end-to-end system tests 99 | 100 | [bdist_wheel] 101 | # Use this option if your package is pure-python 102 | universal = 1 103 | 104 | [devpi:upload] 105 | # Options for the devpi: PyPI server and packaging tool 106 | # VCS export must be deactivated since we are using setuptools-scm 107 | no_vcs = 1 108 | formats = bdist_wheel 109 | 110 | [flake8] 111 | # Some sane defaults for the code style checker flake8 112 | max_line_length = 12 113 | extend_ignore = E203, W503 114 | # ^ Black-compatible 115 | # E203 and W503 have edge cases handled by black 116 | exclude = 117 | .tox 118 | build 119 | dist 120 | .eggs 121 | docs/conf.py 122 | 123 | [pyscaffold] 124 | # PyScaffold's parameters when the project was created. 125 | # This will be used when updating. Do not change! 126 | version = 4.0.2 127 | package = django_plpy 128 | extensions = 129 | markdown 130 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup file for django-plpy. 3 | Use setup.cfg to configure your project. 4 | 5 | This file was generated with PyScaffold 4.0.2. 6 | PyScaffold helps you to put up the scaffold of your new Python project. 7 | Learn more under: https://pyscaffold.org/ 8 | """ 9 | from setuptools import setup 10 | 11 | if __name__ == "__main__": 12 | try: 13 | setup() 14 | except: # noqa 15 | print( 16 | "\n\nAn error occurred while building the project, " 17 | "please ensure you have the most updated version of setuptools, " 18 | "setuptools_scm and wheel with:\n" 19 | " pip install -U setuptools setuptools_scm wheel\n\n" 20 | ) 21 | raise 22 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "Thorin Schiffer" 2 | -------------------------------------------------------------------------------- /src/django_plpy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorin-schiffer/django-plpy/2d63c37ab1a3d74a1d40dfc546104bd66452c7f6/src/django_plpy/__init__.py -------------------------------------------------------------------------------- /src/django_plpy/builder.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from textwrap import dedent 3 | from typing import Dict, List 4 | import json 5 | from django_plpy.settings import ENV_PATHS, PROJECT_PATH 6 | from django_plpy.utils import remove_decorator 7 | from django.conf import settings 8 | 9 | type_mapper = { 10 | int: "integer", 11 | str: "varchar", 12 | inspect._empty: "void", 13 | Dict[str, str]: "JSONB", 14 | List[str]: "varchar[]", 15 | List[int]: "int[]", 16 | bool: "boolean", 17 | float: "real", 18 | } 19 | 20 | 21 | def build_pl_function(f, global_=False) -> str: 22 | """ 23 | Builds the source code of the plpy stored procedure from the local python code. 24 | The function code gets copied and installed to the database. 25 | Use syncfunctions manage.py command to install the functions to the database 26 | @param f: function / callable the code of will be rendered to the plpy stored procedure 27 | @return: the code of the stored procedure 28 | """ 29 | name = f.__name__ 30 | signature = inspect.signature(f) 31 | pl_args = [] 32 | python_args = [] 33 | for arg, specs in signature.parameters.items(): 34 | if specs.annotation is inspect._empty: 35 | raise RuntimeError( 36 | f"Function {f} must be fully annotated to be translated to pl/python" 37 | ) 38 | if specs.annotation not in type_mapper: 39 | raise RuntimeError(f"Unknown type {specs.annotation}") 40 | pl_args.append(f"{arg} {type_mapper[specs.annotation]}") 41 | if specs.annotation == Dict[str, str]: 42 | python_args.append(f"json.loads({arg})") 43 | else: 44 | python_args.append(arg) 45 | 46 | header = ( 47 | f"CREATE OR REPLACE FUNCTION {name} ({','.join(pl_args)}) " 48 | f"RETURNS {type_mapper[signature.return_annotation]}" 49 | ) 50 | 51 | body = remove_decorator(inspect.getsource(f), "plfunction") 52 | return f"""{header} 53 | AS $$ 54 | from typing import Dict, List 55 | import json 56 | {dedent(body)} 57 | return {name}({','.join(python_args)}) 58 | {"GD['{name}'] = {name}" if global_ else ""} 59 | $$ LANGUAGE plpython3u 60 | """ 61 | 62 | 63 | def build_pl_trigger_function( 64 | f, event, when, table=None, model=None, extra_env=None 65 | ) -> str: 66 | """ 67 | Builds source code of the trigger function from the python function f. 68 | The source code will be copied to the trigger function and installed in the database. 69 | Use syncfunctions manage.py command to install the function within the database. 70 | Read more about plpy trigger functions here https://www.postgresql.org/docs/13/plpython-trigger.html 71 | @param f: function/callable 72 | @param event: contains the event as a string: INSERT, UPDATE, DELETE, or TRUNCATE 73 | @param when: contains one of BEFORE, AFTER, or INSTEAD OF 74 | @param table: table name the trigger will be installed on, incompatible with model argument 75 | @param model: django model name the trigger is to be associated with, incompatible with tabel argument 76 | @param extra_env: extra environment to be passed to the pl_enable_orm function, will be dumped plaintext in the 77 | text of the function! 78 | @return: source code of the trigger function 79 | """ 80 | extra_env = extra_env or {} 81 | if not table and not model: 82 | raise RuntimeError("Either model or table must be set for trigger installation") 83 | 84 | name = f.__name__ 85 | if model: 86 | meta = model.objects.model._meta 87 | table = meta.db_table 88 | model_name = meta.object_name 89 | app_name = meta.app_label 90 | import_statement = f""" 91 | extra_env = '{json.dumps(extra_env)}' 92 | plpy.execute( 93 | "select pl_enable_orm(array{ENV_PATHS}, '{PROJECT_PATH}', '{settings.SETTINGS_MODULE}', '%s')" % extra_env 94 | ) 95 | from django.apps import apps 96 | from django.forms.models import model_to_dict 97 | 98 | {model_name} = apps.get_model('{app_name}', '{model_name}') 99 | new = {model_name}(**TD['new']) 100 | old = {model_name}(**TD['old']) if TD['old'] else None 101 | """ 102 | call_statement = f"{name}(new, old, TD, plpy)" 103 | back_convert_statement = """ 104 | TD['new'].update(model_to_dict(new)) 105 | if TD['old']: 106 | TD['old'].update(model_to_dict(old)) 107 | """ 108 | else: 109 | import_statement = back_convert_statement = "" 110 | call_statement = f"{name}(TD, plpy)" 111 | 112 | header = f"CREATE OR REPLACE FUNCTION {name}() RETURNS TRIGGER" 113 | 114 | body = remove_decorator(inspect.getsource(f), "pltrigger") 115 | return f""" 116 | BEGIN; 117 | {header} 118 | AS $$ 119 | {import_statement} 120 | {dedent(body)} 121 | {call_statement} 122 | {back_convert_statement} 123 | return 'MODIFY' 124 | $$ LANGUAGE plpython3u; 125 | 126 | DROP TRIGGER IF EXISTS {name + '_trigger'} ON {table} CASCADE; 127 | CREATE TRIGGER {name + '_trigger'} 128 | {when} {event} ON {table} 129 | FOR EACH ROW 130 | EXECUTE PROCEDURE {name}(); 131 | END; 132 | """ 133 | -------------------------------------------------------------------------------- /src/django_plpy/installer.py: -------------------------------------------------------------------------------- 1 | __author__ = "Thorin Schiffer" 2 | 3 | import inspect 4 | from functools import wraps 5 | from typing import Dict, List 6 | 7 | from django.db import connection 8 | from django_plpy.builder import build_pl_trigger_function, build_pl_function 9 | 10 | 11 | def install_function(f, trigger_params=None, function_params=None, cursor=None): 12 | """ 13 | Installs function f as a trigger or stored procedure to the database. Must have a proper signature: 14 | - td, plpy for trigger without django ORM 15 | - new: Model, old: Model, td, plpy for trigger with django ORM 16 | Stored procedure arguments must be type annotated for proper type mapping to PL/SQL built in types. 17 | Read more about td https://www.postgresql.org/docs/13/plpython-trigger.html 18 | and plpy https://www.postgresql.org/docs/13/plpython-database.html objects 19 | @param f: function/callable to install as 20 | @param trigger_params: dict with params as accepted by build_pl_trigger_function 21 | """ 22 | trigger_params = trigger_params or {} 23 | function_params = function_params or {} 24 | pl_python_function = ( 25 | build_pl_trigger_function(f, **trigger_params) 26 | if trigger_params 27 | else build_pl_function(f, **function_params) 28 | ) 29 | if not cursor: 30 | with connection.cursor() as cursor: 31 | cursor.execute(pl_python_function) 32 | else: 33 | cursor.execute(pl_python_function) 34 | 35 | 36 | pl_functions = {} 37 | pl_triggers = {} 38 | 39 | 40 | def plfunction(*args, **parameters): 41 | """ 42 | Decorator marking a function for installation with manage.py syncfunctions as a stored procedure 43 | @param parameters: parameters. global_ - makes the function available to other plpy functons over GD dict 44 | @return: wrapped registered function 45 | """ 46 | 47 | def _plfunction(f): 48 | @wraps(f) 49 | def installed_func(*args, **kwargs): 50 | return f(*args, **kwargs) 51 | 52 | module = inspect.getmodule(installed_func) 53 | pl_functions[f"{module.__name__}.{installed_func.__qualname__}"] = ( 54 | installed_func, 55 | parameters, 56 | ) 57 | return installed_func 58 | 59 | return _plfunction(args[0]) if args and callable(args[0]) else _plfunction 60 | 61 | 62 | def pltrigger(**trigger_parameters): 63 | """ 64 | Decorator marking a function for installation with manage.py syncfunctions as a trigger function, see 65 | build_pl_trigger_function for parameters 66 | @param trigger_parameters: params of the trigger 67 | @return: wrapped registered function 68 | """ 69 | 70 | def _pl_trigger(f): 71 | @wraps(f) 72 | def installed_func(*args, **kwargs): 73 | return f(*args, **kwargs) 74 | 75 | module = inspect.getmodule(installed_func) 76 | pl_triggers[f"{module.__name__}.{installed_func.__qualname__}"] = ( 77 | installed_func, 78 | trigger_parameters, 79 | ) 80 | return installed_func 81 | 82 | return _pl_trigger 83 | 84 | 85 | @plfunction 86 | def pl_load_path(path: str): # pragma: no cover 87 | """ 88 | Loads function path on the file system to database interpreter 89 | @param path: path on the database's filesystem 90 | """ 91 | import sys 92 | 93 | sys.path.append(path) 94 | 95 | 96 | # this code is only run in the database interpreter, that's why coverage doesn't see it 97 | @plfunction 98 | def pl_load_django( 99 | project_dir: str, django_settings_module: str, extra_env: Dict[str, str] 100 | ): # pragma: no cover 101 | """ 102 | Stored procedure to configure django application in the context of the database interpreter. 103 | @param project_dir: project path 104 | @param django_settings_module: name of the django settings module to use 105 | @param extra_env: extra environment to pass to the database interpreter, like secrets 106 | """ 107 | import os 108 | import sys 109 | 110 | os.environ.update(**extra_env) 111 | from django.core.wsgi import get_wsgi_application 112 | 113 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", django_settings_module) 114 | sys.path.append(project_dir) 115 | get_wsgi_application() 116 | 117 | 118 | @plfunction(global_=True) 119 | def pl_enable_orm( 120 | env_paths: List[str], 121 | project_path: str, 122 | setting_module: str, 123 | extra_env: Dict[str, str], 124 | ): 125 | """ 126 | Loads django to the database interpreter. 127 | @param env_paths: paths to the python library 128 | @param project_dir: project path 129 | @param django_settings_module: name of the django settings module to use 130 | @param extra_env: extra environment to pass to the database interpreter, like secrets 131 | """ 132 | import sys 133 | import os 134 | 135 | extra_env = extra_env or {} 136 | os.environ.update(**extra_env) 137 | for path in env_paths: 138 | sys.path.append(path) 139 | 140 | from django.core.wsgi import get_wsgi_application 141 | 142 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", setting_module) 143 | sys.path.append(project_path) 144 | get_wsgi_application() 145 | 146 | 147 | def sync_functions(): 148 | """ 149 | Installs functions decorated with @plfunction and @pltrigger to the database 150 | """ 151 | for function_name, f in pl_functions.items(): 152 | install_function(f[0], function_params=f[1]) 153 | 154 | for function_name, f in pl_triggers.items(): 155 | install_function(f[0], trigger_params=f[1]) 156 | 157 | 158 | @plfunction 159 | def pl_python_version() -> str: # pragma: no cover 160 | """ 161 | Stored procedure that returns databases python interpreter version 162 | @return: semantic python version X.X.X 163 | """ 164 | from platform import python_version 165 | 166 | return python_version() 167 | 168 | 169 | def get_python_info(): 170 | """ 171 | Return database python info as a dict 172 | @return: dict with python information 173 | """ 174 | install_function(pl_python_version) 175 | with connection.cursor() as cursor: 176 | cursor.execute("select pl_python_version()") 177 | info = {"version": cursor.fetchone()[0]} 178 | return info 179 | -------------------------------------------------------------------------------- /src/django_plpy/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorin-schiffer/django-plpy/2d63c37ab1a3d74a1d40dfc546104bd66452c7f6/src/django_plpy/management/__init__.py -------------------------------------------------------------------------------- /src/django_plpy/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorin-schiffer/django-plpy/2d63c37ab1a3d74a1d40dfc546104bd66452c7f6/src/django_plpy/management/commands/__init__.py -------------------------------------------------------------------------------- /src/django_plpy/management/commands/checkenv.py: -------------------------------------------------------------------------------- 1 | from platform import python_version 2 | 3 | from django.core.management.base import BaseCommand 4 | from django.db import transaction 5 | 6 | from django_plpy.utils import sem_to_minor 7 | from django_plpy.installer import get_python_info 8 | 9 | 10 | class Command(BaseCommand): 11 | """ 12 | Command class for checking if the python versions within the database and the local interpreter 13 | """ 14 | 15 | help = "Checks python information within the plpython" 16 | 17 | @transaction.atomic 18 | def handle(self, *args, **options): 19 | """ 20 | Handles the task execution 21 | @param args: args of the command 22 | @param options: options of the command 23 | """ 24 | info = get_python_info() 25 | self.stdout.write(f"Database's Python version: {info['version']}") 26 | 27 | if sem_to_minor(info["version"]) != sem_to_minor(python_version()): 28 | self.stderr.write( 29 | f"Postgres python and this python's versions don't match, local version: {python_version()}." 30 | f"Django-plpy Django ORM cannot be used in triggers." 31 | ) 32 | elif info["version"] != python_version(): 33 | self.stdout.write( 34 | f"Minor versions match, local version: {python_version()}. " 35 | f"Django-plpy Django ORM can be used in triggers." 36 | ) 37 | else: 38 | self.stdout.write(f"Full version match: {python_version()}") 39 | -------------------------------------------------------------------------------- /src/django_plpy/management/commands/syncfunctions.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.db import transaction 3 | 4 | from django_plpy.installer import ( 5 | pl_functions, 6 | pl_triggers, 7 | ) 8 | from django_plpy.installer import sync_functions 9 | 10 | 11 | class Command(BaseCommand): 12 | """ 13 | Command class for installing or overwriting the plpy functions to the database 14 | """ 15 | 16 | help = "Syncs PL/Python functions, decorated with @plfunction and @pltrigger" 17 | 18 | @transaction.atomic 19 | def handle(self, *args, **options): 20 | """ 21 | Handles the task execution 22 | @param args: args of the command 23 | @param options: options of the command 24 | """ 25 | if not pl_functions and not pl_triggers: 26 | self.stdout.write("No PL/Python functions found") 27 | 28 | sync_functions() 29 | self.stdout.write( 30 | f"Synced {len(pl_functions)} functions and {len(pl_triggers)} triggers" 31 | ) 32 | -------------------------------------------------------------------------------- /src/django_plpy/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.2 on 2021-05-07 11:18 2 | 3 | from django.contrib.postgres.operations import CreateExtension 4 | from django.db import migrations 5 | 6 | 7 | class PythonExtension(CreateExtension): 8 | def __init__(self): 9 | self.name = 'plpython3u' 10 | 11 | 12 | class Migration(migrations.Migration): 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | PythonExtension() 18 | ] 19 | -------------------------------------------------------------------------------- /src/django_plpy/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorin-schiffer/django-plpy/2d63c37ab1a3d74a1d40dfc546104bd66452c7f6/src/django_plpy/migrations/__init__.py -------------------------------------------------------------------------------- /src/django_plpy/settings.py: -------------------------------------------------------------------------------- 1 | try: 2 | from distutils.sysconfig import get_python_lib 3 | 4 | default_env_paths = [get_python_lib()] 5 | except ImportError: 6 | default_env_paths = [] 7 | 8 | from django.conf import settings 9 | 10 | # python environment accessible for the database, within the docker container 11 | # if postgres runs within a docker container 12 | # defaults to the local python lib for the case of no containers when all runs 13 | # on the same machine 14 | ENV_PATHS = getattr(settings, "PLPY_ENV_PATHS", None) or default_env_paths 15 | PROJECT_PATH = getattr(settings, "PLPY_PROJECT_PATH", None) or settings.BASE_DIR.parent 16 | -------------------------------------------------------------------------------- /src/django_plpy/utils.py: -------------------------------------------------------------------------------- 1 | __author__ = "Thorin Schiffer" 2 | 3 | 4 | def remove_decorator(source_code, name): 5 | """ 6 | Removes decorator with the name from the source code 7 | 8 | @param source_code: code of the function as returned by inspect module 9 | @param name: name of the decorator to remove 10 | @return: source code of the function without the decorator statement 11 | """ 12 | start = source_code.find(f"@{name}") 13 | end = source_code.find("def") 14 | if start < 0: 15 | return source_code 16 | return source_code[:start] + source_code[end:] 17 | 18 | 19 | def sem_to_minor(version): 20 | """ 21 | Returns a minor release part of the semantic version 22 | @param version: semantic version in format x.x.x 23 | @return: minor release in format x.x 24 | """ 25 | return ".".join(version.split(".")[:2]) 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorin-schiffer/django-plpy/2d63c37ab1a3d74a1d40dfc546104bd66452c7f6/tests/__init__.py -------------------------------------------------------------------------------- /tests/books/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorin-schiffer/django-plpy/2d63c37ab1a3d74a1d40dfc546104bd66452c7f6/tests/books/__init__.py -------------------------------------------------------------------------------- /tests/books/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import ModelAdmin 2 | from django.contrib import admin 3 | 4 | from tests.books.models import Book 5 | 6 | 7 | @admin.register(Book) 8 | class BookAdmin(ModelAdmin): 9 | list_display = ("name", "amount_sold", "amount_stock", "stock_days_left") 10 | -------------------------------------------------------------------------------- /tests/books/apps.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorin-schiffer/django-plpy/2d63c37ab1a3d74a1d40dfc546104bd66452c7f6/tests/books/apps.py -------------------------------------------------------------------------------- /tests/books/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-13 13:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Book', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('name', models.CharField(max_length=10)), 19 | ('amount_stock', models.IntegerField(default=20)), 20 | ('amount_sold', models.IntegerField(default=10)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tests/books/migrations/0002_alter_book_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-11-21 12:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='book', 15 | name='name', 16 | field=models.CharField(max_length=100), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tests/books/migrations/0003_book_stock_days_left.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-11-21 14:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0002_alter_book_name'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='book', 15 | name='stock_days_left', 16 | field=models.IntegerField(null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tests/books/migrations/0004_alter_book_stock_days_left.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-12-02 13:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0003_book_stock_days_left'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='book', 15 | name='stock_days_left', 16 | field=models.IntegerField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tests/books/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorin-schiffer/django-plpy/2d63c37ab1a3d74a1d40dfc546104bd66452c7f6/tests/books/migrations/__init__.py -------------------------------------------------------------------------------- /tests/books/models.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Model, CharField, IntegerField, Func, F 2 | 3 | from django_plpy.installer import plfunction, pltrigger 4 | 5 | 6 | @plfunction 7 | def pl_max(a: int, b: int) -> int: 8 | if a > b: 9 | return a 10 | return b 11 | 12 | 13 | class Book(Model): 14 | name = CharField(max_length=100) 15 | amount_stock = IntegerField(default=20) 16 | amount_sold = IntegerField(default=10) 17 | stock_days_left = IntegerField(null=True, blank=True) 18 | 19 | def get_max(self): 20 | return ( 21 | Book.objects.annotate( 22 | max_value=Func(F("amount_sold"), F("amount_stock"), function="pl_max") 23 | ) 24 | .get(pk=self.pk) 25 | .max_value 26 | ) 27 | 28 | 29 | @pltrigger(event="UPDATE", when="BEFORE", model=Book) 30 | def pl_update_amount(new: Book, old: Book, td, plpy): 31 | # don't use save method here, it will kill the database because of recursion 32 | new.amount_stock += 10 33 | 34 | 35 | @pltrigger(event="UPDATE", when="BEFORE", model=Book) 36 | def stock_days_left(new: Book, old: Book, td, plpy): 37 | # don't use save method here, it will kill the database because of recursion 38 | new.stock_days_left = int(new.amount_stock / new.amount_sold) 39 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dummy conftest.py for django_plpy. 3 | 4 | If you don't know what this is for, just leave it empty. 5 | Read more about conftest.py under: 6 | - https://docs.pytest.org/en/stable/fixture.html 7 | - https://docs.pytest.org/en/stable/writing_plugins.html 8 | """ 9 | 10 | # import pytest 11 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorin-schiffer/django-plpy/2d63c37ab1a3d74a1d40dfc546104bd66452c7f6/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for testapp project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.testapp.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /tests/testapp/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testapp project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | import environ 15 | import os 16 | 17 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 18 | BASE_DIR = Path(__file__).resolve().parent.parent 19 | env = environ.Env() 20 | environ.Env.read_env(os.path.join(os.path.dirname(BASE_DIR), ".env")) 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = env.str("SECRET_KEY") 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = True 30 | 31 | ALLOWED_HOSTS = [] 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = [ 36 | "django.contrib.admin", 37 | "django.contrib.auth", 38 | "django.contrib.contenttypes", 39 | "django.contrib.sessions", 40 | "django.contrib.messages", 41 | "django.contrib.staticfiles", 42 | "django_extensions", 43 | "tests.books", 44 | "django_plpy", 45 | ] 46 | 47 | MIDDLEWARE = [ 48 | "django.middleware.security.SecurityMiddleware", 49 | "django.contrib.sessions.middleware.SessionMiddleware", 50 | "django.middleware.common.CommonMiddleware", 51 | "django.middleware.csrf.CsrfViewMiddleware", 52 | "django.contrib.auth.middleware.AuthenticationMiddleware", 53 | "django.contrib.messages.middleware.MessageMiddleware", 54 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 55 | ] 56 | 57 | ROOT_URLCONF = "tests.testapp.urls" 58 | 59 | TEMPLATES = [ 60 | { 61 | "BACKEND": "django.template.backends.django.DjangoTemplates", 62 | "DIRS": [], 63 | "APP_DIRS": True, 64 | "OPTIONS": { 65 | "context_processors": [ 66 | "django.template.context_processors.debug", 67 | "django.template.context_processors.request", 68 | "django.contrib.auth.context_processors.auth", 69 | "django.contrib.messages.context_processors.messages", 70 | ], 71 | }, 72 | }, 73 | ] 74 | 75 | WSGI_APPLICATION = "tests.testapp.wsgi.application" 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 79 | 80 | DATABASES = { 81 | "default": env.db(), 82 | } 83 | 84 | # Password validation 85 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 86 | 87 | AUTH_PASSWORD_VALIDATORS = [ 88 | { 89 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 90 | }, 91 | { 92 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 93 | }, 94 | { 95 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 96 | }, 97 | { 98 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 99 | }, 100 | ] 101 | 102 | # Internationalization 103 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 104 | 105 | LANGUAGE_CODE = "en-us" 106 | 107 | TIME_ZONE = "UTC" 108 | 109 | USE_I18N = True 110 | 111 | USE_L10N = True 112 | 113 | USE_TZ = True 114 | 115 | # Static files (CSS, JavaScript, Images) 116 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 117 | 118 | STATIC_URL = "/static/" 119 | 120 | # Default primary key field type 121 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 122 | 123 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 124 | PLPY_ENV_PATHS = env.list("PLPY_ENV_PATHS", default=[]) 125 | PLPY_PROJECT_PATH = env.str("PLPY_PROJECT_PATH", default=None) 126 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | """testapp URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path("admin/", admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /tests/testapp/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testapp project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.testapp.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | from platform import python_version 3 | from typing import List 4 | 5 | from django.core.management import call_command 6 | from django.db import connection 7 | from django.db.models import Func, F, Transform 8 | from django.db.models import IntegerField 9 | from django_plpy.builder import ( 10 | build_pl_function, 11 | build_pl_trigger_function, 12 | ) 13 | from django_plpy.installer import ( 14 | install_function, 15 | plfunction, 16 | pltrigger, 17 | get_python_info, 18 | pl_triggers, 19 | pl_functions, 20 | ) 21 | from django_plpy.utils import sem_to_minor 22 | from pytest import fixture, mark, skip, raises 23 | 24 | from tests.books.models import Book 25 | 26 | 27 | @fixture 28 | def book(db): 29 | return Book.objects.create(name="book") 30 | 31 | 32 | @fixture 33 | def pl_simple_function(): 34 | return """ 35 | CREATE FUNCTION pl_max (a integer, b integer) 36 | RETURNS integer 37 | AS $$ 38 | if a > b: 39 | return a 40 | return b 41 | $$ LANGUAGE plpython3u; 42 | """ 43 | 44 | 45 | @fixture(autouse=True) 46 | def clean_triggers_and_functions(db): 47 | yield 48 | with connection.cursor() as cursor: 49 | cursor.execute( 50 | "DROP TRIGGER IF EXISTS pl_trigger_trigger ON books_book CASCADE;" 51 | ) 52 | cursor.execute("DROP FUNCTION IF EXISTS pl_max;") 53 | 54 | 55 | def test_simple_function(pl_simple_function, db): 56 | with connection.cursor() as cursor: 57 | cursor.execute(pl_simple_function) 58 | cursor.execute("select pl_max(10, 20)") 59 | row = cursor.fetchone() 60 | assert row[0] == 20 61 | 62 | 63 | def pl_max(a: int, b: int) -> int: 64 | if a > b: 65 | return a 66 | return b 67 | 68 | 69 | def test_generate_simple_pl_python_from_function(db): 70 | pl_python_function = build_pl_function(pl_max) 71 | with connection.cursor() as cursor: 72 | cursor.execute(pl_python_function) 73 | cursor.execute("select pl_max(10, 20)") 74 | row = cursor.fetchone() 75 | assert row[0] == 20 76 | 77 | 78 | @fixture 79 | def simple_function(db): 80 | install_function(pl_max) 81 | 82 | 83 | def test_call_simple_function_from_django_orm(simple_function, book): 84 | result = Book.objects.annotate( 85 | max_value=Func(F("amount_sold"), F("amount_stock"), function="pl_max") 86 | ) 87 | assert result[0].max_value == result[0].amount_stock 88 | 89 | 90 | def test_custom_lookup_with_function(simple_function, book): 91 | def plsquare(a: int) -> int: 92 | return a * a 93 | 94 | install_function(plsquare) 95 | 96 | class PySquare(Transform): 97 | lookup_name = "plsquare" 98 | function = "plsquare" 99 | 100 | IntegerField.register_lookup(PySquare) 101 | assert Book.objects.filter(amount_stock__plsquare=400).exists() 102 | # of course also mixes with other lookups 103 | assert Book.objects.filter(amount_stock__plsquare__gt=10).exists() 104 | 105 | 106 | def test_plfunction_decorator_registers(): 107 | @plfunction 108 | def pl_max(a: int, b: int) -> int: 109 | if a > b: 110 | return a 111 | return b 112 | 113 | assert pl_max, {} in pl_functions.values() 114 | 115 | 116 | def test_generate_trigger_function(db): 117 | def pl_trigger(td, plpy): 118 | # mind triggers don't return anything 119 | td["new"]["name"] = td["new"]["name"] + "test" 120 | td["new"]["amount_sold"] = plpy.execute("SELECT count(*) FROM books_book")[0][ 121 | "count" 122 | ] 123 | 124 | pl_python_trigger_function = build_pl_trigger_function( 125 | pl_trigger, event="INSERT", when="BEFORE", table="books_book" 126 | ) 127 | with connection.cursor() as cursor: 128 | cursor.execute(pl_python_trigger_function) 129 | book = Book.objects.create(name="book", amount_sold=1) 130 | book.refresh_from_db() 131 | assert book.name == "booktest" 132 | assert book.amount_sold == 0 133 | 134 | with connection.cursor() as cursor: 135 | cursor.execute( 136 | "DROP TRIGGER IF EXISTS pl_trigger_trigger ON books_book CASCADE;" 137 | ) 138 | 139 | 140 | def test_pltrigger_decorator_registers(): 141 | @pltrigger(event="INSERT", when="BEFORE", table="books_book") 142 | def pl_trigger_test_decorator_registers(td, plpy): 143 | pass 144 | 145 | f, params = next( 146 | x 147 | for x in list(pl_triggers.values()) 148 | if x[0].__name__ == "pl_trigger_test_decorator_registers" 149 | ) 150 | assert params["event"] == "INSERT" 151 | assert params["when"] == "BEFORE" 152 | assert params["table"] == "books_book" 153 | 154 | 155 | @fixture 156 | def same_python_versions(db): 157 | info = get_python_info() 158 | if sem_to_minor(info["version"]) != sem_to_minor(python_version()): 159 | skip("This test can only succeed if db and host python versions match") 160 | 161 | 162 | @mark.django_db(transaction=True) 163 | def test_trigger_model(same_python_versions): 164 | @pltrigger(event="INSERT", when="BEFORE", model=Book, extra_env=dict(os.environ)) 165 | def pl_trigger_trigger_model(new: Book, old: Book, td, plpy): 166 | # don't use save method here, it will kill the database because of recursion 167 | new.stock_days_left = 123 168 | 169 | call_command("syncfunctions") 170 | 171 | book = Book.objects.create(name="book") 172 | book.refresh_from_db() 173 | assert book.stock_days_left == 123 174 | 175 | 176 | def test_function_different_arguments(db): 177 | def pl_test_arguments( 178 | list_str: List[str], list_int: List[int], flag: bool, number: float 179 | ) -> int: 180 | return 1 181 | 182 | install_function(pl_test_arguments) 183 | with connection.cursor() as cursor: 184 | cursor.callproc("pl_test_arguments", [["a", "b"], [1, 2], True, 1.5]) 185 | 186 | 187 | def test_function_unknown_type(db): 188 | def pl_test_arguments(arg: Book) -> int: 189 | return 1 190 | 191 | with raises(RuntimeError): 192 | install_function(pl_test_arguments) 193 | 194 | 195 | def test_function_not_annotated(db): 196 | def pl_test_arguments(arg): 197 | return 1 198 | 199 | with raises(RuntimeError): 200 | install_function(pl_test_arguments) 201 | 202 | 203 | @mark.django_db(transaction=True) 204 | def test_sync_functions(): 205 | @plfunction 206 | def pl_max(a: int, b: int) -> int: 207 | if a > b: 208 | return a 209 | return b 210 | 211 | call_command("syncfunctions") 212 | with connection.cursor() as cursor: 213 | cursor.execute("select pl_max(10, 20)") 214 | row = cursor.fetchone() 215 | assert row[0] == 20 216 | 217 | 218 | @mark.django_db(transaction=True) 219 | def test_check_env(): 220 | call_command("checkenv") 221 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox configuration file 2 | # Read more under https://tox.readthedocs.org/ 3 | # THIS SCRIPT IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS! 4 | 5 | [tox] 6 | minversion = 3.15 7 | envlist = 8 | {py36,py37,py38,py39}-dj22 9 | {py36,py37,py38,py39}-dj32 10 | {py39}-djmaster 11 | 12 | 13 | [testenv] 14 | description = invoke pytest to run automated tests 15 | isolated_build = True 16 | setenv = 17 | TOXINIDIR = {toxinidir} 18 | passenv = 19 | HOME 20 | extras = 21 | testing 22 | commands = 23 | pytest {posargs} 24 | deps = 25 | pip >= 21.1 26 | -r{toxinidir}/requirements_test.txt 27 | dj22: Django==2.2 28 | dj30: Django>=3.0,<3.1 29 | dj31: Django>=3.1,<3.2 30 | dj32: Django>=3.2,<4.0 31 | djmaster: https://github.com/django/django/archive/refs/heads/main.zip 32 | 33 | 34 | [testenv:{clean,build}] 35 | description = 36 | Build (or clean) the package in isolation according to instructions in: 37 | https://setuptools.readthedocs.io/en/latest/build_meta.html#how-to-use-it 38 | https://github.com/pypa/pep517/issues/91 39 | https://github.com/pypa/build 40 | # NOTE: build is still experimental, please refer to the links for updates/issues 41 | skip_install = True 42 | changedir = {toxinidir} 43 | deps = 44 | build: build[virtualenv] 45 | commands = 46 | clean: python -c 'from shutil import rmtree; rmtree("build", True); rmtree("dist", True)' 47 | build: python -m build . 48 | # By default `build` produces wheels, you can also explicitly use the flags `--sdist` and `--wheel` 49 | 50 | 51 | [testenv:{docs,doctests}] 52 | description = invoke sphinx-build to build the docs/run doctests 53 | setenv = 54 | DOCSDIR = {toxinidir}/docs 55 | BUILDDIR = {toxinidir}/docs/_build 56 | docs: BUILD = html 57 | doctests: BUILD = doctest 58 | deps = 59 | -r {toxinidir}/docs/requirements.txt 60 | # ^ requirements.txt shared with Read The Docs 61 | commands = 62 | sphinx-build -b {env:BUILD} -d "{env:BUILDDIR}/doctrees" "{env:DOCSDIR}" "{env:BUILDDIR}/{env:BUILD}" {posargs} 63 | 64 | 65 | [testenv:publish] 66 | description = 67 | Publish the package you have been developing to a package index server. 68 | By default, it uses testpypi. If you really want to publish your package 69 | to be publicly accessible in PyPI, use the `-- --repository pypi` option. 70 | skip_install = True 71 | changedir = {toxinidir} 72 | passenv = 73 | TWINE_USERNAME 74 | TWINE_PASSWORD 75 | TWINE_REPOSITORY 76 | deps = twine 77 | commands = 78 | python -m twine check dist/* 79 | python -m twine upload {posargs:--repository testpypi} dist/* --skip-existing 80 | 81 | 82 | [testenv:lint] 83 | description = 84 | Run linting 85 | skip_install = True 86 | changedir = {toxinidir} 87 | deps = 88 | -r{toxinidir}/requirements_lint.txt 89 | commands = 90 | pre-commit install 91 | pre-commit run --all-files 92 | --------------------------------------------------------------------------------