austial-orm 0.1.5__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {austial_orm-0.1.5 → austial_orm-0.2.1}/.gitignore +4 -0
  2. austial_orm-0.2.1/PKG-INFO +11 -0
  3. {austial_orm-0.1.5 → austial_orm-0.2.1}/pyproject.toml +5 -1
  4. austial_orm-0.2.1/src/austial/orm/__init__.py +110 -0
  5. austial_orm-0.2.1/src/austial/orm/data_source.py +112 -0
  6. austial_orm-0.2.1/src/austial/orm/decorators/__init__.py +27 -0
  7. austial_orm-0.2.1/src/austial/orm/decorators/column.py +107 -0
  8. austial_orm-0.2.1/src/austial/orm/decorators/entity.py +201 -0
  9. austial_orm-0.2.1/src/austial/orm/decorators/inject_repository.py +33 -0
  10. austial_orm-0.2.1/src/austial/orm/decorators/relations.py +179 -0
  11. austial_orm-0.2.1/src/austial/orm/driver/__init__.py +5 -0
  12. austial_orm-0.2.1/src/austial/orm/driver/_sqlalchemy_engine_driver.py +149 -0
  13. austial_orm-0.2.1/src/austial/orm/driver/base.py +75 -0
  14. austial_orm-0.2.1/src/austial/orm/driver/postgres.py +12 -0
  15. austial_orm-0.2.1/src/austial/orm/driver/sqlalchemy_support.py +352 -0
  16. austial_orm-0.2.1/src/austial/orm/driver/sqlite.py +12 -0
  17. austial_orm-0.2.1/src/austial/orm/entity_manager.py +457 -0
  18. austial_orm-0.2.1/src/austial/orm/exceptions.py +67 -0
  19. austial_orm-0.2.1/src/austial/orm/expression.py +143 -0
  20. austial_orm-0.2.1/src/austial/orm/hydration.py +92 -0
  21. austial_orm-0.2.1/src/austial/orm/identifier.py +34 -0
  22. austial_orm-0.2.1/src/austial/orm/metadata/__init__.py +49 -0
  23. austial_orm-0.2.1/src/austial/orm/metadata/column_types.py +123 -0
  24. austial_orm-0.2.1/src/austial/orm/metadata/entity_metadata.py +102 -0
  25. austial_orm-0.2.1/src/austial/orm/metadata/registry.py +42 -0
  26. austial_orm-0.2.1/src/austial/orm/naming_strategy.py +49 -0
  27. austial_orm-0.2.1/src/austial/orm/orm_module.py +105 -0
  28. austial_orm-0.2.1/src/austial/orm/query_builder.py +208 -0
  29. austial_orm-0.2.1/src/austial/orm/repository.py +102 -0
  30. austial_orm-0.2.1/test/aggregate_spec.py +137 -0
  31. austial_orm-0.2.1/test/append_only_spec.py +117 -0
  32. austial_orm-0.2.1/test/di_integration_spec.py +53 -0
  33. austial_orm-0.2.1/test/entity_metadata_spec.py +124 -0
  34. austial_orm-0.2.1/test/fixtures/data_source.py +15 -0
  35. austial_orm-0.2.1/test/fixtures/entities.py +85 -0
  36. austial_orm-0.2.1/test/in_not_in_spec.py +104 -0
  37. austial_orm-0.2.1/test/metadata_export_spec.py +60 -0
  38. austial_orm-0.2.1/test/query_builder_spec.py +90 -0
  39. austial_orm-0.2.1/test/relations_spec.py +99 -0
  40. austial_orm-0.2.1/test/repository_spec.py +148 -0
  41. austial_orm-0.2.1/test/reserved_word_identifiers_spec.py +129 -0
  42. austial_orm-0.2.1/test/transaction_spec.py +55 -0
  43. austial_orm-0.1.5/PKG-INFO +0 -9
  44. austial_orm-0.1.5/src/austial/orm/__init__.py +0 -3
  45. austial_orm-0.1.5/src/austial/orm/database_module.py +0 -61
@@ -9,3 +9,7 @@ htmlcov/
9
9
  dist/
10
10
  build/
11
11
  *.egg-info/
12
+ .DS_Store
13
+
14
+ .claude/settings.local.json
15
+ coverage.xml
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: austial-orm
3
+ Version: 0.2.1
4
+ Summary: A from-scratch, TypeORM-shaped ORM (entities, relations, Repository, QueryBuilder, DataSource) -- mirrors typeorm, with SQLAlchemy Core hidden behind a pluggable Driver as the SQL-compilation/pooling layer.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: aiosqlite>=0.20.0
7
+ Requires-Dist: asyncpg>=0.29.0
8
+ Requires-Dist: austial-common
9
+ Requires-Dist: austial-core
10
+ Requires-Dist: greenlet>=3.1.0
11
+ Requires-Dist: sqlalchemy>=2.0.30
@@ -1,13 +1,17 @@
1
1
  [project]
2
2
  name = "austial-orm"
3
3
  dynamic = ["version"]
4
- description = "DatabaseModule wrapping SQLAlchemy's async engine/session -- mirrors @nestjs/typeorm (no main-monorepo equivalent)."
4
+ description = "A from-scratch, TypeORM-shaped ORM (entities, relations, Repository, QueryBuilder, DataSource) -- mirrors typeorm, with SQLAlchemy Core hidden behind a pluggable Driver as the SQL-compilation/pooling layer."
5
5
  requires-python = ">=3.11"
6
6
  dependencies = [
7
7
  "austial-common",
8
8
  "austial-core",
9
9
  "sqlalchemy>=2.0.30",
10
+ # SQLAlchemy's async engine requires greenlet, but SQLAlchemy only pulls it in on some
11
+ # platforms/Python versions (it does not on 3.14), so declare it explicitly.
12
+ "greenlet>=3.1.0",
10
13
  "aiosqlite>=0.20.0",
14
+ "asyncpg>=0.29.0",
11
15
  ]
12
16
 
13
17
  [build-system]
@@ -0,0 +1,110 @@
1
+ """``austial-orm`` -- a from-scratch, TypeORM-shaped ORM: entities/decorators, relations,
2
+ ``Repository``/``EntityManager``, a fluent ``QueryBuilder``, and DI-native ``OrmModule`` wiring.
3
+ SQLAlchemy Core is used only as a hidden SQL-compilation/pooling seam behind ``Driver`` -- nothing
4
+ in this public API is a SQLAlchemy type, with one deliberate exception:
5
+ ``DataSource.get_metadata()`` returns a real ``sqlalchemy.MetaData`` for Alembic's ``env.py`` to
6
+ use as ``target_metadata``."""
7
+
8
+ from austial.orm.data_source import DataSource
9
+ from austial.orm.decorators import (
10
+ Column,
11
+ CreateDateColumn,
12
+ Entity,
13
+ InjectRepository,
14
+ JoinColumn,
15
+ JoinTable,
16
+ ManyToMany,
17
+ ManyToOne,
18
+ OneToMany,
19
+ OneToOne,
20
+ PrimaryColumn,
21
+ PrimaryGeneratedColumn,
22
+ UpdateDateColumn,
23
+ repository_token,
24
+ )
25
+ from austial.orm.driver import Driver, PostgresDriver, SQLiteDriver
26
+ from austial.orm.entity_manager import DeleteResult, EntityManager, FindOptions, UpdateResult
27
+ from austial.orm.exceptions import (
28
+ AppendOnlyViolationError,
29
+ EntityNotFoundError,
30
+ MissingPrimaryKeyError,
31
+ OrmError,
32
+ QueryFailedError,
33
+ RelationNotFoundError,
34
+ TransactionError,
35
+ )
36
+ from austial.orm.expression import AggregateFn, In, IsNull, NotIn, NotNull
37
+ from austial.orm.metadata.column_types import (
38
+ BigInteger,
39
+ Boolean,
40
+ Date,
41
+ DateTime,
42
+ EnumType,
43
+ Float,
44
+ Integer,
45
+ JSONType,
46
+ Numeric,
47
+ String,
48
+ Text,
49
+ Time,
50
+ UUIDType,
51
+ )
52
+ from austial.orm.naming_strategy import DefaultNamingStrategy, NamingStrategy
53
+ from austial.orm.orm_module import OrmModule
54
+ from austial.orm.query_builder import QueryBuilder
55
+ from austial.orm.repository import Repository
56
+
57
+ __all__ = [
58
+ "AggregateFn",
59
+ "AppendOnlyViolationError",
60
+ "BigInteger",
61
+ "Boolean",
62
+ "Column",
63
+ "CreateDateColumn",
64
+ "DataSource",
65
+ "Date",
66
+ "DateTime",
67
+ "DefaultNamingStrategy",
68
+ "DeleteResult",
69
+ "Driver",
70
+ "Entity",
71
+ "EntityManager",
72
+ "EntityNotFoundError",
73
+ "EnumType",
74
+ "FindOptions",
75
+ "Float",
76
+ "In",
77
+ "IsNull",
78
+ "InjectRepository",
79
+ "Integer",
80
+ "JSONType",
81
+ "JoinColumn",
82
+ "JoinTable",
83
+ "ManyToMany",
84
+ "ManyToOne",
85
+ "MissingPrimaryKeyError",
86
+ "NamingStrategy",
87
+ "NotIn",
88
+ "NotNull",
89
+ "Numeric",
90
+ "OneToMany",
91
+ "OneToOne",
92
+ "OrmError",
93
+ "OrmModule",
94
+ "PostgresDriver",
95
+ "PrimaryColumn",
96
+ "PrimaryGeneratedColumn",
97
+ "QueryBuilder",
98
+ "QueryFailedError",
99
+ "RelationNotFoundError",
100
+ "Repository",
101
+ "SQLiteDriver",
102
+ "String",
103
+ "Text",
104
+ "Time",
105
+ "TransactionError",
106
+ "UUIDType",
107
+ "UpdateDateColumn",
108
+ "UpdateResult",
109
+ "repository_token",
110
+ ]
@@ -0,0 +1,112 @@
1
+ """``DataSource`` -- mirrors TypeORM's ``DataSource`` (formerly ``Connection``): the single
2
+ object that owns one ``Driver`` and one entity ``SchemaCatalog``, and hands out
3
+ ``EntityManager``/``Repository`` instances bound to them.
4
+
5
+ ``austial-core``'s ``module_scanner`` walks ``@Module(imports=[...])`` synchronously and eagerly
6
+ (no async lifecycle hooks exist yet -- see ``orm_module.py``), so constructing a ``DataSource``
7
+ must stay cheap and side-effect-free; the actual DB connection only opens inside
8
+ ``initialize()``, which the app entrypoint must call explicitly::
9
+
10
+ app = await Austial.create(AppModule)
11
+ await app.get(DataSource).initialize()
12
+ await app.listen(8000)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Awaitable, Callable, Sequence
18
+ from typing import TypeVar
19
+
20
+ import sqlalchemy as sa
21
+
22
+ from austial.orm.driver.base import Driver
23
+ from austial.orm.driver.sqlalchemy_support import SchemaCatalog
24
+ from austial.orm.entity_manager import EntityManager
25
+ from austial.orm.naming_strategy import DefaultNamingStrategy, NamingStrategy
26
+ from austial.orm.repository import Repository
27
+
28
+ R = TypeVar("R")
29
+ T = TypeVar("T")
30
+
31
+
32
+ class DataSource:
33
+ def __init__(
34
+ self,
35
+ driver: Driver,
36
+ entities: Sequence[type],
37
+ *,
38
+ naming_strategy: NamingStrategy | None = None,
39
+ synchronize: bool = False,
40
+ ):
41
+ self.driver = driver
42
+ self._entities = list(entities)
43
+ self._naming_strategy = naming_strategy or DefaultNamingStrategy()
44
+ self._synchronize = synchronize
45
+ self._catalog: SchemaCatalog | None = None
46
+ self._manager: EntityManager | None = None
47
+
48
+ async def initialize(self) -> DataSource:
49
+ await self.driver.connect()
50
+
51
+ # Phase-1 pragmatic compromise (see `driver/base.py`'s module docstring): only the
52
+ # SQLAlchemy-backed driver family (`PostgresDriver`/`SQLiteDriver`) exposes
53
+ # `bind_schema()`/needs a `SchemaCatalog` at all -- a hand-rolled future driver with its
54
+ # own schema representation simply wouldn't define it, so this call is best-effort rather
55
+ # than part of the `Driver` ABC surface.
56
+ bind_schema = getattr(self.driver, "bind_schema", None)
57
+ if bind_schema is not None:
58
+ bind_schema(self._entities, self._naming_strategy)
59
+
60
+ self._catalog = SchemaCatalog(self._entities, self._naming_strategy)
61
+ self._manager = EntityManager(self.driver, self._catalog)
62
+
63
+ if self._synchronize:
64
+ # Dev/test convenience only -- issues `CREATE TABLE` for every entity's mapped
65
+ # schema. Not the production path once migrations exist (see plan's forward-compat
66
+ # notes); production deployments should run migrations instead.
67
+ await self.driver.create_all_schemas(self._entities)
68
+
69
+ return self
70
+
71
+ async def destroy(self) -> None:
72
+ await self.driver.close()
73
+
74
+ @property
75
+ def manager(self) -> EntityManager:
76
+ if self._manager is None:
77
+ raise RuntimeError("DataSource.initialize() must be called before use.")
78
+ return self._manager
79
+
80
+ def get_repository(self, entity: type[T]) -> Repository[T]:
81
+ return Repository(entity, self.manager)
82
+
83
+ def get_metadata(self) -> sa.MetaData:
84
+ """Returns the ``sqlalchemy.MetaData`` describing every entity this ``DataSource`` was
85
+ constructed with -- what Alembic's ``env.py`` needs as ``target_metadata``::
86
+
87
+ data_source = DataSource(driver, entities=[...])
88
+ target_metadata = data_source.get_metadata()
89
+
90
+ Building the entity ``SchemaCatalog`` only walks entity/relation metadata (no DB
91
+ connection), so this is safe to call before ``initialize()`` too -- exactly what Alembic
92
+ needs, since its process must not open the app's real connection pool just to import
93
+ ``target_metadata`` at module load time.
94
+
95
+ The one deliberate exception to this package's "nothing outside ``driver/`` imports
96
+ ``sqlalchemy`` itself" rule (see ``driver/sqlalchemy_support.py``'s module docstring):
97
+ Alembic's own API requires a real ``sqlalchemy.MetaData`` instance, so this method is the
98
+ single documented seam where that type crosses the public API boundary.
99
+ """
100
+ if self._catalog is None:
101
+ self._catalog = SchemaCatalog(self._entities, self._naming_strategy)
102
+ return self._catalog.sqla_metadata
103
+
104
+ async def transaction(self, callback: Callable[[EntityManager], Awaitable[R]]) -> R:
105
+ """Opens one connection/transaction via ``Driver.transaction()``, binds a fresh
106
+ ``EntityManager`` to it, and runs ``callback`` -- commits on clean return, rolls back on
107
+ exception (both handled by the driver's transaction context manager)."""
108
+ if self._catalog is None:
109
+ raise RuntimeError("DataSource.initialize() must be called before use.")
110
+ async with self.driver.transaction() as tx_driver:
111
+ tx_manager = EntityManager(tx_driver, self._catalog)
112
+ return await callback(tx_manager)
@@ -0,0 +1,27 @@
1
+ from austial.orm.decorators.column import (
2
+ Column,
3
+ CreateDateColumn,
4
+ PrimaryColumn,
5
+ PrimaryGeneratedColumn,
6
+ UpdateDateColumn,
7
+ )
8
+ from austial.orm.decorators.entity import Entity
9
+ from austial.orm.decorators.inject_repository import InjectRepository, repository_token
10
+ from austial.orm.decorators.relations import JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, OneToOne
11
+
12
+ __all__ = [
13
+ "Column",
14
+ "CreateDateColumn",
15
+ "Entity",
16
+ "InjectRepository",
17
+ "JoinColumn",
18
+ "JoinTable",
19
+ "ManyToMany",
20
+ "ManyToOne",
21
+ "OneToMany",
22
+ "OneToOne",
23
+ "PrimaryColumn",
24
+ "PrimaryGeneratedColumn",
25
+ "UpdateDateColumn",
26
+ "repository_token",
27
+ ]
@@ -0,0 +1,107 @@
1
+ """``Column()``/``PrimaryColumn()``/``PrimaryGeneratedColumn()``/``CreateDateColumn()``/
2
+ ``UpdateDateColumn()`` -- mirrors TypeORM's column decorators.
3
+
4
+ Python has no field-level decorators, so (exactly like ``Inject()`` in ``austial-common``) each
5
+ of these is used as the attribute's *default value* instead::
6
+
7
+ @Entity()
8
+ class Cat:
9
+ id: int = PrimaryGeneratedColumn()
10
+ name: str = Column()
11
+
12
+ ``@Entity()`` (``decorators/entity.py``) is what actually reads these markers back off the class
13
+ and converts them into :class:`~austial.orm.metadata.entity_metadata.ColumnMetadata` plus a real
14
+ dataclass field default -- a bare ``ColumnMarker`` never survives past class-decoration time.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from austial.orm.metadata.column_types import ColumnType
22
+
23
+
24
+ class ColumnMarker:
25
+ __slots__ = (
26
+ "column_name",
27
+ "create_date",
28
+ "default",
29
+ "generated",
30
+ "nullable",
31
+ "primary",
32
+ "type_",
33
+ "unique",
34
+ "index",
35
+ "update_date",
36
+ )
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ name: str | None = None,
42
+ type_: ColumnType | None = None,
43
+ primary: bool = False,
44
+ generated: bool = False,
45
+ nullable: bool = False,
46
+ unique: bool = False,
47
+ index: bool = False,
48
+ default: Any = None,
49
+ create_date: bool = False,
50
+ update_date: bool = False,
51
+ ):
52
+ self.column_name = name
53
+ self.type_ = type_
54
+ self.primary = primary
55
+ self.generated = generated
56
+ self.nullable = nullable
57
+ self.unique = unique
58
+ self.index = index
59
+ self.default = default
60
+ self.create_date = create_date
61
+ self.update_date = update_date
62
+
63
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
64
+ return f"ColumnMarker(name={self.column_name!r}, primary={self.primary!r})"
65
+
66
+
67
+ def Column(
68
+ *,
69
+ name: str | None = None,
70
+ type_: ColumnType | None = None,
71
+ nullable: bool = False,
72
+ unique: bool = False,
73
+ index: bool = False,
74
+ default: Any = None,
75
+ ) -> Any:
76
+ """A regular mapped column. ``type_`` is inferred from the attribute's Python type
77
+ annotation unless given explicitly (mirrors TypeORM inferring from the TS type)."""
78
+ return ColumnMarker(name=name, type_=type_, nullable=nullable, unique=unique, index=index, default=default)
79
+
80
+
81
+ def PrimaryColumn(*, name: str | None = None, type_: ColumnType | None = None, default: Any = None) -> Any:
82
+ """A primary-key column whose value the application assigns (not DB-generated)."""
83
+ return ColumnMarker(name=name, type_=type_, primary=True, generated=False, nullable=False, default=default)
84
+
85
+
86
+ def PrimaryGeneratedColumn(*, name: str | None = None, default: Any = None) -> Any:
87
+ """An auto-incrementing integer primary key -- the common case, TypeORM's
88
+ ``@PrimaryGeneratedColumn()``. ``default`` stays ``None`` in virtually every real usage
89
+ (the DB assigns the value on insert) -- exposed only so a bare ``id: int =
90
+ PrimaryGeneratedColumn()`` still gets an unset-until-persisted attribute."""
91
+ from austial.orm.metadata.column_types import Integer
92
+
93
+ return ColumnMarker(name=name, type_=Integer(), primary=True, generated=True, nullable=False, default=default)
94
+
95
+
96
+ def CreateDateColumn(*, name: str | None = None, default: Any = None) -> Any:
97
+ """Stamped with the current UTC time by ``EntityManager.save()`` on first insert."""
98
+ from austial.orm.metadata.column_types import DateTime
99
+
100
+ return ColumnMarker(name=name, type_=DateTime(timezone=True), nullable=True, create_date=True, default=default)
101
+
102
+
103
+ def UpdateDateColumn(*, name: str | None = None, default: Any = None) -> Any:
104
+ """Stamped with the current UTC time by ``EntityManager.save()`` on every insert/update."""
105
+ from austial.orm.metadata.column_types import DateTime
106
+
107
+ return ColumnMarker(name=name, type_=DateTime(timezone=True), nullable=True, update_date=True, default=default)
@@ -0,0 +1,201 @@
1
+ """``@Entity()`` -- mirrors TypeORM's ``@Entity()`` class decorator.
2
+
3
+ Reads back the ``ColumnMarker``/``RelationMarker`` default values declared via ``Column()``,
4
+ ``PrimaryGeneratedColumn()``, ``ManyToOne()``, etc. (see ``decorators/column.py``/``relations.py``),
5
+ builds an :class:`~austial.orm.metadata.entity_metadata.EntityMetadata`, registers it in
6
+ :class:`~austial.orm.metadata.registry.MetadataRegistry` *and* on the class itself via the
7
+ framework's own ``set_metadata`` mechanism, and turns the class into a plain ``@dataclass`` so
8
+ instances behave like ordinary mutable Python objects (``Cat(name="Tom")``, attribute access/
9
+ assignment, ``repr``/``eq``) -- no active-record magic, no lazy proxies, no dirty-checking.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import sys
16
+ import typing
17
+ from collections.abc import Callable
18
+ from typing import Any, TypeVar
19
+
20
+ from austial.core.metadata import set_metadata
21
+ from austial.orm.decorators.column import ColumnMarker
22
+ from austial.orm.decorators.relations import RelationMarker, target_resolver
23
+ from austial.orm.metadata.column_types import infer_column_type
24
+ from austial.orm.metadata.entity_metadata import ColumnMetadata, EntityMetadata, RelationMetadata
25
+ from austial.orm.metadata.registry import MetadataRegistry
26
+ from austial.orm.naming_strategy import DefaultNamingStrategy, NamingStrategy
27
+
28
+ ENTITY_METADATA = "austial:orm:entity"
29
+
30
+ T = TypeVar("T")
31
+
32
+ _default_naming_strategy = DefaultNamingStrategy()
33
+
34
+
35
+ def _resolve_annotations(cls: type) -> dict[str, Any]:
36
+ """Best-effort, per-attribute annotation resolution -- unlike ``typing.get_type_hints(cls)``,
37
+ one unresolvable forward reference doesn't abort resolution of every other attribute.
38
+
39
+ This matters because a relation's target is routinely a sibling entity declared *later* in
40
+ the same module (``Owner.cats: list["Cat"] = OneToMany(lambda: Cat, ...)``, with ``Cat``
41
+ defined afterwards -- exactly the pattern this framework's own docs use). ``RelationMarker``
42
+ already carries its target as a lazy callable (see ``decorators/relations.py``'s
43
+ ``target_resolver``), never the annotation, so a still-unresolved forward reference on a
44
+ relation attribute is harmless -- only ``Column()``-marked attributes actually consume the
45
+ resolved type (to infer a ``ColumnType`` when none is given explicitly), and those are always
46
+ already-defined types (``str``, ``int``, ``datetime.datetime``, ...), never forward refs.
47
+ """
48
+ module_globals = vars(sys.modules[cls.__module__])
49
+ class_locals = dict(vars(cls))
50
+ resolved: dict[str, Any] = {}
51
+ for attr_name, raw in getattr(cls, "__annotations__", {}).items():
52
+ forward = typing.ForwardRef(raw) if isinstance(raw, str) else raw
53
+ try:
54
+ resolved[attr_name] = typing._eval_type(forward, module_globals, class_locals) # type: ignore[attr-defined]
55
+ except NameError:
56
+ resolved[attr_name] = raw
57
+ return resolved
58
+
59
+
60
+ def _inherited_markers(cls: type) -> dict[str, tuple[Any, Any]]:
61
+ """``{attribute: (python_type, marker)}`` for ``Column()``/relation markers declared on plain
62
+ (non-entity) base classes -- how a mixin such as ``austial.tenancy.Tenanted`` contributes columns.
63
+
64
+ Walks the MRO base-first so a nearer base overrides a farther one; an attribute the entity class
65
+ declares itself always wins (handled by the caller). Bases that are themselves ``@Entity`` classes
66
+ hold dataclass fields rather than markers, so they contribute nothing here (entities do not inherit
67
+ mapped columns from one another).
68
+ """
69
+ inherited: dict[str, tuple[Any, Any]] = {}
70
+ for base in reversed(cls.__mro__[1:]):
71
+ if base is object:
72
+ continue
73
+ base_types = _resolve_annotations(base)
74
+ for attr_name, marker in vars(base).items():
75
+ if attr_name.startswith("_") or attr_name not in base_types:
76
+ continue
77
+ if isinstance(marker, ColumnMarker | RelationMarker):
78
+ inherited[attr_name] = (base_types[attr_name], marker)
79
+ return inherited
80
+
81
+
82
+ @typing.dataclass_transform()
83
+ def Entity(
84
+ name: str | None = None,
85
+ *,
86
+ naming_strategy: NamingStrategy | None = None,
87
+ append_only: bool = False,
88
+ ) -> Callable[[type[T]], type[T]]:
89
+ """Class decorator. Usable as ``@Entity()``, ``@Entity("custom_table_name")``, or
90
+ ``@Entity(append_only=True)`` (composes with the positional ``name``, e.g.
91
+ ``@Entity("ledger_entry", append_only=True)``).
92
+
93
+ ``naming_strategy`` only affects *this* entity's default table/column names when the app's
94
+ ``DataSource`` doesn't otherwise override them; the vast majority of callers never need it.
95
+
96
+ ``append_only=True`` marks the entity as insert-only: ``EntityManager`` (and therefore every
97
+ ``Repository`` method that funnels through it) rejects UPDATE/remove/delete against it,
98
+ raising :class:`~austial.orm.exceptions.AppendOnlyViolationError` -- see that class's
99
+ docstring for what this guard does and does not cover.
100
+ """
101
+ strategy = naming_strategy or _default_naming_strategy
102
+
103
+ def decorator(cls: type[T]) -> type[T]:
104
+ hints = _resolve_annotations(cls)
105
+ columns: list[ColumnMetadata] = []
106
+ relations: list[RelationMetadata] = []
107
+ dataclass_fields: dict[str, Any] = {}
108
+
109
+ # Mapped attributes contributed by mixin bases (own declarations take precedence). They are
110
+ # appended after the class's own attributes and added to ``__annotations__`` so the
111
+ # ``@dataclass`` step below makes them constructor fields too.
112
+ inherited = {name: found for name, found in _inherited_markers(cls).items() if name not in hints}
113
+ for attr_name, (inherited_type, _) in inherited.items():
114
+ hints[attr_name] = inherited_type
115
+ if inherited:
116
+ cls.__annotations__ = {**getattr(cls, "__annotations__", {}), **{n: t for n, (t, _) in inherited.items()}}
117
+
118
+ for attr_name, python_type in hints.items():
119
+ if attr_name.startswith("_"):
120
+ continue
121
+ marker = cls.__dict__.get(attr_name)
122
+ if attr_name in inherited:
123
+ marker = inherited[attr_name][1]
124
+
125
+ if isinstance(marker, ColumnMarker):
126
+ column_type = marker.type_ or infer_column_type(python_type)
127
+ columns.append(
128
+ ColumnMetadata(
129
+ attribute_name=attr_name,
130
+ column_name=strategy.column_name(attr_name, marker.column_name),
131
+ type_=column_type,
132
+ python_type=python_type,
133
+ primary=marker.primary,
134
+ generated=marker.generated,
135
+ nullable=marker.nullable,
136
+ unique=marker.unique,
137
+ index=marker.index,
138
+ default=marker.default,
139
+ create_date=marker.create_date,
140
+ update_date=marker.update_date,
141
+ )
142
+ )
143
+ dataclass_fields[attr_name] = dataclasses.field(default=marker.default)
144
+
145
+ elif isinstance(marker, RelationMarker):
146
+ is_to_many = marker.relation_type.name in ("ONE_TO_MANY", "MANY_TO_MANY")
147
+ relations.append(
148
+ RelationMetadata(
149
+ attribute_name=attr_name,
150
+ relation_type=marker.relation_type,
151
+ target_resolver=target_resolver(marker.target),
152
+ inverse_side=marker.inverse_side,
153
+ nullable=marker.nullable,
154
+ on_delete=marker.on_delete,
155
+ cascade=marker.cascade,
156
+ join_column=marker.join_column.to_metadata() if marker.join_column else None,
157
+ join_table=marker.join_table.to_metadata() if marker.join_table else None,
158
+ )
159
+ )
160
+ dataclass_fields[attr_name] = (
161
+ dataclasses.field(default_factory=marker.default_factory or list)
162
+ if is_to_many
163
+ else dataclasses.field(default=marker.default)
164
+ )
165
+ elif attr_name not in dataclass_fields:
166
+ # A plain, un-marked annotated attribute -- not part of the mapped/persisted
167
+ # schema (matches TypeORM: only decorated properties are columns/relations), but
168
+ # still needs *some* default so it can never violate dataclass field-ordering
169
+ # ("non-default argument follows default argument") relative to the mapped
170
+ # fields above it, since every mapped field always carries an explicit default.
171
+ existing = cls.__dict__.get(attr_name, None)
172
+ dataclass_fields[attr_name] = existing if existing is not None else dataclasses.field(default=None)
173
+
174
+ for attr_name, field_spec in dataclass_fields.items():
175
+ setattr(cls, attr_name, field_spec)
176
+
177
+ entity_cls = dataclasses.dataclass(eq=False, repr=True)(cls)
178
+
179
+ table_name = strategy.table_name(entity_cls.__name__, name)
180
+ metadata = EntityMetadata(
181
+ entity_cls=entity_cls,
182
+ table_name=table_name,
183
+ columns=columns,
184
+ relations=relations,
185
+ append_only=append_only,
186
+ )
187
+
188
+ set_metadata(ENTITY_METADATA, metadata)(entity_cls)
189
+ MetadataRegistry.register(entity_cls, metadata)
190
+ return entity_cls
191
+
192
+ return decorator
193
+
194
+
195
+ def get_entity_metadata(entity_cls: type) -> EntityMetadata:
196
+ from austial.orm.exceptions import OrmError
197
+
198
+ metadata = MetadataRegistry.get(entity_cls)
199
+ if metadata is None:
200
+ raise OrmError(f"{entity_cls!r} is not decorated with @Entity()")
201
+ return metadata
@@ -0,0 +1,33 @@
1
+ """``@InjectRepository(Entity)`` -- mirrors ``@nestjs/typeorm``'s ``@InjectRepository()``.
2
+
3
+ Reuses ``austial.common``'s ``InjectMarker`` directly rather than inventing a parallel marker
4
+ type, so the DI container's existing ``isinstance(default, InjectMarker)`` branch
5
+ (``packages/core/src/austial/core/container.py``) resolves it with zero core-package changes::
6
+
7
+ @Injectable()
8
+ class CatsService:
9
+ def __init__(self, cats: Repository[Cat] = InjectRepository(Cat)):
10
+ self._cats = cats
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from austial.common.decorators.injectable import InjectMarker
18
+
19
+
20
+ def repository_token(entity: type) -> str:
21
+ """The DI token a given entity's repository is registered/injected under. Computed the same
22
+ way in ``orm_module.py`` (``OrmModule.for_feature``) and here, so both sides always agree."""
23
+ return f"austial:orm:repository:{entity.__module__}.{entity.__qualname__}"
24
+
25
+
26
+ def InjectRepository(entity: type) -> Any: # noqa: N802 - decorator-style naming
27
+ """Returns an ``InjectMarker`` at runtime (see ``Container._resolve_constructor_args``), but is
28
+ typed ``Any`` -- like every other param-marker helper (``Body()``, ``Inject()``, ...) -- so it
29
+ can stand as the default for a parameter annotated ``Repository[Cat]`` without mypy flagging an
30
+ ``InjectMarker``/``Repository[Cat]`` mismatch. The annotation is what callers actually get; this
31
+ marker is swapped out for the real repository before the constructor ever runs.
32
+ """
33
+ return InjectMarker(repository_token(entity))