Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/pages/product/apis-integrations/rest-api/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
[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
2 changes: 2 additions & 0 deletions packages/cubejs-api-gateway/openspec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,8 @@ components:
type: "array"
items:
$ref: "#/components/schemas/V1LoadRequestJoinHint"
timezone:
type: "string"
V1LoadRequest:
type: "object"
properties:
Expand Down
2 changes: 1 addition & 1 deletion packages/cubejs-api-gateway/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/cubejs-api-gateway/src/sql-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Sql4SqlResponse> {
Expand Down
78 changes: 78 additions & 0 deletions packages/cubejs-api-gateway/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
});
});
4 changes: 2 additions & 2 deletions packages/cubejs-backend-native/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
export const execSql = async (instance: SqlInterfaceInstance, sqlQuery: string, stream: any, securityContext?: any, cacheMode: CacheMode = 'stale-if-slow', timezone?: string): Promise<void> => {
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
Expand Down
25 changes: 25 additions & 0 deletions packages/cubejs-backend-native/src/node_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ async fn handle_sql_query(
stream_methods: WritableStreamMethods,
sql_query: &str,
cache_mode: &str,
timezone: Option<String>,
) -> Result<(), CubeError> {
let span_id = Some(Arc::new(SpanId::new(
Uuid::new_v4().to_string(),
Expand Down Expand Up @@ -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)?;

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

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

let timezone: Option<String> = match cx.argument::<JsValue>(5) {
Ok(val) => {
if val.is_a::<JsNull, _>(&mut cx) || val.is_a::<JsUndefined, _>(&mut cx) {
None
} else {
match val.downcast::<JsString, _>(&mut cx) {
Ok(v) => Some(v.value(&mut cx)),
Err(_) => None,
}
}
}
Err(_) => None,
};

let js_stream_on_fn = Arc::new(
node_stream
.get::<JsFunction, _, _>(&mut cx, "on")?
Expand Down Expand Up @@ -488,6 +512,7 @@ fn exec_sql(mut cx: FunctionContext) -> JsResult<JsValue> {
stream_methods,
&sql_query,
&cache_mode,
timezone,
)
.await;

Expand Down
2 changes: 1 addition & 1 deletion rust/cubesql/cubeclient/.openapi-generator/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
7.15.0
7.17.0
3 changes: 3 additions & 0 deletions rust/cubesql/cubeclient/src/models/v1_load_request_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub struct V1LoadRequestQuery {
pub subquery_joins: Option<Vec<models::V1LoadRequestQueryJoinSubquery>>,
#[serde(rename = "joinHints", skip_serializing_if = "Option::is_none")]
pub join_hints: Option<Vec<Vec<String>>>,
#[serde(rename = "timezone", skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
}

impl V1LoadRequestQuery {
Expand All @@ -51,6 +53,7 @@ impl V1LoadRequestQuery {
ungrouped: None,
subquery_joins: None,
join_hints: None,
timezone: None,
}
}
}
1 change: 1 addition & 0 deletions rust/cubesql/cubesql/src/compile/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ impl QueryBuilder {
ungrouped: None,
subquery_joins: None,
join_hints: None,
timezone: None,
},
meta: self.meta,
}
Expand Down
1 change: 1 addition & 0 deletions rust/cubesql/cubesql/src/compile/engine/df/wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3357,6 +3357,7 @@ impl WrappedSelectNode {
});

let load_request = V1LoadRequestQuery {
timezone: None,
measures: Some(
aggregate
.iter()
Expand Down
8 changes: 8 additions & 0 deletions rust/cubesql/cubesql/src/compile/rewrite/converter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions rust/cubesql/cubesql/src/sql/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ pub struct SessionState {
auth_context_expiration: Duration,

pub cache_mode: RwLockSync<Option<CacheMode>>,

pub query_timezone: RwLockSync<Option<String>>,
}

impl SessionState {
Expand Down Expand Up @@ -124,6 +126,7 @@ impl SessionState {
statements: RWLockAsync::new(HashMap::new()),
auth_context_expiration,
cache_mode: RwLockSync::new(None),
query_timezone: RwLockSync::new(None),
}
}

Expand Down
Loading