Fixed #21181 -- Added Collate database function.
Thanks Simon Charette for reviews.
This commit is contained in:
parent
60626162f7
commit
63300f7e68
|
@ -302,6 +302,13 @@ class BaseDatabaseFeatures:
|
||||||
# {'d': [{'f': 'g'}]}?
|
# {'d': [{'f': 'g'}]}?
|
||||||
json_key_contains_list_matching_requires_list = False
|
json_key_contains_list_matching_requires_list = False
|
||||||
|
|
||||||
|
# Collation names for use by the Django test suite.
|
||||||
|
test_collations = {
|
||||||
|
'ci': None, # Case-insensitive.
|
||||||
|
'cs': None, # Case-sensitive.
|
||||||
|
'swedish-ci': None # Swedish case-insensitive.
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, connection):
|
def __init__(self, connection):
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
|
|
||||||
|
|
|
@ -44,6 +44,10 @@ class DatabaseFeatures(BaseDatabaseFeatures):
|
||||||
supports_partial_indexes = False
|
supports_partial_indexes = False
|
||||||
supports_order_by_nulls_modifier = False
|
supports_order_by_nulls_modifier = False
|
||||||
order_by_nulls_first = True
|
order_by_nulls_first = True
|
||||||
|
test_collations = {
|
||||||
|
'ci': 'utf8_general_ci',
|
||||||
|
'swedish-ci': 'utf8_swedish_ci',
|
||||||
|
}
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def _mysql_storage_engine(self):
|
def _mysql_storage_engine(self):
|
||||||
|
|
|
@ -61,6 +61,11 @@ class DatabaseFeatures(BaseDatabaseFeatures):
|
||||||
supports_boolean_expr_in_select_clause = False
|
supports_boolean_expr_in_select_clause = False
|
||||||
supports_primitives_in_json_field = False
|
supports_primitives_in_json_field = False
|
||||||
supports_json_field_contains = False
|
supports_json_field_contains = False
|
||||||
|
test_collations = {
|
||||||
|
'ci': 'BINARY_CI',
|
||||||
|
'cs': 'BINARY',
|
||||||
|
'swedish_ci': 'SWEDISH_CI',
|
||||||
|
}
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def introspected_field_types(self):
|
def introspected_field_types(self):
|
||||||
|
|
|
@ -58,6 +58,9 @@ class DatabaseFeatures(BaseDatabaseFeatures):
|
||||||
supports_deferrable_unique_constraints = True
|
supports_deferrable_unique_constraints = True
|
||||||
has_json_operators = True
|
has_json_operators = True
|
||||||
json_key_contains_list_matching_requires_list = True
|
json_key_contains_list_matching_requires_list = True
|
||||||
|
test_collations = {
|
||||||
|
'swedish-ci': 'sv-x-icu',
|
||||||
|
}
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def introspected_field_types(self):
|
def introspected_field_types(self):
|
||||||
|
|
|
@ -44,6 +44,10 @@ class DatabaseFeatures(BaseDatabaseFeatures):
|
||||||
supports_order_by_nulls_modifier = Database.sqlite_version_info >= (3, 30, 0)
|
supports_order_by_nulls_modifier = Database.sqlite_version_info >= (3, 30, 0)
|
||||||
order_by_nulls_first = True
|
order_by_nulls_first = True
|
||||||
supports_json_field_contains = False
|
supports_json_field_contains = False
|
||||||
|
test_collations = {
|
||||||
|
'ci': 'nocase',
|
||||||
|
'cs': 'binary',
|
||||||
|
}
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def supports_atomic_references_rename(self):
|
def supports_atomic_references_rename(self):
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from .comparison import Cast, Coalesce, Greatest, Least, NullIf
|
from .comparison import Cast, Coalesce, Collate, Greatest, Least, NullIf
|
||||||
from .datetime import (
|
from .datetime import (
|
||||||
Extract, ExtractDay, ExtractHour, ExtractIsoWeekDay, ExtractIsoYear,
|
Extract, ExtractDay, ExtractHour, ExtractIsoWeekDay, ExtractIsoYear,
|
||||||
ExtractMinute, ExtractMonth, ExtractQuarter, ExtractSecond, ExtractWeek,
|
ExtractMinute, ExtractMonth, ExtractQuarter, ExtractSecond, ExtractWeek,
|
||||||
|
@ -22,7 +22,7 @@ from .window import (
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# comparison and conversion
|
# comparison and conversion
|
||||||
'Cast', 'Coalesce', 'Greatest', 'Least', 'NullIf',
|
'Cast', 'Coalesce', 'Collate', 'Greatest', 'Least', 'NullIf',
|
||||||
# datetime
|
# datetime
|
||||||
'Extract', 'ExtractDay', 'ExtractHour', 'ExtractMinute', 'ExtractMonth',
|
'Extract', 'ExtractDay', 'ExtractHour', 'ExtractMinute', 'ExtractMonth',
|
||||||
'ExtractQuarter', 'ExtractSecond', 'ExtractWeek', 'ExtractIsoWeekDay',
|
'ExtractQuarter', 'ExtractSecond', 'ExtractWeek', 'ExtractIsoWeekDay',
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
"""Database functions that do comparisons or type conversions."""
|
"""Database functions that do comparisons or type conversions."""
|
||||||
from django.db.models.expressions import Func, Value
|
from django.db.models.expressions import Func, Value
|
||||||
|
from django.utils.regex_helper import _lazy_re_compile
|
||||||
|
|
||||||
|
|
||||||
class Cast(Func):
|
class Cast(Func):
|
||||||
|
@ -74,6 +75,23 @@ class Coalesce(Func):
|
||||||
return self.as_sql(compiler, connection, **extra_context)
|
return self.as_sql(compiler, connection, **extra_context)
|
||||||
|
|
||||||
|
|
||||||
|
class Collate(Func):
|
||||||
|
function = 'COLLATE'
|
||||||
|
template = '%(expressions)s %(function)s %(collation)s'
|
||||||
|
# Inspired from https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
|
||||||
|
collation_re = _lazy_re_compile(r'^[\w\-]+$')
|
||||||
|
|
||||||
|
def __init__(self, expression, collation):
|
||||||
|
if not (collation and self.collation_re.match(collation)):
|
||||||
|
raise ValueError('Invalid collation name: %r.' % collation)
|
||||||
|
self.collation = collation
|
||||||
|
super().__init__(expression)
|
||||||
|
|
||||||
|
def as_sql(self, compiler, connection, **extra_context):
|
||||||
|
extra_context.setdefault('collation', connection.ops.quote_name(self.collation))
|
||||||
|
return super().as_sql(compiler, connection, **extra_context)
|
||||||
|
|
||||||
|
|
||||||
class Greatest(Func):
|
class Greatest(Func):
|
||||||
"""
|
"""
|
||||||
Return the maximum expression.
|
Return the maximum expression.
|
||||||
|
|
|
@ -87,6 +87,25 @@ Usage examples::
|
||||||
>>> now = timezone.now()
|
>>> now = timezone.now()
|
||||||
>>> Coalesce('updated', Cast(now, DateTimeField()))
|
>>> Coalesce('updated', Cast(now, DateTimeField()))
|
||||||
|
|
||||||
|
``Collate``
|
||||||
|
-----------
|
||||||
|
|
||||||
|
.. class:: Collate(expression, collation)
|
||||||
|
|
||||||
|
.. versionadded:: 3.2
|
||||||
|
|
||||||
|
Takes an expression and a collation name to query against.
|
||||||
|
|
||||||
|
For example, to filter case-insensitively in SQLite::
|
||||||
|
|
||||||
|
>>> Author.objects.filter(name=Collate(Value('john'), 'nocase'))
|
||||||
|
<QuerySet [<Author: John>, <Author: john>]>
|
||||||
|
|
||||||
|
It can also be used when ordering, for example with PostgreSQL::
|
||||||
|
|
||||||
|
>>> Author.objects.order_by(Collate('name', 'et-x-icu'))
|
||||||
|
<QuerySet [<Author: Ursula>, <Author: Veronika>, <Author: Ülle>]>
|
||||||
|
|
||||||
``Greatest``
|
``Greatest``
|
||||||
------------
|
------------
|
||||||
|
|
||||||
|
|
|
@ -273,6 +273,9 @@ Models
|
||||||
expressions that don't need to be selected but are used for filtering,
|
expressions that don't need to be selected but are used for filtering,
|
||||||
ordering, or as a part of complex expressions.
|
ordering, or as a part of complex expressions.
|
||||||
|
|
||||||
|
* The new :class:`~django.db.models.functions.Collate` function allows
|
||||||
|
filtering and ordering by specified database collations.
|
||||||
|
|
||||||
Pagination
|
Pagination
|
||||||
~~~~~~~~~~
|
~~~~~~~~~~
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
from django.db import connection
|
||||||
|
from django.db.models import F, Value
|
||||||
|
from django.db.models.functions import Collate
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from ..models import Author
|
||||||
|
|
||||||
|
|
||||||
|
class CollateTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.author1 = Author.objects.create(alias='a', name='Jones 1')
|
||||||
|
cls.author2 = Author.objects.create(alias='A', name='Jones 2')
|
||||||
|
|
||||||
|
def test_collate_filter_ci(self):
|
||||||
|
collation = connection.features.test_collations.get('ci')
|
||||||
|
if not collation:
|
||||||
|
self.skipTest(
|
||||||
|
'This backend does not support case-insensitive collations.'
|
||||||
|
)
|
||||||
|
qs = Author.objects.filter(alias=Collate(Value('a'), collation))
|
||||||
|
self.assertEqual(qs.count(), 2)
|
||||||
|
|
||||||
|
def test_collate_order_by_cs(self):
|
||||||
|
collation = connection.features.test_collations.get('cs')
|
||||||
|
if not collation:
|
||||||
|
self.skipTest(
|
||||||
|
'This backend does not support case-sensitive collations.'
|
||||||
|
)
|
||||||
|
qs = Author.objects.order_by(Collate('alias', collation))
|
||||||
|
self.assertSequenceEqual(qs, [self.author2, self.author1])
|
||||||
|
|
||||||
|
def test_language_collation_order_by(self):
|
||||||
|
collation = connection.features.test_collations.get('swedish-ci')
|
||||||
|
if not collation:
|
||||||
|
self.skipTest('This backend does not support language collations.')
|
||||||
|
author3 = Author.objects.create(alias='O', name='Jones')
|
||||||
|
author4 = Author.objects.create(alias='Ö', name='Jones')
|
||||||
|
author5 = Author.objects.create(alias='P', name='Jones')
|
||||||
|
qs = Author.objects.order_by(Collate(F('alias'), collation), 'name')
|
||||||
|
self.assertSequenceEqual(
|
||||||
|
qs,
|
||||||
|
[self.author1, self.author2, author3, author5, author4],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_collation(self):
|
||||||
|
tests = [
|
||||||
|
None,
|
||||||
|
'',
|
||||||
|
'et-x-icu" OR ',
|
||||||
|
'"schema"."collation"',
|
||||||
|
]
|
||||||
|
msg = "Invalid collation name: %r."
|
||||||
|
for value in tests:
|
||||||
|
with self.subTest(value), self.assertRaisesMessage(ValueError, msg % value):
|
||||||
|
Collate(F('alias'), value)
|
Loading…
Reference in New Issue