diff --git a/packages/s2-core/__tests__/unit/data-set/cell-data.nested.spec.ts b/packages/s2-core/__tests__/unit/data-set/cell-data.nested.spec.ts new file mode 100644 index 0000000000..2dcd235106 --- /dev/null +++ b/packages/s2-core/__tests__/unit/data-set/cell-data.nested.spec.ts @@ -0,0 +1,20 @@ +import { CellData } from '@/data-set/cell-data'; + +describe('CellData nested value access', () => { + const row = { + name: '中国', + user: { region: '华东', city: '上海' }, + metrics: { price: 100, city: { address: '测试名称' } }, + } as Record; + + test('VALUE_FIELD from nested extraField', () => { + const cell = CellData.getCellData(row as any, 'metrics.city.address'); + + expect((cell as any).$$value$$ ?? (cell as any).value).toBeUndefined(); + expect((cell as any).raw).toBeDefined(); + expect((cell as any).value).toBeUndefined(); + expect((cell as any).getValueByField('metrics.city.address')).toBe( + '测试名称', + ); + }); +}); diff --git a/packages/s2-core/__tests__/unit/utils/accessor.spec.ts b/packages/s2-core/__tests__/unit/utils/accessor.spec.ts new file mode 100644 index 0000000000..217c52725a --- /dev/null +++ b/packages/s2-core/__tests__/unit/utils/accessor.spec.ts @@ -0,0 +1,26 @@ +import { getByPath, hasByPath } from '../../../src/utils/accessor'; + +describe('utils/accessor nested path', () => { + const row = { + name: '中国', + user: { region: '华东', city: '上海' }, + metrics: { price: 100, city: { address: '测试名称' } }, + } as Record; + + test('getByPath supports flat key', () => { + expect(getByPath(row, 'name')).toBe('中国'); + }); + + test('getByPath supports level-2 path', () => { + expect(getByPath(row, 'user.region')).toBe('华东'); + }); + + test('getByPath supports level-3 path', () => { + expect(getByPath(row, 'metrics.city.address')).toBe('测试名称'); + }); + + test('hasByPath works for existing and missing paths', () => { + expect(hasByPath(row, 'metrics.price')).toBe(true); + expect(hasByPath(row, 'metrics.missing')).toBe(false); + }); +}); diff --git a/packages/s2-core/__tests__/unit/utils/pivot-data-set.nested.spec.ts b/packages/s2-core/__tests__/unit/utils/pivot-data-set.nested.spec.ts new file mode 100644 index 0000000000..4f538b2355 --- /dev/null +++ b/packages/s2-core/__tests__/unit/utils/pivot-data-set.nested.spec.ts @@ -0,0 +1,28 @@ +import { + getExistValues, + transformDimensionsValues, +} from '../../../src/utils/dataset/pivot-data-set'; + +describe('pivot dataset nested paths', () => { + const row = { + name: '中国', + user: { region: '华东', city: '上海' }, + metrics: { price: 100, city: { address: '测试名称' } }, + } as Record; + + test('transformDimensionsValues supports nested paths', () => { + const dims = ['user.region', 'user.city', 'metrics.city.address']; + const values = transformDimensionsValues(row, dims); + + expect(values).toEqual(['华东', '上海', '测试名称']); + }); + + test('getExistValues supports nested values', () => { + const values = ['name', 'metrics.price', 'metrics.missing']; + + expect(getExistValues(row as any, values)).toEqual([ + 'name', + 'metrics.price', + ]); + }); +}); diff --git a/packages/s2-core/src/data-set/base-data-set.ts b/packages/s2-core/src/data-set/base-data-set.ts index 4dc1e0a7fd..f75eec8536 100644 --- a/packages/s2-core/src/data-set/base-data-set.ts +++ b/packages/s2-core/src/data-set/base-data-set.ts @@ -31,6 +31,7 @@ import type { import type { ValueRange } from '../common/interface/condition'; import type { Node } from '../facet/layout/node'; import type { SpreadSheet } from '../sheet-type'; +import { getByPath } from '../utils/accessor'; import { getValueRangeState, setValueRangeState, @@ -317,7 +318,7 @@ export abstract class BaseDataSet { const fieldValues = compact( map(this.originData, (item) => { - const value = item[field] as string; + const value = getByPath(item, field); return isNil(value) ? null : Number.parseFloat(value); }), diff --git a/packages/s2-core/src/data-set/cell-data.ts b/packages/s2-core/src/data-set/cell-data.ts index e12cdf3fbb..732cb36ba5 100644 --- a/packages/s2-core/src/data-set/cell-data.ts +++ b/packages/s2-core/src/data-set/cell-data.ts @@ -2,6 +2,7 @@ import { EXTRA_FIELD, ORIGIN_FIELD, VALUE_FIELD } from '../common/constant'; import type { ViewMetaData } from '../common/interface/basic'; import type { RawData } from '../common/interface/s2DataConfig'; +import { getByPath } from '../utils/accessor'; export class CellData { constructor( @@ -22,7 +23,7 @@ export class CellData { return field ? data.getValueByField(field) : data[ORIGIN_FIELD]; } - return data?.[field]; + return getByPath(data as unknown as Record, field); } get [ORIGIN_FIELD]() { @@ -34,7 +35,16 @@ export class CellData { } get [VALUE_FIELD]() { - return this.raw[this.extraField]; + // 为保持向后兼容:当 extraField 为嵌套路径(如 a.b.c 或 a.b[0].c)时,不将其暴露为 $$value$$ + // 仅当 extraField 为顶层字段时,才通过 $$value$$ 快捷访问 + if ( + this.extraField && + (this.extraField.includes('.') || this.extraField.includes('[')) + ) { + return undefined; + } + + return getByPath(this.raw, this.extraField); } getValueByField(field: string) { @@ -42,6 +52,6 @@ export class CellData { return this[field]; } - return this.raw[field]; + return getByPath(this.raw, field); } } diff --git a/packages/s2-core/src/data-set/table-data-set.ts b/packages/s2-core/src/data-set/table-data-set.ts index 5c9aa43466..76c3bac94d 100644 --- a/packages/s2-core/src/data-set/table-data-set.ts +++ b/packages/s2-core/src/data-set/table-data-set.ts @@ -7,6 +7,7 @@ import type { SimpleData, } from '../common/interface'; import { getEmptyPlaceholder } from '../utils'; +import { getByPath } from '../utils/accessor'; import { isAscSort, isDescSort } from '../utils/sort-action'; import { BaseDataSet } from './base-data-set'; import type { GetCellDataParams, GetCellMultiDataParams } from './interface'; @@ -67,7 +68,7 @@ export class TableDataSet extends BaseDataSet { each(this.filterParams, ({ filterKey, filteredValues, customFilter }) => { const filteredValuesSet = new Set(filteredValues); const defaultFilterFunc = (row: RawData) => - !filteredValuesSet.has(row[filterKey]); + !filteredValuesSet.has(getByPath(row, filterKey) as any); const filteredData = filter(this.displayData, (row) => { if (customFilter) { @@ -105,7 +106,7 @@ export class TableDataSet extends BaseDataSet { for (let index = 0; index < keys.length; index++) { const k = keys[index]; - if (record[k] !== query[k]) { + if (getByPath(record, k) !== query[k]) { inScope = false; restData.push(record); break; @@ -130,8 +131,12 @@ export class TableDataSet extends BaseDataSet { const reversedSortBy = [...sortBy].reverse(); sortedData = data.sort((a, b) => { - const idxA = reversedSortBy.indexOf(a[sortFieldId] as string); - const idxB = reversedSortBy.indexOf(b[sortFieldId] as string); + const idxA = reversedSortBy.indexOf( + getByPath(a, sortFieldId) as string, + ); + const idxB = reversedSortBy.indexOf( + getByPath(b, sortFieldId) as string, + ); return idxB - idxA; }); @@ -143,11 +148,11 @@ export class TableDataSet extends BaseDataSet { const customSortBy = isFunction(sortBy) ? sortBy : null; const customSort = (record: RawData) => { // 空值占位符按最小值处理 https://github.com/antvis/S2/issues/2707 - if (record[sortFieldId] === placeholder) { + if (getByPath(record, sortFieldId) === placeholder) { return Number.MIN_VALUE; } - return record[sortFieldId]; + return getByPath(record, sortFieldId) as any; }; sortedData = orderBy( @@ -183,7 +188,7 @@ export class TableDataSet extends BaseDataSet { return rowData as Data; } - return rowData[query['field']] as SimpleData; + return getByPath(rowData, query['field']) as SimpleData; } public getCellMultiData( @@ -201,7 +206,7 @@ export class TableDataSet extends BaseDataSet { return rowData as Data[]; } - return rowData.map((item) => item[query['field']]) as Data[]; + return rowData.map((item) => getByPath(item, query['field'])) as Data[]; } public getRowData(cell: CellMeta) { diff --git a/packages/s2-core/src/utils/accessor.ts b/packages/s2-core/src/utils/accessor.ts new file mode 100644 index 0000000000..7b4094f090 --- /dev/null +++ b/packages/s2-core/src/utils/accessor.ts @@ -0,0 +1,29 @@ +import { get, has } from 'lodash'; + +export function getByPath( + record: Record, + field: string, +): T { + if (!record || !field) { + return undefined as any; + } + + // fast path for flat keys + if (field.indexOf('.') === -1 && field.indexOf('[') === -1) { + return record[field] as any; + } + + return get(record, field) as any; +} + +export function hasByPath(record: Record, field: string): boolean { + if (!record || !field) { + return false; + } + + if (field.indexOf('.') === -1 && field.indexOf('[') === -1) { + return field in record; + } + + return has(record, field); +} diff --git a/packages/s2-core/src/utils/dataset/pivot-data-set.ts b/packages/s2-core/src/utils/dataset/pivot-data-set.ts index 022eb8c7aa..48252f2bfc 100644 --- a/packages/s2-core/src/utils/dataset/pivot-data-set.ts +++ b/packages/s2-core/src/utils/dataset/pivot-data-set.ts @@ -7,7 +7,6 @@ import { intersection, isArray, isEmpty, - isNull, isString, last, set, @@ -36,6 +35,7 @@ import type { TotalStatus, } from '../../data-set/interface'; import type { Node } from '../../facet/layout/node'; +import { getByPath, hasByPath } from '../accessor'; import { generateNillString } from '../layout/generate-id'; export function filterExtraDimension(dimensions: CustomHeaderFields = []) { @@ -68,9 +68,10 @@ export function transformDimensionsValues( placeholder = TOTAL_VALUE, ): string[] { return dimensions.reduce((res: string[], dimension: string) => { - const value = record[dimension]; + const exists = hasByPath(record, dimension); + const value = exists ? (getByPath(record, dimension) as string) : undefined; - if (!(dimension in record)) { + if (!exists) { res.push(placeholder); } else { res.push(generateNillString(value as string)); @@ -81,7 +82,7 @@ export function transformDimensionsValues( } export function getExistValues(data: RawData, values: string[]) { - const result = values.filter((v) => v in data); + const result = values.filter((v) => hasByPath(data, v)); if (isEmpty(result)) { result.push(EMPTY_EXTRA_FIELD_PLACEHOLDER); @@ -99,9 +100,10 @@ function transformDimensionsValuesWithExtraFields( function transform(data: RawData, fields: string[], valueField?: string) { return fields.reduce((res: string[], dimension: string) => { - const value = data[dimension]; + const exists = hasByPath(data, dimension); + const value = exists ? (getByPath(data, dimension) as string) : undefined; - if (!(dimension in data)) { + if (!exists) { if (dimension === EXTRA_FIELD && valueField) { res.push(valueField); } else { @@ -406,7 +408,7 @@ export function deleteMetaById(meta: PivotMeta, nodeId: string) { const deletePath = last(paths); let currentMeta = meta; - forEach(paths, (path, idx) => { + forEach(paths, (path: string, idx: number) => { const pathMeta = currentMeta.get(path); if (pathMeta) { @@ -467,14 +469,14 @@ export function getHeaderTotalStatus(row: Node, col: Node): TotalStatus { * 需要将其拓展成多个结构 => [MULTI_VALUE, 女] => [[四川,女], [北京,女], ....] => [[1,1],[2,1],[3,2]....] */ export function existDimensionTotalGroup(path: string[]) { - let multiIdx = null; + let multiIdx: number | null = null; for (let i = 0; i < path.length; i++) { const element = path[i]; if (isMultiValue(element)) { multiIdx = i; - } else if (!isNull(multiIdx) && multiIdx < i) { + } else if (multiIdx !== null && multiIdx < i) { return true; } } @@ -505,7 +507,7 @@ export function getSatisfiedPivotMetaValues(params: { let metaValueList = [rootContainer]; function flattenMetaValue(list: PivotMetaValue[], field: string) { - const allValues = flatMap(list, (metaValue) => { + const allValues = flatMap(list, (metaValue: PivotMetaValue) => { const values: PivotMetaValue[] = []; for (const v of metaValue.children.values()) { @@ -528,7 +530,8 @@ export function getSatisfiedPivotMetaValues(params: { ); allValues.sort( - (a, b) => (indexMap.get(a.id) ?? 0) - (indexMap.get(b.id) ?? 0), + (a: PivotMetaValue, b: PivotMetaValue) => + (indexMap.get(a.id) ?? 0) - (indexMap.get(b.id) ?? 0), ); return allValues; diff --git a/packages/s2-core/src/utils/export/copy/table-copy.ts b/packages/s2-core/src/utils/export/copy/table-copy.ts index 41f07d087b..9f74d718c7 100644 --- a/packages/s2-core/src/utils/export/copy/table-copy.ts +++ b/packages/s2-core/src/utils/export/copy/table-copy.ts @@ -11,6 +11,7 @@ import type { } from '../../../common/interface/export'; import type { Node } from '../../../facet/layout/node'; import type { SpreadSheet } from '../../../sheet-type'; +import { getByPath } from '../../accessor'; import { getColNodeFieldFromNode, getSelectedCols, @@ -74,7 +75,7 @@ class TableDataCellCopy extends BaseDataCellCopy { rowIndex: i, colIndex: j, }); - const value = row?.[field]; + const value = getByPath(row, field); return formatter(value); }), @@ -124,8 +125,8 @@ class TableDataCellCopy extends BaseDataCellCopy { rowIndex, colIndex: i, }); - const value = rowData[field]; - const dataItem = formatter(value); + const value = getByPath(rowData, field); + const dataItem = formatter(value as any); row.push(dataItem as string); } @@ -174,7 +175,7 @@ class TableDataCellCopy extends BaseDataCellCopy { )!; const value = this.isSeriesNumberField(field) ? meta.rowIndex + 1 - : this.displayData[meta.rowIndex]?.[field]; + : getByPath(this.displayData[meta.rowIndex], field); const formatter = this.getFormatter({ field,