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
275 changes: 275 additions & 0 deletions packages/core/src/query/__tests__/window-functions.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* @object-ui/core - Window Function Tests
*
* Tests for ObjectStack Spec v0.7.1 window function support
*/

import { describe, it, expect } from 'vitest';
import { QueryASTBuilder } from '../query-ast';
import type { WindowNode, WindowFunction } from '@object-ui/types';

describe('QueryASTBuilder - Window Functions', () => {
const builder = new QueryASTBuilder();

describe('buildWindow', () => {
it('should build a simple row_number window function', () => {
const config = {
function: 'row_number' as WindowFunction,
alias: 'row_num',
partitionBy: ['department'],
orderBy: [{ field: 'salary', direction: 'desc' as const }],
};

// @ts-ignore - testing private method
const result: WindowNode = builder.buildWindow(config);

expect(result).toMatchObject({
type: 'window',
function: 'row_number',
alias: 'row_num',
});

expect(result.partitionBy).toHaveLength(1);
expect(result.partitionBy![0]).toMatchObject({
type: 'field',
name: 'department',
});

expect(result.orderBy).toHaveLength(1);
expect(result.orderBy![0]).toMatchObject({
field: { type: 'field', name: 'salary' },
direction: 'desc',
});
});

it('should build a rank window function with multiple partition fields', () => {
const config = {
function: 'rank' as WindowFunction,
alias: 'rank_val',
partitionBy: ['department', 'location'],
orderBy: [
{ field: 'performance_score', direction: 'desc' as const },
{ field: 'tenure_years', direction: 'desc' as const },
],
};

// @ts-ignore
const result: WindowNode = builder.buildWindow(config);

expect(result).toMatchObject({
type: 'window',
function: 'rank',
alias: 'rank_val',
});

expect(result.partitionBy).toHaveLength(2);
expect(result.orderBy).toHaveLength(2);
});

it('should build a lag window function with offset and default value', () => {
const config = {
function: 'lag' as WindowFunction,
field: 'revenue',
alias: 'prev_month_revenue',
partitionBy: ['product_id'],
orderBy: [{ field: 'month', direction: 'asc' as const }],
offset: 1,
defaultValue: 0,
};

// @ts-ignore
const result: WindowNode = builder.buildWindow(config);

expect(result).toMatchObject({
type: 'window',
function: 'lag',
alias: 'prev_month_revenue',
offset: 1,
});

expect(result.field).toMatchObject({
type: 'field',
name: 'revenue',
});

expect(result.defaultValue).toMatchObject({
type: 'literal',
value: 0,
data_type: 'number',
});
});

it('should build a lead window function', () => {
const config = {
function: 'lead' as WindowFunction,
field: 'sales',
alias: 'next_day_sales',
orderBy: [{ field: 'date', direction: 'asc' as const }],
offset: 1,
};

// @ts-ignore
const result: WindowNode = builder.buildWindow(config);

expect(result).toMatchObject({
type: 'window',
function: 'lead',
alias: 'next_day_sales',
offset: 1,
});
});

it('should build aggregate window functions (sum, avg, count)', () => {
const sumConfig = {
function: 'sum' as WindowFunction,
field: 'amount',
alias: 'running_total',
orderBy: [{ field: 'date', direction: 'asc' as const }],
frame: {
unit: 'rows' as const,
start: 'unbounded_preceding' as const,
end: 'current_row' as const,
},
};

// @ts-ignore
const result: WindowNode = builder.buildWindow(sumConfig);

expect(result).toMatchObject({
type: 'window',
function: 'sum',
alias: 'running_total',
});

expect(result.field).toMatchObject({
type: 'field',
name: 'amount',
});

expect(result.frame).toEqual({
unit: 'rows',
start: 'unbounded_preceding',
end: 'current_row',
});
});

it('should build first_value window function', () => {
const config = {
function: 'first_value' as WindowFunction,
field: 'price',
alias: 'first_price',
partitionBy: ['product_category'],
orderBy: [{ field: 'created_at', direction: 'asc' as const }],
};

// @ts-ignore
const result: WindowNode = builder.buildWindow(config);

expect(result).toMatchObject({
type: 'window',
function: 'first_value',
alias: 'first_price',
});
});

it('should build last_value window function', () => {
const config = {
function: 'last_value' as WindowFunction,
field: 'status',
alias: 'latest_status',
partitionBy: ['customer_id'],
orderBy: [{ field: 'updated_at', direction: 'desc' as const }],
};

// @ts-ignore
const result: WindowNode = builder.buildWindow(config);

expect(result).toMatchObject({
type: 'window',
function: 'last_value',
alias: 'latest_status',
});
});

it('should handle window function without partition by', () => {
const config = {
function: 'row_number' as WindowFunction,
alias: 'global_row_num',
orderBy: [{ field: 'created_at', direction: 'asc' as const }],
};

// @ts-ignore
const result: WindowNode = builder.buildWindow(config);

expect(result.partitionBy).toBeUndefined();
expect(result.orderBy).toBeDefined();
});

it('should handle window function with frame specification', () => {
const config = {
function: 'avg' as WindowFunction,
field: 'temperature',
alias: 'moving_avg_3days',
orderBy: [{ field: 'date', direction: 'asc' as const }],
frame: {
unit: 'rows' as const,
start: { type: 'preceding' as const, offset: 2 },
end: 'current_row' as const,
},
};

// @ts-ignore
const result: WindowNode = builder.buildWindow(config);

expect(result.frame).toEqual({
unit: 'rows',
start: { type: 'preceding', offset: 2 },
end: 'current_row',
});
});

it('should build dense_rank window function', () => {
const config = {
function: 'dense_rank' as WindowFunction,
alias: 'dense_rank_val',
partitionBy: ['team'],
orderBy: [{ field: 'score', direction: 'desc' as const }],
};

// @ts-ignore
const result: WindowNode = builder.buildWindow(config);

expect(result).toMatchObject({
type: 'window',
function: 'dense_rank',
alias: 'dense_rank_val',
});
});

it('should build percent_rank window function', () => {
const config = {
function: 'percent_rank' as WindowFunction,
alias: 'percentile',
partitionBy: ['class'],
orderBy: [{ field: 'exam_score', direction: 'desc' as const }],
};

// @ts-ignore
const result: WindowNode = builder.buildWindow(config);

expect(result).toMatchObject({
type: 'window',
function: 'percent_rank',
alias: 'percentile',
});
});
});
});
58 changes: 57 additions & 1 deletion packages/core/src/query/query-ast.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
/**
* ObjectUI - Query AST Builder
* Phase 3.3: QuerySchema AST implementation
* ObjectStack Spec v0.7.1: Window functions support
*/

import type {
Expand All@@ -15,6 +16,9 @@ import type {
LimitNode,
OffsetNode,
AggregateNode,
WindowNode,
WindowFunction,
WindowFrame,
FieldNode,
LiteralNode,
OperatorNode,
Expand DownExpand Up@@ -64,7 +68,7 @@ export class QueryASTBuilder {
}

private buildSelect(query: QuerySchema): SelectNode {
const fields: (FieldNode | AggregateNode)[] = [];
const fields: (FieldNode | AggregateNode | WindowNode)[] = [];

if (query.fields && query.fields.length > 0) {
fields.push(...query.fields.map(field => this.buildField(field)));
Expand All@@ -77,6 +81,9 @@ export class QueryASTBuilder {
fields.push(...query.aggregations.map(agg => this.buildAggregation(agg)));
}

// Add window functions if they exist (future extension point)
// query.windows?.forEach(win => fields.push(this.buildWindow(win)));

return {
type: 'select',
fields,
Expand DownExpand Up@@ -279,6 +286,55 @@ export class QueryASTBuilder {
};
}

/**
* Build window function node (ObjectStack Spec v0.7.1)
*/
private buildWindow(config: {
function: WindowFunction;
field?: string;
alias: string;
partitionBy?: string[];
orderBy?: Array<{ field: string; direction: 'asc' | 'desc' }>;
frame?: WindowFrame;
offset?: number;
defaultValue?: any;
}): WindowNode {
const node: WindowNode = {
type: 'window',
function: config.function,
alias: config.alias,
};

if (config.field) {
node.field = this.buildField(config.field);
}

if (config.partitionBy && config.partitionBy.length > 0) {
node.partitionBy = config.partitionBy.map(field => this.buildField(field));
}

if (config.orderBy && config.orderBy.length > 0) {
node.orderBy = config.orderBy.map(sort => ({
field: this.buildField(sort.field),
direction: sort.direction,
}));
}

if (config.frame) {
node.frame = config.frame;
}

if (config.offset !== undefined) {
node.offset = config.offset;
}

if (config.defaultValue !== undefined) {
node.defaultValue = this.buildLiteral(config.defaultValue);
}

return node;
}

optimize(ast: QueryAST): QueryAST {
return ast;
}
Expand Down
Loading
Loading