diff --git a/ormar/models/model_row.py b/ormar/models/model_row.py index 41ddb2074..1110b2f29 100644 --- a/ormar/models/model_row.py +++ b/ormar/models/model_row.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from functools import lru_cache from typing import TYPE_CHECKING, Any, Optional, Union, cast try: @@ -39,6 +40,40 @@ class RowExtractionPlan: PlanCache = dict[tuple[type, str, int], RowExtractionPlan] +@lru_cache(maxsize=2048) +def build_shared_row_plan( + model_cls: type["ModelRow"], + table_prefix: str, + include: frozenset, + exclude: frozenset, + pk_only: bool, +) -> RowExtractionPlan: + """ + Builds a row extraction plan shared between querysets. A plan only depends + on the model, the table prefix and the model's own include/exclude/pk_only + settings, so those are the cache key. + + :param model_cls: model the plan is built for + :type model_cls: type[ModelRow] + :param table_prefix: prefix of the table from AliasManager + :type table_prefix: str + :param include: fields to include for this model + :type include: frozenset + :param exclude: fields to exclude for this model + :type exclude: frozenset + :param pk_only: flag if only the primary key is selected + :type pk_only: bool + :return: extraction plan for this row position + :rtype: RowExtractionPlan + """ + excludable = ExcludableItems() + model_excludable = excludable.get(model_cls=model_cls, alias=table_prefix) # type: ignore + model_excludable.include = set(include) + model_excludable.exclude = set(exclude) + model_excludable.pk_only = pk_only + return model_cls.build_row_extraction_plan(table_prefix, excludable) + + class ModelRow(NewBaseModel): @classmethod def from_row( # noqa: CFQ002 @@ -417,9 +452,9 @@ def get_or_build_row_plan( plan_cache: Optional[PlanCache], ) -> RowExtractionPlan: """ - Return a cached plan for the given key, or build and cache one. When - ``plan_cache`` is ``None`` (legacy / external caller) the plan is - built fresh on every call so behavior matches the pre-cache shape. + Return a plan from the per-queryset cache, falling back to the plans + shared between querysets. When ``plan_cache`` is ``None`` (legacy / + external caller) only the shared plans are used. :param table_prefix: prefix of the table from AliasManager :type table_prefix: str @@ -432,14 +467,38 @@ def get_or_build_row_plan( :rtype: RowExtractionPlan """ if plan_cache is None: - return cls.build_row_extraction_plan(table_prefix, excludable) + return cls.get_shared_row_plan(table_prefix, excludable) key = (cls, table_prefix, id(excludable)) plan = plan_cache.get(key) if plan is None: - plan = cls.build_row_extraction_plan(table_prefix, excludable) + plan = cls.get_shared_row_plan(table_prefix, excludable) plan_cache[key] = plan return plan + @classmethod + def get_shared_row_plan( + cls, table_prefix: str, excludable: ExcludableItems + ) -> RowExtractionPlan: + """ + Return a plan shared between querysets for the model's own + include/exclude/pk_only settings at the given table prefix. + + :param table_prefix: prefix of the table from AliasManager + :type table_prefix: str + :param excludable: structure of fields to include and exclude + :type excludable: ExcludableItems + :return: extraction plan for this row position + :rtype: RowExtractionPlan + """ + model_excludable = excludable.get(model_cls=cls, alias=table_prefix) # type: ignore + return build_shared_row_plan( + cls, + table_prefix, + frozenset(model_excludable.include), + frozenset(model_excludable.exclude), + model_excludable.pk_only, + ) + @staticmethod def apply_row_plan( plan: RowExtractionPlan, diff --git a/ormar/queryset/actions/order_action.py b/ormar/queryset/actions/order_action.py index 7e9506e87..f0d498e3c 100644 --- a/ormar/queryset/actions/order_action.py +++ b/ormar/queryset/actions/order_action.py @@ -1,4 +1,5 @@ import copy +from functools import lru_cache from typing import TYPE_CHECKING, Optional import sqlalchemy @@ -10,6 +11,20 @@ from ormar import Model +@lru_cache(maxsize=2048) +def order_text_clause(expression: str) -> sqlalchemy.sql.expression.TextClause: + """ + Builds a text clause for an ORDER BY expression, cached as text clauses are + immutable and the same orderings (e.g. default pk sort) repeat on every query. + + :param expression: raw ORDER BY expression + :type expression: str + :return: text clause for the expression + :rtype: sqlalchemy.sql.expression.TextClause + """ + return text(expression) + + class OrderAction(QueryAction): """ Represents a single ORDER BY directive on a queryset. @@ -94,7 +109,9 @@ def get_text_clause(self) -> sqlalchemy.sql.expression.TextClause: else: table_name = quoter(f"{prefix}{table_name}") field_name = quoter(field_name) - return text(self._build_order_expression(f"{table_name}.{field_name}")) + return order_text_clause( + self._build_order_expression(f"{table_name}.{field_name}") + ) def _build_order_expression(self, full_column: str) -> str: """ diff --git a/ormar/relations/alias_manager.py b/ormar/relations/alias_manager.py index d1ea622b3..33944196a 100644 --- a/ormar/relations/alias_manager.py +++ b/ormar/relations/alias_manager.py @@ -1,5 +1,6 @@ import string import uuid +from functools import lru_cache from random import choices from typing import TYPE_CHECKING, Any, Optional, Union @@ -13,6 +14,37 @@ from ormar.models import ModelRow +@lru_cache(maxsize=2048) +def build_prefixed_columns( + alias: str, table: sqlalchemy.Table, fields: Optional[frozenset[str]] +) -> tuple[Label[Any], ...]: + """ + Builds labelled columns of a table, cached as labels are immutable and the + same model/alias/fields combination is selected over and over. + + :param alias: alias of given table + :type alias: str + :param table: table from which fields should be aliased + :type table: sqlalchemy.Table + :param fields: column names (ormar aliases) to include, None for all + :type fields: Optional[frozenset[str]] + :return: tuple of labelled columns + :rtype: tuple[Label[Any], ...] + """ + alias = f"{alias}_" if alias else "" + aliased_fields = {f"{alias}{x}" for x in fields} if fields else set() + all_columns = ( + table.columns + if not fields + else [ + col + for col in table.columns + if col.name in fields or col.name in aliased_fields + ] + ) + return tuple(column.label(f"{alias}{column.name}") for column in all_columns) + + def get_table_alias() -> str: """ Creates a random string that is used to alias tables in joins. @@ -78,18 +110,9 @@ def prefixed_columns( :return: list of sqlalchemy text clauses with "column name as aliased name" :rtype: list[text] """ - alias = f"{alias}_" if alias else "" - aliased_fields = [f"{alias}{x}" for x in fields] if fields else [] - all_columns = ( - table.columns - if not fields - else [ - col - for col in table.columns - if col.name in fields or col.name in aliased_fields - ] + return list( + build_prefixed_columns(alias, table, frozenset(fields) if fields else None) ) - return [column.label(f"{alias}{column.name}") for column in all_columns] def prefixed_table_name( self, alias: str, table: sqlalchemy.Table