Skip to content

Commit f18ee64

Browse files
Use str IDs for API (#775)
1 parent 87aadc4 commit f18ee64

File tree

9 files changed

+284
-200
lines changed

9 files changed

+284
-200
lines changed

.mypy.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ warn_return_any = True
2323
warn_unreachable = True
2424
warn_unused_ignores = True
2525

26+
[mypy-aiohttp_admin.backends.sqlalchemy]
27+
# We use Any for several parameters, causing a few of these errors.
28+
disallow_any_decorated = False
29+
2630
[mypy-tests.*]
2731
disallow_any_decorated = False
2832
disallow_untyped_calls = False

admin-js/src/App.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ function dataRequest(resource, endpoint, params) {
105105
for (const [k, v] of Object.entries(params)) {
106106
if (v === undefined)
107107
delete params[k];
108-
if (typeof v === "object" && v !== null)
108+
else if (typeof v === "object" && v !== null)
109109
params[k] = JSON.stringify(v);
110110
}
111111
const query = new URLSearchParams(params).toString();

aiohttp_admin/backends/abc.py

Lines changed: 93 additions & 64 deletions
Large diffs are not rendered by default.

aiohttp_admin/backends/sqlalchemy.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@
1313
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
1414
from sqlalchemy.sql.roles import ExpressionElementRole
1515

16-
from .abc import (
17-
AbstractAdminResource, CreateParams, DeleteManyParams, DeleteParams, GetListParams,
18-
GetManyParams, GetOneParams, Record, UpdateManyParams, UpdateParams)
16+
from .abc import AbstractAdminResource, GetListParams, Meta, Record
1917
from ..types import FunctionState, comp, func, regex
2018

2119
if sys.version_info >= (3, 10):
@@ -155,7 +153,8 @@ def create_filters(columns: sa.ColumnCollection[str, sa.Column[object]],
155153
for k, v in filters.items())
156154

157155

158-
class SAResource(AbstractAdminResource):
156+
# ID is based on PK, which we can't infer from types, so must use Any here.
157+
class SAResource(AbstractAdminResource[Any]):
159158
def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, type[DeclarativeBase]]):
160159
if isinstance(model_or_table, sa.Table):
161160
table = model_or_table
@@ -168,12 +167,14 @@ def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, type[Declara
168167
self.fields = {}
169168
self.inputs = {}
170169
self.omit_fields = set()
170+
self._foreign_rows = set()
171171
record_type = {}
172172
for c in table.c.values():
173173
if c.foreign_keys:
174174
field = "ReferenceField"
175175
inp = "ReferenceInput"
176176
key = next(iter(c.foreign_keys)) # TODO: Test composite foreign keys.
177+
self._foreign_rows.add(c.name)
177178
props: dict[str, Any] = {"reference": key.column.table.name,
178179
"target": key.column.name}
179180
else:
@@ -261,6 +262,7 @@ def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, type[Declara
261262
# TODO: Test composite primary key
262263
raise NotImplementedError("Composite keys not supported yet.")
263264
self.primary_key = pk[0]
265+
self._id_type = table.c[pk[0]].type.python_type
264266

265267
super().__init__(record_type)
266268

@@ -286,56 +288,57 @@ async def get_list(self, params: GetListParams) -> tuple[list[Record], int]:
286288
return entities, count
287289

288290
@handle_errors
289-
async def get_one(self, params: GetOneParams) -> Record:
291+
async def get_one(self, record_id: Any, meta: Meta) -> Record:
290292
async with self._db.connect() as conn:
291-
stmt = sa.select(self._table).where(self._table.c[self.primary_key] == params["id"])
293+
stmt = sa.select(self._table).where(self._table.c[self.primary_key] == record_id)
292294
result = await conn.execute(stmt)
293295
return result.one()._asdict()
294296

295297
@handle_errors
296-
async def get_many(self, params: GetManyParams) -> list[Record]:
298+
async def get_many(self, record_ids: Sequence[Any], meta: Meta) -> list[Record]:
297299
async with self._db.connect() as conn:
298-
stmt = sa.select(self._table).where(self._table.c[self.primary_key].in_(params["ids"]))
300+
stmt = sa.select(self._table).where(self._table.c[self.primary_key].in_(record_ids))
299301
result = await conn.execute(stmt)
300302
return [r._asdict() for r in result]
301303

302304
@handle_errors
303-
async def create(self, params: CreateParams) -> Record:
305+
async def create(self, data: Record, meta: Meta) -> Record:
304306
async with self._db.begin() as conn:
305-
stmt = sa.insert(self._table).values(params["data"]).returning(*self._table.c)
307+
stmt = sa.insert(self._table).values(data).returning(*self._table.c)
306308
try:
307309
row = await conn.execute(stmt)
308310
except sa.exc.IntegrityError:
309-
logger.warning("IntegrityError (%s)", params["data"], exc_info=True)
311+
logger.warning("IntegrityError (%s)", data, exc_info=True)
310312
raise web.HTTPBadRequest(reason="Integrity error (element already exists?)")
311313
return row.one()._asdict()
312314

313315
@handle_errors
314-
async def update(self, params: UpdateParams) -> Record:
316+
async def update(self, record_id: Any, data: Record, previous_data: Record,
317+
meta: Meta) -> Record:
315318
async with self._db.begin() as conn:
316-
stmt = sa.update(self._table).where(self._table.c[self.primary_key] == params["id"])
317-
stmt = stmt.values(params["data"]).returning(*self._table.c)
319+
stmt = sa.update(self._table).where(self._table.c[self.primary_key] == record_id)
320+
stmt = stmt.values(data).returning(*self._table.c)
318321
row = await conn.execute(stmt)
319322
return row.one()._asdict()
320323

321324
@handle_errors
322-
async def update_many(self, params: UpdateManyParams) -> list[Union[str, int]]:
325+
async def update_many(self, record_ids: Sequence[Any], data: Record, meta: Meta) -> list[Any]:
323326
async with self._db.begin() as conn:
324-
stmt = sa.update(self._table).where(self._table.c[self.primary_key].in_(params["ids"]))
325-
stmt = stmt.values(params["data"]).returning(self._table.c[self.primary_key])
327+
stmt = sa.update(self._table).where(self._table.c[self.primary_key].in_(record_ids))
328+
stmt = stmt.values(data).returning(self._table.c[self.primary_key])
326329
return list(await conn.scalars(stmt))
327330

328331
@handle_errors
329-
async def delete(self, params: DeleteParams) -> Record:
332+
async def delete(self, record_id: Any, previous_data: Record, meta: Meta) -> Record:
330333
async with self._db.begin() as conn:
331-
stmt = sa.delete(self._table).where(self._table.c[self.primary_key] == params["id"])
334+
stmt = sa.delete(self._table).where(self._table.c[self.primary_key] == record_id)
332335
row = await conn.execute(stmt.returning(*self._table.c))
333336
return row.one()._asdict()
334337

335338
@handle_errors
336-
async def delete_many(self, params: DeleteManyParams) -> list[Union[str, int]]:
339+
async def delete_many(self, record_ids: Sequence[Any], meta: Meta) -> list[Any]:
337340
async with self._db.begin() as conn:
338-
stmt = sa.delete(self._table).where(self._table.c[self.primary_key].in_(params["ids"]))
341+
stmt = sa.delete(self._table).where(self._table.c[self.primary_key].in_(record_ids))
339342
r = await conn.scalars(stmt.returning(self._table.c[self.primary_key]))
340343
return list(r)
341344

tests/_resources.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,41 @@
1-
from typing import Union
1+
from typing import Sequence
22

3-
from aiohttp_admin.backends.abc import (
4-
AbstractAdminResource, CreateParams, DeleteManyParams, DeleteParams, GetListParams,
5-
GetManyParams, GetOneParams, Record, UpdateManyParams, UpdateParams)
3+
from aiohttp_admin.backends.abc import AbstractAdminResource, GetListParams, Meta, Record
64
from aiohttp_admin.types import ComponentState, InputState
75

86

9-
class DummyResource(AbstractAdminResource):
7+
class DummyResource(AbstractAdminResource[str]):
108
def __init__(self, name: str, fields: dict[str, ComponentState],
119
inputs: dict[str, InputState], primary_key: str):
1210
self.name = name
1311
self.fields = fields
1412
self.inputs = inputs
1513
self.primary_key = primary_key
1614
self.omit_fields = set()
15+
self._id_type = str
16+
self._foreign_rows = set()
1717
super().__init__()
1818

1919
async def get_list(self, params: GetListParams) -> tuple[list[Record], int]: # pragma: no cover # noqa: B950
2020
raise NotImplementedError()
2121

22-
async def get_one(self, params: GetOneParams) -> Record: # pragma: no cover
22+
async def get_one(self, record_id: str, meta: Meta) -> Record: # pragma: no cover
2323
raise NotImplementedError()
2424

25-
async def get_many(self, params: GetManyParams) -> list[Record]: # pragma: no cover
25+
async def get_many(self, record_ids: Sequence[str], meta: Meta) -> list[Record]: # pragma: no cover # noqa: B950
2626
raise NotImplementedError()
2727

28-
async def update(self, params: UpdateParams) -> Record: # pragma: no cover
28+
async def update(self, record_id: str, data: Record, previous_data: Record, meta: Meta) -> Record: # pragma: no cover # noqa: B950
2929
raise NotImplementedError()
3030

31-
async def update_many(self, params: UpdateManyParams) -> list[Union[int, str]]: # pragma: no cover # noqa: B950
31+
async def update_many(self, record_ids: Sequence[str], data: Record, meta: Meta) -> list[str]: # pragma: no cover # noqa: B950
3232
raise NotImplementedError()
3333

34-
async def create(self, params: CreateParams) -> Record: # pragma: no cover
34+
async def create(self, data: Record, meta: Meta) -> Record: # pragma: no cover
3535
raise NotImplementedError()
3636

37-
async def delete(self, params: DeleteParams) -> Record: # pragma: no cover
37+
async def delete(self, record_id: str, previous_data: Record, meta: Meta) -> Record: # pragma: no cover # noqa: B950
3838
raise NotImplementedError()
3939

40-
async def delete_many(self, params: DeleteManyParams) -> list[Union[int, str]]: # pragma: no cover # noqa: B950
40+
async def delete_many(self, record_ids: Sequence[str], meta: Meta) -> list[str]: # pragma: no cover # noqa: B950
4141
raise NotImplementedError()

tests/test_backends_abc.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ async def test_create_with_null(admin_client: TestClient, login: _Login) -> None
1313
p = {"data": json.dumps({"msg": None})}
1414
async with admin_client.post(url, params=p, headers=h) as resp:
1515
assert resp.status == 200, await resp.text()
16-
assert await resp.json() == {"data": {"id": 4, "msg": None}}
16+
assert await resp.json() == {"data": {"id": "4", "msg": None}}

tests/test_backends_sqlalchemy.py

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,11 @@ class TestModel(base): # type: ignore[misc,valid-type]
118118
url = app["admin"].router["test_get_one"].url_for()
119119
async with admin_client.get(url, params={"id": 1}, headers=h) as resp:
120120
assert resp.status == 200
121-
assert await resp.json() == {"data": {"id": 1, "binary": "foo"}}
121+
assert await resp.json() == {"data": {"id": "1", "binary": "foo"}}
122122

123123
async with admin_client.get(url, params={"id": 2}, headers=h) as resp:
124124
assert resp.status == 200
125-
assert await resp.json() == {"data": {"id": 2, "binary": "\x01\x02"}}
125+
assert await resp.json() == {"data": {"id": "2", "binary": "\x01\x02"}}
126126

127127

128128
def test_fk(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None:
@@ -146,6 +146,51 @@ class TestChildModel(base): # type: ignore[misc,valid-type]
146146
"source": "id", "target": "id"}) | {"show_create": True}}
147147

148148

149+
async def test_fk_output(
150+
base: DeclarativeBase, aiohttp_client: Callable[[web.Application], Awaitable[TestClient]],
151+
login: _Login
152+
) -> None:
153+
class TestModel(base): # type: ignore[misc,valid-type]
154+
__tablename__ = "test"
155+
id: Mapped[int] = mapped_column(primary_key=True)
156+
157+
class TestModelParent(base): # type: ignore[misc,valid-type]
158+
__tablename__ = "parent"
159+
id: Mapped[int] = mapped_column(primary_key=True)
160+
child_id: Mapped[int] = mapped_column(sa.ForeignKey(TestModel.id))
161+
162+
app = web.Application()
163+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
164+
db = async_sessionmaker(engine, expire_on_commit=False)
165+
async with engine.begin() as conn:
166+
await conn.run_sync(base.metadata.create_all)
167+
async with db.begin() as sess:
168+
child = TestModel()
169+
sess.add(child)
170+
async with db.begin() as sess:
171+
sess.add(TestModelParent(child_id=child.id))
172+
173+
schema: aiohttp_admin.Schema = {
174+
"security": {
175+
"check_credentials": check_credentials,
176+
"secure": False
177+
},
178+
"resources": ({"model": SAResource(engine, TestModel)},
179+
{"model": SAResource(engine, TestModelParent)})
180+
}
181+
app["admin"] = aiohttp_admin.setup(app, schema)
182+
183+
admin_client = await aiohttp_client(app)
184+
assert admin_client.app
185+
h = await login(admin_client)
186+
187+
url = app["admin"].router["parent_get_one"].url_for()
188+
async with admin_client.get(url, params={"id": 1}, headers=h) as resp:
189+
assert resp.status == 200
190+
# child_id must be converted to str ID.
191+
assert await resp.json() == {"data": {"id": "1", "child_id": "1"}}
192+
193+
149194
def test_relationship(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None:
150195
class TestMany(base): # type: ignore[misc,valid-type]
151196
__tablename__ = "many"
@@ -335,31 +380,31 @@ class TestModel(base): # type: ignore[misc,valid-type]
335380
"sort": json.dumps({"field": "id", "order": "DESC"}), "filter": "{}"}
336381
async with admin_client.get(url, params=p, headers=h) as resp:
337382
assert resp.status == 200
338-
assert await resp.json() == {"data": [{"id": 8, "num": 8, "other": "bar"},
339-
{"id": 5, "num": 5, "other": "foo"}], "total": 2}
383+
assert await resp.json() == {"data": [{"id": "8", "num": 8, "other": "bar"},
384+
{"id": "5", "num": 5, "other": "foo"}], "total": 2}
340385

341386
url = app["admin"].router["test_get_one"].url_for()
342387
async with admin_client.get(url, params={"id": 8}, headers=h) as resp:
343388
assert resp.status == 200
344-
assert await resp.json() == {"data": {"id": 8, "num": 8, "other": "bar"}}
389+
assert await resp.json() == {"data": {"id": "8", "num": 8, "other": "bar"}}
345390

346391
url = app["admin"].router["test_get_many"].url_for()
347-
async with admin_client.get(url, params={"ids": "[5, 8]"}, headers=h) as resp:
392+
async with admin_client.get(url, params={"ids": '["5", "8"]'}, headers=h) as resp:
348393
assert resp.status == 200
349-
assert await resp.json() == {"data": [{"id": 5, "num": 5, "other": "foo"},
350-
{"id": 8, "num": 8, "other": "bar"}]}
394+
assert await resp.json() == {"data": [{"id": "5", "num": 5, "other": "foo"},
395+
{"id": "8", "num": 8, "other": "bar"}]}
351396

352397
url = app["admin"].router["test_create"].url_for()
353398
p = {"data": json.dumps({"num": 12, "other": "this"})}
354399
async with admin_client.post(url, params=p, headers=h) as resp:
355400
assert resp.status == 200
356-
assert await resp.json() == {"data": {"id": 12, "num": 12, "other": "this"}}
401+
assert await resp.json() == {"data": {"id": "12", "num": 12, "other": "this"}}
357402

358403
url = app["admin"].router["test_update"].url_for()
359404
p1 = {"id": 5, "data": json.dumps({"id": 5, "other": "that"}), "previousData": "{}"}
360405
async with admin_client.put(url, params=p1, headers=h) as resp:
361406
assert resp.status == 200
362-
assert await resp.json() == {"data": {"id": 5, "num": 5, "other": "that"}}
407+
assert await resp.json() == {"data": {"id": "5", "num": 5, "other": "that"}}
363408

364409

365410
async def test_datetime(
@@ -396,14 +441,14 @@ class TestModel(base): # type: ignore[misc,valid-type]
396441
url = app["admin"].router["test_get_one"].url_for()
397442
async with admin_client.get(url, params={"id": 1}, headers=h) as resp:
398443
assert resp.status == 200
399-
assert await resp.json() == {"data": {"id": 1, "date": "2023-04-23",
444+
assert await resp.json() == {"data": {"id": "1", "date": "2023-04-23",
400445
"time": "2023-01-02 03:04:00"}}
401446

402447
url = app["admin"].router["test_create"].url_for()
403448
p = {"data": json.dumps({"date": "2024-05-09", "time": "2020-11-12 03:04:05"})}
404449
async with admin_client.post(url, params=p, headers=h) as resp:
405450
assert resp.status == 200
406-
assert await resp.json() == {"data": {"id": 2, "date": "2024-05-09",
451+
assert await resp.json() == {"data": {"id": "2", "date": "2024-05-09",
407452
"time": "2020-11-12 03:04:05"}}
408453

409454

@@ -473,11 +518,11 @@ class TestModel(base): # type: ignore[misc,valid-type]
473518
p = {"data": json.dumps({"foo": True, "bar": 5})}
474519
async with admin_client.post(url, params=p, headers=h) as resp:
475520
assert resp.status == 200
476-
assert await resp.json() == {"data": {"id": 1, "foo": True, "bar": 5}}
521+
assert await resp.json() == {"data": {"id": "1", "foo": True, "bar": 5}}
477522
p = {"data": json.dumps({"foo": None, "bar": -1})}
478523
async with admin_client.post(url, params=p, headers=h) as resp:
479524
assert resp.status == 200
480-
assert await resp.json() == {"data": {"id": 2, "foo": None, "bar": -1}}
525+
assert await resp.json() == {"data": {"id": "2", "foo": None, "bar": -1}}
481526

482527
p = {"data": json.dumps({"foo": 5, "bar": "foo"})}
483528
async with admin_client.post(url, params=p, headers=h) as resp:

0 commit comments

Comments
 (0)