sqlphilosophy 0.1.4__tar.gz → 0.1.8__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 (33) hide show
  1. {sqlphilosophy-0.1.4/src/sqlphilosophy.egg-info → sqlphilosophy-0.1.8}/PKG-INFO +38 -1
  2. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/README.md +37 -0
  3. sqlphilosophy-0.1.8/src/sqlphilosophy/VERSION +1 -0
  4. sqlphilosophy-0.1.8/src/sqlphilosophy/aio/protocols.py +122 -0
  5. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/aio/repository.py +29 -26
  6. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/sql.py +4 -14
  7. sqlphilosophy-0.1.8/src/sqlphilosophy/sync/protocols.py +120 -0
  8. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/sync/repository.py +36 -30
  9. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8/src/sqlphilosophy.egg-info}/PKG-INFO +38 -1
  10. sqlphilosophy-0.1.4/src/sqlphilosophy/VERSION +0 -1
  11. sqlphilosophy-0.1.4/src/sqlphilosophy/aio/protocols.py +0 -26
  12. sqlphilosophy-0.1.4/src/sqlphilosophy/sync/protocols.py +0 -26
  13. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/LICENSE +0 -0
  14. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/MANIFEST.in +0 -0
  15. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/pyproject.toml +0 -0
  16. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/setup.cfg +0 -0
  17. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/__init__.py +0 -0
  18. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/aio/__init__.py +0 -0
  19. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/aio/query.py +0 -0
  20. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/audit/__init__.py +0 -0
  21. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/audit/context.py +0 -0
  22. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/audit/fields.py +0 -0
  23. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/audit/listener.py +0 -0
  24. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/audit/model.py +0 -0
  25. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/py.typed +0 -0
  26. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/sorting.py +0 -0
  27. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/sync/__init__.py +0 -0
  28. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/sync/query.py +0 -0
  29. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy/types.py +0 -0
  30. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy.egg-info/SOURCES.txt +0 -0
  31. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy.egg-info/dependency_links.txt +0 -0
  32. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/src/sqlphilosophy.egg-info/requires.txt +0 -0
  33. {sqlphilosophy-0.1.4 → sqlphilosophy-0.1.8}/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.4
3
+ Version: 0.1.8
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
@@ -111,6 +111,43 @@ with SessionLocal() as session:
111
111
 
112
112
  **Async:** swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
113
113
 
114
+ ## Strongly typed repositories (factory pattern)
115
+
116
+ Domain repositories subclass `BaseRepository[Model, RepositoryFactory]` (or `AsyncBaseRepository` for async) and add typed query helpers. A session-scoped factory implements `RepositoryFactory` to cache repositories and wire `statement()` / `for_repo()` across repos on the same session.
117
+
118
+ ```python
119
+ from sqlalchemy.orm import Session
120
+
121
+ from sqlphilosophy.sync.protocols import RepositoryFactory
122
+ from sqlphilosophy.sync.repository import BaseRepository
123
+
124
+
125
+ class UserRepository(BaseRepository[User, RepositoryFactory]):
126
+ def __init__(self, session: Session, factory: RepositoryFactory) -> None:
127
+ super().__init__(User, session, factory)
128
+
129
+ def get_by_username(self, username: str) -> User | None:
130
+ return self.first(username=username)
131
+
132
+ def get_by_email(self, email: str) -> User | None:
133
+ return self.first(email=email)
134
+
135
+ def get_active_by_email(self, email: str) -> User | None:
136
+ return (
137
+ self.statement()
138
+ .where(User.email == email, User.is_active.is_(True))
139
+ .scalars()
140
+ .first()
141
+ )
142
+ ```
143
+
144
+ Full runnable examples with models, a multi-repo factory, and cross-repository usage:
145
+
146
+ - Sync: [`examples/typed_repository_sync.py`](./examples/typed_repository_sync.py)
147
+ - Async: [`examples/typed_repository_async.py`](./examples/typed_repository_async.py)
148
+
149
+ For async, swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
150
+
114
151
  ## Package layout
115
152
 
116
153
  | Module | Contents |
@@ -72,6 +72,43 @@ with SessionLocal() as session:
72
72
 
73
73
  **Async:** swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
74
74
 
75
+ ## Strongly typed repositories (factory pattern)
76
+
77
+ Domain repositories subclass `BaseRepository[Model, RepositoryFactory]` (or `AsyncBaseRepository` for async) and add typed query helpers. A session-scoped factory implements `RepositoryFactory` to cache repositories and wire `statement()` / `for_repo()` across repos on the same session.
78
+
79
+ ```python
80
+ from sqlalchemy.orm import Session
81
+
82
+ from sqlphilosophy.sync.protocols import RepositoryFactory
83
+ from sqlphilosophy.sync.repository import BaseRepository
84
+
85
+
86
+ class UserRepository(BaseRepository[User, RepositoryFactory]):
87
+ def __init__(self, session: Session, factory: RepositoryFactory) -> None:
88
+ super().__init__(User, session, factory)
89
+
90
+ def get_by_username(self, username: str) -> User | None:
91
+ return self.first(username=username)
92
+
93
+ def get_by_email(self, email: str) -> User | None:
94
+ return self.first(email=email)
95
+
96
+ def get_active_by_email(self, email: str) -> User | None:
97
+ return (
98
+ self.statement()
99
+ .where(User.email == email, User.is_active.is_(True))
100
+ .scalars()
101
+ .first()
102
+ )
103
+ ```
104
+
105
+ Full runnable examples with models, a multi-repo factory, and cross-repository usage:
106
+
107
+ - Sync: [`examples/typed_repository_sync.py`](./examples/typed_repository_sync.py)
108
+ - Async: [`examples/typed_repository_async.py`](./examples/typed_repository_async.py)
109
+
110
+ For async, swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
111
+
75
112
  ## Package layout
76
113
 
77
114
  | Module | Contents |
@@ -0,0 +1 @@
1
+ 0.1.8
@@ -0,0 +1,122 @@
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 Any
7
+ from typing import Optional
8
+ from typing import TYPE_CHECKING
9
+ from typing import Protocol
10
+ from typing import TypeVar
11
+
12
+ if TYPE_CHECKING:
13
+ from sqlphilosophy.aio.repository import AsyncBaseRepository
14
+
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+ from sqlalchemy.orm import DeclarativeBase
17
+
18
+ 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
24
+
25
+ T = TypeVar("T", bound=DeclarativeBase)
26
+ R = TypeVar("R", bound="AsyncBaseRepository[Any, Any]")
27
+
28
+
29
+ class AsyncBaseRepositoryProtocol[T: DeclarativeBase, U: Optional[AsyncRepositoryFactory]](
30
+ Protocol,
31
+ ):
32
+ """Generic read/write surface shared by sqlphilosophy ``AsyncBaseRepository[T]``."""
33
+
34
+ model: type[T]
35
+ _session: AsyncSession
36
+ _factory: Optional[U]
37
+
38
+ async def get(self, obj_id: PrimaryKey, load_relations: Any = None) -> T: ...
39
+
40
+ async def get_by_id(self, obj_id: PrimaryKey, load_relations: Any = None) -> T | None: ...
41
+
42
+ async def get_many(
43
+ self, ids: Sequence[PrimaryKey], load_relations: Any = None
44
+ ) -> Sequence[T]: ...
45
+
46
+ async def first(self, load_relations: Any = None, **filters: RowValue) -> T | None: ...
47
+
48
+ async def filter(
49
+ self,
50
+ *,
51
+ page: int = 1,
52
+ limit: int | None = None,
53
+ load_relations: Any = None,
54
+ **filters: RowValue,
55
+ ) -> Sequence[T]: ...
56
+
57
+ async def get_all(
58
+ self,
59
+ *,
60
+ page: int = 1,
61
+ limit: int | None = None,
62
+ load_relations: Any = None,
63
+ ) -> Sequence[T]: ...
64
+
65
+ async def count(self, **filters: RowValue) -> int: ...
66
+
67
+ async def exists(self, obj_id: PrimaryKey) -> bool: ...
68
+
69
+ async def exists_where(self, **filters: RowValue) -> bool: ...
70
+
71
+ def statement(self) -> AsyncStatementQueryBuilder[T]: ...
72
+
73
+ async def create(self, **fields: object) -> T: ...
74
+
75
+ async def add(self, obj: T) -> T: ...
76
+
77
+ async def get_or_create(
78
+ self, *, defaults: RowMapping | None = None, **lookup: RowValue
79
+ ) -> tuple[T, bool]: ...
80
+
81
+ async def remove(self, obj_id: PrimaryKey) -> bool: ...
82
+
83
+ async def delete_many(self, ids: IdList) -> int: ...
84
+
85
+ async def delete_where(
86
+ self, *, criteria: Sequence[SqlFilter], params: RowMapping | None = None
87
+ ) -> int: ...
88
+
89
+ async def update_partial(
90
+ self,
91
+ obj_id: PrimaryKey,
92
+ fields: RowMapping,
93
+ writable: frozenset[str],
94
+ *,
95
+ touch_updated_on: bool = False,
96
+ ) -> int: ...
97
+
98
+ async def update_where(
99
+ self,
100
+ *,
101
+ criteria: Sequence[SqlFilter],
102
+ values: RowMapping,
103
+ params: RowMapping | None = None,
104
+ ) -> 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
+ ...
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  from collections.abc import Sequence
5
5
  from typing import Any
6
6
  from typing import cast
7
+ from typing import Optional
7
8
  from sqlalchemy import delete
8
9
  from sqlalchemy import func
9
10
  from sqlalchemy import inspect as sa_inspect
@@ -31,7 +32,7 @@ from sqlphilosophy.types import cursor_rowcount
31
32
  LoadRelations = Sequence[LoaderOption]
32
33
 
33
34
 
34
- class AsyncBaseRepository[T: DeclarativeBase]:
35
+ class AsyncBaseRepository[T: DeclarativeBase, U: Optional[AsyncRepositoryFactory]]:
35
36
  """Async session-scoped CRUD helpers for a single mapped model."""
36
37
 
37
38
  def __init__(
@@ -41,7 +42,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
41
42
  factory: AsyncRepositoryFactory | None = None,
42
43
  ) -> None:
43
44
  self.model = model
44
- self.session = session
45
+ self._session = session
45
46
  self._factory = factory
46
47
  pk_cols = self.inspect_model(model).primary_key
47
48
  if len(pk_cols) != 1:
@@ -59,7 +60,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
59
60
 
60
61
  async def list_table_names(self) -> frozenset[str]:
61
62
  """Return visible table names on the session connection."""
62
- connection = await self.session.connection()
63
+ connection = await self._session.connection()
63
64
 
64
65
  def _names(sync_conn: object) -> frozenset[str]:
65
66
  insp = sa_inspect(sync_conn)
@@ -79,7 +80,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
79
80
  return stmt
80
81
 
81
82
  async def _scalar_result(self, stmt: Any, *, unique: bool = False) -> Any:
82
- result = await self.session.scalars(stmt)
83
+ result = await self._session.scalars(stmt)
83
84
  if unique:
84
85
  return result.unique()
85
86
  return result
@@ -88,19 +89,19 @@ class AsyncBaseRepository[T: DeclarativeBase]:
88
89
  self, stmt: Any, params: RowMapping | None = None
89
90
  ) -> list[RowMapping]:
90
91
  """Execute ``stmt`` and return all rows as mappings."""
91
- result = await self.session.execute(stmt, params or {})
92
+ result = await self._session.execute(stmt, params or {})
92
93
  mapped = result.mappings()
93
94
  rows = mapped.all() if hasattr(mapped, "all") else mapped
94
95
  return rows_mapping(rows)
95
96
 
96
97
  async def scalar_count(self, stmt: SqlSelect, params: SqlBindParams | None = None) -> int:
97
98
  """Execute a scalar count/select statement and return ``int``."""
98
- result = await self.session.execute(stmt, params or {})
99
+ result = await self._session.execute(stmt, params or {})
99
100
  return int(result.scalar_one())
100
101
 
101
102
  async def iter_mappings(self, stmt: SqlSelect, params: SqlBindParams | None = None):
102
103
  """Yield each result row as a plain ``dict``."""
103
- result = await self.session.execute(stmt, params or {})
104
+ result = await self._session.execute(stmt, params or {})
104
105
  for row in result.mappings():
105
106
  yield dict(row)
106
107
 
@@ -108,7 +109,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
108
109
  self, stmt: SqlSelect, params: SqlBindParams | None = None
109
110
  ) -> RowMapping | None:
110
111
  """Execute ``stmt`` and return the first row as a mapping, or ``None``."""
111
- result = await self.session.execute(stmt, params or {})
112
+ result = await self._session.execute(stmt, params or {})
112
113
  row = result.mappings().first()
113
114
  return dict(row) if row is not None else None
114
115
 
@@ -116,7 +117,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
116
117
  self, stmt: SqlSelect, params: SqlBindParams | None = None
117
118
  ) -> RowMapping:
118
119
  """Execute ``stmt`` and return exactly one row as a mapping."""
119
- result = await self.session.execute(stmt, params or {})
120
+ result = await self._session.execute(stmt, params or {})
120
121
  return dict(result.mappings().one())
121
122
 
122
123
  async def fetch_mappings_page(
@@ -159,7 +160,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
159
160
  """Fetch a single record by primary key with optional eager loading."""
160
161
  stmt = select(self.model).where(self._pk_column == obj_id)
161
162
  stmt = self._apply_load_relations(stmt, load_relations)
162
- result = await self.session.scalars(stmt)
163
+ result = await self._session.scalars(stmt)
163
164
  return result.first()
164
165
 
165
166
  async def exists(self, obj_id: PrimaryKey) -> bool:
@@ -175,7 +176,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
175
176
  stmt = select(func.count()).select_from(self.model)
176
177
  if filters:
177
178
  stmt = stmt.filter_by(**filters)
178
- result = await self.session.scalar(stmt)
179
+ result = await self._session.scalar(stmt)
179
180
  return int(result or 0)
180
181
 
181
182
  async def first(
@@ -184,7 +185,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
184
185
  """Return the first row matching filters, with optional eager loading."""
185
186
  stmt = select(self.model).filter_by(**filters).limit(1)
186
187
  stmt = self._apply_load_relations(stmt, load_relations)
187
- result = await self.session.scalars(stmt)
188
+ result = await self._session.scalars(stmt)
188
189
  return result.first()
189
190
 
190
191
  async def get(self, obj_id: PrimaryKey, load_relations: LoadRelations | None = None) -> T:
@@ -258,7 +259,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
258
259
  stmt = stmt.join(target_model) # pragma: no cover
259
260
  if filter_expressions:
260
261
  stmt = stmt.where(*filter_expressions)
261
- result = await self.session.execute(stmt)
262
+ result = await self._session.execute(stmt)
262
263
  return cast(Sequence[tuple[T, Any]], result.all())
263
264
 
264
265
  async def create(self, **fields: object) -> T:
@@ -280,8 +281,8 @@ class AsyncBaseRepository[T: DeclarativeBase]:
280
281
 
281
282
  async def add(self, obj: T) -> T:
282
283
  """Stage a new instance; caller commits in the orchestration layer."""
283
- self.session.add(obj)
284
- await self.session.flush()
284
+ self._session.add(obj)
285
+ await self._session.flush()
285
286
  return obj
286
287
 
287
288
  async def update_partial(
@@ -297,12 +298,12 @@ class AsyncBaseRepository[T: DeclarativeBase]:
297
298
  audit_updates = {k: v for k, v in fields.items() if k in writable}
298
299
  if not audit_updates:
299
300
  return 0
300
- row = await self.session.get(self.model, obj_id)
301
+ row = await self._session.get(self.model, obj_id)
301
302
  if row is None:
302
303
  return 0
303
304
  for key, value in audit_updates.items():
304
305
  setattr(row, key, value)
305
- await self.session.flush()
306
+ await self._session.flush()
306
307
  return 1
307
308
  core_updates: RowMapping = {k: v for k, v in fields.items() if k in writable}
308
309
  if not core_updates:
@@ -314,7 +315,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
314
315
  )
315
316
  pk_col = self._pk_column
316
317
  stmt = update(self.model).where(pk_col == obj_id).values(**core_updates)
317
- result = await self.session.execute(stmt)
318
+ result = await self._session.execute(stmt)
318
319
  return cursor_rowcount(result)
319
320
 
320
321
  async def update_where(
@@ -328,7 +329,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
328
329
  if not values:
329
330
  return 0
330
331
  stmt = update(self.model).where(*criteria).values(**values)
331
- result = await self.session.execute(stmt, params or {})
332
+ result = await self._session.execute(stmt, params or {})
332
333
  return cursor_rowcount(result)
333
334
 
334
335
  async def delete_where(
@@ -351,7 +352,7 @@ class AsyncBaseRepository[T: DeclarativeBase]:
351
352
  async def remove(self, obj_id: PrimaryKey) -> bool:
352
353
  """Delete a record by primary key."""
353
354
  statement = delete(self.model).where(self._pk_column == obj_id)
354
- result = await self.session.execute(statement)
355
+ result = await self._session.execute(statement)
355
356
  return cursor_rowcount(result) > 0
356
357
 
357
358
  async def delete_many(self, ids: IdList) -> int:
@@ -359,12 +360,12 @@ class AsyncBaseRepository[T: DeclarativeBase]:
359
360
  if not ids:
360
361
  return 0
361
362
  stmt = delete(self.model).where(self._pk_column.in_(ids))
362
- result = await self.session.execute(stmt)
363
+ result = await self._session.execute(stmt)
363
364
  return cursor_rowcount(result)
364
365
 
365
366
  async def delete_all(self) -> int:
366
367
  """Delete every row for this model. Dev/ops only — prefer ``delete_where`` in app code."""
367
- result = await self.session.execute(delete(self.model))
368
+ result = await self._session.execute(delete(self.model))
368
369
  return cursor_rowcount(result)
369
370
 
370
371
  async def batched_purge_ids(
@@ -389,17 +390,19 @@ class AsyncBaseRepository[T: DeclarativeBase]:
389
390
  if not ids:
390
391
  break
391
392
  total += await self.delete_many(ids)
392
- await self.session.commit()
393
+ await self._session.commit()
393
394
  return total
394
395
 
395
396
  def statement(self) -> AsyncStatementQueryBuilder[T]:
396
397
  """Return a fluent statement builder for reads on this model (default read path)."""
397
398
  if self._factory is not None:
398
399
  return self._factory.create_statement(self.model)
399
- return AsyncSqlAlchemyStatementBuilder(self.session, self.model)
400
+ return AsyncSqlAlchemyStatementBuilder(self._session, self.model)
400
401
 
401
- def for_repo[R](self, repo_class: type[R]) -> R:
402
+ def for_repo[R: AsyncBaseRepository[Any, Any]](
403
+ self, repo_class: type[R]
404
+ ) -> R:
402
405
  """Return a typed entity repository sharing this session and factory."""
403
406
  if self._factory is None:
404
407
  raise RuntimeError("for_repo() requires an AsyncRepositoryFactory")
405
- return self._factory.get_repository(repo_class)
408
+ return cast(R, self._factory.get_repository(repo_class))
@@ -49,20 +49,10 @@ def sql_table(table_name: str, *column_names: str) -> SqlTable:
49
49
 
50
50
  def get_column_value(entity: object) -> ApiObject:
51
51
  """Return mapped column values for an ORM entity instance."""
52
- instance_state = sa_inspect(entity, raiseerr=False)
53
- if instance_state is not None and hasattr(instance_state, "mapper"):
54
- mapper = instance_state.mapper
55
- else:
56
- from sqlphilosophy.sync.repository import BaseRepository
57
-
58
- try:
59
- insp = BaseRepository.inspect_model(type(entity))
60
- except Exception as exc:
61
- raise TypeError(f"{type(entity)!r} is not a mapped SQLAlchemy entity") from exc
62
- if not hasattr(insp, "mapper"):
63
- raise TypeError(f"{type(entity)!r} is not a mapped SQLAlchemy entity")
64
- mapper = insp.mapper
65
- return {attr.key: getattr(entity, attr.key) for attr in mapper.column_attrs}
52
+ insp = sa_inspect(type(entity), raiseerr=False)
53
+ if insp is None:
54
+ raise TypeError(f"{type(entity)!r} is not a mapped SQLAlchemy entity")
55
+ return {attr.key: getattr(entity, attr.key) for attr in insp.mapper.column_attrs}
66
56
 
67
57
 
68
58
  def row_mapping(row: object) -> RowMapping:
@@ -0,0 +1,120 @@
1
+ """Portable 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 Any
7
+ from typing import Optional
8
+ from typing import TYPE_CHECKING
9
+ from typing import Protocol
10
+ from typing import TypeVar
11
+
12
+ if TYPE_CHECKING:
13
+ from sqlphilosophy.sync.repository import BaseRepository
14
+
15
+ from sqlalchemy.orm import DeclarativeBase
16
+ from sqlalchemy.orm import Session
17
+
18
+ from sqlphilosophy.sync.query import StatementQueryBuilder
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
24
+
25
+ T = TypeVar("T", bound=DeclarativeBase)
26
+ R = TypeVar("R", bound="BaseRepository[Any, Any]")
27
+
28
+
29
+ class BaseRepositoryProtocol[T: DeclarativeBase, U: Optional[RepositoryFactory]](Protocol):
30
+ """Generic read/write surface shared by sqlphilosophy ``BaseRepository[T]``."""
31
+
32
+ model: type[T]
33
+ _session: Session
34
+ _factory: Optional[U]
35
+
36
+ def get(self, obj_id: PrimaryKey, load_relations: Any = None) -> T: ...
37
+
38
+ def get_by_id(self, obj_id: PrimaryKey, load_relations: Any = None) -> T | None: ...
39
+
40
+ def get_many(
41
+ self, ids: Sequence[PrimaryKey], load_relations: Any = None
42
+ ) -> Sequence[T]: ...
43
+
44
+ def first(self, load_relations: Any = None, **filters: RowValue) -> T | None: ...
45
+
46
+ def filter(
47
+ self,
48
+ *,
49
+ page: int = 1,
50
+ limit: int | None = None,
51
+ load_relations: Any = None,
52
+ **filters: RowValue,
53
+ ) -> Sequence[T]: ...
54
+
55
+ def get_all(
56
+ self,
57
+ *,
58
+ page: int = 1,
59
+ limit: int | None = None,
60
+ load_relations: Any = None,
61
+ ) -> Sequence[T]: ...
62
+
63
+ def count(self, **filters: RowValue) -> int: ...
64
+
65
+ def exists(self, obj_id: PrimaryKey) -> bool: ...
66
+
67
+ def exists_where(self, **filters: RowValue) -> bool: ...
68
+
69
+ def statement(self) -> StatementQueryBuilder[T]: ...
70
+
71
+ def create(self, **fields: object) -> T: ...
72
+
73
+ def add(self, obj: T) -> T: ...
74
+
75
+ def get_or_create(
76
+ self, *, defaults: RowMapping | None = None, **lookup: RowValue
77
+ ) -> tuple[T, bool]: ...
78
+
79
+ def remove(self, obj_id: PrimaryKey) -> bool: ...
80
+
81
+ def delete_many(self, ids: IdList) -> int: ...
82
+
83
+ def delete_where(
84
+ self, *, criteria: Sequence[SqlFilter], params: RowMapping | None = None
85
+ ) -> int: ...
86
+
87
+ def update_partial(
88
+ self,
89
+ obj_id: PrimaryKey,
90
+ fields: RowMapping,
91
+ writable: frozenset[str],
92
+ *,
93
+ touch_updated_on: bool = False,
94
+ ) -> int: ...
95
+
96
+ def update_where(
97
+ self,
98
+ *,
99
+ criteria: Sequence[SqlFilter],
100
+ values: RowMapping,
101
+ params: RowMapping | None = None,
102
+ ) -> int: ...
103
+
104
+
105
+ class RepositoryFactory(Protocol):
106
+ """Session-scoped factory for statement builders and entity repositories."""
107
+
108
+ def create_statement(self, model: type[T]) -> StatementQueryBuilder[T]:
109
+ """Return a fluent read builder for ``model``."""
110
+ ...
111
+
112
+ def get_repository(self, repo_class: type[R]) -> R:
113
+ """Return a cached typed entity repository."""
114
+ ...
115
+
116
+ def repository(
117
+ self, model: type[T]
118
+ ) -> BaseRepositoryProtocol[T, Optional[RepositoryFactory]]:
119
+ """Return generic CRUD helpers for ``model`` (``BaseRepository`` in Phobos)."""
120
+ ...
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  from collections.abc import Sequence
5
5
  from typing import Any
6
6
  from typing import cast
7
+ from typing import Optional
7
8
  from sqlalchemy import delete
8
9
  from sqlalchemy import func
9
10
  from sqlalchemy import inspect as sa_inspect
@@ -14,6 +15,7 @@ from sqlalchemy.orm import Session
14
15
  from sqlalchemy.orm.interfaces import LoaderOption
15
16
  from sqlphilosophy.sorting import ListQuery
16
17
  from sqlphilosophy.sorting import SortConfig
18
+ from sqlphilosophy.sql import apply_mappings_page
17
19
  from sqlphilosophy.sql import delete_by_ids_model
18
20
  from sqlphilosophy.sql import partial_update_model
19
21
  from sqlphilosophy.sql import rows_mapping
@@ -32,7 +34,7 @@ from sqlphilosophy.types import cursor_rowcount
32
34
  LoadRelations = Sequence[LoaderOption]
33
35
 
34
36
 
35
- class BaseRepository[T: DeclarativeBase]:
37
+ class BaseRepository[T: DeclarativeBase, U: Optional[RepositoryFactory]]:
36
38
  """Session-scoped CRUD helpers for a single mapped model."""
37
39
 
38
40
  def __init__(
@@ -42,7 +44,7 @@ class BaseRepository[T: DeclarativeBase]:
42
44
  factory: RepositoryFactory | None = None,
43
45
  ) -> None:
44
46
  self.model = model
45
- self.session = session
47
+ self._session = session
46
48
  self._factory = factory
47
49
  pk_cols = self.inspect_model(model).primary_key
48
50
  if len(pk_cols) != 1:
@@ -60,7 +62,7 @@ class BaseRepository[T: DeclarativeBase]:
60
62
 
61
63
  def list_table_names(self) -> frozenset[str]:
62
64
  """Return visible table names on the session connection."""
63
- return frozenset(sa_inspect(self.session.connection()).get_table_names())
65
+ return frozenset(sa_inspect(self._session.connection()).get_table_names())
64
66
 
65
67
  def has_table(self, table_name: str) -> bool:
66
68
  """True when ``table_name`` exists on the session connection."""
@@ -73,36 +75,36 @@ class BaseRepository[T: DeclarativeBase]:
73
75
 
74
76
  def _scalar_result(self, stmt: Any, *, unique: bool = False) -> Any:
75
77
  if unique:
76
- return self.session.scalars(stmt).unique()
77
- return self.session.scalars(stmt)
78
+ return self._session.scalars(stmt).unique()
79
+ return self._session.scalars(stmt)
78
80
 
79
81
  def fetch_statement_mappings(
80
82
  self, stmt: Any, params: RowMapping | None = None
81
83
  ) -> list[RowMapping]:
82
84
  """Execute ``stmt`` and return all rows as mappings."""
83
- mapped = self.session.execute(stmt, params or {}).mappings()
85
+ mapped = self._session.execute(stmt, params or {}).mappings()
84
86
  rows = mapped.all() if hasattr(mapped, "all") else mapped
85
87
  return rows_mapping(rows)
86
88
 
87
89
  def scalar_count(self, stmt: SqlSelect, params: SqlBindParams | None = None) -> int:
88
90
  """Execute a scalar count/select statement and return ``int``."""
89
- return int(self.session.execute(stmt, params or {}).scalar_one())
91
+ return int(self._session.execute(stmt, params or {}).scalar_one())
90
92
 
91
93
  def iter_mappings(self, stmt: SqlSelect, params: SqlBindParams | None = None):
92
94
  """Yield each result row as a plain ``dict``."""
93
- for row in self.session.execute(stmt, params or {}).mappings():
95
+ for row in self._session.execute(stmt, params or {}).mappings():
94
96
  yield dict(row)
95
97
 
96
98
  def fetch_mapping_first(
97
99
  self, stmt: SqlSelect, params: SqlBindParams | None = None
98
100
  ) -> RowMapping | None:
99
101
  """Execute ``stmt`` and return the first row as a mapping, or ``None``."""
100
- row = self.session.execute(stmt, params or {}).mappings().first()
102
+ row = self._session.execute(stmt, params or {}).mappings().first()
101
103
  return dict(row) if row is not None else None
102
104
 
103
105
  def fetch_mapping_one(self, stmt: SqlSelect, params: SqlBindParams | None = None) -> RowMapping:
104
106
  """Execute ``stmt`` and return exactly one row as a mapping."""
105
- return dict(self.session.execute(stmt, params or {}).mappings().one())
107
+ return dict(self._session.execute(stmt, params or {}).mappings().one())
106
108
 
107
109
  def fetch_mappings_page(
108
110
  self,
@@ -113,11 +115,13 @@ class BaseRepository[T: DeclarativeBase]:
113
115
  params: RowMapping | None = None,
114
116
  ) -> list[RowMapping]:
115
117
  """Execute ``stmt`` with limit/offset; return normalized row mappings."""
116
- if limit < 0:
117
- raise ValueError("limit must be >= 0")
118
- if offset < 0:
119
- raise ValueError("offset must be >= 0")
120
- return self.fetch_statement_mappings(stmt.limit(limit).offset(offset), params)
118
+ return apply_mappings_page(
119
+ self._session,
120
+ stmt,
121
+ limit=limit,
122
+ offset=offset,
123
+ params=params,
124
+ )
121
125
 
122
126
  def fetch_sorted_mappings(
123
127
  self,
@@ -143,7 +147,7 @@ class BaseRepository[T: DeclarativeBase]:
143
147
  """Fetch a single record by primary key with optional eager loading."""
144
148
  stmt = select(self.model).where(self._pk_column == obj_id)
145
149
  stmt = self._apply_load_relations(stmt, load_relations)
146
- return self.session.scalar(stmt)
150
+ return self._session.scalar(stmt)
147
151
 
148
152
  def exists(self, obj_id: PrimaryKey) -> bool:
149
153
  """True when a row exists for the primary key."""
@@ -158,13 +162,13 @@ class BaseRepository[T: DeclarativeBase]:
158
162
  stmt = select(func.count()).select_from(self.model)
159
163
  if filters:
160
164
  stmt = stmt.filter_by(**filters)
161
- return int(self.session.scalar(stmt) or 0)
165
+ return int(self._session.scalar(stmt) or 0)
162
166
 
163
167
  def first(self, load_relations: LoadRelations | None = None, **filters: RowValue) -> T | None:
164
168
  """Return the first row matching filters, with optional eager loading."""
165
169
  stmt = select(self.model).filter_by(**filters).limit(1)
166
170
  stmt = self._apply_load_relations(stmt, load_relations)
167
- return self.session.scalar(stmt)
171
+ return self._session.scalar(stmt)
168
172
 
169
173
  def get(self, obj_id: PrimaryKey, load_relations: LoadRelations | None = None) -> T:
170
174
  """Fetch a single record by primary key; raise if missing."""
@@ -234,7 +238,7 @@ class BaseRepository[T: DeclarativeBase]:
234
238
  stmt = stmt.join(target_model) # pragma: no cover
235
239
  if filter_expressions:
236
240
  stmt = stmt.where(*filter_expressions)
237
- return cast(Sequence[tuple[T, Any]], self.session.execute(stmt).all())
241
+ return cast(Sequence[tuple[T, Any]], self._session.execute(stmt).all())
238
242
 
239
243
  def create(self, **fields: object) -> T:
240
244
  """Construct, stage, and flush a new instance."""
@@ -255,8 +259,8 @@ class BaseRepository[T: DeclarativeBase]:
255
259
 
256
260
  def add(self, obj: T) -> T:
257
261
  """Stage a new instance; caller commits in the orchestration layer."""
258
- self.session.add(obj)
259
- self.session.flush()
262
+ self._session.add(obj)
263
+ self._session.flush()
260
264
  return obj
261
265
 
262
266
  def update_partial(
@@ -269,7 +273,7 @@ class BaseRepository[T: DeclarativeBase]:
269
273
  ) -> int:
270
274
  """Apply a partial update; returns affected row count (0 if none)."""
271
275
  return partial_update_model(
272
- self.session,
276
+ self._session,
273
277
  self.model,
274
278
  obj_id,
275
279
  fields,
@@ -288,7 +292,7 @@ class BaseRepository[T: DeclarativeBase]:
288
292
  if not values:
289
293
  return 0
290
294
  stmt = update(self.model).where(*criteria).values(**values)
291
- result = self.session.execute(stmt, params or {})
295
+ result = self._session.execute(stmt, params or {})
292
296
  return cursor_rowcount(result)
293
297
 
294
298
  def delete_where(
@@ -311,16 +315,16 @@ class BaseRepository[T: DeclarativeBase]:
311
315
  def remove(self, obj_id: PrimaryKey) -> bool:
312
316
  """Delete a record by primary key."""
313
317
  statement = delete(self.model).where(self._pk_column == obj_id)
314
- result = self.session.execute(statement)
318
+ result = self._session.execute(statement)
315
319
  return cursor_rowcount(result) > 0
316
320
 
317
321
  def delete_many(self, ids: IdList) -> int:
318
322
  """Delete multiple records by primary key."""
319
- return delete_by_ids_model(self.session, self.model, list(ids))
323
+ return delete_by_ids_model(self._session, self.model, list(ids))
320
324
 
321
325
  def delete_all(self) -> int:
322
326
  """Delete every row for this model. Dev/ops only — prefer ``delete_where`` in app code."""
323
- result = self.session.execute(delete(self.model))
327
+ result = self._session.execute(delete(self.model))
324
328
  return cursor_rowcount(result)
325
329
 
326
330
  def batched_purge_ids(
@@ -345,17 +349,19 @@ class BaseRepository[T: DeclarativeBase]:
345
349
  if not ids:
346
350
  break
347
351
  total += self.delete_many(ids)
348
- self.session.commit()
352
+ self._session.commit()
349
353
  return total
350
354
 
351
355
  def statement(self) -> StatementQueryBuilder[T]:
352
356
  """Return a fluent statement builder for reads on this model (default read path)."""
353
357
  if self._factory is not None:
354
358
  return self._factory.create_statement(self.model)
355
- return SqlAlchemyStatementBuilder(self.session, self.model)
359
+ return SqlAlchemyStatementBuilder(self._session, self.model)
356
360
 
357
- def for_repo[R](self, repo_class: type[R]) -> R:
361
+ def for_repo[R: BaseRepository[Any, Any]](
362
+ self, repo_class: type[R]
363
+ ) -> R:
358
364
  """Return a typed entity repository sharing this session and factory."""
359
365
  if self._factory is None:
360
366
  raise RuntimeError("for_repo() requires a RepositoryFactory")
361
- return self._factory.get_repository(repo_class)
367
+ return cast(R, self._factory.get_repository(repo_class))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlphilosophy
3
- Version: 0.1.4
3
+ Version: 0.1.8
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
@@ -111,6 +111,43 @@ with SessionLocal() as session:
111
111
 
112
112
  **Async:** swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
113
113
 
114
+ ## Strongly typed repositories (factory pattern)
115
+
116
+ Domain repositories subclass `BaseRepository[Model, RepositoryFactory]` (or `AsyncBaseRepository` for async) and add typed query helpers. A session-scoped factory implements `RepositoryFactory` to cache repositories and wire `statement()` / `for_repo()` across repos on the same session.
117
+
118
+ ```python
119
+ from sqlalchemy.orm import Session
120
+
121
+ from sqlphilosophy.sync.protocols import RepositoryFactory
122
+ from sqlphilosophy.sync.repository import BaseRepository
123
+
124
+
125
+ class UserRepository(BaseRepository[User, RepositoryFactory]):
126
+ def __init__(self, session: Session, factory: RepositoryFactory) -> None:
127
+ super().__init__(User, session, factory)
128
+
129
+ def get_by_username(self, username: str) -> User | None:
130
+ return self.first(username=username)
131
+
132
+ def get_by_email(self, email: str) -> User | None:
133
+ return self.first(email=email)
134
+
135
+ def get_active_by_email(self, email: str) -> User | None:
136
+ return (
137
+ self.statement()
138
+ .where(User.email == email, User.is_active.is_(True))
139
+ .scalars()
140
+ .first()
141
+ )
142
+ ```
143
+
144
+ Full runnable examples with models, a multi-repo factory, and cross-repository usage:
145
+
146
+ - Sync: [`examples/typed_repository_sync.py`](./examples/typed_repository_sync.py)
147
+ - Async: [`examples/typed_repository_async.py`](./examples/typed_repository_async.py)
148
+
149
+ For async, swap `Session` → `AsyncSession`, `BaseRepository` → `AsyncBaseRepository` from `sqlphilosophy.aio.repository`, and `await` repository methods.
150
+
114
151
  ## Package layout
115
152
 
116
153
  | Module | Contents |
@@ -1 +0,0 @@
1
- 0.1.4
@@ -1,26 +0,0 @@
1
- """Portable async repository factory protocol (no Phobos or app imports)."""
2
-
3
- from __future__ import annotations
4
- from typing import Protocol
5
- from typing import TypeVar
6
- from sqlalchemy.orm import DeclarativeBase
7
- from sqlphilosophy.aio.query import AsyncStatementQueryBuilder
8
-
9
- T = TypeVar("T", bound=DeclarativeBase)
10
- R = TypeVar("R")
11
-
12
-
13
- class AsyncRepositoryFactory(Protocol):
14
- """Session-scoped factory for async statement builders and entity repositories."""
15
-
16
- def create_statement(self, model: type[T]) -> AsyncStatementQueryBuilder[T]:
17
- """Return a fluent async read builder for ``model``."""
18
- ...
19
-
20
- def get_repository(self, repo_class: type[R]) -> R:
21
- """Return a cached typed entity repository."""
22
- ...
23
-
24
- def repository(self, model: type[T]) -> object:
25
- """Return generic CRUD helpers for ``model`` (``AsyncBaseRepository`` in Phobos)."""
26
- ...
@@ -1,26 +0,0 @@
1
- """Portable repository factory protocol (no Phobos or app imports)."""
2
-
3
- from __future__ import annotations
4
- from typing import Protocol
5
- from typing import TypeVar
6
- from sqlalchemy.orm import DeclarativeBase
7
- from sqlphilosophy.sync.query import StatementQueryBuilder
8
-
9
- T = TypeVar("T", bound=DeclarativeBase)
10
- R = TypeVar("R")
11
-
12
-
13
- class RepositoryFactory(Protocol):
14
- """Session-scoped factory for statement builders and entity repositories."""
15
-
16
- def create_statement(self, model: type[T]) -> StatementQueryBuilder[T]:
17
- """Return a fluent read builder for ``model``."""
18
- ...
19
-
20
- def get_repository(self, repo_class: type[R]) -> R:
21
- """Return a cached typed entity repository."""
22
- ...
23
-
24
- def repository(self, model: type[T]) -> object:
25
- """Return generic CRUD helpers for ``model`` (``BaseRepository`` in Phobos)."""
26
- ...
File without changes
File without changes
File without changes