Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 64 additions & 5 deletions ormar/models/model_row.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from functools import lru_cache
from typing import TYPE_CHECKING, Any, Optional, Union, cast

try:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
19 changes: 18 additions & 1 deletion ormar/queryset/actions/order_action.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import copy
from functools import lru_cache
from typing import TYPE_CHECKING, Optional

import sqlalchemy
Expand All @@ -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.
Expand Down Expand Up @@ -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:
"""
Expand Down
45 changes: 34 additions & 11 deletions ormar/relations/alias_manager.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading