Skip to content

Commit 823d1a4

Browse files
committed
feat: AST SQL rewriting
Signed-off-by: Alex Qyoun-ae <4062971+MazterQyou@users.noreply.github.com>
1 parent 3051ccd commit 823d1a4

10 files changed

Lines changed: 7326 additions & 1 deletion

File tree

packages/cubejs-api-gateway/src/gateway.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
ResultWrapper,
2222
rowsToColumnar,
2323
} from '@cubejs-backend/native';
24+
import type { SqlFilterItem, SqlFiltersResponse } from '@cubejs-backend/native';
2425
import type {
2526
Application as ExpressApplication,
2627
ErrorRequestHandler,
@@ -119,6 +120,14 @@ type HandleErrorOptions = {
119120
requestStarted?: Date
120121
};
121122

123+
/**
124+
* Upper bound on the number of filters a single SQL filters request may carry,
125+
* counting the leaves of filter groups. A batch costs one rewrite of the query
126+
* regardless of its size, so the bound is about how large a predicate the
127+
* rewrite engine is asked to saturate over.
128+
*/
129+
const MAX_SQL_FILTERS = 500;
130+
122131
function userAsyncHandler(handler: (req: Request & { context: ExtendedRequestContext }, res: ExpressResponse) => Promise<void>) {
123132
return (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
124133
handler(req as any, res).catch(next);
@@ -422,6 +431,26 @@ class ApiGateway {
422431
});
423432
}));
424433

434+
app.get(`${this.basePath}/v1/sql-filters`, userMiddlewares, userAsyncHandler(async (req: any, res) => {
435+
await this.getSqlFilters({
436+
query: req.query.query,
437+
context: req.context,
438+
res: this.resToResultFn(res)
439+
});
440+
}));
441+
442+
app.post(`${this.basePath}/v1/sql-filters`, jsonParser, userMiddlewares, userAsyncHandler(async (req, res) => {
443+
await this.modifySqlFilters({
444+
query: req.body.query,
445+
add: req.body.add,
446+
set: req.body.set,
447+
delete: req.body.delete,
448+
replace: req.body.replace,
449+
context: req.context,
450+
res: this.resToResultFn(res)
451+
});
452+
}));
453+
425454
app.get(`${this.basePath}/v1/dry-run`, userMiddlewares, userAsyncHandler(async (req: any, res) => {
426455
await this.dryRun({
427456
query: req.query.query,
@@ -1486,6 +1515,154 @@ class ApiGateway {
14861515
}
14871516
}
14881517

1518+
/**
1519+
* Responds with the result of a SQL filters operation. Planning and
1520+
* rewriting failures are reported by the native layer in-band as
1521+
* `{ status: 'error', error }`, so they are mapped onto a 4xx to keep
1522+
* the endpoint's failures visible to status-code-based clients.
1523+
*/
1524+
protected async resSqlFilters(result: SqlFiltersResponse, res: ResponseResultFn) {
1525+
if (result.status === 'error') {
1526+
await res(result, { status: 400 });
1527+
return;
1528+
}
1529+
1530+
await res(result);
1531+
}
1532+
1533+
/**
1534+
* Returns the list of Cube filters of a SQL query in Cube query format,
1535+
* extracted from the logical plan of the query.
1536+
*/
1537+
protected async getSqlFilters({
1538+
query,
1539+
context,
1540+
res,
1541+
}: { query: string } & BaseRequest) {
1542+
try {
1543+
await this.assertApiScope('sql', context.securityContext);
1544+
1545+
if (typeof query !== 'string' || !query.trim()) {
1546+
throw new UserError('query parameter must be a non-empty string');
1547+
}
1548+
1549+
const result = await this.sqlServer.getSqlFilters(query, context.securityContext);
1550+
1551+
await this.resSqlFilters(result, res);
1552+
} catch (e: any) {
1553+
this.handleError({
1554+
e,
1555+
context,
1556+
query,
1557+
res,
1558+
});
1559+
}
1560+
}
1561+
1562+
/**
1563+
* Modifies the Cube filters of a SQL query. Exactly one of `add`, `set`,
1564+
* `delete` or `replace` must be provided. `add` adds the requested filters,
1565+
* `set` replaces all outermost filters with the specified set, `delete`
1566+
* attempts to delete the requested filters (all occurrences of equal
1567+
* filters are deleted), and `replace` replaces one exact set of filters
1568+
* with another (all occurrences of equal filters are replaced). Filters
1569+
* may be `and`/`or` filter groups; existing filters must match perfectly
1570+
* to be deleted or replaced. Only the outermost SELECT is modified, and
1571+
* only dimensions/measures available in the outermost SELECT can be
1572+
* filtered.
1573+
*/
1574+
protected async modifySqlFilters({
1575+
query,
1576+
add,
1577+
set,
1578+
delete: deleteFilters,
1579+
replace,
1580+
context,
1581+
res,
1582+
}: { query: string, add?: unknown, set?: unknown, delete?: unknown, replace?: unknown } & BaseRequest) {
1583+
try {
1584+
await this.assertApiScope('sql', context.securityContext);
1585+
1586+
if (typeof query !== 'string' || !query.trim()) {
1587+
throw new UserError('query parameter must be a non-empty string');
1588+
}
1589+
1590+
const requestedOps = [add, set, deleteFilters, replace].filter((op) => op !== undefined);
1591+
if (requestedOps.length !== 1) {
1592+
throw new UserError('Exactly one of add, set, delete or replace parameters is required');
1593+
}
1594+
1595+
// The size of the predicate the rewrite engine ends up with is what
1596+
// costs, and a filter group nests any number of leaves inside a single
1597+
// array entry, so the leaves are what is bounded
1598+
const countFilters = (filters: unknown[]): number => filters.reduce<number>(
1599+
(total, filter: any) => {
1600+
// A group is whichever of the two fields holds an array - the other
1601+
// may be present and not an array, which is for the native layer to
1602+
// reject rather than something to recurse into
1603+
const group = Array.isArray(filter?.and) ? filter.and : filter?.or;
1604+
1605+
return total + (Array.isArray(group) ? Math.max(countFilters(group), 1) : 1);
1606+
},
1607+
0,
1608+
);
1609+
1610+
const assertFilterArray = (filters: unknown, name: string): SqlFilterItem[] => {
1611+
if (!Array.isArray(filters)) {
1612+
throw new UserError(`${name} parameter must be an array of filters`);
1613+
}
1614+
1615+
if (countFilters(filters) > MAX_SQL_FILTERS) {
1616+
throw new UserError(`${name} parameter must contain at most ${MAX_SQL_FILTERS} filters`);
1617+
}
1618+
1619+
return filters;
1620+
};
1621+
1622+
if (add !== undefined) {
1623+
const result = await this.sqlServer.addSqlFilters(query, assertFilterArray(add, 'add'), context.securityContext);
1624+
1625+
await this.resSqlFilters(result, res);
1626+
return;
1627+
}
1628+
1629+
if (set !== undefined) {
1630+
const result = await this.sqlServer.setSqlFilters(query, assertFilterArray(set, 'set'), context.securityContext);
1631+
1632+
await this.resSqlFilters(result, res);
1633+
return;
1634+
}
1635+
1636+
if (deleteFilters !== undefined) {
1637+
const result = await this.sqlServer.deleteSqlFilters(query, assertFilterArray(deleteFilters, 'delete'), context.securityContext);
1638+
1639+
await this.resSqlFilters(result, res);
1640+
return;
1641+
}
1642+
1643+
if (typeof replace !== 'object' || replace === null || Array.isArray(replace)) {
1644+
throw new UserError('replace parameter must be an object with old and new filter arrays');
1645+
}
1646+
1647+
const { old: oldFilters, new: newFilters } = replace as Record<string, unknown>;
1648+
const result = await this.sqlServer.replaceSqlFilters(
1649+
query,
1650+
assertFilterArray(oldFilters, 'replace.old'),
1651+
assertFilterArray(newFilters, 'replace.new'),
1652+
context.securityContext,
1653+
);
1654+
1655+
await this.resSqlFilters(result, res);
1656+
} catch (e: any) {
1657+
this.handleError({
1658+
e,
1659+
context,
1660+
query,
1661+
res,
1662+
});
1663+
}
1664+
}
1665+
14891666
public async sql({
14901667
query,
14911668
context,

packages/cubejs-api-gateway/src/sql-server.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@ import {
55
execSql,
66
sql4sql,
77
rest4sql,
8+
getSqlFilters,
9+
addSqlFilters,
10+
setSqlFilters,
11+
deleteSqlFilters,
12+
replaceSqlFilters,
813
SqlInterfaceInstance,
914
Request as NativeRequest,
1015
LoadRequestMeta,
1116
Sql4SqlResponse,
1217
QueryConvertResponse,
18+
SqlFilterItem,
19+
SqlFiltersResponse,
1320
} from '@cubejs-backend/native';
1421
import type { ShutdownMode } from '@cubejs-backend/native';
1522
import { displayCLIWarning, getEnv, CacheMode } from '@cubejs-backend/shared';
@@ -88,6 +95,26 @@ export class SQLServer {
8895
return rest4sql(this.getSqlInterfaceInstance(), sqlQuery, securityContext);
8996
}
9097

98+
public async getSqlFilters(sqlQuery: string, securityContext?: unknown): Promise<SqlFiltersResponse> {
99+
return getSqlFilters(this.getSqlInterfaceInstance(), sqlQuery, securityContext);
100+
}
101+
102+
public async addSqlFilters(sqlQuery: string, filters: SqlFilterItem[], securityContext?: unknown): Promise<SqlFiltersResponse> {
103+
return addSqlFilters(this.getSqlInterfaceInstance(), sqlQuery, filters, securityContext);
104+
}
105+
106+
public async setSqlFilters(sqlQuery: string, filters: SqlFilterItem[], securityContext?: unknown): Promise<SqlFiltersResponse> {
107+
return setSqlFilters(this.getSqlInterfaceInstance(), sqlQuery, filters, securityContext);
108+
}
109+
110+
public async deleteSqlFilters(sqlQuery: string, filters: SqlFilterItem[], securityContext?: unknown): Promise<SqlFiltersResponse> {
111+
return deleteSqlFilters(this.getSqlInterfaceInstance(), sqlQuery, filters, securityContext);
112+
}
113+
114+
public async replaceSqlFilters(sqlQuery: string, oldFilters: SqlFilterItem[], newFilters: SqlFilterItem[], securityContext?: unknown): Promise<SqlFiltersResponse> {
115+
return replaceSqlFilters(this.getSqlInterfaceInstance(), sqlQuery, oldFilters, newFilters, securityContext);
116+
}
117+
91118
protected buildCheckSqlAuth(options: SQLServerOptions): CheckSQLAuthFn {
92119
return (options.checkSqlAuth && this.wrapCheckSqlAuthFn(options.checkSqlAuth))
93120
|| this.createDefaultCheckSqlAuthFn(options);

packages/cubejs-backend-native/js/index.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,21 @@ export type QueryConvertResponse = {
170170
error?: string;
171171
};
172172

173+
export type SqlFilterItem = {
174+
member?: string;
175+
operator?: string;
176+
values?: string[];
177+
and?: SqlFilterItem[];
178+
or?: SqlFilterItem[];
179+
};
180+
181+
export type SqlFiltersResponse = {
182+
status: string;
183+
sql?: string;
184+
filters?: SqlFilterItem[];
185+
error?: string;
186+
};
187+
173188
let loadedNative: any = null;
174189

175190
export function loadNative() {
@@ -459,6 +474,36 @@ export const rest4sql = async (instance: SqlInterfaceInstance, sqlQuery: string,
459474
return native.rest4sql(instance, sqlQuery, securityContext ? JSON.stringify(securityContext) : null);
460475
};
461476

477+
export const getSqlFilters = async (instance: SqlInterfaceInstance, sqlQuery: string, securityContext?: unknown): Promise<SqlFiltersResponse> => {
478+
const native = loadNative();
479+
480+
return native.getSqlFilters(instance, sqlQuery, securityContext ? JSON.stringify(securityContext) : null);
481+
};
482+
483+
export const addSqlFilters = async (instance: SqlInterfaceInstance, sqlQuery: string, filters: SqlFilterItem[], securityContext?: unknown): Promise<SqlFiltersResponse> => {
484+
const native = loadNative();
485+
486+
return native.addSqlFilters(instance, sqlQuery, JSON.stringify(filters), securityContext ? JSON.stringify(securityContext) : null);
487+
};
488+
489+
export const setSqlFilters = async (instance: SqlInterfaceInstance, sqlQuery: string, filters: SqlFilterItem[], securityContext?: unknown): Promise<SqlFiltersResponse> => {
490+
const native = loadNative();
491+
492+
return native.setSqlFilters(instance, sqlQuery, JSON.stringify(filters), securityContext ? JSON.stringify(securityContext) : null);
493+
};
494+
495+
export const deleteSqlFilters = async (instance: SqlInterfaceInstance, sqlQuery: string, filters: SqlFilterItem[], securityContext?: unknown): Promise<SqlFiltersResponse> => {
496+
const native = loadNative();
497+
498+
return native.deleteSqlFilters(instance, sqlQuery, JSON.stringify(filters), securityContext ? JSON.stringify(securityContext) : null);
499+
};
500+
501+
export const replaceSqlFilters = async (instance: SqlInterfaceInstance, sqlQuery: string, oldFilters: SqlFilterItem[], newFilters: SqlFilterItem[], securityContext?: unknown): Promise<SqlFiltersResponse> => {
502+
const native = loadNative();
503+
504+
return native.replaceSqlFilters(instance, sqlQuery, JSON.stringify(oldFilters), JSON.stringify(newFilters), securityContext ? JSON.stringify(securityContext) : null);
505+
};
506+
462507
export const buildSqlAndParams = (cubeEvaluator: any): any[] => {
463508
const native = loadNative();
464509
return native.buildSqlAndParams(cubeEvaluator);

packages/cubejs-backend-native/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub mod orchestrator;
2020
pub mod python;
2121
pub mod rest4sql;
2222
pub mod sql4sql;
23+
pub mod sql_filters;
2324
pub mod stream;
2425
pub mod template;
2526
pub mod transport;

packages/cubejs-backend-native/src/node_export.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ use crate::cubesql_utils::with_session;
1818
use crate::logger::NodeBridgeLogger;
1919
use crate::rest4sql::rest4sql;
2020
use crate::sql4sql::sql4sql;
21+
use crate::sql_filters::{
22+
add_sql_filters, delete_sql_filters, get_sql_filters, replace_sql_filters, set_sql_filters,
23+
};
2124
use crate::stream::{OnCloseHandler, OnDrainHandler};
2225
use crate::tokio_runtime_node;
2326
use crate::transport::NodeBridgeTransport;
@@ -845,6 +848,11 @@ pub fn register_module_exports<C: NodeConfiguration + 'static>(
845848
cx.export_function("execSql", exec_sql)?;
846849
cx.export_function("sql4sql", sql4sql)?;
847850
cx.export_function("rest4sql", rest4sql)?;
851+
cx.export_function("getSqlFilters", get_sql_filters)?;
852+
cx.export_function("addSqlFilters", add_sql_filters)?;
853+
cx.export_function("setSqlFilters", set_sql_filters)?;
854+
cx.export_function("deleteSqlFilters", delete_sql_filters)?;
855+
cx.export_function("replaceSqlFilters", replace_sql_filters)?;
848856
cx.export_function("isFallbackBuild", is_fallback_build)?;
849857
cx.export_function("__js_to_clrepr_to_js", debug_js_to_clrepr_to_js)?;
850858

packages/cubejs-backend-native/src/rest4sql.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use cubesql::compile::engine::df::scan::CubeScanNode;
1414
use cubesql::transport::TransportLoadRequestQuery;
1515
use cubesql::CubeError;
1616

17-
fn json_value_to_js<'ctx>(
17+
pub(crate) fn json_value_to_js<'ctx>(
1818
cx: &mut impl Context<'ctx>,
1919
value: &serde_json::Value,
2020
) -> JsResult<'ctx, JsValue> {

0 commit comments

Comments
 (0)