Skip to content

Commit 853caad

Browse files
collerekclaude
andauthored
optimizations to improve performance, add ormar-utils (#1571)
* optimizations to improve performance, add optional ormar-utils package written in rust * update lock * update coverage and lock * bump poetry in workflows * bump lock * make rust utils required dep and simplify the code to only use optimized versions * update lock * Optimize hot paths with caching and Rust reverse alias map Profile-driven optimizations targeting the most expensive ormar functions. Key changes: - Cache alias<->field_name mappings per model class, using Rust build_reverse_alias_map for O(1) lookups (was O(n) linear scan, called 406K times in profiling) - Cache (col_name, field_name) pairs to avoid repeated SA column iteration in own_table_columns and extract_prefixed_table_columns - Use set instead of list for selected_columns membership checks - Cache get_name(lower=True), extract_db_own_fields, ormar_fields_set, and ForeignKey constructors dict - Use frozenset for RelationProxy method check End-to-end benchmark improvements: - iterate: 24-30% faster - first: 26-36% faster - get_all: 18-19% faster - saving: 17-25% faster - select_related: 12-17% faster Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * bump ormar-utils version * add nocover to alias dict access, not hit on normal usage * chore: regenerate lock and reorder imports after rebase Post-rebase tidying: poetry lock bumped to 2.3.3 plus mkdocstrings patch bump, and ruff reordered the third-party `ormar_rust_utils` import in queryset/utils.py. * perf: cache & specialize _process_kwargs hot path (#1649) Profile-driven refactor of NewBaseModel._process_kwargs, which fires on every Model.__init__ (user construction and row hydration). Removes redundant per-init work and skips no-op conversion calls for fields that are neither JSON nor bytes. Changes: - Cache _pydantic_field_names, _extra_is_ignore, _allowed_kwarg_names on the class (lazy-populated on first init); installed via metaclass add_cached_properties alongside the existing _json_fields/_bytes_fields caches. - Replace nested _convert_to_bytes(_convert_json(...)) wrapping with an explicit dispatch loop. Common path (regular ormar field, no JSON, no bytes) avoids the function-call overhead entirely. - Inline _remove_extra_parameters_if_they_should_be_ignored behind the cached _extra_is_ignore bool; remove the method (no external callers). - Remove now-unused _convert_to_bytes / _convert_json methods and the orphaned _convert_json entry in quick_access_views. Behavior unchanged: same ModelError messaging on unknown fields, same JSON encoding, same base64 bytes handling. 628 tests pass at 100 % coverage. Benchmark deltas (median, pytest-benchmark): - test_initializing_models[250] -34.9% - test_initializing_models[500] -8.8% - test_iterate[500] / test_iterate[1000] -7.9% / -7.4% - test_get_all_with_related_models[40] -3.2% - I/O-bound get_one / first / get_or_none within noise cProfile (all_with_related): _process_kwargs tottime 0.365s -> 0.238s (-35%). The line-397 dict-comp is no longer a separate hotspot. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf: replace RelationProxy.__getattribute__ with explicit count/clear (#1650) Profile-driven removal of the per-attribute-access Python override on RelationProxy. The previous __getattribute__ existed solely to redirect two list-method names ("count", "clear") to the QuerysetProxy versions; every other attribute access paid the cost of a Python-level __getattribute__ call only to fall through to super. cProfile (all_with_related, 40 000 row hydrations): __getattribute__ was 0.354 s tottime / 6.2 % of scenario time, with 420 000 calls. After this change it is no longer in the top 25 by tottime — attribute access goes through the C-level lookup path. Replacement: define count() and clear() as async methods directly on RelationProxy. They shadow list.count / list.clear by virtue of MRO and delegate to queryset_proxy after self._initialize_queryset(). The observable async semantics are unchanged: callers always did ``await proxy.count(...)`` / ``await proxy.clear(...)``; the previous override returned a bound async method, the new methods are themselves async — both produce the same coroutine. Behavior unchanged: same signatures (distinct=True / keep_reversed=True defaults), same delegation path, same QuerysetProxy initialization trigger. 628 tests pass at 100 % coverage. Benchmark deltas (median, pytest-benchmark, --warmup=on, 10+ rounds): - test_get_all_with_related_models[10] -17.2 % - test_get_all_with_related_models[20] -15.6 % - test_get_all_with_related_models[40] -7.7 % - test_get_all[250] -13.1 % - test_get_all[500] -9.9 % - test_get_all[1000] -6.4 % - test_iterate[*] within noise - single-row get_one / first I/O-dominated, ±15 % noise Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf: lazy relation machinery in Model.__init__ (#1652) * perf: defer QuerysetProxy construction in RelationProxy Every model materialized from a row eagerly built a QuerysetProxy per reverse/m2m relation, even though the queryset machinery is rarely touched on read paths. Make `RelationProxy.queryset_proxy` a lazy property so the allocation only happens when something actually queries through the relation. Benchmarks (sqlite, on-disk): - init[1000]: 9.74 ms -> 8.42 ms (-13.6%) - get_all[1000]: 15.64 ms -> 14.20 ms (-9.2%) - iterate[1000]: 22.83 ms -> 21.33 ms (-6.6%) * perf: defer RelationProxy construction in Relation Reverse / many-to-many relations allocated their RelationProxy in Relation.__init__ for every model, even when the relation was never read. Construct on first add/get instead, and read related_models directly from the hash-cache update path so a PK change doesn't force materialization of every reverse proxy on the model. Combined with the previous QuerysetProxy change, vs baseline: - init[1000]: 9.74 ms -> 7.06 ms (-27.5%) - get_all[1000]: 15.64 ms -> 12.06 ms (-22.9%) - iterate[1000]: 22.83 ms -> 18.66 ms (-18.3%) * perf: defer Relation construction in RelationsManager RelationsManager.__init__ used to build a Relation for every declared FK on every Model.__init__. Most of those Relation instances are never read — they exist only to be checked for membership or never touched at all on row-materialization paths. Build them on demand in _get(name) using a precomputed name->field lookup instead. Combined with the previous lazy-RelationProxy and lazy-QuerysetProxy changes, vs baseline: - init[1000]: 9.74 ms -> 6.67 ms (-31.6%) - get_all[1000]: 15.64 ms -> 11.58 ms (-26.0%) - iterate[1000]: 22.83 ms -> 18.83 ms (-17.5%) - init[250]: 2.65 ms -> 1.66 ms (-37.4%) * perf: cache row-extraction plan in from_row (#1654) from_row used to recompute, for every row × every join level, the selected-columns set, the prefixed column key strings, and the exclude set. All three depend only on (model_cls, table_prefix, excludable), not on the row, so they can be built once per query and reused across rows. - New RowExtractionPlan dataclass holds the precomputed work. - build_row_extraction_plan / get_or_build_row_plan / apply_row_plan split the lifecycle so callers can build once and apply many. - _process_query_result_rows allocates a per-call plan cache; iterate shares one cache across all yielded chunks so 1-row chunks still amortize. - prefetch_query._instantiate_models builds the plan once before the row loop. - _construct_with_excluded widened to AbstractSet[str] so the plan can store the exclude set as a hashable frozenset. Benchmarks vs the lazy-relation baseline: - get_all[1000]: 11.58 ms -> 9.11 ms (-21.3%) - get_all[500]: 6.80 ms -> 4.88 ms (-28.3%) - iterate[1000]: 18.83 ms -> 15.67 ms (-16.8%) - iterate[250]: 5.62 ms -> 4.54 ms (-19.2%) * perf: unify Relation reverse/m2m container with __dict__ slot (#1655) Reverse / m2m relations were tracked in two parallel containers — the RelationProxy on the Relation, and a plain list on the owner's __dict__ read by pydantic for serialization. Every Relation.add did: 1. Hash-based membership check on the proxy 2. Append to the proxy 3. Dict load of the parallel list 4. Linear `child not in rel` scan over weakproxy entries (in a try wrapper that caught ReferenceError as a "nuke and restart" signal) 5. Append to the parallel list 6. Dict store Steps 3–6 collapse to a single dict store once the proxy itself is the __dict__ slot. The proxy is a list subclass, so pydantic serialization and pickling round-trip unchanged. Kept _find_existing's call site — its dead-weakref probe is a side effect we still rely on (hash collision on a stale entry populates _to_remove so the next get() runs _clean_related). Benchmarks vs the post-#1654 optimization tip: - init_with_related[40]: 1983 µs -> 918 µs (-53.7%) - init_with_related[20]: 718 µs -> 457 µs (-36.4%) - init_with_related[10]: 303 µs -> 239 µs (-21.1%) - get_all_with_related[40]: 5079 µs -> 4552 µs (-10.4%) - workloads with no related-model registration: within noise * perf: fast-path expand_relationship for already-typed Model values (#1656) expand_relationship had a per-call try/except framework around its constructor cache and dispatched on value.__class__.__name__ via a string-keyed dict — even though the dominant case (row materialization, user kwargs that already hold constructed Models) is just "register if asked, return value". Two changes: - Identity-based fast path before the dispatch table: if value is an instance of self.to (cheapest possible identity check via __class__ is), inline the register-if-asked + return. Skips the dict lookup and the _register_existing_model indirection entirely for the most common path. - Replace the lazy try/except cache with a @cached_property. By the time expand_relationship runs, _verify_model_can_be_initialized has already gated on requires_ref_update, so self.to is resolved and the bound methods captured in the dict are stable. _register_existing_model is dead post-fast-path (it was the dispatch target for the same-class case the fast path now covers); deleted, and its dispatch entry removed. Benchmarks vs the post-#1655 optimization tip: - init_with_related[40]: 858 µs -> 810 µs (-5.6%) - init_with_related[10]: 235 µs -> 222 µs (-5.5%) - init[1000]: 7010 µs -> 6517 µs (-7.0%) - get_all_with_related[40]: 4351 µs -> 4124 µs (-5.2%) - iterate[1000]: 17152 µs -> 16188 µs (-5.6%) * perf: in-place index assignment in _merge_items_lists (#1657) * perf: in-place index assignment in _merge_items_lists The matched branch rebuilt value_to_set with a list comprehension that filtered by pk and concatenated [new_val] — O(N) per match, O(K*N) overall. The Rust planner already returns the destination index (other_idx); use it for an O(1) in-place write. Behavior preserved on the workloads exercised by the existing benchmark suite (matched branch always fires on size-1 value_to_set there). Where the worst case actually fires — full PK overlap between current_field and other_value at large list sizes — the new microbenchmark shows 33x speedup at N=100, scaling linearly with N. Side effect: matched items now keep their original other_value index instead of being shuffled to the tail. Today's "shuffle to tail" was an artifact of the rebuild pattern, not a deliberate semantic; the existing tests/test_ordering/ suite passes unchanged. * test: add merge benchmarks (integration + microbench) Adds two benchmark workloads for the row-merging path: - test_select_related_nested_merge: integration benchmark over Project -> Tasks (FK) -> Tags (m2m) covering the full join / materialize / merge pipeline. - test_merge_items_lists_pk_overlap: microbenchmark calling _merge_items_lists directly with full-PK-overlap inputs at list_size in {10, 50, 100}. This is the worst case the matched branch is O(K*N) on; it isolates the inner loop from query and row-materialization noise. The microbenchmark shows the difference clearly: list_size old (O(K*N)) new (O(K)) delta 10 31.4 us 7.0 us -77.6% 50 531.2 us 30.8 us -94.2% 100 2028.5 us 61.8 us -97.0% Growth confirms theory: baseline scales quadratically, new code scales linearly. The benchmark is included so future regressions in this path get caught. * perf: three small wins (#5, #3 remainder, #8) (#1658) * perf: skip _recursive_add wrapper for size-1 merge groups merge_instances_list always built a 1-element wrapper list and called _recursive_add even when group_indices held a single index — the common case for queries with no parent duplication. _recursive_add short-circuits at len(model_group) <= 1 anyway, so the wrapper list, the call, and the [0] index were pure overhead. Hoist the early return one frame up: if the group has exactly one row, read it directly from result_rows. Benchmarks vs the post-#1657 optimization tip: - init[1000]: 6571 us -> 6491 us (-1.2%) - get_all[1000]: 9139 us -> 9069 us (-0.8%) - get_all[500]: 4951 us -> 4799 us (-3.1%) - iterate[1000]: 16373 us -> 16068 us (-1.9%) * perf: cheaper Model.__hash__ and __same__ __hash__ used hash(str(pk) + cls.__name__) on every cache miss — two string allocations per call. Replace with hash((pk, type(self))) which uses CPython's identity hash for type objects and skips the string concat entirely. __same__ used hash(self) == other.__hash__() to compare two saved models, which fills both sides' hash caches just to test equality. Direct (pk, type) compare on the saved-pk path skips both hash allocations; fall through to hash equality only when both sides are unsaved. Saved-pk path is the hot one (every relation-cache lookup goes through __hash__). Unsaved-pk path keeps the str(vals) shape because __dict__ can hold list/dict values (json fields, reverse-relation slots) that aren't hashable directly. Benchmarks (workloads that exercise relation hashing): - init_with_related[40]: 828 us -> 781 us (-5.7%) * perf: specialize _process_kwargs per field-set The per-key dispatch loop ran four set membership checks on every kwarg even though typical models have empty json_fields and bytes_fields and only a couple of relation fields. Restructure so the empty-set checks are hoisted out of the loop: - Pre-compute relation_field_names on the class (cached, like _pydantic_field_names) so expand_relationship is only called for fields that actually need it. - Validate unknown kwargs once up front rather than per-iteration. - Fast path for plain models (no json/bytes/relations) reduces to ``return dict(kwargs), through_tmp_dict`` — a single C-level dict copy. - Slow path skips per-iteration empty-set checks via has_json / has_bytes guards. Side effect: BaseField.expand_relationship is no longer called (only relation fields go through expand_relationship now). Marked as ``# pragma: no cover`` rather than removed since it remains a public API contract on the field base class. Benchmarks vs the previous commit: - get_all[1000]: 9670 us -> 9024 us (-6.7%) - get_all[500]: 5008 us -> 4769 us (-4.8%) - iterate[1000]: 16642 us -> 15723 us (-5.5%) - get_all_with_related[40]: 4408 us -> 4205 us (-4.6%) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d3a7a51 commit 853caad

31 files changed

Lines changed: 838 additions & 468 deletions

.github/workflows/deploy-docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
- name: Install Poetry
2222
uses: snok/install-poetry@v1.4
2323
with:
24-
version: 2.1.3
24+
version: 2.3.2
2525
virtualenvs-create: false
2626
- name: Install dependencies
2727
run: |

.github/workflows/lint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
- name: Install Poetry
2323
uses: snok/install-poetry@v1.4
2424
with:
25-
version: 2.1.3
25+
version: 2.3.2
2626
virtualenvs-create: false
2727

2828
- name: Poetry details

.github/workflows/python-publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
- name: Install Poetry
2626
uses: snok/install-poetry@v1.4
2727
with:
28-
version: 2.1.3
28+
version: 2.3.2
2929
virtualenvs-create: true
3030
virtualenvs-in-project: true
3131

.github/workflows/test-package.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ jobs:
5050
- name: Install Poetry
5151
uses: snok/install-poetry@v1.4
5252
with:
53-
version: 2.1.3
53+
version: 2.3.2
5454
virtualenvs-create: false
5555

5656
- name: Poetry details

.github/workflows/test_docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
- name: Install Poetry
2121
uses: snok/install-poetry@v1.4
2222
with:
23-
version: 2.1.3
23+
version: 2.3.2
2424
virtualenvs-create: false
2525
- name: Install dependencies
2626
run: |

.github/workflows/type-check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
- name: Install Poetry
2323
uses: snok/install-poetry@v1.4
2424
with:
25-
version: 2.1.3
25+
version: 2.3.2
2626
virtualenvs-create: false
2727

2828
- name: Poetry details
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""
2+
Micro-benchmark for get_column_name_from_alias and related alias functions.
3+
"""
4+
5+
import pytest
6+
7+
from benchmarks.conftest import Author, Book
8+
9+
pytestmark = pytest.mark.asyncio
10+
11+
12+
@pytest.mark.parametrize("num_lookups", [1000, 10000])
13+
def test_get_column_name_from_alias(benchmark, num_lookups: int) -> None:
14+
"""Benchmark get_column_name_from_alias - O(n) linear scan per call."""
15+
# Get all column aliases for the model
16+
aliases = [col.name for col in Author.ormar_config.table.columns]
17+
18+
def run() -> None:
19+
for _ in range(num_lookups):
20+
for alias in aliases:
21+
Author.get_column_name_from_alias(alias)
22+
23+
benchmark(run)
24+
25+
26+
@pytest.mark.parametrize("num_lookups", [1000, 10000])
27+
def test_get_column_name_from_alias_book(benchmark, num_lookups: int) -> None:
28+
"""Benchmark on Book model (more fields including FKs)."""
29+
aliases = [col.name for col in Book.ormar_config.table.columns]
30+
31+
def run() -> None:
32+
for _ in range(num_lookups):
33+
for alias in aliases:
34+
Book.get_column_name_from_alias(alias)
35+
36+
benchmark(run)
37+
38+
39+
@pytest.mark.parametrize("num_lookups", [1000, 10000])
40+
def test_translate_columns_to_aliases(benchmark, num_lookups: int) -> None:
41+
"""Benchmark translate_columns_to_aliases - dict key remapping."""
42+
43+
def run() -> None:
44+
for _ in range(num_lookups):
45+
kwargs = {"name": "test", "score": 50, "id": 1}
46+
Author.translate_columns_to_aliases(kwargs)
47+
48+
benchmark(run)
49+
50+
51+
@pytest.mark.parametrize("num_lookups", [1000, 10000])
52+
def test_translate_aliases_to_columns(benchmark, num_lookups: int) -> None:
53+
"""Benchmark translate_aliases_to_columns - reverse remapping."""
54+
# Get aliases
55+
aliases = {
56+
field.get_alias(): "value"
57+
for field_name, field in Author.ormar_config.model_fields.items()
58+
if field.get_alias()
59+
}
60+
if not aliases:
61+
aliases = {"name": "test", "score": 50, "id": 1}
62+
63+
def run() -> None:
64+
for _ in range(num_lookups):
65+
kwargs = dict(aliases)
66+
Author.translate_aliases_to_columns(kwargs)
67+
68+
benchmark(run)

benchmarks/test_benchmark_merge.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Benchmark for nested-join row merging in ``_process_query_result_rows``.
2+
3+
The workload below targets the path optimized by the in-place index
4+
assignment in ``_merge_items_lists``: a parent that fans out across many
5+
joined rows so the matched branch fires repeatedly during
6+
``_recursive_add``, with both halves of late-round merges accumulating
7+
overlapping child PKs.
8+
9+
Shape:
10+
11+
Project (1) -> Task (N, FK) -> Tag (M, m2m, shared across tasks)
12+
13+
A single ``Project`` produces ``N * M`` result rows on
14+
``select_related(["tasks", "tasks__tags"]).all()``. The pairwise
15+
``_recursive_add`` consolidates the duplicates, and in the deeper rounds
16+
both sides hold overlapping ``Task`` PKs (because every adjacent row
17+
carries the same task with a different tag); inside each task the tag
18+
lists also accumulate and overlap across recursion halves.
19+
"""
20+
21+
import pytest
22+
23+
import ormar
24+
from benchmarks.conftest import base_ormar_config
25+
26+
pytestmark = pytest.mark.asyncio
27+
28+
29+
class BenchTag(ormar.Model):
30+
ormar_config = base_ormar_config.copy(tablename="bench_merge_tags")
31+
32+
id: int = ormar.Integer(primary_key=True)
33+
name: str = ormar.String(max_length=50)
34+
35+
36+
class BenchProject(ormar.Model):
37+
ormar_config = base_ormar_config.copy(tablename="bench_merge_projects")
38+
39+
id: int = ormar.Integer(primary_key=True)
40+
name: str = ormar.String(max_length=100)
41+
42+
43+
class BenchTask(ormar.Model):
44+
ormar_config = base_ormar_config.copy(tablename="bench_merge_tasks")
45+
46+
id: int = ormar.Integer(primary_key=True)
47+
project: BenchProject = ormar.ForeignKey(
48+
BenchProject, index=True, related_name="tasks"
49+
)
50+
title: str = ormar.String(max_length=100)
51+
tags: list[BenchTag] = ormar.ManyToMany(BenchTag)
52+
53+
54+
@pytest.mark.parametrize(
55+
("num_tasks", "tags_per_task"),
56+
[(5, 5), (10, 10), (20, 10)],
57+
)
58+
async def test_select_related_nested_merge(
59+
aio_benchmark, num_tasks: int, tags_per_task: int
60+
):
61+
"""Project -> Tasks (FK) -> Tags (m2m) workload.
62+
63+
Result row count is ``num_tasks * tags_per_task`` for one project; every
64+
row materializes a duplicate Project with one Task (with one Tag).
65+
The merge path consolidates duplicates pairwise via ``_recursive_add``
66+
— covers ``_merge_items_lists`` end-to-end through the full join /
67+
materialize / merge pipeline.
68+
"""
69+
project = await BenchProject(name="P").save()
70+
tags = [await BenchTag(name=f"t{i}").save() for i in range(tags_per_task)]
71+
for i in range(num_tasks):
72+
task = await BenchTask(project=project, title=f"T{i}").save()
73+
for tag in tags:
74+
await task.tags.add(tag)
75+
76+
@aio_benchmark
77+
async def query():
78+
return await BenchProject.objects.select_related(["tasks", "tasks__tags"]).all()
79+
80+
result = query()
81+
assert len(result) == 1
82+
assert len(result[0].tasks) == num_tasks
83+
for task in result[0].tasks:
84+
assert len(task.tags) == tags_per_task
85+
86+
87+
@pytest.mark.parametrize("list_size", [10, 50, 100])
88+
def test_merge_items_lists_pk_overlap(benchmark, list_size: int):
89+
"""Microbenchmark for ``_merge_items_lists`` with full PK overlap.
90+
91+
Constructs two equally sized lists of saved tasks where every entry
92+
in ``current_field`` matches an entry in ``other_value`` by PK. This
93+
is the worst case the per-pair list rebuild used to be O(N) on — K
94+
matches, each filtering an N-element ``value_to_set``. With
95+
``other_idx`` driving in-place writes the cost drops from O(K·N) to
96+
O(K).
97+
98+
The benchmark calls the merge classmethod directly so the SA query /
99+
row materialization overhead is excluded — the signal we want is the
100+
inner loop only. Every task is fully populated (no relations to
101+
recurse into) so ``merge_two_instances`` is light and the
102+
``_merge_items_lists`` body itself dominates.
103+
"""
104+
project = BenchProject(id=1, name="p")
105+
current_field = [
106+
BenchTask(id=i, project=project, title=f"T{i}") for i in range(list_size)
107+
]
108+
other_value = [
109+
BenchTask(id=i, project=project, title=f"T{i}") for i in range(list_size)
110+
]
111+
112+
benchmark(
113+
BenchTask._merge_items_lists,
114+
field_name="tasks",
115+
current_field=current_field,
116+
other_value=other_value,
117+
relation_map={"tasks": ...},
118+
)

ormar/fields/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ def expand_relationship(
384384
:return: returns untouched value for normal fields, expands only for relations
385385
:rtype: Any
386386
"""
387-
return value
387+
return value # pragma: no cover
388388

389389
def set_self_reference_flag(self) -> None:
390390
"""

ormar/fields/foreign_key.py

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import sys
33
import uuid
44
from dataclasses import dataclass
5+
from functools import cached_property
56
from random import choices
67
from typing import TYPE_CHECKING, Any, ForwardRef, Optional, Union, cast, overload
78

@@ -491,28 +492,6 @@ def _extract_model_from_sequence(
491492
for val in value
492493
]
493494

494-
def _register_existing_model(
495-
self, value: "Model", child: "Model", to_register: bool
496-
) -> "Model":
497-
"""
498-
Takes already created instance and registers it for parent.
499-
Registration is mutual, so children have also reference to parent.
500-
501-
Used in reverse FK relations and normal FK for single models.
502-
503-
:param value: already instantiated Model
504-
:type value: Model
505-
:param child: child/ related Model
506-
:type child: Model
507-
:param to_register: flag if the relation should be set in RelationshipManager
508-
:type to_register: bool
509-
:return: (if needed) registered Model
510-
:rtype: Model
511-
"""
512-
if to_register:
513-
self.register_relation(model=value, child=child)
514-
return value
515-
516495
def _construct_model_from_dict(
517496
self, value: dict, child: "Model", to_register: bool
518497
) -> "Model":
@@ -611,6 +590,23 @@ def has_unresolved_forward_refs(self) -> bool:
611590
"""
612591
return self.to.__class__ == ForwardRef
613592

593+
@cached_property
594+
def _constructor_dispatch(self) -> dict[str, Any]:
595+
"""
596+
Per-field map from input class name to the constructor handling that
597+
shape. Built once at first access — by the time
598+
``expand_relationship`` runs, ``_verify_model_can_be_initialized``
599+
has already gated on ``requires_ref_update``, so the bound methods
600+
captured here are stable.
601+
602+
:return: dispatch table for the slow path of ``expand_relationship``
603+
:rtype: dict[str, Any]
604+
"""
605+
return {
606+
"dict": self._construct_model_from_dict,
607+
"list": self._extract_model_from_sequence,
608+
}
609+
614610
def expand_relationship(
615611
self,
616612
value: Any,
@@ -636,16 +632,17 @@ def expand_relationship(
636632
"""
637633
if value is None:
638634
return None if not self.virtual else []
639-
constructors = {
640-
f"{self.to.__name__}": self._register_existing_model,
641-
"dict": self._construct_model_from_dict,
642-
"list": self._extract_model_from_sequence,
643-
}
644-
645-
model = constructors.get( # type: ignore
635+
# Fast path: ``value`` is already a Model of ``self.to``. Dominant
636+
# case in row materialization and in user kwargs that pass
637+
# constructed Models directly. Skips the dispatch table and the
638+
# ``_register_existing_model`` indirection entirely.
639+
if value.__class__ is self.to:
640+
if to_register:
641+
self.register_relation(model=value, child=cast("Model", child))
642+
return value
643+
return self._constructor_dispatch.get(
646644
value.__class__.__name__, self._construct_model_from_pk
647645
)(value, child, to_register)
648-
return model
649646

650647
def get_relation_name(self) -> str: # pragma: no cover
651648
"""

0 commit comments

Comments
 (0)