Skip to content

Commit 1689dca

Browse files
committed
feat(google-sheets): add row filtering to read with numeric operators
Adds client-side row filtering to the Google Sheets read (v2) operation. Filter the returned rows by a header column using text operators (contains, not_contains, exact, not_equals, starts_with, ends_with) and numeric/ordering operators (gt, gte, lt, lte). Filtering lives in a pure, unit-tested helper (filterSheetRows) and runs over the fetched read range; an optional `filter` output reports whether the column was found and how many rows matched. Also hardens the surrounding tools: - trim spreadsheetId in write/update/append URL builders (matches read) - URL-encode the v1 read default range - expose valueInputOption for the update operation in the block Backwards compatible: with no filter requested, read output is byte- identical and the `filter` field is omitted. The filterMatchType union is widened additively (4 -> 10 values).
1 parent 6c476cf commit 1689dca

9 files changed

Lines changed: 522 additions & 44 deletions

File tree

apps/sim/blocks/blocks/google_sheets.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -462,9 +462,15 @@ Return ONLY the range string - no sheet name, no explanations, no quotes.`,
462462
type: 'dropdown',
463463
options: [
464464
{ label: 'Contains', id: 'contains' },
465+
{ label: 'Does Not Contain', id: 'not_contains' },
465466
{ label: 'Exact Match', id: 'exact' },
467+
{ label: 'Not Equal To', id: 'not_equals' },
466468
{ label: 'Starts With', id: 'starts_with' },
467469
{ label: 'Ends With', id: 'ends_with' },
470+
{ label: 'Greater Than', id: 'gt' },
471+
{ label: 'Greater Than or Equal', id: 'gte' },
472+
{ label: 'Less Than', id: 'lt' },
473+
{ label: 'Less Than or Equal', id: 'lte' },
468474
],
469475
condition: { field: 'operation', value: 'read' },
470476
mode: 'advanced',
@@ -503,7 +509,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
503509
{ label: 'User Entered (Parse formulas)', id: 'USER_ENTERED' },
504510
{ label: "Raw (Don't parse formulas)", id: 'RAW' },
505511
],
506-
condition: { field: 'operation', value: ['write', 'batch_update'] },
512+
condition: { field: 'operation', value: ['write', 'update', 'batch_update'] },
507513
},
508514
// Update-specific Fields
509515
{
@@ -896,11 +902,15 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
896902
type: 'string',
897903
description: 'Destination spreadsheet ID for copy',
898904
},
899-
filterColumn: { type: 'string', description: 'Column header name to filter on' },
905+
filterColumn: {
906+
type: 'string',
907+
description: 'Column header name to filter the read rows on (within the read range)',
908+
},
900909
filterValue: { type: 'string', description: 'Value to match against the filter column' },
901910
filterMatchType: {
902911
type: 'string',
903-
description: 'Match type: contains, exact, starts_with, or ends_with',
912+
description:
913+
'Match type: contains, not_contains, exact, not_equals, starts_with, ends_with, gt, gte, lt, or lte',
904914
},
905915
},
906916
outputs: {
@@ -920,6 +930,12 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
920930
description: 'Cell values as 2D array',
921931
condition: { field: 'operation', value: 'read' },
922932
},
933+
filter: {
934+
type: 'json',
935+
description:
936+
'Filter summary (present only when a filter was requested): applied, column, matchType, columnFound, matchedRows, totalRows',
937+
condition: { field: 'operation', value: 'read' },
938+
},
923939
// Write/Update/Append outputs
924940
updatedRange: {
925941
type: 'string',

apps/sim/tools/google_sheets/append.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,14 @@ export const appendTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsAppendRe
6969
const range = params.range || 'Sheet1'
7070

7171
const url = new URL(
72-
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId}/values/${encodeURIComponent(range)}:append`
72+
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(range)}:append`
7373
)
7474

7575
// Default to USER_ENTERED if not specified
7676
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
7777
url.searchParams.append('valueInputOption', valueInputOption)
7878

79-
// Default to INSERT_ROWS if not specified
79+
// Only send insertDataOption when the user provides it; the API defaults to OVERWRITE
8080
if (params.insertDataOption) {
8181
url.searchParams.append('insertDataOption', params.insertDataOption)
8282
}
@@ -294,7 +294,7 @@ export const appendV2Tool: ToolConfig<GoogleSheetsV2ToolParams, GoogleSheetsV2Ap
294294
}
295295

296296
const url = new URL(
297-
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId}/values/${encodeURIComponent(sheetName)}:append`
297+
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(sheetName)}:append`
298298
)
299299

300300
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { filterSheetRows } from '@/tools/google_sheets/filter'
6+
7+
const VALUES: unknown[][] = [
8+
['Name', 'Email', 'Status', 'Score'],
9+
['Alice', 'alice@example.com', 'Active', '90'],
10+
['Bob', 'bob@test.com', 'Closed', '40'],
11+
['Carol', 'carol@example.com', 'Active', '7'],
12+
]
13+
14+
describe('filterSheetRows', () => {
15+
it('passes values through unchanged when no filter column is provided', () => {
16+
const result = filterSheetRows(VALUES, {})
17+
expect(result.applied).toBe(false)
18+
expect(result.values).toBe(VALUES)
19+
expect(result.totalRows).toBe(3)
20+
})
21+
22+
it('passes through when filterValue is empty', () => {
23+
const result = filterSheetRows(VALUES, { filterColumn: 'Status', filterValue: '' })
24+
expect(result.applied).toBe(false)
25+
expect(result.values).toBe(VALUES)
26+
})
27+
28+
it('defaults to case-insensitive contains', () => {
29+
const result = filterSheetRows(VALUES, { filterColumn: 'status', filterValue: 'active' })
30+
expect(result.applied).toBe(true)
31+
expect(result.columnFound).toBe(true)
32+
expect(result.values).toEqual([VALUES[0], VALUES[1], VALUES[3]])
33+
expect(result.matchedRows).toBe(2)
34+
})
35+
36+
it('matches column names case-insensitively and trims whitespace', () => {
37+
const result = filterSheetRows(VALUES, {
38+
filterColumn: ' EMAIL ',
39+
filterValue: 'example.com',
40+
})
41+
expect(result.columnFound).toBe(true)
42+
expect(result.matchedRows).toBe(2)
43+
})
44+
45+
it('supports exact and not_equals', () => {
46+
expect(
47+
filterSheetRows(VALUES, {
48+
filterColumn: 'Status',
49+
filterValue: 'Active',
50+
filterMatchType: 'exact',
51+
}).matchedRows
52+
).toBe(2)
53+
expect(
54+
filterSheetRows(VALUES, {
55+
filterColumn: 'Status',
56+
filterValue: 'Active',
57+
filterMatchType: 'not_equals',
58+
}).matchedRows
59+
).toBe(1)
60+
})
61+
62+
it('supports starts_with, ends_with, and not_contains', () => {
63+
expect(
64+
filterSheetRows(VALUES, {
65+
filterColumn: 'Email',
66+
filterValue: 'bob',
67+
filterMatchType: 'starts_with',
68+
}).matchedRows
69+
).toBe(1)
70+
expect(
71+
filterSheetRows(VALUES, {
72+
filterColumn: 'Email',
73+
filterValue: '.com',
74+
filterMatchType: 'ends_with',
75+
}).matchedRows
76+
).toBe(3)
77+
expect(
78+
filterSheetRows(VALUES, {
79+
filterColumn: 'Email',
80+
filterValue: 'example.com',
81+
filterMatchType: 'not_contains',
82+
}).matchedRows
83+
).toBe(1)
84+
})
85+
86+
it('compares numerically for ordering operators (not substring)', () => {
87+
const gt = filterSheetRows(VALUES, {
88+
filterColumn: 'Score',
89+
filterValue: '50',
90+
filterMatchType: 'gt',
91+
})
92+
expect(gt.matchedRows).toBe(1)
93+
expect(gt.values).toEqual([VALUES[0], VALUES[1]])
94+
95+
expect(
96+
filterSheetRows(VALUES, {
97+
filterColumn: 'Score',
98+
filterValue: '40',
99+
filterMatchType: 'gte',
100+
}).matchedRows
101+
).toBe(2)
102+
expect(
103+
filterSheetRows(VALUES, {
104+
filterColumn: 'Score',
105+
filterValue: '40',
106+
filterMatchType: 'lt',
107+
}).matchedRows
108+
).toBe(1)
109+
expect(
110+
filterSheetRows(VALUES, {
111+
filterColumn: 'Score',
112+
filterValue: '40',
113+
filterMatchType: 'lte',
114+
}).matchedRows
115+
).toBe(2)
116+
})
117+
118+
it('orders negative numbers correctly', () => {
119+
const temps: unknown[][] = [
120+
['City', 'Temp'],
121+
['A', '-5'],
122+
['B', '0'],
123+
['C', '-12'],
124+
['D', '3'],
125+
]
126+
expect(
127+
filterSheetRows(temps, { filterColumn: 'Temp', filterValue: '-5', filterMatchType: 'gte' })
128+
.matchedRows
129+
).toBe(3)
130+
expect(
131+
filterSheetRows(temps, { filterColumn: 'Temp', filterValue: '0', filterMatchType: 'lt' })
132+
.matchedRows
133+
).toBe(2)
134+
})
135+
136+
it('excludes blank and non-numeric cells from numeric comparisons', () => {
137+
const scores: unknown[][] = [
138+
['Name', 'Score'],
139+
['Alice', '90'],
140+
['Bob', ''],
141+
['Carol', 'N/A'],
142+
['Dan', '60'],
143+
]
144+
const result = filterSheetRows(scores, {
145+
filterColumn: 'Score',
146+
filterValue: '50',
147+
filterMatchType: 'gt',
148+
})
149+
expect(result.matchedRows).toBe(2)
150+
expect(result.values).toEqual([scores[0], scores[1], scores[4]])
151+
})
152+
153+
it('falls back to lexicographic ordering when values are not numeric (ISO dates)', () => {
154+
const dated: unknown[][] = [
155+
['Task', 'Due'],
156+
['A', '2026-01-15'],
157+
['B', '2026-03-01'],
158+
['C', '2025-12-31'],
159+
]
160+
const result = filterSheetRows(dated, {
161+
filterColumn: 'Due',
162+
filterValue: '2026-01-01',
163+
filterMatchType: 'gte',
164+
})
165+
expect(result.matchedRows).toBe(2)
166+
})
167+
168+
it('reports columnFound=false and leaves values unchanged when the column is missing', () => {
169+
const result = filterSheetRows(VALUES, {
170+
filterColumn: 'Nonexistent',
171+
filterValue: 'x',
172+
})
173+
expect(result.applied).toBe(false)
174+
expect(result.columnFound).toBe(false)
175+
expect(result.values).toBe(VALUES)
176+
expect(result.totalRows).toBe(3)
177+
})
178+
179+
it('handles a header-only sheet without error', () => {
180+
const headerOnly: unknown[][] = [['Name', 'Status']]
181+
const result = filterSheetRows(headerOnly, { filterColumn: 'Status', filterValue: 'Active' })
182+
expect(result.applied).toBe(false)
183+
expect(result.totalRows).toBe(0)
184+
expect(result.values).toBe(headerOnly)
185+
})
186+
187+
it('treats missing cells as empty strings', () => {
188+
const sparse: unknown[][] = [['Name', 'Status'], ['Alice'], ['Bob', 'Active']]
189+
const result = filterSheetRows(sparse, {
190+
filterColumn: 'Status',
191+
filterValue: 'Active',
192+
filterMatchType: 'exact',
193+
})
194+
expect(result.matchedRows).toBe(1)
195+
expect(result.values).toEqual([sparse[0], sparse[2]])
196+
})
197+
198+
it('always retains the header row in filtered output', () => {
199+
const result = filterSheetRows(VALUES, {
200+
filterColumn: 'Status',
201+
filterValue: 'no-match',
202+
filterMatchType: 'exact',
203+
})
204+
expect(result.values).toEqual([VALUES[0]])
205+
expect(result.matchedRows).toBe(0)
206+
})
207+
})

0 commit comments

Comments
 (0)