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.
- {austial_orm-0.1.5 → austial_orm-0.2.1}/.gitignore +4 -0
- austial_orm-0.2.1/PKG-INFO +11 -0
- {austial_orm-0.1.5 → austial_orm-0.2.1}/pyproject.toml +5 -1
- austial_orm-0.2.1/src/austial/orm/__init__.py +110 -0
- austial_orm-0.2.1/src/austial/orm/data_source.py +112 -0
- austial_orm-0.2.1/src/austial/orm/decorators/__init__.py +27 -0
- austial_orm-0.2.1/src/austial/orm/decorators/column.py +107 -0
- austial_orm-0.2.1/src/austial/orm/decorators/entity.py +201 -0
- austial_orm-0.2.1/src/austial/orm/decorators/inject_repository.py +33 -0
- austial_orm-0.2.1/src/austial/orm/decorators/relations.py +179 -0
- austial_orm-0.2.1/src/austial/orm/driver/__init__.py +5 -0
- austial_orm-0.2.1/src/austial/orm/driver/_sqlalchemy_engine_driver.py +149 -0
- austial_orm-0.2.1/src/austial/orm/driver/base.py +75 -0
- austial_orm-0.2.1/src/austial/orm/driver/postgres.py +12 -0
- austial_orm-0.2.1/src/austial/orm/driver/sqlalchemy_support.py +352 -0
- austial_orm-0.2.1/src/austial/orm/driver/sqlite.py +12 -0
- austial_orm-0.2.1/src/austial/orm/entity_manager.py +457 -0
- austial_orm-0.2.1/src/austial/orm/exceptions.py +67 -0
- austial_orm-0.2.1/src/austial/orm/expression.py +143 -0
- austial_orm-0.2.1/src/austial/orm/hydration.py +92 -0
- austial_orm-0.2.1/src/austial/orm/identifier.py +34 -0
- austial_orm-0.2.1/src/austial/orm/metadata/__init__.py +49 -0
- austial_orm-0.2.1/src/austial/orm/metadata/column_types.py +123 -0
- austial_orm-0.2.1/src/austial/orm/metadata/entity_metadata.py +102 -0
- austial_orm-0.2.1/src/austial/orm/metadata/registry.py +42 -0
- austial_orm-0.2.1/src/austial/orm/naming_strategy.py +49 -0
- austial_orm-0.2.1/src/austial/orm/orm_module.py +105 -0
- austial_orm-0.2.1/src/austial/orm/query_builder.py +208 -0
- austial_orm-0.2.1/src/austial/orm/repository.py +102 -0
- austial_orm-0.2.1/test/aggregate_spec.py +137 -0
- austial_orm-0.2.1/test/append_only_spec.py +117 -0
- austial_orm-0.2.1/test/di_integration_spec.py +53 -0
- austial_orm-0.2.1/test/entity_metadata_spec.py +124 -0
- austial_orm-0.2.1/test/fixtures/data_source.py +15 -0
- austial_orm-0.2.1/test/fixtures/entities.py +85 -0
- austial_orm-0.2.1/test/in_not_in_spec.py +104 -0
- austial_orm-0.2.1/test/metadata_export_spec.py +60 -0
- austial_orm-0.2.1/test/query_builder_spec.py +90 -0
- austial_orm-0.2.1/test/relations_spec.py +99 -0
- austial_orm-0.2.1/test/repository_spec.py +148 -0
- austial_orm-0.2.1/test/reserved_word_identifiers_spec.py +129 -0
- austial_orm-0.2.1/test/transaction_spec.py +55 -0
- austial_orm-0.1.5/PKG-INFO +0 -9
- austial_orm-0.1.5/src/austial/orm/__init__.py +0 -3
- austial_orm-0.1.5/src/austial/orm/database_module.py +0 -61
|
@@ -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 = "
|
|
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))
|