Skip to content

Commit 4734d6a

Browse files
feat: add capabilities on aggregate (#1465)
1 parent 2dbb5ba commit 4734d6a

File tree

10 files changed

+664
-38
lines changed

10 files changed

+664
-38
lines changed

packages/agent/src/routes/capabilities.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,44 @@ export default class Capabilities extends BaseRoute {
4141
canUseProjectionOnGetOne: true,
4242
},
4343
collections:
44-
collections?.map(collection => ({
45-
name: collection.name,
46-
fields: Object.entries(collection.schema.fields)
44+
collections?.map(collection => {
45+
const { aggregationCapabilities } = collection.schema;
46+
47+
const fields = Object.entries(collection.schema.fields)
4748
.map(([fieldName, field]) => {
48-
return this.shouldCreateFieldCapability(field)
49-
? {
50-
name: fieldName,
51-
type: field.columnType,
52-
operators: [...field.filterOperators].map(this.pascalCaseToSnakeCase),
53-
}
54-
: null;
49+
if (field.type === 'ManyToOne') {
50+
return {
51+
name: fieldName,
52+
type: 'ManyToOne',
53+
isGroupable:
54+
(collection.schema.fields[field.foreignKey] as ColumnSchema).isGroupable ??
55+
true,
56+
};
57+
}
58+
59+
if (!this.shouldCreateFieldCapability(field)) return null;
60+
61+
return {
62+
name: fieldName,
63+
type: field.columnType,
64+
operators: [...field.filterOperators].map(this.pascalCaseToSnakeCase),
65+
isGroupable: field.isGroupable ?? true,
66+
};
5567
})
56-
.filter(Boolean),
57-
})) ?? [],
68+
.filter(Boolean);
69+
70+
return {
71+
name: collection.name,
72+
fields,
73+
aggregationCapabilities: aggregationCapabilities
74+
? {
75+
supportGroups:
76+
aggregationCapabilities.supportGroups && fields.some(f => f?.isGroupable),
77+
supportedDateOperations: [...aggregationCapabilities.supportedDateOperations],
78+
}
79+
: undefined,
80+
};
81+
}) ?? [],
5882
};
5983
context.response.status = HttpCode.Ok;
6084
}

packages/agent/test/routes/capabilities.test.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ describe('Capabilities', () => {
146146
{
147147
name: 'id',
148148
type: 'Uuid',
149+
isGroupable: true,
149150
operators: [
150151
'blank',
151152
'equal',
@@ -161,6 +162,7 @@ describe('Capabilities', () => {
161162
{
162163
name: 'name',
163164
type: 'String',
165+
isGroupable: true,
164166
operators: [
165167
'blank',
166168
'equal',
@@ -188,6 +190,7 @@ describe('Capabilities', () => {
188190
{
189191
name: 'publishedAt',
190192
type: 'Date',
193+
isGroupable: true,
191194
operators: [
192195
'blank',
193196
'equal',
@@ -217,6 +220,7 @@ describe('Capabilities', () => {
217220
{
218221
name: 'price',
219222
type: 'Number',
223+
isGroupable: true,
220224
operators: [
221225
'blank',
222226
'equal',
@@ -239,6 +243,227 @@ describe('Capabilities', () => {
239243
});
240244
});
241245

246+
describe('when collection has aggregationCapabilities', () => {
247+
test('should include aggregationCapabilities with serialized supportedDateOperations', async () => {
248+
const collectionWithCaps = factories.collection.build({
249+
name: 'orders',
250+
schema: factories.collectionSchema.build({
251+
fields: {
252+
id: factories.columnSchema.uuidPrimaryKey().build(),
253+
author_id: factories.columnSchema.text().build({ isGroupable: true }),
254+
},
255+
aggregationCapabilities: {
256+
supportGroups: true,
257+
supportedDateOperations: new Set(['Year', 'Month']),
258+
},
259+
}),
260+
});
261+
262+
const dsWithCaps = factories.dataSource.buildWithCollection(collectionWithCaps);
263+
const routeWithCaps = new Capabilities(services, options, dsWithCaps);
264+
265+
const context = createMockContext({
266+
...defaultContext,
267+
requestBody: { collectionNames: ['orders'] },
268+
});
269+
270+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
271+
// @ts-ignore
272+
await routeWithCaps.fetchCapabilities(context);
273+
274+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
275+
const { collections } = context.response.body as any;
276+
277+
expect(collections[0].aggregationCapabilities).toEqual({
278+
supportGroups: true,
279+
supportedDateOperations: ['Year', 'Month'],
280+
});
281+
});
282+
283+
test('should derive supportGroups to false when no field is groupable', async () => {
284+
const collectionWithCaps = factories.collection.build({
285+
name: 'orders',
286+
schema: factories.collectionSchema.build({
287+
fields: {
288+
id: factories.columnSchema.uuidPrimaryKey().build({ isGroupable: false }),
289+
},
290+
aggregationCapabilities: {
291+
supportGroups: true,
292+
supportedDateOperations: new Set(),
293+
},
294+
}),
295+
});
296+
297+
const dsWithCaps = factories.dataSource.buildWithCollection(collectionWithCaps);
298+
const routeWithCaps = new Capabilities(services, options, dsWithCaps);
299+
300+
const context = createMockContext({
301+
...defaultContext,
302+
requestBody: { collectionNames: ['orders'] },
303+
});
304+
305+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
306+
// @ts-ignore
307+
await routeWithCaps.fetchCapabilities(context);
308+
309+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
310+
const { collections } = context.response.body as any;
311+
312+
expect(collections[0].aggregationCapabilities.supportGroups).toBe(false);
313+
});
314+
315+
test('should expose isGroupable per field', async () => {
316+
const collectionWithCaps = factories.collection.build({
317+
name: 'orders',
318+
schema: factories.collectionSchema.build({
319+
fields: {
320+
id: factories.columnSchema.uuidPrimaryKey().build({ isGroupable: false }),
321+
status: factories.columnSchema.text().build({ isGroupable: true }),
322+
},
323+
aggregationCapabilities: {
324+
supportGroups: true,
325+
supportedDateOperations: new Set(),
326+
},
327+
}),
328+
});
329+
330+
const dsWithCaps = factories.dataSource.buildWithCollection(collectionWithCaps);
331+
const routeWithCaps = new Capabilities(services, options, dsWithCaps);
332+
333+
const context = createMockContext({
334+
...defaultContext,
335+
requestBody: { collectionNames: ['orders'] },
336+
});
337+
338+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
339+
// @ts-ignore
340+
await routeWithCaps.fetchCapabilities(context);
341+
342+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
343+
const { collections } = context.response.body as any;
344+
const idField = collections[0].fields.find(f => f.name === 'id');
345+
const statusField = collections[0].fields.find(f => f.name === 'status');
346+
347+
expect(idField.isGroupable).toBe(false);
348+
expect(statusField.isGroupable).toBe(true);
349+
});
350+
});
351+
352+
describe('when collection has ManyToOne relations', () => {
353+
test('should return ManyToOne field with isGroupable from foreign key column', async () => {
354+
const collectionWithRelation = factories.collection.build({
355+
name: 'orders',
356+
schema: factories.collectionSchema.build({
357+
fields: {
358+
id: factories.columnSchema.uuidPrimaryKey().build(),
359+
authorId: factories.columnSchema.text().build({ isGroupable: true }),
360+
author: factories.manyToOneSchema.build({
361+
foreignKey: 'authorId',
362+
foreignCollection: 'author',
363+
}),
364+
},
365+
}),
366+
});
367+
368+
const dsWithRelation = factories.dataSource.buildWithCollection(collectionWithRelation);
369+
const routeWithRelation = new Capabilities(services, options, dsWithRelation);
370+
371+
const context = createMockContext({
372+
...defaultContext,
373+
requestBody: { collectionNames: ['orders'] },
374+
});
375+
376+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
377+
// @ts-ignore
378+
await routeWithRelation.fetchCapabilities(context);
379+
380+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
381+
const { collections } = context.response.body as any;
382+
const authorField = collections[0].fields.find(f => f.name === 'author');
383+
384+
expect(authorField).toEqual({
385+
name: 'author',
386+
type: 'ManyToOne',
387+
isGroupable: true,
388+
});
389+
});
390+
391+
test('should set isGroupable to false on ManyToOne when foreign key is not groupable', async () => {
392+
const collectionWithRelation = factories.collection.build({
393+
name: 'orders',
394+
schema: factories.collectionSchema.build({
395+
fields: {
396+
id: factories.columnSchema.uuidPrimaryKey().build(),
397+
authorId: factories.columnSchema.text().build({ isGroupable: false }),
398+
author: factories.manyToOneSchema.build({
399+
foreignKey: 'authorId',
400+
foreignCollection: 'author',
401+
}),
402+
},
403+
}),
404+
});
405+
406+
const dsWithRelation = factories.dataSource.buildWithCollection(collectionWithRelation);
407+
const routeWithRelation = new Capabilities(services, options, dsWithRelation);
408+
409+
const context = createMockContext({
410+
...defaultContext,
411+
requestBody: { collectionNames: ['orders'] },
412+
});
413+
414+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
415+
// @ts-ignore
416+
await routeWithRelation.fetchCapabilities(context);
417+
418+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
419+
const { collections } = context.response.body as any;
420+
const authorField = collections[0].fields.find(f => f.name === 'author');
421+
422+
expect(authorField).toEqual({
423+
name: 'author',
424+
type: 'ManyToOne',
425+
isGroupable: false,
426+
});
427+
});
428+
429+
test('should derive supportGroups to true when only ManyToOne field is groupable', async () => {
430+
const collectionWithRelation = factories.collection.build({
431+
name: 'orders',
432+
schema: factories.collectionSchema.build({
433+
fields: {
434+
id: factories.columnSchema.uuidPrimaryKey().build({ isGroupable: false }),
435+
authorId: factories.columnSchema.text().build({ isGroupable: true }),
436+
author: factories.manyToOneSchema.build({
437+
foreignKey: 'authorId',
438+
foreignCollection: 'author',
439+
}),
440+
},
441+
aggregationCapabilities: {
442+
supportGroups: true,
443+
supportedDateOperations: new Set(),
444+
},
445+
}),
446+
});
447+
448+
const dsWithRelation = factories.dataSource.buildWithCollection(collectionWithRelation);
449+
const routeWithRelation = new Capabilities(services, options, dsWithRelation);
450+
451+
const context = createMockContext({
452+
...defaultContext,
453+
requestBody: { collectionNames: ['orders'] },
454+
});
455+
456+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
457+
// @ts-ignore
458+
await routeWithRelation.fetchCapabilities(context);
459+
460+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
461+
const { collections } = context.response.body as any;
462+
463+
expect(collections[0].aggregationCapabilities.supportGroups).toBe(true);
464+
});
465+
});
466+
242467
describe('when field ColumnType is an object', () => {
243468
test('should not return a field capabilities for that field', async () => {
244469
const context = createMockContext({

0 commit comments

Comments
 (0)