1
0
mirror of https://github.com/django/django.git synced 2024-12-22 17:16:24 +00:00

Fixed #33308 -- Added support for psycopg version 3.

Thanks Simon Charette, Tim Graham, and Adam Johnson for reviews.

Co-authored-by: Florian Apolloner <florian@apolloner.eu>
Co-authored-by: Mariusz Felisiak <felisiak.mariusz@gmail.com>
This commit is contained in:
Daniele Varrazzo 2022-12-01 20:23:43 +01:00 committed by Mariusz Felisiak
parent d44ee518c4
commit 09ffc5c121
42 changed files with 673 additions and 223 deletions

View File

@ -1,8 +1,6 @@
""" """
This object provides quoting for GEOS geometries into PostgreSQL/PostGIS. This object provides quoting for GEOS geometries into PostgreSQL/PostGIS.
""" """
from psycopg2.extensions import ISQLQuote
from django.contrib.gis.db.backends.postgis.pgraster import to_pgraster from django.contrib.gis.db.backends.postgis.pgraster import to_pgraster
from django.contrib.gis.geos import GEOSGeometry from django.contrib.gis.geos import GEOSGeometry
from django.db.backends.postgresql.psycopg_any import sql from django.db.backends.postgresql.psycopg_any import sql
@ -27,6 +25,8 @@ class PostGISAdapter:
def __conform__(self, proto): def __conform__(self, proto):
"""Does the given protocol conform to what Psycopg2 expects?""" """Does the given protocol conform to what Psycopg2 expects?"""
from psycopg2.extensions import ISQLQuote
if proto == ISQLQuote: if proto == ISQLQuote:
return self return self
else: else:

View File

@ -1,17 +1,93 @@
from django.db.backends.base.base import NO_DB_ALIAS from functools import lru_cache
from django.db.backends.postgresql.base import (
DatabaseWrapper as Psycopg2DatabaseWrapper,
)
from django.db.backends.base.base import NO_DB_ALIAS
from django.db.backends.postgresql.base import DatabaseWrapper as PsycopgDatabaseWrapper
from django.db.backends.postgresql.psycopg_any import is_psycopg3
from .adapter import PostGISAdapter
from .features import DatabaseFeatures from .features import DatabaseFeatures
from .introspection import PostGISIntrospection from .introspection import PostGISIntrospection
from .operations import PostGISOperations from .operations import PostGISOperations
from .schema import PostGISSchemaEditor from .schema import PostGISSchemaEditor
if is_psycopg3:
from psycopg.adapt import Dumper
from psycopg.pq import Format
from psycopg.types import TypeInfo
from psycopg.types.string import TextBinaryLoader, TextLoader
class DatabaseWrapper(Psycopg2DatabaseWrapper): class GeometryType:
pass
class GeographyType:
pass
class RasterType:
pass
class BaseTextDumper(Dumper):
def dump(self, obj):
# Return bytes as hex for text formatting
return obj.ewkb.hex().encode()
class BaseBinaryDumper(Dumper):
format = Format.BINARY
def dump(self, obj):
return obj.ewkb
@lru_cache
def postgis_adapters(geo_oid, geog_oid, raster_oid):
class BaseDumper(Dumper):
def __init_subclass__(cls, base_dumper):
super().__init_subclass__()
cls.GeometryDumper = type(
"GeometryDumper", (base_dumper,), {"oid": geo_oid}
)
cls.GeographyDumper = type(
"GeographyDumper", (base_dumper,), {"oid": geog_oid}
)
cls.RasterDumper = type(
"RasterDumper", (BaseTextDumper,), {"oid": raster_oid}
)
def get_key(self, obj, format):
if obj.is_geometry:
return GeographyType if obj.geography else GeometryType
else:
return RasterType
def upgrade(self, obj, format):
if obj.is_geometry:
if obj.geography:
return self.GeographyDumper(GeographyType)
else:
return self.GeometryDumper(GeometryType)
else:
return self.RasterDumper(RasterType)
def dump(self, obj):
raise NotImplementedError
class PostGISTextDumper(BaseDumper, base_dumper=BaseTextDumper):
pass
class PostGISBinaryDumper(BaseDumper, base_dumper=BaseBinaryDumper):
format = Format.BINARY
return PostGISTextDumper, PostGISBinaryDumper
class DatabaseWrapper(PsycopgDatabaseWrapper):
SchemaEditorClass = PostGISSchemaEditor SchemaEditorClass = PostGISSchemaEditor
_type_infos = {
"geometry": {},
"geography": {},
"raster": {},
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if kwargs.get("alias", "") != NO_DB_ALIAS: if kwargs.get("alias", "") != NO_DB_ALIAS:
@ -27,3 +103,45 @@ class DatabaseWrapper(Psycopg2DatabaseWrapper):
if bool(cursor.fetchone()): if bool(cursor.fetchone()):
return return
cursor.execute("CREATE EXTENSION IF NOT EXISTS postgis") cursor.execute("CREATE EXTENSION IF NOT EXISTS postgis")
if is_psycopg3:
# Ensure adapters are registers if PostGIS is used within this
# connection.
self.register_geometry_adapters(self.connection, True)
def get_new_connection(self, conn_params):
connection = super().get_new_connection(conn_params)
if is_psycopg3:
self.register_geometry_adapters(connection)
return connection
if is_psycopg3:
def _register_type(self, pg_connection, typename):
registry = self._type_infos[typename]
try:
info = registry[self.alias]
except KeyError:
info = TypeInfo.fetch(pg_connection, typename)
registry[self.alias] = info
if info: # Can be None if the type does not exist (yet).
info.register(pg_connection)
pg_connection.adapters.register_loader(info.oid, TextLoader)
pg_connection.adapters.register_loader(info.oid, TextBinaryLoader)
return info.oid if info else None
def register_geometry_adapters(self, pg_connection, clear_caches=False):
if clear_caches:
for typename in self._type_infos:
self._type_infos[typename].pop(self.alias, None)
geo_oid = self._register_type(pg_connection, "geometry")
geog_oid = self._register_type(pg_connection, "geography")
raster_oid = self._register_type(pg_connection, "raster")
PostGISTextDumper, PostGISBinaryDumper = postgis_adapters(
geo_oid, geog_oid, raster_oid
)
pg_connection.adapters.register_dumper(PostGISAdapter, PostGISTextDumper)
pg_connection.adapters.register_dumper(PostGISAdapter, PostGISBinaryDumper)

View File

@ -1,10 +1,10 @@
from django.contrib.gis.db.backends.base.features import BaseSpatialFeatures from django.contrib.gis.db.backends.base.features import BaseSpatialFeatures
from django.db.backends.postgresql.features import ( from django.db.backends.postgresql.features import (
DatabaseFeatures as Psycopg2DatabaseFeatures, DatabaseFeatures as PsycopgDatabaseFeatures,
) )
class DatabaseFeatures(BaseSpatialFeatures, Psycopg2DatabaseFeatures): class DatabaseFeatures(BaseSpatialFeatures, PsycopgDatabaseFeatures):
supports_geography = True supports_geography = True
supports_3d_storage = True supports_3d_storage = True
supports_3d_functions = True supports_3d_functions = True

View File

@ -11,6 +11,7 @@ from django.contrib.gis.measure import Distance
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import NotSupportedError, ProgrammingError from django.db import NotSupportedError, ProgrammingError
from django.db.backends.postgresql.operations import DatabaseOperations from django.db.backends.postgresql.operations import DatabaseOperations
from django.db.backends.postgresql.psycopg_any import is_psycopg3
from django.db.models import Func, Value from django.db.models import Func, Value
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.version import get_version_tuple from django.utils.version import get_version_tuple
@ -161,7 +162,8 @@ class PostGISOperations(BaseSpatialOperations, DatabaseOperations):
unsupported_functions = set() unsupported_functions = set()
select = "%s::bytea" select = "%s" if is_psycopg3 else "%s::bytea"
select_extent = None select_extent = None
@cached_property @cached_property
@ -407,6 +409,8 @@ class PostGISOperations(BaseSpatialOperations, DatabaseOperations):
geom_class = expression.output_field.geom_class geom_class = expression.output_field.geom_class
def converter(value, expression, connection): def converter(value, expression, connection):
if isinstance(value, str): # Coming from hex strings.
value = value.encode("ascii")
return None if value is None else GEOSGeometryBase(read(value), geom_class) return None if value is None else GEOSGeometryBase(read(value), geom_class)
return converter return converter

View File

@ -237,7 +237,7 @@ class ArrayField(CheckFieldDefaultMixin, Field):
class ArrayRHSMixin: class ArrayRHSMixin:
def __init__(self, lhs, rhs): def __init__(self, lhs, rhs):
# Don't wrap arrays that contains only None values, psycopg2 doesn't # Don't wrap arrays that contains only None values, psycopg doesn't
# allow this. # allow this.
if isinstance(rhs, (tuple, list)) and any(self._rhs_not_none_values(rhs)): if isinstance(rhs, (tuple, list)) and any(self._rhs_not_none_values(rhs)):
expressions = [] expressions = []

View File

@ -9,6 +9,7 @@ from django.db.backends.postgresql.psycopg_any import (
NumericRange, NumericRange,
Range, Range,
) )
from django.db.models.functions import Cast
from django.db.models.lookups import PostgresOperatorLookup from django.db.models.lookups import PostgresOperatorLookup
from .utils import AttributeSetter from .utils import AttributeSetter
@ -208,7 +209,14 @@ class DateRangeField(RangeField):
return "daterange" return "daterange"
RangeField.register_lookup(lookups.DataContains) class RangeContains(lookups.DataContains):
def get_prep_lookup(self):
if not isinstance(self.rhs, (list, tuple, Range)):
return Cast(self.rhs, self.lhs.field.base_field)
return super().get_prep_lookup()
RangeField.register_lookup(RangeContains)
RangeField.register_lookup(lookups.ContainedBy) RangeField.register_lookup(lookups.ContainedBy)
RangeField.register_lookup(lookups.Overlap) RangeField.register_lookup(lookups.Overlap)

View File

@ -35,6 +35,10 @@ class CreateExtension(Operation):
# installed, otherwise a subsequent data migration would use the same # installed, otherwise a subsequent data migration would use the same
# connection. # connection.
register_type_handlers(schema_editor.connection) register_type_handlers(schema_editor.connection)
if hasattr(schema_editor.connection, "register_geometry_adapters"):
schema_editor.connection.register_geometry_adapters(
schema_editor.connection.connection, True
)
def database_backwards(self, app_label, schema_editor, from_state, to_state): def database_backwards(self, app_label, schema_editor, from_state, to_state):
if not router.allow_migrate(schema_editor.connection.alias, app_label): if not router.allow_migrate(schema_editor.connection.alias, app_label):

View File

@ -39,6 +39,11 @@ class SearchQueryField(Field):
return "tsquery" return "tsquery"
class _Float4Field(Field):
def db_type(self, connection):
return "float4"
class SearchConfig(Expression): class SearchConfig(Expression):
def __init__(self, config): def __init__(self, config):
super().__init__() super().__init__()
@ -138,7 +143,11 @@ class SearchVector(SearchVectorCombinable, Func):
if clone.weight: if clone.weight:
weight_sql, extra_params = compiler.compile(clone.weight) weight_sql, extra_params = compiler.compile(clone.weight)
sql = "setweight({}, {})".format(sql, weight_sql) sql = "setweight({}, {})".format(sql, weight_sql)
return sql, config_params + params + extra_params
# These parameters must be bound on the client side because we may
# want to create an index on this expression.
sql = connection.ops.compose_sql(sql, config_params + params + extra_params)
return sql, []
class CombinedSearchVector(SearchVectorCombinable, CombinedExpression): class CombinedSearchVector(SearchVectorCombinable, CombinedExpression):
@ -244,6 +253,8 @@ class SearchRank(Func):
normalization=None, normalization=None,
cover_density=False, cover_density=False,
): ):
from .fields.array import ArrayField
if not hasattr(vector, "resolve_expression"): if not hasattr(vector, "resolve_expression"):
vector = SearchVector(vector) vector = SearchVector(vector)
if not hasattr(query, "resolve_expression"): if not hasattr(query, "resolve_expression"):
@ -252,6 +263,7 @@ class SearchRank(Func):
if weights is not None: if weights is not None:
if not hasattr(weights, "resolve_expression"): if not hasattr(weights, "resolve_expression"):
weights = Value(weights) weights = Value(weights)
weights = Cast(weights, ArrayField(_Float4Field()))
expressions = (weights,) + expressions expressions = (weights,) + expressions
if normalization is not None: if normalization is not None:
if not hasattr(normalization, "resolve_expression"): if not hasattr(normalization, "resolve_expression"):

View File

@ -1,10 +1,8 @@
import functools import functools
import psycopg2
from psycopg2.extras import register_hstore
from django.db import connections from django.db import connections
from django.db.backends.base.base import NO_DB_ALIAS from django.db.backends.base.base import NO_DB_ALIAS
from django.db.backends.postgresql.psycopg_any import is_psycopg3
def get_type_oids(connection_alias, type_name): def get_type_oids(connection_alias, type_name):
@ -32,7 +30,28 @@ def get_citext_oids(connection_alias):
return get_type_oids(connection_alias, "citext") return get_type_oids(connection_alias, "citext")
def register_type_handlers(connection, **kwargs): if is_psycopg3:
from psycopg.types import TypeInfo, hstore
def register_type_handlers(connection, **kwargs):
if connection.vendor != "postgresql" or connection.alias == NO_DB_ALIAS:
return
oids, array_oids = get_hstore_oids(connection.alias)
for oid, array_oid in zip(oids, array_oids):
ti = TypeInfo("hstore", oid, array_oid)
hstore.register_hstore(ti, connection.connection)
_, citext_oids = get_citext_oids(connection.alias)
for array_oid in citext_oids:
ti = TypeInfo("citext", 0, array_oid)
ti.register(connection.connection)
else:
import psycopg2
from psycopg2.extras import register_hstore
def register_type_handlers(connection, **kwargs):
if connection.vendor != "postgresql" or connection.alias == NO_DB_ALIAS: if connection.vendor != "postgresql" or connection.alias == NO_DB_ALIAS:
return return

View File

@ -207,7 +207,7 @@ class Command(BaseCommand):
self.models.add(obj.object.__class__) self.models.add(obj.object.__class__)
try: try:
obj.save(using=self.using) obj.save(using=self.using)
# psycopg2 raises ValueError if data contains NUL chars. # psycopg raises ValueError if data contains NUL chars.
except (DatabaseError, IntegrityError, ValueError) as e: except (DatabaseError, IntegrityError, ValueError) as e:
e.args = ( e.args = (
"Could not load %(object_label)s(pk=%(pk)s): %(error_msg)s" "Could not load %(object_label)s(pk=%(pk)s): %(error_msg)s"

View File

@ -164,6 +164,8 @@ class BaseDatabaseFeatures:
# Can we roll back DDL in a transaction? # Can we roll back DDL in a transaction?
can_rollback_ddl = False can_rollback_ddl = False
schema_editor_uses_clientside_param_binding = False
# Does it support operations requiring references rename in a transaction? # Does it support operations requiring references rename in a transaction?
supports_atomic_references_rename = True supports_atomic_references_rename = True
@ -335,6 +337,9 @@ class BaseDatabaseFeatures:
# Does the backend support the logical XOR operator? # Does the backend support the logical XOR operator?
supports_logical_xor = False supports_logical_xor = False
# Set to (exception, message) if null characters in text are disallowed.
prohibits_null_characters_in_text_exception = None
# Collation names for use by the Django test suite. # Collation names for use by the Django test suite.
test_collations = { test_collations = {
"ci": None, # Case-insensitive. "ci": None, # Case-insensitive.

View File

@ -525,6 +525,9 @@ class BaseDatabaseOperations:
else: else:
return value return value
def adapt_integerfield_value(self, value, internal_type):
return value
def adapt_datefield_value(self, value): def adapt_datefield_value(self, value):
""" """
Transform a date value to an object compatible with what is expected Transform a date value to an object compatible with what is expected

View File

@ -1,7 +1,7 @@
""" """
PostgreSQL database backend for Django. PostgreSQL database backend for Django.
Requires psycopg 2: https://www.psycopg.org/ Requires psycopg2 >= 2.8.4 or psycopg >= 3.1
""" """
import asyncio import asyncio
@ -21,48 +21,63 @@ from django.utils.safestring import SafeString
from django.utils.version import get_version_tuple from django.utils.version import get_version_tuple
try: try:
try:
import psycopg as Database
except ImportError:
import psycopg2 as Database import psycopg2 as Database
import psycopg2.extensions except ImportError:
import psycopg2.extras raise ImproperlyConfigured("Error loading psycopg2 or psycopg module")
except ImportError as e:
raise ImproperlyConfigured("Error loading psycopg2 module: %s" % e)
def psycopg2_version(): def psycopg_version():
version = psycopg2.__version__.split(" ", 1)[0] version = Database.__version__.split(" ", 1)[0]
return get_version_tuple(version) return get_version_tuple(version)
PSYCOPG2_VERSION = psycopg2_version() if psycopg_version() < (2, 8, 4):
if PSYCOPG2_VERSION < (2, 8, 4):
raise ImproperlyConfigured( raise ImproperlyConfigured(
"psycopg2 version 2.8.4 or newer is required; you have %s" f"psycopg2 version 2.8.4 or newer is required; you have {Database.__version__}"
% psycopg2.__version__ )
if (3,) <= psycopg_version() < (3, 1):
raise ImproperlyConfigured(
f"psycopg version 3.1 or newer is required; you have {Database.__version__}"
) )
# Some of these import psycopg2, so import them after checking if it's installed. from .psycopg_any import IsolationLevel, is_psycopg3 # NOQA isort:skip
from .client import DatabaseClient # NOQA
from .creation import DatabaseCreation # NOQA
from .features import DatabaseFeatures # NOQA
from .introspection import DatabaseIntrospection # NOQA
from .operations import DatabaseOperations # NOQA
from .psycopg_any import IsolationLevel # NOQA
from .schema import DatabaseSchemaEditor # NOQA
psycopg2.extensions.register_adapter(SafeString, psycopg2.extensions.QuotedString) if is_psycopg3:
psycopg2.extras.register_uuid() from psycopg import adapters, sql
from psycopg.pq import Format
# Register support for inet[] manually so we don't have to handle the Inet() from .psycopg_any import get_adapters_template, register_tzloader
# object on load all the time.
INETARRAY_OID = 1041 TIMESTAMPTZ_OID = adapters.types["timestamptz"].oid
INETARRAY = psycopg2.extensions.new_array_type(
else:
import psycopg2.extensions
import psycopg2.extras
psycopg2.extensions.register_adapter(SafeString, psycopg2.extensions.QuotedString)
psycopg2.extras.register_uuid()
# Register support for inet[] manually so we don't have to handle the Inet()
# object on load all the time.
INETARRAY_OID = 1041
INETARRAY = psycopg2.extensions.new_array_type(
(INETARRAY_OID,), (INETARRAY_OID,),
"INETARRAY", "INETARRAY",
psycopg2.extensions.UNICODE, psycopg2.extensions.UNICODE,
) )
psycopg2.extensions.register_type(INETARRAY) psycopg2.extensions.register_type(INETARRAY)
# Some of these import psycopg, so import them after checking if it's installed.
from .client import DatabaseClient # NOQA isort:skip
from .creation import DatabaseCreation # NOQA isort:skip
from .features import DatabaseFeatures # NOQA isort:skip
from .introspection import DatabaseIntrospection # NOQA isort:skip
from .operations import DatabaseOperations # NOQA isort:skip
from .schema import DatabaseSchemaEditor # NOQA isort:skip
class DatabaseWrapper(BaseDatabaseWrapper): class DatabaseWrapper(BaseDatabaseWrapper):
@ -209,6 +224,15 @@ class DatabaseWrapper(BaseDatabaseWrapper):
conn_params["host"] = settings_dict["HOST"] conn_params["host"] = settings_dict["HOST"]
if settings_dict["PORT"]: if settings_dict["PORT"]:
conn_params["port"] = settings_dict["PORT"] conn_params["port"] = settings_dict["PORT"]
if is_psycopg3:
conn_params["context"] = get_adapters_template(
settings.USE_TZ, self.timezone
)
# Disable prepared statements by default to keep connection poolers
# working. Can be reenabled via OPTIONS in the settings dict.
conn_params["prepare_threshold"] = conn_params.pop(
"prepare_threshold", None
)
return conn_params return conn_params
@async_unsafe @async_unsafe
@ -232,17 +256,19 @@ class DatabaseWrapper(BaseDatabaseWrapper):
except ValueError: except ValueError:
raise ImproperlyConfigured( raise ImproperlyConfigured(
f"Invalid transaction isolation level {isolation_level_value} " f"Invalid transaction isolation level {isolation_level_value} "
f"specified. Use one of the IsolationLevel values." f"specified. Use one of the psycopg.IsolationLevel values."
) )
connection = Database.connect(**conn_params) connection = self.Database.connect(**conn_params)
if set_isolation_level: if set_isolation_level:
connection.isolation_level = self.isolation_level connection.isolation_level = self.isolation_level
# Register dummy loads() to avoid a round trip from psycopg2's decode if not is_psycopg3:
# to json.dumps() to json.loads(), when using a custom decoder in # Register dummy loads() to avoid a round trip from psycopg2's
# JSONField. # decode to json.dumps() to json.loads(), when using a custom
# decoder in JSONField.
psycopg2.extras.register_default_jsonb( psycopg2.extras.register_default_jsonb(
conn_or_curs=connection, loads=lambda x: x conn_or_curs=connection, loads=lambda x: x
) )
connection.cursor_factory = Cursor
return connection return connection
def ensure_timezone(self): def ensure_timezone(self):
@ -275,6 +301,14 @@ class DatabaseWrapper(BaseDatabaseWrapper):
) )
else: else:
cursor = self.connection.cursor() cursor = self.connection.cursor()
if is_psycopg3:
# Register the cursor timezone only if the connection disagrees, to
# avoid copying the adapter map.
tzloader = self.connection.adapters.get_loader(TIMESTAMPTZ_OID, Format.TEXT)
if self.timezone != tzloader.timezone:
register_tzloader(self.timezone, cursor)
else:
cursor.tzinfo_factory = self.tzinfo_factory if settings.USE_TZ else None cursor.tzinfo_factory = self.tzinfo_factory if settings.USE_TZ else None
return cursor return cursor
@ -379,7 +413,39 @@ class DatabaseWrapper(BaseDatabaseWrapper):
return CursorDebugWrapper(cursor, self) return CursorDebugWrapper(cursor, self)
class CursorDebugWrapper(BaseCursorDebugWrapper): if is_psycopg3:
class Cursor(Database.Cursor):
"""
A subclass of psycopg cursor implementing callproc.
"""
def callproc(self, name, args=None):
if not isinstance(name, sql.Identifier):
name = sql.Identifier(name)
qparts = [sql.SQL("SELECT * FROM "), name, sql.SQL("(")]
if args:
for item in args:
qparts.append(sql.Literal(item))
qparts.append(sql.SQL(","))
del qparts[-1]
qparts.append(sql.SQL(")"))
stmt = sql.Composed(qparts)
self.execute(stmt)
return args
class CursorDebugWrapper(BaseCursorDebugWrapper):
def copy(self, statement):
with self.debug_sql(statement):
return self.cursor.copy(statement)
else:
Cursor = psycopg2.extensions.cursor
class CursorDebugWrapper(BaseCursorDebugWrapper):
def copy_expert(self, sql, file, *args): def copy_expert(self, sql, file, *args):
with self.debug_sql(sql): with self.debug_sql(sql):
return self.cursor.copy_expert(sql, file, *args) return self.cursor.copy_expert(sql, file, *args)

View File

@ -1,7 +1,8 @@
import operator import operator
from django.db import InterfaceError from django.db import DataError, InterfaceError
from django.db.backends.base.features import BaseDatabaseFeatures from django.db.backends.base.features import BaseDatabaseFeatures
from django.db.backends.postgresql.psycopg_any import is_psycopg3
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -26,6 +27,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
can_introspect_materialized_views = True can_introspect_materialized_views = True
can_distinct_on_fields = True can_distinct_on_fields = True
can_rollback_ddl = True can_rollback_ddl = True
schema_editor_uses_clientside_param_binding = True
supports_combined_alters = True supports_combined_alters = True
nulls_order_largest = True nulls_order_largest = True
closed_cursor_error_class = InterfaceError closed_cursor_error_class = InterfaceError
@ -81,6 +83,13 @@ class DatabaseFeatures(BaseDatabaseFeatures):
}, },
} }
@cached_property
def prohibits_null_characters_in_text_exception(self):
if is_psycopg3:
return DataError, "PostgreSQL text fields cannot contain NUL (0x00) bytes"
else:
return ValueError, "A string literal cannot contain NUL (0x00) characters."
@cached_property @cached_property
def introspected_field_types(self): def introspected_field_types(self):
return { return {

View File

@ -3,9 +3,16 @@ from functools import lru_cache, partial
from django.conf import settings from django.conf import settings
from django.db.backends.base.operations import BaseDatabaseOperations from django.db.backends.base.operations import BaseDatabaseOperations
from django.db.backends.postgresql.psycopg_any import Inet, Jsonb, mogrify from django.db.backends.postgresql.psycopg_any import (
Inet,
Jsonb,
errors,
is_psycopg3,
mogrify,
)
from django.db.backends.utils import split_tzname_delta from django.db.backends.utils import split_tzname_delta
from django.db.models.constants import OnConflict from django.db.models.constants import OnConflict
from django.utils.regex_helper import _lazy_re_compile
@lru_cache @lru_cache
@ -36,6 +43,18 @@ class DatabaseOperations(BaseDatabaseOperations):
"SmallAutoField": "smallint", "SmallAutoField": "smallint",
} }
if is_psycopg3:
from psycopg.types import numeric
integerfield_type_map = {
"SmallIntegerField": numeric.Int2,
"IntegerField": numeric.Int4,
"BigIntegerField": numeric.Int8,
"PositiveSmallIntegerField": numeric.Int2,
"PositiveIntegerField": numeric.Int4,
"PositiveBigIntegerField": numeric.Int8,
}
def unification_cast_sql(self, output_field): def unification_cast_sql(self, output_field):
internal_type = output_field.get_internal_type() internal_type = output_field.get_internal_type()
if internal_type in ( if internal_type in (
@ -56,19 +75,23 @@ class DatabaseOperations(BaseDatabaseOperations):
) )
return "%s" return "%s"
# EXTRACT format cannot be passed in parameters.
_extract_format_re = _lazy_re_compile(r"[A-Z_]+")
def date_extract_sql(self, lookup_type, sql, params): def date_extract_sql(self, lookup_type, sql, params):
# https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-EXTRACT # https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-EXTRACT
extract_sql = f"EXTRACT(%s FROM {sql})"
extract_param = lookup_type
if lookup_type == "week_day": if lookup_type == "week_day":
# For consistency across backends, we return Sunday=1, Saturday=7. # For consistency across backends, we return Sunday=1, Saturday=7.
extract_sql = f"EXTRACT(%s FROM {sql}) + 1" return f"EXTRACT(DOW FROM {sql}) + 1", params
extract_param = "dow"
elif lookup_type == "iso_week_day": elif lookup_type == "iso_week_day":
extract_param = "isodow" return f"EXTRACT(ISODOW FROM {sql})", params
elif lookup_type == "iso_year": elif lookup_type == "iso_year":
extract_param = "isoyear" return f"EXTRACT(ISOYEAR FROM {sql})", params
return extract_sql, (extract_param, *params)
lookup_type = lookup_type.upper()
if not self._extract_format_re.fullmatch(lookup_type):
raise ValueError(f"Invalid lookup type: {lookup_type!r}")
return f"EXTRACT({lookup_type} FROM {sql})", params
def date_trunc_sql(self, lookup_type, sql, params, tzname=None): def date_trunc_sql(self, lookup_type, sql, params, tzname=None):
sql, params = self._convert_sql_to_tz(sql, params, tzname) sql, params = self._convert_sql_to_tz(sql, params, tzname)
@ -100,10 +123,7 @@ class DatabaseOperations(BaseDatabaseOperations):
sql, params = self._convert_sql_to_tz(sql, params, tzname) sql, params = self._convert_sql_to_tz(sql, params, tzname)
if lookup_type == "second": if lookup_type == "second":
# Truncate fractional seconds. # Truncate fractional seconds.
return ( return f"EXTRACT(SECOND FROM DATE_TRUNC(%s, {sql}))", ("second", *params)
f"EXTRACT(%s FROM DATE_TRUNC(%s, {sql}))",
("second", "second", *params),
)
return self.date_extract_sql(lookup_type, sql, params) return self.date_extract_sql(lookup_type, sql, params)
def datetime_trunc_sql(self, lookup_type, sql, params, tzname): def datetime_trunc_sql(self, lookup_type, sql, params, tzname):
@ -114,10 +134,7 @@ class DatabaseOperations(BaseDatabaseOperations):
def time_extract_sql(self, lookup_type, sql, params): def time_extract_sql(self, lookup_type, sql, params):
if lookup_type == "second": if lookup_type == "second":
# Truncate fractional seconds. # Truncate fractional seconds.
return ( return f"EXTRACT(SECOND FROM DATE_TRUNC(%s, {sql}))", ("second", *params)
f"EXTRACT(%s FROM DATE_TRUNC(%s, {sql}))",
("second", "second", *params),
)
return self.date_extract_sql(lookup_type, sql, params) return self.date_extract_sql(lookup_type, sql, params)
def time_trunc_sql(self, lookup_type, sql, params, tzname=None): def time_trunc_sql(self, lookup_type, sql, params, tzname=None):
@ -137,6 +154,16 @@ class DatabaseOperations(BaseDatabaseOperations):
def lookup_cast(self, lookup_type, internal_type=None): def lookup_cast(self, lookup_type, internal_type=None):
lookup = "%s" lookup = "%s"
if lookup_type == "isnull" and internal_type in (
"CharField",
"EmailField",
"TextField",
"CICharField",
"CIEmailField",
"CITextField",
):
return "%s::text"
# Cast text lookups to text to allow things like filter(x__contains=4) # Cast text lookups to text to allow things like filter(x__contains=4)
if lookup_type in ( if lookup_type in (
"iexact", "iexact",
@ -178,7 +205,7 @@ class DatabaseOperations(BaseDatabaseOperations):
return mogrify(sql, params, self.connection) return mogrify(sql, params, self.connection)
def set_time_zone_sql(self): def set_time_zone_sql(self):
return "SET TIME ZONE %s" return "SELECT set_config('TimeZone', %s, false)"
def sql_flush(self, style, tables, *, reset_sequences=False, allow_cascade=False): def sql_flush(self, style, tables, *, reset_sequences=False, allow_cascade=False):
if not tables: if not tables:
@ -278,6 +305,16 @@ class DatabaseOperations(BaseDatabaseOperations):
else: else:
return ["DISTINCT"], [] return ["DISTINCT"], []
if is_psycopg3:
def last_executed_query(self, cursor, sql, params):
try:
return self.compose_sql(sql, params)
except errors.DataError:
return None
else:
def last_executed_query(self, cursor, sql, params): def last_executed_query(self, cursor, sql, params):
# https://www.psycopg.org/docs/cursor.html#cursor.query # https://www.psycopg.org/docs/cursor.html#cursor.query
# The query attribute is a Psycopg extension to the DB API 2.0. # The query attribute is a Psycopg extension to the DB API 2.0.
@ -303,6 +340,13 @@ class DatabaseOperations(BaseDatabaseOperations):
values_sql = ", ".join("(%s)" % sql for sql in placeholder_rows_sql) values_sql = ", ".join("(%s)" % sql for sql in placeholder_rows_sql)
return "VALUES " + values_sql return "VALUES " + values_sql
if is_psycopg3:
def adapt_integerfield_value(self, value, internal_type):
if value is None or hasattr(value, "resolve_expression"):
return value
return self.integerfield_type_map[internal_type](value)
def adapt_datefield_value(self, value): def adapt_datefield_value(self, value):
return value return value

View File

@ -1,31 +1,102 @@
from enum import IntEnum import ipaddress
from functools import lru_cache
from psycopg2 import errors, extensions, sql # NOQA try:
from psycopg2.extras import DateRange, DateTimeRange, DateTimeTZRange, Inet # NOQA from psycopg import ClientCursor, IsolationLevel, adapt, adapters, errors, sql
from psycopg2.extras import Json as Jsonb # NOQA from psycopg.postgres import types
from psycopg2.extras import NumericRange, Range # NOQA from psycopg.types.datetime import TimestamptzLoader
from psycopg.types.json import Jsonb
from psycopg.types.range import Range, RangeDumper
from psycopg.types.string import TextLoader
RANGE_TYPES = (DateRange, DateTimeRange, DateTimeTZRange, NumericRange) Inet = ipaddress.ip_address
DateRange = DateTimeRange = DateTimeTZRange = NumericRange = Range
RANGE_TYPES = (Range,)
class IsolationLevel(IntEnum): TSRANGE_OID = types["tsrange"].oid
TSTZRANGE_OID = types["tstzrange"].oid
def mogrify(sql, params, connection):
return ClientCursor(connection.connection).mogrify(sql, params)
# Adapters.
class BaseTzLoader(TimestamptzLoader):
"""
Load a PostgreSQL timestamptz using the a specific timezone.
The timezone can be None too, in which case it will be chopped.
"""
timezone = None
def load(self, data):
res = super().load(data)
return res.replace(tzinfo=self.timezone)
def register_tzloader(tz, context):
class SpecificTzLoader(BaseTzLoader):
timezone = tz
context.adapters.register_loader("timestamptz", SpecificTzLoader)
class DjangoRangeDumper(RangeDumper):
"""A Range dumper customized for Django."""
def upgrade(self, obj, format):
# Dump ranges containing naive datetimes as tstzrange, because
# Django doesn't use tz-aware ones.
dumper = super().upgrade(obj, format)
if dumper is not self and dumper.oid == TSRANGE_OID:
dumper.oid = TSTZRANGE_OID
return dumper
@lru_cache
def get_adapters_template(use_tz, timezone):
# Create at adapters map extending the base one.
ctx = adapt.AdaptersMap(adapters)
# Register a no-op dumper to avoid a round trip from psycopg version 3
# decode to json.dumps() to json.loads(), when using a custom decoder
# in JSONField.
ctx.register_loader("jsonb", TextLoader)
# Don't convert automatically from PostgreSQL network types to Python
# ipaddress.
ctx.register_loader("inet", TextLoader)
ctx.register_loader("cidr", TextLoader)
ctx.register_dumper(Range, DjangoRangeDumper)
# Register a timestamptz loader configured on self.timezone.
# This, however, can be overridden by create_cursor.
register_tzloader(timezone, ctx)
return ctx
is_psycopg3 = True
except ImportError:
from enum import IntEnum
from psycopg2 import errors, extensions, sql # NOQA
from psycopg2.extras import DateRange, DateTimeRange, DateTimeTZRange, Inet # NOQA
from psycopg2.extras import Json as Jsonb # NOQA
from psycopg2.extras import NumericRange, Range # NOQA
RANGE_TYPES = (DateRange, DateTimeRange, DateTimeTZRange, NumericRange)
class IsolationLevel(IntEnum):
READ_UNCOMMITTED = extensions.ISOLATION_LEVEL_READ_UNCOMMITTED READ_UNCOMMITTED = extensions.ISOLATION_LEVEL_READ_UNCOMMITTED
READ_COMMITTED = extensions.ISOLATION_LEVEL_READ_COMMITTED READ_COMMITTED = extensions.ISOLATION_LEVEL_READ_COMMITTED
REPEATABLE_READ = extensions.ISOLATION_LEVEL_REPEATABLE_READ REPEATABLE_READ = extensions.ISOLATION_LEVEL_REPEATABLE_READ
SERIALIZABLE = extensions.ISOLATION_LEVEL_SERIALIZABLE SERIALIZABLE = extensions.ISOLATION_LEVEL_SERIALIZABLE
def _quote(value, connection=None):
def _quote(value, connection=None):
adapted = extensions.adapt(value) adapted = extensions.adapt(value)
if hasattr(adapted, "encoding"): if hasattr(adapted, "encoding"):
adapted.encoding = "utf8" adapted.encoding = "utf8"
# getquoted() returns a quoted bytestring of the adapted value. # getquoted() returns a quoted bytestring of the adapted value.
return adapted.getquoted().decode() return adapted.getquoted().decode()
sql.quote = _quote
sql.quote = _quote def mogrify(sql, params, connection):
def mogrify(sql, params, connection):
with connection.cursor() as cursor: with connection.cursor() as cursor:
return cursor.mogrify(sql, params).decode() return cursor.mogrify(sql, params).decode()
is_psycopg3 = False

View File

@ -40,6 +40,14 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
) )
sql_delete_procedure = "DROP FUNCTION %(procedure)s(%(param_types)s)" sql_delete_procedure = "DROP FUNCTION %(procedure)s(%(param_types)s)"
def execute(self, sql, params=()):
# Merge the query client-side, as PostgreSQL won't do it server-side.
if params is None:
return super().execute(sql, params)
sql = self.connection.ops.compose_sql(str(sql), params)
# Don't let the superclass touch anything.
return super().execute(sql, None)
sql_add_identity = ( sql_add_identity = (
"ALTER TABLE %(table)s ALTER COLUMN %(column)s ADD " "ALTER TABLE %(table)s ALTER COLUMN %(column)s ADD "
"GENERATED BY DEFAULT AS IDENTITY" "GENERATED BY DEFAULT AS IDENTITY"

View File

@ -2019,6 +2019,10 @@ class IntegerField(Field):
"Field '%s' expected a number but got %r." % (self.name, value), "Field '%s' expected a number but got %r." % (self.name, value),
) from e ) from e
def get_db_prep_value(self, value, connection, prepared=False):
value = super().get_db_prep_value(value, connection, prepared)
return connection.ops.adapt_integerfield_value(value, self.get_internal_type())
def get_internal_type(self): def get_internal_type(self):
return "IntegerField" return "IntegerField"

View File

@ -1,6 +1,7 @@
"""Database functions that do comparisons or type conversions.""" """Database functions that do comparisons or type conversions."""
from django.db import NotSupportedError from django.db import NotSupportedError
from django.db.models.expressions import Func, Value from django.db.models.expressions import Func, Value
from django.db.models.fields import TextField
from django.db.models.fields.json import JSONField from django.db.models.fields.json import JSONField
from django.utils.regex_helper import _lazy_re_compile from django.utils.regex_helper import _lazy_re_compile
@ -158,7 +159,14 @@ class JSONObject(Func):
return super().as_sql(compiler, connection, **extra_context) return super().as_sql(compiler, connection, **extra_context)
def as_postgresql(self, compiler, connection, **extra_context): def as_postgresql(self, compiler, connection, **extra_context):
return self.as_sql( copy = self.copy()
copy.set_source_expressions(
[
Cast(expression, TextField()) if index % 2 == 0 else expression
for index, expression in enumerate(copy.get_source_expressions())
]
)
return super(JSONObject, copy).as_sql(
compiler, compiler,
connection, connection,
function="JSONB_BUILD_OBJECT", function="JSONB_BUILD_OBJECT",

View File

@ -1,7 +1,7 @@
from django.db import NotSupportedError from django.db import NotSupportedError
from django.db.models.expressions import Func, Value from django.db.models.expressions import Func, Value
from django.db.models.fields import CharField, IntegerField from django.db.models.fields import CharField, IntegerField, TextField
from django.db.models.functions import Coalesce from django.db.models.functions import Cast, Coalesce
from django.db.models.lookups import Transform from django.db.models.lookups import Transform
@ -82,6 +82,20 @@ class ConcatPair(Func):
**extra_context, **extra_context,
) )
def as_postgresql(self, compiler, connection, **extra_context):
copy = self.copy()
copy.set_source_expressions(
[
Cast(expression, TextField())
for expression in copy.get_source_expressions()
]
)
return super(ConcatPair, copy).as_sql(
compiler,
connection,
**extra_context,
)
def as_mysql(self, compiler, connection, **extra_context): def as_mysql(self, compiler, connection, **extra_context):
# Use CONCAT_WS with an empty separator so that NULLs are ignored. # Use CONCAT_WS with an empty separator so that NULLs are ignored.
return super().as_sql( return super().as_sql(

View File

@ -568,7 +568,7 @@ class IsNull(BuiltinLookup):
raise ValueError( raise ValueError(
"The QuerySet value for an isnull lookup must be True or False." "The QuerySet value for an isnull lookup must be True or False."
) )
sql, params = compiler.compile(self.lhs) sql, params = self.process_lhs(compiler, connection)
if self.rhs: if self.rhs:
return "%s IS NULL" % sql, params return "%s IS NULL" % sql, params
else: else:

View File

@ -174,7 +174,7 @@ pygments_style = "trac"
intersphinx_mapping = { intersphinx_mapping = {
"python": ("https://docs.python.org/3/", None), "python": ("https://docs.python.org/3/", None),
"sphinx": ("https://www.sphinx-doc.org/en/master/", None), "sphinx": ("https://www.sphinx-doc.org/en/master/", None),
"psycopg2": ("https://www.psycopg.org/docs/", None), "psycopg": ("https://www.psycopg.org/psycopg3/docs/", None),
} }
# Python's docs don't change every week. # Python's docs don't change every week.

View File

@ -429,14 +429,14 @@ Install Django and set up database
recommended that you create a :doc:`virtual environment recommended that you create a :doc:`virtual environment
<python:tutorial/venv>` for each project you create. <python:tutorial/venv>` for each project you create.
psycopg2 psycopg
~~~~~~~~ ~~~~~~~
The ``psycopg2`` Python module provides the interface between Python and the The ``psycopg`` Python module provides the interface between Python and the
PostgreSQL database. ``psycopg2`` can be installed via pip within your Python PostgreSQL database. ``psycopg`` can be installed via pip within your Python
virtual environment:: virtual environment::
...\> py -m pip install psycopg2 ...\> py -m pip install psycopg
.. rubric:: Footnotes .. rubric:: Footnotes
.. [#] GeoDjango uses the :func:`~ctypes.util.find_library` routine from .. [#] GeoDjango uses the :func:`~ctypes.util.find_library` routine from

View File

@ -7,20 +7,25 @@ into a spatial database. :ref:`geosbuild`, :ref:`proj4` and
:ref:`gdalbuild` should be installed prior to building PostGIS. You :ref:`gdalbuild` should be installed prior to building PostGIS. You
might also need additional libraries, see `PostGIS requirements`_. might also need additional libraries, see `PostGIS requirements`_.
The `psycopg2`_ module is required for use as the database adapter when using The `psycopg`_ or `psycopg2`_ module is required for use as the database
GeoDjango with PostGIS. adapter when using GeoDjango with PostGIS.
On Debian/Ubuntu, you are advised to install the following packages: On Debian/Ubuntu, you are advised to install the following packages:
``postgresql-x``, ``postgresql-x-postgis-3``, ``postgresql-server-dev-x``, ``postgresql-x``, ``postgresql-x-postgis-3``, ``postgresql-server-dev-x``,
and ``python3-psycopg2`` (x matching the PostgreSQL version you want to and ``python3-psycopg3`` (x matching the PostgreSQL version you want to
install). Alternately, you can `build from source`_. Consult the install). Alternately, you can `build from source`_. Consult the
platform-specific instructions if you are on :ref:`macos` or :ref:`windows`. platform-specific instructions if you are on :ref:`macos` or :ref:`windows`.
.. _PostGIS: https://postgis.net/ .. _PostGIS: https://postgis.net/
.. _psycopg: https://www.psycopg.org/psycopg3/
.. _psycopg2: https://www.psycopg.org/ .. _psycopg2: https://www.psycopg.org/
.. _PostGIS requirements: https://postgis.net/docs/postgis_installation.html#install_requirements .. _PostGIS requirements: https://postgis.net/docs/postgis_installation.html#install_requirements
.. _build from source: https://postgis.net/docs/postgis_installation.html#install_short_version .. _build from source: https://postgis.net/docs/postgis_installation.html#install_short_version
.. versionchanged:: 4.2
Support for ``psycopg`` 3.1+ was added.
Post-installation Post-installation
================= =================

View File

@ -538,8 +538,8 @@ PostgreSQL. These fields are used to store a range of values; for example the
start and end timestamps of an event, or the range of ages an activity is start and end timestamps of an event, or the range of ages an activity is
suitable for. suitable for.
All of the range fields translate to :ref:`psycopg2 Range objects All of the range fields translate to :ref:`psycopg Range objects
<psycopg2:adapt-range>` in Python, but also accept tuples as input if no bounds <psycopg:adapt-range>` in Python, but also accept tuples as input if no bounds
information is necessary. The default is lower bound included, upper bound information is necessary. The default is lower bound included, upper bound
excluded, that is ``[)`` (see the PostgreSQL documentation for details about excluded, that is ``[)`` (see the PostgreSQL documentation for details about
`different bounds`_). The default bounds can be changed for non-discrete range `different bounds`_). The default bounds can be changed for non-discrete range
@ -553,8 +553,8 @@ the ``default_bounds`` argument.
Stores a range of integers. Based on an Stores a range of integers. Based on an
:class:`~django.db.models.IntegerField`. Represented by an ``int4range`` in :class:`~django.db.models.IntegerField`. Represented by an ``int4range`` in
the database and a :class:`~psycopg2:psycopg2.extras.NumericRange` in the database and a
Python. ``django.db.backends.postgresql.psycopg_any.NumericRange`` in Python.
Regardless of the bounds specified when saving the data, PostgreSQL always Regardless of the bounds specified when saving the data, PostgreSQL always
returns a range in a canonical form that includes the lower bound and returns a range in a canonical form that includes the lower bound and
@ -567,8 +567,8 @@ the ``default_bounds`` argument.
Stores a range of large integers. Based on a Stores a range of large integers. Based on a
:class:`~django.db.models.BigIntegerField`. Represented by an ``int8range`` :class:`~django.db.models.BigIntegerField`. Represented by an ``int8range``
in the database and a :class:`~psycopg2:psycopg2.extras.NumericRange` in in the database and a
Python. ``django.db.backends.postgresql.psycopg_any.NumericRange`` in Python.
Regardless of the bounds specified when saving the data, PostgreSQL always Regardless of the bounds specified when saving the data, PostgreSQL always
returns a range in a canonical form that includes the lower bound and returns a range in a canonical form that includes the lower bound and
@ -581,8 +581,8 @@ the ``default_bounds`` argument.
Stores a range of floating point values. Based on a Stores a range of floating point values. Based on a
:class:`~django.db.models.DecimalField`. Represented by a ``numrange`` in :class:`~django.db.models.DecimalField`. Represented by a ``numrange`` in
the database and a :class:`~psycopg2:psycopg2.extras.NumericRange` in the database and a
Python. ``django.db.backends.postgresql.psycopg_any.NumericRange`` in Python.
.. attribute:: DecimalRangeField.default_bounds .. attribute:: DecimalRangeField.default_bounds
@ -592,7 +592,7 @@ the ``default_bounds`` argument.
default is lower bound included, upper bound excluded, that is ``[)`` default is lower bound included, upper bound excluded, that is ``[)``
(see the PostgreSQL documentation for details about (see the PostgreSQL documentation for details about
`different bounds`_). ``default_bounds`` is not used for `different bounds`_). ``default_bounds`` is not used for
:class:`~psycopg2:psycopg2.extras.NumericRange` inputs. ``django.db.backends.postgresql.psycopg_any.NumericRange`` inputs.
``DateTimeRangeField`` ``DateTimeRangeField``
---------------------- ----------------------
@ -601,8 +601,8 @@ the ``default_bounds`` argument.
Stores a range of timestamps. Based on a Stores a range of timestamps. Based on a
:class:`~django.db.models.DateTimeField`. Represented by a ``tstzrange`` in :class:`~django.db.models.DateTimeField`. Represented by a ``tstzrange`` in
the database and a :class:`~psycopg2:psycopg2.extras.DateTimeTZRange` in the database and a
Python. ``django.db.backends.postgresql.psycopg_any.DateTimeTZRange`` in Python.
.. attribute:: DateTimeRangeField.default_bounds .. attribute:: DateTimeRangeField.default_bounds
@ -612,7 +612,7 @@ the ``default_bounds`` argument.
default is lower bound included, upper bound excluded, that is ``[)`` default is lower bound included, upper bound excluded, that is ``[)``
(see the PostgreSQL documentation for details about (see the PostgreSQL documentation for details about
`different bounds`_). ``default_bounds`` is not used for `different bounds`_). ``default_bounds`` is not used for
:class:`~psycopg2:psycopg2.extras.DateTimeTZRange` inputs. ``django.db.backends.postgresql.psycopg_any.DateTimeTZRange`` inputs.
``DateRangeField`` ``DateRangeField``
------------------ ------------------
@ -621,7 +621,8 @@ the ``default_bounds`` argument.
Stores a range of dates. Based on a Stores a range of dates. Based on a
:class:`~django.db.models.DateField`. Represented by a ``daterange`` in the :class:`~django.db.models.DateField`. Represented by a ``daterange`` in the
database and a :class:`~psycopg2:psycopg2.extras.DateRange` in Python. database and a ``django.db.backends.postgresql.psycopg_any.DateRange`` in
Python.
Regardless of the bounds specified when saving the data, PostgreSQL always Regardless of the bounds specified when saving the data, PostgreSQL always
returns a range in a canonical form that includes the lower bound and returns a range in a canonical form that includes the lower bound and
@ -655,7 +656,7 @@ We will also use the following example objects::
and ``NumericRange``: and ``NumericRange``:
>>> from psycopg2.extras import NumericRange >>> from django.db.backends.postgresql.psycopg_any import NumericRange
Containment functions Containment functions
~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~
@ -690,7 +691,7 @@ The ``contained_by`` lookup is also available on the non-range field types:
:class:`~django.db.models.DateField`, and :class:`~django.db.models.DateField`, and
:class:`~django.db.models.DateTimeField`. For example:: :class:`~django.db.models.DateTimeField`. For example::
>>> from psycopg2.extras import DateTimeTZRange >>> from django.db.backends.postgresql.psycopg_any import DateTimeTZRange
>>> Event.objects.filter( >>> Event.objects.filter(
... start__contained_by=DateTimeTZRange( ... start__contained_by=DateTimeTZRange(
... timezone.now() - datetime.timedelta(hours=1), ... timezone.now() - datetime.timedelta(hours=1),
@ -864,9 +865,9 @@ Defining your own range types
----------------------------- -----------------------------
PostgreSQL allows the definition of custom range types. Django's model and form PostgreSQL allows the definition of custom range types. Django's model and form
field implementations use base classes below, and psycopg2 provides a field implementations use base classes below, and ``psycopg`` provides a
:func:`~psycopg2:psycopg2.extras.register_range` to allow use of custom range :func:`~psycopg:psycopg.types.range.register_range` to allow use of custom
types. range types.
.. class:: RangeField(**options) .. class:: RangeField(**options)
@ -878,7 +879,7 @@ types.
.. attribute:: range_type .. attribute:: range_type
The psycopg2 range type to use. The range type to use.
.. attribute:: form_field .. attribute:: form_field
@ -895,7 +896,7 @@ types.
.. attribute:: range_type .. attribute:: range_type
The psycopg2 range type to use. The range type to use.
Range operators Range operators
--------------- ---------------

View File

@ -173,7 +173,7 @@ not greater than the upper bound. All of these fields use
.. class:: IntegerRangeField .. class:: IntegerRangeField
Based on :class:`~django.forms.IntegerField` and translates its input into Based on :class:`~django.forms.IntegerField` and translates its input into
:class:`~psycopg2:psycopg2.extras.NumericRange`. Default for ``django.db.backends.postgresql.psycopg_any.NumericRange``. Default for
:class:`~django.contrib.postgres.fields.IntegerRangeField` and :class:`~django.contrib.postgres.fields.IntegerRangeField` and
:class:`~django.contrib.postgres.fields.BigIntegerRangeField`. :class:`~django.contrib.postgres.fields.BigIntegerRangeField`.
@ -183,7 +183,7 @@ not greater than the upper bound. All of these fields use
.. class:: DecimalRangeField .. class:: DecimalRangeField
Based on :class:`~django.forms.DecimalField` and translates its input into Based on :class:`~django.forms.DecimalField` and translates its input into
:class:`~psycopg2:psycopg2.extras.NumericRange`. Default for ``django.db.backends.postgresql.psycopg_any.NumericRange``. Default for
:class:`~django.contrib.postgres.fields.DecimalRangeField`. :class:`~django.contrib.postgres.fields.DecimalRangeField`.
``DateTimeRangeField`` ``DateTimeRangeField``
@ -192,7 +192,7 @@ not greater than the upper bound. All of these fields use
.. class:: DateTimeRangeField .. class:: DateTimeRangeField
Based on :class:`~django.forms.DateTimeField` and translates its input into Based on :class:`~django.forms.DateTimeField` and translates its input into
:class:`~psycopg2:psycopg2.extras.DateTimeTZRange`. Default for ``django.db.backends.postgresql.psycopg_any.DateTimeTZRange``. Default for
:class:`~django.contrib.postgres.fields.DateTimeRangeField`. :class:`~django.contrib.postgres.fields.DateTimeRangeField`.
``DateRangeField`` ``DateRangeField``
@ -201,7 +201,7 @@ not greater than the upper bound. All of these fields use
.. class:: DateRangeField .. class:: DateRangeField
Based on :class:`~django.forms.DateField` and translates its input into Based on :class:`~django.forms.DateField` and translates its input into
:class:`~psycopg2:psycopg2.extras.DateRange`. Default for ``django.db.backends.postgresql.psycopg_any.DateRange``. Default for
:class:`~django.contrib.postgres.fields.DateRangeField`. :class:`~django.contrib.postgres.fields.DateRangeField`.
Widgets Widgets

View File

@ -114,11 +114,21 @@ below for information on how to set up your database correctly.
PostgreSQL notes PostgreSQL notes
================ ================
Django supports PostgreSQL 12 and higher. `psycopg2`_ 2.8.4 or higher is Django supports PostgreSQL 12 and higher. `psycopg`_ 3.1+ or `psycopg2`_ 2.8.4+
required, though the latest release is recommended. is required, though the latest `psycopg`_ 3.1+ is recommended.
.. _psycopg: https://www.psycopg.org/psycopg3/
.. _psycopg2: https://www.psycopg.org/ .. _psycopg2: https://www.psycopg.org/
.. note::
Support for ``psycopg2`` is likely to be deprecated and removed at some
point in the future.
.. versionchanged:: 4.2
Support for ``psycopg`` 3.1+ was added.
.. _postgresql-connection-settings: .. _postgresql-connection-settings:
PostgreSQL connection settings PostgreSQL connection settings
@ -199,12 +209,12 @@ level`_. If you need a higher isolation level such as ``REPEATABLE READ`` or
``SERIALIZABLE``, set it in the :setting:`OPTIONS` part of your database ``SERIALIZABLE``, set it in the :setting:`OPTIONS` part of your database
configuration in :setting:`DATABASES`:: configuration in :setting:`DATABASES`::
import psycopg2.extensions from django.db.backends.postgresql.psycopg_any import IsolationLevel
DATABASES = { DATABASES = {
# ... # ...
'OPTIONS': { 'OPTIONS': {
'isolation_level': psycopg2.extensions.ISOLATION_LEVEL_SERIALIZABLE, 'isolation_level': IsolationLevel.SERIALIZABLE,
}, },
} }
@ -216,6 +226,10 @@ configuration in :setting:`DATABASES`::
.. _isolation level: https://www.postgresql.org/docs/current/transaction-iso.html .. _isolation level: https://www.postgresql.org/docs/current/transaction-iso.html
.. versionchanged:: 4.2
``IsolationLevel`` was added.
Indexes for ``varchar`` and ``text`` columns Indexes for ``varchar`` and ``text`` columns
-------------------------------------------- --------------------------------------------
@ -244,7 +258,7 @@ Server-side cursors
When using :meth:`QuerySet.iterator() When using :meth:`QuerySet.iterator()
<django.db.models.query.QuerySet.iterator>`, Django opens a :ref:`server-side <django.db.models.query.QuerySet.iterator>`, Django opens a :ref:`server-side
cursor <psycopg2:server-side-cursors>`. By default, PostgreSQL assumes that cursor <psycopg:server-side-cursors>`. By default, PostgreSQL assumes that
only the first 10% of the results of cursor queries will be fetched. The query only the first 10% of the results of cursor queries will be fetched. The query
planner spends less time planning the query and starts returning results planner spends less time planning the query and starts returning results
faster, but this could diminish performance if more than 10% of the results are faster, but this could diminish performance if more than 10% of the results are

View File

@ -256,10 +256,11 @@ Database backends
* Added the :setting:`TEST['TEMPLATE'] <TEST_TEMPLATE>` setting to let * Added the :setting:`TEST['TEMPLATE'] <TEST_TEMPLATE>` setting to let
PostgreSQL users specify a template for creating the test database. PostgreSQL users specify a template for creating the test database.
* :meth:`.QuerySet.iterator()` now uses :ref:`server-side cursors * :meth:`.QuerySet.iterator()` now uses `server-side cursors`_ on PostgreSQL.
<psycopg2:server-side-cursors>` on PostgreSQL. This feature transfers some of This feature transfers some of the worker memory load (used to hold query
the worker memory load (used to hold query results) to the database and might results) to the database and might increase database memory usage.
increase database memory usage.
.. _server-side cursors: https://www.psycopg.org/docs/usage.html#server-side-cursors
* Added MySQL support for the ``'isolation_level'`` option in * Added MySQL support for the ``'isolation_level'`` option in
:setting:`OPTIONS` to allow specifying the :ref:`transaction isolation level :setting:`OPTIONS` to allow specifying the :ref:`transaction isolation level

View File

@ -26,6 +26,20 @@ and only officially support the latest release of each series.
What's new in Django 4.2 What's new in Django 4.2
======================== ========================
Psycopg 3 support
-----------------
Django now supports `psycopg`_ version 3.1 or higher. To update your code,
install the `psycopg library`_, you don't need to change the
:setting:`ENGINE <DATABASE-ENGINE>` as ``django.db.backends.postgresql``
supports both libraries.
Support for ``psycopg2`` is likely to be deprecated and removed at some point
in the future.
.. _psycopg: https://www.psycopg.org/psycopg3/
.. _psycopg library: https://pypi.org/project/psycopg/
Minor features Minor features
-------------- --------------

View File

@ -397,8 +397,8 @@ tasks, etc.), this should be fine. If it's not (if your follow-up action is so
critical that its failure should mean the failure of the transaction itself), critical that its failure should mean the failure of the transaction itself),
then you don't want to use the :func:`on_commit` hook. Instead, you may want then you don't want to use the :func:`on_commit` hook. Instead, you may want
`two-phase commit`_ such as the :ref:`psycopg Two-Phase Commit protocol support `two-phase commit`_ such as the :ref:`psycopg Two-Phase Commit protocol support
<psycopg2:tpc>` and the :pep:`optional Two-Phase Commit Extensions in the <psycopg:two-phase-commit>` and the :pep:`optional Two-Phase Commit Extensions
Python DB-API specification <249#optional-two-phase-commit-extensions>`. in the Python DB-API specification <249#optional-two-phase-commit-extensions>`.
Callbacks are not run until autocommit is restored on the connection following Callbacks are not run until autocommit is restored on the connection following
the commit (because otherwise any queries done in a callback would open an the commit (because otherwise any queries done in a callback would open an

View File

@ -79,8 +79,9 @@ databases with Django.
In addition to a database backend, you'll need to make sure your Python In addition to a database backend, you'll need to make sure your Python
database bindings are installed. database bindings are installed.
* If you're using PostgreSQL, you'll need the `psycopg2`_ package. Refer to the * If you're using PostgreSQL, you'll need the `psycopg`_ or `psycopg2`_
:ref:`PostgreSQL notes <postgresql-notes>` for further details. package. Refer to the :ref:`PostgreSQL notes <postgresql-notes>` for further
details.
* If you're using MySQL or MariaDB, you'll need a :ref:`DB API driver * If you're using MySQL or MariaDB, you'll need a :ref:`DB API driver
<mysql-db-api-drivers>` like ``mysqlclient``. See :ref:`notes for the MySQL <mysql-db-api-drivers>` like ``mysqlclient``. See :ref:`notes for the MySQL
@ -111,6 +112,7 @@ database queries, Django will need permission to create a test database.
.. _PostgreSQL: https://www.postgresql.org/ .. _PostgreSQL: https://www.postgresql.org/
.. _MariaDB: https://mariadb.org/ .. _MariaDB: https://mariadb.org/
.. _MySQL: https://www.mysql.com/ .. _MySQL: https://www.mysql.com/
.. _psycopg: https://www.psycopg.org/psycopg3/
.. _psycopg2: https://www.psycopg.org/ .. _psycopg2: https://www.psycopg.org/
.. _SQLite: https://www.sqlite.org/ .. _SQLite: https://www.sqlite.org/
.. _cx_Oracle: https://oracle.github.io/python-cx_Oracle/ .. _cx_Oracle: https://oracle.github.io/python-cx_Oracle/

View File

@ -14,6 +14,11 @@ from django.db import (
from django.db.backends.base.base import BaseDatabaseWrapper from django.db.backends.base.base import BaseDatabaseWrapper
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
try:
from django.db.backends.postgresql.psycopg_any import is_psycopg3
except ImportError:
is_psycopg3 = False
@unittest.skipUnless(connection.vendor == "postgresql", "PostgreSQL tests") @unittest.skipUnless(connection.vendor == "postgresql", "PostgreSQL tests")
class Tests(TestCase): class Tests(TestCase):
@ -228,7 +233,7 @@ class Tests(TestCase):
# Since this is a django.test.TestCase, a transaction is in progress # Since this is a django.test.TestCase, a transaction is in progress
# and the isolation level isn't reported as 0. This test assumes that # and the isolation level isn't reported as 0. This test assumes that
# PostgreSQL is configured with the default isolation level. # PostgreSQL is configured with the default isolation level.
# Check the level on the psycopg2 connection, not the Django wrapper. # Check the level on the psycopg connection, not the Django wrapper.
self.assertIsNone(connection.connection.isolation_level) self.assertIsNone(connection.connection.isolation_level)
new_connection = connection.copy() new_connection = connection.copy()
@ -238,7 +243,7 @@ class Tests(TestCase):
try: try:
# Start a transaction so the isolation level isn't reported as 0. # Start a transaction so the isolation level isn't reported as 0.
new_connection.set_autocommit(False) new_connection.set_autocommit(False)
# Check the level on the psycopg2 connection, not the Django wrapper. # Check the level on the psycopg connection, not the Django wrapper.
self.assertEqual( self.assertEqual(
new_connection.connection.isolation_level, new_connection.connection.isolation_level,
IsolationLevel.SERIALIZABLE, IsolationLevel.SERIALIZABLE,
@ -252,7 +257,7 @@ class Tests(TestCase):
new_connection.settings_dict["OPTIONS"]["isolation_level"] = -1 new_connection.settings_dict["OPTIONS"]["isolation_level"] = -1
msg = ( msg = (
"Invalid transaction isolation level -1 specified. Use one of the " "Invalid transaction isolation level -1 specified. Use one of the "
"IsolationLevel values." "psycopg.IsolationLevel values."
) )
with self.assertRaisesMessage(ImproperlyConfigured, msg): with self.assertRaisesMessage(ImproperlyConfigured, msg):
new_connection.ensure_connection() new_connection.ensure_connection()
@ -268,7 +273,7 @@ class Tests(TestCase):
def _select(self, val): def _select(self, val):
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute("SELECT %s", (val,)) cursor.execute("SELECT %s::text[]", (val,))
return cursor.fetchone()[0] return cursor.fetchone()[0]
def test_select_ascii_array(self): def test_select_ascii_array(self):
@ -308,17 +313,18 @@ class Tests(TestCase):
) )
def test_correct_extraction_psycopg_version(self): def test_correct_extraction_psycopg_version(self):
from django.db.backends.postgresql.base import Database, psycopg2_version from django.db.backends.postgresql.base import Database, psycopg_version
with mock.patch.object(Database, "__version__", "4.2.1 (dt dec pq3 ext lo64)"): with mock.patch.object(Database, "__version__", "4.2.1 (dt dec pq3 ext lo64)"):
self.assertEqual(psycopg2_version(), (4, 2, 1)) self.assertEqual(psycopg_version(), (4, 2, 1))
with mock.patch.object( with mock.patch.object(
Database, "__version__", "4.2b0.dev1 (dt dec pq3 ext lo64)" Database, "__version__", "4.2b0.dev1 (dt dec pq3 ext lo64)"
): ):
self.assertEqual(psycopg2_version(), (4, 2)) self.assertEqual(psycopg_version(), (4, 2))
@override_settings(DEBUG=True) @override_settings(DEBUG=True)
def test_copy_cursors(self): @unittest.skipIf(is_psycopg3, "psycopg2 specific test")
def test_copy_to_expert_cursors(self):
out = StringIO() out = StringIO()
copy_expert_sql = "COPY django_session TO STDOUT (FORMAT CSV, HEADER)" copy_expert_sql = "COPY django_session TO STDOUT (FORMAT CSV, HEADER)"
with connection.cursor() as cursor: with connection.cursor() as cursor:
@ -329,6 +335,16 @@ class Tests(TestCase):
[copy_expert_sql, "COPY django_session TO STDOUT"], [copy_expert_sql, "COPY django_session TO STDOUT"],
) )
@override_settings(DEBUG=True)
@unittest.skipUnless(is_psycopg3, "psycopg3 specific test")
def test_copy_cursors(self):
copy_sql = "COPY django_session TO STDOUT (FORMAT CSV, HEADER)"
with connection.cursor() as cursor:
with cursor.copy(copy_sql) as copy:
for row in copy:
pass
self.assertEqual([q["sql"] for q in connection.queries], [copy_sql])
def test_get_database_version(self): def test_get_database_version(self):
new_connection = connection.copy() new_connection = connection.copy()
new_connection.pg_version = 110009 new_connection.pg_version = 110009

View File

@ -454,7 +454,7 @@ class BackendTestCase(TransactionTestCase):
with connection.cursor() as cursor: with connection.cursor() as cursor:
self.assertIsInstance(cursor, CursorWrapper) self.assertIsInstance(cursor, CursorWrapper)
# Both InterfaceError and ProgrammingError seem to be used when # Both InterfaceError and ProgrammingError seem to be used when
# accessing closed cursor (psycopg2 has InterfaceError, rest seem # accessing closed cursor (psycopg has InterfaceError, rest seem
# to use ProgrammingError). # to use ProgrammingError).
with self.assertRaises(connection.features.closed_cursor_error_class): with self.assertRaises(connection.features.closed_cursor_error_class):
# cursor should be closed, so no queries should be possible. # cursor should be closed, so no queries should be possible.
@ -462,12 +462,12 @@ class BackendTestCase(TransactionTestCase):
@unittest.skipUnless( @unittest.skipUnless(
connection.vendor == "postgresql", connection.vendor == "postgresql",
"Psycopg2 specific cursor.closed attribute needed", "Psycopg specific cursor.closed attribute needed",
) )
def test_cursor_contextmanager_closing(self): def test_cursor_contextmanager_closing(self):
# There isn't a generic way to test that cursors are closed, but # There isn't a generic way to test that cursors are closed, but
# psycopg2 offers us a way to check that by closed attribute. # psycopg offers us a way to check that by closed attribute.
# So, run only on psycopg2 for that reason. # So, run only on psycopg for that reason.
with connection.cursor() as cursor: with connection.cursor() as cursor:
self.assertIsInstance(cursor, CursorWrapper) self.assertIsInstance(cursor, CursorWrapper)
self.assertTrue(cursor.closed) self.assertTrue(cursor.closed)

View File

@ -245,7 +245,7 @@ class DateFunctionTests(TestCase):
self.create_model(start_datetime, end_datetime) self.create_model(start_datetime, end_datetime)
self.create_model(end_datetime, start_datetime) self.create_model(end_datetime, start_datetime)
with self.assertRaises((DataError, OperationalError, ValueError)): with self.assertRaises((OperationalError, ValueError)):
DTModel.objects.filter( DTModel.objects.filter(
start_datetime__year=Extract( start_datetime__year=Extract(
"start_datetime", "day' FROM start_datetime)) OR 1=1;--" "start_datetime", "day' FROM start_datetime)) OR 1=1;--"

View File

@ -62,12 +62,18 @@ class ConnectionHandlerTests(SimpleTestCase):
class DatabaseErrorWrapperTests(TestCase): class DatabaseErrorWrapperTests(TestCase):
@unittest.skipUnless(connection.vendor == "postgresql", "PostgreSQL test") @unittest.skipUnless(connection.vendor == "postgresql", "PostgreSQL test")
def test_reraising_backend_specific_database_exception(self): def test_reraising_backend_specific_database_exception(self):
from django.db.backends.postgresql.psycopg_any import is_psycopg3
with connection.cursor() as cursor: with connection.cursor() as cursor:
msg = 'table "X" does not exist' msg = 'table "X" does not exist'
with self.assertRaisesMessage(ProgrammingError, msg) as cm: with self.assertRaisesMessage(ProgrammingError, msg) as cm:
cursor.execute('DROP TABLE "X"') cursor.execute('DROP TABLE "X"')
self.assertNotEqual(type(cm.exception), type(cm.exception.__cause__)) self.assertNotEqual(type(cm.exception), type(cm.exception.__cause__))
self.assertIsNotNone(cm.exception.__cause__) self.assertIsNotNone(cm.exception.__cause__)
if is_psycopg3:
self.assertIsNotNone(cm.exception.__cause__.diag.sqlstate)
self.assertIsNotNone(cm.exception.__cause__.diag.message_primary)
else:
self.assertIsNotNone(cm.exception.__cause__.pgcode) self.assertIsNotNone(cm.exception.__cause__.pgcode)
self.assertIsNotNone(cm.exception.__cause__.pgerror) self.assertIsNotNone(cm.exception.__cause__.pgerror)

View File

@ -916,15 +916,11 @@ class FixtureLoadingTests(DumpDataAssertMixin, TestCase):
with self.assertRaisesMessage(IntegrityError, msg): with self.assertRaisesMessage(IntegrityError, msg):
management.call_command("loaddata", "invalid.json", verbosity=0) management.call_command("loaddata", "invalid.json", verbosity=0)
@unittest.skipUnless( @skipUnlessDBFeature("prohibits_null_characters_in_text_exception")
connection.vendor == "postgresql", "psycopg2 prohibits null characters in data."
)
def test_loaddata_null_characters_on_postgresql(self): def test_loaddata_null_characters_on_postgresql(self):
msg = ( error, msg = connection.features.prohibits_null_characters_in_text_exception
"Could not load fixtures.Article(pk=2): " msg = f"Could not load fixtures.Article(pk=2): {msg}"
"A string literal cannot contain NUL (0x00) characters." with self.assertRaisesMessage(error, msg):
)
with self.assertRaisesMessage(ValueError, msg):
management.call_command("loaddata", "null_character_in_field_value.json") management.call_command("loaddata", "null_character_in_field_value.json")
def test_loaddata_app_option(self): def test_loaddata_app_option(self):

View File

@ -36,7 +36,7 @@ if HAS_POSTGRES:
raise NotImplementedError("This function was not expected to be called") raise NotImplementedError("This function was not expected to be called")
@unittest.skipUnless(HAS_POSTGRES, "The psycopg2 driver is needed for these tests") @unittest.skipUnless(HAS_POSTGRES, "The psycopg driver is needed for these tests")
class TestPostGISVersionCheck(unittest.TestCase): class TestPostGISVersionCheck(unittest.TestCase):
""" """
The PostGIS version check parses correctly the version numbers The PostGIS version check parses correctly the version numbers

View File

@ -1007,7 +1007,7 @@ class TestQuerying(TestCase):
False, False,
) )
self.assertIn( self.assertIn(
"""."value" -> 'test'' = ''"a"'') OR 1 = 1 OR (''d') = '"x"' """, """."value" -> 'test'' = ''"a"'') OR 1 = 1 OR (''d') = '"x"'""",
queries[0]["sql"], queries[0]["sql"],
) )

View File

@ -19,6 +19,7 @@ try:
DateTimeRange, DateTimeRange,
DateTimeTZRange, DateTimeTZRange,
NumericRange, NumericRange,
is_psycopg3,
) )
except ImportError: except ImportError:
pass pass
@ -59,6 +60,7 @@ class PostgresConfigTests(TestCase):
MigrationWriter.serialize(field) MigrationWriter.serialize(field)
assertNotSerializable() assertNotSerializable()
import_name = "psycopg.types.range" if is_psycopg3 else "psycopg2.extras"
with self.modify_settings(INSTALLED_APPS={"append": "django.contrib.postgres"}): with self.modify_settings(INSTALLED_APPS={"append": "django.contrib.postgres"}):
for default, test_field in tests: for default, test_field in tests:
with self.subTest(default=default): with self.subTest(default=default):
@ -68,16 +70,12 @@ class PostgresConfigTests(TestCase):
imports, imports,
{ {
"import django.contrib.postgres.fields.ranges", "import django.contrib.postgres.fields.ranges",
"import psycopg2.extras", f"import {import_name}",
}, },
) )
self.assertIn( self.assertIn(
"%s.%s(default=psycopg2.extras.%r)" f"{field.__module__}.{field.__class__.__name__}"
% ( f"(default={import_name}.{default!r})",
field.__module__,
field.__class__.__name__,
default,
),
serialized_field, serialized_field,
) )
assertNotSerializable() assertNotSerializable()

View File

@ -317,7 +317,7 @@ class TestQuerying(PostgreSQLTestCase):
def test_in_including_F_object(self): def test_in_including_F_object(self):
# This test asserts that Array objects passed to filters can be # This test asserts that Array objects passed to filters can be
# constructed to contain F objects. This currently doesn't work as the # constructed to contain F objects. This currently doesn't work as the
# psycopg2 mogrify method that generates the ARRAY() syntax is # psycopg mogrify method that generates the ARRAY() syntax is
# expecting literals, not column references (#27095). # expecting literals, not column references (#27095).
self.assertSequenceEqual( self.assertSequenceEqual(
NullableIntegerArrayModel.objects.filter(field__in=[[models.F("id")]]), NullableIntegerArrayModel.objects.filter(field__in=[[models.F("id")]]),

View File

@ -1 +1 @@
psycopg2>=2.8.4 psycopg[binary]>=3.1

View File

@ -9,9 +9,9 @@ class SchemaLoggerTests(TestCase):
params = [42, 1337] params = [42, 1337]
with self.assertLogs("django.db.backends.schema", "DEBUG") as cm: with self.assertLogs("django.db.backends.schema", "DEBUG") as cm:
editor.execute(sql, params) editor.execute(sql, params)
if connection.features.schema_editor_uses_clientside_param_binding:
sql = "SELECT * FROM foo WHERE id in (42, 1337)"
params = None
self.assertEqual(cm.records[0].sql, sql) self.assertEqual(cm.records[0].sql, sql)
self.assertEqual(cm.records[0].params, params) self.assertEqual(cm.records[0].params, params)
self.assertEqual( self.assertEqual(cm.records[0].getMessage(), f"{sql}; (params {params})")
cm.records[0].getMessage(),
"SELECT * FROM foo WHERE id in (%s, %s); (params [42, 1337])",
)