sqlphilosophy 0.1.8__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.9/PKG-INFO +177 -0
  2. sqlphilosophy-0.1.9/README.md +138 -0
  3. {sqlphilosophy-0.1.8 → 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.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/aio/protocols.py +8 -24
  8. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/aio/query.py +49 -30
  9. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/aio/repository.py +65 -87
  10. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/context.py +1 -0
  11. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/fields.py +1 -0
  12. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/listener.py +8 -8
  13. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/model.py +4 -4
  14. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sorting.py +20 -4
  15. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sql.py +71 -132
  16. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sync/protocols.py +9 -26
  17. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sync/query.py +52 -43
  18. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sync/repository.py +46 -67
  19. sqlphilosophy-0.1.9/src/sqlphilosophy/trusted_sql.py +106 -0
  20. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/types.py +6 -10
  21. sqlphilosophy-0.1.9/src/sqlphilosophy.egg-info/PKG-INFO +177 -0
  22. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy.egg-info/SOURCES.txt +2 -0
  23. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy.egg-info/requires.txt +1 -1
  24. sqlphilosophy-0.1.8/PKG-INFO +0 -254
  25. sqlphilosophy-0.1.8/README.md +0 -215
  26. sqlphilosophy-0.1.8/src/sqlphilosophy/VERSION +0 -1
  27. sqlphilosophy-0.1.8/src/sqlphilosophy/__init__.py +0 -3
  28. sqlphilosophy-0.1.8/src/sqlphilosophy.egg-info/PKG-INFO +0 -254
  29. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/LICENSE +0 -0
  30. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/MANIFEST.in +0 -0
  31. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/setup.cfg +0 -0
  32. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/aio/__init__.py +0 -0
  33. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/audit/__init__.py +0 -0
  34. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/py.typed +0 -0
  35. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy/sync/__init__.py +0 -0
  36. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy.egg-info/dependency_links.txt +0 -0
  37. {sqlphilosophy-0.1.8 → sqlphilosophy-0.1.9}/src/sqlphilosophy.egg-info/top_level.txt +0 -0
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlphilosophy
3
+ Version: 0.1.9
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: sqlalchemy<3,>=2.0
24
+ Provides-Extra: async
25
+ Requires-Dist: greenlet>=3.0; extra == "async"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
29
+ Requires-Dist: pytest-cov>=6; extra == "dev"
30
+ Requires-Dist: aiosqlite>=0.20; extra == "dev"
31
+ Requires-Dist: greenlet>=3.0; extra == "dev"
32
+ Requires-Dist: build>=1.2; extra == "dev"
33
+ Requires-Dist: twine>=5; extra == "dev"
34
+ Requires-Dist: ruff>=0.15; extra == "dev"
35
+ Requires-Dist: mypy>=1.11; extra == "dev"
36
+ Requires-Dist: bandit>=1.7; extra == "dev"
37
+ Requires-Dist: types-greenlet>=3.0; extra == "dev"
38
+ Dynamic: license-file
39
+
40
+ # sqlphilosophy
41
+
42
+ Portable SQLAlchemy repository kit: sync and async CRUD, fluent statement builders, sort/pagination, Core SQL helpers, and optional audit listeners.
43
+
44
+ | | |
45
+ |---|---|
46
+ | **PyPI** | [`sqlphilosophy`](https://pypi.org/project/sqlphilosophy/) |
47
+ | **GitHub** | [SignalSafeSoftware/sqlphilosophy](https://github.com/SignalSafeSoftware/sqlphilosophy) |
48
+ | **Import** | `sqlphilosophy` (`__version__` only) — use explicit submodules for APIs |
49
+ | **Python** | 3.12+ |
50
+ | **License** | MIT — see [LICENSE](./LICENSE) |
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
+
64
+ ## What this package does
65
+
66
+ - **Repository pattern** for a single mapped model (`BaseRepository`, `AsyncBaseRepository`).
67
+ - **Fluent query builders** with pagination/sort (`StatementQueryBuilder`, `ListQuery`, `SortConfig`).
68
+ - **SQL helpers** for row mapping, partial updates, and developer-defined fragments via **`sqlphilosophy.trusted_sql`**.
69
+ - **Optional audit listeners** and timestamp mixins.
70
+
71
+ ## What this package does not do
72
+
73
+ - Migrations, schema design, or connection pooling configuration.
74
+ - Authorization, multi-tenant isolation, or query sandboxing.
75
+ - Automatic commits for normal CRUD — see [Transaction ownership](#transaction-ownership).
76
+
77
+ ## Install
78
+
79
+ ```bash
80
+ pip install sqlphilosophy
81
+ ```
82
+
83
+ Async ORM (`AsyncSession`) also needs greenlet:
84
+
85
+ ```bash
86
+ pip install sqlphilosophy[async]
87
+ ```
88
+
89
+ Requires Python 3.12+ and SQLAlchemy 2.x.
90
+
91
+ ## Quick start (sync)
92
+
93
+ ```python
94
+ from sqlalchemy import String, create_engine
95
+ from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
96
+
97
+ from sqlphilosophy.sorting import ListQuery
98
+ from sqlphilosophy.sync.repository import BaseRepository
99
+
100
+
101
+ class Base(DeclarativeBase):
102
+ pass
103
+
104
+
105
+ class Widget(Base):
106
+ __tablename__ = "widget"
107
+ id: Mapped[int] = mapped_column(primary_key=True)
108
+ name: Mapped[str] = mapped_column(String(64))
109
+
110
+
111
+ engine = create_engine("sqlite:///:memory:", future=True)
112
+ Base.metadata.create_all(engine)
113
+ SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
114
+
115
+ with SessionLocal() as session:
116
+ repo = BaseRepository(Widget, session)
117
+ repo.create(name="alpha") # stages + flush; does not commit
118
+ session.commit()
119
+
120
+ rows, total = repo.statement().fetch_page(ListQuery.from_page(page=1, size=20))
121
+ assert total >= 1
122
+ ```
123
+
124
+ **Async:** use `AsyncSession`, `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` on repository/builder terminals. See the [repository guide](./docs/repository-guide.md).
125
+
126
+ ## Package layout
127
+
128
+ | Module | Contents |
129
+ |--------|----------|
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 |
137
+
138
+ ## Transaction ownership
139
+
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.
143
+
144
+ ## Raw SQL trust boundaries
145
+
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.
147
+
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).
149
+
150
+ ## Development
151
+
152
+ This repo uses [uv](https://docs.astral.sh/uv/) and [Ruff](https://docs.astral.sh/ruff/):
153
+
154
+ ```bash
155
+ uv sync --extra dev
156
+ uv run pytest
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
+
164
+ uv run python -m build
165
+ ```
166
+
167
+ ## Security
168
+
169
+ See [SECURITY.md](./SECURITY.md) for vulnerability reporting and SQL trust boundaries.
170
+
171
+ ## Releasing
172
+
173
+ See [RELEASING.md](./RELEASING.md) for GitHub + PyPI trusted publishing. See [CHANGELOG.md](./CHANGELOG.md).
174
+
175
+ ## License
176
+
177
+ MIT — see [LICENSE](./LICENSE).
@@ -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)
@@ -3,11 +3,7 @@
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
@@ -16,32 +12,26 @@ from sqlalchemy.ext.asyncio import AsyncSession
16
12
  from sqlalchemy.orm import DeclarativeBase
17
13
 
18
14
  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
15
+ from sqlphilosophy.types import IdList, PrimaryKey, RowMapping, RowValue, SqlFilter
24
16
 
25
17
  T = TypeVar("T", bound=DeclarativeBase)
26
18
  R = TypeVar("R", bound="AsyncBaseRepository[Any, Any]")
27
19
 
28
20
 
29
- class AsyncBaseRepositoryProtocol[T: DeclarativeBase, U: Optional[AsyncRepositoryFactory]](
21
+ class AsyncBaseRepositoryProtocol[T: DeclarativeBase, U: AsyncRepositoryFactory | None](
30
22
  Protocol,
31
23
  ):
32
24
  """Generic read/write surface shared by sqlphilosophy ``AsyncBaseRepository[T]``."""
33
25
 
34
26
  model: type[T]
35
27
  _session: AsyncSession
36
- _factory: Optional[U]
28
+ _factory: U | None
37
29
 
38
30
  async def get(self, obj_id: PrimaryKey, load_relations: Any = None) -> T: ...
39
31
 
40
32
  async def get_by_id(self, obj_id: PrimaryKey, load_relations: Any = None) -> T | None: ...
41
33
 
42
- async def get_many(
43
- self, ids: Sequence[PrimaryKey], load_relations: Any = None
44
- ) -> Sequence[T]: ...
34
+ async def get_many(self, ids: Sequence[PrimaryKey], load_relations: Any = None) -> Sequence[T]: ...
45
35
 
46
36
  async def first(self, load_relations: Any = None, **filters: RowValue) -> T | None: ...
47
37
 
@@ -74,17 +64,13 @@ class AsyncBaseRepositoryProtocol[T: DeclarativeBase, U: Optional[AsyncRepositor
74
64
 
75
65
  async def add(self, obj: T) -> T: ...
76
66
 
77
- async def get_or_create(
78
- self, *, defaults: RowMapping | None = None, **lookup: RowValue
79
- ) -> tuple[T, bool]: ...
67
+ async def get_or_create(self, *, defaults: RowMapping | None = None, **lookup: RowValue) -> tuple[T, bool]: ...
80
68
 
81
69
  async def remove(self, obj_id: PrimaryKey) -> bool: ...
82
70
 
83
71
  async def delete_many(self, ids: IdList) -> int: ...
84
72
 
85
- async def delete_where(
86
- self, *, criteria: Sequence[SqlFilter], params: RowMapping | None = None
87
- ) -> int: ...
73
+ async def delete_where(self, *, criteria: Sequence[SqlFilter], params: RowMapping | None = None) -> int: ...
88
74
 
89
75
  async def update_partial(
90
76
  self,
@@ -115,8 +101,6 @@ class AsyncRepositoryFactory(Protocol):
115
101
  """Return a cached typed entity repository."""
116
102
  ...
117
103
 
118
- def repository(
119
- self, model: type[T]
120
- ) -> AsyncBaseRepositoryProtocol[T, Optional[AsyncRepositoryFactory]]:
104
+ def repository(self, model: type[T]) -> AsyncBaseRepositoryProtocol[T, AsyncRepositoryFactory | None]:
121
105
  """Return generic CRUD helpers for ``model`` (``AsyncBaseRepository`` in Phobos)."""
122
106
  ...