From e5503cb3f3c1b7959bd55253d3a79296f4c8f0ef Mon Sep 17 00:00:00 2001 From: Swen Kooij Date: Fri, 25 Aug 2023 11:54:12 +0200 Subject: [PATCH 01/12] Tolerate unknown fields coming from JOIN'd data --- psqlextra/introspect/models.py | 15 +++++++----- tests/test_introspect.py | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/psqlextra/introspect/models.py b/psqlextra/introspect/models.py index cad84e9..61a478d 100644 --- a/psqlextra/introspect/models.py +++ b/psqlextra/introspect/models.py @@ -28,11 +28,11 @@ def _construct_model( apply_converters: bool = True ) -> TModel: fields_by_name_and_column = {} - for field in inspect_model_local_concrete_fields(model): - fields_by_name_and_column[field.attname] = field + for concrete_field in inspect_model_local_concrete_fields(model): + fields_by_name_and_column[concrete_field.attname] = concrete_field - if field.db_column: - fields_by_name_and_column[field.db_column] = field + if concrete_field.db_column: + fields_by_name_and_column[concrete_field.db_column] = concrete_field indexable_columns = list(columns) @@ -41,9 +41,12 @@ def _construct_model( for index, value in enumerate(values): column = indexable_columns[index] try: - field = cast(Field, model._meta.get_field(column)) + field: Optional[Field] = cast(Field, model._meta.get_field(column)) except FieldDoesNotExist: - field = fields_by_name_and_column[column] + field = fields_by_name_and_column.get(column) + + if not field: + continue field_column_expression = field.get_col(model._meta.db_table) diff --git a/tests/test_introspect.py b/tests/test_introspect.py index 21e842a..5e5a9ff 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -415,3 +415,48 @@ def test_models_from_cursor_generator_efficiency( assert not next(instances_generator, None) assert cursor.rownumber == 2 + + +@pytest.mark.skipif( + django.VERSION < (3, 1), + reason=django_31_skip_reason, +) +def test_models_from_cursor_tolerates_additional_columns( + mocked_model_foreign_keys, mocked_model_varying_fields +): + with connection.cursor() as cursor: + cursor.execute( + f"ALTER TABLE {mocked_model_foreign_keys._meta.db_table} ADD COLUMN new_col text DEFAULT NULL" + ) + cursor.execute( + f"ALTER TABLE {mocked_model_varying_fields._meta.db_table} ADD COLUMN new_col text DEFAULT NULL" + ) + + instance = mocked_model_foreign_keys.objects.create( + varying_fields=mocked_model_varying_fields.objects.create( + title="test", updated_at=timezone.now() + ), + single_field=None, + ) + + with connection.cursor() as cursor: + cursor.execute( + f""" + SELECT fk_t.*, vf_t.* FROM {mocked_model_foreign_keys._meta.db_table} fk_t + INNER JOIN {mocked_model_varying_fields._meta.db_table} vf_t ON vf_t.id = fk_t.varying_fields_id + """ + ) + + queried_instances = list( + models_from_cursor( + mocked_model_foreign_keys, + cursor, + related_fields=["varying_fields"], + ) + ) + + assert len(queried_instances) == 1 + assert queried_instances[0].id == instance.id + assert ( + queried_instances[0].varying_fields.id == instance.varying_fields.id + ) From 65b4688ab304cb35a013ef54170f3d4dc1070da8 Mon Sep 17 00:00:00 2001 From: Sebastian Willing Date: Sun, 19 Nov 2023 21:20:07 +0100 Subject: [PATCH 02/12] Stop updating on conflict if `update_condition` is False but not None Some users of this library set `update_condition=0` on `upsert` for not updating anything on conflict. The `upsert` documentation says: > update_condition: > Only update if this SQL expression evaluates to true. A value evaluating to Python `False` is ignored while the documentation says no update will be done. [#186513018] --- psqlextra/query.py | 4 +++- tests/test_upsert.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/psqlextra/query.py b/psqlextra/query.py index 2d24b5a..65a20c5 100644 --- a/psqlextra/query.py +++ b/psqlextra/query.py @@ -327,7 +327,9 @@ def upsert( self.on_conflict( conflict_target, - ConflictAction.UPDATE, + ConflictAction.UPDATE + if (update_condition or update_condition is None) + else ConflictAction.NOTHING, index_predicate=index_predicate, update_condition=update_condition, update_values=update_values, diff --git a/tests/test_upsert.py b/tests/test_upsert.py index 3aa6207..a9e567b 100644 --- a/tests/test_upsert.py +++ b/tests/test_upsert.py @@ -4,6 +4,7 @@ from django.db import connection, models from django.db.models import F, Q from django.db.models.expressions import CombinedExpression, Value +from django.test.utils import CaptureQueriesContext from psqlextra.expressions import ExcludedCol from psqlextra.fields import HStoreField @@ -144,6 +145,35 @@ def test_upsert_with_update_condition(): assert obj1.active +@pytest.mark.parametrize("update_condition_value", [0, False]) +def test_upsert_with_update_condition_false(update_condition_value): + """Tests that an expression can be used as an upsert update condition.""" + + model = get_fake_model( + { + "name": models.TextField(unique=True), + "priority": models.IntegerField(), + "active": models.BooleanField(), + } + ) + + obj1 = model.objects.create(name="joe", priority=1, active=False) + + with CaptureQueriesContext(connection) as ctx: + upsert_result = model.objects.upsert( + conflict_target=["name"], + update_condition=update_condition_value, + fields=dict(name="joe", priority=2, active=True), + ) + assert upsert_result is None + assert len(ctx) == 1 + assert 'ON CONFLICT ("name") DO NOTHING' in ctx[0]["sql"] + + obj1.refresh_from_db() + assert obj1.priority == 1 + assert not obj1.active + + def test_upsert_with_update_values(): """Tests that the default update values can be overriden with custom expressions.""" From c4aab405823331e17f4683e144e12e64e7541630 Mon Sep 17 00:00:00 2001 From: Swen Kooij Date: Tue, 6 Feb 2024 16:23:26 +0100 Subject: [PATCH 03/12] Prepare for Django 5.x support --- README.md | 2 +- psqlextra/sql.py | 11 +++++++++-- setup.py | 2 +- tox.ini | 3 ++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8127b25..17037d8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ | :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) | -| | **Django Versions** | 2.0, 2.1, 2.2, 3.0, 3.1, 3.2, 4.0, 4.1, 4.2 | +| | **Django Versions** | 2.0, 2.1, 2.2, 3.0, 3.1, 3.2, 4.0, 4.1, 4.2, 5.0 | | | **Python Versions** | 3.6, 3.7, 3.8, 3.9, 3.10, 3.11 | | | **Psycopg Versions** | 2, 3 | | :book: | **Documentation** | [Read The Docs](https://django-postgres-extra.readthedocs.io/en/master/) | diff --git a/psqlextra/sql.py b/psqlextra/sql.py index 3ceb596..b265508 100644 --- a/psqlextra/sql.py +++ b/psqlextra/sql.py @@ -64,8 +64,15 @@ def rename_annotations(self, annotations) -> None: new_annotations[new_name or old_name] = annotation if new_name and self.annotation_select_mask: - self.annotation_select_mask.discard(old_name) - self.annotation_select_mask.add(new_name) + # It's a set in all versions prior to Django 5.x + # and a list in Django 5.x and newer. + # https://github.com/django/django/commit/d6b6e5d0fd4e6b6d0183b4cf6e4bd4f9afc7bf67 + if isinstance(self.annotation_select_mask, set): + self.annotation_select_mask.discard(old_name) + self.annotation_select_mask.add(new_name) + elif isinstance(self.annotation_select_mask, list): + self.annotation_select_mask.remove(old_name) + self.annotation_select_mask.append(new_name) self.annotations.clear() self.annotations.update(new_annotations) diff --git a/setup.py b/setup.py index b3217fb..c3431e2 100644 --- a/setup.py +++ b/setup.py @@ -68,7 +68,7 @@ def run(self): ], python_requires=">=3.6", install_requires=[ - "Django>=2.0,<5.0", + "Django>=2.0,<6.0", "python-dateutil>=2.8.0,<=3.0.0", ], extras_require={ diff --git a/tox.ini b/tox.ini index 3e229d0..70a0e8c 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ envlist = {py36,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} - {py38,py39,py310,py311}-dj{42}-psycopg{28,29,31} + {py310,py311}-dj{42,50}-psycopg{28,29,31} [testenv] deps = @@ -16,6 +16,7 @@ deps = dj40: Django~=4.0.0 dj41: Django~=4.1.0 dj42: Django~=4.2.0 + dj50: Django~=5.0.1 psycopg28: psycopg2[binary]~=2.8 psycopg29: psycopg2[binary]~=2.9 psycopg31: psycopg[binary]~=3.1 From 43a6f222b3ab85661a15ddf783c7171b76769fc8 Mon Sep 17 00:00:00 2001 From: Swen Kooij Date: Wed, 7 Feb 2024 09:07:21 +0100 Subject: [PATCH 04/12] Switch CircleCI to PyPi API token for publishing --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 64e60c3..92d9093 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -142,8 +142,8 @@ jobs: name: Publish package command: > python -m twine upload - --username "${PYPI_REPO_USERNAME}" - --password "${PYPI_REPO_PASSWORD}" + --username "__token__" + --password "${PYPI_API_TOKEN}" --verbose --non-interactive --disable-progress-bar From 13b5672cfe1aed0ec10dcb0b3f4b382d22719de7 Mon Sep 17 00:00:00 2001 From: Swen Kooij Date: Tue, 18 Jun 2024 12:24:46 +0200 Subject: [PATCH 05/12] Fix automatic hstore extension creation not working on Django 4.2 or newer The following change broke the auto setup: https://github.com/django/django/commit/d3e746ace5eeea07216da97d9c3801f2fdc43223 This breaks because the call to `pscygop2.extras.register_hstore` is now conditional. Before, it would be called multiple times with empty OIDS, when eventually our auto registration would kick in and psycopg2 would fetch the OIDs itself. --- psqlextra/backend/base.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/psqlextra/backend/base.py b/psqlextra/backend/base.py index 5c788a0..c8ae73c 100644 --- a/psqlextra/backend/base.py +++ b/psqlextra/backend/base.py @@ -3,6 +3,10 @@ from typing import TYPE_CHECKING from django.conf import settings +from django.contrib.postgres.signals import ( + get_hstore_oids, + register_type_handlers, +) from django.db import ProgrammingError from . import base_impl @@ -94,3 +98,22 @@ def prepare_database(self): "or add the extension manually.", exc_info=True, ) + return + + # Clear old (non-existent), stale oids. + get_hstore_oids.cache_clear() + + # Verify that we (and Django) can find the OIDs + # for hstore. + oids, _ = get_hstore_oids(self.alias) + if not oids: + logger.warning( + '"hstore" extension was created, but we cannot find the oids' + "in the database. Something went wrong.", + ) + return + + # We must trigger Django into registering the type handlers now + # so that any subsequent code can properly use the newly + # registered types. + register_type_handlers(self) From 200f2b9e66bdfd66bfaf63590b9f8e7ccd7ebe74 Mon Sep 17 00:00:00 2001 From: seroy Date: Mon, 20 May 2024 13:06:22 +0400 Subject: [PATCH 06/12] Fix `StopIteration` in deduplication rows code when `conflict_action == ConflictAction.NOTHING` and rows parameter is iterator or generator --- psqlextra/query.py | 14 +++++++++++--- tests/test_on_conflict_nothing.py | 17 ++++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/psqlextra/query.py b/psqlextra/query.py index 65a20c5..c75ce9c 100644 --- a/psqlextra/query.py +++ b/psqlextra/query.py @@ -174,11 +174,19 @@ def bulk_insert( A list of either the dicts of the rows inserted, including the pk or the models of the rows inserted with defaults for any fields not specified """ + if rows is None: + return [] + + def peek(iterable): + try: + first = next(iterable) + except StopIteration: + return None + return list(chain([first], iterable)) - def is_empty(r): - return all([False for _ in r]) + rows = peek(iter(rows)) - if not rows or is_empty(rows): + if not rows: return [] if not self.conflict_target and not self.conflict_action: diff --git a/tests/test_on_conflict_nothing.py b/tests/test_on_conflict_nothing.py index 78c4c5f..eb3b8a3 100644 --- a/tests/test_on_conflict_nothing.py +++ b/tests/test_on_conflict_nothing.py @@ -179,8 +179,15 @@ def test_on_conflict_nothing_duplicate_rows(): rows = [dict(amount=1), dict(amount=1)] - ( - model.objects.on_conflict( - ["amount"], ConflictAction.NOTHING - ).bulk_insert(rows) - ) + inserted_rows = model.objects.on_conflict( + ["amount"], ConflictAction.NOTHING + ).bulk_insert(rows) + + assert len(inserted_rows) == 1 + + rows = iter([dict(amount=2), dict(amount=2)]) + inserted_rows = model.objects.on_conflict( + ["amount"], ConflictAction.NOTHING + ).bulk_insert(rows) + + assert len(inserted_rows) == 1 From 422e91f0a17c467fa9df7db8a622535726b6fd1c Mon Sep 17 00:00:00 2001 From: Swen Kooij Date: Tue, 18 Jun 2024 13:36:49 +0200 Subject: [PATCH 07/12] Add additional tests for ON CONFLICT DO NOTHING duplicate rows filtering --- psqlextra/query.py | 17 +++++++++-------- tests/test_on_conflict_nothing.py | 24 +++++++++++++----------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/psqlextra/query.py b/psqlextra/query.py index c75ce9c..6a86f18 100644 --- a/psqlextra/query.py +++ b/psqlextra/query.py @@ -41,6 +41,14 @@ QuerySetBase = QuerySet +def peek_iterator(iterable): + try: + first = next(iterable) + except StopIteration: + return None + return list(chain([first], iterable)) + + class PostgresQuerySet(QuerySetBase, Generic[TModel]): """Adds support for PostgreSQL specifics.""" @@ -177,14 +185,7 @@ def bulk_insert( if rows is None: return [] - def peek(iterable): - try: - first = next(iterable) - except StopIteration: - return None - return list(chain([first], iterable)) - - rows = peek(iter(rows)) + rows = peek_iterator(iter(rows)) if not rows: return [] diff --git a/tests/test_on_conflict_nothing.py b/tests/test_on_conflict_nothing.py index eb3b8a3..92e74df 100644 --- a/tests/test_on_conflict_nothing.py +++ b/tests/test_on_conflict_nothing.py @@ -170,24 +170,26 @@ def test_on_conflict_nothing_foreign_key_by_id(): assert obj1.data == "some data" -def test_on_conflict_nothing_duplicate_rows(): +@pytest.mark.parametrize( + "rows,expected_row_count", + [ + ([dict(amount=1), dict(amount=1)], 1), + (iter([dict(amount=1), dict(amount=1)]), 1), + ((row for row in [dict(amount=1), dict(amount=1)]), 1), + ([], 0), + (iter([]), 0), + ((row for row in []), 0), + ], +) +def test_on_conflict_nothing_duplicate_rows(rows, expected_row_count): """Tests whether duplicate rows are filtered out when doing a insert NOTHING and no error is raised when the list of rows contains duplicates.""" model = get_fake_model({"amount": models.IntegerField(unique=True)}) - rows = [dict(amount=1), dict(amount=1)] - - inserted_rows = model.objects.on_conflict( - ["amount"], ConflictAction.NOTHING - ).bulk_insert(rows) - - assert len(inserted_rows) == 1 - - rows = iter([dict(amount=2), dict(amount=2)]) inserted_rows = model.objects.on_conflict( ["amount"], ConflictAction.NOTHING ).bulk_insert(rows) - assert len(inserted_rows) == 1 + assert len(inserted_rows) == expected_row_count From 6cb8b4f15d595b00a2fcd7e21990c004645ae0e7 Mon Sep 17 00:00:00 2001 From: Tyler Kennedy Date: Mon, 3 Jun 2024 08:52:23 -0400 Subject: [PATCH 08/12] Allow tuples as a valid type for `meta.key` Most modern linters (like `ruff`) will complain about `meta.key` being a list, as it's a mutable class variable. Allowing a tuple here appears to work fine and removes the need to override linter rules for `meta.key`. --- psqlextra/backend/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psqlextra/backend/schema.py b/psqlextra/backend/schema.py index 28e9211..31a2341 100644 --- a/psqlextra/backend/schema.py +++ b/psqlextra/backend/schema.py @@ -1045,7 +1045,7 @@ def _partitioning_properties_for_model(model: Type[Model]): % (model.__name__, meta.method) ) - if not isinstance(meta.key, list): + if not isinstance(meta.key, (list, tuple)): raise ImproperlyConfigured( ( "Model '%s' is not properly configured to be partitioned." From 7aa6923964ec8352bc871c02fcbffc4b688262b9 Mon Sep 17 00:00:00 2001 From: Filippo Campi Date: Wed, 5 Jun 2024 11:45:40 +0200 Subject: [PATCH 09/12] Support of restart_identity on truncate operation --- docs/source/deletion.rst | 25 +++++++++++++++++++++++++ psqlextra/manager/manager.py | 12 ++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/source/deletion.rst b/docs/source/deletion.rst index c27cdcb..9308594 100644 --- a/docs/source/deletion.rst +++ b/docs/source/deletion.rst @@ -48,3 +48,28 @@ By default, Postgres will raise an error if any other table is referencing one o MyModel.objects.truncate(cascade=True) print(MyModel1.objects.count()) # zero records left print(MyModel2.objects.count()) # zero records left + + +Restart identity +**************** + +If specified, any sequences on the table will be restarted. + +.. code-block:: python + + from django.db import models + from psqlextra.models import PostgresModel + + class MyModel(PostgresModel): + pass + + mymodel = MyModel.objects.create() + assert mymodel.id == 1 + + MyModel.objects.truncate(restart_identity=True) # table is empty after this + print(MyModel.objects.count()) # zero records left + + # Create a new row, it should get ID 1 again because + # the sequence got restarted. + mymodel = MyModel.objects.create() + assert mymodel.id == 1 diff --git a/psqlextra/manager/manager.py b/psqlextra/manager/manager.py index 0931b38..ee1eb58 100644 --- a/psqlextra/manager/manager.py +++ b/psqlextra/manager/manager.py @@ -37,7 +37,10 @@ def __init__(self, *args, **kwargs): ) def truncate( - self, cascade: bool = False, using: Optional[str] = None + self, + cascade: bool = False, + restart_identity: bool = False, + using: Optional[str] = None, ) -> None: """Truncates this model/table using the TRUNCATE statement. @@ -51,14 +54,19 @@ def truncate( False, an error will be raised if there are rows in other tables referencing the rows you're trying to delete. + restart_identity: + Automatically restart sequences owned by + columns of the truncated table(s). """ connection = connections[using or "default"] table_name = connection.ops.quote_name(self.model._meta.db_table) with connection.cursor() as cursor: - sql = "TRUNCATE TABLE %s" % table_name + sql = f"TRUNCATE TABLE {table_name}" if cascade: sql += " CASCADE" + if restart_identity: + sql += " RESTART IDENTITY" cursor.execute(sql) From 1fecd9bb10d2b276aa69a1ecf20c1fff4d52c5e6 Mon Sep 17 00:00:00 2001 From: Swen Kooij Date: Wed, 7 Feb 2024 08:56:02 +0100 Subject: [PATCH 10/12] Upgrade mypy and django-stubs to the latest version --- psqlextra/introspect/models.py | 8 +++++--- setup.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/psqlextra/introspect/models.py b/psqlextra/introspect/models.py index 61a478d..e160bca 100644 --- a/psqlextra/introspect/models.py +++ b/psqlextra/introspect/models.py @@ -7,6 +7,7 @@ Optional, Type, TypeVar, + Union, cast, ) @@ -115,9 +116,10 @@ def models_from_cursor( ) for index, related_field_name in enumerate(related_fields): - related_model = model._meta.get_field( - related_field_name - ).related_model + related_model = cast( + Union[Type[Model], None], + model._meta.get_field(related_field_name).related_model, + ) if not related_model: continue diff --git a/setup.py b/setup.py index c3431e2..918beb8 100644 --- a/setup.py +++ b/setup.py @@ -97,7 +97,7 @@ def run(self): "docformatter==1.4", "mypy==1.2.0; python_version > '3.6'", "mypy==0.971; python_version <= '3.6'", - "django-stubs==1.16.0; python_version > '3.6'", + "django-stubs==4.2.7; python_version > '3.6'", "django-stubs==1.9.0; python_version <= '3.6'", "typing-extensions==4.5.0; python_version > '3.6'", "typing-extensions==4.1.0; python_version <= '3.6'", From cf87e7a5276ce3cb89017fd00d8f7cb15993b25e Mon Sep 17 00:00:00 2001 From: Swen Kooij Date: Wed, 7 Feb 2024 08:56:06 +0100 Subject: [PATCH 11/12] Fix renamed annotations relying on group by clauses not working Co-authored-by: Hannes Engelhardt --- psqlextra/sql.py | 10 ++++++++++ settings.py | 3 +++ tests/test_query.py | 39 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/psqlextra/sql.py b/psqlextra/sql.py index b265508..cf12d8c 100644 --- a/psqlextra/sql.py +++ b/psqlextra/sql.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from collections.abc import Iterable from typing import Any, Dict, List, Optional, Tuple, Union import django @@ -7,6 +8,7 @@ from django.db import connections, models from django.db.models import Expression, sql from django.db.models.constants import LOOKUP_SEP +from django.db.models.expressions import Ref from .compiler import PostgresInsertOnConflictCompiler from .compiler import SQLUpdateCompiler as PostgresUpdateCompiler @@ -74,6 +76,14 @@ def rename_annotations(self, annotations) -> None: self.annotation_select_mask.remove(old_name) self.annotation_select_mask.append(new_name) + if isinstance(self.group_by, Iterable): + for statement in self.group_by: + if not isinstance(statement, Ref): + continue + + if statement.refs in annotations: # type: ignore[attr-defined] + statement.refs = annotations[statement.refs] # type: ignore[attr-defined] + self.annotations.clear() self.annotations.update(new_annotations) diff --git a/settings.py b/settings.py index ed0d0f9..a78eed4 100644 --- a/settings.py +++ b/settings.py @@ -24,3 +24,6 @@ 'psqlextra', 'tests', ) + +USE_TZ = True +TIME_ZONE = 'UTC' diff --git a/tests/test_query.py b/tests/test_query.py index 7db4bea..38d6b3c 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,5 +1,8 @@ +from datetime import datetime, timezone + from django.db import connection, models -from django.db.models import Case, F, Q, Value, When +from django.db.models import Case, F, Min, Q, Value, When +from django.db.models.functions.datetime import TruncSecond from django.test.utils import CaptureQueriesContext, override_settings from psqlextra.expressions import HStoreRef @@ -96,6 +99,40 @@ def test_query_annotate_in_expression(): assert result.is_he_henk == "really henk" +def test_query_annotate_group_by(): + """Tests whether annotations with GROUP BY clauses are properly renamed + when the annotation overwrites a field name.""" + + model = get_fake_model( + { + "name": models.TextField(), + "timestamp": models.DateTimeField(null=False), + "value": models.IntegerField(), + } + ) + + timestamp = datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc) + + model.objects.create(name="me", timestamp=timestamp, value=1) + + result = ( + model.objects.values("name") + .annotate( + timestamp=TruncSecond("timestamp", tzinfo=timezone.utc), + value=Min("value"), + ) + .values_list( + "name", + "value", + "timestamp", + ) + .order_by("name") + .first() + ) + + assert result == ("me", 1, timestamp) + + def test_query_hstore_value_update_f_ref(): """Tests whether F(..) expressions can be used in hstore values when performing update queries.""" From 7d582d92ee5eb774a8306994e319777e3a709d1a Mon Sep 17 00:00:00 2001 From: Swen Kooij Date: Tue, 18 Jun 2024 15:04:33 +0200 Subject: [PATCH 12/12] Work around psycopg2.9 + django 3.0 compatibility issue --- settings.py | 2 +- tests/psqlextra_test_backend/__init__.py | 0 tests/psqlextra_test_backend/base.py | 23 +++++++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tests/psqlextra_test_backend/__init__.py create mode 100644 tests/psqlextra_test_backend/base.py diff --git a/settings.py b/settings.py index a78eed4..7266ccb 100644 --- a/settings.py +++ b/settings.py @@ -11,7 +11,7 @@ 'default': dj_database_url.config(default='postgres:///psqlextra'), } -DATABASES['default']['ENGINE'] = 'psqlextra.backend' +DATABASES['default']['ENGINE'] = 'tests.psqlextra_test_backend' LANGUAGE_CODE = 'en' LANGUAGES = ( diff --git a/tests/psqlextra_test_backend/__init__.py b/tests/psqlextra_test_backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/psqlextra_test_backend/base.py b/tests/psqlextra_test_backend/base.py new file mode 100644 index 0000000..0961a2b --- /dev/null +++ b/tests/psqlextra_test_backend/base.py @@ -0,0 +1,23 @@ +from datetime import timezone + +import django + +from django.conf import settings + +from psqlextra.backend.base import DatabaseWrapper as PSQLExtraDatabaseWrapper + + +class DatabaseWrapper(PSQLExtraDatabaseWrapper): + # Works around the compatibility issue of Django <3.0 and psycopg2.9 + # in combination with USE_TZ + # + # See: https://github.com/psycopg/psycopg2/issues/1293#issuecomment-862835147 + if django.VERSION < (3, 1): + + def create_cursor(self, name=None): + cursor = super().create_cursor(name) + cursor.tzinfo_factory = ( + lambda offset: timezone.utc if settings.USE_TZ else None + ) + + return cursor