Skip to content

Commit 16d9cb6

Browse files
committed
implement proxy inheritance mode
1 parent c03abdd commit 16d9cb6

7 files changed

Lines changed: 706 additions & 15 deletions

File tree

docs/models/inheritance.md

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# Inheritance
22

3-
Out of various types of ORM models inheritance `ormar` currently supports two of them:
3+
Out of various types of ORM models inheritance `ormar` currently supports three of them:
44

55
* **Mixins**
66
* **Concrete table inheritance** (with parents set to `abstract=True`)
7+
* **Proxy models** (with children set to `proxy=True`)
78

89
## Types of inheritance
910

@@ -15,6 +16,8 @@ The short summary of different types of inheritance:
1516
* **Concrete table inheritance [SUPPORTED]** - means that parent is marked as abstract
1617
and each child has its own table with columns from a parent and own child columns, kind
1718
of similar to Mixins but parent also is a Model
19+
* **Proxy models [SUPPORTED]** - means that only parent has an actual table,
20+
children just add methods, modify settings etc. and share the parent's table
1821
* **Single table inheritance [NOT SUPPORTED]** - means that only one table is created
1922
with fields that are combination/sum of the parent and all children models but child
2023
models use only subset of column in db (all parent and own ones, skipping the other
@@ -23,8 +26,6 @@ The short summary of different types of inheritance:
2326
is saved on parent model and part is saved on child model that are connected to each
2427
other by kind of one to one relation and under the hood you operate on two models at
2528
once
26-
* **Proxy models [NOT SUPPORTED]** - means that only parent has an actual table,
27-
children just add methods, modify settings etc.
2829

2930
## Mixins
3031

@@ -193,6 +194,68 @@ assert isinstance(
193194

194195
`created_date: str = ormar.String(max_length=200, name="creation_date2") # exception`
195196

197+
## Proxy models
198+
199+
A proxy model is a child class that **shares its parent's table** and only adds
200+
methods, computed fields, or a custom `queryset_class`. Use it when you want
201+
multiple Python views over the same underlying table, for example to attach
202+
domain methods to a generic record type.
203+
204+
To declare a proxy model, set `proxy=True` on the child's `OrmarConfig`:
205+
206+
```python
207+
base_ormar_config = ormar.OrmarConfig(
208+
database=DatabaseConnection(DATABASE_URL),
209+
metadata=sqlalchemy.MetaData(),
210+
)
211+
212+
213+
class Human(ormar.Model):
214+
ormar_config = base_ormar_config.copy(tablename="humans")
215+
216+
id: int = ormar.Integer(primary_key=True)
217+
first_name: str = ormar.String(max_length=50)
218+
last_name: str = ormar.String(max_length=50)
219+
220+
221+
class User(Human):
222+
ormar_config = base_ormar_config.copy(proxy=True)
223+
224+
def full_name(self) -> str:
225+
return f"{self.first_name} {self.last_name}"
226+
```
227+
228+
Both `Human.objects.all()` and `User.objects.all()` read from the same `humans`
229+
table; the only difference is that `User` rows are returned as `User` instances
230+
and gain the `full_name()` method.
231+
232+
### Constraints
233+
234+
* The base class must be a non-abstract `ormar.Model`. Proxying an
235+
`abstract=True` parent raises `ModelDefinitionError` — use concrete
236+
inheritance for that.
237+
* A proxy model **cannot** declare new ormar fields. Doing so raises
238+
`ModelDefinitionError` because the schema is fixed by the parent.
239+
* `proxy=True` and `abstract=True` are mutually exclusive on the same class.
240+
* Proxy chains (e.g. `Admin(User)` with `proxy=True` where `User` itself is a
241+
proxy of `Human`) are allowed and resolve back to the root concrete table.
242+
* All concrete ormar bases of a proxy must share the same table.
243+
244+
### Behavior notes
245+
246+
* Each proxy class has its own `SignalEmitter`, so a `pre_save` registered on
247+
`User` does not fire when a `Human` instance is saved (and vice versa).
248+
Set `emit_parent_signals=True` on the proxy's `OrmarConfig` to also dispatch
249+
the parent's `pre_save` / `post_save` / `pre_update` / `post_update` /
250+
`pre_delete` / `post_delete` handlers (with `sender=parent_cls`) on every
251+
save / update / delete via the proxy. The flag is opt-in to preserve the
252+
contract of existing handlers that assume the instance is the parent type.
253+
* Reverse relations defined on related models still point at the original
254+
parent class — proxying does not create new reverse accessors.
255+
* The proxy's `queryset_class` and `extra` settings can be overridden via
256+
`OrmarConfig.copy(proxy=True, queryset_class=...)` independently of the
257+
parent.
258+
196259
## Relations in inheritance
197260

198261
You can declare relations in every step of inheritance, so both in parent and child

ormar/models/helpers/models.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,20 @@ def check_required_config_parameters(new_model: type["Model"]) -> None:
7171
:param new_model: newly declared ormar Model
7272
:type new_model: Model class
7373
"""
74-
if new_model.ormar_config.database is None and not new_model.ormar_config.abstract:
74+
if new_model.ormar_config.proxy and new_model.ormar_config.abstract:
75+
raise ormar.ModelDefinitionError(
76+
f"{new_model.__name__} cannot be both proxy and abstract."
77+
)
78+
79+
skip_db_metadata_checks = (
80+
new_model.ormar_config.abstract or new_model.ormar_config.proxy
81+
)
82+
if new_model.ormar_config.database is None and not skip_db_metadata_checks:
7583
raise ormar.ModelDefinitionError(
7684
f"{new_model.__name__} does not have database defined."
7785
)
7886

79-
if new_model.ormar_config.metadata is None and not new_model.ormar_config.abstract:
87+
if new_model.ormar_config.metadata is None and not skip_db_metadata_checks:
8088
raise ormar.ModelDefinitionError(
8189
f"{new_model.__name__} does not have metadata defined."
8290
)

ormar/models/metaclass.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,33 @@ def copy_data_from_parent_model( # noqa: CCR001
353353
:rtype: tuple[dict, dict]
354354
"""
355355
if attrs.get("ormar_config"):
356-
if model_fields and not base_class.ormar_config.abstract: # type: ignore
356+
child_config = attrs["ormar_config"]
357+
child_proxy = getattr(child_config, "proxy", False)
358+
base_abstract = base_class.ormar_config.abstract # type: ignore
359+
360+
if child_proxy:
361+
if base_abstract:
362+
raise ModelDefinitionError(
363+
f"Proxy model {curr_class.__name__} cannot inherit from "
364+
f"abstract class {base_class.__name__}; "
365+
f"use concrete inheritance instead."
366+
)
367+
new_fields = set(model_fields) - set(
368+
base_class.ormar_config.model_fields # type: ignore
369+
)
370+
if new_fields:
371+
raise ModelDefinitionError(
372+
f"Proxy model {curr_class.__name__} cannot declare new ormar "
373+
f"fields: {sorted(new_fields)}."
374+
)
375+
update_attrs_from_base_config(
376+
base_class=base_class, # type: ignore
377+
attrs=attrs,
378+
model_fields=model_fields,
379+
)
380+
return attrs, dict(base_class.ormar_config.model_fields)
381+
382+
if model_fields and not base_abstract:
357383
raise ModelDefinitionError(
358384
f"{curr_class.__name__} cannot inherit "
359385
f"from non abstract class {base_class.__name__}"
@@ -521,6 +547,50 @@ def update_attrs_and_fields(
521547
return updated_model_fields
522548

523549

550+
def wire_proxy_from_parent(new_model: type["Model"]) -> None:
551+
"""
552+
Resolve the concrete ormar parent of a proxy model and share its table,
553+
columns, primary key, model_fields, metadata and database with the proxy.
554+
555+
Proxy models do not own a SQLAlchemy table. They reuse the parent's table
556+
so that queries via the proxy class hit the same physical rows.
557+
558+
:raises ModelDefinitionError: if the proxy has no concrete ormar parent or
559+
if it inherits from multiple concrete ormar models with different tables.
560+
:param new_model: the proxy model class being constructed
561+
:type new_model: type["Model"]
562+
"""
563+
concrete_bases = [
564+
base
565+
for base in new_model.__mro__[1:]
566+
if hasattr(base, "ormar_config")
567+
and not base.ormar_config.abstract
568+
and base is not new_model
569+
]
570+
if not concrete_bases:
571+
raise ModelDefinitionError(
572+
f"Proxy model {new_model.__name__} has no concrete ormar parent."
573+
)
574+
primary_table = concrete_bases[0].ormar_config.table
575+
for other in concrete_bases[1:]:
576+
if other.ormar_config.table is not primary_table:
577+
raise ModelDefinitionError(
578+
f"Proxy model {new_model.__name__} cannot inherit from multiple "
579+
f"concrete ormar models with different tables."
580+
)
581+
parent_cfg = concrete_bases[0].ormar_config
582+
cfg = new_model.ormar_config
583+
cfg.table = parent_cfg.table
584+
cfg.tablename = parent_cfg.tablename
585+
cfg.pkname = parent_cfg.pkname
586+
cfg.columns = parent_cfg.columns
587+
cfg.model_fields = parent_cfg.model_fields
588+
cfg.metadata = parent_cfg.metadata
589+
cfg.database = parent_cfg.database
590+
cfg.alias_manager = parent_cfg.alias_manager
591+
new_model.pk = PkDescriptor(name=parent_cfg.pkname)
592+
593+
524594
def add_field_descriptor(
525595
name: str, field: "BaseField", new_model: type["Model"]
526596
) -> None:
@@ -653,7 +723,9 @@ def __new__( # type: ignore # noqa: CCR001
653723
register_signals(new_model=new_model)
654724
modify_schema_example(model=new_model)
655725

656-
if not new_model.ormar_config.abstract:
726+
if new_model.ormar_config.proxy:
727+
wire_proxy_from_parent(new_model)
728+
elif not new_model.ormar_config.abstract:
657729
new_model = populate_config_tablename_columns_and_pk(name, new_model)
658730
populate_config_sqlalchemy_table_if_required(new_model.ormar_config)
659731
expand_reverse_relationships(new_model)

ormar/models/model.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,32 @@ async def _execute_query(self, expr: Executable, is_select: bool = False) -> Any
3838
)
3939
return row
4040

41+
async def _emit_signal(self, name: str, **kwargs: Any) -> None:
42+
"""
43+
Emit a lifecycle signal on this model's SignalEmitter.
44+
45+
When ``ormar_config.emit_parent_signals`` is True, the same signal is
46+
also dispatched on every concrete ormar ancestor in the MRO. Each
47+
emit uses ``sender=ancestor_cls`` so handlers registered with
48+
``@pre_save(Parent)`` see the parent class as the sender.
49+
50+
:param name: signal name on the SignalEmitter (e.g. ``"pre_save"``).
51+
:type name: str
52+
:param kwargs: extra payload forwarded to receivers.
53+
:type kwargs: Any
54+
"""
55+
cls = type(self)
56+
await getattr(self.ormar_config.signals, name).send(sender=cls, **kwargs)
57+
if not self.ormar_config.emit_parent_signals:
58+
return
59+
seen = {id(self.ormar_config.signals)}
60+
for ancestor in cls.__mro__[1:]:
61+
cfg = getattr(ancestor, "ormar_config", None)
62+
if cfg is None or cfg.abstract or id(cfg.signals) in seen:
63+
continue
64+
seen.add(id(cfg.signals))
65+
await getattr(cfg.signals, name).send(sender=ancestor, **kwargs)
66+
4167
async def upsert(self: T, **kwargs: Any) -> T:
4268
"""
4369
Performs either a save or an update depending on the presence of the pk.
@@ -85,7 +111,7 @@ async def save(self: T) -> T:
85111
:return: saved Model
86112
:rtype: Model
87113
"""
88-
await self.signals.pre_save.send(sender=self.__class__, instance=self)
114+
await self._emit_signal("pre_save", instance=self)
89115
self_fields = self._extract_model_db_fields()
90116

91117
if (
@@ -138,7 +164,7 @@ async def save(self: T) -> T:
138164
await self.load()
139165

140166
self.__setattr_fields__.clear()
141-
await self.signals.post_save.send(sender=self.__class__, instance=self)
167+
await self._emit_signal("post_save", instance=self)
142168
return self
143169

144170
async def save_related( # noqa: CCR001, CFQ002
@@ -274,9 +300,7 @@ async def update(self: T, _columns: Optional[list[str]] = None, **kwargs: Any) -
274300
"You cannot update not saved model! Use save or upsert method."
275301
)
276302

277-
await self.signals.pre_update.send(
278-
sender=self.__class__, instance=self, passed_args=kwargs
279-
)
303+
await self._emit_signal("pre_update", instance=self, passed_args=kwargs)
280304
self_fields = self._extract_model_db_fields()
281305
self_fields.pop(self.get_column_name_from_alias(self.ormar_config.pkname))
282306
if _columns:
@@ -292,7 +316,7 @@ async def update(self: T, _columns: Optional[list[str]] = None, **kwargs: Any) -
292316
await self._execute_query(expr)
293317
self.set_save_status(True)
294318
self.__setattr_fields__.clear()
295-
await self.signals.post_update.send(sender=self.__class__, instance=self)
319+
await self._emit_signal("post_update", instance=self)
296320
return self
297321

298322
async def delete(self) -> int:
@@ -310,12 +334,12 @@ async def delete(self) -> int:
310334
:return: number of deleted rows (for some backends)
311335
:rtype: int
312336
"""
313-
await self.signals.pre_delete.send(sender=self.__class__, instance=self)
337+
await self._emit_signal("pre_delete", instance=self)
314338
expr = self.ormar_config.table.delete()
315339
expr = expr.where(self.pk_column == (getattr(self, self.ormar_config.pkname)))
316340
result = await self._execute_query(expr)
317341
self.set_save_status(False)
318-
await self.signals.post_delete.send(sender=self.__class__, instance=self)
342+
await self._emit_signal("post_delete", instance=self)
319343
return result
320344

321345
async def load(self: T) -> T:

ormar/models/ormar_config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ class OrmarConfig:
2222
tablename: str
2323
order_by: list[str]
2424
abstract: bool
25+
proxy: bool
26+
emit_parent_signals: bool
2527
exclude_parent_fields: list[str]
2628
constraints: list[ColumnCollectionConstraint]
2729

@@ -33,6 +35,8 @@ def __init__(
3335
tablename: Optional[str] = None,
3436
order_by: Optional[list[str]] = None,
3537
abstract: bool = False,
38+
proxy: bool = False,
39+
emit_parent_signals: bool = False,
3640
queryset_class: type[QuerySet] = QuerySet,
3741
extra: Extra = Extra.forbid,
3842
constraints: Optional[list[ColumnCollectionConstraint]] = None,
@@ -52,6 +56,8 @@ def __init__(
5256
self.property_fields: set = set()
5357
self.signals: SignalEmitter = SignalEmitter()
5458
self.abstract = abstract
59+
self.proxy = proxy
60+
self.emit_parent_signals = emit_parent_signals
5561
self.requires_ref_update: bool = False
5662
self.extra = extra
5763
self.queryset_class = queryset_class
@@ -65,6 +71,8 @@ def copy(
6571
tablename: Optional[str] = None,
6672
order_by: Optional[list[str]] = None,
6773
abstract: Optional[bool] = None,
74+
proxy: Optional[bool] = None,
75+
emit_parent_signals: Optional[bool] = None,
6876
queryset_class: Optional[type[QuerySet]] = None,
6977
extra: Optional[Extra] = None,
7078
constraints: Optional[list[ColumnCollectionConstraint]] = None,
@@ -76,6 +84,12 @@ def copy(
7684
tablename=tablename,
7785
order_by=order_by,
7886
abstract=abstract or self.abstract,
87+
proxy=proxy if proxy is not None else self.proxy,
88+
emit_parent_signals=(
89+
emit_parent_signals
90+
if emit_parent_signals is not None
91+
else self.emit_parent_signals
92+
),
7993
queryset_class=queryset_class or self.queryset_class,
8094
extra=extra or self.extra,
8195
constraints=constraints,

0 commit comments

Comments
 (0)