summaryrefslogtreecommitdiff
path: root/lib/sqlalchemy
diff options
context:
space:
mode:
authorMike Bayer <mike_mp@zzzcomputing.com>2021-12-03 14:04:05 -0500
committerMike Bayer <mike_mp@zzzcomputing.com>2021-12-06 18:27:19 -0500
commit22deafe15289d2be55682e1632016004b02b62c0 (patch)
tree5b521531418aebd4e293f848ebe4accbbd9bc5bc /lib/sqlalchemy
parente88dc004e6bcd1418cb8eb811d0aa580c2a44b8f (diff)
downloadsqlalchemy-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.py1
-rw-r--r--lib/sqlalchemy/dialects/mysql/dml.py1
-rw-r--r--lib/sqlalchemy/dialects/postgresql/array.py1
-rw-r--r--lib/sqlalchemy/dialects/postgresql/dml.py1
-rw-r--r--lib/sqlalchemy/dialects/postgresql/ext.py2
-rw-r--r--lib/sqlalchemy/dialects/postgresql/hstore.py8
-rw-r--r--lib/sqlalchemy/dialects/sqlite/dml.py1
-rw-r--r--lib/sqlalchemy/engine/default.py21
-rw-r--r--lib/sqlalchemy/ext/compiler.py105
-rw-r--r--lib/sqlalchemy/orm/attributes.py5
-rw-r--r--lib/sqlalchemy/orm/interfaces.py48
-rw-r--r--lib/sqlalchemy/orm/query.py2
-rw-r--r--lib/sqlalchemy/sql/base.py7
-rw-r--r--lib/sqlalchemy/sql/coercions.py16
-rw-r--r--lib/sqlalchemy/sql/ddl.py3
-rw-r--r--lib/sqlalchemy/sql/elements.py7
-rw-r--r--lib/sqlalchemy/sql/functions.py4
-rw-r--r--lib/sqlalchemy/sql/roles.py5
-rw-r--r--lib/sqlalchemy/sql/traversals.py101
-rw-r--r--lib/sqlalchemy/sql/type_api.py15
-rw-r--r--lib/sqlalchemy/testing/assertions.py9
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"))