Skip to content

Commit 624b737

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
refactor(integrations): simplify Snowflake safeguards
1 parent b6405b0 commit 624b737

9 files changed

Lines changed: 45 additions & 173 deletions

File tree

apps/docs/content/docs/en/integrations/snowflake.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ Cancel a running Snowflake SQL API statement.
145145

146146
### Snowflake Insert Rows
147147

148-
Insert up to 1000 structured JSON rows using bound values.
148+
Insert structured JSON rows using bound values.
149149

150150
#### Input
151151

apps/sim/blocks/blocks/snowflake.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { SnowflakeIcon } from '@/components/icons'
22
import type { BlockConfig, BlockMeta } from '@/blocks/types'
33
import { AuthMode, IntegrationType } from '@/blocks/types'
44
import type { SnowflakeStatementResponse } from '@/tools/snowflake/types'
5-
import { addSnowflakeRequestBytes } from '@/tools/snowflake/utils'
65

76
const statementOperations = [
87
'execute_sql',
@@ -41,10 +40,9 @@ const contextOnlyOperations = [
4140
const dataOperations = ['insert_rows', 'update_rows', 'upsert_rows', 'delete_rows', 'load_data']
4241
const taskDefinitionOperations = ['list_tasks', 'get_task', 'run_task']
4342

44-
function parseJson(value: unknown, label: string, budget: { bytes: number }): unknown {
43+
function parseJson(value: unknown, label: string): unknown {
4544
if (value === undefined || value === null || value === '') return undefined
4645
if (typeof value !== 'string') return value
47-
budget.bytes = addSnowflakeRequestBytes(budget.bytes, value)
4846
try {
4947
return JSON.parse(value)
5048
} catch {
@@ -236,7 +234,7 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
236234
wandConfig: {
237235
enabled: true,
238236
prompt:
239-
'Generate a non-empty JSON array of flat row objects. Every row must have the same keys and the batch must contain at most 1000 rows. Return ONLY the JSON array - no explanations, no extra text.',
237+
'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.',
240238
placeholder: 'Describe the records to write...',
241239
},
242240
},
@@ -598,7 +596,6 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
598596
config: {
599597
tool: (params) => `snowflake_${params.operation}`,
600598
params: (params) => {
601-
const jsonBudget = { bytes: 0 }
602599
const statementParams = () => ({
603600
timeout: optionalNumber(params.timeout),
604601
maxRows: optionalNumber(params.maxRows),
@@ -619,7 +616,7 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
619616
return {
620617
...contextParams(),
621618
async: optionalBoolean(params.async),
622-
bindings: parseJson(params.bindings, 'Bindings', jsonBudget),
619+
bindings: parseJson(params.bindings, 'Bindings'),
623620
}
624621
case 'get_statement':
625622
return {
@@ -629,19 +626,19 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
629626
case 'insert_rows':
630627
return {
631628
...objectParams(),
632-
rows: parseJson(params.rows, 'Rows', jsonBudget),
629+
rows: parseJson(params.rows, 'Rows'),
633630
}
634631
case 'update_rows':
635632
case 'upsert_rows':
636633
return {
637634
...objectParams(),
638-
rows: parseJson(params.rows, 'Rows', jsonBudget),
639-
matchColumns: parseJson(params.matchColumns, 'Match columns', jsonBudget),
635+
rows: parseJson(params.rows, 'Rows'),
636+
matchColumns: parseJson(params.matchColumns, 'Match columns'),
640637
}
641638
case 'delete_rows':
642639
return {
643640
...objectParams(),
644-
filters: parseJson(params.filters, 'Filters', jsonBudget),
641+
filters: parseJson(params.filters, 'Filters'),
645642
}
646643
case 'load_data':
647644
return {
@@ -689,11 +686,7 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
689686
case 'call_procedure':
690687
return {
691688
...objectParams(),
692-
procedureArguments: parseJson(
693-
params.procedureArguments,
694-
'Procedure arguments',
695-
jsonBudget
696-
),
689+
procedureArguments: parseJson(params.procedureArguments, 'Procedure arguments'),
697690
}
698691
default:
699692
return {}
@@ -853,7 +846,7 @@ export const SnowflakeBlockMeta = {
853846
name: 'sync-snowflake-rows',
854847
description: 'Insert, update, or upsert structured records safely in Snowflake.',
855848
content:
856-
'# Synchronize Snowflake Rows\n\n## Steps\n1. Confirm the target table and record keys.\n2. Keep batches at or below 1000 records.\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.',
849+
'# 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.',
857850
},
858851
{
859852
name: 'load-snowflake-stage',

apps/sim/lib/integrations/integrations.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18175,7 +18175,7 @@
1817518175
},
1817618176
{
1817718177
"name": "Insert Rows",
18178-
"description": "Insert up to 1000 structured JSON rows using bound values."
18178+
"description": "Insert structured JSON rows using bound values."
1817918179
},
1818018180
{
1818118181
"name": "Update Rows",

apps/sim/tools/generated/tool-metadata.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/snowflake/insert_rows.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const insertRowsTool: ToolConfig<SnowflakeInsertRowsParams, SnowflakeInse
1818
id: 'snowflake_insert_rows',
1919
version: '1.0.0',
2020
name: 'Snowflake Insert Rows',
21-
description: 'Insert up to 1000 structured JSON rows using bound values.',
21+
description: 'Insert structured JSON rows using bound values.',
2222
params: {
2323
...snowflakeBaseParams,
2424
...snowflakeContextParams,

apps/sim/tools/snowflake/sql.test.ts

Lines changed: 21 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {
2323
normalizeBindings,
2424
qualifiedIdentifier,
2525
} from '@/tools/snowflake/sql'
26-
import { MAX_REQUEST_BYTES, MAX_WRITE_ROWS } from '@/tools/snowflake/utils'
2726

2827
const context = { host: 'acme.snowflakecomputing.com', apiKey: 'secret' }
2928
const table = { ...context, database: 'ANALYTICS', schema: 'PUBLIC', table: 'EVENTS' }
@@ -45,31 +44,25 @@ describe('Snowflake SQL builders', () => {
4544
expect(() => normalizeBindings({ '1': { type: 'NOPE', value: 'x' } } as never)).toThrow(
4645
'Unsupported'
4746
)
48-
expect(() =>
49-
normalizeBindings({ '1': { type: 'TEXT', value: 'x'.repeat(MAX_REQUEST_BYTES) } })
50-
).toThrow('exceeds')
47+
const largeValue = 'x'.repeat(1024 * 1024 + 1)
48+
expect(
49+
normalizeBindings({ '1': { type: 'TEXT', value: largeValue } })?.['1'].value
50+
).toHaveLength(largeValue.length)
5151
expect(SnowflakeBlock.inputs.bindings.description).toContain(
5252
'object keyed by 1-based positions'
5353
)
5454
expect(SnowflakeBlock.inputs.procedureArguments.description).toContain('ordered JSON array')
5555
})
5656

57-
it('rejects oversized JSON block inputs before parsing', () => {
57+
it('parses JSON block inputs above the former Snowflake-specific byte limit', () => {
5858
const mapParams = SnowflakeBlock.tools.config.params
5959
if (!mapParams) throw new Error('Snowflake block must map tool parameters')
60-
expect(() =>
61-
mapParams({
62-
operation: 'insert_rows',
63-
rows: `[{"payload":"${'x'.repeat(MAX_REQUEST_BYTES)}"}]`,
64-
})
65-
).toThrow('exceeds')
66-
expect(() =>
67-
mapParams({
68-
operation: 'update_rows',
69-
rows: `[{"payload":"${'x'.repeat(MAX_REQUEST_BYTES / 2)}"}]`,
70-
matchColumns: `["${'x'.repeat(MAX_REQUEST_BYTES / 2)}"]`,
71-
})
72-
).toThrow('exceeds')
60+
const payload = 'x'.repeat(1024 * 1024 + 1)
61+
const result = mapParams({
62+
operation: 'insert_rows',
63+
rows: `[{"payload":"${payload}"}]`,
64+
}) as { rows: Array<{ payload: string }> }
65+
expect(result.rows[0].payload).toHaveLength(payload.length)
7366
})
7467

7568
it('only coerces fields used by the selected block operation', () => {
@@ -171,23 +164,11 @@ describe('Snowflake SQL builders', () => {
171164
)
172165
})
173166

174-
it('rejects malformed or oversized structured writes', () => {
167+
it('rejects malformed structured writes', () => {
175168
expect(() => buildInsertRows({ ...table, rows: [] })).toThrow('non-empty')
176169
expect(() => buildInsertRows({ ...table, rows: [{ id: 1 }, { other: 2 }] })).toThrow(
177170
'same columns'
178171
)
179-
expect(() =>
180-
buildInsertRows({
181-
...table,
182-
rows: Array.from({ length: MAX_WRITE_ROWS + 1 }, (_, id) => ({ id })),
183-
})
184-
).toThrow('cannot exceed')
185-
expect(() =>
186-
buildInsertRows({
187-
...table,
188-
rows: [{ payload: { value: 'x'.repeat(MAX_REQUEST_BYTES) } }],
189-
})
190-
).toThrow('exceeds')
191172
expect(() => buildInsertRows({ ...table, rows: [{ id: 1 }, { ID: 2 }] })).toThrow(
192173
'same columns'
193174
)
@@ -202,11 +183,17 @@ describe('Snowflake SQL builders', () => {
202183
).toThrow('safe integers')
203184
})
204185

186+
it('builds structured writes above the former 1000-row limit', () => {
187+
const result = buildInsertRows({
188+
...table,
189+
rows: Array.from({ length: 1001 }, (_, id) => ({ id })),
190+
})
191+
expect(Object.keys(result.bindings ?? {})).toHaveLength(1001)
192+
expect(result.statement).toContain('VALUES (?)')
193+
})
194+
205195
it('requires delete filters and binds every filter value', () => {
206196
expect(() => buildDeleteRows({ ...table, filters: {} })).toThrow('cannot be empty')
207-
expect(() =>
208-
buildDeleteRows({ ...table, filters: { payload: 'x'.repeat(MAX_REQUEST_BYTES) } })
209-
).toThrow('exceeds')
210197
expect(buildDeleteRows({ ...table, filters: { id: 7, deleted_at: null } })).toEqual({
211198
statement: 'DELETE FROM ANALYTICS.PUBLIC.EVENTS WHERE id = ? AND deleted_at IS NULL',
212199
bindings: { '1': { type: 'FIXED', value: '7' } },
@@ -380,14 +367,5 @@ describe('Snowflake SQL builders', () => {
380367
procedureArguments: { type: 'TEXT', value: 'x' } as never,
381368
})
382369
).toThrow('JSON array')
383-
expect(() =>
384-
buildCallProcedure({
385-
...context,
386-
database: 'ANALYTICS',
387-
schema: 'PUBLIC',
388-
procedureName: 'REFRESH_MODEL',
389-
procedureArguments: [{ type: 'TEXT', value: 'x'.repeat(MAX_REQUEST_BYTES) }],
390-
})
391-
).toThrow('exceeds')
392370
})
393371
})

apps/sim/tools/snowflake/sql.ts

Lines changed: 1 addition & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,7 @@ import {
1414
type SnowflakeUpdateRowsParams,
1515
type SnowflakeWarehouseParams,
1616
} from '@/tools/snowflake/types'
17-
import {
18-
addSnowflakeRequestBytes,
19-
addSnowflakeRequestOverhead,
20-
MAX_WRITE_ROWS,
21-
normalizeMaxRows,
22-
type SnowflakeStatementSpec,
23-
} from '@/tools/snowflake/utils'
17+
import { normalizeMaxRows, type SnowflakeStatementSpec } from '@/tools/snowflake/utils'
2418

2519
const UNQUOTED_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/
2620
const QUOTED_IDENTIFIER = /^"(?:[^"]|"")+"$/
@@ -96,7 +90,6 @@ export function normalizeBindings(
9690
throw new Error('bindings must be a JSON object keyed by 1-based positions')
9791
}
9892
const normalized: Record<string, SnowflakeBinding> = {}
99-
let requestBytes = 0
10093
let hasBindings = false
10194
for (const position in input) {
10295
if (!Object.hasOwn(input, position)) continue
@@ -114,8 +107,6 @@ export function normalizeBindings(
114107
if (typeof binding.value !== 'string') {
115108
throw new Error(`binding ${position} value must be a string`)
116109
}
117-
requestBytes = addSnowflakeRequestOverhead(requestBytes, 32)
118-
requestBytes = addSnowflakeRequestBytes(requestBytes, position, binding.type, binding.value)
119110
normalized[position] = { type: binding.type, value: binding.value }
120111
}
121112
return hasBindings ? normalized : undefined
@@ -124,13 +115,10 @@ export function normalizeBindings(
124115
class BindingsBuilder {
125116
readonly bindings: Record<string, SnowflakeBinding> = {}
126117
private position = 0
127-
private requestBytes = 0
128118

129119
private addBinding(type: SnowflakeBinding['type'], value: string): string {
130120
this.position += 1
131121
const key = String(this.position)
132-
this.requestBytes = addSnowflakeRequestOverhead(this.requestBytes, 32)
133-
this.requestBytes = addSnowflakeRequestBytes(this.requestBytes, key, type, value)
134122
this.bindings[key] = { type, value }
135123
return '?'
136124
}
@@ -153,59 +141,15 @@ class BindingsBuilder {
153141
return this.addBinding('TEXT', value)
154142
}
155143
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
156-
assertJsonValueWithinRequestBudget(value)
157144
this.addBinding('TEXT', JSON.stringify(value))
158145
return 'PARSE_JSON(?)'
159146
}
160147
throw new Error(`Unsupported Snowflake row value type: ${typeof value}`)
161148
}
162149
}
163150

164-
function assertJsonValueWithinRequestBudget(value: unknown): void {
165-
const pending: Array<{ value: unknown; leave?: boolean }> = [{ value }]
166-
const active = new WeakSet<object>()
167-
let requestBytes = 0
168-
while (pending.length > 0) {
169-
const item = pending.pop()
170-
const current = item?.value
171-
if (item?.leave && current && typeof current === 'object') {
172-
active.delete(current)
173-
continue
174-
}
175-
if (typeof current === 'string') {
176-
requestBytes = addSnowflakeRequestOverhead(requestBytes, 4)
177-
requestBytes = addSnowflakeRequestBytes(requestBytes, current)
178-
continue
179-
}
180-
if (!current || typeof current !== 'object') {
181-
requestBytes = addSnowflakeRequestOverhead(requestBytes, 2)
182-
requestBytes = addSnowflakeRequestBytes(requestBytes, String(current))
183-
continue
184-
}
185-
if (active.has(current)) throw new Error('Snowflake row JSON values cannot be circular')
186-
active.add(current)
187-
pending.push({ value: current, leave: true })
188-
requestBytes = addSnowflakeRequestOverhead(requestBytes, 2)
189-
if (Array.isArray(current)) {
190-
for (const nestedValue of current) {
191-
requestBytes = addSnowflakeRequestOverhead(requestBytes, 1)
192-
pending.push({ value: nestedValue })
193-
}
194-
continue
195-
}
196-
for (const key in current) {
197-
if (!Object.hasOwn(current, key)) continue
198-
requestBytes = addSnowflakeRequestOverhead(requestBytes, 4)
199-
requestBytes = addSnowflakeRequestBytes(requestBytes, key)
200-
pending.push({ value: (current as Record<string, unknown>)[key] })
201-
}
202-
}
203-
}
204-
205151
function validateRows(rows: Array<Record<string, unknown>>): string[] {
206152
if (!Array.isArray(rows) || rows.length === 0) throw new Error('rows must be a non-empty array')
207-
if (rows.length > MAX_WRITE_ROWS) throw new Error(`rows cannot exceed ${MAX_WRITE_ROWS} items`)
208-
assertJsonValueWithinRequestBudget(rows)
209153
const columns = Object.keys(rows[0] ?? {})
210154
if (columns.length === 0) throw new Error('rows must contain at least one column')
211155
const signature = [...columns].sort().join('\u0000')
@@ -305,7 +249,6 @@ export function buildDeleteRows(params: SnowflakeDeleteRowsParams): SnowflakeSta
305249
if (!params.filters || Array.isArray(params.filters) || typeof params.filters !== 'object') {
306250
throw new Error('filters must be a JSON object')
307251
}
308-
assertJsonValueWithinRequestBudget(params.filters)
309252
const filters = Object.entries(params.filters)
310253
if (filters.length === 0) throw new Error('filters cannot be empty')
311254
const binds = new BindingsBuilder()
@@ -503,7 +446,6 @@ export function buildCallProcedure(params: SnowflakeCallProcedureParams): Snowfl
503446
if (!Array.isArray(procedureArguments)) {
504447
throw new Error('procedureArguments must be a JSON array')
505448
}
506-
assertJsonValueWithinRequestBudget(procedureArguments)
507449
const bindings: Record<string, SnowflakeBinding> = {}
508450
const placeholders = procedureArguments.map((argument, index) => {
509451
if (!SNOWFLAKE_BINDING_TYPES.includes(argument.type)) {

apps/sim/tools/snowflake/utils.test.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
99
import {
1010
buildSnowflakeStatementBody,
1111
getSnowflakeHeaders,
12-
MAX_REQUEST_BYTES,
1312
MAX_RESPONSE_BYTES,
1413
normalizeMaxRows,
1514
normalizeSnowflakeHost,
@@ -181,19 +180,20 @@ describe('Snowflake SQL API transport', () => {
181180
)
182181
})
183182

184-
it('enforces result row and request byte limits', () => {
183+
it('enforces result row limits without a Snowflake-specific request cap', () => {
185184
expect(normalizeMaxRows()).toBe(1000)
186185
expect(normalizeMaxRows(10_000)).toBe(10_000)
187186
expect(() => normalizeMaxRows(10_001)).toThrow('between 1 and 10000')
188-
expect(() =>
187+
const statement = 'x'.repeat(1024 * 1024 + 1)
188+
expect(
189189
buildSnowflakeStatementBody(
190190
{ host: 'acme.snowflakecomputing.com', apiKey: 'secret' },
191-
{ statement: 'x'.repeat(MAX_REQUEST_BYTES) }
192-
)
193-
).toThrow('exceeds')
191+
{ statement }
192+
).statement
193+
).toBe(statement)
194194
})
195195

196-
it('builds a bounded SQL API request body with execution context and bindings', () => {
196+
it('builds a SQL API request body with execution context and bindings', () => {
197197
expect(
198198
buildSnowflakeStatementBody(
199199
{
@@ -442,6 +442,7 @@ describe('Snowflake SQL API transport', () => {
442442
})
443443

444444
it('rejects HTTP and SQL-level failures', async () => {
445+
expect(MAX_RESPONSE_BYTES).toBe(10 * 1024 * 1024)
445446
await expect(
446447
transformSnowflakeResponse(jsonResponse({ message: 'Forbidden', code: '390100' }, 401))
447448
).rejects.toThrow('Forbidden')

0 commit comments

Comments
 (0)