]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Double percent signs based on paramstyle, not dialect
authorMike Bayer <mike_mp@zzzcomputing.com>
Mon, 4 Jul 2016 19:54:29 +0000 (15:54 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 5 Apr 2017 16:18:36 +0000 (12:18 -0400)
This patch moves the "doubling" of percent signs into
the base compiler and makes it completely a product
of whether or not the paramstyle is format/pyformat or
not.   Without this paramstyle, percent signs
are not doubled across text(), literal_column(), and
column().

Change-Id: Ie2f278ab1dbb94b5078f85c0096d74dbfa049197
Fixes: #3740
doc/build/changelog/changelog_12.rst
doc/build/changelog/migration_12.rst
doc/build/core/tutorial.rst
lib/sqlalchemy/dialects/mysql/mysqldb.py
lib/sqlalchemy/dialects/postgresql/psycopg2.py
lib/sqlalchemy/sql/compiler.py
lib/sqlalchemy/testing/suite/test_dialect.py
test/sql/test_operators.py
test/sql/test_quote.py
test/sql/test_text.py

index ad9e73160f283c2be760c8af7ca0aad900c35ab2..80be30df2662bc4e364dd534812da72b0bff42b8 100644 (file)
 .. changelog::
     :version: 1.2.0b1
 
+    .. change:: 3740
+        :tags: bug, sql
+        :tickets: 3740
+
+        The system by which percent signs in SQL statements are "doubled"
+        for escaping purposes has been refined.   The "doubling" of percent
+        signs mostly associated with the :obj:`.literal_column` construct
+        as well as operators like :meth:`.ColumnOperators.contains` now
+        occurs based on the stated paramstyle of the DBAPI in use; for
+        percent-sensitive paramstyles as are common with the Postgresql
+        and MySQL drivers the doubling will occur, for others like that
+        of SQLite it will not.   This allows more database-agnostic use
+        of the :obj:`.literal_column` construct to be possible.
+
+        .. seealso::
+
+            :ref:`change_3740`
+
     .. change:: 3957
         :tags: bug, sql
         :tickets: 3957
index 4c2090e50ee6c42208a1361ef723d4a79d8b0f7e..ae4dccd5f348d34dd5ccb8f731e3d6db26491615 100644 (file)
@@ -362,6 +362,41 @@ Where the value of the parameter "x_1" is ``'total/%score'``.
 Key Behavioral Changes - ORM
 ============================
 
+.. _change_3740:
+
+Percent signs in literal_column() now conditionally escaped
+------------------------------------------------------------
+
+The :obj:`.literal_column` construct now escapes percent sign characters
+conditionally, based on whether or not the DBAPI in use makes use of a
+percent-sign-sensitive paramstyle or not (e.g. 'format' or 'pyformat').
+
+Previously, it was not possible to produce a :obj:`.literal_column`
+construct that stated a single percent sign::
+
+    >>> from sqlalchemy import literal_column
+    >>> print(literal_column('some%symbol'))
+    some%%symbol
+
+The percent sign is now unaffected for dialects that are not set to
+use the 'format' or 'pyformat' paramstyles; dialects such most MySQL
+dialects which do state one of these paramstyles will continue to escape
+as is appropriate::
+
+    >>> from sqlalchemy import literal_column
+    >>> print(literal_column('some%symbol'))
+    some%symbol
+    >>> from sqlalchemy.dialects import mysql
+    >>> print(literal_column('some%symbol').compile(dialect=mysql.dialect()))
+    some%%symbol
+
+As part of this change, the doubling that has been present when using
+operators like :meth:`.ColumnOperators.contains`,
+:meth:`.ColumnOperators.startswith` and :meth:`.ColumnOperators.endswith`
+is also refined to only occur when appropriate.
+
+:ticket:`3740`
+
 .. _change_3934:
 
 The after_rollback() Session event now emits before the expiration of objects
index 2e47aa324b7151005de7233e83f822c1adb4ee82..f368c723791e48fb49982fb468e64a6fa5ba7aff 100644 (file)
@@ -1857,7 +1857,7 @@ is the DISTINCT modifier.  A simple DISTINCT clause can be added using the
     >>> conn.execute(stmt).fetchall()
     {opensql}SELECT DISTINCT users.name
     FROM users, addresses
-    WHERE (addresses.email_address LIKE '%%' || users.name || '%%')
+    WHERE (addresses.email_address LIKE '%' || users.name || '%')
     ()
     {stop}[(u'jack',), (u'wendy',)]
 
@@ -2019,7 +2019,7 @@ The resulting SQL from the above statement would render as::
 
     UPDATE users SET name=:name FROM addresses
     WHERE users.id = addresses.id AND
-    addresses.email_address LIKE :email_address_1 || '%%'
+    addresses.email_address LIKE :email_address_1 || '%'
 
 When using MySQL, columns from each table can be assigned to in the
 SET clause directly, using the dictionary form passed to :meth:`.Update.values`::
@@ -2036,7 +2036,7 @@ The tables are referenced explicitly in the SET clause::
 
     UPDATE users, addresses SET addresses.email_address=%s,
             users.name=%s WHERE users.id = addresses.id
-            AND addresses.email_address LIKE concat(%s, '%%')
+            AND addresses.email_address LIKE concat(%s, '%')
 
 SQLAlchemy doesn't do anything special when these constructs are used on
 a non-supporting database.  The ``UPDATE FROM`` syntax generates by default
index 6af860133f7c836089c6d2fe4c165728e55ab060..7941d4c41897eefbb29ff935d1b88637e441b89e 100644 (file)
@@ -64,19 +64,11 @@ class MySQLExecutionContext_mysqldb(MySQLExecutionContext):
 
 
 class MySQLCompiler_mysqldb(MySQLCompiler):
-    def visit_mod_binary(self, binary, operator, **kw):
-        return self.process(binary.left, **kw) + " %% " + \
-            self.process(binary.right, **kw)
-
-    def post_process_text(self, text):
-        return text.replace('%', '%%')
+    pass
 
 
 class MySQLIdentifierPreparer_mysqldb(MySQLIdentifierPreparer):
-
-    def _escape_identifier(self, value):
-        value = value.replace(self.escape_quote, self.escape_to_quote)
-        return value.replace("%", "%%")
+    pass
 
 
 class MySQLDialect_mysqldb(MySQLDialect):
index 50328143e37183bc458418c936a07ec36636a733..31792a49222ccadd40bf732bd7f5f3c55af14a6a 100644 (file)
@@ -455,18 +455,11 @@ class PGExecutionContext_psycopg2(PGExecutionContext):
 
 
 class PGCompiler_psycopg2(PGCompiler):
-    def visit_mod_binary(self, binary, operator, **kw):
-        return self.process(binary.left, **kw) + " %% " + \
-            self.process(binary.right, **kw)
-
-    def post_process_text(self, text):
-        return text.replace('%', '%%')
+    pass
 
 
 class PGIdentifierPreparer_psycopg2(PGIdentifierPreparer):
-    def _escape_identifier(self, value):
-        value = value.replace(self.escape_quote, self.escape_to_quote)
-        return value.replace('%', '%%')
+    pass
 
 
 class PGDialect_psycopg2(PGDialect):
index b18f90312f59c182336d648ea51c174c99bed812..cc42480096d76f6ae2a588e078cb573bc65ccea7 100644 (file)
@@ -695,7 +695,6 @@ class SQLCompiler(Compiled):
             name = self.escape_literal_column(name)
         else:
             name = self.preparer.quote(name)
-
         table = column.table
         if table is None or not include_table or not table.named_with_column:
             return name
@@ -715,12 +714,6 @@ class SQLCompiler(Compiled):
                 self.preparer.quote(tablename) + \
                 "." + name
 
-    def escape_literal_column(self, text):
-        """provide escaping for the literal_column() construct."""
-
-        # TODO: some dialects might need different behavior here
-        return text.replace('%', '%%')
-
     def visit_fromclause(self, fromclause, **kwargs):
         return fromclause.name
 
@@ -732,6 +725,13 @@ class SQLCompiler(Compiled):
         return self.dialect.type_compiler.process(typeclause.type, **kw)
 
     def post_process_text(self, text):
+        if self.preparer._double_percents:
+            text = text.replace('%', '%%')
+        return text
+
+    def escape_literal_column(self, text):
+        if self.preparer._double_percents:
+            text = text.replace('%', '%%')
         return text
 
     def visit_textclause(self, textclause, **kw):
@@ -1048,6 +1048,14 @@ class SQLCompiler(Compiled):
             else:
                 return self._generate_generic_binary(binary, opstring, **kw)
 
+    def visit_mod_binary(self, binary, operator, **kw):
+        if self.preparer._double_percents:
+            return self.process(binary.left, **kw) + " %% " + \
+                self.process(binary.right, **kw)
+        else:
+            return self.process(binary.left, **kw) + " % " + \
+                self.process(binary.right, **kw)
+
     def visit_custom_op_binary(self, element, operator, **kw):
         kw['eager_grouping'] = operator.eager_grouping
         return self._generate_generic_binary(
@@ -2888,6 +2896,7 @@ class IdentifierPreparer(object):
         self.escape_to_quote = self.escape_quote * 2
         self.omit_schema = omit_schema
         self._strings = {}
+        self._double_percents = self.dialect.paramstyle in ('format', 'pyformat')
 
     def _with_schema_translate(self, schema_translate_map):
         prep = self.__class__.__new__(self.__class__)
@@ -2902,7 +2911,10 @@ class IdentifierPreparer(object):
         escaping behavior.
         """
 
-        return value.replace(self.escape_quote, self.escape_to_quote)
+        value = value.replace(self.escape_quote, self.escape_to_quote)
+        if self._double_percents:
+            value = value.replace('%', '%%')
+        return value
 
     def _unescape_identifier(self, value):
         """Canonicalize an escaped identifier.
index 00884a212c99ec52f4fc90191e286e08b0178e1f..0e62c347fe6e6e96cb26cabd47f4752956637df0 100644 (file)
@@ -1,9 +1,11 @@
 from .. import fixtures, config
 from ..config import requirements
 from sqlalchemy import exc
-from sqlalchemy import Integer, String
+from sqlalchemy import Integer, String, select, literal_column
 from .. import assert_raises
 from ..schema import Table, Column
+from .. import provide_metadata
+from .. import eq_
 
 
 class ExceptionTest(fixtures.TablesTest):
@@ -39,3 +41,33 @@ class ExceptionTest(fixtures.TablesTest):
                 self.tables.manual_pk.insert(),
                 {'id': 1, 'data': 'd1'}
             )
+
+
+class EscapingTest(fixtures.TestBase):
+    @provide_metadata
+    def test_percent_sign_round_trip(self):
+        """test that the DBAPI accommodates for escaped / nonescaped
+        percent signs in a way that matches the compiler
+
+        """
+        m = self.metadata
+        t = Table('t', m, Column('data', String(50)))
+        t.create(config.db)
+        with config.db.begin() as conn:
+            conn.execute(t.insert(), dict(data="some % value"))
+            conn.execute(t.insert(), dict(data="some %% other value"))
+
+            eq_(
+                conn.scalar(
+                    select([t.c.data]).where(
+                        t.c.data == literal_column("'some % value'"))
+                ),
+                "some % value"
+            )
+
+            eq_(
+                conn.scalar(
+                    select([t.c.data]).where(
+                        t.c.data == literal_column("'some %% other value'"))
+                ), "some %% other value"
+            )
index 7c3ce1389828d9aae637fa2a4d822a5e35a1238a..217af4337701efd630033f3888b6d26f68c558c5 100644 (file)
@@ -2258,56 +2258,56 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
     def test_contains(self):
         self.assert_compile(
             column('x').contains('y'),
-            "x LIKE '%%' || :x_1 || '%%'",
+            "x LIKE '%' || :x_1 || '%'",
             checkparams={'x_1': 'y'}
         )
 
     def test_contains_escape(self):
         self.assert_compile(
             column('x').contains('a%b_c', escape='\\'),
-            "x LIKE '%%' || :x_1 || '%%' ESCAPE '\\'",
+            "x LIKE '%' || :x_1 || '%' ESCAPE '\\'",
             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 '\\'",
+            "x LIKE '%' || :x_1 || '%' ESCAPE '\\'",
             checkparams={'x_1': 'a\\%b\\_c'}
         )
 
     def test_contains_literal(self):
         self.assert_compile(
             column('x').contains(literal_column('y')),
-            "x LIKE '%%' || y || '%%'",
+            "x LIKE '%' || y || '%'",
             checkparams={}
         )
 
     def test_contains_text(self):
         self.assert_compile(
             column('x').contains(text('y')),
-            "x LIKE '%%' || y || '%%'",
+            "x LIKE '%' || y || '%'",
             checkparams={}
         )
 
     def test_not_contains(self):
         self.assert_compile(
             ~column('x').contains('y'),
-            "x NOT LIKE '%%' || :x_1 || '%%'",
+            "x NOT LIKE '%' || :x_1 || '%'",
             checkparams={'x_1': 'y'}
         )
 
     def test_not_contains_escape(self):
         self.assert_compile(
             ~column('x').contains('a%b_c', escape='\\'),
-            "x NOT LIKE '%%' || :x_1 || '%%' ESCAPE '\\'",
+            "x NOT LIKE '%' || :x_1 || '%' ESCAPE '\\'",
             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 '\\'",
+            "x NOT LIKE '%' || :x_1 || '%' ESCAPE '\\'",
             checkparams={'x_1': 'a\\%b\\_c'}
         )
 
@@ -2402,56 +2402,56 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
     def test_startswith(self):
         self.assert_compile(
             column('x').startswith('y'),
-            "x LIKE :x_1 || '%%'",
+            "x LIKE :x_1 || '%'",
             checkparams={'x_1': 'y'}
         )
 
     def test_startswith_escape(self):
         self.assert_compile(
             column('x').startswith('a%b_c', escape='\\'),
-            "x LIKE :x_1 || '%%' ESCAPE '\\'",
+            "x LIKE :x_1 || '%' ESCAPE '\\'",
             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 '\\'",
+            "x LIKE :x_1 || '%' ESCAPE '\\'",
             checkparams={'x_1': 'a\\%b\\_c'}
         )
 
     def test_not_startswith(self):
         self.assert_compile(
             ~column('x').startswith('y'),
-            "x NOT LIKE :x_1 || '%%'",
+            "x NOT LIKE :x_1 || '%'",
             checkparams={'x_1': 'y'}
         )
 
     def test_not_startswith_escape(self):
         self.assert_compile(
             ~column('x').startswith('a%b_c', escape='\\'),
-            "x NOT LIKE :x_1 || '%%' ESCAPE '\\'",
+            "x NOT LIKE :x_1 || '%' ESCAPE '\\'",
             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 '\\'",
+            "x NOT LIKE :x_1 || '%' ESCAPE '\\'",
             checkparams={'x_1': 'a\\%b\\_c'}
         )
 
     def test_startswith_literal(self):
         self.assert_compile(
             column('x').startswith(literal_column('y')),
-            "x LIKE y || '%%'",
+            "x LIKE y || '%'",
             checkparams={}
         )
 
     def test_startswith_text(self):
         self.assert_compile(
             column('x').startswith(text('y')),
-            "x LIKE y || '%%'",
+            "x LIKE y || '%'",
             checkparams={}
         )
 
@@ -2506,56 +2506,56 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
     def test_endswith(self):
         self.assert_compile(
             column('x').endswith('y'),
-            "x LIKE '%%' || :x_1",
+            "x LIKE '%' || :x_1",
             checkparams={'x_1': 'y'}
         )
 
     def test_endswith_escape(self):
         self.assert_compile(
             column('x').endswith('a%b_c', escape='\\'),
-            "x LIKE '%%' || :x_1 ESCAPE '\\'",
+            "x LIKE '%' || :x_1 ESCAPE '\\'",
             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 '\\'",
+            "x LIKE '%' || :x_1 ESCAPE '\\'",
             checkparams={'x_1': 'a\\%b\\_c'}
         )
 
     def test_not_endswith(self):
         self.assert_compile(
             ~column('x').endswith('y'),
-            "x NOT LIKE '%%' || :x_1",
+            "x NOT LIKE '%' || :x_1",
             checkparams={'x_1': 'y'}
         )
 
     def test_not_endswith_escape(self):
         self.assert_compile(
             ~column('x').endswith('a%b_c', escape='\\'),
-            "x NOT LIKE '%%' || :x_1 ESCAPE '\\'",
+            "x NOT LIKE '%' || :x_1 ESCAPE '\\'",
             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 '\\'",
+            "x NOT LIKE '%' || :x_1 ESCAPE '\\'",
             checkparams={'x_1': 'a\\%b\\_c'}
         )
 
     def test_endswith_literal(self):
         self.assert_compile(
             column('x').endswith(literal_column('y')),
-            "x LIKE '%%' || y",
+            "x LIKE '%' || y",
             checkparams={}
         )
 
     def test_endswith_text(self):
         self.assert_compile(
             column('x').endswith(text('y')),
-            "x LIKE '%%' || y",
+            "x LIKE '%' || y",
             checkparams={}
         )
 
index a436dde670335bfced1141b92bd1cf117b905504..477fca7836998f6ecaede4444a4409b28a68a2ba 100644 (file)
@@ -1,12 +1,12 @@
-from sqlalchemy import *
+from sqlalchemy import MetaData, Table, Column, Integer, select, \
+    ForeignKey, Index, CheckConstraint, inspect, column
 from sqlalchemy import sql, schema
 from sqlalchemy.sql import compiler
 from sqlalchemy.testing import fixtures, AssertsCompiledSQL, eq_
 from sqlalchemy import testing
-from sqlalchemy.sql.elements import (quoted_name,
-                                     _truncated_label,
-                                     _anonymous_label)
+from sqlalchemy.sql.elements import quoted_name, _anonymous_label
 from sqlalchemy.testing.util import picklers
+from sqlalchemy.engine import default
 
 
 class QuoteExecTest(fixtures.TestBase):
@@ -700,7 +700,7 @@ class PreparerTest(fixtures.TestBase):
     """Test the db-agnostic quoting services of IdentifierPreparer."""
 
     def test_unformat(self):
-        prep = compiler.IdentifierPreparer(None)
+        prep = compiler.IdentifierPreparer(default.DefaultDialect())
         unformat = prep.unformat_identifiers
 
         def a_eq(have, want):
@@ -732,7 +732,7 @@ class PreparerTest(fixtures.TestBase):
             def _unescape_identifier(self, value):
                 return value.replace('``', '`')
 
-        prep = Custom(None)
+        prep = Custom(default.DefaultDialect())
         unformat = prep.unformat_identifiers
 
         def a_eq(have, want):
index 4a273e1ee255533a50d76c84314e30b3ed1c3f6b..ca4d10702a3fbdd3a938f67bb10f0d35f1cbfa60 100644 (file)
@@ -317,6 +317,19 @@ class BindParamTest(fixtures.TestBase, AssertsCompiledSQL):
             checkparams={'y': 6, 'x': 5, 'z': 7}
         )
 
+    def test_escaping_percent_signs(self):
+        stmt = text("select '%' where foo like '%bar%'")
+        self.assert_compile(
+            stmt,
+            "select '%' where foo like '%bar%'",
+            dialect="sqlite"
+        )
+
+        self.assert_compile(
+            stmt,
+            "select '%%' where foo like '%%bar%%'",
+            dialect="mysql"
+        )
 
 class AsFromTest(fixtures.TestBase, AssertsCompiledSQL):
     __dialect__ = 'default'