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 | |
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
45 files changed, 979 insertions, 78 deletions
diff --git a/doc/build/changelog/unreleased_14/7394.rst b/doc/build/changelog/unreleased_14/7394.rst new file mode 100644 index 000000000..66bda3e4e --- /dev/null +++ b/doc/build/changelog/unreleased_14/7394.rst @@ -0,0 +1,49 @@ +.. change:: + :tags: bug, sql + :tickets: 7394 + + Custom SQL elements, third party dialects, custom or third party datatypes + will all generate consistent warnings when they do not clearly opt in or + out of SQL statement caching, which is achieved by setting the appropriate + attributes on each type of class. The warning links to documentation + sections which indicate the appropriate approach for each type of object in + order for caching to be enabled. + +.. change:: + :tags: bug, sql + :tickets: 7394 + + Fixed missing caching directives for a few lesser used classes in SQL Core + which would cause ``[no key]`` to be logged for elements which made use of + these. + +.. change:: + :tags: bug, postgresql + :tickets: 7394 + + Fixed missing caching directives for :class:`_postgresql.hstore` and + :class:`_postgresql.array` constructs which would cause ``[no key]`` + to be logged for these elements. + +.. change:: + :tags: bug, orm + :tickets: 7394 + + User defined ORM options, such as those illustrated in the dogpile.caching + example which subclass :class:`_orm.UserDefinedOption`, by definition are + handled on every statement execution and do not need to be considered as + part of the cache key for the statement. Caching of the base + :class:`.ExecutableOption` class has been modified so that it is no longer + a :class:`.HasCacheKey` subclass directly, so that the presence of user + defined option objects will not have the unwanted side effect of disabling + statement caching. Only ORM specific loader and criteria options, which are + all internal to SQLAlchemy, now participate within the caching system. + +.. change:: + :tags: bug, orm + :tickets: 7394 + + Fixed issue where mappings that made use of :func:`_orm.synonym` and + potentially other kinds of "proxy" attributes would not in all cases + successfully generate a cache key for their SQL statements, leading to + degraded performance for those statements.
\ No newline at end of file diff --git a/doc/build/core/connections.rst b/doc/build/core/connections.rst index 08e782351..3de493686 100644 --- a/doc/build/core/connections.rst +++ b/doc/build/core/connections.rst @@ -839,6 +839,8 @@ what the cache is doing, engine logging will include details about the cache's behavior, described in the next section. +.. _sql_caching_logging: + Estimating Cache Performance Using Logging ------------------------------------------ @@ -1106,28 +1108,35 @@ The cache can also be disabled with this argument by sending a value of 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 +The caching feature requires that the dialect's compiler produces SQL +strings that are safe to reuse for many statement invocations, given +a particular cache key that is keyed to that SQL string. 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, +``Select._offset_clause`` attributes by a custom compiler, which +are illustrated later in this section. + +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 an attribute to dialects +known as :attr:`_engine.Dialect.supports_statement_cache`. This attribute is +checked at runtime for its presence directly on a particular dialect's class, +even if it's already present on a superclass, so that even a third party +dialect that subclasses an existing cacheable SQLAlchemy dialect such as +``sqlalchemy.dialects.postgresql.PGDialect`` must still explicitly include this +attribute for caching to be enabled. The attribute should **only** be enabled 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:: +For all third party dialects that don't support this attribute, the logging for +such a dialect will indicate ``dialect does not support caching``. + +When a dialect has been tested against caching, and in particular the SQL +compiler has been updated to not render any literal LIMIT / OFFSET within +a SQL string directly, dialect authors can apply the attribute as follows:: from sqlalchemy.engine.default import DefaultDialect @@ -1141,6 +1150,96 @@ The flag needs to be applied to all subclasses of the dialect as well:: .. versionadded:: 1.4.5 + Added the :attr:`.Dialect.supports_statement_cache` attribute. + +The typical case for dialect modification follows. + +Example: Rendering LIMIT / OFFSET with post compile parameters +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +As an example, suppose a dialect overrides the :meth:`.SQLCompiler.limit_clause` +method, which produces the "LIMIT / OFFSET" clause for a SQL statement, +like this:: + + # pre 1.4 style code + def limit_clause(self, select, **kw): + text = "" + if select._limit is not None: + text += " \n LIMIT %d" % (select._limit, ) + if select._offset is not None: + text += " \n OFFSET %d" % (select._offset, ) + return text + +The above routine renders the :attr:`.Select._limit` and +:attr:`.Select._offset` integer values as literal integers embedded in the SQL +statement. This is a common requirement for databases that do not support using +a bound parameter within the LIMIT/OFFSET clauses of a SELECT statement. +However, rendering the integer value within the initial compilation stage is +directly **incompatible** with caching as the limit and offset integer values +of a :class:`.Select` object are not part of the cache key, so that many +:class:`.Select` statements with different limit/offset values would not render +with the correct value. + +The correction for the above code is to move the literal integer into +SQLAlchemy's :ref:`post-compile <change_4808>` facility, which will render the +literal integer outside of the initial compilation stage, but instead at +execution time before the statement is sent to the DBAPI. This is accessed +within the compilation stage using the :meth:`_sql.BindParameter.render_literal_execute` +method, in conjunction with using the :attr:`.Select._limit_clause` and +:attr:`.Select._offset_clause` attributes, which represent the LIMIT/OFFSET +as a complete SQL expression, as follows:: + + # 1.4 cache-compatible code + def limit_clause(self, select, **kw): + text = "" + + limit_clause = select._limit_clause + offset_clause = select._offset_clause + + if select._simple_int_clause(limit_clause): + text += " \n LIMIT %s" % ( + self.process(limit_clause.render_literal_execute(), **kw) + ) + elif limit_clause is not None: + # assuming the DB doesn't support SQL expressions for LIMIT. + # Otherwise render here normally + raise exc.CompileError( + "dialect 'mydialect' can only render simple integers for LIMIT" + ) + if select._simple_int_clause(offset_clause): + text += " \n OFFSET %s" % ( + self.process(offset_clause.render_literal_execute(), **kw) + ) + elif offset_clause is not None: + # assuming the DB doesn't support SQL expressions for OFFSET. + # Otherwise render here normally + raise exc.CompileError( + "dialect 'mydialect' can only render simple integers for OFFSET" + ) + + return text + +The approach above will generate a compiled SELECT statement that looks like:: + + SELECT x FROM y + LIMIT __[POSTCOMPILE_param_1] + OFFSET __[POSTCOMPILE_param_2] + +Where above, the ``__[POSTCOMPILE_param_1]`` and ``__[POSTCOMPILE_param_2]`` +indicators will be populated with their corresponding integer values at +statement execution time, after the SQL string has been retrieved from the +cache. + +After changes like the above have been made as appropriate, the +:attr:`.Dialect.supports_statement_cache` flag should be set to ``True``. +It is strongly recommended that third party dialects make use of the +`dialect third party test suite <https://github.com/sqlalchemy/sqlalchemy/blob/main/README.dialects.rst>`_ +which will assert that operations like +SELECTs with LIMIT/OFFSET are correctly rendered and cached. + +.. seealso:: + + :ref:`faq_new_caching` - in the :ref:`faq_toplevel` section .. _engine_lambda_caching: diff --git a/doc/build/core/expression_api.rst b/doc/build/core/expression_api.rst index 7d455d200..236e0e2ee 100644 --- a/doc/build/core/expression_api.rst +++ b/doc/build/core/expression_api.rst @@ -12,6 +12,7 @@ see :ref:`sqlexpression_toplevel`. .. toctree:: :maxdepth: 3 + foundation sqlelement operators selectable diff --git a/doc/build/core/foundation.rst b/doc/build/core/foundation.rst new file mode 100644 index 000000000..3a017dd5d --- /dev/null +++ b/doc/build/core/foundation.rst @@ -0,0 +1,32 @@ +.. _core_foundation_toplevel: + +================================================= +SQL Expression Language Foundational Constructs +================================================= + +Base classes and mixins that are used to compose SQL Expression Language +elements. + +.. currentmodule:: sqlalchemy.sql.expression + +.. autoclass:: CacheKey + :members: + +.. autoclass:: ClauseElement + :members: + :inherited-members: + + +.. autoclass:: sqlalchemy.sql.base.DialectKWArgs + :members: + + +.. autoclass:: sqlalchemy.sql.traversals.HasCacheKey + :members: + +.. autoclass:: LambdaElement + :members: + +.. autoclass:: StatementLambdaElement + :members: + diff --git a/doc/build/core/sqlelement.rst b/doc/build/core/sqlelement.rst index 8e6599362..499f26571 100644 --- a/doc/build/core/sqlelement.rst +++ b/doc/build/core/sqlelement.rst @@ -120,20 +120,12 @@ The classes here are generated using the constructors listed at .. autoclass:: BindParameter :members: -.. autoclass:: CacheKey - :members: - .. autoclass:: Case :members: .. autoclass:: Cast :members: -.. autoclass:: ClauseElement - :members: - :inherited-members: - - .. autoclass:: ClauseList :members: @@ -155,8 +147,6 @@ The classes here are generated using the constructors listed at :special-members: :inherited-members: -.. autoclass:: sqlalchemy.sql.base.DialectKWArgs - :members: .. autoclass:: Extract :members: @@ -170,9 +160,6 @@ The classes here are generated using the constructors listed at .. autoclass:: Label :members: -.. autoclass:: LambdaElement - :members: - .. autoclass:: Null :members: @@ -183,9 +170,6 @@ The classes here are generated using the constructors listed at .. autoclass:: Over :members: -.. autoclass:: StatementLambdaElement - :members: - .. autoclass:: TextClause :members: diff --git a/doc/build/core/visitors.rst b/doc/build/core/visitors.rst index 6ef466265..06d839d54 100644 --- a/doc/build/core/visitors.rst +++ b/doc/build/core/visitors.rst @@ -23,4 +23,5 @@ as well as when building out custom SQL expressions using the .. automodule:: sqlalchemy.sql.visitors :members: - :private-members:
\ No newline at end of file + :private-members: + diff --git a/doc/build/errors.rst b/doc/build/errors.rst index 297bcf6f2..4845963b0 100644 --- a/doc/build/errors.rst +++ b/doc/build/errors.rst @@ -122,6 +122,95 @@ this warning is at :ref:`deprecation_20_mode`. :ref:`deprecation_20_mode` - specific guidelines on how to use "2.0 deprecations mode" in SQLAlchemy 1.4. +.. _error_cprf: +.. _caching_caveats: + +Object will not produce a cache key, Performance Implications +-------------------------------------------------------------- + +SQLAlchemy as of version 1.4 includes a +:ref:`SQL compilation caching facility <sql_caching>` which will allow +Core and ORM SQL constructs to cache their stringified form, along with other +structural information used to fetch results from the statement, allowing the +relatively expensive string compilation process to be skipped when another +structurally equivalent construct is next used. This system +relies upon functionality that is implemented for all SQL constructs, including +objects such as :class:`_schema.Column`, +:func:`_sql.select`, and :class:`_types.TypeEngine` objects, to produce a +**cache key** which fully represents their state to the degree that it affects +the SQL compilation process. + +If the warnings in question refer to widely used objects such as +:class:`_schema.Column` objects, and are shown to be affecting the majority of +SQL constructs being emitted (using the estimation techniques described at +:ref:`sql_caching_logging`) such that caching is generally not enabled for an +application, this will negatively impact performance and can in some cases +effectively produce a **performance degradation** compared to prior SQLAlchemy +versions. The FAQ at :ref:`faq_new_caching` covers this in additional detail. + +Caching disables itself if there's any doubt +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Caching relies on being able to generate a cache key that accurately represents +the **complete structure** of a statement in a **consistent** fashion. If a particular +SQL construct (or type) does not have the appropriate directives in place which +allow it to generate a proper cache key, then caching cannot be safely enabled: + +* The cache key must represent the **complete structure**: If the usage of two + separate instances of that construct may result in different SQL being + rendered, caching the SQL against the first instance of the element using a + cache key that does not capture the distinct differences between the first and + second elements will result in incorrect SQL being cached and rendered for the + second instance. + +* The cache key must be **consistent**: If a construct represents state that + changes every time, such as a literal value, producing unique SQL for every + instance of it, this construct is also not safe to cache, as repeated use of + the construct will quickly fill up the statement cache with unique SQL strings + that will likely not be used again, defeating the purpose of the cache. + +For the above two reasons, SQLAlchemy's caching system is **extremely +conservative** about deciding to cache the SQL corresponding to an object. + +Assertion attributes for caching +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The warning is emitted based on the criteria below. For further detail on +each, see the section :ref:`faq_new_caching`. + +* The :class:`.Dialect` itself (i.e. the module that is specified by the + first part of the URL we pass to :func:`_sa.create_engine`, like + ``postgresql+psycopg2://``), must indicate it has been reviewed and tested + to support caching correctly, which is indicated by the + :attr:`.Dialect.supports_statement_cache` attribute being set to ``True``. + When using third party dialects, consult with the maintainers of the dialect + so that they may follow the :ref:`steps to ensure caching may be enabled + <engine_thirdparty_caching>` in their dialect and publish a new release. + +* Third party or user defined types that inherit from either + :class:`.TypeDecorator` or :class:`.UserDefinedType` must include the + :attr:`.ExternalType.cache_ok` attribute in their definition, including for + all derived subclasses, following the guidelines described in the docstring + for :attr:`.ExternalType.cache_ok`. As before, if these datatypes are + imported from third party libraries, consult with the maintainers of that + library so that they may provide the necessary changes to their library and + publish a new release. + +* Third party or user defined SQL constructs that subclass from classes such + as :class:`.ClauseElement`, :class:`_schema.Column`, :class:`_dml.Insert` + etc, including simple subclasses as well as those which are designed to + work with the :ref:`sqlalchemy.ext.compiler_toplevel`, should normally + include the :attr:`.HasCacheKey.inherit_cache` attribute set to ``True`` + or ``False`` based on the design of the construct, following the guidelines + described at :ref:`compilerext_caching`. + +.. seealso:: + + :ref:`sql_caching_logging` - background on observing cache behavior + and efficiency + + :ref:`faq_new_caching` - in the :ref:`faq_toplevel` section + .. _error_s9r1: Object is being merged into a Session along the backref cascade diff --git a/doc/build/faq/performance.rst b/doc/build/faq/performance.rst index 6e1440721..781d6c79d 100644 --- a/doc/build/faq/performance.rst +++ b/doc/build/faq/performance.rst @@ -8,6 +8,166 @@ Performance :class: faq :backlinks: none +.. _faq_new_caching: + +Why is my application slow after upgrading to 1.4 and/or 2.x? +-------------------------------------------------------------- + +SQLAlchemy as of version 1.4 includes a +:ref:`SQL compilation caching facility <sql_caching>` which will allow +Core and ORM SQL constructs to cache their stringified form, along with other +structural information used to fetch results from the statement, allowing the +relatively expensive string compilation process to be skipped when another +structurally equivalent construct is next used. This system +relies upon functionality that is implemented for all SQL constructs, including +objects such as :class:`_schema.Column`, +:func:`_sql.select`, and :class:`_types.TypeEngine` objects, to produce a +**cache key** which fully represents their state to the degree that it affects +the SQL compilation process. + +The caching system allows SQLAlchemy 1.4 and above to be more performant than +SQLAlchemy 1.3 with regards to the time spent converting SQL constructs into +strings repeatedly. However, this only works if caching is enabled for the +dialect and SQL constructs in use; if not, string compilation is usually +similar to that of SQLAlchemy 1.3, with a slight decrease in speed in some +cases. + +There is one case however where if SQLAlchemy's new caching system has been +disabled (for reasons below), performance for the ORM may be in fact +significantly poorer than that of 1.3 or other prior releases which is due to +the lack of caching within ORM lazy loaders and object refresh queries, which +in the 1.3 and earlier releases used the now-legacy ``BakedQuery`` system. If +an application is seeing significant (30% or higher) degradations in +performance (measured in time for operations to complete) when switching to +1.4, this is the likely cause of the issue, with steps to mitigate below. + +.. seealso:: + + :ref:`sql_caching` - overview of the caching system + + :ref:`caching_caveats` - additional information regarding the warnings + generated for elements that don't enable caching. + +Step one - turn on SQL logging and confirm whether or not caching is working +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Here, we want to use the technique described at +:ref:`engine logging <sql_caching_logging>`, looking for statements with the +``[no key]`` indicator or even ``[dialect does not support caching]``. +The indicators we would see for SQL statements that are successfully participating +in the caching system would be indicating ``[generated in Xs]`` when +statements are invoked for the first time and then +``[cached since Xs ago]`` for the vast majority of statements subsequent. +If ``[no key]`` is prevalent in particular for SELECT statements, or +if caching is disabled entirely due to ``[dialect does not support caching]``, +this can be the cause of significant performance degradation. + +.. seealso:: + + :ref:`sql_caching_logging` + + +Step two - identify what constructs are blocking caching from being enabled +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Assuming statements are not being cached, there should be warnings emitted +early in the application's log (SQLAlchemy 1.4.28 and above only) indicating +dialects, :class:`.TypeEngine` objects, and SQL constructs that are not +participating in caching. + +For user defined datatypes such as those which extend :class:`_types.TypeDecorator` +and :class:`_types.UserDefinedType`, the warnings will look like:: + + sqlalchemy.ext.SAWarning: MyType will not produce a cache key because 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. + +For custom and third party SQL elements, such as those constructed using +the techniques described at :ref:`sqlalchemy.ext.compiler_toplevel`, these +warnings will look like:: + + sqlalchemy.exc.SAWarning: Class MyClass 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. + +For custom and third party dialects which make use of the :class:`.Dialect` +class hierarchy, the warnings will look like:: + + sqlalchemy.exc.SAWarning: Dialect database:driver 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. + + +Step three - enable caching for the given objects and/or seek alternatives +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Steps to mitigate the lack of caching include: + +* Review and set :attr:`.ExternalType.cache_ok` to ``True`` for all custom types + which extend from :class:`_types.TypeDecorator`, + :class:`_types.UserDefinedType`, as well as subclasses of these such as + :class:`_types.PickleType`. Set this **only** if the custom type does not + include any additional state attributes which affect how it renders SQL:: + + class MyCustomType(TypeDecorator): + cache_ok = True + impl = String + + If the types in use are from a third-party library, consult with the + maintainers of that library so that it may be adjusted and released. + + .. seealso:: + + :attr:`.ExternalType.cache_ok` - background on requirements to enable + caching for custom datatypes. + +* Make sure third party dialects set :attr:`.Dialect.supports_statement_cache` + to ``True``. What this indicates is that the maintainers of a third party + dialect have made sure their dialect works with SQLAlchemy 1.4 or greater, + and that their dialect doesn't include any compilation features which may get + in the way of caching. As there are some common compilation patterns which + can in fact interfere with caching, it's important that dialect maintainers + check and test this carefully, adjusting for any of the legacy patterns + which won't work with caching. + + .. seealso:: + + :ref:`engine_thirdparty_caching` - background and examples for third-party + dialects to participate in SQL statement caching. + +* Custom SQL classes, including all DQL / DML constructs one might create + using the :ref:`sqlalchemy.ext.compiler_toplevel`, as well as ad-hoc + subclasses of objects such as :class:`_schema.Column` or + :class:`_schema.Table`. The :attr:`.HasCacheKey.inherit_cache` attribute + may be set to ``True`` for trivial subclasses, which do not contain any + subclass-specific state information which affects the SQL compilation. + + .. seealso:: + + :ref:`compilerext_caching` - guidelines for applying the + :attr:`.HasCacheKey.inherit_cache` attribute. + + +.. seealso:: + + :ref:`sql_caching` - caching system overview + + :ref:`caching_caveats` - background on warnings emitted when caching + is not enabled for specific constructs and/or dialects. + + .. _faq_how_to_profile: How can I profile a SQLAlchemy powered application? diff --git a/examples/dogpile_caching/caching_query.py b/examples/dogpile_caching/caching_query.py index dc007a760..4533dce00 100644 --- a/examples/dogpile_caching/caching_query.py +++ b/examples/dogpile_caching/caching_query.py @@ -130,10 +130,19 @@ class FromCache(UserDefinedOption): self.expiration_time = expiration_time self.ignore_expiration = ignore_expiration + # this is not needed as of SQLAlchemy 1.4.28; + # UserDefinedOption classes no longer participate in the SQL + # compilation cache key def _gen_cache_key(self, anon_map, bindparams): return None def _generate_cache_key(self, statement, parameters, orm_cache): + """generate a cache key with which to key the results of a statement. + + This leverages the use of the SQL compilation cache key which is + repurposed as a SQL results key. + + """ statement_cache_key = statement._generate_cache_key() key = statement_cache_key.to_offline_string( 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")) diff --git a/test/dialect/mssql/test_compiler.py b/test/dialect/mssql/test_compiler.py index e9ff8fb19..74722e949 100644 --- a/test/dialect/mssql/test_compiler.py +++ b/test/dialect/mssql/test_compiler.py @@ -180,7 +180,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): t.update() .where(t.c.somecolumn == "q") .values(somecolumn="x") - .with_hint("XYZ", "mysql"), + .with_hint("XYZ", dialect_name="mysql"), "UPDATE sometable SET somecolumn=:somecolumn " "WHERE sometable.somecolumn = :somecolumn_1", ) diff --git a/test/engine/test_execute.py b/test/engine/test_execute.py index 317d0b692..fb4fd02a1 100644 --- a/test/engine/test_execute.py +++ b/test/engine/test_execute.py @@ -3865,6 +3865,7 @@ class DialectDoesntSupportCachingTest(fixtures.TestBase): class MyDialect(SQLiteDialect_pysqlite): statement_compiler = MyCompiler + supports_statement_cache = False from sqlalchemy.dialects import registry diff --git a/test/ext/test_baked.py b/test/ext/test_baked.py index c40ee3395..b3d6ebec2 100644 --- a/test/ext/test_baked.py +++ b/test/ext/test_baked.py @@ -1043,6 +1043,7 @@ class CustomIntegrationTest(testing.AssertsCompiledSQL, BakedTest): from sqlalchemy.orm.interfaces import UserDefinedOption class RelationshipCache(UserDefinedOption): + inherit_cache = True propagate_to_loaders = True diff --git a/test/ext/test_compiler.py b/test/ext/test_compiler.py index 7fb021329..996797122 100644 --- a/test/ext/test_compiler.py +++ b/test/ext/test_compiler.py @@ -37,6 +37,8 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): def test_column(self): class MyThingy(ColumnClause): + inherit_cache = False + def __init__(self, arg=None): super(MyThingy, self).__init__(arg or "MYTHINGY!") @@ -96,7 +98,7 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): def test_no_compile_for_col_label(self): class MyThingy(FunctionElement): - pass + inherit_cache = True @compiles(MyThingy) def visit_thingy(thingy, compiler, **kw): @@ -120,6 +122,8 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): def test_stateful(self): class MyThingy(ColumnClause): + inherit_cache = False + def __init__(self): super(MyThingy, self).__init__("MYTHINGY!") @@ -142,6 +146,8 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): def test_callout_to_compiler(self): class InsertFromSelect(ClauseElement): + inherit_cache = False + def __init__(self, table, select): self.table = table self.select = select @@ -162,7 +168,7 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): def test_no_default_but_has_a_visit(self): class MyThingy(ColumnClause): - pass + inherit_cache = False @compiles(MyThingy, "postgresql") def visit_thingy(thingy, compiler, **kw): @@ -172,7 +178,7 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): def test_no_default_has_no_visit(self): class MyThingy(TypeEngine): - pass + inherit_cache = False @compiles(MyThingy, "postgresql") def visit_thingy(thingy, compiler, **kw): @@ -189,6 +195,7 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): @testing.combinations((True,), (False,)) def test_no_default_proxy_generation(self, named): class my_function(FunctionElement): + inherit_cache = False if named: name = "my_function" type = Numeric() @@ -215,7 +222,7 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): def test_no_default_message(self): class MyThingy(ClauseElement): - pass + inherit_cache = False @compiles(MyThingy, "postgresql") def visit_thingy(thingy, compiler, **kw): @@ -314,7 +321,7 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): from sqlalchemy.dialects import postgresql class MyUtcFunction(FunctionElement): - pass + inherit_cache = True @compiles(MyUtcFunction) def visit_myfunc(element, compiler, **kw): @@ -335,7 +342,7 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): def test_functions_args_noname(self): class myfunc(FunctionElement): - pass + inherit_cache = True @compiles(myfunc) def visit_myfunc(element, compiler, **kw): @@ -351,6 +358,7 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): class greatest(FunctionElement): type = Numeric() name = "greatest" + inherit_cache = True @compiles(greatest) def default_greatest(element, compiler, **kw): @@ -380,12 +388,15 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): def test_function_subclasses_one(self): class Base(FunctionElement): + inherit_cache = True name = "base" class Sub1(Base): + inherit_cache = True name = "sub1" class Sub2(Base): + inherit_cache = True name = "sub2" @compiles(Base) @@ -407,6 +418,7 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): name = "base" class Sub1(Base): + inherit_cache = True name = "sub1" @compiles(Base) @@ -414,9 +426,11 @@ class UserDefinedTest(fixtures.TestBase, AssertsCompiledSQL): return element.name class Sub2(Base): + inherit_cache = True name = "sub2" class SubSub1(Sub1): + inherit_cache = True name = "subsub1" self.assert_compile( @@ -545,7 +559,7 @@ class ExecuteTest(fixtures.TablesTest): @testing.fixture() def insert_fixture(self): class MyInsert(Executable, ClauseElement): - pass + inherit_cache = True @compiles(MyInsert) def _run_myinsert(element, compiler, **kw): @@ -556,7 +570,7 @@ class ExecuteTest(fixtures.TablesTest): @testing.fixture() def select_fixture(self): class MySelect(Executable, ClauseElement): - pass + inherit_cache = True @compiles(MySelect) def _run_myinsert(element, compiler, **kw): diff --git a/test/orm/inheritance/test_assorted_poly.py b/test/orm/inheritance/test_assorted_poly.py index 66a722ccf..f51fb17a4 100644 --- a/test/orm/inheritance/test_assorted_poly.py +++ b/test/orm/inheritance/test_assorted_poly.py @@ -2252,7 +2252,7 @@ class ColSubclassTest( id = Column(Integer, primary_key=True) class MySpecialColumn(Column): - pass + inherit_cache = True class B(A): __tablename__ = "b" diff --git a/test/orm/test_cache_key.py b/test/orm/test_cache_key.py index 5ed856a3c..e3ba870f2 100644 --- a/test/orm/test_cache_key.py +++ b/test/orm/test_cache_key.py @@ -26,6 +26,7 @@ from sqlalchemy.orm import relationship from sqlalchemy.orm import selectinload from sqlalchemy.orm import Session from sqlalchemy.orm import subqueryload +from sqlalchemy.orm import synonym from sqlalchemy.orm import with_expression from sqlalchemy.orm import with_loader_criteria from sqlalchemy.orm import with_polymorphic @@ -387,6 +388,35 @@ class CacheKeyTest(CacheKeyFixture, _fixtures.FixtureTest): compare_values=True, ) + def test_synonyms(self, registry): + """test for issue discovered in #7394""" + + @registry.mapped + class User2(object): + __table__ = self.tables.users + + name_syn = synonym("name") + + @registry.mapped + class Address2(object): + __table__ = self.tables.addresses + + name_syn = synonym("email_address") + + self._run_cache_key_fixture( + lambda: ( + User2.id, + User2.name, + User2.name_syn, + Address2.name_syn, + Address2.email_address, + aliased(User2).name_syn, + aliased(User2, name="foo").name_syn, + aliased(User2, name="bar").name_syn, + ), + compare_values=True, + ) + def test_more_with_entities_sanity_checks(self): """test issue #6503""" User, Address, Keyword, Order, Item = self.classes( diff --git a/test/orm/test_lambdas.py b/test/orm/test_lambdas.py index 53766c434..7373093ee 100644 --- a/test/orm/test_lambdas.py +++ b/test/orm/test_lambdas.py @@ -219,7 +219,7 @@ class LambdaTest(QueryTest, AssertsCompiledSQL): assert_raises_message( exc.ArgumentError, - "Cacheable Core or ORM object expected, got", + "ExecutionOption Core or ORM object expected, got", select(lambda: User).options, lambda: subqueryload(User.addresses), ) diff --git a/test/orm/test_query.py b/test/orm/test_query.py index 32a46463d..4cce3f33f 100644 --- a/test/orm/test_query.py +++ b/test/orm/test_query.py @@ -2112,6 +2112,7 @@ class ExpressionTest(QueryTest, AssertsCompiledSQL): class max_(expression.FunctionElement): name = "max" + inherit_cache = True @compiles(max_) def visit_max(element, compiler, **kw): @@ -2126,6 +2127,7 @@ class ExpressionTest(QueryTest, AssertsCompiledSQL): class not_named_max(expression.ColumnElement): name = "not_named_max" + inherit_cache = True @compiles(not_named_max) def visit_max(element, compiler, **kw): diff --git a/test/sql/test_compare.py b/test/sql/test_compare.py index af78ea19b..ca1eff62b 100644 --- a/test/sql/test_compare.py +++ b/test/sql/test_compare.py @@ -16,6 +16,7 @@ from sqlalchemy import Integer from sqlalchemy import literal_column from sqlalchemy import MetaData from sqlalchemy import or_ +from sqlalchemy import PickleType from sqlalchemy import select from sqlalchemy import String from sqlalchemy import Table @@ -1264,13 +1265,20 @@ class CacheKeyTest(CacheKeyFixture, CoreFixtures, fixtures.TestBase): # the None for cache key will prevent objects # which contain these elements from being cached. f1 = Foobar1() - eq_(f1._generate_cache_key(), None) + with expect_warnings( + "Class Foobar1 will not make use of SQL compilation caching" + ): + eq_(f1._generate_cache_key(), None) f2 = Foobar2() - eq_(f2._generate_cache_key(), None) + with expect_warnings( + "Class Foobar2 will not make use of SQL compilation caching" + ): + eq_(f2._generate_cache_key(), None) s1 = select(column("q"), Foobar2()) + # warning is memoized, won't happen the second time eq_(s1._generate_cache_key(), None) def test_get_children_no_method(self): @@ -1355,6 +1363,7 @@ class CompareAndCopyTest(CoreFixtures, fixtures.TestBase): and ( "__init__" in cls.__dict__ or issubclass(cls, AliasedReturnsRows) + or "inherit_cache" not in cls.__dict__ ) and not issubclass(cls, (Annotated)) and "orm" not in cls.__module__ @@ -1819,3 +1828,69 @@ class TypesTest(fixtures.TestBase): eq_(c1, c2) ne_(c1, c3) eq_(c1, c4) + + def test_thirdparty_sub_subclass_no_cache(self): + class MyType(PickleType): + pass + + expr = column("q", MyType()) == 1 + + with expect_warnings( + r"TypeDecorator MyType\(\) will not produce a cache key" + ): + is_(expr._generate_cache_key(), None) + + def test_userdefined_sub_subclass_no_cache(self): + class MyType(UserDefinedType): + cache_ok = True + + class MySubType(MyType): + pass + + expr = column("q", MySubType()) == 1 + + with expect_warnings( + r"UserDefinedType MySubType\(\) will not produce a cache key" + ): + is_(expr._generate_cache_key(), None) + + def test_userdefined_sub_subclass_cache_ok(self): + class MyType(UserDefinedType): + cache_ok = True + + class MySubType(MyType): + cache_ok = True + + def go1(): + expr = column("q", MySubType()) == 1 + return expr + + def go2(): + expr = column("p", MySubType()) == 1 + return expr + + c1 = go1()._generate_cache_key()[0] + c2 = go1()._generate_cache_key()[0] + c3 = go2()._generate_cache_key()[0] + + eq_(c1, c2) + ne_(c1, c3) + + def test_thirdparty_sub_subclass_cache_ok(self): + class MyType(PickleType): + cache_ok = True + + def go1(): + expr = column("q", MyType()) == 1 + return expr + + def go2(): + expr = column("p", MyType()) == 1 + return expr + + c1 = go1()._generate_cache_key()[0] + c2 = go1()._generate_cache_key()[0] + c3 = go2()._generate_cache_key()[0] + + eq_(c1, c2) + ne_(c1, c3) diff --git a/test/sql/test_functions.py b/test/sql/test_functions.py index 9378cfc38..e08526419 100644 --- a/test/sql/test_functions.py +++ b/test/sql/test_functions.py @@ -81,6 +81,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): # test generic function compile class fake_func(GenericFunction): + inherit_cache = True __return_type__ = sqltypes.Integer def __init__(self, arg, **kwargs): @@ -107,6 +108,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): if use_custom: class MyFunc(FunctionElement): + inherit_cache = True name = "myfunc" type = Integer() @@ -135,6 +137,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): def test_use_labels_function_element(self): class max_(FunctionElement): name = "max" + inherit_cache = True @compiles(max_) def visit_max(element, compiler, **kw): @@ -260,7 +263,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): def test_custom_default_namespace(self): class myfunc(GenericFunction): - pass + inherit_cache = True assert isinstance(func.myfunc(), myfunc) self.assert_compile(func.myfunc(), "myfunc()") @@ -268,6 +271,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): def test_custom_type(self): class myfunc(GenericFunction): type = DateTime + inherit_cache = True assert isinstance(func.myfunc().type, DateTime) self.assert_compile(func.myfunc(), "myfunc()") @@ -275,12 +279,14 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): def test_custom_legacy_type(self): # in case someone was using this system class myfunc(GenericFunction): + inherit_cache = True __return_type__ = DateTime assert isinstance(func.myfunc().type, DateTime) def test_case_sensitive(self): class MYFUNC(GenericFunction): + inherit_cache = True type = DateTime assert isinstance(func.MYFUNC().type, DateTime) @@ -336,6 +342,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): def test_custom_w_custom_name(self): class myfunc(GenericFunction): + inherit_cache = True name = "notmyfunc" assert isinstance(func.notmyfunc(), myfunc) @@ -343,6 +350,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): def test_custom_w_quoted_name(self): class myfunc(GenericFunction): + inherit_cache = True name = quoted_name("NotMyFunc", quote=True) identifier = "myfunc" @@ -350,6 +358,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): def test_custom_w_quoted_name_no_identifier(self): class myfunc(GenericFunction): + inherit_cache = True name = quoted_name("NotMyFunc", quote=True) # note this requires that the quoted name be lower cased for @@ -359,6 +368,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): def test_custom_package_namespace(self): def cls1(pk_name): class myfunc(GenericFunction): + inherit_cache = True package = pk_name return myfunc @@ -372,6 +382,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): def test_custom_name(self): class MyFunction(GenericFunction): name = "my_func" + inherit_cache = True def __init__(self, *args): args = args + (3,) @@ -387,20 +398,24 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): package = "geo" name = "BufferOne" identifier = "buf1" + inherit_cache = True class GeoBuffer2(GenericFunction): type = Integer name = "BufferTwo" identifier = "buf2" + inherit_cache = True class BufferThree(GenericFunction): type = Integer identifier = "buf3" + inherit_cache = True class GeoBufferFour(GenericFunction): type = Integer name = "BufferFour" identifier = "Buf4" + inherit_cache = True self.assert_compile(func.geo.buf1(), "BufferOne()") self.assert_compile(func.buf2(), "BufferTwo()") @@ -413,7 +428,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): def test_custom_args(self): class myfunc(GenericFunction): - pass + inherit_cache = True self.assert_compile( myfunc(1, 2, 3), "myfunc(:myfunc_1, :myfunc_2, :myfunc_3)" @@ -1010,6 +1025,7 @@ class ExecuteTest(fixtures.TestBase): from sqlalchemy.ext.compiler import compiles class myfunc(FunctionElement): + inherit_cache = True type = Date() @compiles(myfunc) diff --git a/test/sql/test_labels.py b/test/sql/test_labels.py index 535d4dd0b..8c8e9dbed 100644 --- a/test/sql/test_labels.py +++ b/test/sql/test_labels.py @@ -805,6 +805,8 @@ class ColExprLabelTest(fixtures.TestBase, AssertsCompiledSQL): def _fixture(self): class SomeColThing(WrapsColumnExpression, ColumnElement): + inherit_cache = False + def __init__(self, expression): self.clause = coercions.expect( roles.ExpressionElementRole, expression diff --git a/test/sql/test_lambdas.py b/test/sql/test_lambdas.py index fd6b1eb41..bbf9716f5 100644 --- a/test/sql/test_lambdas.py +++ b/test/sql/test_lambdas.py @@ -17,6 +17,7 @@ from sqlalchemy.sql import roles from sqlalchemy.sql import select from sqlalchemy.sql import table from sqlalchemy.sql import util as sql_util +from sqlalchemy.sql.base import ExecutableOption from sqlalchemy.sql.traversals import HasCacheKey from sqlalchemy.testing import assert_raises_message from sqlalchemy.testing import AssertsCompiledSQL @@ -810,7 +811,10 @@ class LambdaElementTest( stmt = lambdas.lambda_stmt(lambda: select(column("x"))) - opts = {column("x"), column("y")} + class MyUncacheable(ExecutableOption): + pass + + opts = {MyUncacheable()} assert_raises_message( exc.InvalidRequestError, @@ -942,11 +946,18 @@ class LambdaElementTest( return stmt - s1 = go([column("a"), column("b")]) + class SomeOpt(HasCacheKey, ExecutableOption): + def __init__(self, x): + self.x = x + + def _gen_cache_key(self, anon_map, bindparams): + return (SomeOpt, self.x) + + s1 = go([SomeOpt("a"), SomeOpt("b")]) - s2 = go([column("a"), column("b")]) + s2 = go([SomeOpt("a"), SomeOpt("b")]) - s3 = go([column("q"), column("b")]) + s3 = go([SomeOpt("q"), SomeOpt("b")]) s1key = s1._generate_cache_key() s2key = s2._generate_cache_key() @@ -964,7 +975,7 @@ class LambdaElementTest( return stmt - class SomeOpt(HasCacheKey): + class SomeOpt(HasCacheKey, ExecutableOption): def _gen_cache_key(self, anon_map, bindparams): return ("fixed_key",) @@ -994,8 +1005,8 @@ class LambdaElementTest( return stmt - class SomeOpt(HasCacheKey): - pass + class SomeOpt(HasCacheKey, ExecutableOption): + inherit_cache = False # generates no key, will not be cached eq_(SomeOpt()._generate_cache_key(), None) diff --git a/test/sql/test_operators.py b/test/sql/test_operators.py index 831b75e7e..6e943d236 100644 --- a/test/sql/test_operators.py +++ b/test/sql/test_operators.py @@ -656,6 +656,8 @@ class ExtensionOperatorTest(fixtures.TestBase, testing.AssertsCompiledSQL): def test_contains(self): class MyType(UserDefinedType): + cache_ok = True + class comparator_factory(UserDefinedType.Comparator): def contains(self, other, **kw): return self.op("->")(other) @@ -664,6 +666,8 @@ class ExtensionOperatorTest(fixtures.TestBase, testing.AssertsCompiledSQL): def test_getitem(self): class MyType(UserDefinedType): + cache_ok = True + class comparator_factory(UserDefinedType.Comparator): def __getitem__(self, index): return self.op("->")(index) @@ -682,6 +686,8 @@ class ExtensionOperatorTest(fixtures.TestBase, testing.AssertsCompiledSQL): def test_lshift(self): class MyType(UserDefinedType): + cache_ok = True + class comparator_factory(UserDefinedType.Comparator): def __lshift__(self, other): return self.op("->")(other) @@ -690,6 +696,8 @@ class ExtensionOperatorTest(fixtures.TestBase, testing.AssertsCompiledSQL): def test_rshift(self): class MyType(UserDefinedType): + cache_ok = True + class comparator_factory(UserDefinedType.Comparator): def __rshift__(self, other): return self.op("->")(other) diff --git a/test/sql/test_resultset.py b/test/sql/test_resultset.py index 4aa932b47..e4f07a758 100644 --- a/test/sql/test_resultset.py +++ b/test/sql/test_resultset.py @@ -1797,6 +1797,7 @@ class KeyTargetingTest(fixtures.TablesTest): def test_keyed_targeting_no_label_at_all_one(self, connection): class not_named_max(expression.ColumnElement): name = "not_named_max" + inherit_cache = True @compiles(not_named_max) def visit_max(element, compiler, **kw): @@ -1814,6 +1815,7 @@ class KeyTargetingTest(fixtures.TablesTest): def test_keyed_targeting_no_label_at_all_two(self, connection): class not_named_max(expression.ColumnElement): name = "not_named_max" + inherit_cache = True @compiles(not_named_max) def visit_max(element, compiler, **kw): diff --git a/test/sql/test_types.py b/test/sql/test_types.py index 35467de94..dc47cca46 100644 --- a/test/sql/test_types.py +++ b/test/sql/test_types.py @@ -1497,6 +1497,8 @@ class VariantTest(fixtures.TestBase, AssertsCompiledSQL): return process class UTypeThree(types.UserDefinedType): + cache_ok = True + def get_col_spec(self): return "UTYPETHREE" |