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 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
+ ]
@@ -0,0 +1,5 @@
1
+ from .base import StorageAdapter
2
+ from .sqlalchemy import SQLAlchemyAdapter
3
+ from .sqlmodel import SQLModelAdapter
4
+
5
+ __all__ = ["SQLAlchemyAdapter", "SQLModelAdapter", "StorageAdapter"]
@@ -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,6 @@
1
+ from .sqlalchemy import SQLAlchemyAdapter
2
+
3
+
4
+ class SQLModelAdapter(SQLAlchemyAdapter):
5
+ """Semantic SQLModel adapter backed entirely by SQLAlchemy behavior."""
6
+
@@ -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
@@ -0,0 +1,8 @@
1
+ try:
2
+ import starlette as _starlette # noqa: F401
3
+ except ImportError as exc:
4
+ raise ImportError("Web support requires 'ormate[web]'.") from exc
5
+
6
+ from .middleware import DBSessionMiddleware
7
+
8
+ __all__ = ["DBSessionMiddleware"]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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
+