mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
ca3d42d838
Co-authored-by: Matsu <matias.huhta@n8n.io>
215 lines
6.0 KiB
TypeScript
215 lines
6.0 KiB
TypeScript
import { formatPemBlock } from '@n8n/utils/format-pem-block';
|
|
import { createPrivateKey } from 'crypto';
|
|
import pick from 'lodash/pick';
|
|
import type { INode, IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
|
import { NodeOperationError } from 'n8n-workflow';
|
|
import type snowflake from 'snowflake-sdk';
|
|
|
|
import { routeBinaryProperties } from '@utils/binary';
|
|
|
|
function stripLeadingComments(sqlText: string) {
|
|
let trimmedSql = sqlText.trim();
|
|
|
|
while (
|
|
trimmedSql.startsWith('--') ||
|
|
trimmedSql.startsWith('//') ||
|
|
trimmedSql.startsWith('/*')
|
|
) {
|
|
if (trimmedSql.startsWith('--') || trimmedSql.startsWith('//')) {
|
|
const endOfComment = trimmedSql.search(/[\r\n]/);
|
|
if (endOfComment === -1) return '';
|
|
trimmedSql = trimmedSql.slice(endOfComment + 1).trim();
|
|
continue;
|
|
}
|
|
|
|
const endOfComment = trimmedSql.indexOf('*/');
|
|
if (endOfComment === -1) return trimmedSql;
|
|
trimmedSql = trimmedSql.slice(endOfComment + 2).trim();
|
|
}
|
|
|
|
return trimmedSql;
|
|
}
|
|
|
|
export const isFileTransferQuery = (sqlText: string) => {
|
|
const command = stripLeadingComments(sqlText).slice(0, 3).toUpperCase();
|
|
return command === 'GET' || command === 'PUT';
|
|
};
|
|
|
|
const commonConnectionFields = [
|
|
'account',
|
|
'database',
|
|
'schema',
|
|
'warehouse',
|
|
'role',
|
|
'clientSessionKeepAlive',
|
|
] as const;
|
|
|
|
export type SnowflakeCredential = Pick<
|
|
snowflake.ConnectionOptions,
|
|
(typeof commonConnectionFields)[number] | 'host'
|
|
> &
|
|
(
|
|
| {
|
|
authentication: 'password';
|
|
username?: string;
|
|
password?: string;
|
|
}
|
|
| {
|
|
authentication: 'keyPair';
|
|
username: string;
|
|
privateKey: string;
|
|
passphrase?: string;
|
|
}
|
|
| {
|
|
authentication: 'oauth2';
|
|
token: string;
|
|
}
|
|
);
|
|
|
|
const extractPrivateKey = (credential: { privateKey: string; passphrase?: string }) => {
|
|
const key = formatPemBlock(credential.privateKey);
|
|
|
|
if (!credential.passphrase) return key;
|
|
|
|
const privateKeyObject = createPrivateKey({
|
|
key,
|
|
format: 'pem',
|
|
passphrase: credential.passphrase,
|
|
});
|
|
|
|
return privateKeyObject.export({
|
|
format: 'pem',
|
|
type: 'pkcs8',
|
|
}) as string;
|
|
};
|
|
|
|
export const getConnectionOptions = (credential: SnowflakeCredential, nodeVersion?: number) => {
|
|
const connectionOptions: snowflake.ConnectionOptions = pick(credential, commonConnectionFields);
|
|
if (typeof nodeVersion === 'number' && nodeVersion >= 1.1) {
|
|
// Return DATE/TIME/TIMESTAMP columns as strings so node output stays JSON-safe
|
|
connectionOptions.fetchAsString = ['Date'];
|
|
}
|
|
// Keep host out of commonConnectionFields so blank values can be trimmed and skipped.
|
|
const originHostname = credential.host?.trim();
|
|
if (originHostname) {
|
|
connectionOptions.host = originHostname;
|
|
}
|
|
if (credential.authentication === 'keyPair') {
|
|
connectionOptions.authenticator = 'SNOWFLAKE_JWT';
|
|
connectionOptions.username = credential.username;
|
|
connectionOptions.privateKey = extractPrivateKey(credential);
|
|
} else if (credential.authentication === 'oauth2') {
|
|
connectionOptions.authenticator = 'OAUTH';
|
|
connectionOptions.token = credential.token;
|
|
} else {
|
|
connectionOptions.username = credential.username;
|
|
connectionOptions.password = credential.password;
|
|
}
|
|
return connectionOptions;
|
|
};
|
|
|
|
export async function connect(conn: snowflake.Connection) {
|
|
return await new Promise<void>((resolve, reject) => {
|
|
conn.connect((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
|
|
export async function destroy(conn: snowflake.Connection) {
|
|
return await new Promise<void>((resolve, reject) => {
|
|
conn.destroy((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
|
|
export function escapeSnowflakeIdentifier(identifier: string): string {
|
|
if (identifier.startsWith('"') && identifier.endsWith('"') && identifier.length > 2) {
|
|
// Already quoted — preserve case (Snowflake quoted identifiers are case-sensitive)
|
|
const bare = identifier.slice(1, -1).replace(/""/g, '"');
|
|
return `"${bare.replace(/"/g, '""')}"`;
|
|
}
|
|
// Snowflake stores unquoted identifiers as UPPERCASE by default; uppercase for compatibility
|
|
return `"${identifier.toUpperCase().replace(/"/g, '""')}"`;
|
|
}
|
|
|
|
export function escapeSnowflakeObjectIdentifier(identifier: string): string {
|
|
const parts: string[] = [];
|
|
let current = '';
|
|
let inQuotes = false;
|
|
|
|
for (let i = 0; i < identifier.length; i++) {
|
|
const char = identifier[i];
|
|
if (char === '"') {
|
|
if (inQuotes && identifier[i + 1] === '"') {
|
|
// Escaped double-quote inside a quoted identifier
|
|
current += '""';
|
|
i++;
|
|
} else {
|
|
inQuotes = !inQuotes;
|
|
current += char;
|
|
}
|
|
} else if (char === '.' && !inQuotes) {
|
|
parts.push(current);
|
|
current = '';
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
parts.push(current);
|
|
|
|
return parts.map(escapeSnowflakeIdentifier).join('.');
|
|
}
|
|
|
|
export async function execute(
|
|
conn: snowflake.Connection,
|
|
sqlText: string,
|
|
binds: snowflake.Binds,
|
|
node: INode,
|
|
itemIndex?: number,
|
|
) {
|
|
if (isFileTransferQuery(sqlText)) {
|
|
throw new NodeOperationError(
|
|
node,
|
|
"Local file access isn't allowed. Remove PUT or GET file operations from the query and try again.",
|
|
{ itemIndex },
|
|
);
|
|
}
|
|
|
|
return await new Promise<unknown[] | undefined>((resolve, reject) => {
|
|
conn.execute({
|
|
sqlText,
|
|
binds,
|
|
complete: (error, _, rows) => (error ? reject(error) : resolve(rows)),
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function prepareQueryResults(
|
|
this: IExecuteFunctions,
|
|
rows: IDataObject[] | undefined,
|
|
itemIndex: number,
|
|
nodeVersion: number,
|
|
): Promise<INodeExecutionData[]> {
|
|
if (nodeVersion < 1.1) {
|
|
return this.helpers.constructExecutionMetaData(
|
|
this.helpers.returnJsonArray(rows as IDataObject[]),
|
|
{ itemData: { item: itemIndex } },
|
|
);
|
|
}
|
|
|
|
const returnData: INodeExecutionData[] = [];
|
|
for (const row of rows ?? []) {
|
|
// BINARY columns arrive as Buffers; route them to the item's binary output
|
|
const { json, binary } = await routeBinaryProperties.call(this, row);
|
|
const executionData = this.helpers.constructExecutionMetaData(
|
|
this.helpers.returnJsonArray(json),
|
|
{ itemData: { item: itemIndex } },
|
|
);
|
|
for (const entry of executionData) {
|
|
if (Object.keys(binary).length) {
|
|
entry.binary = binary;
|
|
}
|
|
returnData.push(entry);
|
|
}
|
|
}
|
|
return returnData;
|
|
}
|