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.

Files changed (37) hide show
  1. {sqlphilosophy-0.1.6/src/sqlphilosophy.egg-info → sqlphilosophy-0.1.9}/PKG-INFO +42 -82
  2. sqlphilosophy-0.1.9/README.md +138 -0
  3. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/pyproject.toml +27 -1
  4. sqlphilosophy-0.1.9/src/sqlphilosophy/VERSION +1 -0
  5. sqlphilosophy-0.1.9/src/sqlphilosophy/__init__.py +18 -0
  6. sqlphilosophy-0.1.9/src/sqlphilosophy/_repository_shared.py +114 -0
  7. sqlphilosophy-0.1.9/src/sqlphilosophy/aio/protocols.py +106 -0
  8. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/aio/query.py +49 -30
  9. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/aio/repository.py +88 -107
  10. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/context.py +1 -0
  11. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/fields.py +1 -0
  12. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/listener.py +8 -8
  13. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/model.py +4 -4
  14. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sorting.py +20 -4
  15. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sql.py +75 -165
  16. sqlphilosophy-0.1.9/src/sqlphilosophy/sync/protocols.py +103 -0
  17. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sync/query.py +52 -43
  18. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sync/repository.py +76 -91
  19. sqlphilosophy-0.1.9/src/sqlphilosophy/trusted_sql.py +106 -0
  20. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/types.py +6 -10
  21. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9/src/sqlphilosophy.egg-info}/PKG-INFO +42 -82
  22. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy.egg-info/SOURCES.txt +2 -0
  23. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy.egg-info/requires.txt +1 -1
  24. sqlphilosophy-0.1.6/README.md +0 -178
  25. sqlphilosophy-0.1.6/src/sqlphilosophy/VERSION +0 -1
  26. sqlphilosophy-0.1.6/src/sqlphilosophy/__init__.py +0 -3
  27. sqlphilosophy-0.1.6/src/sqlphilosophy/aio/protocols.py +0 -26
  28. sqlphilosophy-0.1.6/src/sqlphilosophy/sync/protocols.py +0 -26
  29. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/LICENSE +0 -0
  30. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/MANIFEST.in +0 -0
  31. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/setup.cfg +0 -0
  32. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/aio/__init__.py +0 -0
  33. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/__init__.py +0 -0
  34. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/py.typed +0 -0
  35. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sync/__init__.py +0 -0
  36. {sqlphilosophy-0.1.6 → sqlphilosophy-0.1.9}/src/sqlphilosophy.egg-info/dependency_links.txt +0 -0
  37. {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.6
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: flake8>=7; extra == "dev"
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` (explicit submodules — no root re-exports) |
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, filters, and developer-defined raw SQL fragments.
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) below.
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
- ## Full example (sync model + session)
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
- widget = repo.create(name="alpha") # stages + flush; does not commit
117
+ repo.create(name="alpha") # stages + flush; does not commit
106
118
  session.commit()
107
119
 
108
- page = repo.statement().fetch_page(ListQuery.from_page(page=1, size=20))
109
- assert page.total >= 1
120
+ rows, total = repo.statement().fetch_page(ListQuery.from_page(page=1, size=20))
121
+ assert total >= 1
110
122
  ```
111
123
 
112
- **Async:** swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
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.types` | Portable typing aliases (`RowMapping`, `PrimaryKey`, `SqlFilter`, …) |
119
- | `sqlphilosophy.sql` | Row mapping helpers, partial updates, Core table helpers, filter builders |
120
- | `sqlphilosophy.sorting` | `ListQuery`, `SortConfig`, `SortSpec`, pagination/sort resolution |
121
- | `sqlphilosophy.sync` | Sync `BaseRepository`, `StatementQueryBuilder`, `RepositoryFactory` protocol |
122
- | `sqlphilosophy.aio` | Async `AsyncBaseRepository`, `AsyncStatementQueryBuilder`, `AsyncRepositoryFactory` |
123
- | `sqlphilosophy.audit` | Optional SQLAlchemy audit listeners and timestamp mixins |
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` / `update` / `delete` helpers** on repositories call `session.flush()` but **do not commit** unless documented otherwise.
158
- - **`delete_all()`** executes a bulk delete and **does not commit** — the caller owns `session.commit()` / `rollback()` for the work unit.
159
- - **`batched_purge_ids(...)`** deletes matching rows in batches and **commits after each batch** — treat it as a destructive, application-level operation you must authorize first.
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
- The following must be **developer-defined** and must **never** be built from end-user input:
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
- - **`delete_all()`** — removes all rows for the repository model (sync and async variants). Does **not** commit; caller must commit or roll back.
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 flake8 .
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
- "flake8>=7",
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
+ ...