Skip to content

Commit 5f49c2c

Browse files
Implement missing relationship patterns (#713)
1 parent a816a6d commit 5f49c2c

7 files changed

Lines changed: 244 additions & 50 deletions

File tree

admin-js/src/App.js

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
BulkDeleteButton, BulkExportButton, BulkUpdateButton, CreateButton, ExportButton,
1212
FilterButton, SelectColumnsButton, TopToolbar,
1313
// Fields
14-
BooleanField, DateField, NumberField, ReferenceField, ReferenceManyField, TextField,
14+
BooleanField, DateField, NumberField, ReferenceField, ReferenceManyField,
15+
ReferenceOneField, TextField,
1516
// Inputs
1617
BooleanInput, DateInput, DateTimeInput, NumberInput, SelectInput, TextInput,
1718
ReferenceInput as _ReferenceInput,
@@ -33,12 +34,24 @@ const ReferenceInput = (props) => {
3334
);
3435
};
3536

37+
/** Display a single record in a Datagrid-like view (e.g. for ReferenceField). */
38+
const DatagridSingle = (props) => (
39+
<WithRecord {...props} render={
40+
(record) => <Datagrid {...props} data={[record]} bulkActionButtons={false} hover={false} rowClick={false} setSort={false} />
41+
} />
42+
);
43+
3644

3745
const _body = document.querySelector("body");
3846
const STATE = JSON.parse(_body.dataset.state);
3947
// Create a mapping of components, so we can reference them by name later.
40-
const COMPONENTS = {BooleanField, DateField, NumberField, ReferenceField, ReferenceManyField, TextField,
41-
BooleanInput, DateInput, DateTimeInput, NumberInput, ReferenceInput, TextInput};
48+
const COMPONENTS = {
49+
Datagrid, DatagridSingle,
50+
51+
BooleanField, DateField, NumberField, ReferenceField, ReferenceManyField,
52+
ReferenceOneField, TextField,
53+
54+
BooleanInput, DateInput, DateTimeInput, NumberInput, ReferenceInput, TextInput};
4255
const VALIDATORS = {email, maxLength, maxValue, minLength, minValue, regex, required};
4356

4457
/** Make an authenticated API request and return the response object. */
@@ -79,7 +92,8 @@ const dataProvider = {
7992
getList: (resource, params) => dataRequest(resource, "get_list", params),
8093
getMany: (resource, params) => dataRequest(resource, "get_many", params),
8194
getManyReference: (resource, params) => {
82-
params["filter"][params["target"]] = params["id"];
95+
// filter object is reused across requests, so clone it before modifying.
96+
params["filter"] = {...params["filter"], [params["target"]]: params["id"]};
8397
return dataRequest(resource, "get_list", params);
8498
},
8599
getOne: (resource, params) => dataRequest(resource, "get_one", params),
@@ -131,14 +145,18 @@ function createFields(resource, name, permissions) {
131145
"display": Object.keys(state["props"]["children"])},
132146
name, permissions);
133147
delete state["props"]["children"];
134-
c = <C source={field} {...state["props"]}><Datagrid>{child_fields}</Datagrid></C>;
148+
c = <C source={field} {...state["props"]}>{child_fields}</C>;
135149
} else {
136150
c = <C source={field} {...state["props"]} />;
137151
}
138-
// Show icon if user doesn't have permission to view this field (based on filters).
139-
components.push(<WithRecord source={field} label={state["props"]["label"]} render={
140-
(record) => hasPermission(`${name}.${field}.view`, permissions, record) ? c : <VisibilityOffIcon />
141-
} />);
152+
if (field === "_")
153+
// Layout component, not related to a specific field.
154+
components.push(c);
155+
else
156+
// Show icon if user doesn't have permission to view this field (based on filters).
157+
components.push(<WithRecord source={field} label={state["props"]["label"]} render={
158+
(record) => hasPermission(`${name}.${field}.view`, permissions, record) ? c : <VisibilityOffIcon />
159+
} />);
142160
}
143161
return components;
144162
}

aiohttp_admin/backends/sqlalchemy.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, type[Declara
134134
field = "ReferenceField"
135135
inp = "ReferenceInput"
136136
key = next(iter(c.foreign_keys)) # TODO: Test composite foreign keys.
137-
props: dict[str, object] = {"reference": key.column.table.name}
137+
props: dict[str, object] = {"reference": key.column.table.name,
138+
"source": c.name, "target": key.column.name}
138139
else:
139140
field, inp = FIELD_TYPES.get(type(c.type), ("TextField", "TextInput"))
140141
props = {}
@@ -157,18 +158,27 @@ def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, type[Declara
157158
for name, relationship in mapper.relationships.items():
158159
if len(relationship.local_remote_pairs) > 1:
159160
raise NotImplementedError("Composite foreign keys not supported yet.")
160-
pair = relationship.local_remote_pairs[0]
161+
local, remote = relationship.local_remote_pairs[0]
162+
163+
props = {"reference": relationship.entity.persist_selectable.name,
164+
"label": name.title(), "source": local.name, "target": remote.name}
165+
if local.foreign_keys:
166+
t = "ReferenceField"
167+
elif relationship.uselist:
168+
t = "ReferenceManyField"
169+
else:
170+
t = "ReferenceOneField"
171+
161172
children = {}
162173
for c in relationship.target.c.values():
163-
if c is pair[1]: # Skip the foreign key
174+
if c is remote: # Skip the foreign key
164175
continue
165176
field, inp = FIELD_TYPES.get(type(c.type), ("TextField", "TextInput"))
166177
children[c.name] = {"type": field, "props": {}}
178+
container = "Datagrid" if t == "ReferenceManyField" else "DatagridSingle"
179+
props["children"] = {"_": {"type": container, "props": {"children": children}}}
167180

168-
props = {"reference": relationship.entity.persist_selectable.name,
169-
"label": name.title(), "children": children,
170-
"source": pair[0].name, "target": pair[1].name}
171-
self.fields[name] = {"type": "ReferenceManyField", "props": props}
181+
self.fields[name] = {"type": t, "props": props}
172182

173183
self._db = db
174184
self._table = table

aiohttp_admin/routes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ def setup_resources(admin: web.Application, schema: Schema) -> None:
2929
raise ValueError(f"Display includes non-existent field {r['display']}")
3030

3131
repr_field = r.get("repr", m.primary_key)
32+
if repr_field not in m.fields:
33+
raise ValueError(f"repr not a valid field name: {repr_field}")
3234

3335
# Don't modify the resource.
3436
fields = copy.deepcopy(m.fields)

examples/_models.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from enum import Enum
55

66
import sqlalchemy as sa
7-
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
7+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
88

99

1010
class Currency(Enum):
@@ -33,21 +33,3 @@ class SimpleParent(Base):
3333
primary_key=True)
3434
date: Mapped[datetime]
3535
currency: Mapped[Currency] = mapped_column(default="USD")
36-
37-
38-
class Author(Base):
39-
__tablename__ = "author"
40-
41-
id: Mapped[int] = mapped_column(primary_key=True)
42-
name: Mapped[str]
43-
books: Mapped[list["Book"]] = relationship()
44-
45-
46-
class Book(Base):
47-
__tablename__ = "book"
48-
49-
id: Mapped[int] = mapped_column(primary_key=True)
50-
title: Mapped[str]
51-
author_id: Mapped[int | None] = mapped_column(sa.ForeignKey(Author.id))
52-
53-
# author: Mapped[Author] = relationship(back_populates="books")

examples/relationships.py

Lines changed: 131 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,111 @@
11
"""Example that demonstrates use of various foreign key relationships.
22
3+
An example of each SQLAlchemy relationship is included.
4+
However, the many to many relationship requires the react-admin enterprise-edition
5+
(not currently supported by aiohttp-admin).
6+
https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html
7+
38
When running this file, admin will be accessible at /admin.
49
"""
510

11+
import sqlalchemy as sa
612
from aiohttp import web
713
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
14+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
815

916
import aiohttp_admin
10-
from _models import Author, Base, Book
1117
from aiohttp_admin.backends.sqlalchemy import SAResource
1218

1319

20+
class Base(DeclarativeBase):
21+
"""Base model."""
22+
23+
24+
class OneToManyParent(Base):
25+
__tablename__ = "onetomany_parent"
26+
27+
id: Mapped[int] = mapped_column(primary_key=True)
28+
name: Mapped[str]
29+
value: Mapped[int]
30+
children: Mapped[list["OneToManyChild"]] = relationship(back_populates="parent")
31+
32+
33+
class OneToManyChild(Base):
34+
__tablename__ = "onetomany_child"
35+
36+
id: Mapped[int] = mapped_column(primary_key=True)
37+
name: Mapped[str]
38+
value: Mapped[int]
39+
parent_id: Mapped[int] = mapped_column(sa.ForeignKey(OneToManyParent.id))
40+
parent: Mapped[OneToManyParent] = relationship(back_populates="children")
41+
42+
43+
class ManyToOneParent(Base):
44+
__tablename__ = "manytoone_parent"
45+
46+
id: Mapped[int] = mapped_column(primary_key=True)
47+
name: Mapped[str]
48+
value: Mapped[int]
49+
child_id: Mapped[int | None] = mapped_column(sa.ForeignKey("manytoone_child.id"))
50+
child: Mapped["ManyToOneChild | None"] = relationship(back_populates="parents")
51+
52+
53+
class ManyToOneChild(Base):
54+
__tablename__ = "manytoone_child"
55+
56+
id: Mapped[int] = mapped_column(primary_key=True)
57+
name: Mapped[str]
58+
value: Mapped[int]
59+
parents: Mapped[list[ManyToOneParent]] = relationship(back_populates="child")
60+
61+
62+
class OneToOneParent(Base):
63+
__tablename__ = "onetoone_parent"
64+
65+
id: Mapped[int] = mapped_column(primary_key=True)
66+
name: Mapped[str]
67+
value: Mapped[int]
68+
child: Mapped["OneToOneChild"] = relationship(back_populates="parent")
69+
70+
71+
class OneToOneChild(Base):
72+
__tablename__ = "onetoone_child"
73+
74+
id: Mapped[int] = mapped_column(primary_key=True)
75+
name: Mapped[str]
76+
value: Mapped[int]
77+
parent_id: Mapped[int] = mapped_column(sa.ForeignKey(OneToOneParent.id))
78+
parent: Mapped[OneToOneParent] = relationship(back_populates="child")
79+
80+
81+
association_table = sa.Table(
82+
"association_table",
83+
Base.metadata,
84+
sa.Column("left_id", sa.ForeignKey("manytomany_left.id"), primary_key=True),
85+
sa.Column("right_id", sa.ForeignKey("manytomany_right.id"), primary_key=True),
86+
)
87+
88+
89+
class ManyToManyParent(Base):
90+
__tablename__ = "manytomany_left"
91+
92+
id: Mapped[int] = mapped_column(primary_key=True)
93+
name: Mapped[str]
94+
value: Mapped[int]
95+
children: Mapped[list["ManyToManyChild"]] = relationship(secondary=association_table,
96+
back_populates="parents")
97+
98+
99+
class ManyToManyChild(Base):
100+
__tablename__ = "manytomany_right"
101+
102+
id: Mapped[int] = mapped_column(primary_key=True)
103+
name: Mapped[str]
104+
value: Mapped[int]
105+
parents: Mapped[list[ManyToManyParent]] = relationship(secondary=association_table,
106+
back_populates="children")
107+
108+
14109
async def check_credentials(username: str, password: str) -> bool:
15110
return username == "admin" and password == "admin"
16111

@@ -23,13 +118,34 @@ async def create_app() -> web.Application:
23118
async with engine.begin() as conn:
24119
await conn.run_sync(Base.metadata.create_all)
25120
async with session.begin() as sess:
26-
sess.add(Author(name="John Doe"))
27-
author1 = Author(name="Jane Smith")
28-
sess.add(author1)
121+
sess.add(OneToManyParent(name="Foo", value=1))
122+
onetomany_1 = OneToManyParent(name="Bar", value=2)
123+
sess.add(onetomany_1)
124+
manytoone_1 = ManyToOneChild(name="Child Foo", value=4)
125+
sess.add(manytoone_1)
126+
onetoone_1 = OneToOneParent(name="Foo", value=3)
127+
sess.add(onetoone_1)
128+
onetoone_2 = OneToOneParent(name="Bar", value=5)
129+
sess.add(onetoone_2)
130+
manytomany_p1 = ManyToManyParent(name="Foo", value=2)
131+
manytomany_p2 = ManyToManyParent(name="Bar", value=3)
132+
manytomany_c1 = ManyToManyChild(name="Foo Child", value=5)
133+
manytomany_c2 = ManyToManyChild(name="Bar Child", value=6)
134+
manytomany_p1.children.append(manytomany_c1)
135+
manytomany_p1.children.append(manytomany_c2)
136+
manytomany_p2.children.append(manytomany_c1)
137+
manytomany_p2.children.append(manytomany_c2)
138+
sess.add(manytomany_p1)
139+
sess.add(manytomany_p2)
140+
sess.add(manytomany_c1)
141+
sess.add(manytomany_c2)
29142
async with session.begin() as sess:
30-
sess.add(Book(author_id=author1.id, title="Book 1"))
31-
sess.add(Book(author_id=author1.id, title="Book 2"))
32-
sess.add(Book(author_id=author1.id, title="Another book"))
143+
sess.add(OneToManyChild(name="Child Foo", value=1, parent_id=onetomany_1.id))
144+
sess.add(OneToManyChild(name="Child Bar", value=5, parent_id=onetomany_1.id))
145+
sess.add(ManyToOneParent(name="Foo", value=5, child_id=manytoone_1.id))
146+
sess.add(ManyToOneParent(name="Bar", value=3))
147+
sess.add(OneToOneChild(name="Child Foo", value=0, parent_id=onetoone_2.id))
148+
sess.add(OneToOneChild(name="Child Bar", value=2, parent_id=onetoone_1.id))
33149

34150
app = web.Application()
35151

@@ -40,8 +156,14 @@ async def create_app() -> web.Application:
40156
"secure": False
41157
},
42158
"resources": (
43-
{"model": SAResource(engine, Author), "repr": Author.name.name},
44-
{"model": SAResource(engine, Book)}
159+
{"model": SAResource(engine, OneToManyParent), "repr": "name"},
160+
{"model": SAResource(engine, OneToManyChild)},
161+
{"model": SAResource(engine, ManyToOneParent), "repr": "name"},
162+
{"model": SAResource(engine, ManyToOneChild)},
163+
{"model": SAResource(engine, OneToOneParent), "repr": "name"},
164+
{"model": SAResource(engine, OneToOneChild)},
165+
# {"model": SAResource(engine, ManyToManyParent)},
166+
# {"model": SAResource(engine, ManyToManyChild)}
45167
)
46168
}
47169
aiohttp_admin.setup(app, schema)

tests/test_admin.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,14 @@ def test_extra_props() -> None:
128128
"label": "Spam"}
129129
assert test_state["inputs"]["id"]["props"] == {"alwaysOn": "alwaysOn", "type": "email",
130130
"multiline": True, "resettable": False}
131+
132+
133+
def test_invalid_repr() -> None:
134+
app = web.Application()
135+
model = DummyResource("test", {"id": {"type": "TextField", "props": {}},
136+
"foo": {"type": "TextField", "props": {}}}, {}, "id")
137+
schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials},
138+
"resources": ({"model": model, "repr": "bar"},)}
139+
140+
with pytest.raises(ValueError, match=r"not a valid field name: bar"):
141+
aiohttp_admin.setup(app, schema)

0 commit comments

Comments
 (0)