sqlphilosophy 0.1.6__tar.gz → 0.1.9__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.6/src/sqlphilosophy.egg-info → sqlphilosophy-0.1.9}/PKG-INFO +42 -82
- sqlphilosophy-0.1.9/README.md +138 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/pyproject.toml +27 -1
- sqlphilosophy-0.1.9/src/sqlphilosophy/VERSION +1 -0
- sqlphilosophy-0.1.9/src/sqlphilosophy/__init__.py +18 -0
- sqlphilosophy-0.1.9/src/sqlphilosophy/_repository_shared.py +114 -0
- sqlphilosophy-0.1.9/src/sqlphilosophy/aio/protocols.py +106 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/aio/query.py +49 -30
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/aio/repository.py +88 -107
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/context.py +1 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/fields.py +1 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/listener.py +8 -8
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/model.py +4 -4
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sorting.py +20 -4
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sql.py +75 -165
- sqlphilosophy-0.1.9/src/sqlphilosophy/sync/protocols.py +103 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sync/query.py +52 -43
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sync/repository.py +76 -91
- sqlphilosophy-0.1.9/src/sqlphilosophy/trusted_sql.py +106 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/types.py +6 -10
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9/src/sqlphilosophy.egg-info}/PKG-INFO +42 -82
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy.egg-info/SOURCES.txt +2 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy.egg-info/requires.txt +1 -1
- sqlphilosophy-0.1.6/README.md +0 -178
- sqlphilosophy-0.1.6/src/sqlphilosophy/VERSION +0 -1
- sqlphilosophy-0.1.6/src/sqlphilosophy/__init__.py +0 -3
- sqlphilosophy-0.1.6/src/sqlphilosophy/aio/protocols.py +0 -26
- sqlphilosophy-0.1.6/src/sqlphilosophy/sync/protocols.py +0 -26
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/LICENSE +0 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/MANIFEST.in +0 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/setup.cfg +0 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/aio/__init__.py +0 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/__init__.py +0 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/py.typed +0 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sync/__init__.py +0 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy.egg-info/dependency_links.txt +0 -0
- {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/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.9
|
|
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
|
|
@@ -31,7 +31,7 @@ Requires-Dist: aiosqlite>=0.20; extra == "dev"
|
|
|
31
31
|
Requires-Dist: greenlet>=3.0; extra == "dev"
|
|
32
32
|
Requires-Dist: build>=1.2; extra == "dev"
|
|
33
33
|
Requires-Dist: twine>=5; extra == "dev"
|
|
34
|
-
Requires-Dist:
|
|
34
|
+
Requires-Dist: ruff>=0.15; extra == "dev"
|
|
35
35
|
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
36
36
|
Requires-Dist: bandit>=1.7; extra == "dev"
|
|
37
37
|
Requires-Dist: types-greenlet>=3.0; extra == "dev"
|
|
@@ -45,22 +45,34 @@ Portable SQLAlchemy repository kit: sync and async CRUD, fluent statement builde
|
|
|
45
45
|
|---|---|
|
|
46
46
|
| **PyPI** | [`sqlphilosophy`](https://pypi.org/project/sqlphilosophy/) |
|
|
47
47
|
| **GitHub** | [SignalSafeSoftware/sqlphilosophy](https://github.com/SignalSafeSoftware/sqlphilosophy) |
|
|
48
|
-
| **Import** | `sqlphilosophy` (
|
|
48
|
+
| **Import** | `sqlphilosophy` (`__version__` only) — use explicit submodules for APIs |
|
|
49
49
|
| **Python** | 3.12+ |
|
|
50
50
|
| **License** | MIT — see [LICENSE](./LICENSE) |
|
|
51
51
|
|
|
52
|
+
## Documentation
|
|
53
|
+
|
|
54
|
+
| Resource | Description |
|
|
55
|
+
|----------|-------------|
|
|
56
|
+
| [Repository guide](./docs/repository-guide.md) | Entry point: overview, transaction model, links to usage pages |
|
|
57
|
+
| [Usage examples](./docs/usage/) | Focused sync/async code examples by feature area |
|
|
58
|
+
| [Strongly typed repositories](./docs/usage/strongly-typed-repositories.md) | Typed subclasses, factories, protocols, and service patterns |
|
|
59
|
+
| [Before/after SQLAlchemy](./docs/usage/before-after-sqlalchemy.md) | Migration examples: direct SQLAlchemy vs repository-centered code |
|
|
60
|
+
| [Feature matrix](./docs/feature-matrix.md) | Full sync/async capability map |
|
|
61
|
+
| [Typed repository (sync)](./docs/examples/typed_repository_sync.py) | Runnable factory + domain repo example |
|
|
62
|
+
| [Typed repository (async)](./docs/examples/typed_repository_async.py) | Async counterpart |
|
|
63
|
+
|
|
52
64
|
## What this package does
|
|
53
65
|
|
|
54
66
|
- **Repository pattern** for a single mapped model (`BaseRepository`, `AsyncBaseRepository`).
|
|
55
67
|
- **Fluent query builders** with pagination/sort (`StatementQueryBuilder`, `ListQuery`, `SortConfig`).
|
|
56
|
-
- **SQL helpers** for row mapping, partial updates,
|
|
68
|
+
- **SQL helpers** for row mapping, partial updates, and developer-defined fragments via **`sqlphilosophy.trusted_sql`**.
|
|
57
69
|
- **Optional audit listeners** and timestamp mixins.
|
|
58
70
|
|
|
59
71
|
## What this package does not do
|
|
60
72
|
|
|
61
73
|
- Migrations, schema design, or connection pooling configuration.
|
|
62
74
|
- Authorization, multi-tenant isolation, or query sandboxing.
|
|
63
|
-
- Automatic commits for normal CRUD — see [Transaction ownership](#transaction-ownership)
|
|
75
|
+
- Automatic commits for normal CRUD — see [Transaction ownership](#transaction-ownership).
|
|
64
76
|
|
|
65
77
|
## Install
|
|
66
78
|
|
|
@@ -76,7 +88,7 @@ pip install sqlphilosophy[async]
|
|
|
76
88
|
|
|
77
89
|
Requires Python 3.12+ and SQLAlchemy 2.x.
|
|
78
90
|
|
|
79
|
-
##
|
|
91
|
+
## Quick start (sync)
|
|
80
92
|
|
|
81
93
|
```python
|
|
82
94
|
from sqlalchemy import String, create_engine
|
|
@@ -102,105 +114,53 @@ SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
|
|
102
114
|
|
|
103
115
|
with SessionLocal() as session:
|
|
104
116
|
repo = BaseRepository(Widget, session)
|
|
105
|
-
|
|
117
|
+
repo.create(name="alpha") # stages + flush; does not commit
|
|
106
118
|
session.commit()
|
|
107
119
|
|
|
108
|
-
|
|
109
|
-
assert
|
|
120
|
+
rows, total = repo.statement().fetch_page(ListQuery.from_page(page=1, size=20))
|
|
121
|
+
assert total >= 1
|
|
110
122
|
```
|
|
111
123
|
|
|
112
|
-
**Async:**
|
|
124
|
+
**Async:** use `AsyncSession`, `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` on repository/builder terminals. See the [repository guide](./docs/repository-guide.md).
|
|
113
125
|
|
|
114
126
|
## Package layout
|
|
115
127
|
|
|
116
128
|
| Module | Contents |
|
|
117
129
|
|--------|----------|
|
|
118
|
-
| `sqlphilosophy
|
|
119
|
-
| `sqlphilosophy.
|
|
120
|
-
| `sqlphilosophy.
|
|
121
|
-
| `sqlphilosophy.
|
|
122
|
-
| `sqlphilosophy.
|
|
123
|
-
| `sqlphilosophy.
|
|
124
|
-
|
|
125
|
-
## Sync usage
|
|
126
|
-
|
|
127
|
-
```python
|
|
128
|
-
from sqlalchemy.orm import Session
|
|
129
|
-
|
|
130
|
-
from sqlphilosophy.sorting import ListQuery, SortConfig, SortSpec
|
|
131
|
-
from sqlphilosophy.sql import partial_update_model, row_int
|
|
132
|
-
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
133
|
-
from sqlphilosophy.sync.repository import BaseRepository
|
|
134
|
-
from sqlphilosophy.sync.query import SqlAlchemyStatementBuilder
|
|
135
|
-
|
|
136
|
-
repo = BaseRepository(User, session)
|
|
137
|
-
rows = repo.statement().where(User.active.is_(True)).mappings().all()
|
|
138
|
-
|
|
139
|
-
repo = BaseRepository(User, session, factory)
|
|
140
|
-
page = repo.statement().fetch_page(ListQuery.from_page(page=1, size=20))
|
|
141
|
-
other = repo.for_repo(OrderRepository)
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
## Async usage
|
|
145
|
-
|
|
146
|
-
```python
|
|
147
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
148
|
-
|
|
149
|
-
from sqlphilosophy.aio.repository import AsyncBaseRepository
|
|
150
|
-
|
|
151
|
-
repo = AsyncBaseRepository(User, session)
|
|
152
|
-
rows = await repo.statement().where(User.active.is_(True)).mappings().all()
|
|
153
|
-
```
|
|
130
|
+
| `sqlphilosophy` | `__version__` only |
|
|
131
|
+
| `sqlphilosophy.types` | Portable typing aliases |
|
|
132
|
+
| `sqlphilosophy.sql` | Row mapping, partial updates, Core helpers (re-exports `trusted_sql`) |
|
|
133
|
+
| `sqlphilosophy.trusted_sql` | Developer-trusted SQL fragments — see [SECURITY.md](./SECURITY.md) |
|
|
134
|
+
| `sqlphilosophy.sorting` | `ListQuery`, `SortConfig`, `SortSpec` |
|
|
135
|
+
| `sqlphilosophy.sync` / `sqlphilosophy.aio` | Repositories, query builders, factory protocols |
|
|
136
|
+
| `sqlphilosophy.audit` | Optional listeners and timestamp mixins |
|
|
154
137
|
|
|
155
138
|
## Transaction ownership
|
|
156
139
|
|
|
157
|
-
- **`create` / `
|
|
158
|
-
- **`delete_all()`**
|
|
159
|
-
- **`batched_purge_ids(
|
|
160
|
-
- Your application owns **`session.commit()` / `rollback()`** for normal request/work-unit boundaries.
|
|
140
|
+
- **`create` / `add` / `update_partial` / `remove` / `delete_*` / `update_where`** — flush or execute DML; **caller commits**.
|
|
141
|
+
- **`delete_all()`** — bulk delete; **does not commit**.
|
|
142
|
+
- **`batched_purge_ids(..., batch_size=...)`** — deletes in batches and **commits after each batch**; requires **`batch_size >= 1`**. Authorize in application code first.
|
|
161
143
|
|
|
162
144
|
## Raw SQL trust boundaries
|
|
163
145
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
- Raw SQL fragments passed to SQL helper functions
|
|
167
|
-
- Literal column names, table names, and `ORDER BY` expressions
|
|
168
|
-
- Sort field allowlists wired into query builders
|
|
169
|
-
|
|
170
|
-
**User-supplied values must use bind parameters** (SQLAlchemy bound values), not string concatenation into SQL text or identifiers. See [SECURITY.md](./SECURITY.md).
|
|
171
|
-
|
|
172
|
-
## Destructive helpers
|
|
146
|
+
Identifiers and SQL fragments (table/column names, `ORDER BY` text, sort allowlists) must be **developer-defined**, never built from end-user input. User **values** use bind parameters.
|
|
173
147
|
|
|
174
|
-
|
|
175
|
-
- **`batched_purge_ids(...)`** — deletes matching rows in batches and commits each batch.
|
|
176
|
-
|
|
177
|
-
Call only after your application has authorized the operation. These helpers assume the caller understands the data loss impact.
|
|
178
|
-
|
|
179
|
-
## Audit mixins
|
|
180
|
-
|
|
181
|
-
```python
|
|
182
|
-
from sqlphilosophy.audit.context import audit_context
|
|
183
|
-
from sqlphilosophy.audit.listener import configure_audit_listeners
|
|
184
|
-
from sqlphilosophy.audit.model import TimestampModel
|
|
185
|
-
|
|
186
|
-
configure_audit_listeners()
|
|
187
|
-
|
|
188
|
-
with audit_context(actor_id=42):
|
|
189
|
-
session.add(MyModel(name="example"))
|
|
190
|
-
session.flush()
|
|
191
|
-
session.commit()
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
Audit listeners record changes; they do **not** enforce access control.
|
|
148
|
+
Import trusted helpers from **`sqlphilosophy.trusted_sql`** (`sql_table`, `col_eq`, `col_icontains`, `col_range`, `literal_order_expr`, …). The same names are re-exported from `sqlphilosophy.sql`. Details: [SECURITY.md](./SECURITY.md) and the [repository guide](./docs/repository-guide.md).
|
|
195
149
|
|
|
196
150
|
## Development
|
|
197
151
|
|
|
198
|
-
This repo uses [uv](https://docs.astral.sh/uv/):
|
|
152
|
+
This repo uses [uv](https://docs.astral.sh/uv/) and [Ruff](https://docs.astral.sh/ruff/):
|
|
199
153
|
|
|
200
154
|
```bash
|
|
201
155
|
uv sync --extra dev
|
|
202
156
|
uv run pytest
|
|
203
|
-
uv run
|
|
157
|
+
uv run ruff check src tests docs/examples
|
|
158
|
+
uv run ruff format src tests docs/examples
|
|
159
|
+
|
|
160
|
+
# Optional: validate runnable docs examples (SQLite in-memory; CI runs these in smoke-package)
|
|
161
|
+
uv run --extra dev python docs/examples/typed_repository_sync.py
|
|
162
|
+
uv run --extra dev python docs/examples/typed_repository_async.py
|
|
163
|
+
|
|
204
164
|
uv run python -m build
|
|
205
165
|
```
|
|
206
166
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# sqlphilosophy
|
|
2
|
+
|
|
3
|
+
Portable SQLAlchemy repository kit: sync and async CRUD, fluent statement builders, sort/pagination, Core SQL helpers, and optional audit listeners.
|
|
4
|
+
|
|
5
|
+
| | |
|
|
6
|
+
|---|---|
|
|
7
|
+
| **PyPI** | [`sqlphilosophy`](https://pypi.org/project/sqlphilosophy/) |
|
|
8
|
+
| **GitHub** | [SignalSafeSoftware/sqlphilosophy](https://github.com/SignalSafeSoftware/sqlphilosophy) |
|
|
9
|
+
| **Import** | `sqlphilosophy` (`__version__` only) — use explicit submodules for APIs |
|
|
10
|
+
| **Python** | 3.12+ |
|
|
11
|
+
| **License** | MIT — see [LICENSE](./LICENSE) |
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
| Resource | Description |
|
|
16
|
+
|----------|-------------|
|
|
17
|
+
| [Repository guide](./docs/repository-guide.md) | Entry point: overview, transaction model, links to usage pages |
|
|
18
|
+
| [Usage examples](./docs/usage/) | Focused sync/async code examples by feature area |
|
|
19
|
+
| [Strongly typed repositories](./docs/usage/strongly-typed-repositories.md) | Typed subclasses, factories, protocols, and service patterns |
|
|
20
|
+
| [Before/after SQLAlchemy](./docs/usage/before-after-sqlalchemy.md) | Migration examples: direct SQLAlchemy vs repository-centered code |
|
|
21
|
+
| [Feature matrix](./docs/feature-matrix.md) | Full sync/async capability map |
|
|
22
|
+
| [Typed repository (sync)](./docs/examples/typed_repository_sync.py) | Runnable factory + domain repo example |
|
|
23
|
+
| [Typed repository (async)](./docs/examples/typed_repository_async.py) | Async counterpart |
|
|
24
|
+
|
|
25
|
+
## What this package does
|
|
26
|
+
|
|
27
|
+
- **Repository pattern** for a single mapped model (`BaseRepository`, `AsyncBaseRepository`).
|
|
28
|
+
- **Fluent query builders** with pagination/sort (`StatementQueryBuilder`, `ListQuery`, `SortConfig`).
|
|
29
|
+
- **SQL helpers** for row mapping, partial updates, and developer-defined fragments via **`sqlphilosophy.trusted_sql`**.
|
|
30
|
+
- **Optional audit listeners** and timestamp mixins.
|
|
31
|
+
|
|
32
|
+
## What this package does not do
|
|
33
|
+
|
|
34
|
+
- Migrations, schema design, or connection pooling configuration.
|
|
35
|
+
- Authorization, multi-tenant isolation, or query sandboxing.
|
|
36
|
+
- Automatic commits for normal CRUD — see [Transaction ownership](#transaction-ownership).
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install sqlphilosophy
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Async ORM (`AsyncSession`) also needs greenlet:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install sqlphilosophy[async]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Requires Python 3.12+ and SQLAlchemy 2.x.
|
|
51
|
+
|
|
52
|
+
## Quick start (sync)
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from sqlalchemy import String, create_engine
|
|
56
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
|
57
|
+
|
|
58
|
+
from sqlphilosophy.sorting import ListQuery
|
|
59
|
+
from sqlphilosophy.sync.repository import BaseRepository
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Base(DeclarativeBase):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Widget(Base):
|
|
67
|
+
__tablename__ = "widget"
|
|
68
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
69
|
+
name: Mapped[str] = mapped_column(String(64))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
engine = create_engine("sqlite:///:memory:", future=True)
|
|
73
|
+
Base.metadata.create_all(engine)
|
|
74
|
+
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
|
75
|
+
|
|
76
|
+
with SessionLocal() as session:
|
|
77
|
+
repo = BaseRepository(Widget, session)
|
|
78
|
+
repo.create(name="alpha") # stages + flush; does not commit
|
|
79
|
+
session.commit()
|
|
80
|
+
|
|
81
|
+
rows, total = repo.statement().fetch_page(ListQuery.from_page(page=1, size=20))
|
|
82
|
+
assert total >= 1
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Async:** use `AsyncSession`, `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` on repository/builder terminals. See the [repository guide](./docs/repository-guide.md).
|
|
86
|
+
|
|
87
|
+
## Package layout
|
|
88
|
+
|
|
89
|
+
| Module | Contents |
|
|
90
|
+
|--------|----------|
|
|
91
|
+
| `sqlphilosophy` | `__version__` only |
|
|
92
|
+
| `sqlphilosophy.types` | Portable typing aliases |
|
|
93
|
+
| `sqlphilosophy.sql` | Row mapping, partial updates, Core helpers (re-exports `trusted_sql`) |
|
|
94
|
+
| `sqlphilosophy.trusted_sql` | Developer-trusted SQL fragments — see [SECURITY.md](./SECURITY.md) |
|
|
95
|
+
| `sqlphilosophy.sorting` | `ListQuery`, `SortConfig`, `SortSpec` |
|
|
96
|
+
| `sqlphilosophy.sync` / `sqlphilosophy.aio` | Repositories, query builders, factory protocols |
|
|
97
|
+
| `sqlphilosophy.audit` | Optional listeners and timestamp mixins |
|
|
98
|
+
|
|
99
|
+
## Transaction ownership
|
|
100
|
+
|
|
101
|
+
- **`create` / `add` / `update_partial` / `remove` / `delete_*` / `update_where`** — flush or execute DML; **caller commits**.
|
|
102
|
+
- **`delete_all()`** — bulk delete; **does not commit**.
|
|
103
|
+
- **`batched_purge_ids(..., batch_size=...)`** — deletes in batches and **commits after each batch**; requires **`batch_size >= 1`**. Authorize in application code first.
|
|
104
|
+
|
|
105
|
+
## Raw SQL trust boundaries
|
|
106
|
+
|
|
107
|
+
Identifiers and SQL fragments (table/column names, `ORDER BY` text, sort allowlists) must be **developer-defined**, never built from end-user input. User **values** use bind parameters.
|
|
108
|
+
|
|
109
|
+
Import trusted helpers from **`sqlphilosophy.trusted_sql`** (`sql_table`, `col_eq`, `col_icontains`, `col_range`, `literal_order_expr`, …). The same names are re-exported from `sqlphilosophy.sql`. Details: [SECURITY.md](./SECURITY.md) and the [repository guide](./docs/repository-guide.md).
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
This repo uses [uv](https://docs.astral.sh/uv/) and [Ruff](https://docs.astral.sh/ruff/):
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
uv sync --extra dev
|
|
117
|
+
uv run pytest
|
|
118
|
+
uv run ruff check src tests docs/examples
|
|
119
|
+
uv run ruff format src tests docs/examples
|
|
120
|
+
|
|
121
|
+
# Optional: validate runnable docs examples (SQLite in-memory; CI runs these in smoke-package)
|
|
122
|
+
uv run --extra dev python docs/examples/typed_repository_sync.py
|
|
123
|
+
uv run --extra dev python docs/examples/typed_repository_async.py
|
|
124
|
+
|
|
125
|
+
uv run python -m build
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Security
|
|
129
|
+
|
|
130
|
+
See [SECURITY.md](./SECURITY.md) for vulnerability reporting and SQL trust boundaries.
|
|
131
|
+
|
|
132
|
+
## Releasing
|
|
133
|
+
|
|
134
|
+
See [RELEASING.md](./RELEASING.md) for GitHub + PyPI trusted publishing. See [CHANGELOG.md](./CHANGELOG.md).
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -39,7 +39,7 @@ dev = [
|
|
|
39
39
|
"greenlet>=3.0",
|
|
40
40
|
"build>=1.2",
|
|
41
41
|
"twine>=5",
|
|
42
|
-
"
|
|
42
|
+
"ruff>=0.15",
|
|
43
43
|
"mypy>=1.11",
|
|
44
44
|
"bandit>=1.7",
|
|
45
45
|
"types-greenlet>=3.0",
|
|
@@ -83,5 +83,31 @@ python_version = "3.12"
|
|
|
83
83
|
plugins = ["sqlalchemy.ext.mypy.plugin"]
|
|
84
84
|
warn_unused_ignores = true
|
|
85
85
|
|
|
86
|
+
[tool.ruff]
|
|
87
|
+
target-version = "py312"
|
|
88
|
+
line-length = 120
|
|
89
|
+
src = ["src", "tests", "docs/examples"]
|
|
90
|
+
|
|
91
|
+
[tool.ruff.lint]
|
|
92
|
+
select = [
|
|
93
|
+
"E",
|
|
94
|
+
"F",
|
|
95
|
+
"W",
|
|
96
|
+
"I",
|
|
97
|
+
"B",
|
|
98
|
+
"UP",
|
|
99
|
+
"SIM",
|
|
100
|
+
"PT",
|
|
101
|
+
"RUF",
|
|
102
|
+
]
|
|
103
|
+
ignore = [
|
|
104
|
+
"E203",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
[tool.ruff.lint.per-file-ignores]
|
|
108
|
+
"tests/**/*.py" = [
|
|
109
|
+
"PT011",
|
|
110
|
+
]
|
|
111
|
+
|
|
86
112
|
[tool.uv]
|
|
87
113
|
package = true
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.9
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""SQLAlchemy repository kit — sync/async CRUD, query builders, sort, and SQL helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
__all__ = ["__version__"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_version() -> str:
|
|
12
|
+
try:
|
|
13
|
+
return metadata.version("sqlphilosophy")
|
|
14
|
+
except metadata.PackageNotFoundError:
|
|
15
|
+
return (Path(__file__).resolve().parent / "VERSION").read_text(encoding="utf-8").strip()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__version__ = _load_version()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Internal helpers shared by sync and async repository implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal, cast
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import func
|
|
10
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
11
|
+
|
|
12
|
+
from sqlphilosophy.audit.model import AuditMixin
|
|
13
|
+
from sqlphilosophy.types import PrimaryKey, RowMapping, RowValue, SqlFilter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def require_batch_size(batch_size: int) -> None:
|
|
17
|
+
"""Validate batch size for destructive batched repository operations."""
|
|
18
|
+
if batch_size < 1:
|
|
19
|
+
raise ValueError("batch_size must be >= 1")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def require_single_column_primary_key(model: type[DeclarativeBase], mapper: Any) -> Any:
|
|
23
|
+
"""Return the sole primary-key column or raise when the mapper is invalid."""
|
|
24
|
+
pk_cols = mapper.primary_key
|
|
25
|
+
if len(pk_cols) != 1:
|
|
26
|
+
raise TypeError(f"{model.__name__} must have a single-column primary key")
|
|
27
|
+
return pk_cols[0]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def require_page_and_limit(*, page: int, limit: int | None) -> None:
|
|
31
|
+
"""Validate repository pagination inputs."""
|
|
32
|
+
if page < 1:
|
|
33
|
+
raise ValueError("page must be >= 1")
|
|
34
|
+
if limit is not None and limit < 1:
|
|
35
|
+
raise ValueError("limit must be >= 1")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def require_mappings_page_limits(*, limit: int, offset: int) -> None:
|
|
39
|
+
"""Validate limit/offset for mapping page fetches."""
|
|
40
|
+
if limit < 0:
|
|
41
|
+
raise ValueError("limit must be >= 0")
|
|
42
|
+
if offset < 0:
|
|
43
|
+
raise ValueError("offset must be >= 0")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def filter_writable_updates(fields: RowMapping, writable: frozenset[str]) -> RowMapping:
|
|
47
|
+
"""Keep only keys allowed by ``writable``."""
|
|
48
|
+
return {k: v for k, v in fields.items() if k in writable}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def lookup_not_found_message(model: type[DeclarativeBase], obj_id: PrimaryKey) -> str:
|
|
52
|
+
"""Message for ``LookupError`` when a primary-key fetch misses."""
|
|
53
|
+
return f"{model.__name__} matching id={obj_id!r} not found"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def extract_primary_keys(rows: Sequence[RowMapping], pk_key: str) -> list[PrimaryKey]:
|
|
57
|
+
"""Collect primary-key values from statement mapping rows."""
|
|
58
|
+
return [cast(PrimaryKey, row[pk_key]) for row in rows]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class PartialUpdatePlan:
|
|
63
|
+
"""Prepared partial-update action for sync or async execution."""
|
|
64
|
+
|
|
65
|
+
action: Literal["skip", "audit", "core"]
|
|
66
|
+
updates: RowMapping | None = None
|
|
67
|
+
|
|
68
|
+
def updates_for(self, action: Literal["audit", "core"]) -> RowMapping:
|
|
69
|
+
"""Return the updates payload for an audit or core plan."""
|
|
70
|
+
if self.action != action:
|
|
71
|
+
raise RuntimeError(f"partial update plan action mismatch: expected {action!r}, got {self.action!r}")
|
|
72
|
+
if self.updates is None:
|
|
73
|
+
raise RuntimeError(f"partial update plan {action!r} is missing updates payload")
|
|
74
|
+
return self.updates
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def plan_partial_update(
|
|
78
|
+
model: type[DeclarativeBase],
|
|
79
|
+
fields: RowMapping,
|
|
80
|
+
writable: frozenset[str],
|
|
81
|
+
*,
|
|
82
|
+
touch_updated_on: bool = False,
|
|
83
|
+
extra_values: RowMapping | None = None,
|
|
84
|
+
) -> PartialUpdatePlan:
|
|
85
|
+
"""Prepare audit ORM, core UPDATE, or no-op partial update payloads."""
|
|
86
|
+
if issubclass(model, AuditMixin):
|
|
87
|
+
audit_updates = filter_writable_updates(fields, writable)
|
|
88
|
+
if extra_values:
|
|
89
|
+
audit_updates = {**audit_updates, **extra_values}
|
|
90
|
+
if not audit_updates:
|
|
91
|
+
return PartialUpdatePlan("skip")
|
|
92
|
+
return PartialUpdatePlan("audit", audit_updates)
|
|
93
|
+
|
|
94
|
+
core_updates = filter_writable_updates(fields, writable)
|
|
95
|
+
if extra_values:
|
|
96
|
+
core_updates = {**core_updates, **extra_values}
|
|
97
|
+
if not core_updates:
|
|
98
|
+
return PartialUpdatePlan("skip")
|
|
99
|
+
if touch_updated_on:
|
|
100
|
+
core_updates = cast(
|
|
101
|
+
RowMapping,
|
|
102
|
+
{**dict(core_updates), "updated_on": cast(RowValue, func.now())},
|
|
103
|
+
)
|
|
104
|
+
return PartialUpdatePlan("core", core_updates)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def criteria_delete_allowed(criteria: Sequence[SqlFilter]) -> bool:
|
|
108
|
+
"""True when ``delete_where`` should run a lookup before deleting."""
|
|
109
|
+
return bool(criteria)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def bulk_update_allowed(values: RowMapping) -> bool:
|
|
113
|
+
"""True when ``update_where`` has values to apply."""
|
|
114
|
+
return bool(values)
|
|
@@ -0,0 +1,106 @@
|
|
|
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 TYPE_CHECKING, Any, Protocol, TypeVar
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from sqlphilosophy.aio.repository import AsyncBaseRepository
|
|
10
|
+
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
13
|
+
|
|
14
|
+
from sqlphilosophy.aio.query import AsyncStatementQueryBuilder
|
|
15
|
+
from sqlphilosophy.types import IdList, PrimaryKey, RowMapping, RowValue, SqlFilter
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T", bound=DeclarativeBase)
|
|
18
|
+
R = TypeVar("R", bound="AsyncBaseRepository[Any, Any]")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsyncBaseRepositoryProtocol[T: DeclarativeBase, U: AsyncRepositoryFactory | None](
|
|
22
|
+
Protocol,
|
|
23
|
+
):
|
|
24
|
+
"""Generic read/write surface shared by sqlphilosophy ``AsyncBaseRepository[T]``."""
|
|
25
|
+
|
|
26
|
+
model: type[T]
|
|
27
|
+
_session: AsyncSession
|
|
28
|
+
_factory: U | None
|
|
29
|
+
|
|
30
|
+
async def get(self, obj_id: PrimaryKey, load_relations: Any = None) -> T: ...
|
|
31
|
+
|
|
32
|
+
async def get_by_id(self, obj_id: PrimaryKey, load_relations: Any = None) -> T | None: ...
|
|
33
|
+
|
|
34
|
+
async def get_many(self, ids: Sequence[PrimaryKey], load_relations: Any = None) -> Sequence[T]: ...
|
|
35
|
+
|
|
36
|
+
async def first(self, load_relations: Any = None, **filters: RowValue) -> T | None: ...
|
|
37
|
+
|
|
38
|
+
async def filter(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
page: int = 1,
|
|
42
|
+
limit: int | None = None,
|
|
43
|
+
load_relations: Any = None,
|
|
44
|
+
**filters: RowValue,
|
|
45
|
+
) -> Sequence[T]: ...
|
|
46
|
+
|
|
47
|
+
async def get_all(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
page: int = 1,
|
|
51
|
+
limit: int | None = None,
|
|
52
|
+
load_relations: Any = None,
|
|
53
|
+
) -> Sequence[T]: ...
|
|
54
|
+
|
|
55
|
+
async def count(self, **filters: RowValue) -> int: ...
|
|
56
|
+
|
|
57
|
+
async def exists(self, obj_id: PrimaryKey) -> bool: ...
|
|
58
|
+
|
|
59
|
+
async def exists_where(self, **filters: RowValue) -> bool: ...
|
|
60
|
+
|
|
61
|
+
def statement(self) -> AsyncStatementQueryBuilder[T]: ...
|
|
62
|
+
|
|
63
|
+
async def create(self, **fields: object) -> T: ...
|
|
64
|
+
|
|
65
|
+
async def add(self, obj: T) -> T: ...
|
|
66
|
+
|
|
67
|
+
async def get_or_create(self, *, defaults: RowMapping | None = None, **lookup: RowValue) -> tuple[T, bool]: ...
|
|
68
|
+
|
|
69
|
+
async def remove(self, obj_id: PrimaryKey) -> bool: ...
|
|
70
|
+
|
|
71
|
+
async def delete_many(self, ids: IdList) -> int: ...
|
|
72
|
+
|
|
73
|
+
async def delete_where(self, *, criteria: Sequence[SqlFilter], params: RowMapping | None = None) -> int: ...
|
|
74
|
+
|
|
75
|
+
async def update_partial(
|
|
76
|
+
self,
|
|
77
|
+
obj_id: PrimaryKey,
|
|
78
|
+
fields: RowMapping,
|
|
79
|
+
writable: frozenset[str],
|
|
80
|
+
*,
|
|
81
|
+
touch_updated_on: bool = False,
|
|
82
|
+
) -> int: ...
|
|
83
|
+
|
|
84
|
+
async def update_where(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
criteria: Sequence[SqlFilter],
|
|
88
|
+
values: RowMapping,
|
|
89
|
+
params: RowMapping | None = None,
|
|
90
|
+
) -> int: ...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AsyncRepositoryFactory(Protocol):
|
|
94
|
+
"""Session-scoped factory for async statement builders and entity repositories."""
|
|
95
|
+
|
|
96
|
+
def create_statement(self, model: type[T]) -> AsyncStatementQueryBuilder[T]:
|
|
97
|
+
"""Return a fluent async read builder for ``model``."""
|
|
98
|
+
...
|
|
99
|
+
|
|
100
|
+
def get_repository(self, repo_class: type[R]) -> R:
|
|
101
|
+
"""Return a cached typed entity repository."""
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
def repository(self, model: type[T]) -> AsyncBaseRepositoryProtocol[T, AsyncRepositoryFactory | None]:
|
|
105
|
+
"""Return generic CRUD helpers for ``model`` (``AsyncBaseRepository`` in Phobos)."""
|
|
106
|
+
...
|