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.

Files changed (37) hide show
  1. sqlphilosophy-0.2.0/PKG-INFO +179 -0
  2. sqlphilosophy-0.2.0/README.md +139 -0
  3. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/pyproject.toml +28 -1
  4. sqlphilosophy-0.2.0/src/sqlphilosophy/VERSION +1 -0
  5. sqlphilosophy-0.2.0/src/sqlphilosophy/__init__.py +18 -0
  6. sqlphilosophy-0.2.0/src/sqlphilosophy/_repository_shared.py +114 -0
  7. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/aio/protocols.py +30 -40
  8. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/aio/query.py +49 -30
  9. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/aio/repository.py +76 -95
  10. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/audit/context.py +1 -0
  11. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/audit/fields.py +1 -0
  12. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/audit/listener.py +8 -8
  13. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/audit/model.py +4 -4
  14. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/sorting.py +20 -4
  15. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/sql.py +71 -132
  16. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/sync/protocols.py +33 -42
  17. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/sync/query.py +52 -43
  18. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/sync/repository.py +58 -76
  19. sqlphilosophy-0.2.0/src/sqlphilosophy/trusted_sql.py +106 -0
  20. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/types.py +6 -10
  21. sqlphilosophy-0.2.0/src/sqlphilosophy.egg-info/PKG-INFO +179 -0
  22. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy.egg-info/SOURCES.txt +2 -0
  23. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy.egg-info/requires.txt +2 -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.2.0}/LICENSE +0 -0
  30. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/MANIFEST.in +0 -0
  31. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/setup.cfg +0 -0
  32. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/aio/__init__.py +0 -0
  33. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/audit/__init__.py +0 -0
  34. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/py.typed +0 -0
  35. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy/sync/__init__.py +0 -0
  36. {sqlphilosophy-0.1.8 → sqlphilosophy-0.2.0}/src/sqlphilosophy.egg-info/dependency_links.txt +0 -0
  37. {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
- "flake8>=7",
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 AsyncBaseRepositoryProtocol[T: DeclarativeBase, U: Optional[AsyncRepositoryFactory]](
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
- """Generic read/write surface shared by sqlphilosophy ``AsyncBaseRepository[T]``."""
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
- ...