Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,19 @@ jobs:
- run-tests:
pyversion: 313

test-python314:
executor:
name: python
pyversion: "3.14"
pgversion: "18.0"
debiandist: "trixie"
steps:
- checkout
- install-dependencies:
extra: dev, test
- run-tests:
pyversion: 314

analysis:
executor:
name: python
Expand Down Expand Up @@ -222,6 +235,12 @@ workflows:
only: /.*/
branches:
only: /.*/
- test-python314:
filters:
tags:
only: /.*/
branches:
only: /.*/
- analysis:
filters:
tags:
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
| :memo: | **License** | [![License](https://img.shields.io/:license-mit-blue.svg)](http://doge.mit-license.org) |
| :package: | **PyPi** | [![PyPi](https://badge.fury.io/py/django-postgres-extra.svg)](https://pypi.python.org/pypi/django-postgres-extra) |
| :four_leaf_clover: | **Code coverage** | [![Coverage Status](https://coveralls.io/repos/github/SectorLabs/django-postgres-extra/badge.svg?branch=coveralls)](https://coveralls.io/github/SectorLabs/django-postgres-extra?branch=master) |
| <img src="https://cdn.iconscout.com/icon/free/png-256/django-1-282754.png" width="22px" height="22px" align="center" /> | **Django Versions** | 2.0, 2.1, 2.2, 3.0, 3.1, 3.2, 4.0, 4.1, 4.2, 5.0, 5.1, 5.2 |
| <img src="https://cdn3.iconfinder.com/data/icons/logos-and-brands-adobe/512/267_Python-512.png" width="22px" height="22px" align="center" /> | **Python Versions** | 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13 |
| <img src="https://cdn.iconscout.com/icon/free/png-256/django-1-282754.png" width="22px" height="22px" align="center" /> | **Django Versions** | 2.0, 2.1, 2.2, 3.0, 3.1, 3.2, 4.0, 4.1, 4.2, 5.0, 5.1, 5.2, 6.0 |
| <img src="https://cdn3.iconfinder.com/data/icons/logos-and-brands-adobe/512/267_Python-512.png" width="22px" height="22px" align="center" /> | **Python Versions** | 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13, 3.14 |
| <img src="https://pbs.twimg.com/profile_images/1152122059/psycopg-100_400x400.png" width="22px" height="22px" align="center" /> | **Psycopg Versions** | 2, 3 |
| :book: | **Documentation** | [Read The Docs](https://django-postgres-extra.readthedocs.io/en/master/) |
| :warning: | **Upgrade** | [Upgrade from v1.x](https://django-postgres-extra.readthedocs.io/en/master/major_releases.html#new-features)
Expand All @@ -18,7 +18,7 @@
| :droplet: | **Future enhancements** | [Potential features](https://github.com/SectorLabs/django-postgres-extra/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement) |

`django-postgres-extra` aims to make all of PostgreSQL's awesome features available through the Django ORM. We do this by taking care of all the hassle. As opposed to the many small packages that are available to try to bring a single feature to Django with minimal effort. ``django-postgres-extra`` goes the extra mile, with well tested implementations, seamless migrations and much more.

With seamless we mean that any features we add will work truly seamlessly. You should not have to manually modify your migrations to work with fields and objects provided by this package.

---
Expand Down Expand Up @@ -66,6 +66,7 @@ For Django 2.2 and older:
* **HStore unique and required constraints on specific HStore keys**

## Working with the code

### Prerequisites

* PostgreSQL 14 or newer.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import django

from django.db.migrations.operations.models import CreateModel

from psqlextra.backend.migrations.state import (
Expand Down Expand Up @@ -32,12 +34,16 @@ def __init__(
self.with_data = with_data

def state_forwards(self, app_label, state):
options = dict(self.options)
options.setdefault("indexes", [])
if django.VERSION >= (2, 2):
options.setdefault("constraints", [])
state.add_model(
PostgresMaterializedViewModelState(
app_label=app_label,
name=self.name,
fields=list(self.fields),
options=dict(self.options),
options=options,
bases=tuple(self.bases),
managers=list(self.managers),
view_options=dict(self.view_options),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import django

from django.db.migrations.operations.models import CreateModel

from psqlextra.backend.migrations.state import PostgresPartitionedModelState
Expand Down Expand Up @@ -27,12 +29,16 @@ def __init__(
self.partitioning_options = partitioning_options or {}

def state_forwards(self, app_label, state):
options = dict(self.options)
options.setdefault("indexes", [])
if django.VERSION >= (2, 2):
options.setdefault("constraints", [])
state.add_model(
PostgresPartitionedModelState(
app_label=app_label,
name=self.name,
fields=list(self.fields),
options=dict(self.options),
options=options,
bases=tuple(self.bases),
managers=list(self.managers),
partitioning_options=dict(self.partitioning_options),
Expand Down Expand Up @@ -76,12 +82,14 @@ def reduce(self, *args, **kwargs):
# replace CreateModel operation with PostgresCreatePartitionedModel
if isinstance(result, list) and result:
for i, op in enumerate(result):
if isinstance(op, CreateModel):
_, args, kwargs = op.deconstruct()
if isinstance(op, CreateModel) and not isinstance(
op, PostgresCreatePartitionedModel
):
_, op_args, op_kwargs = op.deconstruct()
result[i] = PostgresCreatePartitionedModel(
*args,
**kwargs,
partitioning_options=self.partitioning_options
*op_args,
**op_kwargs,
partitioning_options=self.partitioning_options,
)

return result
8 changes: 7 additions & 1 deletion psqlextra/backend/migrations/operations/create_view_model.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import django

from django.db.migrations.operations.models import CreateModel

from psqlextra.backend.migrations.state import PostgresViewModelState
Expand Down Expand Up @@ -27,12 +29,16 @@ def __init__(
self.view_options = view_options or {}

def state_forwards(self, app_label, state):
options = dict(self.options)
options.setdefault("indexes", [])
if django.VERSION >= (2, 2):
options.setdefault("constraints", [])
state.add_model(
PostgresViewModelState(
app_label=app_label,
name=self.name,
fields=list(self.fields),
options=dict(self.options),
options=options,
bases=tuple(self.bases),
managers=list(self.managers),
view_options=dict(self.view_options),
Expand Down
4 changes: 2 additions & 2 deletions psqlextra/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def as_sql(self, compiler, connection):
sql.append("hstore(%s, NULL)")
params.append(key)

return " || ".join(sql), params
return " || ".join(sql), tuple(params)


class HStoreColumn(expressions.Col):
Expand Down Expand Up @@ -105,7 +105,7 @@ def as_sql(self, compiler, connection):
return (
"%s.%s->'%s'"
% (qn(self.alias), qn(self.target.column), self.hstore_key),
[],
(),
)

def relabeled_clone(self, relabels):
Expand Down
2 changes: 1 addition & 1 deletion psqlextra/lookups.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def as_sql(self, compiler, connection):
_, rhs_params = self.process_rhs(compiler, connection)
rhs = ",".join([f"(%s)" for _ in rhs_params]) # noqa: F541

return f"{lhs} IN (VALUES {rhs})", lhs_params + list(rhs_params)
return f"{lhs} IN (VALUES {rhs})", (*lhs_params, *rhs_params)


@Field.register_lookup
Expand Down
23 changes: 20 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,15 @@ classifiers = [
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
]
requires-python = ">=3.7"
dependencies = [
"Django>=2.0,<6.0",
"Django>=2.0,<7.0",
"python-dateutil>=2.8.0,<=3.0.0",
]
dynamic = ["version"]
Expand Down Expand Up @@ -72,14 +75,17 @@ test = [
"syrupy==4.9.1; python_version >= '3.9'",
"syrupy==2.3.1; python_version <= '3.8'",
]
test-report = ["coveralls==4.0.1"]
test-report = [
"coveralls==4.0.1; python_version < '3.13'",
"coveralls==4.1.0; python_version >= '3.13'",
]
analysis = [
"black==22.3.0",
"flake8==7.2.0",
"autoflake==2.3.1",
"autopep8==2.3.2",
"isort==6.0.1",
"docformatter==1.7.7",
"docformatter==1.7.7; python_version < '3.14'",
"mypy==1.16.0",
"django-stubs==4.2.7",
"typing-extensions==4.14.0",
Expand Down Expand Up @@ -123,6 +129,17 @@ exclude-newer = "3 days"
# solve.
environments = ["python_version >= '3.11'"]

# `untokenize` 0.1.1 (transitive via `docformatter==1.7.7`) ships only an
# sdist whose setup.py uses `ast.Constant.s`, removed in Python 3.14. The
# `python_version < '3.14'` marker on docformatter keeps it out of the 3.14
# install set, but uv still needs metadata for resolution and tries to
# build the sdist with the active interpreter. Vouch for the metadata
# directly (no runtime deps) so uv skips the build.
[[tool.uv.dependency-metadata]]
name = "untokenize"
version = "0.1.1"
requires-dist = []

[tool.black]
line-length = 80
exclude = '''
Expand Down
8 changes: 6 additions & 2 deletions tests/migrations.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import copy

from contextlib import contextmanager
from typing import List
from unittest import mock
Expand Down Expand Up @@ -87,9 +89,11 @@ def make_migration(app_label="tests", from_state=None, to_state=None):
specified_apps=app_labels, dry_run=False
)

# Deep copy states because the autodetector mutates them (e.g.
# options.pop("indexes")) which breaks callers that reuse states.
autodetector = MigrationAutodetector(
from_state or loader.project_state(),
to_state or ProjectState.from_apps(apps),
copy.deepcopy(from_state) if from_state else loader.project_state(),
copy.deepcopy(to_state) if to_state else ProjectState.from_apps(apps),
questioner,
)

Expand Down
39 changes: 34 additions & 5 deletions tests/test_schema_editor_clone_model_to_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@ def _create_schema() -> str:
return name


def _is_not_null_constraint(name, info):
"""PG17+ stores NOT NULL as named constraints in pg_constraint."""
return (
name.endswith("_not_null")
and not info["primary_key"]
and not info["unique"]
and not info["check"]
and not info["index"]
and info["foreign_key"] is None
)


def _filter_not_null_constraints(constraints):
return {
k: v
for k, v in constraints.items()
if not _is_not_null_constraint(k, v)
}


@transaction.atomic
def _assert_cloned_table_is_same(
source_table_fqn: Tuple[str, str],
Expand Down Expand Up @@ -62,11 +82,15 @@ def _assert_cloned_table_is_same(
else:
assert source_relations == target_relations

source_constraints = db_introspection.get_constraints(
source_table_name, schema_name=source_schema_name
source_constraints = _filter_not_null_constraints(
db_introspection.get_constraints(
source_table_name, schema_name=source_schema_name
)
)
target_constraints = db_introspection.get_constraints(
target_table_name, schema_name=target_schema_name
target_constraints = _filter_not_null_constraints(
db_introspection.get_constraints(
target_table_name, schema_name=target_schema_name
)
)
if excluding_constraints_and_indexes:
assert target_constraints == {}
Expand Down Expand Up @@ -168,7 +192,12 @@ def fake_model(fake_model_fk_target_1, fake_model_fk_target_2):
name="first_last_name_uniq",
),
models.CheckConstraint(
check=Q(age__gt=0, height__gt=0), name="age_height_check"
**{
"condition"
if django.VERSION >= (6, 0)
else "check": Q(age__gt=0, height__gt=0)
},
name="age_height_check",
),
],
"unique_together": (
Expand Down
5 changes: 4 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ envlist =
py37-dj{20,21,22,30,31,32}-psycopg{28,29}
{py38,py39,py310}-dj{21,22,30,31,32,40}-psycopg{28,29}
{py38,py39,py310,py311}-dj{41}-psycopg{28,29}
{py310,py311,py312,py313}-dj{42,50,51,52}-psycopg{28,29,31,32}
{py310,py311,py312,py313,py314}-dj{42,50,51,52}-psycopg{28,29,31,32,33}
{py312,py313,py314}-dj{60}-psycopg{28,29,31,32,33}

[testenv]
deps =
Expand All @@ -23,10 +24,12 @@ deps =
dj50: Django~=5.0.1
dj51: Django~=5.1.0
dj52: Django~=5.2.0
dj60: Django~=6.0.0
psycopg28: psycopg2[binary]~=2.8
psycopg29: psycopg2[binary]~=2.9
psycopg31: psycopg[binary]~=3.1
psycopg32: psycopg[binary]~=3.2
psycopg33: psycopg[binary]~=3.3
.[dev]
.[test]
setenv =
Expand Down
Loading