diff --git a/docs/pages/product/apis-integrations/rest-api/reference.mdx b/docs/pages/product/apis-integrations/rest-api/reference.mdx index 00c7c2357225f..f409ad7143bae 100644 --- a/docs/pages/product/apis-integrations/rest-api/reference.mdx +++ b/docs/pages/product/apis-integrations/rest-api/reference.mdx @@ -322,7 +322,8 @@ This endpoint is part of the [SQL API][ref-sql-api]. | Parameter | Description | Required | | --- | --- | --- | -| `query` | The SQL query to run. | ✅ Yes | +| `query` | The SQL query to run | ✅ Yes | +| `timezone` | The [time zone][ref-time-zone] for this query in the [TZ Database Name][link-tzdb] format, e.g., `America/Los_Angeles` | ❌ No | | `cache` | See [cache control][ref-cache-control]. `stale-if-slow` by default | ❌ No | Response: a stream of newline-delimited JSON objects. The first object contains @@ -642,4 +643,6 @@ Keep-Alive: timeout=5 [ref-sql-api]: /product/apis-integrations/sql-api [ref-orchestration-api]: /product/apis-integrations/orchestration-api [ref-folders]: /product/data-modeling/reference/view#folders -[ref-cache-control]: /product/apis-integrations/rest-api#cache-control \ No newline at end of file +[ref-cache-control]: /product/apis-integrations/rest-api#cache-control +[ref-time-zone]: /product/apis-integrations/queries#time-zone +[link-tzdb]: https://en.wikipedia.org/wiki/Tz_database \ No newline at end of file diff --git a/packages/cubejs-api-gateway/openspec.yml b/packages/cubejs-api-gateway/openspec.yml index 681978404dc42..f83b0642b32af 100644 --- a/packages/cubejs-api-gateway/openspec.yml +++ b/packages/cubejs-api-gateway/openspec.yml @@ -479,6 +479,8 @@ components: type: "array" items: $ref: "#/components/schemas/V1LoadRequestJoinHint" + timezone: + type: "string" V1LoadRequest: type: "object" properties: diff --git a/packages/cubejs-api-gateway/src/gateway.ts b/packages/cubejs-api-gateway/src/gateway.ts index d8bc4a96f28be..b5ef3ba78a8d8 100644 --- a/packages/cubejs-api-gateway/src/gateway.ts +++ b/packages/cubejs-api-gateway/src/gateway.ts @@ -438,7 +438,7 @@ class ApiGateway { try { await this.assertApiScope('data', req.context?.securityContext); - await this.sqlServer.execSql(req.body.query, res, req.context?.securityContext, req.body.cache); + await this.sqlServer.execSql(req.body.query, res, req.context?.securityContext, req.body.cache, req.body.timezone); } catch (e: any) { this.handleError({ e, diff --git a/packages/cubejs-api-gateway/src/sql-server.ts b/packages/cubejs-api-gateway/src/sql-server.ts index 8384547bf9149..2d670762c21a9 100644 --- a/packages/cubejs-api-gateway/src/sql-server.ts +++ b/packages/cubejs-api-gateway/src/sql-server.ts @@ -65,8 +65,8 @@ export class SQLServer { throw new Error('Native api gateway is not enabled'); } - public async execSql(sqlQuery: string, stream: any, securityContext?: any, cacheMode?: CacheMode) { - await execSql(this.sqlInterfaceInstance!, sqlQuery, stream, securityContext, cacheMode); + public async execSql(sqlQuery: string, stream: any, securityContext?: any, cacheMode?: CacheMode, timezone?: string) { + await execSql(this.sqlInterfaceInstance!, sqlQuery, stream, securityContext, cacheMode, timezone); } public async sql4sql(sqlQuery: string, disablePostProcessing: boolean, securityContext?: unknown): Promise { diff --git a/packages/cubejs-api-gateway/test/index.test.ts b/packages/cubejs-api-gateway/test/index.test.ts index 78fa66e2d3e43..bf4a2b4d7fe9e 100644 --- a/packages/cubejs-api-gateway/test/index.test.ts +++ b/packages/cubejs-api-gateway/test/index.test.ts @@ -1150,4 +1150,82 @@ describe('API Gateway', () => { expect(dataSourceStorage.$testOrchestratorConnectionsDone).toEqual(false); }); }); + + describe('/v1/cubesql', () => { + test('simple query works', async () => { + const { app, apiGateway } = await createApiGateway(); + + // Mock the sqlServer.execSql method + const execSqlMock = jest.fn(async (query, stream, securityContext, cacheMode, timezone) => { + // Simulate writing schema and data to the stream + stream.write(`${JSON.stringify({ + schema: [{ name: 'id', column_type: 'Int' }] + })}\n`); + stream.write(`${JSON.stringify({ + data: [[1], [2], [3]] + })}\n`); + stream.end(); + }); + + apiGateway.getSQLServer().execSql = execSqlMock; + + await request(app) + .post('/cubejs-api/v1/cubesql') + .set('Content-type', 'application/json') + .set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M') + .send({ + query: 'SELECT id FROM test LIMIT 3' + }) + .responseType('text') + .expect(200); + + // Verify the mock was called with correct parameters + expect(execSqlMock).toHaveBeenCalledWith( + 'SELECT id FROM test LIMIT 3', + expect.anything(), + {}, + undefined, + undefined + ); + }); + + test('timezone can be passed', async () => { + const { app, apiGateway } = await createApiGateway(); + + // Mock the sqlServer.execSql method + const execSqlMock = jest.fn(async (query, stream, securityContext, cacheMode, timezone) => { + // Simulate writing schema and data to the stream + stream.write(`${JSON.stringify({ + schema: [{ name: 'created_at', column_type: 'Timestamp' }] + })}\n`); + stream.write(`${JSON.stringify({ + data: [['2025-12-22T16:00:00.000'], ['2025-12-24T16:00:00.000']] + })}\n`); + stream.end(); + }); + + apiGateway.getSQLServer().execSql = execSqlMock; + + await request(app) + .post('/cubejs-api/v1/cubesql') + .set('Content-type', 'application/json') + .set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M') + .send({ + query: 'SELECT created_at FROM orders WHERE created_at > \'2025-12-22 13:00:00\'::timestamptz', + cache: 'stale-while-revalidate', + timezone: 'America/Los_Angeles' + }) + .responseType('text') + .expect(200); + + // Verify the mock was called with correct parameters including timezone + expect(execSqlMock).toHaveBeenCalledWith( + 'SELECT created_at FROM orders WHERE created_at > \'2025-12-22 13:00:00\'::timestamptz', + expect.anything(), + {}, + 'stale-while-revalidate', + 'America/Los_Angeles' + ); + }); + }); }); diff --git a/packages/cubejs-backend-native/js/index.ts b/packages/cubejs-backend-native/js/index.ts index 36e3cd8a20296..3df9162a486d0 100644 --- a/packages/cubejs-backend-native/js/index.ts +++ b/packages/cubejs-backend-native/js/index.ts @@ -437,10 +437,10 @@ export const shutdownInterface = async (instance: SqlInterfaceInstance, shutdown await native.shutdownInterface(instance, shutdownMode); }; -export const execSql = async (instance: SqlInterfaceInstance, sqlQuery: string, stream: any, securityContext?: any, cacheMode: CacheMode = 'stale-if-slow'): Promise => { +export const execSql = async (instance: SqlInterfaceInstance, sqlQuery: string, stream: any, securityContext?: any, cacheMode: CacheMode = 'stale-if-slow', timezone?: string): Promise => { const native = loadNative(); - await native.execSql(instance, sqlQuery, stream, securityContext ? JSON.stringify(securityContext) : null, cacheMode); + await native.execSql(instance, sqlQuery, stream, securityContext ? JSON.stringify(securityContext) : null, cacheMode, timezone); }; // TODO parse result from native code diff --git a/packages/cubejs-backend-native/src/node_export.rs b/packages/cubejs-backend-native/src/node_export.rs index 2350d6ae0ca10..094eba5c2493d 100644 --- a/packages/cubejs-backend-native/src/node_export.rs +++ b/packages/cubejs-backend-native/src/node_export.rs @@ -226,6 +226,7 @@ async fn handle_sql_query( stream_methods: WritableStreamMethods, sql_query: &str, cache_mode: &str, + timezone: Option, ) -> Result<(), CubeError> { let span_id = Some(Arc::new(SpanId::new( Uuid::new_v4().to_string(), @@ -255,6 +256,15 @@ async fn handle_sql_query( .await?; } + { + let mut cm = session + .state + .query_timezone + .write() + .expect("failed to unlock session query_timezone for change"); + *cm = timezone; + } + let cache_enum = cache_mode.parse().map_err(CubeError::user)?; { @@ -440,6 +450,20 @@ fn exec_sql(mut cx: FunctionContext) -> JsResult { let cache_mode = cx.argument::(4)?.value(&mut cx); + let timezone: Option = match cx.argument::(5) { + Ok(val) => { + if val.is_a::(&mut cx) || val.is_a::(&mut cx) { + None + } else { + match val.downcast::(&mut cx) { + Ok(v) => Some(v.value(&mut cx)), + Err(_) => None, + } + } + } + Err(_) => None, + }; + let js_stream_on_fn = Arc::new( node_stream .get::(&mut cx, "on")? @@ -488,6 +512,7 @@ fn exec_sql(mut cx: FunctionContext) -> JsResult { stream_methods, &sql_query, &cache_mode, + timezone, ) .await; diff --git a/rust/cubesql/cubeclient/.openapi-generator/VERSION b/rust/cubesql/cubeclient/.openapi-generator/VERSION index 368fd8fd8d784..6328c5424a4a6 100644 --- a/rust/cubesql/cubeclient/.openapi-generator/VERSION +++ b/rust/cubesql/cubeclient/.openapi-generator/VERSION @@ -1 +1 @@ -7.15.0 +7.17.0 diff --git a/rust/cubesql/cubeclient/src/models/v1_load_request_query.rs b/rust/cubesql/cubeclient/src/models/v1_load_request_query.rs index ae966f9c65160..3cf2d9c6500e5 100644 --- a/rust/cubesql/cubeclient/src/models/v1_load_request_query.rs +++ b/rust/cubesql/cubeclient/src/models/v1_load_request_query.rs @@ -35,6 +35,8 @@ pub struct V1LoadRequestQuery { pub subquery_joins: Option>, #[serde(rename = "joinHints", skip_serializing_if = "Option::is_none")] pub join_hints: Option>>, + #[serde(rename = "timezone", skip_serializing_if = "Option::is_none")] + pub timezone: Option, } impl V1LoadRequestQuery { @@ -51,6 +53,7 @@ impl V1LoadRequestQuery { ungrouped: None, subquery_joins: None, join_hints: None, + timezone: None, } } } diff --git a/rust/cubesql/cubesql/src/compile/builder.rs b/rust/cubesql/cubesql/src/compile/builder.rs index 7fdc2e86bc908..799947c688092 100644 --- a/rust/cubesql/cubesql/src/compile/builder.rs +++ b/rust/cubesql/cubesql/src/compile/builder.rs @@ -153,6 +153,7 @@ impl QueryBuilder { ungrouped: None, subquery_joins: None, join_hints: None, + timezone: None, }, meta: self.meta, } diff --git a/rust/cubesql/cubesql/src/compile/engine/df/wrapper.rs b/rust/cubesql/cubesql/src/compile/engine/df/wrapper.rs index 4b31eeb658169..bdd37112e145e 100644 --- a/rust/cubesql/cubesql/src/compile/engine/df/wrapper.rs +++ b/rust/cubesql/cubesql/src/compile/engine/df/wrapper.rs @@ -3357,6 +3357,7 @@ impl WrappedSelectNode { }); let load_request = V1LoadRequestQuery { + timezone: None, measures: Some( aggregate .iter() diff --git a/rust/cubesql/cubesql/src/compile/rewrite/converter.rs b/rust/cubesql/cubesql/src/compile/rewrite/converter.rs index 68b715d99f4ad..6f2487ffdb4ed 100644 --- a/rust/cubesql/cubesql/src/compile/rewrite/converter.rs +++ b/rust/cubesql/cubesql/src/compile/rewrite/converter.rs @@ -1583,6 +1583,14 @@ impl LanguageToLogicalPlanConverter { let mut query_order = Vec::new(); let mut query_dimensions = Vec::new(); + query.timezone = self + .cube_context + .session_state + .query_timezone + .read() + .unwrap() + .clone(); + for m in members { match m { LogicalPlanLanguage::Measure(measure_params) => { diff --git a/rust/cubesql/cubesql/src/sql/session.rs b/rust/cubesql/cubesql/src/sql/session.rs index a51b4cf6fe6d3..a1e3b6589b8a2 100644 --- a/rust/cubesql/cubesql/src/sql/session.rs +++ b/rust/cubesql/cubesql/src/sql/session.rs @@ -92,6 +92,8 @@ pub struct SessionState { auth_context_expiration: Duration, pub cache_mode: RwLockSync>, + + pub query_timezone: RwLockSync>, } impl SessionState { @@ -124,6 +126,7 @@ impl SessionState { statements: RWLockAsync::new(HashMap::new()), auth_context_expiration, cache_mode: RwLockSync::new(None), + query_timezone: RwLockSync::new(None), } }