[bug] Normalize null to empty string for NCLOB/CLOB columns; Fix bug 77676

This commit is contained in:
Sergey Konovalov
2025-10-17 13:33:45 +03:00
parent 9a4680497e
commit 401283452a
2 changed files with 96 additions and 6 deletions

View File

@ -75,14 +75,60 @@ let pool = null;
oracledb.fetchAsString = [oracledb.NCLOB, oracledb.CLOB]; oracledb.fetchAsString = [oracledb.NCLOB, oracledb.CLOB];
oracledb.autoCommit = true; oracledb.autoCommit = true;
function columnsToLowercase(rows) { /**
* WeakMap cache for column type maps
* Key: metaData array reference from Oracle result
* Value: Object mapping column names (lowercase) to boolean (is NCLOB/CLOB)
* Automatically garbage collected when metaData is no longer referenced
*/
const columnTypeMapCache = new WeakMap();
/**
* Get or build column type map from metadata
* @param {Array} metaData - Column metadata from Oracle
* @returns {Object.<string, boolean>} Map of column name (lowercase) to isClobColumn flag
*/
function getColumnTypeMap(metaData) {
let columnTypeMap = columnTypeMapCache.get(metaData);
if (!columnTypeMap) {
columnTypeMap = {};
for (let i = 0; i < metaData.length; i++) {
const col = metaData[i];
// Check if column is NCLOB/CLOB (converted to string by fetchAsString config)
const isClobColumn = col.dbType === oracledb.DB_TYPE_NCLOB || col.dbType === oracledb.DB_TYPE_CLOB;
columnTypeMap[col.name.toLowerCase()] = isClobColumn;
}
columnTypeMapCache.set(metaData, columnTypeMap);
}
return columnTypeMap;
}
/**
* Convert column names to lowercase and normalize null values
* Oracle returns null for empty NCLOB/CLOB fields, converts to empty string for consistency with other databases
* @param {Array<Object>} rows - Query result rows
* @param {Array} metaData - Column metadata from Oracle (optional)
* @returns {Array<Object>} Formatted rows with lowercase column names and normalized values
*/
function columnsToLowercase(rows, metaData) {
const columnTypeMap = metaData ? getColumnTypeMap(metaData) : null;
const formattedRows = []; const formattedRows = [];
for (const row of rows) { for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const newRow = {}; const newRow = {};
for (const column in row) { for (const column in row) {
if (Object.hasOwn(row, column)) { if (!Object.hasOwn(row, column)) continue;
newRow[column.toLowerCase()] = row[column];
const columnLower = column.toLowerCase();
let value = row[column];
// Normalize null to empty string for NCLOB/CLOB columns
if (value === null && columnTypeMap?.[columnLower]) {
value = '';
} }
newRow[columnLower] = value;
} }
formattedRows.push(newRow); formattedRows.push(newRow);
@ -121,7 +167,7 @@ async function executeQuery(ctx, sqlCommand, values = [], noModifyRes = false, n
} }
if (result?.rows) { if (result?.rows) {
output = columnsToLowercase(result.rows); output = columnsToLowercase(result.rows, result.metaData);
} }
} else { } else {
output = result; output = result;
@ -204,6 +250,7 @@ async function executeBunch(ctx, sqlCommand, values = [], options, noLog = false
} }
function closePool() { function closePool() {
// WeakMap cache is automatically garbage collected, no manual cleanup needed
return pool?.close(forceClosingCountdownMs); return pool?.close(forceClosingCountdownMs);
} }

View File

@ -104,6 +104,7 @@ const updateIfCases = {
notEmpty: 'baseConnector-updateIf()-tester-not-empty-callback', notEmpty: 'baseConnector-updateIf()-tester-not-empty-callback',
emptyCallback: 'baseConnector-updateIf()-tester-empty-callback' emptyCallback: 'baseConnector-updateIf()-tester-empty-callback'
}; };
const oracleNullHandlingCases = ['baseConnector-oracle-nclob-null-handling', 'baseConnector-oracle-nclob-null-handling-2'];
function createChanges(changesLength, date) { function createChanges(changesLength, date) {
const objChanges = [ const objChanges = [
@ -216,7 +217,8 @@ afterAll(async () => {
...getExpiredCase, ...getExpiredCase,
...getCountWithStatusCase, ...getCountWithStatusCase,
...upsertIds, ...upsertIds,
...updateIfIds ...updateIfIds,
...oracleNullHandlingCases
]; ];
const deletionPool = [ const deletionPool = [
@ -488,6 +490,47 @@ describe('Base database connector', () => {
}); });
}); });
describe('Oracle NCLOB null handling', () => {
const nullHandlingCase = 'baseConnector-oracle-nclob-null-handling';
test('Empty callback is retrieved as empty string (not null)', async () => {
const date = new Date();
const task = createTask(nullHandlingCase, ''); // Empty callback
await noRowsExistenceCheck(cfgTableResult, task.key);
await insertIntoResultTable(date, task);
// Retrieve the row and check callback field
const result = await executeSql(`SELECT callback, baseurl FROM ${cfgTableResult} WHERE id = '${task.key}';`);
expect(result.length).toEqual(1);
// Oracle should normalize null NCLOB to empty string
expect(result[0].callback).toEqual('');
expect(result[0].baseurl).toEqual('');
// Verify they are strings, not null
expect(typeof result[0].callback).toEqual('string');
expect(typeof result[0].baseurl).toEqual('string');
});
test('Null callback does not cause TypeError in getCallbackByUserIndex', async () => {
const date = new Date();
const task = createTask(nullHandlingCase + '-2', '');
await insertIntoResultTable(date, task);
const result = await executeSql(`SELECT callback FROM ${cfgTableResult} WHERE id = '${task.key}';`);
// This should not throw TypeError
const userCallback = new baseConnector.UserCallback();
expect(() => {
userCallback.getCallbackByUserIndex(ctx, result[0].callback, 1);
}).not.toThrow();
const callbackResult = userCallback.getCallbackByUserIndex(ctx, result[0].callback, 1);
expect(callbackResult).toEqual('');
});
});
describe('updateIf() method', () => { describe('updateIf() method', () => {
test('Update with NOT_EMPTY callback mask', async () => { test('Update with NOT_EMPTY callback mask', async () => {
const date = new Date(); const date = new Date();