]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Default caching to opt-out for 3rd party dialects
authorMike Bayer <mike_mp@zzzcomputing.com>
Thu, 1 Apr 2021 21:20:31 +0000 (17:20 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Thu, 1 Apr 2021 22:59:41 +0000 (18:59 -0400)
Added a new flag to the :class:`_engine.Dialect` class called
:attr:`_engine.Dialect.supports_statement_cache`. This flag now needs to be present
directly on a dialect class in order for SQLAlchemy's
:ref:`query cache <sql_caching>` to take effect for that dialect. The
rationale is based on discovered issues such as :ticket:`6173` revealing
that dialects which hardcode literal values from the compiled statement,
often the numerical parameters used for LIMIT / OFFSET, will not be
compatible with caching until these dialects are revised to use the
parameters present in the statement only. For third party dialects where
this flag is not applied, the SQL logging will show the message "dialect
does not support caching", indicating the dialect should seek to apply this
flag once they have verified that no per-statement literal values are being
rendered within the compilation phase.

Fixes: #6184
Change-Id: I6fd5b5d94200458d4cb0e14f2f556dbc25e27e22

42 files changed:
doc/build/changelog/unreleased_14/6184.rst [new file with mode: 0644]
doc/build/core/connections.rst
lib/sqlalchemy/dialects/firebird/base.py
lib/sqlalchemy/dialects/firebird/fdb.py
lib/sqlalchemy/dialects/firebird/kinterbasdb.py
lib/sqlalchemy/dialects/mssql/base.py
lib/sqlalchemy/dialects/mssql/mxodbc.py
lib/sqlalchemy/dialects/mssql/pymssql.py
lib/sqlalchemy/dialects/mssql/pyodbc.py
lib/sqlalchemy/dialects/mysql/aiomysql.py
lib/sqlalchemy/dialects/mysql/base.py
lib/sqlalchemy/dialects/mysql/cymysql.py
lib/sqlalchemy/dialects/mysql/mariadb.py
lib/sqlalchemy/dialects/mysql/mariadbconnector.py
lib/sqlalchemy/dialects/mysql/mysqlconnector.py
lib/sqlalchemy/dialects/mysql/mysqldb.py
lib/sqlalchemy/dialects/mysql/oursql.py
lib/sqlalchemy/dialects/mysql/pymysql.py
lib/sqlalchemy/dialects/mysql/pyodbc.py
lib/sqlalchemy/dialects/oracle/base.py
lib/sqlalchemy/dialects/oracle/cx_oracle.py
lib/sqlalchemy/dialects/postgresql/asyncpg.py
lib/sqlalchemy/dialects/postgresql/base.py
lib/sqlalchemy/dialects/postgresql/pg8000.py
lib/sqlalchemy/dialects/postgresql/psycopg2.py
lib/sqlalchemy/dialects/postgresql/psycopg2cffi.py
lib/sqlalchemy/dialects/postgresql/pygresql.py
lib/sqlalchemy/dialects/postgresql/pypostgresql.py
lib/sqlalchemy/dialects/sqlite/aiosqlite.py
lib/sqlalchemy/dialects/sqlite/base.py
lib/sqlalchemy/dialects/sqlite/pysqlcipher.py
lib/sqlalchemy/dialects/sqlite/pysqlite.py
lib/sqlalchemy/dialects/sybase/base.py
lib/sqlalchemy/dialects/sybase/mxodbc.py
lib/sqlalchemy/dialects/sybase/pyodbc.py
lib/sqlalchemy/dialects/sybase/pysybase.py
lib/sqlalchemy/engine/default.py
lib/sqlalchemy/engine/interfaces.py
lib/sqlalchemy/sql/elements.py
lib/sqlalchemy/sql/selectable.py
test/engine/test_execute.py
test/requirements.py

diff --git a/doc/build/changelog/unreleased_14/6184.rst b/doc/build/changelog/unreleased_14/6184.rst
new file mode 100644 (file)
index 0000000..a5a3724
--- /dev/null
@@ -0,0 +1,21 @@
+.. change::
+    :tags: bug, sql
+    :tickets: 6184
+
+    Added a new flag to the :class:`_engine.Dialect` class called
+    :attr:`_engine.Dialect.supports_statement_cache`. This flag now needs to be present
+    directly on a dialect class in order for SQLAlchemy's
+    :ref:`query cache <sql_caching>` to take effect for that dialect. The
+    rationale is based on discovered issues such as :ticket:`6173` revealing
+    that dialects which hardcode literal values from the compiled statement,
+    often the numerical parameters used for LIMIT / OFFSET, will not be
+    compatible with caching until these dialects are revised to use the
+    parameters present in the statement only. For third party dialects where
+    this flag is not applied, the SQL logging will show the message "dialect
+    does not support caching", indicating the dialect should seek to apply this
+    flag once they have verified that no per-statement literal values are being
+    rendered within the compilation phase.
+
+    .. seealso::
+
+      :ref:`engine_thirdparty_caching`
\ No newline at end of file
index 17fca63bfe72e834ce6c71e316aa3bda72f516bf..b59f827e579696ff3fae74feccdb48f51e05e963 100644 (file)
@@ -1136,6 +1136,47 @@ The cache can also be disabled with this argument by sending a value of
   with engine.connect().execution_options(compiled_cache=None) as conn:
       conn.execute(table.select())
 
+.. _engine_thirdparty_caching:
+
+Caching for Third Party Dialects
+---------------------------------
+
+The caching feature requires that the dialect's compiler produces a SQL
+construct that is generically reusable given a particular cache key.  This means
+that any literal values in a statement, such as the LIMIT/OFFSET values for
+a SELECT, can not be hardcoded in the dialect's compilation scheme, as
+the compiled string will not be re-usable.   SQLAlchemy supports rendered
+bound parameters using the :meth:`_sql.BindParameter.render_literal_execute`
+method which can be applied to the existing ``Select._limit_clause`` and
+``Select._offset_clause`` attributes by a custom compiler.
+
+As there are many third party dialects, many of which may be generating
+literal values from SQL statements without the benefit of the newer "literal execute"
+feature, SQLAlchemy as of version 1.4.5 has added a flag to dialects known as
+:attr:`_engine.Dialect.supports_statement_cache`.  This flag is tested to be present
+directly on a dialect class, and not any superclasses, so that even a third
+party dialect that subclasses an existing cacheable SQLAlchemy dialect such
+as ``sqlalchemy.dialects.postgresql.PGDialect`` must still specify this flag,
+once the dialect has been altered as needed and tested for reusability of
+compiled SQL statements with differing parameters.
+
+For all third party dialects that don't support this flag, the logging for
+such a dialect will indicate ``dialect does not support caching``.   Dialect
+authors can apply the flag as follows::
+
+    from sqlalchemy.engine.default import DefaultDialect
+
+    class MyDialect(DefaultDialect):
+        supports_statement_cache = True
+
+The flag needs to be applied to all subclasses of the dialect as well::
+
+    class MyDBAPIForMyDialect(MyDialect):
+        supports_statement_cache = True
+
+.. versionadded:: 1.4.5
+
+
 .. _engine_lambda_caching:
 
 Using Lambdas to add significant speed gains to statement production
index 7fc914f1b091c6769a4ae392666797ee584b17fd..fcf0c31d39c7f8b7ba7a34efe2f10218c853d6b9 100644 (file)
@@ -623,6 +623,7 @@ class FBDialect(default.DefaultDialect):
     """Firebird dialect"""
 
     name = "firebird"
+    supports_statement_cache = True
 
     max_identifier_length = 31
 
index 14954b0730a2039f7035bf35efce4e8c11c9bef3..18ed65f45eb77761896f12ff0002258b7cd2d3b5 100644 (file)
@@ -66,6 +66,8 @@ from ... import util
 
 
 class FBDialect_fdb(FBDialect_kinterbasdb):
+    supports_statement_cache = True
+
     def __init__(self, enable_rowcount=True, retaining=False, **kwargs):
         super(FBDialect_fdb, self).__init__(
             enable_rowcount=enable_rowcount, retaining=retaining, **kwargs
index 4c937e0de499f0e1e553c75e34d8e776d84968a4..7c91db639cb304f724e803367e26abd2fca2ce89 100644 (file)
@@ -78,6 +78,7 @@ class FBExecutionContext_kinterbasdb(FBExecutionContext):
 
 class FBDialect_kinterbasdb(FBDialect):
     driver = "kinterbasdb"
+    supports_statement_cache = True
     supports_sane_rowcount = False
     supports_sane_multi_rowcount = False
     execution_ctx_cls = FBExecutionContext_kinterbasdb
index bfa1cf08c4c52e8532541ffcb3c45c81b90e5c0a..1fef42c53e9e261cbb608574ba5e07632ac9aaec 100644 (file)
@@ -2576,6 +2576,7 @@ def _schema_elements(schema):
 class MSDialect(default.DefaultDialect):
     # will assume it's at least mssql2005
     name = "mssql"
+    supports_statement_cache = True
     supports_default_values = True
     supports_empty_insert = False
     execution_ctx_cls = MSExecutionContext
index da4e45f07f1aa31a1e870d6da7de54938a854d06..637b048693d3c687851792c0a7d92d5e4d4bc7e7 100644 (file)
@@ -126,6 +126,7 @@ class MSDialect_mxodbc(MxODBCConnector, MSDialect):
     # this is only needed if "native ODBC" mode is used,
     # which is now disabled by default.
     # statement_compiler = MSSQLStrictCompiler
+    supports_statement_cache = True
 
     execution_ctx_cls = MSExecutionContext_mxodbc
 
index 5110badb9332715c4f13473b104e49c9406e53e7..4cc6c4696c165bc93041d1ca917279d2bbfdabff 100644 (file)
@@ -65,6 +65,7 @@ class MSIdentifierPreparer_pymssql(MSIdentifierPreparer):
 
 
 class MSDialect_pymssql(MSDialect):
+    supports_statement_cache = True
     supports_native_decimal = True
     driver = "pymssql"
 
index 6b2fffc4eee2d24fabf871d85f8bc2ad2bd24114..99ab372289ce78e984cf5d43f44203c2ca287dd0 100644 (file)
@@ -431,6 +431,7 @@ class MSExecutionContext_pyodbc(MSExecutionContext):
 
 
 class MSDialect_pyodbc(PyODBCConnector, MSDialect):
+    supports_statement_cache = True
 
     # mssql still has problems with this on Linux
     supports_sane_rowcount_returning = False
index c8c7c0f978af69b7ec676796fdc389c60012e9a6..6c77e7525eb9d343d8c6675996465485e14fe37d 100644 (file)
@@ -266,6 +266,7 @@ class AsyncAdapt_aiomysql_dbapi:
 
 class MySQLDialect_aiomysql(MySQLDialect_pymysql):
     driver = "aiomysql"
+    supports_statement_cache = True
 
     supports_server_side_cursors = True
     _sscursor = AsyncAdapt_aiomysql_ss_cursor
index b3c338bad60a9aa40cee7478bcf11742bd83b7e3..986ed875759c5d857970e9186953602da50764d6 100644 (file)
@@ -2511,6 +2511,8 @@ class MySQLDialect(default.DefaultDialect):
     """
 
     name = "mysql"
+    supports_statement_cache = True
+
     supports_alter = True
 
     # MySQL has no true "boolean" type; we
index 0d7ba5594c7e7526afb4e8fba033a80118d8b83e..ec9fd6edd9abbf5b29ef2bccda9d1653d36a4422 100644 (file)
@@ -43,6 +43,7 @@ class _cymysqlBIT(BIT):
 
 class MySQLDialect_cymysql(MySQLDialect_mysqldb):
     driver = "cymysql"
+    supports_statement_cache = True
 
     description_encoding = None
     supports_sane_rowcount = True
index 0dbb579e815380e71e7defed7fa7742b39fc5116..8ebde462b885b3179ce0adc8afe36b7491cd13b1 100644 (file)
@@ -3,6 +3,7 @@ from .base import MySQLDialect
 
 class MariaDBDialect(MySQLDialect):
     is_mariadb = True
+    supports_statement_cache = True
     name = "mariadb"
 
 
@@ -18,5 +19,5 @@ def loader(driver):
             MariaDBDialect,
             driver_cls,
         ),
-        {},
+        {"supports_statement_cache": True},
     )
index ddc11f6e6301b055d9b5b52205f4394c4d6e95ab..6e3a249504e731ae1ede8096da4bdba3bc827998 100644 (file)
@@ -57,6 +57,7 @@ class MySQLIdentifierPreparer_mariadbconnector(MySQLIdentifierPreparer):
 
 class MySQLDialect_mariadbconnector(MySQLDialect):
     driver = "mariadbconnector"
+    supports_statement_cache = True
 
     # set this to True at the module level to prevent the driver from running
     # against a backend that server detects as MySQL. currently this appears to
index 5ed675b13225fc4f1141abbe754c6386d96e89d4..80f20688b0f459b510ac68f4a131efcf11e6fede 100644 (file)
@@ -85,6 +85,7 @@ class _myconnpyBIT(BIT):
 
 class MySQLDialect_mysqlconnector(MySQLDialect):
     driver = "mysqlconnector"
+    supports_statement_cache = True
 
     supports_unicode_binds = True
 
index 0318b50772c8d8fbf77a42d56a639364fe42fc7f..274f3eea427e8baa71995f26d57c35c805db6421 100644 (file)
@@ -77,6 +77,7 @@ class MySQLIdentifierPreparer_mysqldb(MySQLIdentifierPreparer):
 
 class MySQLDialect_mysqldb(MySQLDialect):
     driver = "mysqldb"
+    supports_statement_cache = True
     supports_unicode_statements = True
     supports_sane_rowcount = True
     supports_sane_multi_rowcount = True
index 5c8c7b7c2d22e25c5938a3af901fd0774c86da1b..06a6115b4ebdc605c5d9b7eb2c63b3b3adcbf724 100644 (file)
@@ -55,6 +55,7 @@ class MySQLExecutionContext_oursql(MySQLExecutionContext):
 
 class MySQLDialect_oursql(MySQLDialect):
     driver = "oursql"
+    supports_statement_cache = True
 
     if util.py2k:
         supports_unicode_binds = True
index 0c321f854b308a231b45df1038145dea5c203036..09b5abffe9cd03189bc51734f9063db07bde65d0 100644 (file)
@@ -35,6 +35,7 @@ from ...util import py3k
 
 class MySQLDialect_pymysql(MySQLDialect_mysqldb):
     driver = "pymysql"
+    supports_statement_cache = True
 
     description_encoding = None
 
index 048586b598952fcd8faba294d7825bb1ab650718..7bc9ff14f51e12083edbd7be0e5e0302345365c9 100644 (file)
@@ -72,6 +72,7 @@ class MySQLExecutionContext_pyodbc(MySQLExecutionContext):
 
 
 class MySQLDialect_pyodbc(PyODBCConnector, MySQLDialect):
+    supports_statement_cache = True
     colspecs = util.update_copy(MySQLDialect.colspecs, {Time: _pyodbcTIME})
     supports_unicode_statements = True
     execution_ctx_cls = MySQLExecutionContext_pyodbc
index 46fcbbbe1a2347a1c47fc5c124fc8bd3d096571e..11ad61675e5aec08c4dc4ab74742d9f3b53e668c 100644 (file)
@@ -1093,10 +1093,10 @@ class OracleCompiler(compiler.SQLCompiler):
                 offset_clause = select._offset_clause
 
                 if select._simple_int_clause(limit_clause):
-                    limit_clause = limit_clause._render_literal_execute()
+                    limit_clause = limit_clause.render_literal_execute()
 
                 if select._simple_int_clause(offset_clause):
-                    offset_clause = offset_clause._render_literal_execute()
+                    offset_clause = offset_clause.render_literal_execute()
 
                 # currently using form at:
                 # https://blogs.oracle.com/oraclemagazine/\
@@ -1434,6 +1434,7 @@ class OracleExecutionContext(default.DefaultExecutionContext):
 
 class OracleDialect(default.DefaultDialect):
     name = "oracle"
+    supports_statement_cache = True
     supports_alter = True
     supports_unicode_statements = False
     supports_unicode_binds = False
index 38b92117b074dfa008e9f867b279043cf289c571..87c817066c39f5f484f265506c0d4eae6fcb5c24 100644 (file)
@@ -797,6 +797,7 @@ class OracleExecutionContext_cx_oracle(OracleExecutionContext):
 
 
 class OracleDialect_cx_oracle(OracleDialect):
+    supports_statement_cache = True
     execution_ctx_cls = OracleExecutionContext_cx_oracle
     statement_compiler = OracleCompiler_cx_oracle
 
index 4580421f68be36b5f5d5696fdbaae9213f3a918b..8cd5bee41fcfc2e40ec9d08a71e33caf1c6cfe74 100644 (file)
@@ -850,6 +850,7 @@ _pg_types = {
 
 class PGDialect_asyncpg(PGDialect):
     driver = "asyncpg"
+    supports_statement_cache = True
 
     supports_unicode_statements = True
     supports_server_side_cursors = True
index 97eb07bdb6e0f89b2b62f2959fc545522ac71422..2c4e316b36ddb2e18157fa14698e37b6e85b98e6 100644 (file)
@@ -3062,6 +3062,7 @@ class PGDeferrableConnectionCharacteristic(
 
 class PGDialect(default.DefaultDialect):
     name = "postgresql"
+    supports_statement_cache = True
     supports_alter = True
     max_identifier_length = 63
     supports_sane_rowcount = True
index 43df6edea218087d0723dc2ec3dd32b81190f07d..eaf6ccbb87d4e909459b85378138448ac6af57cd 100644 (file)
@@ -252,6 +252,7 @@ class PGIdentifierPreparer_pg8000(PGIdentifierPreparer):
 
 class PGDialect_pg8000(PGDialect):
     driver = "pg8000"
+    supports_statement_cache = True
 
     supports_unicode_statements = True
 
index 16f9ecefae5778406f849fe8935b493a9d8ca13d..c2b6790224e6f94710598a1da8c0fb42b6713da3 100644 (file)
@@ -634,6 +634,9 @@ EXECUTEMANY_VALUES_PLUS_BATCH = util.symbol(
 
 class PGDialect_psycopg2(PGDialect):
     driver = "psycopg2"
+
+    supports_statement_cache = True
+
     if util.py2k:
         # turn off supports_unicode_statements for Python 2. psycopg2 supports
         # unicode statements in Py2K. But!  it does not support unicode *bound
index a449f9e657f85496c36a3b86c25e20f319ac0f5e..780244be915bba9457954dd93e478ff82033f0c4 100644 (file)
@@ -28,6 +28,7 @@ from .psycopg2 import PGDialect_psycopg2
 class PGDialect_psycopg2cffi(PGDialect_psycopg2):
     driver = "psycopg2cffi"
     supports_unicode_statements = True
+    supports_statement_cache = True
 
     # psycopg2cffi's first release is 2.5.0, but reports
     # __version__ as 2.4.4.  Subsequent releases seem to have
index 64dd7262d67710a7a719480255180f852d04fec1..718bbf78f200ba0a67ae0eb1d15b555940241471 100644 (file)
@@ -193,6 +193,7 @@ class _PGIdentifierPreparer(PGIdentifierPreparer):
 class PGDialect_pygresql(PGDialect):
 
     driver = "pygresql"
+    supports_statement_cache = True
 
     statement_compiler = _PGCompiler
     preparer = _PGIdentifierPreparer
index 6e4db217dc22cbdf15368e9f7e43a724b46111a3..7d478386775c99350d9added6814cbcc1696bdae 100644 (file)
@@ -52,6 +52,7 @@ class PGExecutionContext_pypostgresql(PGExecutionContext):
 class PGDialect_pypostgresql(PGDialect):
     driver = "pypostgresql"
 
+    supports_statement_cache = True
     supports_unicode_statements = True
     supports_unicode_binds = True
     description_encoding = None
index e4b7d1d520762a3cdd47caccf2a3c3c8db9a53b4..1d09a619dc861b6cc7dba9e6368fa4bcceaf0f32 100644 (file)
@@ -299,6 +299,7 @@ class SQLiteExecutionContext_aiosqlite(SQLiteExecutionContext):
 
 class SQLiteDialect_aiosqlite(SQLiteDialect_pysqlite):
     driver = "aiosqlite"
+    supports_statement_cache = True
 
     is_async = True
 
index 691ca642d1521926c9001bef23c2aa094c90c4fc..83c2a8ea755f6d3d7560174ce1b2bda8f361d173 100644 (file)
@@ -1796,6 +1796,7 @@ class SQLiteDialect(default.DefaultDialect):
     supports_cast = True
     supports_multivalues_insert = True
     tuple_in_values = True
+    supports_statement_cache = True
 
     default_paramstyle = "qmark"
     execution_ctx_cls = SQLiteExecutionContext
index 8f0f46acb0580d2c381b9b3ddfccaab9b7b67bab..ff02d4dee87d779cdf20556cbbd3634aef62f293 100644 (file)
@@ -98,6 +98,7 @@ from ... import util
 
 class SQLiteDialect_pysqlcipher(SQLiteDialect_pysqlite):
     driver = "pysqlcipher"
+    supports_statement_cache = True
 
     pragmas = ("kdf_iter", "cipher", "cipher_page_size", "cipher_use_hmac")
 
index c940faf38863ff5e87002cb6f54f9b17d8e5aa7a..0b091e73b648fd1d4f6caab7a546cd6e699d1bff 100644 (file)
@@ -443,6 +443,7 @@ class _SQLite_pysqliteDate(DATE):
 
 class SQLiteDialect_pysqlite(SQLiteDialect):
     default_paramstyle = "qmark"
+    supports_statement_cache = True
 
     colspecs = util.update_copy(
         SQLiteDialect.colspecs,
index 49243be784eed498d5b0da4ebaf76d8805bd1846..fd5e5b3b6367cbcae23b8fc62bb15c33d7e1fc41 100644 (file)
@@ -632,6 +632,7 @@ class SybaseDialect(default.DefaultDialect):
     supports_unicode_statements = False
     supports_sane_rowcount = False
     supports_sane_multi_rowcount = False
+    supports_statement_cache = True
 
     supports_native_boolean = False
     supports_unicode_binds = False
index 6b2f07c545593f03fd27460f86b243bbb17c4163..7002217c0179204b48a4f91ea8d3831adb26c428 100644 (file)
@@ -28,6 +28,7 @@ class SybaseExecutionContext_mxodbc(SybaseExecutionContext):
 
 class SybaseDialect_mxodbc(MxODBCConnector, SybaseDialect):
     execution_ctx_cls = SybaseExecutionContext_mxodbc
+    supports_statement_cache = True
 
 
 dialect = SybaseDialect_mxodbc
index bbd6d968ae386377a49cfc2cd078a140294b9621..bbaaa7e020a6de92da68858955d1821c18061492 100644 (file)
@@ -77,6 +77,7 @@ class SybaseExecutionContext_pyodbc(SybaseExecutionContext):
 
 class SybaseDialect_pyodbc(PyODBCConnector, SybaseDialect):
     execution_ctx_cls = SybaseExecutionContext_pyodbc
+    supports_statement_cache = True
 
     colspecs = {sqltypes.Numeric: _SybNumeric_pyodbc}
 
index d6d2f2ed2863f59ae79b156bafd8c6d0c398aac2..0c5557e99837b6dd8e7bbc3f5dcf07f3acf10579 100644 (file)
@@ -62,6 +62,8 @@ class SybaseDialect_pysybase(SybaseDialect):
     execution_ctx_cls = SybaseExecutionContext_pysybase
     statement_compiler = SybaseSQLCompiler_pysybase
 
+    supports_statement_cache = True
+
     colspecs = {sqltypes.Numeric: _SybNumeric, sqltypes.Float: sqltypes.Float}
 
     @classmethod
index 7391e7b01ac578e58fbdf349bb9e3aa78a6ad5ac..5193a0273e8db6a70c19fcd7d8acc404a1dfc37d 100644 (file)
@@ -44,6 +44,7 @@ CACHE_HIT = util.symbol("CACHE_HIT")
 CACHE_MISS = util.symbol("CACHE_MISS")
 CACHING_DISABLED = util.symbol("CACHING_DISABLED")
 NO_CACHE_KEY = util.symbol("NO_CACHE_KEY")
+NO_DIALECT_SUPPORT = util.symbol("NO_DIALECT_SUPPORT")
 
 
 class DefaultDialect(interfaces.Dialect):
@@ -57,6 +58,7 @@ class DefaultDialect(interfaces.Dialect):
     supports_comments = False
     inline_comments = False
     use_setinputsizes = False
+    supports_statement_cache = True
 
     # the first value we'd get for an autoincrement
     # column.
@@ -215,6 +217,7 @@ class DefaultDialect(interfaces.Dialect):
     CACHE_MISS = CACHE_MISS
     CACHING_DISABLED = CACHING_DISABLED
     NO_CACHE_KEY = NO_CACHE_KEY
+    NO_DIALECT_SUPPORT = NO_DIALECT_SUPPORT
 
     @util.deprecated_params(
         convert_unicode=(
@@ -319,6 +322,13 @@ class DefaultDialect(interfaces.Dialect):
         self._encoder = codecs.getencoder(self.encoding)
         self._decoder = processors.to_unicode_processor_factory(self.encoding)
 
+    @util.memoized_property
+    def _supports_statement_cache(self):
+        return (
+            self.__class__.__dict__.get("supports_statement_cache", False)
+            is True
+        )
+
     @util.memoized_property
     def _type_memos(self):
         return weakref.WeakKeyDictionary()
@@ -771,6 +781,8 @@ class StrCompileDialect(DefaultDialect):
     type_compiler = compiler.StrSQLTypeCompiler
     preparer = compiler.IdentifierPreparer
 
+    supports_statement_cache = True
+
     supports_sequences = True
     sequences_optional = True
     preexecute_autoincrement_sequences = False
@@ -1137,6 +1149,12 @@ class DefaultExecutionContext(interfaces.ExecutionContext):
             return "generated in %.5fs" % (now - self.compiled._gen_time,)
         elif ch is CACHING_DISABLED:
             return "caching disabled %.5fs" % (now - self.compiled._gen_time,)
+        elif ch is NO_DIALECT_SUPPORT:
+            return "dialect %s+%s does not support caching %.5fs" % (
+                self.dialect.name,
+                self.dialect.driver,
+                now - self.compiled._gen_time,
+            )
         else:
             return "unknown"
 
index 24e0e5b0d1bce83d1bfa83c884542c31f72c613e..5e6cc524e9cec467e9d2ca76e06adcde01c11cf2 100644 (file)
@@ -152,6 +152,24 @@ class Dialect(object):
 
     _has_events = False
 
+    supports_statement_cache = True
+    """indicates if this dialect supports caching.
+
+    All dialects that are compatible with statement caching should set this
+    flag to True directly on each dialect class and subclass that supports
+    it.  SQLAlchemy tests that this flag is locally present on each dialect
+    subclass before it will use statement caching.  This is to provide
+    safety for legacy or new dialects that are not yet fully tested to be
+    compliant with SQL statement caching.
+
+    .. versionadded:: 1.4.5
+
+    .. seealso::
+
+        :ref:`engine_thirdparty_caching`
+
+    """
+
     def create_connect_args(self, url):
         """Build DB-API compatible connection arguments.
 
index b64427d51e8705a72eca925ead6ef23a0b28687e..7a27690b8969697324629c06f120feff6f9790c5 100644 (file)
@@ -514,7 +514,7 @@ class ClauseElement(
         schema_translate_map=None,
         **kw
     ):
-        if compiled_cache is not None:
+        if compiled_cache is not None and dialect._supports_statement_cache:
             elem_cache_key = self._generate_cache_key()
         else:
             elem_cache_key = None
@@ -553,11 +553,13 @@ class ClauseElement(
                 schema_translate_map=schema_translate_map,
                 **kw
             )
-            cache_hit = (
-                dialect.CACHING_DISABLED
-                if compiled_cache is None
-                else dialect.NO_CACHE_KEY
-            )
+
+            if not dialect._supports_statement_cache:
+                cache_hit = dialect.NO_DIALECT_SUPPORT
+            elif compiled_cache is None:
+                cache_hit = dialect.CACHING_DISABLED
+            else:
+                cache_hit = dialect.NO_CACHE_KEY
 
         return compiled_sql, extracted_params, cache_hit
 
@@ -1429,6 +1431,34 @@ class BindParameter(roles.InElementRole, ColumnElement):
         else:
             return self.value
 
+    def render_literal_execute(self):
+        """Produce a copy of this bound parameter that will enable the
+        :paramref:`_sql.BindParameter.literal_execute` flag.
+
+        The :paramref:`_sql.BindParameter.literal_execute` flag will
+        have the effect of the parameter rendered in the compiled SQL
+        string using ``[POSTCOMPILE]`` form, which is a special form that
+        is converted to be a rendering of the literal value of the parameter
+        at SQL execution time.    The rationale is to support caching
+        of SQL statement strings that can embed per-statement literal values,
+        such as LIMIT and OFFSET parameters, in the final SQL string that
+        is passed to the DBAPI.   Dialects in particular may want to use
+        this method within custom compilation schemes.
+
+        .. versionadded:: 1.4.5
+
+        .. seealso::
+
+            :ref:`engine_thirdparty_caching`
+
+        """
+        return self.__class__(
+            self.key,
+            self.value,
+            type_=self.type,
+            literal_execute=True,
+        )
+
     def _with_binary_element_type(self, type_):
         c = ClauseElement._clone(self)
         c.type = type_
index 189deec41dfdd7f186de34a8e87a6b6d2f85ef66..a2e5780f8a50804a207542ec8dced55f1ee16784 100644 (file)
@@ -69,14 +69,6 @@ class _OffsetLimitParam(BindParameter):
     def _limit_offset_value(self):
         return self.effective_value
 
-    def _render_literal_execute(self):
-        return _OffsetLimitParam(
-            self.key,
-            self.value,
-            type_=self.type,
-            literal_execute=True,
-        )
-
 
 @util.deprecated(
     "1.4",
index ce91f76a3daa8b33bf4a39fbd9f5062e910a0f27..09219cfdbb75d8f32b9f1743780af50d4225dc38 100644 (file)
@@ -3631,3 +3631,98 @@ class SetInputSizesTest(fixtures.TablesTest):
                 )
             ],
         )
+
+
+class DialectDoesntSupportCachingTest(fixtures.TestBase):
+    """test the opt-in caching flag added in :ticket:`6184`."""
+
+    __only_on__ = "sqlite+pysqlite"
+
+    __requires__ = ("sqlite_memory",)
+
+    @testing.fixture()
+    def sqlite_no_cache_dialect(self, testing_engine):
+        from sqlalchemy.dialects.sqlite.pysqlite import SQLiteDialect_pysqlite
+        from sqlalchemy.dialects.sqlite.base import SQLiteCompiler
+        from sqlalchemy.sql import visitors
+
+        class MyCompiler(SQLiteCompiler):
+            def translate_select_structure(self, select_stmt, **kwargs):
+                select = select_stmt
+
+                if not getattr(select, "_mydialect_visit", None):
+                    select = visitors.cloned_traverse(select_stmt, {}, {})
+                    if select._limit_clause is not None:
+                        # create a bindparam with a fixed name and hardcode
+                        # it to the given limit.  this breaks caching.
+                        select._limit_clause = bindparam(
+                            "limit", value=select._limit, literal_execute=True
+                        )
+
+                    select._mydialect_visit = True
+
+                return select
+
+        class MyDialect(SQLiteDialect_pysqlite):
+            statement_compiler = MyCompiler
+
+        from sqlalchemy.dialects import registry
+
+        def go(name):
+            return MyDialect
+
+        with mock.patch.object(registry, "load", go):
+            eng = testing_engine()
+            yield eng
+
+    @testing.fixture
+    def data_fixture(self, sqlite_no_cache_dialect):
+        m = MetaData()
+        t = Table("t1", m, Column("x", Integer))
+        with sqlite_no_cache_dialect.begin() as conn:
+            t.create(conn)
+            conn.execute(t.insert(), [{"x": 1}, {"x": 2}, {"x": 3}, {"x": 4}])
+
+        return t
+
+    def test_no_cache(self, sqlite_no_cache_dialect, data_fixture):
+        eng = sqlite_no_cache_dialect
+
+        def go(lim):
+            with eng.connect() as conn:
+                result = conn.execute(
+                    select(data_fixture).order_by(data_fixture.c.x).limit(lim)
+                )
+                return result
+
+        r1 = go(2)
+        r2 = go(3)
+
+        eq_(r1.all(), [(1,), (2,)])
+        eq_(r2.all(), [(1,), (2,), (3,)])
+
+    def test_it_caches(self, sqlite_no_cache_dialect, data_fixture):
+        eng = sqlite_no_cache_dialect
+        eng.dialect.__class__.supports_statement_cache = True
+        del eng.dialect.__dict__["_supports_statement_cache"]
+
+        def go(lim):
+            with eng.connect() as conn:
+                result = conn.execute(
+                    select(data_fixture).order_by(data_fixture.c.x).limit(lim)
+                )
+                return result
+
+        r1 = go(2)
+        r2 = go(3)
+
+        eq_(r1.all(), [(1,), (2,)])
+
+        # wrong answer
+        eq_(
+            r2.all(),
+            [
+                (1,),
+                (2,),
+            ],
+        )
index e71cec6e19898b4609ea42c1c5c3a0c1cfbbc9b4..c0057b94ec01e3d8d8be199add019ab299236f44 100644 (file)
@@ -1049,6 +1049,10 @@ class DefaultRequirements(SuiteRequirements):
                 except exc.DBAPIError:
                     return False
 
+    @property
+    def sqlite_memory(self):
+        return only_on(self._sqlite_memory_db)
+
     @property
     def reflects_json_type(self):
         return only_on(