sqlphilosophy 0.2.1__tar.gz → 0.3.3__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.
Files changed (34) hide show
  1. {sqlphilosophy-0.2.1/src/sqlphilosophy.egg-info → sqlphilosophy-0.3.3}/PKG-INFO +14 -1
  2. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/README.md +13 -0
  3. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/pyproject.toml +0 -1
  4. sqlphilosophy-0.3.3/src/sqlphilosophy/VERSION +1 -0
  5. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/_repository_shared.py +11 -0
  6. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/aio/repository.py +41 -6
  7. sqlphilosophy-0.3.3/src/sqlphilosophy/strict.py +142 -0
  8. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/sync/repository.py +41 -6
  9. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3/src/sqlphilosophy.egg-info}/PKG-INFO +14 -1
  10. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy.egg-info/SOURCES.txt +1 -0
  11. sqlphilosophy-0.2.1/src/sqlphilosophy/VERSION +0 -1
  12. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/LICENSE +0 -0
  13. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/MANIFEST.in +0 -0
  14. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/setup.cfg +0 -0
  15. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/__init__.py +0 -0
  16. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/aio/__init__.py +0 -0
  17. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/aio/protocols.py +0 -0
  18. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/aio/query.py +0 -0
  19. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/audit/__init__.py +0 -0
  20. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/audit/context.py +0 -0
  21. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/audit/fields.py +0 -0
  22. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/audit/listener.py +0 -0
  23. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/audit/model.py +0 -0
  24. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/py.typed +0 -0
  25. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/sorting.py +0 -0
  26. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/sql.py +0 -0
  27. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/sync/__init__.py +0 -0
  28. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/sync/protocols.py +0 -0
  29. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/sync/query.py +0 -0
  30. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/trusted_sql.py +0 -0
  31. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy/types.py +0 -0
  32. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy.egg-info/dependency_links.txt +0 -0
  33. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/src/sqlphilosophy.egg-info/requires.txt +0 -0
  34. {sqlphilosophy-0.2.1 → sqlphilosophy-0.3.3}/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.2.1
3
+ Version: 0.3.3
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
@@ -40,6 +40,8 @@ Dynamic: license-file
40
40
 
41
41
  # sqlphilosophy
42
42
 
43
+ Current source and planned customer-subdomain impact: [current-state summary](docs/current-state-summary.md).
44
+
43
45
  Portable SQLAlchemy repository kit: sync and async CRUD, fluent statement builders, sort/pagination, Core SQL helpers, and optional audit listeners.
44
46
 
45
47
  | | |
@@ -68,6 +70,8 @@ Portable SQLAlchemy repository kit: sync and async CRUD, fluent statement builde
68
70
  - **Repository pattern** for a single mapped model (`BaseRepository`, `AsyncBaseRepository`).
69
71
  - **Fluent query builders** with pagination/sort (`StatementQueryBuilder`, `ListQuery`, `SortConfig`).
70
72
  - **SQL helpers** for row mapping, partial updates, and developer-defined fragments via **`sqlphilosophy.trusted_sql`**.
73
+ - **Opt-in strict row decoders** via `sqlphilosophy.strict` for non-coercing
74
+ scalars, timestamps, enums, collections, UUIDs, and recursive JSON.
71
75
  - **Optional audit listeners** and timestamp mixins.
72
76
 
73
77
  ## What this package does not do
@@ -132,6 +136,7 @@ with SessionLocal() as session:
132
136
  | `sqlphilosophy` | `__version__` only |
133
137
  | `sqlphilosophy.types` | Portable typing aliases |
134
138
  | `sqlphilosophy.sql` | Row mapping, partial updates, Core helpers (re-exports `trusted_sql`) |
139
+ | `sqlphilosophy.strict` | Opt-in non-coercing row decoders and strict JSON validation |
135
140
  | `sqlphilosophy.trusted_sql` | Developer-trusted SQL fragments — see [SECURITY.md](./SECURITY.md) |
136
141
  | `sqlphilosophy.sorting` | `ListQuery`, `SortConfig`, `SortSpec` |
137
142
  | `sqlphilosophy.sync` / `sqlphilosophy.aio` | Repositories, query builders, factory protocols |
@@ -177,3 +182,11 @@ See [RELEASING.md](./RELEASING.md) for GitHub + PyPI trusted publishing. See [CH
177
182
  ## License
178
183
 
179
184
  MIT — see [LICENSE](./LICENSE).
185
+
186
+ ## Expression lookups and scope overrides (0.3.1)
187
+
188
+ Both repository bases provide `find_entity(criteria=..., lock=False)`, `find_row(criteria=..., columns=..., lock=False)` and `count_scoped(criteria=...)`. Criteria must not be empty; projected lookups also require columns. Entity lookups return a mapped object, projected lookups return RowMapping, and absence returns None. These methods never commit.
189
+
190
+ Override `apply_scope(statement)` for additional predicates on these helpers. It runs after projection initialization, because `select_columns`/`select_entity` reset the builder's SELECT. This hook does not implicitly scope existing CRUD helpers or arbitrary builder calls: keep ownership checks in named application repository methods. Existing `first`, `get_by_id`, `count` and statement APIs remain available and unchanged.
191
+
192
+ The 0.3.1 artifact is a local candidate until separately published. Dependent candidate packages use a vendored wheel during validation; publish sqlPhilosophy first before replacing their local sources with registry-only dependencies.
@@ -1,5 +1,7 @@
1
1
  # sqlphilosophy
2
2
 
3
+ Current source and planned customer-subdomain impact: [current-state summary](docs/current-state-summary.md).
4
+
3
5
  Portable SQLAlchemy repository kit: sync and async CRUD, fluent statement builders, sort/pagination, Core SQL helpers, and optional audit listeners.
4
6
 
5
7
  | | |
@@ -28,6 +30,8 @@ Portable SQLAlchemy repository kit: sync and async CRUD, fluent statement builde
28
30
  - **Repository pattern** for a single mapped model (`BaseRepository`, `AsyncBaseRepository`).
29
31
  - **Fluent query builders** with pagination/sort (`StatementQueryBuilder`, `ListQuery`, `SortConfig`).
30
32
  - **SQL helpers** for row mapping, partial updates, and developer-defined fragments via **`sqlphilosophy.trusted_sql`**.
33
+ - **Opt-in strict row decoders** via `sqlphilosophy.strict` for non-coercing
34
+ scalars, timestamps, enums, collections, UUIDs, and recursive JSON.
31
35
  - **Optional audit listeners** and timestamp mixins.
32
36
 
33
37
  ## What this package does not do
@@ -92,6 +96,7 @@ with SessionLocal() as session:
92
96
  | `sqlphilosophy` | `__version__` only |
93
97
  | `sqlphilosophy.types` | Portable typing aliases |
94
98
  | `sqlphilosophy.sql` | Row mapping, partial updates, Core helpers (re-exports `trusted_sql`) |
99
+ | `sqlphilosophy.strict` | Opt-in non-coercing row decoders and strict JSON validation |
95
100
  | `sqlphilosophy.trusted_sql` | Developer-trusted SQL fragments — see [SECURITY.md](./SECURITY.md) |
96
101
  | `sqlphilosophy.sorting` | `ListQuery`, `SortConfig`, `SortSpec` |
97
102
  | `sqlphilosophy.sync` / `sqlphilosophy.aio` | Repositories, query builders, factory protocols |
@@ -137,3 +142,11 @@ See [RELEASING.md](./RELEASING.md) for GitHub + PyPI trusted publishing. See [CH
137
142
  ## License
138
143
 
139
144
  MIT — see [LICENSE](./LICENSE).
145
+
146
+ ## Expression lookups and scope overrides (0.3.1)
147
+
148
+ Both repository bases provide `find_entity(criteria=..., lock=False)`, `find_row(criteria=..., columns=..., lock=False)` and `count_scoped(criteria=...)`. Criteria must not be empty; projected lookups also require columns. Entity lookups return a mapped object, projected lookups return RowMapping, and absence returns None. These methods never commit.
149
+
150
+ Override `apply_scope(statement)` for additional predicates on these helpers. It runs after projection initialization, because `select_columns`/`select_entity` reset the builder's SELECT. This hook does not implicitly scope existing CRUD helpers or arbitrary builder calls: keep ownership checks in named application repository methods. Existing `first`, `get_by_id`, `count` and statement APIs remain available and unchanged.
151
+
152
+ The 0.3.1 artifact is a local candidate until separately published. Dependent candidate packages use a vendored wheel during validation; publish sqlPhilosophy first before replacing their local sources with registry-only dependencies.
@@ -81,7 +81,6 @@ exclude_also = [
81
81
 
82
82
  [tool.mypy]
83
83
  python_version = "3.12"
84
- plugins = ["sqlalchemy.ext.mypy.plugin"]
85
84
  warn_unused_ignores = true
86
85
 
87
86
  [tool.ruff]
@@ -0,0 +1 @@
1
+ 0.3.3
@@ -8,6 +8,7 @@ from typing import Any, Literal, cast
8
8
 
9
9
  from sqlalchemy import func
10
10
  from sqlalchemy.orm import DeclarativeBase
11
+ from sqlalchemy.sql import SQLColumnExpression
11
12
 
12
13
  from sqlphilosophy.audit.model import AuditMixin
13
14
  from sqlphilosophy.types import PrimaryKey, RowMapping, RowValue, SqlFilter
@@ -112,3 +113,13 @@ def criteria_delete_allowed(criteria: Sequence[SqlFilter]) -> bool:
112
113
  def bulk_update_allowed(values: RowMapping) -> bool:
113
114
  """True when ``update_where`` has values to apply."""
114
115
  return bool(values)
116
+
117
+
118
+ def require_lookup_inputs(
119
+ criteria: Sequence[SqlFilter], columns: Sequence[SQLColumnExpression[object]] | None = None
120
+ ) -> None:
121
+ """Reject accidental unscoped or empty-projection expression lookups."""
122
+ if not criteria:
123
+ raise ValueError("lookup criteria must not be empty")
124
+ if columns is not None and not columns:
125
+ raise ValueError("lookup projection must not be empty")
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Sequence
6
- from typing import Any, cast
6
+ from typing import Any, SupportsInt, cast
7
7
 
8
8
  from servicephilosophy import ServiceRepository
9
9
  from sqlalchemy import delete, func, select, update
@@ -11,6 +11,7 @@ from sqlalchemy import inspect as sa_inspect
11
11
  from sqlalchemy.ext.asyncio import AsyncSession
12
12
  from sqlalchemy.orm import DeclarativeBase
13
13
  from sqlalchemy.orm.interfaces import LoaderOption
14
+ from sqlalchemy.sql import SQLColumnExpression
14
15
 
15
16
  from sqlphilosophy._repository_shared import (
16
17
  bulk_update_allowed,
@@ -19,6 +20,7 @@ from sqlphilosophy._repository_shared import (
19
20
  lookup_not_found_message,
20
21
  plan_partial_update,
21
22
  require_batch_size,
23
+ require_lookup_inputs,
22
24
  require_mappings_page_limits,
23
25
  require_page_and_limit,
24
26
  require_single_column_primary_key,
@@ -55,6 +57,11 @@ class AsyncBaseRepository[T: DeclarativeBase, U: AsyncRepositoryFactory](Service
55
57
  self._session = session
56
58
  self._pk_column = require_single_column_primary_key(model, self.inspect_model(model))
57
59
 
60
+ @property
61
+ def session(self) -> AsyncSession:
62
+ """Caller-owned session; exposed for composition ownership checks."""
63
+ return self._session
64
+
58
65
  @classmethod
59
66
  def inspect_model(cls, model: type[DeclarativeBase]) -> Any:
60
67
  """Return SQLAlchemy ORM mapper inspection for ``model``."""
@@ -101,7 +108,7 @@ class AsyncBaseRepository[T: DeclarativeBase, U: AsyncRepositoryFactory](Service
101
108
  async def scalar_count(self, stmt: SqlSelect, params: SqlBindParams | None = None) -> int:
102
109
  """Execute a scalar count/select statement and return ``int``."""
103
110
  result = await self._session.execute(stmt, params or {})
104
- return int(result.scalar_one())
111
+ return int(cast("SupportsInt", result.scalar_one()))
105
112
 
106
113
  async def iter_mappings(self, stmt: SqlSelect, params: SqlBindParams | None = None):
107
114
  """Yield each result row as a plain ``dict``."""
@@ -308,20 +315,20 @@ class AsyncBaseRepository[T: DeclarativeBase, U: AsyncRepositoryFactory](Service
308
315
  *,
309
316
  criteria: Sequence[SqlFilter],
310
317
  values: RowMapping,
311
- params: SqlBindParams | None = None,
318
+ params: RowMapping | None = None,
312
319
  ) -> int:
313
320
  """Bulk UPDATE rows matching ``criteria``; returns affected row count."""
314
321
  if not bulk_update_allowed(values):
315
322
  return 0
316
323
  stmt = update(self.model).where(*criteria).values(**values)
317
- result = await self._session.execute(stmt, params or {})
324
+ result = await self._session.execute(stmt, dict(params) if params is not None else {})
318
325
  return cursor_rowcount(result)
319
326
 
320
327
  async def delete_where(
321
328
  self,
322
329
  *,
323
330
  criteria: Sequence[SqlFilter],
324
- params: SqlBindParams | None = None,
331
+ params: RowMapping | None = None,
325
332
  ) -> int:
326
333
  """Delete rows matching ``criteria`` via PK lookup + ``delete_many``."""
327
334
  if not criteria_delete_allowed(criteria):
@@ -329,7 +336,7 @@ class AsyncBaseRepository[T: DeclarativeBase, U: AsyncRepositoryFactory](Service
329
336
  pk_key = self._pk_column.key
330
337
  builder = self.statement().select_columns(self._pk_column).where(*criteria)
331
338
  if params:
332
- builder = builder.with_params(params)
339
+ builder = builder.with_params(dict(params))
333
340
  rows = await builder.mappings().all()
334
341
  ids = extract_primary_keys(rows, pk_key)
335
342
  return await self.delete_many(ids)
@@ -374,6 +381,34 @@ class AsyncBaseRepository[T: DeclarativeBase, U: AsyncRepositoryFactory](Service
374
381
  await self._session.commit()
375
382
  return total
376
383
 
384
+ def apply_scope(self, statement: AsyncStatementQueryBuilder[T]) -> AsyncStatementQueryBuilder[T]:
385
+ """Host override hook, invoked after projection initialization."""
386
+ return statement
387
+
388
+ async def find_entity(self, *, criteria: Sequence[SqlFilter], lock: bool = False) -> T | None:
389
+ require_lookup_inputs(criteria)
390
+ query = self.apply_scope(self.statement()).where(*criteria)
391
+ if lock:
392
+ query = query.with_for_update()
393
+ return await query.scalars().first()
394
+
395
+ async def find_row(
396
+ self,
397
+ *,
398
+ criteria: Sequence[SqlFilter],
399
+ columns: Sequence[SQLColumnExpression[object]],
400
+ lock: bool = False,
401
+ ) -> RowMapping | None:
402
+ require_lookup_inputs(criteria, columns)
403
+ query = self.apply_scope(self.statement().select_columns(*columns)).where(*criteria)
404
+ if lock:
405
+ query = query.with_for_update()
406
+ return await query.mappings().first()
407
+
408
+ async def count_scoped(self, *, criteria: Sequence[SqlFilter]) -> int:
409
+ require_lookup_inputs(criteria)
410
+ return await self.apply_scope(self.statement().select_entity()).where(*criteria).count()
411
+
377
412
  def statement(self) -> AsyncStatementQueryBuilder[T]:
378
413
  """Return a fluent statement builder for reads on this model (default read path)."""
379
414
  factory = self.maybe_factory
@@ -0,0 +1,142 @@
1
+ """Opt-in, non-coercing row decoders for repository boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from datetime import date, datetime
7
+ from enum import Enum
8
+ from math import isfinite
9
+ from typing import TypeGuard
10
+ from uuid import UUID
11
+
12
+ from sqlphilosophy.types import JSONValue
13
+
14
+ __all__ = [
15
+ "is_strict_json_value",
16
+ "strict_row_bool",
17
+ "strict_row_date",
18
+ "strict_row_datetime",
19
+ "strict_row_enum",
20
+ "strict_row_float",
21
+ "strict_row_int",
22
+ "strict_row_json",
23
+ "strict_row_json_object",
24
+ "strict_row_optional",
25
+ "strict_row_str",
26
+ "strict_row_str_list",
27
+ "strict_row_uuid",
28
+ ]
29
+
30
+ Row = Mapping[str, object]
31
+
32
+
33
+ def _required(row: Row, key: str) -> object:
34
+ return row[key]
35
+
36
+
37
+ def _type_error(key: str, expected: str, value: object) -> TypeError:
38
+ return TypeError(f"expected strict {expected} for {key!r}, got {type(value).__name__}")
39
+
40
+
41
+ def strict_row_int(row: Row, key: str) -> int:
42
+ value = _required(row, key)
43
+ if type(value) is not int:
44
+ raise _type_error(key, "int", value)
45
+ return value
46
+
47
+
48
+ def strict_row_str(row: Row, key: str) -> str:
49
+ value = _required(row, key)
50
+ if type(value) is not str:
51
+ raise _type_error(key, "str", value)
52
+ return value
53
+
54
+
55
+ def strict_row_bool(row: Row, key: str) -> bool:
56
+ value = _required(row, key)
57
+ if type(value) is not bool:
58
+ raise _type_error(key, "bool", value)
59
+ return value
60
+
61
+
62
+ def strict_row_float(row: Row, key: str) -> float:
63
+ value = _required(row, key)
64
+ if type(value) is not float or not isfinite(value):
65
+ raise _type_error(key, "finite float", value)
66
+ return value
67
+
68
+
69
+ def strict_row_datetime(row: Row, key: str) -> datetime:
70
+ value = _required(row, key)
71
+ if type(value) is not datetime:
72
+ raise _type_error(key, "datetime", value)
73
+ return value
74
+
75
+
76
+ def strict_row_date(row: Row, key: str) -> date:
77
+ value = _required(row, key)
78
+ if type(value) is not date:
79
+ raise _type_error(key, "date", value)
80
+ return value
81
+
82
+
83
+ def strict_row_uuid(row: Row, key: str) -> UUID:
84
+ value = _required(row, key)
85
+ if not isinstance(value, UUID):
86
+ raise _type_error(key, "UUID", value)
87
+ return value
88
+
89
+
90
+ def strict_row_enum[EnumT: Enum](row: Row, key: str, enum_type: type[EnumT]) -> EnumT:
91
+ value = _required(row, key)
92
+ if isinstance(value, enum_type):
93
+ return value
94
+ if type(value) is str:
95
+ try:
96
+ return enum_type(value)
97
+ except ValueError as error:
98
+ raise _type_error(key, enum_type.__name__, value) from error
99
+ raise _type_error(key, enum_type.__name__, value)
100
+
101
+
102
+ def strict_row_str_list(row: Row, key: str) -> list[str]:
103
+ value = _required(row, key)
104
+ if type(value) is not list or not all(type(item) is str for item in value):
105
+ raise _type_error(key, "list[str]", value)
106
+ return value
107
+
108
+
109
+ def is_strict_json_value(value: object) -> TypeGuard[JSONValue]:
110
+ if value is None or type(value) in (bool, int, str):
111
+ return True
112
+ if type(value) is float:
113
+ return isfinite(value)
114
+ if type(value) is list:
115
+ return all(is_strict_json_value(item) for item in value)
116
+ if type(value) is dict:
117
+ return all(type(key) is str and is_strict_json_value(item) for key, item in value.items())
118
+ return False
119
+
120
+
121
+ def strict_row_json(row: Row, key: str) -> JSONValue:
122
+ value = _required(row, key)
123
+ if not is_strict_json_value(value):
124
+ raise _type_error(key, "JSON value", value)
125
+ return value
126
+
127
+
128
+ def strict_row_json_object(row: Row, key: str) -> dict[str, JSONValue]:
129
+ value = strict_row_json(row, key)
130
+ if type(value) is not dict:
131
+ raise _type_error(key, "JSON object", value)
132
+ return value
133
+
134
+
135
+ def strict_row_optional[ValueT](
136
+ row: Row,
137
+ key: str,
138
+ decoder: Callable[[Row, str], ValueT],
139
+ ) -> ValueT | None:
140
+ if key not in row or row[key] is None:
141
+ return None
142
+ return decoder(row, key)
@@ -3,13 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Sequence
6
- from typing import Any, cast
6
+ from typing import Any, SupportsInt, cast
7
7
 
8
8
  from servicephilosophy import ServiceRepository
9
9
  from sqlalchemy import delete, func, select, update
10
10
  from sqlalchemy import inspect as sa_inspect
11
11
  from sqlalchemy.orm import DeclarativeBase, Session
12
12
  from sqlalchemy.orm.interfaces import LoaderOption
13
+ from sqlalchemy.sql import SQLColumnExpression
13
14
 
14
15
  from sqlphilosophy._repository_shared import (
15
16
  bulk_update_allowed,
@@ -17,6 +18,7 @@ from sqlphilosophy._repository_shared import (
17
18
  extract_primary_keys,
18
19
  lookup_not_found_message,
19
20
  require_batch_size,
21
+ require_lookup_inputs,
20
22
  require_page_and_limit,
21
23
  require_single_column_primary_key,
22
24
  )
@@ -52,6 +54,11 @@ class BaseRepository[T: DeclarativeBase, U: RepositoryFactory](ServiceRepository
52
54
  self._session = session
53
55
  self._pk_column = require_single_column_primary_key(model, self.inspect_model(model))
54
56
 
57
+ @property
58
+ def session(self) -> Session:
59
+ """Caller-owned session; exposed for composition ownership checks."""
60
+ return self._session
61
+
55
62
  @classmethod
56
63
  def inspect_model(cls, model: type[DeclarativeBase]) -> Any:
57
64
  """Return SQLAlchemy ORM mapper inspection for ``model``."""
@@ -87,7 +94,7 @@ class BaseRepository[T: DeclarativeBase, U: RepositoryFactory](ServiceRepository
87
94
 
88
95
  def scalar_count(self, stmt: SqlSelect, params: SqlBindParams | None = None) -> int:
89
96
  """Execute a scalar count/select statement and return ``int``."""
90
- return int(self._session.execute(stmt, params or {}).scalar_one())
97
+ return int(cast("SupportsInt", self._session.execute(stmt, params or {}).scalar_one()))
91
98
 
92
99
  def iter_mappings(self, stmt: SqlSelect, params: SqlBindParams | None = None):
93
100
  """Yield each result row as a plain ``dict``."""
@@ -274,20 +281,20 @@ class BaseRepository[T: DeclarativeBase, U: RepositoryFactory](ServiceRepository
274
281
  *,
275
282
  criteria: Sequence[SqlFilter],
276
283
  values: RowMapping,
277
- params: SqlBindParams | None = None,
284
+ params: RowMapping | None = None,
278
285
  ) -> int:
279
286
  """Bulk UPDATE rows matching ``criteria``; returns affected row count."""
280
287
  if not bulk_update_allowed(values):
281
288
  return 0
282
289
  stmt = update(self.model).where(*criteria).values(**values)
283
- result = self._session.execute(stmt, params or {})
290
+ result = self._session.execute(stmt, dict(params) if params is not None else {})
284
291
  return cursor_rowcount(result)
285
292
 
286
293
  def delete_where(
287
294
  self,
288
295
  *,
289
296
  criteria: Sequence[SqlFilter],
290
- params: SqlBindParams | None = None,
297
+ params: RowMapping | None = None,
291
298
  ) -> int:
292
299
  """Delete rows matching ``criteria`` via PK lookup + ``delete_many``."""
293
300
  if not criteria_delete_allowed(criteria):
@@ -295,7 +302,7 @@ class BaseRepository[T: DeclarativeBase, U: RepositoryFactory](ServiceRepository
295
302
  pk_key = self._pk_column.key
296
303
  builder = self.statement().select_columns(self._pk_column).where(*criteria)
297
304
  if params:
298
- builder = builder.with_params(params)
305
+ builder = builder.with_params(dict(params))
299
306
  rows = builder.mappings().all()
300
307
  ids = extract_primary_keys(rows, pk_key)
301
308
  return self.delete_many(ids)
@@ -334,6 +341,34 @@ class BaseRepository[T: DeclarativeBase, U: RepositoryFactory](ServiceRepository
334
341
  self._session.commit()
335
342
  return total
336
343
 
344
+ def apply_scope(self, statement: StatementQueryBuilder[T]) -> StatementQueryBuilder[T]:
345
+ """Host override hook, invoked after projection initialization."""
346
+ return statement
347
+
348
+ def find_entity(self, *, criteria: Sequence[SqlFilter], lock: bool = False) -> T | None:
349
+ require_lookup_inputs(criteria)
350
+ query = self.apply_scope(self.statement()).where(*criteria)
351
+ if lock:
352
+ query = query.with_for_update()
353
+ return query.scalars().first()
354
+
355
+ def find_row(
356
+ self,
357
+ *,
358
+ criteria: Sequence[SqlFilter],
359
+ columns: Sequence[SQLColumnExpression[object]],
360
+ lock: bool = False,
361
+ ) -> RowMapping | None:
362
+ require_lookup_inputs(criteria, columns)
363
+ query = self.apply_scope(self.statement().select_columns(*columns)).where(*criteria)
364
+ if lock:
365
+ query = query.with_for_update()
366
+ return query.mappings().first()
367
+
368
+ def count_scoped(self, *, criteria: Sequence[SqlFilter]) -> int:
369
+ require_lookup_inputs(criteria)
370
+ return self.apply_scope(self.statement().select_entity()).where(*criteria).count()
371
+
337
372
  def statement(self) -> StatementQueryBuilder[T]:
338
373
  """Return a fluent statement builder for reads on this model (default read path)."""
339
374
  factory = self.maybe_factory
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlphilosophy
3
- Version: 0.2.1
3
+ Version: 0.3.3
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
@@ -40,6 +40,8 @@ Dynamic: license-file
40
40
 
41
41
  # sqlphilosophy
42
42
 
43
+ Current source and planned customer-subdomain impact: [current-state summary](docs/current-state-summary.md).
44
+
43
45
  Portable SQLAlchemy repository kit: sync and async CRUD, fluent statement builders, sort/pagination, Core SQL helpers, and optional audit listeners.
44
46
 
45
47
  | | |
@@ -68,6 +70,8 @@ Portable SQLAlchemy repository kit: sync and async CRUD, fluent statement builde
68
70
  - **Repository pattern** for a single mapped model (`BaseRepository`, `AsyncBaseRepository`).
69
71
  - **Fluent query builders** with pagination/sort (`StatementQueryBuilder`, `ListQuery`, `SortConfig`).
70
72
  - **SQL helpers** for row mapping, partial updates, and developer-defined fragments via **`sqlphilosophy.trusted_sql`**.
73
+ - **Opt-in strict row decoders** via `sqlphilosophy.strict` for non-coercing
74
+ scalars, timestamps, enums, collections, UUIDs, and recursive JSON.
71
75
  - **Optional audit listeners** and timestamp mixins.
72
76
 
73
77
  ## What this package does not do
@@ -132,6 +136,7 @@ with SessionLocal() as session:
132
136
  | `sqlphilosophy` | `__version__` only |
133
137
  | `sqlphilosophy.types` | Portable typing aliases |
134
138
  | `sqlphilosophy.sql` | Row mapping, partial updates, Core helpers (re-exports `trusted_sql`) |
139
+ | `sqlphilosophy.strict` | Opt-in non-coercing row decoders and strict JSON validation |
135
140
  | `sqlphilosophy.trusted_sql` | Developer-trusted SQL fragments — see [SECURITY.md](./SECURITY.md) |
136
141
  | `sqlphilosophy.sorting` | `ListQuery`, `SortConfig`, `SortSpec` |
137
142
  | `sqlphilosophy.sync` / `sqlphilosophy.aio` | Repositories, query builders, factory protocols |
@@ -177,3 +182,11 @@ See [RELEASING.md](./RELEASING.md) for GitHub + PyPI trusted publishing. See [CH
177
182
  ## License
178
183
 
179
184
  MIT — see [LICENSE](./LICENSE).
185
+
186
+ ## Expression lookups and scope overrides (0.3.1)
187
+
188
+ Both repository bases provide `find_entity(criteria=..., lock=False)`, `find_row(criteria=..., columns=..., lock=False)` and `count_scoped(criteria=...)`. Criteria must not be empty; projected lookups also require columns. Entity lookups return a mapped object, projected lookups return RowMapping, and absence returns None. These methods never commit.
189
+
190
+ Override `apply_scope(statement)` for additional predicates on these helpers. It runs after projection initialization, because `select_columns`/`select_entity` reset the builder's SELECT. This hook does not implicitly scope existing CRUD helpers or arbitrary builder calls: keep ownership checks in named application repository methods. Existing `first`, `get_by_id`, `count` and statement APIs remain available and unchanged.
191
+
192
+ The 0.3.1 artifact is a local candidate until separately published. Dependent candidate packages use a vendored wheel during validation; publish sqlPhilosophy first before replacing their local sources with registry-only dependencies.
@@ -8,6 +8,7 @@ src/sqlphilosophy/_repository_shared.py
8
8
  src/sqlphilosophy/py.typed
9
9
  src/sqlphilosophy/sorting.py
10
10
  src/sqlphilosophy/sql.py
11
+ src/sqlphilosophy/strict.py
11
12
  src/sqlphilosophy/trusted_sql.py
12
13
  src/sqlphilosophy/types.py
13
14
  src/sqlphilosophy.egg-info/PKG-INFO
@@ -1 +0,0 @@
1
- 0.2.1
File without changes
File without changes
File without changes