]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Support tuple IN VALUES for SQLite, others
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 16 Jul 2019 16:41:09 +0000 (12:41 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Fri, 19 Jul 2019 17:08:06 +0000 (13:08 -0400)
Added support for composite (tuple) IN operators with SQLite, by rendering
the VALUES keyword for this backend.  As other backends such as DB2 are
known to use the same syntax, the syntax is enabled in the base compiler
using a dialect-level flag ``tuple_in_values``.   The change also includes
support for "empty IN tuple" expressions for SQLite when using "in_()"
between a tuple value and an empty set.

Fixes: #4766
Change-Id: I416e1af29b31d78f9ae06ec3c3a48ef6d6e813f5

doc/build/changelog/unreleased_13/4766.rst [new file with mode: 0644]
doc/build/orm/loading_relationships.rst
lib/sqlalchemy/dialects/sqlite/base.py
lib/sqlalchemy/engine/default.py
lib/sqlalchemy/sql/coercions.py
lib/sqlalchemy/sql/compiler.py
lib/sqlalchemy/sql/elements.py
test/dialect/test_sqlite.py
test/requirements.py
test/sql/test_compiler.py

diff --git a/doc/build/changelog/unreleased_13/4766.rst b/doc/build/changelog/unreleased_13/4766.rst
new file mode 100644 (file)
index 0000000..afea19a
--- /dev/null
@@ -0,0 +1,11 @@
+.. change::
+    :tags: usecase, sqlite
+    :tickets: 4766
+
+    Added support for composite (tuple) IN operators with SQLite, by rendering
+    the VALUES keyword for this backend.  As other backends such as DB2 are
+    known to use the same syntax, the syntax is enabled in the base compiler
+    using a dialect-level flag ``tuple_in_values``.   The change also includes
+    support for "empty IN tuple" expressions for SQLite when using "in_()"
+    between a tuple value and an empty set.
+
index 1a60693cd600dfafc9bdd2c81e1871a09543e277..8a9a5148cc8d3e19b7338f6c1248a124d8839865 100644 (file)
@@ -835,17 +835,17 @@ as of the 1.2 series.   Things to know about this kind of loading include:
   SQL Server.
 
 * As "selectin" loading relies upon IN, for a mapping with composite primary
-  keys, it must use the "tuple" form of IN, which looks like
-  ``WHERE (table.column_a, table.column_b) IN ((?, ?), (?, ?), (?, ?))``.
-  This syntax is not supported on every database; currently it is known
-  to be only supported by modern PostgreSQL and MySQL versions.  Therefore
-  **selectin loading is not platform-agnostic for composite primary keys**.
-  There is no special logic in SQLAlchemy to check ahead of time which platforms
-  support this syntax or not; if run against a non-supporting platform (such
-  as SQLite), the database will return an error immediately.   An advantage to SQLAlchemy
-  just running the SQL out for it to fail is that if a database like
-  SQLite does start supporting this syntax, it will work without any changes
-  to SQLAlchemy.
+  keys, it must use the "tuple" form of IN, which looks like ``WHERE
+  (table.column_a, table.column_b) IN ((?, ?), (?, ?), (?, ?))``. This syntax
+  is not supported on every database; within the dialects that are included
+  with SQLAlchemy, it is known to be supported by modern PostgreSQL, MySQL and
+  SQLite versions.  Therefore **selectin loading is not platform-agnostic for
+  composite primary keys**. There is no special logic in SQLAlchemy to check
+  ahead of time which platforms support this syntax or not; if run against a
+  non-supporting platform, the database will return an error immediately.   An
+  advantage to SQLAlchemy just running the SQL out for it to fail is that if a
+  particular database does start supporting this syntax, it will work without
+  any changes to SQLAlchemy.
 
 In general, "selectin" loading is probably superior to "subquery" eager loading
 in most ways, save for the syntax requirement with composite primary keys
index b6ca8fe3c95260217ef84079bbd99eb829217a0a..c9309cbad57faf79ed7bd9bc31f6cb3451339d4a 100644 (file)
@@ -1023,8 +1023,11 @@ class SQLiteCompiler(compiler.SQLCompiler):
             self.process(binary.right, **kw),
         )
 
-    def visit_empty_set_expr(self, type_):
-        return "SELECT 1 FROM (SELECT 1) WHERE 1!=1"
+    def visit_empty_set_expr(self, element_types):
+        return "SELECT %s FROM (SELECT %s) WHERE 1!=1" % (
+            ", ".join("1" for type_ in element_types or [INTEGER()]),
+            ", ".join("1" for type_ in element_types or [INTEGER()]),
+        )
 
 
 class SQLiteDDLCompiler(compiler.DDLCompiler):
@@ -1391,6 +1394,7 @@ class SQLiteDialect(default.DefaultDialect):
     supports_empty_insert = False
     supports_cast = True
     supports_multivalues_insert = True
+    tuple_in_values = True
 
     default_paramstyle = "qmark"
     execution_ctx_cls = SQLiteExecutionContext
index f6c30cbf47a3a915291c1ff39e7d09b61a0da519..b56755d62c5eb988f7eb2822ff4068409eb0a736 100644 (file)
@@ -75,6 +75,8 @@ class DefaultDialect(interfaces.Dialect):
 
     supports_simple_order_by_label = True
 
+    tuple_in_values = False
+
     engine_config_types = util.immutabledict(
         [
             ("convert_unicode", util.bool_or_str("force")),
@@ -812,7 +814,9 @@ class DefaultExecutionContext(interfaces.ExecutionContext):
                             for i, tuple_element in enumerate(values, 1)
                             for j, value in enumerate(tuple_element, 1)
                         ]
-                        replacement_expressions[name] = ", ".join(
+                        replacement_expressions[name] = (
+                            "VALUES " if self.dialect.tuple_in_values else ""
+                        ) + ", ".join(
                             "(%s)"
                             % ", ".join(
                                 self.compiled.bindtemplate
index 64d9f0f968b9c962424ff20ba2810e28cac990da..8a9f0b979f0c332e393712c0dccb821f36c80dc2 100644 (file)
@@ -332,7 +332,9 @@ class InElementImpl(RoleImpl, roles.InElementRole):
                     o = expr._bind_param(operator, o)
                 args.append(o)
 
-            return elements.ClauseList(*args)
+            return elements.ClauseList(
+                _tuple_values=isinstance(expr, elements.Tuple), *args
+            )
 
         else:
             self._raise_for_expected(element, **kw)
@@ -354,7 +356,6 @@ class InElementImpl(RoleImpl, roles.InElementRole):
                 return element.self_group(against=operator)
 
         elif isinstance(element, elements.BindParameter) and element.expanding:
-
             if isinstance(expr, elements.Tuple):
                 element = element._with_expanding_in_types(
                     [elem.type for elem in expr]
index ea7e890e74d6d65649c3f5bc06e60f9cfee605a8..740abeb3d7f96970b3fb7cde9bfe6a0e83411196 100644 (file)
@@ -966,13 +966,17 @@ class SQLCompiler(Compiled):
             sep = " "
         else:
             sep = OPERATORS[clauselist.operator]
-        return sep.join(
+
+        text = sep.join(
             s
             for s in (
                 c._compiler_dispatch(self, **kw) for c in clauselist.clauses
             )
             if s
         )
+        if clauselist._tuple_values and self.dialect.tuple_in_values:
+            text = "VALUES " + text
+        return text
 
     def visit_case(self, clause, **kwargs):
         x = "CASE "
index 6d1174d202d2619e0e7bb83a5400111dd7fc99c1..e2df1adc2d5843463a450052ea8f9bc6bdd7e19b 100644 (file)
@@ -1945,7 +1945,7 @@ class ClauseList(
         self.operator = kwargs.pop("operator", operators.comma_op)
         self.group = kwargs.pop("group", True)
         self.group_contents = kwargs.pop("group_contents", True)
-
+        self._tuple_values = kwargs.pop("_tuple_values", False)
         self._text_converter_role = text_converter_role = kwargs.pop(
             "_literal_as_text_role", roles.WhereHavingRole
         )
@@ -2011,6 +2011,8 @@ class ClauseList(
 class BooleanClauseList(ClauseList, ColumnElement):
     __visit_name__ = "clauselist"
 
+    _tuple_values = False
+
     def __init__(self, *arg, **kw):
         raise NotImplementedError(
             "BooleanClauseList has a private constructor"
@@ -2162,13 +2164,15 @@ class Tuple(ClauseList, ColumnElement):
                 [(1, 2), (5, 12), (10, 19)]
             )
 
+        .. versionchanged:: 1.3.6 Added support for SQLite IN tuples.
+
         .. warning::
 
-            The composite IN construct is not supported by all backends,
-            and is currently known to work on PostgreSQL and MySQL,
-            but not SQLite.   Unsupported backends will raise
-            a subclass of :class:`~sqlalchemy.exc.DBAPIError` when such
-            an expression is invoked.
+            The composite IN construct is not supported by all backends, and is
+            currently known to work on PostgreSQL, MySQL, and SQLite.
+            Unsupported backends will raise a subclass of
+            :class:`~sqlalchemy.exc.DBAPIError` when such an expression is
+            invoked.
 
         """
 
index 616652389675a23c51af985846026b67e6d67ca8..e2004069d17abde87d6f01c08247d66190b3e72f 100644 (file)
@@ -8,6 +8,7 @@ from sqlalchemy import and_
 from sqlalchemy import bindparam
 from sqlalchemy import CheckConstraint
 from sqlalchemy import Column
+from sqlalchemy import column
 from sqlalchemy import create_engine
 from sqlalchemy import DefaultClause
 from sqlalchemy import event
@@ -26,6 +27,7 @@ from sqlalchemy import sql
 from sqlalchemy import Table
 from sqlalchemy import testing
 from sqlalchemy import text
+from sqlalchemy import tuple_
 from sqlalchemy import types as sqltypes
 from sqlalchemy import UniqueConstraint
 from sqlalchemy import util
@@ -962,6 +964,12 @@ class SQLTest(fixtures.TestBase, AssertsCompiledSQL):
             dialect=sqlite.dialect(),
         )
 
+    def test_in_tuple(self):
+        self.assert_compile(
+            tuple_(column("q"), column("p")).in_([(1, 2), (3, 4)]),
+            "(q, p) IN (VALUES (?, ?), (?, ?))",
+        )
+
 
 class OnConflictDDLTest(fixtures.TestBase, AssertsCompiledSQL):
 
index 6385b0d8779f8ace2e1fcdb2ca27414d68ad53eb..fefe388688cbce77f46b2fc8de74550c96278cd4 100644 (file)
@@ -263,7 +263,12 @@ class DefaultRequirements(SuiteRequirements):
 
     @property
     def tuple_in(self):
-        return only_on(["mysql", "postgresql"])
+        def _sqlite_tuple_in(config):
+            return against(
+                config, "sqlite"
+            ) and config.db.dialect.dbapi.sqlite_version_info >= (3, 15, 0)
+
+        return only_on(["mysql", "postgresql", _sqlite_tuple_in])
 
     @property
     def independent_cursors(self):
index cd462fb779615c058677f1f8ac5cd9b4cff330dc..a2153082bb3edfadb964426b19e2b6acece3c89b 100644 (file)
@@ -2533,6 +2533,15 @@ class SelectTest(fixtures.TestBase, AssertsCompiledSQL):
             "((:param_1, :param_2), (:param_3, :param_4))",
         )
 
+        dialect = default.DefaultDialect()
+        dialect.tuple_in_values = True
+        self.assert_compile(
+            tuple_(table1.c.myid, table1.c.name).in_([(1, "foo"), (5, "bar")]),
+            "(mytable.myid, mytable.name) IN "
+            "(VALUES (:param_1, :param_2), (:param_3, :param_4))",
+            dialect=dialect,
+        )
+
         self.assert_compile(
             tuple_(table1.c.myid, table1.c.name).in_(
                 [tuple_(table2.c.otherid, table2.c.othername)]
@@ -2557,6 +2566,16 @@ class SelectTest(fixtures.TestBase, AssertsCompiledSQL):
             "(mytable.myid, mytable.name) IN ([EXPANDING_foo])",
         )
 
+        dialect = default.DefaultDialect()
+        dialect.tuple_in_values = True
+        self.assert_compile(
+            tuple_(table1.c.myid, table1.c.name).in_(
+                bindparam("foo", expanding=True)
+            ),
+            "(mytable.myid, mytable.name) IN ([EXPANDING_foo])",
+            dialect=dialect,
+        )
+
         self.assert_compile(
             table1.c.myid.in_(bindparam("foo", expanding=True)),
             "mytable.myid IN ([EXPANDING_foo])",