Skip to content

Commit 84cf5f1

Browse files
Add support for many-to-many relationships (#868)
1 parent 2d3c293 commit 84cf5f1

File tree

14 files changed

+333
-94
lines changed

14 files changed

+333
-94
lines changed

admin-js/src/App.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,7 @@ const dataProvider = {
142142
deleteMany: (resource, params) => dataRequest(resource, "delete_many", params),
143143
getList: (resource, params) => dataRequest(resource, "get_list", params),
144144
getMany: (resource, params) => dataRequest(resource, "get_many", params),
145-
getManyReference: (resource, params) => {
146-
// filter object is reused across requests, so clone it before modifying.
147-
params["filter"] = {...params["filter"], [params["target"]]: params["id"]};
148-
return dataRequest(resource, "get_list", params);
149-
},
145+
getManyReference: (resource, params) => dataRequest(resource, "get_many_ref", params),
150146
getOne: (resource, params) => dataRequest(resource, "get_one", params),
151147
update: (resource, params) => dataRequest(resource, "update", params),
152148
updateMany: (resource, params) => dataRequest(resource, "update_many", params)

admin-js/tests/relationships.test.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ test("datagrid works", async () => {
1212
await userEvent.keyboard("[Escape]");
1313

1414
const grid = await within(table).findByRole("table");
15+
await sleep(0.1);
1516
const childHeaders = within(grid).getAllByRole("columnheader");
1617
expect(childHeaders.slice(1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value"]);
1718
const childRows = within(grid).getAllByRole("row");
@@ -61,6 +62,73 @@ test("onetomany child displays", async () => {
6162
expect(childCells.map((e) => e.textContent)).toEqual(["Bar", "2"]);
6263
});
6364

65+
test("onetoone parents display", async () => {
66+
await userEvent.click(await screen.findByRole("button", {"name": "Open menu"}));
67+
await userEvent.click(await screen.findByText("Onetoone parents"));
68+
69+
await waitFor(() => screen.getByRole("heading", {"name": "Onetoone parents"}));
70+
await sleep(1);
71+
72+
const table = screen.getByRole("table");
73+
await userEvent.click(within(table).getAllByRole("row")[1]);
74+
75+
await waitFor(() => screen.getByRole("heading", {"name": "Onetoone parent Foo"}));
76+
const grid = await screen.findByRole("table");
77+
const childHeaders = within(grid).getAllByRole("columnheader");
78+
expect(childHeaders.map((e) => e.textContent)).toEqual(["Id", "Name", "Value"]);
79+
const childRows = within(grid).getAllByRole("row");
80+
expect(childRows.length).toBe(2);
81+
const childCells = within(childRows[1]).getAllByRole("cell");
82+
expect(childCells.map((e) => e.textContent)).toEqual(["2", "Child Bar", "2"]);
83+
});
84+
85+
test("manytomany left displays", async () => {
86+
await userEvent.click(await screen.findByRole("button", {"name": "Open menu"}));
87+
await userEvent.click(await screen.findByText("Manytomany lefts"));
88+
89+
await waitFor(() => screen.getByRole("heading", {"name": "Manytomany lefts"}));
90+
await sleep(1);
91+
92+
await userEvent.click(screen.getByRole("button", {"name": "Columns"}));
93+
// TODO: Remove when fixed: https://github.com/marmelab/react-admin/issues/9587
94+
await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Children"));
95+
await userEvent.keyboard("[Escape]");
96+
97+
const table = screen.getAllByRole("table")[0];
98+
const headers = within(table.querySelector("thead")).getAllByRole("columnheader");
99+
expect(headers.slice(1, -1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value", "Children"]);
100+
101+
const rows = within(table).getAllByRole("row").filter((e) => e.parentElement.parentElement === table);
102+
const firstCells = within(rows[1]).getAllByRole("cell").filter((e) => e.parentElement === rows[1]);
103+
expect(firstCells.slice(1, -2).map((e) => e.textContent)).toEqual(["1", "Foo", "2"]);
104+
const secondCells = within(rows[2]).getAllByRole("cell").filter((e) => e.parentElement === rows[2]);
105+
expect(secondCells.slice(1, -2).map((e) => e.textContent)).toEqual(["2", "Bar", "3"]);
106+
107+
const firstGrid = await within(firstCells.at(-2)).findByRole("table");
108+
const firstHeaders = within(firstGrid).getAllByRole("columnheader");
109+
await waitFor(() => firstHeaders[1].textContent.trim() != "");
110+
expect(firstHeaders.slice(1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value"]);
111+
const firstRows = within(firstGrid).getAllByRole("row");
112+
expect(firstRows.length).toBe(3);
113+
let cells = within(firstRows[1]).getAllByRole("cell");
114+
expect(cells.slice(1).map((e) => e.textContent)).toEqual(["3", "Bar Child", "6"]);
115+
cells = within(firstRows[2]).getAllByRole("cell");
116+
expect(cells.slice(1).map((e) => e.textContent)).toEqual(["1", "Foo Child", "5"]);
117+
118+
const secondGrid = within(secondCells.at(-2)).getByRole("table");
119+
const secondHeaders = within(secondGrid).getAllByRole("columnheader");
120+
await waitFor(() => secondHeaders[0].textContent.trim() != "");
121+
expect(secondHeaders.slice(1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value"]);
122+
const secondRows = within(secondGrid).getAllByRole("row");
123+
expect(secondRows.length).toBe(4);
124+
cells = within(secondRows[1]).getAllByRole("cell");
125+
expect(cells.slice(1).map((e) => e.textContent)).toEqual(["3", "Bar Child", "6"]);
126+
cells = within(secondRows[2]).getAllByRole("cell");
127+
expect(cells.slice(1).map((e) => e.textContent)).toEqual(["2", "Baz Child", "7"]);
128+
cells = within(secondRows[3]).getAllByRole("cell");
129+
expect(cells.slice(1).map((e) => e.textContent)).toEqual(["1", "Foo Child", "5"]);
130+
});
131+
64132
test("composite foreign key child displays table", async () => {
65133
await userEvent.click(await screen.findByRole("button", {"name": "Open menu"}));
66134
await userEvent.click(await screen.findByText("Composite foreign key children"));

aiohttp_admin/backends/abc.py

Lines changed: 101 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from pydantic import Json
1515

1616
from ..security import check, permissions_as_dict
17-
from ..types import ComponentState, InputState, fk
17+
from ..types import ComponentState, InputState, fk, resources_key
1818

1919
if sys.version_info >= (3, 10):
2020
from typing import TypeAlias
@@ -26,7 +26,7 @@
2626
else:
2727
from typing_extensions import TypedDict
2828

29-
_ID = TypeVar("_ID")
29+
_ID = TypeVar("_ID", bound=tuple[object, ...])
3030
Record = dict[str, object]
3131
Meta = Optional[dict[str, object]]
3232

@@ -87,6 +87,22 @@ class GetManyParams(_Params):
8787
ids: Json[tuple[str, ...]]
8888

8989

90+
class GetManyRefAPIParams(_Params):
91+
target: str
92+
id: str
93+
pagination: Json[_Pagination]
94+
sort: Json[_Sort]
95+
filter: Json[dict[str, object]]
96+
97+
98+
class GetManyRefParams(_Params):
99+
target: tuple[str, ...]
100+
id: tuple[object, ...]
101+
pagination: Json[_Pagination]
102+
sort: Json[_Sort]
103+
filter: Json[dict[str, object]]
104+
105+
90106
class _CreateData(TypedDict):
91107
"""Id will not be included for create calls."""
92108
data: Record
@@ -116,6 +132,11 @@ class DeleteManyParams(_Params):
116132
ids: Json[tuple[str, ...]]
117133

118134

135+
class _ListQuery(TypedDict):
136+
sort: _Sort
137+
filter: dict[str, object]
138+
139+
119140
class AbstractAdminResource(ABC, Generic[_ID]):
120141
name: str
121142
fields: dict[str, ComponentState]
@@ -155,6 +176,10 @@ async def get_one(self, record_id: _ID, meta: Meta) -> Record:
155176
async def get_many(self, record_ids: Sequence[_ID], meta: Meta) -> list[Record]:
156177
"""Return the matching records."""
157178

179+
@abstractmethod
180+
async def get_many_ref(self, params: GetManyRefParams) -> tuple[list[Record], int]:
181+
"""Return list of records and total count available (when not paginating)."""
182+
158183
@abstractmethod
159184
async def update(self, record_id: _ID, data: Record, previous_data: Record,
160185
meta: Meta) -> Record:
@@ -176,38 +201,30 @@ async def delete(self, record_id: _ID, previous_data: Record, meta: Meta) -> Rec
176201
async def delete_many(self, record_ids: Sequence[_ID], meta: Meta) -> list[_ID]:
177202
"""Delete the matching records and return their IDs."""
178203

204+
async def get_many_ref_name(self, target: str, meta: Meta) -> str:
205+
"""Return the resource name for the reference.
206+
207+
This can be used to change which resource should be returned by get_many_ref().
208+
209+
For example, if we have an SQLAlchemy model called 'parent' with a relationship
210+
called children, then a normal get_many_ref_name() call would go to the 'child'
211+
model with the details from the parent, and the default behaviour would work.
212+
213+
However, the SQLAlchemy backend uses the meta to switch this and send the request
214+
to the 'parent' model instead and then use the children ORM attribute to fetch
215+
the referenced resources, thus requiring this method to return 'child'.
216+
This allows the SQLAlchemy backend to support complex relationships (e.g.
217+
many-to-many) without needing react-admin to know the details.
218+
"""
219+
return self.name
220+
179221
# https://marmelab.com/react-admin/DataProviderWriting.html
180222

181223
@final
182224
async def _get_list(self, request: web.Request) -> web.Response:
183225
await check_permission(request, f"admin.{self.name}.view", context=(request, None))
184226
query = check(GetListParams, request.query)
185-
186-
# When sort order refers to "id", this should be translated to primary key.
187-
if query["sort"]["field"] == "id":
188-
query["sort"]["field"] = self.primary_key[0]
189-
else:
190-
query["sort"]["field"] = query["sort"]["field"].removeprefix("data.")
191-
192-
query["filter"].update(check(dict[str, object], query["filter"].pop("data", {}))) # type: ignore[type-var]
193-
194-
merged_filter = {}
195-
for k, v in query["filter"].items():
196-
if k.startswith("fk_"):
197-
v = check(str, v)
198-
for c, cv in zip(k.removeprefix("fk_").split("__"), v.split("|")):
199-
merged_filter[c] = check(self._raw_record_type[c], cv)
200-
else:
201-
merged_filter[k] = check(self._raw_record_type[k], v)
202-
query["filter"] = merged_filter
203-
204-
# Add filters from advanced permissions.
205-
# The permissions will be cached on the request from a previous permissions check.
206-
permissions = permissions_as_dict(request["aiohttpadmin_permissions"])
207-
filters = permissions.get(f"admin.{self.name}.view",
208-
permissions.get(f"admin.{self.name}.*", {}))
209-
for k, v in filters.items():
210-
query["filter"][k] = v
227+
self._process_list_query(query, request)
211228

212229
raw_results, total = await self.get_list(query)
213230
results = [await self._convert_record(r, request) for r in raw_results
@@ -239,6 +256,33 @@ async def _get_many(self, request: web.Request) -> web.Response:
239256
if await permits(request, f"admin.{self.name}.view", context=(request, r))]
240257
return json_response({"data": results})
241258

259+
@final
260+
async def _get_many_ref(self, request: web.Request) -> web.Response:
261+
query = check(GetManyRefAPIParams, request.query)
262+
meta = query["filter"].pop("__meta__", None)
263+
if meta is not None:
264+
query["meta"] = check(dict[str, object], meta)
265+
reference = await self.get_many_ref_name(query["target"], query.get("meta"))
266+
ref_model = request.app[resources_key][reference]
267+
268+
await check_permission(request, f"admin.{ref_model.name}.view", context=(request, None))
269+
270+
ref_model._process_list_query(query, request)
271+
272+
if query["target"].startswith("fk_"):
273+
target = tuple(query["target"].removeprefix("fk_").split("__"))
274+
record_id = tuple(check(self._raw_record_type[k], v)
275+
for k, v in zip(target, query["id"].split("|")))
276+
else:
277+
target = (query["target"],)
278+
record_id = check(self._id_type, query["id"].split("|"))
279+
280+
raw_results, total = await self.get_many_ref({**query, "target": target, "id": record_id})
281+
282+
results = [await ref_model._convert_record(r, request) for r in raw_results
283+
if await permits(request, f"admin.{ref_model.name}.view", context=(request, r))]
284+
return json_response({"data": results, "total": total})
285+
242286
@final
243287
async def _create(self, request: web.Request) -> web.Response:
244288
query = check(CreateParams, request.query)
@@ -350,7 +394,7 @@ async def _delete_many(self, request: web.Request) -> web.Response:
350394
@final
351395
def _check_record(self, record: Record) -> Record:
352396
"""Check and convert input record."""
353-
return check(self._record_type, record) # type: ignore[no-any-return]
397+
return check(self._record_type, record)
354398

355399
@final
356400
async def _convert_record(self, record: Record, request: web.Request) -> APIRecord:
@@ -371,6 +415,33 @@ def _convert_ids(self, ids: Sequence[_ID]) -> tuple[str, ...]:
371415
"""Convert IDs to correct output format."""
372416
return tuple(str(i) for i in ids)
373417

418+
def _process_list_query(self, query: _ListQuery, request: web.Request) -> None:
419+
# When sort order refers to "id", this should be translated to primary key.
420+
if query["sort"]["field"] == "id":
421+
query["sort"]["field"] = self.primary_key[0]
422+
else:
423+
query["sort"]["field"] = query["sort"]["field"].removeprefix("data.")
424+
425+
query["filter"].update(check(dict[str, object], query["filter"].pop("data", {})))
426+
427+
merged_filter = {}
428+
for k, v in query["filter"].items():
429+
if k.startswith("fk_"):
430+
v = check(str, v)
431+
for c, cv in zip(k.removeprefix("fk_").split("__"), v.split("|")):
432+
merged_filter[c] = check(self._raw_record_type[c], cv)
433+
else:
434+
merged_filter[k] = check(self._raw_record_type[k], v)
435+
query["filter"] = merged_filter
436+
437+
# Add filters from advanced permissions.
438+
# The permissions will be cached on the request from a previous permissions check.
439+
permissions = permissions_as_dict(request["aiohttpadmin_permissions"])
440+
filters = permissions.get(f"admin.{self.name}.view",
441+
permissions.get(f"admin.{self.name}.*", {}))
442+
for k, v in filters.items():
443+
query["filter"][k] = v
444+
374445
@cached_property
375446
def routes(self) -> tuple[web.RouteDef, ...]:
376447
"""Routes to act on this resource.
@@ -382,6 +453,7 @@ def routes(self) -> tuple[web.RouteDef, ...]:
382453
web.get(url + "/list", self._get_list, name=self.name + "_get_list"),
383454
web.get(url + "/one", self._get_one, name=self.name + "_get_one"),
384455
web.get(url, self._get_many, name=self.name + "_get_many"),
456+
web.get(url + "/ref", self._get_many_ref, name=self.name + "_get_many_ref"),
385457
web.post(url, self._create, name=self.name + "_create"),
386458
web.put(url + "/update", self._update, name=self.name + "_update"),
387459
web.put(url + "/update_many", self._update_many, name=self.name + "_update_many"),

0 commit comments

Comments
 (0)