Skip to content

Commit 3a76400

Browse files
authored
Merge pull request #301 from objectstack-ai/copilot/complete-development-as-planned
2 parents 59bb9ae + 380cf7a commit 3a76400

10 files changed

Lines changed: 2078 additions & 3 deletions

File tree

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* @object-ui/core - Window Function Tests
11+
*
12+
* Tests for ObjectStack Spec v0.7.1 window function support
13+
*/
14+
15+
import { describe, it, expect } from 'vitest';
16+
import { QueryASTBuilder } from '../query-ast';
17+
import type { WindowNode, WindowFunction } from '@object-ui/types';
18+
19+
describe('QueryASTBuilder - Window Functions', () => {
20+
const builder = new QueryASTBuilder();
21+
22+
describe('buildWindow', () => {
23+
it('should build a simple row_number window function', () => {
24+
const config = {
25+
function: 'row_number' as WindowFunction,
26+
alias: 'row_num',
27+
partitionBy: ['department'],
28+
orderBy: [{ field: 'salary', direction: 'desc' as const }],
29+
};
30+
31+
// @ts-ignore - testing private method
32+
const result: WindowNode = builder.buildWindow(config);
33+
34+
expect(result).toMatchObject({
35+
type: 'window',
36+
function: 'row_number',
37+
alias: 'row_num',
38+
});
39+
40+
expect(result.partitionBy).toHaveLength(1);
41+
expect(result.partitionBy![0]).toMatchObject({
42+
type: 'field',
43+
name: 'department',
44+
});
45+
46+
expect(result.orderBy).toHaveLength(1);
47+
expect(result.orderBy![0]).toMatchObject({
48+
field: { type: 'field', name: 'salary' },
49+
direction: 'desc',
50+
});
51+
});
52+
53+
it('should build a rank window function with multiple partition fields', () => {
54+
const config = {
55+
function: 'rank' as WindowFunction,
56+
alias: 'rank_val',
57+
partitionBy: ['department', 'location'],
58+
orderBy: [
59+
{ field: 'performance_score', direction: 'desc' as const },
60+
{ field: 'tenure_years', direction: 'desc' as const },
61+
],
62+
};
63+
64+
// @ts-ignore
65+
const result: WindowNode = builder.buildWindow(config);
66+
67+
expect(result).toMatchObject({
68+
type: 'window',
69+
function: 'rank',
70+
alias: 'rank_val',
71+
});
72+
73+
expect(result.partitionBy).toHaveLength(2);
74+
expect(result.orderBy).toHaveLength(2);
75+
});
76+
77+
it('should build a lag window function with offset and default value', () => {
78+
const config = {
79+
function: 'lag' as WindowFunction,
80+
field: 'revenue',
81+
alias: 'prev_month_revenue',
82+
partitionBy: ['product_id'],
83+
orderBy: [{ field: 'month', direction: 'asc' as const }],
84+
offset: 1,
85+
defaultValue: 0,
86+
};
87+
88+
// @ts-ignore
89+
const result: WindowNode = builder.buildWindow(config);
90+
91+
expect(result).toMatchObject({
92+
type: 'window',
93+
function: 'lag',
94+
alias: 'prev_month_revenue',
95+
offset: 1,
96+
});
97+
98+
expect(result.field).toMatchObject({
99+
type: 'field',
100+
name: 'revenue',
101+
});
102+
103+
expect(result.defaultValue).toMatchObject({
104+
type: 'literal',
105+
value: 0,
106+
data_type: 'number',
107+
});
108+
});
109+
110+
it('should build a lead window function', () => {
111+
const config = {
112+
function: 'lead' as WindowFunction,
113+
field: 'sales',
114+
alias: 'next_day_sales',
115+
orderBy: [{ field: 'date', direction: 'asc' as const }],
116+
offset: 1,
117+
};
118+
119+
// @ts-ignore
120+
const result: WindowNode = builder.buildWindow(config);
121+
122+
expect(result).toMatchObject({
123+
type: 'window',
124+
function: 'lead',
125+
alias: 'next_day_sales',
126+
offset: 1,
127+
});
128+
});
129+
130+
it('should build aggregate window functions (sum, avg, count)', () => {
131+
const sumConfig = {
132+
function: 'sum' as WindowFunction,
133+
field: 'amount',
134+
alias: 'running_total',
135+
orderBy: [{ field: 'date', direction: 'asc' as const }],
136+
frame: {
137+
unit: 'rows' as const,
138+
start: 'unbounded_preceding' as const,
139+
end: 'current_row' as const,
140+
},
141+
};
142+
143+
// @ts-ignore
144+
const result: WindowNode = builder.buildWindow(sumConfig);
145+
146+
expect(result).toMatchObject({
147+
type: 'window',
148+
function: 'sum',
149+
alias: 'running_total',
150+
});
151+
152+
expect(result.field).toMatchObject({
153+
type: 'field',
154+
name: 'amount',
155+
});
156+
157+
expect(result.frame).toEqual({
158+
unit: 'rows',
159+
start: 'unbounded_preceding',
160+
end: 'current_row',
161+
});
162+
});
163+
164+
it('should build first_value window function', () => {
165+
const config = {
166+
function: 'first_value' as WindowFunction,
167+
field: 'price',
168+
alias: 'first_price',
169+
partitionBy: ['product_category'],
170+
orderBy: [{ field: 'created_at', direction: 'asc' as const }],
171+
};
172+
173+
// @ts-ignore
174+
const result: WindowNode = builder.buildWindow(config);
175+
176+
expect(result).toMatchObject({
177+
type: 'window',
178+
function: 'first_value',
179+
alias: 'first_price',
180+
});
181+
});
182+
183+
it('should build last_value window function', () => {
184+
const config = {
185+
function: 'last_value' as WindowFunction,
186+
field: 'status',
187+
alias: 'latest_status',
188+
partitionBy: ['customer_id'],
189+
orderBy: [{ field: 'updated_at', direction: 'desc' as const }],
190+
};
191+
192+
// @ts-ignore
193+
const result: WindowNode = builder.buildWindow(config);
194+
195+
expect(result).toMatchObject({
196+
type: 'window',
197+
function: 'last_value',
198+
alias: 'latest_status',
199+
});
200+
});
201+
202+
it('should handle window function without partition by', () => {
203+
const config = {
204+
function: 'row_number' as WindowFunction,
205+
alias: 'global_row_num',
206+
orderBy: [{ field: 'created_at', direction: 'asc' as const }],
207+
};
208+
209+
// @ts-ignore
210+
const result: WindowNode = builder.buildWindow(config);
211+
212+
expect(result.partitionBy).toBeUndefined();
213+
expect(result.orderBy).toBeDefined();
214+
});
215+
216+
it('should handle window function with frame specification', () => {
217+
const config = {
218+
function: 'avg' as WindowFunction,
219+
field: 'temperature',
220+
alias: 'moving_avg_3days',
221+
orderBy: [{ field: 'date', direction: 'asc' as const }],
222+
frame: {
223+
unit: 'rows' as const,
224+
start: { type: 'preceding' as const, offset: 2 },
225+
end: 'current_row' as const,
226+
},
227+
};
228+
229+
// @ts-ignore
230+
const result: WindowNode = builder.buildWindow(config);
231+
232+
expect(result.frame).toEqual({
233+
unit: 'rows',
234+
start: { type: 'preceding', offset: 2 },
235+
end: 'current_row',
236+
});
237+
});
238+
239+
it('should build dense_rank window function', () => {
240+
const config = {
241+
function: 'dense_rank' as WindowFunction,
242+
alias: 'dense_rank_val',
243+
partitionBy: ['team'],
244+
orderBy: [{ field: 'score', direction: 'desc' as const }],
245+
};
246+
247+
// @ts-ignore
248+
const result: WindowNode = builder.buildWindow(config);
249+
250+
expect(result).toMatchObject({
251+
type: 'window',
252+
function: 'dense_rank',
253+
alias: 'dense_rank_val',
254+
});
255+
});
256+
257+
it('should build percent_rank window function', () => {
258+
const config = {
259+
function: 'percent_rank' as WindowFunction,
260+
alias: 'percentile',
261+
partitionBy: ['class'],
262+
orderBy: [{ field: 'exam_score', direction: 'desc' as const }],
263+
};
264+
265+
// @ts-ignore
266+
const result: WindowNode = builder.buildWindow(config);
267+
268+
expect(result).toMatchObject({
269+
type: 'window',
270+
function: 'percent_rank',
271+
alias: 'percentile',
272+
});
273+
});
274+
});
275+
});

packages/core/src/query/query-ast.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* ObjectUI - Query AST Builder
33
* Phase 3.3: QuerySchema AST implementation
4+
* ObjectStack Spec v0.7.1: Window functions support
45
*/
56

67
import type {
@@ -15,6 +16,9 @@ import type {
1516
LimitNode,
1617
OffsetNode,
1718
AggregateNode,
19+
WindowNode,
20+
WindowFunction,
21+
WindowFrame,
1822
FieldNode,
1923
LiteralNode,
2024
OperatorNode,
@@ -64,7 +68,7 @@ export class QueryASTBuilder {
6468
}
6569

6670
private buildSelect(query: QuerySchema): SelectNode {
67-
const fields: (FieldNode | AggregateNode)[] = [];
71+
const fields: (FieldNode | AggregateNode | WindowNode)[] = [];
6872

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

84+
// Add window functions if they exist (future extension point)
85+
// query.windows?.forEach(win => fields.push(this.buildWindow(win)));
86+
8087
return {
8188
type: 'select',
8289
fields,
@@ -279,6 +286,55 @@ export class QueryASTBuilder {
279286
};
280287
}
281288

289+
/**
290+
* Build window function node (ObjectStack Spec v0.7.1)
291+
*/
292+
private buildWindow(config: {
293+
function: WindowFunction;
294+
field?: string;
295+
alias: string;
296+
partitionBy?: string[];
297+
orderBy?: Array<{ field: string; direction: 'asc' | 'desc' }>;
298+
frame?: WindowFrame;
299+
offset?: number;
300+
defaultValue?: any;
301+
}): WindowNode {
302+
const node: WindowNode = {
303+
type: 'window',
304+
function: config.function,
305+
alias: config.alias,
306+
};
307+
308+
if (config.field) {
309+
node.field = this.buildField(config.field);
310+
}
311+
312+
if (config.partitionBy && config.partitionBy.length > 0) {
313+
node.partitionBy = config.partitionBy.map(field => this.buildField(field));
314+
}
315+
316+
if (config.orderBy && config.orderBy.length > 0) {
317+
node.orderBy = config.orderBy.map(sort => ({
318+
field: this.buildField(sort.field),
319+
direction: sort.direction,
320+
}));
321+
}
322+
323+
if (config.frame) {
324+
node.frame = config.frame;
325+
}
326+
327+
if (config.offset !== undefined) {
328+
node.offset = config.offset;
329+
}
330+
331+
if (config.defaultValue !== undefined) {
332+
node.defaultValue = this.buildLiteral(config.defaultValue);
333+
}
334+
335+
return node;
336+
}
337+
282338
optimize(ast: QueryAST): QueryAST {
283339
return ast;
284340
}

0 commit comments

Comments
 (0)