sqlphilosophy 0.1.8__tar.gz → 0.2.0__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.2.0/PKG-INFO +179 -0
- sqlphilosophy-0.2.0/README.md +139 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/pyproject.toml +28 -1
- sqlphilosophy-0.2.0/src/sqlphilosophy/VERSION +1 -0
- sqlphilosophy-0.2.0/src/sqlphilosophy/__init__.py +18 -0
- sqlphilosophy-0.2.0/src/sqlphilosophy/_repository_shared.py +114 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/aio/protocols.py +30 -40
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/aio/query.py +49 -30
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/aio/repository.py +76 -95
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/audit/context.py +1 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/audit/fields.py +1 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/audit/listener.py +8 -8
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/audit/model.py +4 -4
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/sorting.py +20 -4
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/sql.py +71 -132
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/sync/protocols.py +33 -42
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/sync/query.py +52 -43
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/sync/repository.py +58 -76
- sqlphilosophy-0.2.0/src/sqlphilosophy/trusted_sql.py +106 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/types.py +6 -10
- sqlphilosophy-0.2.0/src/sqlphilosophy.egg-info/PKG-INFO +179 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy.egg-info/SOURCES.txt +2 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy.egg-info/requires.txt +2 -1
- sqlphilosophy-0.1.8/PKG-INFO +0 -254
- sqlphilosophy-0.1.8/README.md +0 -215
- sqlphilosophy-0.1.8/src/sqlphilosophy/VERSION +0 -1
- sqlphilosophy-0.1.8/src/sqlphilosophy/__init__.py +0 -3
- sqlphilosophy-0.1.8/src/sqlphilosophy.egg-info/PKG-INFO +0 -254
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/LICENSE +0 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/MANIFEST.in +0 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/setup.cfg +0 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/aio/__init__.py +0 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/audit/__init__.py +0 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/py.typed +0 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/sync/__init__.py +0 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy.egg-info/dependency_links.txt +0 -0
- {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlphilosophy
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Portable SQLAlchemy repository kit: sync and async CRUD, statement builders, sort/pagination, and SQL helpers.
|
|
5
|
+
Author-email: Josh Martin <denverprogrammer@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SignalSafeSoftware/sqlphilosophy
|
|
8
|
+
Project-URL: Repository, https://github.com/SignalSafeSoftware/sqlphilosophy
|
|
9
|
+
Project-URL: Documentation, https://github.com/SignalSafeSoftware/sqlphilosophy#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/SignalSafeSoftware/sqlphilosophy/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/SignalSafeSoftware/sqlphilosophy/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: sqlalchemy,repository-pattern,repository,orm,database,pagination,audit,async
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: <4.0,>=3.12
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: servicephilosophy>=0.1.0
|
|
24
|
+
Requires-Dist: sqlalchemy<3,>=2.0
|
|
25
|
+
Provides-Extra: async
|
|
26
|
+
Requires-Dist: greenlet>=3.0; extra == "async"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov>=6; extra == "dev"
|
|
31
|
+
Requires-Dist: aiosqlite>=0.20; extra == "dev"
|
|
32
|
+
Requires-Dist: greenlet>=3.0; extra == "dev"
|
|
33
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
34
|
+
Requires-Dist: twine>=5; extra == "dev"
|
|
35
|
+
Requires-Dist: ruff>=0.15; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
37
|
+
Requires-Dist: bandit>=1.7; extra == "dev"
|
|
38
|
+
Requires-Dist: types-greenlet>=3.0; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# sqlphilosophy
|
|
42
|
+
|
|
43
|
+
Portable SQLAlchemy repository kit: sync and async CRUD, fluent statement builders, sort/pagination, Core SQL helpers, and optional audit listeners.
|
|
44
|
+
|
|
45
|
+
| | |
|
|
46
|
+
|---|---|
|
|
47
|
+
| **PyPI** | [`sqlphilosophy`](https://pypi.org/project/sqlphilosophy/) |
|
|
48
|
+
| **GitHub** | [SignalSafeSoftware/sqlphilosophy](https://github.com/SignalSafeSoftware/sqlphilosophy) |
|
|
49
|
+
| **Import** | `sqlphilosophy` (`__version__` only) — use explicit submodules for APIs |
|
|
50
|
+
| **Python** | 3.12+ |
|
|
51
|
+
| **License** | MIT — see [LICENSE](./LICENSE) |
|
|
52
|
+
|
|
53
|
+
## Documentation
|
|
54
|
+
|
|
55
|
+
| Resource | Description |
|
|
56
|
+
|----------|-------------|
|
|
57
|
+
| [Repository guide](./docs/repository-guide.md) | Entry point: overview, transaction model, links to usage pages |
|
|
58
|
+
| [Usage examples](./docs/usage/) | Focused sync/async code examples by feature area |
|
|
59
|
+
| [Strongly typed repositories](./docs/usage/strongly-typed-repositories.md) | Typed subclasses, factories, protocols, and service patterns |
|
|
60
|
+
| [Service factory composition](./docs/usage/service-factory-composition.md) | Compose sqlPhilosophy SQL repos with servicePhilosophy business services |
|
|
61
|
+
| [Before/after SQLAlchemy](./docs/usage/before-after-sqlalchemy.md) | Migration examples: direct SQLAlchemy vs repository-centered code |
|
|
62
|
+
| [Feature matrix](./docs/feature-matrix.md) | Full sync/async capability map |
|
|
63
|
+
| [Typed repository (sync)](./docs/examples/typed_repository_sync.py) | Runnable factory + domain repo example |
|
|
64
|
+
| [Typed repository (async)](./docs/examples/typed_repository_async.py) | Async counterpart |
|
|
65
|
+
|
|
66
|
+
## What this package does
|
|
67
|
+
|
|
68
|
+
- **Repository pattern** for a single mapped model (`BaseRepository`, `AsyncBaseRepository`).
|
|
69
|
+
- **Fluent query builders** with pagination/sort (`StatementQueryBuilder`, `ListQuery`, `SortConfig`).
|
|
70
|
+
- **SQL helpers** for row mapping, partial updates, and developer-defined fragments via **`sqlphilosophy.trusted_sql`**.
|
|
71
|
+
- **Optional audit listeners** and timestamp mixins.
|
|
72
|
+
|
|
73
|
+
## What this package does not do
|
|
74
|
+
|
|
75
|
+
- Migrations, schema design, or connection pooling configuration.
|
|
76
|
+
- Authorization, multi-tenant isolation, or query sandboxing.
|
|
77
|
+
- Automatic commits for normal CRUD — see [Transaction ownership](#transaction-ownership).
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install sqlphilosophy
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Async ORM (`AsyncSession`) also needs greenlet:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install sqlphilosophy[async]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Requires Python 3.12+ and SQLAlchemy 2.x.
|
|
92
|
+
|
|
93
|
+
## Quick start (sync)
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from sqlalchemy import String, create_engine
|
|
97
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
|
98
|
+
|
|
99
|
+
from sqlphilosophy.sorting import ListQuery
|
|
100
|
+
from sqlphilosophy.sync.repository import BaseRepository
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Base(DeclarativeBase):
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Widget(Base):
|
|
108
|
+
__tablename__ = "widget"
|
|
109
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
110
|
+
name: Mapped[str] = mapped_column(String(64))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
engine = create_engine("sqlite:///:memory:", future=True)
|
|
114
|
+
Base.metadata.create_all(engine)
|
|
115
|
+
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
|
116
|
+
|
|
117
|
+
with SessionLocal() as session:
|
|
118
|
+
repo = BaseRepository(Widget, session)
|
|
119
|
+
repo.create(name="alpha") # stages + flush; does not commit
|
|
120
|
+
session.commit()
|
|
121
|
+
|
|
122
|
+
rows, total = repo.statement().fetch_page(ListQuery.from_page(page=1, size=20))
|
|
123
|
+
assert total >= 1
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Async:** use `AsyncSession`, `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` on repository/builder terminals. See the [repository guide](./docs/repository-guide.md).
|
|
127
|
+
|
|
128
|
+
## Package layout
|
|
129
|
+
|
|
130
|
+
| Module | Contents |
|
|
131
|
+
|--------|----------|
|
|
132
|
+
| `sqlphilosophy` | `__version__` only |
|
|
133
|
+
| `sqlphilosophy.types` | Portable typing aliases |
|
|
134
|
+
| `sqlphilosophy.sql` | Row mapping, partial updates, Core helpers (re-exports `trusted_sql`) |
|
|
135
|
+
| `sqlphilosophy.trusted_sql` | Developer-trusted SQL fragments — see [SECURITY.md](./SECURITY.md) |
|
|
136
|
+
| `sqlphilosophy.sorting` | `ListQuery`, `SortConfig`, `SortSpec` |
|
|
137
|
+
| `sqlphilosophy.sync` / `sqlphilosophy.aio` | Repositories, query builders, factory protocols |
|
|
138
|
+
| `sqlphilosophy.audit` | Optional listeners and timestamp mixins |
|
|
139
|
+
|
|
140
|
+
## Transaction ownership
|
|
141
|
+
|
|
142
|
+
- **`create` / `add` / `update_partial` / `remove` / `delete_*` / `update_where`** — flush or execute DML; **caller commits**.
|
|
143
|
+
- **`delete_all()`** — bulk delete; **does not commit**.
|
|
144
|
+
- **`batched_purge_ids(..., batch_size=...)`** — deletes in batches and **commits after each batch**; requires **`batch_size >= 1`**. Authorize in application code first.
|
|
145
|
+
|
|
146
|
+
## Raw SQL trust boundaries
|
|
147
|
+
|
|
148
|
+
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.
|
|
149
|
+
|
|
150
|
+
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).
|
|
151
|
+
|
|
152
|
+
## Development
|
|
153
|
+
|
|
154
|
+
This repo uses [uv](https://docs.astral.sh/uv/) and [Ruff](https://docs.astral.sh/ruff/):
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
uv sync --extra dev
|
|
158
|
+
uv run pytest
|
|
159
|
+
uv run ruff check src tests docs/examples
|
|
160
|
+
uv run ruff format src tests docs/examples
|
|
161
|
+
|
|
162
|
+
# Optional: validate runnable docs examples (SQLite in-memory; CI runs these in smoke-package)
|
|
163
|
+
uv run --extra dev python docs/examples/typed_repository_sync.py
|
|
164
|
+
uv run --extra dev python docs/examples/typed_repository_async.py
|
|
165
|
+
|
|
166
|
+
uv run python -m build
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Security
|
|
170
|
+
|
|
171
|
+
See [SECURITY.md](./SECURITY.md) for vulnerability reporting and SQL trust boundaries.
|
|
172
|
+
|
|
173
|
+
## Releasing
|
|
174
|
+
|
|
175
|
+
See [RELEASING.md](./RELEASING.md) for GitHub + PyPI trusted publishing. See [CHANGELOG.md](./CHANGELOG.md).
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
| [Service factory composition](./docs/usage/service-factory-composition.md) | Compose sqlPhilosophy SQL repos with servicePhilosophy business services |
|
|
21
|
+
| [Before/after SQLAlchemy](./docs/usage/before-after-sqlalchemy.md) | Migration examples: direct SQLAlchemy vs repository-centered code |
|
|
22
|
+
| [Feature matrix](./docs/feature-matrix.md) | Full sync/async capability map |
|
|
23
|
+
| [Typed repository (sync)](./docs/examples/typed_repository_sync.py) | Runnable factory + domain repo example |
|
|
24
|
+
| [Typed repository (async)](./docs/examples/typed_repository_async.py) | Async counterpart |
|
|
25
|
+
|
|
26
|
+
## What this package does
|
|
27
|
+
|
|
28
|
+
- **Repository pattern** for a single mapped model (`BaseRepository`, `AsyncBaseRepository`).
|
|
29
|
+
- **Fluent query builders** with pagination/sort (`StatementQueryBuilder`, `ListQuery`, `SortConfig`).
|
|
30
|
+
- **SQL helpers** for row mapping, partial updates, and developer-defined fragments via **`sqlphilosophy.trusted_sql`**.
|
|
31
|
+
- **Optional audit listeners** and timestamp mixins.
|
|
32
|
+
|
|
33
|
+
## What this package does not do
|
|
34
|
+
|
|
35
|
+
- Migrations, schema design, or connection pooling configuration.
|
|
36
|
+
- Authorization, multi-tenant isolation, or query sandboxing.
|
|
37
|
+
- Automatic commits for normal CRUD — see [Transaction ownership](#transaction-ownership).
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install sqlphilosophy
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Async ORM (`AsyncSession`) also needs greenlet:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install sqlphilosophy[async]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Requires Python 3.12+ and SQLAlchemy 2.x.
|
|
52
|
+
|
|
53
|
+
## Quick start (sync)
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from sqlalchemy import String, create_engine
|
|
57
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
|
58
|
+
|
|
59
|
+
from sqlphilosophy.sorting import ListQuery
|
|
60
|
+
from sqlphilosophy.sync.repository import BaseRepository
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Base(DeclarativeBase):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Widget(Base):
|
|
68
|
+
__tablename__ = "widget"
|
|
69
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
70
|
+
name: Mapped[str] = mapped_column(String(64))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
engine = create_engine("sqlite:///:memory:", future=True)
|
|
74
|
+
Base.metadata.create_all(engine)
|
|
75
|
+
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
|
76
|
+
|
|
77
|
+
with SessionLocal() as session:
|
|
78
|
+
repo = BaseRepository(Widget, session)
|
|
79
|
+
repo.create(name="alpha") # stages + flush; does not commit
|
|
80
|
+
session.commit()
|
|
81
|
+
|
|
82
|
+
rows, total = repo.statement().fetch_page(ListQuery.from_page(page=1, size=20))
|
|
83
|
+
assert total >= 1
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Async:** use `AsyncSession`, `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` on repository/builder terminals. See the [repository guide](./docs/repository-guide.md).
|
|
87
|
+
|
|
88
|
+
## Package layout
|
|
89
|
+
|
|
90
|
+
| Module | Contents |
|
|
91
|
+
|--------|----------|
|
|
92
|
+
| `sqlphilosophy` | `__version__` only |
|
|
93
|
+
| `sqlphilosophy.types` | Portable typing aliases |
|
|
94
|
+
| `sqlphilosophy.sql` | Row mapping, partial updates, Core helpers (re-exports `trusted_sql`) |
|
|
95
|
+
| `sqlphilosophy.trusted_sql` | Developer-trusted SQL fragments — see [SECURITY.md](./SECURITY.md) |
|
|
96
|
+
| `sqlphilosophy.sorting` | `ListQuery`, `SortConfig`, `SortSpec` |
|
|
97
|
+
| `sqlphilosophy.sync` / `sqlphilosophy.aio` | Repositories, query builders, factory protocols |
|
|
98
|
+
| `sqlphilosophy.audit` | Optional listeners and timestamp mixins |
|
|
99
|
+
|
|
100
|
+
## Transaction ownership
|
|
101
|
+
|
|
102
|
+
- **`create` / `add` / `update_partial` / `remove` / `delete_*` / `update_where`** — flush or execute DML; **caller commits**.
|
|
103
|
+
- **`delete_all()`** — bulk delete; **does not commit**.
|
|
104
|
+
- **`batched_purge_ids(..., batch_size=...)`** — deletes in batches and **commits after each batch**; requires **`batch_size >= 1`**. Authorize in application code first.
|
|
105
|
+
|
|
106
|
+
## Raw SQL trust boundaries
|
|
107
|
+
|
|
108
|
+
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.
|
|
109
|
+
|
|
110
|
+
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).
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
This repo uses [uv](https://docs.astral.sh/uv/) and [Ruff](https://docs.astral.sh/ruff/):
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
uv sync --extra dev
|
|
118
|
+
uv run pytest
|
|
119
|
+
uv run ruff check src tests docs/examples
|
|
120
|
+
uv run ruff format src tests docs/examples
|
|
121
|
+
|
|
122
|
+
# Optional: validate runnable docs examples (SQLite in-memory; CI runs these in smoke-package)
|
|
123
|
+
uv run --extra dev python docs/examples/typed_repository_sync.py
|
|
124
|
+
uv run --extra dev python docs/examples/typed_repository_async.py
|
|
125
|
+
|
|
126
|
+
uv run python -m build
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Security
|
|
130
|
+
|
|
131
|
+
See [SECURITY.md](./SECURITY.md) for vulnerability reporting and SQL trust boundaries.
|
|
132
|
+
|
|
133
|
+
## Releasing
|
|
134
|
+
|
|
135
|
+
See [RELEASING.md](./RELEASING.md) for GitHub + PyPI trusted publishing. See [CHANGELOG.md](./CHANGELOG.md).
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -24,6 +24,7 @@ classifiers = [
|
|
|
24
24
|
]
|
|
25
25
|
dynamic = ["version"]
|
|
26
26
|
dependencies = [
|
|
27
|
+
"servicephilosophy>=0.1.0",
|
|
27
28
|
"sqlalchemy>=2.0,<3",
|
|
28
29
|
]
|
|
29
30
|
|
|
@@ -39,7 +40,7 @@ dev = [
|
|
|
39
40
|
"greenlet>=3.0",
|
|
40
41
|
"build>=1.2",
|
|
41
42
|
"twine>=5",
|
|
42
|
-
"
|
|
43
|
+
"ruff>=0.15",
|
|
43
44
|
"mypy>=1.11",
|
|
44
45
|
"bandit>=1.7",
|
|
45
46
|
"types-greenlet>=3.0",
|
|
@@ -83,5 +84,31 @@ python_version = "3.12"
|
|
|
83
84
|
plugins = ["sqlalchemy.ext.mypy.plugin"]
|
|
84
85
|
warn_unused_ignores = true
|
|
85
86
|
|
|
87
|
+
[tool.ruff]
|
|
88
|
+
target-version = "py312"
|
|
89
|
+
line-length = 120
|
|
90
|
+
src = ["src", "tests", "docs/examples"]
|
|
91
|
+
|
|
92
|
+
[tool.ruff.lint]
|
|
93
|
+
select = [
|
|
94
|
+
"E",
|
|
95
|
+
"F",
|
|
96
|
+
"W",
|
|
97
|
+
"I",
|
|
98
|
+
"B",
|
|
99
|
+
"UP",
|
|
100
|
+
"SIM",
|
|
101
|
+
"PT",
|
|
102
|
+
"RUF",
|
|
103
|
+
]
|
|
104
|
+
ignore = [
|
|
105
|
+
"E203",
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
[tool.ruff.lint.per-file-ignores]
|
|
109
|
+
"tests/**/*.py" = [
|
|
110
|
+
"PT011",
|
|
111
|
+
]
|
|
112
|
+
|
|
86
113
|
[tool.uv]
|
|
87
114
|
package = true
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.2.0
|
|
@@ -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)
|
|
@@ -3,45 +3,57 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
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
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
|
|
11
7
|
|
|
12
8
|
if TYPE_CHECKING:
|
|
13
9
|
from sqlphilosophy.aio.repository import AsyncBaseRepository
|
|
14
10
|
|
|
11
|
+
from servicephilosophy import RepositoryFactoryProtocol, ServiceRepositoryProtocol
|
|
15
12
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
13
|
from sqlalchemy.orm import DeclarativeBase
|
|
17
14
|
|
|
18
15
|
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
|
|
16
|
+
from sqlphilosophy.types import IdList, PrimaryKey, RowMapping, RowValue, SqlFilter
|
|
24
17
|
|
|
25
18
|
T = TypeVar("T", bound=DeclarativeBase)
|
|
26
19
|
R = TypeVar("R", bound="AsyncBaseRepository[Any, Any]")
|
|
27
20
|
|
|
28
21
|
|
|
29
|
-
class
|
|
22
|
+
class AsyncRepositoryFactory(RepositoryFactoryProtocol, Protocol):
|
|
23
|
+
"""Async session-scoped factory for statement builders and entity repositories."""
|
|
24
|
+
|
|
25
|
+
def create_statement(self, model: type[T]) -> AsyncStatementQueryBuilder[T]:
|
|
26
|
+
"""Return a fluent async read builder for ``model``."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def get_repository(self, repo_class: type[R]) -> R:
|
|
30
|
+
"""Return a cached typed entity repository."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def repository(self, model: type[T]) -> AsyncBaseRepositoryProtocol[T, AsyncRepositoryFactory]:
|
|
34
|
+
"""Return generic CRUD helpers for ``model`` (``AsyncBaseRepository``)."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncBaseRepositoryProtocol[T: DeclarativeBase, U: AsyncRepositoryFactory](
|
|
39
|
+
ServiceRepositoryProtocol[U],
|
|
30
40
|
Protocol,
|
|
31
41
|
):
|
|
32
|
-
"""
|
|
42
|
+
"""Async SQL read/write surface for ``AsyncBaseRepository[T]``.
|
|
43
|
+
|
|
44
|
+
Factory access (``.factory``, ``.maybe_factory``, ``.has_factory``) is inherited
|
|
45
|
+
from ``ServiceRepositoryProtocol``; this protocol adds the mapped model, session,
|
|
46
|
+
and SQLAlchemy CRUD/query methods only.
|
|
47
|
+
"""
|
|
33
48
|
|
|
34
49
|
model: type[T]
|
|
35
50
|
_session: AsyncSession
|
|
36
|
-
_factory: Optional[U]
|
|
37
51
|
|
|
38
52
|
async def get(self, obj_id: PrimaryKey, load_relations: Any = None) -> T: ...
|
|
39
53
|
|
|
40
54
|
async def get_by_id(self, obj_id: PrimaryKey, load_relations: Any = None) -> T | None: ...
|
|
41
55
|
|
|
42
|
-
async def get_many(
|
|
43
|
-
self, ids: Sequence[PrimaryKey], load_relations: Any = None
|
|
44
|
-
) -> Sequence[T]: ...
|
|
56
|
+
async def get_many(self, ids: Sequence[PrimaryKey], load_relations: Any = None) -> Sequence[T]: ...
|
|
45
57
|
|
|
46
58
|
async def first(self, load_relations: Any = None, **filters: RowValue) -> T | None: ...
|
|
47
59
|
|
|
@@ -74,17 +86,13 @@ class AsyncBaseRepositoryProtocol[T: DeclarativeBase, U: Optional[AsyncRepositor
|
|
|
74
86
|
|
|
75
87
|
async def add(self, obj: T) -> T: ...
|
|
76
88
|
|
|
77
|
-
async def get_or_create(
|
|
78
|
-
self, *, defaults: RowMapping | None = None, **lookup: RowValue
|
|
79
|
-
) -> tuple[T, bool]: ...
|
|
89
|
+
async def get_or_create(self, *, defaults: RowMapping | None = None, **lookup: RowValue) -> tuple[T, bool]: ...
|
|
80
90
|
|
|
81
91
|
async def remove(self, obj_id: PrimaryKey) -> bool: ...
|
|
82
92
|
|
|
83
93
|
async def delete_many(self, ids: IdList) -> int: ...
|
|
84
94
|
|
|
85
|
-
async def delete_where(
|
|
86
|
-
self, *, criteria: Sequence[SqlFilter], params: RowMapping | None = None
|
|
87
|
-
) -> int: ...
|
|
95
|
+
async def delete_where(self, *, criteria: Sequence[SqlFilter], params: RowMapping | None = None) -> int: ...
|
|
88
96
|
|
|
89
97
|
async def update_partial(
|
|
90
98
|
self,
|
|
@@ -102,21 +110,3 @@ class AsyncBaseRepositoryProtocol[T: DeclarativeBase, U: Optional[AsyncRepositor
|
|
|
102
110
|
values: RowMapping,
|
|
103
111
|
params: RowMapping | None = None,
|
|
104
112
|
) -> 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
|
-
...
|