mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(integrations): add Snowflake PAT integration (#6407)
* feat(integrations): add Snowflake PAT integration * fix(snowflake): scope block params by operation * refactor(integrations): simplify Snowflake safeguards * refactor(snowflake): isolate statement capabilities * fix(snowflake): localize required user agent * chore(snowflake): limit changes to integration scope * fix(snowflake): correct SQL generation, transport, and param conventions Address defects found by validation against the Snowflake SQL API v2 and SQL reference docs. SQL generation: - lift PARSE_JSON out of the VALUES clause into a projecting SELECT; the previous form is rejected for any object or array value - escape backslashes as well as quotes in string literals, closing a COPY option injection through the user-or-llm stagePath and pattern fields - reject "--" in stage paths, which commented out every following clause - emit COPY INTO clauses in the documented positional order - exclude only view types in introspect_schema so temporary, external, and event tables are visible - use plain equality in MERGE and reject null or duplicate match keys - bound rows and bound-value bytes for every statement, measured in UTF-8 - reject qualified task names, which TASK_HISTORY silently ignores - replace a raw NUL byte in the source with its escape sequence Transport: - read DML stats from the documented top-level ResultSet property - drop Link-header and 391908 paging, which belong to the retired API, and report partition completeness as unknown rather than falsely complete - require a 2xx status before trusting a success SQLSTATE - cap response bodies and fail closed on invalid session context names Conventions: - inline shared params into each tool instead of cross-file spreads, which also lets the docs generator emit host and apiKey - use the official Snowflake brand mark on a white tile * fix(snowflake): emit task history time bounds as literals TASK_HISTORY only accepts bind variables for RESULT_LIMIT and TASK_NAME per BCR-1410, and that change explicitly excludes a bind passed through another function first. A bind in SCHEDULED_TIME_RANGE_START/END is therefore dropped without an error, so the requested window became a no-op and the function fell back to returning the most recent runs. Emit validated literals instead, which also restores Snowflake's seven-day range error. Also reject a fractional skip-file percentage at the block boundary rather than in the builder, and correct the cancel description: a cancelled child marks the task graph run failed, so downstream tasks are skipped rather than continuing. --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Mac.localdomain> Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Waleed Latif <walif6@gmail.com>
This commit is contained in:
co-authored by
Bill Leoutsakos
Bill Leoutsakos
Waleed Latif
parent
56200177e6
commit
3096de846e
@@ -8574,10 +8574,11 @@ export function BigQueryIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function SnowflakeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<svg viewBox='0 0 146.36 139.16' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<path
|
||||
fill='#29B5E8'
|
||||
d='M12 2a1 1 0 0 1 1 1v3.59l2.3-2.3a1 1 0 1 1 1.4 1.42L13 9.41V12h2.6l3.7-3.7a1 1 0 0 1 1.4 1.4L18.42 12H22a1 1 0 1 1 0 2h-3.59l2.3 2.3a1 1 0 0 1-1.4 1.4L15.58 14H13v2.59l3.7 3.7a1 1 0 1 1-1.4 1.4L13 19.42V23a1 1 0 1 1-2 0v-3.58l-2.3 2.3a1 1 0 1 1-1.4-1.4l3.7-3.71V14H8.4l-3.7 3.7a1 1 0 0 1-1.4-1.4L5.58 14H2a1 1 0 0 1 0-2h3.59l-2.3-2.3a1 1 0 0 1 1.4-1.4L8.42 12H11V9.41L7.3 5.71a1 1 0 1 1 1.4-1.42l2.3 2.3V3a1 1 0 0 1 1-1Z'
|
||||
fillRule='evenodd'
|
||||
d='M134.81,60.1l-16.47,9.49L134.81,79a8.65,8.65,0,1,1-8.67,15l-29.51-17a8.68,8.68,0,0,1-4.33-7.75,8.48,8.48,0,0,1,.31-2,8.68,8.68,0,0,1,4-5.19l29.51-16.94A8.69,8.69,0,0,1,138,48.31,8.58,8.58,0,0,1,134.81,60.1Zm-15.59,46L89.72,89.13a8.72,8.72,0,0,0-13.06,7.48v33.9a8.69,8.69,0,0,0,17.37,0v-19L110.54,121a8.66,8.66,0,1,0,8.68-15Zm-34-33.16L72.92,85.09a2.44,2.44,0,0,1-1.54.65H67.77a2.51,2.51,0,0,1-1.54-.65L54,72.9a2.45,2.45,0,0,1-.64-1.52v-3.6A2.5,2.5,0,0,1,54,66.25L66.23,54.06a2.5,2.5,0,0,1,1.54-.64h3.61a2.45,2.45,0,0,1,1.54.64L85.18,66.25a2.49,2.49,0,0,1,.63,1.53v3.6A2.44,2.44,0,0,1,85.18,72.9Zm-9.8-3.38A2.59,2.59,0,0,0,74.73,68l-3.55-3.51a2.51,2.51,0,0,0-1.54-.64h-.13a2.46,2.46,0,0,0-1.53.64L64.43,68a2.51,2.51,0,0,0-.63,1.55v.13a2.41,2.41,0,0,0,.63,1.52L68,74.7a2.48,2.48,0,0,0,1.53.64h.13a2.51,2.51,0,0,0,1.54-.64l3.55-3.53a2.49,2.49,0,0,0,.65-1.52ZM19.93,33.08,49.44,50a8.73,8.73,0,0,0,13.07-7.49V8.64a8.69,8.69,0,0,0-17.37,0v19l-16.53-9.5a8.65,8.65,0,1,0-8.68,15ZM84.69,51.16a8.64,8.64,0,0,0,5-1.13l29.5-17a8.65,8.65,0,1,0-8.68-15L94,27.61v-19a8.69,8.69,0,0,0-17.37,0v33.9A8.66,8.66,0,0,0,84.69,51.16ZM54.48,88a8.58,8.58,0,0,0-5,1.13L19.93,106.06a8.66,8.66,0,1,0,8.68,15l16.53-9.49v19a8.69,8.69,0,0,0,17.37,0V96.61A8.65,8.65,0,0,0,54.48,88Zm-8-15.87a8.61,8.61,0,0,0-4-10L13,45.14A8.69,8.69,0,0,0,1.17,48.31,8.59,8.59,0,0,0,4.35,60.1l16.47,9.49L4.35,79A8.65,8.65,0,1,0,13,94l29.48-17A8.59,8.59,0,0,0,46.47,72.13Zm93.15-56.22H138.3v1.63h1.32c.61,0,1-.28,1-.8S140.26,15.91,139.62,15.91Zm-2.94-1.5h3c1.62,0,2.7.89,2.7,2.27a2.16,2.16,0,0,1-1.08,1.9l1.17,1.68v.34h-1.69L139.62,19H138.3V20.6h-1.62Zm8.3,3.22a5.48,5.48,0,0,0-5.58-5.83c-3.31,0-5.51,2.39-5.51,5.83,0,3.28,2.2,5.82,5.51,5.82A5.47,5.47,0,0,0,145,17.63Zm1.38,0c0,3.89-2.6,7.14-7,7.14s-6.89-3.28-6.89-7.14,2.57-7.14,6.89-7.14S146.36,13.73,146.36,17.63Z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -212,6 +212,7 @@ import {
|
||||
SlackIcon,
|
||||
SmartleadIcon,
|
||||
SmtpIcon,
|
||||
SnowflakeIcon,
|
||||
SportmonksIcon,
|
||||
SQSIcon,
|
||||
SquareIcon,
|
||||
@@ -498,6 +499,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
slack: SlackIcon,
|
||||
smartlead: SmartleadIcon,
|
||||
smtp: SmtpIcon,
|
||||
snowflake: SnowflakeIcon,
|
||||
sportmonks: SportmonksIcon,
|
||||
sqs: SQSIcon,
|
||||
square: SquareIcon,
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"slack",
|
||||
"smartlead",
|
||||
"smtp",
|
||||
"snowflake",
|
||||
"sportmonks",
|
||||
"sqs",
|
||||
"square",
|
||||
|
||||
@@ -0,0 +1,965 @@
|
||||
---
|
||||
title: Snowflake
|
||||
description: Query data and manage warehouses and tasks in Snowflake
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="snowflake"
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect with a Snowflake programmatic access token to execute SQL, synchronize structured rows, load staged data, manage warehouses and tasks, inspect schemas, and call stored procedures.
|
||||
|
||||
|
||||
|
||||
## Actions
|
||||
|
||||
### Snowflake Execute SQL
|
||||
|
||||
Execute one parameterized SQL statement through the Snowflake SQL API.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
|
||||
| `maxRows` | number | No | Maximum result rows; defaults to 1000 with a Sim safety limit of 10000 |
|
||||
| `database` | string | No | Database context for this statement |
|
||||
| `schema` | string | No | Schema context for this statement |
|
||||
| `statement` | string | Yes | One Snowflake SQL statement to execute |
|
||||
| `bindings` | json | No | Snowflake bindings keyed by 1-based position, each with type and string value |
|
||||
| `async` | boolean | No | Return immediately with a statement handle |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Get Statement
|
||||
|
||||
Check a running or completed statement and retrieve exactly one result partition. Canceled or failed statements are returned as errors.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `statementHandle` | string | Yes | Statement handle returned by Snowflake |
|
||||
| `partition` | number | No | Zero-based result partition to retrieve; defaults to 0 |
|
||||
| `partitionCount` | number | No | Total number of result partitions, taken from the partitionCount of the first partition. Snowflake omits metadata from every later partition response, so supply this when fetching partition 1 or higher to keep truncated and nextPartition accurate |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Cancel Statement
|
||||
|
||||
Cancel a running Snowflake SQL API statement.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `statementHandle` | string | Yes | Statement handle returned by Snowflake |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Insert Rows
|
||||
|
||||
Insert structured JSON rows using bound values.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
|
||||
| `database` | string | Yes | Database name |
|
||||
| `schema` | string | Yes | Schema name |
|
||||
| `table` | string | Yes | Target Snowflake table name within the selected database and schema context |
|
||||
| `rows` | json | Yes | Non-empty JSON array of row objects with matching keys. Max 1000 rows and 1 MB of bound data per call - stage the files and use Load Data for bulk ingest. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Update Rows
|
||||
|
||||
Update matching rows with a bound MERGE statement without inserting new rows.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
|
||||
| `database` | string | Yes | Database name |
|
||||
| `schema` | string | Yes | Schema name |
|
||||
| `table` | string | Yes | Target Snowflake table name within the selected database and schema context |
|
||||
| `rows` | json | Yes | Non-empty JSON array of row objects with matching keys. Max 1000 rows and 1 MB of bound data per call - stage the files and use Load Data for bulk ingest. |
|
||||
| `matchColumns` | array | Yes | Columns used to match target rows. Match values must be non-null and unique across the submitted rows. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Upsert Rows
|
||||
|
||||
Update matching rows and insert unmatched rows with a bound MERGE statement.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
|
||||
| `database` | string | Yes | Database name |
|
||||
| `schema` | string | Yes | Schema name |
|
||||
| `table` | string | Yes | Target Snowflake table name within the selected database and schema context |
|
||||
| `rows` | json | Yes | Non-empty JSON array of row objects with matching keys. Max 1000 rows and 1 MB of bound data per call - stage the files and use Load Data for bulk ingest. |
|
||||
| `matchColumns` | array | Yes | Columns used to match target rows. Match values must be non-null and unique across the submitted rows. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Delete Rows
|
||||
|
||||
Delete rows matching a required set of bound column filters.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
|
||||
| `database` | string | Yes | Database name |
|
||||
| `schema` | string | Yes | Schema name |
|
||||
| `table` | string | Yes | Target Snowflake table name within the selected database and schema context |
|
||||
| `filters` | json | Yes | Non-empty JSON object of column filters combined with AND. A null value matches rows where that column IS NULL; every other value is compared for equality against a bound parameter. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Load Data
|
||||
|
||||
Load files from an existing Snowflake stage with COPY INTO.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
|
||||
| `maxRows` | number | No | Maximum result rows; defaults to 1000 with a Sim safety limit of 10000 |
|
||||
| `database` | string | Yes | Target database name |
|
||||
| `schema` | string | Yes | Target schema name |
|
||||
| `table` | string | Yes | Target table name |
|
||||
| `stagePath` | string | Yes | Existing stage path, for example @my_stage/path |
|
||||
| `fileFormat` | string | No | Optional named file format |
|
||||
| `pattern` | string | No | Optional regular expression used to select staged files |
|
||||
| `onError` | string | No | COPY error handling: ABORT_STATEMENT, CONTINUE, SKIP_FILE, SKIP_FILE_<count>, or SKIP_FILE_<percent>% |
|
||||
| `purge` | boolean | No | Remove successfully loaded files from the stage |
|
||||
| `force` | boolean | No | Reload files even when Snowflake has loaded them before |
|
||||
| `matchByColumnName` | string | No | CASE_SENSITIVE, CASE_INSENSITIVE, or NONE |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake List Warehouses
|
||||
|
||||
List warehouses visible to the active Snowflake role.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `maxRows` | number | No | Maximum result rows; defaults to 1000 with a Sim safety limit of 10000 |
|
||||
| `nameLike` | string | No | Optional SQL LIKE pattern for warehouse names |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Get Warehouse
|
||||
|
||||
Get the full details for a Snowflake virtual warehouse.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouseName` | string | Yes | Warehouse name |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Resume Warehouse
|
||||
|
||||
Resume a Snowflake virtual warehouse if it is suspended.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouseName` | string | Yes | Warehouse name |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Suspend Warehouse
|
||||
|
||||
Suspend a Snowflake virtual warehouse.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouseName` | string | Yes | Warehouse name |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake List Tasks
|
||||
|
||||
List tasks in a Snowflake schema.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `database` | string | Yes | Database name |
|
||||
| `schema` | string | Yes | Schema name |
|
||||
| `nameLike` | string | No | Optional SQL LIKE pattern for task names |
|
||||
| `limit` | number | No | Maximum task rows, from 1 to 10000 |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Get Task
|
||||
|
||||
Describe a Snowflake task.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `database` | string | Yes | Database name |
|
||||
| `schema` | string | Yes | Schema name |
|
||||
| `taskName` | string | Yes | Task name |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Run Task
|
||||
|
||||
Run a Snowflake task immediately, optionally retrying its last failed graph.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `database` | string | Yes | Database name |
|
||||
| `schema` | string | Yes | Schema name |
|
||||
| `taskName` | string | Yes | Task name |
|
||||
| `retryLast` | boolean | No | Retry the last failed task graph run |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake List Task Runs
|
||||
|
||||
Query up to seven days of Snowflake task history, capped at 10000 rows.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
|
||||
| `taskName` | string | No | Optional task name filter. TASK_HISTORY supports only non-qualified task names, so pass DAILY_LOAD rather than DB.SCHEMA.DAILY_LOAD |
|
||||
| `startTime` | string | No | Optional scheduled-time range start as an ISO timestamp within the last seven days |
|
||||
| `endTime` | string | No | Optional scheduled-time range end as an ISO timestamp within the last seven days |
|
||||
| `errorOnly` | boolean | No | Return only task runs that failed or were cancelled |
|
||||
| `limit` | number | No | Maximum task runs, from 1 to 10000 |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Get Task Run
|
||||
|
||||
Find one task history record by query ID within Snowflake’s seven-day window and 10000 most recent records after optional filters.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
|
||||
| `queryId` | string | Yes | Task run query ID from TASK_HISTORY |
|
||||
| `taskName` | string | No | Optional task name used to narrow the 10000-record history window. TASK_HISTORY supports only non-qualified task names, so pass DAILY_LOAD rather than DB.SCHEMA.DAILY_LOAD |
|
||||
| `startTime` | string | No | Optional scheduled-time range start as an ISO timestamp within the last seven days |
|
||||
| `endTime` | string | No | Optional scheduled-time range end as an ISO timestamp within the last seven days |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Cancel Task Query
|
||||
|
||||
Cancel one running task query by query ID with SYSTEM$CANCEL_QUERY. Task runs already in flight are unaffected and must be cancelled individually; a cancelled child marks the task graph run failed, so downstream tasks are skipped.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
|
||||
| `queryId` | string | Yes | Query ID of the single running task query to cancel, taken from TASK_HISTORY. Cancels only that query. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Get Task Run Output
|
||||
|
||||
Read a task query result with RESULT_SCAN during Snowflake’s 24-hour retention window using the task owner role.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
|
||||
| `maxRows` | number | No | Maximum result rows; defaults to 1000 with a Sim safety limit of 10000 |
|
||||
| `queryId` | string | Yes | Completed task query ID; task results require the task owner role, while manual query results require the same user |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Introspect Schema
|
||||
|
||||
Inspect table and column metadata through Snowflake INFORMATION_SCHEMA views.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
|
||||
| `maxRows` | number | No | Maximum result rows; defaults to 1000 with a Sim safety limit of 10000 |
|
||||
| `database` | string | Yes | Database containing the INFORMATION_SCHEMA views |
|
||||
| `schema` | string | No | Optional exact schema name filter |
|
||||
| `table` | string | No | Optional exact table name filter |
|
||||
| `includeViews` | boolean | No | Include views and materialized views alongside tables |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
|
||||
### Snowflake Call Procedure
|
||||
|
||||
Call a stored procedure with explicitly typed Snowflake bindings.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
|
||||
| `apiKey` | string | Yes | Snowflake programmatic access token |
|
||||
| `role` | string | No | Snowflake role to use for this statement |
|
||||
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
|
||||
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
|
||||
| `maxRows` | number | No | Maximum result rows; defaults to 1000 with a Sim safety limit of 10000 |
|
||||
| `database` | string | Yes | Database name |
|
||||
| `schema` | string | Yes | Schema name |
|
||||
| `procedureName` | string | Yes | Stored procedure name |
|
||||
| `procedureArguments` | array | No | Ordered argument bindings with a Snowflake type and string value |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `statementHandle` | string | Snowflake statement handle |
|
||||
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
|
||||
| `message` | string | Snowflake response message |
|
||||
| `result` | object | Completed result partition, or null while running or when no result is available |
|
||||
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
|
||||
| ↳ `name` | string | Column name |
|
||||
| ↳ `type` | string | Snowflake data type |
|
||||
| ↳ `length` | number | Column length |
|
||||
| ↳ `precision` | number | Numeric precision |
|
||||
| ↳ `scale` | number | Numeric scale |
|
||||
| ↳ `nullable` | boolean | Whether the column is nullable |
|
||||
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
|
||||
| ↳ `totalRows` | number | Total result rows |
|
||||
| ↳ `currentPartition` | number | Zero-based partition returned |
|
||||
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
|
||||
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
|
||||
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
|
||||
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
|
||||
| ↳ `rowsInserted` | number | Rows inserted by the statement |
|
||||
| ↳ `rowsUpdated` | number | Rows updated by the statement |
|
||||
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
|
||||
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
|
||||
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
|
||||
@@ -0,0 +1,837 @@
|
||||
import { SnowflakeIcon } from '@/components/icons'
|
||||
import type { BlockConfig, BlockMeta } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { parseOptionalJsonInput, parseOptionalNumberInput } from '@/blocks/utils'
|
||||
import type { SnowflakeStatementResponse } from '@/tools/snowflake/types'
|
||||
|
||||
const sqlSubmissionOperations = [
|
||||
'execute_sql',
|
||||
'insert_rows',
|
||||
'update_rows',
|
||||
'upsert_rows',
|
||||
'delete_rows',
|
||||
'load_data',
|
||||
'list_warehouses',
|
||||
'get_warehouse',
|
||||
'resume_warehouse',
|
||||
'suspend_warehouse',
|
||||
'list_tasks',
|
||||
'get_task',
|
||||
'run_task',
|
||||
'list_task_runs',
|
||||
'get_task_run',
|
||||
'cancel_task_run',
|
||||
'get_task_run_output',
|
||||
'introspect_schema',
|
||||
'call_procedure',
|
||||
] as const
|
||||
|
||||
const computeOperations = [
|
||||
'execute_sql',
|
||||
'insert_rows',
|
||||
'update_rows',
|
||||
'upsert_rows',
|
||||
'delete_rows',
|
||||
'load_data',
|
||||
'list_task_runs',
|
||||
'get_task_run',
|
||||
'cancel_task_run',
|
||||
'get_task_run_output',
|
||||
'introspect_schema',
|
||||
'call_procedure',
|
||||
] as const
|
||||
|
||||
const maxRowsOperations = [
|
||||
'execute_sql',
|
||||
'load_data',
|
||||
'list_warehouses',
|
||||
'get_task_run_output',
|
||||
'introspect_schema',
|
||||
'call_procedure',
|
||||
] as const
|
||||
|
||||
const dataOperations = [
|
||||
'insert_rows',
|
||||
'update_rows',
|
||||
'upsert_rows',
|
||||
'delete_rows',
|
||||
'load_data',
|
||||
] as const
|
||||
const taskDefinitionOperations = ['list_tasks', 'get_task', 'run_task'] as const
|
||||
|
||||
const sqlSubmissionOperationSet: ReadonlySet<string> = new Set(sqlSubmissionOperations)
|
||||
const maxRowsOperationSet: ReadonlySet<string> = new Set(maxRowsOperations)
|
||||
|
||||
function resolveCopyOnError(value: unknown, threshold: unknown): string | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
if (value !== 'SKIP_FILE_NUMBER' && value !== 'SKIP_FILE_PERCENT') return String(value)
|
||||
|
||||
const number = parseOptionalNumberInput(threshold, 'Skip file threshold')
|
||||
if (number === undefined || number <= 0) {
|
||||
throw new Error('Skip file threshold must be greater than zero')
|
||||
}
|
||||
if (value === 'SKIP_FILE_PERCENT' && (number > 100 || !Number.isInteger(number))) {
|
||||
throw new Error('Skip file percentage must be a whole number between 1 and 100')
|
||||
}
|
||||
if (value === 'SKIP_FILE_NUMBER' && !Number.isInteger(number)) {
|
||||
throw new Error('Skip file error count must be a positive integer')
|
||||
}
|
||||
return `SKIP_FILE_${number}${value === 'SKIP_FILE_PERCENT' ? '%' : ''}`
|
||||
}
|
||||
|
||||
export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
|
||||
type: 'snowflake',
|
||||
name: 'Snowflake',
|
||||
description: 'Query data and manage warehouses and tasks in Snowflake',
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
'Connect with a Snowflake programmatic access token to execute SQL, synchronize structured rows, load staged data, manage warehouses and tasks, inspect schemas, and call stored procedures.',
|
||||
docsLink: 'https://docs.sim.ai/integrations/snowflake',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.Databases,
|
||||
bgColor: '#FFFFFF',
|
||||
icon: SnowflakeIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Execute SQL', id: 'execute_sql' },
|
||||
{ label: 'Get Statement', id: 'get_statement' },
|
||||
{ label: 'Cancel Statement', id: 'cancel_statement' },
|
||||
{ label: 'Insert Rows', id: 'insert_rows' },
|
||||
{ label: 'Update Rows', id: 'update_rows' },
|
||||
{ label: 'Upsert Rows', id: 'upsert_rows' },
|
||||
{ label: 'Delete Rows', id: 'delete_rows' },
|
||||
{ label: 'Load Data', id: 'load_data' },
|
||||
{ label: 'List Warehouses', id: 'list_warehouses' },
|
||||
{ label: 'Get Warehouse', id: 'get_warehouse' },
|
||||
{ label: 'Resume Warehouse', id: 'resume_warehouse' },
|
||||
{ label: 'Suspend Warehouse', id: 'suspend_warehouse' },
|
||||
{ label: 'List Tasks', id: 'list_tasks' },
|
||||
{ label: 'Get Task', id: 'get_task' },
|
||||
{ label: 'Run Task', id: 'run_task' },
|
||||
{ label: 'List Task Runs', id: 'list_task_runs' },
|
||||
{ label: 'Get Task Run', id: 'get_task_run' },
|
||||
{ label: 'Cancel Task Query', id: 'cancel_task_run' },
|
||||
{ label: 'Get Task Run Output', id: 'get_task_run_output' },
|
||||
{ label: 'Introspect Schema', id: 'introspect_schema' },
|
||||
{ label: 'Call Procedure', id: 'call_procedure' },
|
||||
],
|
||||
value: () => 'execute_sql',
|
||||
},
|
||||
{
|
||||
id: 'statement',
|
||||
title: 'SQL Statement',
|
||||
type: 'code',
|
||||
placeholder: 'SELECT * FROM ANALYTICS.PUBLIC.EVENTS LIMIT 100',
|
||||
condition: { field: 'operation', value: 'execute_sql' },
|
||||
required: { field: 'operation', value: 'execute_sql' },
|
||||
},
|
||||
{
|
||||
id: 'bindings',
|
||||
title: 'Bindings',
|
||||
type: 'code',
|
||||
placeholder: '{"1":{"type":"TEXT","value":"active"}}',
|
||||
condition: { field: 'operation', value: 'execute_sql' },
|
||||
mode: 'advanced',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Generate a JSON object keyed by 1-based binding position. Each value must contain a Snowflake binding type and a string value, for example {"1":{"type":"TEXT","value":"active"}}. Return ONLY the JSON object - no explanations, no extra text.',
|
||||
placeholder: 'Describe the values and Snowflake types to bind...',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'async',
|
||||
title: 'Run Asynchronously',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'execute_sql' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'statementHandle',
|
||||
title: 'Statement Handle',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter the Snowflake statement handle',
|
||||
condition: { field: 'operation', value: ['get_statement', 'cancel_statement'] },
|
||||
required: { field: 'operation', value: ['get_statement', 'cancel_statement'] },
|
||||
},
|
||||
{
|
||||
id: 'partition',
|
||||
title: 'Result Partition',
|
||||
type: 'short-input',
|
||||
placeholder: '0',
|
||||
condition: { field: 'operation', value: 'get_statement' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'partitionCount',
|
||||
title: 'Total Partitions',
|
||||
type: 'short-input',
|
||||
placeholder: 'partitionCount from the first partition',
|
||||
condition: { field: 'operation', value: 'get_statement' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'database',
|
||||
title: 'Database',
|
||||
type: 'short-input',
|
||||
placeholder: 'ANALYTICS',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'execute_sql',
|
||||
...dataOperations,
|
||||
...taskDefinitionOperations,
|
||||
'introspect_schema',
|
||||
'call_procedure',
|
||||
],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
...dataOperations,
|
||||
...taskDefinitionOperations,
|
||||
'introspect_schema',
|
||||
'call_procedure',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'schema',
|
||||
title: 'Schema',
|
||||
type: 'short-input',
|
||||
placeholder: 'PUBLIC',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'execute_sql',
|
||||
...dataOperations,
|
||||
...taskDefinitionOperations,
|
||||
'introspect_schema',
|
||||
'call_procedure',
|
||||
],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [...dataOperations, ...taskDefinitionOperations, 'call_procedure'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'table',
|
||||
title: 'Table',
|
||||
type: 'short-input',
|
||||
placeholder: 'EVENTS',
|
||||
condition: { field: 'operation', value: [...dataOperations, 'introspect_schema'] },
|
||||
required: { field: 'operation', value: [...dataOperations] },
|
||||
},
|
||||
{
|
||||
id: 'rows',
|
||||
title: 'Rows',
|
||||
type: 'code',
|
||||
placeholder: '[{"id":1,"status":"active"}]',
|
||||
condition: { field: 'operation', value: ['insert_rows', 'update_rows', 'upsert_rows'] },
|
||||
required: { field: 'operation', value: ['insert_rows', 'update_rows', 'upsert_rows'] },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Generate a non-empty JSON array of flat row objects. Every row must have the same keys. Use Load Data instead for bulk ingestion from staged files. Return ONLY the JSON array - no explanations, no extra text.',
|
||||
placeholder: 'Describe the records to write...',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'matchColumns',
|
||||
title: 'Match Columns',
|
||||
type: 'code',
|
||||
placeholder: '["id"]',
|
||||
condition: { field: 'operation', value: ['update_rows', 'upsert_rows'] },
|
||||
required: { field: 'operation', value: ['update_rows', 'upsert_rows'] },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Generate a JSON array containing the row column names that uniquely match target records, for example ["tenant_id","id"]. Return ONLY the JSON array - no explanations, no extra text.',
|
||||
placeholder: 'Describe the columns that identify a row...',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'filters',
|
||||
title: 'Match Filters',
|
||||
type: 'code',
|
||||
placeholder: '{"status":"expired","tenant_id":42,"archived_at":null}',
|
||||
condition: { field: 'operation', value: 'delete_rows' },
|
||||
required: { field: 'operation', value: 'delete_rows' },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Generate a non-empty JSON object of column filters. All filters are combined with AND. A null value matches rows where that column IS NULL; every other value is matched for equality. Return ONLY the JSON object - no explanations, no extra text.',
|
||||
placeholder: 'Describe the exact rows to delete...',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'stagePath',
|
||||
title: 'Stage Path',
|
||||
type: 'short-input',
|
||||
placeholder: '@RAW_STAGE/2026/08',
|
||||
condition: { field: 'operation', value: 'load_data' },
|
||||
required: { field: 'operation', value: 'load_data' },
|
||||
},
|
||||
{
|
||||
id: 'fileFormat',
|
||||
title: 'Named File Format',
|
||||
type: 'short-input',
|
||||
placeholder: 'ANALYTICS.PUBLIC.CSV_FORMAT',
|
||||
condition: { field: 'operation', value: 'load_data' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'pattern',
|
||||
title: 'File Pattern',
|
||||
type: 'short-input',
|
||||
placeholder: '.*[.]csv',
|
||||
condition: { field: 'operation', value: 'load_data' },
|
||||
mode: 'advanced',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Generate a single regular expression that COPY INTO uses to select staged file paths, for example .*[.]csv or .*/2026/08/.*[.]json[.]gz. Use only regular expression syntax and no quotes. Return ONLY the pattern - no explanations, no extra text.',
|
||||
placeholder: 'Describe which staged files to load...',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'onError',
|
||||
title: 'On Error',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Abort Statement', id: 'ABORT_STATEMENT' },
|
||||
{ label: 'Continue', id: 'CONTINUE' },
|
||||
{ label: 'Skip File', id: 'SKIP_FILE' },
|
||||
{ label: 'Skip File After Error Count', id: 'SKIP_FILE_NUMBER' },
|
||||
{ label: 'Skip File After Error Percentage', id: 'SKIP_FILE_PERCENT' },
|
||||
],
|
||||
value: () => 'ABORT_STATEMENT',
|
||||
condition: { field: 'operation', value: 'load_data' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'onErrorThreshold',
|
||||
title: 'Skip File Threshold',
|
||||
type: 'short-input',
|
||||
placeholder: '10',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'load_data',
|
||||
and: { field: 'onError', value: ['SKIP_FILE_NUMBER', 'SKIP_FILE_PERCENT'] },
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'load_data',
|
||||
and: { field: 'onError', value: ['SKIP_FILE_NUMBER', 'SKIP_FILE_PERCENT'] },
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'purge',
|
||||
title: 'Purge Loaded Files',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'load_data' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'force',
|
||||
title: 'Force Reload',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'load_data' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'matchByColumnName',
|
||||
title: 'Match by Column Name',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'None', id: 'NONE' },
|
||||
{ label: 'Case Sensitive', id: 'CASE_SENSITIVE' },
|
||||
{ label: 'Case Insensitive', id: 'CASE_INSENSITIVE' },
|
||||
],
|
||||
value: () => 'NONE',
|
||||
condition: { field: 'operation', value: 'load_data' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'warehouseName',
|
||||
title: 'Warehouse Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'COMPUTE_WH',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['get_warehouse', 'resume_warehouse', 'suspend_warehouse'],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: ['get_warehouse', 'resume_warehouse', 'suspend_warehouse'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'nameLike',
|
||||
title: 'Name Pattern',
|
||||
type: 'short-input',
|
||||
placeholder: 'ETL%',
|
||||
condition: { field: 'operation', value: ['list_warehouses', 'list_tasks'] },
|
||||
mode: 'advanced',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Generate a single SQL LIKE pattern that matches Snowflake object names, using % for any sequence and _ for one character, for example ETL%. Use only the pattern and no quotes. Return ONLY the pattern - no explanations, no extra text.',
|
||||
placeholder: 'Describe which object names to match...',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'taskName',
|
||||
title: 'Task Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'DAILY_LOAD (name only, not DB.SCHEMA.TASK)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['get_task', 'run_task', 'list_task_runs', 'get_task_run'],
|
||||
},
|
||||
required: { field: 'operation', value: ['get_task', 'run_task'] },
|
||||
},
|
||||
{
|
||||
id: 'retryLast',
|
||||
title: 'Retry Last Failed Run',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'run_task' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: '100',
|
||||
condition: { field: 'operation', value: ['list_tasks', 'list_task_runs'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'startTime',
|
||||
title: 'Scheduled Time From',
|
||||
type: 'short-input',
|
||||
placeholder: '2026-08-01T00:00:00Z',
|
||||
condition: { field: 'operation', value: ['list_task_runs', 'get_task_run'] },
|
||||
mode: 'advanced',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Convert the requested start date and time to an ISO 8601 timestamp within the last seven days. Return ONLY the timestamp - no explanations, no extra text.',
|
||||
placeholder: 'Describe the beginning of the task history window...',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'endTime',
|
||||
title: 'Scheduled Time To',
|
||||
type: 'short-input',
|
||||
placeholder: '2026-08-07T00:00:00Z',
|
||||
condition: { field: 'operation', value: ['list_task_runs', 'get_task_run'] },
|
||||
mode: 'advanced',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Convert the requested end date and time to an ISO 8601 timestamp. Return ONLY the timestamp - no explanations, no extra text.',
|
||||
placeholder: 'Describe the end of the task history window...',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'errorOnly',
|
||||
title: 'Failed and Cancelled Only',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'list_task_runs' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'queryId',
|
||||
title: 'Task Run Query ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Snowflake query UUID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['get_task_run', 'cancel_task_run', 'get_task_run_output'],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: ['get_task_run', 'cancel_task_run', 'get_task_run_output'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeViews',
|
||||
title: 'Include Views',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'introspect_schema' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'procedureName',
|
||||
title: 'Procedure Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'REFRESH_MODEL',
|
||||
condition: { field: 'operation', value: 'call_procedure' },
|
||||
required: { field: 'operation', value: 'call_procedure' },
|
||||
},
|
||||
{
|
||||
id: 'procedureArguments',
|
||||
title: 'Procedure Arguments',
|
||||
type: 'code',
|
||||
placeholder: '[{"type":"TEXT","value":"daily"}]',
|
||||
condition: { field: 'operation', value: 'call_procedure' },
|
||||
mode: 'advanced',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Generate an ordered JSON array of Snowflake procedure bindings. Each item must contain a supported Snowflake binding type and a string value, for example {"type":"TEXT","value":"daily"}. Return ONLY the JSON array - no explanations, no extra text.',
|
||||
placeholder: 'Describe the procedure arguments in order...',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'warehouse',
|
||||
title: 'Execution Warehouse',
|
||||
type: 'short-input',
|
||||
placeholder: 'COMPUTE_WH',
|
||||
condition: { field: 'operation', value: [...computeOperations] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
title: 'Execution Role',
|
||||
type: 'short-input',
|
||||
placeholder: 'ANALYST',
|
||||
condition: { field: 'operation', value: [...sqlSubmissionOperations] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'statementTimeoutSeconds',
|
||||
title: 'Timeout (seconds)',
|
||||
type: 'short-input',
|
||||
placeholder: '60',
|
||||
condition: { field: 'operation', value: [...sqlSubmissionOperations] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'maxRows',
|
||||
title: 'Maximum Rows',
|
||||
type: 'short-input',
|
||||
placeholder: '1000',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [...maxRowsOperations],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'host',
|
||||
title: 'Account Host',
|
||||
type: 'short-input',
|
||||
placeholder: 'myorg-myaccount.snowflakecomputing.com',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'Programmatic Access Token',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Snowflake PAT',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'snowflake_execute_sql',
|
||||
'snowflake_get_statement',
|
||||
'snowflake_cancel_statement',
|
||||
'snowflake_insert_rows',
|
||||
'snowflake_update_rows',
|
||||
'snowflake_upsert_rows',
|
||||
'snowflake_delete_rows',
|
||||
'snowflake_load_data',
|
||||
'snowflake_list_warehouses',
|
||||
'snowflake_get_warehouse',
|
||||
'snowflake_resume_warehouse',
|
||||
'snowflake_suspend_warehouse',
|
||||
'snowflake_list_tasks',
|
||||
'snowflake_get_task',
|
||||
'snowflake_run_task',
|
||||
'snowflake_list_task_runs',
|
||||
'snowflake_get_task_run',
|
||||
'snowflake_cancel_task_run',
|
||||
'snowflake_get_task_run_output',
|
||||
'snowflake_introspect_schema',
|
||||
'snowflake_call_procedure',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => `snowflake_${params.operation}`,
|
||||
params: (params) => {
|
||||
const operation = String(params.operation)
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
if (sqlSubmissionOperationSet.has(operation)) {
|
||||
result.statementTimeoutSeconds = parseOptionalNumberInput(
|
||||
params.statementTimeoutSeconds,
|
||||
'Statement timeout'
|
||||
)
|
||||
}
|
||||
if (maxRowsOperationSet.has(operation)) {
|
||||
result.maxRows = parseOptionalNumberInput(params.maxRows, 'Maximum result rows')
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'execute_sql':
|
||||
result.bindings = parseOptionalJsonInput(params.bindings, 'Bindings')
|
||||
break
|
||||
case 'get_statement':
|
||||
result.partition = parseOptionalNumberInput(params.partition, 'Partition')
|
||||
result.partitionCount = parseOptionalNumberInput(
|
||||
params.partitionCount,
|
||||
'Total partitions'
|
||||
)
|
||||
break
|
||||
case 'insert_rows':
|
||||
result.rows = parseOptionalJsonInput(params.rows, 'Rows')
|
||||
break
|
||||
case 'update_rows':
|
||||
case 'upsert_rows':
|
||||
result.rows = parseOptionalJsonInput(params.rows, 'Rows')
|
||||
result.matchColumns = parseOptionalJsonInput(params.matchColumns, 'Match columns')
|
||||
break
|
||||
case 'delete_rows':
|
||||
result.filters = parseOptionalJsonInput(params.filters, 'Filters')
|
||||
break
|
||||
case 'load_data':
|
||||
result.onError = resolveCopyOnError(params.onError, params.onErrorThreshold)
|
||||
result.onErrorThreshold = undefined
|
||||
break
|
||||
case 'list_tasks':
|
||||
result.limit = parseOptionalNumberInput(params.limit, 'Task limit')
|
||||
break
|
||||
case 'list_task_runs':
|
||||
result.limit = parseOptionalNumberInput(params.limit, 'Task run limit')
|
||||
break
|
||||
case 'call_procedure':
|
||||
result.procedureArguments = parseOptionalJsonInput(
|
||||
params.procedureArguments,
|
||||
'Procedure arguments'
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
host: { type: 'string', description: 'Snowflake account hostname' },
|
||||
apiKey: { type: 'string', description: 'Snowflake programmatic access token' },
|
||||
statement: { type: 'string', description: 'Single SQL statement' },
|
||||
bindings: {
|
||||
type: 'string',
|
||||
description: 'Typed SQL bindings as a JSON object keyed by 1-based positions',
|
||||
},
|
||||
async: { type: 'boolean', description: 'Run the SQL statement asynchronously' },
|
||||
statementHandle: { type: 'string', description: 'Snowflake SQL API statement handle' },
|
||||
partition: { type: 'number', description: 'Zero-based result partition' },
|
||||
partitionCount: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Total result partitions, taken from the first partition. Required for accurate truncation reporting when fetching partition 1 or higher',
|
||||
},
|
||||
database: { type: 'string', description: 'Snowflake database name' },
|
||||
schema: { type: 'string', description: 'Snowflake schema name' },
|
||||
table: { type: 'string', description: 'Snowflake table name' },
|
||||
rows: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Structured rows as a JSON array. Max 1000 rows and 1 MB of bound data per call - stage the files and use Load Data for bulk ingest.',
|
||||
},
|
||||
matchColumns: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Match columns as a JSON array. Match values must be non-null and unique across the submitted rows.',
|
||||
},
|
||||
filters: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Column filters as a JSON object. A null value matches rows where that column IS NULL; every other value is matched for equality',
|
||||
},
|
||||
stagePath: { type: 'string', description: 'Existing Snowflake stage path' },
|
||||
fileFormat: { type: 'string', description: 'Named Snowflake file format' },
|
||||
pattern: { type: 'string', description: 'COPY file selection pattern' },
|
||||
onError: { type: 'string', description: 'COPY error handling policy' },
|
||||
onErrorThreshold: { type: 'number', description: 'COPY skip-file error threshold' },
|
||||
purge: { type: 'boolean', description: 'Purge successfully loaded staged files' },
|
||||
force: { type: 'boolean', description: 'Force staged files to load again' },
|
||||
matchByColumnName: { type: 'string', description: 'COPY column-name matching policy' },
|
||||
warehouseName: { type: 'string', description: 'Warehouse to retrieve or change' },
|
||||
nameLike: { type: 'string', description: 'SQL LIKE pattern for object names' },
|
||||
taskName: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Snowflake task name without a database or schema qualifier, for example DAILY_LOAD',
|
||||
},
|
||||
retryLast: { type: 'boolean', description: 'Retry the last failed task graph' },
|
||||
limit: { type: 'number', description: 'Maximum list results' },
|
||||
startTime: { type: 'string', description: 'Task history scheduled-time range start' },
|
||||
endTime: { type: 'string', description: 'Task history scheduled-time range end' },
|
||||
errorOnly: {
|
||||
type: 'boolean',
|
||||
description: 'Only return task runs that failed or were cancelled',
|
||||
},
|
||||
queryId: {
|
||||
type: 'string',
|
||||
description: 'Task run query UUID. Cancel Task Query cancels only this single query',
|
||||
},
|
||||
includeViews: {
|
||||
type: 'boolean',
|
||||
description: 'Include views and materialized views alongside tables',
|
||||
},
|
||||
procedureName: { type: 'string', description: 'Stored procedure name' },
|
||||
procedureArguments: {
|
||||
type: 'string',
|
||||
description: 'Typed procedure bindings as an ordered JSON array of type/value objects',
|
||||
},
|
||||
warehouse: { type: 'string', description: 'Statement execution warehouse' },
|
||||
role: { type: 'string', description: 'Statement execution role' },
|
||||
statementTimeoutSeconds: { type: 'number', description: 'Statement timeout in seconds' },
|
||||
maxRows: { type: 'number', description: 'Maximum result rows (Sim safety limit: 10000)' },
|
||||
},
|
||||
outputs: {
|
||||
statementHandle: { type: 'string', description: 'Snowflake statement handle' },
|
||||
status: { type: 'string', description: 'SUCCEEDED, RUNNING, or CANCELED' },
|
||||
message: { type: 'string', description: 'Snowflake response message' },
|
||||
result: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Completed result partition ({columns, rows, totalRows, currentPartition, partitionCount, nextPartition, truncated}). columns, totalRows and truncated are null when Snowflake returned a metadata-less partition response and partitionCount was not supplied',
|
||||
},
|
||||
dml: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Completed DML statistics ({rowsInserted, rowsUpdated, rowsDeleted, duplicateRowsUpdated, rowsAffected})',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const SnowflakeBlockMeta = {
|
||||
tags: ['data-warehouse', 'data-analytics', 'cloud'],
|
||||
url: 'https://www.snowflake.com',
|
||||
templates: [
|
||||
{
|
||||
icon: SnowflakeIcon,
|
||||
title: 'Snowflake asynchronous reporting',
|
||||
prompt:
|
||||
'Build a scheduled workflow that starts a long-running Snowflake report asynchronously, polls its statement handle until complete, summarizes the result, and posts the report to Slack.',
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['reporting', 'automation'],
|
||||
alsoIntegrations: ['slack'],
|
||||
},
|
||||
{
|
||||
icon: SnowflakeIcon,
|
||||
title: 'Snowflake incremental upsert',
|
||||
prompt:
|
||||
'Create a workflow that receives changed customer records, validates their keys, and upserts them into a Snowflake dimension table in bounded batches.',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['sync', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: SnowflakeIcon,
|
||||
title: 'Snowflake staged ingestion',
|
||||
prompt:
|
||||
'Build a workflow that loads newly staged CSV files into a Snowflake table with a named file format, records COPY results, and alerts on rejected files.',
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['data', 'monitoring'],
|
||||
},
|
||||
{
|
||||
icon: SnowflakeIcon,
|
||||
title: 'Snowflake task failure monitor',
|
||||
prompt:
|
||||
'Create a workflow that checks recent Snowflake task failures, retrieves the failed run details, summarizes the likely cause, and posts an actionable Slack alert.',
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['monitoring', 'devops'],
|
||||
alsoIntegrations: ['slack'],
|
||||
},
|
||||
{
|
||||
icon: SnowflakeIcon,
|
||||
title: 'Snowflake warehouse cost control',
|
||||
prompt:
|
||||
'Build a scheduled workflow that reviews Snowflake warehouse state and suspends an approved idle warehouse after notifying the data platform channel.',
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['finance', 'devops'],
|
||||
alsoIntegrations: ['slack'],
|
||||
},
|
||||
{
|
||||
icon: SnowflakeIcon,
|
||||
title: 'Snowflake schema-drift audit',
|
||||
prompt:
|
||||
'Create a daily workflow that introspects a Snowflake schema, compares its tables and columns with the previous snapshot, and reports breaking schema changes.',
|
||||
modules: ['scheduled', 'tables', 'agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['monitoring', 'analysis'],
|
||||
},
|
||||
{
|
||||
icon: SnowflakeIcon,
|
||||
title: 'Snowflake procedure orchestrator',
|
||||
prompt:
|
||||
'Build a workflow that validates typed arguments, calls a Snowflake stored procedure, captures its output, and routes failures for operator review.',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['automation', 'devops'],
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
name: 'query-snowflake-data',
|
||||
description: 'Run a bounded parameterized Snowflake query and summarize its result.',
|
||||
content:
|
||||
'# Query Snowflake Data\n\n## Steps\n1. Confirm the warehouse and SQL statement.\n2. Use typed bindings for values.\n3. Execute the statement with a bounded row limit.\n4. If it is asynchronous, poll the handle and request only the needed partition.\n\n## Output\nReturn the rows, column meanings, and a concise summary.',
|
||||
},
|
||||
{
|
||||
name: 'sync-snowflake-rows',
|
||||
description: 'Insert, update, or upsert structured records safely in Snowflake.',
|
||||
content:
|
||||
'# Synchronize Snowflake Rows\n\n## Steps\n1. Confirm the target table and record keys.\n2. Keep the structured request within Sim’s request-size limit, and use Load Data for bulk ingestion.\n3. Choose insert, update, or upsert and provide match columns when needed.\n4. Report Snowflake DML statistics.\n\n## Output\nReturn inserted, updated, deleted, and total affected row counts.',
|
||||
},
|
||||
{
|
||||
name: 'load-snowflake-stage',
|
||||
description: 'Load files already present in a Snowflake stage into a table.',
|
||||
content:
|
||||
'# Load a Snowflake Stage\n\n## Steps\n1. Confirm the target table and existing stage path.\n2. Select the named file format and COPY options.\n3. Run the staged load.\n4. Review every returned file status before reporting success.\n\n## Output\nSummarize loaded and rejected files and any COPY errors.',
|
||||
},
|
||||
{
|
||||
name: 'monitor-snowflake-tasks',
|
||||
description: 'Inspect Snowflake task history and diagnose failed runs.',
|
||||
content:
|
||||
'# Monitor Snowflake Tasks\n\n## Steps\n1. Query the relevant time window within seven days.\n2. Filter to failures when appropriate.\n3. Get the selected run by query ID.\n4. Retrieve output only when it remains available and access permits it.\n\n## Output\nReturn run state, timing, query ID, and a failure summary.',
|
||||
},
|
||||
{
|
||||
name: 'control-snowflake-warehouse',
|
||||
description: 'Inspect and deliberately resume or suspend a Snowflake warehouse.',
|
||||
content:
|
||||
'# Control a Snowflake Warehouse\n\n## Steps\n1. List or describe the target warehouse.\n2. Confirm its name and current state.\n3. Resume or suspend only when requested.\n4. Verify the statement completed.\n\n## Output\nReturn the warehouse name, prior state, requested action, and result.',
|
||||
},
|
||||
{
|
||||
name: 'audit-snowflake-schema',
|
||||
description: 'Inspect Snowflake tables and columns for schema review or drift detection.',
|
||||
content:
|
||||
'# Audit a Snowflake Schema\n\n## Steps\n1. Choose a database and optional schema or table filter.\n2. Decide whether views belong in scope.\n3. Introspect the bounded metadata result.\n4. Compare names, types, nullability, and ordering.\n\n## Output\nReturn the schema inventory and notable compatibility risks.',
|
||||
},
|
||||
{
|
||||
name: 'call-snowflake-procedure',
|
||||
description: 'Call a Snowflake stored procedure with explicitly typed arguments.',
|
||||
content:
|
||||
'# Call a Snowflake Procedure\n\n## Steps\n1. Confirm the fully qualified procedure and argument order.\n2. Assign each argument an explicit Snowflake binding type and string value.\n3. Execute the call and inspect its result.\n\n## Output\nReturn the procedure result and statement handle.',
|
||||
},
|
||||
],
|
||||
} as const satisfies BlockMeta
|
||||
@@ -285,6 +285,7 @@ import { SixtyfourBlock, SixtyfourBlockMeta } from '@/blocks/blocks/sixtyfour'
|
||||
import { SlackBlock, SlackBlockMeta, SlackV2Block } from '@/blocks/blocks/slack'
|
||||
import { SmartleadBlock, SmartleadBlockMeta } from '@/blocks/blocks/smartlead'
|
||||
import { SmtpBlock, SmtpBlockMeta } from '@/blocks/blocks/smtp'
|
||||
import { SnowflakeBlock, SnowflakeBlockMeta } from '@/blocks/blocks/snowflake'
|
||||
import { SportmonksBlock, SportmonksBlockMeta } from '@/blocks/blocks/sportmonks'
|
||||
import { SpotifyBlock, SpotifyBlockMeta } from '@/blocks/blocks/spotify'
|
||||
import { SQSBlock, SQSBlockMeta } from '@/blocks/blocks/sqs'
|
||||
@@ -609,6 +610,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
|
||||
slack_v2: SlackV2Block,
|
||||
smartlead: SmartleadBlock,
|
||||
smtp: SmtpBlock,
|
||||
snowflake: SnowflakeBlock,
|
||||
sportmonks: SportmonksBlock,
|
||||
spotify: SpotifyBlock,
|
||||
sqs: SQSBlock,
|
||||
@@ -898,6 +900,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
|
||||
slack: SlackBlockMeta,
|
||||
smartlead: SmartleadBlockMeta,
|
||||
smtp: SmtpBlockMeta,
|
||||
snowflake: SnowflakeBlockMeta,
|
||||
sportmonks: SportmonksBlockMeta,
|
||||
spotify: SpotifyBlockMeta,
|
||||
sqs: SQSBlockMeta,
|
||||
|
||||
@@ -8574,10 +8574,11 @@ export function BigQueryIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function SnowflakeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<svg viewBox='0 0 146.36 139.16' xmlns='http://www.w3.org/2000/svg' {...props}>
|
||||
<path
|
||||
fill='#29B5E8'
|
||||
d='M12 2a1 1 0 0 1 1 1v3.59l2.3-2.3a1 1 0 1 1 1.4 1.42L13 9.41V12h2.6l3.7-3.7a1 1 0 0 1 1.4 1.4L18.42 12H22a1 1 0 1 1 0 2h-3.59l2.3 2.3a1 1 0 0 1-1.4 1.4L15.58 14H13v2.59l3.7 3.7a1 1 0 1 1-1.4 1.4L13 19.42V23a1 1 0 1 1-2 0v-3.58l-2.3 2.3a1 1 0 1 1-1.4-1.4l3.7-3.71V14H8.4l-3.7 3.7a1 1 0 0 1-1.4-1.4L5.58 14H2a1 1 0 0 1 0-2h3.59l-2.3-2.3a1 1 0 0 1 1.4-1.4L8.42 12H11V9.41L7.3 5.71a1 1 0 1 1 1.4-1.42l2.3 2.3V3a1 1 0 0 1 1-1Z'
|
||||
fillRule='evenodd'
|
||||
d='M134.81,60.1l-16.47,9.49L134.81,79a8.65,8.65,0,1,1-8.67,15l-29.51-17a8.68,8.68,0,0,1-4.33-7.75,8.48,8.48,0,0,1,.31-2,8.68,8.68,0,0,1,4-5.19l29.51-16.94A8.69,8.69,0,0,1,138,48.31,8.58,8.58,0,0,1,134.81,60.1Zm-15.59,46L89.72,89.13a8.72,8.72,0,0,0-13.06,7.48v33.9a8.69,8.69,0,0,0,17.37,0v-19L110.54,121a8.66,8.66,0,1,0,8.68-15Zm-34-33.16L72.92,85.09a2.44,2.44,0,0,1-1.54.65H67.77a2.51,2.51,0,0,1-1.54-.65L54,72.9a2.45,2.45,0,0,1-.64-1.52v-3.6A2.5,2.5,0,0,1,54,66.25L66.23,54.06a2.5,2.5,0,0,1,1.54-.64h3.61a2.45,2.45,0,0,1,1.54.64L85.18,66.25a2.49,2.49,0,0,1,.63,1.53v3.6A2.44,2.44,0,0,1,85.18,72.9Zm-9.8-3.38A2.59,2.59,0,0,0,74.73,68l-3.55-3.51a2.51,2.51,0,0,0-1.54-.64h-.13a2.46,2.46,0,0,0-1.53.64L64.43,68a2.51,2.51,0,0,0-.63,1.55v.13a2.41,2.41,0,0,0,.63,1.52L68,74.7a2.48,2.48,0,0,0,1.53.64h.13a2.51,2.51,0,0,0,1.54-.64l3.55-3.53a2.49,2.49,0,0,0,.65-1.52ZM19.93,33.08,49.44,50a8.73,8.73,0,0,0,13.07-7.49V8.64a8.69,8.69,0,0,0-17.37,0v19l-16.53-9.5a8.65,8.65,0,1,0-8.68,15ZM84.69,51.16a8.64,8.64,0,0,0,5-1.13l29.5-17a8.65,8.65,0,1,0-8.68-15L94,27.61v-19a8.69,8.69,0,0,0-17.37,0v33.9A8.66,8.66,0,0,0,84.69,51.16ZM54.48,88a8.58,8.58,0,0,0-5,1.13L19.93,106.06a8.66,8.66,0,1,0,8.68,15l16.53-9.49v19a8.69,8.69,0,0,0,17.37,0V96.61A8.65,8.65,0,0,0,54.48,88Zm-8-15.87a8.61,8.61,0,0,0-4-10L13,45.14A8.69,8.69,0,0,0,1.17,48.31,8.59,8.59,0,0,0,4.35,60.1l16.47,9.49L4.35,79A8.65,8.65,0,1,0,13,94l29.48-17A8.59,8.59,0,0,0,46.47,72.13Zm93.15-56.22H138.3v1.63h1.32c.61,0,1-.28,1-.8S140.26,15.91,139.62,15.91Zm-2.94-1.5h3c1.62,0,2.7.89,2.7,2.27a2.16,2.16,0,0,1-1.08,1.9l1.17,1.68v.34h-1.69L139.62,19H138.3V20.6h-1.62Zm8.3,3.22a5.48,5.48,0,0,0-5.58-5.83c-3.31,0-5.51,2.39-5.51,5.83,0,3.28,2.2,5.82,5.51,5.82A5.47,5.47,0,0,0,145,17.63Zm1.38,0c0,3.89-2.6,7.14-7,7.14s-6.89-3.28-6.89-7.14,2.57-7.14,6.89-7.14S146.36,13.73,146.36,17.63Z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -210,6 +210,7 @@ import {
|
||||
SlackIcon,
|
||||
SmartleadIcon,
|
||||
SmtpIcon,
|
||||
SnowflakeIcon,
|
||||
SportmonksIcon,
|
||||
SQSIcon,
|
||||
SquareIcon,
|
||||
@@ -478,6 +479,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
slack: SlackIcon,
|
||||
smartlead: SmartleadIcon,
|
||||
smtp: SmtpIcon,
|
||||
snowflake: SnowflakeIcon,
|
||||
sportmonks: SportmonksIcon,
|
||||
sqs: SQSIcon,
|
||||
square: SquareIcon,
|
||||
|
||||
@@ -18242,6 +18242,109 @@
|
||||
"integrationType": "email",
|
||||
"tags": ["messaging", "automation"]
|
||||
},
|
||||
{
|
||||
"type": "snowflake",
|
||||
"slug": "snowflake",
|
||||
"name": "Snowflake",
|
||||
"description": "Query data and manage warehouses and tasks in Snowflake",
|
||||
"longDescription": "Connect with a Snowflake programmatic access token to execute SQL, synchronize structured rows, load staged data, manage warehouses and tasks, inspect schemas, and call stored procedures.",
|
||||
"bgColor": "#FFFFFF",
|
||||
"iconName": "SnowflakeIcon",
|
||||
"docsUrl": "https://docs.sim.ai/integrations/snowflake",
|
||||
"operations": [
|
||||
{
|
||||
"name": "Execute SQL",
|
||||
"description": "Execute one parameterized SQL statement through the Snowflake SQL API."
|
||||
},
|
||||
{
|
||||
"name": "Get Statement",
|
||||
"description": "Check a running or completed statement and retrieve exactly one result partition. Canceled or failed statements are returned as errors."
|
||||
},
|
||||
{
|
||||
"name": "Cancel Statement",
|
||||
"description": "Cancel a running Snowflake SQL API statement."
|
||||
},
|
||||
{
|
||||
"name": "Insert Rows",
|
||||
"description": "Insert structured JSON rows using bound values."
|
||||
},
|
||||
{
|
||||
"name": "Update Rows",
|
||||
"description": "Update matching rows with a bound MERGE statement without inserting new rows."
|
||||
},
|
||||
{
|
||||
"name": "Upsert Rows",
|
||||
"description": "Update matching rows and insert unmatched rows with a bound MERGE statement."
|
||||
},
|
||||
{
|
||||
"name": "Delete Rows",
|
||||
"description": "Delete rows matching a required set of bound column filters."
|
||||
},
|
||||
{
|
||||
"name": "Load Data",
|
||||
"description": "Load files from an existing Snowflake stage with COPY INTO."
|
||||
},
|
||||
{
|
||||
"name": "List Warehouses",
|
||||
"description": "List warehouses visible to the active Snowflake role."
|
||||
},
|
||||
{
|
||||
"name": "Get Warehouse",
|
||||
"description": "Get the full details for a Snowflake virtual warehouse."
|
||||
},
|
||||
{
|
||||
"name": "Resume Warehouse",
|
||||
"description": "Resume a Snowflake virtual warehouse if it is suspended."
|
||||
},
|
||||
{
|
||||
"name": "Suspend Warehouse",
|
||||
"description": "Suspend a Snowflake virtual warehouse."
|
||||
},
|
||||
{
|
||||
"name": "List Tasks",
|
||||
"description": "List tasks in a Snowflake schema."
|
||||
},
|
||||
{
|
||||
"name": "Get Task",
|
||||
"description": "Describe a Snowflake task."
|
||||
},
|
||||
{
|
||||
"name": "Run Task",
|
||||
"description": "Run a Snowflake task immediately, optionally retrying its last failed graph."
|
||||
},
|
||||
{
|
||||
"name": "List Task Runs",
|
||||
"description": "Query up to seven days of Snowflake task history, capped at 10000 rows."
|
||||
},
|
||||
{
|
||||
"name": "Get Task Run",
|
||||
"description": "Find one task history record by query ID within Snowflake’s seven-day window and 10000 most recent records after optional filters."
|
||||
},
|
||||
{
|
||||
"name": "Cancel Task Query",
|
||||
"description": "Cancel one running task query by query ID with SYSTEM$CANCEL_QUERY. Task runs already in flight are unaffected and must be cancelled individually; a cancelled child marks the task graph run failed, so downstream tasks are skipped."
|
||||
},
|
||||
{
|
||||
"name": "Get Task Run Output",
|
||||
"description": "Read a task query result with RESULT_SCAN during Snowflake’s 24-hour retention window using the task owner role."
|
||||
},
|
||||
{
|
||||
"name": "Introspect Schema",
|
||||
"description": "Inspect table and column metadata through Snowflake INFORMATION_SCHEMA views."
|
||||
},
|
||||
{
|
||||
"name": "Call Procedure",
|
||||
"description": "Call a stored procedure with explicitly typed Snowflake bindings."
|
||||
}
|
||||
],
|
||||
"operationCount": 21,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "api-key",
|
||||
"category": "tools",
|
||||
"integrationType": "databases",
|
||||
"tags": ["data-warehouse", "data-analytics", "cloud"]
|
||||
},
|
||||
{
|
||||
"type": "sportmonks",
|
||||
"slug": "sportmonks",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3817,6 +3817,29 @@ import {
|
||||
} from '@/tools/smartlead'
|
||||
import { smsSendTool } from '@/tools/sms'
|
||||
import { smtpSendMailTool } from '@/tools/smtp'
|
||||
import {
|
||||
snowflakeCallProcedureTool,
|
||||
snowflakeCancelStatementTool,
|
||||
snowflakeCancelTaskRunTool,
|
||||
snowflakeDeleteRowsTool,
|
||||
snowflakeExecuteSqlTool,
|
||||
snowflakeGetStatementTool,
|
||||
snowflakeGetTaskRunOutputTool,
|
||||
snowflakeGetTaskRunTool,
|
||||
snowflakeGetTaskTool,
|
||||
snowflakeGetWarehouseTool,
|
||||
snowflakeInsertRowsTool,
|
||||
snowflakeIntrospectSchemaTool,
|
||||
snowflakeListTaskRunsTool,
|
||||
snowflakeListTasksTool,
|
||||
snowflakeListWarehousesTool,
|
||||
snowflakeLoadDataTool,
|
||||
snowflakeResumeWarehouseTool,
|
||||
snowflakeRunTaskTool,
|
||||
snowflakeSuspendWarehouseTool,
|
||||
snowflakeUpdateRowsTool,
|
||||
snowflakeUpsertRowsTool,
|
||||
} from '@/tools/snowflake'
|
||||
import {
|
||||
sportmonksCoreGetCitiesTool,
|
||||
sportmonksCoreGetCityTool,
|
||||
@@ -5386,6 +5409,27 @@ export const tools: Record<string, ToolConfig> = {
|
||||
sendgrid_delete_template: sendGridDeleteTemplateTool,
|
||||
sendgrid_create_template_version: sendGridCreateTemplateVersionTool,
|
||||
smtp_send_mail: smtpSendMailTool,
|
||||
snowflake_call_procedure: snowflakeCallProcedureTool,
|
||||
snowflake_cancel_statement: snowflakeCancelStatementTool,
|
||||
snowflake_cancel_task_run: snowflakeCancelTaskRunTool,
|
||||
snowflake_delete_rows: snowflakeDeleteRowsTool,
|
||||
snowflake_execute_sql: snowflakeExecuteSqlTool,
|
||||
snowflake_get_statement: snowflakeGetStatementTool,
|
||||
snowflake_get_task: snowflakeGetTaskTool,
|
||||
snowflake_get_task_run: snowflakeGetTaskRunTool,
|
||||
snowflake_get_task_run_output: snowflakeGetTaskRunOutputTool,
|
||||
snowflake_get_warehouse: snowflakeGetWarehouseTool,
|
||||
snowflake_insert_rows: snowflakeInsertRowsTool,
|
||||
snowflake_introspect_schema: snowflakeIntrospectSchemaTool,
|
||||
snowflake_list_task_runs: snowflakeListTaskRunsTool,
|
||||
snowflake_list_tasks: snowflakeListTasksTool,
|
||||
snowflake_list_warehouses: snowflakeListWarehousesTool,
|
||||
snowflake_load_data: snowflakeLoadDataTool,
|
||||
snowflake_resume_warehouse: snowflakeResumeWarehouseTool,
|
||||
snowflake_run_task: snowflakeRunTaskTool,
|
||||
snowflake_suspend_warehouse: snowflakeSuspendWarehouseTool,
|
||||
snowflake_update_rows: snowflakeUpdateRowsTool,
|
||||
snowflake_upsert_rows: snowflakeUpsertRowsTool,
|
||||
sportmonks_football_expected_by_player: sportmonksExpectedByPlayerTool,
|
||||
sportmonks_football_expected_by_team: sportmonksExpectedByTeamTool,
|
||||
sportmonks_football_get_all_commentaries: sportmonksGetAllCommentariesTool,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { buildCallProcedure } from '@/tools/snowflake/sql'
|
||||
import type {
|
||||
SnowflakeBinding,
|
||||
SnowflakeCallProcedureParams,
|
||||
SnowflakeStatementResponse,
|
||||
} from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* The block resolves `procedureArguments` to JSON before execution, but a direct tool call
|
||||
* can still deliver the raw JSON string.
|
||||
*/
|
||||
function parseProcedureArguments(value: unknown): SnowflakeBinding[] | undefined {
|
||||
if (typeof value !== 'string') return value as SnowflakeBinding[] | undefined
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
try {
|
||||
return JSON.parse(trimmed) as SnowflakeBinding[]
|
||||
} catch {
|
||||
throw new Error('procedureArguments must be a JSON array of type and value objects')
|
||||
}
|
||||
}
|
||||
|
||||
export const callProcedureTool: ToolConfig<
|
||||
SnowflakeCallProcedureParams,
|
||||
SnowflakeStatementResponse
|
||||
> = {
|
||||
id: 'snowflake_call_procedure',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Call Procedure',
|
||||
description: 'Call a stored procedure with explicitly typed Snowflake bindings.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
maxRows: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum result rows; defaults to 1000 with a Sim safety limit of 10000',
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Database name',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Schema name',
|
||||
},
|
||||
procedureName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Stored procedure name',
|
||||
},
|
||||
procedureArguments: {
|
||||
type: 'array',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ordered argument bindings with a Snowflake type and string value',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['type', 'value'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
type: { type: 'string', description: 'Snowflake binding type' },
|
||||
value: { type: 'string', description: 'Snowflake binding value' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(
|
||||
params,
|
||||
buildCallProcedure({
|
||||
...params,
|
||||
procedureArguments: parseProcedureArguments(params.procedureArguments),
|
||||
}),
|
||||
{
|
||||
warehouse: params.warehouse,
|
||||
maxRows: params.maxRows,
|
||||
}
|
||||
)
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
SnowflakeCancelStatementParams,
|
||||
SnowflakeStatementResponse,
|
||||
} from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
getSnowflakeHeaders,
|
||||
normalizeSnowflakeHost,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const cancelStatementTool: ToolConfig<
|
||||
SnowflakeCancelStatementParams,
|
||||
SnowflakeStatementResponse
|
||||
> = {
|
||||
id: 'snowflake_cancel_statement',
|
||||
name: 'Snowflake Cancel Statement',
|
||||
description: 'Cancel a running Snowflake SQL API statement.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
statementHandle: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement handle returned by Snowflake',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) =>
|
||||
`${normalizeSnowflakeHost(params.host)}/api/v2/statements/${encodeURIComponent(params.statementHandle.trim())}/cancel`,
|
||||
method: 'POST',
|
||||
headers: getSnowflakeHeaders,
|
||||
},
|
||||
transformResponse: transformSnowflakeResult((params) => ({
|
||||
canceled: true,
|
||||
fallbackStatementHandle: params?.statementHandle.trim(),
|
||||
})),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { buildCancelTaskRun } from '@/tools/snowflake/sql'
|
||||
import type {
|
||||
SnowflakeCancelTaskRunParams,
|
||||
SnowflakeStatementResponse,
|
||||
} from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const cancelTaskRunTool: ToolConfig<
|
||||
SnowflakeCancelTaskRunParams,
|
||||
SnowflakeStatementResponse
|
||||
> = {
|
||||
id: 'snowflake_cancel_task_run',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Cancel Task Query',
|
||||
description:
|
||||
'Cancel one running task query by query ID with SYSTEM$CANCEL_QUERY. Task runs already in flight are unaffected and must be cancelled individually; a cancelled child marks the task graph run failed, so downstream tasks are skipped.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
queryId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Query ID of the single running task query to cancel, taken from TASK_HISTORY. Cancels only that query.',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(params, buildCancelTaskRun(params), {
|
||||
warehouse: params.warehouse,
|
||||
maxRows: 1,
|
||||
})
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { buildDeleteRows } from '@/tools/snowflake/sql'
|
||||
import type { SnowflakeDeleteRowsParams, SnowflakeStatementResponse } from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* The block resolves `filters` to JSON before execution, but a direct tool call can still
|
||||
* deliver the raw JSON string.
|
||||
*/
|
||||
function parseFilters(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== 'string') return value as Record<string, unknown>
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>
|
||||
} catch {
|
||||
throw new Error('filters must be a JSON object of column filters')
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteRowsTool: ToolConfig<SnowflakeDeleteRowsParams, SnowflakeStatementResponse> = {
|
||||
id: 'snowflake_delete_rows',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Delete Rows',
|
||||
description: 'Delete rows matching a required set of bound column filters.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Database name',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Schema name',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Target Snowflake table name within the selected database and schema context',
|
||||
},
|
||||
filters: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Non-empty JSON object of column filters combined with AND. A null value matches rows where that column IS NULL; every other value is compared for equality against a bound parameter.',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(
|
||||
params,
|
||||
buildDeleteRows({ ...params, filters: parseFilters(params.filters) }),
|
||||
{ warehouse: params.warehouse }
|
||||
)
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { normalizeBindings } from '@/tools/snowflake/sql'
|
||||
import type {
|
||||
SnowflakeBinding,
|
||||
SnowflakeExecuteSqlParams,
|
||||
SnowflakeStatementResponse,
|
||||
} from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* The block resolves `bindings` to JSON before execution, but a direct tool call can
|
||||
* still deliver the raw JSON string.
|
||||
*/
|
||||
function parseBindings(value: unknown): Record<string, SnowflakeBinding> | undefined {
|
||||
if (typeof value !== 'string') return value as Record<string, SnowflakeBinding> | undefined
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
try {
|
||||
return JSON.parse(trimmed) as Record<string, SnowflakeBinding>
|
||||
} catch {
|
||||
throw new Error('bindings must be a JSON object keyed by 1-based positions')
|
||||
}
|
||||
}
|
||||
|
||||
/** Only a real `true` or the exact string `"true"` submits the statement asynchronously. */
|
||||
function isAsync(value: unknown): boolean {
|
||||
return value === true || value === 'true'
|
||||
}
|
||||
|
||||
export const executeSqlTool: ToolConfig<SnowflakeExecuteSqlParams, SnowflakeStatementResponse> = {
|
||||
id: 'snowflake_execute_sql',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Execute SQL',
|
||||
description: 'Execute one parameterized SQL statement through the Snowflake SQL API.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
maxRows: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum result rows; defaults to 1000 with a Sim safety limit of 10000',
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Database context for this statement',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Schema context for this statement',
|
||||
},
|
||||
statement: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'One Snowflake SQL statement to execute',
|
||||
},
|
||||
bindings: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake bindings keyed by 1-based position, each with type and string value',
|
||||
},
|
||||
async: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Return immediately with a statement handle',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest(
|
||||
(params) =>
|
||||
buildSnowflakeStatementBody(
|
||||
params,
|
||||
{
|
||||
statement: params.statement,
|
||||
bindings: normalizeBindings(parseBindings(params.bindings)),
|
||||
},
|
||||
{
|
||||
context: { database: params.database, schema: params.schema },
|
||||
warehouse: params.warehouse,
|
||||
maxRows: params.maxRows,
|
||||
}
|
||||
),
|
||||
(params) => isAsync(params.async)
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
SnowflakeGetStatementParams,
|
||||
SnowflakeStatementResponse,
|
||||
} from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
getSnowflakeHeaders,
|
||||
normalizeSnowflakeHost,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
function partitionNumber(value?: number): number {
|
||||
const partition = value ?? 0
|
||||
if (!Number.isInteger(partition) || partition < 0) {
|
||||
throw new Error('partition must be a non-negative integer')
|
||||
}
|
||||
return partition
|
||||
}
|
||||
|
||||
/**
|
||||
* Snowflake attaches result metadata only to the first partition's response, so the
|
||||
* partition count has to be carried forward by the caller to make a later partition's
|
||||
* continuation state knowable.
|
||||
*/
|
||||
function partitionCountNumber(value?: number): number | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error('partitionCount must be a positive integer')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export const getStatementTool: ToolConfig<SnowflakeGetStatementParams, SnowflakeStatementResponse> =
|
||||
{
|
||||
id: 'snowflake_get_statement',
|
||||
name: 'Snowflake Get Statement',
|
||||
description:
|
||||
'Check a running or completed statement and retrieve exactly one result partition. Canceled or failed statements are returned as errors.',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
statementHandle: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement handle returned by Snowflake',
|
||||
},
|
||||
partition: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Zero-based result partition to retrieve; defaults to 0',
|
||||
},
|
||||
partitionCount: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Total number of result partitions, taken from the partitionCount of the first partition. Snowflake omits metadata from every later partition response, so supply this when fetching partition 1 or higher to keep truncated and nextPartition accurate',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
const partition = partitionNumber(params.partition)
|
||||
return `${normalizeSnowflakeHost(params.host)}/api/v2/statements/${encodeURIComponent(params.statementHandle.trim())}?partition=${partition}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: getSnowflakeHeaders,
|
||||
},
|
||||
transformResponse: transformSnowflakeResult((params) => ({
|
||||
currentPartition: partitionNumber(params?.partition),
|
||||
partitionCount: partitionCountNumber(params?.partitionCount),
|
||||
fallbackStatementHandle: params?.statementHandle.trim(),
|
||||
})),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { buildGetTask } from '@/tools/snowflake/sql'
|
||||
import type { SnowflakeStatementResponse, SnowflakeTaskParams } from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getTaskTool: ToolConfig<SnowflakeTaskParams, SnowflakeStatementResponse> = {
|
||||
id: 'snowflake_get_task',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Get Task',
|
||||
description: 'Describe a Snowflake task.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Database name',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Schema name',
|
||||
},
|
||||
taskName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Task name',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(params, buildGetTask(params), { maxRows: 1 })
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { buildGetTaskRun } from '@/tools/snowflake/sql'
|
||||
import type { SnowflakeGetTaskRunParams, SnowflakeStatementResponse } from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getTaskRunTool: ToolConfig<SnowflakeGetTaskRunParams, SnowflakeStatementResponse> = {
|
||||
id: 'snowflake_get_task_run',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Get Task Run',
|
||||
description:
|
||||
'Find one task history record by query ID within Snowflake’s seven-day window and 10000 most recent records after optional filters.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
queryId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Task run query ID from TASK_HISTORY',
|
||||
},
|
||||
taskName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Optional task name used to narrow the 10000-record history window. TASK_HISTORY supports only non-qualified task names, so pass DAILY_LOAD rather than DB.SCHEMA.DAILY_LOAD',
|
||||
},
|
||||
startTime: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Optional scheduled-time range start as an ISO timestamp within the last seven days',
|
||||
},
|
||||
endTime: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Optional scheduled-time range end as an ISO timestamp within the last seven days',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(params, buildGetTaskRun(params), {
|
||||
warehouse: params.warehouse,
|
||||
maxRows: 1,
|
||||
})
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { buildGetTaskRunOutput } from '@/tools/snowflake/sql'
|
||||
import type {
|
||||
SnowflakeGetTaskRunOutputParams,
|
||||
SnowflakeStatementResponse,
|
||||
} from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getTaskRunOutputTool: ToolConfig<
|
||||
SnowflakeGetTaskRunOutputParams,
|
||||
SnowflakeStatementResponse
|
||||
> = {
|
||||
id: 'snowflake_get_task_run_output',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Get Task Run Output',
|
||||
description:
|
||||
'Read a task query result with RESULT_SCAN during Snowflake’s 24-hour retention window using the task owner role.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
maxRows: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum result rows; defaults to 1000 with a Sim safety limit of 10000',
|
||||
},
|
||||
queryId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Completed task query ID; task results require the task owner role, while manual query results require the same user',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(params, buildGetTaskRunOutput(params), {
|
||||
warehouse: params.warehouse,
|
||||
maxRows: params.maxRows,
|
||||
})
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { buildGetWarehouse } from '@/tools/snowflake/sql'
|
||||
import type { SnowflakeStatementResponse, SnowflakeWarehouseParams } from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getWarehouseTool: ToolConfig<SnowflakeWarehouseParams, SnowflakeStatementResponse> = {
|
||||
id: 'snowflake_get_warehouse',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Get Warehouse',
|
||||
description: 'Get the full details for a Snowflake virtual warehouse.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouseName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse name',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(params, buildGetWarehouse(params), { maxRows: 1 })
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { callProcedureTool } from '@/tools/snowflake/call_procedure'
|
||||
import { cancelStatementTool } from '@/tools/snowflake/cancel_statement'
|
||||
import { cancelTaskRunTool } from '@/tools/snowflake/cancel_task_run'
|
||||
import { deleteRowsTool } from '@/tools/snowflake/delete_rows'
|
||||
import { executeSqlTool } from '@/tools/snowflake/execute_sql'
|
||||
import { getStatementTool } from '@/tools/snowflake/get_statement'
|
||||
import { getTaskTool } from '@/tools/snowflake/get_task'
|
||||
import { getTaskRunTool } from '@/tools/snowflake/get_task_run'
|
||||
import { getTaskRunOutputTool } from '@/tools/snowflake/get_task_run_output'
|
||||
import { getWarehouseTool } from '@/tools/snowflake/get_warehouse'
|
||||
import { insertRowsTool } from '@/tools/snowflake/insert_rows'
|
||||
import { introspectSchemaTool } from '@/tools/snowflake/introspect_schema'
|
||||
import { listTaskRunsTool } from '@/tools/snowflake/list_task_runs'
|
||||
import { listTasksTool } from '@/tools/snowflake/list_tasks'
|
||||
import { listWarehousesTool } from '@/tools/snowflake/list_warehouses'
|
||||
import { loadDataTool } from '@/tools/snowflake/load_data'
|
||||
import { resumeWarehouseTool } from '@/tools/snowflake/resume_warehouse'
|
||||
import { runTaskTool } from '@/tools/snowflake/run_task'
|
||||
import { suspendWarehouseTool } from '@/tools/snowflake/suspend_warehouse'
|
||||
import { updateRowsTool } from '@/tools/snowflake/update_rows'
|
||||
import { upsertRowsTool } from '@/tools/snowflake/upsert_rows'
|
||||
|
||||
export * from '@/tools/snowflake/types'
|
||||
|
||||
export const snowflakeCallProcedureTool = callProcedureTool
|
||||
export const snowflakeCancelStatementTool = cancelStatementTool
|
||||
export const snowflakeCancelTaskRunTool = cancelTaskRunTool
|
||||
export const snowflakeDeleteRowsTool = deleteRowsTool
|
||||
export const snowflakeExecuteSqlTool = executeSqlTool
|
||||
export const snowflakeGetStatementTool = getStatementTool
|
||||
export const snowflakeGetTaskTool = getTaskTool
|
||||
export const snowflakeGetTaskRunTool = getTaskRunTool
|
||||
export const snowflakeGetTaskRunOutputTool = getTaskRunOutputTool
|
||||
export const snowflakeGetWarehouseTool = getWarehouseTool
|
||||
export const snowflakeInsertRowsTool = insertRowsTool
|
||||
export const snowflakeIntrospectSchemaTool = introspectSchemaTool
|
||||
export const snowflakeListTaskRunsTool = listTaskRunsTool
|
||||
export const snowflakeListTasksTool = listTasksTool
|
||||
export const snowflakeListWarehousesTool = listWarehousesTool
|
||||
export const snowflakeLoadDataTool = loadDataTool
|
||||
export const snowflakeResumeWarehouseTool = resumeWarehouseTool
|
||||
export const snowflakeRunTaskTool = runTaskTool
|
||||
export const snowflakeSuspendWarehouseTool = suspendWarehouseTool
|
||||
export const snowflakeUpdateRowsTool = updateRowsTool
|
||||
export const snowflakeUpsertRowsTool = upsertRowsTool
|
||||
@@ -0,0 +1,96 @@
|
||||
import { buildInsertRows } from '@/tools/snowflake/sql'
|
||||
import type { SnowflakeInsertRowsParams, SnowflakeStatementResponse } from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* The block resolves `rows` to JSON before execution, but a direct tool call can still
|
||||
* deliver the raw JSON string. Values that already arrive parsed pass straight through
|
||||
* to the builder's own shape validation.
|
||||
*/
|
||||
function parseRows(value: unknown): Array<Record<string, unknown>> {
|
||||
if (typeof value !== 'string') return value as Array<Record<string, unknown>>
|
||||
try {
|
||||
return JSON.parse(value) as Array<Record<string, unknown>>
|
||||
} catch {
|
||||
throw new Error('rows must be a JSON array of row objects')
|
||||
}
|
||||
}
|
||||
|
||||
export const insertRowsTool: ToolConfig<SnowflakeInsertRowsParams, SnowflakeStatementResponse> = {
|
||||
id: 'snowflake_insert_rows',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Insert Rows',
|
||||
description: 'Insert structured JSON rows using bound values.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Database name',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Schema name',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Target Snowflake table name within the selected database and schema context',
|
||||
},
|
||||
rows: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Non-empty JSON array of row objects with matching keys. Max 1000 rows and 1 MB of bound data per call - stage the files and use Load Data for bulk ingest.',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(
|
||||
params,
|
||||
buildInsertRows({ ...params, rows: parseRows(params.rows) }),
|
||||
{ warehouse: params.warehouse }
|
||||
)
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { buildIntrospectSchema } from '@/tools/snowflake/sql'
|
||||
import type {
|
||||
SnowflakeIntrospectSchemaParams,
|
||||
SnowflakeStatementResponse,
|
||||
} from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Only a real `true` or the exact string `"true"` drops the TABLE_TYPE filter, so a
|
||||
* direct tool call supplying the truthy string `"false"` still excludes views.
|
||||
*/
|
||||
function includesViews(value: unknown): boolean {
|
||||
return value === true || value === 'true'
|
||||
}
|
||||
|
||||
export const introspectSchemaTool: ToolConfig<
|
||||
SnowflakeIntrospectSchemaParams,
|
||||
SnowflakeStatementResponse
|
||||
> = {
|
||||
id: 'snowflake_introspect_schema',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Introspect Schema',
|
||||
description: 'Inspect table and column metadata through Snowflake INFORMATION_SCHEMA views.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
maxRows: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum result rows; defaults to 1000 with a Sim safety limit of 10000',
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Database containing the INFORMATION_SCHEMA views',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional exact schema name filter',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional exact table name filter',
|
||||
},
|
||||
includeViews: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Include views and materialized views alongside tables',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(
|
||||
params,
|
||||
buildIntrospectSchema({ ...params, includeViews: includesViews(params.includeViews) }),
|
||||
{
|
||||
warehouse: params.warehouse,
|
||||
maxRows: params.maxRows,
|
||||
}
|
||||
)
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { buildListTaskRuns } from '@/tools/snowflake/sql'
|
||||
import type {
|
||||
SnowflakeListTaskRunsParams,
|
||||
SnowflakeStatementResponse,
|
||||
} from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* TASK_HISTORY's ERROR_ONLY argument is rendered from this value, so only a real `true`
|
||||
* or the exact string `"true"` narrows the history to failed and cancelled runs.
|
||||
*/
|
||||
function isErrorOnly(value: unknown): boolean {
|
||||
return value === true || value === 'true'
|
||||
}
|
||||
|
||||
export const listTaskRunsTool: ToolConfig<SnowflakeListTaskRunsParams, SnowflakeStatementResponse> =
|
||||
{
|
||||
id: 'snowflake_list_task_runs',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake List Task Runs',
|
||||
description: 'Query up to seven days of Snowflake task history, capped at 10000 rows.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
taskName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Optional task name filter. TASK_HISTORY supports only non-qualified task names, so pass DAILY_LOAD rather than DB.SCHEMA.DAILY_LOAD',
|
||||
},
|
||||
startTime: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Optional scheduled-time range start as an ISO timestamp within the last seven days',
|
||||
},
|
||||
endTime: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Optional scheduled-time range end as an ISO timestamp within the last seven days',
|
||||
},
|
||||
errorOnly: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Return only task runs that failed or were cancelled',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum task runs, from 1 to 10000',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(
|
||||
params,
|
||||
buildListTaskRuns({ ...params, errorOnly: isErrorOnly(params.errorOnly) }),
|
||||
{
|
||||
warehouse: params.warehouse,
|
||||
maxRows: params.limit,
|
||||
}
|
||||
)
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { buildListTasks } from '@/tools/snowflake/sql'
|
||||
import type { SnowflakeListTasksParams, SnowflakeStatementResponse } from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listTasksTool: ToolConfig<SnowflakeListTasksParams, SnowflakeStatementResponse> = {
|
||||
id: 'snowflake_list_tasks',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake List Tasks',
|
||||
description: 'List tasks in a Snowflake schema.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Database name',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Schema name',
|
||||
},
|
||||
nameLike: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional SQL LIKE pattern for task names',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum task rows, from 1 to 10000',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(params, buildListTasks(params), { maxRows: params.limit })
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { buildListWarehouses } from '@/tools/snowflake/sql'
|
||||
import type {
|
||||
SnowflakeListWarehousesParams,
|
||||
SnowflakeStatementResponse,
|
||||
} from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listWarehousesTool: ToolConfig<
|
||||
SnowflakeListWarehousesParams,
|
||||
SnowflakeStatementResponse
|
||||
> = {
|
||||
id: 'snowflake_list_warehouses',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake List Warehouses',
|
||||
description: 'List warehouses visible to the active Snowflake role.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
maxRows: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum result rows; defaults to 1000 with a Sim safety limit of 10000',
|
||||
},
|
||||
nameLike: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional SQL LIKE pattern for warehouse names',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(params, buildListWarehouses(params.nameLike), {
|
||||
maxRows: params.maxRows,
|
||||
})
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { buildLoadData } from '@/tools/snowflake/sql'
|
||||
import type { SnowflakeLoadDataParams, SnowflakeStatementResponse } from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* COPY INTO omits PURGE and FORCE entirely when the value is absent, which is how
|
||||
* Snowflake's FALSE defaults are inherited. A direct tool call can still supply the
|
||||
* string `"false"`, which is truthy, so only a real `true` or the exact string
|
||||
* `"true"` may enable an irreversible purge.
|
||||
*/
|
||||
function optionalCopyFlag(value: unknown): boolean | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
return value === true || value === 'true'
|
||||
}
|
||||
|
||||
export const loadDataTool: ToolConfig<SnowflakeLoadDataParams, SnowflakeStatementResponse> = {
|
||||
id: 'snowflake_load_data',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Load Data',
|
||||
description: 'Load files from an existing Snowflake stage with COPY INTO.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
maxRows: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum result rows; defaults to 1000 with a Sim safety limit of 10000',
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Target database name',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Target schema name',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Target table name',
|
||||
},
|
||||
stagePath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Existing stage path, for example @my_stage/path',
|
||||
},
|
||||
fileFormat: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional named file format',
|
||||
},
|
||||
pattern: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional regular expression used to select staged files',
|
||||
},
|
||||
onError: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'COPY error handling: ABORT_STATEMENT, CONTINUE, SKIP_FILE, SKIP_FILE_<count>, or SKIP_FILE_<percent>%',
|
||||
},
|
||||
purge: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Remove successfully loaded files from the stage',
|
||||
},
|
||||
force: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Reload files even when Snowflake has loaded them before',
|
||||
},
|
||||
matchByColumnName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'CASE_SENSITIVE, CASE_INSENSITIVE, or NONE',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(
|
||||
params,
|
||||
buildLoadData({
|
||||
...params,
|
||||
purge: optionalCopyFlag(params.purge),
|
||||
force: optionalCopyFlag(params.force),
|
||||
}),
|
||||
{
|
||||
warehouse: params.warehouse,
|
||||
maxRows: params.maxRows,
|
||||
}
|
||||
)
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { buildResumeWarehouse } from '@/tools/snowflake/sql'
|
||||
import type { SnowflakeStatementResponse, SnowflakeWarehouseParams } from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const resumeWarehouseTool: ToolConfig<SnowflakeWarehouseParams, SnowflakeStatementResponse> =
|
||||
{
|
||||
id: 'snowflake_resume_warehouse',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Resume Warehouse',
|
||||
description: 'Resume a Snowflake virtual warehouse if it is suspended.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouseName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse name',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(params, buildResumeWarehouse(params))
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { buildRunTask } from '@/tools/snowflake/sql'
|
||||
import type { SnowflakeRunTaskParams, SnowflakeStatementResponse } from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Only a real `true` or the exact string `"true"` appends RETRY LAST, so a direct tool
|
||||
* call supplying the truthy string `"false"` cannot re-run a failed task graph.
|
||||
*/
|
||||
function isRetryLast(value: unknown): boolean {
|
||||
return value === true || value === 'true'
|
||||
}
|
||||
|
||||
export const runTaskTool: ToolConfig<SnowflakeRunTaskParams, SnowflakeStatementResponse> = {
|
||||
id: 'snowflake_run_task',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Run Task',
|
||||
description: 'Run a Snowflake task immediately, optionally retrying its last failed graph.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Database name',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Schema name',
|
||||
},
|
||||
taskName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Task name',
|
||||
},
|
||||
retryLast: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Retry the last failed task graph run',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(
|
||||
params,
|
||||
buildRunTask({ ...params, retryLast: isRetryLast(params.retryLast) }),
|
||||
{ maxRows: 1 }
|
||||
)
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SnowflakeBlock } from '@/blocks/blocks/snowflake'
|
||||
import {
|
||||
buildCallProcedure,
|
||||
buildCancelTaskRun,
|
||||
buildDeleteRows,
|
||||
buildGetTask,
|
||||
buildGetTaskRun,
|
||||
buildGetTaskRunOutput,
|
||||
buildGetWarehouse,
|
||||
buildInsertRows,
|
||||
buildIntrospectSchema,
|
||||
buildListTaskRuns,
|
||||
buildListTasks,
|
||||
buildListWarehouses,
|
||||
buildLoadData,
|
||||
buildResumeWarehouse,
|
||||
buildRunTask,
|
||||
buildSuspendWarehouse,
|
||||
buildUpdateRows,
|
||||
buildUpsertRows,
|
||||
identifier,
|
||||
normalizeBindings,
|
||||
qualifiedIdentifier,
|
||||
} from '@/tools/snowflake/sql'
|
||||
|
||||
const context = { host: 'acme.snowflakecomputing.com', apiKey: 'secret' }
|
||||
const table = { ...context, database: 'ANALYTICS', schema: 'PUBLIC', table: 'EVENTS' }
|
||||
const queryId = '01b71944-0301-b428-0000-69f706bf0001'
|
||||
|
||||
/** The separator the row-shape and match-key encodings join on; legal inside a quoted identifier. */
|
||||
const SEPARATOR = String.fromCharCode(0)
|
||||
|
||||
describe('Snowflake SQL builders', () => {
|
||||
it('accepts safe identifiers and rejects SQL fragments', () => {
|
||||
expect(identifier('safe_name')).toBe('safe_name')
|
||||
expect(identifier('"Case Sensitive"')).toBe('"Case Sensitive"')
|
||||
expect(qualifiedIdentifier('DB', 'SCHEMA', 'TABLE')).toBe('DB.SCHEMA.TABLE')
|
||||
expect(() => identifier('users; DROP TABLE users')).toThrow('Invalid Snowflake identifier')
|
||||
})
|
||||
|
||||
it('validates explicit bindings', () => {
|
||||
expect(normalizeBindings({ '1': { type: 'DATE', value: '2026-01-01' } })).toEqual({
|
||||
'1': { type: 'DATE', value: '2026-01-01' },
|
||||
})
|
||||
expect(() => normalizeBindings({ '0': { type: 'TEXT', value: 'x' } })).toThrow('positive')
|
||||
expect(() => normalizeBindings({ '1': { type: 'NOPE', value: 'x' } } as never)).toThrow(
|
||||
'Unsupported'
|
||||
)
|
||||
expect(() => normalizeBindings([] as never)).toThrow('JSON object')
|
||||
expect(() => normalizeBindings({ '1': [] } as never)).toThrow('contain type and value')
|
||||
expect(SnowflakeBlock.inputs.bindings.description).toContain(
|
||||
'object keyed by 1-based positions'
|
||||
)
|
||||
expect(SnowflakeBlock.inputs.procedureArguments.description).toContain('ordered JSON array')
|
||||
})
|
||||
|
||||
it('only coerces fields used by the selected block operation', () => {
|
||||
const mapParams = SnowflakeBlock.tools.config.params
|
||||
if (!mapParams) throw new Error('Snowflake block must map tool parameters')
|
||||
|
||||
expect(() =>
|
||||
mapParams({
|
||||
operation: 'execute_sql',
|
||||
rows: '{invalid',
|
||||
filters: '{invalid',
|
||||
procedureArguments: '{invalid',
|
||||
onError: 'SKIP_FILE_NUMBER',
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
mapParams({
|
||||
operation: 'delete_rows',
|
||||
rows: '{invalid',
|
||||
filters: '{"id":1}',
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
mapParams({
|
||||
operation: 'load_data',
|
||||
onError: 'SKIP_FILE_NUMBER',
|
||||
})
|
||||
).toThrow('threshold')
|
||||
})
|
||||
|
||||
it('maps overlapping block fields according to the selected operation', () => {
|
||||
const mapParams = SnowflakeBlock.tools.config.params
|
||||
if (!mapParams) throw new Error('Snowflake block must map tool parameters')
|
||||
const finalParams = (params: Record<string, unknown>) => ({
|
||||
...params,
|
||||
...mapParams(params),
|
||||
})
|
||||
const staleFields = {
|
||||
database: 'OBJECT_DB',
|
||||
schema: 'OBJECT_SCHEMA',
|
||||
taskName: 'TASK_DEFINITION',
|
||||
}
|
||||
|
||||
expect(finalParams({ operation: 'execute_sql', ...staleFields })).toMatchObject({
|
||||
database: 'OBJECT_DB',
|
||||
schema: 'OBJECT_SCHEMA',
|
||||
})
|
||||
expect(finalParams({ operation: 'insert_rows', ...staleFields, rows: '[]' })).toMatchObject({
|
||||
database: 'OBJECT_DB',
|
||||
schema: 'OBJECT_SCHEMA',
|
||||
})
|
||||
expect(finalParams({ operation: 'list_task_runs', ...staleFields })).toMatchObject({
|
||||
taskName: 'TASK_DEFINITION',
|
||||
})
|
||||
expect(finalParams({ operation: 'get_task', ...staleFields })).toMatchObject({
|
||||
database: 'OBJECT_DB',
|
||||
schema: 'OBJECT_SCHEMA',
|
||||
taskName: 'TASK_DEFINITION',
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a bound multi-row INSERT in stable column order', () => {
|
||||
const result = buildInsertRows({
|
||||
...table,
|
||||
rows: [
|
||||
{ id: 1, name: 'Ada' },
|
||||
{ id: 2, name: null },
|
||||
],
|
||||
})
|
||||
expect(result.statement).toBe(
|
||||
'INSERT INTO ANALYTICS.PUBLIC.EVENTS (id, name) VALUES (?, ?), (?, NULL)'
|
||||
)
|
||||
expect(result.bindings).toEqual({
|
||||
'1': { type: 'FIXED', value: '1' },
|
||||
'2': { type: 'TEXT', value: 'Ada' },
|
||||
'3': { type: 'FIXED', value: '2' },
|
||||
})
|
||||
})
|
||||
|
||||
it('lifts PARSE_JSON out of the VALUES clause into a projecting SELECT', () => {
|
||||
const result = buildInsertRows({
|
||||
...table,
|
||||
rows: [
|
||||
{ id: 1, payload: { ok: true } },
|
||||
{ id: 2, payload: null },
|
||||
],
|
||||
})
|
||||
expect(result.statement).toBe(
|
||||
'INSERT INTO ANALYTICS.PUBLIC.EVENTS (id, payload) SELECT C1 AS id, PARSE_JSON(C2) AS payload FROM (VALUES (?, ?), (?, NULL)) AS v (C1, C2)'
|
||||
)
|
||||
expect(result.statement).not.toContain('VALUES (?, PARSE_JSON(?))')
|
||||
expect(result.bindings).toEqual({
|
||||
'1': { type: 'FIXED', value: '1' },
|
||||
'2': { type: 'TEXT', value: '{"ok":true}' },
|
||||
'3': { type: 'FIXED', value: '2' },
|
||||
})
|
||||
})
|
||||
|
||||
it('projects semi-structured MERGE source columns outside the VALUES clause', () => {
|
||||
const upsert = buildUpsertRows({
|
||||
...table,
|
||||
rows: [{ id: 1, payload: { ok: true } }],
|
||||
matchColumns: ['id'],
|
||||
})
|
||||
expect(upsert.statement).toContain(
|
||||
'USING (SELECT C1 AS id, PARSE_JSON(C2) AS payload FROM (VALUES (?, ?)) AS v (C1, C2)) AS source'
|
||||
)
|
||||
expect(upsert.statement).not.toContain('VALUES (?, PARSE_JSON(?))')
|
||||
})
|
||||
|
||||
it('escapes both backslashes and quotes in string literals', () => {
|
||||
expect(
|
||||
buildLoadData({ ...table, stagePath: '@RAW_STAGE', pattern: '.*\\' }).statement
|
||||
).toContain("PATTERN = '.*\\\\'")
|
||||
expect(buildListWarehouses("ETL\\' OR TRUE --").statement).toBe(
|
||||
"SHOW WAREHOUSES LIKE 'ETL\\\\'' OR TRUE --'"
|
||||
)
|
||||
})
|
||||
|
||||
it('caps the number of rows per structured write', () => {
|
||||
const rows = Array.from({ length: 1001 }, (_, index) => ({ id: index }))
|
||||
expect(() => buildInsertRows({ ...table, rows })).toThrow('cannot exceed 1000 per call')
|
||||
expect(() => buildInsertRows({ ...table, rows })).toThrow('snowflake_load_data')
|
||||
expect(() => buildInsertRows({ ...table, rows: [{ blob: 'x'.repeat(1_000_001) }] })).toThrow(
|
||||
'statement budget'
|
||||
)
|
||||
})
|
||||
|
||||
it('counts the bound value budget in UTF-8 bytes, not UTF-16 code units', () => {
|
||||
expect(() => buildInsertRows({ ...table, rows: [{ blob: '中'.repeat(999_999) }] })).toThrow(
|
||||
'statement budget'
|
||||
)
|
||||
expect(() =>
|
||||
buildInsertRows({ ...table, rows: [{ blob: '中'.repeat(333_333) }] })
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('applies the same statement budget to explicit bindings', () => {
|
||||
expect(() =>
|
||||
normalizeBindings({ '1': { type: 'TEXT', value: 'x'.repeat(1_000_001) } })
|
||||
).toThrow('statement budget')
|
||||
expect(() =>
|
||||
normalizeBindings({
|
||||
'1': { type: 'TEXT', value: 'x'.repeat(600_000) },
|
||||
'2': { type: 'TEXT', value: 'x'.repeat(600_000) },
|
||||
})
|
||||
).toThrow('statement budget')
|
||||
expect(() =>
|
||||
buildCallProcedure({
|
||||
...context,
|
||||
database: 'ANALYTICS',
|
||||
schema: 'PUBLIC',
|
||||
procedureName: 'REFRESH_MODEL',
|
||||
procedureArguments: [{ type: 'TEXT', value: '中'.repeat(999_999) }],
|
||||
})
|
||||
).toThrow('statement budget')
|
||||
})
|
||||
|
||||
it('routes a mixed semi-structured column through a single whole-column PARSE_JSON', () => {
|
||||
const result = buildInsertRows({
|
||||
...table,
|
||||
rows: [{ payload: 'text' }, { payload: { a: 1 } }],
|
||||
})
|
||||
expect(result.statement).toBe(
|
||||
'INSERT INTO ANALYTICS.PUBLIC.EVENTS (payload) SELECT PARSE_JSON(C1) AS payload FROM (VALUES (?), (?)) AS v (C1)'
|
||||
)
|
||||
expect(result.bindings).toEqual({
|
||||
'1': { type: 'TEXT', value: '"text"' },
|
||||
'2': { type: 'TEXT', value: '{"a":1}' },
|
||||
})
|
||||
})
|
||||
|
||||
it('builds update-only and upsert MERGE statements with bound values', () => {
|
||||
const params = { ...table, rows: [{ id: 1, name: 'Ada' }], matchColumns: ['ID'] }
|
||||
const update = buildUpdateRows(params)
|
||||
const upsert = buildUpsertRows(params)
|
||||
expect(update.statement).toContain('ON target.id = source.id')
|
||||
expect(update.statement).not.toContain('EQUAL_NULL')
|
||||
expect(update.statement).toContain('WHEN MATCHED THEN UPDATE SET target.name = source.name')
|
||||
expect(update.statement).not.toContain('WHEN NOT MATCHED')
|
||||
expect(upsert.statement).toContain('WHEN NOT MATCHED THEN INSERT (id, name)')
|
||||
expect(upsert.bindings).toEqual({
|
||||
'1': { type: 'FIXED', value: '1' },
|
||||
'2': { type: 'TEXT', value: 'Ada' },
|
||||
})
|
||||
expect(() => buildUpdateRows({ ...params, matchColumns: ['id', 'ID'] })).toThrow('duplicate')
|
||||
expect(() => buildUpdateRows({ ...params, matchColumns: ['id', 'name', 'extra'] })).toThrow(
|
||||
'cannot exceed'
|
||||
)
|
||||
expect(() => buildUpdateRows({ ...params, matchColumns: [7] as never })).toThrow(
|
||||
'column name string'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects match keys a MERGE cannot resolve deterministically', () => {
|
||||
expect(() =>
|
||||
buildUpsertRows({
|
||||
...table,
|
||||
rows: [
|
||||
{ id: 1, name: 'Ada' },
|
||||
{ id: 1, name: 'Grace' },
|
||||
],
|
||||
matchColumns: ['id'],
|
||||
})
|
||||
).toThrow('duplicate match key values for id: number:1')
|
||||
expect(() =>
|
||||
buildUpsertRows({
|
||||
...table,
|
||||
rows: [{ id: null, name: 'Ada' }],
|
||||
matchColumns: ['id'],
|
||||
})
|
||||
).toThrow('match column cannot be null in a row: id')
|
||||
})
|
||||
|
||||
it('encodes composite match keys unambiguously across column boundaries', () => {
|
||||
expect(() =>
|
||||
buildUpsertRows({
|
||||
...table,
|
||||
rows: [
|
||||
{ a: 'p', b: `q${SEPARATOR}string:r`, c: 1 },
|
||||
{ a: `p${SEPARATOR}string:q`, b: 'r', c: 2 },
|
||||
],
|
||||
matchColumns: ['a', 'b'],
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
buildUpsertRows({
|
||||
...table,
|
||||
rows: [
|
||||
{ a: '1', b: 'x', c: 1 },
|
||||
{ a: 1, b: 'x', c: 2 },
|
||||
],
|
||||
matchColumns: ['a', 'b'],
|
||||
})
|
||||
).toThrow('duplicate match key values')
|
||||
})
|
||||
|
||||
it('rejects malformed structured writes', () => {
|
||||
expect(() => buildInsertRows({ ...table, rows: [] })).toThrow('non-empty')
|
||||
expect(() => buildInsertRows({ ...table, rows: [{ id: 1 }, { other: 2 }] })).toThrow(
|
||||
'same columns'
|
||||
)
|
||||
expect(() => buildInsertRows({ ...table, rows: [{ id: 1 }, { ID: 2 }] })).toThrow(
|
||||
'same columns'
|
||||
)
|
||||
expect(() => buildInsertRows({ ...table, rows: [{ id: 1, ID: 2 }] })).toThrow(
|
||||
'duplicate Snowflake column identifiers'
|
||||
)
|
||||
expect(() => buildInsertRows({ ...table, rows: [{ ID: 1, '"ID"': 2 }] })).toThrow(
|
||||
'duplicate Snowflake column identifiers'
|
||||
)
|
||||
expect(() =>
|
||||
buildInsertRows({ ...table, rows: [{ id: Number.MAX_SAFE_INTEGER + 1 }] })
|
||||
).toThrow('safe integers')
|
||||
expect(() =>
|
||||
buildInsertRows({
|
||||
...table,
|
||||
rows: [{ [`"a${SEPARATOR}b"`]: 1 }, { '"a': 9, 'b"': 8 }],
|
||||
})
|
||||
).toThrow('same columns')
|
||||
expect(() => buildInsertRows({ ...table, rows: [{ id: 1, name: 'a' }, { id: 2 }] })).toThrow(
|
||||
'same columns'
|
||||
)
|
||||
})
|
||||
|
||||
it('requires delete filters and binds every filter value', () => {
|
||||
expect(() => buildDeleteRows({ ...table, filters: {} })).toThrow('cannot be empty')
|
||||
expect(() => buildDeleteRows({ ...table, filters: { id: 1, ID: 2 } })).toThrow(
|
||||
'duplicate Snowflake column identifiers'
|
||||
)
|
||||
expect(() => buildDeleteRows({ ...table, filters: { ID: 1, '"ID"': 2 } })).toThrow(
|
||||
'duplicate Snowflake column identifiers'
|
||||
)
|
||||
expect(buildDeleteRows({ ...table, filters: { id: 7, deleted_at: null } })).toEqual({
|
||||
statement: 'DELETE FROM ANALYTICS.PUBLIC.EVENTS WHERE id = ? AND deleted_at IS NULL',
|
||||
bindings: { '1': { type: 'FIXED', value: '7' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('builds COPY INTO with the supported stage and copy options', () => {
|
||||
expect(
|
||||
buildLoadData({
|
||||
...table,
|
||||
stagePath: '@RAW_STAGE/2026/08',
|
||||
fileFormat: 'ANALYTICS.PUBLIC.CSV_FORMAT',
|
||||
pattern: '.*[.]csv',
|
||||
onError: 'CONTINUE',
|
||||
purge: true,
|
||||
force: false,
|
||||
matchByColumnName: 'CASE_INSENSITIVE',
|
||||
}).statement
|
||||
).toBe(
|
||||
"COPY INTO ANALYTICS.PUBLIC.EVENTS FROM @RAW_STAGE/2026/08 PATTERN = '.*[.]csv' FILE_FORMAT = (FORMAT_NAME = 'ANALYTICS.PUBLIC.CSV_FORMAT') ON_ERROR = 'CONTINUE' PURGE = TRUE FORCE = FALSE MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE"
|
||||
)
|
||||
expect(() =>
|
||||
buildLoadData({ ...table, stagePath: '@RAW_STAGE/--', pattern: '.*[.]csv' })
|
||||
).toThrow('SQL line comment')
|
||||
expect(() => buildLoadData({ ...table, stagePath: '@RAW_STAGE/a--b/c' })).toThrow(
|
||||
'SQL line comment'
|
||||
)
|
||||
expect(() => buildLoadData({ ...table, stagePath: '@stage/path; DROP TABLE x' })).toThrow(
|
||||
'stagePath'
|
||||
)
|
||||
expect(
|
||||
buildLoadData({
|
||||
...table,
|
||||
stagePath: '@"Raw Stage"/daily',
|
||||
fileFormat: 'ANALYTICS.PUBLIC."CSV Format"',
|
||||
}).statement
|
||||
).toContain(
|
||||
`FROM @"Raw Stage"/daily FILE_FORMAT = (FORMAT_NAME = 'ANALYTICS.PUBLIC."CSV Format"')`
|
||||
)
|
||||
expect(
|
||||
buildLoadData({
|
||||
...table,
|
||||
stagePath: '@ANALYTICS.PUBLIC.%EVENTS/daily',
|
||||
onError: 'SKIP_FILE_10',
|
||||
}).statement
|
||||
).toContain("FROM @ANALYTICS.PUBLIC.%EVENTS/daily ON_ERROR = 'SKIP_FILE_10'")
|
||||
expect(
|
||||
buildLoadData({ ...table, stagePath: '@RAW_STAGE', onError: 'SKIP_FILE_25%' }).statement
|
||||
).toContain("ON_ERROR = 'SKIP_FILE_25%'")
|
||||
expect(
|
||||
buildLoadData({ ...table, stagePath: '@RAW_STAGE', onError: 'SKIP_FILE_25%' }).statement
|
||||
).toContain("ON_ERROR = 'SKIP_FILE_25%'")
|
||||
expect(() =>
|
||||
buildLoadData({ ...table, stagePath: '@RAW_STAGE', onError: 'SKIP_FILE_2.5%' })
|
||||
).toThrow('onError')
|
||||
expect(() =>
|
||||
buildLoadData({ ...table, stagePath: '@RAW_STAGE', onError: 'SKIP_FILE_0%' })
|
||||
).toThrow('onError')
|
||||
})
|
||||
|
||||
it('builds warehouse statements', () => {
|
||||
expect(buildListWarehouses('ETL%').statement).toBe("SHOW WAREHOUSES LIKE 'ETL%'")
|
||||
expect(buildGetWarehouse({ ...context, warehouseName: 'ETL_WH' }).statement).toBe(
|
||||
`SHOW WAREHOUSES ->> SELECT * FROM $1 WHERE "name" = 'ETL_WH'`
|
||||
)
|
||||
expect(buildGetWarehouse({ ...context, warehouseName: `"etl_%'s"` }).statement).toBe(
|
||||
`SHOW WAREHOUSES ->> SELECT * FROM $1 WHERE "name" = 'etl_%''s'`
|
||||
)
|
||||
expect(buildResumeWarehouse({ ...context, warehouseName: 'ETL_WH' }).statement).toBe(
|
||||
'ALTER WAREHOUSE ETL_WH RESUME IF SUSPENDED'
|
||||
)
|
||||
expect(buildSuspendWarehouse({ ...context, warehouseName: 'ETL_WH' }).statement).toBe(
|
||||
'ALTER WAREHOUSE ETL_WH SUSPEND'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds task definition and execution statements', () => {
|
||||
const task = { ...context, database: 'ANALYTICS', schema: 'PUBLIC', taskName: 'DAILY_LOAD' }
|
||||
expect(buildListTasks({ ...task, limit: 25, nameLike: 'DAILY%' }).statement).toBe(
|
||||
"SHOW TASKS LIKE 'DAILY%' IN SCHEMA ANALYTICS.PUBLIC LIMIT 25"
|
||||
)
|
||||
expect(buildGetTask(task).statement).toBe('DESCRIBE TASK ANALYTICS.PUBLIC.DAILY_LOAD')
|
||||
expect(buildRunTask({ ...task, retryLast: true }).statement).toBe(
|
||||
'EXECUTE TASK ANALYTICS.PUBLIC.DAILY_LOAD RETRY LAST'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds bounded task history, run lookup, cancellation, and output statements', () => {
|
||||
const history = buildListTaskRuns({
|
||||
...context,
|
||||
taskName: 'DAILY_LOAD',
|
||||
startTime: '2026-08-01T00:00:00Z',
|
||||
errorOnly: true,
|
||||
limit: 50,
|
||||
})
|
||||
expect(history.statement).toContain('RESULT_LIMIT => 50, ERROR_ONLY => TRUE')
|
||||
expect(history.bindings).toEqual({ '1': { type: 'TEXT', value: 'DAILY_LOAD' } })
|
||||
const run = buildGetTaskRun({
|
||||
...context,
|
||||
queryId,
|
||||
taskName: 'DAILY_LOAD',
|
||||
startTime: '2026-08-01T00:00:00Z',
|
||||
})
|
||||
expect(run.statement).toContain('TASK_NAME => ?')
|
||||
expect(run.statement).toContain('WHERE QUERY_ID = ?')
|
||||
expect(run.bindings).toEqual({
|
||||
'1': { type: 'TEXT', value: 'DAILY_LOAD' },
|
||||
'2': { type: 'TEXT', value: queryId },
|
||||
})
|
||||
expect(() =>
|
||||
buildListTaskRuns({ ...context, taskName: 'ANALYTICS.PUBLIC.DAILY_LOAD' })
|
||||
).toThrow('unqualified task name')
|
||||
|
||||
/**
|
||||
* TASK_HISTORY silently drops a bind in its time-range arguments, so the window has to
|
||||
* reach Snowflake as a literal or the filter becomes a no-op with no error.
|
||||
*/
|
||||
const window = buildListTaskRuns({
|
||||
...context,
|
||||
startTime: '2026-08-01T00:00:00Z',
|
||||
endTime: '2026-08-02T00:00:00Z',
|
||||
})
|
||||
expect(window.statement).toContain(
|
||||
"SCHEDULED_TIME_RANGE_START => TO_TIMESTAMP_LTZ('2026-08-01T00:00:00Z')"
|
||||
)
|
||||
expect(window.statement).toContain(
|
||||
"SCHEDULED_TIME_RANGE_END => TO_TIMESTAMP_LTZ('2026-08-02T00:00:00Z')"
|
||||
)
|
||||
expect(window.statement).not.toContain('TO_TIMESTAMP_LTZ(?)')
|
||||
expect(window.bindings).toEqual({})
|
||||
expect(() => buildListTaskRuns({ ...context, startTime: 'not-a-timestamp' })).toThrow(
|
||||
'startTime must be an ISO-8601 timestamp'
|
||||
)
|
||||
expect(() => buildGetTaskRun({ ...context, queryId, endTime: 'nope' })).toThrow(
|
||||
'endTime must be an ISO-8601 timestamp'
|
||||
)
|
||||
expect(() =>
|
||||
buildGetTaskRun({ ...context, queryId, taskName: 'ANALYTICS.PUBLIC.DAILY_LOAD' })
|
||||
).toThrow('unqualified task name')
|
||||
expect(buildListTaskRuns({ ...context, taskName: 'daily_load' }).bindings).toMatchObject({
|
||||
'1': { type: 'TEXT', value: 'DAILY_LOAD' },
|
||||
})
|
||||
expect(buildListTaskRuns({ ...context, taskName: '"my.task"' }).bindings).toMatchObject({
|
||||
'1': { type: 'TEXT', value: 'my.task' },
|
||||
})
|
||||
expect(buildGetTaskRun({ ...context, queryId, taskName: '"my.task"' }).bindings).toMatchObject({
|
||||
'1': { type: 'TEXT', value: 'my.task' },
|
||||
})
|
||||
expect(buildCancelTaskRun({ ...context, queryId }).statement).toContain('SYSTEM$CANCEL_QUERY')
|
||||
expect(buildGetTaskRunOutput({ ...context, queryId }).statement).toContain('RESULT_SCAN')
|
||||
expect(() => buildGetTaskRun({ ...context, queryId: "x' OR TRUE" })).toThrow('UUID')
|
||||
})
|
||||
|
||||
it('builds bound schema introspection and typed procedure calls', () => {
|
||||
const schema = buildIntrospectSchema({
|
||||
...context,
|
||||
database: 'ANALYTICS',
|
||||
schema: 'PUBLIC',
|
||||
table: 'EVENTS',
|
||||
})
|
||||
expect(schema.statement).toContain('ANALYTICS.INFORMATION_SCHEMA.COLUMNS')
|
||||
expect(schema.statement).toContain("t.TABLE_TYPE NOT IN ('VIEW', 'MATERIALIZED VIEW')")
|
||||
expect(schema.statement).not.toContain("t.TABLE_TYPE = 'BASE TABLE'")
|
||||
expect(schema.bindings).toEqual({
|
||||
'1': { type: 'TEXT', value: 'PUBLIC' },
|
||||
'2': { type: 'TEXT', value: 'EVENTS' },
|
||||
})
|
||||
expect(
|
||||
buildIntrospectSchema({
|
||||
...context,
|
||||
database: 'analytics',
|
||||
schema: 'public',
|
||||
table: 'events',
|
||||
}).bindings
|
||||
).toEqual({
|
||||
'1': { type: 'TEXT', value: 'PUBLIC' },
|
||||
'2': { type: 'TEXT', value: 'EVENTS' },
|
||||
})
|
||||
expect(
|
||||
buildIntrospectSchema({
|
||||
...context,
|
||||
database: '"Analytics DB"',
|
||||
schema: '"Mixed Schema"',
|
||||
table: '"events"',
|
||||
}).bindings
|
||||
).toEqual({
|
||||
'1': { type: 'TEXT', value: 'Mixed Schema' },
|
||||
'2': { type: 'TEXT', value: 'events' },
|
||||
})
|
||||
|
||||
expect(
|
||||
buildCallProcedure({
|
||||
...context,
|
||||
database: 'ANALYTICS',
|
||||
schema: 'PUBLIC',
|
||||
procedureName: 'REFRESH_MODEL',
|
||||
procedureArguments: [
|
||||
{ type: 'TEXT', value: 'daily' },
|
||||
{ type: 'BOOLEAN', value: 'true' },
|
||||
],
|
||||
})
|
||||
).toEqual({
|
||||
statement: 'CALL ANALYTICS.PUBLIC.REFRESH_MODEL(?, ?)',
|
||||
bindings: {
|
||||
'1': { type: 'TEXT', value: 'daily' },
|
||||
'2': { type: 'BOOLEAN', value: 'true' },
|
||||
},
|
||||
})
|
||||
expect(() =>
|
||||
buildCallProcedure({
|
||||
...context,
|
||||
database: 'ANALYTICS',
|
||||
schema: 'PUBLIC',
|
||||
procedureName: 'REFRESH_MODEL',
|
||||
procedureArguments: { type: 'TEXT', value: 'x' } as never,
|
||||
})
|
||||
).toThrow('JSON array')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,655 @@
|
||||
import { isValidUuid } from '@sim/utils/id'
|
||||
import { isPlainRecord } from '@sim/utils/object'
|
||||
import {
|
||||
SNOWFLAKE_BINDING_TYPES,
|
||||
type SnowflakeBinding,
|
||||
type SnowflakeCallProcedureParams,
|
||||
type SnowflakeCancelTaskRunParams,
|
||||
type SnowflakeDeleteRowsParams,
|
||||
type SnowflakeGetTaskRunOutputParams,
|
||||
type SnowflakeGetTaskRunParams,
|
||||
type SnowflakeInsertRowsParams,
|
||||
type SnowflakeIntrospectSchemaParams,
|
||||
type SnowflakeListTaskRunsParams,
|
||||
type SnowflakeListTasksParams,
|
||||
type SnowflakeLoadDataParams,
|
||||
type SnowflakeRunTaskParams,
|
||||
type SnowflakeTaskParams,
|
||||
type SnowflakeUpdateRowsParams,
|
||||
type SnowflakeWarehouseParams,
|
||||
} from '@/tools/snowflake/types'
|
||||
import { normalizeMaxRows, type SnowflakeStatementSpec } from '@/tools/snowflake/utils'
|
||||
|
||||
const UNQUOTED_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/
|
||||
const QUOTED_IDENTIFIER = /^"(?:[^"]|"")+"$/
|
||||
|
||||
export function identifier(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (UNQUOTED_IDENTIFIER.test(trimmed) || QUOTED_IDENTIFIER.test(trimmed)) return trimmed
|
||||
throw new Error(`Invalid Snowflake identifier: ${value}`)
|
||||
}
|
||||
|
||||
export function qualifiedIdentifier(...parts: string[]): string {
|
||||
if (parts.length === 0 || parts.some((part) => !part?.trim())) {
|
||||
throw new Error('Snowflake identifier parts cannot be empty')
|
||||
}
|
||||
return parts.map(identifier).join('.')
|
||||
}
|
||||
|
||||
function splitQualifiedIdentifier(value: string): string[] {
|
||||
const parts: string[] = []
|
||||
let start = 0
|
||||
let quoted = false
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (value[index] === '"') {
|
||||
if (quoted && value[index + 1] === '"') {
|
||||
index += 1
|
||||
} else {
|
||||
quoted = !quoted
|
||||
}
|
||||
} else if (value[index] === '.' && !quoted) {
|
||||
parts.push(value.slice(start, index))
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
if (quoted) throw new Error(`Invalid Snowflake identifier: ${value}`)
|
||||
parts.push(value.slice(start))
|
||||
return parts
|
||||
}
|
||||
|
||||
function qualifiedIdentifierValue(value: string): string {
|
||||
return qualifiedIdentifier(...splitQualifiedIdentifier(value))
|
||||
}
|
||||
|
||||
function identifierKey(value: string): string {
|
||||
const valid = identifier(value)
|
||||
if (!valid.startsWith('"')) return `resolved:${valid.toUpperCase()}`
|
||||
const exactName = resolvedIdentifierName(valid)
|
||||
return UNQUOTED_IDENTIFIER.test(exactName) && exactName === exactName.toUpperCase()
|
||||
? `resolved:${exactName}`
|
||||
: `quoted:${exactName}`
|
||||
}
|
||||
|
||||
function resolvedIdentifierName(value: string): string {
|
||||
const valid = identifier(value)
|
||||
return valid.startsWith('"') ? valid.slice(1, -1).replaceAll('""', '"') : valid.toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Snowflake processes backslash escape sequences inside single-quoted string constants, so both the
|
||||
* backslash and the quote have to be doubled. Quote doubling never introduces a backslash, so the
|
||||
* two passes commute; the order below is not load-bearing.
|
||||
*/
|
||||
function stringLiteral(value: string): string {
|
||||
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
function requireQueryId(queryId: string): string {
|
||||
const trimmed = queryId.trim()
|
||||
if (!isValidUuid(trimmed)) throw new Error('queryId must be a Snowflake UUID')
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Snowflake recommends limiting query text to 1 MB per statement, and that limit explicitly covers
|
||||
* values supplied through bindings. Statements above it still execute but are truncated before the
|
||||
* metadata store persists them, so they can no longer be retried or inspected.
|
||||
*
|
||||
* The budget is a byte budget, so it is measured against the UTF-8 encoding rather than the
|
||||
* JavaScript string length — a multi-byte string is up to 3x longer on the wire than in code units.
|
||||
*/
|
||||
const MAX_BOUND_VALUE_BYTES = 1_000_000
|
||||
|
||||
const BULK_INGEST_HINT = 'stage the data and use snowflake_load_data for bulk ingest'
|
||||
|
||||
function assertBoundBytesWithinBudget(boundBytes: number): void {
|
||||
if (boundBytes > MAX_BOUND_VALUE_BYTES) {
|
||||
throw new Error(
|
||||
`Snowflake bound values exceed the ${MAX_BOUND_VALUE_BYTES} byte statement budget; send fewer rows per call or ${BULK_INGEST_HINT}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBindings(
|
||||
input?: Record<string, SnowflakeBinding>
|
||||
): Record<string, SnowflakeBinding> | undefined {
|
||||
if (input === undefined) return undefined
|
||||
if (!isPlainRecord(input)) {
|
||||
throw new Error('bindings must be a JSON object keyed by 1-based positions')
|
||||
}
|
||||
const normalized: Record<string, SnowflakeBinding> = {}
|
||||
let hasBindings = false
|
||||
let boundBytes = 0
|
||||
for (const position in input) {
|
||||
if (!Object.hasOwn(input, position)) continue
|
||||
hasBindings = true
|
||||
const binding = input[position]
|
||||
if (!/^[1-9][0-9]*$/.test(position)) {
|
||||
throw new Error('binding keys must be positive integer positions')
|
||||
}
|
||||
if (!isPlainRecord(binding)) {
|
||||
throw new Error(`binding ${position} must contain type and value`)
|
||||
}
|
||||
if (!SNOWFLAKE_BINDING_TYPES.includes(binding.type)) {
|
||||
throw new Error(`Unsupported Snowflake binding type: ${binding.type}`)
|
||||
}
|
||||
if (typeof binding.value !== 'string') {
|
||||
throw new Error(`binding ${position} value must be a string`)
|
||||
}
|
||||
boundBytes += Buffer.byteLength(binding.value, 'utf8')
|
||||
assertBoundBytesWithinBudget(boundBytes)
|
||||
normalized[position] = { type: binding.type, value: binding.value }
|
||||
}
|
||||
return hasBindings ? normalized : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured writes build a single statement holding every row, so the row count is capped well
|
||||
* below the byte budget to keep a typical write far away from the 1 MB statement recommendation.
|
||||
*/
|
||||
const MAX_WRITE_ROWS = 1_000
|
||||
|
||||
class BindingsBuilder {
|
||||
readonly bindings: Record<string, SnowflakeBinding> = {}
|
||||
private position = 0
|
||||
private boundBytes = 0
|
||||
|
||||
private addBinding(type: SnowflakeBinding['type'], value: string): string {
|
||||
this.boundBytes += Buffer.byteLength(value, 'utf8')
|
||||
assertBoundBytesWithinBudget(this.boundBytes)
|
||||
this.position += 1
|
||||
const key = String(this.position)
|
||||
this.bindings[key] = { type, value }
|
||||
return '?'
|
||||
}
|
||||
|
||||
/** Binds a semi-structured value as text so the statement can wrap it in PARSE_JSON. */
|
||||
addJson(value: unknown): string {
|
||||
return this.addBinding('TEXT', JSON.stringify(value) ?? 'null')
|
||||
}
|
||||
|
||||
add(value: unknown): string {
|
||||
if (value === null || value === undefined) return 'NULL'
|
||||
if (typeof value === 'boolean') {
|
||||
return this.addBinding('BOOLEAN', String(value))
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) throw new Error('Snowflake row values must be finite numbers')
|
||||
if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
||||
throw new Error(
|
||||
'Snowflake integer row values must be JavaScript safe integers; pass exact large numerics as strings or typed Execute SQL bindings'
|
||||
)
|
||||
}
|
||||
return this.addBinding(Number.isInteger(value) ? 'FIXED' : 'REAL', String(value))
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return this.addBinding('TEXT', value)
|
||||
}
|
||||
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
|
||||
this.addBinding('TEXT', JSON.stringify(value))
|
||||
return 'PARSE_JSON(?)'
|
||||
}
|
||||
throw new Error(`Unsupported Snowflake row value type: ${typeof value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateRows(rows: Array<Record<string, unknown>>): string[] {
|
||||
if (!Array.isArray(rows) || rows.length === 0) throw new Error('rows must be a non-empty array')
|
||||
if (rows.length > MAX_WRITE_ROWS) {
|
||||
throw new Error(
|
||||
`rows cannot exceed ${MAX_WRITE_ROWS} per call; send the rows in smaller batches or ${BULK_INGEST_HINT}`
|
||||
)
|
||||
}
|
||||
const columns = Object.keys(rows[0] ?? {})
|
||||
if (columns.length === 0) throw new Error('rows must contain at least one column')
|
||||
const identifierKeys = new Set<string>()
|
||||
for (const column of columns) {
|
||||
const key = identifierKey(column)
|
||||
if (identifierKeys.has(key)) {
|
||||
throw new Error(`rows contain duplicate Snowflake column identifiers: ${column}`)
|
||||
}
|
||||
identifierKeys.add(key)
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (!row || Array.isArray(row) || typeof row !== 'object') {
|
||||
throw new Error('every row must be a JSON object')
|
||||
}
|
||||
if (
|
||||
Object.keys(row).length !== columns.length ||
|
||||
!columns.every((column) => Object.hasOwn(row, column))
|
||||
) {
|
||||
throw new Error('every row must contain the same columns')
|
||||
}
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
function isSemiStructured(value: unknown): boolean {
|
||||
return Array.isArray(value) || (typeof value === 'object' && value !== null)
|
||||
}
|
||||
|
||||
interface RowsSource {
|
||||
/** `(?, ?), (?, NULL)` — the literal rows of the derived table. */
|
||||
values: string
|
||||
/** Projection that restores semi-structured columns, aliased back to the row column names. */
|
||||
selectList: string
|
||||
/** Column aliases of the derived table, in row column order. */
|
||||
aliases: string[]
|
||||
/** True when any column had to be routed through PARSE_JSON. */
|
||||
hasSemiStructured: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the bound row literals plus the projection that converts them back to their target types.
|
||||
*
|
||||
* Snowflake rejects semi-structured expressions inside a VALUES clause and documents INSERT ... SELECT
|
||||
* as the supported alternative, so PARSE_JSON is lifted out of the VALUES rows into a projecting
|
||||
* SELECT. A column is converted whole-column rather than per-value so that a mixed column still
|
||||
* produces one consistent VARIANT type.
|
||||
*/
|
||||
function buildRowsSource(
|
||||
rows: Array<Record<string, unknown>>,
|
||||
columns: string[],
|
||||
binds: BindingsBuilder
|
||||
): RowsSource {
|
||||
const semiStructured = new Set(
|
||||
columns.filter((column) => rows.some((row) => isSemiStructured(row[column])))
|
||||
)
|
||||
const aliases = columns.map((_, index) => `C${index + 1}`)
|
||||
const values = rows
|
||||
.map(
|
||||
(row) =>
|
||||
`(${columns
|
||||
.map((column) => {
|
||||
const value = row[column]
|
||||
if (value === null || value === undefined) return 'NULL'
|
||||
return semiStructured.has(column) ? binds.addJson(value) : binds.add(value)
|
||||
})
|
||||
.join(', ')})`
|
||||
)
|
||||
.join(', ')
|
||||
const selectList = columns
|
||||
.map((column, index) => {
|
||||
const alias = aliases[index]
|
||||
const expression = semiStructured.has(column) ? `PARSE_JSON(${alias})` : alias
|
||||
return `${expression} AS ${identifier(column)}`
|
||||
})
|
||||
.join(', ')
|
||||
return { values, selectList, aliases, hasSemiStructured: semiStructured.size > 0 }
|
||||
}
|
||||
|
||||
function valuesSource(source: RowsSource): string {
|
||||
return `(SELECT ${source.selectList} FROM (VALUES ${source.values}) AS v (${source.aliases.join(', ')})) AS source`
|
||||
}
|
||||
|
||||
export function buildInsertRows(params: SnowflakeInsertRowsParams): SnowflakeStatementSpec {
|
||||
const columns = validateRows(params.rows)
|
||||
const binds = new BindingsBuilder()
|
||||
const source = buildRowsSource(params.rows, columns, binds)
|
||||
const target = qualifiedIdentifier(params.database, params.schema, params.table)
|
||||
const columnList = columns.map(identifier).join(', ')
|
||||
const statement = source.hasSemiStructured
|
||||
? `INSERT INTO ${target} (${columnList}) SELECT ${source.selectList} FROM (VALUES ${source.values}) AS v (${source.aliases.join(', ')})`
|
||||
: `INSERT INTO ${target} (${columnList}) VALUES ${source.values}`
|
||||
return { statement, bindings: binds.bindings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects match keys the MERGE cannot resolve deterministically.
|
||||
*
|
||||
* A NULL match value never satisfies the equality join, so such a row would silently fall through
|
||||
* to the not-matched branch. Two source rows sharing a match key join the same target row, which
|
||||
* Snowflake reports as a nondeterministic merge — with ERROR_ON_NONDETERMINISTIC_MERGE at its TRUE
|
||||
* default the whole statement fails server-side without naming the offending key.
|
||||
*/
|
||||
function assertDistinctMatchKeys(
|
||||
rows: Array<Record<string, unknown>>,
|
||||
matchColumns: string[]
|
||||
): void {
|
||||
const seen = new Set<string>()
|
||||
for (const row of rows) {
|
||||
const parts: string[] = []
|
||||
const display: string[] = []
|
||||
for (const column of matchColumns) {
|
||||
const value = row[column]
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(`match column cannot be null in a row: ${column}`)
|
||||
}
|
||||
parts.push(JSON.stringify(typeof value === 'object' ? ['object', value] : String(value)))
|
||||
display.push(
|
||||
`${typeof value}:${typeof value === 'object' ? JSON.stringify(value) : String(value)}`
|
||||
)
|
||||
}
|
||||
const key = parts.join('\u0000')
|
||||
if (seen.has(key)) {
|
||||
throw new Error(
|
||||
`rows contain duplicate match key values for ${matchColumns.join(', ')}: ${display.join(', ')}`
|
||||
)
|
||||
}
|
||||
seen.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
function buildMerge(params: SnowflakeUpdateRowsParams, upsert: boolean): SnowflakeStatementSpec {
|
||||
const columns = validateRows(params.rows)
|
||||
if (!Array.isArray(params.matchColumns) || params.matchColumns.length === 0) {
|
||||
throw new Error('matchColumns must be a non-empty array')
|
||||
}
|
||||
if (params.matchColumns.length > columns.length) {
|
||||
throw new Error('matchColumns cannot exceed the number of row columns')
|
||||
}
|
||||
const columnsByIdentifier = new Map(columns.map((column) => [identifierKey(column), column]))
|
||||
const matchColumns: string[] = []
|
||||
const seenMatchColumns = new Set<string>()
|
||||
for (const matchColumn of params.matchColumns) {
|
||||
if (typeof matchColumn !== 'string') {
|
||||
throw new Error('every matchColumns entry must be a column name string')
|
||||
}
|
||||
const key = identifierKey(matchColumn)
|
||||
if (seenMatchColumns.has(key)) {
|
||||
throw new Error(`matchColumns contains a duplicate Snowflake identifier: ${matchColumn}`)
|
||||
}
|
||||
seenMatchColumns.add(key)
|
||||
const column = columnsByIdentifier.get(key)
|
||||
if (!column) throw new Error(`match column is missing from rows: ${matchColumn}`)
|
||||
matchColumns.push(column)
|
||||
}
|
||||
const updateColumns = columns.filter((column) => !matchColumns.includes(column))
|
||||
if (updateColumns.length === 0) throw new Error('rows must contain a column to update')
|
||||
|
||||
assertDistinctMatchKeys(params.rows, matchColumns)
|
||||
|
||||
const binds = new BindingsBuilder()
|
||||
const source = valuesSource(buildRowsSource(params.rows, columns, binds))
|
||||
const target = qualifiedIdentifier(params.database, params.schema, params.table)
|
||||
const on = matchColumns
|
||||
.map((column) => `target.${identifier(column)} = source.${identifier(column)}`)
|
||||
.join(' AND ')
|
||||
const update = updateColumns
|
||||
.map((column) => `target.${identifier(column)} = source.${identifier(column)}`)
|
||||
.join(', ')
|
||||
const insert = upsert
|
||||
? ` WHEN NOT MATCHED THEN INSERT (${columns.map(identifier).join(', ')}) VALUES (${columns.map((column) => `source.${identifier(column)}`).join(', ')})`
|
||||
: ''
|
||||
return {
|
||||
statement: `MERGE INTO ${target} AS target USING ${source} ON ${on} WHEN MATCHED THEN UPDATE SET ${update}${insert}`,
|
||||
bindings: binds.bindings,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUpdateRows(params: SnowflakeUpdateRowsParams): SnowflakeStatementSpec {
|
||||
return buildMerge(params, false)
|
||||
}
|
||||
|
||||
export function buildUpsertRows(params: SnowflakeUpdateRowsParams): SnowflakeStatementSpec {
|
||||
return buildMerge(params, true)
|
||||
}
|
||||
|
||||
export function buildDeleteRows(params: SnowflakeDeleteRowsParams): SnowflakeStatementSpec {
|
||||
if (!params.filters || Array.isArray(params.filters) || typeof params.filters !== 'object') {
|
||||
throw new Error('filters must be a JSON object')
|
||||
}
|
||||
const filters = Object.entries(params.filters)
|
||||
if (filters.length === 0) throw new Error('filters cannot be empty')
|
||||
const identifierKeys = new Set<string>()
|
||||
for (const [column] of filters) {
|
||||
const key = identifierKey(column)
|
||||
if (identifierKeys.has(key)) {
|
||||
throw new Error(`filters contain duplicate Snowflake column identifiers: ${column}`)
|
||||
}
|
||||
identifierKeys.add(key)
|
||||
}
|
||||
const binds = new BindingsBuilder()
|
||||
const where = filters
|
||||
.map(([column, value]) =>
|
||||
value === null || value === undefined
|
||||
? `${identifier(column)} IS NULL`
|
||||
: `${identifier(column)} = ${binds.add(value)}`
|
||||
)
|
||||
.join(' AND ')
|
||||
return {
|
||||
statement: `DELETE FROM ${qualifiedIdentifier(params.database, params.schema, params.table)} WHERE ${where}`,
|
||||
bindings: binds.bindings,
|
||||
}
|
||||
}
|
||||
|
||||
function stagePath(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed.startsWith('@')) {
|
||||
throw new Error('stagePath must be a simple Snowflake stage reference such as @stage/path')
|
||||
}
|
||||
let slash = -1
|
||||
let quoted = false
|
||||
for (let index = 1; index < trimmed.length; index += 1) {
|
||||
if (trimmed[index] === '"') {
|
||||
if (quoted && trimmed[index + 1] === '"') index += 1
|
||||
else quoted = !quoted
|
||||
} else if (trimmed[index] === '/' && !quoted) {
|
||||
slash = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if (quoted) throw new Error('stagePath contains an unterminated quoted identifier')
|
||||
const reference = trimmed.slice(1, slash === -1 ? undefined : slash)
|
||||
const path = slash === -1 ? '' : trimmed.slice(slash)
|
||||
if (!/^\/[A-Za-z0-9_./=+@%$-]*$/.test(path) && path !== '') {
|
||||
throw new Error('stagePath contains unsupported path characters')
|
||||
}
|
||||
if (path.includes('--')) {
|
||||
throw new Error('stagePath cannot contain a SQL line comment')
|
||||
}
|
||||
if (reference === '~') return `@~${path}`
|
||||
if (reference.startsWith('%')) return `@%${identifier(reference.slice(1))}${path}`
|
||||
const referenceParts = splitQualifiedIdentifier(reference)
|
||||
const finalPart = referenceParts.at(-1)
|
||||
if (finalPart?.startsWith('%')) {
|
||||
const namespace = referenceParts.slice(0, -1)
|
||||
if (namespace.length === 0) throw new Error('stagePath contains an invalid table stage')
|
||||
return `@${qualifiedIdentifier(...namespace)}.%${identifier(finalPart.slice(1))}${path}`
|
||||
}
|
||||
return `@${qualifiedIdentifierValue(reference)}${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* COPY INTO's grammar is positional up to the copy options: FROM, then FILES, then PATTERN, then
|
||||
* FILE_FORMAT, then copyOptions. Only the copy options themselves (ON_ERROR, PURGE, FORCE,
|
||||
* MATCH_BY_COLUMN_NAME) are order-free, and they must all follow FILE_FORMAT.
|
||||
*/
|
||||
export function buildLoadData(params: SnowflakeLoadDataParams): SnowflakeStatementSpec {
|
||||
const clauses = [
|
||||
`COPY INTO ${qualifiedIdentifier(params.database, params.schema, params.table)}`,
|
||||
`FROM ${stagePath(params.stagePath)}`,
|
||||
]
|
||||
if (params.pattern?.trim()) clauses.push(`PATTERN = ${stringLiteral(params.pattern.trim())}`)
|
||||
if (params.fileFormat?.trim()) {
|
||||
const formatName = qualifiedIdentifierValue(params.fileFormat.trim())
|
||||
clauses.push(`FILE_FORMAT = (FORMAT_NAME = ${stringLiteral(formatName)})`)
|
||||
}
|
||||
if (params.onError) {
|
||||
const percentMatch = params.onError.match(/^SKIP_FILE_([0-9]+)%$/)
|
||||
const percentage = percentMatch ? Number(percentMatch[1]) : undefined
|
||||
const validOnError =
|
||||
['ABORT_STATEMENT', 'CONTINUE', 'SKIP_FILE'].includes(params.onError) ||
|
||||
/^SKIP_FILE_[1-9][0-9]*$/.test(params.onError) ||
|
||||
(percentage !== undefined && percentage > 0 && percentage <= 100)
|
||||
if (!validOnError) {
|
||||
throw new Error('Unsupported COPY INTO onError value')
|
||||
}
|
||||
clauses.push(`ON_ERROR = ${stringLiteral(params.onError)}`)
|
||||
}
|
||||
if (params.purge !== undefined) clauses.push(`PURGE = ${params.purge ? 'TRUE' : 'FALSE'}`)
|
||||
if (params.force !== undefined) clauses.push(`FORCE = ${params.force ? 'TRUE' : 'FALSE'}`)
|
||||
if (params.matchByColumnName) {
|
||||
if (!['CASE_SENSITIVE', 'CASE_INSENSITIVE', 'NONE'].includes(params.matchByColumnName)) {
|
||||
throw new Error('Unsupported MATCH_BY_COLUMN_NAME value')
|
||||
}
|
||||
clauses.push(`MATCH_BY_COLUMN_NAME = ${params.matchByColumnName}`)
|
||||
}
|
||||
return { statement: clauses.join(' ') }
|
||||
}
|
||||
|
||||
export function buildListWarehouses(nameLike?: string): SnowflakeStatementSpec {
|
||||
return {
|
||||
statement: `SHOW WAREHOUSES${nameLike?.trim() ? ` LIKE ${stringLiteral(nameLike.trim())}` : ''}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGetWarehouse(params: SnowflakeWarehouseParams): SnowflakeStatementSpec {
|
||||
const warehouseName = resolvedIdentifierName(params.warehouseName)
|
||||
return {
|
||||
statement: `SHOW WAREHOUSES ->> SELECT * FROM $1 WHERE "name" = ${stringLiteral(warehouseName)}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildResumeWarehouse(params: SnowflakeWarehouseParams): SnowflakeStatementSpec {
|
||||
return { statement: `ALTER WAREHOUSE ${identifier(params.warehouseName)} RESUME IF SUSPENDED` }
|
||||
}
|
||||
|
||||
export function buildSuspendWarehouse(params: SnowflakeWarehouseParams): SnowflakeStatementSpec {
|
||||
return { statement: `ALTER WAREHOUSE ${identifier(params.warehouseName)} SUSPEND` }
|
||||
}
|
||||
|
||||
export function buildListTasks(params: SnowflakeListTasksParams): SnowflakeStatementSpec {
|
||||
const limit = normalizeMaxRows(params.limit)
|
||||
return {
|
||||
statement: `SHOW TASKS${params.nameLike?.trim() ? ` LIKE ${stringLiteral(params.nameLike.trim())}` : ''} IN SCHEMA ${qualifiedIdentifier(params.database, params.schema)} LIMIT ${limit}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGetTask(params: SnowflakeTaskParams): SnowflakeStatementSpec {
|
||||
return {
|
||||
statement: `DESCRIBE TASK ${qualifiedIdentifier(params.database, params.schema, params.taskName)}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRunTask(params: SnowflakeRunTaskParams): SnowflakeStatementSpec {
|
||||
return {
|
||||
statement: `EXECUTE TASK ${qualifiedIdentifier(params.database, params.schema, params.taskName)}${params.retryLast ? ' RETRY LAST' : ''}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TASK_HISTORY documents that "Only non-qualified task names are supported" — a qualified name
|
||||
* yields an empty result set rather than an error. The name is also resolved the way Snowflake
|
||||
* stored it, so an unquoted name is upper-cased and a quoted one keeps its exact spelling.
|
||||
*/
|
||||
/**
|
||||
* TASK_HISTORY time-range arguments are `constant_expr` and are not in BCR-1410's bind
|
||||
* allowlist for this function, which covers only RESULT_LIMIT and TASK_NAME. A bind here
|
||||
* is silently dropped rather than rejected, which would turn the requested window into a
|
||||
* no-op, so the timestamp is emitted as a literal instead.
|
||||
*/
|
||||
function taskHistoryTimestamp(value: string, field: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (Number.isNaN(Date.parse(trimmed))) {
|
||||
throw new Error(`${field} must be an ISO-8601 timestamp within the last seven days`)
|
||||
}
|
||||
return `TO_TIMESTAMP_LTZ(${stringLiteral(trimmed)})`
|
||||
}
|
||||
|
||||
function unqualifiedTaskName(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (splitQualifiedIdentifier(trimmed).length > 1) {
|
||||
throw new Error(
|
||||
`taskName must be an unqualified task name without a database or schema prefix: ${trimmed}`
|
||||
)
|
||||
}
|
||||
return resolvedIdentifierName(trimmed)
|
||||
}
|
||||
|
||||
export function buildListTaskRuns(params: SnowflakeListTaskRunsParams): SnowflakeStatementSpec {
|
||||
const limit = normalizeMaxRows(params.limit)
|
||||
const binds = new BindingsBuilder()
|
||||
const args = [`RESULT_LIMIT => ${limit}`, `ERROR_ONLY => ${params.errorOnly ? 'TRUE' : 'FALSE'}`]
|
||||
if (params.taskName?.trim()) {
|
||||
args.push(`TASK_NAME => ${binds.add(unqualifiedTaskName(params.taskName))}`)
|
||||
}
|
||||
if (params.startTime?.trim()) {
|
||||
args.push(
|
||||
`SCHEDULED_TIME_RANGE_START => ${taskHistoryTimestamp(params.startTime, 'startTime')}`
|
||||
)
|
||||
}
|
||||
if (params.endTime?.trim()) {
|
||||
args.push(`SCHEDULED_TIME_RANGE_END => ${taskHistoryTimestamp(params.endTime, 'endTime')}`)
|
||||
}
|
||||
return {
|
||||
statement: `SELECT * FROM TABLE(SNOWFLAKE.INFORMATION_SCHEMA.TASK_HISTORY(${args.join(', ')})) ORDER BY SCHEDULED_TIME DESC`,
|
||||
bindings: binds.bindings,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGetTaskRun(params: SnowflakeGetTaskRunParams): SnowflakeStatementSpec {
|
||||
const binds = new BindingsBuilder()
|
||||
const args = ['RESULT_LIMIT => 10000']
|
||||
if (params.taskName?.trim()) {
|
||||
args.push(`TASK_NAME => ${binds.add(unqualifiedTaskName(params.taskName))}`)
|
||||
}
|
||||
if (params.startTime?.trim()) {
|
||||
args.push(
|
||||
`SCHEDULED_TIME_RANGE_START => ${taskHistoryTimestamp(params.startTime, 'startTime')}`
|
||||
)
|
||||
}
|
||||
if (params.endTime?.trim()) {
|
||||
args.push(`SCHEDULED_TIME_RANGE_END => ${taskHistoryTimestamp(params.endTime, 'endTime')}`)
|
||||
}
|
||||
const queryId = binds.add(requireQueryId(params.queryId))
|
||||
return {
|
||||
statement: `SELECT * FROM TABLE(SNOWFLAKE.INFORMATION_SCHEMA.TASK_HISTORY(${args.join(', ')})) WHERE QUERY_ID = ${queryId} LIMIT 1`,
|
||||
bindings: binds.bindings,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCancelTaskRun(params: SnowflakeCancelTaskRunParams): SnowflakeStatementSpec {
|
||||
return {
|
||||
statement: `SELECT SYSTEM$CANCEL_QUERY(${stringLiteral(requireQueryId(params.queryId))}) AS STATUS`,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGetTaskRunOutput(
|
||||
params: SnowflakeGetTaskRunOutputParams
|
||||
): SnowflakeStatementSpec {
|
||||
return {
|
||||
statement: `SELECT * FROM TABLE(RESULT_SCAN(${stringLiteral(requireQueryId(params.queryId))}))`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* INFORMATION_SCHEMA.TABLES.TABLE_TYPE is one of BASE TABLE, TEMPORARY TABLE, EXTERNAL TABLE,
|
||||
* EVENT TABLE, VIEW, or MATERIALIZED VIEW. Excluding only the view types keeps every table kind
|
||||
* visible; matching 'BASE TABLE' alone would silently hide temporary, external, and event tables.
|
||||
*/
|
||||
const VIEW_TABLE_TYPES = ["'VIEW'", "'MATERIALIZED VIEW'"] as const
|
||||
|
||||
export function buildIntrospectSchema(
|
||||
params: SnowflakeIntrospectSchemaParams
|
||||
): SnowflakeStatementSpec {
|
||||
const binds = new BindingsBuilder()
|
||||
const filters: string[] = []
|
||||
if (params.schema?.trim()) {
|
||||
filters.push(`c.TABLE_SCHEMA = ${binds.add(resolvedIdentifierName(params.schema))}`)
|
||||
}
|
||||
if (params.table?.trim()) {
|
||||
filters.push(`c.TABLE_NAME = ${binds.add(resolvedIdentifierName(params.table))}`)
|
||||
}
|
||||
if (!params.includeViews) filters.push(`t.TABLE_TYPE NOT IN (${VIEW_TABLE_TYPES.join(', ')})`)
|
||||
const where = filters.length > 0 ? ` WHERE ${filters.join(' AND ')}` : ''
|
||||
return {
|
||||
statement: `SELECT c.TABLE_CATALOG, c.TABLE_SCHEMA, c.TABLE_NAME, t.TABLE_TYPE, t.ROW_COUNT, t.BYTES, c.COLUMN_NAME, c.ORDINAL_POSITION, c.COLUMN_DEFAULT, c.IS_NULLABLE, c.DATA_TYPE, c.CHARACTER_MAXIMUM_LENGTH, c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.COMMENT FROM ${identifier(params.database)}.INFORMATION_SCHEMA.COLUMNS AS c JOIN ${identifier(params.database)}.INFORMATION_SCHEMA.TABLES AS t ON c.TABLE_CATALOG = t.TABLE_CATALOG AND c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME${where} ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION`,
|
||||
bindings: binds.bindings,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCallProcedure(params: SnowflakeCallProcedureParams): SnowflakeStatementSpec {
|
||||
const procedureArguments = params.procedureArguments ?? []
|
||||
if (!Array.isArray(procedureArguments)) {
|
||||
throw new Error('procedureArguments must be a JSON array')
|
||||
}
|
||||
const bindings = normalizeBindings(
|
||||
Object.fromEntries(procedureArguments.map((argument, index) => [String(index + 1), argument]))
|
||||
)
|
||||
const placeholders = procedureArguments.map(() => '?')
|
||||
return {
|
||||
statement: `CALL ${qualifiedIdentifier(params.database, params.schema, params.procedureName)}(${placeholders.join(', ')})`,
|
||||
bindings,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { buildSuspendWarehouse } from '@/tools/snowflake/sql'
|
||||
import type { SnowflakeStatementResponse, SnowflakeWarehouseParams } from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const suspendWarehouseTool: ToolConfig<
|
||||
SnowflakeWarehouseParams,
|
||||
SnowflakeStatementResponse
|
||||
> = {
|
||||
id: 'snowflake_suspend_warehouse',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Suspend Warehouse',
|
||||
description: 'Suspend a Snowflake virtual warehouse.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouseName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse name',
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(params, buildSuspendWarehouse(params))
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { OutputProperty, ToolResponse } from '@/tools/types'
|
||||
|
||||
export const SNOWFLAKE_BINDING_TYPES = [
|
||||
'FIXED',
|
||||
'REAL',
|
||||
'DECFLOAT',
|
||||
'TEXT',
|
||||
'BINARY',
|
||||
'BOOLEAN',
|
||||
'DATE',
|
||||
'TIME',
|
||||
'TIMESTAMP_TZ',
|
||||
'TIMESTAMP_LTZ',
|
||||
'TIMESTAMP_NTZ',
|
||||
] as const
|
||||
|
||||
export type SnowflakeBindingType = (typeof SNOWFLAKE_BINDING_TYPES)[number]
|
||||
|
||||
export interface SnowflakeBinding {
|
||||
type: SnowflakeBindingType
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface SnowflakeBaseParams {
|
||||
host: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export interface SnowflakeStatementParams extends SnowflakeBaseParams {
|
||||
role?: string
|
||||
statementTimeoutSeconds?: number
|
||||
}
|
||||
|
||||
export interface SnowflakeComputeParams extends SnowflakeStatementParams {
|
||||
warehouse?: string
|
||||
}
|
||||
|
||||
export interface SnowflakeResultParams extends SnowflakeComputeParams {
|
||||
maxRows?: number
|
||||
}
|
||||
|
||||
export interface SnowflakeExecuteSqlParams extends SnowflakeResultParams {
|
||||
database?: string
|
||||
schema?: string
|
||||
statement: string
|
||||
bindings?: Record<string, SnowflakeBinding>
|
||||
async?: boolean
|
||||
}
|
||||
|
||||
export interface SnowflakeGetStatementParams extends SnowflakeBaseParams {
|
||||
statementHandle: string
|
||||
partition?: number
|
||||
/**
|
||||
* Total partition count reported by the first partition. Snowflake omits all
|
||||
* metadata from later partition responses, so continuation is only knowable
|
||||
* when this is carried forward from the partition-0 result.
|
||||
*/
|
||||
partitionCount?: number
|
||||
}
|
||||
|
||||
export interface SnowflakeCancelStatementParams extends SnowflakeBaseParams {
|
||||
statementHandle: string
|
||||
}
|
||||
|
||||
export interface SnowflakeTableParams extends SnowflakeComputeParams {
|
||||
database: string
|
||||
schema: string
|
||||
table: string
|
||||
}
|
||||
|
||||
export interface SnowflakeInsertRowsParams extends SnowflakeTableParams {
|
||||
rows: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export interface SnowflakeUpdateRowsParams extends SnowflakeInsertRowsParams {
|
||||
matchColumns: string[]
|
||||
}
|
||||
|
||||
export type SnowflakeUpsertRowsParams = SnowflakeUpdateRowsParams
|
||||
|
||||
export interface SnowflakeDeleteRowsParams extends SnowflakeTableParams {
|
||||
filters: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SnowflakeLoadDataParams extends SnowflakeTableParams {
|
||||
maxRows?: number
|
||||
stagePath: string
|
||||
fileFormat?: string
|
||||
pattern?: string
|
||||
onError?: string
|
||||
purge?: boolean
|
||||
force?: boolean
|
||||
matchByColumnName?: 'CASE_SENSITIVE' | 'CASE_INSENSITIVE' | 'NONE'
|
||||
}
|
||||
|
||||
export interface SnowflakeListWarehousesParams extends SnowflakeStatementParams {
|
||||
maxRows?: number
|
||||
nameLike?: string
|
||||
}
|
||||
|
||||
export interface SnowflakeWarehouseParams extends SnowflakeStatementParams {
|
||||
warehouseName: string
|
||||
}
|
||||
|
||||
export interface SnowflakeListTasksParams extends SnowflakeStatementParams {
|
||||
database: string
|
||||
schema: string
|
||||
nameLike?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface SnowflakeTaskParams extends SnowflakeStatementParams {
|
||||
database: string
|
||||
schema: string
|
||||
taskName: string
|
||||
}
|
||||
|
||||
export interface SnowflakeRunTaskParams extends SnowflakeTaskParams {
|
||||
retryLast?: boolean
|
||||
}
|
||||
|
||||
export interface SnowflakeListTaskRunsParams extends SnowflakeComputeParams {
|
||||
taskName?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
errorOnly?: boolean
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface SnowflakeGetTaskRunParams extends SnowflakeComputeParams {
|
||||
queryId: string
|
||||
taskName?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
export interface SnowflakeGetTaskRunOutputParams extends SnowflakeResultParams {
|
||||
queryId: string
|
||||
}
|
||||
|
||||
export interface SnowflakeCancelTaskRunParams extends SnowflakeComputeParams {
|
||||
queryId: string
|
||||
}
|
||||
|
||||
export interface SnowflakeIntrospectSchemaParams extends SnowflakeResultParams {
|
||||
database: string
|
||||
schema?: string
|
||||
table?: string
|
||||
includeViews?: boolean
|
||||
}
|
||||
|
||||
export interface SnowflakeCallProcedureParams extends SnowflakeResultParams {
|
||||
database: string
|
||||
schema: string
|
||||
procedureName: string
|
||||
procedureArguments?: SnowflakeBinding[]
|
||||
}
|
||||
|
||||
export interface SnowflakeColumn {
|
||||
name: string
|
||||
type: string
|
||||
length: number | null
|
||||
precision: number | null
|
||||
scale: number | null
|
||||
nullable: boolean
|
||||
}
|
||||
|
||||
export type SnowflakeStatementStatus = 'SUCCEEDED' | 'RUNNING' | 'CANCELED'
|
||||
|
||||
export interface SnowflakeResultOutput {
|
||||
/**
|
||||
* Result column metadata, or null when it is unknown because Snowflake sent a
|
||||
* metadata-less partition response.
|
||||
*/
|
||||
columns: SnowflakeColumn[] | null
|
||||
rows: Array<Array<string | null>>
|
||||
totalRows: number | null
|
||||
currentPartition: number
|
||||
/**
|
||||
* Total number of partitions in the result set, or null when unknown.
|
||||
* Snowflake reports this only on the first partition.
|
||||
*/
|
||||
partitionCount: number | null
|
||||
nextPartition: number | null
|
||||
/**
|
||||
* True when more result partitions remain, false when the result set is
|
||||
* complete, and null when Snowflake sent a metadata-less partition response
|
||||
* and the total partition count was not supplied by the caller. Snowflake
|
||||
* reports no signal for a `rows_per_resultset` cap, so a server-side row cap
|
||||
* is never reflected here.
|
||||
*/
|
||||
truncated: boolean | null
|
||||
}
|
||||
|
||||
export interface SnowflakeDmlStats {
|
||||
rowsInserted: number
|
||||
rowsUpdated: number
|
||||
rowsDeleted: number
|
||||
duplicateRowsUpdated: number
|
||||
rowsAffected: number
|
||||
}
|
||||
|
||||
export interface SnowflakeStatementOutput {
|
||||
statementHandle: string
|
||||
status: SnowflakeStatementStatus
|
||||
message: string | null
|
||||
result: SnowflakeResultOutput | null
|
||||
dml: SnowflakeDmlStats | null
|
||||
}
|
||||
|
||||
export interface SnowflakeStatementResponse extends ToolResponse {
|
||||
output: SnowflakeStatementOutput
|
||||
}
|
||||
|
||||
export const SNOWFLAKE_STATEMENT_OUTPUTS = {
|
||||
statementHandle: { type: 'string', description: 'Snowflake statement handle' },
|
||||
status: { type: 'string', description: 'Statement status: SUCCEEDED, RUNNING, or CANCELED' },
|
||||
message: { type: 'string', description: 'Snowflake response message', nullable: true },
|
||||
result: {
|
||||
type: 'object',
|
||||
description: 'Completed result partition, or null while running or when no result is available',
|
||||
nullable: true,
|
||||
properties: {
|
||||
columns: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response',
|
||||
nullable: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Column name' },
|
||||
type: { type: 'string', description: 'Snowflake data type' },
|
||||
length: { type: 'number', description: 'Column length', nullable: true },
|
||||
precision: { type: 'number', description: 'Numeric precision', nullable: true },
|
||||
scale: { type: 'number', description: 'Numeric scale', nullable: true },
|
||||
nullable: { type: 'boolean', description: 'Whether the column is nullable' },
|
||||
},
|
||||
},
|
||||
},
|
||||
rows: {
|
||||
type: 'array',
|
||||
description: 'One complete Snowflake result partition as string or null arrays',
|
||||
items: { type: 'array', description: 'A result row in column order' },
|
||||
},
|
||||
totalRows: { type: 'number', description: 'Total result rows', nullable: true },
|
||||
currentPartition: { type: 'number', description: 'Zero-based partition returned' },
|
||||
partitionCount: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions',
|
||||
nullable: true,
|
||||
},
|
||||
nextPartition: {
|
||||
type: 'number',
|
||||
description: 'Next partition to request with Get Statement, if one exists',
|
||||
nullable: true,
|
||||
},
|
||||
truncated: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
dml: {
|
||||
type: 'object',
|
||||
description: 'Completed DML statistics, or null when the statement has no DML statistics',
|
||||
nullable: true,
|
||||
properties: {
|
||||
rowsInserted: { type: 'number', description: 'Rows inserted by the statement' },
|
||||
rowsUpdated: { type: 'number', description: 'Rows updated by the statement' },
|
||||
rowsDeleted: { type: 'number', description: 'Rows deleted by the statement' },
|
||||
duplicateRowsUpdated: {
|
||||
type: 'number',
|
||||
description: 'Duplicate rows updated by the statement',
|
||||
},
|
||||
rowsAffected: {
|
||||
type: 'number',
|
||||
description: 'Total inserted, updated, and deleted rows',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, OutputProperty>
|
||||
@@ -0,0 +1,118 @@
|
||||
import { buildUpdateRows } from '@/tools/snowflake/sql'
|
||||
import type { SnowflakeStatementResponse, SnowflakeUpdateRowsParams } from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* The block resolves `rows` to JSON before execution, but a direct tool call can still
|
||||
* deliver the raw JSON string. Values that already arrive parsed pass straight through
|
||||
* to the builder's own shape validation.
|
||||
*/
|
||||
function parseRows(value: unknown): Array<Record<string, unknown>> {
|
||||
if (typeof value !== 'string') return value as Array<Record<string, unknown>>
|
||||
try {
|
||||
return JSON.parse(value) as Array<Record<string, unknown>>
|
||||
} catch {
|
||||
throw new Error('rows must be a JSON array of row objects')
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts `matchColumns` as a JSON string from a direct tool call. */
|
||||
function parseMatchColumns(value: unknown): string[] {
|
||||
if (typeof value !== 'string') return value as string[]
|
||||
try {
|
||||
return JSON.parse(value) as string[]
|
||||
} catch {
|
||||
throw new Error('matchColumns must be a JSON array of column names')
|
||||
}
|
||||
}
|
||||
|
||||
export const updateRowsTool: ToolConfig<SnowflakeUpdateRowsParams, SnowflakeStatementResponse> = {
|
||||
id: 'snowflake_update_rows',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Update Rows',
|
||||
description: 'Update matching rows with a bound MERGE statement without inserting new rows.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Database name',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Schema name',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Target Snowflake table name within the selected database and schema context',
|
||||
},
|
||||
rows: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Non-empty JSON array of row objects with matching keys. Max 1000 rows and 1 MB of bound data per call - stage the files and use Load Data for bulk ingest.',
|
||||
},
|
||||
matchColumns: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Columns used to match target rows. Match values must be non-null and unique across the submitted rows.',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(
|
||||
params,
|
||||
buildUpdateRows({
|
||||
...params,
|
||||
rows: parseRows(params.rows),
|
||||
matchColumns: parseMatchColumns(params.matchColumns),
|
||||
}),
|
||||
{ warehouse: params.warehouse }
|
||||
)
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { buildUpsertRows } from '@/tools/snowflake/sql'
|
||||
import type { SnowflakeStatementResponse, SnowflakeUpsertRowsParams } from '@/tools/snowflake/types'
|
||||
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
snowflakeStatementRequest,
|
||||
transformSnowflakeResult,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* The block resolves `rows` to JSON before execution, but a direct tool call can still
|
||||
* deliver the raw JSON string. Values that already arrive parsed pass straight through
|
||||
* to the builder's own shape validation.
|
||||
*/
|
||||
function parseRows(value: unknown): Array<Record<string, unknown>> {
|
||||
if (typeof value !== 'string') return value as Array<Record<string, unknown>>
|
||||
try {
|
||||
return JSON.parse(value) as Array<Record<string, unknown>>
|
||||
} catch {
|
||||
throw new Error('rows must be a JSON array of row objects')
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts `matchColumns` as a JSON string from a direct tool call. */
|
||||
function parseMatchColumns(value: unknown): string[] {
|
||||
if (typeof value !== 'string') return value as string[]
|
||||
try {
|
||||
return JSON.parse(value) as string[]
|
||||
} catch {
|
||||
throw new Error('matchColumns must be a JSON array of column names')
|
||||
}
|
||||
}
|
||||
|
||||
export const upsertRowsTool: ToolConfig<SnowflakeUpsertRowsParams, SnowflakeStatementResponse> = {
|
||||
id: 'snowflake_upsert_rows',
|
||||
version: '1.0.0',
|
||||
name: 'Snowflake Upsert Rows',
|
||||
description: 'Update matching rows and insert unmatched rows with a bound MERGE statement.',
|
||||
params: {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Database name',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Schema name',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Target Snowflake table name within the selected database and schema context',
|
||||
},
|
||||
rows: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Non-empty JSON array of row objects with matching keys. Max 1000 rows and 1 MB of bound data per call - stage the files and use Load Data for bulk ingest.',
|
||||
},
|
||||
matchColumns: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Columns used to match target rows. Match values must be non-null and unique across the submitted rows.',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
request: snowflakeStatementRequest((params) =>
|
||||
buildSnowflakeStatementBody(
|
||||
params,
|
||||
buildUpsertRows({
|
||||
...params,
|
||||
rows: parseRows(params.rows),
|
||||
matchColumns: parseMatchColumns(params.matchColumns),
|
||||
}),
|
||||
{ warehouse: params.warehouse }
|
||||
)
|
||||
),
|
||||
transformResponse: transformSnowflakeResult(),
|
||||
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SnowflakeBlock } from '@/blocks/blocks/snowflake'
|
||||
import { prepareToolRequest } from '@/tools/request-transport'
|
||||
import * as snowflakeTools from '@/tools/snowflake'
|
||||
import { cancelStatementTool } from '@/tools/snowflake/cancel_statement'
|
||||
import { executeSqlTool } from '@/tools/snowflake/execute_sql'
|
||||
import { getStatementTool } from '@/tools/snowflake/get_statement'
|
||||
import { insertRowsTool } from '@/tools/snowflake/insert_rows'
|
||||
import { listTaskRunsTool } from '@/tools/snowflake/list_task_runs'
|
||||
import { listTasksTool } from '@/tools/snowflake/list_tasks'
|
||||
import {
|
||||
buildSnowflakeStatementBody,
|
||||
getSnowflakeHeaders,
|
||||
normalizeMaxRows,
|
||||
normalizeSnowflakeHost,
|
||||
readSnowflakeResult,
|
||||
SNOWFLAKE_MAX_RESPONSE_BYTES,
|
||||
} from '@/tools/snowflake/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, headers?: HeadersInit) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
})
|
||||
}
|
||||
|
||||
function registeredSnowflakeTools(): ToolConfig[] {
|
||||
return Object.values(snowflakeTools).filter(
|
||||
(value): value is ToolConfig =>
|
||||
typeof value === 'object' && value !== null && 'id' in value && 'request' in value
|
||||
)
|
||||
}
|
||||
|
||||
function mergedBlockInputs(inputs: Record<string, unknown>): Record<string, unknown> {
|
||||
const mapParams = SnowflakeBlock.tools.config.params
|
||||
if (!mapParams) throw new Error('Snowflake block must map tool parameters')
|
||||
return { ...inputs, ...mapParams(inputs) }
|
||||
}
|
||||
|
||||
describe('Snowflake integration contracts', () => {
|
||||
it('keeps all 21 block operations aligned with registered tool IDs', () => {
|
||||
const tools = registeredSnowflakeTools()
|
||||
const operationBlock = SnowflakeBlock.subBlocks.find((block) => block.id === 'operation')
|
||||
const operationIds = operationBlock?.options?.map((option) => String(option.id)) ?? []
|
||||
const expectedToolIds = operationIds.map((operation) => `snowflake_${operation}`)
|
||||
|
||||
expect(operationIds).toHaveLength(21)
|
||||
expect(SnowflakeBlock.tools.access).toEqual(expectedToolIds)
|
||||
expect(tools.map((tool) => tool.id).sort()).toEqual([...expectedToolIds].sort())
|
||||
for (const operation of operationIds) {
|
||||
expect(SnowflakeBlock.tools.config.tool({ operation })).toBe(`snowflake_${operation}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps tool parameters and outputs represented by the block contract', () => {
|
||||
for (const tool of registeredSnowflakeTools()) {
|
||||
expect(tool.params.host).toMatchObject({ required: true, visibility: 'user-only' })
|
||||
expect(tool.params.apiKey).toMatchObject({ required: true, visibility: 'user-only' })
|
||||
expect(tool.params).not.toHaveProperty('timeout')
|
||||
expect(tool.version).toBe('1.0.0')
|
||||
for (const param of Object.keys(tool.params)) {
|
||||
expect(SnowflakeBlock.inputs, `${tool.id}.${param} block input`).toHaveProperty(param)
|
||||
expect(
|
||||
SnowflakeBlock.subBlocks.some((subBlock) => subBlock.id === param),
|
||||
`${tool.id}.${param} sub-block`
|
||||
).toBe(true)
|
||||
}
|
||||
for (const output of Object.keys(tool.outputs ?? {})) {
|
||||
expect(SnowflakeBlock.outputs, `${tool.id}.${output} block output`).toHaveProperty(output)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('declares every shared connection and session param identically across tools', () => {
|
||||
/**
|
||||
* Each tool inlines these params rather than spreading a shared object, matching
|
||||
* the convention used by every other integration. Duplication is only safe while
|
||||
* the definitions stay byte-identical, so pin them here.
|
||||
*/
|
||||
const shared = {
|
||||
host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Snowflake programmatic access token',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Snowflake role to use for this statement',
|
||||
},
|
||||
statementTimeoutSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
|
||||
},
|
||||
warehouse: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
|
||||
},
|
||||
maxRows: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum result rows; defaults to 1000 with a Sim safety limit of 10000',
|
||||
},
|
||||
} as const
|
||||
|
||||
const seen: Record<string, number> = {}
|
||||
for (const tool of registeredSnowflakeTools()) {
|
||||
for (const [name, expected] of Object.entries(shared)) {
|
||||
const param = tool.params[name]
|
||||
if (!param) continue
|
||||
seen[name] = (seen[name] ?? 0) + 1
|
||||
expect(param, `${tool.id}.${name}`).toEqual(expected)
|
||||
}
|
||||
}
|
||||
|
||||
expect(seen).toEqual({
|
||||
host: 21,
|
||||
apiKey: 21,
|
||||
role: 19,
|
||||
statementTimeoutSeconds: 19,
|
||||
warehouse: 12,
|
||||
maxRows: 6,
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces every non-string tool param of the selected operation and nothing else', () => {
|
||||
const mapParams = SnowflakeBlock.tools.config.params
|
||||
if (!mapParams) throw new Error('Snowflake block must map tool parameters')
|
||||
const operationBlock = SnowflakeBlock.subBlocks.find((block) => block.id === 'operation')
|
||||
const operationIds = operationBlock?.options?.map((option) => String(option.id)) ?? []
|
||||
/** Param types the block must convert from the string a text sub-block emits. */
|
||||
const coercedTypes = new Set(['number', 'json', 'array', 'object'])
|
||||
const inputs: Record<string, unknown> = {
|
||||
statementTimeoutSeconds: '60',
|
||||
maxRows: '100',
|
||||
partition: '1',
|
||||
partitionCount: '2',
|
||||
limit: '10',
|
||||
async: true,
|
||||
retryLast: true,
|
||||
errorOnly: true,
|
||||
includeViews: true,
|
||||
purge: true,
|
||||
force: true,
|
||||
bindings: '{"1":{"type":"TEXT","value":"x"}}',
|
||||
rows: '[{"id":1,"value":"x"}]',
|
||||
matchColumns: '["id"]',
|
||||
filters: '{"id":1}',
|
||||
procedureArguments: '[{"type":"TEXT","value":"x"}]',
|
||||
onError: 'CONTINUE',
|
||||
onErrorThreshold: '1',
|
||||
}
|
||||
|
||||
let coveredCoercions = 0
|
||||
for (const operation of operationIds) {
|
||||
const toolId = `snowflake_${operation}`
|
||||
const tool = registeredSnowflakeTools().find((candidate) => candidate.id === toolId)
|
||||
if (!tool) throw new Error(`Missing Snowflake tool ${toolId}`)
|
||||
const mapped = mapParams({ operation, ...inputs })
|
||||
|
||||
const unexpected = Object.entries(mapped)
|
||||
.filter(([key, value]) => !(key in tool.params) && value !== undefined)
|
||||
.map(([key]) => key)
|
||||
expect(unexpected, `${toolId} returned params the tool does not accept`).toEqual([])
|
||||
|
||||
const expectedCoercions = Object.entries(tool.params)
|
||||
.filter(([key, param]) => coercedTypes.has(param.type) && typeof inputs[key] === 'string')
|
||||
.map(([key]) => key)
|
||||
coveredCoercions += expectedCoercions.length
|
||||
for (const key of expectedCoercions) {
|
||||
expect(mapped, `${toolId}.${key} must be coerced by the block`).toHaveProperty(key)
|
||||
expect(typeof mapped[key], `${toolId}.${key} must not stay a raw string`).not.toBe('string')
|
||||
expect(mapped[key], `${toolId}.${key} must not be dropped`).toBeDefined()
|
||||
}
|
||||
}
|
||||
|
||||
expect(coveredCoercions, 'coercion fixture must exercise every coerced tool param').toBe(37)
|
||||
|
||||
expect(
|
||||
mapParams({ operation: 'load_data', onError: 'SKIP_FILE_PERCENT', onErrorThreshold: '5' })
|
||||
).toMatchObject({ onError: 'SKIP_FILE_5%', onErrorThreshold: undefined })
|
||||
})
|
||||
|
||||
it('uses native boolean switches and scopes the COPY threshold to Load Data', () => {
|
||||
const booleanInputs = [
|
||||
['execute_sql', 'async'],
|
||||
['load_data', 'purge'],
|
||||
['load_data', 'force'],
|
||||
['run_task', 'retryLast'],
|
||||
['list_task_runs', 'errorOnly'],
|
||||
['introspect_schema', 'includeViews'],
|
||||
] as const
|
||||
for (const [operation, id] of booleanInputs) {
|
||||
const subBlock = SnowflakeBlock.subBlocks.find((candidate) => candidate.id === id)
|
||||
expect(subBlock?.type, id).toBe('switch')
|
||||
expect(subBlock?.options, id).toBeUndefined()
|
||||
expect(mergedBlockInputs({ operation, [id]: true })[id], id).toBe(true)
|
||||
}
|
||||
|
||||
const threshold = SnowflakeBlock.subBlocks.find(
|
||||
(candidate) => candidate.id === 'onErrorThreshold'
|
||||
)
|
||||
const expectedRule = {
|
||||
field: 'operation',
|
||||
value: 'load_data',
|
||||
and: { field: 'onError', value: ['SKIP_FILE_NUMBER', 'SKIP_FILE_PERCENT'] },
|
||||
}
|
||||
expect(threshold?.condition).toEqual(expectedRule)
|
||||
expect(threshold?.required).toEqual(expectedRule)
|
||||
})
|
||||
|
||||
it('uses one stable statement output contract for all operations', () => {
|
||||
const expectedOutputs = ['statementHandle', 'status', 'message', 'result', 'dml']
|
||||
expect(Object.keys(SnowflakeBlock.outputs)).toEqual(expectedOutputs)
|
||||
for (const tool of registeredSnowflakeTools()) {
|
||||
expect(Object.keys(tool.outputs ?? {}), tool.id).toEqual(expectedOutputs)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Snowflake SQL API transport', () => {
|
||||
it('normalizes account hosts and sets PAT-specific headers', () => {
|
||||
expect(normalizeSnowflakeHost('acme-prod.snowflakecomputing.com')).toBe(
|
||||
'https://acme-prod.snowflakecomputing.com'
|
||||
)
|
||||
expect(normalizeSnowflakeHost('https://acme-prod.snowflakecomputing.cn')).toBe(
|
||||
'https://acme-prod.snowflakecomputing.cn'
|
||||
)
|
||||
expect(() => normalizeSnowflakeHost('http://acme.snowflakecomputing.com')).toThrow('HTTPS')
|
||||
expect(() => normalizeSnowflakeHost('snowflakecomputing.com.evil.test')).toThrow(
|
||||
'account hostname'
|
||||
)
|
||||
expect(() => normalizeSnowflakeHost('https://user@acme.snowflakecomputing.com')).toThrow(
|
||||
'only the account hostname'
|
||||
)
|
||||
expect(() => normalizeSnowflakeHost('acme.snowflakecomputing.com/api')).toThrow(
|
||||
'only the account hostname'
|
||||
)
|
||||
|
||||
const snowflakeHeaders = getSnowflakeHeaders({
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: ' secret ',
|
||||
})
|
||||
expect(snowflakeHeaders).toMatchObject({
|
||||
Authorization: 'Bearer secret',
|
||||
'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN',
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Sim/1.0 (+https://sim.ai)',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps statement timeout in the Snowflake body, not the HTTP transport', () => {
|
||||
const prepared = prepareToolRequest(executeSqlTool, {
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: 'secret',
|
||||
statement: 'SELECT 1',
|
||||
statementTimeoutSeconds: 60,
|
||||
})
|
||||
|
||||
expect(prepared.timeout).toBeUndefined()
|
||||
expect(JSON.parse(prepared.body ?? '{}')).toMatchObject({ timeout: 60 })
|
||||
})
|
||||
|
||||
it('builds explicit execution context and bounded result settings', () => {
|
||||
expect(normalizeMaxRows()).toBe(1000)
|
||||
expect(normalizeMaxRows(10_000)).toBe(10_000)
|
||||
expect(() => normalizeMaxRows(10_001)).toThrow('Sim safety limit of 10000')
|
||||
|
||||
expect(
|
||||
buildSnowflakeStatementBody(
|
||||
{
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: 'secret',
|
||||
role: '"Analyst ""Plus"""',
|
||||
statementTimeoutSeconds: 30,
|
||||
},
|
||||
{ statement: 'SELECT ?', bindings: { '1': { type: 'FIXED', value: '7' } } },
|
||||
{
|
||||
context: { database: 'analytics', schema: '"Mixed Schema"' },
|
||||
warehouse: 'compute_wh',
|
||||
maxRows: 25,
|
||||
}
|
||||
)
|
||||
).toEqual({
|
||||
statement: 'SELECT ?',
|
||||
timeout: 30,
|
||||
warehouse: 'COMPUTE_WH',
|
||||
database: 'ANALYTICS',
|
||||
schema: 'Mixed Schema',
|
||||
role: 'Analyst "Plus"',
|
||||
parameters: { rows_per_resultset: 25 },
|
||||
bindings: { '1': { type: 'FIXED', value: '7' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses list limits as the SQL API result bound', () => {
|
||||
const auth = { host: 'acme.snowflakecomputing.com', apiKey: 'secret' }
|
||||
const listTasksBody = listTasksTool.request.body
|
||||
const listRunsBody = listTaskRunsTool.request.body
|
||||
if (typeof listTasksBody !== 'function' || typeof listRunsBody !== 'function') {
|
||||
throw new Error('Snowflake list tool request bodies must be functions')
|
||||
}
|
||||
expect(
|
||||
listTasksBody({ ...auth, database: 'ANALYTICS', schema: 'PUBLIC', limit: 5000 })
|
||||
).toMatchObject({ parameters: { rows_per_resultset: 5000 } })
|
||||
expect(listRunsBody({ ...auth, limit: 2500 })).toMatchObject({
|
||||
parameters: { rows_per_resultset: 2500 },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores stale hidden capabilities after the real block input merge', () => {
|
||||
const listTasksBody = listTasksTool.request.body
|
||||
const insertRowsBody = insertRowsTool.request.body
|
||||
const executeBody = executeSqlTool.request.body
|
||||
if (
|
||||
typeof listTasksBody !== 'function' ||
|
||||
typeof insertRowsBody !== 'function' ||
|
||||
typeof executeBody !== 'function'
|
||||
) {
|
||||
throw new Error('Snowflake statement request bodies must be functions')
|
||||
}
|
||||
|
||||
const listTasks = listTasksBody(
|
||||
mergedBlockInputs({
|
||||
operation: 'list_tasks',
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: 'secret',
|
||||
database: 'ANALYTICS',
|
||||
schema: 'PUBLIC',
|
||||
limit: '25',
|
||||
maxRows: 'not-a-number',
|
||||
warehouse: 'STALE_WH',
|
||||
}) as never
|
||||
)
|
||||
expect(listTasks).toMatchObject({ parameters: { rows_per_resultset: 25 } })
|
||||
expect(listTasks).not.toHaveProperty('warehouse')
|
||||
|
||||
const insertRows = insertRowsBody(
|
||||
mergedBlockInputs({
|
||||
operation: 'insert_rows',
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: 'secret',
|
||||
database: 'ANALYTICS',
|
||||
schema: 'PUBLIC',
|
||||
table: 'EVENTS',
|
||||
rows: '[{"id":1}]',
|
||||
maxRows: 'not-a-number',
|
||||
}) as never
|
||||
)
|
||||
expect(insertRows).toMatchObject({ parameters: { rows_per_resultset: 1000 } })
|
||||
|
||||
const execute = executeBody(
|
||||
mergedBlockInputs({
|
||||
operation: 'execute_sql',
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: 'secret',
|
||||
statement: 'SELECT 1',
|
||||
warehouse: 'compute_wh',
|
||||
maxRows: '25',
|
||||
}) as never
|
||||
)
|
||||
expect(execute).toMatchObject({
|
||||
warehouse: 'COMPUTE_WH',
|
||||
parameters: { rows_per_resultset: 25 },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the complete requested partition without client-side row slicing', async () => {
|
||||
const response = jsonResponse(
|
||||
{
|
||||
statementHandle: 'handle',
|
||||
data: [['1'], ['2'], ['3']],
|
||||
resultSetMetaData: {
|
||||
numRows: 5,
|
||||
rowType: [{ name: 'ID', type: 'fixed', nullable: false }],
|
||||
partitionInfo: [{ rowCount: 3 }, { rowCount: 2 }],
|
||||
},
|
||||
},
|
||||
200,
|
||||
{
|
||||
Link: '</api/v2/statements/handle?partition=1>; rel="next"',
|
||||
}
|
||||
)
|
||||
const transformed = await getStatementTool.transformResponse?.(response, {
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: 'secret',
|
||||
statementHandle: 'handle',
|
||||
partition: 0,
|
||||
})
|
||||
|
||||
expect(transformed?.output.result).toMatchObject({
|
||||
rows: [['1'], ['2'], ['3']],
|
||||
totalRows: 5,
|
||||
currentPartition: 0,
|
||||
partitionCount: 2,
|
||||
nextPartition: 1,
|
||||
truncated: true,
|
||||
})
|
||||
expect(transformed?.output.result).not.toHaveProperty('partitions')
|
||||
})
|
||||
|
||||
it('never claims completeness for a metadata-less partition response', async () => {
|
||||
const metadatalessPartition = { statementHandle: 'handle', data: [['2']] }
|
||||
|
||||
const unknown = await readSnowflakeResult(jsonResponse(metadatalessPartition), {
|
||||
currentPartition: 1,
|
||||
})
|
||||
expect(unknown.result).toEqual({
|
||||
columns: null,
|
||||
rows: [['2']],
|
||||
totalRows: null,
|
||||
currentPartition: 1,
|
||||
partitionCount: null,
|
||||
nextPartition: null,
|
||||
truncated: null,
|
||||
})
|
||||
|
||||
const middle = await readSnowflakeResult(jsonResponse(metadatalessPartition), {
|
||||
currentPartition: 1,
|
||||
partitionCount: 3,
|
||||
})
|
||||
expect(middle.result).toMatchObject({
|
||||
currentPartition: 1,
|
||||
partitionCount: 3,
|
||||
nextPartition: 2,
|
||||
truncated: true,
|
||||
})
|
||||
|
||||
const last = await readSnowflakeResult(jsonResponse(metadatalessPartition), {
|
||||
currentPartition: 2,
|
||||
partitionCount: 3,
|
||||
})
|
||||
expect(last.result).toMatchObject({
|
||||
currentPartition: 2,
|
||||
partitionCount: 3,
|
||||
nextPartition: null,
|
||||
truncated: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('derives truncation only from partitionInfo, ignoring the deprecated SQL API signals', async () => {
|
||||
const linked = await readSnowflakeResult(
|
||||
jsonResponse(
|
||||
{
|
||||
code: '391908',
|
||||
statementHandle: 'single-partition',
|
||||
data: [['1']],
|
||||
resultSetMetaData: { numRows: 1, partitionInfo: [{ rowCount: 1 }] },
|
||||
},
|
||||
200,
|
||||
{ Link: '</api/v2/statements/single-partition?partition=1>; rel="next"' }
|
||||
)
|
||||
)
|
||||
expect(linked.result).toMatchObject({ nextPartition: null, truncated: false })
|
||||
})
|
||||
|
||||
it('handles pending statements, documented DML stats, and Snowflake failures', async () => {
|
||||
const pending = await readSnowflakeResult(
|
||||
jsonResponse({ statementHandle: 'handle', message: 'Running' }, 202)
|
||||
)
|
||||
expect(pending).toEqual({
|
||||
statementHandle: 'handle',
|
||||
status: 'RUNNING',
|
||||
message: 'Running',
|
||||
result: null,
|
||||
dml: null,
|
||||
})
|
||||
await expect(readSnowflakeResult(jsonResponse({ message: 'Running' }, 202))).rejects.toThrow(
|
||||
'without a statement handle'
|
||||
)
|
||||
const knownPending = await readSnowflakeResult(jsonResponse({ message: 'Running' }, 202), {
|
||||
fallbackStatementHandle: 'known-handle',
|
||||
})
|
||||
expect(knownPending).toMatchObject({
|
||||
statementHandle: 'known-handle',
|
||||
status: 'RUNNING',
|
||||
result: null,
|
||||
dml: null,
|
||||
})
|
||||
|
||||
const dml = await readSnowflakeResult(
|
||||
jsonResponse({
|
||||
statementHandle: 'dml',
|
||||
data: [['caption fallback is intentionally ignored']],
|
||||
resultSetMetaData: {
|
||||
rowType: [{ name: 'number of rows inserted', type: 'fixed' }],
|
||||
stats: {
|
||||
numRowsInserted: 2,
|
||||
numRowsUpdated: 1,
|
||||
numRowsDeleted: 3,
|
||||
numDuplicateRowsUpdated: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
expect(dml.dml).toEqual({
|
||||
rowsInserted: 2,
|
||||
rowsUpdated: 1,
|
||||
rowsDeleted: 3,
|
||||
duplicateRowsUpdated: 1,
|
||||
rowsAffected: 6,
|
||||
})
|
||||
|
||||
const topLevelDml = await readSnowflakeResult(
|
||||
jsonResponse({
|
||||
statementHandle: 'dml',
|
||||
stats: {
|
||||
numRowsInserted: 2,
|
||||
numRowsUpdated: 1,
|
||||
numRowsDeleted: 3,
|
||||
numDuplicateRowsUpdated: 1,
|
||||
},
|
||||
resultSetMetaData: { rowType: [{ name: 'number of rows inserted', type: 'fixed' }] },
|
||||
})
|
||||
)
|
||||
expect(topLevelDml.dml).toEqual({
|
||||
rowsInserted: 2,
|
||||
rowsUpdated: 1,
|
||||
rowsDeleted: 3,
|
||||
duplicateRowsUpdated: 1,
|
||||
rowsAffected: 6,
|
||||
})
|
||||
|
||||
const zeroDml = await readSnowflakeResult(
|
||||
jsonResponse({ statementHandle: 'zero-dml', resultSetMetaData: { stats: {} } })
|
||||
)
|
||||
expect(zeroDml.dml).toEqual({
|
||||
rowsInserted: 0,
|
||||
rowsUpdated: 0,
|
||||
rowsDeleted: 0,
|
||||
duplicateRowsUpdated: 0,
|
||||
rowsAffected: 0,
|
||||
})
|
||||
|
||||
await expect(
|
||||
readSnowflakeResult(
|
||||
jsonResponse({ sqlState: '42000', code: '001003', message: 'SQL compilation error' })
|
||||
)
|
||||
).rejects.toThrow('SQLSTATE 42000')
|
||||
await expect(readSnowflakeResult(new Response('{invalid'))).rejects.toThrow('invalid JSON')
|
||||
await expect(readSnowflakeResult(new Response(''))).rejects.toThrow('invalid JSON')
|
||||
await expect(readSnowflakeResult(jsonResponse(null))).rejects.toThrow('invalid JSON')
|
||||
await expect(readSnowflakeResult(jsonResponse([]))).rejects.toThrow('invalid JSON')
|
||||
})
|
||||
|
||||
it('returns async and cancellation states through the common contract', async () => {
|
||||
const pending = await executeSqlTool.transformResponse?.(
|
||||
jsonResponse({ statementHandle: 'async-handle', message: 'Running' }, 202),
|
||||
{
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: 'secret',
|
||||
statement: 'SELECT 1',
|
||||
async: true,
|
||||
}
|
||||
)
|
||||
expect(pending?.output).toEqual({
|
||||
statementHandle: 'async-handle',
|
||||
status: 'RUNNING',
|
||||
message: 'Running',
|
||||
result: null,
|
||||
dml: null,
|
||||
})
|
||||
|
||||
const canceled = await cancelStatementTool.transformResponse?.(
|
||||
jsonResponse({ statementHandle: 'cancel-handle', sqlState: '57014', message: 'Canceled' }),
|
||||
{
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: 'secret',
|
||||
statementHandle: 'cancel-handle',
|
||||
}
|
||||
)
|
||||
expect(canceled?.output).toEqual({
|
||||
statementHandle: 'cancel-handle',
|
||||
status: 'CANCELED',
|
||||
message: 'Canceled',
|
||||
result: null,
|
||||
dml: null,
|
||||
})
|
||||
|
||||
await expect(
|
||||
cancelStatementTool.transformResponse?.(
|
||||
jsonResponse({
|
||||
statementHandle: 'cancel-handle',
|
||||
sqlState: '42000',
|
||||
message: 'Unexpected cancellation failure',
|
||||
}),
|
||||
{
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: 'secret',
|
||||
statementHandle: 'cancel-handle',
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('SQLSTATE 42000')
|
||||
|
||||
const alreadyFinished = await cancelStatementTool.transformResponse?.(
|
||||
jsonResponse({
|
||||
statementHandle: 'cancel-handle',
|
||||
code: '000000',
|
||||
sqlState: '00000',
|
||||
message: 'Statement executed successfully',
|
||||
}),
|
||||
{
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: 'secret',
|
||||
statementHandle: 'cancel-handle',
|
||||
}
|
||||
)
|
||||
expect(alreadyFinished?.output).toMatchObject({
|
||||
statementHandle: 'cancel-handle',
|
||||
status: 'SUCCEEDED',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects error bodies without a SQLSTATE and never lets a SQLSTATE launder an error status', async () => {
|
||||
await expect(
|
||||
readSnowflakeResult(
|
||||
jsonResponse({ code: '390318', message: 'Authentication token has expired' }, 401)
|
||||
)
|
||||
).rejects.toThrow('Snowflake statement failed (HTTP 401, 390318)')
|
||||
|
||||
await expect(
|
||||
readSnowflakeResult(
|
||||
jsonResponse(
|
||||
{
|
||||
statementHandle: 'timed-out',
|
||||
code: '000000',
|
||||
sqlState: '00000',
|
||||
message: 'The execution of the statement was cancelled',
|
||||
},
|
||||
408
|
||||
)
|
||||
)
|
||||
).rejects.toThrow('Snowflake statement failed (HTTP 408, SQLSTATE 00000, 000000)')
|
||||
|
||||
await expect(
|
||||
readSnowflakeResult(jsonResponse({ statementHandle: 'server-error', sqlState: '' }, 500))
|
||||
).rejects.toThrow('Snowflake statement failed (HTTP 500, SQLSTATE )')
|
||||
|
||||
await expect(
|
||||
cancelStatementTool.transformResponse?.(
|
||||
jsonResponse({ statementHandle: 'cancel-handle', sqlState: '57014' }, 408),
|
||||
{
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: 'secret',
|
||||
statementHandle: 'cancel-handle',
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Snowflake statement failed (HTTP 408, SQLSTATE 57014)')
|
||||
})
|
||||
|
||||
it('caps the response body size on both the declared and streamed paths', async () => {
|
||||
/**
|
||||
* Pinned to a literal: every other assertion here derives its fixture from the
|
||||
* constant, so raising the ceiling would otherwise leave the suite green.
|
||||
*/
|
||||
expect(SNOWFLAKE_MAX_RESPONSE_BYTES).toBe(10 * 1024 * 1024)
|
||||
|
||||
await expect(
|
||||
readSnowflakeResult(
|
||||
jsonResponse({ statementHandle: 'huge' }, 200, {
|
||||
'Content-Length': String(SNOWFLAKE_MAX_RESPONSE_BYTES + 1),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Snowflake response body exceeds maximum size')
|
||||
|
||||
let emittedBytes = 0
|
||||
let canceled = false
|
||||
const chunk = new Uint8Array(1024 * 1024)
|
||||
chunk.fill(0x20)
|
||||
const chunked = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (emittedBytes >= SNOWFLAKE_MAX_RESPONSE_BYTES * 1.5) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
emittedBytes += chunk.byteLength
|
||||
controller.enqueue(chunk)
|
||||
},
|
||||
cancel() {
|
||||
canceled = true
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
expect(chunked.headers.get('content-length')).toBeNull()
|
||||
|
||||
await expect(readSnowflakeResult(chunked)).rejects.toThrow(
|
||||
'Snowflake response body exceeds maximum size'
|
||||
)
|
||||
expect(canceled).toBe(true)
|
||||
expect(emittedBytes).toBeLessThan(SNOWFLAKE_MAX_RESPONSE_BYTES * 2)
|
||||
})
|
||||
|
||||
it('rejects session-context names that are not Snowflake identifiers', () => {
|
||||
const auth = { host: 'acme.snowflakecomputing.com', apiKey: 'secret' }
|
||||
const spec = { statement: 'SELECT 1' }
|
||||
expect(() => buildSnowflakeStatementBody({ ...auth, role: 'ACCOUNTADMIN; --' }, spec)).toThrow(
|
||||
'Snowflake role must be an unquoted identifier'
|
||||
)
|
||||
expect(() => buildSnowflakeStatementBody(auth, spec, { warehouse: 'compute wh' })).toThrow(
|
||||
'Snowflake warehouse must be an unquoted identifier'
|
||||
)
|
||||
expect(() =>
|
||||
buildSnowflakeStatementBody(auth, spec, { context: { database: 'ANALYTICS.PUBLIC' } })
|
||||
).toThrow('Snowflake database must be an unquoted identifier')
|
||||
expect(() =>
|
||||
buildSnowflakeStatementBody(auth, spec, { context: { schema: '"unterminated' } })
|
||||
).toThrow('Snowflake schema must be an unquoted identifier')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Snowflake common result contract', () => {
|
||||
it('preserves raw Snowflake rows, exact numeric strings, and continuation metadata', async () => {
|
||||
const rows = [
|
||||
['one.csv', 'LOADED', '9007199254740993'],
|
||||
['two.csv', 'PARTIALLY_LOADED', '7'],
|
||||
]
|
||||
const result = await readSnowflakeResult(
|
||||
jsonResponse(
|
||||
{
|
||||
code: '000000',
|
||||
sqlState: '00000',
|
||||
message: 'Statement executed successfully',
|
||||
statementHandle: 'copy',
|
||||
data: rows,
|
||||
resultSetMetaData: {
|
||||
numRows: 3,
|
||||
partitionInfo: [{ rowCount: 2 }, { rowCount: 1 }],
|
||||
rowType: ['FILE', 'STATUS', 'ROWS_LOADED'].map((name) => ({ name, type: 'text' })),
|
||||
},
|
||||
},
|
||||
200,
|
||||
{ Link: '</api/v2/statements/copy?partition=1>; rel="next"' }
|
||||
)
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
statementHandle: 'copy',
|
||||
status: 'SUCCEEDED',
|
||||
message: 'Statement executed successfully',
|
||||
dml: null,
|
||||
result: {
|
||||
rows,
|
||||
totalRows: 3,
|
||||
currentPartition: 0,
|
||||
partitionCount: 2,
|
||||
nextPartition: 1,
|
||||
truncated: true,
|
||||
},
|
||||
})
|
||||
expect(result).not.toHaveProperty('code')
|
||||
expect(result).not.toHaveProperty('sqlState')
|
||||
expect(result.result).not.toHaveProperty('partitions')
|
||||
})
|
||||
|
||||
it('keeps submission and polling responses structurally identical', async () => {
|
||||
const body = {
|
||||
statementHandle: 'handle',
|
||||
data: [['value']],
|
||||
resultSetMetaData: {
|
||||
numRows: 1,
|
||||
rowType: [{ name: 'RESULT', type: 'text', nullable: true }],
|
||||
partitionInfo: [{ rowCount: 1, uncompressedSize: 10 }],
|
||||
},
|
||||
}
|
||||
const params = {
|
||||
host: 'acme.snowflakecomputing.com',
|
||||
apiKey: 'secret',
|
||||
statement: 'SELECT 1',
|
||||
}
|
||||
const submitted = await executeSqlTool.transformResponse?.(jsonResponse(body), params)
|
||||
const polled = await getStatementTool.transformResponse?.(jsonResponse(body), {
|
||||
...params,
|
||||
statementHandle: 'handle',
|
||||
partition: 0,
|
||||
})
|
||||
|
||||
expect(Object.keys(submitted?.output ?? {})).toEqual(Object.keys(polled?.output ?? {}))
|
||||
expect(submitted?.output).toEqual(polled?.output)
|
||||
expect(submitted?.output.result).toMatchObject({
|
||||
rows: [['value']],
|
||||
totalRows: 1,
|
||||
truncated: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,385 @@
|
||||
import { isPlainRecord } from '@sim/utils/object'
|
||||
import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
|
||||
import type {
|
||||
SnowflakeBaseParams,
|
||||
SnowflakeBinding,
|
||||
SnowflakeColumn,
|
||||
SnowflakeDmlStats,
|
||||
SnowflakeResultOutput,
|
||||
SnowflakeStatementOutput,
|
||||
SnowflakeStatementParams,
|
||||
SnowflakeStatementResponse,
|
||||
SnowflakeStatementStatus,
|
||||
} from '@/tools/snowflake/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const DEFAULT_MAX_ROWS = 1_000
|
||||
export const SIM_MAX_RESULT_ROWS = 10_000
|
||||
|
||||
/**
|
||||
* Byte ceiling for a single Snowflake SQL API response body. Get Statement can
|
||||
* fetch an arbitrary partition of a result set Sim never submitted, so no
|
||||
* Sim-side row cap bounds the payload.
|
||||
*
|
||||
* Matches `MAX_HTTP_RESPONSE_BODY_BYTES` in `@/tools/http/request`. Rows are
|
||||
* emitted verbatim into workflow state and logs, and the nearest real ceiling
|
||||
* downstream is the 6 MiB function-context budget in `@/executor/variables/resolver`,
|
||||
* so a bigger limit here only converts an explicit error into an opaque failure
|
||||
* later. The largest partition in Snowflake's own documented example is under
|
||||
* 0.5 MB. Reduce `maxRows` on the submitting statement to keep partitions
|
||||
* under the limit.
|
||||
*/
|
||||
export const SNOWFLAKE_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
const SNOWFLAKE_HOST_SUFFIXES = ['.snowflakecomputing.com', '.snowflakecomputing.cn']
|
||||
|
||||
/** SQLSTATE values Snowflake returns for a statement that completed normally. */
|
||||
const SNOWFLAKE_SUCCESS_SQL_STATES = ['', '00000']
|
||||
|
||||
/** SQLSTATE/code the Cancel Statement endpoint returns for an actual cancellation. */
|
||||
const SNOWFLAKE_CANCELED_SQL_STATE = '57014'
|
||||
const SNOWFLAKE_CANCELED_CODE = '000604'
|
||||
|
||||
interface SnowflakeApiColumn {
|
||||
name?: string
|
||||
type?: string
|
||||
length?: number | null
|
||||
precision?: number | null
|
||||
scale?: number | null
|
||||
nullable?: boolean
|
||||
}
|
||||
|
||||
interface SnowflakeApiPartitionInfo {
|
||||
rowCount?: number
|
||||
uncompressedSize?: number
|
||||
compressedSize?: number
|
||||
}
|
||||
|
||||
interface SnowflakeApiStats {
|
||||
numRowsInserted?: number
|
||||
numRowsUpdated?: number
|
||||
numRowsDeleted?: number
|
||||
numDuplicateRowsUpdated?: number
|
||||
}
|
||||
|
||||
interface SnowflakeApiResponse {
|
||||
code?: string
|
||||
sqlState?: string
|
||||
message?: string
|
||||
statementHandle?: string
|
||||
data?: Array<Array<string | null>>
|
||||
/**
|
||||
* The SQL API reference declares `stats` as a direct property of the ResultSet
|
||||
* object, and also describes it under `resultSetMetaData`. Snowflake's own docs
|
||||
* are inconsistent here, so both shapes are read with the top-level one winning.
|
||||
*/
|
||||
stats?: SnowflakeApiStats
|
||||
resultSetMetaData?: {
|
||||
numRows?: number
|
||||
format?: string
|
||||
rowType?: SnowflakeApiColumn[]
|
||||
partitionInfo?: SnowflakeApiPartitionInfo[]
|
||||
stats?: SnowflakeApiStats
|
||||
}
|
||||
}
|
||||
|
||||
export interface SnowflakeStatementSpec {
|
||||
statement: string
|
||||
bindings?: Record<string, SnowflakeBinding>
|
||||
}
|
||||
|
||||
interface SnowflakeResponseOptions {
|
||||
currentPartition?: number
|
||||
/**
|
||||
* Total partition count carried forward from the first partition, which is
|
||||
* the only response Snowflake attaches result metadata to.
|
||||
*/
|
||||
partitionCount?: number
|
||||
canceled?: boolean
|
||||
fallbackStatementHandle?: string
|
||||
}
|
||||
|
||||
interface SnowflakeStatementBodyOptions {
|
||||
context?: {
|
||||
database?: string
|
||||
schema?: string
|
||||
}
|
||||
warehouse?: string
|
||||
maxRows?: number
|
||||
}
|
||||
|
||||
export function normalizeSnowflakeHost(host: string): string {
|
||||
const raw = host.trim()
|
||||
if (!raw) throw new Error('Snowflake host is required')
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`)
|
||||
} catch {
|
||||
throw new Error('Invalid Snowflake account host')
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:') throw new Error('Snowflake host must use HTTPS')
|
||||
if (url.href !== `https://${url.hostname}/`) {
|
||||
throw new Error('Snowflake host must contain only the account hostname')
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
if (!SNOWFLAKE_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) {
|
||||
throw new Error('Snowflake host must be an official Snowflake account hostname')
|
||||
}
|
||||
return `https://${hostname}`
|
||||
}
|
||||
|
||||
export function getSnowflakeHeaders(params: SnowflakeBaseParams): Record<string, string> {
|
||||
const apiKey = params.apiKey.trim()
|
||||
if (!apiKey) throw new Error('Snowflake programmatic access token is required')
|
||||
return {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Sim/1.0 (+https://sim.ai)',
|
||||
'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN',
|
||||
}
|
||||
}
|
||||
|
||||
export function snowflakeStatementRequest<P extends SnowflakeBaseParams>(
|
||||
body: (params: P) => Record<string, unknown>,
|
||||
asynchronous?: (params: P) => boolean
|
||||
): ToolConfig<P>['request'] {
|
||||
return {
|
||||
url: (params) =>
|
||||
`${normalizeSnowflakeHost(params.host)}/api/v2/statements${asynchronous?.(params) ? '?async=true' : ''}`,
|
||||
method: 'POST',
|
||||
headers: getSnowflakeHeaders,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMaxRows(value?: number): number {
|
||||
const maxRows = value ?? DEFAULT_MAX_ROWS
|
||||
if (!Number.isInteger(maxRows) || maxRows < 1 || maxRows > SIM_MAX_RESULT_ROWS) {
|
||||
throw new Error(
|
||||
`maxRows must be an integer between 1 and the Sim safety limit of ${SIM_MAX_RESULT_ROWS}`
|
||||
)
|
||||
}
|
||||
return maxRows
|
||||
}
|
||||
|
||||
export function normalizeStatementTimeout(value?: number): number | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (!Number.isInteger(value) || value < 0 || value > 604_800) {
|
||||
throw new Error('statementTimeoutSeconds must be an integer between 0 and 604800 seconds')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a Snowflake session-context identifier to the literal name the SQL
|
||||
* API expects in `role`/`warehouse`/`database`/`schema`. Input that is neither
|
||||
* a bare identifier nor a double-quoted identifier is rejected rather than
|
||||
* forwarded verbatim, keeping this fail-closed like the rest of the transport.
|
||||
*/
|
||||
function normalizeContextName(value: string, field: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (/^[A-Za-z_][A-Za-z0-9_$]*$/.test(trimmed)) return trimmed.toUpperCase()
|
||||
if (/^"(?:[^"]|"")+"$/.test(trimmed)) {
|
||||
return trimmed.slice(1, -1).replaceAll('""', '"')
|
||||
}
|
||||
throw new Error(`Snowflake ${field} must be an unquoted identifier or a double-quoted identifier`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `/api/v2/statements` request body.
|
||||
*
|
||||
* `parameters.rows_per_resultset` caps the result set server-side. Snowflake
|
||||
* emits no response field indicating the cap was applied, so a capped `SELECT`
|
||||
* is indistinguishable from one that genuinely matched that many rows — the
|
||||
* result's `truncated` flag reports remaining partitions only and never this cap.
|
||||
*/
|
||||
export function buildSnowflakeStatementBody(
|
||||
params: SnowflakeStatementParams,
|
||||
spec: SnowflakeStatementSpec,
|
||||
options: SnowflakeStatementBodyOptions = {}
|
||||
): Record<string, unknown> {
|
||||
if (!/\S/.test(spec.statement)) throw new Error('Snowflake statement is required')
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
statement: spec.statement,
|
||||
parameters: { rows_per_resultset: normalizeMaxRows(options.maxRows) },
|
||||
}
|
||||
const statementTimeoutSeconds = normalizeStatementTimeout(params.statementTimeoutSeconds)
|
||||
if (statementTimeoutSeconds !== undefined) body.timeout = statementTimeoutSeconds
|
||||
if (options.warehouse?.trim())
|
||||
body.warehouse = normalizeContextName(options.warehouse, 'warehouse')
|
||||
if (options.context?.database?.trim()) {
|
||||
body.database = normalizeContextName(options.context.database, 'database')
|
||||
}
|
||||
if (options.context?.schema?.trim()) {
|
||||
body.schema = normalizeContextName(options.context.schema, 'schema')
|
||||
}
|
||||
if (params.role?.trim()) body.role = normalizeContextName(params.role, 'role')
|
||||
if (spec.bindings && Object.keys(spec.bindings).length > 0) body.bindings = spec.bindings
|
||||
return body
|
||||
}
|
||||
|
||||
function mapColumn(column: SnowflakeApiColumn): SnowflakeColumn {
|
||||
return {
|
||||
name: column.name ?? '',
|
||||
type: column.type ?? '',
|
||||
length: column.length ?? null,
|
||||
precision: column.precision ?? null,
|
||||
scale: column.scale ?? null,
|
||||
nullable: column.nullable ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSnowflakeResult(
|
||||
response: Response,
|
||||
options: SnowflakeResponseOptions = {}
|
||||
): Promise<SnowflakeStatementOutput> {
|
||||
const data = await readSnowflakeJson(response)
|
||||
const pending = response.status === 202
|
||||
const cancelRequest = options.canceled === true
|
||||
assertSnowflakeSuccess(response, data, cancelRequest)
|
||||
|
||||
const statementHandle = (data.statementHandle ?? options.fallbackStatementHandle ?? '').trim()
|
||||
if (pending && !statementHandle) {
|
||||
throw new Error('Snowflake returned a running statement without a statement handle')
|
||||
}
|
||||
|
||||
const canceled = cancelRequest && isCanceledResponse(data)
|
||||
const hasResult =
|
||||
!pending && !canceled && (data.resultSetMetaData !== undefined || data.data !== undefined)
|
||||
const result = hasResult
|
||||
? buildResultOutput(data, options.currentPartition ?? 0, options.partitionCount)
|
||||
: null
|
||||
const stats = data.stats ?? data.resultSetMetaData?.stats
|
||||
const dml = !pending && !canceled && stats ? buildDmlStats(stats) : null
|
||||
|
||||
return {
|
||||
statementHandle,
|
||||
status: statementStatus(pending, canceled),
|
||||
message: data.message ?? null,
|
||||
result,
|
||||
dml,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Snowflake body is a success only when the HTTP status is 2xx *and* any
|
||||
* SQLSTATE it carries is a successful one. Both must hold: a body without
|
||||
* `sqlState` must not be read as a completed statement on an error status, and
|
||||
* a successful `sqlState` must not launder an error status either — HTTP 408
|
||||
* ("the execution of the statement exceeded the timeout period ... was
|
||||
* cancelled") returns a QueryStatus body, and every documented success body is
|
||||
* 200 or 202, so requiring both rejects nothing valid.
|
||||
*/
|
||||
function assertSnowflakeSuccess(
|
||||
response: Response,
|
||||
data: SnowflakeApiResponse,
|
||||
cancelRequest: boolean
|
||||
): void {
|
||||
const successfulSqlStates = cancelRequest
|
||||
? [...SNOWFLAKE_SUCCESS_SQL_STATES, SNOWFLAKE_CANCELED_SQL_STATE]
|
||||
: SNOWFLAKE_SUCCESS_SQL_STATES
|
||||
const failed =
|
||||
!response.ok || (data.sqlState !== undefined && !successfulSqlStates.includes(data.sqlState))
|
||||
if (!failed) return
|
||||
|
||||
const descriptor = [
|
||||
`HTTP ${response.status}`,
|
||||
data.sqlState === undefined ? undefined : `SQLSTATE ${data.sqlState}`,
|
||||
data.code,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
throw new Error(`Snowflake statement failed (${descriptor}): ${data.message ?? 'Unknown error'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel Statement reports SQLSTATE 57014 / code 000604 only when it actually
|
||||
* canceled the statement; a successful SQLSTATE means it had already finished.
|
||||
*/
|
||||
function isCanceledResponse(data: SnowflakeApiResponse): boolean {
|
||||
return data.sqlState === SNOWFLAKE_CANCELED_SQL_STATE || data.code === SNOWFLAKE_CANCELED_CODE
|
||||
}
|
||||
|
||||
export function transformSnowflakeResult<P extends SnowflakeBaseParams>(
|
||||
options?: (params?: P) => SnowflakeResponseOptions
|
||||
): (response: Response, params?: P) => Promise<SnowflakeStatementResponse> {
|
||||
return async (response, params) => ({
|
||||
success: true,
|
||||
output: await readSnowflakeResult(response, options?.(params)),
|
||||
})
|
||||
}
|
||||
|
||||
async function readSnowflakeJson(response: Response): Promise<SnowflakeApiResponse> {
|
||||
const body = await readResponseTextWithLimit(response, {
|
||||
maxBytes: SNOWFLAKE_MAX_RESPONSE_BYTES,
|
||||
label: 'Snowflake response body',
|
||||
})
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(body)
|
||||
} catch {
|
||||
throw new Error('Snowflake returned an invalid JSON response')
|
||||
}
|
||||
if (!isPlainRecord(data)) throw new Error('Snowflake returned an invalid JSON response')
|
||||
return data as SnowflakeApiResponse
|
||||
}
|
||||
|
||||
function statementStatus(pending: boolean, canceled: boolean): SnowflakeStatementStatus {
|
||||
if (canceled) return 'CANCELED'
|
||||
return pending ? 'RUNNING' : 'SUCCEEDED'
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/v2/statements` pages exclusively through the `partition` query parameter
|
||||
* and `resultSetMetaData.partitionInfo`. The `Link` response header and the
|
||||
* `391908` truncated-result code belong to the retired `/api/statements` API and
|
||||
* are never emitted here, so neither is consulted.
|
||||
*
|
||||
* Snowflake documents that partition responses after the first "do not contain
|
||||
* any metadata" and that "metadata for all partitions is provided in the first
|
||||
* partition". A later partition therefore carries no continuation signal of its
|
||||
* own: unless the caller passes the partition count forward from partition 0,
|
||||
* completeness and column metadata are unknown and are reported as `null`
|
||||
* rather than asserted as a complete, column-less result.
|
||||
*/
|
||||
function buildResultOutput(
|
||||
data: SnowflakeApiResponse,
|
||||
currentPartition: number,
|
||||
knownPartitionCount?: number
|
||||
): SnowflakeResultOutput {
|
||||
const metadata = data.resultSetMetaData
|
||||
const partitionCount = metadata?.partitionInfo?.length ?? knownPartitionCount ?? null
|
||||
const nextPartition =
|
||||
partitionCount !== null && currentPartition + 1 < partitionCount ? currentPartition + 1 : null
|
||||
const completenessKnown = partitionCount !== null || currentPartition === 0
|
||||
return {
|
||||
columns:
|
||||
metadata === undefined && currentPartition > 0
|
||||
? null
|
||||
: (metadata?.rowType ?? []).map(mapColumn),
|
||||
rows: data.data ?? [],
|
||||
totalRows: metadata?.numRows ?? null,
|
||||
currentPartition,
|
||||
partitionCount,
|
||||
nextPartition,
|
||||
truncated: completenessKnown ? nextPartition !== null : null,
|
||||
}
|
||||
}
|
||||
|
||||
function buildDmlStats(stats: SnowflakeApiStats): SnowflakeDmlStats {
|
||||
const rowsInserted = stats.numRowsInserted ?? 0
|
||||
const rowsUpdated = stats.numRowsUpdated ?? 0
|
||||
const rowsDeleted = stats.numRowsDeleted ?? 0
|
||||
return {
|
||||
rowsInserted,
|
||||
rowsUpdated,
|
||||
rowsDeleted,
|
||||
duplicateRowsUpdated: stats.numDuplicateRowsUpdated ?? 0,
|
||||
rowsAffected: rowsInserted + rowsUpdated + rowsDeleted,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user