feat(sheets-data-validation): add DataValidationListCacheService for caching list validation results (#6219)

Co-authored-by: wpxp123456 <2677556700@qq.com>
This commit is contained in:
WEI ZHANG
2025-12-01 11:22:43 +08:00
committed by GitHub
co-authored by wpxp123456
parent 500fc83d04
commit 2bc292903d
8 changed files with 278 additions and 56 deletions
@@ -182,10 +182,10 @@ export class DropdownWidget implements IBaseDataValidationWidget {
const map = this._ensureMap(subUnitId);
const key = this._generateKey(row, col);
const list = validator.getListWithColor(rule);
const colorMap = validator.getListWithColorMap(rule);
const value = getCellValueOrigin(data);
const valueStr = `${value ?? ''}`;
const activeItem = list.find((i) => i.label === valueStr);
const activeColor = colorMap[valueStr];
let { tb, vt, ht, pd } = style || {};
tb = tb ?? WrapStrategy.WRAP;
vt = vt ?? VerticalAlign.BOTTOM;
@@ -271,7 +271,7 @@ export class DropdownWidget implements IBaseDataValidationWidget {
Rect.drawWith(ctx as UniverRenderingContext, {
width: rectWidth,
height: rectHeight,
fill: activeItem?.color || DROP_DOWN_DEFAULT_COLOR,
fill: activeColor || DROP_DOWN_DEFAULT_COLOR,
radius: RADIUS_BG,
});
ctx.save();
@@ -38,7 +38,7 @@ import {
SheetSkeletonService,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { DataValidationCacheService, DataValidationCustomFormulaService, DataValidationFormulaService, SheetDataValidationModel, SheetsDataValidationValidatorService } from '@univerjs/sheets-data-validation';
import { DataValidationCacheService, DataValidationCustomFormulaService, DataValidationFormulaService, DataValidationListCacheService, SheetDataValidationModel, SheetsDataValidationValidatorService } from '@univerjs/sheets-data-validation';
import { RegisterOtherFormulaService } from '@univerjs/sheets-formula';
import enUS from '@univerjs/sheets/locale/en-US';
import zhCN from '@univerjs/sheets/locale/zh-CN';
@@ -130,6 +130,7 @@ export function createFacadeTestBed(workbookData?: IWorkbookData, dependencies?:
([
// data validation
[DataValidationCacheService],
[DataValidationListCacheService],
[DataValidationFormulaService],
[DataValidationCustomFormulaService],
[RegisterOtherFormulaService],
@@ -39,7 +39,7 @@ import {
SheetSkeletonService,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { DataValidationCacheService, DataValidationCustomFormulaService, DataValidationFormulaService, SheetDataValidationModel, SheetsDataValidationValidatorService } from '@univerjs/sheets-data-validation';
import { DataValidationCacheService, DataValidationCustomFormulaService, DataValidationFormulaService, DataValidationListCacheService, SheetDataValidationModel, SheetsDataValidationValidatorService } from '@univerjs/sheets-data-validation';
import { RegisterOtherFormulaService } from '@univerjs/sheets-formula';
import enUS from '@univerjs/sheets/locale/en-US';
import zhCN from '@univerjs/sheets/locale/zh-CN';
@@ -96,6 +96,7 @@ export interface ITestBed {
injector: Injector;
}
// eslint-disable-next-line max-lines-per-function
export function createWorksheetTestBed(workbookData?: IWorkbookData, dependencies?: Dependency[]): ITestBed {
const univer = new Univer();
const injector = univer.__getInjector();
@@ -126,6 +127,7 @@ export function createWorksheetTestBed(workbookData?: IWorkbookData, dependencie
// data validation
[DataValidationCacheService],
[DataValidationFormulaService],
[DataValidationListCacheService],
[DataValidationCustomFormulaService],
[RegisterOtherFormulaService],
[IActiveDirtyManagerService, { useClass: ActiveDirtyManagerService }],
@@ -39,6 +39,7 @@ export { UniverSheetsDataValidationPlugin } from './plugin';
export { DataValidationCacheService } from './services/dv-cache.service';
export { DataValidationCustomFormulaService } from './services/dv-custom-formula.service';
export { DataValidationFormulaService } from './services/dv-formula.service';
export { DataValidationListCacheService, type IListCacheItem } from './services/dv-list-cache.service';
export { SheetsDataValidationValidatorService } from './services/dv-validator-service';
export { createDefaultNewRule } from './utils/create';
export { getFormulaCellData, getFormulaResult } from './utils/formula';
@@ -47,6 +47,7 @@ import { SheetDataValidationModel } from './models/sheet-data-validation-model';
import { DataValidationCacheService } from './services/dv-cache.service';
import { DataValidationCustomFormulaService } from './services/dv-custom-formula.service';
import { DataValidationFormulaService } from './services/dv-formula.service';
import { DataValidationListCacheService } from './services/dv-list-cache.service';
import { SheetsDataValidationValidatorService } from './services/dv-validator-service';
@DependentOn(UniverDataValidationPlugin)
@@ -74,6 +75,7 @@ export class UniverSheetsDataValidationPlugin extends Plugin {
override onStarting() {
([
[DataValidationCacheService],
[DataValidationListCacheService],
[DataValidationFormulaService],
[DataValidationCustomFormulaService],
[SheetsDataValidationValidatorService],
@@ -21,6 +21,7 @@ import { DataValidationModel, DataValidatorRegistryService } from '@univerjs/dat
import { RegisterOtherFormulaService } from '@univerjs/sheets-formula';
import { shouldOffsetFormulaByRange } from '../utils/formula';
import { DataValidationCacheService } from './dv-cache.service';
import { DataValidationListCacheService } from './dv-list-cache.service';
type RuleId = string;
type UnitId = string;
@@ -34,7 +35,8 @@ export class DataValidationFormulaService extends Disposable {
@Inject(RegisterOtherFormulaService) private _registerOtherFormulaService: RegisterOtherFormulaService,
@Inject(DataValidationCacheService) private readonly _dataValidationCacheService: DataValidationCacheService,
@Inject(DataValidationModel) private readonly _dataValidationModel: DataValidationModel,
@Inject(DataValidatorRegistryService) private readonly _validatorRegistryService: DataValidatorRegistryService
@Inject(DataValidatorRegistryService) private readonly _validatorRegistryService: DataValidatorRegistryService,
@Inject(DataValidationListCacheService) private readonly _listCacheService: DataValidationListCacheService
) {
super();
this._initFormulaResultHandler();
@@ -52,12 +54,15 @@ export class DataValidationFormulaService extends Disposable {
const results = unitMap[subUnitId];
const formulaMap = this._ensureRuleFormulaMap(unitId, subUnitId);
results.forEach((result) => {
if (formulaMap.get(result.extra?.ruleId)) {
const rule = this._dataValidationModel.getRuleById(unitId, subUnitId, result.extra?.ruleId);
const ruleId = result.extra?.ruleId;
if (ruleId && formulaMap.get(ruleId)) {
const rule = this._dataValidationModel.getRuleById(unitId, subUnitId, ruleId);
if (rule) {
// Mark list cache dirty when formula result changes
this._listCacheService.markRuleDirty(unitId, subUnitId, ruleId);
this._dataValidationCacheService.markRangeDirty(unitId, subUnitId, rule.ranges);
}
};
}
});
}
}
@@ -0,0 +1,193 @@
/**
* 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, IDataValidationRule, Nullable } from '@univerjs/core';
import type { IOtherFormulaResult } from '@univerjs/sheets-formula';
import { Disposable, Inject, Injector, isFormulaString, numfmt } from '@univerjs/core';
import { DataValidationModel } from '@univerjs/data-validation';
import { isLegalFormulaResult } from '../utils/formula';
import { getCellValueOrigin } from '../utils/get-cell-data-origin';
import { deserializeListOptions } from '../validators/util';
import { DataValidationFormulaService } from './dv-formula.service';
export interface IListCacheItem {
list: string[];
listWithColor: Array<{ label: string; color: string }>;
colorMap: Record<string, string>;
}
type UnitId = string;
type SubUnitId = string;
type RuleId = string;
/**
* Service for caching data validation list results.
* Cache is invalidated when formula results change (through markRuleDirty).
*/
export class DataValidationListCacheService extends Disposable {
// Cache structure: unitId -> subUnitId -> ruleId -> cache item
private _cache: Map<UnitId, Map<SubUnitId, Map<RuleId, IListCacheItem>>> = new Map();
constructor(
@Inject(Injector) private readonly _injector: Injector,
@Inject(DataValidationModel) private readonly _dataValidationModel: DataValidationModel
) {
super();
this._initRuleChangeListener();
}
private _initRuleChangeListener(): void {
this.disposeWithMe(
this._dataValidationModel.ruleChange$.subscribe((change) => {
if (change.type === 'remove' || change.type === 'update') {
this.markRuleDirty(change.unitId, change.subUnitId, change.rule.uid);
}
})
);
}
/**
* Get cached list data or compute and cache it if not exists.
*/
getOrCompute(
unitId: string,
subUnitId: string,
rule: IDataValidationRule
): IListCacheItem {
// Check cache first
const cached = this.getCache(unitId, subUnitId, rule.uid);
if (cached) {
return cached;
}
const formulaService = this._injector.get(DataValidationFormulaService);
// Compute and cache
const results = formulaService.getRuleFormulaResultSync(unitId, subUnitId, rule.uid);
return this.computeAndCache(unitId, subUnitId, rule, results);
}
private _ensureCache(unitId: string, subUnitId: string): Map<RuleId, IListCacheItem> {
let unitMap = this._cache.get(unitId);
if (!unitMap) {
unitMap = new Map();
this._cache.set(unitId, unitMap);
}
let subUnitMap = unitMap.get(subUnitId);
if (!subUnitMap) {
subUnitMap = new Map();
unitMap.set(subUnitId, subUnitMap);
}
return subUnitMap;
}
/**
* Get cached list data for a rule. Returns undefined if not cached.
*/
getCache(unitId: string, subUnitId: string, ruleId: string): IListCacheItem | undefined {
return this._cache.get(unitId)?.get(subUnitId)?.get(ruleId);
}
/**
* Set cache for a rule.
*/
setCache(unitId: string, subUnitId: string, ruleId: string, item: IListCacheItem): void {
const cache = this._ensureCache(unitId, subUnitId);
cache.set(ruleId, item);
}
/**
* Mark a rule's cache as dirty (invalidate it).
* Called when formula results change.
*/
markRuleDirty(unitId: string, subUnitId: string, ruleId: string): void {
this._cache.get(unitId)?.get(subUnitId)?.delete(ruleId);
}
/**
* Clear all caches.
*/
clear(): void {
this._cache.clear();
}
/**
* Compute list data from formula result and cache it.
*/
computeAndCache(
unitId: string,
subUnitId: string,
rule: IDataValidationRule,
formulaResult: Nullable<Nullable<IOtherFormulaResult>[]>
): IListCacheItem {
const { formula1 = '', formula2 = '' } = rule;
// Compute list
const list = isFormulaString(formula1)
? this._getRuleFormulaResultSet(formulaResult?.[0]?.result?.[0]?.[0])
: deserializeListOptions(formula1);
// Compute listWithColor and colorMap
const colorList = formula2.split(',');
const listWithColor = list.map((label, i) => ({ label, color: colorList[i] || '' }));
const colorMap: Record<string, string> = {};
for (const item of listWithColor) {
if (item.color) {
colorMap[item.label] = item.color;
}
}
const cacheItem: IListCacheItem = { list, listWithColor, colorMap };
this.setCache(unitId, subUnitId, rule.uid, cacheItem);
return cacheItem;
}
/**
* Extract string list from formula result cells.
*/
private _getRuleFormulaResultSet(result: Nullable<Nullable<ICellData>[][]>): string[] {
if (!result) {
return [];
}
const resultSet = new Set<string>();
for (let i = 0, rowLen = result.length; i < rowLen; i++) {
const row = result[i];
if (!row) continue;
for (let j = 0, colLen = row.length; j < colLen; j++) {
const cell = row[j];
const value = getCellValueOrigin(cell);
if (value !== null && value !== undefined) {
// Handle number format
if (typeof value !== 'string' && typeof cell?.s === 'object' && cell.s?.n?.pattern) {
resultSet.add(numfmt.format(cell.s.n.pattern, value, { throws: false }));
continue;
}
const valueStr = typeof value === 'string' ? value : String(value);
if (isLegalFormulaResult(valueStr)) {
resultSet.add(valueStr);
}
}
}
}
return [...resultSet];
}
}
@@ -20,32 +20,37 @@ import { DataValidationRenderMode, DataValidationType, isFormulaString, IUniverI
import { BaseDataValidator } from '@univerjs/data-validation';
import { deserializeRangeWithSheet, isReferenceString, LexerTreeBuilder, sequenceNodeType } from '@univerjs/engine-formula';
import { DataValidationFormulaService } from '../services/dv-formula.service';
import { DataValidationListCacheService } from '../services/dv-list-cache.service';
import { getFormulaResult, isLegalFormulaResult } from '../utils/formula';
import { getCellValueOrigin } from '../utils/get-cell-data-origin';
import { deserializeListOptions } from './util';
export function getRuleFormulaResultSet(result: Nullable<Nullable<ICellData>[][]>) {
// Keep getRuleFormulaResultSet for backward compatibility (used in isValidType)
export function getRuleFormulaResultSet(result: Nullable<Nullable<ICellData>[][]>): string[] {
if (!result) {
return [];
}
const resultSet = new Set<string>();
result.forEach(
(row) => {
row.forEach((cell) => {
const value = getCellValueOrigin(cell);
if (value !== null && value !== undefined) {
if (typeof value !== 'string' && typeof cell?.s === 'object' && cell.s?.n?.pattern) {
resultSet.add(numfmt.format(cell.s.n.pattern, value, { throws: false }));
return;
}
if (isLegalFormulaResult(value.toString())) {
resultSet.add(value.toString());
}
const resultSet = new Set<string>();
for (let i = 0, rowLen = result.length; i < rowLen; i++) {
const row = result[i];
if (!row) continue;
for (let j = 0, colLen = row.length; j < colLen; j++) {
const cell = row[j];
const value = getCellValueOrigin(cell);
if (value !== null && value !== undefined) {
if (typeof value !== 'string' && typeof cell?.s === 'object' && cell.s?.n?.pattern) {
resultSet.add(numfmt.format(cell.s.n.pattern, value, { throws: false }));
continue;
}
});
const valueStr = typeof value === 'string' ? value : String(value);
if (isLegalFormulaResult(valueStr)) {
resultSet.add(valueStr);
}
}
}
);
}
return [...resultSet];
}
@@ -92,6 +97,8 @@ export class ListValidator extends BaseDataValidator {
protected formulaService = this.injector.get(DataValidationFormulaService);
private _lexer = this.injector.get(LexerTreeBuilder);
private _univerInstanceService = this.injector.get(IUniverInstanceService);
private _listCacheService = this.injector.get(DataValidationListCacheService);
order = 50;
override readonly offsetFormulaByRange = false;
@@ -177,52 +184,63 @@ export class ListValidator extends BaseDataValidator {
return this.localeService.t('dataValidation.list.error');
}
getList(rule: IDataValidationRule, currentUnitId?: string, currentSubUnitId?: string) {
const { formula1 = '' } = rule;
const univerInstanceService = this.injector.get(IUniverInstanceService);
const workbook = (currentUnitId ? univerInstanceService.getUniverSheetInstance(currentUnitId) : undefined) ?? univerInstanceService.getCurrentUnitForType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) return [];
private _getUnitAndSubUnit(currentUnitId?: string, currentSubUnitId?: string): { unitId: string; subUnitId: string } | null {
const workbook = (currentUnitId ? this._univerInstanceService.getUniverSheetInstance(currentUnitId) : undefined)
?? this._univerInstanceService.getCurrentUnitForType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) return null;
const worksheet = (currentSubUnitId ? workbook.getSheetBySheetId(currentSubUnitId) : undefined) ?? workbook.getActiveSheet();
if (!worksheet) return [];
if (!worksheet) return null;
const unitId = workbook.getUnitId();
const subUnitId = worksheet.getSheetId();
const results = this.formulaService.getRuleFormulaResultSync(unitId, subUnitId, rule.uid);
return isFormulaString(formula1) ? getRuleFormulaResultSet(results?.[0]?.result?.[0][0]) : deserializeListOptions(formula1);
return {
unitId: workbook.getUnitId(),
subUnitId: worksheet.getSheetId(),
};
}
getList(rule: IDataValidationRule, currentUnitId?: string, currentSubUnitId?: string): string[] {
const location = this._getUnitAndSubUnit(currentUnitId, currentSubUnitId);
if (!location) return [];
const { unitId, subUnitId } = location;
return this._listCacheService.getOrCompute(
unitId,
subUnitId,
rule
).list;
}
async getListAsync(rule: IDataValidationRule, currentUnitId?: string, currentSubUnitId?: string) {
const { formula1 = '' } = rule;
const univerInstanceService = this.injector.get(IUniverInstanceService);
const workbook = (currentUnitId ? univerInstanceService.getUniverSheetInstance(currentUnitId) : undefined) ?? univerInstanceService.getCurrentUnitForType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) return [];
const location = this._getUnitAndSubUnit(currentUnitId, currentSubUnitId);
if (!location) return [];
const worksheet = (currentSubUnitId ? workbook.getSheetBySheetId(currentSubUnitId) : undefined) ?? workbook.getActiveSheet();
if (!worksheet) return [];
const unitId = workbook.getUnitId();
const subUnitId = worksheet.getSheetId();
const { unitId, subUnitId } = location;
const results = await this.formulaService.getRuleFormulaResult(unitId, subUnitId, rule.uid);
return isFormulaString(formula1) ? getRuleFormulaResultSet(results?.[0]?.result?.[0][0]) : deserializeListOptions(formula1);
}
getListWithColor(rule: IDataValidationRule, currentUnitId?: string, currentSubUnitId?: string) {
const list = this.getList(rule, currentUnitId, currentSubUnitId);
const colorList = (rule.formula2 || '').split(',');
getListWithColor(rule: IDataValidationRule, currentUnitId?: string, currentSubUnitId?: string): Array<{ label: string; color: string }> {
const location = this._getUnitAndSubUnit(currentUnitId, currentSubUnitId);
if (!location) return [];
return list.map((label, i) => ({ label, color: colorList[i] }));
const { unitId, subUnitId } = location;
return this._listCacheService.getOrCompute(
unitId,
subUnitId,
rule
).listWithColor;
}
getListWithColorMap(rule: IDataValidationRule, currentUnitId?: string, currentSubUnitId?: string) {
const list = this.getListWithColor(rule, currentUnitId, currentSubUnitId);
const map: Record<string, string> = {};
getListWithColorMap(rule: IDataValidationRule, currentUnitId?: string, currentSubUnitId?: string): Record<string, string> {
const location = this._getUnitAndSubUnit(currentUnitId, currentSubUnitId);
if (!location) return {};
list.forEach((item) => {
if (item.color) {
map[item.label] = item.color;
}
});
return map;
const { unitId, subUnitId } = location;
return this._listCacheService.getOrCompute(
unitId,
subUnitId,
rule
).colorMap;
}
}