Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(sheets): QUERY() function for SQL-like data queries (#85)

Add QUERY(data, query, [headers]) with support for:
- SELECT (specific columns or *)
- WHERE (comparison operators: >, <, >=, <=, =, !=, <>)
- ORDER BY (ASC/DESC)
- LIMIT

Column references use Col1, Col2, etc. Query module is standalone
with 30 unit tests covering parsing, comparison, and execution.

+432
+12
src/sheets/formulas.ts
··· 7 7 8 8 import type { CellRef, CellValue, CrossSheetResolver, NamedRangesMap, RangeArray, FormatType } from './types.js'; 9 9 import { parseSparklineArgs } from './sparkline.js'; 10 + import { executeQuery } from './query.js'; 10 11 11 12 // --- Tokenizer --- 12 13 type TokenTypeValue = 'NUMBER' | 'STRING' | 'BOOLEAN' | 'CELL_REF' | 'CROSS_SHEET_REF' | 'RANGE' | 'FUNCTION' | 'IDENTIFIER' | 'OPERATOR' | 'LPAREN' | 'RPAREN' | 'COMMA' | 'COLON' | 'BANG' | 'EOF'; ··· 1267 1268 const chIdx = Math.floor(toNum(args[0])); 1268 1269 if (chIdx < 1 || chIdx >= args.length) return '#VALUE!'; 1269 1270 return args[chIdx]; 1271 + } 1272 + 1273 + // --- QUERY (#85) --- 1274 + case 'QUERY': { 1275 + // QUERY(data, query, [headers]) 1276 + const source = Array.isArray(args[0]) ? args[0] : [args[0]]; 1277 + const queryStr = String(args[1] ?? ''); 1278 + const hasHeaders = args[2] !== undefined ? Boolean(args[2]) : true; 1279 + const rows = (source as RangeArray)._rangeRows || source.length; 1280 + const cols = (source as RangeArray)._rangeCols || 1; 1281 + return executeQuery(source, rows, cols, queryStr, hasHeaders); 1270 1282 } 1271 1283 1272 1284 // --- Sparkline ---
+234
src/sheets/query.ts
··· 1 + /** 2 + * QUERY() function (#85) 3 + * 4 + * A simplified Google-Sheets-compatible QUERY function that supports: 5 + * SELECT — column selection (Col1, Col2, etc.) 6 + * WHERE — row filtering with comparison operators 7 + * ORDER BY — sorting by columns (ASC/DESC) 8 + * LIMIT — row count limiting 9 + * 10 + * Column references use Col1, Col2, etc. (1-based). 11 + * Query syntax: "SELECT Col1, Col3 WHERE Col2 > 10 ORDER BY Col1 LIMIT 5" 12 + */ 13 + 14 + import type { RangeArray } from './types.js'; 15 + 16 + export interface QueryResult { 17 + data: unknown[][]; 18 + cols: number; 19 + } 20 + 21 + interface ParsedQuery { 22 + selectCols: number[] | null; // null = all columns 23 + where: WhereClause | null; 24 + orderBy: OrderClause | null; 25 + limit: number | null; 26 + } 27 + 28 + interface WhereClause { 29 + col: number; 30 + op: string; 31 + value: unknown; 32 + } 33 + 34 + interface OrderClause { 35 + col: number; 36 + desc: boolean; 37 + } 38 + 39 + /** 40 + * Parse a column reference like "Col1" or "Col3" to 0-based index. 41 + */ 42 + export function parseColRef(ref: string): number { 43 + const match = ref.match(/^Col(\d+)$/i); 44 + if (!match) return -1; 45 + return parseInt(match[1], 10) - 1; 46 + } 47 + 48 + /** 49 + * Parse a query string into structured clauses. 50 + */ 51 + export function parseQuery(query: string): ParsedQuery { 52 + const result: ParsedQuery = { 53 + selectCols: null, 54 + where: null, 55 + orderBy: null, 56 + limit: null, 57 + }; 58 + 59 + // Normalize whitespace 60 + let q = query.trim(); 61 + 62 + // Extract LIMIT 63 + const limitMatch = q.match(/\bLIMIT\s+(\d+)\s*$/i); 64 + if (limitMatch) { 65 + result.limit = parseInt(limitMatch[1], 10); 66 + q = q.slice(0, limitMatch.index).trim(); 67 + } 68 + 69 + // Extract ORDER BY 70 + const orderMatch = q.match(/\bORDER\s+BY\s+(Col\d+)(?:\s+(ASC|DESC))?\s*$/i); 71 + if (orderMatch) { 72 + result.orderBy = { 73 + col: parseColRef(orderMatch[1]), 74 + desc: (orderMatch[2] || '').toUpperCase() === 'DESC', 75 + }; 76 + q = q.slice(0, orderMatch.index).trim(); 77 + } 78 + 79 + // Extract WHERE 80 + const whereMatch = q.match(/\bWHERE\s+(Col\d+)\s*(>=|<=|!=|<>|>|<|=)\s*(.+?)\s*$/i); 81 + if (whereMatch) { 82 + result.where = { 83 + col: parseColRef(whereMatch[1]), 84 + op: whereMatch[2], 85 + value: parseValue(whereMatch[3].trim()), 86 + }; 87 + q = q.slice(0, whereMatch.index).trim(); 88 + } 89 + 90 + // Extract SELECT 91 + const selectMatch = q.match(/^SELECT\s+(.+)$/i); 92 + if (selectMatch) { 93 + const colStr = selectMatch[1].trim(); 94 + if (colStr !== '*') { 95 + result.selectCols = colStr.split(/\s*,\s*/).map(parseColRef).filter(c => c >= 0); 96 + } 97 + } 98 + 99 + return result; 100 + } 101 + 102 + /** 103 + * Parse a literal value from a WHERE clause. 104 + */ 105 + function parseValue(raw: string): unknown { 106 + // Quoted string 107 + if ((raw.startsWith("'") && raw.endsWith("'")) || (raw.startsWith('"') && raw.endsWith('"'))) { 108 + return raw.slice(1, -1); 109 + } 110 + // Boolean 111 + if (raw.toUpperCase() === 'TRUE') return true; 112 + if (raw.toUpperCase() === 'FALSE') return false; 113 + // Number 114 + const n = Number(raw); 115 + if (!isNaN(n)) return n; 116 + // Fallback to string 117 + return raw; 118 + } 119 + 120 + /** 121 + * Compare a cell value against a WHERE clause value using the given operator. 122 + */ 123 + export function compareValues(cellValue: unknown, op: string, target: unknown): boolean { 124 + // Normalize empty 125 + if (cellValue === '' || cellValue === null || cellValue === undefined) { 126 + if (op === '=' && (target === '' || target === null || target === undefined)) return true; 127 + if (op === '!=' || op === '<>') return target !== '' && target !== null && target !== undefined; 128 + return false; 129 + } 130 + 131 + // Numeric comparison 132 + const cv = typeof cellValue === 'number' ? cellValue : Number(cellValue); 133 + const tv = typeof target === 'number' ? target : Number(target); 134 + if (!isNaN(cv) && !isNaN(tv)) { 135 + switch (op) { 136 + case '>': return cv > tv; 137 + case '<': return cv < tv; 138 + case '>=': return cv >= tv; 139 + case '<=': return cv <= tv; 140 + case '=': return cv === tv; 141 + case '!=': case '<>': return cv !== tv; 142 + } 143 + } 144 + 145 + // String comparison 146 + const cs = String(cellValue).toLowerCase(); 147 + const ts = String(target).toLowerCase(); 148 + switch (op) { 149 + case '=': return cs === ts; 150 + case '!=': case '<>': return cs !== ts; 151 + case '>': return cs > ts; 152 + case '<': return cs < ts; 153 + case '>=': return cs >= ts; 154 + case '<=': return cs <= ts; 155 + } 156 + 157 + return false; 158 + } 159 + 160 + /** 161 + * Execute a QUERY against a flat range array. 162 + * 163 + * @param data Flat array of cell values (row-major) 164 + * @param rows Number of rows 165 + * @param cols Number of columns 166 + * @param query Query string 167 + * @param headers Whether first row contains headers (default: true) 168 + * @returns RangeArray with results or error string 169 + */ 170 + export function executeQuery( 171 + data: unknown[], 172 + rows: number, 173 + cols: number, 174 + query: string, 175 + headers = true, 176 + ): RangeArray | string { 177 + const parsed = parseQuery(query); 178 + const startRow = headers ? 1 : 0; 179 + 180 + // Build row arrays 181 + let rowArrays: unknown[][] = []; 182 + for (let r = startRow; r < rows; r++) { 183 + const row: unknown[] = []; 184 + for (let c = 0; c < cols; c++) { 185 + row.push(data[r * cols + c]); 186 + } 187 + rowArrays.push(row); 188 + } 189 + 190 + // WHERE filter 191 + if (parsed.where) { 192 + const { col, op, value } = parsed.where; 193 + if (col >= 0 && col < cols) { 194 + rowArrays = rowArrays.filter(row => compareValues(row[col], op, value)); 195 + } 196 + } 197 + 198 + // ORDER BY 199 + if (parsed.orderBy) { 200 + const { col, desc } = parsed.orderBy; 201 + if (col >= 0 && col < cols) { 202 + rowArrays.sort((a, b) => { 203 + const va = a[col]; 204 + const vb = b[col]; 205 + const na = typeof va === 'number' ? va : Number(va); 206 + const nb = typeof vb === 'number' ? vb : Number(vb); 207 + let cmp: number; 208 + if (!isNaN(na) && !isNaN(nb)) { 209 + cmp = na - nb; 210 + } else { 211 + cmp = String(va ?? '').localeCompare(String(vb ?? '')); 212 + } 213 + return desc ? -cmp : cmp; 214 + }); 215 + } 216 + } 217 + 218 + // LIMIT 219 + if (parsed.limit !== null && parsed.limit >= 0) { 220 + rowArrays = rowArrays.slice(0, parsed.limit); 221 + } 222 + 223 + // SELECT columns 224 + const selectCols = parsed.selectCols || Array.from({ length: cols }, (_, i) => i); 225 + const resultRows = rowArrays.map(row => selectCols.map(c => row[c] ?? '')); 226 + 227 + if (resultRows.length === 0) return '#N/A'; 228 + 229 + // Flatten to RangeArray 230 + const flat: RangeArray = resultRows.flat() as RangeArray; 231 + flat._rangeRows = resultRows.length; 232 + flat._rangeCols = selectCols.length; 233 + return flat; 234 + }
+186
tests/query.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { parseColRef, parseQuery, compareValues, executeQuery } from '../src/sheets/query.js'; 3 + 4 + describe('parseColRef', () => { 5 + it('parses Col1 to index 0', () => { 6 + expect(parseColRef('Col1')).toBe(0); 7 + }); 8 + 9 + it('parses Col3 to index 2', () => { 10 + expect(parseColRef('Col3')).toBe(2); 11 + }); 12 + 13 + it('is case-insensitive', () => { 14 + expect(parseColRef('col2')).toBe(1); 15 + }); 16 + 17 + it('returns -1 for invalid ref', () => { 18 + expect(parseColRef('A1')).toBe(-1); 19 + expect(parseColRef('foo')).toBe(-1); 20 + }); 21 + }); 22 + 23 + describe('parseQuery', () => { 24 + it('parses SELECT *', () => { 25 + const q = parseQuery('SELECT *'); 26 + expect(q.selectCols).toBeNull(); 27 + }); 28 + 29 + it('parses SELECT with specific columns', () => { 30 + const q = parseQuery('SELECT Col1, Col3'); 31 + expect(q.selectCols).toEqual([0, 2]); 32 + }); 33 + 34 + it('parses WHERE clause', () => { 35 + const q = parseQuery('SELECT * WHERE Col2 > 10'); 36 + expect(q.where).toEqual({ col: 1, op: '>', value: 10 }); 37 + }); 38 + 39 + it('parses WHERE with string value', () => { 40 + const q = parseQuery("SELECT * WHERE Col1 = 'hello'"); 41 + expect(q.where).toEqual({ col: 0, op: '=', value: 'hello' }); 42 + }); 43 + 44 + it('parses ORDER BY ascending', () => { 45 + const q = parseQuery('SELECT * ORDER BY Col1'); 46 + expect(q.orderBy).toEqual({ col: 0, desc: false }); 47 + }); 48 + 49 + it('parses ORDER BY descending', () => { 50 + const q = parseQuery('SELECT * ORDER BY Col2 DESC'); 51 + expect(q.orderBy).toEqual({ col: 1, desc: true }); 52 + }); 53 + 54 + it('parses LIMIT', () => { 55 + const q = parseQuery('SELECT * LIMIT 5'); 56 + expect(q.limit).toBe(5); 57 + }); 58 + 59 + it('parses full query with all clauses', () => { 60 + const q = parseQuery('SELECT Col1, Col3 WHERE Col2 > 5 ORDER BY Col1 DESC LIMIT 10'); 61 + expect(q.selectCols).toEqual([0, 2]); 62 + expect(q.where).toEqual({ col: 1, op: '>', value: 5 }); 63 + expect(q.orderBy).toEqual({ col: 0, desc: true }); 64 + expect(q.limit).toBe(10); 65 + }); 66 + 67 + it('parses != operator', () => { 68 + const q = parseQuery('SELECT * WHERE Col1 != 0'); 69 + expect(q.where!.op).toBe('!='); 70 + }); 71 + 72 + it('parses >= operator', () => { 73 + const q = parseQuery('SELECT * WHERE Col1 >= 100'); 74 + expect(q.where!.op).toBe('>='); 75 + }); 76 + }); 77 + 78 + describe('compareValues', () => { 79 + it('numeric greater than', () => { 80 + expect(compareValues(10, '>', 5)).toBe(true); 81 + expect(compareValues(3, '>', 5)).toBe(false); 82 + }); 83 + 84 + it('numeric less than', () => { 85 + expect(compareValues(3, '<', 5)).toBe(true); 86 + }); 87 + 88 + it('numeric equality', () => { 89 + expect(compareValues(5, '=', 5)).toBe(true); 90 + expect(compareValues(5, '=', 6)).toBe(false); 91 + }); 92 + 93 + it('numeric inequality', () => { 94 + expect(compareValues(5, '!=', 6)).toBe(true); 95 + expect(compareValues(5, '!=', 5)).toBe(false); 96 + }); 97 + 98 + it('string equality (case-insensitive)', () => { 99 + expect(compareValues('Hello', '=', 'hello')).toBe(true); 100 + }); 101 + 102 + it('string inequality', () => { 103 + expect(compareValues('a', '<>', 'b')).toBe(true); 104 + }); 105 + 106 + it('empty value handling', () => { 107 + expect(compareValues('', '=', '')).toBe(true); 108 + expect(compareValues(null, '=', '')).toBe(true); 109 + expect(compareValues('', '!=', 'x')).toBe(true); 110 + }); 111 + }); 112 + 113 + describe('executeQuery', () => { 114 + // Sample data: 4 rows x 3 cols (first row = headers) 115 + // Name | Age | City 116 + // Alice | 30 | NYC 117 + // Bob | 25 | LA 118 + // Carol | 35 | NYC 119 + const data = ['Name', 'Age', 'City', 'Alice', 30, 'NYC', 'Bob', 25, 'LA', 'Carol', 35, 'NYC']; 120 + const rows = 4; 121 + const cols = 3; 122 + 123 + it('SELECT * returns all data rows', () => { 124 + const result = executeQuery(data, rows, cols, 'SELECT *'); 125 + expect(Array.isArray(result)).toBe(true); 126 + const arr = result as unknown[]; 127 + expect((result as any)._rangeRows).toBe(3); 128 + expect((result as any)._rangeCols).toBe(3); 129 + expect(arr.slice(0, 3)).toEqual(['Alice', 30, 'NYC']); 130 + }); 131 + 132 + it('SELECT specific columns', () => { 133 + const result = executeQuery(data, rows, cols, 'SELECT Col1, Col3'); 134 + expect(Array.isArray(result)).toBe(true); 135 + expect((result as any)._rangeCols).toBe(2); 136 + const arr = [...(result as unknown[])]; 137 + expect(arr).toEqual(['Alice', 'NYC', 'Bob', 'LA', 'Carol', 'NYC']); 138 + }); 139 + 140 + it('WHERE filters rows', () => { 141 + const result = executeQuery(data, rows, cols, 'SELECT * WHERE Col2 > 28'); 142 + expect(Array.isArray(result)).toBe(true); 143 + expect((result as any)._rangeRows).toBe(2); 144 + const arr = [...(result as unknown[])]; 145 + expect(arr[0]).toBe('Alice'); // Age 30 146 + expect(arr[3]).toBe('Carol'); // Age 35 147 + }); 148 + 149 + it('WHERE with string match', () => { 150 + const result = executeQuery(data, rows, cols, "SELECT Col1 WHERE Col3 = 'NYC'"); 151 + expect(Array.isArray(result)).toBe(true); 152 + const arr = [...(result as unknown[])]; 153 + expect(arr).toEqual(['Alice', 'Carol']); 154 + }); 155 + 156 + it('ORDER BY ascending', () => { 157 + const result = executeQuery(data, rows, cols, 'SELECT Col1 ORDER BY Col2'); 158 + const arr = [...(result as unknown[])]; 159 + expect(arr).toEqual(['Bob', 'Alice', 'Carol']); // 25, 30, 35 160 + }); 161 + 162 + it('ORDER BY descending', () => { 163 + const result = executeQuery(data, rows, cols, 'SELECT Col1 ORDER BY Col2 DESC'); 164 + const arr = [...(result as unknown[])]; 165 + expect(arr).toEqual(['Carol', 'Alice', 'Bob']); // 35, 30, 25 166 + }); 167 + 168 + it('LIMIT restricts output', () => { 169 + const result = executeQuery(data, rows, cols, 'SELECT * LIMIT 1'); 170 + expect((result as any)._rangeRows).toBe(1); 171 + }); 172 + 173 + it('returns #N/A when no rows match', () => { 174 + const result = executeQuery(data, rows, cols, 'SELECT * WHERE Col2 > 100'); 175 + expect(result).toBe('#N/A'); 176 + }); 177 + 178 + it('full query: SELECT + WHERE + ORDER BY + LIMIT', () => { 179 + const result = executeQuery(data, rows, cols, 'SELECT Col1, Col2 WHERE Col2 >= 25 ORDER BY Col2 DESC LIMIT 2'); 180 + expect(Array.isArray(result)).toBe(true); 181 + expect((result as any)._rangeRows).toBe(2); 182 + expect((result as any)._rangeCols).toBe(2); 183 + const arr = [...(result as unknown[])]; 184 + expect(arr).toEqual(['Carol', 35, 'Alice', 30]); 185 + }); 186 + });