fix(base): canonicalize formula table names (#7471)

This commit is contained in:
Univer
2026-08-11 01:01:31 +08:00
committed by GitHub
parent 36575dd719
commit 7541531f5b
7 changed files with 522 additions and 64 deletions
@@ -0,0 +1,164 @@
/**
* 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 { BaseDataModel } from '../base-data-model';
import { getEmptySnapshot } from '../empty-snapshot';
import {
allocateBaseFormulaTableName,
createBaseFormulaTableNameMap,
createBaseFormulaTableReferenceNormalizer,
getBaseFormulaTableName,
migrateBaseFormulaTableNames,
normalizeBaseFormulaTableName,
normalizeBaseFormulaTableReferences,
} from '../formula-table-name';
describe('Base formula table names', () => {
it('normalizes display names into OOXML-compatible identifiers', () => {
expect(normalizeBaseFormulaTableName('07 | Inventory and alerts')).toBe('_07_Inventory_and_alerts');
expect(normalizeBaseFormulaTableName('A1')).toBe('_A1');
expect(normalizeBaseFormulaTableName('R')).toBe('_R');
expect(normalizeBaseFormulaTableName('库存')).toBe('Table');
});
it('uses one deterministic namespace for normalized duplicates and suffix collisions', () => {
const snapshot = {
tables: {
c: { id: 'c', name: 'Sales-table' },
b: { id: 'b', name: 'Sales_table_2' },
a: { id: 'a', name: 'Sales table' },
},
};
expect(Object.fromEntries(createBaseFormulaTableNameMap(snapshot))).toEqual({
a: 'Sales_table',
b: 'Sales_table_2',
c: 'Sales_table_3',
});
expect(getBaseFormulaTableName(snapshot.tables.c, snapshot)).toBe('Sales_table_3');
});
it('preserves persisted formula names when a lower-sorting colliding table is added', () => {
const before = {
tables: {
b: { id: 'b', name: 'Sales table', formulaName: 'Sales_table' },
},
};
const insertedFormulaName = allocateBaseFormulaTableName(
'Sales-table',
createBaseFormulaTableNameMap(before).values()
);
const after = {
tables: {
b: before.tables.b,
a: { id: 'a', name: 'Sales-table', formulaName: insertedFormulaName },
},
};
expect(Object.fromEntries(createBaseFormulaTableNameMap(after))).toEqual({
a: 'Sales_table_2',
b: 'Sales_table',
});
expect(normalizeBaseFormulaTableReferences('=SUM(Sales_table[Amount])', after)).toBe(
'=SUM(Sales_table[Amount])'
);
});
it('keeps the persisted formula name when the display name changes', () => {
const snapshot = {
tables: {
a: { id: 'a', name: 'Revenue', formulaName: 'Sales' },
},
};
expect(getBaseFormulaTableName(snapshot.tables.a, snapshot)).toBe('Sales');
});
it('does not collapse a persisted suffix when another table is deleted', () => {
const snapshot = {
tables: {
b: { id: 'b', name: 'Sales-table', formulaName: 'Sales_table_2' },
},
};
expect(getBaseFormulaTableName(snapshot.tables.b, snapshot)).toBe('Sales_table_2');
});
it('migrates id-derived input aliases without touching strings or external tables', () => {
const snapshot = {
tables: {
'table-1': { id: 'table-1', name: 'Work Items' },
},
};
expect(normalizeBaseFormulaTableReferences(
'=SUM(_T_table_x2d_1[Amount],table-1[Amount],"_T_table_x2d_1[Amount]",Book!_T_table_x2d_1[Amount])',
snapshot
)).toBe('=SUM(Work_Items[Amount],Work_Items[Amount],"_T_table_x2d_1[Amount]",Book!_T_table_x2d_1[Amount])');
});
it('reuses one compiled alias normalizer across formulas', () => {
const snapshot = {
tables: {
'table.+(1)': { id: 'table.+(1)', name: 'Work Items' },
},
};
const normalizeReferences = createBaseFormulaTableReferenceNormalizer(snapshot);
expect(normalizeReferences('=SUM(table.+(1)[Amount])')).toBe('=SUM(Work_Items[Amount])');
expect(normalizeReferences('="table.+(1)[Amount]"&Book!table.+(1)[Amount]')).toBe(
'="table.+(1)[Amount]"&Book!table.+(1)[Amount]'
);
});
it('persists a canonical formula name while migrating a historical snapshot', () => {
const snapshot = {
tables: {
'table-1': {
id: 'table-1',
name: 'Work Items',
formulaName: undefined as string | undefined,
fields: {
total: { type: 'formula', config: { formula: '=SUM(table-1[Amount])' } },
},
cellData: {
0: { 0: { f: '=SUM(_T_table_x2d_1[Amount])' } },
},
},
},
};
migrateBaseFormulaTableNames(snapshot as never);
expect(snapshot.tables['table-1'].formulaName).toBe('Work_Items');
expect(snapshot.tables['table-1'].fields.total.config.formula).toBe('=SUM(Work_Items[Amount])');
expect(snapshot.tables['table-1'].cellData[0][0].f).toBe('=SUM(Work_Items[Amount])');
migrateBaseFormulaTableNames(snapshot as never);
expect(snapshot.tables['table-1'].formulaName).toBe('Work_Items');
expect(snapshot.tables['table-1'].fields.total.config.formula).toBe('=SUM(Work_Items[Amount])');
});
it('derives a historical formula name without silently changing the OT snapshot', () => {
const snapshot = getEmptySnapshot('base-1', 'Base');
snapshot.tables['table-1'].name = 'Work Items';
const loaded = new BaseDataModel(snapshot).getSnapshot();
expect(loaded.tables['table-1'].formulaName).toBeUndefined();
expect(getBaseFormulaTableName(loaded.tables['table-1'], loaded)).toBe('Work_Items');
});
});
+5 -1
View File
@@ -87,7 +87,11 @@ export class BaseDataModel extends UnitModel<IBaseSnapshot, UniverInstanceType.U
}
setSnapshot(snapshot: IBaseSnapshot): void {
this._snapshot = isNormalizedBaseSnapshot(snapshot) ? snapshot : normalizeBaseSnapshot(snapshot);
if (isNormalizedBaseSnapshot(snapshot)) {
this._snapshot = snapshot;
} else {
this._snapshot = normalizeBaseSnapshot(snapshot);
}
this._name$.next(snapshot.name);
}
@@ -0,0 +1,285 @@
/**
* 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 { IBaseSnapshot, ITableSnapshot } from './typedef';
type BaseFormulaTable = Pick<ITableSnapshot, 'id' | 'name' | 'formulaName'>;
interface IBaseFormulaSnapshot {
tables: Record<string, BaseFormulaTable>;
}
interface ICompiledBaseFormulaTableAliases {
formulaNameByAlias: ReadonlyMap<string, string>;
pattern?: RegExp;
}
export function normalizeBaseFormulaTableName(displayName: string): string {
const replaced = displayName.trim().replace(/[^A-Za-z0-9_.]+/g, '_');
const normalized = /[A-Za-z0-9]/.test(replaced) ? replaced : 'Table';
const prefixed = /^[A-Za-z_]/.test(normalized) ? normalized : `_${normalized}`;
const nonReference = /^[RC]$/i.test(prefixed)
|| /^[A-Za-z]{1,3}[1-9]\d*$/.test(prefixed)
|| /^R(?:\d+)?C(?:\d+)?$/i.test(prefixed)
? `_${prefixed}`
: prefixed;
return nonReference.slice(0, 255);
}
export function createBaseFormulaTableNameMap(snapshot: IBaseFormulaSnapshot): ReadonlyMap<string, string> {
const result = new Map<string, string>();
const usedNames = new Set<string>();
const nextSuffixByBaseName = new Map<string, number>();
const tables = Object.values(snapshot.tables).sort((left, right) => left.id.localeCompare(right.id));
// Persisted names own the namespace. Missing historical names are allocated only
// after every valid persisted name has been reserved, so loading an old table can
// never steal the stable formula identity of a newer table.
for (const table of tables) {
const formulaName = validBaseFormulaTableName(table.formulaName);
if (!formulaName || usedNames.has(formulaName.toLowerCase())) continue;
usedNames.add(formulaName.toLowerCase());
result.set(table.id, formulaName);
}
for (const table of tables) {
if (result.has(table.id)) continue;
const formulaName = allocateBaseFormulaTableNameFromSet(
table.name,
usedNames,
table.formulaName,
nextSuffixByBaseName
);
usedNames.add(formulaName.toLowerCase());
result.set(table.id, formulaName);
}
return result;
}
export function allocateBaseFormulaTableName(
displayName: string,
existingNames: Iterable<string>,
preferredName?: string
): string {
const usedNames = new Set(Array.from(existingNames, (name) => name.toLowerCase()));
return allocateBaseFormulaTableNameFromSet(displayName, usedNames, preferredName);
}
function allocateBaseFormulaTableNameFromSet(
displayName: string,
usedNames: ReadonlySet<string>,
preferredName?: string,
nextSuffixByBaseName = new Map<string, number>()
): string {
const validPreferredName = validBaseFormulaTableName(preferredName);
if (validPreferredName && !usedNames.has(validPreferredName.toLowerCase())) {
return validPreferredName;
}
const baseName = normalizeBaseFormulaTableName(displayName);
const baseNameKey = baseName.toLowerCase();
if (!usedNames.has(baseNameKey)) {
return baseName;
}
let suffixNumber = nextSuffixByBaseName.get(baseNameKey) ?? 2;
while (true) {
const suffix = `_${suffixNumber++}`;
const formulaName = `${baseName.slice(0, 255 - suffix.length)}${suffix}`;
if (!usedNames.has(formulaName.toLowerCase())) {
nextSuffixByBaseName.set(baseNameKey, suffixNumber);
return formulaName;
}
}
}
export function getBaseFormulaTableName(
table: BaseFormulaTable,
snapshot: IBaseFormulaSnapshot
): string {
return createBaseFormulaTableNameMap(snapshot).get(table.id) ?? normalizeBaseFormulaTableName(table.name);
}
export function normalizeBaseFormulaTableReferences(formula: string, snapshot: IBaseFormulaSnapshot): string {
return createBaseFormulaTableReferenceNormalizer(snapshot)(formula);
}
export function createBaseFormulaTableReferenceNormalizer(
snapshot: IBaseFormulaSnapshot,
formulaNames = createBaseFormulaTableNameMap(snapshot)
): (formula: string) => string {
const compiledAliases = compileBaseFormulaTableAliases(createBaseFormulaTableAliases(snapshot, formulaNames));
return (formula) => rewriteFormulaTableAliases(formula, compiledAliases);
}
export function migrateBaseFormulaTableNames(snapshot: IBaseSnapshot): void {
const formulaNames = createBaseFormulaTableNameMap(snapshot);
const normalizeReferences = createBaseFormulaTableReferenceNormalizer(snapshot, formulaNames);
for (const table of Object.values(snapshot.tables)) {
table.formulaName = formulaNames.get(table.id) ?? normalizeBaseFormulaTableName(table.name);
}
for (const table of Object.values(snapshot.tables)) {
for (const field of Object.values(table.fields)) {
if (field.type !== 'formula' || typeof field.config?.formula !== 'string') continue;
field.config.formula = normalizeReferences(field.config.formula);
}
for (const row of Object.values(table.cellData ?? {})) {
for (const cell of Object.values(row ?? {})) {
if (cell && typeof cell.f === 'string') {
cell.f = normalizeReferences(cell.f);
}
}
}
}
}
function createBaseFormulaTableAliases(
snapshot: IBaseFormulaSnapshot,
formulaNames = createBaseFormulaTableNameMap(snapshot)
): Array<{ alias: string; formulaName: string }> {
const legacyFormulaNames = createLegacyBaseFormulaTableNameMap(snapshot);
const formulaNameOwners = new Map(
Array.from(formulaNames, ([tableId, formulaName]) => [formulaName.toLowerCase(), tableId])
);
const displayNameCounts = new Map<string, number>();
for (const table of Object.values(snapshot.tables)) {
const key = table.name.toLowerCase();
displayNameCounts.set(key, (displayNameCounts.get(key) ?? 0) + 1);
}
return Object.values(snapshot.tables)
.flatMap((table) => {
const formulaName = formulaNames.get(table.id) ?? normalizeBaseFormulaTableName(table.name);
const inputAliases = [
table.id,
createLegacyBaseFormulaTableName(table.id),
legacyFormulaNames.get(table.id),
table.formulaName,
];
if (displayNameCounts.get(table.name.toLowerCase()) === 1) {
inputAliases.push(table.name);
}
return inputAliases
.filter((alias): alias is string => Boolean(alias))
.flatMap((alias) => [alias, quoteBaseFormulaTableAlias(alias)])
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => alias.toLowerCase() !== formulaName.toLowerCase())
.filter((alias) => {
const owner = formulaNameOwners.get(unquoteBaseFormulaTableAlias(alias).toLowerCase());
return owner == null || owner === table.id;
})
.map((alias) => ({ alias, formulaName }));
})
.sort((left, right) => right.alias.length - left.alias.length);
}
function createLegacyBaseFormulaTableNameMap(snapshot: IBaseFormulaSnapshot): ReadonlyMap<string, string> {
const result = new Map<string, string>();
const usedNames = new Set<string>();
const nextSuffixByBaseName = new Map<string, number>();
const tables = Object.values(snapshot.tables).sort((left, right) => left.id.localeCompare(right.id));
for (const table of tables) {
const formulaName = allocateBaseFormulaTableNameFromSet(
table.name,
usedNames,
undefined,
nextSuffixByBaseName
);
usedNames.add(formulaName.toLowerCase());
result.set(table.id, formulaName);
}
return result;
}
function validBaseFormulaTableName(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed && normalizeBaseFormulaTableName(trimmed) === trimmed ? trimmed : undefined;
}
function quoteBaseFormulaTableAlias(alias: string): string | undefined {
return /[^A-Za-z0-9_.]/.test(alias) ? `'${alias.replaceAll("'", "''")}'` : undefined;
}
function unquoteBaseFormulaTableAlias(alias: string): string {
return alias.startsWith("'") && alias.endsWith("'")
? alias.slice(1, -1).replaceAll("''", "'")
: alias;
}
function compileBaseFormulaTableAliases(
aliases: ReadonlyArray<{ alias: string; formulaName: string }>
): ICompiledBaseFormulaTableAliases {
const formulaNameByAlias = new Map<string, string>();
for (const { alias, formulaName } of aliases) {
const key = alias.toLowerCase();
if (!formulaNameByAlias.has(key)) {
formulaNameByAlias.set(key, formulaName);
}
}
const orderedAliases = Array.from(formulaNameByAlias.keys()).sort((left, right) => right.length - left.length);
return {
formulaNameByAlias,
pattern: orderedAliases.length ? new RegExp(`(${orderedAliases.map(escapeRegExp).join('|')})(\\s*)\\[`, 'gi') : undefined,
};
}
function createLegacyBaseFormulaTableName(tableId: string): string {
const encoded = Array.from(tableId, (character) => /[A-Za-z0-9]/.test(character) ? character : `_x${character.codePointAt(0)?.toString(16) ?? '0'}_`).join('');
return `_T_${encoded}`;
}
function rewriteFormulaTableAliases(
formula: string,
aliases: ICompiledBaseFormulaTableAliases
): string {
if (!aliases.pattern) return formula;
let scannedUntil = 0;
let inString = false;
aliases.pattern.lastIndex = 0;
return formula.replace(
aliases.pattern,
(match: string, alias: string, whitespace: string, offset: number) => {
inString = scanFormulaStringState(formula, scannedUntil, offset, inString);
scannedUntil = offset;
const previous = formula[offset - 1];
const hasIdentifierPrefix = previous != null && /[A-Za-z0-9_.]/.test(previous);
const isExternalReference = previous === '!';
if (hasIdentifierPrefix || isExternalReference || inString) {
return match;
}
const formulaName = aliases.formulaNameByAlias.get(alias.toLowerCase());
return formulaName ? `${formulaName}${whitespace}[` : match;
}
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function scanFormulaStringState(formula: string, start: number, end: number, initialState: boolean): boolean {
let inString = initialState;
for (let index = start; index < end; index++) {
if (formula[index] !== '"') continue;
if (inString && formula[index + 1] === '"') {
index++;
continue;
}
inString = !inString;
}
return inString;
}
+9
View File
@@ -20,6 +20,15 @@ export {
getEmptySnapshot as getBasesEmptySnapshot,
type ICreateDefaultBaseTableSnapshotOptions,
} from './empty-snapshot';
export {
allocateBaseFormulaTableName,
createBaseFormulaTableNameMap,
createBaseFormulaTableReferenceNormalizer,
getBaseFormulaTableName,
migrateBaseFormulaTableNames,
normalizeBaseFormulaTableName,
normalizeBaseFormulaTableReferences,
} from './formula-table-name';
export {
assertBaseTableRecordIdentity,
BASE_RECORD_ID_FIELD_ID,
+2
View File
@@ -316,6 +316,8 @@ export interface IBaseSnapshot {
export interface ITableSnapshot {
id: TableId;
name: string;
/** Stable canonical name used by formulas and exported structured references. */
formulaName?: string;
fields: Record<FieldId, IFieldSnapshot>;
fieldOrder: FieldId[];
records: Record<RecordId, IRecordSnapshot>;
@@ -165,7 +165,7 @@ const TEST_BASE_DATA: Partial<IBaseSnapshot> = {
name: 'Total',
type: BaseFieldType.Formula,
config: {
formula: '=SUM({Amount}, [Qty], tableOther[External])',
formula: '=SUM({Amount}, [Qty], _T_tableOther[External])',
},
},
},
@@ -1090,7 +1090,7 @@ describe('Test formula data model', () => {
expect(formulaData['base-test']?.['table-main']).toEqual({
0: {
7: {
f: '=SUM(_T_table_x2d_main[[#This Row],[Amount]], _T_table_x2d_main[[#This Row],[Qty]], _T_tableOther[[#Data],[External]])',
f: '=SUM(Sales[[#This Row],[Amount]], Sales[[#This Row],[Qty]], Sales_2[[#Data],[External]])',
si: 'total',
},
},
@@ -1103,9 +1103,9 @@ describe('Test formula data model', () => {
name: 'Base',
unitType: UniverInstanceType.UNIVER_BASE,
});
expect(calculateData.unitSheetNameMap['base-test']?.Sales).toBe('tableOther');
expect(calculateData.unitSheetNameMap['base-test']?._T_table_x2d_main).toBe('table-main');
expect(calculateData.unitSheetNameMap['base-test']?._T_tableOther).toBe('tableOther');
expect(calculateData.unitSheetNameMap['base-test']?.Sales).toBe('table-main');
expect(calculateData.unitSheetNameMap['base-test']?.Sales_2).toBe('tableOther');
expect(calculateData.unitSheetNameMap['base-test']?._T_tableOther).toBeUndefined();
expect(tableData?.rowCount).toBe(1);
expect(tableData?.columnCount).toBe(8);
expect(tableData?.cellData.getValue(0, 0)).toEqual({ v: 'record-1', t: CellValueType.STRING });
@@ -1122,7 +1122,9 @@ describe('Test formula data model', () => {
const snapshot = structuredClone(TEST_BASE_DATA);
snapshot.id = 'base-structured-scope';
snapshot.tables!['table-main'].name = 'Orders';
snapshot.tables!['table-main'].formulaName = 'Orders';
snapshot.tables!.tableOther.name = 'Pricing';
snapshot.tables!.tableOther.formulaName = 'Pricing';
snapshot.tables!['table-main'].fields.total.config = {
formula: '=Orders[@[Amount]]+[@[Qty]]+SUM(Pricing[External])',
};
@@ -1155,6 +1157,7 @@ describe('Test formula data model', () => {
const snapshot = structuredClone(TEST_BASE_DATA);
snapshot.id = 'base-real-table-name';
snapshot.tables!['table-main'].name = 'table';
snapshot.tables!['table-main'].formulaName = 'table';
snapshot.tables!['table-main'].fields.total.config = {
formula: '=SUM(table[Amount])+table[[#This Row],[Qty]]',
};
@@ -32,7 +32,7 @@ import type {
IUnitStylesData,
} from '../basics/common';
import type { IImageFormulaInfo } from '../engine/value-object/primitive-object';
import { BooleanNumber, CellValueType, Disposable, Inject, isFormulaId, isFormulaString, IUniverInstanceService, ObjectMatrix, RANGE_TYPE, Styles, UniverInstanceType } from '@univerjs/core';
import { BooleanNumber, CellValueType, createBaseFormulaTableNameMap, Disposable, getBaseFormulaTableName, Inject, isFormulaId, isFormulaString, IUniverInstanceService, ObjectMatrix, RANGE_TYPE, Styles, UniverInstanceType } from '@univerjs/core';
import { LexerTreeBuilder } from '../engine/analysis/lexer-tree-builder';
import { deserializeRangeWithSheet } from '../engine/utils/reference';
import { clearArrayFormulaCellDataByCell, updateFormulaDataByCellValue } from './utils/formula-data-util';
@@ -191,9 +191,11 @@ export class FormulaDataModel extends Disposable {
formulaData[unitId] = {};
const tables = Object.values(snapshot.tables);
const formulaTableNames = createBaseFormulaTableNameMap(snapshot);
for (let j = 0; j < tables.length; j++) {
const table = tables[j];
const tableFormulaData: Record<number, Record<number, IFormulaDataItem>> = {};
const normalizedFormulaByFieldId = new Map<string, string>();
const recordOrder = table.recordOrder;
if (!recordOrder) {
formulaData[unitId]![table.id] = tableFormulaData;
@@ -215,9 +217,14 @@ export class FormulaDataModel extends Disposable {
if (!formula) {
continue;
}
let normalizedFormula = normalizedFormulaByFieldId.get(field.id);
if (normalizedFormula == null) {
normalizedFormula = normalizeBaseFormulaForEngine(formula, table, snapshot, formulaTableNames);
normalizedFormulaByFieldId.set(field.id, normalizedFormula);
}
tableFormulaData[row] ??= {};
tableFormulaData[row][col] = {
f: normalizeBaseFormulaForEngine(formula, table, snapshot),
f: normalizedFormula,
si: field.id,
};
}
@@ -407,6 +414,7 @@ export class FormulaDataModel extends Disposable {
};
const baseData: ISheetData = {};
const tableNameMap: { [tableName: string]: string } = {};
const formulaTableNames = createBaseFormulaTableNameMap(snapshot);
for (const table of Object.values(snapshot.tables)) {
baseData[table.id] = {
@@ -416,7 +424,7 @@ export class FormulaDataModel extends Disposable {
rowData: {},
columnData: {},
};
addEngineBaseTableNameMappings(tableNameMap, table, snapshot);
addEngineBaseTableNameMappings(tableNameMap, table, formulaTableNames);
}
allUnitData[unitId] = baseData;
@@ -951,40 +959,45 @@ const BASE_BRACKET_FIELD_REF_PATTERN = /(^|[^\w\[])\[@?([^\[\]#]+)\](?!\])/g;
const BASE_EXTERNAL_A1_REF_PATTERN = /(?:'\[[^\]]+\](?:[^']|'')+'|\[[^\]]+\][^\s'!]+)!\$?[A-Z]{1,3}\$?\d+(?::\$?[A-Z]{1,3}\$?\d+)?/gi;
const BASE_EXTERNAL_STRUCTURED_REFERENCE_PREFIX = /(?:'((?:[^']|'')+)'|\[[^\]]+\]|[A-Za-z0-9_.-]+)![^\s!\[\]]+\[/g;
function normalizeBaseFormulaForEngine(formula: string, currentTable: ITableSnapshot, snapshot: IBaseSnapshot): string {
function normalizeBaseFormulaForEngine(
formula: string,
currentTable: ITableSnapshot,
snapshot: IBaseSnapshot,
formulaTableNames: ReadonlyMap<string, string>
): string {
const refs: string[] = [];
const hold = (ref: string) => {
const index = refs.push(ref) - 1;
return `__BASE_FORMULA_REF_${index}__`;
};
const normalized = protectBaseFormulaParts(formula, hold)
.replace(BASE_LEGACY_FIELD_REF_PATTERN, (_match, fieldName: string) => hold(createEngineThisRowRef(currentTable, fieldName, snapshot)))
.replace(BASE_LEGACY_FIELD_REF_PATTERN, (_match, fieldName: string) => hold(createEngineThisRowRef(currentTable, fieldName, formulaTableNames)))
.replace(BASE_TABLE_SCOPED_FIELD_REF_PATTERN, (_match, sourceTableName: string, scope: string, fieldName: string) => {
const targetTable = resolveBaseFormulaTable(sourceTableName, currentTable, snapshot);
const targetTable = resolveBaseFormulaTable(sourceTableName, currentTable, snapshot, formulaTableNames);
if (!targetTable) return `${sourceTableName}[[#${scope}],[${fieldName}]]`;
return hold(scope.toLowerCase() === 'data'
? createEngineColumnRef(targetTable, fieldName, snapshot)
: createEngineThisRowRef(targetTable, fieldName, snapshot));
? createEngineColumnRef(targetTable, fieldName, formulaTableNames)
: createEngineThisRowRef(targetTable, fieldName, formulaTableNames));
})
.replace(BASE_TABLE_CURRENT_ROW_FIELD_REF_PATTERN, (_match, sourceTableName: string, fieldName: string) => {
const targetTable = resolveBaseFormulaTable(sourceTableName, currentTable, snapshot);
const targetTable = resolveBaseFormulaTable(sourceTableName, currentTable, snapshot, formulaTableNames);
return targetTable
? hold(createEngineThisRowRef(targetTable, fieldName, snapshot))
? hold(createEngineThisRowRef(targetTable, fieldName, formulaTableNames))
: `${sourceTableName}[@[${fieldName}]]`;
})
.replace(BASE_SCOPED_FIELD_REF_PATTERN, (_match, prefix: string, scope: string, fieldName: string) => `${prefix}${hold(
scope.toLowerCase() === 'data'
? createEngineColumnRef(currentTable, fieldName, snapshot)
: createEngineThisRowRef(currentTable, fieldName, snapshot)
? createEngineColumnRef(currentTable, fieldName, formulaTableNames)
: createEngineThisRowRef(currentTable, fieldName, formulaTableNames)
)}`)
.replace(BASE_CURRENT_ROW_FIELD_REF_PATTERN, (_match, prefix: string, fieldName: string) =>
`${prefix}${hold(createEngineThisRowRef(currentTable, fieldName, snapshot))}`)
`${prefix}${hold(createEngineThisRowRef(currentTable, fieldName, formulaTableNames))}`)
.replace(BASE_TABLE_FIELD_REF_PATTERN, (_match, sourceTableName: string, fieldName: string) => {
const targetTable = resolveBaseFormulaTable(sourceTableName, currentTable, snapshot);
if (targetTable) return hold(createEngineColumnRef(targetTable, fieldName, snapshot));
const targetTable = resolveBaseFormulaTable(sourceTableName, currentTable, snapshot, formulaTableNames);
if (targetTable) return hold(createEngineColumnRef(targetTable, fieldName, formulaTableNames));
return `${sourceTableName}[${fieldName}]`;
})
.replace(BASE_BRACKET_FIELD_REF_PATTERN, (_match, prefix: string, fieldName: string) => `${prefix}${hold(createEngineThisRowRef(currentTable, fieldName, snapshot))}`);
.replace(BASE_BRACKET_FIELD_REF_PATTERN, (_match, prefix: string, fieldName: string) => `${prefix}${hold(createEngineThisRowRef(currentTable, fieldName, formulaTableNames))}`);
return normalized.replace(/__BASE_FORMULA_REF_(\d+)__/g, (_match, index: string) => refs[Number(index)] ?? '');
}
@@ -1050,47 +1063,20 @@ function isInsideBaseFormulaString(formula: string, position: number): boolean {
return inString;
}
function createEngineThisRowRef(table: ITableSnapshot, fieldName: string, snapshot: IBaseSnapshot): string {
return `${getEngineBaseTableName(table, snapshot)}[[#This Row],[${fieldName}]]`;
function createEngineThisRowRef(table: ITableSnapshot, fieldName: string, formulaTableNames: ReadonlyMap<string, string>): string {
return `${formulaTableNames.get(table.id) ?? getBaseFormulaTableName(table, { tables: { [table.id]: table } })}[[#This Row],[${fieldName}]]`;
}
function createEngineColumnRef(table: ITableSnapshot, fieldName: string, snapshot: IBaseSnapshot): string {
return `${getEngineBaseTableName(table, snapshot)}[[#Data],[${fieldName}]]`;
}
function getEngineBaseTableName(table: ITableSnapshot, snapshot: IBaseSnapshot): string {
const tables = Object.values(snapshot.tables);
let sameNameCount = 0;
for (let i = 0; i < tables.length; i++) {
const item = tables[i];
if (item.name.trim().toLowerCase() === table.name.trim().toLowerCase()) {
sameNameCount++;
if (sameNameCount > 1) {
break;
}
}
}
return sameNameCount === 1 && isExcelTableName(table.name)
? table.name
: createStableExcelTableName(table.id);
function createEngineColumnRef(table: ITableSnapshot, fieldName: string, formulaTableNames: ReadonlyMap<string, string>): string {
return `${formulaTableNames.get(table.id) ?? getBaseFormulaTableName(table, { tables: { [table.id]: table } })}[[#Data],[${fieldName}]]`;
}
function addEngineBaseTableNameMappings(
tableNameMap: Record<string, string>,
table: ITableSnapshot,
snapshot: IBaseSnapshot
formulaTableNames: ReadonlyMap<string, string>
): void {
tableNameMap[table.name] = table.id;
tableNameMap[getEngineBaseTableName(table, snapshot)] = table.id;
}
function isExcelTableName(name: string): boolean {
if (name.length === 0 || name.length > 255 || !/^[A-Za-z_][A-Za-z0-9_.]*$/.test(name)) {
return false;
}
return !/^[RC]$/i.test(name)
&& !/^[A-Za-z]{1,3}[1-9]\d*$/.test(name)
&& !/^R(?:\d+)?C(?:\d+)?$/i.test(name);
tableNameMap[formulaTableNames.get(table.id) ?? table.name] = table.id;
}
function createStableExcelTableName(tableId: string): string {
@@ -1204,16 +1190,21 @@ function isBaseCellData(value: unknown): value is IBaseCellData {
);
}
function resolveBaseFormulaTable(tableName: string | undefined, currentTable: ITableSnapshot, snapshot: IBaseSnapshot): ITableSnapshot | undefined {
if (!tableName || tableName === currentTable.id || tableName === currentTable.name) {
function resolveBaseFormulaTable(
tableName: string | undefined,
currentTable: ITableSnapshot,
snapshot: IBaseSnapshot,
formulaTableNames: ReadonlyMap<string, string>
): ITableSnapshot | undefined {
if (!tableName) {
return currentTable;
}
const byId = snapshot.tables[tableName];
if (byId) {
return byId;
}
const matches = Object.values(snapshot.tables).filter((table) => table.name === tableName);
const normalizedName = tableName.toLowerCase();
const matches = Object.values(snapshot.tables).filter((table) =>
table.id.toLowerCase() === normalizedName
|| table.name.toLowerCase() === normalizedName
|| formulaTableNames.get(table.id)?.toLowerCase() === normalizedName
|| createStableExcelTableName(table.id).toLowerCase() === normalizedName
);
return matches.length === 1 ? matches[0] : undefined;
}