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

Configure Feed

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

Merge pull request 'feat(sheets): FILTER, SORT, UNIQUE dynamic array functions (#86)' (#113) from feat/dynamic-array-functions into main

scott 048902f4 3467a4ab

+220
+134
src/sheets/formulas.ts
··· 1269 1269 return args[chIdx]; 1270 1270 } 1271 1271 1272 + // --- Dynamic Array Functions (#86) --- 1273 + case 'FILTER': { 1274 + // FILTER(array, include, [if_empty]) 1275 + const source = Array.isArray(args[0]) ? args[0] : [args[0]]; 1276 + const include = Array.isArray(args[1]) ? args[1] : [args[1]]; 1277 + const ifEmpty = args[2] !== undefined ? args[2] : '#N/A'; 1278 + const rows = (source as RangeArray)._rangeRows || source.length; 1279 + const cols = (source as RangeArray)._rangeCols || 1; 1280 + 1281 + const filtered: unknown[] = []; 1282 + for (let r = 0; r < rows; r++) { 1283 + const keep = include[r]; 1284 + // Truthy = include (booleans, non-zero numbers) 1285 + if (keep === true || (typeof keep === 'number' && keep !== 0)) { 1286 + if (cols > 1) { 1287 + for (let c = 0; c < cols; c++) { 1288 + filtered.push(source[r * cols + c]); 1289 + } 1290 + } else { 1291 + filtered.push(source[r]); 1292 + } 1293 + } 1294 + } 1295 + if (filtered.length === 0) return ifEmpty; 1296 + const result: RangeArray = filtered as RangeArray; 1297 + const filteredRows = cols > 1 ? filtered.length / cols : filtered.length; 1298 + result._rangeRows = filteredRows; 1299 + result._rangeCols = cols; 1300 + return result; 1301 + } 1302 + 1303 + case 'SORT': { 1304 + // SORT(array, [sort_index], [sort_order], [by_col]) 1305 + const source = Array.isArray(args[0]) ? [...args[0]] : [args[0]]; 1306 + const sortIndex = args[1] !== undefined ? toNum(args[1]) : 1; 1307 + const sortOrder = args[2] !== undefined ? toNum(args[2]) : 1; // 1=asc, -1=desc 1308 + const rows = (args[0] as RangeArray)?._rangeRows || source.length; 1309 + const cols = (args[0] as RangeArray)?._rangeCols || 1; 1310 + 1311 + if (cols <= 1) { 1312 + // Single column — sort values directly 1313 + const sorted = source.filter(v => v !== '' && v !== null && v !== undefined); 1314 + sorted.sort((a, b) => { 1315 + const na = typeof a === 'number' ? a : NaN; 1316 + const nb = typeof b === 'number' ? b : NaN; 1317 + if (!isNaN(na) && !isNaN(nb)) return (na - nb) * sortOrder; 1318 + return String(a ?? '').localeCompare(String(b ?? '')) * sortOrder; 1319 + }); 1320 + const result: RangeArray = sorted as RangeArray; 1321 + result._rangeRows = sorted.length; 1322 + result._rangeCols = 1; 1323 + return result; 1324 + } 1325 + 1326 + // Multi-column: sort rows by sort_index column 1327 + const rowArrays: unknown[][] = []; 1328 + for (let r = 0; r < rows; r++) { 1329 + const row: unknown[] = []; 1330 + for (let c = 0; c < cols; c++) { 1331 + row.push(source[r * cols + c]); 1332 + } 1333 + rowArrays.push(row); 1334 + } 1335 + const si = Math.max(0, sortIndex - 1); // 1-based to 0-based 1336 + rowArrays.sort((a, b) => { 1337 + const va = a[si]; 1338 + const vb = b[si]; 1339 + const na = typeof va === 'number' ? va : NaN; 1340 + const nb = typeof vb === 'number' ? vb : NaN; 1341 + if (!isNaN(na) && !isNaN(nb)) return (na - nb) * sortOrder; 1342 + return String(va ?? '').localeCompare(String(vb ?? '')) * sortOrder; 1343 + }); 1344 + const flatResult: RangeArray = rowArrays.flat() as RangeArray; 1345 + flatResult._rangeRows = rows; 1346 + flatResult._rangeCols = cols; 1347 + return flatResult; 1348 + } 1349 + 1350 + case 'UNIQUE': { 1351 + // UNIQUE(array, [by_col], [exactly_once]) 1352 + const source = Array.isArray(args[0]) ? args[0] : [args[0]]; 1353 + const exactlyOnce = args[2] === true; 1354 + const rows = (args[0] as RangeArray)?._rangeRows || source.length; 1355 + const cols = (args[0] as RangeArray)?._rangeCols || 1; 1356 + 1357 + if (cols <= 1) { 1358 + // Single column 1359 + const seen = new Map<string, { value: unknown; count: number }>(); 1360 + for (const v of source) { 1361 + if (v === '' || v === null || v === undefined) continue; 1362 + const key = String(v); 1363 + const existing = seen.get(key); 1364 + if (existing) { 1365 + existing.count++; 1366 + } else { 1367 + seen.set(key, { value: v, count: 1 }); 1368 + } 1369 + } 1370 + const values = exactlyOnce 1371 + ? [...seen.values()].filter(e => e.count === 1).map(e => e.value) 1372 + : [...seen.values()].map(e => e.value); 1373 + const result: RangeArray = values as RangeArray; 1374 + result._rangeRows = values.length; 1375 + result._rangeCols = 1; 1376 + return result.length === 0 ? '#N/A' : result; 1377 + } 1378 + 1379 + // Multi-column: unique rows 1380 + const rowKeys = new Map<string, { row: unknown[]; count: number }>(); 1381 + const orderedKeys: string[] = []; 1382 + for (let r = 0; r < rows; r++) { 1383 + const row: unknown[] = []; 1384 + for (let c = 0; c < cols; c++) { 1385 + row.push(source[r * cols + c]); 1386 + } 1387 + const key = row.map(v => String(v ?? '')).join('\0'); 1388 + const existing = rowKeys.get(key); 1389 + if (existing) { 1390 + existing.count++; 1391 + } else { 1392 + rowKeys.set(key, { row, count: 1 }); 1393 + orderedKeys.push(key); 1394 + } 1395 + } 1396 + const uniqueRows = exactlyOnce 1397 + ? orderedKeys.filter(k => rowKeys.get(k)!.count === 1).map(k => rowKeys.get(k)!.row) 1398 + : orderedKeys.map(k => rowKeys.get(k)!.row); 1399 + if (uniqueRows.length === 0) return '#N/A'; 1400 + const flatResult: RangeArray = uniqueRows.flat() as RangeArray; 1401 + flatResult._rangeRows = uniqueRows.length; 1402 + flatResult._rangeCols = cols; 1403 + return flatResult; 1404 + } 1405 + 1272 1406 // --- Sparkline --- 1273 1407 case 'SPARKLINE': { 1274 1408 // First arg is data (range array), second is optional type/options
+86
tests/formulas.test.ts
··· 735 735 expect(evalWith('DIVIDE(-20, 4)')).toBe(-5); 736 736 }); 737 737 }); 738 + 739 + // --- Dynamic Array Functions (#86) --- 740 + 741 + describe('FILTER', () => { 742 + it('filters single column by boolean array', () => { 743 + const cells = { A1: 10, A2: 20, A3: 30, B1: true, B2: false, B3: true }; 744 + const result = evalWith('FILTER(A1:A3,B1:B3)', cells); 745 + expect(Array.isArray(result)).toBe(true); 746 + expect([...(result as unknown[])]).toEqual([10, 30]); 747 + }); 748 + 749 + it('filters by numeric condition (non-zero = include)', () => { 750 + const cells = { A1: 'a', A2: 'b', A3: 'c', B1: 1, B2: 0, B3: 1 }; 751 + const result = evalWith('FILTER(A1:A3,B1:B3)', cells); 752 + expect([...(result as unknown[])]).toEqual(['a', 'c']); 753 + }); 754 + 755 + it('returns if_empty when no matches', () => { 756 + const cells = { A1: 10, B1: false }; 757 + const result = evalWith('FILTER(A1:A1,B1:B1,"none")', cells); 758 + expect(result).toBe('none'); 759 + }); 760 + 761 + it('returns #N/A when no matches and no if_empty', () => { 762 + const cells = { A1: 10, B1: false }; 763 + const result = evalWith('FILTER(A1:A1,B1:B1)', cells); 764 + expect(result).toBe('#N/A'); 765 + }); 766 + }); 767 + 768 + describe('SORT', () => { 769 + it('sorts single column ascending by default', () => { 770 + const cells = { A1: 30, A2: 10, A3: 20 }; 771 + const result = evalWith('SORT(A1:A3)', cells); 772 + expect(Array.isArray(result)).toBe(true); 773 + expect([...(result as unknown[])]).toEqual([10, 20, 30]); 774 + }); 775 + 776 + it('sorts single column descending with -1', () => { 777 + const cells = { A1: 30, A2: 10, A3: 20 }; 778 + const result = evalWith('SORT(A1:A3,1,-1)', cells); 779 + expect([...(result as unknown[])]).toEqual([30, 20, 10]); 780 + }); 781 + 782 + it('sorts strings alphabetically', () => { 783 + const cells = { A1: 'cherry', A2: 'apple', A3: 'banana' }; 784 + const result = evalWith('SORT(A1:A3)', cells); 785 + expect([...(result as unknown[])]).toEqual(['apple', 'banana', 'cherry']); 786 + }); 787 + 788 + it('sorts multi-column by specified column', () => { 789 + const cells = { A1: 'b', B1: 2, A2: 'a', B2: 1, A3: 'c', B3: 3 }; 790 + const result = evalWith('SORT(A1:B3,2,1)', cells); 791 + expect(Array.isArray(result)).toBe(true); 792 + // Sorted by column 2 ascending: [a,1, b,2, c,3] 793 + const arr = [...(result as unknown[])]; 794 + expect(arr).toEqual(['a', 1, 'b', 2, 'c', 3]); 795 + }); 796 + }); 797 + 798 + describe('UNIQUE', () => { 799 + it('removes duplicates from single column', () => { 800 + const cells = { A1: 'a', A2: 'b', A3: 'a', A4: 'c' }; 801 + const result = evalWith('UNIQUE(A1:A4)', cells); 802 + expect(Array.isArray(result)).toBe(true); 803 + expect([...(result as unknown[])]).toEqual(['a', 'b', 'c']); 804 + }); 805 + 806 + it('preserves order of first occurrence', () => { 807 + const cells = { A1: 3, A2: 1, A3: 2, A4: 1 }; 808 + const result = evalWith('UNIQUE(A1:A4)', cells); 809 + expect([...(result as unknown[])]).toEqual([3, 1, 2]); 810 + }); 811 + 812 + it('returns exactly-once values when third arg is true', () => { 813 + const cells = { A1: 'a', A2: 'b', A3: 'a', A4: 'c' }; 814 + const result = evalWith('UNIQUE(A1:A4,,TRUE)', cells); 815 + expect([...(result as unknown[])]).toEqual(['b', 'c']); 816 + }); 817 + 818 + it('returns #N/A when no unique values', () => { 819 + const cells = { A1: 'a', A2: 'a' }; 820 + const result = evalWith('UNIQUE(A1:A2,,TRUE)', cells); 821 + expect(result).toBe('#N/A'); 822 + }); 823 + });