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
3 changes: 3 additions & 0 deletions .github/workflows/drivers-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ jobs:
- databricks-jdbc-export-bucket-gcs-prefix
- mssql
- mysql
- oracle
- postgres
- postgres-pre-agg-credentials
- redshift
Expand Down Expand Up @@ -299,6 +300,8 @@ jobs:
use_tesseract_sql_planner: true
- database: mssql
use_tesseract_sql_planner: true
- database: oracle
use_tesseract_sql_planner: true
fail-fast: false

steps:
Expand Down
189 changes: 153 additions & 36 deletions packages/cubejs-oracle-driver/driver/OracleDriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,28 @@
const {
getEnv,
assertDataSource,
Pool,
} = require('@cubejs-backend/shared');
const { BaseDriver, TableColumn } = require('@cubejs-backend/base-driver');
const { BaseDriver, TableColumn, createPoolName } = require('@cubejs-backend/base-driver');
const oracledb = require('oracledb');
const { reduce } = require('ramda');

// Maps Oracle `metaData.dbTypeName` strings to Cube generic types. NUMBER and the
// TIMESTAMP* family are handled separately (scale-based / prefix match) below.
const OracleTypeToGenericType = {
varchar2: 'text',
nvarchar2: 'text',
char: 'text',
nchar: 'text',
clob: 'text',
nclob: 'text',
long: 'text',
binary_float: 'float',
binary_double: 'double',
date: 'timestamp',
'number': 'decimal',
};

const sortByKeys = (unordered) => {
const ordered = {};

Expand Down Expand Up @@ -43,20 +60,11 @@ const reduceCb = (result, i) => {
return sortByKeys(result);
};

/**
* Oracle driver class.
*/
class OracleDriver extends BaseDriver {
/**
* Returns default concurrency value.
*/
static getDefaultConcurrency() {
return 2;
}

/**
* Class constructor.
*/
constructor(config = {}) {
super({
testConnectionTimeout: config.testConnectionTimeout,
Expand All @@ -72,20 +80,51 @@ class OracleDriver extends BaseDriver {
this.db.partRows = 100000;
this.db.maxRows = 100000;
this.db.prefetchRows = 500;

const { maxPoolSize, pool, ...connectionConfig } = config;

this.config = {
user: getEnv('dbUser', { dataSource, preAggregations }),
password: getEnv('dbPass', { dataSource, preAggregations }),
db: getEnv('dbName', { dataSource, preAggregations }),
host: getEnv('dbHost', { dataSource, preAggregations }),
port: getEnv('dbPort', { dataSource, preAggregations }) || 1521,
poolMin: 0,
poolMax:
config.maxPoolSize ||
getEnv('dbMaxPoolSize', { dataSource, preAggregations }) ||
50,
...config
...connectionConfig,
};
this.config.connectionString = this.config.connectionString || `${this.config.host}:${this.config.port}/${this.config.db}`;

const poolName = createPoolName('oracle', dataSource, preAggregations);
this.pool = new Pool(poolName, {
create: async () => {
const connection = await this.db.getConnection(this.config);
await OracleDriver.initConnection(connection);

return connection;
},
validate: async (connection) => {
try {
await connection.ping();
} catch (e) {
this.databasePoolError(e);
return false;
}

return true;
},
destroy: (connection) => connection.close(),
}, {
min: 0,
max:
maxPoolSize ||
getEnv('dbMaxPoolSize', { dataSource, preAggregations }) ||
50,
evictionRunIntervalMillis: 10000,
softIdleTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
testOnBorrow: true,
acquireTimeoutMillis: 20000,
...pool,
});
}

async tablesSchema() {
Expand All @@ -110,12 +149,33 @@ class OracleDriver extends BaseDriver {
return reduce(reduceCb, {}, data);
}

async getConnectionFromPool() {
if (!this.pool) {
this.pool = await this.db.createPool(this.config);
}
/**
* Runs once per pooled session. Aligns the session NLS formats with the ISO-ish
* date strings Cube binds, so implicit string→DATE/TIMESTAMP conversions (e.g.
* the native planner's `CAST(? AS TIMESTAMP)` over a 'YYYY-MM-DD' filter bound)
* parse instead of failing with ORA-01843 under Oracle's default NLS. Explicit
* TO_DATE/TO_TIMESTAMP calls carry their own masks and are unaffected.
* @protected
*/
static async initConnection(connection) {
await connection.execute(
"ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD' NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD' NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF TZH:TZM'"
);
}

/**
* Acquires a connection from the pool, runs `fn`, and always releases the
* connection back to the pool (on both success and failure).
* @protected
*/
async withConnection(fn) {
const connection = await this.pool.acquire();

return this.pool.getConnection()
try {
return await fn(connection);
} finally {
await this.pool.release(connection);
}
}

async testConnection() {
Expand All @@ -127,36 +187,93 @@ class OracleDriver extends BaseDriver {
throw new Error('Oracle can not work with table names longer than 128 symbols. ' +
`Consider using the 'sqlAlias' attribute in your cube definition for ${quotedTableName}.`);
}

return super.createTable(quotedTableName, columns);
}

async query(query, values) {
const conn = await this.getConnectionFromPool();
static normalizeParams(query, values) {
if (!values || values.length === 0) {
return { sql: query, binds: {} };
}

try {
const res = await conn.execute(query, values || {});
const binds = {};
const valueToName = new Map();
let idx = 0;
let nextName = 0;

// `:"?"` must be matched as a whole before a lone `?`, so it appears first
// in the alternation; since it starts with `:`, its inner `?` is consumed
// as part of the match and never matched again on its own.
//
// Placeholders carrying the same value share a single named bind. This is
// semantically identical (the same value is bound) and keeps repeated
// expressions textually identical across clauses — required by Oracle, which
// otherwise rejects e.g. a CASE expression in both SELECT and GROUP BY when
// its param renders as two different bind names (ORA-00979).
const sql = query.replace(/:"\?"|\?/g, () => {
const value = values[idx];
idx += 1;
// A Map distinguishes values by SameValueZero, so 1 and '1' stay separate;
// the raw value works as the key without stringifying.
let name = valueToName.get(value);
if (name === undefined) {
name = `cb_param_${nextName}`;
nextName += 1;
valueToName.set(value, name);
binds[name] = value;
}
return `:${name}`;
});

return { sql, binds };
}

async query(query, values) {
return this.withConnection(async (conn) => {
const { sql, binds } = OracleDriver.normalizeParams(query, values);
const res = await conn.execute(sql, binds);
return res && res.rows;
} catch (e) {
throw (e);
} finally {
try {
await conn.close();
} catch (e) {
throw e;
});
}

static metaDataToColumnTypes(metaData) {
return (metaData || []).map((column) => {
const dbTypeName = (column.dbTypeName || '').toLowerCase();
let type = 'text';

if (dbTypeName.startsWith('timestamp')) {
type = 'timestamp';
} else {
type = OracleTypeToGenericType[dbTypeName] || 'text';
}
}

return { name: column.name, type };
});
}

async downloadQueryResults(query, values, _options) {
return this.withConnection(async (conn) => {
const { sql, binds } = OracleDriver.normalizeParams(query, values);
const res = await conn.execute(sql, binds);
return {
rows: (res && res.rows) || [],
types: OracleDriver.metaDataToColumnTypes(res && res.metaData),
};
});
}

release() {
return this.pool && this.pool.close();
async release() {
await this.pool.drain();
await this.pool.clear();
}

readOnly() {
return true;
}

wrapQueryWithLimit(query) {
query.query = `SELECT * FROM (${query.query}) AS t WHERE ROWNUM <= ${query.limit}`;
// Oracle forbids the `AS` keyword for table/subquery aliases.
query.query = `SELECT * FROM (${query.query}) t WHERE ROWNUM <= ${query.limit}`;
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/cubejs-oracle-driver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
"main": "driver/OracleDriver.js",
"dependencies": {
"@cubejs-backend/base-driver": "1.6.64",
"@cubejs-backend/shared": "1.6.64",
"ramda": "^0.27.0"
},
"devDependencies": {
"@types/oracledb": "^6.2.3"
},
"optionalDependencies": {
"oracledb": "^6.2.0"
},
Expand Down
81 changes: 81 additions & 0 deletions packages/cubejs-schema-compiler/src/adapter/OracleQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const GRANULARITY_VALUE = {
minute: 'mm',
second: 'ss',
month: 'MM',
quarter: 'Q',
year: 'YYYY'
};

Expand Down Expand Up @@ -94,6 +95,7 @@ export class OracleQuery extends BaseQuery {
if (!granularity) {
return dimension;
}

return `TRUNC(${dimension}, '${GRANULARITY_VALUE[granularity]}')`;
}

Expand Down Expand Up @@ -179,6 +181,85 @@ export class OracleQuery extends BaseQuery {
return res;
}

public dateBin(interval: string, source: string, origin: string): string {
const parsed = parseSqlInterval(interval);
const originTs = `TO_TIMESTAMP('${origin}', 'YYYY-MM-DD"T"HH24:MI:SS.FF3')`;

const totalMonths = (parsed.year || 0) * 12 + (parsed.quarter || 0) * 3 + (parsed.month || 0);
const totalSeconds = (parsed.week || 0) * 604800 + (parsed.day || 0) * 86400 +
(parsed.hour || 0) * 3600 + (parsed.minute || 0) * 60 + (parsed.second || 0);

// Pure month-based interval: bin with calendar-accurate month arithmetic.
if (totalMonths > 0 && totalSeconds === 0) {
return `ADD_MONTHS(${originTs}, FLOOR(MONTHS_BETWEEN(${source}, ${originTs}) / ${totalMonths}) * ${totalMonths})`;
}

// Pure fixed-length interval: bin with second arithmetic.
// (CAST(... AS DATE) - CAST(... AS DATE)) yields a day count; * 86400 → seconds.
if (totalSeconds > 0 && totalMonths === 0) {
const diffSeconds = `(CAST(${source} AS DATE) - CAST(${originTs} AS DATE)) * 86400`;
return `${originTs} + NUMTODSINTERVAL(FLOOR(${diffSeconds} / ${totalSeconds}) * ${totalSeconds}, 'SECOND')`;
}

throw new UserError(`Mixed month/second intervals are not supported for Oracle custom granularities: ${interval}`);
}

public seriesSql(timeDimension) {
const values = timeDimension.timeSeries().map(
([from, to]) => `SELECT '${from}' f, '${to}' t FROM DUAL`
).join(' UNION ALL ');
return `SELECT TO_TIMESTAMP(dates.f, 'YYYY-MM-DD"T"HH24:MI:SS.FF3') as ${this.escapeColumnName('date_from')}, ` +
`TO_TIMESTAMP(dates.t, 'YYYY-MM-DD"T"HH24:MI:SS.FF3') as ${this.escapeColumnName('date_to')} ` +
`FROM (${values}) dates`;
}

public sqlTemplates() {
const templates = super.sqlTemplates();
// Oracle forbids `AS` before a table/subquery alias.
templates.expressions.query_aliased = '{{ query }} {{ quoted_alias }}';
// Oracle does not support positional GROUP BY — group by expressions.
templates.statements.group_by_exprs = '{{ group_by | map(attribute=\'expr\') | join(\', \') }}';
// No `AS` before the FROM subquery alias, and Oracle row-limiting syntax.
templates.statements.select = '{% if ctes %} WITH \n' +
'{{ ctes | join(\',\n\') }}\n' +
'{% endif %}' +
'SELECT {% if distinct %}DISTINCT {% endif %}' +
'{{ select_concat | map(attribute=\'aliased\') | join(\', \') }} {% if from %}\n' +
'FROM (\n' +
'{{ from | indent(2, true) }}\n' +
') {{ from_alias }}{% elif from_prepared %}\n' +
'FROM {{ from_prepared }}' +
'{% endif %}' +
'{% for join in joins %}\n{{ join }}{% endfor %}' +
'{% if filter %}\nWHERE {{ filter }}{% endif %}' +
'{% if group_by %}\nGROUP BY {{ group_by }}{% endif %}' +
'{% if having %}\nHAVING {{ having }}{% endif %}' +
'{% if order_by %}\nORDER BY {{ order_by | map(attribute=\'expr\') | join(\', \') }}{% endif %}' +
'{% if offset is not none %}\nOFFSET {{ offset }} ROWS{% endif %}' +
'{% if limit is not none %}\nFETCH NEXT {{ limit }} ROWS ONLY{% endif %}';
// Oracle has no `::` cast and no `VALUES` row-constructor table source. Build the
// series with TO_TIMESTAMP + UNION ALL SELECT ... FROM DUAL, and no `AS` before
// the derived-table alias (Oracle forbids it). `seria` items are [from, to] pairs.
templates.statements.time_series_select = 'SELECT TO_TIMESTAMP(dates.f, \'YYYY-MM-DD"T"HH24:MI:SS.FF3\') AS "date_from",\n' +
'TO_TIMESTAMP(dates.t, \'YYYY-MM-DD"T"HH24:MI:SS.FF3\') AS "date_to" \n' +
'FROM (\n' +
'{% for time_item in seria %}' +
'SELECT \'{{ time_item[0] }}\' f, \'{{ time_item[1] }}\' t FROM DUAL' +
'{% if not loop.last %} UNION ALL\n{% endif %}' +
'{% endfor %}' +
') dates';

delete templates.expressions.ilike;
templates.tesseract.ilike = 'LOWER({{ expr }}) {% if negated %}NOT {% endif %}LIKE LOWER({{ pattern }})';

// Oracle has no `STRING` type (used by the default in CAST(... AS STRING),
// e.g. the multi-column count() concatenation). CAST to VARCHAR2 requires a
// length, so use the max standard VARCHAR2 size.
templates.types.string = 'VARCHAR2(4000)';

return templates;
}

public newFilter(filter) {
return new OracleFilter(this, filter);
}
Expand Down
Loading
Loading