-
Notifications
You must be signed in to change notification settings - Fork 51.6k
fix(Oracle DB Node): Resolve SQL compatibility issues and correct outBind generation #21489
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 11 commits
d7c4a3b
31d2b27
18d67de
cced0df
57cc2a9
b800722
ca8f64a
6e844bb
e62f125
53487f6
9a7ddfb
41c2dd7
7329ec6
f17991e
f2ef65f
4a377a3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -87,6 +87,10 @@ export async function execute( | |
| ): Promise<INodeExecutionData[]> { | ||
| const queries: QueryWithValues[] = []; | ||
|
|
||
| const conn = await pool.getConnection(); | ||
| const isCDBSupported = conn.oracleServerVersion >= 1200000000; | ||
| await conn.close(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Connection leak from unclosed database connectionConnection resource leak: The database connection is acquired but not protected by try-finally. If an error occurs after
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| for (let i = 0; i < items.length; i++) { | ||
| const schema = this.getNodeParameter('schema', i, undefined, { | ||
| extractValue: true, | ||
|
|
@@ -102,26 +106,46 @@ export async function execute( | |
| const outputColumns = this.getNodeParameter('options.outputColumns', i, ['*']) as string[]; | ||
|
|
||
| let query = ''; | ||
| if (outputColumns.includes('*')) { | ||
| query = `SELECT * FROM ${quoteSqlIdentifier(schema)}.${quoteSqlIdentifier(table)}`; | ||
| } else { | ||
| const quotedColumns = outputColumns.map(quoteSqlIdentifier).join(','); | ||
| query = `SELECT ${quotedColumns} FROM ${quoteSqlIdentifier(schema)}.${quoteSqlIdentifier(table)}`; | ||
| } | ||
| let innerQuery = outputColumns.includes('*') | ||
| ? `SELECT * FROM ${quoteSqlIdentifier(schema)}.${quoteSqlIdentifier(table)}` | ||
| : `SELECT ${outputColumns.map(quoteSqlIdentifier).join(',')} FROM ${quoteSqlIdentifier(schema)}.${quoteSqlIdentifier(table)}`; | ||
|
|
||
| // Add WHERE clause | ||
| const whereClauses = | ||
| ((this.getNodeParameter('where', i, []) as IDataObject).values as WhereClause[]) || []; | ||
| const combineConditions = this.getNodeParameter('combineConditions', i, 'AND') as string; | ||
| [query, values] = addWhereClauses(query, whereClauses, combineConditions, columnMetaDataObject); | ||
| [innerQuery, values] = addWhereClauses( | ||
| innerQuery, | ||
| whereClauses, | ||
| combineConditions, | ||
| columnMetaDataObject, | ||
| ); | ||
|
|
||
| // Add ORDER BY if needed | ||
| const sortRules = | ||
| ((this.getNodeParameter('sort', i, []) as IDataObject).values as SortRule[]) || []; | ||
| query = addSortRules(query, sortRules); | ||
| innerQuery = addSortRules(innerQuery, sortRules); | ||
|
|
||
| // Handle LIMIT / pagination | ||
| const returnAll = this.getNodeParameter('returnAll', i, false); | ||
| if (!returnAll) { | ||
| const limit = this.getNodeParameter('limit', i, 50); | ||
| query += ` FETCH FIRST ${limit} ROWS ONLY`; | ||
|
|
||
| if (isCDBSupported) { | ||
| // Oracle 12c+ (FETCH FIRST) | ||
| query += `${innerQuery} FETCH FIRST ${limit} ROWS ONLY`; | ||
| } else { | ||
| if (sortRules.length > 0 || whereClauses.length > 0) { | ||
| // Wrap inner query to preserve WHERE + ORDER BY | ||
| query = `SELECT * FROM (${innerQuery}) WHERE ROWNUM <= ${limit}`; | ||
| } else { | ||
| // No ORDER BY or WHERE: safe to append ROWNUM inline | ||
| query = `${innerQuery} WHERE ROWNUM <= ${limit}`; | ||
| } | ||
| } | ||
| } else { | ||
| // return all: no limit | ||
| query = innerQuery; | ||
| } | ||
|
|
||
| const queryWithValues = { query, values }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -79,6 +79,7 @@ export type OracleDBNodeCredentials = { | |||||
| poolMin: number; | ||||||
| poolMax: number; | ||||||
| poolIncrement: number; | ||||||
| privilege?: number; | ||||||
| sslServerCertDN?: string; | ||||||
| sslServerDNMatch?: boolean; | ||||||
| sslAllowWeakDNMatch?: boolean; | ||||||
|
|
@@ -122,3 +123,16 @@ export type ExecuteOpBindParam = | |||||
| values: number[]; | ||||||
| }; | ||||||
| }); | ||||||
|
|
||||||
| // Definition of row returned for column information. | ||||||
| export interface TableColumnRow { | ||||||
| COLUMN_NAME: string; | ||||||
| DATA_TYPE: string; | ||||||
| DATA_LENGTH: number; | ||||||
| CHAR_LENGTH: number; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please type CHAR_LENGTH as number | null since ALL_TAB_COLUMNS.CHAR_LENGTH is NULL for non-character columns; keeping it as number can lead callers to assume it’s always numeric and skip null checks. Prompt for AI agents
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The CHAR_LENGTH will come as 0 if its not valid like for number type.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the feedback! I've saved this as a new learning to improve future reviews. |
||||||
| DEFAULT_LENGTH: number | null; | ||||||
| NULLABLE: 'Y' | 'N'; | ||||||
| IDENTITY_COLUMN?: 'YES' | 'NO'; // only present in 12c+ | ||||||
| HAS_DEFAULT: 'YES' | 'NO'; | ||||||
| CONSTRAINT_TYPES?: string | null; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ import type { | |
| SortRule, | ||
| ColumnMap, | ||
| OracleDBNodeOptions, | ||
| TableColumnRow, | ||
| } from './interfaces'; | ||
|
|
||
| const n8nTypetoDBType: { [key: string]: oracledb.DbType } = { | ||
|
|
@@ -240,13 +241,17 @@ export async function getColumnMetaData( | |
| let conn: oracledb.Connection | undefined; | ||
| try { | ||
| conn = await pool.getConnection(); | ||
| const sql = `WITH constraint_info AS ( | ||
|
|
||
| const isCDBSupported = conn.oracleServerVersion >= 1200000000; | ||
|
|
||
| const sql = isCDBSupported | ||
| ? ` | ||
| WITH constraint_info AS ( | ||
| SELECT | ||
| acc.owner, | ||
| acc.table_name, | ||
| acc.column_name, | ||
| LISTAGG(ac.constraint_type, ',') | ||
| WITHIN GROUP (ORDER BY ac.constraint_type) AS constraint_types | ||
| LISTAGG(ac.constraint_type, ',') WITHIN GROUP (ORDER BY ac.constraint_type) AS constraint_types | ||
| FROM all_cons_columns acc | ||
| JOIN all_constraints ac | ||
| ON acc.constraint_name = ac.constraint_name | ||
|
|
@@ -264,39 +269,74 @@ SELECT | |
| CASE WHEN atc.DATA_DEFAULT IS NOT NULL THEN 'YES' ELSE 'NO' END AS HAS_DEFAULT, | ||
| ci.constraint_types | ||
| FROM all_tab_columns atc | ||
| LEFT JOIN constraint_info ci | ||
| ON atc.owner = ci.owner | ||
| AND atc.table_name = ci.table_name | ||
| AND atc.column_name = ci.column_name | ||
| WHERE atc.owner = :schema_name | ||
| AND atc.table_name = :table_name | ||
| ORDER BY atc.COLUMN_NAME` | ||
| : ` | ||
| WITH constraint_info AS ( | ||
| SELECT | ||
| acc.owner, | ||
| acc.table_name, | ||
| acc.column_name, | ||
| RTRIM( | ||
| XMLAGG( | ||
| XMLELEMENT(e, ac.constraint_type || ',') | ||
| ORDER BY ac.constraint_type | ||
| ).EXTRACT('//text()').getClobVal(), | ||
| ',' | ||
| ) AS constraint_types | ||
| FROM all_cons_columns acc | ||
| JOIN all_constraints ac | ||
| ON acc.constraint_name = ac.constraint_name | ||
| AND acc.owner = ac.owner | ||
| GROUP BY acc.owner, acc.table_name, acc.column_name | ||
| ) | ||
| SELECT | ||
| atc.COLUMN_NAME, | ||
| atc.DATA_TYPE, | ||
| atc.DATA_LENGTH, | ||
| atc.CHAR_LENGTH, | ||
| atc.DEFAULT_LENGTH, | ||
| atc.NULLABLE, | ||
| CASE WHEN atc.DATA_DEFAULT IS NOT NULL THEN 'YES' ELSE 'NO' END AS HAS_DEFAULT, | ||
| ci.constraint_types | ||
| FROM all_tab_columns atc | ||
| LEFT JOIN constraint_info ci | ||
| ON atc.owner = ci.owner | ||
| AND atc.table_name = ci.table_name | ||
| AND atc.column_name = ci.column_name | ||
| WHERE atc.owner = :schema_name | ||
| AND atc.table_name = :table_name | ||
| ORDER BY atc.COLUMN_NAME`; | ||
| const result = await conn.execute( | ||
|
|
||
| const result = await conn.execute<TableColumnRow>( | ||
| sql, | ||
| { table_name: table, schema_name: schema }, | ||
| { outFormat: oracledb.OUT_FORMAT_OBJECT }, | ||
| ); | ||
|
|
||
| // If schema is not available, throw error. | ||
| if (!result.rows || result.rows.length === 0) { | ||
| const rows = result.rows ?? []; | ||
| if (rows.length === 0) { | ||
| throw new NodeOperationError(node, 'Schema Information does not exist for selected table', { | ||
| itemIndex: index, | ||
| }); | ||
| } | ||
|
|
||
| return ( | ||
| result.rows?.map((row: any) => ({ | ||
| columnName: row.COLUMN_NAME, | ||
| isGenerated: row.IDENTITY_COLUMN === 'YES' ? 'ALWAYS' : 'NEVER', | ||
| columnDefault: row.HAS_DEFAULT, | ||
| dataType: row.DATA_TYPE, | ||
| isNullable: row.NULLABLE === 'Y', | ||
| maxSize: row.DATA_LENGTH, | ||
| })) ?? [] | ||
| ); | ||
| return rows.map((row) => ({ | ||
| columnName: row.COLUMN_NAME, | ||
| isGenerated: isCDBSupported && row.IDENTITY_COLUMN === 'YES' ? 'ALWAYS' : 'NEVER', | ||
| columnDefault: row.HAS_DEFAULT, | ||
| dataType: row.DATA_TYPE, | ||
| isNullable: row.NULLABLE === 'Y', | ||
| maxSize: row.DATA_LENGTH, | ||
| })); | ||
| } finally { | ||
| if (conn) { | ||
| await conn.close(); // Ensure connection is closed | ||
| await conn.close(); | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -329,10 +369,20 @@ function normalizeOutBinds( | |
| stmtBatching: string, | ||
| outputColumns: string[], | ||
| ): Array<Record<string, any>> { | ||
| if (!Array.isArray(outBinds)) return []; | ||
|
|
||
| const rows: Array<Record<string, any>> = []; | ||
|
|
||
| if (!Array.isArray(outBinds)) { | ||
| // For execute operation mode, we get outBinds as object and | ||
| // array for other insert, update, upsert operations. | ||
| const row: Record<string, any> = {}; | ||
| for (const [key, val] of Object.entries(outBinds as Record<string, unknown>)) { | ||
| // If val is expected to be an array, safely extract the first element | ||
| row[key] = Array.isArray(val) ? val[0] : val; | ||
| } | ||
| rows.push(row); | ||
| return rows; | ||
| } | ||
|
|
||
| // executeMany case outBinds-> [ [[col1Row1Val], [col2Row1Val]], [[col1Row2Val], [col2Row2Val]], ...] | ||
| if (stmtBatching === 'single') { | ||
| for (const batch of outBinds as any[][]) { | ||
|
|
@@ -772,6 +822,17 @@ function generateBindVariablesList( | |
| return query.replace(regex, generatedSqlString); | ||
| } | ||
|
|
||
| function isSerializedBuffer(val: unknown): val is { type: 'Buffer'; data: number[] } { | ||
| return ( | ||
| typeof val === 'object' && | ||
| val !== null && | ||
| 'type' in val && | ||
| 'data' in val && | ||
| (val as any).type === 'Buffer' && | ||
| Array.isArray((val as any).data) | ||
| ); | ||
| } | ||
|
|
||
| export function getBindParameters( | ||
| query: string, | ||
| parameterList: ExecuteOpBindParam[], | ||
|
|
@@ -795,7 +856,24 @@ export function getBindParameters( | |
| break; | ||
| case 'blob': | ||
| bindVal = item.valueBlob; | ||
| break; | ||
|
|
||
| // Allow null or undefined to represent SQL NULL BLOB values | ||
| if (bindVal === null) { | ||
| break; | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Standardize BLOB Null Value ChecksThe comment states both |
||
|
|
||
| if (Buffer.isBuffer(bindVal)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Null BLOB bind values now trigger a UserError. Allow nulls to pass through so existing workflows can still write NULL into BLOB columns. Prompt for AI agents✅ Addressed in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added NULL check |
||
| break; | ||
| } | ||
|
|
||
| // Serialized form: { type: 'Buffer', data: [...] } | ||
| if (isSerializedBuffer(bindVal)) { | ||
| bindVal = Buffer.from((bindVal as any).data); | ||
| break; | ||
| } | ||
| throw new UserError( | ||
| 'BLOB data must be a valid Buffer or \'{ type: "Buffer", data: [...] }\'', | ||
| ); | ||
| case 'date': { | ||
| const val = item.valueDate; | ||
| if (typeof val === 'string') { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rule violated: Prefer Typeguards over Type casting
Casting the
oracledbmodule toanyto access privilege constants suppresses its declared typings and violates the "Prefer Typeguards over Type casting" rule; please rely on typed keys (e.g., viasatisfies/as const) so the lookup stays type-safe.Prompt for AI agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The map is static and keys are always exposed by oracledb module.