diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index c76ed05bff..297c1b5966 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -163,7 +163,7 @@ describe('OAuth Utils', () => { const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') - expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') + expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined) expect(mockDb.update).toHaveBeenCalled() expect(mockDb.set).toHaveBeenCalled() expect(result).toEqual({ accessToken: 'new-token', refreshed: true }) @@ -251,7 +251,7 @@ describe('OAuth Utils', () => { const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') - expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') + expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined) expect(mockDb.update).toHaveBeenCalled() expect(mockDb.set).toHaveBeenCalled() expect(token).toBe('new-token') diff --git a/apps/sim/blocks/blocks/snowflake.ts b/apps/sim/blocks/blocks/snowflake.ts new file mode 100644 index 0000000000..24d5e047af --- /dev/null +++ b/apps/sim/blocks/blocks/snowflake.ts @@ -0,0 +1,594 @@ +import { SnowflakeIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' +import type { SnowflakeResponse } from '@/tools/snowflake/types' + +export const SnowflakeBlock: BlockConfig = { + type: 'snowflake', + name: 'Snowflake', + description: 'Execute queries on Snowflake data warehouse', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate Snowflake into your workflow. Execute SQL queries, insert, update, and delete rows, list databases, schemas, and tables, and describe table structures in your Snowflake data warehouse.', + docsLink: 'https://docs.sim.ai/tools/snowflake', + category: 'tools', + bgColor: '#E0E0E0', + icon: SnowflakeIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Execute Query', id: 'execute_query' }, + { label: 'Insert Rows', id: 'insert_rows' }, + { label: 'Update Rows', id: 'update_rows' }, + { label: 'Delete Rows', id: 'delete_rows' }, + { label: 'List Databases', id: 'list_databases' }, + { label: 'List Schemas', id: 'list_schemas' }, + { label: 'List Tables', id: 'list_tables' }, + { label: 'List Views', id: 'list_views' }, + { label: 'List Warehouses', id: 'list_warehouses' }, + { label: 'List File Formats', id: 'list_file_formats' }, + { label: 'List Stages', id: 'list_stages' }, + { label: 'Describe Table', id: 'describe_table' }, + ], + value: () => 'execute_query', + }, + { + id: 'accountUrl', + title: 'Account URL', + type: 'short-input', + placeholder: 'your-account.snowflakecomputing.com', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + required: true, + }, + { + id: 'accessToken', + title: 'Personal Access Token', + type: 'short-input', + placeholder: 'Enter your Snowflake PAT', + description: 'Generate a PAT in Snowflake Snowsight', + password: true, + required: true, + }, + { + id: 'warehouse', + title: 'Warehouse', + type: 'short-input', + placeholder: 'Warehouse name', + }, + { + id: 'role', + title: 'Role', + type: 'short-input', + placeholder: 'Role name', + }, + { + id: 'query', + title: 'SQL Query', + type: 'long-input', + required: true, + placeholder: 'Enter SQL query (e.g., SELECT * FROM database.schema.table LIMIT 10)', + condition: { + field: 'operation', + value: 'execute_query', + }, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert Snowflake SQL developer. Generate Snowflake SQL queries based on the user's natural language request. + +### CONTEXT +{context} + +### CRITICAL INSTRUCTION +Return ONLY the SQL query. Do not include any explanations, markdown formatting, comments, or additional text. Just the raw SQL query that can be executed directly in Snowflake. + +### SNOWFLAKE SQL GUIDELINES +1. **Syntax**: Use standard Snowflake SQL syntax and functions +2. **Fully Qualified Names**: Use database.schema.table format when possible +3. **Case Sensitivity**: Identifiers are case-insensitive unless quoted +4. **Performance**: Consider using LIMIT clauses for large datasets +5. **Data Types**: Use appropriate Snowflake data types (VARIANT for JSON, TIMESTAMP_NTZ, etc.) + +### COMMON SNOWFLAKE SQL PATTERNS + +**Basic SELECT**: +SELECT * FROM database.schema.table LIMIT 100; + +**Filtered Query**: +SELECT column1, column2 +FROM database.schema.table +WHERE status = 'active' + AND created_at > DATEADD(day, -7, CURRENT_DATE()) +LIMIT 100; + +**Aggregate Functions**: +SELECT + category, + COUNT(*) as total_count, + AVG(amount) as avg_amount, + SUM(amount) as total_amount +FROM database.schema.sales +GROUP BY category +ORDER BY total_amount DESC; + +**JOIN Operations**: +SELECT + u.user_id, + u.name, + o.order_id, + o.total +FROM database.schema.users u +INNER JOIN database.schema.orders o + ON u.user_id = o.user_id +WHERE o.created_at > CURRENT_DATE() - 30; + +**Window Functions**: +SELECT + user_id, + order_date, + amount, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date DESC) as row_num +FROM database.schema.orders; + +**JSON/VARIANT Queries**: +SELECT + id, + json_data:field::STRING as field_value, + json_data:nested.value::NUMBER as nested_value +FROM database.schema.json_table +WHERE json_data:status::STRING = 'active'; + +**FLATTEN for Arrays**: +SELECT + id, + f.value::STRING as array_item +FROM database.schema.table, +LATERAL FLATTEN(input => array_column) f; + +**CTE (Common Table Expression)**: +WITH active_users AS ( + SELECT user_id, name + FROM database.schema.users + WHERE status = 'active' +) +SELECT + au.name, + COUNT(o.order_id) as order_count +FROM active_users au +LEFT JOIN database.schema.orders o ON au.user_id = o.user_id +GROUP BY au.name; + +**Date/Time Functions**: +SELECT + DATE_TRUNC('month', order_date) as month, + COUNT(*) as orders +FROM database.schema.orders +WHERE order_date >= DATEADD(year, -1, CURRENT_DATE()) +GROUP BY month +ORDER BY month DESC; + +**INSERT Statement**: +INSERT INTO database.schema.table (column1, column2, column3) +VALUES ('value1', 123, CURRENT_TIMESTAMP()); + +**UPDATE Statement**: +UPDATE database.schema.table +SET status = 'processed', updated_at = CURRENT_TIMESTAMP() +WHERE id = 123; + +**DELETE Statement**: +DELETE FROM database.schema.table +WHERE created_at < DATEADD(year, -2, CURRENT_DATE()); + +**MERGE Statement (Upsert)**: +MERGE INTO database.schema.target t +USING database.schema.source s +ON t.id = s.id +WHEN MATCHED THEN + UPDATE SET t.value = s.value, t.updated_at = CURRENT_TIMESTAMP() +WHEN NOT MATCHED THEN + INSERT (id, value, created_at) VALUES (s.id, s.value, CURRENT_TIMESTAMP()); + +### SNOWFLAKE SPECIFIC FEATURES + +**SAMPLE Clause** (for testing with large tables): +SELECT * FROM database.schema.large_table SAMPLE (1000 ROWS); + +**QUALIFY Clause** (filter window functions): +SELECT + user_id, + order_date, + amount +FROM database.schema.orders +QUALIFY ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date DESC) = 1; + +**Time Travel**: +SELECT * FROM database.schema.table AT (TIMESTAMP => '2024-01-01 00:00:00'::TIMESTAMP); + +### BEST PRACTICES +1. Always use LIMIT when exploring data +2. Use WHERE clauses to filter data efficiently +3. Index commonly queried columns +4. Use appropriate date functions (DATEADD, DATE_TRUNC, DATEDIFF) +5. For JSON data, use proper casting (::STRING, ::NUMBER, etc.) +6. Use CTEs for complex queries to improve readability + +### REMEMBER +Return ONLY the SQL query - no explanations, no markdown code blocks, no extra text. The query should be ready to execute.`, + placeholder: + 'Describe the SQL query you need (e.g., "Get all orders from the last 7 days with customer names")...', + generationType: 'sql-query', + }, + }, + { + id: 'database', + title: 'Database', + type: 'short-input', + placeholder: 'Database name', + required: true, + condition: { + field: 'operation', + value: [ + 'list_schemas', + 'list_tables', + 'list_views', + 'list_file_formats', + 'list_stages', + 'describe_table', + 'insert_rows', + 'update_rows', + 'delete_rows', + ], + }, + }, + { + id: 'schema', + title: 'Schema', + type: 'short-input', + placeholder: 'Schema name', + required: true, + condition: { + field: 'operation', + value: [ + 'list_tables', + 'list_views', + 'list_file_formats', + 'list_stages', + 'describe_table', + 'insert_rows', + 'update_rows', + 'delete_rows', + ], + }, + }, + { + id: 'table', + title: 'Table', + type: 'short-input', + placeholder: 'Table name', + required: true, + condition: { + field: 'operation', + value: ['describe_table', 'insert_rows', 'update_rows', 'delete_rows'], + }, + }, + { + id: 'columns', + title: 'Columns', + type: 'long-input', + placeholder: '["column1", "column2", "column3"]', + required: true, + condition: { + field: 'operation', + value: 'insert_rows', + }, + }, + { + id: 'values', + title: 'Values', + type: 'long-input', + placeholder: '[["value1", "value2", "value3"], ["value4", "value5", "value6"]]', + required: true, + condition: { + field: 'operation', + value: 'insert_rows', + }, + }, + { + id: 'updates', + title: 'Updates', + type: 'long-input', + placeholder: '{"column1": "new_value", "column2": 123, "updated_at": "2024-01-01"}', + required: true, + condition: { + field: 'operation', + value: 'update_rows', + }, + }, + { + id: 'whereClause', + title: 'WHERE Clause', + type: 'long-input', + placeholder: 'id = 123 (leave empty to update/delete ALL rows)', + required: false, + condition: { + field: 'operation', + value: ['update_rows', 'delete_rows'], + }, + }, + { + id: 'timeout', + title: 'Timeout (seconds)', + type: 'short-input', + placeholder: '60', + condition: { + field: 'operation', + value: 'execute_query', + }, + }, + ], + tools: { + access: [ + 'snowflake_execute_query', + 'snowflake_insert_rows', + 'snowflake_update_rows', + 'snowflake_delete_rows', + 'snowflake_list_databases', + 'snowflake_list_schemas', + 'snowflake_list_tables', + 'snowflake_list_views', + 'snowflake_list_warehouses', + 'snowflake_list_file_formats', + 'snowflake_list_stages', + 'snowflake_describe_table', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'execute_query': + return 'snowflake_execute_query' + case 'insert_rows': + return 'snowflake_insert_rows' + case 'update_rows': + return 'snowflake_update_rows' + case 'delete_rows': + return 'snowflake_delete_rows' + case 'list_databases': + return 'snowflake_list_databases' + case 'list_schemas': + return 'snowflake_list_schemas' + case 'list_tables': + return 'snowflake_list_tables' + case 'list_views': + return 'snowflake_list_views' + case 'list_warehouses': + return 'snowflake_list_warehouses' + case 'list_file_formats': + return 'snowflake_list_file_formats' + case 'list_stages': + return 'snowflake_list_stages' + case 'describe_table': + return 'snowflake_describe_table' + default: + throw new Error(`Unknown operation: ${params.operation}`) + } + }, + params: (params) => { + const { operation, ...rest } = params + + // Build base params - use PAT directly as accessToken + const baseParams: Record = { + accessToken: params.accessToken, + accountUrl: params.accountUrl, + } + + // Add optional warehouse and role if provided + if (params.warehouse) { + baseParams.warehouse = params.warehouse + } + + if (params.role) { + baseParams.role = params.role + } + + // Operation-specific params + switch (operation) { + case 'execute_query': { + if (!params.query) { + throw new Error('Query is required for execute_query operation') + } + baseParams.query = params.query + if (params.database) baseParams.database = params.database + if (params.schema) baseParams.schema = params.schema + if (params.timeout) baseParams.timeout = Number.parseInt(params.timeout) + break + } + + case 'list_databases': { + // No additional params needed + break + } + + case 'list_schemas': { + if (!params.database) { + throw new Error('Database is required for list_schemas operation') + } + baseParams.database = params.database + break + } + + case 'list_tables': { + if (!params.database || !params.schema) { + throw new Error('Database and Schema are required for list_tables operation') + } + baseParams.database = params.database + baseParams.schema = params.schema + break + } + + case 'list_views': { + if (!params.database || !params.schema) { + throw new Error('Database and Schema are required for list_views operation') + } + baseParams.database = params.database + baseParams.schema = params.schema + break + } + + case 'list_warehouses': { + // No additional params needed + break + } + + case 'list_file_formats': { + if (!params.database || !params.schema) { + throw new Error('Database and Schema are required for list_file_formats operation') + } + baseParams.database = params.database + baseParams.schema = params.schema + break + } + + case 'list_stages': { + if (!params.database || !params.schema) { + throw new Error('Database and Schema are required for list_stages operation') + } + baseParams.database = params.database + baseParams.schema = params.schema + break + } + + case 'describe_table': { + if (!params.database || !params.schema || !params.table) { + throw new Error( + 'Database, Schema, and Table are required for describe_table operation' + ) + } + baseParams.database = params.database + baseParams.schema = params.schema + baseParams.table = params.table + break + } + + case 'insert_rows': { + if (!params.database || !params.schema || !params.table) { + throw new Error('Database, Schema, and Table are required for insert_rows operation') + } + if (!params.columns || !params.values) { + throw new Error('Columns and Values are required for insert_rows operation') + } + + // Parse columns and values if they are strings + let columns = params.columns + let values = params.values + + if (typeof columns === 'string') { + try { + columns = JSON.parse(columns) + } catch (e) { + throw new Error('Columns must be a valid JSON array') + } + } + + if (typeof values === 'string') { + try { + values = JSON.parse(values) + } catch (e) { + throw new Error('Values must be a valid JSON array of arrays') + } + } + + baseParams.database = params.database + baseParams.schema = params.schema + baseParams.table = params.table + baseParams.columns = columns + baseParams.values = values + if (params.timeout) baseParams.timeout = Number.parseInt(params.timeout) + break + } + + case 'update_rows': { + if (!params.database || !params.schema || !params.table) { + throw new Error('Database, Schema, and Table are required for update_rows operation') + } + if (!params.updates) { + throw new Error('Updates object is required for update_rows operation') + } + + // Parse updates if it's a string + let updates = params.updates + if (typeof updates === 'string') { + try { + updates = JSON.parse(updates) + } catch (e) { + throw new Error('Updates must be a valid JSON object') + } + } + + baseParams.database = params.database + baseParams.schema = params.schema + baseParams.table = params.table + baseParams.updates = updates + if (params.whereClause) baseParams.whereClause = params.whereClause + if (params.timeout) baseParams.timeout = Number.parseInt(params.timeout) + break + } + + case 'delete_rows': { + if (!params.database || !params.schema || !params.table) { + throw new Error('Database, Schema, and Table are required for delete_rows operation') + } + + baseParams.database = params.database + baseParams.schema = params.schema + baseParams.table = params.table + if (params.whereClause) baseParams.whereClause = params.whereClause + if (params.timeout) baseParams.timeout = Number.parseInt(params.timeout) + break + } + + default: + throw new Error(`Unknown operation: ${operation}`) + } + + return baseParams + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + accountUrl: { + type: 'string', + description: 'Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + accessToken: { + type: 'string', + description: 'Snowflake Personal Access Token (PAT)', + }, + warehouse: { type: 'string', description: 'Warehouse name' }, + role: { type: 'string', description: 'Role name' }, + query: { type: 'string', description: 'SQL query to execute' }, + database: { type: 'string', description: 'Database name' }, + schema: { type: 'string', description: 'Schema name' }, + table: { type: 'string', description: 'Table name' }, + columns: { type: 'json', description: 'Array of column names for insert operation' }, + values: { type: 'json', description: 'Array of arrays containing values for insert operation' }, + updates: { + type: 'json', + description: 'Object containing column-value pairs for update operation', + }, + whereClause: { type: 'string', description: 'WHERE clause for update/delete operations' }, + timeout: { type: 'string', description: 'Query timeout in seconds' }, + }, + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + output: { + type: 'json', + description: + 'Operation results containing query data, databases, schemas, tables, or column definitions based on the selected operation', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 40b5ad6aad..68abb6be26 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -97,6 +97,7 @@ import { SharepointBlock } from '@/blocks/blocks/sharepoint' import { ShopifyBlock } from '@/blocks/blocks/shopify' import { SlackBlock } from '@/blocks/blocks/slack' import { SmtpBlock } from '@/blocks/blocks/smtp' +import { SnowflakeBlock } from '@/blocks/blocks/snowflake' import { SSHBlock } from '@/blocks/blocks/ssh' import { StagehandBlock } from '@/blocks/blocks/stagehand' import { StagehandAgentBlock } from '@/blocks/blocks/stagehand_agent' @@ -234,6 +235,7 @@ export const registry: Record = { shopify: ShopifyBlock, slack: SlackBlock, smtp: SmtpBlock, + snowflake: SnowflakeBlock, ssh: SSHBlock, stagehand: StagehandBlock, stagehand_agent: StagehandAgentBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index d6dc0ae4d2..e6ee4433f6 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4089,3 +4089,18 @@ export function PolymarketIcon(props: SVGProps) { ) } + +export function SnowflakeIcon(props: SVGProps) { + return ( + + + + ) +} diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index bfd86227d1..f82e744f50 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -109,6 +109,7 @@ export type OAuthService = | 'shopify' | 'zoom' | 'wordpress' + export interface OAuthProviderConfig { id: OAuthProvider name: string diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2433ae86df..0f73c4bbf2 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1010,6 +1010,20 @@ import { } from '@/tools/slack' import { smsSendTool } from '@/tools/sms' import { smtpSendMailTool } from '@/tools/smtp' +import { + snowflakeDeleteRowsTool, + snowflakeDescribeTableTool, + snowflakeExecuteQueryTool, + snowflakeInsertRowsTool, + snowflakeListDatabasesTool, + snowflakeListFileFormatsTool, + snowflakeListSchemasTool, + snowflakeListStagesTool, + snowflakeListTablesTool, + snowflakeListViewsTool, + snowflakeListWarehousesTool, + snowflakeUpdateRowsTool, +} from '@/tools/snowflake' import { checkCommandExistsTool as sshCheckCommandExistsTool, checkFileExistsTool as sshCheckFileExistsTool, @@ -2425,4 +2439,16 @@ export const tools: Record = { zoom_get_meeting_recordings: zoomGetMeetingRecordingsTool, zoom_delete_recording: zoomDeleteRecordingTool, zoom_list_past_participants: zoomListPastParticipantsTool, + snowflake_execute_query: snowflakeExecuteQueryTool, + snowflake_insert_rows: snowflakeInsertRowsTool, + snowflake_update_rows: snowflakeUpdateRowsTool, + snowflake_delete_rows: snowflakeDeleteRowsTool, + snowflake_list_databases: snowflakeListDatabasesTool, + snowflake_list_schemas: snowflakeListSchemasTool, + snowflake_list_tables: snowflakeListTablesTool, + snowflake_list_views: snowflakeListViewsTool, + snowflake_list_warehouses: snowflakeListWarehousesTool, + snowflake_list_file_formats: snowflakeListFileFormatsTool, + snowflake_list_stages: snowflakeListStagesTool, + snowflake_describe_table: snowflakeDescribeTableTool, } diff --git a/apps/sim/tools/snowflake/delete_rows.ts b/apps/sim/tools/snowflake/delete_rows.ts new file mode 100644 index 0000000000..ff3038fb4b --- /dev/null +++ b/apps/sim/tools/snowflake/delete_rows.ts @@ -0,0 +1,192 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeDeleteRowsParams, + SnowflakeDeleteRowsResponse, +} from '@/tools/snowflake/types' +import { parseAccountUrl, sanitizeIdentifier, validateWhereClause } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeDeleteRowsTool') + +/** + * Build DELETE SQL statement from parameters + */ +function buildDeleteSQL( + database: string, + schema: string, + table: string, + whereClause?: string +): string { + const sanitizedDatabase = sanitizeIdentifier(database) + const sanitizedSchema = sanitizeIdentifier(schema) + const sanitizedTable = sanitizeIdentifier(table) + const fullTableName = `${sanitizedDatabase}.${sanitizedSchema}.${sanitizedTable}` + + let sql = `DELETE FROM ${fullTableName}` + + if (whereClause?.trim()) { + validateWhereClause(whereClause) + sql += ` WHERE ${whereClause}` + } + + return sql +} + +export const snowflakeDeleteRowsTool: ToolConfig< + SnowflakeDeleteRowsParams, + SnowflakeDeleteRowsResponse +> = { + id: 'snowflake_delete_rows', + name: 'Snowflake Delete Rows', + description: 'Delete rows from a Snowflake table', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Snowflake Personal Access Token (PAT)', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name', + }, + table: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Table name', + }, + whereClause: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'WHERE clause to filter rows to delete (e.g., "id = 123" or "status = \'inactive\' AND created_at < \'2024-01-01\'"). WARNING: If not provided, ALL rows will be deleted.', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + timeout: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Query timeout in seconds (default: 60)', + }, + }, + + request: { + url: (params: SnowflakeDeleteRowsParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeDeleteRowsParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }), + body: (params: SnowflakeDeleteRowsParams) => { + // Build DELETE SQL + const deleteSQL = buildDeleteSQL( + params.database, + params.schema, + params.table, + params.whereClause + ) + + logger.info('Building DELETE statement', { + database: params.database, + schema: params.schema, + table: params.table, + hasWhereClause: !!params.whereClause, + }) + + // Log warning if no WHERE clause provided + if (!params.whereClause) { + logger.warn('DELETE statement has no WHERE clause - ALL rows will be deleted', { + table: `${params.database}.${params.schema}.${params.table}`, + }) + } + + const requestBody: Record = { + statement: deleteSQL, + timeout: params.timeout || 60, + database: params.database, + schema: params.schema, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeDeleteRowsParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to delete rows from Snowflake table', { + status: response.status, + errorText, + table: params ? `${params.database}.${params.schema}.${params.table}` : 'unknown', + }) + throw new Error(`Failed to delete rows: ${response.status} - ${errorText}`) + } + + const data = await response.json() + + // Extract number of rows deleted from response + const rowsDeleted = data.statementStatusUrl ? 'unknown' : 0 + + return { + success: true, + output: { + statementHandle: data.statementHandle, + rowsDeleted, + message: `Successfully deleted rows from ${params?.database}.${params?.schema}.${params?.table}`, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'Delete operation result', + }, + }, +} diff --git a/apps/sim/tools/snowflake/describe_table.ts b/apps/sim/tools/snowflake/describe_table.ts new file mode 100644 index 0000000000..5e84c7ffe5 --- /dev/null +++ b/apps/sim/tools/snowflake/describe_table.ts @@ -0,0 +1,133 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeDescribeTableParams, + SnowflakeDescribeTableResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl, sanitizeIdentifier } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeDescribeTableTool') + +export const snowflakeDescribeTableTool: ToolConfig< + SnowflakeDescribeTableParams, + SnowflakeDescribeTableResponse +> = { + id: 'snowflake_describe_table', + name: 'Snowflake Describe Table', + description: 'Get the schema and structure of a Snowflake table', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Snowflake Personal Access Token (PAT)', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name', + }, + table: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Table name to describe', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeDescribeTableParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeDescribeTableParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }), + body: (params: SnowflakeDescribeTableParams) => { + const sanitizedDatabase = sanitizeIdentifier(params.database) + const sanitizedSchema = sanitizeIdentifier(params.schema) + const sanitizedTable = sanitizeIdentifier(params.table) + const fullTableName = `${sanitizedDatabase}.${sanitizedSchema}.${sanitizedTable}` + + const requestBody: Record = { + statement: `DESCRIBE TABLE ${fullTableName}`, + timeout: 60, + database: params.database, + schema: params.schema, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeDescribeTableParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to describe Snowflake table', { + status: response.status, + errorText, + }) + throw new Error(`Failed to describe table: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + columns: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'Table column definitions and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/execute_query.ts b/apps/sim/tools/snowflake/execute_query.ts new file mode 100644 index 0000000000..340b73b5f5 --- /dev/null +++ b/apps/sim/tools/snowflake/execute_query.ts @@ -0,0 +1,150 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeExecuteQueryParams, + SnowflakeExecuteQueryResponse, +} from '@/tools/snowflake/types' +import { + extractColumnMetadata, + extractResponseData, + parseAccountUrl, +} from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeExecuteQueryTool') + +export const snowflakeExecuteQueryTool: ToolConfig< + SnowflakeExecuteQueryParams, + SnowflakeExecuteQueryResponse +> = { + id: 'snowflake_execute_query', + name: 'Snowflake Execute Query', + description: 'Execute a SQL query on your Snowflake data warehouse', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Snowflake Personal Access Token (PAT)', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + query: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'SQL query to execute (SELECT, INSERT, UPDATE, DELETE, etc.)', + }, + database: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Database to use for the query (optional)', + }, + schema: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Schema to use for the query (optional)', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use for query execution (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use for query execution (optional)', + }, + timeout: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Query timeout in seconds (default: 60)', + }, + }, + + request: { + url: (params: SnowflakeExecuteQueryParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeExecuteQueryParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }), + body: (params: SnowflakeExecuteQueryParams) => { + const requestBody: Record = { + statement: params.query, + timeout: params.timeout || 60, + } + + if (params.database) { + requestBody.database = params.database + } + + if (params.schema) { + requestBody.schema = params.schema + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeExecuteQueryParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to execute Snowflake query', { + status: response.status, + errorText, + }) + throw new Error(`Failed to execute query: ${response.status} - ${errorText}`) + } + + const data = await response.json() + + const extractedData = extractResponseData(data) + const columns = extractColumnMetadata(data) + + return { + success: true, + output: { + statementHandle: data.statementHandle, + data: extractedData, + rowCount: extractedData.length, + columns, + message: data.message || 'Query executed successfully', + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'Query execution results and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/index.ts b/apps/sim/tools/snowflake/index.ts new file mode 100644 index 0000000000..63c37c1dca --- /dev/null +++ b/apps/sim/tools/snowflake/index.ts @@ -0,0 +1,27 @@ +import { snowflakeDeleteRowsTool } from '@/tools/snowflake/delete_rows' +import { snowflakeDescribeTableTool } from '@/tools/snowflake/describe_table' +import { snowflakeExecuteQueryTool } from '@/tools/snowflake/execute_query' +import { snowflakeInsertRowsTool } from '@/tools/snowflake/insert_rows' +import { snowflakeListDatabasesTool } from '@/tools/snowflake/list_databases' +import { snowflakeListFileFormatsTool } from '@/tools/snowflake/list_file_formats' +import { snowflakeListSchemasTool } from '@/tools/snowflake/list_schemas' +import { snowflakeListStagesTool } from '@/tools/snowflake/list_stages' +import { snowflakeListTablesTool } from '@/tools/snowflake/list_tables' +import { snowflakeListViewsTool } from '@/tools/snowflake/list_views' +import { snowflakeListWarehousesTool } from '@/tools/snowflake/list_warehouses' +import { snowflakeUpdateRowsTool } from '@/tools/snowflake/update_rows' + +export { + snowflakeExecuteQueryTool, + snowflakeListDatabasesTool, + snowflakeListSchemasTool, + snowflakeListTablesTool, + snowflakeDescribeTableTool, + snowflakeListViewsTool, + snowflakeListWarehousesTool, + snowflakeListFileFormatsTool, + snowflakeListStagesTool, + snowflakeInsertRowsTool, + snowflakeUpdateRowsTool, + snowflakeDeleteRowsTool, +} diff --git a/apps/sim/tools/snowflake/insert_rows.ts b/apps/sim/tools/snowflake/insert_rows.ts new file mode 100644 index 0000000000..60d381f21c --- /dev/null +++ b/apps/sim/tools/snowflake/insert_rows.ts @@ -0,0 +1,226 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeInsertRowsParams, + SnowflakeInsertRowsResponse, +} from '@/tools/snowflake/types' +import { parseAccountUrl, sanitizeIdentifier } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeInsertRowsTool') + +/** + * Build INSERT SQL statement from parameters with proper identifier quoting + */ +function buildInsertSQL( + database: string, + schema: string, + table: string, + columns: string[], + values: any[][] +): string { + const sanitizedDatabase = sanitizeIdentifier(database) + const sanitizedSchema = sanitizeIdentifier(schema) + const sanitizedTable = sanitizeIdentifier(table) + const fullTableName = `${sanitizedDatabase}.${sanitizedSchema}.${sanitizedTable}` + + const columnList = columns.map((col) => sanitizeIdentifier(col)).join(', ') + + const valuesClause = values + .map((rowValues) => { + const formattedValues = rowValues.map((val) => { + if (val === null || val === undefined) { + return 'NULL' + } + if (typeof val === 'string') { + return `'${val.replace(/'/g, "''")}'` + } + if (typeof val === 'boolean') { + return val ? 'TRUE' : 'FALSE' + } + return String(val) + }) + return `(${formattedValues.join(', ')})` + }) + .join(', ') + + return `INSERT INTO ${fullTableName} (${columnList}) VALUES ${valuesClause}` +} + +export const snowflakeInsertRowsTool: ToolConfig< + SnowflakeInsertRowsParams, + SnowflakeInsertRowsResponse +> = { + id: 'snowflake_insert_rows', + name: 'Snowflake Insert Rows', + description: 'Insert rows into a Snowflake table', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Snowflake Personal Access Token (PAT)', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name', + }, + table: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Table name', + }, + columns: { + type: 'array', + required: true, + visibility: 'user-only', + description: 'Array of column names to insert data into', + }, + values: { + type: 'array', + required: true, + visibility: 'user-only', + description: + 'Array of arrays containing values to insert. Each inner array represents one row and must match the order of columns.', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + timeout: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Query timeout in seconds (default: 60)', + }, + }, + + request: { + url: (params: SnowflakeInsertRowsParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeInsertRowsParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }), + body: (params: SnowflakeInsertRowsParams) => { + // Validate inputs + if (!Array.isArray(params.columns) || params.columns.length === 0) { + throw new Error('Columns must be a non-empty array') + } + + if (!Array.isArray(params.values) || params.values.length === 0) { + throw new Error('Values must be a non-empty array') + } + + // Validate each row has correct number of values + for (let i = 0; i < params.values.length; i++) { + if (!Array.isArray(params.values[i])) { + throw new Error(`Values row ${i} must be an array`) + } + if (params.values[i].length !== params.columns.length) { + throw new Error( + `Values row ${i} has ${params.values[i].length} values but ${params.columns.length} columns were specified` + ) + } + } + + const insertSQL = buildInsertSQL( + params.database, + params.schema, + params.table, + params.columns, + params.values + ) + + logger.info('Building INSERT statement', { + database: params.database, + schema: params.schema, + table: params.table, + columnCount: params.columns.length, + rowCount: params.values.length, + }) + + const requestBody: Record = { + statement: insertSQL, + timeout: params.timeout || 60, + database: params.database, + schema: params.schema, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeInsertRowsParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to insert rows into Snowflake table', { + status: response.status, + errorText, + table: params ? `${params.database}.${params.schema}.${params.table}` : 'unknown', + }) + throw new Error(`Failed to insert rows: ${response.status} - ${errorText}`) + } + + const data = await response.json() + + const rowsInserted = data.statementStatusUrl ? params?.values.length || 0 : 0 + + return { + success: true, + output: { + statementHandle: data.statementHandle, + rowsInserted, + message: `Successfully inserted ${rowsInserted} row(s) into ${params?.database}.${params?.schema}.${params?.table}`, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'Insert operation result with row count', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_databases.ts b/apps/sim/tools/snowflake/list_databases.ts new file mode 100644 index 0000000000..0ebfb55ff2 --- /dev/null +++ b/apps/sim/tools/snowflake/list_databases.ts @@ -0,0 +1,108 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeListDatabasesParams, + SnowflakeListDatabasesResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListDatabasesTool') + +export const snowflakeListDatabasesTool: ToolConfig< + SnowflakeListDatabasesParams, + SnowflakeListDatabasesResponse +> = { + id: 'snowflake_list_databases', + name: 'Snowflake List Databases', + description: 'List all databases in your Snowflake account', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Snowflake Personal Access Token (PAT)', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListDatabasesParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListDatabasesParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }), + body: (params: SnowflakeListDatabasesParams) => { + const requestBody: Record = { + statement: 'SHOW DATABASES', + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListDatabasesParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake databases', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list databases: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + databases: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of databases and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_file_formats.ts b/apps/sim/tools/snowflake/list_file_formats.ts new file mode 100644 index 0000000000..265f36bf97 --- /dev/null +++ b/apps/sim/tools/snowflake/list_file_formats.ts @@ -0,0 +1,120 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeListFileFormatsParams, + SnowflakeListFileFormatsResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListFileFormatsTool') + +export const snowflakeListFileFormatsTool: ToolConfig< + SnowflakeListFileFormatsParams, + SnowflakeListFileFormatsResponse +> = { + id: 'snowflake_list_file_formats', + name: 'Snowflake List File Formats', + description: 'List all file formats in a Snowflake schema', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Snowflake Personal Access Token (PAT)', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name to list file formats from', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListFileFormatsParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListFileFormatsParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }), + body: (params: SnowflakeListFileFormatsParams) => { + const requestBody: Record = { + statement: `SHOW FILE FORMATS IN ${params.database}.${params.schema}`, + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListFileFormatsParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake file formats', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list file formats: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + fileFormats: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of file formats and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_schemas.ts b/apps/sim/tools/snowflake/list_schemas.ts new file mode 100644 index 0000000000..7514f99022 --- /dev/null +++ b/apps/sim/tools/snowflake/list_schemas.ts @@ -0,0 +1,117 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeListSchemasParams, + SnowflakeListSchemasResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl, sanitizeIdentifier } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListSchemasTool') + +export const snowflakeListSchemasTool: ToolConfig< + SnowflakeListSchemasParams, + SnowflakeListSchemasResponse +> = { + id: 'snowflake_list_schemas', + name: 'Snowflake List Schemas', + description: 'List all schemas in a Snowflake database', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Snowflake Personal Access Token (PAT)', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to list schemas from', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListSchemasParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListSchemasParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }), + body: (params: SnowflakeListSchemasParams) => { + const sanitizedDatabase = sanitizeIdentifier(params.database) + + const requestBody: Record = { + statement: `SHOW SCHEMAS IN DATABASE ${sanitizedDatabase}`, + timeout: 60, + database: params.database, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListSchemasParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake schemas', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list schemas: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + schemas: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of schemas and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_stages.ts b/apps/sim/tools/snowflake/list_stages.ts new file mode 100644 index 0000000000..3d70e79793 --- /dev/null +++ b/apps/sim/tools/snowflake/list_stages.ts @@ -0,0 +1,120 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeListStagesParams, + SnowflakeListStagesResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListStagesTool') + +export const snowflakeListStagesTool: ToolConfig< + SnowflakeListStagesParams, + SnowflakeListStagesResponse +> = { + id: 'snowflake_list_stages', + name: 'Snowflake List Stages', + description: 'List all stages in a Snowflake schema', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Snowflake Personal Access Token (PAT)', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name to list stages from', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListStagesParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListStagesParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }), + body: (params: SnowflakeListStagesParams) => { + const requestBody: Record = { + statement: `SHOW STAGES IN ${params.database}.${params.schema}`, + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListStagesParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake stages', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list stages: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + stages: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of stages and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_tables.ts b/apps/sim/tools/snowflake/list_tables.ts new file mode 100644 index 0000000000..4df9a9d674 --- /dev/null +++ b/apps/sim/tools/snowflake/list_tables.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeListTablesParams, + SnowflakeListTablesResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl, sanitizeIdentifier } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListTablesTool') + +export const snowflakeListTablesTool: ToolConfig< + SnowflakeListTablesParams, + SnowflakeListTablesResponse +> = { + id: 'snowflake_list_tables', + name: 'Snowflake List Tables', + description: 'List all tables in a Snowflake schema', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Snowflake Personal Access Token (PAT)', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name to list tables from', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListTablesParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListTablesParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }), + body: (params: SnowflakeListTablesParams) => { + const sanitizedDatabase = sanitizeIdentifier(params.database) + const sanitizedSchema = sanitizeIdentifier(params.schema) + + const requestBody: Record = { + statement: `SHOW TABLES IN ${sanitizedDatabase}.${sanitizedSchema}`, + timeout: 60, + database: params.database, + schema: params.schema, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListTablesParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake tables', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list tables: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + tables: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of tables and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_views.ts b/apps/sim/tools/snowflake/list_views.ts new file mode 100644 index 0000000000..f9973273bd --- /dev/null +++ b/apps/sim/tools/snowflake/list_views.ts @@ -0,0 +1,117 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { SnowflakeListViewsParams, SnowflakeListViewsResponse } from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListViewsTool') + +export const snowflakeListViewsTool: ToolConfig< + SnowflakeListViewsParams, + SnowflakeListViewsResponse +> = { + id: 'snowflake_list_views', + name: 'Snowflake List Views', + description: 'List all views in a Snowflake schema', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Snowflake Personal Access Token (PAT)', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name to list views from', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListViewsParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListViewsParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }), + body: (params: SnowflakeListViewsParams) => { + const requestBody: Record = { + statement: `SHOW VIEWS IN ${params.database}.${params.schema}`, + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListViewsParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake views', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list views: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + views: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of views and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_warehouses.ts b/apps/sim/tools/snowflake/list_warehouses.ts new file mode 100644 index 0000000000..79e2d1eba3 --- /dev/null +++ b/apps/sim/tools/snowflake/list_warehouses.ts @@ -0,0 +1,108 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeListWarehousesParams, + SnowflakeListWarehousesResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListWarehousesTool') + +export const snowflakeListWarehousesTool: ToolConfig< + SnowflakeListWarehousesParams, + SnowflakeListWarehousesResponse +> = { + id: 'snowflake_list_warehouses', + name: 'Snowflake List Warehouses', + description: 'List all warehouses in the Snowflake account', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Snowflake Personal Access Token (PAT)', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListWarehousesParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListWarehousesParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }), + body: (params: SnowflakeListWarehousesParams) => { + const requestBody: Record = { + statement: 'SHOW WAREHOUSES', + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListWarehousesParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake warehouses', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list warehouses: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + warehouses: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of warehouses and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/types.ts b/apps/sim/tools/snowflake/types.ts new file mode 100644 index 0000000000..1ceda4ff4b --- /dev/null +++ b/apps/sim/tools/snowflake/types.ts @@ -0,0 +1,342 @@ +import type { ToolResponse } from '@/tools/types' + +/** + * Snowflake tool types and interfaces + */ + +/** + * Base parameters for Snowflake operations + */ +export interface SnowflakeBaseParams { + accessToken: string + accountUrl: string +} + +/** + * Parameters for executing a SQL query + */ +export interface SnowflakeExecuteQueryParams extends SnowflakeBaseParams { + query: string + database?: string + schema?: string + warehouse?: string + role?: string + timeout?: number +} + +/** + * Parameters for listing databases + */ +export interface SnowflakeListDatabasesParams extends SnowflakeBaseParams { + warehouse?: string + role?: string +} + +/** + * Parameters for listing schemas + */ +export interface SnowflakeListSchemasParams extends SnowflakeBaseParams { + database: string + warehouse?: string + role?: string +} + +/** + * Parameters for listing tables + */ +export interface SnowflakeListTablesParams extends SnowflakeBaseParams { + database: string + schema: string + warehouse?: string + role?: string +} + +/** + * Parameters for describing a table + */ +export interface SnowflakeDescribeTableParams extends SnowflakeBaseParams { + database: string + schema: string + table: string + warehouse?: string + role?: string +} + +/** + * Parameters for listing views + */ +export interface SnowflakeListViewsParams extends SnowflakeBaseParams { + database: string + schema: string + warehouse?: string + role?: string +} + +/** + * Parameters for listing warehouses + */ +export interface SnowflakeListWarehousesParams extends SnowflakeBaseParams { + warehouse?: string + role?: string +} + +/** + * Parameters for listing file formats + */ +export interface SnowflakeListFileFormatsParams extends SnowflakeBaseParams { + database: string + schema: string + warehouse?: string + role?: string +} + +/** + * Parameters for listing stages + */ +export interface SnowflakeListStagesParams extends SnowflakeBaseParams { + database: string + schema: string + warehouse?: string + role?: string +} + +/** + * Response for execute query operations + */ +export interface SnowflakeExecuteQueryResponse extends ToolResponse { + output: { + statementHandle?: string + message?: string + data?: any[] + rowCount?: number + columns?: Array<{ + name: string + type: string + }> + ts: string + } +} + +/** + * Response for list databases operation + */ +export interface SnowflakeListDatabasesResponse extends ToolResponse { + output: { + databases?: Array<{ + name: string + created_on: string + owner: string + }> + ts: string + } +} + +/** + * Response for list schemas operation + */ +export interface SnowflakeListSchemasResponse extends ToolResponse { + output: { + schemas?: Array<{ + name: string + database_name: string + created_on: string + owner: string + }> + ts: string + } +} + +/** + * Response for list tables operation + */ +export interface SnowflakeListTablesResponse extends ToolResponse { + output: { + tables?: Array<{ + name: string + database_name: string + schema_name: string + kind: string + created_on: string + row_count: number + }> + ts: string + } +} + +/** + * Response for describe table operation + */ +export interface SnowflakeDescribeTableResponse extends ToolResponse { + output: { + columns?: Array<{ + name: string + type: string + kind: string + null: string + default: string | null + primary_key: string + unique_key: string + check: string | null + expression: string | null + comment: string | null + }> + ts: string + } +} + +/** + * Response for list views operation + */ +export interface SnowflakeListViewsResponse extends ToolResponse { + output: { + views?: Array<{ + name: string + database_name: string + schema_name: string + created_on: string + owner: string + }> + ts: string + } +} + +/** + * Response for list warehouses operation + */ +export interface SnowflakeListWarehousesResponse extends ToolResponse { + output: { + warehouses?: Array<{ + name: string + state: string + size: string + created_on: string + owner: string + }> + ts: string + } +} + +/** + * Response for list file formats operation + */ +export interface SnowflakeListFileFormatsResponse extends ToolResponse { + output: { + fileFormats?: Array<{ + name: string + type: string + owner: string + created_on: string + }> + ts: string + } +} + +/** + * Response for list stages operation + */ +export interface SnowflakeListStagesResponse extends ToolResponse { + output: { + stages?: Array<{ + name: string + type: string + url: string + created_on: string + owner: string + }> + ts: string + } +} + +/** + * Parameters for inserting rows + */ +export interface SnowflakeInsertRowsParams extends SnowflakeBaseParams { + database: string + schema: string + table: string + columns: string[] + values: any[][] + warehouse?: string + role?: string + timeout?: number +} + +/** + * Parameters for updating rows + */ +export interface SnowflakeUpdateRowsParams extends SnowflakeBaseParams { + database: string + schema: string + table: string + updates: Record + whereClause?: string + warehouse?: string + role?: string + timeout?: number +} + +/** + * Parameters for deleting rows + */ +export interface SnowflakeDeleteRowsParams extends SnowflakeBaseParams { + database: string + schema: string + table: string + whereClause?: string + warehouse?: string + role?: string + timeout?: number +} + +/** + * Response for insert rows operation + */ +export interface SnowflakeInsertRowsResponse extends ToolResponse { + output: { + statementHandle?: string + rowsInserted?: number + message?: string + ts: string + } +} + +/** + * Response for update rows operation + */ +export interface SnowflakeUpdateRowsResponse extends ToolResponse { + output: { + statementHandle?: string + rowsUpdated?: number | string + message?: string + ts: string + } +} + +/** + * Response for delete rows operation + */ +export interface SnowflakeDeleteRowsResponse extends ToolResponse { + output: { + statementHandle?: string + rowsDeleted?: number | string + message?: string + ts: string + } +} + +/** + * Generic Snowflake response type for the block + */ +export type SnowflakeResponse = + | SnowflakeExecuteQueryResponse + | SnowflakeListDatabasesResponse + | SnowflakeListSchemasResponse + | SnowflakeListTablesResponse + | SnowflakeDescribeTableResponse + | SnowflakeListViewsResponse + | SnowflakeListWarehousesResponse + | SnowflakeListFileFormatsResponse + | SnowflakeListStagesResponse + | SnowflakeInsertRowsResponse + | SnowflakeUpdateRowsResponse + | SnowflakeDeleteRowsResponse diff --git a/apps/sim/tools/snowflake/update_rows.ts b/apps/sim/tools/snowflake/update_rows.ts new file mode 100644 index 0000000000..ce807f005b --- /dev/null +++ b/apps/sim/tools/snowflake/update_rows.ts @@ -0,0 +1,232 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeUpdateRowsParams, + SnowflakeUpdateRowsResponse, +} from '@/tools/snowflake/types' +import { parseAccountUrl, sanitizeIdentifier, validateWhereClause } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeUpdateRowsTool') + +/** + * Build UPDATE SQL statement from parameters with proper identifier quoting + */ +function buildUpdateSQL( + database: string, + schema: string, + table: string, + updates: Record, + whereClause?: string +): string { + const sanitizedDatabase = sanitizeIdentifier(database) + const sanitizedSchema = sanitizeIdentifier(schema) + const sanitizedTable = sanitizeIdentifier(table) + const fullTableName = `${sanitizedDatabase}.${sanitizedSchema}.${sanitizedTable}` + + const setClause = Object.entries(updates) + .map(([column, value]) => { + const sanitizedColumn = sanitizeIdentifier(column) + + let formattedValue: string + + if (value === null || value === undefined) { + formattedValue = 'NULL' + } else if (typeof value === 'string') { + // Escape single quotes by doubling them + formattedValue = `'${value.replace(/'/g, "''")}'` + } else if (typeof value === 'boolean') { + formattedValue = value ? 'TRUE' : 'FALSE' + } else { + formattedValue = String(value) + } + + return `${sanitizedColumn} = ${formattedValue}` + }) + .join(', ') + + let sql = `UPDATE ${fullTableName} SET ${setClause}` + + if (whereClause?.trim()) { + validateWhereClause(whereClause) + sql += ` WHERE ${whereClause}` + } + + return sql +} + +export const snowflakeUpdateRowsTool: ToolConfig< + SnowflakeUpdateRowsParams, + SnowflakeUpdateRowsResponse +> = { + id: 'snowflake_update_rows', + name: 'Snowflake Update Rows', + description: 'Update rows in a Snowflake table', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Snowflake Personal Access Token (PAT)', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name', + }, + table: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Table name', + }, + updates: { + type: 'object', + required: true, + visibility: 'user-only', + description: + 'Object containing column-value pairs to update (e.g., {"status": "active", "updated_at": "2024-01-01"})', + }, + whereClause: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'WHERE clause to filter rows to update (e.g., "id = 123" or "status = \'pending\' AND created_at < \'2024-01-01\'"). If not provided, all rows will be updated.', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + timeout: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Query timeout in seconds (default: 60)', + }, + }, + + request: { + url: (params: SnowflakeUpdateRowsParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeUpdateRowsParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }), + body: (params: SnowflakeUpdateRowsParams) => { + // Validate inputs + if ( + !params.updates || + typeof params.updates !== 'object' || + Object.keys(params.updates).length === 0 + ) { + throw new Error('Updates must be a non-empty object with column-value pairs') + } + + // Build UPDATE SQL + const updateSQL = buildUpdateSQL( + params.database, + params.schema, + params.table, + params.updates, + params.whereClause + ) + + logger.info('Building UPDATE statement', { + database: params.database, + schema: params.schema, + table: params.table, + updateColumnCount: Object.keys(params.updates).length, + hasWhereClause: !!params.whereClause, + }) + + // Log warning if no WHERE clause provided + if (!params.whereClause) { + logger.warn('UPDATE statement has no WHERE clause - all rows will be updated', { + table: `${params.database}.${params.schema}.${params.table}`, + }) + } + + const requestBody: Record = { + statement: updateSQL, + timeout: params.timeout || 60, + database: params.database, + schema: params.schema, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeUpdateRowsParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to update rows in Snowflake table', { + status: response.status, + errorText, + table: params ? `${params.database}.${params.schema}.${params.table}` : 'unknown', + }) + throw new Error(`Failed to update rows: ${response.status} - ${errorText}`) + } + + const data = await response.json() + + // Extract number of rows updated from response + const rowsUpdated = data.statementStatusUrl ? 'unknown' : 0 + + return { + success: true, + output: { + statementHandle: data.statementHandle, + rowsUpdated, + message: `Successfully updated rows in ${params?.database}.${params?.schema}.${params?.table}`, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'Update operation result', + }, + }, +} diff --git a/apps/sim/tools/snowflake/utils.ts b/apps/sim/tools/snowflake/utils.ts new file mode 100644 index 0000000000..daf77d4ca3 --- /dev/null +++ b/apps/sim/tools/snowflake/utils.ts @@ -0,0 +1,184 @@ +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('Snowflake Utils') + +/** + * Build the base Snowflake SQL API URL + */ +export function buildSnowflakeSQLAPIUrl(accountUrl: string): string { + // Remove https:// if present + const cleanUrl = accountUrl.replace(/^https?:\/\//, '') + return `https://${cleanUrl}/api/v2/statements` +} + +/** + * Execute a Snowflake SQL statement + */ +export async function executeSnowflakeStatement( + accountUrl: string, + accessToken: string, + query: string, + options?: { + database?: string + schema?: string + warehouse?: string + role?: string + timeout?: number + async?: boolean + } +): Promise { + const apiUrl = buildSnowflakeSQLAPIUrl(accountUrl) + + const requestBody: any = { + statement: query, + timeout: options?.timeout || 60, + } + + if (options?.database) { + requestBody.database = options.database + } + + if (options?.schema) { + requestBody.schema = options.schema + } + + if (options?.warehouse) { + requestBody.warehouse = options.warehouse + } + + if (options?.role) { + requestBody.role = options.role + } + + if (options?.async) { + requestBody.async = true + } + + logger.info('Executing Snowflake statement', { + accountUrl, + hasAccessToken: !!accessToken, + database: options?.database, + schema: options?.schema, + }) + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', + }, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Snowflake API error', { + status: response.status, + statusText: response.statusText, + errorText, + }) + throw new Error(`Snowflake API error: ${response.status} - ${errorText}`) + } + + const data = await response.json() + logger.info('Snowflake statement executed successfully') + + return data +} + +/** + * Parse Snowflake account URL to ensure proper format + */ +export function parseAccountUrl(accountUrl: string): string { + // Remove protocol if present + let cleanUrl = accountUrl.replace(/^https?:\/\//, '') + + // Remove trailing slash if present + cleanUrl = cleanUrl.replace(/\/$/, '') + + // If it doesn't contain snowflakecomputing.com, append it + if (!cleanUrl.includes('snowflakecomputing.com')) { + cleanUrl = `${cleanUrl}.snowflakecomputing.com` + } + + return cleanUrl +} + +/** + * Extract data from Snowflake API response + */ +export function extractResponseData(response: any): any[] { + if (!response.data || response.data.length === 0) { + return [] + } + + const rows: any[] = [] + + for (const row of response.data) { + const rowData: any = {} + for (let i = 0; i < row.length; i++) { + const columnName = response.resultSetMetaData?.rowType?.[i]?.name || `column_${i}` + rowData[columnName] = row[i] + } + rows.push(rowData) + } + + return rows +} + +/** + * Extract column metadata from Snowflake API response + */ +export function extractColumnMetadata(response: any): Array<{ name: string; type: string }> { + if (!response.resultSetMetaData?.rowType) { + return [] + } + + return response.resultSetMetaData.rowType.map((col: any) => ({ + name: col.name, + type: col.type, + })) +} + +export function sanitizeIdentifier(identifier: string): string { + if (identifier.includes('.')) { + const parts = identifier.split('.') + return parts.map((part) => sanitizeSingleIdentifier(part)).join('.') + } + + return sanitizeSingleIdentifier(identifier) +} + +export function validateWhereClause(where: string): void { + const dangerousPatterns = [ + /;\s*(drop|delete|insert|update|create|alter|grant|revoke|truncate)/i, + /union\s+select/i, + /into\s+outfile/i, + /load_file/i, + /--/, + /\/\*/, + /\*\//, + /xp_cmdshell/i, + /exec\s*\(/i, + /execute\s+immediate/i, + ] + + for (const pattern of dangerousPatterns) { + if (pattern.test(where)) { + throw new Error('WHERE clause contains potentially dangerous operation') + } + } +} + +function sanitizeSingleIdentifier(identifier: string): string { + const cleaned = identifier.replace(/"/g, '') + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) { + throw new Error( + `Invalid identifier: ${identifier}. Identifiers must start with a letter or underscore and contain only letters, numbers, and underscores.` + ) + } + + return `"${cleaned}"` +}