mirror of
https://github.com/dream-num/univer.git
synced 2026-10-02 04:24:34 +08:00
fix(base): canonicalize formula table names (#7471)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user