From: Mike Bayer Date: Thu, 1 Apr 2021 21:20:31 +0000 (-0400) Subject: Default caching to opt-out for 3rd party dialects X-Git-Tag: rel_1_4_5~5 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=20c0f774e5517514da811bc446812baa6b1f32f1;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Default caching to opt-out for 3rd party dialects 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 ` 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 --- diff --git a/doc/build/changelog/unreleased_14/6184.rst b/doc/build/changelog/unreleased_14/6184.rst new file mode 100644 index 0000000000..a5a3724469 --- /dev/null +++ b/doc/build/changelog/unreleased_14/6184.rst @@ -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 ` 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 diff --git a/doc/build/core/connections.rst b/doc/build/core/connections.rst index 17fca63bfe..b59f827e57 100644 --- a/doc/build/core/connections.rst +++ b/doc/build/core/connections.rst @@ -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 diff --git a/lib/sqlalchemy/dialects/firebird/base.py b/lib/sqlalchemy/dialects/firebird/base.py index 7fc914f1b0..fcf0c31d39 100644 --- a/lib/sqlalchemy/dialects/firebird/base.py +++ b/lib/sqlalchemy/dialects/firebird/base.py @@ -623,6 +623,7 @@ class FBDialect(default.DefaultDialect): """Firebird dialect""" name = "firebird" + supports_statement_cache = True max_identifier_length = 31 diff --git a/lib/sqlalchemy/dialects/firebird/fdb.py b/lib/sqlalchemy/dialects/firebird/fdb.py index 14954b0730..18ed65f45e 100644 --- a/lib/sqlalchemy/dialects/firebird/fdb.py +++ b/lib/sqlalchemy/dialects/firebird/fdb.py @@ -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 diff --git a/lib/sqlalchemy/dialects/firebird/kinterbasdb.py b/lib/sqlalchemy/dialects/firebird/kinterbasdb.py index 4c937e0de4..7c91db639c 100644 --- a/lib/sqlalchemy/dialects/firebird/kinterbasdb.py +++ b/lib/sqlalchemy/dialects/firebird/kinterbasdb.py @@ -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 diff --git a/lib/sqlalchemy/dialects/mssql/base.py b/lib/sqlalchemy/dialects/mssql/base.py index bfa1cf08c4..1fef42c53e 100644 --- a/lib/sqlalchemy/dialects/mssql/base.py +++ b/lib/sqlalchemy/dialects/mssql/base.py @@ -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 diff --git a/lib/sqlalchemy/dialects/mssql/mxodbc.py b/lib/sqlalchemy/dialects/mssql/mxodbc.py index da4e45f07f..637b048693 100644 --- a/lib/sqlalchemy/dialects/mssql/mxodbc.py +++ b/lib/sqlalchemy/dialects/mssql/mxodbc.py @@ -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 diff --git a/lib/sqlalchemy/dialects/mssql/pymssql.py b/lib/sqlalchemy/dialects/mssql/pymssql.py index 5110badb93..4cc6c4696c 100644 --- a/lib/sqlalchemy/dialects/mssql/pymssql.py +++ b/lib/sqlalchemy/dialects/mssql/pymssql.py @@ -65,6 +65,7 @@ class MSIdentifierPreparer_pymssql(MSIdentifierPreparer): class MSDialect_pymssql(MSDialect): + supports_statement_cache = True supports_native_decimal = True driver = "pymssql" diff --git a/lib/sqlalchemy/dialects/mssql/pyodbc.py b/lib/sqlalchemy/dialects/mssql/pyodbc.py index 6b2fffc4ee..99ab372289 100644 --- a/lib/sqlalchemy/dialects/mssql/pyodbc.py +++ b/lib/sqlalchemy/dialects/mssql/pyodbc.py @@ -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 diff --git a/lib/sqlalchemy/dialects/mysql/aiomysql.py b/lib/sqlalchemy/dialects/mysql/aiomysql.py index c8c7c0f978..6c77e7525e 100644 --- a/lib/sqlalchemy/dialects/mysql/aiomysql.py +++ b/lib/sqlalchemy/dialects/mysql/aiomysql.py @@ -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 diff --git a/lib/sqlalchemy/dialects/mysql/base.py b/lib/sqlalchemy/dialects/mysql/base.py index b3c338bad6..986ed87575 100644 --- a/lib/sqlalchemy/dialects/mysql/base.py +++ b/lib/sqlalchemy/dialects/mysql/base.py @@ -2511,6 +2511,8 @@ class MySQLDialect(default.DefaultDialect): """ name = "mysql" + supports_statement_cache = True + supports_alter = True # MySQL has no true "boolean" type; we diff --git a/lib/sqlalchemy/dialects/mysql/cymysql.py b/lib/sqlalchemy/dialects/mysql/cymysql.py index 0d7ba5594c..ec9fd6edd9 100644 --- a/lib/sqlalchemy/dialects/mysql/cymysql.py +++ b/lib/sqlalchemy/dialects/mysql/cymysql.py @@ -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 diff --git a/lib/sqlalchemy/dialects/mysql/mariadb.py b/lib/sqlalchemy/dialects/mysql/mariadb.py index 0dbb579e81..8ebde462b8 100644 --- a/lib/sqlalchemy/dialects/mysql/mariadb.py +++ b/lib/sqlalchemy/dialects/mysql/mariadb.py @@ -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}, ) diff --git a/lib/sqlalchemy/dialects/mysql/mariadbconnector.py b/lib/sqlalchemy/dialects/mysql/mariadbconnector.py index ddc11f6e63..6e3a249504 100644 --- a/lib/sqlalchemy/dialects/mysql/mariadbconnector.py +++ b/lib/sqlalchemy/dialects/mysql/mariadbconnector.py @@ -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 diff --git a/lib/sqlalchemy/dialects/mysql/mysqlconnector.py b/lib/sqlalchemy/dialects/mysql/mysqlconnector.py index 5ed675b132..80f20688b0 100644 --- a/lib/sqlalchemy/dialects/mysql/mysqlconnector.py +++ b/lib/sqlalchemy/dialects/mysql/mysqlconnector.py @@ -85,6 +85,7 @@ class _myconnpyBIT(BIT): class MySQLDialect_mysqlconnector(MySQLDialect): driver = "mysqlconnector" + supports_statement_cache = True supports_unicode_binds = True diff --git a/lib/sqlalchemy/dialects/mysql/mysqldb.py b/lib/sqlalchemy/dialects/mysql/mysqldb.py index 0318b50772..274f3eea42 100644 --- a/lib/sqlalchemy/dialects/mysql/mysqldb.py +++ b/lib/sqlalchemy/dialects/mysql/mysqldb.py @@ -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 diff --git a/lib/sqlalchemy/dialects/mysql/oursql.py b/lib/sqlalchemy/dialects/mysql/oursql.py index 5c8c7b7c2d..06a6115b4e 100644 --- a/lib/sqlalchemy/dialects/mysql/oursql.py +++ b/lib/sqlalchemy/dialects/mysql/oursql.py @@ -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 diff --git a/lib/sqlalchemy/dialects/mysql/pymysql.py b/lib/sqlalchemy/dialects/mysql/pymysql.py index 0c321f854b..09b5abffe9 100644 --- a/lib/sqlalchemy/dialects/mysql/pymysql.py +++ b/lib/sqlalchemy/dialects/mysql/pymysql.py @@ -35,6 +35,7 @@ from ...util import py3k class MySQLDialect_pymysql(MySQLDialect_mysqldb): driver = "pymysql" + supports_statement_cache = True description_encoding = None diff --git a/lib/sqlalchemy/dialects/mysql/pyodbc.py b/lib/sqlalchemy/dialects/mysql/pyodbc.py index 048586b598..7bc9ff14f5 100644 --- a/lib/sqlalchemy/dialects/mysql/pyodbc.py +++ b/lib/sqlalchemy/dialects/mysql/pyodbc.py @@ -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 diff --git a/lib/sqlalchemy/dialects/oracle/base.py b/lib/sqlalchemy/dialects/oracle/base.py index 46fcbbbe1a..11ad61675e 100644 --- a/lib/sqlalchemy/dialects/oracle/base.py +++ b/lib/sqlalchemy/dialects/oracle/base.py @@ -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 diff --git a/lib/sqlalchemy/dialects/oracle/cx_oracle.py b/lib/sqlalchemy/dialects/oracle/cx_oracle.py index 38b92117b0..87c817066c 100644 --- a/lib/sqlalchemy/dialects/oracle/cx_oracle.py +++ b/lib/sqlalchemy/dialects/oracle/cx_oracle.py @@ -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 diff --git a/lib/sqlalchemy/dialects/postgresql/asyncpg.py b/lib/sqlalchemy/dialects/postgresql/asyncpg.py index 4580421f68..8cd5bee41f 100644 --- a/lib/sqlalchemy/dialects/postgresql/asyncpg.py +++ b/lib/sqlalchemy/dialects/postgresql/asyncpg.py @@ -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 diff --git a/lib/sqlalchemy/dialects/postgresql/base.py b/lib/sqlalchemy/dialects/postgresql/base.py index 97eb07bdb6..2c4e316b36 100644 --- a/lib/sqlalchemy/dialects/postgresql/base.py +++ b/lib/sqlalchemy/dialects/postgresql/base.py @@ -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 diff --git a/lib/sqlalchemy/dialects/postgresql/pg8000.py b/lib/sqlalchemy/dialects/postgresql/pg8000.py index 43df6edea2..eaf6ccbb87 100644 --- a/lib/sqlalchemy/dialects/postgresql/pg8000.py +++ b/lib/sqlalchemy/dialects/postgresql/pg8000.py @@ -252,6 +252,7 @@ class PGIdentifierPreparer_pg8000(PGIdentifierPreparer): class PGDialect_pg8000(PGDialect): driver = "pg8000" + supports_statement_cache = True supports_unicode_statements = True diff --git a/lib/sqlalchemy/dialects/postgresql/psycopg2.py b/lib/sqlalchemy/dialects/postgresql/psycopg2.py index 16f9ecefae..c2b6790224 100644 --- a/lib/sqlalchemy/dialects/postgresql/psycopg2.py +++ b/lib/sqlalchemy/dialects/postgresql/psycopg2.py @@ -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 diff --git a/lib/sqlalchemy/dialects/postgresql/psycopg2cffi.py b/lib/sqlalchemy/dialects/postgresql/psycopg2cffi.py index a449f9e657..780244be91 100644 --- a/lib/sqlalchemy/dialects/postgresql/psycopg2cffi.py +++ b/lib/sqlalchemy/dialects/postgresql/psycopg2cffi.py @@ -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 diff --git a/lib/sqlalchemy/dialects/postgresql/pygresql.py b/lib/sqlalchemy/dialects/postgresql/pygresql.py index 64dd7262d6..718bbf78f2 100644 --- a/lib/sqlalchemy/dialects/postgresql/pygresql.py +++ b/lib/sqlalchemy/dialects/postgresql/pygresql.py @@ -193,6 +193,7 @@ class _PGIdentifierPreparer(PGIdentifierPreparer): class PGDialect_pygresql(PGDialect): driver = "pygresql" + supports_statement_cache = True statement_compiler = _PGCompiler preparer = _PGIdentifierPreparer diff --git a/lib/sqlalchemy/dialects/postgresql/pypostgresql.py b/lib/sqlalchemy/dialects/postgresql/pypostgresql.py index 6e4db217dc..7d47838677 100644 --- a/lib/sqlalchemy/dialects/postgresql/pypostgresql.py +++ b/lib/sqlalchemy/dialects/postgresql/pypostgresql.py @@ -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 diff --git a/lib/sqlalchemy/dialects/sqlite/aiosqlite.py b/lib/sqlalchemy/dialects/sqlite/aiosqlite.py index e4b7d1d520..1d09a619dc 100644 --- a/lib/sqlalchemy/dialects/sqlite/aiosqlite.py +++ b/lib/sqlalchemy/dialects/sqlite/aiosqlite.py @@ -299,6 +299,7 @@ class SQLiteExecutionContext_aiosqlite(SQLiteExecutionContext): class SQLiteDialect_aiosqlite(SQLiteDialect_pysqlite): driver = "aiosqlite" + supports_statement_cache = True is_async = True diff --git a/lib/sqlalchemy/dialects/sqlite/base.py b/lib/sqlalchemy/dialects/sqlite/base.py index 691ca642d1..83c2a8ea75 100644 --- a/lib/sqlalchemy/dialects/sqlite/base.py +++ b/lib/sqlalchemy/dialects/sqlite/base.py @@ -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 diff --git a/lib/sqlalchemy/dialects/sqlite/pysqlcipher.py b/lib/sqlalchemy/dialects/sqlite/pysqlcipher.py index 8f0f46acb0..ff02d4dee8 100644 --- a/lib/sqlalchemy/dialects/sqlite/pysqlcipher.py +++ b/lib/sqlalchemy/dialects/sqlite/pysqlcipher.py @@ -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") diff --git a/lib/sqlalchemy/dialects/sqlite/pysqlite.py b/lib/sqlalchemy/dialects/sqlite/pysqlite.py index c940faf388..0b091e73b6 100644 --- a/lib/sqlalchemy/dialects/sqlite/pysqlite.py +++ b/lib/sqlalchemy/dialects/sqlite/pysqlite.py @@ -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, diff --git a/lib/sqlalchemy/dialects/sybase/base.py b/lib/sqlalchemy/dialects/sybase/base.py index 49243be784..fd5e5b3b63 100644 --- a/lib/sqlalchemy/dialects/sybase/base.py +++ b/lib/sqlalchemy/dialects/sybase/base.py @@ -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 diff --git a/lib/sqlalchemy/dialects/sybase/mxodbc.py b/lib/sqlalchemy/dialects/sybase/mxodbc.py index 6b2f07c545..7002217c01 100644 --- a/lib/sqlalchemy/dialects/sybase/mxodbc.py +++ b/lib/sqlalchemy/dialects/sybase/mxodbc.py @@ -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 diff --git a/lib/sqlalchemy/dialects/sybase/pyodbc.py b/lib/sqlalchemy/dialects/sybase/pyodbc.py index bbd6d968ae..bbaaa7e020 100644 --- a/lib/sqlalchemy/dialects/sybase/pyodbc.py +++ b/lib/sqlalchemy/dialects/sybase/pyodbc.py @@ -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} diff --git a/lib/sqlalchemy/dialects/sybase/pysybase.py b/lib/sqlalchemy/dialects/sybase/pysybase.py index d6d2f2ed28..0c5557e998 100644 --- a/lib/sqlalchemy/dialects/sybase/pysybase.py +++ b/lib/sqlalchemy/dialects/sybase/pysybase.py @@ -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 diff --git a/lib/sqlalchemy/engine/default.py b/lib/sqlalchemy/engine/default.py index 7391e7b01a..5193a0273e 100644 --- a/lib/sqlalchemy/engine/default.py +++ b/lib/sqlalchemy/engine/default.py @@ -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" diff --git a/lib/sqlalchemy/engine/interfaces.py b/lib/sqlalchemy/engine/interfaces.py index 24e0e5b0d1..5e6cc524e9 100644 --- a/lib/sqlalchemy/engine/interfaces.py +++ b/lib/sqlalchemy/engine/interfaces.py @@ -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. diff --git a/lib/sqlalchemy/sql/elements.py b/lib/sqlalchemy/sql/elements.py index b64427d51e..7a27690b89 100644 --- a/lib/sqlalchemy/sql/elements.py +++ b/lib/sqlalchemy/sql/elements.py @@ -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_ diff --git a/lib/sqlalchemy/sql/selectable.py b/lib/sqlalchemy/sql/selectable.py index 189deec41d..a2e5780f8a 100644 --- a/lib/sqlalchemy/sql/selectable.py +++ b/lib/sqlalchemy/sql/selectable.py @@ -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", diff --git a/test/engine/test_execute.py b/test/engine/test_execute.py index ce91f76a3d..09219cfdbb 100644 --- a/test/engine/test_execute.py +++ b/test/engine/test_execute.py @@ -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,), + ], + ) diff --git a/test/requirements.py b/test/requirements.py index e71cec6e19..c0057b94ec 100644 --- a/test/requirements.py +++ b/test/requirements.py @@ -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(