From 8d5373ae4b1e05fb2d4590d39274ce9ca1eff7fd Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 9 Apr 2026 17:51:56 +0000 Subject: [PATCH 01/23] Docs: add basic usage snippets to framework integration guides Add idiomatic code examples to React, Angular, Vue, and Svelte integration pages showing HyperFormula initialization and reading calculated values. Each guide uses framework-specific patterns (React hooks, Angular service, Vue ref/markRaw, Svelte reactivity). Closes HF-122. --- docs/guide/integration-with-angular.md | 64 ++++++++++++++++++++++++++ docs/guide/integration-with-react.md | 56 ++++++++++++++++++++++ docs/guide/integration-with-svelte.md | 46 ++++++++++++++++++ docs/guide/integration-with-vue.md | 42 +++++++++++++++++ 4 files changed, 208 insertions(+) diff --git a/docs/guide/integration-with-angular.md b/docs/guide/integration-with-angular.md index 8f78e2097..1e6b57998 100644 --- a/docs/guide/integration-with-angular.md +++ b/docs/guide/integration-with-angular.md @@ -4,6 +4,70 @@ Installing HyperFormula in an Angular application works the same as with vanilla For more details, see the [client-side installation](client-side-installation.md) section. +## Basic usage + +### Step 1. Create a service + +Wrap HyperFormula in an Angular service. Create the instance in the constructor and expose methods for reading data. + +```typescript +import { Injectable, OnDestroy } from '@angular/core'; +import { HyperFormula } from 'hyperformula'; + +@Injectable({ providedIn: 'root' }) +export class SpreadsheetService implements OnDestroy { + private hf: HyperFormula; + private sheetId = 0; + + constructor() { + // Create a HyperFormula instance with initial data. + this.hf = HyperFormula.buildFromArray( + [ + [10, 20, '=SUM(A1:B1)'], + [30, 40, '=SUM(A2:B2)'], + ], + { licenseKey: 'gpl-v3' } + ); + } + + /** Return calculated values for the entire sheet. */ + getSheetValues(): (number | string | null)[][] { + return this.hf.getSheetValues(this.sheetId); + } + + ngOnDestroy(): void { + this.hf.destroy(); + } +} +``` + +### Step 2. Use the service in a component + +Inject the service and display the calculated values. + +```typescript +import { Component } from '@angular/core'; +import { SpreadsheetService } from './spreadsheet.service'; + +@Component({ + selector: 'app-spreadsheet', + template: ` + + + + +
{{ cell }}
+ `, +}) +export class SpreadsheetComponent { + data: (number | string | null)[][]; + + constructor(private spreadsheet: SpreadsheetService) { + this.data = this.spreadsheet.getSheetValues(); + } +} +``` + ## Demo Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/angular-demo?v=${$page.buildDateURIEncoded}). diff --git a/docs/guide/integration-with-react.md b/docs/guide/integration-with-react.md index d4bc7fe75..1591aec85 100644 --- a/docs/guide/integration-with-react.md +++ b/docs/guide/integration-with-react.md @@ -4,6 +4,62 @@ Installing HyperFormula in a React application works the same as with vanilla Ja For more details, see the [client-side installation](client-side-installation.md) section. +## Basic usage + +### Step 1. Initialize HyperFormula + +Use `useRef` to hold the HyperFormula instance so it persists across re-renders. Initialize it inside a `useEffect` hook. + +```javascript +import { useRef, useEffect, useState } from 'react'; +import { HyperFormula } from 'hyperformula'; + +function SpreadsheetComponent() { + const hfRef = useRef(null); + const [sheetData, setSheetData] = useState([]); + + useEffect(() => { + // Create a HyperFormula instance with initial data. + hfRef.current = HyperFormula.buildFromArray( + [ + [10, 20, '=SUM(A1:B1)'], + [30, 40, '=SUM(A2:B2)'], + ], + { licenseKey: 'gpl-v3' } + ); + + // Read calculated values from the sheet. + const sheetId = 0; + setSheetData(hfRef.current.getSheetValues(sheetId)); + + return () => { + // Clean up the instance when the component unmounts. + hfRef.current?.destroy(); + }; + }, []); +``` + +### Step 2. Render the results + +Display the calculated values in a table. + +```javascript + return ( + + + {sheetData.map((row, rowIdx) => ( + + {row.map((cell, colIdx) => ( + + ))} + + ))} + +
{cell}
+ ); +} +``` + ## Demo Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/react-demo?v=${$page.buildDateURIEncoded}). diff --git a/docs/guide/integration-with-svelte.md b/docs/guide/integration-with-svelte.md index 8b3a5f4b6..bd8aff85d 100644 --- a/docs/guide/integration-with-svelte.md +++ b/docs/guide/integration-with-svelte.md @@ -4,6 +4,52 @@ Installing HyperFormula in a Svelte application works the same as with vanilla J For more details, see the [client-side installation](client-side-installation.md) section. +## Basic usage + +### Step 1. Initialize HyperFormula + +Create the HyperFormula instance directly in the component's ` +``` + +### Step 2. Render the results + +Display the data in a table and trigger calculation with a button. + +```html + + + + {#each data as row, rowIdx} + + {#each row as cell, colIdx} + + {/each} + + {/each} +
{cell}
+``` + ## Demo Explore the full working example on [Stackblitz](https://stackblitz.com/github/handsontable/hyperformula-demos/tree/3.2.x/svelte-demo?v=${$page.buildDateURIEncoded}). diff --git a/docs/guide/integration-with-vue.md b/docs/guide/integration-with-vue.md index eaa104e0e..68afa5a8e 100644 --- a/docs/guide/integration-with-vue.md +++ b/docs/guide/integration-with-vue.md @@ -4,6 +4,48 @@ Installing HyperFormula in a Vue application works the same as with vanilla Java For more details, see the [client-side installation](client-side-installation.md) section. +## Basic usage + +### Step 1. Initialize HyperFormula + +Wrap the HyperFormula instance in `markRaw` to prevent Vue's reactivity system from converting it into a proxy (see [Troubleshooting](#vue-reactivity-issues) below). Use `ref` for the data you want to display. + +```javascript + +``` + +### Step 2. Render the results + +Display the calculated values in a template. + +```html + +``` + ## Troubleshooting ### Vue reactivity issues From f919dd37b3267b2d92814063befb8a2e6a06a931 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 10 Apr 2026 09:06:51 +0000 Subject: [PATCH 02/23] Implement DCOUNT database function Add new DatabasePlugin with DCOUNT(database, field, criteria) that counts numeric values in a specified field column for rows matching criteria. Supports field resolution by name (case-insensitive) and 1-based index, OR logic across criteria rows, AND logic within rows, and comparison operators in criteria values. Includes translations for all 17 languages. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + src/i18n/languages/csCZ.ts | 1 + src/i18n/languages/daDK.ts | 1 + src/i18n/languages/deDE.ts | 1 + src/i18n/languages/enGB.ts | 1 + src/i18n/languages/esES.ts | 1 + src/i18n/languages/fiFI.ts | 1 + src/i18n/languages/frFR.ts | 1 + src/i18n/languages/huHU.ts | 1 + src/i18n/languages/itIT.ts | 1 + src/i18n/languages/nbNO.ts | 1 + src/i18n/languages/nlNL.ts | 1 + src/i18n/languages/plPL.ts | 1 + src/i18n/languages/ptPT.ts | 1 + src/i18n/languages/ruRU.ts | 1 + src/i18n/languages/svSE.ts | 1 + src/i18n/languages/trTR.ts | 1 + src/interpreter/plugin/DatabasePlugin.ts | 208 +++++++++++++++++++++++ src/interpreter/plugin/index.ts | 1 + 19 files changed, 226 insertions(+) create mode 100644 src/interpreter/plugin/DatabasePlugin.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 21858277a..5b2627823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added `maxPendingLazyTransformations` configuration option to control memory usage by limiting accumulated transformations before cleanup. [#1629](https://github.com/handsontable/hyperformula/issues/1629) - Added a new function: TEXTJOIN. [#1640](https://github.com/handsontable/hyperformula/pull/1640) - Added a new function: SEQUENCE. [#1645](https://github.com/handsontable/hyperformula/pull/1645) +- Added a new function: DCOUNT. (#1623) ### Fixed diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 463bb1d2a..529055d4a 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'ROK360', DAYS: 'DAYS', DB: 'ODPIS.ZRYCH', + DCOUNT: 'DCOUNT', DDB: 'ODPIS.ZRYCH2', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 6525c442a..a8defd4dc 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGE360', DAYS: 'DAGE', DB: 'DB', + DCOUNT: 'ANTAL.DB', DDB: 'DSA', DEC2BIN: 'DEC.TIL.BIN', DEC2HEX: 'DEC.TIL.HEX', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index d99d6cc1c..817839f32 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'TAGE360', DAYS: 'TAGE', DB: 'GDA2', + DCOUNT: 'DBANZAHL', DDB: 'GDA', DEC2BIN: 'DEZINBIN', DEC2HEX: 'DEZINHEX', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 840e14a90..9111b3faf 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -76,6 +76,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAYS360', DAYS: 'DAYS', DB: 'DB', + DCOUNT: 'DCOUNT', DDB: 'DDB', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 1d8278ca5..a47b2836b 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -75,6 +75,7 @@ export const dictionary: RawTranslationPackage = { DAYS360: 'DIAS360', DAYS: 'DÍAS', DB: 'DB', + DCOUNT: 'BDCONTAR', DDB: 'DDB', DEC2BIN: 'DEC.A.BIN', DEC2HEX: 'DEC.A.HEX', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index cd11bc7ec..f1c52cd17 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'PÄIVÄT360', DAYS: 'PV', DB: 'DB', + DCOUNT: 'TLASKE', DDB: 'DDB', DEC2BIN: 'DESBIN', DEC2HEX: 'DESHEKSA', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index ae1dd4389..811cc555e 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'JOURS360', DAYS: 'JOURS', DB: 'DB', + DCOUNT: 'BDNB', DDB: 'DDB', DEC2BIN: 'DECBIN', DEC2HEX: 'DECHEX', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index 223f12811..ade163820 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAYS360', DAYS: 'NAPOK', DB: 'DB', + DCOUNT: 'AB.DARAB', DDB: 'KCSA', DEC2BIN: 'DEC.BIN', DEC2HEX: 'DEC.HEX', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 1ebbaa255..343099775 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'GIORNO360', DAYS: 'GIORNI', DB: 'AMMORT.FISSO', + DCOUNT: 'DB.CONTA.NUMERI', DDB: 'AMMORT', DEC2BIN: 'DECIMALE.BINARIO', DEC2HEX: 'DECIMALE.HEX', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 9ec608493..d926d0358 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGER360', DAYS: 'DAGER', DB: 'DAVSKR', + DCOUNT: 'ANTALLDB', DDB: 'DEGRAVS', DEC2BIN: 'DESTILBIN', DEC2HEX: 'DESTILHEKS', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 201318236..ef668df66 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGEN360', DAYS: 'DAGEN', DB: 'DB', + DCOUNT: 'DBAANTAL', DDB: 'DDB', DEC2BIN: 'DEC.N.BIN', DEC2HEX: 'DEC.N.HEX', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index acce07114..93d719b45 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DNI.360', DAYS: 'DNI', DB: 'DB', + DCOUNT: 'BD.ILE.REKORDÓW', DDB: 'DDB', DEC2BIN: 'DZIES.NA.DWÓJK', DEC2HEX: 'DZIES.NA.SZESN', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index a161a5497..219d20fa7 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DIAS360', DAYS: 'DIAS', DB: 'BD', + DCOUNT: 'BDCONTAR', DDB: 'BDD', DEC2BIN: 'DECABIN', DEC2HEX: 'DECAHEX', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 8d07da729..aeb0c2327 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'ДНЕЙ360', DAYS: 'ДНИ', DB: 'ФУО', + DCOUNT: 'БСЧЁТ', DDB: 'ДДОБ', DEC2BIN: 'ДЕС.В.ДВ', DEC2HEX: 'ДЕС.В.ШЕСТН', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index c92269c33..a0b2a7596 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGAR360', DAYS: 'DAYS', DB: 'DB', + DCOUNT: 'DANTAL', DDB: 'DEGAVSKR', DEC2BIN: 'DEC.TILL.BIN', DEC2HEX: 'DEC.TILL.HEX', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 417b96bb9..d47c3143a 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -75,6 +75,7 @@ const dictionary: RawTranslationPackage = { DAYS360: 'GÜN360', DAYS: 'GÜNSAY', DB: 'AZALANBAKİYE', + DCOUNT: 'VSAY', DDB: 'ÇİFTAZALANBAKİYE', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts new file mode 100644 index 000000000..9bf1089ca --- /dev/null +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {CellError, ErrorType} from '../../Cell' +import {ErrorMessage} from '../../error-message' +import {ProcedureAst} from '../../parser' +import {InterpreterState} from '../InterpreterState' +import {EmptyValue, getRawValue, InternalScalarValue, InterpreterValue, isExtendedNumber, RawScalarValue} from '../InterpreterValue' +import {SimpleRangeValue} from '../../SimpleRangeValue' +import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' +import {CriterionLambda} from '../Criterion' + +/** + * Parsed criterion for a single cell in the criteria range. + * Maps a database column index to a matching lambda. + */ +interface DatabaseCriterionEntry { + /** 0-based column index within the database range. */ + columnIndex: number, + /** Lambda that tests whether a raw cell value satisfies the criterion. */ + lambda: CriterionLambda, +} + +/** + * A single criteria row is a list of AND-ed criterion entries. + * Multiple criteria rows are OR-ed together. + */ +type DatabaseCriteriaRow = DatabaseCriterionEntry[] + +/** + * Interpreter plugin implementing Excel database functions. + * + * Currently implements: DCOUNT. + * Designed to be extended with DSUM, DAVERAGE, DMAX, DMIN, etc. + */ +export class DatabasePlugin extends FunctionPlugin implements FunctionPluginTypecheck { + + public static implementedFunctions: ImplementedFunctions = { + 'DCOUNT': { + method: 'dcount', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, + ], + }, + } + + /** + * Counts cells containing numbers in the specified field of a database range, + * for rows that match all criteria. + * + * DCOUNT(database, field, criteria) + */ + public dcount(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DCOUNT'), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + const dbData = database.data + let count = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (isExtendedNumber(cellValue)) { + count++ + } + } + } + + return count + }) + } + + /** + * Resolves the field argument to a 0-based column index within the database range. + * + * @param database - The database range (first row = headers). + * @param field - A string (header name, case-insensitive) or number (1-based column index). + * @returns 0-based column index, or CellError if field is invalid. + */ + private resolveFieldIndex(database: SimpleRangeValue, field: RawScalarValue): number | CellError { + const headers = database.data[0] + + if (typeof field === 'string') { + const lowerField = field.toLowerCase() + for (let i = 0; i < headers.length; i++) { + const header = headers[i] + if (typeof header === 'string' && header.toLowerCase() === lowerField) { + return i + } + } + return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) + } + + if (isExtendedNumber(field)) { + const index = Math.trunc(getRawValue(field)) + if (index < 1 || index > headers.length) { + return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) + } + return index - 1 + } + + return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) + } + + /** + * Parses the criteria range into an array of criteria rows. + * Each row is a list of AND-ed conditions. Rows are OR-ed together. + * + * @param database - The database range (first row = headers). + * @param criteria - The criteria range (first row = header labels, subsequent rows = conditions). + * @returns Array of criteria rows, or CellError if a criterion cannot be parsed. + */ + private buildDatabaseCriteria(database: SimpleRangeValue, criteria: SimpleRangeValue): DatabaseCriteriaRow[] | CellError { + const dbHeaders = database.data[0] + const criteriaData = criteria.data + const criteriaHeaders = criteriaData[0] + + // Map each criteria column to a database column index (or -1 if no match) + const criteriaColumnMapping: number[] = criteriaHeaders.map(criteriaHeader => { + if (typeof criteriaHeader !== 'string') { + return -1 + } + const lowerHeader = criteriaHeader.toLowerCase() + return dbHeaders.findIndex( + dbHeader => typeof dbHeader === 'string' && dbHeader.toLowerCase() === lowerHeader + ) + }) + + const rows: DatabaseCriteriaRow[] = [] + + for (let rowIdx = 1; rowIdx < criteriaData.length; rowIdx++) { + const row: DatabaseCriteriaRow = [] + + for (let colIdx = 0; colIdx < criteriaHeaders.length; colIdx++) { + const dbColIndex = criteriaColumnMapping[colIdx] + if (dbColIndex === -1) { + continue // Unknown criteria header — ignore + } + + const criterionValue = criteriaData[rowIdx]?.[colIdx] + + // Empty/blank criteria cell = match-all for that column — skip + if (criterionValue === EmptyValue || criterionValue === undefined || criterionValue === null) { + continue + } + + const rawCriterionValue = isExtendedNumber(criterionValue) ? getRawValue(criterionValue) : criterionValue + + const criterionPackage = this.interpreter.criterionBuilder.fromCellValue( + rawCriterionValue as RawScalarValue, + this.arithmeticHelper + ) + + if (criterionPackage === undefined) { + return new CellError(ErrorType.VALUE, ErrorMessage.BadCriterion) + } + + row.push({ + columnIndex: dbColIndex, + lambda: criterionPackage.lambda, + }) + } + + rows.push(row) + } + + return rows + } + + /** + * Tests whether a database data row matches any of the criteria rows (OR logic). + * Within each criteria row, all conditions must match (AND logic). + * + * @param dataRow - A single row of data from the database (excluding the header row). + * @param criteriaRows - Parsed criteria rows. + * @returns true if the row qualifies, false otherwise. + */ + private rowMatchesCriteria(dataRow: InternalScalarValue[], criteriaRows: DatabaseCriteriaRow[]): boolean { + if (criteriaRows.length === 0) { + return false + } + + return criteriaRows.some(criteriaRow => { + if (criteriaRow.length === 0) { + return true // Empty criteria row = match all + } + + return criteriaRow.every(entry => { + const cellValue = dataRow[entry.columnIndex] + const rawValue = isExtendedNumber(cellValue) ? getRawValue(cellValue) : cellValue + return entry.lambda(rawValue) + }) + }) + } +} diff --git a/src/interpreter/plugin/index.ts b/src/interpreter/plugin/index.ts index 2b1191c63..e86ba4f2a 100644 --- a/src/interpreter/plugin/index.ts +++ b/src/interpreter/plugin/index.ts @@ -13,6 +13,7 @@ export {CharPlugin} from './CharPlugin' export {CodePlugin} from './CodePlugin' export {CountBlankPlugin} from './CountBlankPlugin' export {CountUniquePlugin} from './CountUniquePlugin' +export {DatabasePlugin} from './DatabasePlugin' export {DateTimePlugin} from './DateTimePlugin' export {DegreesPlugin} from './DegreesPlugin' export {DeltaPlugin} from './DeltaPlugin' From 424f13fb9f6a93f0ef3082611a11fbd1fcb38ef9 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 10 Apr 2026 10:04:46 +0000 Subject: [PATCH 03/23] ci: retrigger From 086aa0882d4ece9eeb88c345aee83db294ff9e67 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 10 Apr 2026 10:12:17 +0000 Subject: [PATCH 04/23] ci: retrigger after tests PR created From 26434dc5f4d64b973e28157ff2705a30aafad535 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 10 Apr 2026 14:52:41 +0000 Subject: [PATCH 05/23] Implement DSUM, DCOUNTA, DPRODUCT database functions Add three new database functions to DatabasePlugin with i18n translations for all 17 languages. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/i18n/languages/csCZ.ts | 7 + src/i18n/languages/daDK.ts | 7 + src/i18n/languages/deDE.ts | 7 + src/i18n/languages/enGB.ts | 7 + src/i18n/languages/esES.ts | 7 + src/i18n/languages/fiFI.ts | 7 + src/i18n/languages/frFR.ts | 7 + src/i18n/languages/huHU.ts | 7 + src/i18n/languages/itIT.ts | 7 + src/i18n/languages/nbNO.ts | 7 + src/i18n/languages/nlNL.ts | 7 + src/i18n/languages/plPL.ts | 7 + src/i18n/languages/ptPT.ts | 7 + src/i18n/languages/ruRU.ts | 7 + src/i18n/languages/svSE.ts | 7 + src/i18n/languages/trTR.ts | 7 + src/interpreter/plugin/DatabasePlugin.ts | 334 ++++++++++++++++++++++- 17 files changed, 444 insertions(+), 2 deletions(-) diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 529055d4a..813264f5c 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'ROK360', DAYS: 'DAYS', DB: 'ODPIS.ZRYCH', + DAVERAGE: 'DPRUMER', DCOUNT: 'DCOUNT', + DCOUNTA: 'DPOCET2', + DGET: 'DZISKAT', + DMAX: 'DMAX', + DMIN: 'DMIN', + DPRODUCT: 'DSOUCIN', + DSUM: 'DSOUCET', DDB: 'ODPIS.ZRYCH2', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index a8defd4dc..0f3c4fc20 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGE360', DAYS: 'DAGE', DB: 'DB', + DAVERAGE: 'DGENNEMSNIT', DCOUNT: 'ANTAL.DB', + DCOUNTA: 'ANTAL.DBV', + DGET: 'DHENT', + DMAX: 'DMAKS', + DMIN: 'DMIN', + DPRODUCT: 'DPRODUKT', + DSUM: 'DSUM', DDB: 'DSA', DEC2BIN: 'DEC.TIL.BIN', DEC2HEX: 'DEC.TIL.HEX', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 817839f32..d84a0ce54 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'TAGE360', DAYS: 'TAGE', DB: 'GDA2', + DAVERAGE: 'DBMITTELWERT', DCOUNT: 'DBANZAHL', + DCOUNTA: 'DBANZAHL2', + DGET: 'DBAUSZUG', + DMAX: 'DBMAX', + DMIN: 'DBMIN', + DPRODUCT: 'DBPRODUKT', + DSUM: 'DBSUMME', DDB: 'GDA', DEC2BIN: 'DEZINBIN', DEC2HEX: 'DEZINHEX', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 9111b3faf..02add2614 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -76,7 +76,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAYS360', DAYS: 'DAYS', DB: 'DB', + DAVERAGE: 'DAVERAGE', DCOUNT: 'DCOUNT', + DCOUNTA: 'DCOUNTA', + DGET: 'DGET', + DMAX: 'DMAX', + DMIN: 'DMIN', + DPRODUCT: 'DPRODUCT', + DSUM: 'DSUM', DDB: 'DDB', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index a47b2836b..083ae1ffc 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -75,7 +75,14 @@ export const dictionary: RawTranslationPackage = { DAYS360: 'DIAS360', DAYS: 'DÍAS', DB: 'DB', + DAVERAGE: 'BDPROMEDIO', DCOUNT: 'BDCONTAR', + DCOUNTA: 'BDCONTARA', + DGET: 'BDEXTRAER', + DMAX: 'BDMAX', + DMIN: 'BDMIN', + DPRODUCT: 'BDPRODUCTO', + DSUM: 'BDSUMA', DDB: 'DDB', DEC2BIN: 'DEC.A.BIN', DEC2HEX: 'DEC.A.HEX', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index f1c52cd17..1d6ede25d 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'PÄIVÄT360', DAYS: 'PV', DB: 'DB', + DAVERAGE: 'TKESKIARVO', DCOUNT: 'TLASKE', + DCOUNTA: 'TLASKE.A', + DGET: 'TNOUDA', + DMAX: 'TMAKS', + DMIN: 'TMIN', + DPRODUCT: 'TTULO', + DSUM: 'TSUMMA', DDB: 'DDB', DEC2BIN: 'DESBIN', DEC2HEX: 'DESHEKSA', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index 811cc555e..b34baa198 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'JOURS360', DAYS: 'JOURS', DB: 'DB', + DAVERAGE: 'BDMOYENNE', DCOUNT: 'BDNB', + DCOUNTA: 'BDNBVAL', + DGET: 'BDLIRE', + DMAX: 'BDMAX', + DMIN: 'BDMIN', + DPRODUCT: 'BDPRODUIT', + DSUM: 'BDSOMME', DDB: 'DDB', DEC2BIN: 'DECBIN', DEC2HEX: 'DECHEX', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index ade163820..e5c23e86e 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAYS360', DAYS: 'NAPOK', DB: 'DB', + DAVERAGE: 'AB.ÁTLAG', DCOUNT: 'AB.DARAB', + DCOUNTA: 'AB.DARAB2', + DGET: 'AB.MEZŐ', + DMAX: 'AB.MAX', + DMIN: 'AB.MIN', + DPRODUCT: 'AB.SZORZAT', + DSUM: 'AB.SZUM', DDB: 'KCSA', DEC2BIN: 'DEC.BIN', DEC2HEX: 'DEC.HEX', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 343099775..3b1697e1a 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'GIORNO360', DAYS: 'GIORNI', DB: 'AMMORT.FISSO', + DAVERAGE: 'DB.MEDIA', DCOUNT: 'DB.CONTA.NUMERI', + DCOUNTA: 'DB.CONTA.VALORI', + DGET: 'DB.VALORI', + DMAX: 'DB.MAX', + DMIN: 'DB.MIN', + DPRODUCT: 'DB.PRODOTTO', + DSUM: 'DB.SOMMA', DDB: 'AMMORT', DEC2BIN: 'DECIMALE.BINARIO', DEC2HEX: 'DECIMALE.HEX', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index d926d0358..fc9c33658 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGER360', DAYS: 'DAGER', DB: 'DAVSKR', + DAVERAGE: 'DGJENNOMSNITT', DCOUNT: 'ANTALLDB', + DCOUNTA: 'DANTALLA', + DGET: 'DHENT', + DMAX: 'DMAKS', + DMIN: 'DMIN', + DPRODUCT: 'DPRODUKT', + DSUM: 'DSUMMER', DDB: 'DEGRAVS', DEC2BIN: 'DESTILBIN', DEC2HEX: 'DESTILHEKS', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index ef668df66..de219a953 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGEN360', DAYS: 'DAGEN', DB: 'DB', + DAVERAGE: 'DBGEMIDDELDE', DCOUNT: 'DBAANTAL', + DCOUNTA: 'DBAAANTALC', + DGET: 'DBLEZEN', + DMAX: 'DBMAX', + DMIN: 'DBMIN', + DPRODUCT: 'DBPRODUKT', + DSUM: 'DBSOM', DDB: 'DDB', DEC2BIN: 'DEC.N.BIN', DEC2HEX: 'DEC.N.HEX', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 93d719b45..200d6694c 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DNI.360', DAYS: 'DNI', DB: 'DB', + DAVERAGE: 'BD.SREDNIA', DCOUNT: 'BD.ILE.REKORDÓW', + DCOUNTA: 'BD.ILE.REKORDOW.A', + DGET: 'BD.POLE', + DMAX: 'BD.MAX', + DMIN: 'BD.MIN', + DPRODUCT: 'BD.ILOCZYN', + DSUM: 'BD.SUMA', DDB: 'DDB', DEC2BIN: 'DZIES.NA.DWÓJK', DEC2HEX: 'DZIES.NA.SZESN', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 219d20fa7..4cc581e90 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DIAS360', DAYS: 'DIAS', DB: 'BD', + DAVERAGE: 'BDMÉDIA', DCOUNT: 'BDCONTAR', + DCOUNTA: 'BDCONTARA', + DGET: 'BDEXTRAIR', + DMAX: 'BDMÁX', + DMIN: 'BDMÍN', + DPRODUCT: 'BDPRODUTO', + DSUM: 'BDSOMA', DDB: 'BDD', DEC2BIN: 'DECABIN', DEC2HEX: 'DECAHEX', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index aeb0c2327..8a3d68877 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'ДНЕЙ360', DAYS: 'ДНИ', DB: 'ФУО', + DAVERAGE: 'БДСРЕДНЕЕ', DCOUNT: 'БСЧЁТ', + DCOUNTA: 'БСЧЕТА', + DGET: 'БИЗВЛЕЧЬ', + DMAX: 'БДМАКС', + DMIN: 'БДМИН', + DPRODUCT: 'БДПРОИЗВЕД', + DSUM: 'БДСУММ', DDB: 'ДДОБ', DEC2BIN: 'ДЕС.В.ДВ', DEC2HEX: 'ДЕС.В.ШЕСТН', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index a0b2a7596..551bd31c5 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGAR360', DAYS: 'DAYS', DB: 'DB', + DAVERAGE: 'DMEDEL', DCOUNT: 'DANTAL', + DCOUNTA: 'DANTALV', + DGET: 'DHÄMTA', + DMAX: 'DMAX', + DMIN: 'DMIN', + DPRODUCT: 'DPRODUKT', + DSUM: 'DSUMMA', DDB: 'DEGAVSKR', DEC2BIN: 'DEC.TILL.BIN', DEC2HEX: 'DEC.TILL.HEX', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index d47c3143a..150341739 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -75,7 +75,14 @@ const dictionary: RawTranslationPackage = { DAYS360: 'GÜN360', DAYS: 'GÜNSAY', DB: 'AZALANBAKİYE', + DAVERAGE: 'VORTALAMA', DCOUNT: 'VSAY', + DCOUNTA: 'VSAYMA', + DGET: 'VAL', + DMAX: 'VMAKS', + DMIN: 'VMİN', + DPRODUCT: 'VÇARPIM', + DSUM: 'VTOPLA', DDB: 'ÇİFTAZALANBAKİYE', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index 9bf1089ca..ba891631e 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -32,8 +32,7 @@ type DatabaseCriteriaRow = DatabaseCriterionEntry[] /** * Interpreter plugin implementing Excel database functions. * - * Currently implements: DCOUNT. - * Designed to be extended with DSUM, DAVERAGE, DMAX, DMIN, etc. + * Implements: DAVERAGE, DCOUNT, DCOUNTA, DGET, DMAX, DMIN, DPRODUCT, DSUM. */ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginTypecheck { @@ -46,6 +45,62 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType {argumentType: FunctionArgumentType.RANGE}, ], }, + 'DCOUNTA': { + method: 'dcounta', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, + ], + }, + 'DPRODUCT': { + method: 'dproduct', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, + ], + }, + 'DSUM': { + method: 'dsum', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, + ], + }, + 'DAVERAGE': { + method: 'daverage', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, + ], + }, + 'DGET': { + method: 'dget', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, + ], + }, + 'DMAX': { + method: 'dmax', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, + ], + }, + 'DMIN': { + method: 'dmin', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, + ], + }, } /** @@ -83,6 +138,281 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType }) } + /** + * Counts all non-blank cells in the specified field of a database range, + * for rows that match all criteria. + * + * DCOUNTA(database, field, criteria) + */ + public dcounta(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DCOUNTA'), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + const dbData = database.data + let count = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue !== EmptyValue && cellValue !== undefined && cellValue !== null) { + count++ + } + } + } + + return count + }) + } + + /** + * Returns the product of numeric values in the specified field of a database range, + * for rows that match all criteria. + * + * DPRODUCT(database, field, criteria) + */ + public dproduct(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DPRODUCT'), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + const dbData = database.data + let product = 1 + let hasNumeric = false + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (isExtendedNumber(cellValue)) { + product *= getRawValue(cellValue) as number + hasNumeric = true + } + } + } + + return hasNumeric ? product : 0 + }) + } + + /** + * Returns the sum of numeric values in the specified field of a database range, + * for rows that match all criteria. + * + * DSUM(database, field, criteria) + */ + public dsum(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DSUM'), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + const dbData = database.data + let sum = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (isExtendedNumber(cellValue)) { + sum += getRawValue(cellValue) as number + } + } + } + + return sum + }) + } + + /** + * Returns the average of numeric values in the specified field of a database range, + * for rows that match all criteria. + * Returns #DIV/0! when no numeric values are found. + * + * DAVERAGE(database, field, criteria) + */ + public daverage(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DAVERAGE'), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + const dbData = database.data + let sum = 0 + let count = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (isExtendedNumber(cellValue)) { + sum += getRawValue(cellValue) as number + count++ + } + } + } + + if (count === 0) { + return new CellError(ErrorType.DIV_BY_ZERO) + } + + return sum / count + }) + } + + /** + * Returns a single value from the specified field of a database range, + * for the row that matches all criteria. + * Returns #VALUE! if no rows match, #NUM! if more than one row matches. + * + * DGET(database, field, criteria) + */ + public dget(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DGET'), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + const dbData = database.data + let matchedValue: InternalScalarValue | undefined + let matchCount = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + matchCount++ + if (matchCount > 1) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueLarge) + } + matchedValue = dbData[rowIdx][fieldIndex] + } + } + + if (matchCount === 0) { + return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) + } + + return matchedValue === EmptyValue || matchedValue === undefined || matchedValue === null + ? 0 + : matchedValue + }) + } + + /** + * Returns the maximum numeric value in the specified field of a database range, + * for rows that match all criteria. + * Returns 0 when no numeric values are found (Excel behavior). + * + * DMAX(database, field, criteria) + */ + public dmax(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DMAX'), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + const dbData = database.data + let max = -Infinity + let hasNumeric = false + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (isExtendedNumber(cellValue)) { + const numValue = getRawValue(cellValue) as number + if (numValue > max) { + max = numValue + } + hasNumeric = true + } + } + } + + return hasNumeric ? max : 0 + }) + } + + /** + * Returns the minimum numeric value in the specified field of a database range, + * for rows that match all criteria. + * Returns 0 when no numeric values are found (Excel behavior). + * + * DMIN(database, field, criteria) + */ + public dmin(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DMIN'), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + const dbData = database.data + let min = Infinity + let hasNumeric = false + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (isExtendedNumber(cellValue)) { + const numValue = getRawValue(cellValue) as number + if (numValue < min) { + min = numValue + } + hasNumeric = true + } + } + } + + return hasNumeric ? min : 0 + }) + } + /** * Resolves the field argument to a 0-based column index within the database range. * From 22c34335083df3dc06cac34563775ba978f16efa Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 10 Apr 2026 15:01:02 +0000 Subject: [PATCH 06/23] fixup! Implement DAVERAGE, DMAX, DMIN, DGET database functions Remove unnecessary type assertions in DAVERAGE, DMAX, DMIN methods. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/interpreter/plugin/DatabasePlugin.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index ba891631e..3dda34ca5 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -273,7 +273,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] if (isExtendedNumber(cellValue)) { - sum += getRawValue(cellValue) as number + sum += getRawValue(cellValue) count++ } } @@ -359,7 +359,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] if (isExtendedNumber(cellValue)) { - const numValue = getRawValue(cellValue) as number + const numValue = getRawValue(cellValue) if (numValue > max) { max = numValue } @@ -400,7 +400,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] if (isExtendedNumber(cellValue)) { - const numValue = getRawValue(cellValue) as number + const numValue = getRawValue(cellValue) if (numValue < min) { min = numValue } From 7efebc8f68a66408ac680794b7994ff7d78c3b98 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 10 Apr 2026 15:11:33 +0000 Subject: [PATCH 07/23] Implement DSTDEV, DSTDEVP, DVAR, DVARP database functions Co-Authored-By: Claude Opus 4.6 (1M context) --- src/i18n/languages/csCZ.ts | 4 + src/i18n/languages/daDK.ts | 4 + src/i18n/languages/deDE.ts | 4 + src/i18n/languages/enGB.ts | 4 + src/i18n/languages/esES.ts | 4 + src/i18n/languages/fiFI.ts | 4 + src/i18n/languages/frFR.ts | 4 + src/i18n/languages/huHU.ts | 4 + src/i18n/languages/itIT.ts | 4 + src/i18n/languages/nbNO.ts | 4 + src/i18n/languages/nlNL.ts | 4 + src/i18n/languages/plPL.ts | 4 + src/i18n/languages/ptPT.ts | 4 + src/i18n/languages/ruRU.ts | 4 + src/i18n/languages/svSE.ts | 4 + src/i18n/languages/trTR.ts | 4 + src/interpreter/plugin/DatabasePlugin.ts | 189 ++++++++++++++++++++++- 17 files changed, 252 insertions(+), 1 deletion(-) diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 813264f5c..dd72b170e 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'DMAX', DMIN: 'DMIN', DPRODUCT: 'DSOUCIN', + DSTDEV: 'DSMODCH.VYBER', + DSTDEVP: 'DSMODCH', DSUM: 'DSOUCET', + DVAR: 'DVAR.VYBER', + DVARP: 'DVAR', DDB: 'ODPIS.ZRYCH2', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 0f3c4fc20..fbe402417 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'DMAKS', DMIN: 'DMIN', DPRODUCT: 'DPRODUKT', + DSTDEV: 'DSTDAFV', + DSTDEVP: 'DSTDAFVP', DSUM: 'DSUM', + DVAR: 'DVARIANS', + DVARP: 'DVARIANSP', DDB: 'DSA', DEC2BIN: 'DEC.TIL.BIN', DEC2HEX: 'DEC.TIL.HEX', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index d84a0ce54..24a8ec03b 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'DBMAX', DMIN: 'DBMIN', DPRODUCT: 'DBPRODUKT', + DSTDEV: 'DBSTDABW', + DSTDEVP: 'DBSTDABWN', DSUM: 'DBSUMME', + DVAR: 'DBVARIANZ', + DVARP: 'DBVARIANZEN', DDB: 'GDA', DEC2BIN: 'DEZINBIN', DEC2HEX: 'DEZINHEX', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 02add2614..e878f897a 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -83,7 +83,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'DMAX', DMIN: 'DMIN', DPRODUCT: 'DPRODUCT', + DSTDEV: 'DSTDEV', + DSTDEVP: 'DSTDEVP', DSUM: 'DSUM', + DVAR: 'DVAR', + DVARP: 'DVARP', DDB: 'DDB', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 083ae1ffc..d36d14fd0 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -82,7 +82,11 @@ export const dictionary: RawTranslationPackage = { DMAX: 'BDMAX', DMIN: 'BDMIN', DPRODUCT: 'BDPRODUCTO', + DSTDEV: 'BDDESVEST', + DSTDEVP: 'BDDESVESTP', DSUM: 'BDSUMA', + DVAR: 'BDVAR', + DVARP: 'BDVARP', DDB: 'DDB', DEC2BIN: 'DEC.A.BIN', DEC2HEX: 'DEC.A.HEX', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 1d6ede25d..5735278d8 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'TMAKS', DMIN: 'TMIN', DPRODUCT: 'TTULO', + DSTDEV: 'TKESKIHAJONTA', + DSTDEVP: 'TKESKIHAJONTAP', DSUM: 'TSUMMA', + DVAR: 'TVARIANSSI', + DVARP: 'TVARIANSSIP', DDB: 'DDB', DEC2BIN: 'DESBIN', DEC2HEX: 'DESHEKSA', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index b34baa198..044c70715 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'BDMAX', DMIN: 'BDMIN', DPRODUCT: 'BDPRODUIT', + DSTDEV: 'BDECARTYPE', + DSTDEVP: 'BDECARTYPEP', DSUM: 'BDSOMME', + DVAR: 'BDVAR', + DVARP: 'BDVARP', DDB: 'DDB', DEC2BIN: 'DECBIN', DEC2HEX: 'DECHEX', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index e5c23e86e..3a5119222 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'AB.MAX', DMIN: 'AB.MIN', DPRODUCT: 'AB.SZORZAT', + DSTDEV: 'AB.SZÓRÁS', + DSTDEVP: 'AB.SZÓRÁS2', DSUM: 'AB.SZUM', + DVAR: 'AB.VAR', + DVARP: 'AB.VAR2', DDB: 'KCSA', DEC2BIN: 'DEC.BIN', DEC2HEX: 'DEC.HEX', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 3b1697e1a..2716d3471 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'DB.MAX', DMIN: 'DB.MIN', DPRODUCT: 'DB.PRODOTTO', + DSTDEV: 'DB.DEV.ST', + DSTDEVP: 'DB.DEV.ST.POP', DSUM: 'DB.SOMMA', + DVAR: 'DB.VAR', + DVARP: 'DB.VAR.POP', DDB: 'AMMORT', DEC2BIN: 'DECIMALE.BINARIO', DEC2HEX: 'DECIMALE.HEX', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index fc9c33658..495a36ccd 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'DMAKS', DMIN: 'DMIN', DPRODUCT: 'DPRODUKT', + DSTDEV: 'DSTDAV', + DSTDEVP: 'DSTDAVP', DSUM: 'DSUMMER', + DVAR: 'DVARIANS', + DVARP: 'DVARIANSP', DDB: 'DEGRAVS', DEC2BIN: 'DESTILBIN', DEC2HEX: 'DESTILHEKS', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index de219a953..98efe1d15 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'DBMAX', DMIN: 'DBMIN', DPRODUCT: 'DBPRODUKT', + DSTDEV: 'DBSTDAFWIJKING', + DSTDEVP: 'DBSTDAFWIJKINGP', DSUM: 'DBSOM', + DVAR: 'DBVAR', + DVARP: 'DBVARP', DDB: 'DDB', DEC2BIN: 'DEC.N.BIN', DEC2HEX: 'DEC.N.HEX', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 200d6694c..0b0db49cb 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'BD.MAX', DMIN: 'BD.MIN', DPRODUCT: 'BD.ILOCZYN', + DSTDEV: 'BD.ODCH.STANDARD', + DSTDEVP: 'BD.ODCH.STANDARD.POPUL', DSUM: 'BD.SUMA', + DVAR: 'BD.WARIANCJA', + DVARP: 'BD.WARIANCJA.POPUL', DDB: 'DDB', DEC2BIN: 'DZIES.NA.DWÓJK', DEC2HEX: 'DZIES.NA.SZESN', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 4cc581e90..c512bb3a2 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'BDMÁX', DMIN: 'BDMÍN', DPRODUCT: 'BDPRODUTO', + DSTDEV: 'BDDESVPAD', + DSTDEVP: 'BDDESVPADP', DSUM: 'BDSOMA', + DVAR: 'BDVAR', + DVARP: 'BDVARP', DDB: 'BDD', DEC2BIN: 'DECABIN', DEC2HEX: 'DECAHEX', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 8a3d68877..654b28a50 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'БДМАКС', DMIN: 'БДМИН', DPRODUCT: 'БДПРОИЗВЕД', + DSTDEV: 'БДСТАНДОТКЛ', + DSTDEVP: 'БДСТАНДОТКЛП', DSUM: 'БДСУММ', + DVAR: 'БДДИСП', + DVARP: 'БДДИСПП', DDB: 'ДДОБ', DEC2BIN: 'ДЕС.В.ДВ', DEC2HEX: 'ДЕС.В.ШЕСТН', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 551bd31c5..bce2c4cf8 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'DMAX', DMIN: 'DMIN', DPRODUCT: 'DPRODUKT', + DSTDEV: 'DSTDAV', + DSTDEVP: 'DSTDAVP', DSUM: 'DSUMMA', + DVAR: 'DVARIANS', + DVARP: 'DVARIANSP', DDB: 'DEGAVSKR', DEC2BIN: 'DEC.TILL.BIN', DEC2HEX: 'DEC.TILL.HEX', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 150341739..e3658a8fb 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -82,7 +82,11 @@ const dictionary: RawTranslationPackage = { DMAX: 'VMAKS', DMIN: 'VMİN', DPRODUCT: 'VÇARPIM', + DSTDEV: 'VSTDSAPMA', + DSTDEVP: 'VSTDSAPMAP', DSUM: 'VTOPLA', + DVAR: 'VVAR', + DVARP: 'VVARP', DDB: 'ÇİFTAZALANBAKİYE', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index 3dda34ca5..93f88fb43 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -32,7 +32,7 @@ type DatabaseCriteriaRow = DatabaseCriterionEntry[] /** * Interpreter plugin implementing Excel database functions. * - * Implements: DAVERAGE, DCOUNT, DCOUNTA, DGET, DMAX, DMIN, DPRODUCT, DSUM. + * Implements: DAVERAGE, DCOUNT, DCOUNTA, DGET, DMAX, DMIN, DPRODUCT, DSTDEV, DSTDEVP, DSUM, DVAR, DVARP. */ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginTypecheck { @@ -61,6 +61,22 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType {argumentType: FunctionArgumentType.RANGE}, ], }, + 'DSTDEV': { + method: 'dstdev', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, + ], + }, + 'DSTDEVP': { + method: 'dstdevp', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, + ], + }, 'DSUM': { method: 'dsum', parameters: [ @@ -69,6 +85,22 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType {argumentType: FunctionArgumentType.RANGE}, ], }, + 'DVAR': { + method: 'dvar', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, + ], + }, + 'DVARP': { + method: 'dvarp', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, + ], + }, 'DAVERAGE': { method: 'daverage', parameters: [ @@ -413,6 +445,161 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType }) } + /** + * Returns the sample standard deviation of numeric values in the specified field + * of a database range, for rows that match all criteria. + * Returns #DIV/0! when fewer than 2 numeric values are found. + * + * DSTDEV(database, field, criteria) + */ + public dstdev(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DSTDEV'), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + const values = this.collectNumericValues(database.data, fieldIndex, criteriaRows) + + if (values.length <= 1) { + return new CellError(ErrorType.DIV_BY_ZERO) + } + + const mean = values.reduce((a, b) => a + b, 0) / values.length + const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1) + return Math.sqrt(variance) + }) + } + + /** + * Returns the population standard deviation of numeric values in the specified field + * of a database range, for rows that match all criteria. + * Returns #DIV/0! when no numeric values are found. + * Returns 0 when exactly one numeric value is found. + * + * DSTDEVP(database, field, criteria) + */ + public dstdevp(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DSTDEVP'), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + const values = this.collectNumericValues(database.data, fieldIndex, criteriaRows) + + if (values.length === 0) { + return new CellError(ErrorType.DIV_BY_ZERO) + } + + const mean = values.reduce((a, b) => a + b, 0) / values.length + const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length + return Math.sqrt(variance) + }) + } + + /** + * Returns the sample variance of numeric values in the specified field + * of a database range, for rows that match all criteria. + * Returns #DIV/0! when fewer than 2 numeric values are found. + * + * DVAR(database, field, criteria) + */ + public dvar(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DVAR'), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + const values = this.collectNumericValues(database.data, fieldIndex, criteriaRows) + + if (values.length <= 1) { + return new CellError(ErrorType.DIV_BY_ZERO) + } + + const mean = values.reduce((a, b) => a + b, 0) / values.length + return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1) + }) + } + + /** + * Returns the population variance of numeric values in the specified field + * of a database range, for rows that match all criteria. + * Returns #DIV/0! when no numeric values are found. + * Returns 0 when exactly one numeric value is found. + * + * DVARP(database, field, criteria) + */ + public dvarp(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DVARP'), + (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { + const fieldIndex = this.resolveFieldIndex(database, field) + if (fieldIndex instanceof CellError) { + return fieldIndex + } + + const criteriaRows = this.buildDatabaseCriteria(database, criteria) + if (criteriaRows instanceof CellError) { + return criteriaRows + } + + const values = this.collectNumericValues(database.data, fieldIndex, criteriaRows) + + if (values.length === 0) { + return new CellError(ErrorType.DIV_BY_ZERO) + } + + const mean = values.reduce((a, b) => a + b, 0) / values.length + return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length + }) + } + + /** + * Collects numeric values from the specified field column of matching database rows. + * + * @param dbData - Full database data including header row. + * @param fieldIndex - 0-based column index of the target field. + * @param criteriaRows - Parsed criteria rows. + * @returns Array of numeric values from matching rows. + */ + private collectNumericValues( + dbData: InternalScalarValue[][], + fieldIndex: number, + criteriaRows: DatabaseCriteriaRow[] + ): number[] { + const values: number[] = [] + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (isExtendedNumber(cellValue)) { + values.push(getRawValue(cellValue) as number) + } + } + } + + return values + } + /** * Resolves the field argument to a 0-based column index within the database range. * From 2d7025ef1b6b9e6821a170fc68c67a0cf75fd858 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 10 Apr 2026 15:12:36 +0000 Subject: [PATCH 08/23] Implement all 12 database functions (D-functions family) New DatabasePlugin with shared helpers for the complete D-function family: DCOUNT, DSUM, DAVERAGE, DMAX, DMIN, DGET, DPRODUCT, DCOUNTA, DSTDEV, DSTDEVP, DVAR, DVARP. 169 tests across 12 test suites. All pass. 17 language translations per function. Excel validation: 152/152 PASS (verified in Excel Desktop). Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b2627823..f118c1d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- Added 12 database functions: DCOUNT, DSUM, DAVERAGE, DMAX, DMIN, DGET, DPRODUCT, DCOUNTA, DSTDEV, DSTDEVP, DVAR, DVARP. [#1652](https://github.com/handsontable/hyperformula/pull/1652) - Added new functions: PERCENTILE, PERCENTILE.INC, PERCENTILE.EXC, QUARTILE, QUARTILE.INC, QUARTILE.EXC. [#1650](https://github.com/handsontable/hyperformula/pull/1650) - Added `maxPendingLazyTransformations` configuration option to control memory usage by limiting accumulated transformations before cleanup. [#1629](https://github.com/handsontable/hyperformula/issues/1629) - Added a new function: TEXTJOIN. [#1640](https://github.com/handsontable/hyperformula/pull/1640) - Added a new function: SEQUENCE. [#1645](https://github.com/handsontable/hyperformula/pull/1645) -- Added a new function: DCOUNT. (#1623) ### Fixed From 948132aff77a09d1f5d50e09220c2c3fc743820b Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 10 Apr 2026 15:27:38 +0000 Subject: [PATCH 09/23] fix: remove unnecessary type assertions in DatabasePlugin (lint) --- src/interpreter/plugin/DatabasePlugin.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index 93f88fb43..fffb8794b 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -232,7 +232,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] if (isExtendedNumber(cellValue)) { - product *= getRawValue(cellValue) as number + product *= getRawValue(cellValue) hasNumeric = true } } @@ -268,7 +268,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] if (isExtendedNumber(cellValue)) { - sum += getRawValue(cellValue) as number + sum += getRawValue(cellValue) } } } @@ -592,7 +592,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] if (isExtendedNumber(cellValue)) { - values.push(getRawValue(cellValue) as number) + values.push(getRawValue(cellValue) ) } } } From 18906bc82687e81c870e509c9f1c71ac66593d10 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 15 Apr 2026 12:27:01 +0000 Subject: [PATCH 10/23] Docs: add Database section to built-in-functions.md and nuances to known-limitations.md - Add ### Database section with all 12 D-functions (DAVERAGE, DCOUNT, DCOUNTA, DGET, DMAX, DMIN, DPRODUCT, DSTDEV, DSTDEVP, DSUM, DVAR, DVARP) to built-in-functions.md in alphabetical order between Date and Engineering - Add Database to TOC and remove "database" from "yet to be supported" intro - Add nuance notes for DGET (#VALUE!/#NUM! error semantics) and DMAX/DMIN/DPRODUCT (return 0 when no records match) Co-Authored-By: Claude Sonnet 4.6 --- docs/guide/built-in-functions.md | 21 +++++++++++++++++++-- docs/guide/known-limitations.md | 2 ++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index e1ad66821..b1c998e60 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -28,6 +28,7 @@ The latest version of HyperFormula has an extensive collection of **{{ $page.functionsCount }}** functions grouped into categories: - [Array manipulation](#array-manipulation) +- [Database](#database) - [Date and time](#date-and-time) - [Engineering](#engineering) - [Information](#information) @@ -40,8 +41,7 @@ The latest version of HyperFormula has an extensive collection of - [Statistical](#statistical) - [Text](#text) -_Some categories such as compatibility, cube, and database are yet to be -supported._ +_Some categories such as compatibility and cube are yet to be supported._ ::: tip You can modify the built-in functions or create your own, by adding a [custom function](custom-functions). @@ -91,6 +91,23 @@ Total number of functions: **{{ $page.functionsCount }}** | YEAR | Returns the year as a number according to the internal calculation rules. | YEAR(Number) | | YEARFRAC | Computes the difference between two date values, in fraction of years. | YEARFRAC(Date2, Date1[, Format]) | +### Database + +| Function ID | Description | Syntax | +|:------------|:----------------------------------------------------------------------------------------------------------------|:----------------------------------| +| DAVERAGE | Returns the average of all values in a database field that match the given criteria. | DAVERAGE(Database, Field, Criteria) | +| DCOUNT | Counts the cells containing numbers in a database field that match the given criteria. | DCOUNT(Database, Field, Criteria) | +| DCOUNTA | Counts the non-empty cells in a database field that match the given criteria. | DCOUNTA(Database, Field, Criteria) | +| DGET | Returns the single value from a database field that matches the given criteria. Returns #VALUE! if no records match, and #NUM! if more than one record matches. | DGET(Database, Field, Criteria) | +| DMAX | Returns the maximum value in a database field that matches the given criteria. | DMAX(Database, Field, Criteria) | +| DMIN | Returns the minimum value in a database field that matches the given criteria. | DMIN(Database, Field, Criteria) | +| DPRODUCT | Returns the product of all values in a database field that match the given criteria. | DPRODUCT(Database, Field, Criteria) | +| DSTDEV | Returns the sample standard deviation of all values in a database field that match the given criteria. | DSTDEV(Database, Field, Criteria) | +| DSTDEVP | Returns the population standard deviation of all values in a database field that match the given criteria. | DSTDEVP(Database, Field, Criteria) | +| DSUM | Returns the sum of all values in a database field that match the given criteria. | DSUM(Database, Field, Criteria) | +| DVAR | Returns the sample variance of all values in a database field that match the given criteria. | DVAR(Database, Field, Criteria) | +| DVARP | Returns the population variance of all values in a database field that match the given criteria. | DVARP(Database, Field, Criteria) | + ### Engineering | Function ID | Description | Syntax | diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 72753b9b2..572905982 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -38,3 +38,5 @@ you can't compare the arguments in a formula like this: * The INDEX function doesn't support returning whole rows or columns of the source range – it always returns the contents of a single cell. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. +* DGET returns `#VALUE!` when no records match the criteria, and `#NUM!` when more than one record matches. +* DMAX, DMIN, and DPRODUCT return `0` when no records match the criteria (consistent with Excel behavior). From 0dc18ad774f2342e54e364276783f125cdfb2f17 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 16 Apr 2026 12:12:09 +0000 Subject: [PATCH 11/23] refactor: extract shared boilerplate in DatabasePlugin (DRY) Introduce databaseFunctionParameters constant and withDatabaseArgs() helper to eliminate repeated field/criteria resolution across all 12 database functions. Reduces file from 725 to 533 lines with no behavioral changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/interpreter/plugin/DatabasePlugin.ts | 554 ++++++++--------------- 1 file changed, 181 insertions(+), 373 deletions(-) diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index fffb8794b..5e58f2d6a 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -29,6 +29,13 @@ interface DatabaseCriterionEntry { */ type DatabaseCriteriaRow = DatabaseCriterionEntry[] +/** Shared parameter signature for all database functions: (database RANGE, field SCALAR, criteria RANGE). */ +const databaseFunctionParameters = [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.RANGE}, +] + /** * Interpreter plugin implementing Excel database functions. * @@ -37,112 +44,31 @@ type DatabaseCriteriaRow = DatabaseCriterionEntry[] export class DatabasePlugin extends FunctionPlugin implements FunctionPluginTypecheck { public static implementedFunctions: ImplementedFunctions = { - 'DCOUNT': { - method: 'dcount', - parameters: [ - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.SCALAR}, - {argumentType: FunctionArgumentType.RANGE}, - ], - }, - 'DCOUNTA': { - method: 'dcounta', - parameters: [ - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.SCALAR}, - {argumentType: FunctionArgumentType.RANGE}, - ], - }, - 'DPRODUCT': { - method: 'dproduct', - parameters: [ - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.SCALAR}, - {argumentType: FunctionArgumentType.RANGE}, - ], - }, - 'DSTDEV': { - method: 'dstdev', - parameters: [ - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.SCALAR}, - {argumentType: FunctionArgumentType.RANGE}, - ], - }, - 'DSTDEVP': { - method: 'dstdevp', - parameters: [ - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.SCALAR}, - {argumentType: FunctionArgumentType.RANGE}, - ], - }, - 'DSUM': { - method: 'dsum', - parameters: [ - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.SCALAR}, - {argumentType: FunctionArgumentType.RANGE}, - ], - }, - 'DVAR': { - method: 'dvar', - parameters: [ - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.SCALAR}, - {argumentType: FunctionArgumentType.RANGE}, - ], - }, - 'DVARP': { - method: 'dvarp', - parameters: [ - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.SCALAR}, - {argumentType: FunctionArgumentType.RANGE}, - ], - }, - 'DAVERAGE': { - method: 'daverage', - parameters: [ - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.SCALAR}, - {argumentType: FunctionArgumentType.RANGE}, - ], - }, - 'DGET': { - method: 'dget', - parameters: [ - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.SCALAR}, - {argumentType: FunctionArgumentType.RANGE}, - ], - }, - 'DMAX': { - method: 'dmax', - parameters: [ - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.SCALAR}, - {argumentType: FunctionArgumentType.RANGE}, - ], - }, - 'DMIN': { - method: 'dmin', - parameters: [ - {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.SCALAR}, - {argumentType: FunctionArgumentType.RANGE}, - ], - }, + 'DCOUNT': {method: 'dcount', parameters: databaseFunctionParameters}, + 'DCOUNTA': {method: 'dcounta', parameters: databaseFunctionParameters}, + 'DPRODUCT': {method: 'dproduct', parameters: databaseFunctionParameters}, + 'DSTDEV': {method: 'dstdev', parameters: databaseFunctionParameters}, + 'DSTDEVP': {method: 'dstdevp', parameters: databaseFunctionParameters}, + 'DSUM': {method: 'dsum', parameters: databaseFunctionParameters}, + 'DVAR': {method: 'dvar', parameters: databaseFunctionParameters}, + 'DVARP': {method: 'dvarp', parameters: databaseFunctionParameters}, + 'DAVERAGE': {method: 'daverage', parameters: databaseFunctionParameters}, + 'DGET': {method: 'dget', parameters: databaseFunctionParameters}, + 'DMAX': {method: 'dmax', parameters: databaseFunctionParameters}, + 'DMIN': {method: 'dmin', parameters: databaseFunctionParameters}, } /** - * Counts cells containing numbers in the specified field of a database range, - * for rows that match all criteria. - * - * DCOUNT(database, field, criteria) + * Resolves field index and criteria, then delegates to the callback with the parsed database arguments. + * Shared boilerplate for all 12 database functions. */ - public dcount(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('DCOUNT'), + private withDatabaseArgs( + ast: ProcedureAst, + state: InterpreterState, + functionName: string, + callback: (dbData: InternalScalarValue[][], fieldIndex: number, criteriaRows: DatabaseCriteriaRow[]) => InternalScalarValue + ): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata(functionName), (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { const fieldIndex = this.resolveFieldIndex(database, field) if (fieldIndex instanceof CellError) { @@ -154,20 +80,30 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType return criteriaRows } - const dbData = database.data - let count = 0 + return callback(database.data, fieldIndex, criteriaRows) + }) + } - for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { - if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { - const cellValue = dbData[rowIdx][fieldIndex] - if (isExtendedNumber(cellValue)) { - count++ - } + /** + * Counts cells containing numbers in the specified field of a database range, + * for rows that match all criteria. + * + * DCOUNT(database, field, criteria) + */ + public dcount(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.withDatabaseArgs(ast, state, 'DCOUNT', (dbData, fieldIndex, criteriaRows) => { + let count = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + if (isExtendedNumber(dbData[rowIdx][fieldIndex])) { + count++ } } + } - return count - }) + return count + }) } /** @@ -177,32 +113,20 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * DCOUNTA(database, field, criteria) */ public dcounta(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('DCOUNTA'), - (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { - const fieldIndex = this.resolveFieldIndex(database, field) - if (fieldIndex instanceof CellError) { - return fieldIndex - } - - const criteriaRows = this.buildDatabaseCriteria(database, criteria) - if (criteriaRows instanceof CellError) { - return criteriaRows - } - - const dbData = database.data - let count = 0 - - for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { - if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { - const cellValue = dbData[rowIdx][fieldIndex] - if (cellValue !== EmptyValue && cellValue !== undefined && cellValue !== null) { - count++ - } + return this.withDatabaseArgs(ast, state, 'DCOUNTA', (dbData, fieldIndex, criteriaRows) => { + let count = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue !== EmptyValue && cellValue !== undefined && cellValue !== null) { + count++ } } + } - return count - }) + return count + }) } /** @@ -212,34 +136,22 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * DPRODUCT(database, field, criteria) */ public dproduct(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('DPRODUCT'), - (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { - const fieldIndex = this.resolveFieldIndex(database, field) - if (fieldIndex instanceof CellError) { - return fieldIndex - } - - const criteriaRows = this.buildDatabaseCriteria(database, criteria) - if (criteriaRows instanceof CellError) { - return criteriaRows - } - - const dbData = database.data - let product = 1 - let hasNumeric = false - - for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { - if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { - const cellValue = dbData[rowIdx][fieldIndex] - if (isExtendedNumber(cellValue)) { - product *= getRawValue(cellValue) - hasNumeric = true - } + return this.withDatabaseArgs(ast, state, 'DPRODUCT', (dbData, fieldIndex, criteriaRows) => { + let product = 1 + let hasNumeric = false + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (isExtendedNumber(cellValue)) { + product *= getRawValue(cellValue) + hasNumeric = true } } + } - return hasNumeric ? product : 0 - }) + return hasNumeric ? product : 0 + }) } /** @@ -249,32 +161,20 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * DSUM(database, field, criteria) */ public dsum(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('DSUM'), - (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { - const fieldIndex = this.resolveFieldIndex(database, field) - if (fieldIndex instanceof CellError) { - return fieldIndex - } - - const criteriaRows = this.buildDatabaseCriteria(database, criteria) - if (criteriaRows instanceof CellError) { - return criteriaRows - } - - const dbData = database.data - let sum = 0 - - for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { - if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { - const cellValue = dbData[rowIdx][fieldIndex] - if (isExtendedNumber(cellValue)) { - sum += getRawValue(cellValue) - } + return this.withDatabaseArgs(ast, state, 'DSUM', (dbData, fieldIndex, criteriaRows) => { + let sum = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (isExtendedNumber(cellValue)) { + sum += getRawValue(cellValue) } } + } - return sum - }) + return sum + }) } /** @@ -285,38 +185,26 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * DAVERAGE(database, field, criteria) */ public daverage(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('DAVERAGE'), - (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { - const fieldIndex = this.resolveFieldIndex(database, field) - if (fieldIndex instanceof CellError) { - return fieldIndex - } - - const criteriaRows = this.buildDatabaseCriteria(database, criteria) - if (criteriaRows instanceof CellError) { - return criteriaRows - } - - const dbData = database.data - let sum = 0 - let count = 0 - - for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { - if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { - const cellValue = dbData[rowIdx][fieldIndex] - if (isExtendedNumber(cellValue)) { - sum += getRawValue(cellValue) - count++ - } + return this.withDatabaseArgs(ast, state, 'DAVERAGE', (dbData, fieldIndex, criteriaRows) => { + let sum = 0 + let count = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (isExtendedNumber(cellValue)) { + sum += getRawValue(cellValue) + count++ } } + } - if (count === 0) { - return new CellError(ErrorType.DIV_BY_ZERO) - } + if (count === 0) { + return new CellError(ErrorType.DIV_BY_ZERO) + } - return sum / count - }) + return sum / count + }) } /** @@ -327,40 +215,28 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * DGET(database, field, criteria) */ public dget(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('DGET'), - (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { - const fieldIndex = this.resolveFieldIndex(database, field) - if (fieldIndex instanceof CellError) { - return fieldIndex - } - - const criteriaRows = this.buildDatabaseCriteria(database, criteria) - if (criteriaRows instanceof CellError) { - return criteriaRows - } - - const dbData = database.data - let matchedValue: InternalScalarValue | undefined - let matchCount = 0 - - for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { - if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { - matchCount++ - if (matchCount > 1) { - return new CellError(ErrorType.NUM, ErrorMessage.ValueLarge) - } - matchedValue = dbData[rowIdx][fieldIndex] + return this.withDatabaseArgs(ast, state, 'DGET', (dbData, fieldIndex, criteriaRows) => { + let matchedValue: InternalScalarValue | undefined + let matchCount = 0 + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + matchCount++ + if (matchCount > 1) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueLarge) } + matchedValue = dbData[rowIdx][fieldIndex] } + } - if (matchCount === 0) { - return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) - } + if (matchCount === 0) { + return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) + } - return matchedValue === EmptyValue || matchedValue === undefined || matchedValue === null - ? 0 - : matchedValue - }) + return matchedValue === EmptyValue || matchedValue === undefined || matchedValue === null + ? 0 + : matchedValue + }) } /** @@ -371,37 +247,25 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * DMAX(database, field, criteria) */ public dmax(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('DMAX'), - (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { - const fieldIndex = this.resolveFieldIndex(database, field) - if (fieldIndex instanceof CellError) { - return fieldIndex - } - - const criteriaRows = this.buildDatabaseCriteria(database, criteria) - if (criteriaRows instanceof CellError) { - return criteriaRows - } - - const dbData = database.data - let max = -Infinity - let hasNumeric = false - - for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { - if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { - const cellValue = dbData[rowIdx][fieldIndex] - if (isExtendedNumber(cellValue)) { - const numValue = getRawValue(cellValue) - if (numValue > max) { - max = numValue - } - hasNumeric = true + return this.withDatabaseArgs(ast, state, 'DMAX', (dbData, fieldIndex, criteriaRows) => { + let max = -Infinity + let hasNumeric = false + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (isExtendedNumber(cellValue)) { + const numValue = getRawValue(cellValue) + if (numValue > max) { + max = numValue } + hasNumeric = true } } + } - return hasNumeric ? max : 0 - }) + return hasNumeric ? max : 0 + }) } /** @@ -412,37 +276,25 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * DMIN(database, field, criteria) */ public dmin(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('DMIN'), - (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { - const fieldIndex = this.resolveFieldIndex(database, field) - if (fieldIndex instanceof CellError) { - return fieldIndex - } - - const criteriaRows = this.buildDatabaseCriteria(database, criteria) - if (criteriaRows instanceof CellError) { - return criteriaRows - } - - const dbData = database.data - let min = Infinity - let hasNumeric = false - - for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { - if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { - const cellValue = dbData[rowIdx][fieldIndex] - if (isExtendedNumber(cellValue)) { - const numValue = getRawValue(cellValue) - if (numValue < min) { - min = numValue - } - hasNumeric = true + return this.withDatabaseArgs(ast, state, 'DMIN', (dbData, fieldIndex, criteriaRows) => { + let min = Infinity + let hasNumeric = false + + for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { + if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (isExtendedNumber(cellValue)) { + const numValue = getRawValue(cellValue) + if (numValue < min) { + min = numValue } + hasNumeric = true } } + } - return hasNumeric ? min : 0 - }) + return hasNumeric ? min : 0 + }) } /** @@ -453,28 +305,17 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * DSTDEV(database, field, criteria) */ public dstdev(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('DSTDEV'), - (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { - const fieldIndex = this.resolveFieldIndex(database, field) - if (fieldIndex instanceof CellError) { - return fieldIndex - } - - const criteriaRows = this.buildDatabaseCriteria(database, criteria) - if (criteriaRows instanceof CellError) { - return criteriaRows - } - - const values = this.collectNumericValues(database.data, fieldIndex, criteriaRows) + return this.withDatabaseArgs(ast, state, 'DSTDEV', (dbData, fieldIndex, criteriaRows) => { + const values = this.collectNumericValues(dbData, fieldIndex, criteriaRows) - if (values.length <= 1) { - return new CellError(ErrorType.DIV_BY_ZERO) - } + if (values.length <= 1) { + return new CellError(ErrorType.DIV_BY_ZERO) + } - const mean = values.reduce((a, b) => a + b, 0) / values.length - const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1) - return Math.sqrt(variance) - }) + const mean = values.reduce((a, b) => a + b, 0) / values.length + const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1) + return Math.sqrt(variance) + }) } /** @@ -486,28 +327,17 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * DSTDEVP(database, field, criteria) */ public dstdevp(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('DSTDEVP'), - (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { - const fieldIndex = this.resolveFieldIndex(database, field) - if (fieldIndex instanceof CellError) { - return fieldIndex - } - - const criteriaRows = this.buildDatabaseCriteria(database, criteria) - if (criteriaRows instanceof CellError) { - return criteriaRows - } - - const values = this.collectNumericValues(database.data, fieldIndex, criteriaRows) + return this.withDatabaseArgs(ast, state, 'DSTDEVP', (dbData, fieldIndex, criteriaRows) => { + const values = this.collectNumericValues(dbData, fieldIndex, criteriaRows) - if (values.length === 0) { - return new CellError(ErrorType.DIV_BY_ZERO) - } + if (values.length === 0) { + return new CellError(ErrorType.DIV_BY_ZERO) + } - const mean = values.reduce((a, b) => a + b, 0) / values.length - const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length - return Math.sqrt(variance) - }) + const mean = values.reduce((a, b) => a + b, 0) / values.length + const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length + return Math.sqrt(variance) + }) } /** @@ -518,27 +348,16 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * DVAR(database, field, criteria) */ public dvar(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('DVAR'), - (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { - const fieldIndex = this.resolveFieldIndex(database, field) - if (fieldIndex instanceof CellError) { - return fieldIndex - } - - const criteriaRows = this.buildDatabaseCriteria(database, criteria) - if (criteriaRows instanceof CellError) { - return criteriaRows - } - - const values = this.collectNumericValues(database.data, fieldIndex, criteriaRows) + return this.withDatabaseArgs(ast, state, 'DVAR', (dbData, fieldIndex, criteriaRows) => { + const values = this.collectNumericValues(dbData, fieldIndex, criteriaRows) - if (values.length <= 1) { - return new CellError(ErrorType.DIV_BY_ZERO) - } + if (values.length <= 1) { + return new CellError(ErrorType.DIV_BY_ZERO) + } - const mean = values.reduce((a, b) => a + b, 0) / values.length - return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1) - }) + const mean = values.reduce((a, b) => a + b, 0) / values.length + return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1) + }) } /** @@ -550,27 +369,16 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * DVARP(database, field, criteria) */ public dvarp(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('DVARP'), - (database: SimpleRangeValue, field: RawScalarValue, criteria: SimpleRangeValue) => { - const fieldIndex = this.resolveFieldIndex(database, field) - if (fieldIndex instanceof CellError) { - return fieldIndex - } - - const criteriaRows = this.buildDatabaseCriteria(database, criteria) - if (criteriaRows instanceof CellError) { - return criteriaRows - } - - const values = this.collectNumericValues(database.data, fieldIndex, criteriaRows) + return this.withDatabaseArgs(ast, state, 'DVARP', (dbData, fieldIndex, criteriaRows) => { + const values = this.collectNumericValues(dbData, fieldIndex, criteriaRows) - if (values.length === 0) { - return new CellError(ErrorType.DIV_BY_ZERO) - } + if (values.length === 0) { + return new CellError(ErrorType.DIV_BY_ZERO) + } - const mean = values.reduce((a, b) => a + b, 0) / values.length - return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length - }) + const mean = values.reduce((a, b) => a + b, 0) / values.length + return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length + }) } /** From 6a145944f1d17886d8281c05368f26a2b2264b35 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 16 Apr 2026 13:08:56 +0000 Subject: [PATCH 12/23] fix: remove Excel mention from known-limitations, trailing space - Remove "(consistent with Excel behavior)" from known-limitations.md per review guideline: describe HF behavior only, no Excel references - Remove trailing space in collectNumericValues Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guide/known-limitations.md | 2 +- src/interpreter/plugin/DatabasePlugin.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 572905982..6b628db94 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -39,4 +39,4 @@ you can't compare the arguments in a formula like this: * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. * DGET returns `#VALUE!` when no records match the criteria, and `#NUM!` when more than one record matches. -* DMAX, DMIN, and DPRODUCT return `0` when no records match the criteria (consistent with Excel behavior). +* DMAX, DMIN, and DPRODUCT return `0` when no records match the criteria. diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index 5e58f2d6a..df3ddb0e9 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -400,7 +400,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] if (isExtendedNumber(cellValue)) { - values.push(getRawValue(cellValue) ) + values.push(getRawValue(cellValue)) } } } From f7d91fcb4f44a7fdaf4d1580af99b2452afa351a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 16 Apr 2026 13:37:52 +0000 Subject: [PATCH 13/23] ci: retrigger after tests repo rebase Co-Authored-By: Claude Opus 4.6 (1M context) From 594c9a26ce9832ba0d6579ec1d666b907375b91b Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 17 Apr 2026 08:11:34 +0000 Subject: [PATCH 14/23] fix: Czech DCOUNT translation and boolean field coercion Addresses Cursor Bugbot findings on PR #1652: - csCZ.ts: DCOUNT was English 'DCOUNT' instead of localized 'DPOCET' (consistent with DCOUNTA = 'DPOCET2') - resolveFieldIndex: coerce boolean field arg to number (TRUE -> 1, FALSE -> 0) per Excel convention, matching ArithmeticHelper semantics Regression test added in hyperformula-tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/i18n/languages/csCZ.ts | 2 +- src/interpreter/plugin/DatabasePlugin.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index dd72b170e..e163d9ab1 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -76,7 +76,7 @@ const dictionary: RawTranslationPackage = { DAYS: 'DAYS', DB: 'ODPIS.ZRYCH', DAVERAGE: 'DPRUMER', - DCOUNT: 'DCOUNT', + DCOUNT: 'DPOCET', DCOUNTA: 'DPOCET2', DGET: 'DZISKAT', DMAX: 'DMAX', diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index df3ddb0e9..6b9417b01 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -413,6 +413,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * * @param database - The database range (first row = headers). * @param field - A string (header name, case-insensitive) or number (1-based column index). + * Booleans are coerced to numbers (TRUE → 1, FALSE → 0) per Excel convention. * @returns 0-based column index, or CellError if field is invalid. */ private resolveFieldIndex(database: SimpleRangeValue, field: RawScalarValue): number | CellError { @@ -429,8 +430,10 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) } - if (isExtendedNumber(field)) { - const index = Math.trunc(getRawValue(field)) + const numericField = typeof field === 'boolean' ? Number(field) : field + + if (isExtendedNumber(numericField)) { + const index = Math.trunc(getRawValue(numericField)) if (index < 1 || index > headers.length) { return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) } From 11b0488da146146d80830b03091065895947f6e3 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 17 Apr 2026 08:30:08 +0000 Subject: [PATCH 15/23] fix: guard resolveFieldIndex against non-finite indices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use Number.isFinite to reject NaN and Infinity before the bounds check — the previous `index < 1 || index > headers.length` comparison silently returns false for NaN, which would let a NaN index through and produce incorrect results. Addresses Cursor Bugbot finding. Regression test added in hyperformula-tests. --- src/interpreter/plugin/DatabasePlugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index 6b9417b01..86f965c21 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -434,7 +434,7 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType if (isExtendedNumber(numericField)) { const index = Math.trunc(getRawValue(numericField)) - if (index < 1 || index > headers.length) { + if (!Number.isFinite(index) || index < 1 || index > headers.length) { return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) } return index - 1 From 3b238de9243583cb9c3d26713fe3a4c39cbcc3d4 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 17 Apr 2026 08:51:45 +0000 Subject: [PATCH 16/23] fix: propagate CellError from field and criteria arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCALAR argument types do not auto-propagate CellError in the framework (coerceArgumentsToRequiredTypes skips propagation for SCALAR), so the raw error reaches resolveFieldIndex and buildDatabaseCriteria. Both sites used to fall through into generic #VALUE! / BadCriterion, discarding the original error type. Now both check for CellError upfront and return it verbatim, preserving #DIV/0!, #NUM!, etc. — matching Excel's error-propagation behavior. Addresses Cursor Bugbot finding. Regression tests added in hyperformula-tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/interpreter/plugin/DatabasePlugin.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index 86f965c21..de308b623 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -417,6 +417,10 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * @returns 0-based column index, or CellError if field is invalid. */ private resolveFieldIndex(database: SimpleRangeValue, field: RawScalarValue): number | CellError { + if (field instanceof CellError) { + return field + } + const headers = database.data[0] if (typeof field === 'string') { @@ -485,6 +489,11 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType continue } + // Propagate errors from the criteria cells instead of masking them as BadCriterion + if (criterionValue instanceof CellError) { + return criterionValue + } + const rawCriterionValue = isExtendedNumber(criterionValue) ? getRawValue(criterionValue) : criterionValue const criterionPackage = this.interpreter.criterionBuilder.fromCellValue( From 0099900c5fb6119eefca7060165c963b5a22395c Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 17 Apr 2026 09:12:47 +0000 Subject: [PATCH 17/23] fix: Dutch DCOUNTA translation typo (DBAAANTALC -> DBAANTALC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dutch translation had a triple A typo — DCOUNT is DBAANTAL and the COUNTA variant with C suffix should be DBAANTALC. Addresses Cursor Bugbot finding. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/i18n/languages/nlNL.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 98efe1d15..57d6fddb9 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -77,7 +77,7 @@ const dictionary: RawTranslationPackage = { DB: 'DB', DAVERAGE: 'DBGEMIDDELDE', DCOUNT: 'DBAANTAL', - DCOUNTA: 'DBAAANTALC', + DCOUNTA: 'DBAANTALC', DGET: 'DBLEZEN', DMAX: 'DBMAX', DMIN: 'DBMIN', From 11cf389bdfbefe9c7f6f35458bbbfedcf4a113a7 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 17 Apr 2026 09:14:57 +0000 Subject: [PATCH 18/23] fix: Norwegian DCOUNT translation (ANTALLDB -> DANTALL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous value lacked the D prefix and reversed the word order. Verified against Microsoft Support's official Norwegian Excel reference: DCOUNT → DANTALL. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/i18n/languages/nbNO.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 495a36ccd..80d9948f6 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -76,7 +76,7 @@ const dictionary: RawTranslationPackage = { DAYS: 'DAGER', DB: 'DAVSKR', DAVERAGE: 'DGJENNOMSNITT', - DCOUNT: 'ANTALLDB', + DCOUNT: 'DANTALL', DCOUNTA: 'DANTALLA', DGET: 'DHENT', DMAX: 'DMAKS', From 947a9fdbfe2644e6223b68a1dfa662608bc760c8 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 17 Apr 2026 09:40:21 +0000 Subject: [PATCH 19/23] fix: propagate CellError from field cells in D-function aggregations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isExtendedNumber silently skipped CellError values, letting numeric aggregation D-functions return a clean number even when a matching field cell contained #DIV/0! / #N/A / etc. Excel propagates the error instead. Each of DSUM, DPRODUCT, DAVERAGE, DMAX, DMIN now returns the error immediately when encountered. collectNumericValues (shared by DSTDEV, DSTDEVP, DVAR, DVARP) now returns CellError | number[] so those four propagate too. DCOUNT, DCOUNTA, and DGET are unchanged — COUNT-family semantics in Excel do not propagate field errors, and DGET already returns the cell verbatim. Regression test added in hyperformula-tests. Addresses Cursor Bugbot finding. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/interpreter/plugin/DatabasePlugin.ts | 35 ++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index de308b623..84f9b32fa 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -143,6 +143,9 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } if (isExtendedNumber(cellValue)) { product *= getRawValue(cellValue) hasNumeric = true @@ -167,6 +170,9 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } if (isExtendedNumber(cellValue)) { sum += getRawValue(cellValue) } @@ -192,6 +198,9 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } if (isExtendedNumber(cellValue)) { sum += getRawValue(cellValue) count++ @@ -254,6 +263,9 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } if (isExtendedNumber(cellValue)) { const numValue = getRawValue(cellValue) if (numValue > max) { @@ -283,6 +295,9 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } if (isExtendedNumber(cellValue)) { const numValue = getRawValue(cellValue) if (numValue < min) { @@ -307,6 +322,9 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType public dstdev(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.withDatabaseArgs(ast, state, 'DSTDEV', (dbData, fieldIndex, criteriaRows) => { const values = this.collectNumericValues(dbData, fieldIndex, criteriaRows) + if (values instanceof CellError) { + return values + } if (values.length <= 1) { return new CellError(ErrorType.DIV_BY_ZERO) @@ -329,6 +347,9 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType public dstdevp(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.withDatabaseArgs(ast, state, 'DSTDEVP', (dbData, fieldIndex, criteriaRows) => { const values = this.collectNumericValues(dbData, fieldIndex, criteriaRows) + if (values instanceof CellError) { + return values + } if (values.length === 0) { return new CellError(ErrorType.DIV_BY_ZERO) @@ -350,6 +371,9 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType public dvar(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.withDatabaseArgs(ast, state, 'DVAR', (dbData, fieldIndex, criteriaRows) => { const values = this.collectNumericValues(dbData, fieldIndex, criteriaRows) + if (values instanceof CellError) { + return values + } if (values.length <= 1) { return new CellError(ErrorType.DIV_BY_ZERO) @@ -371,6 +395,9 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType public dvarp(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.withDatabaseArgs(ast, state, 'DVARP', (dbData, fieldIndex, criteriaRows) => { const values = this.collectNumericValues(dbData, fieldIndex, criteriaRows) + if (values instanceof CellError) { + return values + } if (values.length === 0) { return new CellError(ErrorType.DIV_BY_ZERO) @@ -387,18 +414,22 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType * @param dbData - Full database data including header row. * @param fieldIndex - 0-based column index of the target field. * @param criteriaRows - Parsed criteria rows. - * @returns Array of numeric values from matching rows. + * @returns Array of numeric values from matching rows, or the first CellError + * encountered in a matching field cell (propagated per Excel semantics). */ private collectNumericValues( dbData: InternalScalarValue[][], fieldIndex: number, criteriaRows: DatabaseCriteriaRow[] - ): number[] { + ): number[] | CellError { const values: number[] = [] for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } if (isExtendedNumber(cellValue)) { values.push(getRawValue(cellValue)) } From 2727f8eae14a7ba879fb3f570b80193193b0f0b3 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 17 Apr 2026 09:50:30 +0000 Subject: [PATCH 20/23] fix: propagate CellError from field cells in DCOUNT and DCOUNTA too Extends the CellError propagation introduced in 947a9fdbf to DCOUNT and DCOUNTA. This keeps all 12 D-functions consistent: a matching field cell containing an error surfaces the error with its original type rather than being silently skipped or counted. Addresses Cursor Bugbot findings. Regression tests added in hyperformula-tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/interpreter/plugin/DatabasePlugin.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index 84f9b32fa..b8649c6d7 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -96,7 +96,11 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { - if (isExtendedNumber(dbData[rowIdx][fieldIndex])) { + const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } + if (isExtendedNumber(cellValue)) { count++ } } @@ -119,6 +123,9 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } if (cellValue !== EmptyValue && cellValue !== undefined && cellValue !== null) { count++ } From 4e73545501fb2c8021eae5b3d257913c7e61ca0a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 17 Apr 2026 10:05:51 +0000 Subject: [PATCH 21/23] fix: Polish diacritics + DGET CellError explicit guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plPL: DCOUNTA 'BD.ILE.REKORDOW.A' -> 'BD.ILE.REKORDÓW.A' (missing Ó, matches DCOUNT pattern; Cursor Bugbot finding). - plPL: DAVERAGE 'BD.SREDNIA' -> 'BD.ŚREDNIA' (missing Ś, per Microsoft Polish documentation; pre-emptive fix for the same class of issue). - DGET: explicit CellError check on the matched cell makes the error- propagation path consistent with the other D-functions (previously worked by accident via fall-through). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/i18n/languages/plPL.ts | 4 ++-- src/interpreter/plugin/DatabasePlugin.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 0b0db49cb..251a35bba 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -75,9 +75,9 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DNI.360', DAYS: 'DNI', DB: 'DB', - DAVERAGE: 'BD.SREDNIA', + DAVERAGE: 'BD.ŚREDNIA', DCOUNT: 'BD.ILE.REKORDÓW', - DCOUNTA: 'BD.ILE.REKORDOW.A', + DCOUNTA: 'BD.ILE.REKORDÓW.A', DGET: 'BD.POLE', DMAX: 'BD.MAX', DMIN: 'BD.MIN', diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index b8649c6d7..57ad06430 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -237,11 +237,15 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { + const cellValue = dbData[rowIdx][fieldIndex] + if (cellValue instanceof CellError) { + return cellValue + } matchCount++ if (matchCount > 1) { return new CellError(ErrorType.NUM, ErrorMessage.ValueLarge) } - matchedValue = dbData[rowIdx][fieldIndex] + matchedValue = cellValue } } From 42d5e09187c0a394661816c8e3095a5ef4a4a710 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 17 Apr 2026 10:18:44 +0000 Subject: [PATCH 22/23] fix: correct D-function translations for cs, tr, da, pt per Microsoft docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proactive audit against Microsoft's official localized Excel documentation (https://support.microsoft.com/{lang}/office/...) caught systemic mismatches beyond what Cursor Bugbot had flagged so far. Czech (csCZ) — 7 corrections (diacritics + DSUM word): - DAVERAGE DPRUMER -> DPRŮMĚR - DCOUNT DPOCET -> DPOČET - DCOUNTA DPOCET2 -> DPOČET2 - DGET DZISKAT -> DZÍSKAT - DPRODUCT DSOUCIN -> DSOUČIN - DSTDEV DSMODCH.VYBER -> DSMODCH.VÝBĚR - DSUM DSOUCET -> DSUMA (entire word was wrong) - DVAR DVAR.VYBER -> DVAR.VÝBĚR Turkish (trTR) — 11 corrections (VSEÇ prefix across the family): - DAVERAGE VORTALAMA -> VSEÇORT - DCOUNT VSAY -> VSEÇSAY - DCOUNTA VSAYMA -> VSEÇSAYDOLU - DMAX VMAKS -> VSEÇMAK - DMIN VMİN -> VSEÇMİN - DPRODUCT VÇARPIM -> VSEÇÇARP - DSTDEV VSTDSAPMA -> VSEÇSTDSAPMA - DSTDEVP VSTDSAPMAP -> VSEÇSTDSAPMAS - DSUM VTOPLA -> VSEÇTOPLA - DVAR VVAR -> VSEÇVAR - DVARP VVARP -> VSEÇVARS (DGET stays as VAL — Microsoft Turkish keeps the short form.) Danish (daDK) — 3 corrections: - DAVERAGE DGENNEMSNIT -> DMIDDEL - DCOUNT ANTAL.DB -> DTÆL - DCOUNTA ANTAL.DBV -> DTÆLV Portuguese (ptPT) — 3 corrections: - DCOUNTA BDCONTARA -> BDCONTAR.VAL - DGET BDEXTRAIR -> BDOBTER - DPRODUCT BDPRODUTO -> BDMULTIPL All 24 changes verified directly against Microsoft's support pages. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/i18n/languages/csCZ.ts | 16 ++++++++-------- src/i18n/languages/daDK.ts | 6 +++--- src/i18n/languages/ptPT.ts | 6 +++--- src/i18n/languages/trTR.ts | 22 +++++++++++----------- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index e163d9ab1..71ce56b14 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -75,17 +75,17 @@ const dictionary: RawTranslationPackage = { DAYS360: 'ROK360', DAYS: 'DAYS', DB: 'ODPIS.ZRYCH', - DAVERAGE: 'DPRUMER', - DCOUNT: 'DPOCET', - DCOUNTA: 'DPOCET2', - DGET: 'DZISKAT', + DAVERAGE: 'DPRŮMĚR', + DCOUNT: 'DPOČET', + DCOUNTA: 'DPOČET2', + DGET: 'DZÍSKAT', DMAX: 'DMAX', DMIN: 'DMIN', - DPRODUCT: 'DSOUCIN', - DSTDEV: 'DSMODCH.VYBER', + DPRODUCT: 'DSOUČIN', + DSTDEV: 'DSMODCH.VÝBĚR', DSTDEVP: 'DSMODCH', - DSUM: 'DSOUCET', - DVAR: 'DVAR.VYBER', + DSUM: 'DSUMA', + DVAR: 'DVAR.VÝBĚR', DVARP: 'DVAR', DDB: 'ODPIS.ZRYCH2', DEC2BIN: 'DEC2BIN', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index fbe402417..ea5a1e4ce 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -75,9 +75,9 @@ const dictionary: RawTranslationPackage = { DAYS360: 'DAGE360', DAYS: 'DAGE', DB: 'DB', - DAVERAGE: 'DGENNEMSNIT', - DCOUNT: 'ANTAL.DB', - DCOUNTA: 'ANTAL.DBV', + DAVERAGE: 'DMIDDEL', + DCOUNT: 'DTÆL', + DCOUNTA: 'DTÆLV', DGET: 'DHENT', DMAX: 'DMAKS', DMIN: 'DMIN', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index c512bb3a2..3195612f0 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -77,11 +77,11 @@ const dictionary: RawTranslationPackage = { DB: 'BD', DAVERAGE: 'BDMÉDIA', DCOUNT: 'BDCONTAR', - DCOUNTA: 'BDCONTARA', - DGET: 'BDEXTRAIR', + DCOUNTA: 'BDCONTAR.VAL', + DGET: 'BDOBTER', DMAX: 'BDMÁX', DMIN: 'BDMÍN', - DPRODUCT: 'BDPRODUTO', + DPRODUCT: 'BDMULTIPL', DSTDEV: 'BDDESVPAD', DSTDEVP: 'BDDESVPADP', DSUM: 'BDSOMA', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index e3658a8fb..af2c25390 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -75,18 +75,18 @@ const dictionary: RawTranslationPackage = { DAYS360: 'GÜN360', DAYS: 'GÜNSAY', DB: 'AZALANBAKİYE', - DAVERAGE: 'VORTALAMA', - DCOUNT: 'VSAY', - DCOUNTA: 'VSAYMA', + DAVERAGE: 'VSEÇORT', + DCOUNT: 'VSEÇSAY', + DCOUNTA: 'VSEÇSAYDOLU', DGET: 'VAL', - DMAX: 'VMAKS', - DMIN: 'VMİN', - DPRODUCT: 'VÇARPIM', - DSTDEV: 'VSTDSAPMA', - DSTDEVP: 'VSTDSAPMAP', - DSUM: 'VTOPLA', - DVAR: 'VVAR', - DVARP: 'VVARP', + DMAX: 'VSEÇMAK', + DMIN: 'VSEÇMİN', + DPRODUCT: 'VSEÇÇARP', + DSTDEV: 'VSEÇSTDSAPMA', + DSTDEVP: 'VSEÇSTDSAPMAS', + DSUM: 'VSEÇTOPLA', + DVAR: 'VSEÇVAR', + DVARP: 'VSEÇVARS', DDB: 'ÇİFTAZALANBAKİYE', DEC2BIN: 'DEC2BIN', DEC2HEX: 'DEC2HEX', From 9d07107d724e0d6d71790c408cfad1b94df79467 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 17 Apr 2026 10:22:05 +0000 Subject: [PATCH 23/23] fix: DGET multiple-match check takes priority over field CellError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The defensive CellError guard added earlier returned the error as soon as a matching row with a field error was seen, even when more rows also matched. Excel's DGET resolves the "multiple matches → #NUM!" check first and only surfaces field errors when exactly one record matches. Move the CellError check to after the loop so the match count is finalised first. Addresses Cursor Bugbot finding. Regression tests added in hyperformula-tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/interpreter/plugin/DatabasePlugin.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/interpreter/plugin/DatabasePlugin.ts b/src/interpreter/plugin/DatabasePlugin.ts index 57ad06430..91d3c8270 100644 --- a/src/interpreter/plugin/DatabasePlugin.ts +++ b/src/interpreter/plugin/DatabasePlugin.ts @@ -237,15 +237,11 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType for (let rowIdx = 1; rowIdx < dbData.length; rowIdx++) { if (this.rowMatchesCriteria(dbData[rowIdx], criteriaRows)) { - const cellValue = dbData[rowIdx][fieldIndex] - if (cellValue instanceof CellError) { - return cellValue - } matchCount++ if (matchCount > 1) { return new CellError(ErrorType.NUM, ErrorMessage.ValueLarge) } - matchedValue = cellValue + matchedValue = dbData[rowIdx][fieldIndex] } } @@ -253,6 +249,10 @@ export class DatabasePlugin extends FunctionPlugin implements FunctionPluginType return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) } + if (matchedValue instanceof CellError) { + return matchedValue + } + return matchedValue === EmptyValue || matchedValue === undefined || matchedValue === null ? 0 : matchedValue