Skip to content
Open
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
61 changes: 56 additions & 5 deletions packages/cubejs-schema-compiler/src/adapter/OracleQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,46 @@ class OracleFilter extends BaseFilter {
}

export class OracleQuery extends BaseQuery {
private static readonly ORACLE_TZ_FORMAT_WITH_Z = 'YYYY-MM-DD"T"HH24:MI:SS.FF"Z"';

private static readonly ORACLE_TZ_FORMAT_NO_Z = 'YYYY-MM-DD"T"HH24:MI:SS.FF';

/**
* Determines if a value represents a SQL identifier (column name) rather than a bind parameter.
* Handles both unquoted identifiers (e.g., "date_from", "table.column") and quoted
* identifiers (e.g., "date_from", "table"."column").
*/
private isIdentifierToken(value: string): boolean {
return (
/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(value) ||
/^"[^"]+"(\."[^"]+")*$/.test(value)
);
}

/**
* Generates Oracle TO_TIMESTAMP_TZ function call for timezone-aware timestamp conversion.
*
* The format string must match the actual data format:
* - Filter parameters ('?') come as ISO 8601 strings with 'Z' suffix (e.g., '2024-01-01T00:00:00.000Z')
* - Generated time series columns (date_from/date_to) contain VALUES data without 'Z' (e.g., '2024-01-01T00:00:00.000')
*
* @param value - Either '?' for bind parameters, a column identifier, or a SQL expression
* @param includeZFormat - Whether format string should expect 'Z' suffix (true for filter params, false for series columns)
* @returns Oracle SQL expression with appropriate bind placeholder or direct column reference
*/
private toTimestampTz(value: string, includeZFormat: boolean): string {
const format = includeZFormat ? OracleQuery.ORACLE_TZ_FORMAT_WITH_Z : OracleQuery.ORACLE_TZ_FORMAT_NO_Z;
if (value === '?') {
return `TO_TIMESTAMP_TZ(:"?", '${format}')`;
}
if (this.isIdentifierToken(value)) {
// Column identifiers (e.g., date_from, date_to from generated time series) - use directly
return `TO_TIMESTAMP_TZ(${value}, '${format}')`;
}
// SQL expressions or literals - embed directly in TO_TIMESTAMP_TZ call
return `TO_TIMESTAMP_TZ(${value}, '${format}')`;
}

/**
* "LIMIT" on Oracle is illegal
* TODO replace with limitOffsetClause override
Expand Down Expand Up @@ -75,15 +115,26 @@ export class OracleQuery extends BaseQuery {
return field;
}

/**
* Casts a value to Oracle DATE type using timezone-aware parsing.
* For bind parameters ('?'), includes 'Z' suffix in format string.
* For column identifiers (e.g., date_from/date_to from time series), omits 'Z'.
*
* @param value - Bind parameter placeholder '?', column identifier, or SQL expression
*/
public dateTimeCast(value) {
// Use timezone-aware parsing for ISO 8601 with milliseconds and trailing 'Z', then cast to DATE
// to preserve index-friendly comparisons against DATE columns.
return `CAST(TO_TIMESTAMP_TZ(:"${value}", 'YYYY-MM-DD"T"HH24:MI:SS.FF"Z"') AS DATE)`;
return `CAST(${this.toTimestampTz(value, value === '?')} AS DATE)`;
}

/**
* Casts a value to Oracle TIMESTAMP WITH TIME ZONE.
* For bind parameters ('?'), includes 'Z' suffix in format string.
* For column identifiers (e.g., date_from/date_to from time series), omits 'Z'.
*
* @param value - Bind parameter placeholder '?', column identifier, or SQL expression
*/
public timeStampCast(value) {
// Return timezone-aware timestamp for TIMESTAMP comparisons
return `TO_TIMESTAMP_TZ(:"${value}", 'YYYY-MM-DD"T"HH24:MI:SS.FF"Z"')`;
return this.toTimestampTz(value, value === '?');
}

public timeStampParam(timeDimension) {
Expand Down
64 changes: 64 additions & 0 deletions packages/cubejs-schema-compiler/test/unit/oracle-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,4 +904,68 @@ describe('OracleQuery', () => {
expect(sql).toMatch(/GROUP BY\s+TRUNC/);
expect(params).toEqual(['2024-01-01T00:00:00.000Z', '2024-12-31T23:59:59.999Z']);
});

it('does not bind generated time series date_from/date_to', async () => {
await compiler.compile();

const query = new OracleQuery(
{ joinGraph, cubeEvaluator, compiler },
{
measures: ['visitors.thisPeriod', 'visitors.priorPeriod'],
timeDimensions: [
{
dimension: 'visitors.createdAt',
dateRange: ['2023-01-01', '2024-12-31'],
granularity: 'year'
}
],
filters: [
{ member: 'visitors.source', operator: 'equals', values: ['web'] }
],
timezone: 'UTC'
}
);

const [sql] = query.buildSqlAndParams();

// Ensure generated time series columns are not treated as bind params
expect(sql).not.toMatch(/:\s*"date_from"/);
expect(sql).not.toMatch(/:\s*"date_to"/);

// Ensure we cast series columns directly via TO_TIMESTAMP_TZ(..., '...FF') without binds
expect(sql).toMatch(/TO_TIMESTAMP_TZ\(date_from,\s*'YYYY-MM-DD"T"HH24:MI:SS\.FF'\)/);
expect(sql).toMatch(/TO_TIMESTAMP_TZ\(date_to,\s*'YYYY-MM-DD"T"HH24:MI:SS\.FF'\)/);
});

it('uses Z format for bind parameters and no-Z format for column identifiers', async () => {
await compiler.compile();

const query = new OracleQuery(
{ joinGraph, cubeEvaluator, compiler },
{
measures: ['visitors.count'],
timezone: 'UTC'
}
);

// Test direct method calls to verify format selection logic
// Bind parameter '?' should use Z format (for ISO 8601 strings with Z)
const bindResult = query.dateTimeCast('?');
expect(bindResult).toContain('TO_TIMESTAMP_TZ(:"?",');
expect(bindResult).toContain('YYYY-MM-DD"T"HH24:MI:SS.FF"Z"');

// Column identifier should use no-Z format (for VALUES data without Z)
const columnResult = query.dateTimeCast('date_from');
expect(columnResult).toContain('TO_TIMESTAMP_TZ(date_from,');
expect(columnResult).toContain('YYYY-MM-DD"T"HH24:MI:SS.FF');
expect(columnResult).not.toContain('"Z"');

// Verify timeStampCast has same behavior
const bindTimestamp = query.timeStampCast('?');
expect(bindTimestamp).toContain('YYYY-MM-DD"T"HH24:MI:SS.FF"Z"');

const columnTimestamp = query.timeStampCast('date_to');
expect(columnTimestamp).toContain('YYYY-MM-DD"T"HH24:MI:SS.FF');
expect(columnTimestamp).not.toContain('"Z"');
});
});
Loading