mirror of
https://github.com/dream-num/univer.git
synced 2026-08-28 23:01:30 +08:00
fix(engine-formula): align Excel lookup and table semantics (#7448)
This commit is contained in:
@@ -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 {
|
||||
|
||||
+29
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user