From ccfd3693d4658d9c367396630052b2962683564b Mon Sep 17 00:00:00 2001 From: collerek Date: Thu, 8 Oct 2026 16:07:07 +0200 Subject: [PATCH 1/2] chore: exclude Markdown files from ruff format Since ruff 0.16.10 ruff format also rewrites Python code blocks in Markdown, so make pre-commit (and the git hook running it) reformatted README.md and the docs on every run. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 50adacf2e..5e41bab8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,6 +176,7 @@ ignore = ["E402"] [tool.ruff.format] indent-style = "space" docstring-code-format = true +exclude = ["*.md"] [tool.ruff.lint.isort] known-first-party = ["ormar"] \ No newline at end of file From 3580dff75e57859f838a1a4ea96b27240ae84a6d Mon Sep 17 00:00:00 2001 From: collerek Date: Thu, 8 Oct 2026 16:07:26 +0200 Subject: [PATCH 2/2] perf: cache per-model parts of select building and row parsing Labelled column lists, row extraction plans and ORDER BY text clauses only depend on the model, alias and selected fields, yet were rebuilt on every query. Cache them (bounded lru caches) so repeated queries reuse them. --- ormar/models/model_row.py | 69 ++++++++++++++++++++++++-- ormar/queryset/actions/order_action.py | 19 ++++++- ormar/relations/alias_manager.py | 45 +++++++++++++---- 3 files changed, 116 insertions(+), 17 deletions(-) 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