Skip to content
Merged
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
43 changes: 37 additions & 6 deletions packages/datasource-cosmos/src/utils/aggregation-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,31 @@ export default class AggregationConverter {
return `${operation}(c.${this.toCosmosField(aggregation.field, 'Aggregation target field')})`;
}

/**
* Mapping from Forest Admin DateOperation to the number of characters to extract
* from an ISO 8601 date string (e.g. "2024-01-15T10:30:00Z").
* Year: 4 chars -> "2024", Month: 7 -> "2024-01", Day: 10 -> "2024-01-15"
*/
private static DATE_OPERATION_TO_LENGTH: Record<string, number> = {
Year: 4,
Month: 7,
Day: 10,
};

/**
* Build a Cosmos DB expression that truncates a date field to the given granularity.
* Uses LEFT() on ISO 8601 string dates stored in Cosmos DB.
*/
private static buildDateGroupExpression(field: string, operation: string): string {
const length = this.DATE_OPERATION_TO_LENGTH[operation];

if (!length) {
throw new Error(`Unsupported date operation: "${operation}"`);
}

return `LEFT(${field}, ${length})`;
}

/**
* Build aggregation query with grouping
*/
Expand All @@ -90,23 +115,29 @@ export default class AggregationConverter {
): SqlQuerySpec {
const groups = aggregation.groups || [];

if (groups.length !== 1 || groups[0].operation) {
if (groups.length !== 1) {
throw new Error(
'Complex grouping with multiple fields or date operations requires ' +
'Complex grouping with multiple fields requires ' +
'application-level processing. Please implement this using the raw query ' +
'results and post-processing.',
);
}

const groupField = `c.${this.toCosmosField(groups[0].field, 'Group by field')}`;
const group = groups[0];
const cosmosField = `c.${this.toCosmosField(group.field, 'Group by field')}`;

const groupExpression = group.operation
? this.buildDateGroupExpression(cosmosField, group.operation)
: cosmosField;

const aggregateExpr = this.buildAggregateExpression(aggregation);

const query = `
SELECT ${groupField} as groupKey, ${aggregateExpr} as aggregateValue
SELECT ${groupExpression} as groupKey, ${aggregateExpr} as aggregateValue
FROM c
${whereFragment}
GROUP BY ${groupField}
ORDER BY ${groupField}
GROUP BY ${groupExpression}

${limit ? `OFFSET 0 LIMIT ${limit}` : ''}
`
.trim()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ describe('AggregationConverter', () => {
expect(result.query).toContain('SELECT c.status as groupKey, COUNT(1) as aggregateValue');
expect(result.query).toContain('FROM c');
expect(result.query).toContain('GROUP BY c.status');
expect(result.query).toContain('ORDER BY c.status');
});

it('should build aggregation with nested field', () => {
Expand Down Expand Up @@ -98,7 +97,6 @@ describe('AggregationConverter', () => {
'SELECT c.address["city"] as groupKey, COUNT(1) as aggregateValue',
);
expect(result.query).toContain('GROUP BY c.address["city"]');
expect(result.query).toContain('ORDER BY c.address["city"]');
});

it('should build aggregation on nested field with grouping by another nested field', () => {
Expand All @@ -115,6 +113,78 @@ describe('AggregationConverter', () => {
);
expect(result.query).toContain('GROUP BY c.shipping["method"]');
});

describe('date group operations (time-based charts)', () => {
it.each([
['Day', 10],
['Month', 7],
['Year', 4],
])('should build a grouped query with %s date operation', (operation, expectedLength) => {
const aggregation = new Aggregation({
operation: 'Count',
field: null,
groups: [{ field: 'createdAt', operation: operation as any }],
});

const result = AggregationConverter.buildAggregationQuery(aggregation);
const expectedExpr = `LEFT(c.createdAt, ${expectedLength})`;

expect(result.query).toContain(`SELECT ${expectedExpr} as groupKey`);
expect(result.query).toContain(`GROUP BY ${expectedExpr}`);
});

it('should build a date-grouped query with aggregation on a field', () => {
const aggregation = new Aggregation({
operation: 'Sum',
field: 'amount->value',
groups: [{ field: 'operationDate', operation: 'Month' as any }],
});

const result = AggregationConverter.buildAggregationQuery(aggregation);

expect(result.query).toContain('LEFT(c.operationDate, 7) as groupKey');
expect(result.query).toContain('SUM(c.amount["value"]) as aggregateValue');
});

it('should build a date-grouped query with WHERE condition', () => {
const aggregation = new Aggregation({
operation: 'Count',
field: null,
groups: [{ field: 'createdAt', operation: 'Day' as any }],
});

const conditionTree = new ConditionTreeLeaf('status', 'Equal', 'active');
const result = AggregationConverter.buildAggregationQuery(aggregation, conditionTree);

expect(result.query).toContain('WHERE');
expect(result.query).toContain('c.status = @param0');
expect(result.parameters).toEqual([{ name: '@param0', value: 'active' }]);
});

it('should build a date-grouped query on a nested date field', () => {
const aggregation = new Aggregation({
operation: 'Count',
field: null,
groups: [{ field: 'metadata->timestamp', operation: 'Year' as any }],
});

const result = AggregationConverter.buildAggregationQuery(aggregation);

expect(result.query).toContain('LEFT(c.metadata["timestamp"], 4)');
});

it('should throw for unsupported date operations', () => {
const aggregation = new Aggregation({
operation: 'Count',
field: null,
groups: [{ field: 'createdAt', operation: 'Week' as any }],
});

expect(() => AggregationConverter.buildAggregationQuery(aggregation)).toThrow(
'Unsupported date operation: "Week"',
);
});
});
});

describe('processAggregationResults', () => {
Expand Down
Loading