From eb512bef5048b5374935e2ca81e5f5fb139d3006 Mon Sep 17 00:00:00 2001 From: Univer <68851825+DR-Univer@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:25:10 +0800 Subject: [PATCH] feat(engine-formula): support cross-workbook references (#7292) --- .../src/basics/__tests__/regex.spec.ts | 17 +- packages/engine-formula/src/basics/common.ts | 19 ++ packages/engine-formula/src/basics/regex.ts | 12 +- .../__tests__/create-command-test-bed.ts | 2 + .../engine/analysis/__tests__/parser.spec.ts | 322 +++++++++++++++++- .../src/engine/ast-node/function-node.ts | 14 +- .../src/engine/ast-node/reference-node.ts | 129 ++++--- .../__tests__/table-reference-object.spec.ts | 89 +++++ .../reference-object/base-reference-object.ts | 10 + .../reference-object/cell-reference-object.ts | 1 + .../column-reference-object.ts | 1 + .../reference-object/row-reference-object.ts | 1 + .../table-reference-object.ts | 24 +- .../engine/utils/__tests__/reference.spec.ts | 40 +++ .../utils/__tests__/unit-qualifier.spec.ts | 59 ++++ .../src/engine/utils/r1c1-reference.ts | 6 +- .../src/engine/utils/reference.ts | 67 +++- .../src/engine/utils/unit-qualifier.ts | 96 ++++++ .../__tests__/create-function-test-bed.ts | 2 + .../src/functions/base-function.ts | 9 + .../src/functions/lookup/indirect/index.ts | 16 + packages/engine-formula/src/index.ts | 14 + .../__tests__/formula-data.model.spec.ts | 23 ++ .../src/models/formula-data.model.ts | 15 + packages/engine-formula/src/plugin.ts | 2 + .../__tests__/current-data.service.spec.ts | 30 +- .../unit-reference-resolver.service.spec.ts | 170 +++++++++ .../src/services/current-data.service.ts | 25 +- .../unit-reference-resolver.service.ts | 125 +++++++ .../unit-qualifier-rename.controller.spec.ts | 43 +++ .../controllers/__tests__/unit-rename.spec.ts | 66 ++++ .../unit-qualifier-rename.controller.ts | 119 +++++++ .../update-defined-name.controller.ts | 17 +- .../controllers/update-formula.controller.ts | 15 +- .../controllers/utils/ref-range-formula.ts | 4 + .../src/controllers/utils/ref-range-param.ts | 16 + packages/sheets-formula/src/index.ts | 1 + packages/sheets-formula/src/plugin.ts | 3 + .../calculate-result-apply.controller.spec.ts | 47 +++ .../formula/create-function-test-bed.ts | 3 + .../calculate-result-apply.controller.ts | 8 + 41 files changed, 1602 insertions(+), 80 deletions(-) create mode 100644 packages/engine-formula/src/engine/reference-object/__tests__/table-reference-object.spec.ts create mode 100644 packages/engine-formula/src/engine/utils/__tests__/unit-qualifier.spec.ts create mode 100644 packages/engine-formula/src/engine/utils/unit-qualifier.ts create mode 100644 packages/engine-formula/src/services/__tests__/unit-reference-resolver.service.spec.ts create mode 100644 packages/engine-formula/src/services/unit-reference-resolver.service.ts create mode 100644 packages/sheets-formula/src/controllers/__tests__/unit-qualifier-rename.controller.spec.ts create mode 100644 packages/sheets-formula/src/controllers/__tests__/unit-rename.spec.ts create mode 100644 packages/sheets-formula/src/controllers/unit-qualifier-rename.controller.ts create mode 100644 packages/sheets/src/controllers/__tests__/calculate-result-apply.controller.spec.ts diff --git a/packages/engine-formula/src/basics/__tests__/regex.spec.ts b/packages/engine-formula/src/basics/__tests__/regex.spec.ts index 7cd4644d62..646a765ef1 100644 --- a/packages/engine-formula/src/basics/__tests__/regex.spec.ts +++ b/packages/engine-formula/src/basics/__tests__/regex.spec.ts @@ -68,6 +68,14 @@ describe('Test ref regex', () => { expect(new RegExp(REFERENCE_TABLE_MULTIPLE_COLUMN_REGEX).test('Table1[[#Title],[#Data],[Column1]:[Column10]]')).toBe(true); }); + it('distinguishes A1 workbook qualifiers from Table qualifiers', () => { + expect(regexTestSingeRange('[Book]Sheet1!A1')).toBe(true); + expect(regexTestSingeRange('[1]Sheet1!A1')).toBe(true); + expect(new RegExp(REFERENCE_TABLE_SINGLE_COLUMN_REGEX).test('[Book]Sheet1!A1')).toBe(false); + expect(new RegExp(REFERENCE_TABLE_SINGLE_COLUMN_REGEX).test('[1]!SalesTable[Amount]')).toBe(true); + expect(new RegExp(REFERENCE_TABLE_SINGLE_COLUMN_REGEX).test('Sales.xlsx!SalesTable[Amount]')).toBe(true); + }); + it('isReferenceString', () => { expect(isReferenceString('A1')).toBeTruthy(); expect(isReferenceString('Sheet1!A1')).toBeTruthy(); @@ -424,10 +432,13 @@ describe('Test ref regex', () => { expect(TITLE_ONLY.test('Table1[#This Row]')).toBe(true); }); - // Should allow sheet/unit prefix if UNIT_NAME_REGEX supports it - it('rejects unit prefix', () => { + it('accepts external Unit qualifiers', () => { TITLE_ONLY.lastIndex = 0; - expect(TITLE_ONLY.test('Sheet1!TableA[#Data]')).toBe(false); + expect(TITLE_ONLY.test('Book.xlsx!TableA[#Data]')).toBe(true); + TITLE_ONLY.lastIndex = 0; + expect(TITLE_ONLY.test("'Customer Base'!TableA[#Data]")).toBe(true); + TITLE_ONLY.lastIndex = 0; + expect(TITLE_ONLY.test('[1]!TableA[#Data]')).toBe(true); }); // Should reject non-hash titles diff --git a/packages/engine-formula/src/basics/common.ts b/packages/engine-formula/src/basics/common.ts index 7cc0fd8454..015436a960 100644 --- a/packages/engine-formula/src/basics/common.ts +++ b/packages/engine-formula/src/basics/common.ts @@ -25,6 +25,7 @@ import type { Nullable, ObjectMatrix, Styles, + UniverInstanceType, } from '@univerjs/core'; import type { sequenceNodeType } from '../engine/utils/sequence'; import type { IImageFormulaInfo } from '../engine/value-object/primitive-object'; @@ -92,6 +93,18 @@ export interface IUnitSheetNameMap { [unitId: string]: Nullable<{ [sheetName: string]: string }>; } +export type FormulaUnitType = UniverInstanceType.UNIVER_SHEET | UniverInstanceType.UNIVER_BASE; + +export interface IFormulaUnitNameMapItem { + name: string; + unitType: FormulaUnitType; +} + +/** Runtime Unit metadata keyed by stable unitId. */ +export interface IFormulaUnitNameMap { + [unitId: string]: IFormulaUnitNameMapItem; +} + export interface IUnitSheetIdToNameMap { [unitId: string]: Nullable<{ [sheetId: string]: string }>; } @@ -194,6 +207,11 @@ export interface ISuperTable { sheetId: string; titleMap: Map; range: IRange; + /** + * Whether the projected range contains a physical header row. + * Sheet tables default to true; Base virtual tables store records from row 0. + */ + showHeader?: boolean; } export enum TableOptionType { @@ -228,6 +246,7 @@ export interface IFormulaDatasetConfig { allUnitData?: IUnitData; unitStylesData?: IUnitStylesData; unitSheetNameMap?: IUnitSheetNameMap; + unitNameMap?: IFormulaUnitNameMap; maxIteration?: number; isCalculateTreeModel?: boolean; rowData?: IUnitRowData; // Include rows hidden by filters diff --git a/packages/engine-formula/src/basics/regex.ts b/packages/engine-formula/src/basics/regex.ts index df951637d4..58a8465274 100644 --- a/packages/engine-formula/src/basics/regex.ts +++ b/packages/engine-formula/src/basics/regex.ts @@ -70,13 +70,17 @@ const TABLE_CONTENT_REGEX = '\\[((? { dirtyUnitFeatureMap: {}, dirtyUnitOtherFormulaMap: {}, excludedCell: {}, - allUnitData: { - [testBed.unitId]: testBed.sheetData, + }); + formulaCurrentConfigService.registerUnitData({ + [testBed.unitId]: testBed.sheetData, + 'sales-source': { + 'source-sheet': { + cellData: new ObjectMatrix({ + 0: { 0: { v: 42 }, 1: { v: 'Other' } }, + 1: { 0: { v: 10 }, 1: { v: 20 } }, + }), + rowCount: 2, + columnCount: 2, + rowData: {}, + columnData: {}, + }, }, + 'base-source': { + 'base-table-sheet': { + cellData: new ObjectMatrix({ + 0: { 0: { v: 'Value' } }, + 1: { 0: { v: 7 } }, + }), + rowCount: 2, + columnCount: 1, + rowData: {}, + columnData: {}, + }, + }, + }); + formulaCurrentConfigService.registerUnitNameMap({ + [testBed.unitId]: { + name: 'Host.xlsx', + unitType: UniverInstanceType.UNIVER_SHEET, + }, + 'sales-source': { + name: 'Sales', + unitType: UniverInstanceType.UNIVER_SHEET, + }, + 'base-source': { + name: 'BaseData', + unitType: UniverInstanceType.UNIVER_BASE, + }, + }); + formulaCurrentConfigService.registerSheetNameMap({ + [testBed.unitId]: { Sheet1: testBed.sheetId }, + 'sales-source': { Data: 'source-sheet' }, + 'base-source': { Records: 'base-table-sheet' }, }); const sheetItem = testBed.sheetData[testBed.sheetId]; @@ -114,6 +161,8 @@ describe('Test indirect', () => { new StdevP(FUNCTION_NAMES_STATISTICAL.STDEV_P), new Regexmatch(FUNCTION_NAMES_TEXT.REGEXMATCH), new Hstack(FUNCTION_NAMES_LOOKUP.HSTACK), + new Indirect(FUNCTION_NAMES_LOOKUP.INDIRECT), + new Offset(FUNCTION_NAMES_LOOKUP.OFFSET), new Groupby(FUNCTION_NAMES_LOGICAL.GROUPBY), new If(FUNCTION_NAMES_LOGICAL.IF), new Percentof(FUNCTION_NAMES_LOGICAL.PERCENTOF) @@ -136,11 +185,33 @@ describe('Test indirect', () => { endColumn: 4, }, }); + superTableService.registerTable('sales-source', 'SalesTable', { + sheetId: 'source-sheet', + titleMap: new Map([['Amount', 0]]), + range: { + startRow: 0, + endRow: 1, + startColumn: 0, + endColumn: 1, + }, + }); + superTableService.registerTable('base-source', 'BaseTable', { + sheetId: 'base-table-sheet', + titleMap: new Map([['Value', 0]]), + range: { + startRow: 0, + endRow: 1, + startColumn: 0, + endColumn: 0, + }, + }); }); describe('normal', () => { it('preserves xleta aggregator tokens inside GROUPBY', () => { - const lexerNode = lexer.treeBuilder('=_xlfn.GROUPBY(A1:A3,A1:A3,_xlfn.HSTACK(_xleta.COUNTA,_xleta.PERCENTOF),0)'); + const lexerNode = lexer.treeBuilder( + '=_xlfn.GROUPBY(A1:A3,A1:A3,_xlfn.HSTACK(_xleta.COUNTA,_xleta.PERCENTOF),0)' + ); const astNode = astTreeBuilder.parse(lexerNode as LexerNode) as BaseAstNode; const groupbyNode = astNode.getChildren()[0]; const hstackNode = groupbyNode.getChildren()[2]; @@ -160,6 +231,247 @@ describe('Test indirect', () => { expect((result as BaseValueObject).getValue()).toStrictEqual(ErrorType.REF); }); + it('resolves a cross-workbook A1 qualifier by display name', () => { + const lexerNode = lexer.treeBuilder("=SUM('[sales.XLSX]Data'!A1)"); + const astNode = astTreeBuilder.parse(lexerNode as LexerNode); + const result = interpreter.execute(generateExecuteAstNodeData(astNode as BaseAstNode)); + + expect((result as BaseValueObject).getValue()).toBe(42); + }); + + it('stores the resolved source unit id in dependency ranges', async () => { + get(IFormulaCurrentConfigService).registerFormulaData({ + test: { + sheet1: { + 0: { + 0: { f: "='[Sales.xlsx]Data'!A1" }, + }, + }, + }, + }); + + const dependencyGenerator = get(IFormulaDependencyGenerator); + await dependencyGenerator.generate(); + const trees = await dependencyGenerator.getAllDependencyJson(); + + expect(trees[0]?.rangeList).toContainEqual({ + unitId: 'sales-source', + sheetId: 'source-sheet', + range: expect.objectContaining({ + startRow: 0, + endRow: 0, + startColumn: 0, + endColumn: 0, + }), + }); + }); + + it('resolves display-name and legacy-id external Table references', () => { + for (const formula of [ + '=SUM(Sales.xlsx!SalesTable[Amount])', + '=SUM(Sales.xlsx!SalesTable[[#Data],[Amount]])', + "=SUM('Sales.xlsx'!SalesTable[Amount])", + '=SUM([sales-source]SalesTable[Amount])', + ]) { + const lexerNode = lexer.treeBuilder(formula); + const astNode = astTreeBuilder.parse(lexerNode as LexerNode); + const result = interpreter.execute(generateExecuteAstNodeData(astNode as BaseAstNode)); + + expect((result as BaseValueObject).getValue()).toBe(10); + } + + const sectionLexerNode = lexer.treeBuilder('=SUM(Sales.xlsx!SalesTable[#Data])'); + const sectionAstNode = astTreeBuilder.parse(sectionLexerNode as LexerNode); + const sectionResult = interpreter.execute(generateExecuteAstNodeData(sectionAstNode as BaseAstNode)); + expect((sectionResult as BaseValueObject).getValue()).toBe(30); + }); + + it('stores the resolved source Unit in external Table dependency ranges', async () => { + get(IFormulaCurrentConfigService).registerFormulaData({ + test: { + sheet1: { + 0: { + 0: { f: '=SUM(Sales.xlsx!SalesTable[Amount])' }, + }, + }, + }, + }); + + const dependencyGenerator = get(IFormulaDependencyGenerator); + await dependencyGenerator.generate(); + const trees = await dependencyGenerator.getAllDependencyJson(); + + expect(trees[0]?.rangeList).toContainEqual({ + unitId: 'sales-source', + sheetId: 'source-sheet', + range: expect.objectContaining({ + startRow: 1, + endRow: 1, + startColumn: 0, + endColumn: 0, + }), + }); + }); + + it('reads current target Table metadata when the AST executes', () => { + const lexerNode = lexer.treeBuilder('=SUM(Sales.xlsx!SalesTable[Amount])'); + const astNode = astTreeBuilder.parse(lexerNode as LexerNode); + + get(ISuperTableService).registerTable('sales-source', 'SalesTable', { + sheetId: 'source-sheet', + titleMap: new Map([['Amount', 1]]), + range: { + startRow: 0, + endRow: 1, + startColumn: 0, + endColumn: 1, + }, + }); + const result = interpreter.execute(generateExecuteAstNodeData(astNode as BaseAstNode)); + + expect((result as BaseValueObject).getValue()).toBe(20); + }); + + it('uses one structured-reference path for Base and Sheet host/source combinations', () => { + const currentConfig = get(IFormulaCurrentConfigService); + const tableService = get(ISuperTableService); + currentConfig.registerUnitNameMap({ + test: { name: 'Host Base', unitType: UniverInstanceType.UNIVER_BASE }, + 'sales-source': { name: 'Sales.xlsx', unitType: UniverInstanceType.UNIVER_SHEET }, + 'base-source': { name: 'BaseData', unitType: UniverInstanceType.UNIVER_BASE }, + }); + + for (const formula of ['=SUM(BaseData!BaseTable[Value])', '=SUM(Sales.xlsx!SalesTable[Amount])']) { + const result = interpreter.execute( + generateExecuteAstNodeData( + astTreeBuilder.parse(lexer.treeBuilder(formula) as LexerNode) as BaseAstNode + ) + ); + expect((result as BaseValueObject).getValue()).toBe(formula.includes('BaseTable') ? 7 : 10); + } + + for (const formula of ["=SUM('[Sales]Data'!A1:A2)", '=SUM([Sales]Data!A1:A2)']) { + const baseToSheetA1 = interpreter.execute( + generateExecuteAstNodeData( + astTreeBuilder.parse(lexer.treeBuilder(formula) as LexerNode) as BaseAstNode + ) + ); + expect((baseToSheetA1 as BaseValueObject).getValue()).toBe(52); + } + + currentConfig.registerUnitNameMap({ + test: { name: 'Host Sheet', unitType: UniverInstanceType.UNIVER_SHEET }, + 'sales-source': { name: 'Sales.xlsx', unitType: UniverInstanceType.UNIVER_SHEET }, + 'base-source': { name: 'BaseData', unitType: UniverInstanceType.UNIVER_BASE }, + }); + const sheetToBase = interpreter.execute( + generateExecuteAstNodeData( + astTreeBuilder.parse( + lexer.treeBuilder('=SUM(BaseData!BaseTable[Value])') as LexerNode + ) as BaseAstNode + ) + ); + expect((sheetToBase as BaseValueObject).getValue()).toBe(7); + + const oldAst = astTreeBuilder.parse( + lexer.treeBuilder('=SUM(BaseData!BaseTable[Value])') as LexerNode + ) as BaseAstNode; + tableService.remove('base-source', 'BaseTable'); + tableService.registerTable('base-source', 'RenamedTable', { + sheetId: 'base-table-sheet', + titleMap: new Map([['RenamedValue', 0]]), + range: { startRow: 0, endRow: 1, startColumn: 0, endColumn: 0 }, + }); + expect((interpreter.execute(generateExecuteAstNodeData(oldAst)) as BaseValueObject).getValue()).toBe( + ErrorType.REF + ); + const renamed = interpreter.execute( + generateExecuteAstNodeData( + astTreeBuilder.parse( + lexer.treeBuilder('=SUM(BaseData!RenamedTable[RenamedValue])') as LexerNode + ) as BaseAstNode + ) + ); + expect((renamed as BaseValueObject).getValue()).toBe(7); + }); + + it('does not parse an external Table-looking string literal as a reference', () => { + const lexerNode = lexer.treeBuilder('="Sales.xlsx!SalesTable[Amount]"'); + const astNode = astTreeBuilder.parse(lexerNode as LexerNode); + const result = interpreter.execute(generateExecuteAstNodeData(astNode as BaseAstNode)); + + expect((result as BaseValueObject).getValue()).toBe('Sales.xlsx!SalesTable[Amount]'); + }); + + it('uses the shared qualifier resolver for INDIRECT and preserves the target through OFFSET', () => { + for (const formula of [ + '=SUM(INDIRECT("\'[Sales.xlsx]Data\'!A2"))', + '=SUM(OFFSET(INDIRECT("\'[Sales.xlsx]Data\'!A1"),1,0))', + "=SUM(OFFSET('[Sales.xlsx]Data'!A1,1,0))", + ]) { + const lexerNode = lexer.treeBuilder(formula); + const astNode = astTreeBuilder.parse(lexerNode as LexerNode); + const result = interpreter.execute(generateExecuteAstNodeData(astNode as BaseAstNode)); + + expect((result as BaseValueObject).getValue()).toBe(10); + } + }); + + it('second-schedules a dynamic INDIRECT formula when the resolved source range is dirty', async () => { + const currentConfigService = get(IFormulaCurrentConfigService); + currentConfigService.registerFormulaData({ + test: { + sheet1: { + 0: { + 0: { f: '=INDIRECT("\'[Sales.xlsx]Data\'!A2")' }, + }, + }, + }, + }); + currentConfigService.loadDirtyRangesAndExcludedCell( + [ + { + unitId: 'sales-source', + sheetId: 'source-sheet', + range: { + startRow: 1, + endRow: 1, + startColumn: 0, + endColumn: 0, + }, + }, + ], + {} + ); + + const trees = await get(IFormulaDependencyGenerator).generate(); + + expect(trees).toHaveLength(1); + expect(trees[0]).toMatchObject({ + unitId: 'test', + subUnitId: 'sheet1', + formula: '=INDIRECT("\'[Sales.xlsx]Data\'!A2")', + }); + }); + + it('returns REF for missing or ambiguous cross-workbook qualifiers', () => { + const missingLexerNode = lexer.treeBuilder("='[Missing.xlsx]Data'!A1"); + const missingAstNode = astTreeBuilder.parse(missingLexerNode as LexerNode); + const missingResult = interpreter.execute(generateExecuteAstNodeData(missingAstNode as BaseAstNode)); + + expect((missingResult as BaseValueObject).getValue()).toBe(ErrorType.REF); + + get(IFormulaCurrentConfigService).registerUnitNameMap({ + first: { name: 'Duplicate.xlsx', unitType: UniverInstanceType.UNIVER_SHEET }, + second: { name: 'DUPLICATE.XLSX', unitType: UniverInstanceType.UNIVER_SHEET }, + }); + const duplicateLexerNode = lexer.treeBuilder("='[Duplicate.xlsx]Data'!A1"); + const duplicateAstNode = astTreeBuilder.parse(duplicateLexerNode as LexerNode); + const duplicateResult = interpreter.execute(generateExecuteAstNodeData(duplicateAstNode as BaseAstNode)); + + expect((duplicateResult as BaseValueObject).getValue()).toBe(ErrorType.REF); + }); + it('Name error', async () => { const lexerNode = lexer.treeBuilder(`=sum(${ErrorType.NAME} + 1, sum(${ErrorType.REF} + 1))`); @@ -516,7 +828,9 @@ describe('Test indirect', () => { }); it('supports totals row arithmetic with line-break column names', async () => { - const lexerNode = lexer.treeBuilder('=Table1[[#Totals],[CASH\r\nIN]]-Table1[[#Totals],[CASH\r\nOUT]]+A1') as LexerNode; + const lexerNode = lexer.treeBuilder( + '=Table1[[#Totals],[CASH\r\nIN]]-Table1[[#Totals],[CASH\r\nOUT]]+A1' + ) as LexerNode; const astNode = astTreeBuilder.parse(lexerNode) as BaseAstNode; const result = interpreter.execute(generateExecuteAstNodeData(astNode)); expect((result as BaseValueObject).getValue()).toStrictEqual(101); diff --git a/packages/engine-formula/src/engine/ast-node/function-node.ts b/packages/engine-formula/src/engine/ast-node/function-node.ts index b7ea7bfe71..4c4b63e782 100644 --- a/packages/engine-formula/src/engine/ast-node/function-node.ts +++ b/packages/engine-formula/src/engine/ast-node/function-node.ts @@ -37,6 +37,7 @@ import { IFormulaCurrentConfigService } from '../../services/current-data.servic import { IDefinedNamesService } from '../../services/defined-names.service'; import { IFunctionService } from '../../services/function.service'; import { IFormulaRuntimeService } from '../../services/runtime.service'; +import { IFormulaUnitReferenceResolver } from '../../services/unit-reference-resolver.service'; import { prefixHandler } from '../utils/prefix-handler'; import { ArrayValueObject, transformToValueObject, ValueObjectFactory } from '../value-object/array-value-object'; import { ErrorValueObject } from '../value-object/base-value-object'; @@ -51,7 +52,8 @@ export class FunctionNode extends BaseAstNode { private _currentConfigService: IFormulaCurrentConfigService, private _runtimeService: IFormulaRuntimeService, private _definedNamesService: IDefinedNamesService, - private _formulaDataModel: FormulaDataModel + private _formulaDataModel: FormulaDataModel, + private _unitReferenceResolver: IFormulaUnitReferenceResolver ) { super(token); @@ -74,6 +76,10 @@ export class FunctionNode extends BaseAstNode { if (this._functionExecutor.needsFormulaDataModel) { this._functionExecutor.setFormulaDataModel(this._formulaDataModel); } + + if (this._functionExecutor.needsUnitReferenceResolver) { + this._functionExecutor.setUnitReferenceResolver(this._unitReferenceResolver); + } } override get nodeType() { @@ -464,7 +470,8 @@ export class FunctionNodeFactory extends BaseAstNodeFactory { @IFormulaRuntimeService private readonly _runtimeService: IFormulaRuntimeService, @IDefinedNamesService private readonly _definedNamesService: IDefinedNamesService, @Inject(Injector) private readonly _injector: Injector, - @Inject(FormulaDataModel) private readonly _formulaDataModel: FormulaDataModel + @Inject(FormulaDataModel) private readonly _formulaDataModel: FormulaDataModel, + @IFormulaUnitReferenceResolver private readonly _unitReferenceResolver: IFormulaUnitReferenceResolver ) { super(); } @@ -486,7 +493,8 @@ export class FunctionNodeFactory extends BaseAstNodeFactory { this._currentConfigService, this._runtimeService, this._definedNamesService, - this._formulaDataModel + this._formulaDataModel, + this._unitReferenceResolver ); } diff --git a/packages/engine-formula/src/engine/ast-node/reference-node.ts b/packages/engine-formula/src/engine/ast-node/reference-node.ts index 4272a99a78..a7893b0159 100644 --- a/packages/engine-formula/src/engine/ast-node/reference-node.ts +++ b/packages/engine-formula/src/engine/ast-node/reference-node.ts @@ -15,6 +15,7 @@ */ import type { Nullable } from '@univerjs/core'; +import type { BaseReferenceObject } from '../reference-object/base-reference-object'; import { ErrorType } from '../../basics/error-type'; import { regexTestReferenceTableAllColumn, @@ -30,6 +31,7 @@ import { IFormulaCurrentConfigService } from '../../services/current-data.servic import { IFunctionService } from '../../services/function.service'; import { IFormulaRuntimeService } from '../../services/runtime.service'; import { ISuperTableService } from '../../services/super-table.service'; +import { IFormulaUnitReferenceResolver } from '../../services/unit-reference-resolver.service'; import { LexerNode } from '../analysis/lexer-node'; import { TableReferenceObject } from '../reference-object/table-reference-object'; import { prefixHandler } from '../utils/prefix-handler'; @@ -40,6 +42,12 @@ import { BaseAstNode } from './base-ast-node'; import { BaseAstNodeFactory, DEFAULT_AST_NODE_FACTORY_Z_INDEX } from './base-ast-node-factory'; import { NODE_ORDER_MAP, NodeType } from './node-type'; +interface ITableReferenceDescriptor { + unitQualifier: string; + tableName: string; + columnStruct: string | undefined; +} + export class ReferenceNode extends BaseAstNode { private _refOffsetX = 0; private _refOffsetY = 0; @@ -49,8 +57,10 @@ export class ReferenceNode extends BaseAstNode { private _runtimeService: IFormulaRuntimeService, operatorString: string, private _referenceObjectType: ReferenceObjectType, + private _unitReferenceResolver: IFormulaUnitReferenceResolver, + private _superTableService: ISuperTableService, private _isPrepareMerge: boolean = false, - private _tableReferenceObject?: TableReferenceObject + private _tableReference?: ITableReferenceDescriptor ) { super(operatorString); } @@ -63,37 +73,50 @@ export class ReferenceNode extends BaseAstNode { const currentConfigService = this._currentConfigService; const runtimeService = this._runtimeService; - const referenceObject = this._tableReferenceObject || getReferenceObjectFromCache(this.getToken(), this._referenceObjectType); + let referenceObject: BaseReferenceObject; + if (this._tableReference) { + const { unitQualifier, tableName, columnStruct } = this._tableReference; + const resolution = this._unitReferenceResolver.resolve({ + hostUnitId: runtimeService.currentUnitId, + qualifier: unitQualifier, + referenceKind: 'table', + }); + if (typeof resolution === 'string') { + this.setValue(ErrorValueObject.create(resolution)); + return; + } + const tableMap = this._superTableService.getTableMap(resolution.unitId); + const tableData = Array.from(tableMap?.entries() || []).find(([name]) => name.toLocaleLowerCase() === tableName.toLocaleLowerCase())?.[1]; + if (!tableData) { + this.setValue(ErrorValueObject.create(ErrorType.REF)); + return; + } + referenceObject = new TableReferenceObject( + this.getToken(), + tableData, + columnStruct, + this._superTableService.getTableOptionMap() + ); + referenceObject.setUnitQualifier(unitQualifier); + referenceObject.setForcedUnitIdDirect(resolution.unitId); + } else { + referenceObject = getReferenceObjectFromCache(this.getToken(), this._referenceObjectType); + const unitQualifier = referenceObject.getUnitQualifier(); + if (unitQualifier) { + const resolution = this._unitReferenceResolver.resolve({ + hostUnitId: runtimeService.currentUnitId, + qualifier: unitQualifier, + referenceKind: 'a1', + }); + if (typeof resolution === 'string') { + this.setValue(ErrorValueObject.create(resolution)); + return; + } + referenceObject.setForcedUnitIdDirect(resolution.unitId); + } + } - referenceObject.setDefaultUnitId(runtimeService.currentUnitId); - - referenceObject.setDefaultSheetId(runtimeService.currentSubUnitId); - - referenceObject.setForcedSheetId(currentConfigService.getSheetNameMap()); - - referenceObject.setUnitData(currentConfigService.getUnitData()); - - referenceObject.setArrayFormulaCellData(currentConfigService.getArrayFormulaCellData()); - - referenceObject.setArrayFormulaRange(currentConfigService.getArrayFormulaRange()); - - referenceObject.setRuntimeData(runtimeService.getUnitData()); - - referenceObject.setUnitStylesData(currentConfigService.getUnitStylesData()); - - referenceObject.setRuntimeArrayFormulaCellData(runtimeService.getRuntimeArrayFormulaCellData()); - - referenceObject.setRuntimeArrayFormulaRange(runtimeService.getUnitArrayFormula()); - - referenceObject.setRuntimeFeatureCellData(runtimeService.getRuntimeFeatureCellData()); - - const currentRow = runtimeService.currentRow; - const currentCol = runtimeService.currentColumn; - referenceObject.setCurrentRowAndColumn(currentRow, currentCol); - - const { x, y } = this.getRefOffset(); - - referenceObject.setRefOffset(x, y); + this._configureReferenceObject(referenceObject, currentConfigService, runtimeService); if (!this._isPrepareMerge && referenceObject.isExceedRange()) { this.setValue(ErrorValueObject.create(ErrorType.NAME)); @@ -102,6 +125,27 @@ export class ReferenceNode extends BaseAstNode { } } + private _configureReferenceObject( + referenceObject: BaseReferenceObject, + currentConfigService: IFormulaCurrentConfigService, + runtimeService: IFormulaRuntimeService + ): void { + referenceObject.setDefaultUnitId(runtimeService.currentUnitId); + referenceObject.setDefaultSheetId(runtimeService.currentSubUnitId); + referenceObject.setForcedSheetId(currentConfigService.getSheetNameMap()); + referenceObject.setUnitData(currentConfigService.getUnitData()); + referenceObject.setArrayFormulaCellData(currentConfigService.getArrayFormulaCellData()); + referenceObject.setArrayFormulaRange(currentConfigService.getArrayFormulaRange()); + referenceObject.setRuntimeData(runtimeService.getUnitData()); + referenceObject.setUnitStylesData(currentConfigService.getUnitStylesData()); + referenceObject.setRuntimeArrayFormulaCellData(runtimeService.getRuntimeArrayFormulaCellData()); + referenceObject.setRuntimeArrayFormulaRange(runtimeService.getUnitArrayFormula()); + referenceObject.setRuntimeFeatureCellData(runtimeService.getRuntimeFeatureCellData()); + referenceObject.setCurrentRowAndColumn(runtimeService.currentRow, runtimeService.currentColumn); + const { x, y } = this.getRefOffset(); + referenceObject.setRefOffset(x, y); + } + setRefOffset(x: number = 0, y: number = 0) { this._refOffsetX = x; this._refOffsetY = y; @@ -120,7 +164,8 @@ export class ReferenceNodeFactory extends BaseAstNodeFactory { @IFormulaCurrentConfigService private readonly _currentConfigService: IFormulaCurrentConfigService, @IFormulaRuntimeService private readonly _formulaRuntimeService: IFormulaRuntimeService, @IFunctionService private readonly _functionService: IFunctionService, - @ISuperTableService private readonly _superTableService: ISuperTableService + @ISuperTableService private readonly _superTableService: ISuperTableService, + @IFormulaUnitReferenceResolver private readonly _unitReferenceResolver: IFormulaUnitReferenceResolver ) { super(); } @@ -189,12 +234,12 @@ export class ReferenceNodeFactory extends BaseAstNodeFactory { const runtimeService = this._formulaRuntimeService; const makeRef = (type: ReferenceObjectType) => - new ReferenceNode(currentConfigService, runtimeService, tokenTrim, type, isPrepareMerge); + new ReferenceNode(currentConfigService, runtimeService, tokenTrim, type, this._unitReferenceResolver, this._superTableService, isPrepareMerge); const tableMap = this._getTableMap(); const isSuperTableDirect = tableMap?.has(tokenTrim) ?? false; if (isSuperTableDirect) { - return this._getTableReferenceNode(tokenTrim, isLexerNode, isPrepareMerge, true); + return this._getTableReferenceNode(tokenTrim, isPrepareMerge, true); } const isCellRange = regexTestSingeRange(tokenTrim); @@ -213,26 +258,26 @@ export class ReferenceNodeFactory extends BaseAstNodeFactory { return makeRef(ReferenceObjectType.COLUMN); } - return this._getTableReferenceNode(tokenTrim, isLexerNode, isPrepareMerge, false); + return this._getTableReferenceNode(tokenTrim, isPrepareMerge, false); } - private _getTableReferenceNode(tokenTrim: string, isLexerNode: boolean, isPrepareMerge: boolean, isSuperTableDirectly: boolean = false) { + private _getTableReferenceNode(tokenTrim: string, isPrepareMerge: boolean, isSuperTableDirectly: boolean = false) { if (!this._checkTokenIsTableReference(tokenTrim) && !isSuperTableDirectly) { return; } - const { tableName, columnStruct } = splitTableStructuredRef(tokenTrim); + const { unitQualifier, tableName, columnStruct } = splitTableStructuredRef(tokenTrim); const tableMap = this._getTableMap(); - if (!isLexerNode && tableMap?.has(tableName)) { - const columnDataString = columnStruct; - const tableData = tableMap.get(tableName)!; - const tableOption = this._superTableService.getTableOptionMap(); + const hasLocalTable = Array.from(tableMap?.keys() || []).some((name) => name.toLocaleLowerCase() === tableName.toLocaleLowerCase()); + if (unitQualifier || hasLocalTable) { return new ReferenceNode( this._currentConfigService, this._formulaRuntimeService, tokenTrim, ReferenceObjectType.COLUMN, + this._unitReferenceResolver, + this._superTableService, isPrepareMerge, - new TableReferenceObject(tokenTrim, tableData, columnDataString, tableOption) + { unitQualifier, tableName, columnStruct } ); } } diff --git a/packages/engine-formula/src/engine/reference-object/__tests__/table-reference-object.spec.ts b/packages/engine-formula/src/engine/reference-object/__tests__/table-reference-object.spec.ts new file mode 100644 index 0000000000..94840458ce --- /dev/null +++ b/packages/engine-formula/src/engine/reference-object/__tests__/table-reference-object.spec.ts @@ -0,0 +1,89 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ObjectMatrix } from '@univerjs/core'; +import { describe, expect, it } from 'vitest'; +import { TableOptionType } from '../../../basics/common'; +import { ErrorType } from '../../../basics/error-type'; +import { TableReferenceObject } from '../table-reference-object'; + +const options = new Map([ + ['#Data', TableOptionType.DATA], + ['#This Row', TableOptionType.THIS_ROW], +]); + +describe('TableReferenceObject current row', () => { + it('includes row zero for a headerless Base virtual table', () => { + const reference = new TableReferenceObject('Orders[[#Data],[Amount]]', { + sheetId: 'orders', + titleMap: new Map([['Amount', 0]]), + range: { startRow: 0, endRow: 1, startColumn: 0, endColumn: 0 }, + showHeader: false, + }, '[[#Data],[Amount]]', options); + reference.setDefaultUnitId('base'); + reference.setUnitData({ + base: { + orders: { + cellData: new ObjectMatrix({ 0: { 0: { v: 10 } }, 1: { 0: { v: 20 } } }), + rowCount: 2, + columnCount: 1, + rowData: {}, + columnData: {}, + }, + }, + }); + + expect(reference.toArrayValueObject(false).getFirstCell().getValue()).toBe(10); + }); + + it('returns N/A when the host row has no corresponding target table row', () => { + const reference = new TableReferenceObject('Table[[#This Row],[Amount]]', { + sheetId: 'sheet', + titleMap: new Map([['Amount', 0]]), + range: { startRow: 0, endRow: 2, startColumn: 0, endColumn: 0 }, + }, '[[#This Row],[Amount]]', options); + reference.setDefaultUnitId('unit'); + reference.setCurrentRowAndColumn(4, 0); + reference.setUnitData({ + unit: { + sheet: { + cellData: new ObjectMatrix({ 1: { 0: { v: 10 } }, 2: { 0: { v: 20 } } }), + rowCount: 3, + columnCount: 1, + rowData: {}, + columnData: {}, + }, + }, + }); + + expect(reference.toArrayValueObject(false).getFirstCell().getValue()).toBe(ErrorType.NA); + }); +}); +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/packages/engine-formula/src/engine/reference-object/base-reference-object.ts b/packages/engine-formula/src/engine/reference-object/base-reference-object.ts index e04b225887..33dbc1c5c2 100644 --- a/packages/engine-formula/src/engine/reference-object/base-reference-object.ts +++ b/packages/engine-formula/src/engine/reference-object/base-reference-object.ts @@ -64,6 +64,8 @@ export class BaseReferenceObject extends ObjectClassType { private _forcedUnitId: string = ''; + private _unitQualifier: string = ''; + private _runtimeData: IRuntimeUnitDataType = {}; private _arrayFormulaCellData: IRuntimeUnitDataType = {}; @@ -253,6 +255,14 @@ export class BaseReferenceObject extends ObjectClassType { } } + setUnitQualifier(unitQualifier: string) { + this._unitQualifier = unitQualifier; + } + + getUnitQualifier() { + return this._unitQualifier; + } + getForcedUnitId() { return this._forcedUnitId; } diff --git a/packages/engine-formula/src/engine/reference-object/cell-reference-object.ts b/packages/engine-formula/src/engine/reference-object/cell-reference-object.ts index ebb3c31140..76bab133be 100644 --- a/packages/engine-formula/src/engine/reference-object/cell-reference-object.ts +++ b/packages/engine-formula/src/engine/reference-object/cell-reference-object.ts @@ -30,6 +30,7 @@ export class CellReferenceObject extends BaseReferenceObject { constructor(token: string) { super(token); const grid = deserializeRangeWithSheetWithCache(token); + this.setUnitQualifier(grid.unitId); this.setForcedUnitIdDirect(grid.unitId); this.setForcedSheetName(grid.sheetName); this.setRangeData(grid.range); diff --git a/packages/engine-formula/src/engine/reference-object/column-reference-object.ts b/packages/engine-formula/src/engine/reference-object/column-reference-object.ts index 26d7f2b80b..aea62976f5 100644 --- a/packages/engine-formula/src/engine/reference-object/column-reference-object.ts +++ b/packages/engine-formula/src/engine/reference-object/column-reference-object.ts @@ -26,6 +26,7 @@ export class ColumnReferenceObject extends BaseReferenceObject { constructor(token: string) { super(token); const grid = deserializeRangeWithSheetWithCache(token); + this.setUnitQualifier(grid.unitId); this.setForcedUnitIdDirect(grid.unitId); this.setForcedSheetName(grid.sheetName); const range: IRange = { diff --git a/packages/engine-formula/src/engine/reference-object/row-reference-object.ts b/packages/engine-formula/src/engine/reference-object/row-reference-object.ts index 75717bde0b..bfc8ed6c0a 100644 --- a/packages/engine-formula/src/engine/reference-object/row-reference-object.ts +++ b/packages/engine-formula/src/engine/reference-object/row-reference-object.ts @@ -28,6 +28,7 @@ export class RowReferenceObject extends BaseReferenceObject { constructor(token: string) { super(token); const grid = deserializeRangeWithSheetWithCache(token); + this.setUnitQualifier(grid.unitId); this.setForcedUnitIdDirect(grid.unitId); this.setForcedSheetName(grid.sheetName); const range: IRange = { diff --git a/packages/engine-formula/src/engine/reference-object/table-reference-object.ts b/packages/engine-formula/src/engine/reference-object/table-reference-object.ts index 05fa3bd792..dedcdf545e 100644 --- a/packages/engine-formula/src/engine/reference-object/table-reference-object.ts +++ b/packages/engine-formula/src/engine/reference-object/table-reference-object.ts @@ -14,8 +14,10 @@ * limitations under the License. */ +import type { ICellData, Nullable } from '@univerjs/core'; import type { ISuperTable, IUnitSheetNameMap } from '../../basics/common'; import { TableOptionType } from '../../basics/common'; +import { ErrorType } from '../../basics/error-type'; import { matchToken } from '../../basics/token'; import { BaseReferenceObject } from './base-reference-object'; @@ -60,6 +62,7 @@ export class TableReferenceObject extends BaseReferenceObject { const tableStartRow = range.startRow; const tableEndRow = range.endRow; + const dataStartRow = tableStartRow + (this._tableData.showHeader === false ? 0 : 1); let startRow = -1; let endRow = -1; @@ -70,11 +73,15 @@ export class TableReferenceObject extends BaseReferenceObject { endRow = tableEndRow; break; case TableOptionType.DATA: - // Default: First row is header, data area = [startRow+1, endRow] - startRow = tableStartRow + 1; + startRow = dataStartRow; endRow = tableEndRow; break; case TableOptionType.HEADERS: + if (this._tableData.showHeader === false) { + startRow = -1; + endRow = -1; + break; + } startRow = tableStartRow; endRow = tableStartRow; break; @@ -89,8 +96,7 @@ export class TableReferenceObject extends BaseReferenceObject { break; } default: - // Defensive: Unknown type defaults to DATA - startRow = tableStartRow + 1; + startRow = dataStartRow; endRow = tableEndRow; break; } @@ -118,6 +124,16 @@ export class TableReferenceObject extends BaseReferenceObject { return rangeData; } + override getCellData(row: number, column: number): Nullable { + if (this._isCurrentRowForRange) { + const { startRow, endRow } = this._tableData.range; + if (row < startRow || row > endRow) { + return { v: ErrorType.NA }; + } + } + return super.getCellData(row, column); + } + override getRefOffset() { return { x: 0, diff --git a/packages/engine-formula/src/engine/utils/__tests__/reference.spec.ts b/packages/engine-formula/src/engine/utils/__tests__/reference.spec.ts index d3533f5210..5320eb7607 100644 --- a/packages/engine-formula/src/engine/utils/__tests__/reference.spec.ts +++ b/packages/engine-formula/src/engine/utils/__tests__/reference.spec.ts @@ -26,9 +26,38 @@ import { needsQuoting, serializeRange, serializeRangeToRefString, + splitTableStructuredRef, } from '../reference'; describe('Test Reference', () => { + it('splits local, display, OOXML and legacy Table qualifiers', () => { + expect(splitTableStructuredRef('SalesTable[Amount]')).toEqual({ + unitQualifier: '', + tableName: 'SalesTable', + columnStruct: '[Amount]', + }); + expect(splitTableStructuredRef('Sales.xlsx!SalesTable[Amount]')).toEqual({ + unitQualifier: 'Sales.xlsx', + tableName: 'SalesTable', + columnStruct: '[Amount]', + }); + expect(splitTableStructuredRef("'Customer Base'!Orders[[#Data],[Total]]")).toEqual({ + unitQualifier: 'Customer Base', + tableName: 'Orders', + columnStruct: '[[#Data],[Total]]', + }); + expect(splitTableStructuredRef('[1]!SalesTable[Amount]')).toEqual({ + unitQualifier: '1', + tableName: 'SalesTable', + columnStruct: '[Amount]', + }); + expect(splitTableStructuredRef('[runtime-id]SalesTable[Amount]')).toEqual({ + unitQualifier: 'runtime-id', + tableName: 'SalesTable', + columnStruct: '[Amount]', + }); + }); + it('getAbsoluteRefTypeWithSingleString', () => { expect(getAbsoluteRefTypeWithSingleString('A4')).toEqual(AbsoluteRefType.NONE); @@ -330,29 +359,34 @@ describe('Test Reference', () => { expect(handleRefStringInfo('A1:A2')).toStrictEqual({ refBody: 'A1:A2', sheetName: '', + unitQualifier: '', unitId: '', }); expect(handleRefStringInfo('sheet1!A1')).toStrictEqual({ refBody: 'A1', sheetName: 'sheet1', + unitQualifier: '', unitId: '', }); expect(handleRefStringInfo('[Book1]Sheet1!A1')).toStrictEqual({ refBody: 'A1', sheetName: 'Sheet1', + unitQualifier: 'Book1', unitId: 'Book1', }); expect(handleRefStringInfo("'[Book1]Sheet1'!R2C3")).toStrictEqual({ refBody: 'R2C3', sheetName: 'Sheet1', + unitQualifier: 'Book1', unitId: 'Book1', }); expect(handleRefStringInfo("'sheet-1'!A1")).toStrictEqual({ refBody: 'A1', sheetName: 'sheet-1', + unitQualifier: '', unitId: '', }); @@ -360,6 +394,7 @@ describe('Test Reference', () => { expect(handleRefStringInfo("'sheet''1'!A1")).toStrictEqual({ refBody: 'A1', sheetName: "sheet'1", + unitQualifier: '', unitId: '', }); @@ -367,12 +402,14 @@ describe('Test Reference', () => { expect(handleRefStringInfo("'sheet''''1'!A1")).toStrictEqual({ refBody: 'A1', sheetName: "sheet''1", + unitQualifier: '', unitId: '', }); expect(handleRefStringInfo("'[Book-1.xlsx]Sheet1'!$A$4")).toStrictEqual({ refBody: '$A$4', sheetName: 'Sheet1', + unitQualifier: 'Book-1.xlsx', unitId: 'Book-1.xlsx', }); @@ -380,6 +417,7 @@ describe('Test Reference', () => { expect(handleRefStringInfo("'[Book''1.xlsx]Sheet1'!$A$4")).toStrictEqual({ refBody: '$A$4', sheetName: 'Sheet1', + unitQualifier: "Book'1.xlsx", unitId: "Book'1.xlsx", }); @@ -387,12 +425,14 @@ describe('Test Reference', () => { expect(handleRefStringInfo("'[Book''''1.xlsx]Sheet1'!$A$4")).toStrictEqual({ refBody: '$A$4', sheetName: 'Sheet1', + unitQualifier: "Book''1.xlsx", unitId: "Book''1.xlsx", }); expect(handleRefStringInfo("'[Book-1.xlsx]sheet-1'!$A$4")).toStrictEqual({ refBody: '$A$4', sheetName: 'sheet-1', + unitQualifier: 'Book-1.xlsx', unitId: 'Book-1.xlsx', }); }); diff --git a/packages/engine-formula/src/engine/utils/__tests__/unit-qualifier.spec.ts b/packages/engine-formula/src/engine/utils/__tests__/unit-qualifier.spec.ts new file mode 100644 index 0000000000..8cf284db18 --- /dev/null +++ b/packages/engine-formula/src/engine/utils/__tests__/unit-qualifier.spec.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { refactorFormulaUnitQualifier } from '../unit-qualifier'; + +describe('refactorFormulaUnitQualifier', () => { + it('updates A1 and Table qualifiers without touching similar names', () => { + expect(refactorFormulaUnitQualifier( + "=SUM([Sales.xlsx]Data!A1,'[Sales.xlsx]Q 1'!B2,Sales.xlsx!SalesTable[Amount],Sales.xlsx.bak!T[V])", + 'Sales.xlsx', + 'FY 2027.xlsx' + )).toBe("=SUM([FY 2027.xlsx]Data!A1,'[FY 2027.xlsx]Q 1'!B2,'FY 2027.xlsx'!SalesTable[Amount],Sales.xlsx.bak!T[V])"); + }); + + it('updates INDIRECT reference literals but leaves ordinary string literals unchanged', () => { + expect(refactorFormulaUnitQualifier( + '=INDIRECT("[Sales.xlsx]Data!A1")&"[Sales.xlsx]Data!A1"', + 'Sales.xlsx', + 'Costs.xlsx' + )).toBe('=INDIRECT("[Costs.xlsx]Data!A1")&"[Sales.xlsx]Data!A1"'); + }); + + it('preserves bracketed and quoted structured-reference styles', () => { + expect(refactorFormulaUnitQualifier( + "=SUM([Sales.xlsx]!T[A])+SUM('Sales.xlsx'!T[B])", + 'sales.XLSX', + "Director's Plan" + )).toBe("=SUM([Director's Plan]!T[A])+SUM('Director''s Plan'!T[B])"); + }); +}); +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/packages/engine-formula/src/engine/utils/r1c1-reference.ts b/packages/engine-formula/src/engine/utils/r1c1-reference.ts index e15aba7213..4e1a187d0c 100644 --- a/packages/engine-formula/src/engine/utils/r1c1-reference.ts +++ b/packages/engine-formula/src/engine/utils/r1c1-reference.ts @@ -50,7 +50,7 @@ function singleReference(refBody: string, currentRow = 0, currentColumn = 0) { } export function deserializeRangeForR1C1(refString: string, currentRow = 0, currentColumn = 0): IUnitRangeName { - const { refBody, sheetName, unitId } = handleRefStringInfo(refString); + const { refBody, sheetName, unitQualifier } = handleRefStringInfo(refString); const colonIndex = refBody.indexOf(':'); @@ -78,7 +78,7 @@ export function deserializeRangeForR1C1(refString: string, currentRow = 0, curre }; return { - unitId, + unitId: unitQualifier, sheetName, @@ -103,7 +103,7 @@ export function deserializeRangeForR1C1(refString: string, currentRow = 0, curre const endColumn = endGrid.column; return { - unitId, + unitId: unitQualifier, sheetName, diff --git a/packages/engine-formula/src/engine/utils/reference.ts b/packages/engine-formula/src/engine/utils/reference.ts index 04cf7aedec..968a64b69c 100644 --- a/packages/engine-formula/src/engine/utils/reference.ts +++ b/packages/engine-formula/src/engine/utils/reference.ts @@ -198,11 +198,11 @@ export function singleReferenceToGrid(refBody: string) { export function handleRefStringInfo(refString: string) { const unitIdMatch = UNIT_NAME_REGEX_PRECOMPILING.exec(refString); - let unitId = ''; + let unitQualifier = ''; if (unitIdMatch != null) { - unitId = unitIdMatch[0].trim(); - unitId = unquoteSheetName(unitId.slice(1, unitId.length - 1)); + unitQualifier = unitIdMatch[0].trim(); + unitQualifier = unquoteSheetName(unitQualifier.slice(1, unitQualifier.length - 1)); refString = refString.replace(UNIT_NAME_REGEX_PRECOMPILING, ''); } @@ -224,12 +224,14 @@ export function handleRefStringInfo(refString: string) { return { refBody, sheetName, - unitId, + unitQualifier, + /** @deprecated Use unitQualifier. Kept for reference-grid compatibility. */ + unitId: unitQualifier, }; } export function deserializeRangeWithSheet(refString: string): IUnitRangeName { - const { refBody, sheetName, unitId } = handleRefStringInfo(refString); + const { refBody, sheetName, unitQualifier } = handleRefStringInfo(refString); const colonIndex = refBody.indexOf(':'); @@ -248,7 +250,7 @@ export function deserializeRangeWithSheet(refString: string): IUnitRangeName { }; return { - unitId, + unitId: unitQualifier, sheetName, range, }; @@ -275,7 +277,7 @@ export function deserializeRangeWithSheet(refString: string): IUnitRangeName { } return { - unitId, + unitId: unitQualifier, sheetName, range: { startRow, @@ -477,12 +479,55 @@ function startsWithNonAlphabetic(name: string) { } export function splitTableStructuredRef(ref: string) { - const idx = ref.indexOf('['); + let unitQualifier = ''; + let tableRef = ref.trim(); + let quoteOpen = false; + let bracketDepth = 0; + let qualifierEnd = -1; + + for (let i = 0; i < tableRef.length; i++) { + const char = tableRef[i]; + if (char === "'") { + if (quoteOpen && tableRef[i + 1] === "'") { + i++; + continue; + } + quoteOpen = !quoteOpen; + } else if (!quoteOpen && char === '[') { + bracketDepth++; + } else if (!quoteOpen && char === ']') { + bracketDepth--; + } else if (!quoteOpen && bracketDepth === 0 && char === '!') { + qualifierEnd = i; + break; + } + } + + if (qualifierEnd >= 0) { + unitQualifier = tableRef.slice(0, qualifierEnd).trim(); + tableRef = tableRef.slice(qualifierEnd + 1); + if (unitQualifier.startsWith("'") && unitQualifier.endsWith("'")) { + unitQualifier = unitQualifier.slice(1, -1); + } + if (unitQualifier.startsWith('[') && unitQualifier.endsWith(']')) { + unitQualifier = unitQualifier.slice(1, -1); + } + unitQualifier = unquoteSheetName(unitQualifier); + } else if (tableRef.startsWith('[')) { + const legacyQualifierEnd = tableRef.indexOf(']'); + if (legacyQualifierEnd > 0) { + unitQualifier = unquoteSheetName(tableRef.slice(1, legacyQualifierEnd)); + tableRef = tableRef.slice(legacyQualifierEnd + 1); + } + } + + const idx = tableRef.indexOf('['); if (idx === -1) { - return { tableName: ref, struct: '' }; + return { unitQualifier, tableName: tableRef, columnStruct: '' }; } return { - tableName: ref.slice(0, idx), - columnStruct: ref.slice(idx), // include [[...]] + unitQualifier, + tableName: tableRef.slice(0, idx), + columnStruct: tableRef.slice(idx), // include [[...]] }; } diff --git a/packages/engine-formula/src/engine/utils/unit-qualifier.ts b/packages/engine-formula/src/engine/utils/unit-qualifier.ts new file mode 100644 index 0000000000..b8452d31e0 --- /dev/null +++ b/packages/engine-formula/src/engine/utils/unit-qualifier.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const A1_UNIT_QUALIFIER = /('?)\[([^\]]+)\]((?:[^']|'')+)\1!(?=\$?[A-Z]{1,3}\$?\d+)/gi; +const TABLE_UNIT_QUALIFIER = /(^|[^\w.])(?:'((?:[^']|'')+)'|\[([^\]]+)\]|([A-Za-z0-9_.-]+))!(?=[^\s!\[\]]+\[)/g; +const INDIRECT_LITERAL = /\bINDIRECT\s*\(\s*"((?:[^"]|"")*)"/gi; + +function equalsQualifier(actual: string, expected: string): boolean { + return actual.replace(/''/g, "'").toLocaleLowerCase() === expected.toLocaleLowerCase(); +} + +function quoteQualifier(name: string): string { + return /^[A-Za-z0-9_.-]+$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`; +} + +function refactorReferenceSegment(segment: string, oldName: string, newName: string): string { + const withA1 = segment.replace( + A1_UNIT_QUALIFIER, + (token, quote: string, qualifier: string, sheetName: string) => { + if (!equalsQualifier(qualifier, oldName)) return token; + const escapedName = newName.replace(/'/g, "''"); + return quote ? `'[${escapedName}]${sheetName}'!` : `[${newName}]${sheetName}!`; + } + ); + return withA1.replace( + TABLE_UNIT_QUALIFIER, + (token, boundary: string, quoted: string | undefined, bracketed: string | undefined, plain: string | undefined) => { + const qualifier = quoted ?? bracketed ?? plain ?? ''; + if (!equalsQualifier(qualifier, oldName)) return token; + if (quoted != null) return `${boundary}'${newName.replace(/'/g, "''")}'!`; + if (bracketed != null) return `${boundary}[${newName}]!`; + return `${boundary}${quoteQualifier(newName)}!`; + } + ); +} + +function refactorOutsideStrings(formula: string, oldName: string, newName: string): string { + let result = ''; + let chunk = ''; + let inString = false; + for (let index = 0; index < formula.length; index++) { + const character = formula[index]; + if (character !== '"') { + chunk += character; + continue; + } + if (inString && formula[index + 1] === '"') { + chunk += '""'; + index++; + continue; + } + result += inString ? chunk : refactorReferenceSegment(chunk, oldName, newName); + result += '"'; + chunk = ''; + inString = !inString; + } + return result + (inString ? chunk : refactorReferenceSegment(chunk, oldName, newName)); +} + +/** Refactors only parsed reference qualifiers and INDIRECT literal references, never arbitrary text. */ +export function refactorFormulaUnitQualifier(formula: string, oldName: string, newName: string): string { + if (!oldName || oldName === newName) return formula; + const withIndirect = formula.replace(INDIRECT_LITERAL, (token, literal: string) => { + const next = refactorReferenceSegment(literal.replace(/""/g, '"'), oldName, newName).replace(/"/g, '""'); + return token.replace(literal, next); + }); + return refactorOutsideStrings(withIndirect, oldName, newName); +} +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/packages/engine-formula/src/functions/__tests__/create-function-test-bed.ts b/packages/engine-formula/src/functions/__tests__/create-function-test-bed.ts index 66ffd96970..ae141d645d 100644 --- a/packages/engine-formula/src/functions/__tests__/create-function-test-bed.ts +++ b/packages/engine-formula/src/functions/__tests__/create-function-test-bed.ts @@ -56,6 +56,7 @@ import { IOtherFormulaManagerService, OtherFormulaManagerService } from '../../s import { FormulaRuntimeService, IFormulaRuntimeService } from '../../services/runtime.service'; import { ISheetRowFilteredService, SheetRowFilteredService } from '../../services/sheet-row-filtered.service'; import { ISuperTableService, SuperTableService } from '../../services/super-table.service'; +import { FormulaUnitReferenceResolver, IFormulaUnitReferenceResolver } from '../../services/unit-reference-resolver.service'; const getTestWorkbookData = (): IWorkbookData => { return { @@ -176,6 +177,7 @@ export function createFunctionTestBed(workbookData?: IWorkbookData, dependencies injector.add([LexerTreeBuilder]); injector.add([IFormulaCurrentConfigService, { useClass: FormulaCurrentConfigService }]); + injector.add([IFormulaUnitReferenceResolver, { useClass: FormulaUnitReferenceResolver }]); injector.add([IHyperlinkEngineFormulaService, { useClass: HyperlinkEngineFormulaService }]); injector.add([IFormulaRuntimeService, { useClass: FormulaRuntimeService }]); injector.add([IFunctionService, { useClass: FunctionService }]); diff --git a/packages/engine-formula/src/functions/base-function.ts b/packages/engine-formula/src/functions/base-function.ts index 1f96ad542c..05dbe0726a 100644 --- a/packages/engine-formula/src/functions/base-function.ts +++ b/packages/engine-formula/src/functions/base-function.ts @@ -28,6 +28,7 @@ import type { BaseValueObject } from '../engine/value-object/base-value-object'; import type { FormulaFunctionResultValueType, FormulaFunctionValueType } from '../engine/value-object/primitive-object'; import type { FormulaDataModel } from '../models/formula-data.model'; import type { IDefinedNameMapItem } from '../services/defined-names.service'; +import type { IFormulaUnitReferenceResolver } from '../services/unit-reference-resolver.service'; import { ErrorType } from '../basics/error-type'; import { regexTestSingeRange, regexTestSingleColumn, regexTestSingleRow } from '../basics/regex'; import { compareToken } from '../basics/token'; @@ -54,6 +55,7 @@ export class BaseFunction { private _sheetOrder: string[]; private _sheetNameMap: { [sheetId: string]: string }; protected _formulaDataModel: Nullable; + protected _unitReferenceResolver: Nullable; protected _rowCount: number = -1; protected _columnCount: number = -1; @@ -82,6 +84,9 @@ export class BaseFunction { */ needsFormulaDataModel: boolean = false; + /** Whether the function resolves external Unit qualifiers. */ + needsUnitReferenceResolver: boolean = false; + /** * Whether the function needs the number of rows and columns in the sheet */ @@ -200,6 +205,10 @@ export class BaseFunction { this._formulaDataModel = _formulaDataModel; } + setUnitReferenceResolver(unitReferenceResolver: IFormulaUnitReferenceResolver) { + this._unitReferenceResolver = unitReferenceResolver; + } + setSheetRowColumnCount(rowCount: number, columnCount: number) { this._rowCount = rowCount; this._columnCount = columnCount; diff --git a/packages/engine-formula/src/functions/lookup/indirect/index.ts b/packages/engine-formula/src/functions/lookup/indirect/index.ts index 77b3ea9ee7..4ff8d7afce 100644 --- a/packages/engine-formula/src/functions/lookup/indirect/index.ts +++ b/packages/engine-formula/src/functions/lookup/indirect/index.ts @@ -47,6 +47,8 @@ export class Indirect extends BaseFunction { override maxParams = 2; + override needsUnitReferenceResolver = true; + override isAddress() { return true; } @@ -106,6 +108,7 @@ export class Indirect extends BaseFunction { const rangeReferenceObject = new RangeReferenceObject(range); + rangeReferenceObject.setUnitQualifier(unitId); rangeReferenceObject.setForcedUnitIdDirect(unitId); rangeReferenceObject.setForcedSheetName(sheetName); @@ -133,6 +136,7 @@ export class Indirect extends BaseFunction { const rangeReferenceObject = new RangeReferenceObject(range); + rangeReferenceObject.setUnitQualifier(unitId); rangeReferenceObject.setForcedUnitIdDirect(unitId); rangeReferenceObject.setForcedSheetName(sheetName); @@ -143,6 +147,18 @@ export class Indirect extends BaseFunction { if (this.unitId == null || this.subUnitId == null) { return ErrorValueObject.create(ErrorType.REF); } + const unitQualifier = object.getUnitQualifier(); + if (unitQualifier && this._unitReferenceResolver) { + const resolution = this._unitReferenceResolver.resolve({ + hostUnitId: this.unitId, + qualifier: unitQualifier, + referenceKind: 'a1', + }); + if (typeof resolution === 'string') { + return ErrorValueObject.create(resolution); + } + object.setForcedUnitIdDirect(resolution.unitId); + } object.setDefaultUnitId(this.unitId); object.setDefaultSheetId(this.subUnitId); return object; diff --git a/packages/engine-formula/src/index.ts b/packages/engine-formula/src/index.ts index 8a5c8548dd..011b437346 100644 --- a/packages/engine-formula/src/index.ts +++ b/packages/engine-formula/src/index.ts @@ -15,6 +15,7 @@ */ export type { + FormulaUnitType, IArrayFormulaEmbeddedMap, IArrayFormulaRangeType, IArrayFormulaUnitCellType, @@ -29,6 +30,8 @@ export type { IFormulaDatasetConfig, IFormulaExecuteResultMap, IFormulaStringMap, + IFormulaUnitNameMap, + IFormulaUnitNameMapItem, IRuntimeImageFormulaDataType, IRuntimeUnitDataType, ISheetData, @@ -200,6 +203,7 @@ export { handleRefStringInfo } from './engine/utils/reference'; export { deserializeRangeWithSheetWithCache } from './engine/utils/reference-cache'; export { generateStringWithSequence, sequenceNodeType } from './engine/utils/sequence'; export type { ISequenceNode } from './engine/utils/sequence'; +export { refactorFormulaUnitQualifier } from './engine/utils/unit-qualifier'; export { ArrayValueObject, ValueObjectFactory } from './engine/value-object/array-value-object'; export { BaseValueObject, ErrorValueObject } from './engine/value-object/base-value-object'; export { LambdaValueObjectObject } from './engine/value-object/lambda-value-object'; @@ -296,3 +300,13 @@ export type { IAllRuntimeData, IExecutionInProgressParams } from './services/run export { ISheetRowFilteredService, SheetRowFilteredService } from './services/sheet-row-filtered.service'; export { ISuperTableService } from './services/super-table.service'; export { SuperTableService } from './services/super-table.service'; +export { + FormulaUnitReferenceResolver, + IFormulaUnitReferenceResolver, + normalizeFormulaUnitName, +} from './services/unit-reference-resolver.service'; +export type { + FormulaUnitReferenceKind, + IFormulaUnitReferenceResolution, + IFormulaUnitReferenceResolveInput, +} from './services/unit-reference-resolver.service'; diff --git a/packages/engine-formula/src/models/__tests__/formula-data.model.spec.ts b/packages/engine-formula/src/models/__tests__/formula-data.model.spec.ts index 9f72f57f44..f225e8f793 100644 --- a/packages/engine-formula/src/models/__tests__/formula-data.model.spec.ts +++ b/packages/engine-formula/src/models/__tests__/formula-data.model.spec.ts @@ -1027,6 +1027,10 @@ describe('Test formula data model', () => { const calculateData = formulaDataModel.getCalculateData(); expect(calculateData.allUnitData.test?.sheet1.rowCount).toBeGreaterThan(0); expect(calculateData.unitSheetNameMap.test?.Sheet1).toBe('sheet1'); + expect(calculateData.unitNameMap.test).toEqual({ + name: '', + unitType: UniverInstanceType.UNIVER_SHEET, + }); const sheetFormulaData = formulaDataModel.getSheetFormulaData('test', 'sheet1'); expect(sheetFormulaData?.[0]?.[0]?.f).toBe('=A1'); @@ -1060,6 +1064,10 @@ describe('Test formula data model', () => { const calculateData = formulaDataModel.getCalculateData(); const tableData = calculateData.allUnitData['base-test']?.['table-main']; + expect(calculateData.unitNameMap['base-test']).toEqual({ + name: 'Base', + unitType: UniverInstanceType.UNIVER_BASE, + }); expect(calculateData.unitSheetNameMap['base-test']?.Sales).toBe('tableOther'); expect(tableData?.rowCount).toBe(1); expect(tableData?.columnCount).toBe(7); @@ -1069,6 +1077,21 @@ describe('Test formula data model', () => { expect(tableData?.cellData.getValue(0, 4)).toEqual({ v: 'Invoice', t: CellValueType.STRING }); expect(tableData?.cellData.getValue(0, 5)).toEqual({ v: '', t: CellValueType.STRING }); }); + + it('should preserve workbook-qualified A1 references in Base formulas', () => { + const univerInstanceService = get(IUniverInstanceService); + univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_BASE, BaseDataModel); + const snapshot = structuredClone(TEST_BASE_DATA); + snapshot.id = 'base-external-a1'; + snapshot.tables!['table-main'].fields.total.config = { + formula: '=SUM([Host.xlsx]Sheet1!$A$1:$B$10)', + }; + univer.createUnit(UniverInstanceType.UNIVER_BASE, snapshot); + + expect(formulaDataModel.getFormulaData()['base-external-a1']?.['table-main']?.[0]?.[6]?.f).toBe( + '=SUM([Host.xlsx]Sheet1!$A$1:$B$10)' + ); + }); }); }); diff --git a/packages/engine-formula/src/models/formula-data.model.ts b/packages/engine-formula/src/models/formula-data.model.ts index 353274df96..d2b64ed062 100644 --- a/packages/engine-formula/src/models/formula-data.model.ts +++ b/packages/engine-formula/src/models/formula-data.model.ts @@ -21,6 +21,7 @@ import type { IFormulaData, IFormulaDataItem, IFormulaIdMap, + IFormulaUnitNameMap, IRuntimeUnitDataType, ISheetData, IUnitData, @@ -356,9 +357,16 @@ export class FormulaDataModel extends Disposable { const unitSheetNameMap: IUnitSheetNameMap = {}; + const unitNameMap: IFormulaUnitNameMap = {}; + for (const workbook of unitAllSheet) { const unitId = workbook.getUnitId(); + unitNameMap[unitId] = { + name: workbook.name, + unitType: UniverInstanceType.UNIVER_SHEET, + }; + const sheets = workbook.getSheets(); const sheetData: ISheetData = {}; @@ -392,6 +400,10 @@ export class FormulaDataModel extends Disposable { for (const base of unitAllBases) { const snapshot = base.getSnapshot(); const unitId = base.getUnitId(); + unitNameMap[unitId] = { + name: snapshot.name, + unitType: UniverInstanceType.UNIVER_BASE, + }; const baseData: ISheetData = {}; const tableNameMap: { [tableName: string]: string } = {}; @@ -415,6 +427,7 @@ export class FormulaDataModel extends Disposable { allUnitData, unitStylesData, unitSheetNameMap, + unitNameMap, }; } @@ -926,6 +939,7 @@ export function initSheetFormulaData( const BASE_LEGACY_FIELD_REF_PATTERN = /\{([^}]+)\}/g; const BASE_TABLE_FIELD_REF_PATTERN = /\b([A-Z_]\w*)\[([^\]]+)\]/gi; const BASE_BRACKET_FIELD_REF_PATTERN = /(^|[^A-Za-z0-9_\]\[])\[([^\]]+)\]/g; +const BASE_EXTERNAL_A1_REF_PATTERN = /(?:'\[[^\]]+\](?:[^']|'')+'|\[[^\]]+\][^\s'!]+)!\$?[A-Z]{1,3}\$?\d+(?::\$?[A-Z]{1,3}\$?\d+)?/gi; function normalizeBaseFormulaForEngine(formula: string, currentTable: ITableSnapshot, snapshot: IBaseSnapshot): string { const refs: string[] = []; @@ -934,6 +948,7 @@ function normalizeBaseFormulaForEngine(formula: string, currentTable: ITableSnap return `__BASE_FORMULA_REF_${index}__`; }; const normalized = formula + .replace(BASE_EXTERNAL_A1_REF_PATTERN, (reference) => hold(reference)) .replace(BASE_LEGACY_FIELD_REF_PATTERN, (_match, fieldName: string) => hold(createEngineThisRowRef(currentTable, fieldName, snapshot))) .replace(BASE_TABLE_FIELD_REF_PATTERN, (_match, sourceTableName: string, fieldName: string) => { const targetTable = resolveBaseFormulaTable(sourceTableName, currentTable, snapshot); diff --git a/packages/engine-formula/src/plugin.ts b/packages/engine-formula/src/plugin.ts index 23973536c6..2c55b78e64 100644 --- a/packages/engine-formula/src/plugin.ts +++ b/packages/engine-formula/src/plugin.ts @@ -65,6 +65,7 @@ import { RegisterOtherFormulaService } from './services/register-other-formula.s import { FormulaRuntimeService, IFormulaRuntimeService } from './services/runtime.service'; import { ISheetRowFilteredService, SheetRowFilteredService } from './services/sheet-row-filtered.service'; import { ISuperTableService, SuperTableService } from './services/super-table.service'; +import { FormulaUnitReferenceResolver, IFormulaUnitReferenceResolver } from './services/unit-reference-resolver.service'; export class UniverFormulaEnginePlugin extends Plugin { static override pluginName = 'UNIVER_ENGINE_FORMULA_PLUGIN'; @@ -184,6 +185,7 @@ export class UniverFormulaEnginePlugin extends Plugin { [ICalculateFormulaService, { useClass: CalculateFormulaService }], [IDependencyManagerService, { useClass: DependencyManagerService }], [IFormulaDependencyGenerator, { useClass: FormulaDependencyGenerator }], + [IFormulaUnitReferenceResolver, { useClass: FormulaUnitReferenceResolver }], ]; dependencies.forEach((dependency) => this._injector.add(dependency)); diff --git a/packages/engine-formula/src/services/__tests__/current-data.service.spec.ts b/packages/engine-formula/src/services/__tests__/current-data.service.spec.ts index ece5d0f653..8654d2d7dc 100644 --- a/packages/engine-formula/src/services/__tests__/current-data.service.spec.ts +++ b/packages/engine-formula/src/services/__tests__/current-data.service.spec.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Injector, IUniverInstanceService, LocaleService, ObjectMatrix } from '@univerjs/core'; +import { Injector, IUniverInstanceService, LocaleService, ObjectMatrix, UniverInstanceType } from '@univerjs/core'; import { describe, expect, it, vi } from 'vitest'; import { FormulaDataModel } from '../../models/formula-data.model'; import { FormulaCurrentConfigService, IFormulaCurrentConfigService } from '../current-data.service'; @@ -43,6 +43,7 @@ function createService() { const formulaDataModel = { getCalculateData: vi.fn(() => ({ allUnitData: {}, + unitNameMap: {}, unitSheetNameMap: {}, unitStylesData: {}, })), @@ -114,6 +115,12 @@ describe('FormulaCurrentConfigService', () => { SheetA: 'sheetA', }, } as never, + unitNameMap: { + unitA: { + name: 'Sales.xlsx', + unitType: UniverInstanceType.UNIVER_SHEET, + }, + }, formulaData: { unitA: { sheetA: {} } } as never, arrayFormulaCellData: {}, arrayFormulaRange: {}, @@ -132,6 +139,12 @@ describe('FormulaCurrentConfigService', () => { expect(service.getUnitData().unitA.sheetB.rowData).toEqual({ 0: { h: 20 } }); expect(service.getSheetName('unitA', 'sheetA')).toBe('SheetA'); expect(service.getSheetName('unitA', 'sheetB')).toBe('SheetB'); + expect(service.getUnitNameMap()).toEqual({ + unitA: { + name: 'Sales.xlsx', + unitType: UniverInstanceType.UNIVER_SHEET, + }, + }); expect(service.getClearDependencyTreeCache()).toEqual({ unitA: { sheetA: 'SheetA' } }); expect(service.getDirtyData()).toEqual(expect.objectContaining({ forceCalculation: true, @@ -156,6 +169,12 @@ describe('FormulaCurrentConfigService', () => { }, }, unitStylesData: { 'unit-current': {} }, + unitNameMap: { + 'unit-current': { + name: 'Current.xlsx', + unitType: UniverInstanceType.UNIVER_SHEET, + }, + }, unitSheetNameMap: { 'unit-current': { Main: 'sheet-current' } }, }); @@ -175,6 +194,7 @@ describe('FormulaCurrentConfigService', () => { expect(service.getExecuteUnitId()).toBe('unit-current'); expect(service.getExecuteSubUnitId()).toBe('sheet-current'); + expect(service.getUnitNameMap()['unit-current']?.name).toBe('Current.xlsx'); expect(service.getSheetsInfo()).toEqual({ sheetOrder: ['sheet-current'], sheetNameMap: { 'sheet-current': 'Main' }, @@ -204,6 +224,7 @@ describe('FormulaCurrentConfigService', () => { formulaDataModel.getCalculateData.mockReturnValue({ allUnitData: {}, unitStylesData: {}, + unitNameMap: {}, unitSheetNameMap: {}, }); formulaDataModel.getFormulaData.mockReturnValue({ unitLite: { sheetLite: { 1: { 1: { f: '=A1' } } } } }); @@ -221,6 +242,12 @@ describe('FormulaCurrentConfigService', () => { service.registerUnitData({ unit: { sheet: { cellData: new ObjectMatrix({}), rowCount: 1, columnCount: 1, rowData: {}, columnData: {} } } } as never); service.registerFormulaData({ unit: { sheet: { 1: { 1: { f: '=1' } } } } } as never); service.registerSheetNameMap({ unit: { Sheet: 'sheet' } } as never); + service.registerUnitNameMap({ + unit: { + name: 'Book.xlsx', + unitType: UniverInstanceType.UNIVER_SHEET, + }, + }); service.loadDirtyRangesAndExcludedCell( [{ unitId: 'unit', sheetId: 'sheet', range: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 } }], { unit: { sheet: { 0: { 0: true } } } } as never @@ -235,6 +262,7 @@ describe('FormulaCurrentConfigService', () => { expect(service.getUnitData()).toEqual({}); expect(service.getFormulaData()).toEqual({}); expect(service.getSheetNameMap()).toEqual({}); + expect(service.getUnitNameMap()).toEqual({}); expect(service.getExcludedRange()).toEqual({}); }); }); diff --git a/packages/engine-formula/src/services/__tests__/unit-reference-resolver.service.spec.ts b/packages/engine-formula/src/services/__tests__/unit-reference-resolver.service.spec.ts new file mode 100644 index 0000000000..185b983e80 --- /dev/null +++ b/packages/engine-formula/src/services/__tests__/unit-reference-resolver.service.spec.ts @@ -0,0 +1,170 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UniverInstanceType } from '@univerjs/core'; +import { describe, expect, it } from 'vitest'; +import { ErrorType } from '../../basics/error-type'; +import { FormulaUnitReferenceResolver } from '../unit-reference-resolver.service'; + +function createResolver( + unitNameMap: Record< + string, + { name: string; unitType: UniverInstanceType.UNIVER_SHEET | UniverInstanceType.UNIVER_BASE } + >, + unitData: Record = {} +) { + return new FormulaUnitReferenceResolver({ + getUnitNameMap: () => unitNameMap, + getUnitData: () => unitData, + } as never); +} + +describe('FormulaUnitReferenceResolver', () => { + it('keeps runtime unit ids compatible and resolves empty qualifiers to the host', () => { + const resolver = createResolver({ + host: { name: 'Host.xlsx', unitType: UniverInstanceType.UNIVER_SHEET }, + }); + + expect(resolver.resolve({ hostUnitId: 'host', qualifier: '', referenceKind: 'a1' })).toEqual({ + unitId: 'host', + unitType: UniverInstanceType.UNIVER_SHEET, + }); + expect(resolver.resolve({ hostUnitId: 'other', qualifier: 'host', referenceKind: 'a1' })).toEqual({ + unitId: 'host', + unitType: UniverInstanceType.UNIVER_SHEET, + }); + }); + + it('resolves Sheet and Base display names case-insensitively', () => { + const resolver = createResolver({ + sheet: { name: 'Sales.xlsx', unitType: UniverInstanceType.UNIVER_SHEET }, + base: { name: 'Customer Base', unitType: UniverInstanceType.UNIVER_BASE }, + }); + + expect(resolver.resolve({ hostUnitId: 'host', qualifier: 'sales.XLSX', referenceKind: 'a1' })).toEqual({ + unitId: 'sheet', + unitType: UniverInstanceType.UNIVER_SHEET, + }); + expect(resolver.resolve({ hostUnitId: 'host', qualifier: 'customer base', referenceKind: 'table' })).toEqual({ + unitId: 'base', + unitType: UniverInstanceType.UNIVER_BASE, + }); + }); + + it('resolves imported workbook names without their Excel file extension', () => { + const resolver = createResolver({ + sheet: { name: 'Sales 2026', unitType: UniverInstanceType.UNIVER_SHEET }, + base: { name: 'Customer Base', unitType: UniverInstanceType.UNIVER_BASE }, + unicode: { name: 'ÉTÉ 2026', unitType: UniverInstanceType.UNIVER_SHEET }, + }); + + expect(resolver.resolve({ hostUnitId: 'host', qualifier: 'sales 2026.XLSX', referenceKind: 'a1' })).toEqual({ + unitId: 'sheet', + unitType: UniverInstanceType.UNIVER_SHEET, + }); + expect( + resolver.resolve({ + hostUnitId: 'host', + qualifier: 'customer base.xls', + referenceKind: 'table', + }) + ).toEqual({ + unitId: 'base', + unitType: UniverInstanceType.UNIVER_BASE, + }); + + expect(resolver.resolve({ hostUnitId: 'host', qualifier: 'sales 2026.xlsm', referenceKind: 'a1' })).toEqual({ + unitId: 'sheet', + unitType: UniverInstanceType.UNIVER_SHEET, + }); + expect(resolver.resolve({ hostUnitId: 'host', qualifier: 'été 2026.xlsx', referenceKind: 'a1' })).toEqual({ + unitId: 'unicode', + unitType: UniverInstanceType.UNIVER_SHEET, + }); + }); + + it('prefers an exact workbook name before extension-compatible aliases', () => { + const resolver = createResolver({ + imported: { name: 'Sales', unitType: UniverInstanceType.UNIVER_SHEET }, + explicit: { name: 'Sales.xlsx', unitType: UniverInstanceType.UNIVER_SHEET }, + }); + + expect(resolver.resolve({ hostUnitId: 'host', qualifier: 'Sales.xlsx', referenceKind: 'a1' })).toMatchObject({ + unitId: 'explicit', + }); + }); + + it('does not treat empty names as aliases', () => { + const resolver = createResolver({ + unnamed: { name: '', unitType: UniverInstanceType.UNIVER_SHEET }, + }); + + expect(resolver.resolve({ hostUnitId: 'host', qualifier: '', referenceKind: 'a1' })).toBe(ErrorType.REF); + }); + + it('returns REF for missing or ambiguous names', () => { + const resolver = createResolver({ + first: { name: 'Sales.xlsx', unitType: UniverInstanceType.UNIVER_SHEET }, + second: { name: 'SALES.XLSX', unitType: UniverInstanceType.UNIVER_BASE }, + }); + + expect(resolver.resolve({ hostUnitId: 'host', qualifier: 'missing.xlsx', referenceKind: 'a1' })).toBe( + ErrorType.REF + ); + expect(resolver.resolve({ hostUnitId: 'host', qualifier: 'sales.xlsx', referenceKind: 'table' })).toBe( + ErrorType.REF + ); + }); + + it('returns REF for ambiguous extension-compatible aliases', () => { + const resolver = createResolver({ + first: { name: 'Sales', unitType: UniverInstanceType.UNIVER_SHEET }, + second: { name: 'SALES', unitType: UniverInstanceType.UNIVER_BASE }, + }); + + expect(resolver.resolve({ hostUnitId: 'host', qualifier: 'sales.xlsx', referenceKind: 'table' })).toBe( + ErrorType.REF + ); + }); + + it('accepts synthetic runtime ids that only exist in unit data', () => { + const resolver = createResolver({}, { 'external:1': {} }); + + expect(resolver.resolve({ hostUnitId: 'host', qualifier: 'external:1', referenceKind: 'a1' })).toEqual({ + unitId: 'external:1', + unitType: undefined, + }); + }); + + it('allows Base-to-Sheet A1 but keeps A1 references to Base forbidden', () => { + const resolver = createResolver({ + sheet: { name: 'Sales.xlsx', unitType: UniverInstanceType.UNIVER_SHEET }, + base: { name: 'Customer Base', unitType: UniverInstanceType.UNIVER_BASE }, + }); + + expect(resolver.resolve({ hostUnitId: 'sheet', qualifier: 'Customer Base', referenceKind: 'a1' })).toBe( + ErrorType.REF + ); + expect(resolver.resolve({ hostUnitId: 'base', qualifier: 'Sales.xlsx', referenceKind: 'a1' })).toEqual({ + unitId: 'sheet', + unitType: UniverInstanceType.UNIVER_SHEET, + }); + expect(resolver.resolve({ hostUnitId: 'base', qualifier: 'Sales.xlsx', referenceKind: 'table' })).toEqual({ + unitId: 'sheet', + unitType: UniverInstanceType.UNIVER_SHEET, + }); + }); +}); diff --git a/packages/engine-formula/src/services/current-data.service.ts b/packages/engine-formula/src/services/current-data.service.ts index 26084b4318..2f82df4692 100644 --- a/packages/engine-formula/src/services/current-data.service.ts +++ b/packages/engine-formula/src/services/current-data.service.ts @@ -24,6 +24,7 @@ import type { IDirtyUnitSuperTableMap, IFormulaData, IFormulaDatasetConfig, + IFormulaUnitNameMap, IRuntimeUnitDataType, IUnitData, IUnitExcludedCell, @@ -73,6 +74,8 @@ export interface IFormulaCurrentConfigService { getSheetNameMap(): IUnitSheetNameMap; + getUnitNameMap(): IFormulaUnitNameMap; + isForceCalculate(): boolean; getDirtyRanges(): IUnitRange[]; @@ -91,6 +94,8 @@ export interface IFormulaCurrentConfigService { registerSheetNameMap(sheetNameMap: IUnitSheetNameMap): void; + registerUnitNameMap(unitNameMap: IFormulaUnitNameMap): void; + getExcludedRange(): Nullable; loadDirtyRangesAndExcludedCell(dirtyRanges: IUnitRange[], excludedCell?: IUnitExcludedCell): void; @@ -142,6 +147,8 @@ export class FormulaCurrentConfigService extends Disposable implements IFormulaC private _sheetNameMap: IUnitSheetNameMap = {}; + private _unitNameMap: IFormulaUnitNameMap = {}; + private _forceCalculate: boolean = false; private _clearDependencyTreeCache: IDirtyUnitSheetNameMap = {}; @@ -182,6 +189,7 @@ export class FormulaCurrentConfigService extends Disposable implements IFormulaC this._arrayFormulaRange = {}; this._formulaData = {}; this._sheetNameMap = {}; + this._unitNameMap = {}; this._clearDependencyTreeCache = {}; this._dirtyRanges = []; this._dirtyNameMap = {}; @@ -237,6 +245,10 @@ export class FormulaCurrentConfigService extends Disposable implements IFormulaC return this._sheetNameMap; } + getUnitNameMap() { + return this._unitNameMap; + } + isForceCalculate() { return this._forceCalculate; } @@ -326,14 +338,17 @@ export class FormulaCurrentConfigService extends Disposable implements IFormulaC this._unitData = config.allUnitData; this._unitStylesData = config.unitStylesData; this._sheetNameMap = config.unitSheetNameMap; + this._unitNameMap = config.unitNameMap || {}; } else { - const { allUnitData, unitSheetNameMap, unitStylesData } = this._loadSheetData(); + const { allUnitData, unitNameMap, unitSheetNameMap, unitStylesData } = this._loadSheetData(); this._unitData = allUnitData; this._unitStylesData = unitStylesData; this._sheetNameMap = unitSheetNameMap; + + this._unitNameMap = unitNameMap; } // apply row data, including rows hidden by filters @@ -369,7 +384,7 @@ export class FormulaCurrentConfigService extends Disposable implements IFormulaC } loadDataLite(rowData?: IUnitRowData) { - const { allUnitData, unitSheetNameMap, unitStylesData } = this._loadSheetData(); + const { allUnitData, unitNameMap, unitSheetNameMap, unitStylesData } = this._loadSheetData(); this._unitData = allUnitData; @@ -377,6 +392,8 @@ export class FormulaCurrentConfigService extends Disposable implements IFormulaC this._sheetNameMap = unitSheetNameMap; + this._unitNameMap = unitNameMap; + this._formulaData = this._formulaDataModel.getFormulaData(); this._arrayFormulaCellData = convertUnitDataToRuntime(this._formulaDataModel.getArrayFormulaCellData()); this._arrayFormulaRange = this._formulaDataModel.getArrayFormulaRange(); @@ -421,6 +438,10 @@ export class FormulaCurrentConfigService extends Disposable implements IFormulaC this._sheetNameMap = sheetNameMap; } + registerUnitNameMap(unitNameMap: IFormulaUnitNameMap) { + this._unitNameMap = unitNameMap; + } + // private _loadOtherFormulaData() { // const unitAllDoc = this._univerInstanceService.getAllUniverDocsInstance(); diff --git a/packages/engine-formula/src/services/unit-reference-resolver.service.ts b/packages/engine-formula/src/services/unit-reference-resolver.service.ts new file mode 100644 index 0000000000..49f7cea765 --- /dev/null +++ b/packages/engine-formula/src/services/unit-reference-resolver.service.ts @@ -0,0 +1,125 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { FormulaUnitType } from '../basics/common'; +import { createIdentifier, UniverInstanceType } from '@univerjs/core'; +import { ErrorType } from '../basics/error-type'; +import { IFormulaCurrentConfigService } from './current-data.service'; + +export type FormulaUnitReferenceKind = 'a1' | 'table'; + +export interface IFormulaUnitReferenceResolveInput { + hostUnitId: string; + qualifier: string; + referenceKind: FormulaUnitReferenceKind; +} + +export interface IFormulaUnitReferenceResolution { + unitId: string; + unitType?: FormulaUnitType; +} + +export interface IFormulaUnitReferenceResolver { + resolve(input: IFormulaUnitReferenceResolveInput): IFormulaUnitReferenceResolution | ErrorType; +} + +export const IFormulaUnitReferenceResolver = createIdentifier( + 'univer.formula.unit-reference-resolver' +); + +const EXCEL_WORKBOOK_EXTENSION = /\.(?:xlsx|xlsm|xlsb|xltx|xltm|xls)$/i; + +export function normalizeFormulaUnitName(name: string): string { + return name.replace(EXCEL_WORKBOOK_EXTENSION, '').toLowerCase(); +} + +export class FormulaUnitReferenceResolver implements IFormulaUnitReferenceResolver { + constructor( + @IFormulaCurrentConfigService + protected readonly _currentConfigService: IFormulaCurrentConfigService + ) {} + + resolve({ + hostUnitId, + qualifier, + referenceKind, + }: IFormulaUnitReferenceResolveInput): IFormulaUnitReferenceResolution | ErrorType { + const unitNameMap = this._currentConfigService.getUnitNameMap(); + const unitData = this._currentConfigService.getUnitData(); + const address = qualifier || hostUnitId; + const direct = unitNameMap[address]; + + if (direct || unitData[address]) { + return this._validateReferenceKind( + hostUnitId, + referenceKind, + { + unitId: address, + unitType: direct?.unitType, + }, + unitNameMap + ); + } + + if (!qualifier) { + return ErrorType.REF; + } + + const namedUnits = Object.entries(unitNameMap).filter(([, item]) => item.name.length > 0); + const normalizedQualifier = qualifier.toLowerCase(); + const exactMatches = namedUnits.filter(([, item]) => item.name.toLowerCase() === normalizedQualifier); + const matches = + exactMatches.length > 0 + ? exactMatches + : namedUnits.filter( + ([, item]) => normalizeFormulaUnitName(item.name) === normalizeFormulaUnitName(qualifier) + ); + + if (matches.length !== 1) { + return ErrorType.REF; + } + + const [unitId, item] = matches[0]; + return this._validateReferenceKind( + hostUnitId, + referenceKind, + { + unitId, + unitType: item.unitType, + }, + unitNameMap + ); + } + + private _validateReferenceKind( + hostUnitId: string, + referenceKind: FormulaUnitReferenceKind, + resolution: IFormulaUnitReferenceResolution, + unitNameMap: ReturnType + ): IFormulaUnitReferenceResolution | ErrorType { + if (referenceKind !== 'a1' || resolution.unitId === hostUnitId) { + return resolution; + } + const hostType = unitNameMap[hostUnitId]?.unitType; + if (resolution.unitType === UniverInstanceType.UNIVER_BASE) { + return ErrorType.REF; + } + if (hostType === UniverInstanceType.UNIVER_BASE && resolution.unitType !== UniverInstanceType.UNIVER_SHEET) { + return ErrorType.REF; + } + return resolution; + } +} diff --git a/packages/sheets-formula/src/controllers/__tests__/unit-qualifier-rename.controller.spec.ts b/packages/sheets-formula/src/controllers/__tests__/unit-qualifier-rename.controller.spec.ts new file mode 100644 index 0000000000..ae511264e6 --- /dev/null +++ b/packages/sheets-formula/src/controllers/__tests__/unit-qualifier-rename.controller.spec.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ObjectMatrix } from '@univerjs/core'; +import { describe, expect, it } from 'vitest'; +import { collectUnitQualifierFormulaPatches } from '../unit-qualifier-rename.controller'; + +describe('UnitQualifierRenameController', () => { + it('builds persisted Sheet cell patches for a renamed Base Unit', () => { + const workbook = { + getUnitId: () => 'host', + getSheets: () => [{ + getSheetId: () => 'sheet', + getCellMatrix: () => new ObjectMatrix({ + 0: { 0: { f: '=SUM(BaseData!Tasks[Amount])' } }, + 1: { 0: { f: '=INDIRECT("[BaseData]Data!A1")&"BaseData!Tasks[Amount]"' } }, + }), + }], + } as never; + + expect(collectUnitQualifierFormulaPatches(workbook, 'BaseData', 'FY Base')).toEqual([{ + unitId: 'host', + subUnitId: 'sheet', + cellValue: { + 0: { 0: { f: "=SUM('FY Base'!Tasks[Amount])" } }, + 1: { 0: { f: '=INDIRECT("[FY Base]Data!A1")&"BaseData!Tasks[Amount]"' } }, + }, + }]); + }); +}); diff --git a/packages/sheets-formula/src/controllers/__tests__/unit-rename.spec.ts b/packages/sheets-formula/src/controllers/__tests__/unit-rename.spec.ts new file mode 100644 index 0000000000..7c3d381b10 --- /dev/null +++ b/packages/sheets-formula/src/controllers/__tests__/unit-rename.spec.ts @@ -0,0 +1,66 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { IFormulaData } from '@univerjs/engine-formula'; +import { describe, expect, it } from 'vitest'; +import { UpdateFormulaController } from '../update-formula.controller'; +import { FormulaReferenceMoveType } from '../utils/ref-range-formula'; + +describe('UpdateFormulaController Unit rename', () => { + it('refactors qualifiers across host workbooks and leaves ordinary strings intact', () => { + const controller = Object.create(UpdateFormulaController.prototype) as { + _getFormulaReferenceMoveInfo: ( + data: IFormulaData, + sheetNames: Record>, + move: object + ) => { newFormulaData: IFormulaData }; + }; + const formulaData = { + host: { + sheet: { + 0: { 0: { f: '=SUM([Sales.xlsx]Data!A1)+SUM(Sales.xlsx!T[V])' } }, + 1: { 0: { f: '=INDIRECT("[Sales.xlsx]Data!B2")&"[Sales.xlsx]Data!B2"' } }, + }, + }, + } as IFormulaData; + + const { newFormulaData } = controller._getFormulaReferenceMoveInfo(formulaData, {}, { + type: FormulaReferenceMoveType.SetUnitName, + unitId: 'sales-unit', + sheetId: '', + oldUnitName: 'Sales.xlsx', + unitName: 'FY 2027.xlsx', + }); + + expect(newFormulaData.host?.sheet?.[0]?.[0]?.f).toBe("=SUM([FY 2027.xlsx]Data!A1)+SUM('FY 2027.xlsx'!T[V])"); + expect(newFormulaData.host?.sheet?.[1]?.[0]?.f).toBe('=INDIRECT("[FY 2027.xlsx]Data!B2")&"[Sales.xlsx]Data!B2"'); + }); +}); +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/packages/sheets-formula/src/controllers/unit-qualifier-rename.controller.ts b/packages/sheets-formula/src/controllers/unit-qualifier-rename.controller.ts new file mode 100644 index 0000000000..9df5e27f70 --- /dev/null +++ b/packages/sheets-formula/src/controllers/unit-qualifier-rename.controller.ts @@ -0,0 +1,119 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ICellData, IMutationInfo, UnitModel, Workbook } from '@univerjs/core'; +import { Disposable, ICommandService, IUndoRedoService, IUniverInstanceService, ObjectMatrix, sequenceExecute, UniverInstanceType } from '@univerjs/core'; +import { IDefinedNamesService, refactorFormulaUnitQualifier, SetDefinedNameMutation } from '@univerjs/engine-formula'; +import { SetRangeValuesMutation } from '@univerjs/sheets'; + +export interface IUnitQualifierFormulaPatch { + unitId: string; + subUnitId: string; + cellValue: Record>; +} + +export function collectUnitQualifierFormulaPatches( + workbook: Workbook, + oldName: string, + newName: string +): IUnitQualifierFormulaPatch[] { + const unitId = workbook.getUnitId(); + return workbook.getSheets().flatMap((sheet) => { + const updates = new ObjectMatrix(); + sheet.getCellMatrix().forValue((row, column, cell) => { + if (!cell?.f) return; + const formula = refactorFormulaUnitQualifier(cell.f, oldName, newName); + if (formula !== cell.f) updates.setValue(row, column, { f: formula }); + }); + const cellValue = updates.getData(); + return Object.keys(cellValue).length > 0 + ? [{ unitId, subUnitId: sheet.getSheetId(), cellValue }] + : []; + }); +} + +/** Keeps persisted Sheet formulas and defined names aligned when a Base Unit is renamed. */ +export class UnitQualifierRenameController extends Disposable { + private readonly _names = new Map(); + + constructor( + @ICommandService private readonly _commandService: ICommandService, + @IUndoRedoService private readonly _undoRedoService: IUndoRedoService, + @IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService, + @IDefinedNamesService private readonly _definedNamesService: IDefinedNamesService + ) { + super(); + this._univerInstanceService.getAllUnitsForType(UniverInstanceType.UNIVER_BASE) + .forEach((unit) => this._watch(unit)); + this.disposeWithMe(this._univerInstanceService.getTypeOfUnitAdded$(UniverInstanceType.UNIVER_BASE) + .subscribe(({ unit }) => this._watch(unit))); + this.disposeWithMe(this._univerInstanceService.getTypeOfUnitDisposed$(UniverInstanceType.UNIVER_BASE) + .subscribe((unit) => this._names.delete(unit.getUnitId()))); + } + + private _watch(unit: UnitModel): void { + const unitId = unit.getUnitId(); + this.disposeWithMe(unit.name$.subscribe((name) => { + const oldName = this._names.get(unitId); + this._names.set(unitId, name); + if (!oldName || oldName === name) return; + this._refactor(unitId, oldName, name); + })); + } + + private _refactor(renamedUnitId: string, oldName: string, newName: string): void { + const redos: IMutationInfo[] = []; + const undos: IMutationInfo[] = []; + for (const workbook of this._univerInstanceService.getAllUnitsForType(UniverInstanceType.UNIVER_SHEET)) { + for (const patch of collectUnitQualifierFormulaPatches(workbook, oldName, newName)) { + const sheet = workbook.getSheetBySheetId(patch.subUnitId); + if (!sheet) continue; + const undoCellValue = new ObjectMatrix(); + new ObjectMatrix(patch.cellValue).forValue((row, column) => { + undoCellValue.setValue(row, column, sheet.getCellRaw(row, column) ?? null); + }); + redos.push({ id: SetRangeValuesMutation.id, params: patch }); + undos.unshift({ + id: SetRangeValuesMutation.id, + params: { ...patch, cellValue: undoCellValue.getData() }, + }); + } + const definedNames = this._definedNamesService.getDefinedNameMap(workbook.getUnitId()); + for (const item of Object.values(definedNames ?? {})) { + const formulaOrRefString = refactorFormulaUnitQualifier(item.formulaOrRefString, oldName, newName); + if (formulaOrRefString !== item.formulaOrRefString) { + redos.push({ id: SetDefinedNameMutation.id, params: { + unitId: workbook.getUnitId(), + ...item, + formulaOrRefString, + } }); + undos.unshift({ id: SetDefinedNameMutation.id, params: { + unitId: workbook.getUnitId(), + ...item, + } }); + } + } + } + if (!redos.length || !sequenceExecute(redos, this._commandService).result) { + return; + } + this._undoRedoService.pushUndoRedo({ + unitID: renamedUnitId, + undoMutations: undos, + redoMutations: redos, + }); + } +} diff --git a/packages/sheets-formula/src/controllers/update-defined-name.controller.ts b/packages/sheets-formula/src/controllers/update-defined-name.controller.ts index a62c01fa4e..e1b8ba85df 100644 --- a/packages/sheets-formula/src/controllers/update-defined-name.controller.ts +++ b/packages/sheets-formula/src/controllers/update-defined-name.controller.ts @@ -24,7 +24,7 @@ import { IUniverInstanceService, UniverInstanceType, } from '@univerjs/core'; -import { deserializeRangeWithSheetWithCache, ErrorType, generateStringWithSequence, IDefinedNamesService, LexerTreeBuilder, sequenceNodeType, serializeRangeToRefString, SetDefinedNameMutation } from '@univerjs/engine-formula'; +import { deserializeRangeWithSheetWithCache, ErrorType, generateStringWithSequence, IDefinedNamesService, LexerTreeBuilder, refactorFormulaUnitQualifier, sequenceNodeType, serializeRangeToRefString, SetDefinedNameMutation } from '@univerjs/engine-formula'; import { RemoveDefinedNameCommand, SetDefinedNameCommand, SheetInterceptorService } from '@univerjs/sheets'; import { FormulaReferenceMoveType, updateRefOffset } from './utils/ref-range-formula'; import { getNewRangeByMoveParam } from './utils/ref-range-move'; @@ -78,6 +78,12 @@ export class UpdateDefinedNameController extends Disposable { }; } + if (result.type === FormulaReferenceMoveType.SetUnitName) { + result.oldUnitName = this._univerInstanceService + .getUnit(result.unitId, UniverInstanceType.UNIVER_SHEET) + ?.getName(); + } + return this._getUpdateDefinedNameMutations(workbook, result); }, }) @@ -102,6 +108,15 @@ export class UpdateDefinedNameController extends Disposable { // eslint-disable-next-line max-lines-per-function Object.values(definedNames).forEach((item) => { const { formulaOrRefString } = item; + if (type === FormulaReferenceMoveType.SetUnitName) { + const { oldUnitName, unitName } = moveParams; + if (!oldUnitName || !unitName) return true; + const nextFormula = refactorFormulaUnitQualifier(formulaOrRefString, oldUnitName, unitName); + if (nextFormula === formulaOrRefString) return true; + redoMutations.push({ id: SetDefinedNameMutation.id, params: { unitId, ...item, formulaOrRefString: nextFormula } }); + undoMutations.push({ id: SetDefinedNameMutation.id, params: { unitId, ...item } }); + return true; + } const sequenceNodes = this._lexerTreeBuilder.sequenceNodesBuilder(formulaOrRefString); if (sequenceNodes == null) { return true; diff --git a/packages/sheets-formula/src/controllers/update-formula.controller.ts b/packages/sheets-formula/src/controllers/update-formula.controller.ts index 25327f0e99..a01557f6b1 100644 --- a/packages/sheets-formula/src/controllers/update-formula.controller.ts +++ b/packages/sheets-formula/src/controllers/update-formula.controller.ts @@ -44,7 +44,7 @@ import { Tools, UniverInstanceType, } from '@univerjs/core'; -import { deserializeRangeWithSheetWithCache, ErrorType, FormulaDataModel, generateStringWithSequence, IDefinedNamesService, initSheetFormulaData, LexerTreeBuilder, sequenceNodeType, serializeRangeToRefString, SetArrayFormulaDataMutation, SetFormulaDataMutation, SetTriggerFormulaCalculationStartMutation, splitTableStructuredRef } from '@univerjs/engine-formula'; +import { deserializeRangeWithSheetWithCache, ErrorType, FormulaDataModel, generateStringWithSequence, IDefinedNamesService, initSheetFormulaData, LexerTreeBuilder, refactorFormulaUnitQualifier, sequenceNodeType, serializeRangeToRefString, SetArrayFormulaDataMutation, SetFormulaDataMutation, SetTriggerFormulaCalculationStartMutation, splitTableStructuredRef } from '@univerjs/engine-formula'; import { ClearSelectionFormatCommand, InsertSheetMutation, @@ -320,7 +320,10 @@ export class UpdateFormulaController extends Disposable { const result = getReferenceMoveParams(workbook, command); if (result) { - const { unitSheetNameMap } = this._formulaDataModel.getCalculateData(); + const { unitNameMap, unitSheetNameMap } = this._formulaDataModel.getCalculateData(); + if (result.type === FormulaReferenceMoveType.SetUnitName) { + result.oldUnitName = unitNameMap?.[result.unitId]?.name; + } const oldFormulaData = this._formulaDataModel.getFormulaData(); // change formula reference @@ -390,6 +393,14 @@ export class UpdateFormulaController extends Disposable { const { f: formulaString, x, y, si } = formulaDataItem; + if (type === FormulaReferenceMoveType.SetUnitName) { + const { oldUnitName, unitName } = formulaReferenceMoveParam; + if (!oldUnitName || !unitName) return true; + const nextFormula = refactorFormulaUnitQualifier(formulaString, oldUnitName, unitName); + if (nextFormula !== formulaString) newFormulaDataItem.setValue(row, column, { f: nextFormula }); + return true; + } + const sequenceNodes = this._lexerTreeBuilder.sequenceNodesBuilder(formulaString); if (sequenceNodes == null) { diff --git a/packages/sheets-formula/src/controllers/utils/ref-range-formula.ts b/packages/sheets-formula/src/controllers/utils/ref-range-formula.ts index ec7b9a2479..bbce6a5871 100644 --- a/packages/sheets-formula/src/controllers/utils/ref-range-formula.ts +++ b/packages/sheets-formula/src/controllers/utils/ref-range-formula.ts @@ -35,6 +35,7 @@ export enum FormulaReferenceMoveType { InsertMoveDown, // range InsertMoveRight, // range SetName, + SetUnitName, RemoveSheet, SetDefinedName, // update defined name RemoveDefinedName, // remove defined name @@ -54,6 +55,8 @@ export interface IFormulaReferenceMoveParam { from?: IRange; to?: IRange; sheetName?: string; + oldUnitName?: string; + unitName?: string; /** * defined name id */ @@ -73,6 +76,7 @@ export interface IFormulaReferenceMoveParam { const formulaReferenceSheetList = [ FormulaReferenceMoveType.SetName, + FormulaReferenceMoveType.SetUnitName, FormulaReferenceMoveType.RemoveSheet, FormulaReferenceMoveType.SetDefinedName, FormulaReferenceMoveType.RemoveDefinedName, diff --git a/packages/sheets-formula/src/controllers/utils/ref-range-param.ts b/packages/sheets-formula/src/controllers/utils/ref-range-param.ts index 226e78c0f0..1e5832fbc0 100644 --- a/packages/sheets-formula/src/controllers/utils/ref-range-param.ts +++ b/packages/sheets-formula/src/controllers/utils/ref-range-param.ts @@ -28,6 +28,7 @@ import type { IMoveRowsCommandParams, IRemoveRowColCommandParams, IRemoveSheetCommandParams, + ISetWorkbookNameCommandParams, ISetWorksheetNameCommandParams, } from '@univerjs/sheets'; import type { IFormulaReferenceMoveParam } from './ref-range-formula'; @@ -47,6 +48,7 @@ import { RemoveRowCommand, RemoveSheetCommand, SetDefinedNameCommand, + SetWorkbookNameCommand, SetWorksheetNameCommand, } from '@univerjs/sheets'; import { FormulaReferenceMoveType } from './ref-range-formula'; @@ -116,6 +118,9 @@ export function getReferenceMoveParams(workbook: Workbook, command: ICommandInfo case SetWorksheetNameCommand.id: result = handleRefSetWorksheetName(command as ICommandInfo, workbook); break; + case SetWorkbookNameCommand.id: + result = handleRefSetWorkbookName(command as ICommandInfo); + break; case RemoveSheetCommand.id: result = handleRefRemoveWorksheet(command as ICommandInfo, workbook); break; @@ -396,6 +401,17 @@ function handleRefSetWorksheetName(command: ICommandInfo): Nullable { + const { params } = command; + if (!params) return null; + return { + type: FormulaReferenceMoveType.SetUnitName, + unitId: params.unitId, + sheetId: '', + unitName: params.name, + }; +} + function handleRefRemoveWorksheet(command: ICommandInfo, workbook: Workbook) { const { params } = command; if (!params) return null; diff --git a/packages/sheets-formula/src/index.ts b/packages/sheets-formula/src/index.ts index 9eebe1120d..697b31eebf 100644 --- a/packages/sheets-formula/src/index.ts +++ b/packages/sheets-formula/src/index.ts @@ -23,6 +23,7 @@ export { FormulaAutoFillController } from './controllers/formula-auto-fill.contr export { FormulaCalculationSessionController } from './controllers/formula-calculation-session.controller'; export { ImageFormulaCellInterceptorController } from './controllers/image-formula-cell-interceptor.controller'; export { TriggerCalculationController } from './controllers/trigger-calculation.controller'; +export { collectUnitQualifierFormulaPatches, UnitQualifierRenameController } from './controllers/unit-qualifier-rename.controller'; export { UpdateDefinedNameController } from './controllers/update-defined-name.controller'; export { UpdateFormulaController } from './controllers/update-formula.controller'; export { UniverRemoteSheetsFormulaPlugin, UniverSheetsFormulaPlugin } from './plugin'; diff --git a/packages/sheets-formula/src/plugin.ts b/packages/sheets-formula/src/plugin.ts index ea3b60eb83..08979f9f75 100644 --- a/packages/sheets-formula/src/plugin.ts +++ b/packages/sheets-formula/src/plugin.ts @@ -37,6 +37,7 @@ import { FormulaController } from './controllers/formula.controller'; import { ImageFormulaCellInterceptorController } from './controllers/image-formula-cell-interceptor.controller'; import { SuperTableController } from './controllers/super-table.controller'; import { TriggerCalculationController } from './controllers/trigger-calculation.controller'; +import { UnitQualifierRenameController } from './controllers/unit-qualifier-rename.controller'; import { UpdateDefinedNameController } from './controllers/update-defined-name.controller'; import { UpdateFormulaController } from './controllers/update-formula.controller'; import { DescriptionService, IDescriptionService } from './services/description.service'; @@ -118,6 +119,7 @@ export class UniverSheetsFormulaPlugin extends Plugin { [UpdateDefinedNameController], [SuperTableController], [FormulaAutoFillController], + [UnitQualifierRenameController], ]; // If the plugin do not execute formula, it should delegate a remote proxy. @@ -141,6 +143,7 @@ export class UniverSheetsFormulaPlugin extends Plugin { [UpdateFormulaController], [UpdateDefinedNameController], [FormulaAutoFillController], + [UnitQualifierRenameController], ]); // There is no rendering in the nodejs environment, so initialize it here diff --git a/packages/sheets/src/controllers/__tests__/calculate-result-apply.controller.spec.ts b/packages/sheets/src/controllers/__tests__/calculate-result-apply.controller.spec.ts new file mode 100644 index 0000000000..305c954ae4 --- /dev/null +++ b/packages/sheets/src/controllers/__tests__/calculate-result-apply.controller.spec.ts @@ -0,0 +1,47 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ICommandService } from '@univerjs/core'; +import { SetFormulaCalculationResultMutation } from '@univerjs/engine-formula'; +import { describe, expect, it } from 'vitest'; +import { SetRangeValuesMutation } from '../../commands/mutations/set-range-values.mutation'; +import { CalculateResultApplyController } from '../calculate-result-apply.controller'; +import { createFunctionTestBed } from './formula/create-function-test-bed'; + +describe('CalculateResultApplyController', () => { + it('skips non-sheet unit results without blocking later sheet results', async () => { + const testBed = createFunctionTestBed(); + const commandService = testBed.get(ICommandService); + commandService.registerCommand(SetFormulaCalculationResultMutation); + commandService.registerCommand(SetRangeValuesMutation); + testBed.get(CalculateResultApplyController); + + await commandService.executeCommand(SetFormulaCalculationResultMutation.id, { + unitData: { + 'base-unit': { + 'table-1': { 0: { 0: { v: 900 } } }, + }, + [testBed.unitId]: { + [testBed.sheetId]: { 0: { 0: { v: 2760 } } }, + }, + }, + unitOtherData: {}, + }); + + expect(testBed.sheet.getSheetBySheetId(testBed.sheetId)?.getCellMatrix().getValue(0, 0)?.v).toBe(2760); + testBed.univer.dispose(); + }); +}); diff --git a/packages/sheets/src/controllers/__tests__/formula/create-function-test-bed.ts b/packages/sheets/src/controllers/__tests__/formula/create-function-test-bed.ts index 5d111a8608..6689b1ed06 100644 --- a/packages/sheets/src/controllers/__tests__/formula/create-function-test-bed.ts +++ b/packages/sheets/src/controllers/__tests__/formula/create-function-test-bed.ts @@ -45,6 +45,7 @@ import { FormulaDataModel, FormulaDependencyGenerator, FormulaRuntimeService, + FormulaUnitReferenceResolver, FunctionNodeFactory, FunctionService, GlobalComputingStatusService, @@ -56,6 +57,7 @@ import { IFormulaCurrentConfigService, IFormulaDependencyGenerator, IFormulaRuntimeService, + IFormulaUnitReferenceResolver, IFunctionService, IHyperlinkEngineFormulaService, Interpreter, @@ -199,6 +201,7 @@ export function createFunctionTestBed(workbookData?: IWorkbookData, dependencies injector.add([IFormulaCurrentConfigService, { useClass: FormulaCurrentConfigService }]); injector.add([IHyperlinkEngineFormulaService, { useClass: HyperlinkEngineFormulaService }]); injector.add([IFormulaRuntimeService, { useClass: FormulaRuntimeService }]); + injector.add([IFormulaUnitReferenceResolver, { useClass: FormulaUnitReferenceResolver }]); injector.add([IFunctionService, { useClass: FunctionService }]); injector.add([IOtherFormulaManagerService, { useClass: OtherFormulaManagerService }]); injector.add([IFeatureCalculationManagerService, { useClass: FeatureCalculationManagerService }]); diff --git a/packages/sheets/src/controllers/calculate-result-apply.controller.ts b/packages/sheets/src/controllers/calculate-result-apply.controller.ts index 094dc3158d..f44e766783 100644 --- a/packages/sheets/src/controllers/calculate-result-apply.controller.ts +++ b/packages/sheets/src/controllers/calculate-result-apply.controller.ts @@ -48,6 +48,10 @@ export class CalculateResultApplyController extends Disposable { for (let i = 0; i < unitIds.length; i++) { const unitId = unitIds[i]; + const workbook = this._univerInstanceService.getUniverSheetInstance(unitId); + if (!workbook) { + continue; + } const sheetData = unitData[unitId]; if (sheetData == null) { @@ -64,6 +68,10 @@ export class CalculateResultApplyController extends Disposable { continue; } + if (!workbook.getSheetBySheetId(sheetId)) { + continue; + } + const cellValue = this._getMergedCellData(unitId, sheetId, cellData); const setRangeValuesMutation = {