]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
add autoescape option to startswith, endswith, and contains
authorDiana Clarke <diana.joan.clarke@gmail.com>
Sun, 10 Apr 2016 19:19:03 +0000 (15:19 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 14 Mar 2017 13:49:52 +0000 (09:49 -0400)
Fixes: #2694
Change-Id: I34c0bdcb01c2b76b9ab6cd315dae13e3dd8a502b
Pull-request: https://github.com/zzzeek/sqlalchemy/pull/207

doc/build/changelog/changelog_12.rst
doc/build/changelog/migration_12.rst
lib/sqlalchemy/sql/operators.py
test/sql/test_operators.py

index 83df9167f929dd8dbc6728bd632fa9c1d8b61ded..503c59a268f351626aa088a0aa0e870daec2270c 100644 (file)
 
         .. seealso::
 
-            :ref:`change_3276`
\ No newline at end of file
+            :ref:`change_3276`
+
+    .. change:: 2694
+        :tags: feature, sql
+        :tickets: 2694
+
+        Added a new option ``autoescape`` to the "startswith" and
+        "endswith" classes of comparators; this supplies an escape character
+        also applies it to all occurrences of the wildcard characters "%"
+        and "_" automatically.  Pull request courtesy Diana Clarke.
+
+        .. seealso::
+
+            :ref:`change_2694`
index 0950482287acea0effa399ab686dfde0e62783e4..c63d585fb0b6f44888cfb759ef5077efaac0a04a 100644 (file)
@@ -37,6 +37,30 @@ New Features and Improvements - ORM
 New Features and Improvements - Core
 ====================================
 
+.. _change_2694:
+
+New "autoescape" option for startswith(), endswith()
+----------------------------------------------------
+
+The "autoescape" parameter is added to :meth:`.Operators.startswith`,
+:meth:`.Operators.endswith`, :meth:`.Operators.contains`.  This parameter
+does what "escape" does, except that it also automatically performs a search-
+and-replace of any wildcard characters to be escaped by that character, as
+these operators already add the wildcard expression on the outside of the
+given value.
+
+An expression such as::
+
+    >>> column('x').startswith('total%score', autoescape='/')
+
+Renders as::
+
+    x LIKE :x_1 || '%%' ESCAPE '/'
+
+Where the value of the parameter "x_1" is ``'total/%score'``.
+
+:ticket:`2694`
+
 Key Behavioral Changes - ORM
 ============================
 
index d883392998a58b129e345bb59372e2b97ac59a6d..1690d546be0532b987ea2a5124be334a941593c2 100644 (file)
@@ -196,7 +196,7 @@ class Operators(object):
 class custom_op(object):
     """Represent a 'custom' operator.
 
-    :class:`.custom_op` is normally instantitated when the
+    :class:`.custom_op` is normally instantiated when the
     :meth:`.ColumnOperators.op` method is used to create a
     custom operator callable.  The class can also be used directly
     when programmatically constructing expressions.   E.g.
@@ -541,6 +541,31 @@ class ColumnOperators(Operators):
 
         In a column context, produces the clause ``LIKE '<other>%'``
 
+        E.g.::
+
+            select([sometable]).where(sometable.c.column.startswith("foobar"))
+
+        :param other: expression to be compared, with SQL wildcard
+          matching (``%`` and ``_``) enabled, e.g.::
+
+            somecolumn.startswith("foo%bar")
+
+        :param escape: optional escape character, renders the ``ESCAPE``
+          keyword allowing that escape character to be used to manually
+          disable SQL wildcard matching (``%`` and ``_``) in the expression,
+          e.g.::
+
+            somecolumn.startswith("foo/%bar", escape="/")
+
+        :param autoescape: optional escape character, renders the ``ESCAPE``
+          keyword and uses that escape character to auto escape the
+          expression, disabling all SQL wildcard matching (``%`` and ``_``),
+          e.g.::
+
+            somecolumn.startswith("foo%bar", autoescape="/")
+
+          .. versionadded:: 1.2
+
         """
         return self.operate(startswith_op, other, **kwargs)
 
@@ -549,6 +574,31 @@ class ColumnOperators(Operators):
 
         In a column context, produces the clause ``LIKE '%<other>'``
 
+        E.g.::
+
+            select([sometable]).where(sometable.c.column.endswith("foobar"))
+
+        :param other: expression to be compared, with SQL wildcard
+          matching (``%`` and ``_``) enabled, e.g.::
+
+            somecolumn.endswith("foo%bar")
+
+        :param escape: optional escape character, renders the ``ESCAPE``
+          keyword allowing that escape character to be used to manually
+          disable SQL wildcard matching (``%`` and ``_``) in the expression,
+          e.g.::
+
+            somecolumn.endswith("foo/%bar", escape="/")
+
+        :param autoescape: optional escape character, renders the ``ESCAPE``
+          keyword and uses that escape character to auto escape the
+          expression, disabling all SQL wildcard matching (``%`` and ``_``),
+          e.g.::
+
+            somecolumn.endswith("foo%bar", autoescape="/")
+
+          .. versionadded:: 1.2
+
         """
         return self.operate(endswith_op, other, **kwargs)
 
@@ -557,6 +607,31 @@ class ColumnOperators(Operators):
 
         In a column context, produces the clause ``LIKE '%<other>%'``
 
+        E.g.::
+
+            select([sometable]).where(sometable.c.column.contains("foobar"))
+
+        :param other: expression to compare, with SQL wildcard
+          matching (``%`` and ``_``) enabled, e.g.::
+
+            somecolumn.contains("foo%bar")
+
+        :param escape: optional escape character, renders the ``ESCAPE``
+          keyword allowing that escape character to be used to manually
+          disable SQL wildcard matching (``%`` and ``_``) in the expression,
+          e.g.::
+
+            somecolumn.contains("foo/%bar", escape="/")
+
+        :param autoescape: optional escape character, renders the ``ESCAPE``
+          keyword and uses that escape character to auto escape the
+          expression, disabling all SQL wildcard matching (``%`` and ``_``),
+          e.g.::
+
+            somecolumn.contains("foo%bar", autoescape="/")
+
+          .. versionadded:: 1.2
+
         """
         return self.operate(contains_op, other, **kwargs)
 
@@ -736,6 +811,10 @@ class ColumnOperators(Operators):
         return self.reverse_operate(truediv, other)
 
 
+def _escaped(value, escape):
+    return value.replace('%', escape + '%').replace('_', escape + '_')
+
+
 def from_():
     raise NotImplementedError()
 
@@ -824,28 +903,46 @@ def all_op(a):
     return a.all_()
 
 
-def startswith_op(a, b, escape=None):
-    return a.startswith(b, escape=escape)
+def startswith_op(a, b, escape=None, autoescape=None):
+    if autoescape:
+        return a.startswith(_escaped(b, autoescape), escape=autoescape)
+    else:
+        return a.startswith(b, escape=escape)
 
 
-def notstartswith_op(a, b, escape=None):
-    return ~a.startswith(b, escape=escape)
+def notstartswith_op(a, b, escape=None, autoescape=None):
+    if autoescape:
+        return ~a.startswith(_escaped(b, autoescape), escape=autoescape)
+    else:
+        return ~a.startswith(b, escape=escape)
 
 
-def endswith_op(a, b, escape=None):
-    return a.endswith(b, escape=escape)
+def endswith_op(a, b, escape=None, autoescape=None):
+    if autoescape:
+        return a.endswith(_escaped(b, autoescape), escape=autoescape)
+    else:
+        return a.endswith(b, escape=escape)
 
 
-def notendswith_op(a, b, escape=None):
-    return ~a.endswith(b, escape=escape)
+def notendswith_op(a, b, escape=None, autoescape=None):
+    if autoescape:
+        return ~a.endswith(_escaped(b, autoescape), escape=autoescape)
+    else:
+        return ~a.endswith(b, escape=escape)
 
 
-def contains_op(a, b, escape=None):
-    return a.contains(b, escape=escape)
+def contains_op(a, b, escape=None, autoescape=None):
+    if autoescape:
+        return a.contains(_escaped(b, autoescape), escape=autoescape)
+    else:
+        return a.contains(b, escape=escape)
 
 
-def notcontains_op(a, b, escape=None):
-    return ~a.contains(b, escape=escape)
+def notcontains_op(a, b, escape=None, autoescape=None):
+    if autoescape:
+        return ~a.contains(_escaped(b, autoescape), escape=autoescape)
+    else:
+        return ~a.contains(b, escape=escape)
 
 
 def match_op(a, b, **kw):
index 59cae6584c6c72aa84ccab52768fd88074f9b1bc..0bdebab588900f1323a0ebd2831c2ba89b80485b 100644 (file)
@@ -15,7 +15,7 @@ from sqlalchemy.sql.elements import _literal_as_text
 from sqlalchemy.schema import Column, Table, MetaData
 from sqlalchemy.sql import compiler
 from sqlalchemy.types import TypeEngine, TypeDecorator, UserDefinedType, \
-    Boolean, NullType, MatchType, Indexable, Concatenable, ARRAY, JSON, \
+    Boolean, MatchType, Indexable, Concatenable, ARRAY, JSON, \
     DateTime
 from sqlalchemy.dialects import mysql, firebird, postgresql, oracle, \
     sqlite, mssql
@@ -2226,9 +2226,16 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
 
     def test_contains_escape(self):
         self.assert_compile(
-            column('x').contains('y', escape='\\'),
+            column('x').contains('a%b_c', escape='\\'),
             "x LIKE '%%' || :x_1 || '%%' ESCAPE '\\'",
-            checkparams={'x_1': 'y'}
+            checkparams={'x_1': 'a%b_c'}
+        )
+
+    def test_contains_autoescape(self):
+        self.assert_compile(
+            column('x').contains('a%b_c', autoescape='\\'),
+            "x LIKE '%%' || :x_1 || '%%' ESCAPE '\\'",
+            checkparams={'x_1': 'a\\%b\\_c'}
         )
 
     def test_contains_literal(self):
@@ -2254,9 +2261,16 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
 
     def test_not_contains_escape(self):
         self.assert_compile(
-            ~column('x').contains('y', escape='\\'),
+            ~column('x').contains('a%b_c', escape='\\'),
             "x NOT LIKE '%%' || :x_1 || '%%' ESCAPE '\\'",
-            checkparams={'x_1': 'y'}
+            checkparams={'x_1': 'a%b_c'}
+        )
+
+    def test_not_contains_autoescape(self):
+        self.assert_compile(
+            ~column('x').contains('a%b_c', autoescape='\\'),
+            "x NOT LIKE '%%' || :x_1 || '%%' ESCAPE '\\'",
+            checkparams={'x_1': 'a\\%b\\_c'}
         )
 
     def test_contains_concat(self):
@@ -2291,6 +2305,62 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
             dialect=mysql.dialect()
         )
 
+    def test_like(self):
+        self.assert_compile(
+            column('x').like('y'),
+            "x LIKE :x_1",
+            checkparams={'x_1': 'y'}
+        )
+
+    def test_like_escape(self):
+        self.assert_compile(
+            column('x').like('a%b_c', escape='\\'),
+            "x LIKE :x_1 ESCAPE '\\'",
+            checkparams={'x_1': 'a%b_c'}
+        )
+
+    def test_ilike(self):
+        self.assert_compile(
+            column('x').ilike('y'),
+            "lower(x) LIKE lower(:x_1)",
+            checkparams={'x_1': 'y'}
+        )
+
+    def test_ilike_escape(self):
+        self.assert_compile(
+            column('x').ilike('a%b_c', escape='\\'),
+            "lower(x) LIKE lower(:x_1) ESCAPE '\\'",
+            checkparams={'x_1': 'a%b_c'}
+        )
+
+    def test_notlike(self):
+        self.assert_compile(
+            column('x').notlike('y'),
+            "x NOT LIKE :x_1",
+            checkparams={'x_1': 'y'}
+        )
+
+    def test_notlike_escape(self):
+        self.assert_compile(
+            column('x').notlike('a%b_c', escape='\\'),
+            "x NOT LIKE :x_1 ESCAPE '\\'",
+            checkparams={'x_1': 'a%b_c'}
+        )
+
+    def test_notilike(self):
+        self.assert_compile(
+            column('x').notilike('y'),
+            "lower(x) NOT LIKE lower(:x_1)",
+            checkparams={'x_1': 'y'}
+        )
+
+    def test_notilike_escape(self):
+        self.assert_compile(
+            column('x').notilike('a%b_c', escape='\\'),
+            "lower(x) NOT LIKE lower(:x_1) ESCAPE '\\'",
+            checkparams={'x_1': 'a%b_c'}
+        )
+
     def test_startswith(self):
         self.assert_compile(
             column('x').startswith('y'),
@@ -2300,9 +2370,16 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
 
     def test_startswith_escape(self):
         self.assert_compile(
-            column('x').startswith('y', escape='\\'),
+            column('x').startswith('a%b_c', escape='\\'),
             "x LIKE :x_1 || '%%' ESCAPE '\\'",
-            checkparams={'x_1': 'y'}
+            checkparams={'x_1': 'a%b_c'}
+        )
+
+    def test_startswith_autoescape(self):
+        self.assert_compile(
+            column('x').startswith('a%b_c', autoescape='\\'),
+            "x LIKE :x_1 || '%%' ESCAPE '\\'",
+            checkparams={'x_1': 'a\\%b\\_c'}
         )
 
     def test_not_startswith(self):
@@ -2314,9 +2391,16 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
 
     def test_not_startswith_escape(self):
         self.assert_compile(
-            ~column('x').startswith('y', escape='\\'),
+            ~column('x').startswith('a%b_c', escape='\\'),
             "x NOT LIKE :x_1 || '%%' ESCAPE '\\'",
-            checkparams={'x_1': 'y'}
+            checkparams={'x_1': 'a%b_c'}
+        )
+
+    def test_not_startswith_autoescape(self):
+        self.assert_compile(
+            ~column('x').startswith('a%b_c', autoescape='\\'),
+            "x NOT LIKE :x_1 || '%%' ESCAPE '\\'",
+            checkparams={'x_1': 'a\\%b\\_c'}
         )
 
     def test_startswith_literal(self):
@@ -2390,9 +2474,16 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
 
     def test_endswith_escape(self):
         self.assert_compile(
-            column('x').endswith('y', escape='\\'),
+            column('x').endswith('a%b_c', escape='\\'),
             "x LIKE '%%' || :x_1 ESCAPE '\\'",
-            checkparams={'x_1': 'y'}
+            checkparams={'x_1': 'a%b_c'}
+        )
+
+    def test_endswith_autoescape(self):
+        self.assert_compile(
+            column('x').endswith('a%b_c', autoescape='\\'),
+            "x LIKE '%%' || :x_1 ESCAPE '\\'",
+            checkparams={'x_1': 'a\\%b\\_c'}
         )
 
     def test_not_endswith(self):
@@ -2404,9 +2495,16 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
 
     def test_not_endswith_escape(self):
         self.assert_compile(
-            ~column('x').endswith('y', escape='\\'),
+            ~column('x').endswith('a%b_c', escape='\\'),
             "x NOT LIKE '%%' || :x_1 ESCAPE '\\'",
-            checkparams={'x_1': 'y'}
+            checkparams={'x_1': 'a%b_c'}
+        )
+
+    def test_not_endswith_autoescape(self):
+        self.assert_compile(
+            ~column('x').endswith('a%b_c', autoescape='\\'),
+            "x NOT LIKE '%%' || :x_1 ESCAPE '\\'",
+            checkparams={'x_1': 'a\\%b\\_c'}
         )
 
     def test_endswith_literal(self):