sqlarec 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.
- sqlarec/__init__.py +27 -0
- sqlarec/core/__init__.py +13 -0
- sqlarec/core/base_model.py +244 -0
- sqlarec/core/query.py +177 -0
- sqlarec/core/update.py +135 -0
- sqlarec/database.py +60 -0
- sqlarec/py.typed +1 -0
- sqlarec/utils/__init__.py +5 -0
- sqlarec/utils/identifiers.py +8 -0
- sqlarec-0.1.0.dist-info/METADATA +324 -0
- sqlarec-0.1.0.dist-info/RECORD +12 -0
- sqlarec-0.1.0.dist-info/WHEEL +4 -0
sqlarec/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""A context-aware Active Record API for synchronous SQLAlchemy.
|
|
2
|
+
|
|
3
|
+
Applications remain responsible for creating and registering a session, and for
|
|
4
|
+
committing or rolling back transactions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from sqlarec.core import (
|
|
8
|
+
BaseModel,
|
|
9
|
+
ModelQuery,
|
|
10
|
+
ModelUpdate,
|
|
11
|
+
RowQuery,
|
|
12
|
+
RowUpdate,
|
|
13
|
+
)
|
|
14
|
+
from sqlarec.database import get_engine, init_engine, new_session
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BaseModel",
|
|
18
|
+
"ModelQuery",
|
|
19
|
+
"ModelUpdate",
|
|
20
|
+
"RowQuery",
|
|
21
|
+
"RowUpdate",
|
|
22
|
+
"get_engine",
|
|
23
|
+
"init_engine",
|
|
24
|
+
"new_session",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
sqlarec/core/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Core model and statement wrapper implementations."""
|
|
2
|
+
|
|
3
|
+
from .base_model import BaseModel
|
|
4
|
+
from .query import ModelQuery, RowQuery
|
|
5
|
+
from .update import ModelUpdate, RowUpdate
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BaseModel",
|
|
9
|
+
"ModelQuery",
|
|
10
|
+
"ModelUpdate",
|
|
11
|
+
"RowQuery",
|
|
12
|
+
"RowUpdate",
|
|
13
|
+
]
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Declarative base model with context-aware persistence helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from typing import Any, ClassVar, Generic, Self, TypeVar, cast, overload
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import Integer, inspect, select, update
|
|
9
|
+
from sqlalchemy import Sequence as SQLSequence
|
|
10
|
+
from sqlalchemy.orm import DeclarativeBase, Session
|
|
11
|
+
|
|
12
|
+
from sqlarec.core.query import ModelQuery, RowQuery, _ModelQueryProperty
|
|
13
|
+
from sqlarec.core.update import Update
|
|
14
|
+
from sqlarec.utils import generate_identifier
|
|
15
|
+
|
|
16
|
+
ValueT = TypeVar("ValueT")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _ClassProperty(Generic[ValueT]):
|
|
20
|
+
"""Read-only descriptor for a value computed from a class."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, getter: Callable[[Any], ValueT]) -> None:
|
|
23
|
+
self.getter = getter
|
|
24
|
+
|
|
25
|
+
def __get__(
|
|
26
|
+
self,
|
|
27
|
+
instance: object | None,
|
|
28
|
+
owner: type[Any],
|
|
29
|
+
) -> ValueT:
|
|
30
|
+
return self.getter(owner)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BaseModel(DeclarativeBase):
|
|
34
|
+
"""Base for SQLAlchemy models with query and persistence conveniences.
|
|
35
|
+
|
|
36
|
+
Register a session provider before using model operations. Write helpers
|
|
37
|
+
flush changes but never commit, so the application retains control of the
|
|
38
|
+
transaction boundary.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
__abstract__ = True
|
|
42
|
+
|
|
43
|
+
_session_provider: ClassVar[Callable[[], Session] | None] = None
|
|
44
|
+
query = _ModelQueryProperty()
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def register_session_provider(cls, provider: Callable[[], Session]) -> None:
|
|
48
|
+
"""Register the callback used to obtain the current session.
|
|
49
|
+
|
|
50
|
+
The provider is shared by every model that inherits from ``BaseModel``.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
provider: Zero-argument callable that returns the current synchronous
|
|
54
|
+
SQLAlchemy session.
|
|
55
|
+
"""
|
|
56
|
+
BaseModel._session_provider = provider
|
|
57
|
+
|
|
58
|
+
@_ClassProperty
|
|
59
|
+
def session(cls) -> Session:
|
|
60
|
+
"""Return the current session supplied by the registered provider.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
RuntimeError: If no session provider has been registered.
|
|
64
|
+
"""
|
|
65
|
+
provider = BaseModel._session_provider
|
|
66
|
+
if provider is None:
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
"No session provider registered. Call "
|
|
69
|
+
"BaseModel.register_session_provider() at app startup."
|
|
70
|
+
)
|
|
71
|
+
return provider()
|
|
72
|
+
|
|
73
|
+
@overload
|
|
74
|
+
@classmethod
|
|
75
|
+
def select(cls) -> ModelQuery[Self]: ...
|
|
76
|
+
|
|
77
|
+
@overload
|
|
78
|
+
@classmethod
|
|
79
|
+
def select(cls, *entities: Any) -> RowQuery: ...
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def select(cls, *entities: Any) -> ModelQuery[Self] | RowQuery:
|
|
83
|
+
"""Create an entity query or a row query for selected expressions."""
|
|
84
|
+
if entities:
|
|
85
|
+
return RowQuery(select(*entities), cls.session)
|
|
86
|
+
return ModelQuery(select(cls), cls.session)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def update(cls) -> Update:
|
|
90
|
+
"""Create an immutable update wrapper for this model."""
|
|
91
|
+
return Update(update(cls), cls.session)
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def create(cls, **values: Any) -> Self:
|
|
95
|
+
"""Construct, add, and flush a model without committing.
|
|
96
|
+
|
|
97
|
+
A string-compatible single primary key receives a UUID hex identifier
|
|
98
|
+
when it has no supplied value, auto-increment behavior, or default.
|
|
99
|
+
"""
|
|
100
|
+
if (
|
|
101
|
+
cls.has_one_primary_key()
|
|
102
|
+
and cls.get_primary_key_name() not in values
|
|
103
|
+
and not cls.is_auto_increment()
|
|
104
|
+
and not cls.has_primary_key_default()
|
|
105
|
+
):
|
|
106
|
+
values[cls.get_primary_key_name()] = generate_identifier()
|
|
107
|
+
return cls.create_instance(**values)
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def create_instance(cls, **values: Any) -> Self:
|
|
111
|
+
"""Construct, add, and flush a model without generating an identifier."""
|
|
112
|
+
instance = cls(**values)
|
|
113
|
+
return instance.save()
|
|
114
|
+
|
|
115
|
+
def save(self) -> Self:
|
|
116
|
+
"""Add and flush this model without committing the transaction."""
|
|
117
|
+
self.session.add(self)
|
|
118
|
+
self.session.flush()
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def delete(self) -> None:
|
|
122
|
+
"""Delete and flush this model without committing the transaction."""
|
|
123
|
+
self.session.delete(self)
|
|
124
|
+
self.session.flush()
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def get_by_pk(cls, value: Any) -> Self | None:
|
|
128
|
+
"""Return the model with a primary-key identity, or ``None``."""
|
|
129
|
+
return cls.session.get(cls, value)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def exists(cls, value: Any) -> bool:
|
|
133
|
+
"""Return whether a model exists for a primary-key identity."""
|
|
134
|
+
return cls.get_by_pk(value) is not None
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def get_or_create(cls, **values: Any) -> Self:
|
|
138
|
+
"""Return a matching model or create and flush a new one."""
|
|
139
|
+
primary_key_names = cls.get_primary_key_names()
|
|
140
|
+
if all(name in values for name in primary_key_names):
|
|
141
|
+
identity_values = tuple(values[name] for name in primary_key_names)
|
|
142
|
+
identity = (
|
|
143
|
+
identity_values[0] if len(identity_values) == 1 else identity_values
|
|
144
|
+
)
|
|
145
|
+
instance = cls.get_by_pk(identity)
|
|
146
|
+
else:
|
|
147
|
+
instance = cls.get_instance_by_keys(**values)
|
|
148
|
+
return cls.create(**values) if instance is None else instance
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def all(cls) -> Sequence[Self]:
|
|
152
|
+
"""Return every row mapped by this model."""
|
|
153
|
+
return cls.query.all()
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def get_instance_by_keys(cls, **values: Any) -> Self | None:
|
|
157
|
+
"""Return zero or one model matching mapped attribute values.
|
|
158
|
+
|
|
159
|
+
SQLAlchemy raises ``MultipleResultsFound`` when the supplied attributes
|
|
160
|
+
do not identify at most one row.
|
|
161
|
+
"""
|
|
162
|
+
return cls.query.filter_by(**values).one_or_none()
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def filter_by_keys(cls, **values: Any) -> Sequence[Self]:
|
|
166
|
+
"""Return all models matching mapped attribute values."""
|
|
167
|
+
return cls.query.filter_by(**values).all()
|
|
168
|
+
|
|
169
|
+
def get_id(self) -> Any:
|
|
170
|
+
"""Return the model's primary-key value or composite-key tuple."""
|
|
171
|
+
return self.get_primary_key_value()
|
|
172
|
+
|
|
173
|
+
def get_primary_key_value(self) -> Any:
|
|
174
|
+
"""Return the primary-key value in mapper-defined column order."""
|
|
175
|
+
values = tuple(getattr(self, name) for name in self.get_primary_key_names())
|
|
176
|
+
return values[0] if len(values) == 1 else values
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def get_primary_key_name(cls) -> str:
|
|
180
|
+
"""Return the primary-key name for a single-key model.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
RuntimeError: If the model uses a composite primary key.
|
|
184
|
+
"""
|
|
185
|
+
names = cls.get_primary_key_names()
|
|
186
|
+
if len(names) != 1:
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
f"{cls.__name__} must have exactly one primary key column"
|
|
189
|
+
)
|
|
190
|
+
return names[0]
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def get_primary_key_names(cls) -> tuple[str, ...]:
|
|
194
|
+
"""Return primary-key names in mapper-defined order."""
|
|
195
|
+
return tuple(cast(str, column.key) for column in inspect(cls).primary_key)
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def has_one_primary_key(cls) -> bool:
|
|
199
|
+
"""Return whether the model has one primary-key column."""
|
|
200
|
+
return len(inspect(cls).primary_key) == 1
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def is_auto_increment(cls) -> bool:
|
|
204
|
+
"""Return whether the single primary key uses integer auto-increment."""
|
|
205
|
+
if not cls.has_one_primary_key():
|
|
206
|
+
return False
|
|
207
|
+
primary_key = inspect(cls).primary_key[0]
|
|
208
|
+
return primary_key.autoincrement is True or (
|
|
209
|
+
primary_key.autoincrement == "auto"
|
|
210
|
+
and isinstance(primary_key.type, Integer)
|
|
211
|
+
and not primary_key.foreign_keys
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def has_primary_key_default(cls) -> bool:
|
|
216
|
+
"""Return whether the single primary key defines a client/server default."""
|
|
217
|
+
if not cls.has_one_primary_key():
|
|
218
|
+
return False
|
|
219
|
+
primary_key = inspect(cls).primary_key[0]
|
|
220
|
+
return primary_key.default is not None or primary_key.server_default is not None
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def is_following_sequence(cls) -> bool:
|
|
224
|
+
"""Return whether the single primary key uses a default or sequence."""
|
|
225
|
+
if not cls.has_one_primary_key():
|
|
226
|
+
return False
|
|
227
|
+
primary_key = inspect(cls).primary_key[0]
|
|
228
|
+
return primary_key.server_default is not None or isinstance(
|
|
229
|
+
primary_key.default,
|
|
230
|
+
SQLSequence,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def to_dict(self) -> dict[str, Any]:
|
|
234
|
+
"""Return mapped column values keyed by column name."""
|
|
235
|
+
return {
|
|
236
|
+
column.key: getattr(self, column.key) for column in self.__table__.columns
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
def __repr__(self) -> str:
|
|
240
|
+
values = ", ".join(
|
|
241
|
+
f"{column.key}={getattr(self, column.key)!r}"
|
|
242
|
+
for column in self.__table__.columns
|
|
243
|
+
)
|
|
244
|
+
return f"{type(self).__name__}({values})"
|
sqlarec/core/query.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Immutable wrappers around SQLAlchemy select statements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any, Generic, Self, TypeVar, cast
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import Select, select, union, union_all
|
|
9
|
+
from sqlalchemy.engine import MappingResult, Result, Row, ScalarResult
|
|
10
|
+
from sqlalchemy.orm import Session, aliased
|
|
11
|
+
|
|
12
|
+
ModelT = TypeVar("ModelT")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Query:
|
|
16
|
+
"""Build a select statement without mutating earlier query objects."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
statement: Any,
|
|
21
|
+
session: Session,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Initialize a query for a statement and its execution session."""
|
|
24
|
+
self.statement = statement
|
|
25
|
+
self.session = session
|
|
26
|
+
|
|
27
|
+
def _new(self, statement: Any) -> Self:
|
|
28
|
+
return self.__class__(statement, self.session)
|
|
29
|
+
|
|
30
|
+
def where(self, *criteria: Any) -> Self:
|
|
31
|
+
"""Return a query with SQL ``WHERE`` criteria applied."""
|
|
32
|
+
return self._new(self.statement.where(*criteria))
|
|
33
|
+
|
|
34
|
+
def filter_by(self, **kwargs: Any) -> Self:
|
|
35
|
+
"""Return a query filtered by mapped attribute names."""
|
|
36
|
+
return self._new(self.statement.filter_by(**kwargs))
|
|
37
|
+
|
|
38
|
+
def order_by(self, *clauses: Any) -> Self:
|
|
39
|
+
"""Return a query with SQL ``ORDER BY`` clauses applied."""
|
|
40
|
+
return self._new(self.statement.order_by(*clauses))
|
|
41
|
+
|
|
42
|
+
def group_by(self, *clauses: Any) -> Self:
|
|
43
|
+
"""Return a query with SQL ``GROUP BY`` clauses applied."""
|
|
44
|
+
return self._new(self.statement.group_by(*clauses))
|
|
45
|
+
|
|
46
|
+
def having(self, *criteria: Any) -> Self:
|
|
47
|
+
"""Return a query with SQL ``HAVING`` criteria applied."""
|
|
48
|
+
return self._new(self.statement.having(*criteria))
|
|
49
|
+
|
|
50
|
+
def join(self, target: Any, *props: Any, **kwargs: Any) -> Self:
|
|
51
|
+
"""Return a query with an inner join applied."""
|
|
52
|
+
return self._new(self.statement.join(target, *props, **kwargs))
|
|
53
|
+
|
|
54
|
+
def outerjoin(self, target: Any, *props: Any, **kwargs: Any) -> Self:
|
|
55
|
+
"""Return a query with a left outer join applied."""
|
|
56
|
+
return self._new(self.statement.outerjoin(target, *props, **kwargs))
|
|
57
|
+
|
|
58
|
+
def limit(self, limit: int | None) -> Self:
|
|
59
|
+
"""Return a query limited to at most ``limit`` rows."""
|
|
60
|
+
return self._new(self.statement.limit(limit))
|
|
61
|
+
|
|
62
|
+
def offset(self, offset: int | None) -> Self:
|
|
63
|
+
"""Return a query with a row offset applied."""
|
|
64
|
+
return self._new(self.statement.offset(offset))
|
|
65
|
+
|
|
66
|
+
def distinct(self, *expressions: Any) -> Self:
|
|
67
|
+
"""Return a query with SQL ``DISTINCT`` applied."""
|
|
68
|
+
return self._new(self.statement.distinct(*expressions))
|
|
69
|
+
|
|
70
|
+
def options(self, *options: Any) -> Self:
|
|
71
|
+
"""Return a query with SQLAlchemy ORM loader options applied."""
|
|
72
|
+
return self._new(self.statement.options(*options))
|
|
73
|
+
|
|
74
|
+
def union(self, *others: Query | Select[Any]) -> Self:
|
|
75
|
+
"""Return a query that unions this statement with other statements."""
|
|
76
|
+
statements = [
|
|
77
|
+
other.statement if isinstance(other, Query) else other for other in others
|
|
78
|
+
]
|
|
79
|
+
return self._new(union(self.statement, *statements))
|
|
80
|
+
|
|
81
|
+
def union_all(self, *others: Query | Select[Any]) -> Self:
|
|
82
|
+
"""Return a query that unions all rows from the supplied statements."""
|
|
83
|
+
statements = [
|
|
84
|
+
other.statement if isinstance(other, Query) else other for other in others
|
|
85
|
+
]
|
|
86
|
+
return self._new(union_all(self.statement, *statements))
|
|
87
|
+
|
|
88
|
+
def compile(self, *args: Any, **kwargs: Any) -> Any:
|
|
89
|
+
"""Compile the wrapped statement with SQLAlchemy."""
|
|
90
|
+
return self.statement.compile(*args, **kwargs)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ModelQuery(Query, Generic[ModelT]):
|
|
94
|
+
"""Execute a select statement and return mapped model instances."""
|
|
95
|
+
|
|
96
|
+
def _compound_query(
|
|
97
|
+
self,
|
|
98
|
+
operation: Any,
|
|
99
|
+
*others: Query | Select[Any],
|
|
100
|
+
) -> Self:
|
|
101
|
+
entity = self.statement.column_descriptions[0]["entity"]
|
|
102
|
+
statements = [
|
|
103
|
+
other.statement if isinstance(other, Query) else other for other in others
|
|
104
|
+
]
|
|
105
|
+
compound_subquery = operation(self.statement, *statements).subquery()
|
|
106
|
+
entity_alias = aliased(entity, compound_subquery)
|
|
107
|
+
return self.__class__(select(entity_alias), self.session)
|
|
108
|
+
|
|
109
|
+
def union(self, *others: Query | Select[Any]) -> Self:
|
|
110
|
+
"""Return an entity query containing distinct rows from all statements."""
|
|
111
|
+
return self._compound_query(union, *others)
|
|
112
|
+
|
|
113
|
+
def union_all(self, *others: Query | Select[Any]) -> Self:
|
|
114
|
+
"""Return an entity query containing every row from all statements."""
|
|
115
|
+
return self._compound_query(union_all, *others)
|
|
116
|
+
|
|
117
|
+
def execute(self) -> ScalarResult[ModelT]:
|
|
118
|
+
"""Execute the statement and return its scalar result."""
|
|
119
|
+
return self.session.scalars(self.statement)
|
|
120
|
+
|
|
121
|
+
def all(self) -> Sequence[ModelT]:
|
|
122
|
+
"""Return all matching model instances."""
|
|
123
|
+
return self.execute().all()
|
|
124
|
+
|
|
125
|
+
def first(self) -> ModelT | None:
|
|
126
|
+
"""Return the first matching model, or ``None``."""
|
|
127
|
+
return self.session.scalars(self.statement.limit(1)).first()
|
|
128
|
+
|
|
129
|
+
def one(self) -> ModelT:
|
|
130
|
+
"""Return exactly one model or raise a SQLAlchemy result error."""
|
|
131
|
+
return self.execute().one()
|
|
132
|
+
|
|
133
|
+
def one_or_none(self) -> ModelT | None:
|
|
134
|
+
"""Return zero or one model or raise when multiple rows match."""
|
|
135
|
+
return self.execute().one_or_none()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class RowQuery(Query):
|
|
139
|
+
"""Execute a select statement and return SQLAlchemy rows."""
|
|
140
|
+
|
|
141
|
+
def execute(self) -> Result[Any]:
|
|
142
|
+
"""Execute the statement and return its row result."""
|
|
143
|
+
return self.session.execute(self.statement)
|
|
144
|
+
|
|
145
|
+
def all(self) -> Sequence[Row[Any]]:
|
|
146
|
+
"""Return all matching rows."""
|
|
147
|
+
return self.execute().all()
|
|
148
|
+
|
|
149
|
+
def first(self) -> Row[Any] | None:
|
|
150
|
+
"""Return the first matching row, or ``None``."""
|
|
151
|
+
return self.session.execute(self.statement.limit(1)).first()
|
|
152
|
+
|
|
153
|
+
def one(self) -> Row[Any]:
|
|
154
|
+
"""Return exactly one row or raise a SQLAlchemy result error."""
|
|
155
|
+
return self.execute().one()
|
|
156
|
+
|
|
157
|
+
def one_or_none(self) -> Row[Any] | None:
|
|
158
|
+
"""Return zero or one row or raise when multiple rows match."""
|
|
159
|
+
return self.execute().one_or_none()
|
|
160
|
+
|
|
161
|
+
def mappings(self) -> MappingResult:
|
|
162
|
+
"""Execute the statement and return mapping-style rows."""
|
|
163
|
+
return self.execute().mappings()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _ModelQueryProperty:
|
|
167
|
+
"""Descriptor that creates a fresh model query on every access."""
|
|
168
|
+
|
|
169
|
+
def __get__(
|
|
170
|
+
self,
|
|
171
|
+
instance: object | None,
|
|
172
|
+
owner: type[ModelT],
|
|
173
|
+
) -> ModelQuery[ModelT]:
|
|
174
|
+
return ModelQuery(
|
|
175
|
+
select(cast(Any, owner)),
|
|
176
|
+
cast(Any, owner).session,
|
|
177
|
+
)
|
sqlarec/core/update.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Immutable wrappers around SQLAlchemy update statements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any, Generic, Self, TypeVar, cast, overload
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import Update as SQLUpdate
|
|
9
|
+
from sqlalchemy import inspect
|
|
10
|
+
from sqlalchemy.engine import CursorResult, MappingResult, Result, Row, ScalarResult
|
|
11
|
+
from sqlalchemy.orm import Mapper, Session
|
|
12
|
+
|
|
13
|
+
ModelT = TypeVar("ModelT")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UpdateBuilder:
|
|
17
|
+
"""Build an update statement without mutating earlier wrapper objects."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, statement: SQLUpdate, session: Session) -> None:
|
|
20
|
+
"""Initialize a wrapper for a statement and its execution session."""
|
|
21
|
+
self.statement = statement
|
|
22
|
+
self.session = session
|
|
23
|
+
|
|
24
|
+
def _new(self, statement: SQLUpdate) -> Self:
|
|
25
|
+
return self.__class__(statement, self.session)
|
|
26
|
+
|
|
27
|
+
def where(self, *criteria: Any) -> Self:
|
|
28
|
+
"""Return an update with SQL ``WHERE`` criteria applied."""
|
|
29
|
+
return self._new(self.statement.where(*criteria))
|
|
30
|
+
|
|
31
|
+
def values(self, *args: Any, **kwargs: Any) -> Self:
|
|
32
|
+
"""Return an update with new column values applied."""
|
|
33
|
+
return self._new(self.statement.values(*args, **kwargs))
|
|
34
|
+
|
|
35
|
+
def execution_options(self, **kwargs: Any) -> Self:
|
|
36
|
+
"""Return an update with SQLAlchemy execution options applied."""
|
|
37
|
+
return self._new(self.statement.execution_options(**kwargs))
|
|
38
|
+
|
|
39
|
+
def compile(self, *args: Any, **kwargs: Any) -> Any:
|
|
40
|
+
"""Compile the wrapped statement with SQLAlchemy."""
|
|
41
|
+
return self.statement.compile(*args, **kwargs)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Update(UpdateBuilder):
|
|
45
|
+
"""Execute an update or choose a typed wrapper for returned values."""
|
|
46
|
+
|
|
47
|
+
@overload
|
|
48
|
+
def returning(
|
|
49
|
+
self,
|
|
50
|
+
entity: type[ModelT],
|
|
51
|
+
/,
|
|
52
|
+
**kwargs: Any,
|
|
53
|
+
) -> ModelUpdate[ModelT]: ...
|
|
54
|
+
|
|
55
|
+
@overload
|
|
56
|
+
def returning(
|
|
57
|
+
self,
|
|
58
|
+
column: Any,
|
|
59
|
+
/,
|
|
60
|
+
*columns: Any,
|
|
61
|
+
**kwargs: Any,
|
|
62
|
+
) -> RowUpdate: ...
|
|
63
|
+
|
|
64
|
+
def returning(
|
|
65
|
+
self,
|
|
66
|
+
entity_or_column: Any,
|
|
67
|
+
/,
|
|
68
|
+
*columns: Any,
|
|
69
|
+
**kwargs: Any,
|
|
70
|
+
) -> ModelUpdate[Any] | RowUpdate:
|
|
71
|
+
"""Return an entity or row update based on the returning expression."""
|
|
72
|
+
expressions = (entity_or_column, *columns)
|
|
73
|
+
statement = self.statement.returning(*expressions, **kwargs)
|
|
74
|
+
if len(expressions) == 1 and isinstance(
|
|
75
|
+
inspect(entity_or_column, raiseerr=False),
|
|
76
|
+
Mapper,
|
|
77
|
+
):
|
|
78
|
+
return ModelUpdate(statement, self.session)
|
|
79
|
+
return RowUpdate(statement, self.session)
|
|
80
|
+
|
|
81
|
+
def execute(self) -> CursorResult[Any]:
|
|
82
|
+
"""Execute the update and return its cursor result."""
|
|
83
|
+
return cast(CursorResult[Any], self.session.execute(self.statement))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ModelUpdate(UpdateBuilder, Generic[ModelT]):
|
|
87
|
+
"""Execute an update that returns mapped model instances."""
|
|
88
|
+
|
|
89
|
+
def execute(self) -> ScalarResult[ModelT]:
|
|
90
|
+
"""Execute the update and return its scalar result."""
|
|
91
|
+
return self.session.scalars(self.statement)
|
|
92
|
+
|
|
93
|
+
def all(self) -> Sequence[ModelT]:
|
|
94
|
+
"""Return all updated model instances."""
|
|
95
|
+
return self.execute().all()
|
|
96
|
+
|
|
97
|
+
def first(self) -> ModelT | None:
|
|
98
|
+
"""Return the first updated model, or ``None``."""
|
|
99
|
+
return self.execute().first()
|
|
100
|
+
|
|
101
|
+
def one(self) -> ModelT:
|
|
102
|
+
"""Return exactly one updated model."""
|
|
103
|
+
return self.execute().one()
|
|
104
|
+
|
|
105
|
+
def one_or_none(self) -> ModelT | None:
|
|
106
|
+
"""Return zero or one updated model."""
|
|
107
|
+
return self.execute().one_or_none()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class RowUpdate(UpdateBuilder):
|
|
111
|
+
"""Execute an update that returns SQLAlchemy rows."""
|
|
112
|
+
|
|
113
|
+
def execute(self) -> Result[Any]:
|
|
114
|
+
"""Execute the update and return its row result."""
|
|
115
|
+
return self.session.execute(self.statement)
|
|
116
|
+
|
|
117
|
+
def all(self) -> Sequence[Row[Any]]:
|
|
118
|
+
"""Return all rows produced by the update."""
|
|
119
|
+
return self.execute().all()
|
|
120
|
+
|
|
121
|
+
def first(self) -> Row[Any] | None:
|
|
122
|
+
"""Return the first produced row, or ``None``."""
|
|
123
|
+
return self.execute().first()
|
|
124
|
+
|
|
125
|
+
def one(self) -> Row[Any]:
|
|
126
|
+
"""Return exactly one produced row."""
|
|
127
|
+
return self.execute().one()
|
|
128
|
+
|
|
129
|
+
def one_or_none(self) -> Row[Any] | None:
|
|
130
|
+
"""Return zero or one produced row."""
|
|
131
|
+
return self.execute().one_or_none()
|
|
132
|
+
|
|
133
|
+
def mappings(self) -> MappingResult:
|
|
134
|
+
"""Execute the update and return mapping-style rows."""
|
|
135
|
+
return self.execute().mappings()
|
sqlarec/database.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Engine and session helpers for synchronous SQLAlchemy applications."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import create_engine
|
|
6
|
+
from sqlalchemy.engine import Engine
|
|
7
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
8
|
+
|
|
9
|
+
_engine: Engine | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def init_engine(database_uri: str, **kwargs: Any) -> Engine:
|
|
13
|
+
"""Create and register the package's default SQLAlchemy engine.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
database_uri: SQLAlchemy database URL.
|
|
17
|
+
**kwargs: Additional keyword arguments passed to
|
|
18
|
+
:func:`sqlalchemy.create_engine`.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
The newly created synchronous engine.
|
|
22
|
+
|
|
23
|
+
Note:
|
|
24
|
+
Calling this function replaces the registered default engine. Existing
|
|
25
|
+
sessions continue to use the engine to which they were originally bound.
|
|
26
|
+
"""
|
|
27
|
+
global _engine
|
|
28
|
+
_engine = create_engine(database_uri, **kwargs)
|
|
29
|
+
return _engine
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_engine() -> Engine:
|
|
33
|
+
"""Return the registered default engine.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
RuntimeError: If :func:`init_engine` has not been called.
|
|
37
|
+
"""
|
|
38
|
+
if _engine is None:
|
|
39
|
+
raise RuntimeError("Database engine not initialized; call init_engine() first")
|
|
40
|
+
return _engine
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def new_session(engine: Engine | None = None) -> Session:
|
|
44
|
+
"""Create a synchronous session without opening a transaction eagerly.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
engine: Engine to bind. The registered default engine is used when this
|
|
48
|
+
argument is omitted.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A session configured with ``autoflush=False`` and
|
|
52
|
+
``expire_on_commit=False``.
|
|
53
|
+
"""
|
|
54
|
+
bind = get_engine() if engine is None else engine
|
|
55
|
+
factory = sessionmaker(
|
|
56
|
+
bind=bind,
|
|
57
|
+
autoflush=False,
|
|
58
|
+
expire_on_commit=False,
|
|
59
|
+
)
|
|
60
|
+
return factory()
|
sqlarec/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlarec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A context-aware Active Record API for synchronous SQLAlchemy 2.
|
|
5
|
+
Author: Hamza Senhaji Rhazi
|
|
6
|
+
Keywords: active-record,orm,sql,sqlalchemy
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: sqlalchemy<3,>=2.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# sqlarec
|
|
20
|
+
|
|
21
|
+
`sqlarec` adds a small, context-aware Active Record API on top of synchronous
|
|
22
|
+
SQLAlchemy 2. It keeps model operations concise without forcing your application
|
|
23
|
+
to pass a `Session` through every service and repository call.
|
|
24
|
+
|
|
25
|
+
It is designed for applications that want concise model operations:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
user = User.query.where(User.email == "hamza@example.com").one_or_none()
|
|
29
|
+
users = User.query.order_by(User.name).all()
|
|
30
|
+
user = User.create(name="Hamza", email="hamza@example.com")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Why sqlarec
|
|
34
|
+
|
|
35
|
+
Regular SQLAlchemy makes session ownership explicit, but passing the same session
|
|
36
|
+
through every layer can become repetitive:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
def find_user(session, email):
|
|
40
|
+
return session.scalars(select(User).where(User.email == email)).one_or_none()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`sqlarec` lets your application register a session-provider callback once. Models
|
|
44
|
+
resolve the current session only when they execute an operation:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
user = User.query.where(User.email == email).one_or_none()
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This separates two responsibilities:
|
|
51
|
+
|
|
52
|
+
- Your application creates the session and decides when to commit, roll back, and
|
|
53
|
+
close it.
|
|
54
|
+
- Your models use the current session without receiving it as an argument on
|
|
55
|
+
every call.
|
|
56
|
+
|
|
57
|
+
Register the provider once in your application setup, away from model and
|
|
58
|
+
business logic. A command runner, background-job worker, or web middleware can
|
|
59
|
+
then create the current session and manage its transaction lifecycle. Models use
|
|
60
|
+
that session through `User.query`, `User.create()`, or `User.session` without
|
|
61
|
+
requiring every function to accept and forward a session argument.
|
|
62
|
+
|
|
63
|
+
The following framework-neutral middleware sketch shows the principle:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from contextvars import ContextVar
|
|
67
|
+
|
|
68
|
+
from sqlalchemy.orm import Session
|
|
69
|
+
|
|
70
|
+
from sqlarec import BaseModel, new_session
|
|
71
|
+
|
|
72
|
+
current_session = ContextVar[Session]("current_session")
|
|
73
|
+
|
|
74
|
+
# Register this once during application startup.
|
|
75
|
+
BaseModel.register_session_provider(current_session.get)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def database_middleware(handler):
|
|
79
|
+
def wrapped(request):
|
|
80
|
+
session = new_session()
|
|
81
|
+
token = current_session.set(session)
|
|
82
|
+
try:
|
|
83
|
+
response = handler(request)
|
|
84
|
+
session.commit()
|
|
85
|
+
return response
|
|
86
|
+
except Exception:
|
|
87
|
+
session.rollback()
|
|
88
|
+
raise
|
|
89
|
+
finally:
|
|
90
|
+
current_session.reset(token)
|
|
91
|
+
session.close()
|
|
92
|
+
|
|
93
|
+
return wrapped
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Code executed inside that middleware can use a model from anywhere in the
|
|
97
|
+
application:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
@database_middleware
|
|
101
|
+
def get_user(request):
|
|
102
|
+
return User.query.where(User.email == request.email).one_or_none()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The handler does not receive a session. `User.query` resolves the session bound
|
|
106
|
+
by the middleware to the current execution context, so concurrent requests do
|
|
107
|
+
not share sessions.
|
|
108
|
+
|
|
109
|
+
As a result, business code remains focused on model operations while the
|
|
110
|
+
application retains an explicit and reliable transaction boundary. `sqlarec`
|
|
111
|
+
does not depend on a web framework and never commits inside model methods.
|
|
112
|
+
|
|
113
|
+
## Requirements
|
|
114
|
+
|
|
115
|
+
- Python 3.11 or later
|
|
116
|
+
- SQLAlchemy 2
|
|
117
|
+
- A synchronous SQLAlchemy `Session`
|
|
118
|
+
- [uv](https://docs.astral.sh/uv/) for development
|
|
119
|
+
|
|
120
|
+
## Install the package
|
|
121
|
+
|
|
122
|
+
Install the project and development tools:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
uv sync
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Install only runtime dependencies:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
uv sync --no-dev
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Create your first model
|
|
135
|
+
|
|
136
|
+
Models inherit from `BaseModel` and use standard SQLAlchemy mapped columns:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from sqlalchemy import Boolean, String
|
|
140
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
141
|
+
|
|
142
|
+
from sqlarec import BaseModel, init_engine, new_session
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class User(BaseModel):
|
|
146
|
+
__tablename__ = "users"
|
|
147
|
+
|
|
148
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
149
|
+
name: Mapped[str] = mapped_column(String(100))
|
|
150
|
+
email: Mapped[str] = mapped_column(String(255), unique=True)
|
|
151
|
+
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
engine = init_engine("sqlite:///:memory:")
|
|
155
|
+
BaseModel.metadata.create_all(engine)
|
|
156
|
+
|
|
157
|
+
session = new_session()
|
|
158
|
+
BaseModel.register_session_provider(lambda: session)
|
|
159
|
+
|
|
160
|
+
User.create(name="Hamza", email="hamza@example.com")
|
|
161
|
+
session.commit()
|
|
162
|
+
|
|
163
|
+
user = User.query.one()
|
|
164
|
+
print(user.email)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Expected output:
|
|
168
|
+
|
|
169
|
+
```text
|
|
170
|
+
hamza@example.com
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`BaseModel` inherits from SQLAlchemy's `DeclarativeBase`. Relationships,
|
|
174
|
+
constraints, indexes, and mapper configuration continue to use normal SQLAlchemy
|
|
175
|
+
APIs.
|
|
176
|
+
|
|
177
|
+
## Register the current session
|
|
178
|
+
|
|
179
|
+
Register a zero-argument callback that returns the session your application wants
|
|
180
|
+
models to use:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from sqlarec import BaseModel
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_session():
|
|
187
|
+
return session
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
BaseModel.register_session_provider(get_session)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Register the provider during application startup, not separately for every
|
|
194
|
+
concurrent request. The provider itself can retrieve a request-, job-, or
|
|
195
|
+
context-local session:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from contextvars import ContextVar
|
|
199
|
+
|
|
200
|
+
from sqlalchemy.orm import Session
|
|
201
|
+
|
|
202
|
+
from sqlarec import BaseModel
|
|
203
|
+
|
|
204
|
+
current_session: ContextVar[Session] = ContextVar("current_session")
|
|
205
|
+
|
|
206
|
+
BaseModel.register_session_provider(current_session.get)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Middleware can set and reset this context variable around each request. This
|
|
210
|
+
keeps concurrent request sessions isolated while allowing model calls to resolve
|
|
211
|
+
the correct session. Only synchronous sessions are supported.
|
|
212
|
+
|
|
213
|
+
## Query models and rows
|
|
214
|
+
|
|
215
|
+
`Model.query` and `Model.select()` return immutable `ModelQuery` wrappers. Their
|
|
216
|
+
result methods return mapped instances:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
users = User.query.all()
|
|
220
|
+
user = User.query.where(User.email == "hamza@example.com").one_or_none()
|
|
221
|
+
active = User.query.filter_by(active=True).order_by(User.name).limit(20).all()
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Passing columns to `Model.select()` returns a `RowQuery`:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
rows = User.select(User.id, User.email).order_by(User.id).all()
|
|
228
|
+
mappings = User.select(User.id, User.email).mappings().all()
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The result behavior remains explicit:
|
|
232
|
+
|
|
233
|
+
```text
|
|
234
|
+
User.query.all() -> Sequence[User]
|
|
235
|
+
User.select().all() -> Sequence[User]
|
|
236
|
+
User.select(User.id, User.email).all() -> Sequence[Row]
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Query builders include `where()`, `filter_by()`, `order_by()`, `group_by()`,
|
|
240
|
+
`having()`, `join()`, `outerjoin()`, `limit()`, `offset()`, `distinct()`,
|
|
241
|
+
`options()`, `union()`, and `union_all()`.
|
|
242
|
+
|
|
243
|
+
## Create, update, and delete models
|
|
244
|
+
|
|
245
|
+
Model writes flush the current session but never commit:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
user = User.create(name="Hamza", email="hamza@example.com")
|
|
249
|
+
|
|
250
|
+
user.name = "Hamza S."
|
|
251
|
+
user.save()
|
|
252
|
+
|
|
253
|
+
User.update().where(User.active.is_(False)).values(active=True).execute()
|
|
254
|
+
|
|
255
|
+
user.delete()
|
|
256
|
+
session.commit()
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Keeping the transaction boundary outside model methods lets an application commit
|
|
260
|
+
or roll back a complete unit of work atomically.
|
|
261
|
+
|
|
262
|
+
Single primary keys support direct lookup:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
user = User.get_by_pk(42)
|
|
266
|
+
exists = User.exists(42)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
String primary keys without a Python or database default receive a generated UUID
|
|
270
|
+
hex value. Composite primary-key lookup accepts a tuple in mapper-defined key
|
|
271
|
+
order.
|
|
272
|
+
|
|
273
|
+
## Use SQLAlchemy directly when needed
|
|
274
|
+
|
|
275
|
+
Every query and update wrapper exposes its underlying SQLAlchemy statement:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
query = User.query.where(User.active.is_(True))
|
|
279
|
+
statement = query.statement
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Use the registered session for operations the wrappers do not cover:
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
result = User.session.execute(custom_statement)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`sqlarec` is an ergonomic layer, not a replacement for SQLAlchemy.
|
|
289
|
+
|
|
290
|
+
## Develop the library
|
|
291
|
+
|
|
292
|
+
```text
|
|
293
|
+
sqlarec/
|
|
294
|
+
├── src/sqlarec/
|
|
295
|
+
│ ├── __init__.py
|
|
296
|
+
│ ├── database.py
|
|
297
|
+
│ ├── core/
|
|
298
|
+
│ │ ├── base_model.py
|
|
299
|
+
│ │ ├── query.py
|
|
300
|
+
│ │ └── update.py
|
|
301
|
+
│ └── utils/
|
|
302
|
+
│ └── identifiers.py
|
|
303
|
+
├── tests/
|
|
304
|
+
├── Makefile
|
|
305
|
+
├── pyproject.toml
|
|
306
|
+
└── uv.lock
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
| Command | Purpose |
|
|
310
|
+
| --------------------- | --------------------------------------------- |
|
|
311
|
+
| `make install` | Install runtime and development dependencies. |
|
|
312
|
+
| `make install-prod` | Install runtime dependencies only. |
|
|
313
|
+
| `make test` | Run pytest. |
|
|
314
|
+
| `make lint` | Check source and tests with Ruff. |
|
|
315
|
+
| `make typecheck` | Check package types with mypy. |
|
|
316
|
+
| `make format` | Format source and tests with Ruff. |
|
|
317
|
+
| `make clean` | Remove Python, pytest, and Ruff caches. |
|
|
318
|
+
|
|
319
|
+
## Current limitations
|
|
320
|
+
|
|
321
|
+
- You must register a session provider before model operations.
|
|
322
|
+
- Only synchronous SQLAlchemy sessions are supported.
|
|
323
|
+
- Query wrappers cover common operations; use the underlying statement for
|
|
324
|
+
advanced SQLAlchemy features.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
sqlarec/__init__.py,sha256=cLfw8P6tujMFM8n5YxFSTPJyIzl3d9SZb5-MBOSo1a0,545
|
|
2
|
+
sqlarec/database.py,sha256=fs-xXs8WRGGJ48gNnpzCHkZbOkPALE3ic8R5TxsGl3U,1727
|
|
3
|
+
sqlarec/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
4
|
+
sqlarec/core/__init__.py,sha256=sOVhOV2IBnItyziRM3Zex_OXFej0FhVfjVZehmMANbE,276
|
|
5
|
+
sqlarec/core/base_model.py,sha256=F7i1z1qqGTLAo4zaUKJ0Hac2EMWK0VfzNub8roIakQs,8710
|
|
6
|
+
sqlarec/core/query.py,sha256=-jdJAeiyb3_5otUpr0J1yMhw1lHURXUFY-_wMOOPlz4,6708
|
|
7
|
+
sqlarec/core/update.py,sha256=Q7DfdcDOuCVbStOSWCR8KD6WkSsfo7W_8gT1Z-W9P94,4481
|
|
8
|
+
sqlarec/utils/__init__.py,sha256=A52FzxKyUHOMDYL1lVAZk20LvHBjArBqvyoZ45qlMqE,131
|
|
9
|
+
sqlarec/utils/identifiers.py,sha256=bPH7JwPiIj-gZZE6Do_BZGuUXx_cobrMDAFXD74FUZs,199
|
|
10
|
+
sqlarec-0.1.0.dist-info/METADATA,sha256=eA_LZZ91UMAiUA7HS3sv_gcS-ovnSZZBku4lmoGE8yk,9296
|
|
11
|
+
sqlarec-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
sqlarec-0.1.0.dist-info/RECORD,,
|