Skip to content

Commit 499ddc6

Browse files
feat(api-gateway): Support query timezone in /cubesql API endpoint (#10189)
* openapi spec: add timezone to request * pass query timezone through cubesql into cube.js * add some basic tests * docs: `timezone` parameter for the `/v1/cubesql` endpoint --------- Co-authored-by: Igor Lukanin <[email protected]>
1 parent 91f8dea commit 499ddc6

File tree

13 files changed

+132
-8
lines changed

13 files changed

+132
-8
lines changed

docs/pages/product/apis-integrations/rest-api/reference.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,8 @@ This endpoint is part of the [SQL API][ref-sql-api].
322322

323323
| Parameter | Description | Required |
324324
| --- | --- | --- |
325-
| `query` | The SQL query to run. | ✅ Yes |
325+
| `query` | The SQL query to run | ✅ Yes |
326+
| `timezone` | The [time zone][ref-time-zone] for this query in the [TZ Database Name][link-tzdb] format, e.g., `America/Los_Angeles` | ❌ No |
326327
| `cache` | See [cache control][ref-cache-control]. `stale-if-slow` by default | ❌ No |
327328

328329
Response: a stream of newline-delimited JSON objects. The first object contains
@@ -642,4 +643,6 @@ Keep-Alive: timeout=5
642643
[ref-sql-api]: /product/apis-integrations/sql-api
643644
[ref-orchestration-api]: /product/apis-integrations/orchestration-api
644645
[ref-folders]: /product/data-modeling/reference/view#folders
645-
[ref-cache-control]: /product/apis-integrations/rest-api#cache-control
646+
[ref-cache-control]: /product/apis-integrations/rest-api#cache-control
647+
[ref-time-zone]: /product/apis-integrations/queries#time-zone
648+
[link-tzdb]: https://en.wikipedia.org/wiki/Tz_database

packages/cubejs-api-gateway/openspec.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,8 @@ components:
479479
type: "array"
480480
items:
481481
$ref: "#/components/schemas/V1LoadRequestJoinHint"
482+
timezone:
483+
type: "string"
482484
V1LoadRequest:
483485
type: "object"
484486
properties:

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ class ApiGateway {
438438
try {
439439
await this.assertApiScope('data', req.context?.securityContext);
440440

441-
await this.sqlServer.execSql(req.body.query, res, req.context?.securityContext, req.body.cache);
441+
await this.sqlServer.execSql(req.body.query, res, req.context?.securityContext, req.body.cache, req.body.timezone);
442442
} catch (e: any) {
443443
this.handleError({
444444
e,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ export class SQLServer {
6565
throw new Error('Native api gateway is not enabled');
6666
}
6767

68-
public async execSql(sqlQuery: string, stream: any, securityContext?: any, cacheMode?: CacheMode) {
69-
await execSql(this.sqlInterfaceInstance!, sqlQuery, stream, securityContext, cacheMode);
68+
public async execSql(sqlQuery: string, stream: any, securityContext?: any, cacheMode?: CacheMode, timezone?: string) {
69+
await execSql(this.sqlInterfaceInstance!, sqlQuery, stream, securityContext, cacheMode, timezone);
7070
}
7171

7272
public async sql4sql(sqlQuery: string, disablePostProcessing: boolean, securityContext?: unknown): Promise<Sql4SqlResponse> {

packages/cubejs-api-gateway/test/index.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,4 +1150,82 @@ describe('API Gateway', () => {
11501150
expect(dataSourceStorage.$testOrchestratorConnectionsDone).toEqual(false);
11511151
});
11521152
});
1153+
1154+
describe('/v1/cubesql', () => {
1155+
test('simple query works', async () => {
1156+
const { app, apiGateway } = await createApiGateway();
1157+
1158+
// Mock the sqlServer.execSql method
1159+
const execSqlMock = jest.fn(async (query, stream, securityContext, cacheMode, timezone) => {
1160+
// Simulate writing schema and data to the stream
1161+
stream.write(`${JSON.stringify({
1162+
schema: [{ name: 'id', column_type: 'Int' }]
1163+
})}\n`);
1164+
stream.write(`${JSON.stringify({
1165+
data: [[1], [2], [3]]
1166+
})}\n`);
1167+
stream.end();
1168+
});
1169+
1170+
apiGateway.getSQLServer().execSql = execSqlMock;
1171+
1172+
await request(app)
1173+
.post('/cubejs-api/v1/cubesql')
1174+
.set('Content-type', 'application/json')
1175+
.set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M')
1176+
.send({
1177+
query: 'SELECT id FROM test LIMIT 3'
1178+
})
1179+
.responseType('text')
1180+
.expect(200);
1181+
1182+
// Verify the mock was called with correct parameters
1183+
expect(execSqlMock).toHaveBeenCalledWith(
1184+
'SELECT id FROM test LIMIT 3',
1185+
expect.anything(),
1186+
{},
1187+
undefined,
1188+
undefined
1189+
);
1190+
});
1191+
1192+
test('timezone can be passed', async () => {
1193+
const { app, apiGateway } = await createApiGateway();
1194+
1195+
// Mock the sqlServer.execSql method
1196+
const execSqlMock = jest.fn(async (query, stream, securityContext, cacheMode, timezone) => {
1197+
// Simulate writing schema and data to the stream
1198+
stream.write(`${JSON.stringify({
1199+
schema: [{ name: 'created_at', column_type: 'Timestamp' }]
1200+
})}\n`);
1201+
stream.write(`${JSON.stringify({
1202+
data: [['2025-12-22T16:00:00.000'], ['2025-12-24T16:00:00.000']]
1203+
})}\n`);
1204+
stream.end();
1205+
});
1206+
1207+
apiGateway.getSQLServer().execSql = execSqlMock;
1208+
1209+
await request(app)
1210+
.post('/cubejs-api/v1/cubesql')
1211+
.set('Content-type', 'application/json')
1212+
.set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M')
1213+
.send({
1214+
query: 'SELECT created_at FROM orders WHERE created_at > \'2025-12-22 13:00:00\'::timestamptz',
1215+
cache: 'stale-while-revalidate',
1216+
timezone: 'America/Los_Angeles'
1217+
})
1218+
.responseType('text')
1219+
.expect(200);
1220+
1221+
// Verify the mock was called with correct parameters including timezone
1222+
expect(execSqlMock).toHaveBeenCalledWith(
1223+
'SELECT created_at FROM orders WHERE created_at > \'2025-12-22 13:00:00\'::timestamptz',
1224+
expect.anything(),
1225+
{},
1226+
'stale-while-revalidate',
1227+
'America/Los_Angeles'
1228+
);
1229+
});
1230+
});
11531231
});

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -437,10 +437,10 @@ export const shutdownInterface = async (instance: SqlInterfaceInstance, shutdown
437437
await native.shutdownInterface(instance, shutdownMode);
438438
};
439439

440-
export const execSql = async (instance: SqlInterfaceInstance, sqlQuery: string, stream: any, securityContext?: any, cacheMode: CacheMode = 'stale-if-slow'): Promise<void> => {
440+
export const execSql = async (instance: SqlInterfaceInstance, sqlQuery: string, stream: any, securityContext?: any, cacheMode: CacheMode = 'stale-if-slow', timezone?: string): Promise<void> => {
441441
const native = loadNative();
442442

443-
await native.execSql(instance, sqlQuery, stream, securityContext ? JSON.stringify(securityContext) : null, cacheMode);
443+
await native.execSql(instance, sqlQuery, stream, securityContext ? JSON.stringify(securityContext) : null, cacheMode, timezone);
444444
};
445445

446446
// TODO parse result from native code

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ async fn handle_sql_query(
226226
stream_methods: WritableStreamMethods,
227227
sql_query: &str,
228228
cache_mode: &str,
229+
timezone: Option<String>,
229230
) -> Result<(), CubeError> {
230231
let span_id = Some(Arc::new(SpanId::new(
231232
Uuid::new_v4().to_string(),
@@ -255,6 +256,15 @@ async fn handle_sql_query(
255256
.await?;
256257
}
257258

259+
{
260+
let mut cm = session
261+
.state
262+
.query_timezone
263+
.write()
264+
.expect("failed to unlock session query_timezone for change");
265+
*cm = timezone;
266+
}
267+
258268
let cache_enum = cache_mode.parse().map_err(CubeError::user)?;
259269

260270
{
@@ -440,6 +450,20 @@ fn exec_sql(mut cx: FunctionContext) -> JsResult<JsValue> {
440450

441451
let cache_mode = cx.argument::<JsString>(4)?.value(&mut cx);
442452

453+
let timezone: Option<String> = match cx.argument::<JsValue>(5) {
454+
Ok(val) => {
455+
if val.is_a::<JsNull, _>(&mut cx) || val.is_a::<JsUndefined, _>(&mut cx) {
456+
None
457+
} else {
458+
match val.downcast::<JsString, _>(&mut cx) {
459+
Ok(v) => Some(v.value(&mut cx)),
460+
Err(_) => None,
461+
}
462+
}
463+
}
464+
Err(_) => None,
465+
};
466+
443467
let js_stream_on_fn = Arc::new(
444468
node_stream
445469
.get::<JsFunction, _, _>(&mut cx, "on")?
@@ -488,6 +512,7 @@ fn exec_sql(mut cx: FunctionContext) -> JsResult<JsValue> {
488512
stream_methods,
489513
&sql_query,
490514
&cache_mode,
515+
timezone,
491516
)
492517
.await;
493518

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
7.15.0
1+
7.17.0

rust/cubesql/cubeclient/src/models/v1_load_request_query.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ pub struct V1LoadRequestQuery {
3535
pub subquery_joins: Option<Vec<models::V1LoadRequestQueryJoinSubquery>>,
3636
#[serde(rename = "joinHints", skip_serializing_if = "Option::is_none")]
3737
pub join_hints: Option<Vec<Vec<String>>>,
38+
#[serde(rename = "timezone", skip_serializing_if = "Option::is_none")]
39+
pub timezone: Option<String>,
3840
}
3941

4042
impl V1LoadRequestQuery {
@@ -51,6 +53,7 @@ impl V1LoadRequestQuery {
5153
ungrouped: None,
5254
subquery_joins: None,
5355
join_hints: None,
56+
timezone: None,
5457
}
5558
}
5659
}

rust/cubesql/cubesql/src/compile/builder.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ impl QueryBuilder {
153153
ungrouped: None,
154154
subquery_joins: None,
155155
join_hints: None,
156+
timezone: None,
156157
},
157158
meta: self.meta,
158159
}

0 commit comments

Comments
 (0)