sqlphilosophy 0.1.0__py3-none-any.whl
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.
- sqlphilosophy/VERSION +1 -0
- sqlphilosophy/__init__.py +3 -0
- sqlphilosophy/aio/__init__.py +3 -0
- sqlphilosophy/aio/protocols.py +26 -0
- sqlphilosophy/aio/query.py +396 -0
- sqlphilosophy/aio/repository.py +400 -0
- sqlphilosophy/audit/__init__.py +3 -0
- sqlphilosophy/audit/context.py +37 -0
- sqlphilosophy/audit/fields.py +24 -0
- sqlphilosophy/audit/listener.py +99 -0
- sqlphilosophy/audit/model.py +59 -0
- sqlphilosophy/py.typed +0 -0
- sqlphilosophy/sorting.py +97 -0
- sqlphilosophy/sql.py +532 -0
- sqlphilosophy/sync/__init__.py +3 -0
- sqlphilosophy/sync/protocols.py +26 -0
- sqlphilosophy/sync/query.py +392 -0
- sqlphilosophy/sync/repository.py +360 -0
- sqlphilosophy/types.py +61 -0
- sqlphilosophy-0.1.0.dist-info/METADATA +134 -0
- sqlphilosophy-0.1.0.dist-info/RECORD +24 -0
- sqlphilosophy-0.1.0.dist-info/WHEEL +5 -0
- sqlphilosophy-0.1.0.dist-info/licenses/LICENSE +21 -0
- sqlphilosophy-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Generic session-bound repository for ORM CRUD."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
from sqlalchemy import delete
|
|
7
|
+
from sqlalchemy import func
|
|
8
|
+
from sqlalchemy import inspect as sa_inspect
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
from sqlalchemy import update
|
|
11
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
12
|
+
from sqlalchemy.orm import Session
|
|
13
|
+
from sqlalchemy.orm.interfaces import LoaderOption
|
|
14
|
+
from sqlphilosophy.sorting import ListQuery
|
|
15
|
+
from sqlphilosophy.sorting import SortConfig
|
|
16
|
+
from sqlphilosophy.sql import apply_mappings_page
|
|
17
|
+
from sqlphilosophy.sql import delete_by_ids_model
|
|
18
|
+
from sqlphilosophy.sql import partial_update_model
|
|
19
|
+
from sqlphilosophy.sql import rows_mapping
|
|
20
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
21
|
+
from sqlphilosophy.sync.query import SqlAlchemyStatementBuilder
|
|
22
|
+
from sqlphilosophy.sync.query import StatementQueryBuilder
|
|
23
|
+
from sqlphilosophy.types import IdList
|
|
24
|
+
from sqlphilosophy.types import PrimaryKey
|
|
25
|
+
from sqlphilosophy.types import RowMapping
|
|
26
|
+
from sqlphilosophy.types import SqlBindParams
|
|
27
|
+
from sqlphilosophy.types import SqlSelect
|
|
28
|
+
|
|
29
|
+
LoadRelations = Sequence[LoaderOption]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BaseRepository[T: DeclarativeBase]:
|
|
33
|
+
"""Session-scoped CRUD helpers for a single mapped model."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
model: type[T],
|
|
38
|
+
session: Session,
|
|
39
|
+
factory: RepositoryFactory | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.model = model
|
|
42
|
+
self.session = session
|
|
43
|
+
self._factory = factory
|
|
44
|
+
pk_cols = self.inspect_model(model).primary_key
|
|
45
|
+
if len(pk_cols) != 1:
|
|
46
|
+
raise TypeError(f"{model.__name__} must have a single-column primary key")
|
|
47
|
+
self._pk_column = pk_cols[0]
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def inspect_model(cls, model: type[DeclarativeBase]) -> Any:
|
|
51
|
+
"""Return SQLAlchemy ORM mapper inspection for ``model``."""
|
|
52
|
+
return sa_inspect(model)
|
|
53
|
+
|
|
54
|
+
def inspect(self) -> Any:
|
|
55
|
+
"""Return SQLAlchemy ORM mapper inspection for this repository's model."""
|
|
56
|
+
return self.inspect_model(self.model)
|
|
57
|
+
|
|
58
|
+
def list_table_names(self) -> frozenset[str]:
|
|
59
|
+
"""Return visible table names on the session connection."""
|
|
60
|
+
return frozenset(sa_inspect(self.session.connection()).get_table_names())
|
|
61
|
+
|
|
62
|
+
def has_table(self, table_name: str) -> bool:
|
|
63
|
+
"""True when ``table_name`` exists on the session connection."""
|
|
64
|
+
return table_name in self.list_table_names()
|
|
65
|
+
|
|
66
|
+
def _apply_load_relations(self, stmt: Any, load_relations: LoadRelations | None) -> Any:
|
|
67
|
+
if load_relations:
|
|
68
|
+
return stmt.options(*load_relations)
|
|
69
|
+
return stmt
|
|
70
|
+
|
|
71
|
+
def _scalar_result(self, stmt: Any, *, unique: bool = False) -> Any:
|
|
72
|
+
if unique:
|
|
73
|
+
return self.session.scalars(stmt).unique()
|
|
74
|
+
return self.session.scalars(stmt)
|
|
75
|
+
|
|
76
|
+
def fetch_statement_mappings(
|
|
77
|
+
self, stmt: Any, params: RowMapping | None = None
|
|
78
|
+
) -> list[RowMapping]:
|
|
79
|
+
"""Execute ``stmt`` and return all rows as mappings."""
|
|
80
|
+
mapped = self.session.execute(stmt, params or {}).mappings()
|
|
81
|
+
rows = mapped.all() if hasattr(mapped, "all") else mapped
|
|
82
|
+
return rows_mapping(rows)
|
|
83
|
+
|
|
84
|
+
def scalar_count(self, stmt: SqlSelect, params: SqlBindParams | None = None) -> int:
|
|
85
|
+
"""Execute a scalar count/select statement and return ``int``."""
|
|
86
|
+
return int(self.session.execute(stmt, params or {}).scalar_one())
|
|
87
|
+
|
|
88
|
+
def iter_mappings(self, stmt: SqlSelect, params: SqlBindParams | None = None):
|
|
89
|
+
"""Yield each result row as a plain ``dict``."""
|
|
90
|
+
for row in self.session.execute(stmt, params or {}).mappings():
|
|
91
|
+
yield dict(row)
|
|
92
|
+
|
|
93
|
+
def fetch_mapping_first(
|
|
94
|
+
self, stmt: SqlSelect, params: SqlBindParams | None = None
|
|
95
|
+
) -> RowMapping | None:
|
|
96
|
+
"""Execute ``stmt`` and return the first row as a mapping, or ``None``."""
|
|
97
|
+
row = self.session.execute(stmt, params or {}).mappings().first()
|
|
98
|
+
return dict(row) if row is not None else None
|
|
99
|
+
|
|
100
|
+
def fetch_mapping_one(self, stmt: SqlSelect, params: SqlBindParams | None = None) -> RowMapping:
|
|
101
|
+
"""Execute ``stmt`` and return exactly one row as a mapping."""
|
|
102
|
+
return dict(self.session.execute(stmt, params or {}).mappings().one())
|
|
103
|
+
|
|
104
|
+
def fetch_mappings_page(
|
|
105
|
+
self,
|
|
106
|
+
stmt: Any,
|
|
107
|
+
*,
|
|
108
|
+
limit: int,
|
|
109
|
+
offset: int,
|
|
110
|
+
params: RowMapping | None = None,
|
|
111
|
+
) -> list[RowMapping]:
|
|
112
|
+
"""Execute ``stmt`` with limit/offset; return normalized row mappings."""
|
|
113
|
+
return apply_mappings_page(
|
|
114
|
+
self.session,
|
|
115
|
+
stmt,
|
|
116
|
+
limit=limit,
|
|
117
|
+
offset=offset,
|
|
118
|
+
params=params,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def fetch_sorted_mappings(
|
|
122
|
+
self,
|
|
123
|
+
stmt: Any,
|
|
124
|
+
*,
|
|
125
|
+
list_query: ListQuery,
|
|
126
|
+
params: RowMapping | None = None,
|
|
127
|
+
sort: SortConfig | None = None,
|
|
128
|
+
) -> list[RowMapping]:
|
|
129
|
+
"""Apply optional sort, then return one page of row mappings."""
|
|
130
|
+
if sort is not None:
|
|
131
|
+
stmt = stmt.order_by(*sort.order_clauses(list_query.order_by))
|
|
132
|
+
return self.fetch_mappings_page(
|
|
133
|
+
stmt,
|
|
134
|
+
limit=list_query.limit,
|
|
135
|
+
offset=list_query.offset,
|
|
136
|
+
params=params,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def get_by_id(
|
|
140
|
+
self, obj_id: PrimaryKey, load_relations: LoadRelations | None = None
|
|
141
|
+
) -> T | None:
|
|
142
|
+
"""Fetch a single record by primary key with optional eager loading."""
|
|
143
|
+
stmt = select(self.model).where(self._pk_column == obj_id)
|
|
144
|
+
stmt = self._apply_load_relations(stmt, load_relations)
|
|
145
|
+
return self.session.scalar(stmt)
|
|
146
|
+
|
|
147
|
+
def exists(self, obj_id: PrimaryKey) -> bool:
|
|
148
|
+
"""True when a row exists for the primary key."""
|
|
149
|
+
return self.get_by_id(obj_id) is not None
|
|
150
|
+
|
|
151
|
+
def exists_where(self, **filters: object) -> bool:
|
|
152
|
+
"""True when at least one row matches optional equality filters."""
|
|
153
|
+
return self.count(**filters) > 0
|
|
154
|
+
|
|
155
|
+
def count(self, **filters: object) -> int:
|
|
156
|
+
"""Count rows matching optional equality filters."""
|
|
157
|
+
stmt = select(func.count()).select_from(self.model)
|
|
158
|
+
if filters:
|
|
159
|
+
stmt = stmt.filter_by(**filters)
|
|
160
|
+
return int(self.session.scalar(stmt) or 0)
|
|
161
|
+
|
|
162
|
+
def first(self, load_relations: LoadRelations | None = None, **filters: object) -> T | None:
|
|
163
|
+
"""Return the first row matching filters, with optional eager loading."""
|
|
164
|
+
stmt = select(self.model).filter_by(**filters).limit(1)
|
|
165
|
+
stmt = self._apply_load_relations(stmt, load_relations)
|
|
166
|
+
return self.session.scalar(stmt)
|
|
167
|
+
|
|
168
|
+
def get(self, obj_id: PrimaryKey, load_relations: LoadRelations | None = None) -> T:
|
|
169
|
+
"""Fetch a single record by primary key; raise if missing."""
|
|
170
|
+
obj = self.get_by_id(obj_id, load_relations=load_relations)
|
|
171
|
+
if obj is None:
|
|
172
|
+
raise LookupError(f"{self.model.__name__} matching id={obj_id!r} not found")
|
|
173
|
+
return obj
|
|
174
|
+
|
|
175
|
+
def get_many(
|
|
176
|
+
self, ids: Sequence[PrimaryKey], load_relations: LoadRelations | None = None
|
|
177
|
+
) -> Sequence[T]:
|
|
178
|
+
"""Fetch multiple records by primary key."""
|
|
179
|
+
if not ids:
|
|
180
|
+
return []
|
|
181
|
+
stmt = select(self.model).where(self._pk_column.in_(ids))
|
|
182
|
+
stmt = self._apply_load_relations(stmt, load_relations)
|
|
183
|
+
return self._scalar_result(stmt, unique=load_relations is not None).all()
|
|
184
|
+
|
|
185
|
+
def filter(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
page: int = 1,
|
|
189
|
+
limit: int | None = None,
|
|
190
|
+
load_relations: LoadRelations | None = None,
|
|
191
|
+
**filters: object,
|
|
192
|
+
) -> Sequence[T]:
|
|
193
|
+
"""Return rows matching optional equality filters, optionally paginated."""
|
|
194
|
+
if page < 1:
|
|
195
|
+
raise ValueError("page must be >= 1")
|
|
196
|
+
if limit is not None and limit < 1:
|
|
197
|
+
raise ValueError("limit must be >= 1")
|
|
198
|
+
stmt = select(self.model).filter_by(**filters).order_by(self._pk_column)
|
|
199
|
+
if limit is not None:
|
|
200
|
+
stmt = stmt.limit(limit).offset((page - 1) * limit)
|
|
201
|
+
stmt = self._apply_load_relations(stmt, load_relations)
|
|
202
|
+
return self._scalar_result(stmt, unique=load_relations is not None).all()
|
|
203
|
+
|
|
204
|
+
def get_all(
|
|
205
|
+
self,
|
|
206
|
+
*,
|
|
207
|
+
page: int = 1,
|
|
208
|
+
limit: int | None = None,
|
|
209
|
+
load_relations: LoadRelations | None = None,
|
|
210
|
+
) -> Sequence[T]:
|
|
211
|
+
"""Fetch records for this model type, optionally paginated by ``page`` and ``limit``."""
|
|
212
|
+
if page < 1:
|
|
213
|
+
raise ValueError("page must be >= 1")
|
|
214
|
+
if limit is not None and limit < 1:
|
|
215
|
+
raise ValueError("limit must be >= 1")
|
|
216
|
+
statement = select(self.model).order_by(self._pk_column)
|
|
217
|
+
if limit is not None:
|
|
218
|
+
statement = statement.limit(limit).offset((page - 1) * limit)
|
|
219
|
+
statement = self._apply_load_relations(statement, load_relations)
|
|
220
|
+
return self._scalar_result(statement, unique=load_relations is not None).all()
|
|
221
|
+
|
|
222
|
+
def get_with_join(
|
|
223
|
+
self,
|
|
224
|
+
target_model: type[Any],
|
|
225
|
+
*filter_expressions: Any,
|
|
226
|
+
join_on: Any = None,
|
|
227
|
+
) -> Sequence[tuple[T, Any]]:
|
|
228
|
+
"""Explicit INNER JOIN returning ``(base_row, target_row)`` tuples."""
|
|
229
|
+
stmt = select(self.model, target_model)
|
|
230
|
+
if join_on is not None:
|
|
231
|
+
stmt = stmt.join(target_model, join_on)
|
|
232
|
+
else:
|
|
233
|
+
stmt = stmt.join(target_model) # pragma: no cover
|
|
234
|
+
if filter_expressions:
|
|
235
|
+
stmt = stmt.where(*filter_expressions)
|
|
236
|
+
return self.session.execute(stmt).all()
|
|
237
|
+
|
|
238
|
+
def create(self, **fields: object) -> T:
|
|
239
|
+
"""Construct, stage, and flush a new instance."""
|
|
240
|
+
return self.add(self.model(**fields))
|
|
241
|
+
|
|
242
|
+
def get_or_create(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
defaults: RowMapping | None = None,
|
|
246
|
+
**lookup: object,
|
|
247
|
+
) -> tuple[T, bool]:
|
|
248
|
+
"""Return ``(instance, created)`` for equality ``lookup`` filters."""
|
|
249
|
+
existing = self.first(**lookup)
|
|
250
|
+
if existing is not None:
|
|
251
|
+
return existing, False
|
|
252
|
+
payload: RowMapping = {**(defaults or {}), **lookup}
|
|
253
|
+
return self.create(**payload), True
|
|
254
|
+
|
|
255
|
+
def add(self, obj: T) -> T:
|
|
256
|
+
"""Stage a new instance; caller commits in the orchestration layer."""
|
|
257
|
+
self.session.add(obj)
|
|
258
|
+
self.session.flush()
|
|
259
|
+
return obj
|
|
260
|
+
|
|
261
|
+
def update_partial(
|
|
262
|
+
self,
|
|
263
|
+
obj_id: PrimaryKey,
|
|
264
|
+
fields: RowMapping,
|
|
265
|
+
writable: frozenset[str],
|
|
266
|
+
*,
|
|
267
|
+
touch_updated_on: bool = False,
|
|
268
|
+
) -> int:
|
|
269
|
+
"""Apply a partial update; returns affected row count (0 if none)."""
|
|
270
|
+
return partial_update_model(
|
|
271
|
+
self.session,
|
|
272
|
+
self.model,
|
|
273
|
+
obj_id,
|
|
274
|
+
fields,
|
|
275
|
+
writable,
|
|
276
|
+
touch_updated_on=touch_updated_on,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def update_where(
|
|
280
|
+
self,
|
|
281
|
+
*,
|
|
282
|
+
criteria: Sequence[object],
|
|
283
|
+
values: RowMapping,
|
|
284
|
+
params: SqlBindParams | None = None,
|
|
285
|
+
) -> int:
|
|
286
|
+
"""Bulk UPDATE rows matching ``criteria``; returns affected row count."""
|
|
287
|
+
if not values:
|
|
288
|
+
return 0
|
|
289
|
+
stmt = update(self.model).where(*criteria).values(**values)
|
|
290
|
+
result = self.session.execute(stmt, params or {})
|
|
291
|
+
return int(result.rowcount or 0)
|
|
292
|
+
|
|
293
|
+
def delete_where(
|
|
294
|
+
self,
|
|
295
|
+
*,
|
|
296
|
+
criteria: Sequence[object],
|
|
297
|
+
params: SqlBindParams | None = None,
|
|
298
|
+
) -> int:
|
|
299
|
+
"""Delete rows matching ``criteria`` via PK lookup + ``delete_many``."""
|
|
300
|
+
if not criteria:
|
|
301
|
+
return 0
|
|
302
|
+
pk_key = self._pk_column.key
|
|
303
|
+
builder = self.statement().select_columns(self._pk_column).where(*criteria)
|
|
304
|
+
if params:
|
|
305
|
+
builder = builder.with_params(params)
|
|
306
|
+
rows = builder.mappings().all()
|
|
307
|
+
ids = [row[pk_key] for row in rows]
|
|
308
|
+
return self.delete_many(ids)
|
|
309
|
+
|
|
310
|
+
def remove(self, obj_id: PrimaryKey) -> bool:
|
|
311
|
+
"""Delete a record by primary key."""
|
|
312
|
+
statement = delete(self.model).where(self._pk_column == obj_id)
|
|
313
|
+
result = self.session.execute(statement)
|
|
314
|
+
return bool(result.rowcount)
|
|
315
|
+
|
|
316
|
+
def delete_many(self, ids: IdList) -> int:
|
|
317
|
+
"""Delete multiple records by primary key."""
|
|
318
|
+
return delete_by_ids_model(self.session, self.model, list(ids))
|
|
319
|
+
|
|
320
|
+
def delete_all(self) -> int:
|
|
321
|
+
"""Delete every row for this model. Dev/ops only — prefer ``delete_where`` in app code."""
|
|
322
|
+
result = self.session.execute(delete(self.model))
|
|
323
|
+
return int(result.rowcount or 0)
|
|
324
|
+
|
|
325
|
+
def batched_purge_ids(
|
|
326
|
+
self,
|
|
327
|
+
*,
|
|
328
|
+
criteria: list[object],
|
|
329
|
+
batch_size: int,
|
|
330
|
+
) -> int:
|
|
331
|
+
"""Delete rows matching ``criteria`` in ``batch_size`` chunks, committing each batch."""
|
|
332
|
+
pk_key = self._pk_column.key
|
|
333
|
+
total = 0
|
|
334
|
+
while True:
|
|
335
|
+
rows = (
|
|
336
|
+
self.statement()
|
|
337
|
+
.select_columns(self._pk_column)
|
|
338
|
+
.where(*criteria)
|
|
339
|
+
.limit(batch_size)
|
|
340
|
+
.mappings()
|
|
341
|
+
.all()
|
|
342
|
+
)
|
|
343
|
+
ids = [row[pk_key] for row in rows]
|
|
344
|
+
if not ids:
|
|
345
|
+
break
|
|
346
|
+
total += self.delete_many(ids)
|
|
347
|
+
self.session.commit()
|
|
348
|
+
return total
|
|
349
|
+
|
|
350
|
+
def statement(self) -> StatementQueryBuilder[T]:
|
|
351
|
+
"""Return a fluent statement builder for reads on this model (default read path)."""
|
|
352
|
+
if self._factory is not None:
|
|
353
|
+
return self._factory.create_statement(self.model)
|
|
354
|
+
return SqlAlchemyStatementBuilder(self.session, self.model)
|
|
355
|
+
|
|
356
|
+
def for_repo[R](self, repo_class: type[R]) -> R:
|
|
357
|
+
"""Return a typed entity repository sharing this session and factory."""
|
|
358
|
+
if self._factory is None:
|
|
359
|
+
raise RuntimeError("for_repo() requires a RepositoryFactory")
|
|
360
|
+
return self._factory.get_repository(repo_class)
|
sqlphilosophy/types.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Portable SQLAlchemy repository typing aliases (PyPI-safe, no app imports)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from datetime import date
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
9
|
+
from sqlalchemy.sql import ColumnElement
|
|
10
|
+
from sqlalchemy.sql import Select
|
|
11
|
+
from sqlalchemy.sql.selectable import FromClause
|
|
12
|
+
from sqlalchemy.sql.selectable import LateralFromClause
|
|
13
|
+
from sqlalchemy.sql.selectable import ScalarSelect
|
|
14
|
+
from sqlalchemy.sql.selectable import TableClause
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ApiObject",
|
|
18
|
+
"ApiScalar",
|
|
19
|
+
"IdList",
|
|
20
|
+
"JSONObject",
|
|
21
|
+
"JSONScalar",
|
|
22
|
+
"JSONValue",
|
|
23
|
+
"OrmModel",
|
|
24
|
+
"PrimaryKey",
|
|
25
|
+
"RowMapping",
|
|
26
|
+
"RowValue",
|
|
27
|
+
"SqlBindParams",
|
|
28
|
+
"SqlFilter",
|
|
29
|
+
"SqlFilters",
|
|
30
|
+
"SqlFromClause",
|
|
31
|
+
"SqlLateral",
|
|
32
|
+
"SqlOrderColumn",
|
|
33
|
+
"SqlScalarSubquery",
|
|
34
|
+
"SqlSelect",
|
|
35
|
+
"SqlTable",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
type JSONScalar = str | int | float | bool | None
|
|
39
|
+
type JSONValue = JSONScalar | list[JSONValue] | dict[str, JSONValue]
|
|
40
|
+
type JSONObject = dict[str, JSONValue]
|
|
41
|
+
|
|
42
|
+
type ApiScalar = JSONScalar | datetime | date | UUID
|
|
43
|
+
type RowValue = JSONScalar | datetime | date | UUID | bytes | dict[str, RowValue] | list[RowValue]
|
|
44
|
+
type ApiObject = dict[str, RowValue]
|
|
45
|
+
|
|
46
|
+
type PrimaryKey = int | str | UUID
|
|
47
|
+
type IdList = list[PrimaryKey]
|
|
48
|
+
|
|
49
|
+
type SqlBindParams = dict[str, RowValue]
|
|
50
|
+
type SqlFilter = ColumnElement[bool]
|
|
51
|
+
type SqlFilters = list[SqlFilter]
|
|
52
|
+
type SqlOrderColumn = ColumnElement[object]
|
|
53
|
+
type SqlSelect = Select[tuple[object, ...]]
|
|
54
|
+
type SqlFromClause = FromClause
|
|
55
|
+
type SqlLateral = LateralFromClause
|
|
56
|
+
type SqlScalarSubquery = ScalarSelect[object]
|
|
57
|
+
type SqlTable = TableClause
|
|
58
|
+
|
|
59
|
+
type RowMapping = Mapping[str, RowValue]
|
|
60
|
+
|
|
61
|
+
type OrmModel = type[DeclarativeBase]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlphilosophy
|
|
3
|
+
Version: 0.1.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: sqlalchemy<3,>=2.0
|
|
24
|
+
Provides-Extra: async
|
|
25
|
+
Requires-Dist: greenlet>=3.0; extra == "async"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
30
|
+
Requires-Dist: aiosqlite>=0.20; extra == "dev"
|
|
31
|
+
Requires-Dist: greenlet>=3.0; extra == "dev"
|
|
32
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
33
|
+
Requires-Dist: twine>=6.1; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# sqlphilosophy
|
|
37
|
+
|
|
38
|
+
Portable SQLAlchemy repository kit for typed CRUD, fluent statement building, sort/pagination, and Core SQL helpers — with explicit sync and async session APIs.
|
|
39
|
+
|
|
40
|
+
| | |
|
|
41
|
+
|---|---|
|
|
42
|
+
| **PyPI** | [`sqlphilosophy`](https://pypi.org/project/sqlphilosophy/) |
|
|
43
|
+
| **GitHub** | [SignalSafeSoftware/sqlphilosophy](https://github.com/SignalSafeSoftware/sqlphilosophy) |
|
|
44
|
+
| **Import** | `sqlphilosophy` (no root reexports — use explicit submodules below) |
|
|
45
|
+
|
|
46
|
+
Developed in the [DeliveryPlus](https://github.com/SignalSafeSoftware/DeliveryPlus) monorepo under `libs/sqlphilosophy`; this tree is the publishable package source.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install sqlphilosophy
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Async ORM (`AsyncSession`) also needs greenlet:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install sqlphilosophy[async]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Requires Python 3.12+ and SQLAlchemy 2.x.
|
|
61
|
+
|
|
62
|
+
## Package layout
|
|
63
|
+
|
|
64
|
+
| Module | Contents |
|
|
65
|
+
|--------|----------|
|
|
66
|
+
| `sqlphilosophy.types` | Portable typing aliases (`RowMapping`, `PrimaryKey`, `SqlFilter`, …) |
|
|
67
|
+
| `sqlphilosophy.sql` | Row mapping helpers, partial updates, Core table helpers, filter builders |
|
|
68
|
+
| `sqlphilosophy.sorting` | `ListQuery`, `SortConfig`, `SortSpec`, pagination/sort resolution |
|
|
69
|
+
| `sqlphilosophy.sync` | Sync `BaseRepository`, `StatementQueryBuilder`, `RepositoryFactory` protocol |
|
|
70
|
+
| `sqlphilosophy.aio` | Async `AsyncBaseRepository`, `AsyncStatementQueryBuilder`, `AsyncRepositoryFactory` |
|
|
71
|
+
| `sqlphilosophy.audit` | Optional SQLAlchemy audit listeners and timestamp mixins |
|
|
72
|
+
|
|
73
|
+
## Sync usage
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from sqlalchemy.orm import Session
|
|
77
|
+
|
|
78
|
+
from sqlphilosophy.sorting import ListQuery, SortConfig, SortSpec
|
|
79
|
+
from sqlphilosophy.sql import partial_update_model, row_int
|
|
80
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
81
|
+
from sqlphilosophy.sync.repository import BaseRepository
|
|
82
|
+
from sqlphilosophy.sync.query import SqlAlchemyStatementBuilder
|
|
83
|
+
|
|
84
|
+
# Without a factory — statement() returns SqlAlchemyStatementBuilder directly
|
|
85
|
+
repo = BaseRepository(User, session)
|
|
86
|
+
rows = repo.statement().where(User.active.is_(True)).mappings().all()
|
|
87
|
+
|
|
88
|
+
# With a factory — statement() and for_repo() delegate to the factory
|
|
89
|
+
repo = BaseRepository(User, session, factory)
|
|
90
|
+
page = repo.statement().fetch_page(ListQuery.from_page(page=1, size=20))
|
|
91
|
+
other = repo.for_repo(OrderRepository)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Async usage
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
98
|
+
|
|
99
|
+
from sqlphilosophy.aio.repository import AsyncBaseRepository
|
|
100
|
+
|
|
101
|
+
repo = AsyncBaseRepository(User, session)
|
|
102
|
+
rows = await repo.statement().where(User.active.is_(True)).mappings().all()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Audit mixins
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from sqlphilosophy.audit.context import audit_context
|
|
109
|
+
from sqlphilosophy.audit.listener import configure_audit_listeners
|
|
110
|
+
from sqlphilosophy.audit.model import TimestampModel
|
|
111
|
+
|
|
112
|
+
configure_audit_listeners()
|
|
113
|
+
|
|
114
|
+
with audit_context(actor_id=42):
|
|
115
|
+
session.add(MyModel(name="example"))
|
|
116
|
+
session.flush()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
python -m pip install -e ".[dev]"
|
|
123
|
+
python -m pytest
|
|
124
|
+
python -m build
|
|
125
|
+
python -m twine check dist/*
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Releasing
|
|
129
|
+
|
|
130
|
+
See [RELEASING.md](./RELEASING.md) for GitHub + PyPI trusted publishing.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
sqlphilosophy/VERSION,sha256=6d2FB_S_DG9CRY5BrqgzrQvT9hJycjNe7pv01YVB7Wc,6
|
|
2
|
+
sqlphilosophy/__init__.py,sha256=ZyxzTD4IOajt6DKQLYTFAd5FqMZiqARHTqmVatJ8L8I,117
|
|
3
|
+
sqlphilosophy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
sqlphilosophy/sorting.py,sha256=B6FTNj7g08g3MWnoiKpOjKp_b3OLDd4fdVhB48taI3o,3174
|
|
5
|
+
sqlphilosophy/sql.py,sha256=sSxwdR9nybMDdFsCxzMLoDi1bBQtZt-hpUqCSlE3ePw,16605
|
|
6
|
+
sqlphilosophy/types.py,sha256=2ED-HLlymXVUmhjq8sm6Z6MBTxeaELY6f-I2K7mChi0,1754
|
|
7
|
+
sqlphilosophy/aio/__init__.py,sha256=kM_nFV5Gw44MDuBecpXTiYdb0i65wqVC108NA3neQ6s,60
|
|
8
|
+
sqlphilosophy/aio/protocols.py,sha256=4EvHfbahHJO300kUysBKtzl--0_tINliYX0imYGKBo4,901
|
|
9
|
+
sqlphilosophy/aio/query.py,sha256=AkI-SjoGstYB7iVdQYz2We3hdJgnG4l2F0TJvNzF3So,12997
|
|
10
|
+
sqlphilosophy/aio/repository.py,sha256=GbSTFp2EzvHjTrhSml3YL8A-K6Y6Fw4DmnjTXb-lQYM,15664
|
|
11
|
+
sqlphilosophy/audit/__init__.py,sha256=AwN_xJNLVtsc7n_p3Lk485QoVyco4l9pz-K37TOcMnw,70
|
|
12
|
+
sqlphilosophy/audit/context.py,sha256=ckLNvf5g_YqnFui2aDhxXs521DKjLIu1d1GrH3n0h8s,919
|
|
13
|
+
sqlphilosophy/audit/fields.py,sha256=QVf0uC632rFFKzBv1pHoZRLXfq_l7pBfRLWF98zFDyo,625
|
|
14
|
+
sqlphilosophy/audit/listener.py,sha256=0PiCzVXiQKUISZhH2Lhs7KTIb_BD7B1_fs6vGWnnbus,3546
|
|
15
|
+
sqlphilosophy/audit/model.py,sha256=8S9kZdasUzGgV0gUnHnDcEhy4cXTt0NKykghPpETOBk,1839
|
|
16
|
+
sqlphilosophy/sync/__init__.py,sha256=AIxiTARXneNQjb1yEcX_2fI_pCkDMEyVmFPkcVfCig4,59
|
|
17
|
+
sqlphilosophy/sync/protocols.py,sha256=Y8PJmGdeW12H-kfleyA7OvfKnWXypn_KieDalKx6PaE,864
|
|
18
|
+
sqlphilosophy/sync/query.py,sha256=FuuPiLXJfIUyuGqO0D14Adrinqb5sx5f8lw1KN98nuI,12725
|
|
19
|
+
sqlphilosophy/sync/repository.py,sha256=jq8atAGyeuVa1H9bTE6QZODBT6Lj4E-8MeNPrNpYs9w,13792
|
|
20
|
+
sqlphilosophy-0.1.0.dist-info/licenses/LICENSE,sha256=gLXN1zYlPx8WFJGVkSSGrmNEL07Dl7HznSumDZelvio,1063
|
|
21
|
+
sqlphilosophy-0.1.0.dist-info/METADATA,sha256=xq4AhOFxq3yFAmcqnBlcnGk_wUCv1qchZWT8iiAcqTc,4726
|
|
22
|
+
sqlphilosophy-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
23
|
+
sqlphilosophy-0.1.0.dist-info/top_level.txt,sha256=sT2j0APcke3yvJP5eQ6PM0kZcePIG-Vf-GR_94aWTcI,14
|
|
24
|
+
sqlphilosophy-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Josh Martin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sqlphilosophy
|