Skip to content

Commit 3d1901f

Browse files
committed
Merged PR 6873571: Fix and use SQLModel AsyncSession
The whole point of SQLModel is that it returns typed objects that play nice with Pydantic and allow your editor's autocomplete functions to work properly. Except when you're using `sqlalchemy.AsyncSession`, it... doesn't. And there *is* a `sqlmodel.AsyncSession`, but it's broken. And there is a *fix* for it in an upstream PR, but it's not merged yet. fastapi/sqlmodel#58 This PR just copies and uses the upstream PR implementation of `sqlmodel.AsyncSession`. Most of the actual diff here is related to the side-benefit that we no longer have to unwrap the row-tuples that `sqlalchemy.execute` returns, but most of the actual benefit for doing this is that we'll now actually get our appropriately-typed model objects back out of `session.exec` instead of `Any`. Related work items: #15767106
1 parent 09bb29e commit 3d1901f

6 files changed

Lines changed: 134 additions & 35 deletions

File tree

server/app/api/auth.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22

33
import fastapi_microsoft_identity
44
from fastapi import Depends, HTTPException, Request, Response
5-
from sqlalchemy import select
6-
from sqlalchemy.ext.asyncio import AsyncSession
75
from sqlalchemy.orm.exc import NoResultFound
6+
from sqlmodel import select
87

9-
from app.core.db import get_session
8+
from app.core.db import AsyncSession, get_session
109
from app.core.models import Account, RepoAccess, Role
1110
from app.core.schemas import RepoId
1211

@@ -39,7 +38,8 @@ async def get_active_account(
3938

4039
statement = select(Account).where(Account.oid == oid)
4140
try:
42-
account = (await session.execute(statement)).one()[0]
41+
results = await session.exec(statement)
42+
account = results.one()
4343
except NoResultFound:
4444
raise HTTPException(
4545
status_code=403, detail=f"Domain UUID {id} is not provisioned in PMC. {SUPPORT}"
@@ -109,7 +109,7 @@ async def requires_repo_permission(
109109
statement = select(RepoAccess).where(
110110
RepoAccess.account_id == account.id, RepoAccess.repo_id == id
111111
)
112-
if (await session.execute(statement)).one_or_none():
112+
if (await session.exec(statement)).one_or_none():
113113
return
114114

115115
raise HTTPException(

server/app/api/routes/access.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
from typing import Any, List
33

44
from fastapi import APIRouter, Depends
5-
from sqlalchemy.ext.asyncio import AsyncSession
6-
from sqlalchemy.future import select
5+
from sqlmodel import select
76

8-
from app.core.db import get_session
7+
from app.core.db import AsyncSession, get_session
98
from app.core.models import Account, OwnedPackage, RepoAccess
109
from app.core.schemas import (
1110
AccountRepoPackagePermissionUpdate,
@@ -41,7 +40,8 @@ async def _get_named_accounts(session: AsyncSession, account_names: List[str]) -
4140
ret = []
4241
for name in account_names:
4342
statement = select(Account).where(Account.name == name)
44-
account = (await session.execute(statement)).one()[0]
43+
results = await session.exec(statement)
44+
account = results.one()
4545
ret.append(account)
4646
return ret
4747

@@ -52,8 +52,8 @@ async def list_repo_access(
5252
session: AsyncSession = Depends(get_session),
5353
) -> List[RepoAccessResponse]:
5454
statement = select(RepoAccess)
55-
results = (await session.execute(statement)).all()
56-
return [x[0] for x in results]
55+
results = await session.exec(statement)
56+
return list(results.all())
5757

5858

5959
@router.post("/access/repo/{id}/clone_from/{original_id}/", response_model=List[RepoAccessResponse])
@@ -64,15 +64,14 @@ async def clone_repo_access_from(
6464
) -> Any:
6565
"""Additively clone the repo permissions from another repo."""
6666
statement = select(RepoAccess).where(RepoAccess.repo_id == id)
67-
current_perms = (await session.execute(statement)).all()
68-
current_perms_accounts = [x[0].account_id for x in current_perms]
67+
current_perms = (await session.exec(statement)).all()
68+
current_perms_accounts = [x.account_id for x in current_perms]
6969

7070
statement = select(RepoAccess).where(RepoAccess.repo_id == original_id)
71-
original_perms = (await session.execute(statement)).all()
71+
original_perms = (await session.exec(statement)).all()
7272

7373
new_perms = []
7474
for perm in original_perms:
75-
perm = perm[0] # unwrap the row tuple
7675
if perm.account_id not in current_perms_accounts:
7776
new_perm = RepoAccess(account_id=perm.account_id, repo_id=id, operator=perm.operator)
7877
new_perms.append(new_perm)

server/app/api/routes/account.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@
33

44
from fastapi import APIRouter, Depends, HTTPException
55
from sqlalchemy import func
6-
from sqlalchemy.ext.asyncio import AsyncSession
76
from sqlalchemy.future import select
87
from sqlalchemy.sql.selectable import Select
98

10-
from app.core.db import get_session
9+
from app.core.db import AsyncSession, get_session
1110
from app.core.models import Account
1211
from app.core.schemas import (
1312
AccountCreate,
@@ -25,10 +24,10 @@ async def _get_list(
2524
) -> Tuple[List[Any], int]:
2625
"""Takes a query and returns a page of results and count of total results."""
2726
count_query = select(func.count()).select_from(query.subquery())
28-
count = (await session.execute(count_query)).scalar_one()
27+
count = (await session.exec(count_query)).scalar_one()
2928

3029
query = query.limit(limit).offset(offset)
31-
results = (await session.execute(query)).scalars().all()
30+
results = (await session.exec(query)).scalars().all()
3231

3332
return results, count
3433

server/app/api/routes/repository.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
from typing import Any, Optional
44

55
from fastapi import APIRouter, Depends, HTTPException
6-
from sqlalchemy import select
7-
from sqlalchemy.ext.asyncio import AsyncSession
6+
from sqlmodel import select
87

98
from app.api.auth import (
109
get_active_account,
@@ -13,7 +12,7 @@
1312
requires_repo_permission,
1413
)
1514
from app.core.config import settings
16-
from app.core.db import get_session
15+
from app.core.db import AsyncSession, get_session
1716
from app.core.models import Account, OwnedPackage, RepoAccess, Role
1817
from app.core.schemas import (
1918
PackageListResponse,
@@ -128,10 +127,7 @@ async def update_packages(
128127
statement = select(RepoAccess).where(
129128
RepoAccess.account_id == account.id, RepoAccess.repo_id == id
130129
)
131-
repo_perm = (await session.execute(statement)).one_or_none()
132-
# sqlalchemy returns things from `session.execute` wrapped in tuples, for legacy reasons
133-
if repo_perm:
134-
repo_perm = repo_perm[0]
130+
repo_perm = (await session.exec(statement)).one_or_none()
135131

136132
if account.role == Role.Publisher and not repo_perm:
137133
raise HTTPException(
@@ -142,10 +138,8 @@ async def update_packages(
142138
# Create a mapping of package names to accounts that are allowed to modify them in this repo.
143139
package_name_to_account_id = defaultdict(set)
144140
statement = select(OwnedPackage).where(OwnedPackage.repo_id == id)
145-
for owned_package_tuple in await session.execute(statement):
146-
# sqlalchemy returns things from `session.execute` wrapped in tuples, for legacy reasons
147-
op = owned_package_tuple[0]
148-
package_name_to_account_id[op.package_name].add(op.account_id)
141+
for owned_package in await session.exec(statement):
142+
package_name_to_account_id[owned_package.package_name].add(owned_package.account_id)
149143

150144
# Next enforce package adding permissions
151145
if add_names and account.role not in (Role.Repo_Admin, Role.Publisher):

server/app/core/db.py

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,118 @@
1-
from typing import AsyncGenerator
1+
from typing import Any, AsyncGenerator, Mapping, Optional, Sequence, TypeVar, Union, overload
22

3-
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
3+
from sqlalchemy import util
4+
from sqlalchemy.ext.asyncio import AsyncSession as _AsyncSession
5+
from sqlalchemy.ext.asyncio import create_async_engine
6+
from sqlalchemy.ext.asyncio import engine as _engine
7+
from sqlalchemy.ext.asyncio.engine import AsyncConnection, AsyncEngine
48
from sqlalchemy.orm import sessionmaker
5-
from sqlmodel import SQLModel
9+
from sqlalchemy.util.concurrency import greenlet_spawn
10+
from sqlmodel import Session, SQLModel
11+
from sqlmodel.engine.result import Result, ScalarResult
12+
from sqlmodel.sql.base import Executable
13+
from sqlmodel.sql.expression import Select, SelectOfScalar
614

715
from app.core.config import settings
816

17+
_TSelectParam = TypeVar("_TSelectParam")
18+
19+
20+
class AsyncSession(_AsyncSession):
21+
"""
22+
SQLModel provides a Session wrapper over the regular sqlalchemy session that:
23+
1) unwraps the legacy rows-are-returned-as-objects-wrapped-in-tuples behavior if you call "exec"
24+
2) passes through Pydantic type hints of the returned objects.
25+
26+
SQLModel has an equivalent wrapper for AsyncSession (sqlmodel.ext.asyncio.session.AsyncSession),
27+
but it's busted and throws type errors if you try to use it normally like you would Session.
28+
There's an upstream PR to fix it, but it's not merged yet. Until it's merged and fixed let's
29+
just copy-and-paste it in ourselves.
30+
https://github.com/tiangolo/sqlmodel/pull/58
31+
"""
32+
33+
sync_session: Session
34+
35+
def __init__(
36+
self,
37+
bind: Optional[Union[AsyncConnection, AsyncEngine]] = None,
38+
binds: Optional[Mapping[object, Union[AsyncConnection, AsyncEngine]]] = None,
39+
**kw: Any,
40+
):
41+
# All the same code of the original AsyncSession
42+
kw["future"] = True
43+
if bind:
44+
self.bind = bind
45+
bind = _engine._get_sync_engine_or_connection(bind) # type: ignore
46+
47+
if binds:
48+
self.binds = binds
49+
binds = {
50+
key: _engine._get_sync_engine_or_connection(b) # type: ignore
51+
for key, b in binds.items()
52+
}
53+
54+
self.sync_session = self._proxied = self._assign_proxied( # type: ignore
55+
Session(bind=bind, binds=binds, **kw) # type: ignore
56+
)
57+
58+
@overload
59+
async def exec(
60+
self,
61+
statement: Select[_TSelectParam],
62+
*,
63+
params: Optional[Union[Mapping[str, Any], Sequence[Mapping[str, Any]]]] = None,
64+
execution_options: Mapping[str, Any] = util.EMPTY_DICT,
65+
bind_arguments: Optional[Mapping[str, Any]] = None,
66+
_parent_execute_state: Optional[Any] = None,
67+
_add_event: Optional[Any] = None,
68+
**kw: Any,
69+
) -> Result[_TSelectParam]:
70+
...
71+
72+
@overload
73+
async def exec(
74+
self,
75+
statement: SelectOfScalar[_TSelectParam],
76+
*,
77+
params: Optional[Union[Mapping[str, Any], Sequence[Mapping[str, Any]]]] = None,
78+
execution_options: Mapping[str, Any] = util.EMPTY_DICT,
79+
bind_arguments: Optional[Mapping[str, Any]] = None,
80+
_parent_execute_state: Optional[Any] = None,
81+
_add_event: Optional[Any] = None,
82+
**kw: Any,
83+
) -> ScalarResult[_TSelectParam]:
84+
...
85+
86+
async def exec( # type: ignore
87+
self,
88+
statement: Union[
89+
Select[_TSelectParam],
90+
SelectOfScalar[_TSelectParam],
91+
Executable[_TSelectParam],
92+
],
93+
params: Optional[Union[Mapping[str, Any], Sequence[Mapping[str, Any]]]] = None,
94+
execution_options: Mapping[Any, Any] = util.EMPTY_DICT,
95+
bind_arguments: Optional[Mapping[str, Any]] = None,
96+
**kw: Any,
97+
) -> ScalarResult[_TSelectParam]:
98+
# TODO: the documentation says execution_options accepts a dict, but only
99+
# util.immutabledict has the union() method. Is this a bug in SQLAlchemy?
100+
execution_options = execution_options.union({"prebuffer_rows": True}) # type: ignore
101+
102+
return await greenlet_spawn(
103+
self.sync_session.exec,
104+
statement,
105+
params=params,
106+
execution_options=execution_options,
107+
bind_arguments=bind_arguments,
108+
**kw,
109+
)
110+
111+
async def __aenter__(self) -> "AsyncSession":
112+
# PyCharm does not understand TypeVar here :/
113+
return await super().__aenter__()
114+
115+
9116
engine = create_async_engine(settings.db_uri(), **settings.db_engine_args())
10117
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
11118

server/tests/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
import pytest_asyncio
88
from fastapi import FastAPI
99
from httpx import AsyncClient
10-
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
10+
from sqlalchemy.ext.asyncio import create_async_engine
1111
from sqlmodel import SQLModel
1212

1313
from app.api.auth import get_active_account
1414
from app.core.config import settings
15-
from app.core.db import async_session, get_session
15+
from app.core.db import AsyncSession, async_session, get_session
1616
from app.core.models import Account, Role
1717
from app.core.schemas import RepoType
1818
from app.main import app as fastapi_app

0 commit comments

Comments
 (0)