spakky-sqlalchemy 5.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spakky_sqlalchemy-5.0.1/PKG-INFO +255 -0
- spakky_sqlalchemy-5.0.1/README.md +243 -0
- spakky_sqlalchemy-5.0.1/pyproject.toml +80 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/__init__.py +4 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/common/__init__.py +0 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/common/config.py +71 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/common/constants.py +1 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/error.py +7 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/main.py +41 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/orm/__init__.py +0 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/orm/error.py +7 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/orm/schema_registry.py +78 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/orm/table.py +83 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/persistency/__init__.py +0 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/persistency/connection_manager.py +76 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/persistency/error.py +7 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/persistency/repository.py +405 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/persistency/session_manager.py +103 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/persistency/transaction.py +68 -0
- spakky_sqlalchemy-5.0.1/src/spakky/plugins/sqlalchemy/py.typed +0 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: spakky-sqlalchemy
|
|
3
|
+
Version: 5.0.1
|
|
4
|
+
Summary: SQLAlchemy plugin for Spakky framework
|
|
5
|
+
Author: Spakky
|
|
6
|
+
Author-email: Spakky <sejong418@icloud.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Dist: spakky-data>=5.0.1
|
|
9
|
+
Requires-Dist: sqlalchemy>=2.0.45
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Spakky SQLAlchemy
|
|
14
|
+
|
|
15
|
+
SQLAlchemy integration plugin for [Spakky Framework](https://github.com/E5presso/spakky-framework).
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install spakky-sqlalchemy
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or install via Spakky extras:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install spakky[sqlalchemy]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
Set environment variables with the `SPAKKY_SQLALCHEMY__` prefix:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Required
|
|
35
|
+
export SPAKKY_SQLALCHEMY__CONNECTION_STRING="postgresql+psycopg://user:pass@localhost/db"
|
|
36
|
+
|
|
37
|
+
# Engine options (optional)
|
|
38
|
+
export SPAKKY_SQLALCHEMY__ECHO="false"
|
|
39
|
+
export SPAKKY_SQLALCHEMY__ECHO_POOL="false"
|
|
40
|
+
|
|
41
|
+
# Connection pool options (optional)
|
|
42
|
+
export SPAKKY_SQLALCHEMY__POOL_SIZE="5"
|
|
43
|
+
export SPAKKY_SQLALCHEMY__POOL_MAX_OVERFLOW="10"
|
|
44
|
+
export SPAKKY_SQLALCHEMY__POOL_TIMEOUT="30.0"
|
|
45
|
+
export SPAKKY_SQLALCHEMY__POOL_RECYCLE="-1"
|
|
46
|
+
export SPAKKY_SQLALCHEMY__POOL_PRE_PING="false"
|
|
47
|
+
|
|
48
|
+
# Session options (optional)
|
|
49
|
+
export SPAKKY_SQLALCHEMY__SESSION_AUTOFLUSH="true"
|
|
50
|
+
export SPAKKY_SQLALCHEMY__SESSION_EXPIRE_ON_COMMIT="true"
|
|
51
|
+
|
|
52
|
+
# Transaction options (optional)
|
|
53
|
+
export SPAKKY_SQLALCHEMY__AUTOCOMMIT="true"
|
|
54
|
+
|
|
55
|
+
# Async support (optional)
|
|
56
|
+
export SPAKKY_SQLALCHEMY__SUPPORT_ASYNC_MODE="true"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
### Defining Tables with Domain Mapping
|
|
62
|
+
|
|
63
|
+
Use `@Table` decorator and inherit from `AbstractTable` to define ORM tables with domain model mapping:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from uuid import UUID
|
|
67
|
+
from sqlalchemy import String
|
|
68
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
69
|
+
from spakky.plugins.sqlalchemy.orm.table import AbstractTable, Table
|
|
70
|
+
from spakky.domain.models.aggregate_root import AbstractAggregateRoot
|
|
71
|
+
|
|
72
|
+
# Domain model
|
|
73
|
+
class User(AbstractAggregateRoot[UUID]):
|
|
74
|
+
id: UUID
|
|
75
|
+
name: str
|
|
76
|
+
email: str
|
|
77
|
+
|
|
78
|
+
# Table definition with domain mapping
|
|
79
|
+
@Table()
|
|
80
|
+
class UserTable(AbstractMappableTable[User]):
|
|
81
|
+
__tablename__ = "users"
|
|
82
|
+
|
|
83
|
+
id: Mapped[UUID] = mapped_column(primary_key=True)
|
|
84
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
85
|
+
email: Mapped[str] = mapped_column(String(255))
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_domain(cls, domain: User) -> "UserTable":
|
|
89
|
+
return cls(id=domain.id, name=domain.name, email=domain.email)
|
|
90
|
+
|
|
91
|
+
def to_domain(self) -> User:
|
|
92
|
+
return User(id=self.id, name=self.name, email=self.email)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Repository Implementation
|
|
96
|
+
|
|
97
|
+
Extend `AbstractGenericRepository` or `AbstractAsyncGenericRepository`:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from uuid import UUID
|
|
101
|
+
from spakky.data.stereotype.repository import Repository
|
|
102
|
+
from spakky.plugins.sqlalchemy.persistency.repository import (
|
|
103
|
+
AbstractGenericRepository,
|
|
104
|
+
AbstractAsyncGenericRepository,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Synchronous repository
|
|
108
|
+
@Repository()
|
|
109
|
+
class UserRepository(AbstractGenericRepository[User, UUID]):
|
|
110
|
+
pass # CRUD methods inherited
|
|
111
|
+
|
|
112
|
+
# Asynchronous repository
|
|
113
|
+
@Repository()
|
|
114
|
+
class AsyncUserRepository(AbstractAsyncGenericRepository[User, UUID]):
|
|
115
|
+
pass # Async CRUD methods inherited
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Using Transactions
|
|
119
|
+
|
|
120
|
+
Use the `@Transactional` decorator from `spakky-data`:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from spakky.core.stereotype.usecase import UseCase
|
|
124
|
+
from spakky.data.aspects.transactional import Transactional
|
|
125
|
+
|
|
126
|
+
@UseCase()
|
|
127
|
+
class CreateUserUseCase:
|
|
128
|
+
def __init__(self, user_repo: UserRepository) -> None:
|
|
129
|
+
self._user_repo = user_repo
|
|
130
|
+
|
|
131
|
+
@Transactional()
|
|
132
|
+
def execute(self, name: str, email: str) -> User:
|
|
133
|
+
user = User.create(name, email)
|
|
134
|
+
return self._user_repo.save(user)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Async Transactions
|
|
138
|
+
|
|
139
|
+
The same `@Transactional()` decorator works for both sync and async methods.
|
|
140
|
+
The framework automatically selects the correct aspect based on whether the method is a coroutine.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from spakky.data.aspects.transactional import Transactional
|
|
144
|
+
|
|
145
|
+
@UseCase()
|
|
146
|
+
class AsyncCreateUserUseCase:
|
|
147
|
+
def __init__(self, user_repo: AsyncUserRepository) -> None:
|
|
148
|
+
self._user_repo = user_repo
|
|
149
|
+
|
|
150
|
+
@Transactional()
|
|
151
|
+
async def execute(self, name: str, email: str) -> User:
|
|
152
|
+
user = User.create(name, email)
|
|
153
|
+
return await self._user_repo.save(user)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Accessing Session Directly
|
|
157
|
+
|
|
158
|
+
For complex queries, access the SQLAlchemy session directly in **QueryUseCase**.
|
|
159
|
+
Following CQRS principles, queries should be implemented directly rather than
|
|
160
|
+
adding query methods to repositories.
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from spakky.core.common.mutability import immutable
|
|
164
|
+
from spakky.core.stereotype.usecase import UseCase
|
|
165
|
+
from spakky.domain.application.query import AbstractQuery, IAsyncQueryUseCase
|
|
166
|
+
from spakky.plugins.sqlalchemy.persistency.session_manager import AsyncSessionManager
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@immutable
|
|
170
|
+
class FindUserByEmailQuery(AbstractQuery):
|
|
171
|
+
email: str
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@immutable
|
|
175
|
+
class UserDTO:
|
|
176
|
+
id: UUID
|
|
177
|
+
name: str
|
|
178
|
+
email: str
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@UseCase()
|
|
182
|
+
class FindUserByEmailUseCase(IAsyncQueryUseCase[FindUserByEmailQuery, UserDTO | None]):
|
|
183
|
+
def __init__(self, session_manager: AsyncSessionManager) -> None:
|
|
184
|
+
self._session_manager = session_manager
|
|
185
|
+
|
|
186
|
+
async def run(self, query: FindUserByEmailQuery) -> UserDTO | None:
|
|
187
|
+
result = await self._session_manager.session.execute(
|
|
188
|
+
select(UserTable).where(UserTable.email == query.email)
|
|
189
|
+
)
|
|
190
|
+
table = result.scalar_one_or_none()
|
|
191
|
+
if table is None:
|
|
192
|
+
return None
|
|
193
|
+
return UserDTO(id=table.id, name=table.name, email=table.email)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Schema Registry
|
|
197
|
+
|
|
198
|
+
Access `SchemaRegistry` to get table metadata for migrations:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from spakky.plugins.sqlalchemy.orm.schema_registry import SchemaRegistry
|
|
202
|
+
|
|
203
|
+
@Pod()
|
|
204
|
+
class MigrationService:
|
|
205
|
+
def __init__(self, schema_registry: SchemaRegistry) -> None:
|
|
206
|
+
self._schema_registry = schema_registry
|
|
207
|
+
|
|
208
|
+
def get_metadata(self) -> MetaData:
|
|
209
|
+
return self._schema_registry.metadata
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Features
|
|
213
|
+
|
|
214
|
+
- **Domain-Table mapping**: Bidirectional conversion between domain models and ORM tables
|
|
215
|
+
- **Generic repositories**: Pre-built CRUD operations with composite PK support
|
|
216
|
+
- **Sync and Async support**: Full support for both synchronous and asynchronous operations
|
|
217
|
+
- **Scoped sessions**: Thread/context-safe session management
|
|
218
|
+
- **Optimistic locking**: Built-in `VersionConflictError` for concurrent updates
|
|
219
|
+
- **Schema registry**: Centralized table metadata management
|
|
220
|
+
|
|
221
|
+
## Components
|
|
222
|
+
|
|
223
|
+
| Component | Description |
|
|
224
|
+
|-----------|-------------|
|
|
225
|
+
| `@Table` | Decorator for registering ORM tables with domain mapping |
|
|
226
|
+
| `AbstractTable` | Base class for ORM tables with `from_domain`/`to_domain` |
|
|
227
|
+
| `AbstractGenericRepository` | Sync repository with CRUD operations |
|
|
228
|
+
| `AbstractAsyncGenericRepository` | Async repository with CRUD operations |
|
|
229
|
+
| `SchemaRegistry` | Central registry for table-domain mappings |
|
|
230
|
+
| `SessionManager` | Sync scoped session management |
|
|
231
|
+
| `AsyncSessionManager` | Async scoped session management |
|
|
232
|
+
| `Transaction` | Sync transaction implementation |
|
|
233
|
+
| `AsyncTransaction` | Async transaction implementation |
|
|
234
|
+
| `ConnectionManager` | Sync SQLAlchemy engine lifecycle |
|
|
235
|
+
| `AsyncConnectionManager` | Async SQLAlchemy engine lifecycle |
|
|
236
|
+
| `SQLAlchemyConnectionConfig` | Configuration via environment variables |
|
|
237
|
+
|
|
238
|
+
## Repository Methods
|
|
239
|
+
|
|
240
|
+
Both sync and async repositories provide:
|
|
241
|
+
|
|
242
|
+
| Method | Description |
|
|
243
|
+
|--------|-------------|
|
|
244
|
+
| `get(id)` | Get aggregate by ID, raises `EntityNotFoundError` if not found |
|
|
245
|
+
| `get_or_none(id)` | Get aggregate by ID, returns `None` if not found |
|
|
246
|
+
| `contains(id)` | Check if aggregate exists |
|
|
247
|
+
| `range(ids)` | Get multiple aggregates by ID list |
|
|
248
|
+
| `save(aggregate)` | Save (insert or update) an aggregate |
|
|
249
|
+
| `save_all(aggregates)` | Save multiple aggregates |
|
|
250
|
+
| `delete(aggregate)` | Delete an aggregate |
|
|
251
|
+
| `delete_all(aggregates)` | Delete multiple aggregates |
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
MIT License
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Spakky SQLAlchemy
|
|
2
|
+
|
|
3
|
+
SQLAlchemy integration plugin for [Spakky Framework](https://github.com/E5presso/spakky-framework).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install spakky-sqlalchemy
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install via Spakky extras:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install spakky[sqlalchemy]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
Set environment variables with the `SPAKKY_SQLALCHEMY__` prefix:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Required
|
|
23
|
+
export SPAKKY_SQLALCHEMY__CONNECTION_STRING="postgresql+psycopg://user:pass@localhost/db"
|
|
24
|
+
|
|
25
|
+
# Engine options (optional)
|
|
26
|
+
export SPAKKY_SQLALCHEMY__ECHO="false"
|
|
27
|
+
export SPAKKY_SQLALCHEMY__ECHO_POOL="false"
|
|
28
|
+
|
|
29
|
+
# Connection pool options (optional)
|
|
30
|
+
export SPAKKY_SQLALCHEMY__POOL_SIZE="5"
|
|
31
|
+
export SPAKKY_SQLALCHEMY__POOL_MAX_OVERFLOW="10"
|
|
32
|
+
export SPAKKY_SQLALCHEMY__POOL_TIMEOUT="30.0"
|
|
33
|
+
export SPAKKY_SQLALCHEMY__POOL_RECYCLE="-1"
|
|
34
|
+
export SPAKKY_SQLALCHEMY__POOL_PRE_PING="false"
|
|
35
|
+
|
|
36
|
+
# Session options (optional)
|
|
37
|
+
export SPAKKY_SQLALCHEMY__SESSION_AUTOFLUSH="true"
|
|
38
|
+
export SPAKKY_SQLALCHEMY__SESSION_EXPIRE_ON_COMMIT="true"
|
|
39
|
+
|
|
40
|
+
# Transaction options (optional)
|
|
41
|
+
export SPAKKY_SQLALCHEMY__AUTOCOMMIT="true"
|
|
42
|
+
|
|
43
|
+
# Async support (optional)
|
|
44
|
+
export SPAKKY_SQLALCHEMY__SUPPORT_ASYNC_MODE="true"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
### Defining Tables with Domain Mapping
|
|
50
|
+
|
|
51
|
+
Use `@Table` decorator and inherit from `AbstractTable` to define ORM tables with domain model mapping:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from uuid import UUID
|
|
55
|
+
from sqlalchemy import String
|
|
56
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
57
|
+
from spakky.plugins.sqlalchemy.orm.table import AbstractTable, Table
|
|
58
|
+
from spakky.domain.models.aggregate_root import AbstractAggregateRoot
|
|
59
|
+
|
|
60
|
+
# Domain model
|
|
61
|
+
class User(AbstractAggregateRoot[UUID]):
|
|
62
|
+
id: UUID
|
|
63
|
+
name: str
|
|
64
|
+
email: str
|
|
65
|
+
|
|
66
|
+
# Table definition with domain mapping
|
|
67
|
+
@Table()
|
|
68
|
+
class UserTable(AbstractMappableTable[User]):
|
|
69
|
+
__tablename__ = "users"
|
|
70
|
+
|
|
71
|
+
id: Mapped[UUID] = mapped_column(primary_key=True)
|
|
72
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
73
|
+
email: Mapped[str] = mapped_column(String(255))
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_domain(cls, domain: User) -> "UserTable":
|
|
77
|
+
return cls(id=domain.id, name=domain.name, email=domain.email)
|
|
78
|
+
|
|
79
|
+
def to_domain(self) -> User:
|
|
80
|
+
return User(id=self.id, name=self.name, email=self.email)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Repository Implementation
|
|
84
|
+
|
|
85
|
+
Extend `AbstractGenericRepository` or `AbstractAsyncGenericRepository`:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from uuid import UUID
|
|
89
|
+
from spakky.data.stereotype.repository import Repository
|
|
90
|
+
from spakky.plugins.sqlalchemy.persistency.repository import (
|
|
91
|
+
AbstractGenericRepository,
|
|
92
|
+
AbstractAsyncGenericRepository,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Synchronous repository
|
|
96
|
+
@Repository()
|
|
97
|
+
class UserRepository(AbstractGenericRepository[User, UUID]):
|
|
98
|
+
pass # CRUD methods inherited
|
|
99
|
+
|
|
100
|
+
# Asynchronous repository
|
|
101
|
+
@Repository()
|
|
102
|
+
class AsyncUserRepository(AbstractAsyncGenericRepository[User, UUID]):
|
|
103
|
+
pass # Async CRUD methods inherited
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Using Transactions
|
|
107
|
+
|
|
108
|
+
Use the `@Transactional` decorator from `spakky-data`:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from spakky.core.stereotype.usecase import UseCase
|
|
112
|
+
from spakky.data.aspects.transactional import Transactional
|
|
113
|
+
|
|
114
|
+
@UseCase()
|
|
115
|
+
class CreateUserUseCase:
|
|
116
|
+
def __init__(self, user_repo: UserRepository) -> None:
|
|
117
|
+
self._user_repo = user_repo
|
|
118
|
+
|
|
119
|
+
@Transactional()
|
|
120
|
+
def execute(self, name: str, email: str) -> User:
|
|
121
|
+
user = User.create(name, email)
|
|
122
|
+
return self._user_repo.save(user)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Async Transactions
|
|
126
|
+
|
|
127
|
+
The same `@Transactional()` decorator works for both sync and async methods.
|
|
128
|
+
The framework automatically selects the correct aspect based on whether the method is a coroutine.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from spakky.data.aspects.transactional import Transactional
|
|
132
|
+
|
|
133
|
+
@UseCase()
|
|
134
|
+
class AsyncCreateUserUseCase:
|
|
135
|
+
def __init__(self, user_repo: AsyncUserRepository) -> None:
|
|
136
|
+
self._user_repo = user_repo
|
|
137
|
+
|
|
138
|
+
@Transactional()
|
|
139
|
+
async def execute(self, name: str, email: str) -> User:
|
|
140
|
+
user = User.create(name, email)
|
|
141
|
+
return await self._user_repo.save(user)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Accessing Session Directly
|
|
145
|
+
|
|
146
|
+
For complex queries, access the SQLAlchemy session directly in **QueryUseCase**.
|
|
147
|
+
Following CQRS principles, queries should be implemented directly rather than
|
|
148
|
+
adding query methods to repositories.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from spakky.core.common.mutability import immutable
|
|
152
|
+
from spakky.core.stereotype.usecase import UseCase
|
|
153
|
+
from spakky.domain.application.query import AbstractQuery, IAsyncQueryUseCase
|
|
154
|
+
from spakky.plugins.sqlalchemy.persistency.session_manager import AsyncSessionManager
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@immutable
|
|
158
|
+
class FindUserByEmailQuery(AbstractQuery):
|
|
159
|
+
email: str
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@immutable
|
|
163
|
+
class UserDTO:
|
|
164
|
+
id: UUID
|
|
165
|
+
name: str
|
|
166
|
+
email: str
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@UseCase()
|
|
170
|
+
class FindUserByEmailUseCase(IAsyncQueryUseCase[FindUserByEmailQuery, UserDTO | None]):
|
|
171
|
+
def __init__(self, session_manager: AsyncSessionManager) -> None:
|
|
172
|
+
self._session_manager = session_manager
|
|
173
|
+
|
|
174
|
+
async def run(self, query: FindUserByEmailQuery) -> UserDTO | None:
|
|
175
|
+
result = await self._session_manager.session.execute(
|
|
176
|
+
select(UserTable).where(UserTable.email == query.email)
|
|
177
|
+
)
|
|
178
|
+
table = result.scalar_one_or_none()
|
|
179
|
+
if table is None:
|
|
180
|
+
return None
|
|
181
|
+
return UserDTO(id=table.id, name=table.name, email=table.email)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Schema Registry
|
|
185
|
+
|
|
186
|
+
Access `SchemaRegistry` to get table metadata for migrations:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from spakky.plugins.sqlalchemy.orm.schema_registry import SchemaRegistry
|
|
190
|
+
|
|
191
|
+
@Pod()
|
|
192
|
+
class MigrationService:
|
|
193
|
+
def __init__(self, schema_registry: SchemaRegistry) -> None:
|
|
194
|
+
self._schema_registry = schema_registry
|
|
195
|
+
|
|
196
|
+
def get_metadata(self) -> MetaData:
|
|
197
|
+
return self._schema_registry.metadata
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Features
|
|
201
|
+
|
|
202
|
+
- **Domain-Table mapping**: Bidirectional conversion between domain models and ORM tables
|
|
203
|
+
- **Generic repositories**: Pre-built CRUD operations with composite PK support
|
|
204
|
+
- **Sync and Async support**: Full support for both synchronous and asynchronous operations
|
|
205
|
+
- **Scoped sessions**: Thread/context-safe session management
|
|
206
|
+
- **Optimistic locking**: Built-in `VersionConflictError` for concurrent updates
|
|
207
|
+
- **Schema registry**: Centralized table metadata management
|
|
208
|
+
|
|
209
|
+
## Components
|
|
210
|
+
|
|
211
|
+
| Component | Description |
|
|
212
|
+
|-----------|-------------|
|
|
213
|
+
| `@Table` | Decorator for registering ORM tables with domain mapping |
|
|
214
|
+
| `AbstractTable` | Base class for ORM tables with `from_domain`/`to_domain` |
|
|
215
|
+
| `AbstractGenericRepository` | Sync repository with CRUD operations |
|
|
216
|
+
| `AbstractAsyncGenericRepository` | Async repository with CRUD operations |
|
|
217
|
+
| `SchemaRegistry` | Central registry for table-domain mappings |
|
|
218
|
+
| `SessionManager` | Sync scoped session management |
|
|
219
|
+
| `AsyncSessionManager` | Async scoped session management |
|
|
220
|
+
| `Transaction` | Sync transaction implementation |
|
|
221
|
+
| `AsyncTransaction` | Async transaction implementation |
|
|
222
|
+
| `ConnectionManager` | Sync SQLAlchemy engine lifecycle |
|
|
223
|
+
| `AsyncConnectionManager` | Async SQLAlchemy engine lifecycle |
|
|
224
|
+
| `SQLAlchemyConnectionConfig` | Configuration via environment variables |
|
|
225
|
+
|
|
226
|
+
## Repository Methods
|
|
227
|
+
|
|
228
|
+
Both sync and async repositories provide:
|
|
229
|
+
|
|
230
|
+
| Method | Description |
|
|
231
|
+
|--------|-------------|
|
|
232
|
+
| `get(id)` | Get aggregate by ID, raises `EntityNotFoundError` if not found |
|
|
233
|
+
| `get_or_none(id)` | Get aggregate by ID, returns `None` if not found |
|
|
234
|
+
| `contains(id)` | Check if aggregate exists |
|
|
235
|
+
| `range(ids)` | Get multiple aggregates by ID list |
|
|
236
|
+
| `save(aggregate)` | Save (insert or update) an aggregate |
|
|
237
|
+
| `save_all(aggregates)` | Save multiple aggregates |
|
|
238
|
+
| `delete(aggregate)` | Delete an aggregate |
|
|
239
|
+
| `delete_all(aggregates)` | Delete multiple aggregates |
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT License
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "spakky-sqlalchemy"
|
|
3
|
+
version = "5.0.1"
|
|
4
|
+
description = "SQLAlchemy plugin for Spakky framework"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Spakky", email = "sejong418@icloud.com" }]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"spakky-data>=5.0.1",
|
|
11
|
+
"sqlalchemy>=2.0.45",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[dependency-groups]
|
|
15
|
+
dev = [
|
|
16
|
+
"testcontainers[postgres]>=4.13.3",
|
|
17
|
+
"psycopg[binary]>=3.2.6",
|
|
18
|
+
"greenlet>=3.1.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.entry-points."spakky.plugins"]
|
|
22
|
+
spakky-sqlalchemy = "spakky.plugins.sqlalchemy.main:initialize"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["uv_build>=0.9.13,<0.10.0"]
|
|
26
|
+
build-backend = "uv_build"
|
|
27
|
+
|
|
28
|
+
[tool.uv.build-backend]
|
|
29
|
+
module-root = "src"
|
|
30
|
+
module-name = "spakky.plugins.sqlalchemy"
|
|
31
|
+
|
|
32
|
+
[tool.pyrefly]
|
|
33
|
+
python-version = "3.14"
|
|
34
|
+
search_path = ["src", "."]
|
|
35
|
+
project_excludes = ["**/__pycache__", "**/*.pyc"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
builtins = ["_"]
|
|
39
|
+
cache-dir = "~/.cache/ruff"
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
pythonpath = "src/spakky/plugins/sqlalchemy"
|
|
43
|
+
testpaths = "tests"
|
|
44
|
+
python_files = ["test_*.py"]
|
|
45
|
+
asyncio_mode = "auto"
|
|
46
|
+
addopts = """
|
|
47
|
+
--cov
|
|
48
|
+
--cov-report=term
|
|
49
|
+
--cov-report=xml
|
|
50
|
+
--no-cov-on-fail
|
|
51
|
+
--strict-markers
|
|
52
|
+
--dist=loadfile
|
|
53
|
+
-p no:warnings
|
|
54
|
+
-n auto
|
|
55
|
+
--spec
|
|
56
|
+
"""
|
|
57
|
+
spec_test_format = "{result} {docstring_summary}"
|
|
58
|
+
|
|
59
|
+
[tool.coverage.run]
|
|
60
|
+
include = ["src/spakky/plugins/sqlalchemy/*"]
|
|
61
|
+
branch = true
|
|
62
|
+
|
|
63
|
+
[tool.coverage.report]
|
|
64
|
+
show_missing = true
|
|
65
|
+
precision = 2
|
|
66
|
+
fail_under = 90
|
|
67
|
+
skip_empty = true
|
|
68
|
+
exclude_lines = [
|
|
69
|
+
"pragma: no cover",
|
|
70
|
+
"def __repr__",
|
|
71
|
+
"raise AssertionError",
|
|
72
|
+
"raise NotImplementedError",
|
|
73
|
+
"@(abc\\.)?abstractmethod",
|
|
74
|
+
"@(typing\\.)?overload",
|
|
75
|
+
"\\.\\.\\.\\.",
|
|
76
|
+
"pass",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[tool.uv.sources]
|
|
80
|
+
spakky-data = { workspace = true }
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from typing import ClassVar
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
4
|
+
from spakky.core.stereotype.configuration import Configuration
|
|
5
|
+
|
|
6
|
+
from spakky.plugins.sqlalchemy.common.constants import (
|
|
7
|
+
SPAKKY_SQLALCHEMY_CONFIG_ENV_PREFIX,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@Configuration()
|
|
12
|
+
class SQLAlchemyConnectionConfig(BaseSettings):
|
|
13
|
+
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
|
14
|
+
env_prefix=SPAKKY_SQLALCHEMY_CONFIG_ENV_PREFIX,
|
|
15
|
+
env_file_encoding="utf-8",
|
|
16
|
+
env_nested_delimiter="__",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# --- Connection ---
|
|
20
|
+
connection_string: str
|
|
21
|
+
"""SQLAlchemy database connection URL (e.g. postgresql+psycopg://user:pass@host/db)."""
|
|
22
|
+
|
|
23
|
+
# --- Engine ---
|
|
24
|
+
echo: bool = False
|
|
25
|
+
"""If True, the engine logs all SQL statements to the default logger."""
|
|
26
|
+
echo_pool: bool = False
|
|
27
|
+
"""If True, the connection pool logs all checkout/checkin events."""
|
|
28
|
+
|
|
29
|
+
# --- Connection Pool (QueuePool) Defaults ---
|
|
30
|
+
DEFAULT_POOL_SIZE: ClassVar[int] = 5
|
|
31
|
+
"""Default number of connections in the pool."""
|
|
32
|
+
DEFAULT_POOL_MAX_OVERFLOW: ClassVar[int] = 10
|
|
33
|
+
"""Default maximum overflow connections."""
|
|
34
|
+
DEFAULT_POOL_TIMEOUT: ClassVar[float] = 30.0
|
|
35
|
+
"""Default seconds to wait when pool is exhausted."""
|
|
36
|
+
DEFAULT_POOL_RECYCLE: ClassVar[int] = -1
|
|
37
|
+
"""Default connection recycle time (-1 = disabled)."""
|
|
38
|
+
DEFAULT_POOL_PRE_PING: ClassVar[bool] = False
|
|
39
|
+
"""Default pre-ping setting."""
|
|
40
|
+
|
|
41
|
+
# --- Connection Pool (QueuePool) ---
|
|
42
|
+
pool_size: int = DEFAULT_POOL_SIZE
|
|
43
|
+
"""Number of connections to maintain persistently in the pool."""
|
|
44
|
+
pool_max_overflow: int = DEFAULT_POOL_MAX_OVERFLOW
|
|
45
|
+
"""Maximum number of connections that can be opened beyond pool_size."""
|
|
46
|
+
pool_timeout: float = DEFAULT_POOL_TIMEOUT
|
|
47
|
+
"""Seconds to wait before raising an error when the pool is exhausted."""
|
|
48
|
+
pool_recycle: int = DEFAULT_POOL_RECYCLE
|
|
49
|
+
"""Recycle connections after this many seconds to prevent stale connections.
|
|
50
|
+
Recommended when connecting through a proxy or firewall with idle timeouts."""
|
|
51
|
+
pool_pre_ping: bool = DEFAULT_POOL_PRE_PING
|
|
52
|
+
"""If True, tests each connection for liveness before returning it from the pool.
|
|
53
|
+
Prevents errors caused by connections dropped by the database server."""
|
|
54
|
+
|
|
55
|
+
# --- Session ---
|
|
56
|
+
session_autoflush: bool = True
|
|
57
|
+
"""If True, the session automatically flushes pending changes before queries."""
|
|
58
|
+
session_expire_on_commit: bool = True
|
|
59
|
+
"""If True, ORM objects are expired after each commit, forcing a reload on next access."""
|
|
60
|
+
|
|
61
|
+
# --- Transaction ---
|
|
62
|
+
autocommit: bool = True
|
|
63
|
+
"""If True, transactions are automatically committed after with statements. If False, transactions must be manually committed or rolled back."""
|
|
64
|
+
|
|
65
|
+
# --- Async Mode ---
|
|
66
|
+
support_async_mode: bool = True
|
|
67
|
+
"""If True, registers async Pods (AsyncSessionManager, AsyncTransaction).
|
|
68
|
+
Set to False when using database drivers that don't support async operations."""
|
|
69
|
+
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
super().__init__()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
SPAKKY_SQLALCHEMY_CONFIG_ENV_PREFIX = "SPAKKY_SQLALCHEMY__"
|