From abf8e390a4161a051ed1c4be11b9447866b06ba8 Mon Sep 17 00:00:00 2001 From: Nick Pope Date: Wed, 9 Jan 2019 00:12:10 +0000 Subject: [PATCH] Refs #28643 -- Added Reverse database function. Thanks Mariusz Felisiak for Oracle advice and review. --- django/db/backends/sqlite3/base.py | 1 + django/db/models/functions/__init__.py | 6 ++-- django/db/models/functions/text.py | 19 ++++++++++ docs/ref/models/database-functions.txt | 21 +++++++++++ docs/releases/2.2.txt | 7 ++-- tests/db_functions/text/test_reverse.py | 46 +++++++++++++++++++++++++ 6 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 tests/db_functions/text/test_reverse.py diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index 5b1c80d27c..31b2b1053b 100644 --- a/django/db/backends/sqlite3/base.py +++ b/django/db/backends/sqlite3/base.py @@ -215,6 +215,7 @@ class DatabaseWrapper(BaseDatabaseWrapper): conn.create_function('POWER', 2, none_guard(operator.pow)) conn.create_function('RADIANS', 1, none_guard(math.radians)) conn.create_function('REPEAT', 2, none_guard(operator.mul)) + conn.create_function('REVERSE', 1, none_guard(lambda x: x[::-1])) conn.create_function('RPAD', 3, _sqlite_rpad) conn.create_function('SIN', 1, none_guard(math.sin)) conn.create_function('SQRT', 1, none_guard(math.sqrt)) diff --git a/django/db/models/functions/__init__.py b/django/db/models/functions/__init__.py index 2d58821653..ed863b92af 100644 --- a/django/db/models/functions/__init__.py +++ b/django/db/models/functions/__init__.py @@ -11,7 +11,7 @@ from .math import ( ) from .text import ( Chr, Concat, ConcatPair, Left, Length, Lower, LPad, LTrim, Ord, Repeat, - Replace, Right, RPad, RTrim, StrIndex, Substr, Trim, Upper, + Replace, Reverse, Right, RPad, RTrim, StrIndex, Substr, Trim, Upper, ) from .window import ( CumeDist, DenseRank, FirstValue, Lag, LastValue, Lead, NthValue, Ntile, @@ -34,8 +34,8 @@ __all__ = [ 'Sin', 'Sqrt', 'Tan', # text 'Chr', 'Concat', 'ConcatPair', 'Left', 'Length', 'Lower', 'LPad', 'LTrim', - 'Ord', 'Repeat', 'Replace', 'Right', 'RPad', 'RTrim', 'StrIndex', 'Substr', - 'Trim', 'Upper', + 'Ord', 'Repeat', 'Replace', 'Reverse', 'Right', 'RPad', 'RTrim', + 'StrIndex', 'Substr', 'Trim', 'Upper', # window 'CumeDist', 'DenseRank', 'FirstValue', 'Lag', 'LastValue', 'Lead', 'NthValue', 'Ntile', 'PercentRank', 'Rank', 'RowNumber', diff --git a/django/db/models/functions/text.py b/django/db/models/functions/text.py index db6d0c6c58..9624f0911f 100644 --- a/django/db/models/functions/text.py +++ b/django/db/models/functions/text.py @@ -185,6 +185,25 @@ class Replace(Func): super().__init__(expression, text, replacement, **extra) +class Reverse(Transform): + function = 'REVERSE' + lookup_name = 'reverse' + + def as_oracle(self, compiler, connection, **extra_context): + # REVERSE in Oracle is undocumented and doesn't support multi-byte + # strings. Use a special subquery instead. + return super().as_sql( + compiler, connection, + template=( + '(SELECT LISTAGG(s) WITHIN GROUP (ORDER BY n DESC) FROM ' + '(SELECT LEVEL n, SUBSTR(%(expressions)s, LEVEL, 1) s ' + 'FROM DUAL CONNECT BY LEVEL <= LENGTH(%(expressions)s)) ' + 'GROUP BY %(expressions)s)' + ), + **extra_context + ) + + class Right(Left): function = 'RIGHT' diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index 2661b65c78..c82a7cd7b7 100644 --- a/docs/ref/models/database-functions.txt +++ b/docs/ref/models/database-functions.txt @@ -1377,6 +1377,27 @@ Usage example:: >>> Author.objects.values('name') +``Reverse`` +----------- + +.. class:: Reverse(expression, **extra) + +.. versionadded:: 2.2 + +Accepts a single text field or expression and returns the characters of that +expression in reverse order. + +It can also be registered as a transform as described in :class:`Length`. The +default lookup name is ``reverse``. + +Usage example:: + + >>> from django.db.models.functions import Reverse + >>> Author.objects.create(name='Margaret Smith') + >>> author = Author.objects.annotate(backward=Reverse('name')).get() + >>> print(author.backward) + htimS teragraM + ``Right`` --------- diff --git a/docs/releases/2.2.txt b/docs/releases/2.2.txt index c371d50281..45d436ebe1 100644 --- a/docs/releases/2.2.txt +++ b/docs/releases/2.2.txt @@ -221,10 +221,9 @@ Models * Added support for partial indexes (:attr:`.Index.condition`). -* Added many :ref:`math database functions `. - -* The new :class:`~django.db.models.functions.NullIf` database function - returns ``None`` if the two expressions are equal. +* Added the :class:`~django.db.models.functions.NullIf` and + :class:`~django.db.models.functions.Reverse` database functions, as well as + many :ref:`math database functions `. * Setting the new ``ignore_conflicts`` parameter of :meth:`.QuerySet.bulk_create` to ``True`` tells the database to ignore diff --git a/tests/db_functions/text/test_reverse.py b/tests/db_functions/text/test_reverse.py new file mode 100644 index 0000000000..1cc1045b04 --- /dev/null +++ b/tests/db_functions/text/test_reverse.py @@ -0,0 +1,46 @@ +from django.db import connection +from django.db.models import CharField +from django.db.models.functions import Length, Reverse, Trim +from django.test import TestCase +from django.test.utils import register_lookup + +from ..models import Author + + +class ReverseTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.john = Author.objects.create(name='John Smith', alias='smithj') + cls.elena = Author.objects.create(name='Élena Jordan', alias='elena') + cls.python = Author.objects.create(name='パイソン') + + def test_null(self): + author = Author.objects.annotate(backward=Reverse('alias')).get(pk=self.python.pk) + self.assertEqual(author.backward, '' if connection.features.interprets_empty_strings_as_nulls else None) + + def test_basic(self): + authors = Author.objects.annotate(backward=Reverse('name')) + self.assertQuerysetEqual( + authors, + [ + ('John Smith', 'htimS nhoJ'), + ('Élena Jordan', 'nadroJ anelÉ'), + ('パイソン', 'ンソイパ'), + ], + lambda a: (a.name, a.backward), + ordered=False, + ) + + def test_transform(self): + with register_lookup(CharField, Reverse): + authors = Author.objects.all() + self.assertCountEqual(authors.filter(name__reverse=self.john.name[::-1]), [self.john]) + self.assertCountEqual(authors.exclude(name__reverse=self.john.name[::-1]), [self.elena, self.python]) + + def test_expressions(self): + author = Author.objects.annotate(backward=Reverse(Trim('name'))).get(pk=self.john.pk) + self.assertEqual(author.backward, self.john.name[::-1]) + with register_lookup(CharField, Reverse), register_lookup(CharField, Length): + authors = Author.objects.all() + self.assertCountEqual(authors.filter(name__reverse__length__gt=7), [self.john, self.elena]) + self.assertCountEqual(authors.exclude(name__reverse__length__gt=7), [self.python])