sqlphilosophy 0.1.4__tar.gz → 0.1.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of sqlphilosophy might be problematic. Click here for more details.
- {sqlphilosophy-0.1.4/src/sqlphilosophy.egg-info → sqlphilosophy-0.1.8}/PKG-INFO +38 -1
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/README.md +37 -0
- sqlphilosophy-0.1.8/src/sqlphilosophy/VERSION +1 -0
- sqlphilosophy-0.1.8/src/sqlphilosophy/aio/protocols.py +122 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/aio/repository.py +29 -26
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/sql.py +4 -14
- sqlphilosophy-0.1.8/src/sqlphilosophy/sync/protocols.py +120 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/sync/repository.py +36 -30
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8/src/sqlphilosophy.egg-info}/PKG-INFO +38 -1
- sqlphilosophy-0.1.4/src/sqlphilosophy/VERSION +0 -1
- sqlphilosophy-0.1.4/src/sqlphilosophy/aio/protocols.py +0 -26
- sqlphilosophy-0.1.4/src/sqlphilosophy/sync/protocols.py +0 -26
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/LICENSE +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/MANIFEST.in +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/pyproject.toml +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/setup.cfg +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/__init__.py +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/aio/__init__.py +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/aio/query.py +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/audit/__init__.py +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/audit/context.py +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/audit/fields.py +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/audit/listener.py +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/audit/model.py +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/py.typed +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/sorting.py +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/sync/__init__.py +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/sync/query.py +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/types.py +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy.egg-info/SOURCES.txt +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy.egg-info/dependency_links.txt +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy.egg-info/requires.txt +0 -0
- {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlphilosophy
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Portable SQLAlchemy repository kit: sync and async CRUD, statement builders, sort/pagination, and SQL helpers.
|
|
5
5
|
Author-email: Josh Martin <denverprogrammer@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -111,6 +111,43 @@ with SessionLocal() as session:
|
|
|
111
111
|
|
|
112
112
|
**Async:** swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
|
|
113
113
|
|
|
114
|
+
## Strongly typed repositories (factory pattern)
|
|
115
|
+
|
|
116
|
+
Domain repositories subclass `BaseRepository[Model, RepositoryFactory]` (or `AsyncBaseRepository` for async) and add typed query helpers. A session-scoped factory implements `RepositoryFactory` to cache repositories and wire `statement()` / `for_repo()` across repos on the same session.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from sqlalchemy.orm import Session
|
|
120
|
+
|
|
121
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
122
|
+
from sqlphilosophy.sync.repository import BaseRepository
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class UserRepository(BaseRepository[User, RepositoryFactory]):
|
|
126
|
+
def __init__(self, session: Session, factory: RepositoryFactory) -> None:
|
|
127
|
+
super().__init__(User, session, factory)
|
|
128
|
+
|
|
129
|
+
def get_by_username(self, username: str) -> User | None:
|
|
130
|
+
return self.first(username=username)
|
|
131
|
+
|
|
132
|
+
def get_by_email(self, email: str) -> User | None:
|
|
133
|
+
return self.first(email=email)
|
|
134
|
+
|
|
135
|
+
def get_active_by_email(self, email: str) -> User | None:
|
|
136
|
+
return (
|
|
137
|
+
self.statement()
|
|
138
|
+
.where(User.email == email, User.is_active.is_(True))
|
|
139
|
+
.scalars()
|
|
140
|
+
.first()
|
|
141
|
+
)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Full runnable examples with models, a multi-repo factory, and cross-repository usage:
|
|
145
|
+
|
|
146
|
+
- Sync: [`examples/typed_repository_sync.py`](./examples/typed_repository_sync.py)
|
|
147
|
+
- Async: [`examples/typed_repository_async.py`](./examples/typed_repository_async.py)
|
|
148
|
+
|
|
149
|
+
For async, swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
|
|
150
|
+
|
|
114
151
|
## Package layout
|
|
115
152
|
|
|
116
153
|
| Module | Contents |
|
|
@@ -72,6 +72,43 @@ with SessionLocal() as session:
|
|
|
72
72
|
|
|
73
73
|
**Async:** swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
|
|
74
74
|
|
|
75
|
+
## Strongly typed repositories (factory pattern)
|
|
76
|
+
|
|
77
|
+
Domain repositories subclass `BaseRepository[Model, RepositoryFactory]` (or `AsyncBaseRepository` for async) and add typed query helpers. A session-scoped factory implements `RepositoryFactory` to cache repositories and wire `statement()` / `for_repo()` across repos on the same session.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from sqlalchemy.orm import Session
|
|
81
|
+
|
|
82
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
83
|
+
from sqlphilosophy.sync.repository import BaseRepository
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class UserRepository(BaseRepository[User, RepositoryFactory]):
|
|
87
|
+
def __init__(self, session: Session, factory: RepositoryFactory) -> None:
|
|
88
|
+
super().__init__(User, session, factory)
|
|
89
|
+
|
|
90
|
+
def get_by_username(self, username: str) -> User | None:
|
|
91
|
+
return self.first(username=username)
|
|
92
|
+
|
|
93
|
+
def get_by_email(self, email: str) -> User | None:
|
|
94
|
+
return self.first(email=email)
|
|
95
|
+
|
|
96
|
+
def get_active_by_email(self, email: str) -> User | None:
|
|
97
|
+
return (
|
|
98
|
+
self.statement()
|
|
99
|
+
.where(User.email == email, User.is_active.is_(True))
|
|
100
|
+
.scalars()
|
|
101
|
+
.first()
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Full runnable examples with models, a multi-repo factory, and cross-repository usage:
|
|
106
|
+
|
|
107
|
+
- Sync: [`examples/typed_repository_sync.py`](./examples/typed_repository_sync.py)
|
|
108
|
+
- Async: [`examples/typed_repository_async.py`](./examples/typed_repository_async.py)
|
|
109
|
+
|
|
110
|
+
For async, swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
|
|
111
|
+
|
|
75
112
|
## Package layout
|
|
76
113
|
|
|
77
114
|
| Module | Contents |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.8
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Portable async repository factory and repository protocols (no Phobos or app imports)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
from typing import TypeVar
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sqlphilosophy.aio.repository import AsyncBaseRepository
|
|
14
|
+
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
17
|
+
|
|
18
|
+
from sqlphilosophy.aio.query import AsyncStatementQueryBuilder
|
|
19
|
+
from sqlphilosophy.types import IdList
|
|
20
|
+
from sqlphilosophy.types import PrimaryKey
|
|
21
|
+
from sqlphilosophy.types import RowMapping
|
|
22
|
+
from sqlphilosophy.types import RowValue
|
|
23
|
+
from sqlphilosophy.types import SqlFilter
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T", bound=DeclarativeBase)
|
|
26
|
+
R = TypeVar("R", bound="AsyncBaseRepository[Any, Any]")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncBaseRepositoryProtocol[T: DeclarativeBase, U: Optional[AsyncRepositoryFactory]](
|
|
30
|
+
Protocol,
|
|
31
|
+
):
|
|
32
|
+
"""Generic read/write surface shared by sqlphilosophy ``AsyncBaseRepository[T]``."""
|
|
33
|
+
|
|
34
|
+
model: type[T]
|
|
35
|
+
_session: AsyncSession
|
|
36
|
+
_factory: Optional[U]
|
|
37
|
+
|
|
38
|
+
async def get(self, obj_id: PrimaryKey, load_relations: Any = None) -> T: ...
|
|
39
|
+
|
|
40
|
+
async def get_by_id(self, obj_id: PrimaryKey, load_relations: Any = None) -> T | None: ...
|
|
41
|
+
|
|
42
|
+
async def get_many(
|
|
43
|
+
self, ids: Sequence[PrimaryKey], load_relations: Any = None
|
|
44
|
+
) -> Sequence[T]: ...
|
|
45
|
+
|
|
46
|
+
async def first(self, load_relations: Any = None, **filters: RowValue) -> T | None: ...
|
|
47
|
+
|
|
48
|
+
async def filter(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
page: int = 1,
|
|
52
|
+
limit: int | None = None,
|
|
53
|
+
load_relations: Any = None,
|
|
54
|
+
**filters: RowValue,
|
|
55
|
+
) -> Sequence[T]: ...
|
|
56
|
+
|
|
57
|
+
async def get_all(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
page: int = 1,
|
|
61
|
+
limit: int | None = None,
|
|
62
|
+
load_relations: Any = None,
|
|
63
|
+
) -> Sequence[T]: ...
|
|
64
|
+
|
|
65
|
+
async def count(self, **filters: RowValue) -> int: ...
|
|
66
|
+
|
|
67
|
+
async def exists(self, obj_id: PrimaryKey) -> bool: ...
|
|
68
|
+
|
|
69
|
+
async def exists_where(self, **filters: RowValue) -> bool: ...
|
|
70
|
+
|
|
71
|
+
def statement(self) -> AsyncStatementQueryBuilder[T]: ...
|
|
72
|
+
|
|
73
|
+
async def create(self, **fields: object) -> T: ...
|
|
74
|
+
|
|
75
|
+
async def add(self, obj: T) -> T: ...
|
|
76
|
+
|
|
77
|
+
async def get_or_create(
|
|
78
|
+
self, *, defaults: RowMapping | None = None, **lookup: RowValue
|
|
79
|
+
) -> tuple[T, bool]: ...
|
|
80
|
+
|
|
81
|
+
async def remove(self, obj_id: PrimaryKey) -> bool: ...
|
|
82
|
+
|
|
83
|
+
async def delete_many(self, ids: IdList) -> int: ...
|
|
84
|
+
|
|
85
|
+
async def delete_where(
|
|
86
|
+
self, *, criteria: Sequence[SqlFilter], params: RowMapping | None = None
|
|
87
|
+
) -> int: ...
|
|
88
|
+
|
|
89
|
+
async def update_partial(
|
|
90
|
+
self,
|
|
91
|
+
obj_id: PrimaryKey,
|
|
92
|
+
fields: RowMapping,
|
|
93
|
+
writable: frozenset[str],
|
|
94
|
+
*,
|
|
95
|
+
touch_updated_on: bool = False,
|
|
96
|
+
) -> int: ...
|
|
97
|
+
|
|
98
|
+
async def update_where(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
criteria: Sequence[SqlFilter],
|
|
102
|
+
values: RowMapping,
|
|
103
|
+
params: RowMapping | None = None,
|
|
104
|
+
) -> int: ...
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AsyncRepositoryFactory(Protocol):
|
|
108
|
+
"""Session-scoped factory for async statement builders and entity repositories."""
|
|
109
|
+
|
|
110
|
+
def create_statement(self, model: type[T]) -> AsyncStatementQueryBuilder[T]:
|
|
111
|
+
"""Return a fluent async read builder for ``model``."""
|
|
112
|
+
...
|
|
113
|
+
|
|
114
|
+
def get_repository(self, repo_class: type[R]) -> R:
|
|
115
|
+
"""Return a cached typed entity repository."""
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
def repository(
|
|
119
|
+
self, model: type[T]
|
|
120
|
+
) -> AsyncBaseRepositoryProtocol[T, Optional[AsyncRepositoryFactory]]:
|
|
121
|
+
"""Return generic CRUD helpers for ``model`` (``AsyncBaseRepository`` in Phobos)."""
|
|
122
|
+
...
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
from collections.abc import Sequence
|
|
5
5
|
from typing import Any
|
|
6
6
|
from typing import cast
|
|
7
|
+
from typing import Optional
|
|
7
8
|
from sqlalchemy import delete
|
|
8
9
|
from sqlalchemy import func
|
|
9
10
|
from sqlalchemy import inspect as sa_inspect
|
|
@@ -31,7 +32,7 @@ from sqlphilosophy.types import cursor_rowcount
|
|
|
31
32
|
LoadRelations = Sequence[LoaderOption]
|
|
32
33
|
|
|
33
34
|
|
|
34
|
-
class AsyncBaseRepository[T: DeclarativeBase]:
|
|
35
|
+
class AsyncBaseRepository[T: DeclarativeBase, U: Optional[AsyncRepositoryFactory]]:
|
|
35
36
|
"""Async session-scoped CRUD helpers for a single mapped model."""
|
|
36
37
|
|
|
37
38
|
def __init__(
|
|
@@ -41,7 +42,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
41
42
|
factory: AsyncRepositoryFactory | None = None,
|
|
42
43
|
) -> None:
|
|
43
44
|
self.model = model
|
|
44
|
-
self.
|
|
45
|
+
self._session = session
|
|
45
46
|
self._factory = factory
|
|
46
47
|
pk_cols = self.inspect_model(model).primary_key
|
|
47
48
|
if len(pk_cols) != 1:
|
|
@@ -59,7 +60,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
59
60
|
|
|
60
61
|
async def list_table_names(self) -> frozenset[str]:
|
|
61
62
|
"""Return visible table names on the session connection."""
|
|
62
|
-
connection = await self.
|
|
63
|
+
connection = await self._session.connection()
|
|
63
64
|
|
|
64
65
|
def _names(sync_conn: object) -> frozenset[str]:
|
|
65
66
|
insp = sa_inspect(sync_conn)
|
|
@@ -79,7 +80,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
79
80
|
return stmt
|
|
80
81
|
|
|
81
82
|
async def _scalar_result(self, stmt: Any, *, unique: bool = False) -> Any:
|
|
82
|
-
result = await self.
|
|
83
|
+
result = await self._session.scalars(stmt)
|
|
83
84
|
if unique:
|
|
84
85
|
return result.unique()
|
|
85
86
|
return result
|
|
@@ -88,19 +89,19 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
88
89
|
self, stmt: Any, params: RowMapping | None = None
|
|
89
90
|
) -> list[RowMapping]:
|
|
90
91
|
"""Execute ``stmt`` and return all rows as mappings."""
|
|
91
|
-
result = await self.
|
|
92
|
+
result = await self._session.execute(stmt, params or {})
|
|
92
93
|
mapped = result.mappings()
|
|
93
94
|
rows = mapped.all() if hasattr(mapped, "all") else mapped
|
|
94
95
|
return rows_mapping(rows)
|
|
95
96
|
|
|
96
97
|
async def scalar_count(self, stmt: SqlSelect, params: SqlBindParams | None = None) -> int:
|
|
97
98
|
"""Execute a scalar count/select statement and return ``int``."""
|
|
98
|
-
result = await self.
|
|
99
|
+
result = await self._session.execute(stmt, params or {})
|
|
99
100
|
return int(result.scalar_one())
|
|
100
101
|
|
|
101
102
|
async def iter_mappings(self, stmt: SqlSelect, params: SqlBindParams | None = None):
|
|
102
103
|
"""Yield each result row as a plain ``dict``."""
|
|
103
|
-
result = await self.
|
|
104
|
+
result = await self._session.execute(stmt, params or {})
|
|
104
105
|
for row in result.mappings():
|
|
105
106
|
yield dict(row)
|
|
106
107
|
|
|
@@ -108,7 +109,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
108
109
|
self, stmt: SqlSelect, params: SqlBindParams | None = None
|
|
109
110
|
) -> RowMapping | None:
|
|
110
111
|
"""Execute ``stmt`` and return the first row as a mapping, or ``None``."""
|
|
111
|
-
result = await self.
|
|
112
|
+
result = await self._session.execute(stmt, params or {})
|
|
112
113
|
row = result.mappings().first()
|
|
113
114
|
return dict(row) if row is not None else None
|
|
114
115
|
|
|
@@ -116,7 +117,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
116
117
|
self, stmt: SqlSelect, params: SqlBindParams | None = None
|
|
117
118
|
) -> RowMapping:
|
|
118
119
|
"""Execute ``stmt`` and return exactly one row as a mapping."""
|
|
119
|
-
result = await self.
|
|
120
|
+
result = await self._session.execute(stmt, params or {})
|
|
120
121
|
return dict(result.mappings().one())
|
|
121
122
|
|
|
122
123
|
async def fetch_mappings_page(
|
|
@@ -159,7 +160,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
159
160
|
"""Fetch a single record by primary key with optional eager loading."""
|
|
160
161
|
stmt = select(self.model).where(self._pk_column == obj_id)
|
|
161
162
|
stmt = self._apply_load_relations(stmt, load_relations)
|
|
162
|
-
result = await self.
|
|
163
|
+
result = await self._session.scalars(stmt)
|
|
163
164
|
return result.first()
|
|
164
165
|
|
|
165
166
|
async def exists(self, obj_id: PrimaryKey) -> bool:
|
|
@@ -175,7 +176,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
175
176
|
stmt = select(func.count()).select_from(self.model)
|
|
176
177
|
if filters:
|
|
177
178
|
stmt = stmt.filter_by(**filters)
|
|
178
|
-
result = await self.
|
|
179
|
+
result = await self._session.scalar(stmt)
|
|
179
180
|
return int(result or 0)
|
|
180
181
|
|
|
181
182
|
async def first(
|
|
@@ -184,7 +185,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
184
185
|
"""Return the first row matching filters, with optional eager loading."""
|
|
185
186
|
stmt = select(self.model).filter_by(**filters).limit(1)
|
|
186
187
|
stmt = self._apply_load_relations(stmt, load_relations)
|
|
187
|
-
result = await self.
|
|
188
|
+
result = await self._session.scalars(stmt)
|
|
188
189
|
return result.first()
|
|
189
190
|
|
|
190
191
|
async def get(self, obj_id: PrimaryKey, load_relations: LoadRelations | None = None) -> T:
|
|
@@ -258,7 +259,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
258
259
|
stmt = stmt.join(target_model) # pragma: no cover
|
|
259
260
|
if filter_expressions:
|
|
260
261
|
stmt = stmt.where(*filter_expressions)
|
|
261
|
-
result = await self.
|
|
262
|
+
result = await self._session.execute(stmt)
|
|
262
263
|
return cast(Sequence[tuple[T, Any]], result.all())
|
|
263
264
|
|
|
264
265
|
async def create(self, **fields: object) -> T:
|
|
@@ -280,8 +281,8 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
280
281
|
|
|
281
282
|
async def add(self, obj: T) -> T:
|
|
282
283
|
"""Stage a new instance; caller commits in the orchestration layer."""
|
|
283
|
-
self.
|
|
284
|
-
await self.
|
|
284
|
+
self._session.add(obj)
|
|
285
|
+
await self._session.flush()
|
|
285
286
|
return obj
|
|
286
287
|
|
|
287
288
|
async def update_partial(
|
|
@@ -297,12 +298,12 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
297
298
|
audit_updates = {k: v for k, v in fields.items() if k in writable}
|
|
298
299
|
if not audit_updates:
|
|
299
300
|
return 0
|
|
300
|
-
row = await self.
|
|
301
|
+
row = await self._session.get(self.model, obj_id)
|
|
301
302
|
if row is None:
|
|
302
303
|
return 0
|
|
303
304
|
for key, value in audit_updates.items():
|
|
304
305
|
setattr(row, key, value)
|
|
305
|
-
await self.
|
|
306
|
+
await self._session.flush()
|
|
306
307
|
return 1
|
|
307
308
|
core_updates: RowMapping = {k: v for k, v in fields.items() if k in writable}
|
|
308
309
|
if not core_updates:
|
|
@@ -314,7 +315,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
314
315
|
)
|
|
315
316
|
pk_col = self._pk_column
|
|
316
317
|
stmt = update(self.model).where(pk_col == obj_id).values(**core_updates)
|
|
317
|
-
result = await self.
|
|
318
|
+
result = await self._session.execute(stmt)
|
|
318
319
|
return cursor_rowcount(result)
|
|
319
320
|
|
|
320
321
|
async def update_where(
|
|
@@ -328,7 +329,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
328
329
|
if not values:
|
|
329
330
|
return 0
|
|
330
331
|
stmt = update(self.model).where(*criteria).values(**values)
|
|
331
|
-
result = await self.
|
|
332
|
+
result = await self._session.execute(stmt, params or {})
|
|
332
333
|
return cursor_rowcount(result)
|
|
333
334
|
|
|
334
335
|
async def delete_where(
|
|
@@ -351,7 +352,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
351
352
|
async def remove(self, obj_id: PrimaryKey) -> bool:
|
|
352
353
|
"""Delete a record by primary key."""
|
|
353
354
|
statement = delete(self.model).where(self._pk_column == obj_id)
|
|
354
|
-
result = await self.
|
|
355
|
+
result = await self._session.execute(statement)
|
|
355
356
|
return cursor_rowcount(result) > 0
|
|
356
357
|
|
|
357
358
|
async def delete_many(self, ids: IdList) -> int:
|
|
@@ -359,12 +360,12 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
359
360
|
if not ids:
|
|
360
361
|
return 0
|
|
361
362
|
stmt = delete(self.model).where(self._pk_column.in_(ids))
|
|
362
|
-
result = await self.
|
|
363
|
+
result = await self._session.execute(stmt)
|
|
363
364
|
return cursor_rowcount(result)
|
|
364
365
|
|
|
365
366
|
async def delete_all(self) -> int:
|
|
366
367
|
"""Delete every row for this model. Dev/ops only — prefer ``delete_where`` in app code."""
|
|
367
|
-
result = await self.
|
|
368
|
+
result = await self._session.execute(delete(self.model))
|
|
368
369
|
return cursor_rowcount(result)
|
|
369
370
|
|
|
370
371
|
async def batched_purge_ids(
|
|
@@ -389,17 +390,19 @@ class AsyncBaseRepository[T: DeclarativeBase]:
|
|
|
389
390
|
if not ids:
|
|
390
391
|
break
|
|
391
392
|
total += await self.delete_many(ids)
|
|
392
|
-
await self.
|
|
393
|
+
await self._session.commit()
|
|
393
394
|
return total
|
|
394
395
|
|
|
395
396
|
def statement(self) -> AsyncStatementQueryBuilder[T]:
|
|
396
397
|
"""Return a fluent statement builder for reads on this model (default read path)."""
|
|
397
398
|
if self._factory is not None:
|
|
398
399
|
return self._factory.create_statement(self.model)
|
|
399
|
-
return AsyncSqlAlchemyStatementBuilder(self.
|
|
400
|
+
return AsyncSqlAlchemyStatementBuilder(self._session, self.model)
|
|
400
401
|
|
|
401
|
-
def for_repo[R
|
|
402
|
+
def for_repo[R: AsyncBaseRepository[Any, Any]](
|
|
403
|
+
self, repo_class: type[R]
|
|
404
|
+
) -> R:
|
|
402
405
|
"""Return a typed entity repository sharing this session and factory."""
|
|
403
406
|
if self._factory is None:
|
|
404
407
|
raise RuntimeError("for_repo() requires an AsyncRepositoryFactory")
|
|
405
|
-
return self._factory.get_repository(repo_class)
|
|
408
|
+
return cast(R, self._factory.get_repository(repo_class))
|
|
@@ -49,20 +49,10 @@ def sql_table(table_name: str, *column_names: str) -> SqlTable:
|
|
|
49
49
|
|
|
50
50
|
def get_column_value(entity: object) -> ApiObject:
|
|
51
51
|
"""Return mapped column values for an ORM entity instance."""
|
|
52
|
-
|
|
53
|
-
if
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
from sqlphilosophy.sync.repository import BaseRepository
|
|
57
|
-
|
|
58
|
-
try:
|
|
59
|
-
insp = BaseRepository.inspect_model(type(entity))
|
|
60
|
-
except Exception as exc:
|
|
61
|
-
raise TypeError(f"{type(entity)!r} is not a mapped SQLAlchemy entity") from exc
|
|
62
|
-
if not hasattr(insp, "mapper"):
|
|
63
|
-
raise TypeError(f"{type(entity)!r} is not a mapped SQLAlchemy entity")
|
|
64
|
-
mapper = insp.mapper
|
|
65
|
-
return {attr.key: getattr(entity, attr.key) for attr in mapper.column_attrs}
|
|
52
|
+
insp = sa_inspect(type(entity), raiseerr=False)
|
|
53
|
+
if insp is None:
|
|
54
|
+
raise TypeError(f"{type(entity)!r} is not a mapped SQLAlchemy entity")
|
|
55
|
+
return {attr.key: getattr(entity, attr.key) for attr in insp.mapper.column_attrs}
|
|
66
56
|
|
|
67
57
|
|
|
68
58
|
def row_mapping(row: object) -> RowMapping:
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Portable repository factory and repository protocols (no Phobos or app imports)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
from typing import TypeVar
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sqlphilosophy.sync.repository import BaseRepository
|
|
14
|
+
|
|
15
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
16
|
+
from sqlalchemy.orm import Session
|
|
17
|
+
|
|
18
|
+
from sqlphilosophy.sync.query import StatementQueryBuilder
|
|
19
|
+
from sqlphilosophy.types import IdList
|
|
20
|
+
from sqlphilosophy.types import PrimaryKey
|
|
21
|
+
from sqlphilosophy.types import RowMapping
|
|
22
|
+
from sqlphilosophy.types import RowValue
|
|
23
|
+
from sqlphilosophy.types import SqlFilter
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T", bound=DeclarativeBase)
|
|
26
|
+
R = TypeVar("R", bound="BaseRepository[Any, Any]")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BaseRepositoryProtocol[T: DeclarativeBase, U: Optional[RepositoryFactory]](Protocol):
|
|
30
|
+
"""Generic read/write surface shared by sqlphilosophy ``BaseRepository[T]``."""
|
|
31
|
+
|
|
32
|
+
model: type[T]
|
|
33
|
+
_session: Session
|
|
34
|
+
_factory: Optional[U]
|
|
35
|
+
|
|
36
|
+
def get(self, obj_id: PrimaryKey, load_relations: Any = None) -> T: ...
|
|
37
|
+
|
|
38
|
+
def get_by_id(self, obj_id: PrimaryKey, load_relations: Any = None) -> T | None: ...
|
|
39
|
+
|
|
40
|
+
def get_many(
|
|
41
|
+
self, ids: Sequence[PrimaryKey], load_relations: Any = None
|
|
42
|
+
) -> Sequence[T]: ...
|
|
43
|
+
|
|
44
|
+
def first(self, load_relations: Any = None, **filters: RowValue) -> T | None: ...
|
|
45
|
+
|
|
46
|
+
def filter(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
page: int = 1,
|
|
50
|
+
limit: int | None = None,
|
|
51
|
+
load_relations: Any = None,
|
|
52
|
+
**filters: RowValue,
|
|
53
|
+
) -> Sequence[T]: ...
|
|
54
|
+
|
|
55
|
+
def get_all(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
page: int = 1,
|
|
59
|
+
limit: int | None = None,
|
|
60
|
+
load_relations: Any = None,
|
|
61
|
+
) -> Sequence[T]: ...
|
|
62
|
+
|
|
63
|
+
def count(self, **filters: RowValue) -> int: ...
|
|
64
|
+
|
|
65
|
+
def exists(self, obj_id: PrimaryKey) -> bool: ...
|
|
66
|
+
|
|
67
|
+
def exists_where(self, **filters: RowValue) -> bool: ...
|
|
68
|
+
|
|
69
|
+
def statement(self) -> StatementQueryBuilder[T]: ...
|
|
70
|
+
|
|
71
|
+
def create(self, **fields: object) -> T: ...
|
|
72
|
+
|
|
73
|
+
def add(self, obj: T) -> T: ...
|
|
74
|
+
|
|
75
|
+
def get_or_create(
|
|
76
|
+
self, *, defaults: RowMapping | None = None, **lookup: RowValue
|
|
77
|
+
) -> tuple[T, bool]: ...
|
|
78
|
+
|
|
79
|
+
def remove(self, obj_id: PrimaryKey) -> bool: ...
|
|
80
|
+
|
|
81
|
+
def delete_many(self, ids: IdList) -> int: ...
|
|
82
|
+
|
|
83
|
+
def delete_where(
|
|
84
|
+
self, *, criteria: Sequence[SqlFilter], params: RowMapping | None = None
|
|
85
|
+
) -> int: ...
|
|
86
|
+
|
|
87
|
+
def update_partial(
|
|
88
|
+
self,
|
|
89
|
+
obj_id: PrimaryKey,
|
|
90
|
+
fields: RowMapping,
|
|
91
|
+
writable: frozenset[str],
|
|
92
|
+
*,
|
|
93
|
+
touch_updated_on: bool = False,
|
|
94
|
+
) -> int: ...
|
|
95
|
+
|
|
96
|
+
def update_where(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
criteria: Sequence[SqlFilter],
|
|
100
|
+
values: RowMapping,
|
|
101
|
+
params: RowMapping | None = None,
|
|
102
|
+
) -> int: ...
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RepositoryFactory(Protocol):
|
|
106
|
+
"""Session-scoped factory for statement builders and entity repositories."""
|
|
107
|
+
|
|
108
|
+
def create_statement(self, model: type[T]) -> StatementQueryBuilder[T]:
|
|
109
|
+
"""Return a fluent read builder for ``model``."""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
def get_repository(self, repo_class: type[R]) -> R:
|
|
113
|
+
"""Return a cached typed entity repository."""
|
|
114
|
+
...
|
|
115
|
+
|
|
116
|
+
def repository(
|
|
117
|
+
self, model: type[T]
|
|
118
|
+
) -> BaseRepositoryProtocol[T, Optional[RepositoryFactory]]:
|
|
119
|
+
"""Return generic CRUD helpers for ``model`` (``BaseRepository`` in Phobos)."""
|
|
120
|
+
...
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
from collections.abc import Sequence
|
|
5
5
|
from typing import Any
|
|
6
6
|
from typing import cast
|
|
7
|
+
from typing import Optional
|
|
7
8
|
from sqlalchemy import delete
|
|
8
9
|
from sqlalchemy import func
|
|
9
10
|
from sqlalchemy import inspect as sa_inspect
|
|
@@ -14,6 +15,7 @@ from sqlalchemy.orm import Session
|
|
|
14
15
|
from sqlalchemy.orm.interfaces import LoaderOption
|
|
15
16
|
from sqlphilosophy.sorting import ListQuery
|
|
16
17
|
from sqlphilosophy.sorting import SortConfig
|
|
18
|
+
from sqlphilosophy.sql import apply_mappings_page
|
|
17
19
|
from sqlphilosophy.sql import delete_by_ids_model
|
|
18
20
|
from sqlphilosophy.sql import partial_update_model
|
|
19
21
|
from sqlphilosophy.sql import rows_mapping
|
|
@@ -32,7 +34,7 @@ from sqlphilosophy.types import cursor_rowcount
|
|
|
32
34
|
LoadRelations = Sequence[LoaderOption]
|
|
33
35
|
|
|
34
36
|
|
|
35
|
-
class BaseRepository[T: DeclarativeBase]:
|
|
37
|
+
class BaseRepository[T: DeclarativeBase, U: Optional[RepositoryFactory]]:
|
|
36
38
|
"""Session-scoped CRUD helpers for a single mapped model."""
|
|
37
39
|
|
|
38
40
|
def __init__(
|
|
@@ -42,7 +44,7 @@ class BaseRepository[T: DeclarativeBase]:
|
|
|
42
44
|
factory: RepositoryFactory | None = None,
|
|
43
45
|
) -> None:
|
|
44
46
|
self.model = model
|
|
45
|
-
self.
|
|
47
|
+
self._session = session
|
|
46
48
|
self._factory = factory
|
|
47
49
|
pk_cols = self.inspect_model(model).primary_key
|
|
48
50
|
if len(pk_cols) != 1:
|
|
@@ -60,7 +62,7 @@ class BaseRepository[T: DeclarativeBase]:
|
|
|
60
62
|
|
|
61
63
|
def list_table_names(self) -> frozenset[str]:
|
|
62
64
|
"""Return visible table names on the session connection."""
|
|
63
|
-
return frozenset(sa_inspect(self.
|
|
65
|
+
return frozenset(sa_inspect(self._session.connection()).get_table_names())
|
|
64
66
|
|
|
65
67
|
def has_table(self, table_name: str) -> bool:
|
|
66
68
|
"""True when ``table_name`` exists on the session connection."""
|
|
@@ -73,36 +75,36 @@ class BaseRepository[T: DeclarativeBase]:
|
|
|
73
75
|
|
|
74
76
|
def _scalar_result(self, stmt: Any, *, unique: bool = False) -> Any:
|
|
75
77
|
if unique:
|
|
76
|
-
return self.
|
|
77
|
-
return self.
|
|
78
|
+
return self._session.scalars(stmt).unique()
|
|
79
|
+
return self._session.scalars(stmt)
|
|
78
80
|
|
|
79
81
|
def fetch_statement_mappings(
|
|
80
82
|
self, stmt: Any, params: RowMapping | None = None
|
|
81
83
|
) -> list[RowMapping]:
|
|
82
84
|
"""Execute ``stmt`` and return all rows as mappings."""
|
|
83
|
-
mapped = self.
|
|
85
|
+
mapped = self._session.execute(stmt, params or {}).mappings()
|
|
84
86
|
rows = mapped.all() if hasattr(mapped, "all") else mapped
|
|
85
87
|
return rows_mapping(rows)
|
|
86
88
|
|
|
87
89
|
def scalar_count(self, stmt: SqlSelect, params: SqlBindParams | None = None) -> int:
|
|
88
90
|
"""Execute a scalar count/select statement and return ``int``."""
|
|
89
|
-
return int(self.
|
|
91
|
+
return int(self._session.execute(stmt, params or {}).scalar_one())
|
|
90
92
|
|
|
91
93
|
def iter_mappings(self, stmt: SqlSelect, params: SqlBindParams | None = None):
|
|
92
94
|
"""Yield each result row as a plain ``dict``."""
|
|
93
|
-
for row in self.
|
|
95
|
+
for row in self._session.execute(stmt, params or {}).mappings():
|
|
94
96
|
yield dict(row)
|
|
95
97
|
|
|
96
98
|
def fetch_mapping_first(
|
|
97
99
|
self, stmt: SqlSelect, params: SqlBindParams | None = None
|
|
98
100
|
) -> RowMapping | None:
|
|
99
101
|
"""Execute ``stmt`` and return the first row as a mapping, or ``None``."""
|
|
100
|
-
row = self.
|
|
102
|
+
row = self._session.execute(stmt, params or {}).mappings().first()
|
|
101
103
|
return dict(row) if row is not None else None
|
|
102
104
|
|
|
103
105
|
def fetch_mapping_one(self, stmt: SqlSelect, params: SqlBindParams | None = None) -> RowMapping:
|
|
104
106
|
"""Execute ``stmt`` and return exactly one row as a mapping."""
|
|
105
|
-
return dict(self.
|
|
107
|
+
return dict(self._session.execute(stmt, params or {}).mappings().one())
|
|
106
108
|
|
|
107
109
|
def fetch_mappings_page(
|
|
108
110
|
self,
|
|
@@ -113,11 +115,13 @@ class BaseRepository[T: DeclarativeBase]:
|
|
|
113
115
|
params: RowMapping | None = None,
|
|
114
116
|
) -> list[RowMapping]:
|
|
115
117
|
"""Execute ``stmt`` with limit/offset; return normalized row mappings."""
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
return apply_mappings_page(
|
|
119
|
+
self._session,
|
|
120
|
+
stmt,
|
|
121
|
+
limit=limit,
|
|
122
|
+
offset=offset,
|
|
123
|
+
params=params,
|
|
124
|
+
)
|
|
121
125
|
|
|
122
126
|
def fetch_sorted_mappings(
|
|
123
127
|
self,
|
|
@@ -143,7 +147,7 @@ class BaseRepository[T: DeclarativeBase]:
|
|
|
143
147
|
"""Fetch a single record by primary key with optional eager loading."""
|
|
144
148
|
stmt = select(self.model).where(self._pk_column == obj_id)
|
|
145
149
|
stmt = self._apply_load_relations(stmt, load_relations)
|
|
146
|
-
return self.
|
|
150
|
+
return self._session.scalar(stmt)
|
|
147
151
|
|
|
148
152
|
def exists(self, obj_id: PrimaryKey) -> bool:
|
|
149
153
|
"""True when a row exists for the primary key."""
|
|
@@ -158,13 +162,13 @@ class BaseRepository[T: DeclarativeBase]:
|
|
|
158
162
|
stmt = select(func.count()).select_from(self.model)
|
|
159
163
|
if filters:
|
|
160
164
|
stmt = stmt.filter_by(**filters)
|
|
161
|
-
return int(self.
|
|
165
|
+
return int(self._session.scalar(stmt) or 0)
|
|
162
166
|
|
|
163
167
|
def first(self, load_relations: LoadRelations | None = None, **filters: RowValue) -> T | None:
|
|
164
168
|
"""Return the first row matching filters, with optional eager loading."""
|
|
165
169
|
stmt = select(self.model).filter_by(**filters).limit(1)
|
|
166
170
|
stmt = self._apply_load_relations(stmt, load_relations)
|
|
167
|
-
return self.
|
|
171
|
+
return self._session.scalar(stmt)
|
|
168
172
|
|
|
169
173
|
def get(self, obj_id: PrimaryKey, load_relations: LoadRelations | None = None) -> T:
|
|
170
174
|
"""Fetch a single record by primary key; raise if missing."""
|
|
@@ -234,7 +238,7 @@ class BaseRepository[T: DeclarativeBase]:
|
|
|
234
238
|
stmt = stmt.join(target_model) # pragma: no cover
|
|
235
239
|
if filter_expressions:
|
|
236
240
|
stmt = stmt.where(*filter_expressions)
|
|
237
|
-
return cast(Sequence[tuple[T, Any]], self.
|
|
241
|
+
return cast(Sequence[tuple[T, Any]], self._session.execute(stmt).all())
|
|
238
242
|
|
|
239
243
|
def create(self, **fields: object) -> T:
|
|
240
244
|
"""Construct, stage, and flush a new instance."""
|
|
@@ -255,8 +259,8 @@ class BaseRepository[T: DeclarativeBase]:
|
|
|
255
259
|
|
|
256
260
|
def add(self, obj: T) -> T:
|
|
257
261
|
"""Stage a new instance; caller commits in the orchestration layer."""
|
|
258
|
-
self.
|
|
259
|
-
self.
|
|
262
|
+
self._session.add(obj)
|
|
263
|
+
self._session.flush()
|
|
260
264
|
return obj
|
|
261
265
|
|
|
262
266
|
def update_partial(
|
|
@@ -269,7 +273,7 @@ class BaseRepository[T: DeclarativeBase]:
|
|
|
269
273
|
) -> int:
|
|
270
274
|
"""Apply a partial update; returns affected row count (0 if none)."""
|
|
271
275
|
return partial_update_model(
|
|
272
|
-
self.
|
|
276
|
+
self._session,
|
|
273
277
|
self.model,
|
|
274
278
|
obj_id,
|
|
275
279
|
fields,
|
|
@@ -288,7 +292,7 @@ class BaseRepository[T: DeclarativeBase]:
|
|
|
288
292
|
if not values:
|
|
289
293
|
return 0
|
|
290
294
|
stmt = update(self.model).where(*criteria).values(**values)
|
|
291
|
-
result = self.
|
|
295
|
+
result = self._session.execute(stmt, params or {})
|
|
292
296
|
return cursor_rowcount(result)
|
|
293
297
|
|
|
294
298
|
def delete_where(
|
|
@@ -311,16 +315,16 @@ class BaseRepository[T: DeclarativeBase]:
|
|
|
311
315
|
def remove(self, obj_id: PrimaryKey) -> bool:
|
|
312
316
|
"""Delete a record by primary key."""
|
|
313
317
|
statement = delete(self.model).where(self._pk_column == obj_id)
|
|
314
|
-
result = self.
|
|
318
|
+
result = self._session.execute(statement)
|
|
315
319
|
return cursor_rowcount(result) > 0
|
|
316
320
|
|
|
317
321
|
def delete_many(self, ids: IdList) -> int:
|
|
318
322
|
"""Delete multiple records by primary key."""
|
|
319
|
-
return delete_by_ids_model(self.
|
|
323
|
+
return delete_by_ids_model(self._session, self.model, list(ids))
|
|
320
324
|
|
|
321
325
|
def delete_all(self) -> int:
|
|
322
326
|
"""Delete every row for this model. Dev/ops only — prefer ``delete_where`` in app code."""
|
|
323
|
-
result = self.
|
|
327
|
+
result = self._session.execute(delete(self.model))
|
|
324
328
|
return cursor_rowcount(result)
|
|
325
329
|
|
|
326
330
|
def batched_purge_ids(
|
|
@@ -345,17 +349,19 @@ class BaseRepository[T: DeclarativeBase]:
|
|
|
345
349
|
if not ids:
|
|
346
350
|
break
|
|
347
351
|
total += self.delete_many(ids)
|
|
348
|
-
self.
|
|
352
|
+
self._session.commit()
|
|
349
353
|
return total
|
|
350
354
|
|
|
351
355
|
def statement(self) -> StatementQueryBuilder[T]:
|
|
352
356
|
"""Return a fluent statement builder for reads on this model (default read path)."""
|
|
353
357
|
if self._factory is not None:
|
|
354
358
|
return self._factory.create_statement(self.model)
|
|
355
|
-
return SqlAlchemyStatementBuilder(self.
|
|
359
|
+
return SqlAlchemyStatementBuilder(self._session, self.model)
|
|
356
360
|
|
|
357
|
-
def for_repo[R
|
|
361
|
+
def for_repo[R: BaseRepository[Any, Any]](
|
|
362
|
+
self, repo_class: type[R]
|
|
363
|
+
) -> R:
|
|
358
364
|
"""Return a typed entity repository sharing this session and factory."""
|
|
359
365
|
if self._factory is None:
|
|
360
366
|
raise RuntimeError("for_repo() requires a RepositoryFactory")
|
|
361
|
-
return self._factory.get_repository(repo_class)
|
|
367
|
+
return cast(R, self._factory.get_repository(repo_class))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlphilosophy
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Portable SQLAlchemy repository kit: sync and async CRUD, statement builders, sort/pagination, and SQL helpers.
|
|
5
5
|
Author-email: Josh Martin <denverprogrammer@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -111,6 +111,43 @@ with SessionLocal() as session:
|
|
|
111
111
|
|
|
112
112
|
**Async:** swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
|
|
113
113
|
|
|
114
|
+
## Strongly typed repositories (factory pattern)
|
|
115
|
+
|
|
116
|
+
Domain repositories subclass `BaseRepository[Model, RepositoryFactory]` (or `AsyncBaseRepository` for async) and add typed query helpers. A session-scoped factory implements `RepositoryFactory` to cache repositories and wire `statement()` / `for_repo()` across repos on the same session.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from sqlalchemy.orm import Session
|
|
120
|
+
|
|
121
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
122
|
+
from sqlphilosophy.sync.repository import BaseRepository
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class UserRepository(BaseRepository[User, RepositoryFactory]):
|
|
126
|
+
def __init__(self, session: Session, factory: RepositoryFactory) -> None:
|
|
127
|
+
super().__init__(User, session, factory)
|
|
128
|
+
|
|
129
|
+
def get_by_username(self, username: str) -> User | None:
|
|
130
|
+
return self.first(username=username)
|
|
131
|
+
|
|
132
|
+
def get_by_email(self, email: str) -> User | None:
|
|
133
|
+
return self.first(email=email)
|
|
134
|
+
|
|
135
|
+
def get_active_by_email(self, email: str) -> User | None:
|
|
136
|
+
return (
|
|
137
|
+
self.statement()
|
|
138
|
+
.where(User.email == email, User.is_active.is_(True))
|
|
139
|
+
.scalars()
|
|
140
|
+
.first()
|
|
141
|
+
)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Full runnable examples with models, a multi-repo factory, and cross-repository usage:
|
|
145
|
+
|
|
146
|
+
- Sync: [`examples/typed_repository_sync.py`](./examples/typed_repository_sync.py)
|
|
147
|
+
- Async: [`examples/typed_repository_async.py`](./examples/typed_repository_async.py)
|
|
148
|
+
|
|
149
|
+
For async, swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
|
|
150
|
+
|
|
114
151
|
## Package layout
|
|
115
152
|
|
|
116
153
|
| Module | Contents |
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.1.4
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
"""Portable async repository factory protocol (no Phobos or app imports)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
from typing import Protocol
|
|
5
|
-
from typing import TypeVar
|
|
6
|
-
from sqlalchemy.orm import DeclarativeBase
|
|
7
|
-
from sqlphilosophy.aio.query import AsyncStatementQueryBuilder
|
|
8
|
-
|
|
9
|
-
T = TypeVar("T", bound=DeclarativeBase)
|
|
10
|
-
R = TypeVar("R")
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class AsyncRepositoryFactory(Protocol):
|
|
14
|
-
"""Session-scoped factory for async statement builders and entity repositories."""
|
|
15
|
-
|
|
16
|
-
def create_statement(self, model: type[T]) -> AsyncStatementQueryBuilder[T]:
|
|
17
|
-
"""Return a fluent async read builder for ``model``."""
|
|
18
|
-
...
|
|
19
|
-
|
|
20
|
-
def get_repository(self, repo_class: type[R]) -> R:
|
|
21
|
-
"""Return a cached typed entity repository."""
|
|
22
|
-
...
|
|
23
|
-
|
|
24
|
-
def repository(self, model: type[T]) -> object:
|
|
25
|
-
"""Return generic CRUD helpers for ``model`` (``AsyncBaseRepository`` in Phobos)."""
|
|
26
|
-
...
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
"""Portable repository factory protocol (no Phobos or app imports)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
from typing import Protocol
|
|
5
|
-
from typing import TypeVar
|
|
6
|
-
from sqlalchemy.orm import DeclarativeBase
|
|
7
|
-
from sqlphilosophy.sync.query import StatementQueryBuilder
|
|
8
|
-
|
|
9
|
-
T = TypeVar("T", bound=DeclarativeBase)
|
|
10
|
-
R = TypeVar("R")
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class RepositoryFactory(Protocol):
|
|
14
|
-
"""Session-scoped factory for statement builders and entity repositories."""
|
|
15
|
-
|
|
16
|
-
def create_statement(self, model: type[T]) -> StatementQueryBuilder[T]:
|
|
17
|
-
"""Return a fluent read builder for ``model``."""
|
|
18
|
-
...
|
|
19
|
-
|
|
20
|
-
def get_repository(self, repo_class: type[R]) -> R:
|
|
21
|
-
"""Return a cached typed entity repository."""
|
|
22
|
-
...
|
|
23
|
-
|
|
24
|
-
def repository(self, model: type[T]) -> object:
|
|
25
|
-
"""Return generic CRUD helpers for ``model`` (``BaseRepository`` in Phobos)."""
|
|
26
|
-
...
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|