Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/agent/src/routes/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export default class Capabilities extends BaseRoute {
: null;
})
.filter(Boolean),
aggregateCapabilities: collection.schema.aggregateCapabilities,
})) ?? [],
};
context.response.status = HttpCode.Ok;
Expand Down
37 changes: 37 additions & 0 deletions packages/agent/test/routes/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,43 @@ describe('Capabilities', () => {
});
});

describe('when collection has aggregateCapabilities', () => {
test('should include aggregateCapabilities in the response', async () => {
const collectionWithCaps = factories.collection.build({
name: 'orders',
schema: factories.collectionSchema.build({
fields: {
id: factories.columnSchema.uuidPrimaryKey().build(),
},
aggregateCapabilities: {
supportGroups: ['author_id'],
supportDateOperations: new Set(['Year', 'Month']),
},
}),
});

const dsWithCaps = factories.dataSource.buildWithCollection(collectionWithCaps);
const routeWithCaps = new Capabilities(services, options, dsWithCaps);

const context = createMockContext({
...defaultContext,
requestBody: { collectionNames: ['orders'] },
});

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await routeWithCaps.fetchCapabilities(context);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { collections } = context.response.body as any;

expect(collections[0].aggregateCapabilities).toEqual({
supportGroups: ['author_id'],
supportDateOperations: new Set(['Year', 'Month']),
});
});
});

describe('when field ColumnType is an object', () => {
test('should not return a field capabilities for that field', async () => {
const context = createMockContext({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type {
AggregateResult,
Aggregation,
Caller,
CollectionSchema,
ColumnSchema,
Expand Down Expand Up @@ -43,6 +45,17 @@ export default class ValidationDecorator extends CollectionDecorator {
this.markSchemaAsDirty();
}

override async aggregate(
caller: Caller,
filter: Filter,
aggregation: Aggregation,
limit?: number,
): Promise<AggregateResult[]> {
this.validateAggregation(aggregation);

return super.aggregate(caller, filter, aggregation, limit);
}

override async create(caller: Caller, data: RecordData[]): Promise<RecordData[]> {
for (const record of data) this.validate(record, caller.timezone, true);

Expand Down Expand Up @@ -74,6 +87,45 @@ export default class ValidationDecorator extends CollectionDecorator {
return schema;
}

private validateAggregation(aggregation: Aggregation): void {
const capabilities = this.schema.aggregateCapabilities;
if (!capabilities) return;

const groups = aggregation.groups ?? [];
if (groups.length === 0) return;

const { supportGroups } = capabilities;

if (supportGroups === false) {
throw new ValidationError('This collection does not support aggregate with groups.');
}

if (Array.isArray(supportGroups)) {
for (const group of groups) {
if (!supportGroups.includes(group.field)) {
throw new ValidationError(
`This collection does not support grouping by field '${group.field}'. ` +
`Supported group fields: [${supportGroups.join(', ')}].`,
);
}
}
}

for (const group of groups) {
if (group.operation && !capabilities.supportDateOperations.has(group.operation)) {
const supported =
capabilities.supportDateOperations.size > 0
? [...capabilities.supportDateOperations].join(', ')
: 'none';

throw new ValidationError(
`This collection does not support the '${group.operation}' date operation. ` +
`Supported date operations: [${supported}].`,
);
}
}
}

private validate(record: RecordData, timezone: string, allFields: boolean): void {
for (const [name, rules] of Object.entries(this.validation)) {
if (allFields || record[name] !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { Collection, DataSource } from '@forestadmin/datasource-toolkit';

import {
Aggregation,
DataSourceDecorator,
MissingFieldError,
RelationFieldAccessDeniedError,
ValidationError,
} from '@forestadmin/datasource-toolkit';
import * as factories from '@forestadmin/datasource-toolkit/dist/test/__factories__';

Expand Down Expand Up @@ -142,6 +144,162 @@ describe('SortEmulationDecoratorCollection', () => {
});
});

describe('Aggregate validation', () => {
function buildWithCapabilities(aggregateCapabilities) {
const col = factories.collection.build({
name: 'books',
schema: factories.collectionSchema.build({
fields: {
id: factories.columnSchema.uuidPrimaryKey().build(),
title: factories.columnSchema.build(),
},
aggregateCapabilities,
}),
});
const ds = factories.dataSource.buildWithCollection(col);
const decorated = new DataSourceDecorator(ds, ValidationDecorator);

return { col, decorated: decorated.getCollection('books') };
}

test('should pass when aggregateCapabilities allows all (supportGroups: true)', async () => {
const { col, decorated } = buildWithCapabilities({
supportGroups: true,
supportDateOperations: new Set(['Year', 'Quarter', 'Month', 'Week', 'Day']),
});

await decorated.aggregate(
factories.caller.build(),
factories.filter.build(),
new Aggregation({ operation: 'Count', groups: [{ field: 'title' }] }),
);

expect(col.aggregate).toHaveBeenCalled();
});

test('should pass when aggregation has no groups even if groups are not supported', async () => {
const { col, decorated } = buildWithCapabilities({
supportGroups: false,
supportDateOperations: new Set(),
});

await decorated.aggregate(
factories.caller.build(),
factories.filter.build(),
new Aggregation({ operation: 'Count' }),
);

expect(col.aggregate).toHaveBeenCalled();
});

test('should throw when supportGroups is false and aggregation has groups', async () => {
const { col, decorated } = buildWithCapabilities({
supportGroups: false,
supportDateOperations: new Set(),
});

const fn = () =>
decorated.aggregate(
factories.caller.build(),
factories.filter.build(),
new Aggregation({ operation: 'Count', groups: [{ field: 'title' }] }),
);

await expect(fn).rejects.toThrow(ValidationError);
await expect(fn).rejects.toThrow('does not support aggregate with groups');
expect(col.aggregate).not.toHaveBeenCalled();
});

test('should throw when group field is not in allowed list', async () => {
const { col, decorated } = buildWithCapabilities({
supportGroups: ['id'],
supportDateOperations: new Set(),
});

const fn = () =>
decorated.aggregate(
factories.caller.build(),
factories.filter.build(),
new Aggregation({ operation: 'Count', groups: [{ field: 'title' }] }),
);

await expect(fn).rejects.toThrow(ValidationError);
await expect(fn).rejects.toThrow("does not support grouping by field 'title'");
await expect(fn).rejects.toThrow('Supported group fields: [id]');
expect(col.aggregate).not.toHaveBeenCalled();
});

test('should pass when group field is in allowed list', async () => {
const { col, decorated } = buildWithCapabilities({
supportGroups: ['title'],
supportDateOperations: new Set(),
});

await decorated.aggregate(
factories.caller.build(),
factories.filter.build(),
new Aggregation({ operation: 'Count', groups: [{ field: 'title' }] }),
);

expect(col.aggregate).toHaveBeenCalled();
});

test('should throw when date operation is not supported', async () => {
const { col, decorated } = buildWithCapabilities({
supportGroups: true,
supportDateOperations: new Set(),
});

const fn = () =>
decorated.aggregate(
factories.caller.build(),
factories.filter.build(),
new Aggregation({
operation: 'Count',
groups: [{ field: 'title', operation: 'Month' }],
}),
);

await expect(fn).rejects.toThrow(ValidationError);
await expect(fn).rejects.toThrow("does not support the 'Month' date operation");
await expect(fn).rejects.toThrow('Supported date operations: [none]');
expect(col.aggregate).not.toHaveBeenCalled();
});

test('should pass when date operation is supported', async () => {
const { col, decorated } = buildWithCapabilities({
supportGroups: true,
supportDateOperations: new Set(['Year', 'Month']),
});

await decorated.aggregate(
factories.caller.build(),
factories.filter.build(),
new Aggregation({
operation: 'Count',
groups: [{ field: 'title', operation: 'Year' }],
}),
);

expect(col.aggregate).toHaveBeenCalled();
});

test('should pass when group has no date operation even if none are supported', async () => {
const { col, decorated } = buildWithCapabilities({
supportGroups: true,
supportDateOperations: new Set(),
});

await decorated.aggregate(
factories.caller.build(),
factories.filter.build(),
new Aggregation({ operation: 'Count', groups: [{ field: 'title' }] }),
);

expect(col.aggregate).toHaveBeenCalled();
});
});

describe('Validation on a defined value', () => {
beforeEach(() => {
newBooks.addValidation('title', { operator: 'LongerThan', value: 5 });
Expand Down
28 changes: 9 additions & 19 deletions packages/datasource-mongoose/test/collection.test.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,40 @@
/* eslint-disable no-underscore-dangle */
/* eslint-disable jest/no-disabled-tests */

import type { Connection } from 'mongoose';
import type { Connection, Model } from 'mongoose';

import { Schema, model } from 'mongoose';

import MongooseCollection from '../src/collection';
import MongooseDatasource from '../src/datasource';

describe('MongooseCollection', () => {
let connection: Connection;

afterEach(async () => {
await connection?.close();
});

it('should build a collection with the right datasource and schema', () => {
const mockedConnection = { models: {} } as Connection;
const dataSource = new MongooseDatasource(mockedConnection);

const carsModel = model('aModel', new Schema({ aField: { type: Number } }));
const carsModel = model('aModel', new Schema({ aField: { type: Number } })) as Model<unknown>;

const mongooseCollection = new MongooseCollection(dataSource, carsModel, [
{ prefix: null, asFields: [], asModels: [] },
]);

expect(mongooseCollection.dataSource).toEqual(dataSource);
expect(mongooseCollection.name).toEqual('aModel');
expect(mongooseCollection.schema).toEqual({
actions: {},
charts: [],
countable: true,
fields: {
aField: expect.any(Object),
_id: expect.any(Object),
},
searchable: false,
segments: [],
expect(mongooseCollection.schema.fields).toEqual({
aField: expect.any(Object),
_id: expect.any(Object),
});
});

it("should escape collection names even when they don't have a prefix", () => {
const mockedConnection = { models: {} } as Connection;
const dataSource = new MongooseDatasource(mockedConnection);

const systemUsersModel = model('system.users', new Schema({ aField: { type: Number } }));
const systemUsersModel = model(
'system.users',
new Schema({ aField: { type: Number } }),
) as Model<unknown>;

const mongooseCollection = new MongooseCollection(dataSource, systemUsersModel, [
{ prefix: null, asFields: [], asModels: [] },
Expand Down
15 changes: 14 additions & 1 deletion packages/datasource-toolkit/src/base-collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import type PaginatedFilter from './interfaces/query/filter/paginated';
import type Filter from './interfaces/query/filter/unpaginated';
import type Projection from './interfaces/query/projection';
import type { CompositeId, RecordData } from './interfaces/record';
import type { ActionSchema, CollectionSchema, FieldSchema } from './interfaces/schema';
import type {
ActionSchema,
AggregateCapabilities,
CollectionSchema,
FieldSchema,
} from './interfaces/schema';

import { SchemaUtils } from './index';

Expand All @@ -30,6 +35,10 @@ export default abstract class BaseCollection implements Collection {
fields: {},
searchable: false,
segments: [],
aggregateCapabilities: {
supportGroups: true,
supportDateOperations: new Set(['Year', 'Quarter', 'Month', 'Week', 'Day']),
},
};
}

Expand Down Expand Up @@ -73,6 +82,10 @@ export default abstract class BaseCollection implements Collection {
this.schema.searchable = true;
}

protected setAggregateCapabilities(capabilities: AggregateCapabilities): void {
this.schema.aggregateCapabilities = capabilities;
}

abstract create(caller: Caller, data: RecordData[]): Promise<RecordData[]>;

abstract list(
Expand Down
Loading
Loading