diff --git a/apps/sim/blocks/blocks/google_sheets.ts b/apps/sim/blocks/blocks/google_sheets.ts index ef61d86444..eef0c84d0a 100644 --- a/apps/sim/blocks/blocks/google_sheets.ts +++ b/apps/sim/blocks/blocks/google_sheets.ts @@ -462,9 +462,15 @@ Return ONLY the range string - no sheet name, no explanations, no quotes.`, type: 'dropdown', options: [ { label: 'Contains', id: 'contains' }, + { label: 'Does Not Contain', id: 'not_contains' }, { label: 'Exact Match', id: 'exact' }, + { label: 'Not Equal To', id: 'not_equals' }, { label: 'Starts With', id: 'starts_with' }, { label: 'Ends With', id: 'ends_with' }, + { label: 'Greater Than', id: 'gt' }, + { label: 'Greater Than or Equal', id: 'gte' }, + { label: 'Less Than', id: 'lt' }, + { label: 'Less Than or Equal', id: 'lte' }, ], condition: { field: 'operation', value: 'read' }, mode: 'advanced', @@ -503,7 +509,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, { label: 'User Entered (Parse formulas)', id: 'USER_ENTERED' }, { label: "Raw (Don't parse formulas)", id: 'RAW' }, ], - condition: { field: 'operation', value: ['write', 'batch_update'] }, + condition: { field: 'operation', value: ['write', 'update', 'batch_update'] }, }, // Update-specific Fields { @@ -896,11 +902,15 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, type: 'string', description: 'Destination spreadsheet ID for copy', }, - filterColumn: { type: 'string', description: 'Column header name to filter on' }, + filterColumn: { + type: 'string', + description: 'Column header name to filter the read rows on (within the read range)', + }, filterValue: { type: 'string', description: 'Value to match against the filter column' }, filterMatchType: { type: 'string', - description: 'Match type: contains, exact, starts_with, or ends_with', + description: + 'Match type: contains, not_contains, exact, not_equals, starts_with, ends_with, gt, gte, lt, or lte', }, }, outputs: { @@ -920,6 +930,12 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, description: 'Cell values as 2D array', condition: { field: 'operation', value: 'read' }, }, + filter: { + type: 'json', + description: + 'Filter summary (present only when a filter was requested): applied, column, matchType, columnFound, matchedRows, totalRows', + condition: { field: 'operation', value: 'read' }, + }, // Write/Update/Append outputs updatedRange: { type: 'string', diff --git a/apps/sim/tools/google_sheets/append.ts b/apps/sim/tools/google_sheets/append.ts index b3cdfb06b9..7f290a2ce3 100644 --- a/apps/sim/tools/google_sheets/append.ts +++ b/apps/sim/tools/google_sheets/append.ts @@ -69,14 +69,14 @@ export const appendTool: ToolConfig { + it('passes values through unchanged when no filter column is provided', () => { + const result = filterSheetRows(VALUES, {}) + expect(result.applied).toBe(false) + expect(result.values).toBe(VALUES) + expect(result.totalRows).toBe(3) + }) + + it('passes through when filterValue is empty', () => { + const result = filterSheetRows(VALUES, { filterColumn: 'Status', filterValue: '' }) + expect(result.applied).toBe(false) + expect(result.values).toBe(VALUES) + }) + + it('defaults to case-insensitive contains', () => { + const result = filterSheetRows(VALUES, { filterColumn: 'status', filterValue: 'active' }) + expect(result.applied).toBe(true) + expect(result.columnFound).toBe(true) + expect(result.values).toEqual([VALUES[0], VALUES[1], VALUES[3]]) + expect(result.matchedRows).toBe(2) + }) + + it('matches column names case-insensitively and trims whitespace', () => { + const result = filterSheetRows(VALUES, { + filterColumn: ' EMAIL ', + filterValue: 'example.com', + }) + expect(result.columnFound).toBe(true) + expect(result.matchedRows).toBe(2) + }) + + it('supports exact and not_equals', () => { + expect( + filterSheetRows(VALUES, { + filterColumn: 'Status', + filterValue: 'Active', + filterMatchType: 'exact', + }).matchedRows + ).toBe(2) + expect( + filterSheetRows(VALUES, { + filterColumn: 'Status', + filterValue: 'Active', + filterMatchType: 'not_equals', + }).matchedRows + ).toBe(1) + }) + + it('supports starts_with, ends_with, and not_contains', () => { + expect( + filterSheetRows(VALUES, { + filterColumn: 'Email', + filterValue: 'bob', + filterMatchType: 'starts_with', + }).matchedRows + ).toBe(1) + expect( + filterSheetRows(VALUES, { + filterColumn: 'Email', + filterValue: '.com', + filterMatchType: 'ends_with', + }).matchedRows + ).toBe(3) + expect( + filterSheetRows(VALUES, { + filterColumn: 'Email', + filterValue: 'example.com', + filterMatchType: 'not_contains', + }).matchedRows + ).toBe(1) + }) + + it('compares numerically for ordering operators (not substring)', () => { + const gt = filterSheetRows(VALUES, { + filterColumn: 'Score', + filterValue: '50', + filterMatchType: 'gt', + }) + expect(gt.matchedRows).toBe(1) + expect(gt.values).toEqual([VALUES[0], VALUES[1]]) + + expect( + filterSheetRows(VALUES, { + filterColumn: 'Score', + filterValue: '40', + filterMatchType: 'gte', + }).matchedRows + ).toBe(2) + expect( + filterSheetRows(VALUES, { + filterColumn: 'Score', + filterValue: '40', + filterMatchType: 'lt', + }).matchedRows + ).toBe(1) + expect( + filterSheetRows(VALUES, { + filterColumn: 'Score', + filterValue: '40', + filterMatchType: 'lte', + }).matchedRows + ).toBe(2) + }) + + it('orders negative numbers correctly', () => { + const temps: unknown[][] = [ + ['City', 'Temp'], + ['A', '-5'], + ['B', '0'], + ['C', '-12'], + ['D', '3'], + ] + expect( + filterSheetRows(temps, { filterColumn: 'Temp', filterValue: '-5', filterMatchType: 'gte' }) + .matchedRows + ).toBe(3) + expect( + filterSheetRows(temps, { filterColumn: 'Temp', filterValue: '0', filterMatchType: 'lt' }) + .matchedRows + ).toBe(2) + }) + + it('excludes blank and non-numeric cells from numeric comparisons', () => { + const scores: unknown[][] = [ + ['Name', 'Score'], + ['Alice', '90'], + ['Bob', ''], + ['Carol', 'N/A'], + ['Dan', '60'], + ] + const result = filterSheetRows(scores, { + filterColumn: 'Score', + filterValue: '50', + filterMatchType: 'gt', + }) + expect(result.matchedRows).toBe(2) + expect(result.values).toEqual([scores[0], scores[1], scores[4]]) + }) + + it('falls back to lexicographic ordering when values are not numeric (ISO dates)', () => { + const dated: unknown[][] = [ + ['Task', 'Due'], + ['A', '2026-01-15'], + ['B', '2026-03-01'], + ['C', '2025-12-31'], + ] + const result = filterSheetRows(dated, { + filterColumn: 'Due', + filterValue: '2026-01-01', + filterMatchType: 'gte', + }) + expect(result.matchedRows).toBe(2) + }) + + it('reports columnFound=false and leaves values unchanged when the column is missing', () => { + const result = filterSheetRows(VALUES, { + filterColumn: 'Nonexistent', + filterValue: 'x', + }) + expect(result.applied).toBe(false) + expect(result.columnFound).toBe(false) + expect(result.matchedRows).toBe(0) + expect(result.values).toBe(VALUES) + expect(result.totalRows).toBe(3) + }) + + it('handles a header-only sheet and reports the column as found when it exists', () => { + const headerOnly: unknown[][] = [['Name', 'Status']] + const result = filterSheetRows(headerOnly, { filterColumn: 'Status', filterValue: 'Active' }) + expect(result.applied).toBe(false) + expect(result.columnFound).toBe(true) + expect(result.matchedRows).toBe(0) + expect(result.totalRows).toBe(0) + expect(result.values).toBe(headerOnly) + }) + + it('reports columnFound=false for a header-only sheet when the column is absent', () => { + const headerOnly: unknown[][] = [['Name', 'Status']] + const result = filterSheetRows(headerOnly, { filterColumn: 'Nonexistent', filterValue: 'x' }) + expect(result.applied).toBe(false) + expect(result.columnFound).toBe(false) + expect(result.matchedRows).toBe(0) + expect(result.values).toBe(headerOnly) + }) + + it('reports columnFound=false for an empty values array', () => { + const empty: unknown[][] = [] + const result = filterSheetRows(empty, { filterColumn: 'Status', filterValue: 'Active' }) + expect(result.applied).toBe(false) + expect(result.columnFound).toBe(false) + expect(result.matchedRows).toBe(0) + expect(result.totalRows).toBe(0) + }) + + it('treats missing cells as empty strings', () => { + const sparse: unknown[][] = [['Name', 'Status'], ['Alice'], ['Bob', 'Active']] + const result = filterSheetRows(sparse, { + filterColumn: 'Status', + filterValue: 'Active', + filterMatchType: 'exact', + }) + expect(result.matchedRows).toBe(1) + expect(result.values).toEqual([sparse[0], sparse[2]]) + }) + + it('always retains the header row in filtered output', () => { + const result = filterSheetRows(VALUES, { + filterColumn: 'Status', + filterValue: 'no-match', + filterMatchType: 'exact', + }) + expect(result.values).toEqual([VALUES[0]]) + expect(result.matchedRows).toBe(0) + }) +}) diff --git a/apps/sim/tools/google_sheets/filter.ts b/apps/sim/tools/google_sheets/filter.ts new file mode 100644 index 0000000000..ea4caa460a --- /dev/null +++ b/apps/sim/tools/google_sheets/filter.ts @@ -0,0 +1,152 @@ +/** + * Client-side row filtering for Google Sheets read results. + * + * The Google Sheets REST API (`spreadsheets.values.get`) has no server-side + * content filtering — `DataFilter` selects only by A1 range, grid range, or + * developer metadata, never by cell value. Filtering by cell content must + * therefore happen after values are fetched, over the window of rows the read + * returned (e.g. the default `A1:Z1000`), not the entire sheet. + */ + +/** + * Supported ways to compare a cell against the filter value. Text operators are + * case-insensitive. The ordering operators (`gt`/`gte`/`lt`/`lte`) compare + * numerically when both operands parse as finite numbers, fall back to + * case-insensitive lexicographic comparison when both are non-numeric (which + * orders ISO dates correctly), and never match when one side is numeric and the + * other is not (the values are not comparable). + */ +export type SheetFilterMatchType = + | 'contains' + | 'not_contains' + | 'exact' + | 'not_equals' + | 'starts_with' + | 'ends_with' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + +export interface SheetFilterOptions { + filterColumn?: string + filterValue?: string + filterMatchType?: SheetFilterMatchType +} + +export interface SheetFilterResult { + /** The (possibly filtered) values, always including the header row when present. */ + values: unknown[][] + /** Whether row filtering was actually applied to the data rows. */ + applied: boolean + /** Whether the requested filter column was found in the header row. */ + columnFound: boolean + /** Number of data rows (excluding the header) that matched the filter. */ + matchedRows: number + /** Total number of data rows (excluding the header) that were considered. */ + totalRows: number +} + +const DEFAULT_MATCH_TYPE: SheetFilterMatchType = 'contains' + +/** + * Parses a cell string as a finite number, or returns null when it is blank or + * non-numeric so callers can fall back to lexicographic comparison. + */ +function asFiniteNumber(value: string): number | null { + if (value.trim() === '') return null + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +/** Case-insensitive lexicographic comparison returning -1, 0, or 1. */ +function compareLexicographic(cell: string, target: string): number { + return Math.sign(cell.toLowerCase().localeCompare(target.toLowerCase())) +} + +/** Evaluates a single cell against the filter target for the given match type. */ +function matchesCell(cell: string, target: string, matchType: SheetFilterMatchType): boolean { + switch (matchType) { + case 'gt': + case 'gte': + case 'lt': + case 'lte': { + const cellNum = asFiniteNumber(cell) + const targetNum = asFiniteNumber(target) + let cmp: number + if (cellNum !== null && targetNum !== null) { + cmp = Math.sign(cellNum - targetNum) + } else if (cellNum === null && targetNum === null) { + cmp = compareLexicographic(cell, target) + } else { + return false + } + if (matchType === 'gt') return cmp > 0 + if (matchType === 'gte') return cmp >= 0 + if (matchType === 'lt') return cmp < 0 + return cmp <= 0 + } + case 'exact': + return cell.toLowerCase() === target.toLowerCase() + case 'not_equals': + return cell.toLowerCase() !== target.toLowerCase() + case 'starts_with': + return cell.toLowerCase().startsWith(target.toLowerCase()) + case 'ends_with': + return cell.toLowerCase().endsWith(target.toLowerCase()) + case 'not_contains': + return !cell.toLowerCase().includes(target.toLowerCase()) + default: + return cell.toLowerCase().includes(target.toLowerCase()) + } +} + +/** + * Filters a 2D values array (header row + data rows) by matching a single column + * against a target value. Returns the original values untouched when no filter + * is requested, when there are no data rows, or when the column is not found — + * the `applied`/`columnFound` flags let callers distinguish "no match possible" + * from "everything matched". + */ +export function filterSheetRows( + values: unknown[][], + options: SheetFilterOptions +): SheetFilterResult { + const { filterColumn, filterValue, filterMatchType } = options + const totalRows = Math.max(values.length - 1, 0) + + if (!filterColumn || filterValue === undefined || filterValue === '') { + return { values, applied: false, columnFound: true, matchedRows: totalRows, totalRows } + } + + const headers = values[0] ?? [] + const normalizedColumn = filterColumn.trim().toLowerCase() + const columnIndex = headers.findIndex( + (header) => String(header).trim().toLowerCase() === normalizedColumn + ) + const columnFound = columnIndex !== -1 + + // No data rows to evaluate (empty or header-only sheet): nothing matched, but + // still report whether the requested column actually exists in the header. + if (values.length <= 1) { + return { values, applied: false, columnFound, matchedRows: 0, totalRows: 0 } + } + + // Column not found: leave rows untouched and report zero matches, not totalRows. + if (!columnFound) { + return { values, applied: false, columnFound: false, matchedRows: 0, totalRows } + } + + const matchType = filterMatchType ?? DEFAULT_MATCH_TYPE + const matched = values + .slice(1) + .filter((row) => matchesCell(String(row[columnIndex] ?? ''), filterValue, matchType)) + + return { + values: [values[0], ...matched], + applied: true, + columnFound: true, + matchedRows: matched.length, + totalRows, + } +} diff --git a/apps/sim/tools/google_sheets/read.test.ts b/apps/sim/tools/google_sheets/read.test.ts new file mode 100644 index 0000000000..829969bcdb --- /dev/null +++ b/apps/sim/tools/google_sheets/read.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { readV2Tool } from '@/tools/google_sheets/read' +import type { GoogleSheetsV2ToolParams } from '@/tools/google_sheets/types' + +const SPREADSHEET_ID = 'abc123' +const URL = `https://sheets.googleapis.com/v4/spreadsheets/${SPREADSHEET_ID}/values/Sheet1!A1:Z1000` + +const SHEET_DATA = { + range: 'Sheet1!A1:D4', + values: [ + ['Name', 'Status'], + ['Alice', 'Active'], + ['Bob', 'Closed'], + ], +} + +function mockResponse(body: unknown, url = URL): Response { + // double-cast-allowed: lightweight Response stub for transformResponse unit test + return { url, json: async () => body } as unknown as Response +} + +const baseParams: GoogleSheetsV2ToolParams = { + accessToken: 'token', + spreadsheetId: SPREADSHEET_ID, + sheetName: 'Sheet1', +} + +describe('readV2Tool.transformResponse', () => { + it('returns values untouched and omits the filter field when no filter is requested', async () => { + const result = await readV2Tool.transformResponse!(mockResponse(SHEET_DATA), baseParams) + + expect(result.output.values).toEqual(SHEET_DATA.values) + expect('filter' in result.output).toBe(false) + expect(result.output.range).toBe(SHEET_DATA.range) + expect(result.output.metadata.spreadsheetId).toBe(SPREADSHEET_ID) + }) + + it('omits the filter field when filterColumn is set but filterValue is empty', async () => { + const result = await readV2Tool.transformResponse!(mockResponse(SHEET_DATA), { + ...baseParams, + filterColumn: 'Status', + filterValue: '', + }) + + expect('filter' in result.output).toBe(false) + expect(result.output.values).toEqual(SHEET_DATA.values) + }) + + it('filters rows and reports filter metadata when a filter is applied', async () => { + const result = await readV2Tool.transformResponse!(mockResponse(SHEET_DATA), { + ...baseParams, + filterColumn: 'Status', + filterValue: 'Active', + filterMatchType: 'exact', + }) + + expect(result.output.values).toEqual([ + ['Name', 'Status'], + ['Alice', 'Active'], + ]) + expect(result.output.filter).toEqual({ + applied: true, + column: 'Status', + matchType: 'exact', + columnFound: true, + matchedRows: 1, + totalRows: 2, + }) + }) + + it('leaves values unchanged and reports columnFound=false when the column is missing', async () => { + const result = await readV2Tool.transformResponse!(mockResponse(SHEET_DATA), { + ...baseParams, + filterColumn: 'Nonexistent', + filterValue: 'x', + }) + + expect(result.output.values).toEqual(SHEET_DATA.values) + expect(result.output.filter?.columnFound).toBe(false) + expect(result.output.filter?.applied).toBe(false) + expect(result.output.filter?.matchedRows).toBe(0) + }) + + it('handles a response with no values array', async () => { + const result = await readV2Tool.transformResponse!(mockResponse({ range: 'Sheet1!A1' }), { + ...baseParams, + filterColumn: 'Status', + filterValue: 'Active', + }) + + expect(result.output.values).toEqual([]) + expect(result.output.filter?.applied).toBe(false) + }) +}) diff --git a/apps/sim/tools/google_sheets/read.ts b/apps/sim/tools/google_sheets/read.ts index 7494884727..c3b8bfa994 100644 --- a/apps/sim/tools/google_sheets/read.ts +++ b/apps/sim/tools/google_sheets/read.ts @@ -1,3 +1,4 @@ +import { filterSheetRows } from '@/tools/google_sheets/filter' import type { GoogleSheetsReadResponse, GoogleSheetsToolParams, @@ -53,7 +54,7 @@ export const readTool: ToolConfig 1) { - const headers = values[0] as string[] - const columnIndex = headers.findIndex( - (h) => String(h).toLowerCase() === params.filterColumn!.toLowerCase() - ) + const filterRequested = + Boolean(params?.filterColumn) && + params?.filterValue !== undefined && + params?.filterValue !== '' - if (columnIndex !== -1) { - const matchType = params.filterMatchType ?? 'contains' - const filterVal = params.filterValue.toLowerCase() - - const filteredRows = values.slice(1).filter((row) => { - const cellValue = String(row[columnIndex] ?? '').toLowerCase() - switch (matchType) { - case 'exact': - return cellValue === filterVal - case 'starts_with': - return cellValue.startsWith(filterVal) - case 'ends_with': - return cellValue.endsWith(filterVal) - default: - return cellValue.includes(filterVal) - } - }) - - // Return header row + matching rows - values = [values[0], ...filteredRows] - } - } + const filterResult = filterSheetRows(rawValues, { + filterColumn: params?.filterColumn, + filterValue: params?.filterValue, + filterMatchType: params?.filterMatchType, + }) return { success: true, output: { sheetName: params?.sheetName ?? '', range: data.range ?? '', - values, + values: filterResult.values, metadata: { spreadsheetId: metadata.spreadsheetId, spreadsheetUrl: metadata.spreadsheetUrl, }, + ...(filterRequested + ? { + filter: { + applied: filterResult.applied, + column: params?.filterColumn ?? '', + matchType: params?.filterMatchType ?? 'contains', + columnFound: filterResult.columnFound, + matchedRows: filterResult.matchedRows, + totalRows: filterResult.totalRows, + }, + } + : {}), }, } }, diff --git a/apps/sim/tools/google_sheets/types.ts b/apps/sim/tools/google_sheets/types.ts index 8379d78ebe..119a71e305 100644 --- a/apps/sim/tools/google_sheets/types.ts +++ b/apps/sim/tools/google_sheets/types.ts @@ -1,6 +1,7 @@ import type { GoogleSheetsV2DeleteRowsResponse } from '@/tools/google_sheets/delete_rows' import type { GoogleSheetsV2DeleteSheetResponse } from '@/tools/google_sheets/delete_sheet' import type { GoogleSheetsV2DeleteSpreadsheetResponse } from '@/tools/google_sheets/delete_spreadsheet' +import type { SheetFilterMatchType } from '@/tools/google_sheets/filter' import type { ToolResponse } from '@/tools/types' interface GoogleSheetsRange { @@ -81,12 +82,28 @@ export type GoogleSheetsResponse = // V2 Types - with explicit sheetName parameter +export interface GoogleSheetsFilterInfo { + /** Whether row filtering was actually applied to the data rows. */ + applied: boolean + /** The column header the filter targeted. */ + column: string + /** The match type used for the comparison. */ + matchType: SheetFilterMatchType + /** Whether the requested filter column was found in the header row. */ + columnFound: boolean + /** Number of data rows (excluding the header) that matched the filter. */ + matchedRows: number + /** Total number of data rows (excluding the header) that were considered. */ + totalRows: number +} + export interface GoogleSheetsV2ReadResponse extends ToolResponse { output: { sheetName: string range: string values: any[][] metadata: GoogleSheetsMetadata + filter?: GoogleSheetsFilterInfo } } @@ -134,7 +151,7 @@ export interface GoogleSheetsV2ToolParams { majorDimension?: 'ROWS' | 'COLUMNS' filterColumn?: string filterValue?: string - filterMatchType?: 'contains' | 'exact' | 'starts_with' | 'ends_with' + filterMatchType?: SheetFilterMatchType } export type GoogleSheetsV2Response = diff --git a/apps/sim/tools/google_sheets/update.ts b/apps/sim/tools/google_sheets/update.ts index fb088c60c7..f6900e2bc8 100644 --- a/apps/sim/tools/google_sheets/update.ts +++ b/apps/sim/tools/google_sheets/update.ts @@ -63,7 +63,7 @@ export const updateTool: ToolConfig