Skip to content

Commit 9da8e07

Browse files
authored
feat(oracle-driver): Improvements & fixes, fix cube-js#10834 (cube-js#11174)
1 parent dc7fba9 commit 9da8e07

17 files changed

Lines changed: 16678 additions & 139 deletions

File tree

.github/workflows/drivers-tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ jobs:
265265
- databricks-jdbc-export-bucket-gcs-prefix
266266
- mssql
267267
- mysql
268+
- oracle
268269
- postgres
269270
- postgres-pre-agg-credentials
270271
- redshift
@@ -299,6 +300,8 @@ jobs:
299300
use_tesseract_sql_planner: true
300301
- database: mssql
301302
use_tesseract_sql_planner: true
303+
- database: oracle
304+
use_tesseract_sql_planner: true
302305
fail-fast: false
303306

304307
steps:

packages/cubejs-oracle-driver/driver/OracleDriver.js

Lines changed: 153 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,28 @@
77
const {
88
getEnv,
99
assertDataSource,
10+
Pool,
1011
} = require('@cubejs-backend/shared');
11-
const { BaseDriver, TableColumn } = require('@cubejs-backend/base-driver');
12+
const { BaseDriver, TableColumn, createPoolName } = require('@cubejs-backend/base-driver');
1213
const oracledb = require('oracledb');
1314
const { reduce } = require('ramda');
1415

16+
// Maps Oracle `metaData.dbTypeName` strings to Cube generic types. NUMBER and the
17+
// TIMESTAMP* family are handled separately (scale-based / prefix match) below.
18+
const OracleTypeToGenericType = {
19+
varchar2: 'text',
20+
nvarchar2: 'text',
21+
char: 'text',
22+
nchar: 'text',
23+
clob: 'text',
24+
nclob: 'text',
25+
long: 'text',
26+
binary_float: 'float',
27+
binary_double: 'double',
28+
date: 'timestamp',
29+
'number': 'decimal',
30+
};
31+
1532
const sortByKeys = (unordered) => {
1633
const ordered = {};
1734

@@ -43,20 +60,11 @@ const reduceCb = (result, i) => {
4360
return sortByKeys(result);
4461
};
4562

46-
/**
47-
* Oracle driver class.
48-
*/
4963
class OracleDriver extends BaseDriver {
50-
/**
51-
* Returns default concurrency value.
52-
*/
5364
static getDefaultConcurrency() {
5465
return 2;
5566
}
5667

57-
/**
58-
* Class constructor.
59-
*/
6068
constructor(config = {}) {
6169
super({
6270
testConnectionTimeout: config.testConnectionTimeout,
@@ -72,20 +80,51 @@ class OracleDriver extends BaseDriver {
7280
this.db.partRows = 100000;
7381
this.db.maxRows = 100000;
7482
this.db.prefetchRows = 500;
83+
84+
const { maxPoolSize, pool, ...connectionConfig } = config;
85+
7586
this.config = {
7687
user: getEnv('dbUser', { dataSource, preAggregations }),
7788
password: getEnv('dbPass', { dataSource, preAggregations }),
7889
db: getEnv('dbName', { dataSource, preAggregations }),
7990
host: getEnv('dbHost', { dataSource, preAggregations }),
8091
port: getEnv('dbPort', { dataSource, preAggregations }) || 1521,
81-
poolMin: 0,
82-
poolMax:
83-
config.maxPoolSize ||
84-
getEnv('dbMaxPoolSize', { dataSource, preAggregations }) ||
85-
50,
86-
...config
92+
...connectionConfig,
8793
};
8894
this.config.connectionString = this.config.connectionString || `${this.config.host}:${this.config.port}/${this.config.db}`;
95+
96+
const poolName = createPoolName('oracle', dataSource, preAggregations);
97+
this.pool = new Pool(poolName, {
98+
create: async () => {
99+
const connection = await this.db.getConnection(this.config);
100+
await OracleDriver.initConnection(connection);
101+
102+
return connection;
103+
},
104+
validate: async (connection) => {
105+
try {
106+
await connection.ping();
107+
} catch (e) {
108+
this.databasePoolError(e);
109+
return false;
110+
}
111+
112+
return true;
113+
},
114+
destroy: (connection) => connection.close(),
115+
}, {
116+
min: 0,
117+
max:
118+
maxPoolSize ||
119+
getEnv('dbMaxPoolSize', { dataSource, preAggregations }) ||
120+
50,
121+
evictionRunIntervalMillis: 10000,
122+
softIdleTimeoutMillis: 30000,
123+
idleTimeoutMillis: 30000,
124+
testOnBorrow: true,
125+
acquireTimeoutMillis: 20000,
126+
...pool,
127+
});
89128
}
90129

91130
async tablesSchema() {
@@ -110,12 +149,33 @@ class OracleDriver extends BaseDriver {
110149
return reduce(reduceCb, {}, data);
111150
}
112151

113-
async getConnectionFromPool() {
114-
if (!this.pool) {
115-
this.pool = await this.db.createPool(this.config);
116-
}
152+
/**
153+
* Runs once per pooled session. Aligns the session NLS formats with the ISO-ish
154+
* date strings Cube binds, so implicit string→DATE/TIMESTAMP conversions (e.g.
155+
* the native planner's `CAST(? AS TIMESTAMP)` over a 'YYYY-MM-DD' filter bound)
156+
* parse instead of failing with ORA-01843 under Oracle's default NLS. Explicit
157+
* TO_DATE/TO_TIMESTAMP calls carry their own masks and are unaffected.
158+
* @protected
159+
*/
160+
static async initConnection(connection) {
161+
await connection.execute(
162+
"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'"
163+
);
164+
}
165+
166+
/**
167+
* Acquires a connection from the pool, runs `fn`, and always releases the
168+
* connection back to the pool (on both success and failure).
169+
* @protected
170+
*/
171+
async withConnection(fn) {
172+
const connection = await this.pool.acquire();
117173

118-
return this.pool.getConnection()
174+
try {
175+
return await fn(connection);
176+
} finally {
177+
await this.pool.release(connection);
178+
}
119179
}
120180

121181
async testConnection() {
@@ -127,36 +187,93 @@ class OracleDriver extends BaseDriver {
127187
throw new Error('Oracle can not work with table names longer than 128 symbols. ' +
128188
`Consider using the 'sqlAlias' attribute in your cube definition for ${quotedTableName}.`);
129189
}
190+
130191
return super.createTable(quotedTableName, columns);
131192
}
132193

133-
async query(query, values) {
134-
const conn = await this.getConnectionFromPool();
194+
static normalizeParams(query, values) {
195+
if (!values || values.length === 0) {
196+
return { sql: query, binds: {} };
197+
}
135198

136-
try {
137-
const res = await conn.execute(query, values || {});
199+
const binds = {};
200+
const valueToName = new Map();
201+
let idx = 0;
202+
let nextName = 0;
203+
204+
// `:"?"` must be matched as a whole before a lone `?`, so it appears first
205+
// in the alternation; since it starts with `:`, its inner `?` is consumed
206+
// as part of the match and never matched again on its own.
207+
//
208+
// Placeholders carrying the same value share a single named bind. This is
209+
// semantically identical (the same value is bound) and keeps repeated
210+
// expressions textually identical across clauses — required by Oracle, which
211+
// otherwise rejects e.g. a CASE expression in both SELECT and GROUP BY when
212+
// its param renders as two different bind names (ORA-00979).
213+
const sql = query.replace(/:"\?"|\?/g, () => {
214+
const value = values[idx];
215+
idx += 1;
216+
// A Map distinguishes values by SameValueZero, so 1 and '1' stay separate;
217+
// the raw value works as the key without stringifying.
218+
let name = valueToName.get(value);
219+
if (name === undefined) {
220+
name = `cb_param_${nextName}`;
221+
nextName += 1;
222+
valueToName.set(value, name);
223+
binds[name] = value;
224+
}
225+
return `:${name}`;
226+
});
227+
228+
return { sql, binds };
229+
}
230+
231+
async query(query, values) {
232+
return this.withConnection(async (conn) => {
233+
const { sql, binds } = OracleDriver.normalizeParams(query, values);
234+
const res = await conn.execute(sql, binds);
138235
return res && res.rows;
139-
} catch (e) {
140-
throw (e);
141-
} finally {
142-
try {
143-
await conn.close();
144-
} catch (e) {
145-
throw e;
236+
});
237+
}
238+
239+
static metaDataToColumnTypes(metaData) {
240+
return (metaData || []).map((column) => {
241+
const dbTypeName = (column.dbTypeName || '').toLowerCase();
242+
let type = 'text';
243+
244+
if (dbTypeName.startsWith('timestamp')) {
245+
type = 'timestamp';
246+
} else {
247+
type = OracleTypeToGenericType[dbTypeName] || 'text';
146248
}
147-
}
249+
250+
return { name: column.name, type };
251+
});
252+
}
253+
254+
async downloadQueryResults(query, values, _options) {
255+
return this.withConnection(async (conn) => {
256+
const { sql, binds } = OracleDriver.normalizeParams(query, values);
257+
const res = await conn.execute(sql, binds);
258+
return {
259+
rows: (res && res.rows) || [],
260+
types: OracleDriver.metaDataToColumnTypes(res && res.metaData),
261+
};
262+
});
148263
}
149264

150-
release() {
151-
return this.pool && this.pool.close();
265+
async release() {
266+
await this.pool.drain();
267+
await this.pool.clear();
152268
}
153269

154270
readOnly() {
155271
return true;
156272
}
157273

158274
wrapQueryWithLimit(query) {
159-
query.query = `SELECT * FROM (${query.query}) AS t WHERE ROWNUM <= ${query.limit}`;
275+
// Oracle forbids the `AS` keyword for table/subquery aliases.
276+
query.query = `SELECT * FROM (${query.query}) t WHERE ROWNUM <= ${query.limit}`;
160277
}
161278
}
162279

packages/cubejs-oracle-driver/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@
1414
"main": "driver/OracleDriver.js",
1515
"dependencies": {
1616
"@cubejs-backend/base-driver": "1.6.64",
17+
"@cubejs-backend/shared": "1.6.64",
1718
"ramda": "^0.27.0"
1819
},
20+
"devDependencies": {
21+
"@types/oracledb": "^6.2.3"
22+
},
1923
"optionalDependencies": {
2024
"oracledb": "^6.2.0"
2125
},

packages/cubejs-schema-compiler/src/adapter/OracleQuery.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const GRANULARITY_VALUE = {
1111
minute: 'mm',
1212
second: 'ss',
1313
month: 'MM',
14+
quarter: 'Q',
1415
year: 'YYYY'
1516
};
1617

@@ -94,6 +95,7 @@ export class OracleQuery extends BaseQuery {
9495
if (!granularity) {
9596
return dimension;
9697
}
98+
9799
return `TRUNC(${dimension}, '${GRANULARITY_VALUE[granularity]}')`;
98100
}
99101

@@ -179,6 +181,85 @@ export class OracleQuery extends BaseQuery {
179181
return res;
180182
}
181183

184+
public dateBin(interval: string, source: string, origin: string): string {
185+
const parsed = parseSqlInterval(interval);
186+
const originTs = `TO_TIMESTAMP('${origin}', 'YYYY-MM-DD"T"HH24:MI:SS.FF3')`;
187+
188+
const totalMonths = (parsed.year || 0) * 12 + (parsed.quarter || 0) * 3 + (parsed.month || 0);
189+
const totalSeconds = (parsed.week || 0) * 604800 + (parsed.day || 0) * 86400 +
190+
(parsed.hour || 0) * 3600 + (parsed.minute || 0) * 60 + (parsed.second || 0);
191+
192+
// Pure month-based interval: bin with calendar-accurate month arithmetic.
193+
if (totalMonths > 0 && totalSeconds === 0) {
194+
return `ADD_MONTHS(${originTs}, FLOOR(MONTHS_BETWEEN(${source}, ${originTs}) / ${totalMonths}) * ${totalMonths})`;
195+
}
196+
197+
// Pure fixed-length interval: bin with second arithmetic.
198+
// (CAST(... AS DATE) - CAST(... AS DATE)) yields a day count; * 86400 → seconds.
199+
if (totalSeconds > 0 && totalMonths === 0) {
200+
const diffSeconds = `(CAST(${source} AS DATE) - CAST(${originTs} AS DATE)) * 86400`;
201+
return `${originTs} + NUMTODSINTERVAL(FLOOR(${diffSeconds} / ${totalSeconds}) * ${totalSeconds}, 'SECOND')`;
202+
}
203+
204+
throw new UserError(`Mixed month/second intervals are not supported for Oracle custom granularities: ${interval}`);
205+
}
206+
207+
public seriesSql(timeDimension) {
208+
const values = timeDimension.timeSeries().map(
209+
([from, to]) => `SELECT '${from}' f, '${to}' t FROM DUAL`
210+
).join(' UNION ALL ');
211+
return `SELECT TO_TIMESTAMP(dates.f, 'YYYY-MM-DD"T"HH24:MI:SS.FF3') as ${this.escapeColumnName('date_from')}, ` +
212+
`TO_TIMESTAMP(dates.t, 'YYYY-MM-DD"T"HH24:MI:SS.FF3') as ${this.escapeColumnName('date_to')} ` +
213+
`FROM (${values}) dates`;
214+
}
215+
216+
public sqlTemplates() {
217+
const templates = super.sqlTemplates();
218+
// Oracle forbids `AS` before a table/subquery alias.
219+
templates.expressions.query_aliased = '{{ query }} {{ quoted_alias }}';
220+
// Oracle does not support positional GROUP BY — group by expressions.
221+
templates.statements.group_by_exprs = '{{ group_by | map(attribute=\'expr\') | join(\', \') }}';
222+
// No `AS` before the FROM subquery alias, and Oracle row-limiting syntax.
223+
templates.statements.select = '{% if ctes %} WITH \n' +
224+
'{{ ctes | join(\',\n\') }}\n' +
225+
'{% endif %}' +
226+
'SELECT {% if distinct %}DISTINCT {% endif %}' +
227+
'{{ select_concat | map(attribute=\'aliased\') | join(\', \') }} {% if from %}\n' +
228+
'FROM (\n' +
229+
'{{ from | indent(2, true) }}\n' +
230+
') {{ from_alias }}{% elif from_prepared %}\n' +
231+
'FROM {{ from_prepared }}' +
232+
'{% endif %}' +
233+
'{% for join in joins %}\n{{ join }}{% endfor %}' +
234+
'{% if filter %}\nWHERE {{ filter }}{% endif %}' +
235+
'{% if group_by %}\nGROUP BY {{ group_by }}{% endif %}' +
236+
'{% if having %}\nHAVING {{ having }}{% endif %}' +
237+
'{% if order_by %}\nORDER BY {{ order_by | map(attribute=\'expr\') | join(\', \') }}{% endif %}' +
238+
'{% if offset is not none %}\nOFFSET {{ offset }} ROWS{% endif %}' +
239+
'{% if limit is not none %}\nFETCH NEXT {{ limit }} ROWS ONLY{% endif %}';
240+
// Oracle has no `::` cast and no `VALUES` row-constructor table source. Build the
241+
// series with TO_TIMESTAMP + UNION ALL SELECT ... FROM DUAL, and no `AS` before
242+
// the derived-table alias (Oracle forbids it). `seria` items are [from, to] pairs.
243+
templates.statements.time_series_select = 'SELECT TO_TIMESTAMP(dates.f, \'YYYY-MM-DD"T"HH24:MI:SS.FF3\') AS "date_from",\n' +
244+
'TO_TIMESTAMP(dates.t, \'YYYY-MM-DD"T"HH24:MI:SS.FF3\') AS "date_to" \n' +
245+
'FROM (\n' +
246+
'{% for time_item in seria %}' +
247+
'SELECT \'{{ time_item[0] }}\' f, \'{{ time_item[1] }}\' t FROM DUAL' +
248+
'{% if not loop.last %} UNION ALL\n{% endif %}' +
249+
'{% endfor %}' +
250+
') dates';
251+
252+
delete templates.expressions.ilike;
253+
templates.tesseract.ilike = 'LOWER({{ expr }}) {% if negated %}NOT {% endif %}LIKE LOWER({{ pattern }})';
254+
255+
// Oracle has no `STRING` type (used by the default in CAST(... AS STRING),
256+
// e.g. the multi-column count() concatenation). CAST to VARCHAR2 requires a
257+
// length, so use the max standard VARCHAR2 size.
258+
templates.types.string = 'VARCHAR2(4000)';
259+
260+
return templates;
261+
}
262+
182263
public newFilter(filter) {
183264
return new OracleFilter(this, filter);
184265
}

0 commit comments

Comments
 (0)