Skip to content

Commit d43c221

Browse files
feat: add handling for paginated data and allow loading all pages at once (#77)
* add handling of paginated requests/responses * run prettier * fix branch testing coverage * add test case for single entity to fix branch testing coverage * fix eslint issues * add option for chunking and add some comments to the code * fix typecheck error * fix unit tests * rename test cases a bit Co-authored-by: Anbraten <[email protected]> * make unit test title more specific * restructured page handling * fix unit test Co-authored-by: Anbraten <[email protected]>
1 parent 2fa1984 commit d43c221

File tree

3 files changed

+261
-9
lines changed

3 files changed

+261
-9
lines changed

src/useFind.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import { AdapterService } from '@feathersjs/adapter-commons/lib';
12
import type { FeathersError } from '@feathersjs/errors';
2-
import type { Application, FeathersService, Params, ServiceMethods } from '@feathersjs/feathers';
3+
import type { Application, FeathersService, Paginated, Params, Query, ServiceMethods } from '@feathersjs/feathers';
34
import sift from 'sift';
45
import { getCurrentInstance, onBeforeUnmount, Ref, ref, watch } from 'vue';
56

6-
import { getId, ServiceModel, ServiceTypes } from './utils';
7+
import { getId, isPaginated, ServiceModel, ServiceTypes } from './utils';
78

89
function loadServiceEventHandlers<
910
CustomApplication extends Application,
@@ -87,21 +88,32 @@ export type UseFindFunc<CustomApplication> = <
8788
params?: Ref<Params | undefined | null>,
8889
) => UseFind<M>;
8990

91+
type Options = {
92+
disableUnloadingEventHandlers: boolean;
93+
loadAllPages: boolean;
94+
};
95+
96+
const defaultOptions: Options = { disableUnloadingEventHandlers: false, loadAllPages: false };
97+
9098
export default <CustomApplication extends Application>(feathers: CustomApplication) =>
9199
<T extends keyof ServiceTypes<CustomApplication>, M = ServiceModel<CustomApplication, T>>(
92100
serviceName: T,
93101
params: Ref<Params | undefined | null> = ref({ paginate: false, query: {} }),
94-
{ disableUnloadingEventHandlers } = { disableUnloadingEventHandlers: false },
102+
options: Partial<Options> = {},
95103
): UseFind<M> => {
104+
const { disableUnloadingEventHandlers, loadAllPages } = { ...defaultOptions, ...options };
96105
// type cast is fine here (source: https://github.com/vuejs/vue-next/issues/2136#issuecomment-693524663)
97106
const data = ref<M[]>([]) as Ref<M[]>;
98107
const isLoading = ref(false);
99108
const error = ref<FeathersError>();
100109

101110
const service = feathers.service(serviceName as string);
102111
const unloadEventHandlers = loadServiceEventHandlers(service, params, data);
112+
let unloaded = false;
113+
114+
const currentFindCall = ref(0);
103115

104-
const find = async () => {
116+
const find = async (call: number) => {
105117
isLoading.value = true;
106118
error.value = undefined;
107119

@@ -112,10 +124,44 @@ export default <CustomApplication extends Application>(feathers: CustomApplicati
112124
}
113125

114126
try {
127+
const originalParams: Params = params.value;
128+
const originalQuery: Query & { $limit?: number } = originalParams.query || {};
115129
// TODO: the typecast below is necessary due to the prerelease state of feathers v5. The problem there is
116130
// that the AdapterService interface is not yet updated and is not compatible with the ServiceMethods interface.
117-
const res = await (service as unknown as ServiceMethods<M>).find(params.value);
118-
data.value = Array.isArray(res) ? res : [res];
131+
const res = await (service as unknown as ServiceMethods<M> | AdapterService<M>).find(originalParams);
132+
if (call !== currentFindCall.value) {
133+
// stop handling response since there already is a new find call running within this composition
134+
return;
135+
}
136+
if (isPaginated(res) && !loadAllPages) {
137+
data.value = [...res.data];
138+
} else if (!isPaginated(res)) {
139+
data.value = Array.isArray(res) ? res : [res];
140+
} else {
141+
// extract data from page response
142+
let loadedPage: Paginated<M> = res;
143+
let loadedItemsCount = loadedPage.data.length;
144+
data.value = [...loadedPage.data];
145+
// limit might not be specified in the original query if default pagination from backend is applied, that's why we use this fallback pattern
146+
const limit: number = originalQuery.$limit || loadedPage.data.length;
147+
// if chunking is enabled we go on requesting all following pages until all data have been received
148+
while (!unloaded && loadedPage.total > loadedItemsCount) {
149+
// skip can be a string in cases where key based chunking/pagination is done e.g. in DynamoDb via `LastEvaluatedKey`
150+
const skip: string | number =
151+
typeof loadedPage.skip === 'string' ? loadedPage.skip : loadedPage.skip + limit;
152+
// request next page
153+
loadedPage = (await (service as unknown as ServiceMethods<M> | AdapterService<M>).find({
154+
...originalParams,
155+
query: { ...originalQuery, $skip: skip, $limit: limit },
156+
})) as Paginated<M>;
157+
if (call !== currentFindCall.value) {
158+
// stop handling/requesting further pages since there already is a new find call running within this composition
159+
return;
160+
}
161+
loadedItemsCount += loadedPage.data.length;
162+
data.value = [...data.value, ...loadedPage.data];
163+
}
164+
}
119165
} catch (_error) {
120166
error.value = _error as FeathersError;
121167
}
@@ -124,10 +170,12 @@ export default <CustomApplication extends Application>(feathers: CustomApplicati
124170
};
125171

126172
const load = () => {
127-
void find();
173+
currentFindCall.value = currentFindCall.value + 1;
174+
void find(currentFindCall.value);
128175
};
129176

130177
const unload = () => {
178+
unloaded = true;
131179
unloadEventHandlers();
132180
feathers.off('connect', load);
133181
};

src/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { AdapterService } from '@feathersjs/adapter-commons';
2-
import type { Application, Id, ServiceMethods } from '@feathersjs/feathers';
2+
import type { Application, Id, Paginated, ServiceMethods } from '@feathersjs/feathers';
33

44
export type PotentialIds = {
55
id?: Id;
@@ -29,3 +29,8 @@ export type ServiceModel<
2929
: ServiceTypes<CustomApplication>[T] extends ServiceMethods<infer M2>
3030
? M2
3131
: never;
32+
33+
export function isPaginated<T>(response: T | T[] | Paginated<T>): response is Paginated<T> {
34+
const { total, limit, skip, data } = response as Paginated<T>;
35+
return total !== undefined && limit !== undefined && skip !== undefined && data !== undefined && Array.isArray(data);
36+
}

test/useFind.test.ts

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FeathersError, GeneralError } from '@feathersjs/errors';
2-
import type { Application, Params } from '@feathersjs/feathers';
2+
import type { Application, Paginated, Params } from '@feathersjs/feathers';
33
import { flushPromises } from '@vue/test-utils';
44
import { beforeEach, describe, expect, it, vi } from 'vitest';
55
import { nextTick, ref } from 'vue';
@@ -423,6 +423,36 @@ describe('Find composition', () => {
423423
expect(findComposition && findComposition.error.value).toBeTruthy();
424424
});
425425

426+
it('should load single entity response', async () => {
427+
expect.assertions(3);
428+
429+
// given
430+
const serviceFind = vi.fn(() => testModel);
431+
432+
const feathersMock = {
433+
service: () => ({
434+
find: serviceFind,
435+
on: vi.fn(),
436+
off: vi.fn(),
437+
}),
438+
on: vi.fn(),
439+
off: vi.fn(),
440+
} as unknown as Application;
441+
const useFind = useFindOriginal(feathersMock);
442+
443+
// when
444+
let findComposition = null as UseFind<TestModel> | null;
445+
mountComposition(() => {
446+
findComposition = useFind('testModels');
447+
});
448+
await nextTick();
449+
450+
// then
451+
expect(serviceFind).toHaveBeenCalledTimes(1);
452+
expect(findComposition).toBeTruthy();
453+
expect(findComposition && findComposition.data.value).toStrictEqual([testModel]);
454+
});
455+
426456
describe('Event Handlers', () => {
427457
it('should listen to "create" events', () => {
428458
expect.assertions(2);
@@ -935,4 +965,173 @@ describe('Find composition', () => {
935965
expect(serviceOff).toHaveBeenCalledTimes(4); // unload of: created, updated, patched, removed events
936966
});
937967
});
968+
969+
describe('pagination', () => {
970+
it('should handle paginated data', async () => {
971+
expect.assertions(3);
972+
973+
// given
974+
let startItemIndex = 0;
975+
const serviceFind = vi.fn(() => {
976+
const page: Paginated<TestModel> = {
977+
total: testModels.length,
978+
skip: startItemIndex,
979+
limit: 1,
980+
data: testModels.slice(startItemIndex, startItemIndex + 1),
981+
};
982+
startItemIndex++;
983+
return page;
984+
});
985+
986+
const feathersMock = {
987+
service: () => ({
988+
find: serviceFind,
989+
on: vi.fn(),
990+
off: vi.fn(),
991+
}),
992+
on: vi.fn(),
993+
off: vi.fn(),
994+
} as unknown as Application;
995+
const useFind = useFindOriginal(feathersMock);
996+
997+
// when
998+
let findComposition = null as UseFind<TestModel> | null;
999+
mountComposition(() => {
1000+
findComposition = useFind('testModels');
1001+
});
1002+
await nextTick();
1003+
1004+
// then
1005+
expect(serviceFind).toHaveBeenCalledTimes(1);
1006+
expect(findComposition).toBeTruthy();
1007+
expect(findComposition && findComposition.data.value).toStrictEqual(testModels.slice(0, 1));
1008+
});
1009+
1010+
it('should load all data with chunking', async () => {
1011+
expect.assertions(3);
1012+
1013+
// given
1014+
let startItemIndex = 0;
1015+
const serviceFind = vi.fn(() => {
1016+
const page: Paginated<TestModel> = {
1017+
total: testModels.length,
1018+
skip: startItemIndex,
1019+
limit: 1,
1020+
data: testModels.slice(startItemIndex, startItemIndex + 1),
1021+
};
1022+
startItemIndex++;
1023+
return page;
1024+
});
1025+
1026+
const feathersMock = {
1027+
service: () => ({
1028+
find: serviceFind,
1029+
on: vi.fn(),
1030+
off: vi.fn(),
1031+
}),
1032+
on: vi.fn(),
1033+
off: vi.fn(),
1034+
} as unknown as Application;
1035+
const useFind = useFindOriginal(feathersMock);
1036+
1037+
// when
1038+
let findComposition = null as UseFind<TestModel> | null;
1039+
mountComposition(() => {
1040+
findComposition = useFind('testModels', undefined, { loadAllPages: true });
1041+
});
1042+
await nextTick();
1043+
1044+
// then
1045+
expect(serviceFind).toHaveBeenCalledTimes(2);
1046+
expect(findComposition).toBeTruthy();
1047+
expect(findComposition && findComposition.data.value).toStrictEqual(testModels);
1048+
});
1049+
1050+
it('should load data with pagination using lastEvaluatedKey patterns', async () => {
1051+
expect.assertions(3);
1052+
1053+
// given
1054+
const serviceFind = vi.fn((params?: Params) => {
1055+
const startItemIndex = testModels.findIndex(({ _id }) => _id === params?.query?.$skip) + 1;
1056+
const data = testModels.slice(startItemIndex, startItemIndex + 1);
1057+
const page: Paginated<TestModel> = {
1058+
total: testModels.length,
1059+
skip: data[data.length - 1]._id as unknown as number,
1060+
limit: 1,
1061+
data,
1062+
};
1063+
return page;
1064+
});
1065+
1066+
const feathersMock = {
1067+
service: () => ({
1068+
find: serviceFind,
1069+
on: vi.fn(),
1070+
off: vi.fn(),
1071+
}),
1072+
on: vi.fn(),
1073+
off: vi.fn(),
1074+
} as unknown as Application;
1075+
const useFind = useFindOriginal(feathersMock);
1076+
1077+
// when
1078+
let findComposition = null as UseFind<TestModel> | null;
1079+
mountComposition(() => {
1080+
findComposition = useFind('testModels', undefined, { loadAllPages: true });
1081+
});
1082+
await nextTick();
1083+
1084+
// then
1085+
expect(serviceFind).toHaveBeenCalledTimes(2);
1086+
expect(findComposition).toBeTruthy();
1087+
expect(findComposition && findComposition.data.value).toStrictEqual(testModels);
1088+
});
1089+
1090+
it('should stop further page requests if find was retriggered due to a change to params or connection reset', async () => {
1091+
expect.assertions(3);
1092+
1093+
// given
1094+
let startItemIndex = 0;
1095+
let data = [additionalTestModel, ...testModels];
1096+
const serviceFind = vi.fn(() => {
1097+
const page: Paginated<TestModel> = {
1098+
total: data.length,
1099+
skip: startItemIndex,
1100+
limit: 1,
1101+
data: data.slice(startItemIndex, startItemIndex + 1),
1102+
};
1103+
startItemIndex++;
1104+
return page;
1105+
});
1106+
const emitter = eventHelper();
1107+
const feathersMock = {
1108+
service: () => ({
1109+
find: serviceFind,
1110+
on: vi.fn(),
1111+
off: vi.fn(),
1112+
}),
1113+
on: emitter.on,
1114+
off: vi.fn(),
1115+
} as unknown as Application;
1116+
const useFind = useFindOriginal(feathersMock);
1117+
let findComposition = null as UseFind<TestModel> | null;
1118+
mountComposition(() => {
1119+
findComposition = useFind('testModels', undefined, { loadAllPages: true });
1120+
});
1121+
await nextTick();
1122+
serviceFind.mockClear();
1123+
data = testModels;
1124+
startItemIndex = 0;
1125+
1126+
// when
1127+
emitter.emit('connect');
1128+
await nextTick();
1129+
await nextTick();
1130+
1131+
// then
1132+
expect(serviceFind).toHaveBeenCalledTimes(2);
1133+
expect(findComposition).toBeTruthy();
1134+
expect(findComposition && findComposition.data.value).toStrictEqual(testModels);
1135+
});
1136+
});
9381137
});

0 commit comments

Comments
 (0)