fastapi-restly 0.5.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.
- fastapi_restly/__init__.py +106 -0
- fastapi_restly/_exception_handlers.py +194 -0
- fastapi_restly/_pytest_fixtures.py +256 -0
- fastapi_restly/db/__init__.py +31 -0
- fastapi_restly/db/_globals.py +76 -0
- fastapi_restly/db/_proxy.py +42 -0
- fastapi_restly/db/_session.py +275 -0
- fastapi_restly/exceptions.py +12 -0
- fastapi_restly/models/__init__.py +18 -0
- fastapi_restly/models/_base.py +84 -0
- fastapi_restly/objects.py +144 -0
- fastapi_restly/py.typed +0 -0
- fastapi_restly/pytest_fixtures.py +24 -0
- fastapi_restly/query/__init__.py +13 -0
- fastapi_restly/query/_impl.py +594 -0
- fastapi_restly/query/_shared.py +22 -0
- fastapi_restly/schemas/__init__.py +29 -0
- fastapi_restly/schemas/_base.py +518 -0
- fastapi_restly/schemas/_generator.py +382 -0
- fastapi_restly/testing/__init__.py +20 -0
- fastapi_restly/testing/_client.py +98 -0
- fastapi_restly/testing/_fixtures.py +20 -0
- fastapi_restly/views/__init__.py +40 -0
- fastapi_restly/views/_async.py +216 -0
- fastapi_restly/views/_base.py +1294 -0
- fastapi_restly/views/_openapi.py +206 -0
- fastapi_restly/views/_react_admin.py +393 -0
- fastapi_restly/views/_sync.py +213 -0
- fastapi_restly-0.5.0.dist-info/METADATA +407 -0
- fastapi_restly-0.5.0.dist-info/RECORD +34 -0
- fastapi_restly-0.5.0.dist-info/WHEEL +5 -0
- fastapi_restly-0.5.0.dist-info/entry_points.txt +2 -0
- fastapi_restly-0.5.0.dist-info/licenses/LICENSE +21 -0
- fastapi_restly-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
2
|
+
from typing import AsyncIterator, Iterator
|
|
3
|
+
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession as SA_AsyncSession
|
|
5
|
+
from sqlalchemy.orm import Session as SA_Session
|
|
6
|
+
|
|
7
|
+
from ..exceptions import RestlyConfigurationError
|
|
8
|
+
from ._globals import _fr_globals
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@asynccontextmanager
|
|
12
|
+
async def open_async_session() -> AsyncIterator[SA_AsyncSession]:
|
|
13
|
+
"""Open an async database session for use outside of request context.
|
|
14
|
+
|
|
15
|
+
Example::
|
|
16
|
+
|
|
17
|
+
async with fr.open_async_session() as session:
|
|
18
|
+
result = await session.execute(select(User))
|
|
19
|
+
"""
|
|
20
|
+
if _fr_globals.async_make_session is None:
|
|
21
|
+
raise RestlyConfigurationError(
|
|
22
|
+
"Call fr.configure() before using open_async_session()."
|
|
23
|
+
)
|
|
24
|
+
async with _fr_globals.async_make_session() as sess:
|
|
25
|
+
yield sess
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def open_session() -> Iterator[SA_Session]:
|
|
30
|
+
"""Open a sync database session for use outside of request context.
|
|
31
|
+
|
|
32
|
+
Example::
|
|
33
|
+
|
|
34
|
+
with fr.open_session() as session:
|
|
35
|
+
result = session.execute(select(User))
|
|
36
|
+
"""
|
|
37
|
+
if _fr_globals.make_session is None:
|
|
38
|
+
raise RestlyConfigurationError(
|
|
39
|
+
"Call fr.configure() before using open_session()."
|
|
40
|
+
)
|
|
41
|
+
with _fr_globals.make_session() as sess:
|
|
42
|
+
yield sess
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator, Callable, Iterator
|
|
2
|
+
from inspect import signature
|
|
3
|
+
from typing import Annotated, Any, cast
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, FastAPI
|
|
6
|
+
from sqlalchemy import Engine, create_engine
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession as SA_AsyncSession
|
|
9
|
+
from sqlalchemy.orm import Session as SA_Session
|
|
10
|
+
from sqlalchemy.orm import sessionmaker
|
|
11
|
+
|
|
12
|
+
from .._exception_handlers import register_default_exception_handlers
|
|
13
|
+
from ..exceptions import RestlyConfigurationError
|
|
14
|
+
from ._globals import _fr_globals
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import orjson
|
|
18
|
+
except ImportError:
|
|
19
|
+
json_deserializer = None
|
|
20
|
+
json_serializer = None
|
|
21
|
+
else:
|
|
22
|
+
|
|
23
|
+
def orjson_serializer(obj):
|
|
24
|
+
return orjson.dumps(
|
|
25
|
+
obj, option=orjson.OPT_NAIVE_UTC | orjson.OPT_NON_STR_KEYS
|
|
26
|
+
).decode()
|
|
27
|
+
|
|
28
|
+
json_deserializer = orjson.loads
|
|
29
|
+
json_serializer = orjson_serializer
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _setup_async_database_connection(
|
|
33
|
+
async_database_url: str | None = None,
|
|
34
|
+
*,
|
|
35
|
+
async_engine: AsyncEngine | None = None,
|
|
36
|
+
async_make_session: async_sessionmaker[Any] | None = None,
|
|
37
|
+
) -> async_sessionmaker[Any]:
|
|
38
|
+
if not async_make_session:
|
|
39
|
+
if not async_engine:
|
|
40
|
+
async_engine = create_async_engine(
|
|
41
|
+
async_database_url, # type: ignore[arg-type]
|
|
42
|
+
json_serializer=json_serializer,
|
|
43
|
+
json_deserializer=json_deserializer,
|
|
44
|
+
)
|
|
45
|
+
async_make_session = async_sessionmaker(
|
|
46
|
+
bind=async_engine, autoflush=False, expire_on_commit=False
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_fr_globals.async_database_url = async_database_url
|
|
50
|
+
_fr_globals.async_make_session = async_make_session
|
|
51
|
+
return async_make_session
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _setup_database_connection(
|
|
55
|
+
database_url: str | None = None,
|
|
56
|
+
*,
|
|
57
|
+
engine: Engine | None = None,
|
|
58
|
+
make_session: sessionmaker[Any] | None = None,
|
|
59
|
+
) -> sessionmaker[Any]:
|
|
60
|
+
if make_session is None:
|
|
61
|
+
if engine is None:
|
|
62
|
+
engine = create_engine(
|
|
63
|
+
database_url, # type: ignore[arg-type]
|
|
64
|
+
json_serializer=json_serializer,
|
|
65
|
+
json_deserializer=json_deserializer,
|
|
66
|
+
)
|
|
67
|
+
make_session = sessionmaker(bind=engine, expire_on_commit=False)
|
|
68
|
+
|
|
69
|
+
_fr_globals.database_url = database_url
|
|
70
|
+
_fr_globals.make_session = make_session
|
|
71
|
+
return make_session
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def configure(
|
|
75
|
+
app: FastAPI | None = None,
|
|
76
|
+
*,
|
|
77
|
+
async_database_url: str | None = None,
|
|
78
|
+
async_engine: AsyncEngine | None = None,
|
|
79
|
+
async_make_session: async_sessionmaker[Any] | None = None,
|
|
80
|
+
database_url: str | None = None,
|
|
81
|
+
engine: Engine | None = None,
|
|
82
|
+
make_session: sessionmaker[Any] | None = None,
|
|
83
|
+
session_generator: Callable[[], AsyncIterator[SA_AsyncSession]] | None = None,
|
|
84
|
+
sync_session_generator: Callable[[], Iterator[SA_Session]] | None = None,
|
|
85
|
+
commit_session_on_response: bool | None = None,
|
|
86
|
+
install_default_exception_handlers: bool = True,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Configure FastAPI-Restly. Call once at startup.
|
|
89
|
+
|
|
90
|
+
Pass async parameters (``async_database_url``, ``async_engine``, or
|
|
91
|
+
``async_make_session``) to enable async support, sync parameters
|
|
92
|
+
(``database_url``, ``engine``, or ``make_session``) for sync support,
|
|
93
|
+
or both if your application uses both.
|
|
94
|
+
|
|
95
|
+
Use ``session_generator`` / ``sync_session_generator`` to plug in a
|
|
96
|
+
custom session factory instead of the built-in one.
|
|
97
|
+
|
|
98
|
+
By default, Restly commits built-in request sessions when an endpoint
|
|
99
|
+
successfully produces a response. Set ``commit_session_on_response=False``
|
|
100
|
+
to own commit/rollback calls yourself. Leave it unset to keep the current
|
|
101
|
+
default. Custom session generators always own their transaction lifecycle.
|
|
102
|
+
|
|
103
|
+
Pass your :class:`FastAPI` ``app`` to install fastapi-restly's default
|
|
104
|
+
exception handlers (currently: a translator that turns SQLAlchemy
|
|
105
|
+
:class:`~sqlalchemy.exc.IntegrityError` into HTTP 409 Conflict). Set
|
|
106
|
+
``install_default_exception_handlers=False`` to opt out. If you do not
|
|
107
|
+
pass ``app`` here, the handlers are registered the first time a view is
|
|
108
|
+
mounted via :func:`fastapi_restly.include_view` instead.
|
|
109
|
+
"""
|
|
110
|
+
if not any(
|
|
111
|
+
(
|
|
112
|
+
async_database_url is not None,
|
|
113
|
+
async_engine is not None,
|
|
114
|
+
async_make_session is not None,
|
|
115
|
+
database_url is not None,
|
|
116
|
+
engine is not None,
|
|
117
|
+
make_session is not None,
|
|
118
|
+
session_generator is not None,
|
|
119
|
+
sync_session_generator is not None,
|
|
120
|
+
commit_session_on_response is not None,
|
|
121
|
+
app is not None and install_default_exception_handlers,
|
|
122
|
+
)
|
|
123
|
+
):
|
|
124
|
+
raise TypeError("fr.configure() requires at least one setup argument.")
|
|
125
|
+
|
|
126
|
+
if commit_session_on_response is not None:
|
|
127
|
+
_fr_globals.commit_session_on_response = commit_session_on_response
|
|
128
|
+
if (
|
|
129
|
+
async_database_url is not None
|
|
130
|
+
or async_engine is not None
|
|
131
|
+
or async_make_session is not None
|
|
132
|
+
):
|
|
133
|
+
_setup_async_database_connection(
|
|
134
|
+
async_database_url=async_database_url,
|
|
135
|
+
async_engine=async_engine,
|
|
136
|
+
async_make_session=async_make_session,
|
|
137
|
+
)
|
|
138
|
+
if database_url is not None or engine is not None or make_session is not None:
|
|
139
|
+
_setup_database_connection(
|
|
140
|
+
database_url=database_url, engine=engine, make_session=make_session
|
|
141
|
+
)
|
|
142
|
+
if session_generator is not None:
|
|
143
|
+
_fr_globals.session_generator = session_generator
|
|
144
|
+
if sync_session_generator is not None:
|
|
145
|
+
_fr_globals.sync_session_generator = sync_session_generator
|
|
146
|
+
if app is not None and install_default_exception_handlers:
|
|
147
|
+
register_default_exception_handlers(app)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def activate_savepoint_only_mode(
|
|
151
|
+
make_session: async_sessionmaker[Any] | sessionmaker[Any],
|
|
152
|
+
) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Intended for use in tests. Puts the session factory into savepoint-only mode so
|
|
155
|
+
that no test data is ever committed to the database. Each test can roll back
|
|
156
|
+
instantly by closing the session, leaving the database clean for the next test.
|
|
157
|
+
|
|
158
|
+
This is done with "create_savepoint" mode and a wrapper on engine.connect() that
|
|
159
|
+
begins the outer transaction before the Session can use it.
|
|
160
|
+
https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#session-external-transaction
|
|
161
|
+
"""
|
|
162
|
+
engine = _get_sync_engine(make_session)
|
|
163
|
+
|
|
164
|
+
# Check if already activated (look for the marker attribute we set)
|
|
165
|
+
if hasattr(engine.connect, "_original_connect"):
|
|
166
|
+
return # Already activated, skip
|
|
167
|
+
|
|
168
|
+
original_connect = engine.connect
|
|
169
|
+
|
|
170
|
+
def _begin_on_connect():
|
|
171
|
+
connection = original_connect()
|
|
172
|
+
connection.begin()
|
|
173
|
+
return connection
|
|
174
|
+
|
|
175
|
+
# Using setattr to silence pyright
|
|
176
|
+
setattr(_begin_on_connect, "_original_connect", original_connect)
|
|
177
|
+
|
|
178
|
+
engine.connect = _begin_on_connect
|
|
179
|
+
make_session.configure(join_transaction_mode="create_savepoint")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def deactivate_savepoint_only_mode(
|
|
183
|
+
make_session: async_sessionmaker[Any] | sessionmaker[Any],
|
|
184
|
+
) -> None:
|
|
185
|
+
"""
|
|
186
|
+
Reverts the effect of `activate_savepoint_only_mode`.
|
|
187
|
+
Restores the original engine.connect and disables savepoint-only mode.
|
|
188
|
+
"""
|
|
189
|
+
engine = _get_sync_engine(make_session)
|
|
190
|
+
_begin_on_connect = cast(Any, engine.connect)
|
|
191
|
+
if hasattr(_begin_on_connect, "_original_connect"):
|
|
192
|
+
# Restore the original connect that was saved by activate_savepoint_only_mode
|
|
193
|
+
engine.connect = _begin_on_connect._original_connect
|
|
194
|
+
# If engine was never activated, there is nothing to restore; this is safe to call
|
|
195
|
+
|
|
196
|
+
make_session.configure(join_transaction_mode=None)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_async_engine() -> AsyncEngine:
|
|
200
|
+
"""Return the async engine registered via configure()."""
|
|
201
|
+
if _fr_globals.async_make_session is None:
|
|
202
|
+
raise RestlyConfigurationError(
|
|
203
|
+
"Call fr.configure() before using get_async_engine()."
|
|
204
|
+
)
|
|
205
|
+
return _fr_globals.async_make_session.kw["bind"]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_engine() -> Engine:
|
|
209
|
+
"""Return the sync engine registered via configure()."""
|
|
210
|
+
if _fr_globals.make_session is None:
|
|
211
|
+
raise RestlyConfigurationError(
|
|
212
|
+
"Call fr.configure() before using get_engine()."
|
|
213
|
+
)
|
|
214
|
+
return _fr_globals.make_session.kw["bind"]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _get_sync_engine(make_session: async_sessionmaker[Any] | sessionmaker[Any]) -> Engine:
|
|
218
|
+
engine = make_session.kw["bind"]
|
|
219
|
+
if isinstance(engine, AsyncEngine):
|
|
220
|
+
return engine.sync_engine
|
|
221
|
+
return engine
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def _async_generate_session() -> AsyncIterator[SA_AsyncSession]:
|
|
225
|
+
"""FastAPI dependency for async database session."""
|
|
226
|
+
if _fr_globals.session_generator is not None:
|
|
227
|
+
async for session in _fr_globals.session_generator():
|
|
228
|
+
yield session
|
|
229
|
+
return
|
|
230
|
+
if _fr_globals.async_make_session is None:
|
|
231
|
+
raise RestlyConfigurationError(
|
|
232
|
+
"Call fr.configure() before using AsyncSessionDep."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# FastAPI does not support contextmanagers as dependency directly,
|
|
236
|
+
# but it does support generators.
|
|
237
|
+
async with _fr_globals.async_make_session() as session:
|
|
238
|
+
yield session
|
|
239
|
+
if _fr_globals.commit_session_on_response and session.is_active:
|
|
240
|
+
try:
|
|
241
|
+
await session.commit()
|
|
242
|
+
except Exception:
|
|
243
|
+
await session.rollback()
|
|
244
|
+
raise
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _session_dependency(dependency: Callable[..., Any]) -> Any:
|
|
248
|
+
depends = cast(Callable[..., Any], Depends)
|
|
249
|
+
if "scope" in signature(Depends).parameters:
|
|
250
|
+
return depends(dependency, scope="function")
|
|
251
|
+
return depends(dependency)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
AsyncSessionDep = Annotated[SA_AsyncSession, _session_dependency(_async_generate_session)]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _generate_session() -> Iterator[SA_Session]:
|
|
258
|
+
"""FastAPI dependency for sync database session."""
|
|
259
|
+
if _fr_globals.sync_session_generator is not None:
|
|
260
|
+
yield from _fr_globals.sync_session_generator()
|
|
261
|
+
return
|
|
262
|
+
if _fr_globals.make_session is None:
|
|
263
|
+
raise RestlyConfigurationError("Call fr.configure() before using SessionDep.")
|
|
264
|
+
|
|
265
|
+
with _fr_globals.make_session() as session:
|
|
266
|
+
yield session
|
|
267
|
+
if _fr_globals.commit_session_on_response and session.is_active:
|
|
268
|
+
try:
|
|
269
|
+
session.commit()
|
|
270
|
+
except Exception:
|
|
271
|
+
session.rollback()
|
|
272
|
+
raise
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
SessionDep = Annotated[SA_Session, _session_dependency(_generate_session)]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Public exception hierarchy for FastAPI-Restly."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RestlyError(Exception):
|
|
5
|
+
"""Base class for FastAPI-Restly framework errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RestlyConfigurationError(RestlyError):
|
|
9
|
+
"""Raised when Restly is used before required configuration is available."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = ["RestlyConfigurationError", "RestlyError"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from ._base import (
|
|
2
|
+
CASCADE_ALL_ASYNC,
|
|
3
|
+
CASCADE_ALL_DELETE_ORPHAN_ASYNC,
|
|
4
|
+
DataclassBase,
|
|
5
|
+
IDBase,
|
|
6
|
+
IDMixin,
|
|
7
|
+
TimestampsMixin,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
# Public API for ``fastapi_restly.models``.
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CASCADE_ALL_ASYNC",
|
|
13
|
+
"CASCADE_ALL_DELETE_ORPHAN_ASYNC",
|
|
14
|
+
"DataclassBase",
|
|
15
|
+
"IDBase",
|
|
16
|
+
"IDMixin",
|
|
17
|
+
"TimestampsMixin",
|
|
18
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Enum, func
|
|
7
|
+
from sqlalchemy.orm import (
|
|
8
|
+
DeclarativeBase,
|
|
9
|
+
Mapped,
|
|
10
|
+
MappedAsDataclass,
|
|
11
|
+
declared_attr,
|
|
12
|
+
mapped_column,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Provide an alternative settings for relationship cascade "all" and
|
|
16
|
+
# "all, delete-orphan". The "refresh-expire" cascade will cause
|
|
17
|
+
# issues in an async context. See also:
|
|
18
|
+
# https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#preventing-implicit-io-when-using-asyncsession
|
|
19
|
+
# `CASCADE_ALL_ASYNC` should be used instead.
|
|
20
|
+
CASCADE_ALL_ASYNC = "save-update, merge, delete, expunge"
|
|
21
|
+
CASCADE_ALL_DELETE_ORPHAN_ASYNC = CASCADE_ALL_ASYNC + ", delete-orphan"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def utc_now() -> datetime:
|
|
25
|
+
"""Replacement for the deprecated datetime.utcnow()"""
|
|
26
|
+
return datetime.now(timezone.utc)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TimestampsMixin(MappedAsDataclass, kw_only=True):
|
|
30
|
+
"""
|
|
31
|
+
Dataclass mixin adding UTC-aware created_at and updated_at timestamps.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
35
|
+
default_factory=utc_now, server_default=func.now()
|
|
36
|
+
)
|
|
37
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
38
|
+
default_factory=utc_now, onupdate=utc_now, server_default=func.now()
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class IDMixin(MappedAsDataclass, kw_only=True):
|
|
43
|
+
"""Dataclass mixin adding an auto-incrementing integer `id` primary key."""
|
|
44
|
+
|
|
45
|
+
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TableNameMixin:
|
|
49
|
+
"""Mixin that auto-generates snake_case table names from class names."""
|
|
50
|
+
|
|
51
|
+
@declared_attr
|
|
52
|
+
@classmethod
|
|
53
|
+
def __tablename__(cls) -> Any:
|
|
54
|
+
return underscore(cls.__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def underscore(name: str) -> str:
|
|
58
|
+
"""Convert CamelCase class name to snake_case table name.
|
|
59
|
+
|
|
60
|
+
Handles acronyms correctly: HTTPServer -> http_server, XMLParser -> xml_parser.
|
|
61
|
+
"""
|
|
62
|
+
# Insert underscore before an uppercase letter that follows a lowercase letter
|
|
63
|
+
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
|
64
|
+
# Insert underscore before an uppercase letter that is followed by a lowercase letter
|
|
65
|
+
# (handles the end of an acronym: "HTTPServer" -> "HTTP_Server")
|
|
66
|
+
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
|
|
67
|
+
return s2.lower()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DataclassBase(TableNameMixin, MappedAsDataclass, DeclarativeBase, kw_only=True):
|
|
71
|
+
"""SQLAlchemy declarative base with dataclass semantics."""
|
|
72
|
+
|
|
73
|
+
type_annotation_map = {
|
|
74
|
+
# native_enum=False so enums are persisted as strings in the
|
|
75
|
+
# database, not as Postgres TYPE objects. This prevents
|
|
76
|
+
# requiring database migrations for every enum change.
|
|
77
|
+
enum.Enum: Enum(enum.Enum, native_enum=False, length=64)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class IDBase(IDMixin, DataclassBase):
|
|
82
|
+
"""Convenience base: DataclassBase + integer `id` primary key."""
|
|
83
|
+
|
|
84
|
+
__abstract__ = True
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from typing import TypeVar as _TypeVar
|
|
2
|
+
|
|
3
|
+
import pydantic as _pydantic
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession as _AsyncSession
|
|
5
|
+
from sqlalchemy.orm import DeclarativeBase as _DeclarativeBase
|
|
6
|
+
from sqlalchemy.orm import Session as _Session
|
|
7
|
+
|
|
8
|
+
from .schemas._base import (
|
|
9
|
+
_async_resolve_ids_to_sqlalchemy_objects,
|
|
10
|
+
_resolve_ids_to_sqlalchemy_objects,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
_T = _TypeVar("_T", bound=_DeclarativeBase)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_from_schema(
|
|
17
|
+
session: _Session,
|
|
18
|
+
model_cls: type[_T],
|
|
19
|
+
schema_obj: _pydantic.BaseModel,
|
|
20
|
+
schema_cls: type[_pydantic.BaseModel] | None = None,
|
|
21
|
+
) -> _T:
|
|
22
|
+
"""Build ``model_cls`` from ``schema_obj`` and add it to ``session``.
|
|
23
|
+
|
|
24
|
+
This is the schema-to-ORM mapping primitive. It resolves Restly reference
|
|
25
|
+
fields, skips read-only inputs, applies schema defaults, and stages the
|
|
26
|
+
object in the session. It does not flush and does not run view-level
|
|
27
|
+
``perform_create`` business logic.
|
|
28
|
+
"""
|
|
29
|
+
from .views._base import (
|
|
30
|
+
apply_create_assignments,
|
|
31
|
+
build_create_plan,
|
|
32
|
+
validate_resolved_reference_consistency,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_resolve_ids_to_sqlalchemy_objects(session, schema_obj)
|
|
36
|
+
validate_resolved_reference_consistency(model_cls, schema_obj, schema_cls)
|
|
37
|
+
create_plan = build_create_plan(model_cls, schema_obj, schema_cls)
|
|
38
|
+
obj = model_cls(**create_plan.kwargs)
|
|
39
|
+
apply_create_assignments(obj, create_plan.post_assignments)
|
|
40
|
+
session.add(obj)
|
|
41
|
+
return obj
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def apply_schema(
|
|
45
|
+
session: _Session,
|
|
46
|
+
obj: _T,
|
|
47
|
+
schema_obj: _pydantic.BaseModel,
|
|
48
|
+
schema_cls: type[_pydantic.BaseModel] | None = None,
|
|
49
|
+
) -> _T:
|
|
50
|
+
"""Apply writable fields from ``schema_obj`` to ``obj``.
|
|
51
|
+
|
|
52
|
+
This is the schema-to-ORM update primitive. It resolves Restly reference
|
|
53
|
+
fields and applies only writable inputs. It does not flush and does not run
|
|
54
|
+
view-level ``perform_update`` business logic.
|
|
55
|
+
"""
|
|
56
|
+
from .views._base import (
|
|
57
|
+
apply_update_to_object,
|
|
58
|
+
validate_resolved_reference_consistency,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
_resolve_ids_to_sqlalchemy_objects(session, schema_obj)
|
|
62
|
+
validate_resolved_reference_consistency(type(obj), schema_obj, schema_cls)
|
|
63
|
+
apply_update_to_object(obj, schema_obj, schema_cls)
|
|
64
|
+
return obj
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def save_object(session: _Session, obj: _T) -> _T:
|
|
68
|
+
"""Flush the session and refresh ``obj`` from the database."""
|
|
69
|
+
session.flush()
|
|
70
|
+
session.refresh(obj)
|
|
71
|
+
return obj
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def delete_object(session: _Session, obj: _DeclarativeBase) -> None:
|
|
75
|
+
"""Delete ``obj`` and flush the session."""
|
|
76
|
+
session.delete(obj)
|
|
77
|
+
session.flush()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def async_build_from_schema(
|
|
81
|
+
session: _AsyncSession,
|
|
82
|
+
model_cls: type[_T],
|
|
83
|
+
schema_obj: _pydantic.BaseModel,
|
|
84
|
+
schema_cls: type[_pydantic.BaseModel] | None = None,
|
|
85
|
+
) -> _T:
|
|
86
|
+
"""Async equivalent of :func:`build_from_schema`."""
|
|
87
|
+
from .views._base import (
|
|
88
|
+
apply_create_assignments,
|
|
89
|
+
build_create_plan,
|
|
90
|
+
validate_resolved_reference_consistency,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
await _async_resolve_ids_to_sqlalchemy_objects(session, schema_obj)
|
|
94
|
+
validate_resolved_reference_consistency(model_cls, schema_obj, schema_cls)
|
|
95
|
+
create_plan = build_create_plan(model_cls, schema_obj, schema_cls)
|
|
96
|
+
obj = model_cls(**create_plan.kwargs)
|
|
97
|
+
apply_create_assignments(obj, create_plan.post_assignments)
|
|
98
|
+
session.add(obj)
|
|
99
|
+
return obj
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def async_apply_schema(
|
|
103
|
+
session: _AsyncSession,
|
|
104
|
+
obj: _T,
|
|
105
|
+
schema_obj: _pydantic.BaseModel,
|
|
106
|
+
schema_cls: type[_pydantic.BaseModel] | None = None,
|
|
107
|
+
) -> _T:
|
|
108
|
+
"""Async equivalent of :func:`apply_schema`."""
|
|
109
|
+
from .views._base import (
|
|
110
|
+
apply_update_to_object,
|
|
111
|
+
validate_resolved_reference_consistency,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
await _async_resolve_ids_to_sqlalchemy_objects(session, schema_obj)
|
|
115
|
+
validate_resolved_reference_consistency(type(obj), schema_obj, schema_cls)
|
|
116
|
+
apply_update_to_object(obj, schema_obj, schema_cls)
|
|
117
|
+
return obj
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def async_save_object(session: _AsyncSession, obj: _T) -> _T:
|
|
121
|
+
"""Async equivalent of :func:`save_object`."""
|
|
122
|
+
await session.flush()
|
|
123
|
+
await session.refresh(obj)
|
|
124
|
+
return obj
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def async_delete_object(
|
|
128
|
+
session: _AsyncSession, obj: _DeclarativeBase
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Async equivalent of :func:`delete_object`."""
|
|
131
|
+
await session.delete(obj)
|
|
132
|
+
await session.flush()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
__all__ = [
|
|
136
|
+
"apply_schema",
|
|
137
|
+
"async_apply_schema",
|
|
138
|
+
"async_build_from_schema",
|
|
139
|
+
"async_delete_object",
|
|
140
|
+
"async_save_object",
|
|
141
|
+
"build_from_schema",
|
|
142
|
+
"delete_object",
|
|
143
|
+
"save_object",
|
|
144
|
+
]
|
fastapi_restly/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from fastapi_restly._pytest_fixtures import (
|
|
3
|
+
_shared_connection,
|
|
4
|
+
restly_app,
|
|
5
|
+
restly_async_session,
|
|
6
|
+
restly_client,
|
|
7
|
+
restly_project_root,
|
|
8
|
+
restly_session,
|
|
9
|
+
)
|
|
10
|
+
except ModuleNotFoundError as exc:
|
|
11
|
+
if exc.name in {"httpx", "pytest"}:
|
|
12
|
+
raise ModuleNotFoundError(
|
|
13
|
+
"fastapi_restly.pytest_fixtures requires optional testing dependencies. "
|
|
14
|
+
'Install them with: pip install "fastapi-restly[testing]"'
|
|
15
|
+
) from exc
|
|
16
|
+
raise
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"restly_app",
|
|
20
|
+
"restly_async_session",
|
|
21
|
+
"restly_client",
|
|
22
|
+
"restly_project_root",
|
|
23
|
+
"restly_session",
|
|
24
|
+
]
|