fix(Oracle Database Node): Support OUT bind parameters (#30083)

Co-authored-by: Garrit Franke <32395585+garritfra@users.noreply.github.com>
Co-authored-by: Jon <jonathan.bennetts@gmail.com>
This commit is contained in:
JerryWu 2026-06-12 22:18:47 +08:00 committed by GitHub
parent 24c264ca28
commit d3406dd75f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 300 additions and 21 deletions

View File

@ -263,6 +263,22 @@ export const optionsCollection: INodeProperties[] = [
description:
'This property is a query tuning option to set the number of additional rows the underlying Oracle driver fetches during the internal initial statement execution phase of a query',
},
{
displayName: 'String OUT Bind Max Size',
name: 'stringOutBindMaxSize',
type: 'number',
default: 4000,
typeOptions: {
minValue: 1,
},
displayOptions: {
show: {
'/operation': ['execute'],
},
},
description:
'Maximum byte size for string OUT and IN-OUT bind parameters in execute statements',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options
displayName: 'Output Columns',

View File

@ -82,7 +82,11 @@ export async function execute(
((this.getNodeParameter('options.params', index, {}) as IDataObject)
.values as ExecuteOpBindParam[]) || [];
if (parameterIDataObjectList.length) {
const { updatedQuery, bindParameters } = getBindParameters(query, parameterIDataObjectList);
const { updatedQuery, bindParameters } = getBindParameters(
query,
parameterIDataObjectList,
nodeOptions,
);
query = updatedQuery;
values = bindParameters;
}

View File

@ -58,6 +58,7 @@ export type OracleDBNodeOptions = {
keepInStmtCache?: boolean;
maxRows?: number;
prefetchRows?: number;
stringOutBindMaxSize?: number;
// n8n options
largeNumbersOutputAsString?: boolean; // bigInt

View File

@ -27,6 +27,16 @@ import type {
TableColumnRow,
} from './interfaces';
type DefaultStringBindParam = Omit<
Extract<ExecuteOpBindParam, { datatype: 'string' }>,
'datatype' | 'valueString'
> & {
datatype?: undefined;
valueString?: string;
};
type RuntimeExecuteOpBindParam = ExecuteOpBindParam | DefaultStringBindParam;
const n8nTypetoDBType: { [key: string]: oracledb.DbType } = {
boolean: oracledb.DB_TYPE_BOOLEAN,
date: oracledb.DATE,
@ -39,6 +49,8 @@ const n8nTypetoDBType: { [key: string]: oracledb.DbType } = {
blob: oracledb.BLOB,
};
export const DEFAULT_STRING_OUT_BIND_MAX_SIZE = 4000;
function isDateType(type: string) {
return /^(timestamp(\(\d+\))?( with(?: local)? time zone)?|date)$/i.test(type);
}
@ -837,26 +849,59 @@ function isSerializedBuffer(val: unknown): val is { type: 'Buffer'; data: number
export function getBindParameters(
query: string,
parameterList: ExecuteOpBindParam[],
options: Pick<OracleDBNodeOptions, 'stringOutBindMaxSize'> = {},
): { updatedQuery: string; bindParameters: QueryValue } {
const bindParameters: ObjectQueryValue = {};
const stringOutBindMaxSize = options.stringOutBindMaxSize ?? DEFAULT_STRING_OUT_BIND_MAX_SIZE;
for (const item of parameterList) {
if (!item.parseInStatement) {
const itemWithOptionalDatatype = item as RuntimeExecuteOpBindParam;
const bindItem = (
!itemWithOptionalDatatype.datatype
? {
...itemWithOptionalDatatype,
datatype: 'string',
valueString: itemWithOptionalDatatype.valueString ?? '',
}
: itemWithOptionalDatatype
) as ExecuteOpBindParam;
if (!bindItem.parseInStatement) {
let bindVal = null;
const type = item.datatype;
const type = bindItem.datatype;
const dir =
bindItem.bindDirection === 'in'
? oracledb.BIND_IN
: bindItem.bindDirection === 'out'
? oracledb.BIND_OUT
: oracledb.BIND_INOUT;
if (bindItem.bindDirection === 'out') {
const bindParameter: oracledb.BindParameter = {
type: n8nTypetoDBType[type],
dir,
};
if (type === 'string') {
bindParameter.maxSize = stringOutBindMaxSize;
}
bindParameters[bindItem.name] = bindParameter;
continue;
}
switch (type) {
case 'number':
bindVal = item.valueNumber;
bindVal = bindItem.valueNumber;
break;
case 'string':
bindVal = item.valueString;
bindVal = bindItem.valueString;
break;
case 'boolean':
bindVal = item.valueBoolean;
bindVal = bindItem.valueBoolean;
break;
case 'blob':
bindVal = item.valueBlob;
bindVal = bindItem.valueBlob;
// Allow null or undefined to represent SQL NULL BLOB values
if (bindVal === null) {
@ -876,7 +921,7 @@ export function getBindParameters(
'BLOB data must be a valid Buffer or \'{ type: "Buffer", data: [...] }\'',
);
case 'date': {
const val = item.valueDate;
const val = bindItem.valueDate;
if (typeof val === 'string') {
bindVal = new Date(val); // string → Date
} else if (val instanceof Date) {
@ -890,7 +935,7 @@ export function getBindParameters(
break;
}
case 'sparse': {
const val = item.valueSparse;
const val = bindItem.valueSparse;
let indices = val.indices;
let values = val.values;
const dims = val.dimensions;
@ -922,7 +967,7 @@ export function getBindParameters(
}
case 'vector':
{
const val = item.valueVector;
const val = bindItem.valueVector;
bindVal = val;
if (typeof val === 'string') {
@ -938,7 +983,7 @@ export function getBindParameters(
break;
case 'json':
{
const val = item.valueJson;
const val = bindItem.valueJson;
bindVal = val;
if (typeof val === 'string') {
@ -954,19 +999,19 @@ export function getBindParameters(
throw new UserError(`Unsupported Bind type: ${type}`);
}
const dir =
item.bindDirection === 'in'
? oracledb.BIND_IN
: item.bindDirection === 'out'
? oracledb.BIND_OUT
: oracledb.BIND_INOUT;
bindParameters[item.name] = {
type: n8nTypetoDBType[item.datatype],
const bindParameter: oracledb.BindParameter = {
type: n8nTypetoDBType[type],
val: bindVal,
dir,
};
if (bindItem.bindDirection === 'inout' && type === 'string') {
bindParameter.maxSize = stringOutBindMaxSize;
}
bindParameters[bindItem.name] = bindParameter;
} else {
query = generateBindVariablesList(item, bindParameters, query);
query = generateBindVariablesList(bindItem, bindParameters, query);
}
}
return { updatedQuery: query, bindParameters };

View File

@ -1060,7 +1060,7 @@ VALUES (
OUTENAME: {
type: oracleDBTypes.STRING,
dir: 3003,
val: undefined,
maxSize: 4000,
},
};

View File

@ -237,9 +237,222 @@ describe('Test getBindParameters ', () => {
expect(updatedQuery).toEqual(query);
expect(bindParameters).toEqual(expectedBindParams);
});
it('should configure out bind parameters without input values', () => {
const query = 'BEGIN demo(:out_string, :inout_string, :out_number); END;';
const paramList = [
{
name: 'out_string',
bindDirection: 'out',
datatype: 'string',
valueString: '',
parseInStatement: false,
},
{
name: 'inout_string',
bindDirection: 'inout',
datatype: 'string',
valueString: 'input',
parseInStatement: false,
},
{
name: 'out_number',
bindDirection: 'out',
datatype: 'number',
valueNumber: 0,
parseInStatement: false,
},
] as ExecuteOpBindParam[];
const { updatedQuery, bindParameters } = getBindParameters(query, paramList);
expect(updatedQuery).toEqual(query);
expect(bindParameters).toEqual({
out_string: {
type: oracleDBTypes.STRING,
dir: oracleDBTypes.BIND_OUT,
maxSize: 4000,
},
inout_string: {
type: oracleDBTypes.STRING,
val: 'input',
dir: oracleDBTypes.BIND_INOUT,
maxSize: 4000,
},
out_number: {
type: oracleDBTypes.NUMBER,
dir: oracleDBTypes.BIND_OUT,
},
});
});
it('should use a custom max size for string out bind parameters', () => {
const query = 'BEGIN demo(:out_string, :inout_string); END;';
const paramList = [
{
name: 'out_string',
bindDirection: 'out',
datatype: 'string',
valueString: '',
parseInStatement: false,
},
{
name: 'inout_string',
bindDirection: 'inout',
datatype: 'string',
valueString: 'input',
parseInStatement: false,
},
] as ExecuteOpBindParam[];
const { updatedQuery, bindParameters } = getBindParameters(query, paramList, {
stringOutBindMaxSize: 1024,
});
expect(updatedQuery).toEqual(query);
expect(bindParameters).toEqual({
out_string: {
type: oracleDBTypes.STRING,
dir: oracleDBTypes.BIND_OUT,
maxSize: 1024,
},
inout_string: {
type: oracleDBTypes.STRING,
val: 'input',
dir: oracleDBTypes.BIND_INOUT,
maxSize: 1024,
},
});
});
it('should use the default string type for out bind parameters when the data type is missing', () => {
const query = 'BEGIN demo(:out_string); END;';
const paramList = [
{
name: 'out_string',
bindDirection: 'out',
parseInStatement: false,
},
] as unknown as ExecuteOpBindParam[];
const { updatedQuery, bindParameters } = getBindParameters(query, paramList);
expect(updatedQuery).toEqual(query);
expect(bindParameters).toEqual({
out_string: {
type: oracleDBTypes.STRING,
dir: oracleDBTypes.BIND_OUT,
maxSize: 4000,
},
});
});
it('should use the default string type for out bind parameters when the data type is empty', () => {
const query = 'BEGIN demo(:out_string); END;';
const paramList = [
{
name: 'out_string',
bindDirection: 'out',
datatype: '',
parseInStatement: false,
},
] as unknown as ExecuteOpBindParam[];
const { updatedQuery, bindParameters } = getBindParameters(query, paramList);
expect(updatedQuery).toEqual(query);
expect(bindParameters).toEqual({
out_string: {
type: oracleDBTypes.STRING,
dir: oracleDBTypes.BIND_OUT,
maxSize: 4000,
},
});
});
it('should skip value parsing for out bind parameters', () => {
const query = 'BEGIN demo(:out_blob); END;';
const paramList = [
{
name: 'out_blob',
bindDirection: 'out',
datatype: 'blob',
parseInStatement: false,
},
] as unknown as ExecuteOpBindParam[];
const { updatedQuery, bindParameters } = getBindParameters(query, paramList);
expect(updatedQuery).toEqual(query);
expect(bindParameters).toEqual({
out_blob: {
type: oracleDBTypes.BLOB,
dir: oracleDBTypes.BIND_OUT,
},
});
});
});
describe('Test configureQueryRunner', () => {
it('should return object out bind values from execute operations', async () => {
const execute = jest.fn().mockResolvedValue({ outBinds: { ret: 'registered' } });
const close = jest.fn().mockResolvedValue(undefined);
const connection = { execute, close };
const getConnection = jest.fn().mockResolvedValue(connection);
const pool = { getConnection } as unknown as oracleDBTypes.Pool;
const constructExecutionMetaData = jest
.fn()
.mockImplementation((data: INodeExecutionData[]) => data);
const context = {
helpers: {
constructExecutionMetaData,
},
} as unknown as IExecuteFunctions;
const node = {} as unknown as INode;
const queryRunner = configureQueryRunner.call(context, node, false, pool);
const result = await queryRunner(
[
{
query: 'BEGIN demo(:ret); END;',
values: {
ret: {
type: oracleDBTypes.STRING,
dir: oracleDBTypes.BIND_OUT,
maxSize: 4000,
},
},
},
],
[],
{
operation: 'execute',
stmtBatching: 'independently',
},
);
expect(result).toEqual([{ json: { ret: 'registered' } }]);
expect(result).not.toEqual([{ json: { success: true } }]);
expect(constructExecutionMetaData).toHaveBeenCalledWith([{ json: { ret: 'registered' } }], {
itemData: { item: 0 },
});
expect(getConnection).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith(
'BEGIN demo(:ret); END;',
{
ret: {
type: oracleDBTypes.STRING,
dir: oracleDBTypes.BIND_OUT,
maxSize: 4000,
},
},
expect.objectContaining({
outFormat: oracleDBTypes.OUT_FORMAT_OBJECT,
}),
);
expect(close).toHaveBeenCalledTimes(1);
});
it('should append out-bind execution data one item at a time without spread push', async () => {
const pushSpy = jest.spyOn(Array.prototype, 'push');
const outBinds = [