fix(engine-formula): align Excel lookup and table semantics (#7448)

This commit is contained in:
Univer
2026-08-08 17:27:16 +08:00
committed by GitHub
parent 994076619c
commit bcf059aa85
9 changed files with 238 additions and 26 deletions
@@ -228,6 +228,8 @@ export interface ISuperTable {
* Sheet tables default to true; Base virtual tables store records from row 0.
*/
showHeader?: boolean;
/** Whether the projected range contains a physical totals row. */
showFooter?: boolean;
}
export enum TableOptionType {
@@ -21,7 +21,9 @@ import { ErrorType } from '../../../basics/error-type';
import { TableReferenceObject } from '../table-reference-object';
const options = new Map([
['#All', TableOptionType.ALL],
['#Data', TableOptionType.DATA],
['#Totals', TableOptionType.TOTALS],
['#This Row', TableOptionType.THIS_ROW],
]);
@@ -72,6 +74,33 @@ describe('TableReferenceObject current row', () => {
expect(reference.toArrayValueObject(false).getFirstCell().getValue()).toBe(ErrorType.NA);
});
it('excludes a declared totals row from data references', () => {
const table = {
sheetId: 'sheet',
titleMap: new Map([['Amount', 0]]),
range: { startRow: 0, endRow: 3, startColumn: 0, endColumn: 0 },
showFooter: true,
};
expect(new TableReferenceObject('Table[Amount]', table, '[Amount]', options).getRangeData()).toEqual({
startRow: 1,
endRow: 2,
startColumn: 0,
endColumn: 0,
});
expect(new TableReferenceObject(
'Table[[#Totals],[Amount]]',
table,
'[[#Totals],[Amount]]',
options
).getRangeData()).toEqual({
startRow: 3,
endRow: 3,
startColumn: 0,
endColumn: 0,
});
});
it('matches CRLF structured references against LF table column names', () => {
const reference = new TableReferenceObject('Table_FMEA[[#This Row],[F\r\n(1-5)]]', {
sheetId: 'sheet',
@@ -63,6 +63,7 @@ export class TableReferenceObject extends BaseReferenceObject {
const tableStartRow = range.startRow;
const tableEndRow = range.endRow;
const dataStartRow = tableStartRow + (this._tableData.showHeader === false ? 0 : 1);
const dataEndRow = tableEndRow - (this._tableData.showFooter === true ? 1 : 0);
let startRow = -1;
let endRow = -1;
@@ -74,7 +75,7 @@ export class TableReferenceObject extends BaseReferenceObject {
break;
case TableOptionType.DATA:
startRow = dataStartRow;
endRow = tableEndRow;
endRow = dataEndRow;
break;
case TableOptionType.HEADERS:
if (this._tableData.showHeader === false) {
@@ -90,14 +91,14 @@ export class TableReferenceObject extends BaseReferenceObject {
endRow = tableEndRow;
break;
case TableOptionType.THIS_ROW: {
const r = this._resolveThisRow(tableStartRow, tableEndRow);
const r = this._resolveThisRow(dataStartRow, dataEndRow);
startRow = r;
endRow = r;
break;
}
default:
startRow = dataStartRow;
endRow = tableEndRow;
endRow = dataEndRow;
break;
}
@@ -127,7 +128,8 @@ export class TableReferenceObject extends BaseReferenceObject {
override getCellData(row: number, column: number): Nullable<ICellData> {
if (this._isCurrentRowForRange) {
const { startRow, endRow } = this._tableData.range;
if (row < startRow || row > endRow) {
const lastCurrentRow = endRow - (this._tableData.showFooter === true ? 1 : 0);
if (row < startRow || row > lastCurrentRow) {
return { v: ErrorType.NA };
}
}
@@ -366,8 +368,8 @@ export class TableReferenceObject extends BaseReferenceObject {
}
/** Resolve #This Row's row number; takes first data row (tableStartRow+1) when no context available */
private _resolveThisRow(tableStartRow: number, tableEndRow: number): number {
private _resolveThisRow(dataStartRow: number, dataEndRow: number): number {
this._isCurrentRowForRange = true;
return Math.min(tableStartRow + 1, tableEndRow);
return Math.min(dataStartRow, dataEndRow);
}
}
@@ -651,7 +651,8 @@ export class ArrayValueObject extends BaseValueObject {
valueObject: BaseValueObject,
searchType: ArrayOrderSearchType = ArrayOrderSearchType.MIN,
isDesc = false,
isFuzzyMatching = false
isFuzzyMatching = false,
keepFirstNearest = false
) {
let result: Nullable<BaseValueObject>;
let maxOrMin: Nullable<BaseValueObject>;
@@ -659,6 +660,14 @@ export class ArrayValueObject extends BaseValueObject {
let maxOrMinPosition: Nullable<{ row: number; column: number }>;
const _isNearer = (itemValue: BaseValueObject, currentValue: BaseValueObject) => {
const distance = itemValue.minus(valueObject).abs();
const currentDistance = currentValue.minus(valueObject).abs();
return keepFirstNearest
? distance.isLessThan(currentDistance).getValue() === true
: distance.isLessThanOrEqual(currentDistance).getValue() === true;
};
const _handleMatch = (itemValue: Nullable<BaseValueObject>, row: number, column: number) => {
// Skip the blank cells
if (itemValue == null || itemValue.isNull()) {
@@ -684,11 +693,7 @@ export class ArrayValueObject extends BaseValueObject {
if (itemValue.isGreaterThan(valueObject).getValue() === true) {
if (
maxOrMin == null ||
itemValue
.minus(valueObject)
.abs()
.isLessThanOrEqual(maxOrMin.minus(valueObject).abs())
.getValue() === true
_isNearer(itemValue, maxOrMin)
) {
maxOrMin = itemValue;
maxOrMinPosition = { row, column };
@@ -698,11 +703,7 @@ export class ArrayValueObject extends BaseValueObject {
if (itemValue.isLessThan(valueObject).getValue() === true) {
if (
maxOrMin == null ||
itemValue
.minus(valueObject)
.abs()
.isLessThanOrEqual(maxOrMin.minus(valueObject).abs())
.getValue() === true
_isNearer(itemValue, maxOrMin)
) {
maxOrMin = itemValue;
maxOrMinPosition = { row, column };
@@ -1532,11 +1533,14 @@ export class ArrayValueObject extends BaseValueObject {
const sheetId = this.getSheetId();
const startRow = this.getCurrentRow();
const startColumn = this.getCurrentColumn();
const isWildcardComparison = batchOperatorType === BatchOperatorType.COMPARE &&
valueObject.isString() &&
isWildcard(String(valueObject.getValue()));
/**
* If comparison operations are conducted for a single numerical value,
* then retrieve the judgment from the inverted index. This enhances performance.
*/
if (batchOperatorType === BatchOperatorType.COMPARE && this._useInvertedIndexCache) {
if (batchOperatorType === BatchOperatorType.COMPARE && this._useInvertedIndexCache && !isWildcardComparison) {
const { rowsInCache, rowsNotInCache } = CELL_INVERTED_INDEX_CACHE.canUseCache(
unitId,
sheetId,
@@ -443,9 +443,10 @@ export class BaseFunction {
searchArray: ArrayValueObject,
resultArray: ArrayValueObject,
searchType: ArrayOrderSearchType = ArrayOrderSearchType.MIN,
isDesc = false
isDesc = false,
keepFirstNearest = false
) {
const position = searchArray.orderSearch(value, searchType, isDesc);
const position = searchArray.orderSearch(value, searchType, isDesc, false, keepFirstNearest);
if (position == null) {
return ErrorValueObject.create(ErrorType.NA);
@@ -540,9 +541,10 @@ export class BaseFunction {
resultArray: ArrayValueObject,
searchType: ArrayOrderSearchType = ArrayOrderSearchType.MIN,
isDesc = false,
axis = 0
axis = 0,
keepFirstNearest = false
) {
const position = searchArray.orderSearch(value, searchType, isDesc);
const position = searchArray.orderSearch(value, searchType, isDesc, false, keepFirstNearest);
if (position == null) {
return ErrorValueObject.create(ErrorType.NA);
@@ -120,6 +120,45 @@ describe('Test xlookup', () => {
expect(getObjectValue(resultObject)).toBe(0);
});
it('keeps blank, numeric zero, text zero, and empty text distinct', () => {
const lookupArray = ArrayValueObject.create({
calculateValueList: [
[NullValueObject.create()],
[NumberValueObject.create(0)],
[StringValueObject.create('0')],
[StringValueObject.create('')],
],
rowCount: 4,
columnCount: 1,
unitId: '',
sheetId: '',
row: 0,
column: 0,
});
const returnArray = ArrayValueObject.createByArray([
['blank'],
['zero'],
['zero-text'],
['empty-text'],
]);
expect(getObjectValue(testFunction.calculate(
NumberValueObject.create(0),
lookupArray,
returnArray
))).toBe('zero');
expect(getObjectValue(testFunction.calculate(
StringValueObject.create('0'),
lookupArray,
returnArray
))).toBe('zero-text');
expect(getObjectValue(testFunction.calculate(
StringValueObject.create(''),
lookupArray,
returnArray
))).toBe('empty-text');
});
it('Search array', async () => {
const resultObject = testFunction.calculate(
arrayValueObject2.slice(undefined, [1, 2])!,
@@ -332,6 +371,18 @@ describe('Test xlookup', () => {
expect(getObjectValue(resultObject).toString()).toBe('800');
});
it('keeps the first duplicate approximate candidate in forward search', () => {
const resultObject = testFunction.calculate(
NumberValueObject.create(3),
ArrayValueObject.createByArray([[1], [2], [2], [4], [7]]),
ArrayValueObject.createByArray([['one'], ['two-first'], ['two-last'], ['four'], ['seven']]),
NullValueObject.create(),
NumberValueObject.create(-1)
);
expect(getObjectValue(resultObject)).toBe('two-first');
});
it('match_mode 1', async () => {
const resultObject = testFunction.calculate(
NumberValueObject.create(110),
@@ -264,11 +264,12 @@ export class Xlookup extends BaseFunction {
resultArray,
matchModeValue === 1 ? ArrayOrderSearchType.MAX : ArrayOrderSearchType.MIN,
searchModeValue === -1,
axis
axis,
true
);
}
return this.equalSearchExpand(value, searchArray, resultArray, searchModeValue !== -1, axis);
return this._exactSearchExpand(value, searchArray, resultArray, searchModeValue !== -1, axis);
}
private _handleSingleObject(
@@ -294,11 +295,81 @@ export class Xlookup extends BaseFunction {
searchArray,
resultArray,
matchModeValue === 1 ? ArrayOrderSearchType.MAX : ArrayOrderSearchType.MIN,
searchModeValue === -1
searchModeValue === -1,
true
);
}
return this.equalSearch(value, searchArray, resultArray, searchModeValue !== -1);
return this._exactSearch(value, searchArray, resultArray, searchModeValue !== -1);
}
private _exactSearch(
value: BaseValueObject,
searchArray: ArrayValueObject,
resultArray: ArrayValueObject,
isFirst: boolean
): BaseValueObject {
const position = this._findExactPosition(value, searchArray, isFirst);
if (position == null) {
return ErrorValueObject.create(ErrorType.NA);
}
return resultArray.get(position.row, position.column) ?? ErrorValueObject.create(ErrorType.NA);
}
private _exactSearchExpand(
value: BaseValueObject,
searchArray: ArrayValueObject,
resultArray: ArrayValueObject,
isFirst: boolean,
axis: number
): BaseValueObject {
const position = this._findExactPosition(value, searchArray, isFirst);
if (position == null) {
return ErrorValueObject.create(ErrorType.NA);
}
return axis === 0
? resultArray.slice([position.row, position.row + 1]) ?? ErrorValueObject.create(ErrorType.NA)
: resultArray.slice(undefined, [position.column, position.column + 1]) ?? ErrorValueObject.create(ErrorType.NA);
}
private _findExactPosition(
value: BaseValueObject,
searchArray: ArrayValueObject,
isFirst: boolean
): Nullable<{ row: number; column: number }> {
let position: Nullable<{ row: number; column: number }>;
const find = (candidate: Nullable<BaseValueObject>, row: number, column: number) => {
if (candidate != null && this._isExactMatch(candidate, value)) {
position = { row, column };
return false;
}
};
if (isFirst) {
searchArray.iterator(find);
} else {
searchArray.iteratorReverse(find);
}
return position;
}
private _isExactMatch(candidate: BaseValueObject, value: BaseValueObject): boolean {
if (candidate.isError() || value.isError()) {
return candidate.isError() && value.isError() && candidate.getValue() === value.getValue();
}
if (
candidate.isNull() !== value.isNull() ||
candidate.isNumber() !== value.isNumber() ||
candidate.isString() !== value.isString() ||
candidate.isBoolean() !== value.isBoolean()
) {
return false;
}
return candidate.isEqual(value).getValue() === true;
}
private _blankResultAsZero(value: BaseValueObject) {
@@ -79,6 +79,14 @@ const getTestWorkbookData = (): IWorkbookData => {
v: '2025-01',
t: CellValueType.STRING,
},
9: {
v: 30,
t: CellValueType.NUMBER,
},
10: {
v: 0,
t: CellValueType.NUMBER,
},
},
1: {
0: {
@@ -109,6 +117,14 @@ const getTestWorkbookData = (): IWorkbookData => {
v: '2025-02',
t: CellValueType.STRING,
},
9: {
v: 40,
t: CellValueType.NUMBER,
},
10: {
v: '0',
t: CellValueType.STRING,
},
},
2: {
0: {
@@ -131,6 +147,14 @@ const getTestWorkbookData = (): IWorkbookData => {
v: '2025-03',
t: CellValueType.STRING,
},
9: {
v: 50,
t: CellValueType.NUMBER,
},
10: {
v: true,
t: CellValueType.BOOLEAN,
},
},
3: {
0: {
@@ -153,6 +177,24 @@ const getTestWorkbookData = (): IWorkbookData => {
v: '2025-04',
t: CellValueType.STRING,
},
9: {
v: 60,
t: CellValueType.NUMBER,
},
10: {
v: 5,
t: CellValueType.NUMBER,
},
},
4: {
9: {
v: 70,
t: CellValueType.NUMBER,
},
10: {
v: '5',
t: CellValueType.STRING,
},
},
},
},
@@ -282,5 +324,10 @@ describe('Test sumifs function', () => {
const result = await calculate('=SUMIFS(A1:A4,I1:I4,">="&G1,I1:I4,"<="&H1)');
expect(result).toBe(6);
});
it('does not coerce numeric text when matching a wildcard criterion', async () => {
const result = await calculate('=SUMIFS(J1:J5,K1:K5,"<>",K1:K5,"<>*")');
expect(result).toBe(140);
});
});
});
@@ -19,10 +19,12 @@ import type { FUniver } from '@univerjs/core/facade';
import { ICommandService } from '@univerjs/core/services/command/command.service.js';
import {
FormulaExecuteStageType,
OtherFormulaMarkDirty,
RemoveOtherFormulaMutation,
SetFormulaCalculationNotificationMutation,
SetFormulaCalculationResultMutation,
SetFormulaCalculationStartMutation,
SetOtherFormulaMutation,
} from '@univerjs/engine-formula';
import { SetSelectionsOperation } from '@univerjs/sheets';
import {
@@ -75,7 +77,9 @@ describe('Test conditional formatting facade', () => {
SetConditionalRuleMutation,
SetCfCommand,
SetSelectionsOperation,
OtherFormulaMarkDirty,
RemoveOtherFormulaMutation,
SetOtherFormulaMutation,
SetFormulaCalculationStartMutation,
SetFormulaCalculationNotificationMutation,
SetFormulaCalculationResultMutation,