Skip to content

Commit cd3a5ba

Browse files
Copilothotlong
andcommitted
Extract query-specific logic into query module (FilterTranslator, QueryBuilder)
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 71864a0 commit cd3a5ba

5 files changed

Lines changed: 251 additions & 179 deletions

File tree

packages/foundation/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export * from './plugin';
2020
export * from './validator-plugin';
2121
export * from './formula-plugin';
2222

23+
// Export query-specific modules (ObjectQL core competency)
24+
export * from './query';
25+
2326
export * from './action';
2427
export * from './hook';
2528
export * from './object';
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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 type { Filter } from '@objectql/types';
10+
import type { FilterNode } from '@objectstack/spec';
11+
12+
/**
13+
* Filter Translator
14+
*
15+
* Translates ObjectQL Filter (FilterCondition) to ObjectStack FilterNode format.
16+
* Converts modern object-based syntax to legacy array-based syntax for backward compatibility.
17+
*
18+
* @example
19+
* Input: { age: { $gte: 18 }, $or: [{ status: "active" }, { role: "admin" }] }
20+
* Output: [["age", ">=", 18], "or", [["status", "=", "active"], "or", ["role", "=", "admin"]]]
21+
*/
22+
export class FilterTranslator {
23+
/**
24+
* Translate filters from ObjectQL format to ObjectStack FilterNode format
25+
*/
26+
translate(filters?: Filter): FilterNode | undefined {
27+
if (!filters) {
28+
return undefined;
29+
}
30+
31+
// Backward compatibility: if it's already an array (old format), pass through
32+
if (Array.isArray(filters)) {
33+
return filters as unknown as FilterNode;
34+
}
35+
36+
// If it's an empty object, return undefined
37+
if (typeof filters === 'object' && Object.keys(filters).length === 0) {
38+
return undefined;
39+
}
40+
41+
return this.convertToNode(filters);
42+
}
43+
44+
/**
45+
* Recursively converts FilterCondition to FilterNode array format
46+
*/
47+
private convertToNode(filter: Filter): FilterNode {
48+
const nodes: any[] = [];
49+
50+
// Process logical operators first
51+
if (filter.$and) {
52+
const andNodes = filter.$and.map(f => this.convertToNode(f));
53+
nodes.push(...this.interleaveWithOperator(andNodes, 'and'));
54+
}
55+
56+
if (filter.$or) {
57+
const orNodes = filter.$or.map(f => this.convertToNode(f));
58+
if (nodes.length > 0) {
59+
nodes.push('and');
60+
}
61+
nodes.push(...this.interleaveWithOperator(orNodes, 'or'));
62+
}
63+
64+
// Note: $not operator is not currently supported in the legacy FilterNode format
65+
if (filter.$not) {
66+
throw new Error('$not operator is not supported. Use $ne for field negation instead.');
67+
}
68+
69+
// Process field conditions
70+
for (const [field, value] of Object.entries(filter)) {
71+
if (field.startsWith('$')) {
72+
continue; // Skip logical operators (already processed)
73+
}
74+
75+
if (nodes.length > 0) {
76+
nodes.push('and');
77+
}
78+
79+
// Handle field value
80+
if (value === null || value === undefined) {
81+
nodes.push([field, '=', value]);
82+
} else if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
83+
// Explicit operators - multiple operators on same field are AND-ed together
84+
const entries = Object.entries(value);
85+
for (let i = 0; i < entries.length; i++) {
86+
const [op, opValue] = entries[i];
87+
88+
// Add 'and' before each operator (except the very first node)
89+
if (nodes.length > 0 || i > 0) {
90+
nodes.push('and');
91+
}
92+
93+
const legacyOp = this.mapOperatorToLegacy(op);
94+
nodes.push([field, legacyOp, opValue]);
95+
}
96+
} else {
97+
// Implicit equality
98+
nodes.push([field, '=', value]);
99+
}
100+
}
101+
102+
// Return as FilterNode (type assertion for backward compatibility)
103+
return (nodes.length === 1 ? nodes[0] : nodes) as unknown as FilterNode;
104+
}
105+
106+
/**
107+
* Interleaves filter nodes with a logical operator
108+
*/
109+
private interleaveWithOperator(nodes: FilterNode[], operator: string): any[] {
110+
if (nodes.length === 0) return [];
111+
if (nodes.length === 1) return [nodes[0]];
112+
113+
const result: any[] = [nodes[0]];
114+
for (let i = 1; i < nodes.length; i++) {
115+
result.push(operator, nodes[i]);
116+
}
117+
return result;
118+
}
119+
120+
/**
121+
* Maps modern $-prefixed operators to legacy format
122+
*/
123+
private mapOperatorToLegacy(operator: string): string {
124+
const mapping: Record<string, string> = {
125+
'$eq': '=',
126+
'$ne': '!=',
127+
'$gt': '>',
128+
'$gte': '>=',
129+
'$lt': '<',
130+
'$lte': '<=',
131+
'$in': 'in',
132+
'$nin': 'nin',
133+
'$contains': 'contains',
134+
'$startsWith': 'startswith',
135+
'$endsWith': 'endswith',
136+
'$null': 'is_null',
137+
'$exist': 'is_not_null',
138+
'$between': 'between',
139+
};
140+
141+
return mapping[operator] || operator.replace('$', '');
142+
}
143+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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+
/**
10+
* Query Module
11+
*
12+
* This module contains ObjectQL's query-specific functionality:
13+
* - FilterTranslator: Converts ObjectQL filters to ObjectStack FilterNode
14+
* - QueryBuilder: Builds ObjectStack QueryAST from ObjectQL UnifiedQuery
15+
*
16+
* These are the core components that differentiate ObjectQL from generic runtime systems.
17+
*/
18+
19+
export * from './filter-translator';
20+
export * from './query-builder';
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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 type { UnifiedQuery } from '@objectql/types';
10+
import type { QueryAST } from '@objectstack/spec';
11+
import { FilterTranslator } from './filter-translator';
12+
13+
/**
14+
* Query Builder
15+
*
16+
* Builds ObjectStack QueryAST from ObjectQL UnifiedQuery.
17+
* This is the central query construction module for ObjectQL.
18+
*/
19+
export class QueryBuilder {
20+
private filterTranslator: FilterTranslator;
21+
22+
constructor() {
23+
this.filterTranslator = new FilterTranslator();
24+
}
25+
26+
/**
27+
* Build a QueryAST from a UnifiedQuery
28+
*
29+
* @param objectName - Target object name
30+
* @param query - ObjectQL UnifiedQuery
31+
* @returns ObjectStack QueryAST
32+
*/
33+
build(objectName: string, query: UnifiedQuery): QueryAST {
34+
const ast: QueryAST = {
35+
object: objectName,
36+
};
37+
38+
// Map fields
39+
if (query.fields) {
40+
ast.fields = query.fields;
41+
}
42+
43+
// Map filters using FilterTranslator
44+
if (query.filters) {
45+
ast.filters = this.filterTranslator.translate(query.filters);
46+
}
47+
48+
// Map sort
49+
if (query.sort) {
50+
ast.sort = query.sort.map(([field, order]) => ({
51+
field,
52+
order: order as 'asc' | 'desc'
53+
}));
54+
}
55+
56+
// Map pagination
57+
if (query.limit !== undefined) {
58+
ast.top = query.limit;
59+
}
60+
if (query.skip !== undefined) {
61+
ast.skip = query.skip;
62+
}
63+
64+
// Map groupBy
65+
if (query.groupBy) {
66+
ast.groupBy = query.groupBy;
67+
}
68+
69+
// Map aggregations
70+
if (query.aggregate) {
71+
ast.aggregations = query.aggregate.map(agg => ({
72+
function: agg.func as any,
73+
field: agg.field,
74+
alias: agg.alias || `${agg.func}_${agg.field}`
75+
}));
76+
}
77+
78+
return ast;
79+
}
80+
}

0 commit comments

Comments
 (0)