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:
parent
d44ee518c4
commit
09ffc5c121
@ -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:
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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 = []
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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"):
|
||||||
|
@ -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,30 +30,51 @@ 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:
|
||||||
if connection.vendor != "postgresql" or connection.alias == NO_DB_ALIAS:
|
from psycopg.types import TypeInfo, hstore
|
||||||
return
|
|
||||||
|
|
||||||
oids, array_oids = get_hstore_oids(connection.alias)
|
def register_type_handlers(connection, **kwargs):
|
||||||
# Don't register handlers when hstore is not available on the database.
|
if connection.vendor != "postgresql" or connection.alias == NO_DB_ALIAS:
|
||||||
#
|
return
|
||||||
# If someone tries to create an hstore field it will error there. This is
|
|
||||||
# necessary as someone may be using PSQL without extensions installed but
|
|
||||||
# be using other features of contrib.postgres.
|
|
||||||
#
|
|
||||||
# This is also needed in order to create the connection in order to install
|
|
||||||
# the hstore extension.
|
|
||||||
if oids:
|
|
||||||
register_hstore(
|
|
||||||
connection.connection, globally=True, oid=oids, array_oid=array_oids
|
|
||||||
)
|
|
||||||
|
|
||||||
oids, citext_oids = get_citext_oids(connection.alias)
|
oids, array_oids = get_hstore_oids(connection.alias)
|
||||||
# Don't register handlers when citext is not available on the database.
|
for oid, array_oid in zip(oids, array_oids):
|
||||||
#
|
ti = TypeInfo("hstore", oid, array_oid)
|
||||||
# The same comments in the above call to register_hstore() also apply here.
|
hstore.register_hstore(ti, connection.connection)
|
||||||
if oids:
|
|
||||||
array_type = psycopg2.extensions.new_array_type(
|
_, citext_oids = get_citext_oids(connection.alias)
|
||||||
citext_oids, "citext[]", psycopg2.STRING
|
for array_oid in citext_oids:
|
||||||
)
|
ti = TypeInfo("citext", 0, array_oid)
|
||||||
psycopg2.extensions.register_type(array_type, None)
|
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:
|
||||||
|
return
|
||||||
|
|
||||||
|
oids, array_oids = get_hstore_oids(connection.alias)
|
||||||
|
# Don't register handlers when hstore is not available on the database.
|
||||||
|
#
|
||||||
|
# If someone tries to create an hstore field it will error there. This is
|
||||||
|
# necessary as someone may be using PSQL without extensions installed but
|
||||||
|
# be using other features of contrib.postgres.
|
||||||
|
#
|
||||||
|
# This is also needed in order to create the connection in order to install
|
||||||
|
# the hstore extension.
|
||||||
|
if oids:
|
||||||
|
register_hstore(
|
||||||
|
connection.connection, globally=True, oid=oids, array_oid=array_oids
|
||||||
|
)
|
||||||
|
|
||||||
|
oids, citext_oids = get_citext_oids(connection.alias)
|
||||||
|
# Don't register handlers when citext is not available on the database.
|
||||||
|
#
|
||||||
|
# The same comments in the above call to register_hstore() also apply here.
|
||||||
|
if oids:
|
||||||
|
array_type = psycopg2.extensions.new_array_type(
|
||||||
|
citext_oids, "citext[]", psycopg2.STRING
|
||||||
|
)
|
||||||
|
psycopg2.extensions.register_type(array_type, None)
|
||||||
|
@ -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"
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
import psycopg2 as Database
|
try:
|
||||||
import psycopg2.extensions
|
import psycopg as Database
|
||||||
import psycopg2.extras
|
except ImportError:
|
||||||
except ImportError as e:
|
import psycopg2 as Database
|
||||||
raise ImproperlyConfigured("Error loading psycopg2 module: %s" % e)
|
except ImportError:
|
||||||
|
raise ImproperlyConfigured("Error loading psycopg2 or psycopg module")
|
||||||
|
|
||||||
|
|
||||||
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(
|
|
||||||
(INETARRAY_OID,),
|
else:
|
||||||
"INETARRAY",
|
import psycopg2.extensions
|
||||||
psycopg2.extensions.UNICODE,
|
import psycopg2.extras
|
||||||
)
|
|
||||||
psycopg2.extensions.register_type(INETARRAY)
|
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",
|
||||||
|
psycopg2.extensions.UNICODE,
|
||||||
|
)
|
||||||
|
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
|
||||||
psycopg2.extras.register_default_jsonb(
|
# decoder in JSONField.
|
||||||
conn_or_curs=connection, loads=lambda x: x
|
psycopg2.extras.register_default_jsonb(
|
||||||
)
|
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,7 +301,15 @@ class DatabaseWrapper(BaseDatabaseWrapper):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cursor = self.connection.cursor()
|
cursor = self.connection.cursor()
|
||||||
cursor.tzinfo_factory = self.tzinfo_factory if settings.USE_TZ else None
|
|
||||||
|
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
|
||||||
return cursor
|
return cursor
|
||||||
|
|
||||||
def tzinfo_factory(self, offset):
|
def tzinfo_factory(self, offset):
|
||||||
@ -379,11 +413,43 @@ class DatabaseWrapper(BaseDatabaseWrapper):
|
|||||||
return CursorDebugWrapper(cursor, self)
|
return CursorDebugWrapper(cursor, self)
|
||||||
|
|
||||||
|
|
||||||
class CursorDebugWrapper(BaseCursorDebugWrapper):
|
if is_psycopg3:
|
||||||
def copy_expert(self, sql, file, *args):
|
|
||||||
with self.debug_sql(sql):
|
|
||||||
return self.cursor.copy_expert(sql, file, *args)
|
|
||||||
|
|
||||||
def copy_to(self, file, table, *args, **kwargs):
|
class Cursor(Database.Cursor):
|
||||||
with self.debug_sql(sql="COPY %s TO STDOUT" % table):
|
"""
|
||||||
return self.cursor.copy_to(file, table, *args, **kwargs)
|
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):
|
||||||
|
with self.debug_sql(sql):
|
||||||
|
return self.cursor.copy_expert(sql, file, *args)
|
||||||
|
|
||||||
|
def copy_to(self, file, table, *args, **kwargs):
|
||||||
|
with self.debug_sql(sql="COPY %s TO STDOUT" % table):
|
||||||
|
return self.cursor.copy_to(file, table, *args, **kwargs)
|
||||||
|
@ -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 {
|
||||||
|
@ -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,12 +305,22 @@ class DatabaseOperations(BaseDatabaseOperations):
|
|||||||
else:
|
else:
|
||||||
return ["DISTINCT"], []
|
return ["DISTINCT"], []
|
||||||
|
|
||||||
def last_executed_query(self, cursor, sql, params):
|
if is_psycopg3:
|
||||||
# https://www.psycopg.org/docs/cursor.html#cursor.query
|
|
||||||
# The query attribute is a Psycopg extension to the DB API 2.0.
|
def last_executed_query(self, cursor, sql, params):
|
||||||
if cursor.query is not None:
|
try:
|
||||||
return cursor.query.decode()
|
return self.compose_sql(sql, params)
|
||||||
return None
|
except errors.DataError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def last_executed_query(self, cursor, sql, params):
|
||||||
|
# https://www.psycopg.org/docs/cursor.html#cursor.query
|
||||||
|
# The query attribute is a Psycopg extension to the DB API 2.0.
|
||||||
|
if cursor.query is not None:
|
||||||
|
return cursor.query.decode()
|
||||||
|
return None
|
||||||
|
|
||||||
def return_insert_columns(self, fields):
|
def return_insert_columns(self, fields):
|
||||||
if not fields:
|
if not fields:
|
||||||
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
READ_UNCOMMITTED = extensions.ISOLATION_LEVEL_READ_UNCOMMITTED
|
TSTZRANGE_OID = types["tstzrange"].oid
|
||||||
READ_COMMITTED = extensions.ISOLATION_LEVEL_READ_COMMITTED
|
|
||||||
REPEATABLE_READ = extensions.ISOLATION_LEVEL_REPEATABLE_READ
|
|
||||||
SERIALIZABLE = extensions.ISOLATION_LEVEL_SERIALIZABLE
|
|
||||||
|
|
||||||
|
def mogrify(sql, params, connection):
|
||||||
|
return ClientCursor(connection.connection).mogrify(sql, params)
|
||||||
|
|
||||||
def _quote(value, connection=None):
|
# Adapters.
|
||||||
adapted = extensions.adapt(value)
|
class BaseTzLoader(TimestamptzLoader):
|
||||||
if hasattr(adapted, "encoding"):
|
"""
|
||||||
adapted.encoding = "utf8"
|
Load a PostgreSQL timestamptz using the a specific timezone.
|
||||||
# getquoted() returns a quoted bytestring of the adapted value.
|
The timezone can be None too, in which case it will be chopped.
|
||||||
return adapted.getquoted().decode()
|
"""
|
||||||
|
|
||||||
|
timezone = None
|
||||||
|
|
||||||
sql.quote = _quote
|
def load(self, data):
|
||||||
|
res = super().load(data)
|
||||||
|
return res.replace(tzinfo=self.timezone)
|
||||||
|
|
||||||
|
def register_tzloader(tz, context):
|
||||||
|
class SpecificTzLoader(BaseTzLoader):
|
||||||
|
timezone = tz
|
||||||
|
|
||||||
def mogrify(sql, params, connection):
|
context.adapters.register_loader("timestamptz", SpecificTzLoader)
|
||||||
with connection.cursor() as cursor:
|
|
||||||
return cursor.mogrify(sql, params).decode()
|
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_COMMITTED = extensions.ISOLATION_LEVEL_READ_COMMITTED
|
||||||
|
REPEATABLE_READ = extensions.ISOLATION_LEVEL_REPEATABLE_READ
|
||||||
|
SERIALIZABLE = extensions.ISOLATION_LEVEL_SERIALIZABLE
|
||||||
|
|
||||||
|
def _quote(value, connection=None):
|
||||||
|
adapted = extensions.adapt(value)
|
||||||
|
if hasattr(adapted, "encoding"):
|
||||||
|
adapted.encoding = "utf8"
|
||||||
|
# getquoted() returns a quoted bytestring of the adapted value.
|
||||||
|
return adapted.getquoted().decode()
|
||||||
|
|
||||||
|
sql.quote = _quote
|
||||||
|
|
||||||
|
def mogrify(sql, params, connection):
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
return cursor.mogrify(sql, params).decode()
|
||||||
|
|
||||||
|
is_psycopg3 = False
|
||||||
|
@ -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"
|
||||||
|
@ -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"
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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(
|
||||||
|
@ -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:
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
=================
|
=================
|
||||||
|
|
||||||
|
@ -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
|
||||||
---------------
|
---------------
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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/
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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;--"
|
||||||
|
@ -62,14 +62,20 @@ 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__)
|
||||||
self.assertIsNotNone(cm.exception.__cause__.pgcode)
|
if is_psycopg3:
|
||||||
self.assertIsNotNone(cm.exception.__cause__.pgerror)
|
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__.pgerror)
|
||||||
|
|
||||||
|
|
||||||
class LoadBackendTests(SimpleTestCase):
|
class LoadBackendTests(SimpleTestCase):
|
||||||
|
12
tests/fixtures/tests.py
vendored
12
tests/fixtures/tests.py
vendored
@ -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):
|
||||||
|
@ -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
|
||||||
|
@ -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"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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")]]),
|
||||||
|
@ -1 +1 @@
|
|||||||
psycopg2>=2.8.4
|
psycopg[binary]>=3.1
|
||||||
|
@ -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])",
|
|
||||||
)
|
|
||||||
|
Loading…
Reference in New Issue
Block a user