Skip to content

Commit 6905f41

Browse files
Copilothotlong
andcommitted
Add QueryAST support and DriverInterface compatibility to SQL driver
- Added @objectstack/spec dependency - Implemented driver metadata (name, version, supports) - Added normalizeQuery() to support both QueryAST and legacy formats - Added support for 'top' parameter (QueryAST) alongside 'limit' (legacy) - Added support for 'aggregations' (QueryAST) alongside 'aggregate' (legacy) - Added connect() and checkHealth() lifecycle methods - Created comprehensive QueryAST format tests - Maintained full backward compatibility with existing UnifiedQuery format Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent fcf27ff commit 6905f41

3 files changed

Lines changed: 251 additions & 10 deletions

File tree

packages/drivers/sql/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
},
2424
"dependencies": {
2525
"@objectql/types": "workspace:*",
26+
"@objectstack/spec": "^0.2.0",
2627
"knex": "^3.1.0"
2728
},
2829
"devDependencies": {

packages/drivers/sql/src/index.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export class SqlDriver implements Driver {
127127
*
128128
* QueryAST format uses 'top' for limit, while UnifiedQuery uses 'limit'.
129129
* QueryAST sort is array of {field, order}, while UnifiedQuery is array of [field, order].
130+
* QueryAST uses 'aggregations', while legacy uses 'aggregate'.
130131
*/
131132
private normalizeQuery(query: any): any {
132133
if (!query) return {};
@@ -138,6 +139,16 @@ export class SqlDriver implements Driver {
138139
normalized.limit = normalized.top;
139140
}
140141

142+
// Normalize aggregations/aggregate
143+
if (normalized.aggregations !== undefined && normalized.aggregate === undefined) {
144+
// Convert QueryAST aggregations format to legacy aggregate format
145+
normalized.aggregate = normalized.aggregations.map((agg: any) => ({
146+
func: agg.function,
147+
field: agg.field,
148+
alias: agg.alias
149+
}));
150+
}
151+
141152
// Normalize sort format
142153
if (normalized.sort && Array.isArray(normalized.sort)) {
143154
// Check if it's already in the array format [field, order]
@@ -250,12 +261,14 @@ export class SqlDriver implements Driver {
250261
}
251262

252263
async count(objectName: string, filters: any, options?: any): Promise<number> {
264+
// Normalize the query to support both QueryAST and legacy formats
265+
const normalizedQuery = this.normalizeQuery(filters);
253266
const builder = this.getBuilder(objectName, options);
254267

255-
let actualFilters = filters;
268+
let actualFilters = normalizedQuery;
256269
// If filters is a query object with a 'filters' property, use that
257-
if (filters && !Array.isArray(filters) && filters.filters) {
258-
actualFilters = filters.filters;
270+
if (normalizedQuery && !Array.isArray(normalizedQuery) && normalizedQuery.filters) {
271+
actualFilters = normalizedQuery.filters;
259272
}
260273

261274
if (actualFilters) {
@@ -285,25 +298,26 @@ export class SqlDriver implements Driver {
285298

286299
// Aggregation
287300
async aggregate(objectName: string, query: any, options?: any): Promise<any> {
301+
const normalizedQuery = this.normalizeQuery(query);
288302
const builder = this.getBuilder(objectName, options);
289303

290304
// 1. Filter
291-
if (query.filters) {
292-
this.applyFilters(builder, query.filters);
305+
if (normalizedQuery.filters) {
306+
this.applyFilters(builder, normalizedQuery.filters);
293307
}
294308

295309
// 2. GroupBy
296-
if (query.groupBy) {
297-
builder.groupBy(query.groupBy);
310+
if (normalizedQuery.groupBy) {
311+
builder.groupBy(normalizedQuery.groupBy);
298312
// Select grouping keys
299-
for (const field of query.groupBy) {
313+
for (const field of normalizedQuery.groupBy) {
300314
builder.select(field);
301315
}
302316
}
303317

304318
// 3. Aggregate Functions
305-
if (query.aggregate) {
306-
for (const agg of query.aggregate) {
319+
if (normalizedQuery.aggregate) {
320+
for (const agg of normalizedQuery.aggregate) {
307321
// func: 'sum', field: 'amount', alias: 'total'
308322
const rawFunc = this.mapAggregateFunc(agg.func);
309323
if (agg.alias) {
@@ -872,6 +886,28 @@ export class SqlDriver implements Driver {
872886
return uniqueColumns;
873887
}
874888

889+
/**
890+
* Connect to the database (optional - connection is established in constructor)
891+
* This method is here for DriverInterface compatibility.
892+
*/
893+
async connect(): Promise<void> {
894+
// Connection is already established in constructor via Knex
895+
// This is a no-op for compatibility with DriverInterface
896+
return Promise.resolve();
897+
}
898+
899+
/**
900+
* Check database connection health
901+
*/
902+
async checkHealth(): Promise<boolean> {
903+
try {
904+
await this.knex.raw('SELECT 1');
905+
return true;
906+
} catch (error) {
907+
return false;
908+
}
909+
}
910+
875911
async disconnect() {
876912
await this.knex.destroy();
877913
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* ObjectQL
3+
* Copyright (c) 2026-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+
import { SqlDriver } from '../src';
10+
11+
/**
12+
* QueryAST format tests
13+
*
14+
* Tests the driver's compatibility with @objectstack/spec QueryAST format
15+
* which uses:
16+
* - 'top' instead of 'limit'
17+
* - 'aggregations' instead of 'aggregate'
18+
* - sort as array of {field, order} objects
19+
*/
20+
describe('SqlDriver (QueryAST Format)', () => {
21+
let driver: SqlDriver;
22+
23+
beforeEach(async () => {
24+
// Init ephemeral in-memory database
25+
driver = new SqlDriver({
26+
client: 'sqlite3',
27+
connection: {
28+
filename: ':memory:'
29+
},
30+
useNullAsDefault: true
31+
});
32+
33+
const k = (driver as any).knex;
34+
35+
await k.schema.createTable('products', (t: any) => {
36+
t.string('id').primary();
37+
t.string('name');
38+
t.float('price');
39+
t.string('category');
40+
});
41+
42+
await k('products').insert([
43+
{ id: '1', name: 'Laptop', price: 1200, category: 'Electronics' },
44+
{ id: '2', name: 'Mouse', price: 25, category: 'Electronics' },
45+
{ id: '3', name: 'Desk', price: 350, category: 'Furniture' },
46+
{ id: '4', name: 'Chair', price: 200, category: 'Furniture' },
47+
{ id: '5', name: 'Monitor', price: 400, category: 'Electronics' }
48+
]);
49+
});
50+
51+
afterEach(async () => {
52+
const k = (driver as any).knex;
53+
await k.destroy();
54+
});
55+
56+
describe('Driver Metadata', () => {
57+
it('should expose driver metadata for ObjectStack compatibility', () => {
58+
expect(driver.name).toBe('SqlDriver');
59+
expect(driver.version).toBeDefined();
60+
expect(driver.supports).toBeDefined();
61+
expect(driver.supports.transactions).toBe(true);
62+
expect(driver.supports.joins).toBe(true);
63+
});
64+
});
65+
66+
describe('Lifecycle Methods', () => {
67+
it('should support connect method', async () => {
68+
await expect(driver.connect()).resolves.toBeUndefined();
69+
});
70+
71+
it('should support checkHealth method', async () => {
72+
const healthy = await driver.checkHealth();
73+
expect(healthy).toBe(true);
74+
});
75+
76+
it('should support disconnect method', async () => {
77+
await expect(driver.disconnect()).resolves.toBeUndefined();
78+
// After disconnect, health check should fail
79+
const healthy = await driver.checkHealth();
80+
expect(healthy).toBe(false);
81+
});
82+
});
83+
84+
describe('QueryAST Format Support', () => {
85+
it('should support QueryAST with "top" instead of "limit"', async () => {
86+
const query = {
87+
fields: ['name', 'price'],
88+
top: 2,
89+
sort: [{ field: 'price', order: 'asc' as const }]
90+
};
91+
const results = await driver.find('products', query);
92+
93+
expect(results.length).toBe(2);
94+
expect(results[0].name).toBe('Mouse');
95+
expect(results[1].name).toBe('Chair');
96+
});
97+
98+
it('should support QueryAST sort format with object notation', async () => {
99+
const query = {
100+
fields: ['name'],
101+
sort: [
102+
{ field: 'category', order: 'asc' as const },
103+
{ field: 'price', order: 'desc' as const }
104+
]
105+
};
106+
const results = await driver.find('products', query);
107+
108+
// Electronics: Monitor(400), Laptop(1200), Mouse(25)
109+
// Furniture: Desk(350), Chair(200)
110+
expect(results.length).toBe(5);
111+
expect(results[0].name).toBe('Laptop'); // Electronics, highest price
112+
expect(results[3].name).toBe('Desk'); // Furniture, highest price
113+
});
114+
115+
it('should support QueryAST with filters and pagination', async () => {
116+
const query = {
117+
filters: [['category', '=', 'Electronics']],
118+
skip: 1,
119+
top: 1,
120+
sort: [{ field: 'price', order: 'asc' as const }]
121+
};
122+
const results = await driver.find('products', query);
123+
124+
expect(results.length).toBe(1);
125+
expect(results[0].name).toBe('Monitor'); // Second cheapest electronics
126+
});
127+
128+
it('should support aggregations in QueryAST format', async () => {
129+
const query = {
130+
aggregations: [
131+
{ function: 'sum' as const, field: 'price', alias: 'total_price' },
132+
{ function: 'count' as const, field: '*', alias: 'count' }
133+
],
134+
groupBy: ['category']
135+
};
136+
const results = await driver.aggregate('products', query);
137+
138+
expect(results.length).toBe(2);
139+
140+
const electronics = results.find((r: any) => r.category === 'Electronics');
141+
const furniture = results.find((r: any) => r.category === 'Furniture');
142+
143+
expect(electronics).toBeDefined();
144+
expect(electronics.total_price).toBe(1625); // 1200 + 25 + 400
145+
146+
expect(furniture).toBeDefined();
147+
expect(furniture.total_price).toBe(550); // 350 + 200
148+
});
149+
150+
it('should support count with QueryAST format', async () => {
151+
const query = {
152+
filters: [['price', '>', 300]]
153+
};
154+
const count = await driver.count('products', query);
155+
expect(count).toBe(3); // Laptop, Desk, Monitor
156+
});
157+
});
158+
159+
describe('Backward Compatibility', () => {
160+
it('should still support legacy UnifiedQuery format with "limit"', async () => {
161+
const query = {
162+
fields: ['name'],
163+
limit: 2,
164+
sort: [['price', 'asc']]
165+
};
166+
const results = await driver.find('products', query);
167+
168+
expect(results.length).toBe(2);
169+
expect(results[0].name).toBe('Mouse');
170+
});
171+
172+
it('should still support legacy aggregate format', async () => {
173+
const query = {
174+
aggregate: [
175+
{ func: 'avg', field: 'price', alias: 'avg_price' }
176+
],
177+
groupBy: ['category']
178+
};
179+
const results = await driver.aggregate('products', query);
180+
181+
expect(results.length).toBe(2);
182+
const electronics = results.find((r: any) => r.category === 'Electronics');
183+
expect(electronics.avg_price).toBeCloseTo(541.67, 1); // (1200 + 25 + 400) / 3
184+
});
185+
});
186+
187+
describe('Mixed Format Support', () => {
188+
it('should handle query with both top and skip', async () => {
189+
const query = {
190+
top: 3,
191+
skip: 2,
192+
sort: [{ field: 'name', order: 'asc' as const }]
193+
};
194+
const results = await driver.find('products', query);
195+
196+
expect(results.length).toBe(3);
197+
// Alphabetically: Chair, Desk, Laptop, Monitor, Mouse
198+
// Skip 2 (Chair, Desk), take 3 (Laptop, Monitor, Mouse)
199+
expect(results[0].name).toBe('Laptop');
200+
expect(results[1].name).toBe('Monitor');
201+
expect(results[2].name).toBe('Mouse');
202+
});
203+
});
204+
});

0 commit comments

Comments
 (0)