Chart builder: filter/calculate transforms, data preview, and inline expression validation

This commit is contained in:
2026-06-11 13:01:23 +03:00
parent 1791ee9f8d
commit 05df0cd7d3
13 changed files with 1919 additions and 27 deletions
+234
View File
@@ -16,8 +16,16 @@ import {
buildChartSpec,
buildSnippetSpecText,
generateChartName,
validFilterOps,
filterOpArity,
buildTransforms,
calculatedFieldNames,
effectiveColumns,
pruneEncodings,
type BuilderCalculate,
type BuilderColumns,
type BuilderConfig,
type BuilderFilter,
type ChannelMapping,
} from './chart-builder';
import { VEGA_LITE_SCHEMA_URL } from './snippet';
@@ -831,3 +839,229 @@ describe('generateChartName', () => {
expect(generateChartName(config)).toBe('Circle chart of Sales');
});
});
// --- Data transforms: filters + calculated fields (spec §06 → Data) -------------
const filter = (over: Partial<BuilderFilter>): BuilderFilter => ({
id: 'f1',
mode: 'predicate',
...over,
});
const calc = (over: Partial<BuilderCalculate>): BuilderCalculate => ({
id: 'c1',
expr: '',
as: '',
...over,
});
const withFilters = (...filters: BuilderFilter[]): BuilderConfig => ({
datasetName: 'D',
mark: 'bar',
encodings: {},
filters,
});
describe('validFilterOps (guarded operators per field type)', () => {
it('offers ordering + range for measures and temporal fields', () => {
const ordered = ['equal', 'notEqual', 'lt', 'lte', 'gt', 'gte', 'range'];
expect(validFilterOps('quantitative')).toEqual(ordered);
expect(validFilterOps('temporal')).toEqual(ordered);
});
it('offers only equality + membership for categories (no ordering)', () => {
expect(validFilterOps('nominal')).toEqual(['equal', 'notEqual', 'oneOf']);
expect(validFilterOps('ordinal')).toEqual(['equal', 'notEqual', 'oneOf']);
});
});
describe('filterOpArity', () => {
it('classifies single / range / list operators', () => {
expect(filterOpArity('equal')).toBe('single');
expect(filterOpArity('gte')).toBe('single');
expect(filterOpArity('range')).toBe('range');
expect(filterOpArity('oneOf')).toBe('list');
});
});
describe('buildTransforms (predicate coercion + shape)', () => {
it('coerces a quantitative predicate value to a number', () => {
const t = buildTransforms(
withFilters(filter({ field: 'value', fieldType: 'quantitative', op: 'gt', value: '10' })),
);
expect(t).toEqual([{ filter: { field: 'value', gt: 10 } }]);
});
it('keeps a categorical value a string', () => {
const t = buildTransforms(
withFilters(filter({ field: 'category', fieldType: 'nominal', op: 'equal', value: 'East' })),
);
expect(t).toEqual([{ filter: { field: 'category', equal: 'East' } }]);
});
it('expresses notEqual as a {not} wrapper (no bare inequality predicate)', () => {
const t = buildTransforms(
withFilters(
filter({ field: 'category', fieldType: 'nominal', op: 'notEqual', value: 'East' }),
),
);
expect(t).toEqual([{ filter: { not: { field: 'category', equal: 'East' } } }]);
});
it('builds a two-bound range with coerced numbers', () => {
const t = buildTransforms(
withFilters(
filter({
field: 'value',
fieldType: 'quantitative',
op: 'range',
value: '0',
value2: '100',
}),
),
);
expect(t).toEqual([{ filter: { field: 'value', range: [0, 100] } }]);
});
it('splits and trims a oneOf membership list', () => {
const t = buildTransforms(
withFilters(
filter({
field: 'category',
fieldType: 'nominal',
op: 'oneOf',
value: 'East, West ,North',
}),
),
);
expect(t).toEqual([{ filter: { field: 'category', oneOf: ['East', 'West', 'North'] } }]);
});
it('passes an expression-mode filter through verbatim', () => {
const t = buildTransforms(withFilters(filter({ mode: 'expression', expr: 'datum.value > 0' })));
expect(t).toEqual([{ filter: 'datum.value > 0' }]);
});
it('skips incomplete entries (blank value, blank range bound, blank expression)', () => {
const t = buildTransforms(
withFilters(
filter({ field: 'value', fieldType: 'quantitative', op: 'gt', value: '' }),
filter({ id: 'f2', field: 'value', fieldType: 'quantitative', op: 'range', value: '1' }),
filter({ id: 'f3', mode: 'expression', expr: ' ' }),
),
);
expect(t).toEqual([]);
});
it('emits calculated fields before filters, in list order', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {},
calculates: [calc({ as: 'total', expr: 'datum.a + datum.b' })],
filters: [filter({ field: 'total', fieldType: 'quantitative', op: 'gt', value: '5' })],
};
expect(buildTransforms(config)).toEqual([
{ calculate: 'datum.a + datum.b', as: 'total' },
{ filter: { field: 'total', gt: 5 } },
]);
});
it('skips an unnamed or empty calculate', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {},
calculates: [calc({ as: '', expr: 'datum.a' }), calc({ id: 'c2', as: 'x', expr: '' })],
};
expect(buildTransforms(config)).toEqual([]);
});
});
describe('buildChartSpec — transform integration', () => {
it('places transform between data and mark, only when something resolves', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: { x: { field: 'category', type: 'nominal' } },
filters: [filter({ field: 'value', fieldType: 'quantitative', op: 'gte', value: '0' })],
});
expect(spec.transform).toEqual([{ filter: { field: 'value', gte: 0 } }]);
expect(Object.keys(spec)).toEqual(['$schema', 'data', 'transform', 'mark', 'encoding']);
});
it('omits the transform key when no filter/calculate resolves', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: { x: { field: 'category', type: 'nominal' } },
filters: [filter({ field: 'value', fieldType: 'quantitative', op: 'gt', value: '' })],
});
expect(spec).not.toHaveProperty('transform');
});
});
describe('calculatedFieldNames', () => {
it('returns named (as) fields in order, trimmed, dropping empties', () => {
expect(
calculatedFieldNames([
calc({ as: ' total ', expr: '1' }),
calc({ id: 'c2', as: '', expr: '2' }),
]),
).toEqual(['total']);
});
it('handles an absent list', () => {
expect(calculatedFieldNames(undefined)).toEqual([]);
});
});
describe('effectiveColumns', () => {
it('appends calculated fields as numeric columns the channels can use', () => {
const eff = effectiveColumns(columns, [calc({ as: 'ratio', expr: 'datum.value / 2' })]);
expect(eff.columns).toContain('ratio');
expect(eff.columnTypes).toContainEqual({ name: 'ratio', type: 'number' });
});
it('does not shadow a real column with a same-named calculate', () => {
const eff = effectiveColumns(columns, [calc({ as: 'value', expr: '1' })]);
expect(eff.columns.filter((c) => c === 'value')).toHaveLength(1);
});
it('returns the base columns unchanged (same ref) when there are no calculates', () => {
expect(effectiveColumns(columns, undefined)).toBe(columns);
expect(effectiveColumns(columns, [])).toBe(columns);
});
});
describe('pruneEncodings', () => {
it('clears a channel mapped to a now-missing field', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: { y: { field: 'gone', type: 'quantitative' } },
calculates: [],
};
expect(pruneEncodings(config, columns).encodings.y).toBeNull();
});
it('keeps channels on real or still-present calculated fields (same ref)', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'category', type: 'nominal' },
y: { field: 'ratio', type: 'quantitative' },
},
calculates: [calc({ as: 'ratio', expr: '1' })],
};
expect(pruneEncodings(config, columns)).toBe(config);
});
it('leaves a field-less count mapping alone', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: { y: { type: 'quantitative', aggregate: 'count' } },
};
expect(pruneEncodings(config, columns)).toBe(config);
});
});
+267 -4
View File
@@ -73,6 +73,82 @@ export type SortOrder = (typeof SORT_ORDERS)[number];
export const STACK_MODES = ['zero', 'normalize'] as const;
export type StackMode = (typeof STACK_MODES)[number];
/**
* Comparison operators a guarded filter predicate offers (Vega-Lite field
* predicates), in menu order. The set a given field admits is narrowed by
* `validFilterOps` — only a measure/temporal field offers ordering (`lt`…`gt`) and
* `range`; a category offers membership (`oneOf`). `notEqual` is expressed as a
* `{ not: { …equal } }` logical wrapper (Vega-Lite has no bare `!=` predicate).
*/
export const FILTER_OPS = [
'equal',
'notEqual',
'lt',
'lte',
'gt',
'gte',
'range',
'oneOf',
] as const;
export type FilterOp = (typeof FILTER_OPS)[number];
/** How a filter expresses its predicate: a guarded shelf, or a raw expression. */
export type FilterMode = 'predicate' | 'expression';
/**
* One top-level data filter (Vega-Lite `transform: [{ filter }]`), applied to the
* raw rows **before** encoding aggregation — so "filter rows, then aggregate" is the
* natural reading. Two modes share one shape (the UI toggles between them on a single
* row):
*
* - **predicate** — a guarded `field op value` triple (Voyager-style, no expression
* to write for the common case). `range` carries a second bound (`value2`);
* `oneOf` reads `value` as a comma-separated membership list. Values are coerced
* by `fieldType` (a quantitative field compares as a number).
* - **expression** — a raw Vega expression string (`datum.x > 0`), the power-form
* for what the guarded shelf can't say (validated in the UI via
* `expr-validate.ts`).
*
* `id` is a stable key for list editing only; it never reaches the produced spec.
* A partially-filled filter (no value yet, empty expression) is simply skipped by
* the assembler, so the live preview keeps rendering while the user types.
*/
export interface BuilderFilter {
/** Stable list key (UI-assigned); not serialized into the spec. */
id: string;
/** Which form this filter takes. */
mode: FilterMode;
/** Predicate mode: the column being filtered (a dataset or calculated field). */
field?: string;
/** Predicate mode: the field's Vega-Lite type — drives the op menu + coercion. */
fieldType?: FieldType;
/** Predicate mode: the comparison operator. */
op?: FilterOp;
/** Predicate mode: the comparison value (`range` lower bound; `oneOf` CSV list). */
value?: string;
/** Predicate mode: the upper bound, for `range` only. */
value2?: string;
/** Expression mode: a raw Vega predicate expression. */
expr?: string;
}
/**
* One derived field (Vega-Lite `transform: [{ calculate, as }]`): a row-wise Vega
* expression producing a new column the rest of the builder can encode like any
* other. Calculates are emitted **before** filters (a row-wise calculate is
* order-independent — it computes the same per-row value regardless of which rows
* survive — so running it first is equivalent and lets a filter reference the
* derived field). `id` is a UI list key only.
*/
export interface BuilderCalculate {
/** Stable list key (UI-assigned); not serialized into the spec. */
id: string;
/** The Vega expression, e.g. `datum.price * datum.quantity`. */
expr: string;
/** The new field's name (appears in the channel dropdowns once non-empty). */
as: string;
}
/**
* One channel's mapping: a dataset column `field` plus its `type`, with optional
* transforms. `field` is omitted only for a `count` aggregate (which counts records
@@ -108,6 +184,10 @@ export interface BuilderConfig {
sort?: SortOrder;
/** Stacking for bar/area + a colour series (part-to-whole). */
stack?: StackMode;
/** Derived fields (`transform: [{ calculate, as }]`), emitted before `filters`. */
calculates?: BuilderCalculate[];
/** Row filters (`transform: [{ filter }]`), applied before encoding aggregation. */
filters?: BuilderFilter[];
}
/**
@@ -665,6 +745,182 @@ export function builderWarnings(
return warnings;
}
// --- Top-level data transforms: filters + calculated fields (spec §06 → Data). ----
/**
* The comparison operators a field of this type admits (spec §06 → Data filters).
* A measure or a temporal field can be ordered and ranged (`lt`…`gte`, `range`);
* a category (nominal/ordinal) offers equality and membership (`oneOf`) only —
* ordering categories by value isn't meaningful. `equal`/`notEqual` apply to every
* type. Mirrors Vega-Lite's field-predicate grammar (Voyager's guarded filter).
*/
export function validFilterOps(fieldType: FieldType): FilterOp[] {
if (fieldType === 'quantitative' || fieldType === 'temporal') {
return ['equal', 'notEqual', 'lt', 'lte', 'gt', 'gte', 'range'];
}
return ['equal', 'notEqual', 'oneOf'];
}
/** `range` reads two bounds; `oneOf` a membership list; the rest a single value. */
export function filterOpArity(op: FilterOp): 'single' | 'range' | 'list' {
if (op === 'range') return 'range';
if (op === 'oneOf') return 'list';
return 'single';
}
/**
* Coerce a filter's text value to the type Vega-Lite compares against: a
* quantitative field compares as a number (so `> 10` orders numerically, not
* lexically); every other type compares as the raw string. ISO date strings sort
* lexically == chronologically, so temporal predicates work as strings without a
* date parse; non-ISO dates are the expression-mode case. A non-numeric string on a
* quantitative field falls back to the string (Vega-Lite then compares loosely).
*/
function coerceFilterValue(value: string, fieldType: FieldType): string | number {
if (fieldType === 'quantitative') {
const n = Number(value);
return value.trim() !== '' && Number.isFinite(n) ? n : value;
}
return value;
}
/**
* The Vega-Lite field predicate for a guarded filter, or `null` when it is still
* incomplete (no field/op, or a value the operator needs is blank) — an incomplete
* filter is skipped so the preview keeps rendering. `notEqual` wraps an `equal`
* predicate in a `{ not }` (Vega-Lite has no bare inequality predicate).
*/
function predicateObject(filter: BuilderFilter): Record<string, unknown> | null {
const { field, op } = filter;
if (!field || !op) return null;
// TODO: the data-derived column name reaches `field:` unescaped here, as it also
// does in encodingObject. A column whose name contains `.`/`[`/`]` is then read as
// a nested-property accessor rather than a literal field. core/rendering.ts exports
// escapeVegaField for exactly this but nothing currently wires it into the builder.
const type = filter.fieldType ?? 'nominal';
const value = filter.value ?? '';
const coerce = (v: string) => coerceFilterValue(v, type);
switch (op) {
case 'equal':
return value === '' ? null : { field, equal: coerce(value) };
case 'notEqual':
return value === '' ? null : { not: { field, equal: coerce(value) } };
case 'lt':
return value === '' ? null : { field, lt: coerce(value) };
case 'lte':
return value === '' ? null : { field, lte: coerce(value) };
case 'gt':
return value === '' ? null : { field, gt: coerce(value) };
case 'gte':
return value === '' ? null : { field, gte: coerce(value) };
case 'range': {
const upper = filter.value2 ?? '';
if (value === '' || upper === '') return null;
return { field, range: [coerce(value), coerce(upper)] };
}
case 'oneOf': {
const items = value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
return items.length === 0 ? null : { field, oneOf: items.map(coerce) };
}
default:
return null;
}
}
/** One filter's `{ filter }` transform entry, or `null` when incomplete. */
function filterTransformObject(filter: BuilderFilter): Record<string, unknown> | null {
if (filter.mode === 'expression') {
const expr = (filter.expr ?? '').trim();
return expr === '' ? null : { filter: expr };
}
const predicate = predicateObject(filter);
return predicate ? { filter: predicate } : null;
}
/** One calculate's `{ calculate, as }` transform entry, or `null` when incomplete. */
function calculateTransformObject(calc: BuilderCalculate): Record<string, unknown> | null {
const expr = calc.expr.trim();
const as = calc.as.trim();
return expr === '' || as === '' ? null : { calculate: expr, as };
}
/**
* The complete top-level `transform` array for a configuration: every calculated
* field first (so filters and encodings can reference the derived columns), then
* every filter, each in the user's list order. Incomplete entries (a half-typed
* filter, an unnamed calculate) are dropped so a configuration mid-edit still
* produces a renderable spec. An empty result means no `transform` key is emitted.
*/
export function buildTransforms(config: BuilderConfig): Array<Record<string, unknown>> {
const out: Array<Record<string, unknown>> = [];
for (const calc of config.calculates ?? []) {
const t = calculateTransformObject(calc);
if (t) out.push(t);
}
for (const filter of config.filters ?? []) {
const t = filterTransformObject(filter);
if (t) out.push(t);
}
return out;
}
/** The named (`as`) derived fields a config defines, in order, ignoring unnamed ones. */
export function calculatedFieldNames(
calculates: readonly BuilderCalculate[] | undefined,
): string[] {
return (calculates ?? []).map((c) => c.as.trim()).filter((as) => as !== '');
}
/**
* The dataset's columns augmented with the config's calculated fields, so the UI's
* column dropdowns and field-type logic treat a derived field like any other. A
* calculated field's inferred type is unknown, so it defaults to **number**
* (quantitative — the common arithmetic case; the user can retype it within the
* valid set on the channel). Calculated names that collide with a real column are
* skipped (the real column wins). No cardinality stats are derived for them.
*/
export function effectiveColumns(
base: BuilderColumns,
calculates: readonly BuilderCalculate[] | undefined,
): BuilderColumns {
const added = calculatedFieldNames(calculates).filter(
(name, i, all) => !base.columns.includes(name) && all.indexOf(name) === i,
);
if (added.length === 0) return base;
return {
columns: [...base.columns, ...added],
columnTypes: [
...base.columnTypes,
...added.map((name): { name: string; type: ColumnType } => ({ name, type: 'number' })),
],
columnStats: base.columnStats,
};
}
/**
* Clear any channel whose mapped column no longer exists among the effective
* columns — the cleanup after a calculated field is removed or renamed, so the
* produced spec never encodes a dangling field (which Vega-Lite would render empty).
* Returns the same config object when nothing changed (stable for React equality).
*/
export function pruneEncodings(config: BuilderConfig, base: BuilderColumns): BuilderConfig {
const available = new Set(effectiveColumns(base, config.calculates).columns);
let changed = false;
const encodings = { ...config.encodings };
for (const channel of CHANNELS) {
const mapping = encodings[channel];
if (mapping && mapping.field !== undefined && !available.has(mapping.field)) {
encodings[channel] = null;
changed = true;
}
}
return changed ? { ...config, encodings } : config;
}
/** A built Vega-Lite spec, as a plain object (serialize with `buildSnippetSpecText`). */
export type ChartSpec = Record<string, unknown>;
@@ -686,9 +942,10 @@ function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
/**
* Assemble the complete Vega-Lite spec from a builder configuration (spec §06 →
* Output). Includes the schema reference, a named data reference to the dataset,
* the mark with tooltips enabled, every mapped encoding (field, type, and any
* aggregate/bin/timeUnit transform), chart-level sort (rank a categorical axis by
* its measure) and stack (part-to-whole), and any explicit width/height. Unmapped
* any top-level `transform` (calculated fields then row filters), the mark with
* tooltips enabled, every mapped encoding (field, type, and any aggregate/bin/
* timeUnit transform), chart-level sort (rank a categorical axis by its measure)
* and stack (part-to-whole), and any explicit width/height. Unmapped
* channels are omitted; if nothing is mapped the `encoding` block is omitted
* entirely (validation prevents saving that, but the live preview may render a bare
* mark while the user is still configuring).
@@ -697,9 +954,15 @@ export function buildChartSpec(config: BuilderConfig): ChartSpec {
const spec: ChartSpec = {
$schema: VEGA_LITE_SCHEMA_URL,
data: { name: config.datasetName },
mark: { type: config.mark, tooltip: true },
};
// Top-level transforms (calculated fields, then filters) sit between data and
// mark — applied to the raw rows before encoding aggregation.
const transform = buildTransforms(config);
if (transform.length > 0) spec.transform = transform;
spec.mark = { type: config.mark, tooltip: true };
const encoding: Record<string, Record<string, unknown>> = {};
for (const [channel, mapping] of mappedChannels(config)) {
encoding[channel] = encodingObject(mapping);
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { validateExpression, referencedFields } from './expr-validate';
describe('validateExpression', () => {
it('accepts a well-formed Vega expression', () => {
expect(validateExpression('datum.price * datum.qty').valid).toBe(true);
expect(validateExpression("datum.region === 'East' && datum.value > 0").valid).toBe(true);
});
it('treats an empty / whitespace expression as valid (incomplete, not a mistake)', () => {
expect(validateExpression('').valid).toBe(true);
expect(validateExpression(' ').valid).toBe(true);
});
it('rejects a malformed expression with a message', () => {
const result = validateExpression('datum.price *');
expect(result.valid).toBe(false);
expect(result.error).toBeTruthy();
});
});
describe('referencedFields', () => {
it('collects datum.field and datum["field"] references, de-duplicated in order', () => {
expect(referencedFields("datum.price * datum['qty'] + datum.price")).toEqual(['price', 'qty']);
});
it('ignores function calls and non-datum identifiers', () => {
expect(referencedFields('toNumber(datum.amount) + PI')).toEqual(['amount']);
});
it('skips dynamic (computed, non-literal) access', () => {
expect(referencedFields('datum[someVar]')).toEqual([]);
});
it('returns nothing for an empty or unparseable expression', () => {
expect(referencedFields('')).toEqual([]);
expect(referencedFields('datum.price *')).toEqual([]);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Vega expression validation (spec §06 → Data; chart-builder enhancement 1E).
*
* Portable core: no browser APIs, no React. The Chart Builder's filter
* (expression mode) and calculated-field controls let the user write a raw Vega
* expression; this module checks it with **the same parser Vega-Lite uses at render
* time** (`vega-expression`'s `parseExpression`), so the inline "is this valid?"
* feedback agrees exactly with what the chart will accept — no second, divergent
* grammar. It also extracts the `datum.<field>` references so the UI can flag a typo
* against the dataset's actual columns before the chart silently renders empty.
*
* `vega-expression` is a pure dependency already in the bundle (Vega pulls it in for
* rendering), so importing it here adds nothing and keeps this module browser-free.
*/
import { parseExpression } from 'vega-expression';
/** The outcome of validating one expression string. */
export interface ExprValidation {
/** True when the expression parses (or is empty — an empty field isn't an error). */
valid: boolean;
/** A short parser message when invalid; absent when valid. */
error?: string;
}
/**
* Validate a Vega expression. An **empty** string is treated as valid (it is an
* incomplete entry, not a mistake — the assembler skips it), so the UI shows no
* error until the user actually types something malformed. A parse failure returns
* the parser's message, trimmed of the noisy position suffix where present.
*/
export function validateExpression(expr: string): ExprValidation {
if (expr.trim() === '') return { valid: true };
try {
parseExpression(expr);
return { valid: true };
} catch (e) {
return { valid: false, error: cleanParserMessage((e as Error).message) };
}
}
/** Tidy a vega-expression parse error for inline display (drop a trailing "(N)"). */
function cleanParserMessage(message: string): string {
return message.replace(/\s*\(\d+\)\s*$/, '').trim() || 'Invalid expression.';
}
/**
* The distinct `datum.<field>` (and `datum['field']`) column names an expression
* references, in first-seen order — best-effort, for flagging unknown fields in the
* UI. Returns an empty list for an empty or unparseable expression (validation
* surfaces the parse error separately). Only direct member access off `datum` is
* collected; dynamic access (`datum[someVar]`) is not a static field name and is
* skipped.
*/
export function referencedFields(expr: string): string[] {
if (expr.trim() === '') return [];
let ast: unknown;
try {
ast = parseExpression(expr);
} catch {
return [];
}
const fields: string[] = [];
const seen = new Set<string>();
const visit = (node: unknown): void => {
if (Array.isArray(node)) {
for (const child of node) visit(child);
return;
}
if (!node || typeof node !== 'object') return;
const rec = node as Record<string, unknown>;
if (rec.type === 'MemberExpression') {
const name = datumMemberName(rec);
if (name !== null && !seen.has(name)) {
seen.add(name);
fields.push(name);
}
}
for (const key of Object.keys(rec)) {
if (key === 'type') continue;
visit(rec[key]);
}
};
visit(ast);
return fields;
}
/**
* The static field name of a `datum.<name>` / `datum['name']` member access, or
* `null` when the node isn't such an access (a different object, computed-dynamic
* access, or a non-string key).
*/
function datumMemberName(member: Record<string, unknown>): string | null {
const object = member.object as Record<string, unknown> | undefined;
if (!object || object.type !== 'Identifier' || object.name !== 'datum') return null;
const property = member.property as Record<string, unknown> | undefined;
if (!property) return null;
if (member.computed) {
// datum['field'] — a string literal key is a static field name.
return property.type === 'Literal' && typeof property.value === 'string'
? property.value
: null;
}
// datum.field — an identifier key.
return property.type === 'Identifier' && typeof property.name === 'string' ? property.name : null;
}