first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { createPrivateKey } from 'crypto';
|
||||
import pick from 'lodash/pick';
|
||||
import type snowflake from 'snowflake-sdk';
|
||||
|
||||
import { formatPrivateKey } from '@utils/utilities';
|
||||
|
||||
const commonConnectionFields = [
|
||||
'account',
|
||||
'database',
|
||||
'schema',
|
||||
'warehouse',
|
||||
'role',
|
||||
'clientSessionKeepAlive',
|
||||
] as const;
|
||||
|
||||
export type SnowflakeCredential = Pick<
|
||||
snowflake.ConnectionOptions,
|
||||
(typeof commonConnectionFields)[number]
|
||||
> &
|
||||
(
|
||||
| {
|
||||
authentication: 'password';
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
| {
|
||||
authentication: 'keyPair';
|
||||
username: string;
|
||||
privateKey: string;
|
||||
passphrase?: string;
|
||||
}
|
||||
);
|
||||
|
||||
const extractPrivateKey = (credential: { privateKey: string; passphrase?: string }) => {
|
||||
const key = formatPrivateKey(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) => {
|
||||
const connectionOptions: snowflake.ConnectionOptions = pick(credential, commonConnectionFields);
|
||||
if (credential.authentication === 'keyPair') {
|
||||
connectionOptions.authenticator = 'SNOWFLAKE_JWT';
|
||||
connectionOptions.username = credential.username;
|
||||
connectionOptions.privateKey = extractPrivateKey(credential);
|
||||
} 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 async function execute(
|
||||
conn: snowflake.Connection,
|
||||
sqlText: string,
|
||||
binds: snowflake.InsertBinds,
|
||||
) {
|
||||
return await new Promise<any[] | undefined>((resolve, reject) => {
|
||||
conn.execute({
|
||||
sqlText,
|
||||
binds,
|
||||
complete: (error, _, rows) => (error ? reject(error) : resolve(rows)),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.snowflake",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Data & Storage"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/snowflake/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.snowflake/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import snowflake from 'snowflake-sdk';
|
||||
|
||||
import { getResolvables } from '@utils/utilities';
|
||||
|
||||
import {
|
||||
connect,
|
||||
destroy,
|
||||
execute,
|
||||
getConnectionOptions,
|
||||
type SnowflakeCredential,
|
||||
} from './GenericFunctions';
|
||||
|
||||
export class Snowflake implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Snowflake',
|
||||
name: 'snowflake',
|
||||
icon: 'file:snowflake.svg',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'Get, add and update data in Snowflake',
|
||||
defaults: {
|
||||
name: 'Snowflake',
|
||||
},
|
||||
usableAsTool: true,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
parameterPane: 'wide',
|
||||
credentials: [
|
||||
{
|
||||
name: 'snowflake',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Execute Query',
|
||||
value: 'executeQuery',
|
||||
description: 'Execute an SQL query',
|
||||
action: 'Execute a SQL query',
|
||||
},
|
||||
{
|
||||
name: 'Insert',
|
||||
value: 'insert',
|
||||
description: 'Insert rows in database',
|
||||
action: 'Insert rows in database',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: 'update',
|
||||
description: 'Update rows in database',
|
||||
action: 'Update rows in database',
|
||||
},
|
||||
],
|
||||
default: 'insert',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// executeQuery
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
noDataExpression: true,
|
||||
typeOptions: {
|
||||
editor: 'sqlEditor',
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['executeQuery'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'SELECT id, name FROM product WHERE id < 40',
|
||||
required: true,
|
||||
description: 'The SQL query to execute',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// insert
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Table',
|
||||
name: 'table',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['insert'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'Name of the table in which to insert data to',
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['insert'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'id,name,description',
|
||||
description:
|
||||
'Comma-separated list of the properties which should used as columns for the new rows',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// update
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Table',
|
||||
name: 'table',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'Name of the table in which to update data in',
|
||||
},
|
||||
{
|
||||
displayName: 'Update Key',
|
||||
name: 'updateKey',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: 'id',
|
||||
required: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-id
|
||||
description:
|
||||
'Name of the property which decides which rows in the database should be updated. Normally that would be "id".',
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'name,description',
|
||||
description:
|
||||
'Comma-separated list of the properties which should used as columns for rows to update',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const credentials = await this.getCredentials<SnowflakeCredential>('snowflake');
|
||||
// Disable logging - https://docs.snowflake.com/en/developer-guide/node-js/nodejs-driver-logs#configure-the-default-logging-behavior
|
||||
snowflake.configure({
|
||||
logFilePath: 'STDOUT',
|
||||
logLevel: 'OFF',
|
||||
});
|
||||
|
||||
const connectionOptions = getConnectionOptions(credentials);
|
||||
const connection = snowflake.createConnection(connectionOptions);
|
||||
|
||||
await connect(connection);
|
||||
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const items = this.getInputData();
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
if (operation === 'executeQuery') {
|
||||
// ----------------------------------
|
||||
// executeQuery
|
||||
// ----------------------------------
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let query = this.getNodeParameter('query', i) as string;
|
||||
|
||||
for (const resolvable of getResolvables(query)) {
|
||||
query = query.replace(resolvable, this.evaluateExpression(resolvable, i) as string);
|
||||
}
|
||||
|
||||
const responseData = await execute(connection, query, []);
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'insert') {
|
||||
// ----------------------------------
|
||||
// insert
|
||||
// ----------------------------------
|
||||
|
||||
const table = this.getNodeParameter('table', 0) as string;
|
||||
const columnString = this.getNodeParameter('columns', 0) as string;
|
||||
const columns = columnString.split(',').map((column) => column.trim());
|
||||
const query = `INSERT INTO ${table}(${columns.join(',')}) VALUES (${columns
|
||||
.map((_column) => '?')
|
||||
.join(',')})`;
|
||||
const data = this.helpers.copyInputItems(items, columns);
|
||||
const binds = data.map((element) => Object.values(element));
|
||||
await execute(connection, query, binds as unknown as snowflake.InsertBinds);
|
||||
data.forEach((d, i) => {
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(d),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push(...executionData);
|
||||
});
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
// ----------------------------------
|
||||
// update
|
||||
// ----------------------------------
|
||||
|
||||
const table = this.getNodeParameter('table', 0) as string;
|
||||
const updateKey = this.getNodeParameter('updateKey', 0) as string;
|
||||
const columnString = this.getNodeParameter('columns', 0) as string;
|
||||
const columns = columnString.split(',').map((column) => column.trim());
|
||||
|
||||
if (!columns.includes(updateKey)) {
|
||||
columns.unshift(updateKey);
|
||||
}
|
||||
|
||||
const query = `UPDATE ${table} SET ${columns
|
||||
.map((column) => `${column} = ?`)
|
||||
.join(',')} WHERE ${updateKey} = ?;`;
|
||||
const data = this.helpers.copyInputItems(items, columns);
|
||||
const binds = data.map((element) => Object.values(element).concat(element[updateKey]));
|
||||
for (let i = 0; i < binds.length; i++) {
|
||||
await execute(connection, query, binds[i] as unknown as snowflake.InsertBinds);
|
||||
}
|
||||
data.forEach((d, i) => {
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(d),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push(...executionData);
|
||||
});
|
||||
}
|
||||
|
||||
await destroy(connection);
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { getConnectionOptions } from '../GenericFunctions';
|
||||
|
||||
jest.mock('crypto');
|
||||
|
||||
describe('getConnectionOptions', () => {
|
||||
const commonOptions = {
|
||||
account: 'test-account',
|
||||
database: 'test-database',
|
||||
schema: 'test-schema',
|
||||
warehouse: 'test-warehouse',
|
||||
role: 'test-role',
|
||||
clientSessionKeepAlive: true,
|
||||
};
|
||||
|
||||
describe('should return connection options', () => {
|
||||
it('with username and password for password authentication', () => {
|
||||
const result = getConnectionOptions({
|
||||
...commonOptions,
|
||||
authentication: 'password',
|
||||
username: 'test-username',
|
||||
password: 'test-password',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
...commonOptions,
|
||||
username: 'test-username',
|
||||
password: 'test-password',
|
||||
});
|
||||
});
|
||||
|
||||
it('with private key for keyPair authentication', () => {
|
||||
const result = getConnectionOptions({
|
||||
...commonOptions,
|
||||
username: 'test-username',
|
||||
authentication: 'keyPair',
|
||||
privateKey: 'test-private-key',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
...commonOptions,
|
||||
username: 'test-username',
|
||||
authenticator: 'SNOWFLAKE_JWT',
|
||||
privateKey: 'test-private-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('with private key for keyPair authentication and passphrase', () => {
|
||||
const createPrivateKeySpy = jest.spyOn(crypto, 'createPrivateKey').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
export: () => 'test-private-key',
|
||||
}) as unknown as crypto.KeyObject,
|
||||
);
|
||||
const result = getConnectionOptions({
|
||||
...commonOptions,
|
||||
username: 'test-username',
|
||||
authentication: 'keyPair',
|
||||
privateKey: 'encrypted-private-key',
|
||||
passphrase: 'test-passphrase',
|
||||
});
|
||||
|
||||
expect(createPrivateKeySpy).toHaveBeenCalledWith({
|
||||
key: 'encrypted-private-key',
|
||||
format: 'pem',
|
||||
passphrase: 'test-passphrase',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
...commonOptions,
|
||||
username: 'test-username',
|
||||
authenticator: 'SNOWFLAKE_JWT',
|
||||
privateKey: 'test-private-key',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 63 66"><use xlink:href="#a" x=".5" y=".5"/><symbol id="a" overflow="visible"><path fill="#29b5e8" fill-rule="nonzero" stroke="none" d="m8.645 15.304 13.008 7.8a3.72 3.72 0 0 0 4.589-.601 4 4 0 0 0 1.227-2.908V3.962a3.81 3.81 0 0 0-1.861-3.42 3.81 3.81 0 0 0-3.893 0 3.81 3.81 0 0 0-1.861 3.42v8.896l-7.387-4.43a3.8 3.8 0 0 0-2.922-.4 3.7 3.7 0 0 0-2.3 1.844 4.05 4.05 0 0 0 1.4 5.422m31.27 7.8 13.008-7.8a4.056 4.056 0 0 0 1.4-5.424 3.75 3.75 0 0 0-5.22-1.452l-7.3 4.37v-8.84A3.81 3.81 0 0 0 39.94.534a3.81 3.81 0 0 0-3.889 0 3.81 3.81 0 0 0-1.863 3.414v15.323a4.1 4.1 0 0 0 .494 2.367 3.75 3.75 0 0 0 2.3 1.844 3.7 3.7 0 0 0 2.922-.4m-11.567 8.894c.013-.25.108-.5.272-.68l1.52-1.58a1.06 1.06 0 0 1 .658-.282h.057a1.05 1.05 0 0 1 .656.282l1.52 1.58a1.12 1.12 0 0 1 .272.681v.06a1.13 1.13 0 0 1-.272.683L31.5 34.3a1.04 1.04 0 0 1-.656.284h-.057a1.04 1.04 0 0 1-.658-.284l-1.52-1.58a1.13 1.13 0 0 1-.272-.683zm-4.604-.65v1.364a1.54 1.54 0 0 0 .372.93l5.16 5.357a1.42 1.42 0 0 0 .895.386h1.312a1.42 1.42 0 0 0 .895-.386l5.16-5.357a1.54 1.54 0 0 0 .372-.93v-1.364a1.54 1.54 0 0 0-.372-.93l-5.16-5.357a1.42 1.42 0 0 0-.895-.386H30.16a1.42 1.42 0 0 0-.895.386l-5.16 5.367a1.55 1.55 0 0 0-.372.93m-21.818-3.71 7.365 4.417-7.365 4.413A4.06 4.06 0 0 0 .516 41.88a3.75 3.75 0 0 0 2.3 1.844 3.75 3.75 0 0 0 2.922-.392l13.008-7.8a4.11 4.11 0 0 0 1.9-3.492 4.16 4.16 0 0 0-1.9-3.492l-13.008-7.8a3.8 3.8 0 0 0-2.922-.4 3.7 3.7 0 0 0-2.3 1.844 4.053 4.053 0 0 0 1.4 5.422m38.995 4.442a4 4 0 0 0 1.91 3.477l13 7.8a3.745 3.745 0 0 0 5.22-1.451 4.05 4.05 0 0 0-1.4-5.424l-7.356-4.414 7.365-4.417a4.054 4.054 0 0 0 1.4-5.422 3.74 3.74 0 0 0-2.3-1.844 3.7 3.7 0 0 0-2.92.4l-13 7.8a4 4 0 0 0-1.91 3.507m-16.655 8.446a3.7 3.7 0 0 0-2.611.464l-13.008 7.8a4.055 4.055 0 0 0-1.4 5.422 3.74 3.74 0 0 0 2.3 1.843 3.75 3.75 0 0 0 2.922-.392l7.387-4.43v8.83a3.81 3.81 0 0 0 5.755 3.425 3.81 3.81 0 0 0 1.858-3.425V44.406a3.91 3.91 0 0 0-3.205-3.903m28.66 8.276-13.008-7.8a3.75 3.75 0 0 0-2.922-.392 3.74 3.74 0 0 0-2.3 1.843 4.1 4.1 0 0 0-.494 2.37v15.25a3.81 3.81 0 0 0 5.755 3.425 3.81 3.81 0 0 0 1.859-3.425v-8.764l7.287 4.37a3.8 3.8 0 0 0 2.922.4 3.7 3.7 0 0 0 2.3-1.844c1.057-1.9.44-4.28-1.4-5.422"/></symbol></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
Reference in New Issue
Block a user