Skip to content

Commit 588b84a

Browse files
authored
Add support for embed and prefetch (#181)
* Add support for embed and prefetch
1 parent 6a6f0e7 commit 588b84a

9 files changed

Lines changed: 914 additions & 129 deletions

File tree

README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,86 @@ columns: ['id', 'json_data->>blood_type', 'json_data->phones']
271271

272272
**Note**: not working for `create` and `updateMany`.
273273

274+
### Embeds and prefetch
275+
276+
`ra-data-postgrest` supports React Admin [embed](https://marmelab.com/react-admin/DataProviders.html#embedding-relationships) and [prefetch](https://marmelab.com/react-admin/DataProviders.html#prefetching-relationships) features.
277+
278+
For instance, here's how to prefetch posts authors (many-to-one relationship):
279+
280+
```jsx
281+
import { Datagrid, List, ReferenceField, TextField } from 'react-admin';
282+
283+
const PostList = () => (
284+
<List queryOptions={{ meta: { prefetch: ['authors'] } }}>
285+
<Datagrid>
286+
<TextField source="title" />
287+
<ReferenceField source="author_id" reference="authors" />
288+
</Datagrid>
289+
</List>
290+
)
291+
```
292+
293+
Here's how to embed posts authors instead:
294+
295+
```jsx
296+
import { Datagrid, List, ReferenceField, TextField } from 'react-admin';
297+
298+
const PostList = () => (
299+
<List queryOptions={{ meta: { embed: ['authors'] } }}>
300+
<Datagrid>
301+
<TextField source="title" />
302+
<TextField source="authors.name" />
303+
</Datagrid>
304+
</List>
305+
)
306+
```
307+
308+
This will result in a single query to the database and populate React Admin cache for the `authors` resource.
309+
310+
This works for one-to-many relationships too. For instance, here's how to prefetch all books from an author:
311+
312+
```jsx
313+
import { Show, SimpleShowLayout, ReferenceManyField, Datagrid, TextField, DateField } from 'react-admin';
314+
315+
const AuthorShow = () => (
316+
<Show queryOptions={{ meta: { prefetch: ['books'] } }}>
317+
<SimpleShowLayout>
318+
<TextField source="first_name" />
319+
<TextField source="last_name" />
320+
<ReferenceManyField reference="books" target="author_id" label="Books">
321+
<Datagrid>
322+
<TextField source="title" />
323+
<DateField source="published_at" />
324+
</Datagrid>
325+
</ReferenceManyField>
326+
</SimpleShowLayout>
327+
</Show>
328+
);
329+
```
330+
331+
Here's how to embed the books instead:
332+
333+
```jsx
334+
import { Show, SimpleShowLayout, ArrayField, Datagrid, TextField, DateField } from 'react-admin';
335+
336+
const AuthorShow = () => (
337+
<Show queryOptions={{ meta: { prefetch: ['books'] } }}>
338+
<SimpleShowLayout>
339+
<TextField source="first_name" />
340+
<TextField source="last_name" />
341+
<ArrayField source="books">
342+
<Datagrid>
343+
<TextField source="title" />
344+
<DateField source="published_at" />
345+
</Datagrid>
346+
</ArrayField>
347+
</SimpleShowLayout>
348+
</Show>
349+
);
350+
```
351+
352+
This will result in a single query to the database and populate React Admin cache for the `books` resource.
353+
274354
## Developers notes
275355

276356
The current development of this library was done with node v19.10 and npm 8.19.3. In this version the unit tests and the development environment should work.

src/index.ts

Lines changed: 139 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,15 @@ export default (config: IDataProviderConfig): DataProvider => ({
186186
the pagination. If you are using CORS, did you declare Content-Range in the Access-Control-Expose-Headers header?`
187187
);
188188
}
189+
const data = json.map(obj => dataWithVirtualId(obj, primaryKey));
190+
const prefetched = getPrefetchedData(data, params.meta?.prefetch);
189191
return {
190-
data: json.map(obj => dataWithVirtualId(obj, primaryKey)),
192+
data: removePrefetchedData(data, params.meta?.prefetch),
191193
total: parseInt(
192194
headers.get('content-range').split('/').pop(),
193195
10
194196
),
197+
meta: params.meta?.prefetch ? { prefetched } : undefined,
195198
};
196199
});
197200
},
@@ -213,9 +216,17 @@ export default (config: IDataProviderConfig): DataProvider => ({
213216
...useCustomSchema(config.schema, metaSchema, 'GET'),
214217
}),
215218
})
216-
.then(({ json }) => ({
217-
data: dataWithVirtualId(json, primaryKey),
218-
}));
219+
.then(({ json }) => {
220+
const data = dataWithVirtualId(json, primaryKey);
221+
const prefetched = getPrefetchedData(
222+
data,
223+
params.meta?.prefetch
224+
);
225+
return {
226+
data: removePrefetchedData(data, params.meta?.prefetch),
227+
meta: params.meta?.prefetch ? { prefetched } : undefined,
228+
};
229+
});
219230
},
220231

221232
getMany: (resource, params: Partial<GetManyParams> = {}) => {
@@ -236,9 +247,19 @@ export default (config: IDataProviderConfig): DataProvider => ({
236247
...useCustomSchema(config.schema, metaSchema, 'GET'),
237248
}),
238249
})
239-
.then(({ json }) => ({
240-
data: json.map(data => dataWithVirtualId(data, primaryKey)),
241-
}));
250+
.then(({ json }) => {
251+
const data = json.map(data =>
252+
dataWithVirtualId(data, primaryKey)
253+
);
254+
const prefetched = getPrefetchedData(
255+
data,
256+
params.meta?.prefetch
257+
);
258+
return {
259+
data: removePrefetchedData(data, params.meta?.prefetch),
260+
meta: params.meta?.prefetch ? { prefetched } : undefined,
261+
};
262+
});
242263
},
243264

244265
getManyReference: (
@@ -292,12 +313,15 @@ export default (config: IDataProviderConfig): DataProvider => ({
292313
the pagination. If you are using CORS, did you declare Content-Range in the Access-Control-Expose-Headers header?`
293314
);
294315
}
316+
const data = json.map(data => dataWithVirtualId(data, primaryKey));
317+
const prefetched = getPrefetchedData(data, params.meta?.prefetch);
295318
return {
296-
data: json.map(data => dataWithVirtualId(data, primaryKey)),
319+
data: removePrefetchedData(data, params.meta?.prefetch),
297320
total: parseInt(
298321
headers.get('content-range').split('/').pop(),
299322
10
300323
),
324+
meta: params.meta?.prefetch ? { prefetched } : undefined,
301325
};
302326
});
303327
},
@@ -332,9 +356,17 @@ export default (config: IDataProviderConfig): DataProvider => ({
332356
}),
333357
body,
334358
})
335-
.then(({ json }) => ({
336-
data: dataWithVirtualId(json, primaryKey),
337-
}));
359+
.then(({ json }) => {
360+
const data = dataWithVirtualId(json, primaryKey);
361+
const prefetched = getPrefetchedData(
362+
data,
363+
params.meta?.prefetch
364+
);
365+
return {
366+
data: removePrefetchedData(data, params.meta?.prefetch),
367+
meta: params.meta?.prefetch ? { prefetched } : undefined,
368+
};
369+
});
338370
},
339371

340372
updateMany: (resource, params: Partial<UpdateManyParams> = {}) => {
@@ -391,12 +423,20 @@ export default (config: IDataProviderConfig): DataProvider => ({
391423
dataWithoutVirtualId(params.data, primaryKey)
392424
),
393425
})
394-
.then(({ json }) => ({
395-
data: {
426+
.then(({ json }) => {
427+
const data = {
396428
...json,
397429
id: encodeId(json, primaryKey),
398-
},
399-
}));
430+
};
431+
const prefetched = getPrefetchedData(
432+
data,
433+
params.meta?.prefetch
434+
);
435+
return {
436+
data: removePrefetchedData(data, params.meta?.prefetch),
437+
meta: params.meta?.prefetch ? { prefetched } : undefined,
438+
};
439+
});
400440
},
401441

402442
delete: (resource, params: Partial<DeleteParams> = {}) => {
@@ -419,9 +459,17 @@ export default (config: IDataProviderConfig): DataProvider => ({
419459
...useCustomSchema(config.schema, metaSchema, 'DELETE'),
420460
}),
421461
})
422-
.then(({ json }) => ({
423-
data: dataWithVirtualId(json, primaryKey),
424-
}));
462+
.then(({ json }) => {
463+
const data = dataWithVirtualId(json, primaryKey);
464+
const prefetched = getPrefetchedData(
465+
data,
466+
params.meta?.prefetch
467+
);
468+
return {
469+
data: removePrefetchedData(data, params.meta?.prefetch),
470+
meta: params.meta?.prefetch ? { prefetched } : undefined,
471+
};
472+
});
425473
},
426474

427475
deleteMany: (resource, params: Partial<DeleteManyParams> = {}) => {
@@ -458,3 +506,76 @@ const getChanges = (data: any, previousData: any) => {
458506
}, {});
459507
return changes;
460508
};
509+
510+
/**
511+
* Extract embeds from Postgrest responses
512+
*
513+
* When calling Postgrest database.getOne('posts', 123, { embed: 'tags' }),
514+
* the Postgrest response adds a `tags` key to the response, containing the
515+
* related tags. Something like:
516+
*
517+
* { id: 123, title: 'React-query in details', tags: [{ id: 1, name: 'react' }, { id: 1, name: 'query' }] }
518+
*
519+
* We want to copy all the embeds in a data object, that will later
520+
* be included into the response meta.prefetched key.
521+
*
522+
* @example getPrefetchedData({ id: 123, title: 'React-query in details', tags: [{ id: 1, name: 'react' }, { id: 1, name: 'query' }] }, ['tags'])
523+
* // {
524+
* // tags: [{ id: 1, name: 'react' }, { id: 1, name: 'query' }]
525+
* // }
526+
*/
527+
const getPrefetchedData = (data, prefetchParam?: string[]) => {
528+
if (!prefetchParam) return undefined;
529+
const prefetched = {};
530+
const dataArray = Array.isArray(data) ? data : [data];
531+
prefetchParam.forEach(resource => {
532+
dataArray.forEach(record => {
533+
if (!prefetched[resource]) {
534+
prefetched[resource] = [];
535+
}
536+
const prefetchedData = Array.isArray(record[resource])
537+
? record[resource]
538+
: [record[resource]];
539+
prefetchedData.forEach(prefetchedRecord => {
540+
if (
541+
prefetched[resource].some(r => r.id === prefetchedRecord.id)
542+
) {
543+
// do not add the record if it's already there
544+
return;
545+
}
546+
prefetched[resource].push(prefetchedRecord);
547+
});
548+
});
549+
});
550+
551+
return prefetched;
552+
};
553+
554+
/**
555+
* Remove embeds from Postgrest responses
556+
*
557+
* When calling Postgrest database.getOne('posts', 123, { embed: 'tags' }),
558+
* the Postgrest response adds a `post` key to the response, containing the
559+
* related post. Something like:
560+
*
561+
* { id: 123, title: 'React-query in details', tags: [{ id: 1, name: 'react' }, { id: 1, name: 'query' }] }
562+
*
563+
* We want to remove all the embeds from the response.
564+
*
565+
* @example removePrefetchedData({ id: 123, title: 'React-query in details', tags: [{ id: 1, name: 'react' }, { id: 1, name: 'query' }] }, 'tags')
566+
* // { id: 123, title: 'React-query in details' }
567+
*/
568+
const removePrefetchedData = (data, prefetchParam?: string[]) => {
569+
if (!prefetchParam) return data;
570+
const dataArray = Array.isArray(data) ? data : [data];
571+
const newDataArray = dataArray.map(record => {
572+
const newRecord = {};
573+
for (const key in record) {
574+
if (!prefetchParam.includes(key)) {
575+
newRecord[key] = record[key];
576+
}
577+
}
578+
return newRecord;
579+
});
580+
return Array.isArray(data) ? newDataArray : newDataArray[0];
581+
};

src/urlBuilder.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,15 @@ export const parseFilters = (
168168
: meta.columns;
169169
}
170170

171+
if (meta?.embed || meta?.prefetch) {
172+
const columns = getColumnsParam(meta.embed, meta.prefetch);
173+
if (columns) {
174+
result.select = result.select
175+
? `${result.select},${columns.join(',')}`
176+
: `*,${columns.join(',')}`;
177+
}
178+
}
179+
171180
return result;
172181
};
173182

@@ -295,6 +304,16 @@ export const getQuery = (
295304
: meta.columns;
296305
}
297306

307+
if (meta?.embed || meta?.prefetch) {
308+
const columns = getColumnsParam(meta.embed, meta.prefetch);
309+
if (columns) {
310+
result.select = result.select
311+
? `${result.select},${columns.join(',')}`
312+
// if users did not specify any columns, we must add the wildcard select to not get only the embeds/prefetch
313+
: `*,${columns.join(',')}`;
314+
}
315+
}
316+
298317
return result;
299318
};
300319

@@ -318,3 +337,14 @@ export const getOrderBy = (
318337
return `${field}.${postgRestOrder}`;
319338
}
320339
};
340+
341+
/**
342+
* Compute the columns parameter for the embed and prefetch query meta properties
343+
*/
344+
const getColumnsParam = (embed: string[], prefetch: string[]) => {
345+
if (!embed && !prefetch) return;
346+
const param = new Set<string>();
347+
if (embed) embed.forEach(resource => param.add(`${resource}(*)`));
348+
if (prefetch) prefetch.forEach(resource => param.add(`${resource}(*)`));
349+
return Array.from(param);
350+
};

0 commit comments

Comments
 (0)