ormate 0.1.0__py3-none-any.whl
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.
- ormate/__init__.py +12 -0
- ormate/adapters/__init__.py +5 -0
- ormate/adapters/base.py +35 -0
- ormate/adapters/elasticsearch.py +133 -0
- ormate/adapters/sqlalchemy.py +147 -0
- ormate/adapters/sqlmodel.py +6 -0
- ormate/elasticsearch/__init__.py +12 -0
- ormate/elasticsearch/document.py +20 -0
- ormate/protocols.py +17 -0
- ormate/py.typed +1 -0
- ormate/repository.py +142 -0
- ormate/sqlalchemy/__init__.py +16 -0
- ormate/sqlalchemy/database.py +174 -0
- ormate/sqlalchemy/sessions.py +90 -0
- ormate/web/__init__.py +8 -0
- ormate/web/middleware.py +43 -0
- ormate-0.1.0.dist-info/METADATA +279 -0
- ormate-0.1.0.dist-info/RECORD +20 -0
- ormate-0.1.0.dist-info/WHEEL +4 -0
- ormate-0.1.0.dist-info/licenses/LICENSE +22 -0
ormate/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .adapters import SQLAlchemyAdapter, SQLModelAdapter, StorageAdapter
|
|
2
|
+
from .repository import ModelRepository
|
|
3
|
+
from .sqlalchemy.database import AsyncDatabase, Database
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AsyncDatabase",
|
|
7
|
+
"Database",
|
|
8
|
+
"ModelRepository",
|
|
9
|
+
"SQLAlchemyAdapter",
|
|
10
|
+
"SQLModelAdapter",
|
|
11
|
+
"StorageAdapter",
|
|
12
|
+
]
|
ormate/adapters/base.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from typing import Any, Protocol, runtime_checkable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@runtime_checkable
|
|
6
|
+
class StorageAdapter(Protocol):
|
|
7
|
+
"""Backend contract used by ModelRepository."""
|
|
8
|
+
|
|
9
|
+
def storage_name(self, model: type[Any]) -> str: ...
|
|
10
|
+
|
|
11
|
+
async def create(self, model: type[Any], items: Sequence[Mapping[str, Any]]) -> list[Any]: ...
|
|
12
|
+
|
|
13
|
+
async def read(
|
|
14
|
+
self,
|
|
15
|
+
model: type[Any],
|
|
16
|
+
query: Any = None,
|
|
17
|
+
*,
|
|
18
|
+
limit: int | None = None,
|
|
19
|
+
offset: int | None = None,
|
|
20
|
+
statement: Any = None,
|
|
21
|
+
) -> list[Any]: ...
|
|
22
|
+
|
|
23
|
+
async def update(self, model: type[Any], query: Any, values: Mapping[str, Any]) -> list[Any]: ...
|
|
24
|
+
|
|
25
|
+
async def delete(self, model: type[Any], query: Any) -> list[Any]: ...
|
|
26
|
+
|
|
27
|
+
async def count(self, model: type[Any], query: Any = None, *, statement: Any = None) -> int: ...
|
|
28
|
+
|
|
29
|
+
async def exists(self, model: type[Any], query: Any = None) -> bool: ...
|
|
30
|
+
|
|
31
|
+
def primary_key_query(self, model: type[Any], primary_key: Any) -> Any: ...
|
|
32
|
+
|
|
33
|
+
def primary_keys_query(self, model: type[Any], primary_keys: Sequence[Any]) -> Any: ...
|
|
34
|
+
|
|
35
|
+
async def execute(self, statement: Any, params: Any = None, **kwargs: Any) -> Any: ...
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ElasticsearchAdapter:
|
|
6
|
+
"""Async Elasticsearch adapter using native Query DSL dictionaries."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, client: Any, *, refresh: bool | str = False, default_size: int = 1000) -> None:
|
|
9
|
+
if default_size < 1:
|
|
10
|
+
raise ValueError("default_size must be greater than zero")
|
|
11
|
+
self.client = client
|
|
12
|
+
self.refresh = refresh
|
|
13
|
+
self.default_size = default_size
|
|
14
|
+
|
|
15
|
+
def storage_name(self, model: type[Any]) -> str:
|
|
16
|
+
index_name = getattr(model, "index_name", None)
|
|
17
|
+
if not isinstance(index_name, str) or not index_name:
|
|
18
|
+
raise TypeError("Elasticsearch document model must define a non-empty index_name")
|
|
19
|
+
return index_name
|
|
20
|
+
|
|
21
|
+
def _document(self, model: type[Any], values: Mapping[str, Any]) -> Any:
|
|
22
|
+
validator = getattr(model, "model_validate", None)
|
|
23
|
+
if not callable(validator):
|
|
24
|
+
raise TypeError("Elasticsearch document model must provide model_validate()")
|
|
25
|
+
return validator(values)
|
|
26
|
+
|
|
27
|
+
def _source(self, document: Any) -> dict[str, Any]:
|
|
28
|
+
serializer = getattr(document, "document_source", None)
|
|
29
|
+
if not callable(serializer):
|
|
30
|
+
raise TypeError("Elasticsearch document must provide document_source()")
|
|
31
|
+
return dict(serializer())
|
|
32
|
+
|
|
33
|
+
def _from_hit(self, model: type[Any], hit: Mapping[str, Any]) -> Any:
|
|
34
|
+
factory = getattr(model, "from_hit", None)
|
|
35
|
+
if not callable(factory):
|
|
36
|
+
raise TypeError("Elasticsearch document model must provide from_hit()")
|
|
37
|
+
return factory(dict(hit))
|
|
38
|
+
|
|
39
|
+
def primary_key_query(self, model: type[Any], primary_key: Any) -> dict[str, Any]:
|
|
40
|
+
return {"ids": {"values": [str(primary_key)]}}
|
|
41
|
+
|
|
42
|
+
def primary_keys_query(self, model: type[Any], primary_keys: Sequence[Any]) -> dict[str, Any]:
|
|
43
|
+
return {"ids": {"values": [str(primary_key) for primary_key in primary_keys]}}
|
|
44
|
+
|
|
45
|
+
async def create(self, model: type[Any], items: Sequence[Mapping[str, Any]]) -> list[Any]:
|
|
46
|
+
index_name = self.storage_name(model)
|
|
47
|
+
results = []
|
|
48
|
+
for values in items:
|
|
49
|
+
document = self._document(model, values)
|
|
50
|
+
response = await self.client.index(
|
|
51
|
+
index=index_name,
|
|
52
|
+
id=getattr(document, "id", None),
|
|
53
|
+
document=self._source(document),
|
|
54
|
+
refresh=self.refresh,
|
|
55
|
+
)
|
|
56
|
+
result_values = {"id": response.get("_id"), **self._source(document)}
|
|
57
|
+
results.append(self._document(model, result_values))
|
|
58
|
+
return results
|
|
59
|
+
|
|
60
|
+
async def read(
|
|
61
|
+
self,
|
|
62
|
+
model: type[Any],
|
|
63
|
+
query: Any = None,
|
|
64
|
+
*,
|
|
65
|
+
limit: int | None = None,
|
|
66
|
+
offset: int | None = None,
|
|
67
|
+
statement: Any = None,
|
|
68
|
+
) -> list[Any]:
|
|
69
|
+
if statement is not None and not isinstance(statement, Mapping):
|
|
70
|
+
raise TypeError("Elasticsearch statement must be a search keyword mapping")
|
|
71
|
+
search_options = dict(statement or {})
|
|
72
|
+
search_options.setdefault("query", query or {"match_all": {}})
|
|
73
|
+
search_options.setdefault("from_", offset or 0)
|
|
74
|
+
search_options.setdefault("size", limit if limit is not None else self.default_size)
|
|
75
|
+
response = await self.client.search(index=self.storage_name(model), **search_options)
|
|
76
|
+
return [self._from_hit(model, hit) for hit in response["hits"]["hits"]]
|
|
77
|
+
|
|
78
|
+
async def update(self, model: type[Any], query: Any, values: Mapping[str, Any]) -> list[Any]:
|
|
79
|
+
documents = await self.read(model, query)
|
|
80
|
+
index_name = self.storage_name(model)
|
|
81
|
+
results = []
|
|
82
|
+
for document in documents:
|
|
83
|
+
document_id = getattr(document, "id", None)
|
|
84
|
+
if document_id is None:
|
|
85
|
+
continue
|
|
86
|
+
await self.client.update(
|
|
87
|
+
index=index_name,
|
|
88
|
+
id=document_id,
|
|
89
|
+
doc=dict(values),
|
|
90
|
+
refresh=self.refresh,
|
|
91
|
+
)
|
|
92
|
+
merged = {"id": document_id, **self._source(document), **values}
|
|
93
|
+
results.append(self._document(model, merged))
|
|
94
|
+
return results
|
|
95
|
+
|
|
96
|
+
async def delete(self, model: type[Any], query: Any) -> list[Any]:
|
|
97
|
+
documents = await self.read(model, query)
|
|
98
|
+
index_name = self.storage_name(model)
|
|
99
|
+
for document in documents:
|
|
100
|
+
document_id = getattr(document, "id", None)
|
|
101
|
+
if document_id is not None:
|
|
102
|
+
await self.client.delete(index=index_name, id=document_id, refresh=self.refresh)
|
|
103
|
+
return documents
|
|
104
|
+
|
|
105
|
+
async def count(self, model: type[Any], query: Any = None, *, statement: Any = None) -> int:
|
|
106
|
+
if statement is not None:
|
|
107
|
+
raise TypeError("Elasticsearch count does not accept a statement; pass Query DSL through query")
|
|
108
|
+
response = await self.client.count(index=self.storage_name(model), query=query or {"match_all": {}})
|
|
109
|
+
return int(response["count"])
|
|
110
|
+
|
|
111
|
+
async def exists(self, model: type[Any], query: Any = None) -> bool:
|
|
112
|
+
return await self.count(model, query) > 0
|
|
113
|
+
|
|
114
|
+
async def execute(self, statement: Any, params: Any = None, **kwargs: Any) -> Any:
|
|
115
|
+
if not isinstance(statement, str):
|
|
116
|
+
raise TypeError("Elasticsearch native execute statement must be a client method name")
|
|
117
|
+
method = getattr(self.client, statement, None)
|
|
118
|
+
if not callable(method):
|
|
119
|
+
raise ValueError(f"Unknown Elasticsearch client method: {statement}")
|
|
120
|
+
call_options = dict(params or {})
|
|
121
|
+
call_options.update(kwargs)
|
|
122
|
+
return await method(**call_options)
|
|
123
|
+
|
|
124
|
+
async def search(self, model: type[Any], query: Mapping[str, Any], **kwargs: Any) -> Any:
|
|
125
|
+
return await self.client.search(index=self.storage_name(model), query=query, **kwargs)
|
|
126
|
+
|
|
127
|
+
async def aggregate(self, model: type[Any], aggregations: Mapping[str, Any], query: Any = None) -> Any:
|
|
128
|
+
return await self.client.search(
|
|
129
|
+
index=self.storage_name(model),
|
|
130
|
+
query=query or {"match_all": {}},
|
|
131
|
+
aggregations=aggregations,
|
|
132
|
+
size=0,
|
|
133
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import Select, and_, false, func, inspect, or_, select
|
|
5
|
+
from sqlalchemy.engine import Result
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
from sqlalchemy.sql import Executable
|
|
8
|
+
|
|
9
|
+
from ormate.sqlalchemy.database import AsyncDatabase, DatabaseLike, EngineLike, ensure_database
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SQLAlchemyAdapter:
|
|
13
|
+
"""Storage adapter for SQLAlchemy Declarative and SQLModel table models."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, database: EngineLike) -> None:
|
|
16
|
+
self.database: DatabaseLike = ensure_database(database)
|
|
17
|
+
|
|
18
|
+
def select(self, model: type[Any]) -> Select[tuple[Any]]:
|
|
19
|
+
return select(model)
|
|
20
|
+
|
|
21
|
+
def _mapper(self, model: type[Any]) -> Any:
|
|
22
|
+
mapper = inspect(model)
|
|
23
|
+
if mapper is None:
|
|
24
|
+
raise TypeError(f"{model!r} is not a mapped SQLAlchemy model")
|
|
25
|
+
return mapper
|
|
26
|
+
|
|
27
|
+
def storage_name(self, model: type[Any]) -> str:
|
|
28
|
+
return str(self._mapper(model).local_table.name)
|
|
29
|
+
|
|
30
|
+
def primary_key_query(self, model: type[Any], primary_key: Any) -> Any:
|
|
31
|
+
primary_keys = tuple(self._mapper(model).primary_key)
|
|
32
|
+
if len(primary_keys) == 1:
|
|
33
|
+
values = (primary_key,)
|
|
34
|
+
elif isinstance(primary_key, Mapping):
|
|
35
|
+
try:
|
|
36
|
+
values = tuple(primary_key[column.key] for column in primary_keys)
|
|
37
|
+
except KeyError as exc:
|
|
38
|
+
raise ValueError(f"Missing composite primary-key field: {exc.args[0]}") from exc
|
|
39
|
+
elif isinstance(primary_key, tuple) and len(primary_key) == len(primary_keys):
|
|
40
|
+
values = primary_key
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError("Composite primary keys require a mapping or a tuple with one value per key")
|
|
43
|
+
return and_(*(column == value for column, value in zip(primary_keys, values, strict=True)))
|
|
44
|
+
|
|
45
|
+
def primary_keys_query(self, model: type[Any], primary_keys: Sequence[Any]) -> Any:
|
|
46
|
+
if not primary_keys:
|
|
47
|
+
return false()
|
|
48
|
+
mapped_keys = tuple(self._mapper(model).primary_key)
|
|
49
|
+
if len(mapped_keys) == 1:
|
|
50
|
+
return mapped_keys[0].in_(primary_keys)
|
|
51
|
+
return or_(*(self.primary_key_query(model, primary_key) for primary_key in primary_keys))
|
|
52
|
+
|
|
53
|
+
async def create(self, model: type[Any], items: Sequence[Mapping[str, Any]]) -> list[Any]:
|
|
54
|
+
def operation(session: Session) -> list[Any]:
|
|
55
|
+
objects = [model(**values) for values in items]
|
|
56
|
+
session.add_all(objects)
|
|
57
|
+
session.flush()
|
|
58
|
+
for obj in objects:
|
|
59
|
+
session.refresh(obj)
|
|
60
|
+
return objects
|
|
61
|
+
|
|
62
|
+
return await self.database.async_run(operation)
|
|
63
|
+
|
|
64
|
+
async def read(
|
|
65
|
+
self,
|
|
66
|
+
model: type[Any],
|
|
67
|
+
query: Any = None,
|
|
68
|
+
*,
|
|
69
|
+
limit: int | None = None,
|
|
70
|
+
offset: int | None = None,
|
|
71
|
+
statement: Select[Any] | None = None,
|
|
72
|
+
) -> list[Any]:
|
|
73
|
+
stmt = statement if statement is not None else self.select(model)
|
|
74
|
+
if query is not None:
|
|
75
|
+
stmt = stmt.where(query)
|
|
76
|
+
if offset is not None:
|
|
77
|
+
stmt = stmt.offset(offset)
|
|
78
|
+
if limit is not None:
|
|
79
|
+
stmt = stmt.limit(limit)
|
|
80
|
+
|
|
81
|
+
def operation(session: Session) -> list[Any]:
|
|
82
|
+
return list(session.scalars(stmt).all())
|
|
83
|
+
|
|
84
|
+
return await self.database.async_run(operation)
|
|
85
|
+
|
|
86
|
+
async def update(self, model: type[Any], query: Any, values: Mapping[str, Any]) -> list[Any]:
|
|
87
|
+
mapper = self._mapper(model)
|
|
88
|
+
mapped_fields = {attribute.key for attribute in mapper.column_attrs}
|
|
89
|
+
|
|
90
|
+
def operation(session: Session) -> list[Any]:
|
|
91
|
+
objects = list(session.scalars(self.select(model).where(query)).all())
|
|
92
|
+
for obj in objects:
|
|
93
|
+
for key, value in values.items():
|
|
94
|
+
if key in mapped_fields:
|
|
95
|
+
setattr(obj, key, value)
|
|
96
|
+
session.flush()
|
|
97
|
+
for obj in objects:
|
|
98
|
+
session.refresh(obj)
|
|
99
|
+
return objects
|
|
100
|
+
|
|
101
|
+
return await self.database.async_run(operation)
|
|
102
|
+
|
|
103
|
+
async def delete(self, model: type[Any], query: Any) -> list[Any]:
|
|
104
|
+
def operation(session: Session) -> list[Any]:
|
|
105
|
+
objects = list(session.scalars(self.select(model).where(query)).all())
|
|
106
|
+
for obj in objects:
|
|
107
|
+
session.delete(obj)
|
|
108
|
+
session.flush()
|
|
109
|
+
return objects
|
|
110
|
+
|
|
111
|
+
return await self.database.async_run(operation)
|
|
112
|
+
|
|
113
|
+
async def count(self, model: type[Any], query: Any = None, *, statement: Select[Any] | None = None) -> int:
|
|
114
|
+
stmt = statement if statement is not None else self.select(model)
|
|
115
|
+
if query is not None:
|
|
116
|
+
stmt = stmt.where(query)
|
|
117
|
+
|
|
118
|
+
def operation(session: Session) -> int:
|
|
119
|
+
count_statement = select(func.count()).select_from(stmt.order_by(None).subquery())
|
|
120
|
+
return int(session.scalar(count_statement) or 0)
|
|
121
|
+
|
|
122
|
+
return await self.database.async_run(operation)
|
|
123
|
+
|
|
124
|
+
async def exists(self, model: type[Any], query: Any = None) -> bool:
|
|
125
|
+
statement = select(1).select_from(model)
|
|
126
|
+
if query is not None:
|
|
127
|
+
statement = statement.where(query)
|
|
128
|
+
statement = statement.limit(1)
|
|
129
|
+
|
|
130
|
+
def operation(session: Session) -> bool:
|
|
131
|
+
return session.scalar(statement) is not None
|
|
132
|
+
|
|
133
|
+
return await self.database.async_run(operation)
|
|
134
|
+
|
|
135
|
+
async def execute(
|
|
136
|
+
self,
|
|
137
|
+
statement: Executable,
|
|
138
|
+
params: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = None,
|
|
139
|
+
**kwargs: Any,
|
|
140
|
+
) -> Result[Any]:
|
|
141
|
+
def operation(session: Session) -> Result[Any]:
|
|
142
|
+
return session.execute(statement, params=params, **kwargs)
|
|
143
|
+
|
|
144
|
+
if isinstance(self.database, AsyncDatabase):
|
|
145
|
+
async with self.database.session_generator() as session:
|
|
146
|
+
return await session.execute(statement, params=params, **kwargs)
|
|
147
|
+
return await self.database.async_run(operation)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
try:
|
|
2
|
+
import elasticsearch as _elasticsearch # noqa: F401
|
|
3
|
+
import pydantic as _pydantic # noqa: F401
|
|
4
|
+
except ImportError as exc:
|
|
5
|
+
raise ImportError("Elasticsearch support requires 'ormate[elasticsearch]'.") from exc
|
|
6
|
+
|
|
7
|
+
from ormate.adapters.elasticsearch import ElasticsearchAdapter
|
|
8
|
+
|
|
9
|
+
from .document import ElasticsearchDocument
|
|
10
|
+
|
|
11
|
+
__all__ = ["ElasticsearchAdapter", "ElasticsearchDocument"]
|
|
12
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Any, ClassVar
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ElasticsearchDocument(BaseModel):
|
|
7
|
+
"""Base model for Elasticsearch `_source` documents."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(extra="allow")
|
|
10
|
+
|
|
11
|
+
index_name: ClassVar[str]
|
|
12
|
+
id: str | None = None
|
|
13
|
+
|
|
14
|
+
def document_source(self) -> dict[str, Any]:
|
|
15
|
+
return self.model_dump(exclude={"id"}, exclude_none=True)
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_hit(cls, hit: dict[str, Any]) -> "ElasticsearchDocument":
|
|
19
|
+
return cls.model_validate({"id": hit.get("_id"), **hit.get("_source", {})})
|
|
20
|
+
|
ormate/protocols.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from typing import Any, Protocol, runtime_checkable
|
|
3
|
+
|
|
4
|
+
ExecuteParams = Mapping[str, Any] | Sequence[Mapping[str, Any]]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@runtime_checkable
|
|
8
|
+
class DumpableModel(Protocol):
|
|
9
|
+
def model_dump(self, *, exclude_unset: bool = ...) -> dict[str, Any]: ...
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ValidatableModel(Protocol):
|
|
13
|
+
@classmethod
|
|
14
|
+
def model_validate(cls, obj: Any, *, from_attributes: bool = ...) -> Any: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
type ModelInput = Mapping[str, Any] | DumpableModel
|
ormate/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
ormate/repository.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from typing import Any, cast
|
|
3
|
+
|
|
4
|
+
from .adapters.base import StorageAdapter
|
|
5
|
+
from .protocols import DumpableModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ModelRepository[TableModel, ReadModel]:
|
|
9
|
+
"""Backend-independent repository with separate write and read models."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
adapter: StorageAdapter,
|
|
14
|
+
model: type[TableModel],
|
|
15
|
+
read_model: type[ReadModel] | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
if not isinstance(adapter, StorageAdapter):
|
|
18
|
+
raise TypeError("adapter must implement StorageAdapter")
|
|
19
|
+
self.adapter = adapter
|
|
20
|
+
self.model = model
|
|
21
|
+
self.read_model = read_model
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def storage_name(self) -> str:
|
|
25
|
+
return self.adapter.storage_name(self.model)
|
|
26
|
+
|
|
27
|
+
def to_dict(self, item: Mapping[str, Any] | DumpableModel) -> dict[str, Any]:
|
|
28
|
+
if isinstance(item, Mapping):
|
|
29
|
+
return dict(item)
|
|
30
|
+
if isinstance(item, DumpableModel):
|
|
31
|
+
return item.model_dump(exclude_unset=True)
|
|
32
|
+
raise TypeError(f"Expected a mapping or model_dump()-compatible object, got {type(item).__name__}")
|
|
33
|
+
|
|
34
|
+
def encode_for_storage(self, values: dict[str, Any]) -> dict[str, Any]:
|
|
35
|
+
hook = getattr(self.model, "encode_for_storage", None)
|
|
36
|
+
return dict(hook(dict(values))) if callable(hook) else values
|
|
37
|
+
|
|
38
|
+
def decode_from_storage(self, values: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
hook = getattr(self.read_model, "decode_from_storage", None)
|
|
40
|
+
return dict(hook(dict(values))) if callable(hook) else values
|
|
41
|
+
|
|
42
|
+
def to_read_model(self, obj: TableModel | Mapping[str, Any]) -> ReadModel | TableModel:
|
|
43
|
+
if self.read_model is None:
|
|
44
|
+
return cast(TableModel, obj)
|
|
45
|
+
model_fields = getattr(self.read_model, "model_fields", None)
|
|
46
|
+
field_names = model_fields.keys() if isinstance(model_fields, Mapping) else ()
|
|
47
|
+
if isinstance(obj, Mapping):
|
|
48
|
+
values = dict(obj)
|
|
49
|
+
else:
|
|
50
|
+
values = {field: getattr(obj, field) for field in field_names if hasattr(obj, field)}
|
|
51
|
+
if not values:
|
|
52
|
+
values = {key: value for key, value in vars(obj).items() if not key.startswith("_")}
|
|
53
|
+
values = self.decode_from_storage(values)
|
|
54
|
+
validator = getattr(self.read_model, "model_validate", None)
|
|
55
|
+
if not callable(validator):
|
|
56
|
+
raise TypeError("read_model must provide model_validate()")
|
|
57
|
+
return cast(ReadModel, validator(values, from_attributes=True))
|
|
58
|
+
|
|
59
|
+
async def create_item(self, item: Mapping[str, Any] | DumpableModel) -> ReadModel | TableModel:
|
|
60
|
+
return (await self.create_items([item]))[0]
|
|
61
|
+
|
|
62
|
+
async def create_items(self, items: Sequence[Mapping[str, Any] | DumpableModel]) -> list[ReadModel | TableModel]:
|
|
63
|
+
if not items:
|
|
64
|
+
return []
|
|
65
|
+
values = [self.encode_for_storage(self.to_dict(item)) for item in items]
|
|
66
|
+
objects = await self.adapter.create(self.model, values)
|
|
67
|
+
return [self.to_read_model(obj) for obj in objects]
|
|
68
|
+
|
|
69
|
+
async def read_items(
|
|
70
|
+
self,
|
|
71
|
+
query: Any = None,
|
|
72
|
+
*,
|
|
73
|
+
limit: int | None = None,
|
|
74
|
+
offset: int | None = None,
|
|
75
|
+
statement: Any = None,
|
|
76
|
+
) -> list[ReadModel | TableModel]:
|
|
77
|
+
objects = await self.adapter.read(
|
|
78
|
+
self.model,
|
|
79
|
+
query,
|
|
80
|
+
limit=limit,
|
|
81
|
+
offset=offset,
|
|
82
|
+
statement=statement,
|
|
83
|
+
)
|
|
84
|
+
return [self.to_read_model(obj) for obj in objects]
|
|
85
|
+
|
|
86
|
+
async def read_one_item(self, query: Any = None) -> ReadModel | TableModel | None:
|
|
87
|
+
items = await self.read_items(query, limit=1)
|
|
88
|
+
return items[0] if items else None
|
|
89
|
+
|
|
90
|
+
async def read_item_by_primary_key(self, primary_key: Any) -> ReadModel | TableModel | None:
|
|
91
|
+
return await self.read_one_item(self.adapter.primary_key_query(self.model, primary_key))
|
|
92
|
+
|
|
93
|
+
async def read_items_by_primary_keys(self, primary_keys: Sequence[Any]) -> list[ReadModel | TableModel]:
|
|
94
|
+
return await self.read_items(self.adapter.primary_keys_query(self.model, primary_keys))
|
|
95
|
+
|
|
96
|
+
async def update_items_by_query(
|
|
97
|
+
self,
|
|
98
|
+
query: Any,
|
|
99
|
+
item: Mapping[str, Any] | DumpableModel,
|
|
100
|
+
) -> list[ReadModel | TableModel]:
|
|
101
|
+
values = self.encode_for_storage(self.to_dict(item))
|
|
102
|
+
objects = await self.adapter.update(self.model, query, values)
|
|
103
|
+
return [self.to_read_model(obj) for obj in objects]
|
|
104
|
+
|
|
105
|
+
async def update_item_by_primary_key(
|
|
106
|
+
self,
|
|
107
|
+
primary_key: Any,
|
|
108
|
+
item: Mapping[str, Any] | DumpableModel,
|
|
109
|
+
) -> ReadModel | TableModel | None:
|
|
110
|
+
results = await self.update_items_by_query(self.adapter.primary_key_query(self.model, primary_key), item)
|
|
111
|
+
return results[0] if results else None
|
|
112
|
+
|
|
113
|
+
async def update_items_by_primary_keys(
|
|
114
|
+
self,
|
|
115
|
+
primary_keys: Sequence[Any],
|
|
116
|
+
item: Mapping[str, Any] | DumpableModel,
|
|
117
|
+
) -> list[ReadModel | TableModel]:
|
|
118
|
+
if not primary_keys:
|
|
119
|
+
return []
|
|
120
|
+
return await self.update_items_by_query(self.adapter.primary_keys_query(self.model, primary_keys), item)
|
|
121
|
+
|
|
122
|
+
async def delete_items_by_query(self, query: Any) -> list[ReadModel | TableModel]:
|
|
123
|
+
objects = await self.adapter.delete(self.model, query)
|
|
124
|
+
return [self.to_read_model(obj) for obj in objects]
|
|
125
|
+
|
|
126
|
+
async def delete_item_by_primary_key(self, primary_key: Any) -> ReadModel | TableModel | None:
|
|
127
|
+
results = await self.delete_items_by_query(self.adapter.primary_key_query(self.model, primary_key))
|
|
128
|
+
return results[0] if results else None
|
|
129
|
+
|
|
130
|
+
async def delete_items_by_primary_keys(self, primary_keys: Sequence[Any]) -> list[ReadModel | TableModel]:
|
|
131
|
+
if not primary_keys:
|
|
132
|
+
return []
|
|
133
|
+
return await self.delete_items_by_query(self.adapter.primary_keys_query(self.model, primary_keys))
|
|
134
|
+
|
|
135
|
+
async def count(self, query: Any = None, *, statement: Any = None) -> int:
|
|
136
|
+
return await self.adapter.count(self.model, query, statement=statement)
|
|
137
|
+
|
|
138
|
+
async def exists(self, query: Any = None) -> bool:
|
|
139
|
+
return await self.adapter.exists(self.model, query)
|
|
140
|
+
|
|
141
|
+
async def execute(self, statement: Any, params: Any = None, **kwargs: Any) -> Any:
|
|
142
|
+
return await self.adapter.execute(statement, params=params, **kwargs)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Any
|
|
2
|
+
|
|
3
|
+
from .database import AsyncDatabase, Database
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from ormate.adapters.sqlalchemy import SQLAlchemyAdapter
|
|
7
|
+
|
|
8
|
+
__all__ = ["AsyncDatabase", "Database", "SQLAlchemyAdapter"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def __getattr__(name: str) -> Any:
|
|
12
|
+
if name == "SQLAlchemyAdapter":
|
|
13
|
+
from ormate.adapters.sqlalchemy import SQLAlchemyAdapter
|
|
14
|
+
|
|
15
|
+
return SQLAlchemyAdapter
|
|
16
|
+
raise AttributeError(name)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
|
5
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import URL, create_engine
|
|
10
|
+
from sqlalchemy.engine import Engine
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
12
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
13
|
+
|
|
14
|
+
from .sessions import AsyncSessionScope, SessionScope
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Database:
|
|
20
|
+
def __init__(self, engine: Engine, **session_options: Any) -> None:
|
|
21
|
+
self.engine = engine
|
|
22
|
+
session_options.setdefault("expire_on_commit", False)
|
|
23
|
+
self.session_maker = sessionmaker(bind=engine, class_=Session, **session_options)
|
|
24
|
+
self._session_context: ContextVar[Session | None] = ContextVar(f"ormate_sync_{id(self)}", default=None)
|
|
25
|
+
self._scope_stack: ContextVar[tuple[SessionScope, ...]] = ContextVar(
|
|
26
|
+
f"ormate_sync_scope_stack_{id(self)}", default=()
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def create(
|
|
31
|
+
cls,
|
|
32
|
+
url: str | URL,
|
|
33
|
+
*,
|
|
34
|
+
session_options: Mapping[str, Any] | None = None,
|
|
35
|
+
**engine_options: Any,
|
|
36
|
+
) -> Database:
|
|
37
|
+
return cls(create_engine(url, **engine_options), **dict(session_options or {}))
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def session(self) -> Session:
|
|
41
|
+
session = self._session_context.get()
|
|
42
|
+
if session is None:
|
|
43
|
+
raise RuntimeError("No active database scope; use 'with db:' or 'with db.session_scope()'.")
|
|
44
|
+
return session
|
|
45
|
+
|
|
46
|
+
def __enter__(self) -> Session:
|
|
47
|
+
scope = self.session_scope()
|
|
48
|
+
self._scope_stack.set((*self._scope_stack.get(), scope))
|
|
49
|
+
return scope.__enter__()
|
|
50
|
+
|
|
51
|
+
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
|
|
52
|
+
stack = self._scope_stack.get()
|
|
53
|
+
if not stack:
|
|
54
|
+
raise RuntimeError("Database scope stack is empty")
|
|
55
|
+
scope = stack[-1]
|
|
56
|
+
try:
|
|
57
|
+
scope.__exit__(exc_type, exc_value, traceback)
|
|
58
|
+
finally:
|
|
59
|
+
self._scope_stack.set(stack[:-1])
|
|
60
|
+
|
|
61
|
+
def session_scope(self, scope: Any = None) -> SessionScope:
|
|
62
|
+
return SessionScope(self, scope)
|
|
63
|
+
|
|
64
|
+
@contextmanager
|
|
65
|
+
def session_generator(self) -> Iterator[Session]:
|
|
66
|
+
current = self._session_context.get()
|
|
67
|
+
if current is not None:
|
|
68
|
+
yield current
|
|
69
|
+
return
|
|
70
|
+
with self.session_scope() as session:
|
|
71
|
+
yield session
|
|
72
|
+
|
|
73
|
+
def run(self, fn: Callable[..., T], *args: Any, is_session: bool = True, **kwargs: Any) -> T:
|
|
74
|
+
if is_session:
|
|
75
|
+
with self.session_generator() as session:
|
|
76
|
+
return fn(session, *args, **kwargs)
|
|
77
|
+
with self.engine.begin() as connection:
|
|
78
|
+
return fn(connection, *args, **kwargs)
|
|
79
|
+
|
|
80
|
+
async def async_run(self, fn: Callable[..., T], *args: Any, is_session: bool = True, **kwargs: Any) -> T:
|
|
81
|
+
return await asyncio.to_thread(lambda: self.run(fn, *args, is_session=is_session, **kwargs))
|
|
82
|
+
|
|
83
|
+
def commit(self) -> None:
|
|
84
|
+
self.session.commit()
|
|
85
|
+
|
|
86
|
+
def rollback(self) -> None:
|
|
87
|
+
self.session.rollback()
|
|
88
|
+
|
|
89
|
+
def close(self) -> None:
|
|
90
|
+
self.engine.dispose()
|
|
91
|
+
|
|
92
|
+
async def dispose(self) -> None:
|
|
93
|
+
await asyncio.to_thread(self.close)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AsyncDatabase:
|
|
97
|
+
def __init__(self, engine: AsyncEngine, **session_options: Any) -> None:
|
|
98
|
+
self.engine = engine
|
|
99
|
+
session_options.setdefault("expire_on_commit", False)
|
|
100
|
+
self.session_maker = async_sessionmaker(bind=engine, class_=AsyncSession, **session_options)
|
|
101
|
+
self._session_context: ContextVar[AsyncSession | None] = ContextVar(f"ormate_async_{id(self)}", default=None)
|
|
102
|
+
self._scope_stack: ContextVar[tuple[AsyncSessionScope, ...]] = ContextVar(
|
|
103
|
+
f"ormate_async_scope_stack_{id(self)}", default=()
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def create(
|
|
108
|
+
cls, url: str | URL, *, session_options: Mapping[str, Any] | None = None, **engine_options: Any
|
|
109
|
+
) -> AsyncDatabase:
|
|
110
|
+
return cls(create_async_engine(url, **engine_options), **dict(session_options or {}))
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def session(self) -> AsyncSession:
|
|
114
|
+
session = self._session_context.get()
|
|
115
|
+
if session is None:
|
|
116
|
+
raise RuntimeError("No active database scope; use 'async with db'.")
|
|
117
|
+
return session
|
|
118
|
+
|
|
119
|
+
async def __aenter__(self) -> AsyncSession:
|
|
120
|
+
scope = self.session_scope()
|
|
121
|
+
self._scope_stack.set((*self._scope_stack.get(), scope))
|
|
122
|
+
return await scope.__aenter__()
|
|
123
|
+
|
|
124
|
+
async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
|
|
125
|
+
stack = self._scope_stack.get()
|
|
126
|
+
if not stack:
|
|
127
|
+
raise RuntimeError("Async database scope stack is empty")
|
|
128
|
+
scope = stack[-1]
|
|
129
|
+
try:
|
|
130
|
+
await scope.__aexit__(exc_type, exc_value, traceback)
|
|
131
|
+
finally:
|
|
132
|
+
self._scope_stack.set(stack[:-1])
|
|
133
|
+
|
|
134
|
+
def session_scope(self, scope: Any = None) -> AsyncSessionScope:
|
|
135
|
+
return AsyncSessionScope(self, scope)
|
|
136
|
+
|
|
137
|
+
@asynccontextmanager
|
|
138
|
+
async def session_generator(self) -> AsyncIterator[AsyncSession]:
|
|
139
|
+
current = self._session_context.get()
|
|
140
|
+
if current is not None:
|
|
141
|
+
yield current
|
|
142
|
+
return
|
|
143
|
+
async with self.session_scope() as session:
|
|
144
|
+
yield session
|
|
145
|
+
|
|
146
|
+
async def async_run(self, fn: Callable[..., T], *args: Any, is_session: bool = True, **kwargs: Any) -> T:
|
|
147
|
+
if is_session:
|
|
148
|
+
async with self.session_generator() as session:
|
|
149
|
+
return await session.run_sync(fn, *args, **kwargs)
|
|
150
|
+
async with self.engine.begin() as connection:
|
|
151
|
+
return await connection.run_sync(fn, *args, **kwargs)
|
|
152
|
+
|
|
153
|
+
async def commit(self) -> None:
|
|
154
|
+
await self.session.commit()
|
|
155
|
+
|
|
156
|
+
async def rollback(self) -> None:
|
|
157
|
+
await self.session.rollback()
|
|
158
|
+
|
|
159
|
+
async def dispose(self) -> None:
|
|
160
|
+
await self.engine.dispose()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
DatabaseLike = Database | AsyncDatabase
|
|
164
|
+
EngineLike = Engine | AsyncEngine | DatabaseLike
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def ensure_database(engine: EngineLike) -> DatabaseLike:
|
|
168
|
+
if isinstance(engine, (Database, AsyncDatabase)):
|
|
169
|
+
return engine
|
|
170
|
+
if isinstance(engine, AsyncEngine):
|
|
171
|
+
return AsyncDatabase(engine)
|
|
172
|
+
if isinstance(engine, Engine):
|
|
173
|
+
return Database(engine)
|
|
174
|
+
raise TypeError(f"Unsupported database engine: {type(engine).__name__}")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import Token
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionScope:
|
|
12
|
+
def __init__(self, db: Any, scope: Any = None) -> None:
|
|
13
|
+
self.db = db
|
|
14
|
+
self.scope = scope if scope is not None else object()
|
|
15
|
+
self.token: Token[Any] | None = None
|
|
16
|
+
self.session: Session | None = None
|
|
17
|
+
self.owned = False
|
|
18
|
+
|
|
19
|
+
def __enter__(self) -> Session:
|
|
20
|
+
current = self.db._session_context.get()
|
|
21
|
+
if isinstance(self.scope, Session):
|
|
22
|
+
self.session = self.scope
|
|
23
|
+
elif current is not None:
|
|
24
|
+
self.session = current
|
|
25
|
+
else:
|
|
26
|
+
self.session = self.db.session_maker()
|
|
27
|
+
self.owned = True
|
|
28
|
+
self.token = self.db._session_context.set(self.session)
|
|
29
|
+
return self.session
|
|
30
|
+
|
|
31
|
+
def __exit__(
|
|
32
|
+
self,
|
|
33
|
+
exc_type: type[BaseException] | None,
|
|
34
|
+
exc_value: BaseException | None,
|
|
35
|
+
traceback: TracebackType | None,
|
|
36
|
+
) -> None:
|
|
37
|
+
try:
|
|
38
|
+
if self.owned and self.session is not None:
|
|
39
|
+
if exc_type is None:
|
|
40
|
+
self.session.commit()
|
|
41
|
+
else:
|
|
42
|
+
self.session.rollback()
|
|
43
|
+
elif exc_type is not None and self.session is not None:
|
|
44
|
+
self.session.rollback()
|
|
45
|
+
finally:
|
|
46
|
+
if self.owned and self.session is not None:
|
|
47
|
+
self.session.close()
|
|
48
|
+
if self.token is not None:
|
|
49
|
+
self.db._session_context.reset(self.token)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AsyncSessionScope:
|
|
53
|
+
def __init__(self, db: Any, scope: Any = None) -> None:
|
|
54
|
+
self.db = db
|
|
55
|
+
self.scope = scope if scope is not None else object()
|
|
56
|
+
self.token: Token[Any] | None = None
|
|
57
|
+
self.session: AsyncSession | None = None
|
|
58
|
+
self.owned = False
|
|
59
|
+
|
|
60
|
+
async def __aenter__(self) -> AsyncSession:
|
|
61
|
+
current = self.db._session_context.get()
|
|
62
|
+
if isinstance(self.scope, AsyncSession):
|
|
63
|
+
self.session = self.scope
|
|
64
|
+
elif current is not None:
|
|
65
|
+
self.session = current
|
|
66
|
+
else:
|
|
67
|
+
self.session = self.db.session_maker()
|
|
68
|
+
self.owned = True
|
|
69
|
+
self.token = self.db._session_context.set(self.session)
|
|
70
|
+
return self.session
|
|
71
|
+
|
|
72
|
+
async def __aexit__(
|
|
73
|
+
self,
|
|
74
|
+
exc_type: type[BaseException] | None,
|
|
75
|
+
exc_value: BaseException | None,
|
|
76
|
+
traceback: TracebackType | None,
|
|
77
|
+
) -> None:
|
|
78
|
+
try:
|
|
79
|
+
if self.owned and self.session is not None:
|
|
80
|
+
if exc_type is None:
|
|
81
|
+
await self.session.commit()
|
|
82
|
+
else:
|
|
83
|
+
await self.session.rollback()
|
|
84
|
+
elif exc_type is not None and self.session is not None:
|
|
85
|
+
await self.session.rollback()
|
|
86
|
+
finally:
|
|
87
|
+
if self.owned and self.session is not None:
|
|
88
|
+
await self.session.close()
|
|
89
|
+
if self.token is not None:
|
|
90
|
+
self.db._session_context.reset(self.token)
|
ormate/web/__init__.py
ADDED
ormate/web/middleware.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import Final
|
|
2
|
+
|
|
3
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
4
|
+
|
|
5
|
+
from ormate.sqlalchemy.database import AsyncDatabase, EngineLike, ensure_database
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DBSessionMiddleware:
|
|
9
|
+
"""Pure ASGI transaction middleware for an AsyncDatabase."""
|
|
10
|
+
|
|
11
|
+
SCOPE_KEY_PREFIX: Final = "ormate.database"
|
|
12
|
+
|
|
13
|
+
def __init__(self, app: ASGIApp, db: EngineLike, *, rollback_on_http_error: bool = False) -> None:
|
|
14
|
+
self.app = app
|
|
15
|
+
database = ensure_database(db)
|
|
16
|
+
if not isinstance(database, AsyncDatabase):
|
|
17
|
+
raise TypeError("DBSessionMiddleware requires an AsyncDatabase or AsyncEngine")
|
|
18
|
+
self.db = database
|
|
19
|
+
self.rollback_on_http_error = rollback_on_http_error
|
|
20
|
+
self.scope_key = f"{self.SCOPE_KEY_PREFIX}:{id(database)}"
|
|
21
|
+
|
|
22
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
23
|
+
if scope["type"] not in {"http", "websocket"} or scope.get(self.scope_key):
|
|
24
|
+
await self.app(scope, receive, send)
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
status_code = 200
|
|
28
|
+
|
|
29
|
+
async def capture_status(message: Message) -> None:
|
|
30
|
+
nonlocal status_code
|
|
31
|
+
if message["type"] == "http.response.start":
|
|
32
|
+
status_code = message["status"]
|
|
33
|
+
await send(message)
|
|
34
|
+
|
|
35
|
+
async with self.db.session_scope(scope=id(scope)):
|
|
36
|
+
scope[self.scope_key] = self.db
|
|
37
|
+
try:
|
|
38
|
+
await self.app(scope, receive, capture_status)
|
|
39
|
+
if self.rollback_on_http_error and status_code >= 400:
|
|
40
|
+
await self.db.rollback()
|
|
41
|
+
except Exception:
|
|
42
|
+
await self.db.rollback()
|
|
43
|
+
raise
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ormate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async repositories with separate read/write models and pluggable storage adapters
|
|
5
|
+
Author: zhangzhanqi
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 zhangzhanqi
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Keywords: asyncio,database,elasticsearch,repository,sqlalchemy,sqlmodel
|
|
30
|
+
Classifier: Development Status :: 3 - Alpha
|
|
31
|
+
Classifier: Framework :: AsyncIO
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
36
|
+
Classifier: Typing :: Typed
|
|
37
|
+
Requires-Python: >=3.13
|
|
38
|
+
Requires-Dist: sqlalchemy<3,>=2.0.36
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: aiosqlite>=0.20; extra == 'dev'
|
|
41
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
42
|
+
Requires-Dist: elasticsearch[async]<10,>=8.17; extra == 'dev'
|
|
43
|
+
Requires-Dist: httpx>=0.28; extra == 'dev'
|
|
44
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
45
|
+
Requires-Dist: pydantic>=2.10; extra == 'dev'
|
|
46
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
47
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
48
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
49
|
+
Requires-Dist: sqlmodel>=0.0.22; extra == 'dev'
|
|
50
|
+
Requires-Dist: starlette>=0.41; extra == 'dev'
|
|
51
|
+
Requires-Dist: twine>=6.0; extra == 'dev'
|
|
52
|
+
Provides-Extra: elasticsearch
|
|
53
|
+
Requires-Dist: elasticsearch[async]<10,>=8.17; extra == 'elasticsearch'
|
|
54
|
+
Requires-Dist: pydantic>=2.10; extra == 'elasticsearch'
|
|
55
|
+
Provides-Extra: mysql
|
|
56
|
+
Requires-Dist: aiomysql>=0.2; extra == 'mysql'
|
|
57
|
+
Requires-Dist: pymysql>=1.1; extra == 'mysql'
|
|
58
|
+
Provides-Extra: postgresql
|
|
59
|
+
Requires-Dist: asyncpg>=0.30; extra == 'postgresql'
|
|
60
|
+
Requires-Dist: psycopg[binary]>=3.2; extra == 'postgresql'
|
|
61
|
+
Provides-Extra: sqlite
|
|
62
|
+
Requires-Dist: aiosqlite>=0.20; extra == 'sqlite'
|
|
63
|
+
Provides-Extra: sqlmodel
|
|
64
|
+
Requires-Dist: sqlmodel>=0.0.22; extra == 'sqlmodel'
|
|
65
|
+
Provides-Extra: web
|
|
66
|
+
Requires-Dist: starlette>=0.41; extra == 'web'
|
|
67
|
+
Description-Content-Type: text/markdown
|
|
68
|
+
|
|
69
|
+
# ormate
|
|
70
|
+
|
|
71
|
+
ormate 是一个异步 Repository 工具包,用同一套 CRUD 接口访问 SQLAlchemy、SQLModel 和 Elasticsearch。Repository 负责输入输出模型转换,Adapter 负责具体的存储操作和查询语法。
|
|
72
|
+
|
|
73
|
+
项目目前处于 `0.1.0` 阶段,API 在 `1.0` 前仍可能调整。
|
|
74
|
+
|
|
75
|
+
## 安装
|
|
76
|
+
|
|
77
|
+
需要 Python 3.13 或更高版本。核心包只依赖 SQLAlchemy:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install ormate
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
其他依赖按需安装:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install "ormate[sqlite]"
|
|
87
|
+
pip install "ormate[sqlmodel]"
|
|
88
|
+
pip install "ormate[postgresql]"
|
|
89
|
+
pip install "ormate[mysql]"
|
|
90
|
+
pip install "ormate[elasticsearch]"
|
|
91
|
+
pip install "ormate[web]"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## SQLAlchemy
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from pydantic import BaseModel
|
|
98
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
99
|
+
|
|
100
|
+
from ormate import AsyncDatabase, ModelRepository, SQLAlchemyAdapter
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Base(DeclarativeBase):
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class User(Base):
|
|
108
|
+
__tablename__ = "users"
|
|
109
|
+
|
|
110
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
111
|
+
name: Mapped[str]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class UserCreate(BaseModel):
|
|
115
|
+
id: int
|
|
116
|
+
name: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class UserRead(BaseModel):
|
|
120
|
+
id: int
|
|
121
|
+
name: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
db = AsyncDatabase.create("sqlite+aiosqlite:///app.db")
|
|
125
|
+
repository = ModelRepository(SQLAlchemyAdapter(db), User, UserRead)
|
|
126
|
+
|
|
127
|
+
async with db.engine.begin() as connection:
|
|
128
|
+
await connection.run_sync(Base.metadata.create_all)
|
|
129
|
+
|
|
130
|
+
created = await repository.create_item(UserCreate(id=1, name="Ada"))
|
|
131
|
+
loaded = await repository.read_item_by_primary_key(1)
|
|
132
|
+
updated = await repository.update_item_by_primary_key(1, {"name": "Grace"})
|
|
133
|
+
deleted = await repository.delete_item_by_primary_key(1)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Create 和 Update 参数可以是字典,也可以是实现了 `model_dump(exclude_unset=True)` 的 Pydantic/SQLModel 对象。传入 ReadModel 时,该模型需要提供 `model_validate()`;不传则返回存储模型。
|
|
137
|
+
|
|
138
|
+
SQLAlchemy 条件查询直接使用 SQLAlchemy 表达式:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
users = await repository.read_items(User.name.contains("Ada"), limit=20)
|
|
142
|
+
total = await repository.count(User.name.contains("Ada"))
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## SQLModel 与事务
|
|
146
|
+
|
|
147
|
+
`SQLModelAdapter` 继承 `SQLAlchemyAdapter`,两者使用相同的 Session 和事务实现。绑定到同一个 `Database` 后,SQLAlchemy 和 SQLModel 的操作可以放在同一个事务中:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
audit_logs = ModelRepository(SQLAlchemyAdapter(db), AuditLog)
|
|
151
|
+
tasks = ModelRepository(SQLModelAdapter(db), Task)
|
|
152
|
+
|
|
153
|
+
async with db:
|
|
154
|
+
await audit_logs.create_item({"id": 1, "message": "task created"})
|
|
155
|
+
await tasks.create_item({"title": "publish package"})
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
作用域正常退出时提交,发生异常时回滚。同步代码可以使用 `Database` 和 `with db:`。
|
|
159
|
+
|
|
160
|
+
相关示例:
|
|
161
|
+
|
|
162
|
+
- `examples/async_sqlalchemy.py`
|
|
163
|
+
- `examples/sync_sqlalchemy.py`
|
|
164
|
+
- `examples/async_sqlmodel.py`
|
|
165
|
+
- `examples/shared_transaction.py`
|
|
166
|
+
|
|
167
|
+
## Elasticsearch
|
|
168
|
+
|
|
169
|
+
Elasticsearch 使用 Pydantic 文档模型,查询参数保持原生 Query DSL:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from ormate import ModelRepository
|
|
173
|
+
from ormate.elasticsearch import ElasticsearchAdapter, ElasticsearchDocument
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ArticleDocument(ElasticsearchDocument):
|
|
177
|
+
index_name = "articles"
|
|
178
|
+
|
|
179
|
+
title: str
|
|
180
|
+
content: str
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
adapter = ElasticsearchAdapter(client, refresh="wait_for")
|
|
184
|
+
articles = ModelRepository(adapter, ArticleDocument)
|
|
185
|
+
|
|
186
|
+
created = await articles.create_item(
|
|
187
|
+
{"id": "quickstart", "title": "ormate", "content": "pluggable adapters"}
|
|
188
|
+
)
|
|
189
|
+
matched = await articles.read_items({"match": {"content": "adapters"}}, limit=10)
|
|
190
|
+
updated = await articles.update_item_by_primary_key("quickstart", {"title": "ormate 0.1"})
|
|
191
|
+
deleted = await articles.delete_item_by_primary_key("quickstart")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
完整的索引初始化、CRUD、计数、聚合和客户端关闭示例在 `examples/elasticsearch.py`:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
ELASTICSEARCH_URL=http://localhost:9200 uv run python examples/elasticsearch.py
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
全文检索和聚合等 ES 专属操作直接通过 `ElasticsearchAdapter` 调用。Elasticsearch 不参与 SQL 事务;SQL 与 ES 双写需要使用 outbox、消息队列或补偿机制。
|
|
201
|
+
|
|
202
|
+
## Repository 和 Adapter
|
|
203
|
+
|
|
204
|
+
`ModelRepository` 提供以下通用操作:
|
|
205
|
+
|
|
206
|
+
- 单项和批量创建
|
|
207
|
+
- 条件查询、主键查询和批量主键查询
|
|
208
|
+
- 条件更新和删除
|
|
209
|
+
- `count`、`exists`、`limit`、`offset`
|
|
210
|
+
- 后端原生命令执行
|
|
211
|
+
|
|
212
|
+
SQLAlchemy/SQLModel 额外支持复合主键。后端特有的能力不放进通用协议,例如 SQL JOIN 和 ES 聚合分别留在对应 Adapter 中。
|
|
213
|
+
|
|
214
|
+
实现 `StorageAdapter` 协议可以接入其他存储:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
repository = ModelRepository(custom_adapter, CustomModel, CustomRead)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Adapter 负责持久化、查询、主键条件和存储名称。Repository 负责输入转换、`encode_for_storage()`、`decode_from_storage()` 和 ReadModel 构造。
|
|
221
|
+
|
|
222
|
+
ormate 不定义 `id`、`created_at`、`updated_at` 等业务字段。SQLAlchemy/SQLModel 继续使用 `__tablename__`,Elasticsearch 使用 `index_name`,统一名称可以从 Repository 获取:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
repository.storage_name
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`ElasticsearchDocument.id` 只用于映射 Elasticsearch `_id`。
|
|
229
|
+
|
|
230
|
+
## Web 中间件
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from ormate.web import DBSessionMiddleware
|
|
234
|
+
|
|
235
|
+
app.add_middleware(
|
|
236
|
+
DBSessionMiddleware,
|
|
237
|
+
db=db,
|
|
238
|
+
rollback_on_http_error=True,
|
|
239
|
+
)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
中间件接受 `AsyncDatabase` 或 `AsyncEngine`,为每个 HTTP/WebSocket 请求建立独立会话作用域。
|
|
243
|
+
|
|
244
|
+
## 当前限制
|
|
245
|
+
|
|
246
|
+
- PostgreSQL、MySQL 和真实 Elasticsearch 集群尚未加入自动化集成测试。
|
|
247
|
+
- Elasticsearch 条件批量更新和删除受 `default_size` 限制,默认最多处理 1000 个匹配文档。
|
|
248
|
+
- ES mapping 迁移、bulk、PIT/search_after 和乐观并发控制尚未实现。
|
|
249
|
+
- 不提供 SQL 与 Elasticsearch 之间的分布式事务。
|
|
250
|
+
|
|
251
|
+
## 路线图
|
|
252
|
+
|
|
253
|
+
`0.1.x` 用于完成首次 PyPI 发布,当前还需要经过 TestPyPI 安装验证。
|
|
254
|
+
|
|
255
|
+
`0.2` 主要完善 Elasticsearch:bulk 写入、PIT/search_after、索引 mapping 管理、并发冲突处理和真实集群测试。
|
|
256
|
+
|
|
257
|
+
`0.3` 主要完善关系型数据库:PostgreSQL/MySQL 测试矩阵、分页结果类型、显式 savepoint API 和批量性能优化。
|
|
258
|
+
|
|
259
|
+
后续会补充 Adapter 契约测试工具,并评估 MongoDB、RedisJSON 以及 SQL 到 ES 的 outbox 示例。到 `1.0` 前会冻结公共 API,补齐迁移指南和性能基准。
|
|
260
|
+
|
|
261
|
+
路线图只表示开发方向,不承诺具体发布日期。
|
|
262
|
+
|
|
263
|
+
## 开发
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
uv sync --all-extras
|
|
267
|
+
uv run ruff check .
|
|
268
|
+
uv run mypy
|
|
269
|
+
uv run pytest
|
|
270
|
+
uv build
|
|
271
|
+
uv run twine check dist/*
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
发布步骤见 `docs/publishing.md`。
|
|
275
|
+
|
|
276
|
+
## License
|
|
277
|
+
|
|
278
|
+
[MIT License](LICENSE)
|
|
279
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
ormate/__init__.py,sha256=8drbFhYVZ7PVR-cDBsKZWmRFLLPec4VLk-EIVYmQbEQ,315
|
|
2
|
+
ormate/protocols.py,sha256=7S10KP0OUcog4193WCord3DCL2n5thOdQfSZdQEzrKY,482
|
|
3
|
+
ormate/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
4
|
+
ormate/repository.py,sha256=fELOUK53SnpmNSV8PrPIaQIel8WdymRQ8p8piVajVcw,6261
|
|
5
|
+
ormate/adapters/__init__.py,sha256=BN5nzhykjq4W94glsiMmOrCVlZnftlcdREjC-Z6HS68,183
|
|
6
|
+
ormate/adapters/base.py,sha256=nT2HI86fzyFVRMRwW_QdIdAd832Gxhjd2Q4s-1K6HsA,1209
|
|
7
|
+
ormate/adapters/elasticsearch.py,sha256=q2C5FgjM-eTh8LgKG6ETNdudghd_ueaCfU1oEaDeiO8,6092
|
|
8
|
+
ormate/adapters/sqlalchemy.py,sha256=6uenSOrj79tFPrbmP-xK4yrxPSOTbS25AYl11l3gB-E,5904
|
|
9
|
+
ormate/adapters/sqlmodel.py,sha256=B3EoS04u31DyQCpfvkQCeY3rtpEeKr3BfMEW0qoHato,163
|
|
10
|
+
ormate/elasticsearch/__init__.py,sha256=NIvBYK46ERKsG413eyrdIY-iMqHi5vvZqVsGx9pTBI8,397
|
|
11
|
+
ormate/elasticsearch/document.py,sha256=yZh4Hw15TncaFCGCiZ_eQBFPXsPqSA9epWPDZ3tnc-E,572
|
|
12
|
+
ormate/sqlalchemy/__init__.py,sha256=HiIAoF64wWYRdw3vjoQZ9zP5jtFYOUpoTuinw8Ixv7c,430
|
|
13
|
+
ormate/sqlalchemy/database.py,sha256=aqxpF-NWc4byHw5uhpvQu5WdxDWsvoHDr7DwKVOs180,6433
|
|
14
|
+
ormate/sqlalchemy/sessions.py,sha256=AlIZHO4dMwemJWBPntA2Mjoql-mrVkaSUK8_eWKf80k,3085
|
|
15
|
+
ormate/web/__init__.py,sha256=GWYqhiQUCqMv3swwP7mELK5zbqcMABxK8Tq7PzFTQrg,231
|
|
16
|
+
ormate/web/middleware.py,sha256=zfhY6eXmwpTxTxjzUWtv_F_lT2KGteRIsdM9k3nsTGw,1670
|
|
17
|
+
ormate-0.1.0.dist-info/METADATA,sha256=A6GTjsblfAwygEvuoHl5qJt7cDnykdJJfISyNR0GkRo,9601
|
|
18
|
+
ormate-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
19
|
+
ormate-0.1.0.dist-info/licenses/LICENSE,sha256=0Qv1NR8OzTBwQvKk3XCtNGnN_35_3K3itnIGI0_QuUQ,1069
|
|
20
|
+
ormate-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zhangzhanqi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|