mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(google-sheets): add row filtering to read with numeric operators (#4822)
* 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). * fix(google-sheets): correct filter metadata for missing column and header-only sheets - matchedRows is now 0 (not totalRows) when the filter column is not found, so it no longer contradicts applied=false / columnFound=false - columnFound now reflects an actual header lookup for empty/header-only sheets instead of being hardcoded true - add tests covering header-only and empty sheets with present/absent columns
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -69,14 +69,14 @@ export const appendTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsAppendRe
|
||||
const range = params.range || 'Sheet1'
|
||||
|
||||
const url = new URL(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId}/values/${encodeURIComponent(range)}:append`
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(range)}:append`
|
||||
)
|
||||
|
||||
// Default to USER_ENTERED if not specified
|
||||
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
|
||||
url.searchParams.append('valueInputOption', valueInputOption)
|
||||
|
||||
// Default to INSERT_ROWS if not specified
|
||||
// Only send insertDataOption when the user provides it; the API defaults to OVERWRITE
|
||||
if (params.insertDataOption) {
|
||||
url.searchParams.append('insertDataOption', params.insertDataOption)
|
||||
}
|
||||
@@ -294,7 +294,7 @@ export const appendV2Tool: ToolConfig<GoogleSheetsV2ToolParams, GoogleSheetsV2Ap
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId}/values/${encodeURIComponent(sheetName)}:append`
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(sheetName)}:append`
|
||||
)
|
||||
|
||||
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { filterSheetRows } from '@/tools/google_sheets/filter'
|
||||
|
||||
const VALUES: unknown[][] = [
|
||||
['Name', 'Email', 'Status', 'Score'],
|
||||
['Alice', 'alice@example.com', 'Active', '90'],
|
||||
['Bob', 'bob@test.com', 'Closed', '40'],
|
||||
['Carol', 'carol@example.com', 'Active', '7'],
|
||||
]
|
||||
|
||||
describe('filterSheetRows', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { filterSheetRows } from '@/tools/google_sheets/filter'
|
||||
import type {
|
||||
GoogleSheetsReadResponse,
|
||||
GoogleSheetsToolParams,
|
||||
@@ -53,7 +54,7 @@ export const readTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsReadRespon
|
||||
// Keep a generous column/row bound to avoid huge payloads
|
||||
if (!params.range) {
|
||||
const defaultRange = 'A1:Z1000'
|
||||
return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${defaultRange}`
|
||||
return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(defaultRange)}`
|
||||
}
|
||||
|
||||
// Otherwise, get values from the specified range
|
||||
@@ -159,7 +160,7 @@ export const readV2Tool: ToolConfig<GoogleSheetsV2ToolParams, GoogleSheetsV2Read
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Column name (from header row) to filter on. If not provided, no filtering is applied.',
|
||||
'Column name (from the header row) to filter on. Filtering is applied to the rows returned by the read range (the default is A1:Z1000), not the entire sheet. If not provided, no filtering is applied.',
|
||||
},
|
||||
filterValue: {
|
||||
type: 'string',
|
||||
@@ -172,7 +173,7 @@ export const readV2Tool: ToolConfig<GoogleSheetsV2ToolParams, GoogleSheetsV2Read
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'How to match the filter value: "contains", "exact", "starts_with", or "ends_with". Defaults to "contains".',
|
||||
'How to match the filter value. Text: "contains", "not_contains", "exact", "not_equals", "starts_with", "ends_with". Numeric/ordering: "gt", "gte", "lt", "lte" (numeric when both values are numbers, otherwise lexicographic). Defaults to "contains".',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -216,48 +217,41 @@ export const readV2Tool: ToolConfig<GoogleSheetsV2ToolParams, GoogleSheetsV2Read
|
||||
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
|
||||
}
|
||||
|
||||
let values: unknown[][] = data.values ?? []
|
||||
const rawValues: unknown[][] = data.values ?? []
|
||||
|
||||
// Apply client-side filtering only when both filterColumn and filterValue are provided
|
||||
if (params?.filterColumn && params?.filterValue !== undefined && values.length > 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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -63,7 +63,7 @@ export const updateTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsUpdateRe
|
||||
const range = params.range || 'Sheet1!A2'
|
||||
|
||||
const url = new URL(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId}/values/${encodeURIComponent(range)}`
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(range)}`
|
||||
)
|
||||
|
||||
// Default to USER_ENTERED if not specified
|
||||
@@ -251,7 +251,7 @@ export const updateV2Tool: ToolConfig<GoogleSheetsV2ToolParams, GoogleSheetsV2Up
|
||||
const fullRange = `${sheetName}!${cellRange}`
|
||||
|
||||
const url = new URL(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId}/values/${encodeURIComponent(fullRange)}`
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(fullRange)}`
|
||||
)
|
||||
|
||||
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
|
||||
|
||||
@@ -63,7 +63,7 @@ export const writeTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsWriteResp
|
||||
const range = params.range || 'Sheet1!A2'
|
||||
|
||||
const url = new URL(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId}/values/${encodeURIComponent(range)}`
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(range)}`
|
||||
)
|
||||
|
||||
// Default to USER_ENTERED if not specified
|
||||
@@ -253,7 +253,7 @@ export const writeV2Tool: ToolConfig<GoogleSheetsV2ToolParams, GoogleSheetsV2Wri
|
||||
const fullRange = `${sheetName}!${cellRange}`
|
||||
|
||||
const url = new URL(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId}/values/${encodeURIComponent(fullRange)}`
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(fullRange)}`
|
||||
)
|
||||
|
||||
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
|
||||
|
||||
Reference in New Issue
Block a user