diff options
| author | Mike Bayer <mike_mp@zzzcomputing.com> | 2021-12-03 14:04:05 -0500 |
|---|---|---|
| committer | Mike Bayer <mike_mp@zzzcomputing.com> | 2021-12-06 18:27:19 -0500 |
| commit | 22deafe15289d2be55682e1632016004b02b62c0 (patch) | |
| tree | 5b521531418aebd4e293f848ebe4accbbd9bc5bc /lib/sqlalchemy | |
| parent | e88dc004e6bcd1418cb8eb811d0aa580c2a44b8f (diff) | |
| download | sqlalchemy-22deafe15289d2be55682e1632016004b02b62c0.tar.gz | |
Warn when caching is disabled / document
This patch adds new warnings for all elements that
don't indicate their caching behavior, including user-defined
ClauseElement subclasses and third party dialects.
it additionally adds new documentation to discuss an apparent
performance degradation in 1.4 when caching is disabled as a
result in the significant expense incurred by ORM
lazy loaders, which in 1.3 used BakedQuery so were actually
cached.
As a result of adding the warnings, a fair degree of
lesser used SQL expression objects identified that they did not
define caching behavior so would have been producing
``[no key]``, including PostgreSQL constructs ``hstore``
and ``array``. These have been amended to use inherit
cache where appropriate. "on conflict" constructs in
PostgreSQL, MySQL, SQLite still explicitly don't generate
a cache key at this time.
The change also adds a test for all constructs via
assert_compile() to assert they will not generate cache
warnings.
Fixes: #7394
Change-Id: I85958affbb99bfad0f5efa21bc8f2a95e7e46981
Diffstat (limited to 'lib/sqlalchemy')
| -rw-r--r-- | lib/sqlalchemy/dialects/mssql/base.py | 1 | ||||
| -rw-r--r-- | lib/sqlalchemy/dialects/mysql/dml.py | 1 | ||||
| -rw-r--r-- | lib/sqlalchemy/dialects/postgresql/array.py | 1 | ||||
| -rw-r--r-- | lib/sqlalchemy/dialects/postgresql/dml.py | 1 | ||||
| -rw-r--r-- | lib/sqlalchemy/dialects/postgresql/ext.py | 2 | ||||
| -rw-r--r-- | lib/sqlalchemy/dialects/postgresql/hstore.py | 8 | ||||
| -rw-r--r-- | lib/sqlalchemy/dialects/sqlite/dml.py | 1 | ||||
| -rw-r--r-- | lib/sqlalchemy/engine/default.py | 21 | ||||
| -rw-r--r-- | lib/sqlalchemy/ext/compiler.py | 105 | ||||
| -rw-r--r-- | lib/sqlalchemy/orm/attributes.py | 5 | ||||
| -rw-r--r-- | lib/sqlalchemy/orm/interfaces.py | 48 | ||||
| -rw-r--r-- | lib/sqlalchemy/orm/query.py | 2 | ||||
| -rw-r--r-- | lib/sqlalchemy/sql/base.py | 7 | ||||
| -rw-r--r-- | lib/sqlalchemy/sql/coercions.py | 16 | ||||
| -rw-r--r-- | lib/sqlalchemy/sql/ddl.py | 3 | ||||
| -rw-r--r-- | lib/sqlalchemy/sql/elements.py | 7 | ||||
| -rw-r--r-- | lib/sqlalchemy/sql/functions.py | 4 | ||||
| -rw-r--r-- | lib/sqlalchemy/sql/roles.py | 5 | ||||
| -rw-r--r-- | lib/sqlalchemy/sql/traversals.py | 101 | ||||
| -rw-r--r-- | lib/sqlalchemy/sql/type_api.py | 15 | ||||
| -rw-r--r-- | lib/sqlalchemy/testing/assertions.py | 9 |
21 files changed, 338 insertions, 25 deletions
diff --git a/lib/sqlalchemy/dialects/mssql/base.py b/lib/sqlalchemy/dialects/mssql/base.py index f0a7364a3..5b38c4bb5 100644 --- a/lib/sqlalchemy/dialects/mssql/base.py +++ b/lib/sqlalchemy/dialects/mssql/base.py @@ -1353,6 +1353,7 @@ class TryCast(sql.elements.Cast): __visit_name__ = "try_cast" stringify_dialect = "mssql" + inherit_cache = True def __init__(self, *arg, **kw): """Create a TRY_CAST expression. diff --git a/lib/sqlalchemy/dialects/mysql/dml.py b/lib/sqlalchemy/dialects/mysql/dml.py index e2f78783c..790733cbf 100644 --- a/lib/sqlalchemy/dialects/mysql/dml.py +++ b/lib/sqlalchemy/dialects/mysql/dml.py @@ -25,6 +25,7 @@ class Insert(StandardInsert): """ stringify_dialect = "mysql" + inherit_cache = False @property def inserted(self): diff --git a/lib/sqlalchemy/dialects/postgresql/array.py b/lib/sqlalchemy/dialects/postgresql/array.py index ebe47c8d1..a8010c0fa 100644 --- a/lib/sqlalchemy/dialects/postgresql/array.py +++ b/lib/sqlalchemy/dialects/postgresql/array.py @@ -87,6 +87,7 @@ class array(expression.ClauseList, expression.ColumnElement): __visit_name__ = "array" stringify_dialect = "postgresql" + inherit_cache = True def __init__(self, clauses, **kw): clauses = [ diff --git a/lib/sqlalchemy/dialects/postgresql/dml.py b/lib/sqlalchemy/dialects/postgresql/dml.py index c561b73a1..4451639f3 100644 --- a/lib/sqlalchemy/dialects/postgresql/dml.py +++ b/lib/sqlalchemy/dialects/postgresql/dml.py @@ -35,6 +35,7 @@ class Insert(StandardInsert): """ stringify_dialect = "postgresql" + inherit_cache = False @util.memoized_property def excluded(self): diff --git a/lib/sqlalchemy/dialects/postgresql/ext.py b/lib/sqlalchemy/dialects/postgresql/ext.py index f779a8010..e323da8be 100644 --- a/lib/sqlalchemy/dialects/postgresql/ext.py +++ b/lib/sqlalchemy/dialects/postgresql/ext.py @@ -54,6 +54,7 @@ class aggregate_order_by(expression.ColumnElement): __visit_name__ = "aggregate_order_by" stringify_dialect = "postgresql" + inherit_cache = False def __init__(self, target, *order_by): self.target = coercions.expect(roles.ExpressionElementRole, target) @@ -99,6 +100,7 @@ class ExcludeConstraint(ColumnCollectionConstraint): __visit_name__ = "exclude_constraint" where = None + inherit_cache = False create_drop_stringify_dialect = "postgresql" diff --git a/lib/sqlalchemy/dialects/postgresql/hstore.py b/lib/sqlalchemy/dialects/postgresql/hstore.py index 2ade4b7c1..77220a33a 100644 --- a/lib/sqlalchemy/dialects/postgresql/hstore.py +++ b/lib/sqlalchemy/dialects/postgresql/hstore.py @@ -273,41 +273,49 @@ class hstore(sqlfunc.GenericFunction): type = HSTORE name = "hstore" + inherit_cache = True class _HStoreDefinedFunction(sqlfunc.GenericFunction): type = sqltypes.Boolean name = "defined" + inherit_cache = True class _HStoreDeleteFunction(sqlfunc.GenericFunction): type = HSTORE name = "delete" + inherit_cache = True class _HStoreSliceFunction(sqlfunc.GenericFunction): type = HSTORE name = "slice" + inherit_cache = True class _HStoreKeysFunction(sqlfunc.GenericFunction): type = ARRAY(sqltypes.Text) name = "akeys" + inherit_cache = True class _HStoreValsFunction(sqlfunc.GenericFunction): type = ARRAY(sqltypes.Text) name = "avals" + inherit_cache = True class _HStoreArrayFunction(sqlfunc.GenericFunction): type = ARRAY(sqltypes.Text) name = "hstore_to_array" + inherit_cache = True class _HStoreMatrixFunction(sqlfunc.GenericFunction): type = ARRAY(sqltypes.Text) name = "hstore_to_matrix" + inherit_cache = True # diff --git a/lib/sqlalchemy/dialects/sqlite/dml.py b/lib/sqlalchemy/dialects/sqlite/dml.py index a93e31beb..e4d8bd943 100644 --- a/lib/sqlalchemy/dialects/sqlite/dml.py +++ b/lib/sqlalchemy/dialects/sqlite/dml.py @@ -36,6 +36,7 @@ class Insert(StandardInsert): """ stringify_dialect = "sqlite" + inherit_cache = False @util.memoized_property def excluded(self): diff --git a/lib/sqlalchemy/engine/default.py b/lib/sqlalchemy/engine/default.py index 9574e9980..e91e34f00 100644 --- a/lib/sqlalchemy/engine/default.py +++ b/lib/sqlalchemy/engine/default.py @@ -321,10 +321,23 @@ class DefaultDialect(interfaces.Dialect): @util.memoized_property def _supports_statement_cache(self): - return ( - self.__class__.__dict__.get("supports_statement_cache", False) - is True - ) + ssc = self.__class__.__dict__.get("supports_statement_cache", None) + if ssc is None: + util.warn( + "Dialect %s:%s will not make use of SQL compilation caching " + "as it does not set the 'supports_statement_cache' attribute " + "to ``True``. This can have " + "significant performance implications including some " + "performance degradations in comparison to prior SQLAlchemy " + "versions. Dialect maintainers should seek to set this " + "attribute to True after appropriate development and testing " + "for SQLAlchemy 1.4 caching support. Alternatively, this " + "attribute may be set to False which will disable this " + "warning." % (self.name, self.driver), + code="cprf", + ) + + return bool(ssc) @util.memoized_property def _type_memos(self): diff --git a/lib/sqlalchemy/ext/compiler.py b/lib/sqlalchemy/ext/compiler.py index d961e2c81..2b3e4cd7c 100644 --- a/lib/sqlalchemy/ext/compiler.py +++ b/lib/sqlalchemy/ext/compiler.py @@ -18,7 +18,7 @@ more callables defining its compilation:: from sqlalchemy.sql.expression import ColumnClause class MyColumn(ColumnClause): - pass + inherit_cache = True @compiles(MyColumn) def compile_mycolumn(element, compiler, **kw): @@ -47,6 +47,7 @@ invoked for the dialect in use:: from sqlalchemy.schema import DDLElement class AlterColumn(DDLElement): + inherit_cache = False def __init__(self, column, cmd): self.column = column @@ -64,6 +65,8 @@ invoked for the dialect in use:: The second ``visit_alter_table`` will be invoked when any ``postgresql`` dialect is used. +.. _compilerext_compiling_subelements: + Compiling sub-elements of a custom expression construct ======================================================= @@ -78,6 +81,8 @@ method which can be used for compilation of embedded attributes:: from sqlalchemy.sql.expression import Executable, ClauseElement class InsertFromSelect(Executable, ClauseElement): + inherit_cache = False + def __init__(self, table, select): self.table = table self.select = select @@ -131,9 +136,6 @@ a bound parameter; when emitting DDL, bound parameters are typically not supported. - - - Changing the default compilation of existing constructs ======================================================= @@ -202,6 +204,7 @@ A synopsis is as follows: class timestamp(ColumnElement): type = TIMESTAMP() + inherit_cache = True * :class:`~sqlalchemy.sql.functions.FunctionElement` - This is a hybrid of a ``ColumnElement`` and a "from clause" like object, and represents a SQL @@ -214,6 +217,7 @@ A synopsis is as follows: class coalesce(FunctionElement): name = 'coalesce' + inherit_cache = True @compiles(coalesce) def compile(element, compiler, **kw): @@ -237,6 +241,95 @@ A synopsis is as follows: SQL statement that can be passed directly to an ``execute()`` method. It is already implicit within ``DDLElement`` and ``FunctionElement``. +Most of the above constructs also respond to SQL statement caching. A +subclassed construct will want to define the caching behavior for the object, +which usually means setting the flag ``inherit_cache`` to the value of +``False`` or ``True``. See the next section :ref:`compilerext_caching` +for background. + + +.. _compilerext_caching: + +Enabling Caching Support for Custom Constructs +============================================== + +SQLAlchemy as of version 1.4 includes a +:ref:`SQL compilation caching facility <sql_caching>` which will allow +equivalent SQL constructs to cache their stringified form, along with other +structural information used to fetch results from the statement. + +For reasons discussed at :ref:`caching_caveats`, the implementation of this +caching system takes a conservative approach towards including custom SQL +constructs and/or subclasses within the caching system. This includes that +any user-defined SQL constructs, including all the examples for this +extension, will not participate in caching by default unless they positively +assert that they are able to do so. The :attr:`.HasCacheKey.inherit_cache` +attribute when set to ``True`` at the class level of a specific subclass +will indicate that instances of this class may be safely cached, using the +cache key generation scheme of the immediate superclass. This applies +for example to the "synopsis" example indicated previously:: + + class MyColumn(ColumnClause): + inherit_cache = True + + @compiles(MyColumn) + def compile_mycolumn(element, compiler, **kw): + return "[%s]" % element.name + +Above, the ``MyColumn`` class does not include any new state that +affects its SQL compilation; the cache key of ``MyColumn`` instances will +make use of that of the ``ColumnClause`` superclass, meaning it will take +into account the class of the object (``MyColumn``), the string name and +datatype of the object:: + + >>> MyColumn("some_name", String())._generate_cache_key() + CacheKey( + key=('0', <class '__main__.MyColumn'>, + 'name', 'some_name', + 'type', (<class 'sqlalchemy.sql.sqltypes.String'>, + ('length', None), ('collation', None)) + ), bindparams=[]) + +For objects that are likely to be **used liberally as components within many +larger statements**, such as :class:`_schema.Column` subclasses and custom SQL +datatypes, it's important that **caching be enabled as much as possible**, as +this may otherwise negatively affect performance. + +An example of an object that **does** contain state which affects its SQL +compilation is the one illustrated at :ref:`compilerext_compiling_subelements`; +this is an "INSERT FROM SELECT" construct that combines together a +:class:`_schema.Table` as well as a :class:`_sql.Select` construct, each of +which independently affect the SQL string generation of the construct. For +this class, the example illustrates that it simply does not participate in +caching:: + + class InsertFromSelect(Executable, ClauseElement): + inherit_cache = False + + def __init__(self, table, select): + self.table = table + self.select = select + + @compiles(InsertFromSelect) + def visit_insert_from_select(element, compiler, **kw): + return "INSERT INTO %s (%s)" % ( + compiler.process(element.table, asfrom=True, **kw), + compiler.process(element.select, **kw) + ) + +While it is also possible that the above ``InsertFromSelect`` could be made to +produce a cache key that is composed of that of the :class:`_schema.Table` and +:class:`_sql.Select` components together, the API for this is not at the moment +fully public. However, for an "INSERT FROM SELECT" construct, which is only +used by itself for specific operations, caching is not as critical as in the +previous example. + +For objects that are **used in relative isolation and are generally +standalone**, such as custom :term:`DML` constructs like an "INSERT FROM +SELECT", **caching is generally less critical** as the lack of caching for such +a construct will have only localized implications for that specific operation. + + Further Examples ================ @@ -259,6 +352,7 @@ For PostgreSQL and Microsoft SQL Server:: class utcnow(expression.FunctionElement): type = DateTime() + inherit_cache = True @compiles(utcnow, 'postgresql') def pg_utcnow(element, compiler, **kw): @@ -295,6 +389,7 @@ accommodates two arguments:: class greatest(expression.FunctionElement): type = Numeric() name = 'greatest' + inherit_cache = True @compiles(greatest) def default_greatest(element, compiler, **kw): @@ -326,7 +421,7 @@ don't have a "false" constant:: from sqlalchemy.ext.compiler import compiles class sql_false(expression.ColumnElement): - pass + inherit_cache = True @compiles(sql_false) def default_false(element, compiler, **kw): diff --git a/lib/sqlalchemy/orm/attributes.py b/lib/sqlalchemy/orm/attributes.py index aa48bf496..b66d55250 100644 --- a/lib/sqlalchemy/orm/attributes.py +++ b/lib/sqlalchemy/orm/attributes.py @@ -523,6 +523,11 @@ def create_proxied_attribute(descriptor): _is_internal_proxy = True + _cache_key_traversal = [ + ("key", visitors.ExtendedInternalTraversal.dp_string), + ("_parententity", visitors.ExtendedInternalTraversal.dp_multi), + ] + @property def _impl_uses_objects(self): return ( diff --git a/lib/sqlalchemy/orm/interfaces.py b/lib/sqlalchemy/orm/interfaces.py index 264161085..2c6818a93 100644 --- a/lib/sqlalchemy/orm/interfaces.py +++ b/lib/sqlalchemy/orm/interfaces.py @@ -753,15 +753,54 @@ class ORMOption(ExecutableOption): _is_strategy_option = False -class LoaderOption(ORMOption): - """Describe a loader modification to an ORM statement at compilation time. +class CompileStateOption(HasCacheKey, ORMOption): + """base for :class:`.ORMOption` classes that affect the compilation of + a SQL query and therefore need to be part of the cache key. + + .. note:: :class:`.CompileStateOption` is generally non-public and + should not be used as a base class for user-defined options; instead, + use :class:`.UserDefinedOption`, which is easier to use as it does not + interact with ORM compilation internals or caching. + + :class:`.CompileStateOption` defines an internal attribute + ``_is_compile_state=True`` which has the effect of the ORM compilation + routines for SELECT and other statements will call upon these options when + a SQL string is being compiled. As such, these classes implement + :class:`.HasCacheKey` and need to provide robust ``_cache_key_traversal`` + structures. + + The :class:`.CompileStateOption` class is used to implement the ORM + :class:`.LoaderOption` and :class:`.CriteriaOption` classes. + + .. versionadded:: 1.4.28 - .. versionadded:: 1.4 """ _is_compile_state = True + def process_compile_state(self, compile_state): + """Apply a modification to a given :class:`.CompileState`.""" + + def process_compile_state_replaced_entities( + self, compile_state, mapper_entities + ): + """Apply a modification to a given :class:`.CompileState`, + given entities that were replaced by with_only_columns() or + with_entities(). + + .. versionadded:: 1.4.19 + + """ + + +class LoaderOption(CompileStateOption): + """Describe a loader modification to an ORM statement at compilation time. + + .. versionadded:: 1.4 + + """ + def process_compile_state_replaced_entities( self, compile_state, mapper_entities ): @@ -778,7 +817,7 @@ class LoaderOption(ORMOption): """Apply a modification to a given :class:`.CompileState`.""" -class CriteriaOption(ORMOption): +class CriteriaOption(CompileStateOption): """Describe a WHERE criteria modification to an ORM statement at compilation time. @@ -786,7 +825,6 @@ class CriteriaOption(ORMOption): """ - _is_compile_state = True _is_criteria_option = True def process_compile_state(self, compile_state): diff --git a/lib/sqlalchemy/orm/query.py b/lib/sqlalchemy/orm/query.py index 38eb33bc4..bd80749d2 100644 --- a/lib/sqlalchemy/orm/query.py +++ b/lib/sqlalchemy/orm/query.py @@ -3404,6 +3404,8 @@ class AliasOption(interfaces.LoaderOption): """ + inherit_cache = False + def process_compile_state(self, compile_state): pass diff --git a/lib/sqlalchemy/sql/base.py b/lib/sqlalchemy/sql/base.py index 6b0182751..4165751ca 100644 --- a/lib/sqlalchemy/sql/base.py +++ b/lib/sqlalchemy/sql/base.py @@ -769,11 +769,13 @@ class CacheableOptions(Options, HasCacheKey): return HasCacheKey._generate_cache_key_for_object(self) -class ExecutableOption(HasCopyInternals, HasCacheKey): +class ExecutableOption(HasCopyInternals): _annotations = util.EMPTY_DICT __visit_name__ = "executable_option" + _is_has_cache_key = False + def _clone(self, **kw): """Create a shallow copy of this ExecutableOption.""" c = self.__class__.__new__(self.__class__) @@ -847,7 +849,8 @@ class Executable(roles.StatementRole, Generative): """ self._with_options += tuple( - coercions.expect(roles.HasCacheKeyRole, opt) for opt in options + coercions.expect(roles.ExecutableOptionRole, opt) + for opt in options ) @_generative diff --git a/lib/sqlalchemy/sql/coercions.py b/lib/sqlalchemy/sql/coercions.py index 480d2c680..07da49c4e 100644 --- a/lib/sqlalchemy/sql/coercions.py +++ b/lib/sqlalchemy/sql/coercions.py @@ -12,6 +12,7 @@ import re from . import operators from . import roles from . import visitors +from .base import ExecutableOption from .base import Options from .traversals import HasCacheKey from .visitors import Visitable @@ -458,6 +459,21 @@ class HasCacheKeyImpl(RoleImpl): return element +class ExecutableOptionImpl(RoleImpl): + __slots__ = () + + def _implicit_coercions( + self, original_element, resolved, argname=None, **kw + ): + if isinstance(original_element, ExecutableOption): + return original_element + else: + self._raise_for_expected(original_element, argname, resolved) + + def _literal_coercion(self, element, **kw): + return element + + class ExpressionElementImpl(_ColumnCoercions, RoleImpl): __slots__ = () diff --git a/lib/sqlalchemy/sql/ddl.py b/lib/sqlalchemy/sql/ddl.py index c700271e9..ef0906328 100644 --- a/lib/sqlalchemy/sql/ddl.py +++ b/lib/sqlalchemy/sql/ddl.py @@ -21,6 +21,9 @@ from ..util import topological class _DDLCompiles(ClauseElement): + _hierarchy_supports_caching = False + """disable cache warnings for all _DDLCompiles subclasses. """ + def _compiler(self, dialect, **kw): """Return a compiler appropriate for this ClauseElement, given a Dialect.""" diff --git a/lib/sqlalchemy/sql/elements.py b/lib/sqlalchemy/sql/elements.py index a7b86d3ec..00270c9b5 100644 --- a/lib/sqlalchemy/sql/elements.py +++ b/lib/sqlalchemy/sql/elements.py @@ -3646,6 +3646,8 @@ class CollectionAggregate(UnaryExpression): """ + inherit_cache = True + @classmethod def _create_any(cls, expr): """Produce an ANY expression. @@ -3953,7 +3955,7 @@ class IndexExpression(BinaryExpression): """Represent the class of expressions that are like an "index" operation.""" - pass + inherit_cache = True class GroupedElement(ClauseElement): @@ -5040,14 +5042,17 @@ class _IdentifiedClause(Executable, ClauseElement): class SavepointClause(_IdentifiedClause): __visit_name__ = "savepoint" + inherit_cache = False class RollbackToSavepointClause(_IdentifiedClause): __visit_name__ = "rollback_to_savepoint" + inherit_cache = False class ReleaseSavepointClause(_IdentifiedClause): __visit_name__ = "release_savepoint" + inherit_cache = False class quoted_name(util.MemoizedSlots, str): diff --git a/lib/sqlalchemy/sql/functions.py b/lib/sqlalchemy/sql/functions.py index fff2defe0..5d2e78065 100644 --- a/lib/sqlalchemy/sql/functions.py +++ b/lib/sqlalchemy/sql/functions.py @@ -917,6 +917,7 @@ class GenericFunction(Function, metaclass=_GenericMeta): class as_utc(GenericFunction): type = DateTime + inherit_cache = True print(select(func.as_utc())) @@ -931,6 +932,7 @@ class GenericFunction(Function, metaclass=_GenericMeta): class as_utc(GenericFunction): type = DateTime package = "time" + inherit_cache = True The above function would be available from :data:`.func` using the package name ``time``:: @@ -948,6 +950,7 @@ class GenericFunction(Function, metaclass=_GenericMeta): package = "geo" name = "ST_Buffer" identifier = "buffer" + inherit_cache = True The above function will render as follows:: @@ -966,6 +969,7 @@ class GenericFunction(Function, metaclass=_GenericMeta): package = "geo" name = quoted_name("ST_Buffer", True) identifier = "buffer" + inherit_cache = True The above function will render as:: diff --git a/lib/sqlalchemy/sql/roles.py b/lib/sqlalchemy/sql/roles.py index c4eedd4a4..1f6a8ddf2 100644 --- a/lib/sqlalchemy/sql/roles.py +++ b/lib/sqlalchemy/sql/roles.py @@ -40,6 +40,11 @@ class HasCacheKeyRole(SQLRole): _role_name = "Cacheable Core or ORM object" +class ExecutableOptionRole(SQLRole): + __slots__ = () + _role_name = "ExecutionOption Core or ORM object" + + class LiteralValueRole(SQLRole): __slots__ = () _role_name = "Literal Python value" diff --git a/lib/sqlalchemy/sql/traversals.py b/lib/sqlalchemy/sql/traversals.py index 914a78dae..d58b5c2bb 100644 --- a/lib/sqlalchemy/sql/traversals.py +++ b/lib/sqlalchemy/sql/traversals.py @@ -49,7 +49,50 @@ def _preconfigure_traversals(target_hierarchy): class HasCacheKey: + """Mixin for objects which can produce a cache key. + + .. seealso:: + + :class:`.CacheKey` + + :ref:`sql_caching` + + """ + _cache_key_traversal = NO_CACHE + + _is_has_cache_key = True + + _hierarchy_supports_caching = True + """private attribute which may be set to False to prevent the + inherit_cache warning from being emitted for a hierarchy of subclasses. + + Currently applies to the DDLElement hierarchy which does not implement + caching. + + """ + + inherit_cache = None + """Indicate if this :class:`.HasCacheKey` instance should make use of the + cache key generation scheme used by its immediate superclass. + + The attribute defaults to ``None``, which indicates that a construct has + not yet taken into account whether or not its appropriate for it to + participate in caching; this is functionally equivalent to setting the + value to ``False``, except that a warning is also emitted. + + This flag can be set to ``True`` on a particular class, if the SQL that + corresponds to the object does not change based on attributes which + are local to this class, and not its superclass. + + .. seealso:: + + :ref:`compilerext_caching` - General guideslines for setting the + :attr:`.HasCacheKey.inherit_cache` attribute for third-party or user + defined SQL constructs. + + """ + __slots__ = () @classmethod @@ -60,7 +103,8 @@ class HasCacheKey: so should only be called once per class. """ - inherit = cls.__dict__.get("inherit_cache", False) + inherit_cache = cls.__dict__.get("inherit_cache", None) + inherit = bool(inherit_cache) if inherit: _cache_key_traversal = getattr(cls, "_cache_key_traversal", None) @@ -89,6 +133,23 @@ class HasCacheKey: ) if _cache_key_traversal is None: cls._generated_cache_key_traversal = NO_CACHE + if ( + inherit_cache is None + and cls._hierarchy_supports_caching + ): + util.warn( + "Class %s will not make use of SQL compilation " + "caching as it does not set the 'inherit_cache' " + "attribute to ``True``. This can have " + "significant performance implications including " + "some performance degradations in comparison to " + "prior SQLAlchemy versions. Set this attribute " + "to True if this object can make use of the cache " + "key generated by the superclass. Alternatively, " + "this attribute may be set to False which will " + "disable this warning." % (cls.__name__), + code="cprf", + ) return NO_CACHE return _cache_key_traversal_visitor.generate_dispatch( @@ -273,6 +334,15 @@ class MemoizedHasCacheKey(HasCacheKey, HasMemoized): class CacheKey(namedtuple("CacheKey", ["key", "bindparams"])): + """The key used to identify a SQL statement construct in the + SQL compilation cache. + + .. seealso:: + + :ref:`sql_caching` + + """ + def __hash__(self): """CacheKey itself is not hashable - hash the .key portion""" @@ -480,7 +550,19 @@ class _CacheKey(ExtendedInternalTraversal): tuple(elem._gen_cache_key(anon_map, bindparams) for elem in obj), ) - visit_executable_options = visit_has_cache_key_list + def visit_executable_options( + self, attrname, obj, parent, anon_map, bindparams + ): + if not obj: + return () + return ( + attrname, + tuple( + elem._gen_cache_key(anon_map, bindparams) + for elem in obj + if elem._is_has_cache_key + ), + ) def visit_inspectable_list( self, attrname, obj, parent, anon_map, bindparams @@ -1086,7 +1168,20 @@ class TraversalComparatorStrategy(InternalTraversal, util.MemoizedSlots): ): return COMPARE_FAILED - visit_executable_options = visit_has_cache_key_list + def visit_executable_options( + self, attrname, left_parent, left, right_parent, right, **kw + ): + for l, r in zip_longest(left, right, fillvalue=None): + if ( + l._gen_cache_key(self.anon_map[0], []) + if l._is_has_cache_key + else l + ) != ( + r._gen_cache_key(self.anon_map[1], []) + if r._is_has_cache_key + else r + ): + return COMPARE_FAILED def visit_clauseelement( self, attrname, left_parent, left, right_parent, right, **kw diff --git a/lib/sqlalchemy/sql/type_api.py b/lib/sqlalchemy/sql/type_api.py index a32b80c95..cc226d7e3 100644 --- a/lib/sqlalchemy/sql/type_api.py +++ b/lib/sqlalchemy/sql/type_api.py @@ -979,18 +979,23 @@ class ExternalType: @property def _static_cache_key(self): - if self.cache_ok is None: + cache_ok = self.__class__.__dict__.get("cache_ok", None) + + if cache_ok is None: subtype_idx = self.__class__.__mro__.index(ExternalType) subtype = self.__class__.__mro__[max(subtype_idx - 1, 0)] util.warn( "%s %r will not produce a cache key because " - "the ``cache_ok`` flag is not set to True. " - "Set this flag to True if this type object's " + "the ``cache_ok`` attribute is not set to True. This can " + "have significant performance implications including some " + "performance degradations in comparison to prior SQLAlchemy " + "versions. Set this attribute to True if this type object's " "state is safe to use in a cache key, or False to " - "disable this warning." % (subtype.__name__, self) + "disable this warning." % (subtype.__name__, self), + code="cprf", ) - elif self.cache_ok is True: + elif cache_ok is True: return super(ExternalType, self)._static_cache_key return NO_CACHE diff --git a/lib/sqlalchemy/testing/assertions.py b/lib/sqlalchemy/testing/assertions.py index 234ab4b93..2acf15195 100644 --- a/lib/sqlalchemy/testing/assertions.py +++ b/lib/sqlalchemy/testing/assertions.py @@ -546,6 +546,15 @@ class AssertsCompiledSQL: # are the "self.statement" element c = CheckCompilerAccess(clause).compile(dialect=dialect, **kw) + if isinstance(clause, sqltypes.TypeEngine): + cache_key_no_warnings = clause._static_cache_key + if cache_key_no_warnings: + hash(cache_key_no_warnings) + else: + cache_key_no_warnings = clause._generate_cache_key() + if cache_key_no_warnings: + hash(cache_key_no_warnings[0]) + param_str = repr(getattr(c, "params", {})) param_str = param_str.encode("utf-8").decode("ascii", "ignore") print(("\nSQL String:\n" + str(c) + param_str).encode("utf-8")) |
