diff --git a/packages/agent/src/routes/capabilities.ts b/packages/agent/src/routes/capabilities.ts index de5dd8e3f9..74fb1f2105 100644 --- a/packages/agent/src/routes/capabilities.ts +++ b/packages/agent/src/routes/capabilities.ts @@ -41,20 +41,34 @@ export default class Capabilities extends BaseRoute { canUseProjectionOnGetOne: true, }, collections: - collections?.map(collection => ({ - name: collection.name, - fields: Object.entries(collection.schema.fields) + collections?.map(collection => { + const { aggregationCapabilities } = collection.schema; + + const fields = Object.entries(collection.schema.fields) .map(([fieldName, field]) => { - return this.shouldCreateFieldCapability(field) - ? { - name: fieldName, - type: field.columnType, - operators: [...field.filterOperators].map(this.pascalCaseToSnakeCase), - } - : null; + if (!this.shouldCreateFieldCapability(field)) return null; + + return { + name: fieldName, + type: field.columnType, + operators: [...field.filterOperators].map(this.pascalCaseToSnakeCase), + isGroupable: field.isGroupable ?? true, + }; }) - .filter(Boolean), - })) ?? [], + .filter(Boolean); + + return { + name: collection.name, + fields, + aggregationCapabilities: aggregationCapabilities + ? { + supportGroups: + aggregationCapabilities.supportGroups && fields.some(f => f?.isGroupable), + supportedDateOperations: [...aggregationCapabilities.supportedDateOperations], + } + : undefined, + }; + }) ?? [], }; context.response.status = HttpCode.Ok; } diff --git a/packages/agent/test/routes/capabilities.test.ts b/packages/agent/test/routes/capabilities.test.ts index e8524d69ee..464edaeea5 100644 --- a/packages/agent/test/routes/capabilities.test.ts +++ b/packages/agent/test/routes/capabilities.test.ts @@ -146,6 +146,7 @@ describe('Capabilities', () => { { name: 'id', type: 'Uuid', + isGroupable: true, operators: [ 'blank', 'equal', @@ -161,6 +162,7 @@ describe('Capabilities', () => { { name: 'name', type: 'String', + isGroupable: true, operators: [ 'blank', 'equal', @@ -188,6 +190,7 @@ describe('Capabilities', () => { { name: 'publishedAt', type: 'Date', + isGroupable: true, operators: [ 'blank', 'equal', @@ -217,6 +220,7 @@ describe('Capabilities', () => { { name: 'price', type: 'Number', + isGroupable: true, operators: [ 'blank', 'equal', @@ -239,6 +243,112 @@ describe('Capabilities', () => { }); }); + describe('when collection has aggregateCapabilities', () => { + test('should include aggregateCapabilities with serialized supportDateOperations', async () => { + const collectionWithCaps = factories.collection.build({ + name: 'orders', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + author_id: factories.columnSchema.text().build({ isGroupable: true }), + }, + aggregateCapabilities: { + supportGroups: true, + 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: true, + supportDateOperations: ['Year', 'Month'], + }); + }); + + test('should derive supportGroups to false when no field is groupable', async () => { + const collectionWithCaps = factories.collection.build({ + name: 'orders', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build({ isGroupable: false }), + }, + aggregateCapabilities: { + supportGroups: true, + supportDateOperations: new Set(), + }, + }), + }); + + 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.supportGroups).toBe(false); + }); + + test('should expose isGroupable per field', async () => { + const collectionWithCaps = factories.collection.build({ + name: 'orders', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build({ isGroupable: false }), + status: factories.columnSchema.text().build({ isGroupable: true }), + }, + aggregateCapabilities: { + supportGroups: true, + supportDateOperations: new Set(), + }, + }), + }); + + 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; + const idField = collections[0].fields.find(f => f.name === 'id'); + const statusField = collections[0].fields.find(f => f.name === 'status'); + + expect(idField.isGroupable).toBe(false); + expect(statusField.isGroupable).toBe(true); + }); + }); + describe('when field ColumnType is an object', () => { test('should not return a field capabilities for that field', async () => { const context = createMockContext({ diff --git a/packages/datasource-customizer/src/decorators/validation/collection.ts b/packages/datasource-customizer/src/decorators/validation/collection.ts index f586f16443..6d41789be1 100644 --- a/packages/datasource-customizer/src/decorators/validation/collection.ts +++ b/packages/datasource-customizer/src/decorators/validation/collection.ts @@ -1,4 +1,6 @@ import type { + AggregateResult, + Aggregation, Caller, CollectionSchema, ColumnSchema, @@ -43,6 +45,17 @@ export default class ValidationDecorator extends CollectionDecorator { this.markSchemaAsDirty(); } + override async aggregate( + caller: Caller, + filter: Filter, + aggregation: Aggregation, + limit?: number, + ): Promise { + this.validateAggregation(aggregation); + + return super.aggregate(caller, filter, aggregation, limit); + } + override async create(caller: Caller, data: RecordData[]): Promise { for (const record of data) this.validate(record, caller.timezone, true); @@ -74,6 +87,40 @@ export default class ValidationDecorator extends CollectionDecorator { return schema; } + private validateAggregation(aggregation: Aggregation): void { + const capabilities = this.schema.aggregationCapabilities; + if (!capabilities) return; + + const groups = aggregation.groups ?? []; + if (groups.length === 0) return; + + if (!capabilities.supportGroups) { + throw new ValidationError('This collection does not support aggregate with groups.'); + } + + for (const group of groups) { + const field = this.schema.fields[group.field]; + + if (field?.type === 'Column' && field.isGroupable === false) { + throw new ValidationError(`Field '${group.field}' is not groupable.`); + } + } + + for (const group of groups) { + if (group.operation && !capabilities.supportedDateOperations.has(group.operation)) { + const supported = + capabilities.supportedDateOperations.size > 0 + ? [...capabilities.supportedDateOperations].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) { diff --git a/packages/datasource-customizer/test/decorators/validation/collection.test.ts b/packages/datasource-customizer/test/decorators/validation/collection.test.ts index fc9da66fdf..f38117ba2d 100644 --- a/packages/datasource-customizer/test/decorators/validation/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/validation/collection.test.ts @@ -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__'; @@ -142,6 +144,167 @@ describe('SortEmulationDecoratorCollection', () => { }); }); + describe('Aggregate validation', () => { + function buildWithCapabilities(aggregateCapabilities, fields?) { + const col = factories.collection.build({ + name: 'books', + schema: factories.collectionSchema.build({ + fields: 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 field is not groupable', async () => { + const { col, decorated } = buildWithCapabilities( + { supportGroups: true, supportDateOperations: new Set() }, + { + id: factories.columnSchema.uuidPrimaryKey().build(), + title: factories.columnSchema.build({ isGroupable: false }), + }, + ); + + 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("'title' is not groupable"); + expect(col.aggregate).not.toHaveBeenCalled(); + }); + + test('should pass when field is groupable', async () => { + const { col, decorated } = buildWithCapabilities( + { supportGroups: true, supportDateOperations: new Set() }, + { + id: factories.columnSchema.uuidPrimaryKey().build(), + title: factories.columnSchema.build({ isGroupable: true }), + }, + ); + + 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 }); diff --git a/packages/datasource-mongoose/test/collection.test.ts b/packages/datasource-mongoose/test/collection.test.ts index 37e9e730fd..8f7152ab03 100644 --- a/packages/datasource-mongoose/test/collection.test.ts +++ b/packages/datasource-mongoose/test/collection.test.ts @@ -1,7 +1,7 @@ /* 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'; @@ -9,17 +9,11 @@ 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; const mongooseCollection = new MongooseCollection(dataSource, carsModel, [ { prefix: null, asFields: [], asModels: [] }, @@ -27,16 +21,9 @@ describe('MongooseCollection', () => { 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), }); }); @@ -44,7 +31,10 @@ describe('MongooseCollection', () => { 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; const mongooseCollection = new MongooseCollection(dataSource, systemUsersModel, [ { prefix: null, asFields: [], asModels: [] }, diff --git a/packages/datasource-replica/test/integrations/schema/flattener.test.ts b/packages/datasource-replica/test/integrations/schema/flattener.test.ts index 4288da7a44..ea6bcdeb4b 100644 --- a/packages/datasource-replica/test/integrations/schema/flattener.test.ts +++ b/packages/datasource-replica/test/integrations/schema/flattener.test.ts @@ -41,6 +41,7 @@ describe('flattener', () => { columnType: 'Number', filterOperators: expect.any(Set), type: 'Column', + isGroupable: false, isSortable: true, isPrimaryKey: true, }, @@ -57,7 +58,7 @@ describe('flattener', () => { allowNull: true, columnType: 'String', filterOperators: expect.any(Set), - + isGroupable: true, type: 'Column', isSortable: true, }, @@ -65,7 +66,7 @@ describe('flattener', () => { allowNull: true, columnType: 'String', filterOperators: expect.any(Set), - + isGroupable: false, type: 'Column', isSortable: true, isPrimaryKey: true, @@ -74,7 +75,7 @@ describe('flattener', () => { allowNull: true, columnType: 'Number', filterOperators: expect.any(Set), - + isGroupable: true, type: 'Column', isSortable: true, }, @@ -115,6 +116,7 @@ describe('flattener', () => { columnType: 'Number', filterOperators: expect.any(Set), type: 'Column', + isGroupable: false, isSortable: true, isPrimaryKey: true, }, @@ -123,6 +125,7 @@ describe('flattener', () => { columnType: 'String', filterOperators: expect.any(Set), type: 'Column', + isGroupable: true, isSortable: true, }, 'fieldObject@@@fields.subObject.fieldNumber': { @@ -130,6 +133,7 @@ describe('flattener', () => { columnType: 'Number', filterOperators: expect.any(Set), type: 'Column', + isGroupable: true, isSortable: true, }, }); @@ -169,6 +173,7 @@ describe('flattener', () => { columnType: 'Number', filterOperators: expect.any(Set), type: 'Column', + isGroupable: false, isSortable: true, isPrimaryKey: true, defaultValue: undefined, @@ -188,6 +193,7 @@ describe('flattener', () => { }, defaultValue: undefined, isReadOnly: undefined, + isGroupable: true, validation: undefined, isSortable: true, type: 'Column', @@ -329,6 +335,7 @@ describe('flattener', () => { defaultValue: undefined, filterOperators: expect.any(Set), isReadOnly: undefined, + isGroupable: true, isSortable: true, type: 'Column', validation: undefined, @@ -352,6 +359,7 @@ describe('flattener', () => { filterOperators: expect.any(Set), isPrimaryKey: true, isReadOnly: undefined, + isGroupable: false, isSortable: true, type: 'Column', validation: undefined, @@ -362,6 +370,7 @@ describe('flattener', () => { defaultValue: undefined, filterOperators: expect.any(Set), isReadOnly: undefined, + isGroupable: true, isSortable: true, type: 'Column', validation: undefined, diff --git a/packages/datasource-replica/test/integrations/schema/schema.test.ts b/packages/datasource-replica/test/integrations/schema/schema.test.ts index bf66244f3b..55b9b5cf67 100644 --- a/packages/datasource-replica/test/integrations/schema/schema.test.ts +++ b/packages/datasource-replica/test/integrations/schema/schema.test.ts @@ -144,6 +144,7 @@ describe('schema', () => { type: 'Column', validation: undefined, isReadOnly: undefined, + isGroupable: false, isSortable: true, isPrimaryKey: true, defaultValue: undefined, @@ -155,6 +156,7 @@ describe('schema', () => { type: 'Column', validation: undefined, isReadOnly: undefined, + isGroupable: true, isSortable: true, defaultValue: undefined, }, @@ -165,6 +167,7 @@ describe('schema', () => { type: 'Column', validation: undefined, isReadOnly: undefined, + isGroupable: true, isSortable: true, defaultValue: undefined, }, @@ -175,6 +178,7 @@ describe('schema', () => { type: 'Column', validation: undefined, isReadOnly: undefined, + isGroupable: true, isSortable: true, defaultValue: undefined, }, @@ -184,6 +188,7 @@ describe('schema', () => { defaultValue: undefined, filterOperators: expect.any(Set), isReadOnly: undefined, + isGroupable: true, isSortable: true, type: 'Column', validation: undefined, @@ -195,6 +200,7 @@ describe('schema', () => { type: 'Column', validation: undefined, isReadOnly: undefined, + isGroupable: true, isSortable: true, defaultValue: undefined, }, @@ -205,6 +211,7 @@ describe('schema', () => { type: 'Column', validation: undefined, isReadOnly: undefined, + isGroupable: true, isSortable: true, defaultValue: undefined, }, @@ -215,6 +222,7 @@ describe('schema', () => { type: 'Column', validation: undefined, isReadOnly: undefined, + isGroupable: true, isSortable: true, defaultValue: undefined, }, @@ -225,6 +233,7 @@ describe('schema', () => { enumValues: expect.any(Array), filterOperators: expect.any(Set), isReadOnly: undefined, + isGroupable: true, isSortable: true, type: 'Column', validation: undefined, diff --git a/packages/datasource-toolkit/src/base-collection.ts b/packages/datasource-toolkit/src/base-collection.ts index 481da2f68e..ce83fe4289 100644 --- a/packages/datasource-toolkit/src/base-collection.ts +++ b/packages/datasource-toolkit/src/base-collection.ts @@ -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, + AggregationCapabilities, + CollectionSchema, + FieldSchema, +} from './interfaces/schema'; import { SchemaUtils } from './index'; @@ -30,6 +35,10 @@ export default abstract class BaseCollection implements Collection { fields: {}, searchable: false, segments: [], + aggregationCapabilities: { + supportGroups: true, + supportedDateOperations: new Set(['Year', 'Quarter', 'Month', 'Week', 'Day']), + }, }; } @@ -52,6 +61,10 @@ export default abstract class BaseCollection implements Collection { protected addField(name: string, schema: FieldSchema): void { SchemaUtils.throwIfAlreadyDefinedField(this.schema, name, this.name); + if (schema.type === 'Column' && schema.isGroupable === undefined) { + schema.isGroupable = !schema.isPrimaryKey; + } + this.schema.fields[name] = schema; } @@ -73,6 +86,10 @@ export default abstract class BaseCollection implements Collection { this.schema.searchable = true; } + protected setAggregationCapabilities(capabilities: AggregationCapabilities): void { + this.schema.aggregationCapabilities = capabilities; + } + abstract create(caller: Caller, data: RecordData[]): Promise; abstract list( diff --git a/packages/datasource-toolkit/src/interfaces/schema.ts b/packages/datasource-toolkit/src/interfaces/schema.ts index 6e3e18227b..0a53482dfe 100644 --- a/packages/datasource-toolkit/src/interfaces/schema.ts +++ b/packages/datasource-toolkit/src/interfaces/schema.ts @@ -1,5 +1,11 @@ +import type { DateOperation } from './query/aggregation'; import type { Operator } from './query/condition-tree/nodes/operators'; +export type AggregationCapabilities = { + supportGroups: boolean; + supportedDateOperations: Set; +}; + export type ActionScope = 'Single' | 'Bulk' | 'Global'; export type ActionSchema = { @@ -21,6 +27,7 @@ export type CollectionSchema = { fields: { [fieldName: string]: FieldSchema }; searchable: boolean; segments: string[]; + aggregationCapabilities?: AggregationCapabilities; }; export type RelationSchema = ManyToOneSchema | OneToManySchema | OneToOneSchema | ManyToManySchema; @@ -33,6 +40,7 @@ export type ColumnSchema = { filterOperators?: Set; defaultValue?: unknown; enumValues?: string[]; + isGroupable?: boolean; isPrimaryKey?: boolean; isReadOnly?: boolean; isSortable?: boolean; diff --git a/packages/datasource-toolkit/test/base-collection.test.ts b/packages/datasource-toolkit/test/base-collection.test.ts index 5265429224..300577c64b 100644 --- a/packages/datasource-toolkit/test/base-collection.test.ts +++ b/packages/datasource-toolkit/test/base-collection.test.ts @@ -1,14 +1,12 @@ // eslint-disable-next-line max-classes-per-file import type { AggregateResult } from '../src/interfaces/query/aggregation'; import type { RecordData } from '../src/interfaces/record'; -import type { CollectionSchema, ColumnSchema, FieldSchema } from '../src/interfaces/schema'; +import type { ColumnSchema, FieldSchema } from '../src/interfaces/schema'; import * as factories from './__factories__'; import BaseCollection from '../src/base-collection'; class ConcreteCollection extends BaseCollection { - override schema: CollectionSchema; - constructor() { super('books', factories.dataSource.build()); } @@ -213,6 +211,72 @@ describe('BaseCollection', () => { }); }); + describe('aggregateCapabilities', () => { + it('should have default capabilities with all groups and date operations supported', () => { + const collection = new ConcreteCollection(); + + expect(collection.schema.aggregateCapabilities).toEqual({ + supportGroups: true, + supportDateOperations: new Set(['Year', 'Quarter', 'Month', 'Week', 'Day']), + }); + }); + }); + + describe('setAggregateCapabilities', () => { + class CollectionWithRestrictedAggregation extends ConcreteCollection { + constructor() { + super(); + + this.setAggregateCapabilities({ + supportGroups: false, + supportDateOperations: new Set(), + }); + } + } + + it('should override aggregate capabilities', () => { + const collection = new CollectionWithRestrictedAggregation(); + + expect(collection.schema.aggregateCapabilities).toEqual({ + supportGroups: false, + supportDateOperations: new Set(), + }); + }); + }); + + describe('addField with isGroupable', () => { + class CollectionWithFields extends ConcreteCollection { + constructor() { + super(); + + this.addField('id', factories.columnSchema.uuidPrimaryKey().build()); + this.addField('title', factories.columnSchema.build()); + this.addField('status', factories.columnSchema.build({ isGroupable: false })); + } + } + + it('should default isGroupable to false for primary keys', () => { + const collection = new CollectionWithFields(); + const field = collection.schema.fields.id as ColumnSchema; + expect(field.type).toBe('Column'); + expect(field.isGroupable).toBe(false); + }); + + it('should default isGroupable to true for non-PK columns', () => { + const collection = new CollectionWithFields(); + const field = collection.schema.fields.title as ColumnSchema; + expect(field.type).toBe('Column'); + expect(field.isGroupable).toBe(true); + }); + + it('should respect explicit isGroupable override', () => { + const collection = new CollectionWithFields(); + const field = collection.schema.fields.status as ColumnSchema; + expect(field.type).toBe('Column'); + expect(field.isGroupable).toBe(false); + }); + }); + describe('execute', () => { test('it always throws', async () => { const collection = new ConcreteCollection();