Core: classify spec data blocks by Vega-Lite fidelity (spec-data) for dataset refs

This commit is contained in:
2026-06-28 19:48:24 +03:00
parent 31458114fb
commit e69430834a
9 changed files with 385 additions and 142 deletions
+20
View File
@@ -203,6 +203,26 @@ describe('prepareSpecForRender — dataset resolution (spec §04 Rendering Contr
expect(out.data).toEqual({ name: 'local' });
});
test('native Vega-Lite data with a name label is left untouched (not resolved, never throws)', () => {
// A name on inline/url data, or a generator, is native Vega-Lite — even when
// the name is absent from the library. Resolution must not touch or reject it.
const inline = { data: { name: 'pts', values: [{ a: 1 }] }, mark: 'point' };
expect(prepareSpecForRender(inline, { datasets }).data).toEqual({
name: 'pts',
values: [{ a: 1 }],
});
const url = { data: { name: 'remote', url: 'https://x/y.csv' } };
expect(prepareSpecForRender(url, { datasets }).data).toEqual({
name: 'remote',
url: 'https://x/y.csv',
});
const gen = { data: { name: 'seq', sequence: { start: 0, stop: 5 } } };
expect(prepareSpecForRender(gen, { datasets }).data).toEqual({
name: 'seq',
sequence: { start: 0, stop: 5 },
});
});
test('resolution runs before fit-mode: a ref + fit mode produce both transforms', () => {
const spec = { data: { name: 'JsonDs' }, mark: 'bar' };
const out = prepareSpecForRender(spec, { datasets, fitMode: 'full' }) as unknown as {
+26 -33
View File
@@ -10,13 +10,15 @@
* 1. Dataset reference resolution — implemented in M3 (see below).
* 2. Fit-mode sizing — implemented in M2.
*
* Step 1 (spec §04 → Rendering Contract): every named-data reference
* Step 1 (spec §04 → Rendering Contract): every library reference
* (`{ data: { name } }`) is replaced in-place with the referenced library
* dataset's actual contents, shaped by source and format. A name the spec defines
* for itself via a top-level `datasets` object is left untouched (Vega-Lite
* resolves it natively); an unknown library name throws `DatasetNotFoundError`.
* Resolution recurses into the same nested sub-specs as fit-mode, runs before
* sizing, and operates only on the copy.
* dataset's actual contents, shaped by source and format. What counts as a library
* reference is decided by `core/spec-data` (named data, not self-defined), so a
* `name` riding on inline `values`/`url`/a generator is native Vega-Lite data and
* is left untouched; a name the spec defines for itself via a top-level `datasets`
* object is left untouched too (Vega-Lite resolves it natively); an unknown library
* name throws `DatasetNotFoundError`. Resolution recurses into the same nested
* sub-specs as fit-mode, runs before sizing, and operates only on the copy.
*
* The copy-not-mutate invariant and the call site the renderer depends on are
* fixed.
@@ -24,6 +26,7 @@
import type { DataFormat } from './format-detection';
import type { DataSource } from './dataset';
import { libraryRefName, selfDefinedNames } from './spec-data';
/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
export type FitMode = 'default' | 'width' | 'height' | 'full';
@@ -113,16 +116,6 @@ function applyFitMode(node: unknown, mode: FitMode): void {
if (isSpecNode(node.spec)) applyFitMode(node.spec, mode);
}
/** The set of dataset names a spec defines for itself via top-level `datasets`. */
function selfDefinedDatasetNames(spec: unknown): Set<string> {
const names = new Set<string>();
if (isSpecNode(spec)) {
const datasets = spec.datasets;
if (isSpecNode(datasets)) for (const key of Object.keys(datasets)) names.add(key);
}
return names;
}
/**
* Build the replacement `data` object for one resolved reference (spec §04 →
* Rendering Contract, step 1). `rest` is the reference's other keys (e.g. a
@@ -157,13 +150,16 @@ function resolvedData(dataset: ResolvableDataset, rest: SpecNode): SpecNode {
}
/**
* Replace every named-data reference in `node` with its library dataset's
* contents, recursing through the spec (arrays and objects) so refs anywhere are
* resolved — matching `extractDatasetRefs`, including pruning the `data` and
* top-level `datasets` payload keys so resolution never descends into user data
* rows. A self-defined name is left untouched; an unknown library name throws
* `DatasetNotFoundError`. Matching is case-insensitive, mirroring naming.ts.
* Mutates in place; the caller already works on a copy.
* Replace every library reference in `node` with its dataset's contents, recursing
* through the spec (arrays and objects) so refs anywhere are resolved — matching
* `extractDatasetRefs`, including pruning the `data` and top-level `datasets`
* payload keys so resolution never descends into user data rows. What counts as a
* library reference is decided by the shared `libraryRefName` (core/spec-data):
* named data the spec does not define for itself. A `name` riding on inline
* `values`, a `url`, or a generator is native Vega-Lite data and is left untouched;
* an unknown library name throws `DatasetNotFoundError`. Matching is
* case-insensitive, mirroring naming.ts. Mutates in place; the caller already works
* on a copy.
*/
function resolveDatasetRefs(
node: unknown,
@@ -176,15 +172,12 @@ function resolveDatasetRefs(
}
if (!isSpecNode(node)) return;
const data = node.data;
if (isSpecNode(data) && typeof data.name === 'string') {
const name = data.name;
if (!selfDefined.has(name)) {
const dataset = byName.get(name.toLowerCase());
if (!dataset) throw new DatasetNotFoundError(name);
const { name: _drop, ...rest } = data;
node.data = resolvedData(dataset, rest);
}
const refName = libraryRefName(node.data, selfDefined);
if (refName !== null) {
const dataset = byName.get(refName.toLowerCase());
if (!dataset) throw new DatasetNotFoundError(refName);
const { name: _drop, ...rest } = node.data as SpecNode;
node.data = resolvedData(dataset, rest);
}
// Recurse into every key except the two that hold data payloads (`data` —
@@ -229,7 +222,7 @@ export function prepareSpecForRender<T>(spec: T, options: PrepareOptions = {}):
const datasets = options.datasets ?? [];
const byName = new Map<string, ResolvableDataset>();
for (const d of datasets) byName.set(d.name.toLowerCase(), d);
resolveDatasetRefs(copy, byName, selfDefinedDatasetNames(copy));
resolveDatasetRefs(copy, byName, selfDefinedNames(copy));
// 2. Fit-mode sizing.
applyFitMode(copy, options.fitMode ?? 'default');
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, test } from 'vitest';
import { classifyData, libraryRefName, selfDefinedNames } from './spec-data';
describe('classifyData', () => {
test('classifies the four Vega-Lite data shapes', () => {
expect(classifyData({ url: 'x.csv' })).toBe('url');
expect(classifyData({ values: [{ a: 1 }] })).toBe('inline');
expect(classifyData({ sequence: { start: 0, stop: 5 } })).toBe('generator');
expect(classifyData({ sphere: true })).toBe('generator');
expect(classifyData({ graticule: true })).toBe('generator');
expect(classifyData({ name: 'Sales' })).toBe('named');
});
test('url / values / generator win over a bare name (Vega-Lite precedence)', () => {
// A name on these shapes is a runtime-rebind label, not a named reference.
expect(classifyData({ name: 'pts', values: [{ a: 1 }] })).toBe('inline');
expect(classifyData({ name: 'remote', url: 'x.csv' })).toBe('url');
expect(classifyData({ name: 'seq', sequence: { start: 0, stop: 5 } })).toBe('generator');
});
test('returns null for non-data values', () => {
expect(classifyData(null)).toBeNull();
expect(classifyData(undefined)).toBeNull();
expect(classifyData('string')).toBeNull();
expect(classifyData([])).toBeNull();
expect(classifyData({})).toBeNull();
expect(classifyData({ name: 123 })).toBeNull(); // a non-string name is not named data
});
});
describe('selfDefinedNames', () => {
test('collects top-level datasets keys', () => {
expect([...selfDefinedNames({ datasets: { a: [], b: [] } })].sort()).toEqual(['a', 'b']);
});
test('is empty when there is no datasets map', () => {
expect(selfDefinedNames({ data: { name: 'x' } }).size).toBe(0);
expect(selfDefinedNames(null).size).toBe(0);
});
});
describe('libraryRefName', () => {
const none = new Set<string>();
test('returns the name of a true named reference', () => {
expect(libraryRefName({ name: 'Sales' }, none)).toBe('Sales');
});
test('returns null for inline/url/generator data carrying a name', () => {
expect(libraryRefName({ name: 'pts', values: [{ a: 1 }] }, none)).toBeNull();
expect(libraryRefName({ name: 'remote', url: 'x.csv' }, none)).toBeNull();
expect(libraryRefName({ name: 'seq', sequence: { start: 0, stop: 5 } }, none)).toBeNull();
});
test('returns null for a self-defined name', () => {
expect(libraryRefName({ name: 'local' }, new Set(['local']))).toBeNull();
});
test('returns null for a non-data value', () => {
expect(libraryRefName(null, none)).toBeNull();
expect(libraryRefName({ values: [{ a: 1 }] }, none)).toBeNull();
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* The Vega-Lite data model — the single place that decides what a `data` block
* *means* (docs/architecture/05 → Rendering Contract; 07 → Relationships). Pure
* core: no browser APIs, no React.
*
* A Vega-Lite `data` block is exactly one of four shapes (mirrored from
* vega-lite/src/data.ts — `isUrlData`/`isInlineData`/`isNamedData`/`isGenerator`):
*
* - **url** — has `url`
* - **inline** — has `values` (and MAY also carry a `name` label)
* - **generator** — has `sequence` | `sphere` | `graticule`
* - **named** — has a string `name`, and NONE of the above
*
* Astrolabe binds its library datasets through *named* data: `{ data: { name } }`
* resolves to the library dataset's rows at render time (core/rendering). The
* fidelity rule, taken verbatim from Vega-Lite, is that a `name` ALONE does not
* make a reference — a `name` riding on inline `values`, a `url`, or a generator
* is a label Vega-Lite uses to rebind data at runtime, and Astrolabe must leave it
* untouched. Classifying by this rule (not by "has a string name") is what keeps
* Astrolabe a strict *extension* of Vega-Lite rather than a divergence: every
* native data form keeps working, and only true named references are resolved.
*
* Reference detection (`extractDatasetRefs`/rename in core/spec-refs) and reference
* resolution (core/rendering) both run through `libraryRefName` here, so the two
* can never disagree on what counts as a library dependency.
*/
import { isJsonObject } from './spec-config';
/** The four Vega-Lite data shapes; `null` when the value is not a data block. */
export type DataKind = 'url' | 'inline' | 'generator' | 'named';
/** The keys that mark a Vega-Lite data *generator* (sequence/sphere/graticule). */
const GENERATOR_KEYS = ['sequence', 'sphere', 'graticule'] as const;
/**
* Classify a Vega-Lite `data` value, matching Vega-Lite's own precedence: `url`
* and `values` and generator keys win over a bare `name`, so a named-inline or
* named-url block is inline/url data — not a named reference. Returns `null` for a
* non-object, `data: null`, or an empty/unrecognized block.
*/
export function classifyData(data: unknown): DataKind | null {
if (!isJsonObject(data)) return null;
if ('url' in data) return 'url';
if ('values' in data) return 'inline';
if (GENERATOR_KEYS.some((key) => key in data)) return 'generator';
if (typeof data.name === 'string') return 'named';
return null;
}
/**
* The set of dataset names a spec defines for itself via a top-level `datasets`
* object. These are resolved natively by Vega-Lite, so they are never library
* dependencies and are excluded from reference detection and resolution.
*/
export function selfDefinedNames(spec: unknown): Set<string> {
const names = new Set<string>();
if (isJsonObject(spec) && isJsonObject(spec.datasets)) {
for (const key of Object.keys(spec.datasets)) names.add(key);
}
return names;
}
/**
* The library dataset name a `data` block references, or `null` when it is not a
* library reference — i.e. it is named data (`classifyData` → `'named'`) whose
* name is not one the spec defines for itself. This is the single predicate behind
* both reference extraction and render-time resolution.
*/
export function libraryRefName(data: unknown, selfDefined: ReadonlySet<string>): string | null {
if (classifyData(data) !== 'named') return null;
const { name } = data as { name: string };
return selfDefined.has(name) ? null : name;
}
+19 -4
View File
@@ -33,11 +33,24 @@ describe('extractDatasetRefs', () => {
expect(extractDatasetRefs('{ not valid json')).toEqual([]);
});
test('inline-data and url-data references (no name) are ignored', () => {
test('inline-data and url-data (no name) are not references', () => {
expect(extractDatasetRefs({ data: { values: [{ a: 1 }] } })).toEqual([]);
expect(extractDatasetRefs({ data: { url: 'http://x/y.csv' } })).toEqual([]);
});
test('a name on inline/url data is a Vega-Lite label, not a library reference', () => {
// `{ name, values }` is inline data and `{ name, url }` is url data — the name
// is Vega-Lite's runtime-rebind label, not a dependency (vega-lite/src/data.ts).
expect(extractDatasetRefs({ data: { name: 'pts', values: [{ a: 1 }] } })).toEqual([]);
expect(extractDatasetRefs({ data: { name: 'remote', url: 'http://x/y.csv' } })).toEqual([]);
});
test('generator data is not a reference', () => {
expect(extractDatasetRefs({ data: { sequence: { start: 0, stop: 10 } } })).toEqual([]);
expect(extractDatasetRefs({ data: { name: 'globe', sphere: true } })).toEqual([]);
expect(extractDatasetRefs({ data: { graticule: true } })).toEqual([]);
});
test('excludes names the spec defines for itself via top-level datasets', () => {
const spec = {
datasets: { foo: [{ a: 1 }] },
@@ -128,14 +141,16 @@ describe('renameDatasetInSpec', () => {
expect(Object.keys(out.datasets)).toEqual(['Old']);
});
test('does not rewrite a "data" field buried in inline data rows', () => {
test('does not rewrite a named-inline block or a "data" field buried in its rows', () => {
// `{ name, values }` is inline data — the name is a Vega-Lite label, not a
// library reference — so neither it nor the row payload is renamed.
const spec = {
data: { name: 'Old', values: [{ data: { name: 'Old' } }] },
mark: 'bar',
};
const out = renameDatasetInSpec(spec, 'Old', 'New');
expect(out.data.name).toBe('New'); // the real reference is renamed
expect(out.data.values[0].data.name).toBe('Old'); // the row payload is left alone
expect(out.data.name).toBe('Old');
expect(out.data.values[0].data.name).toBe('Old');
});
test('renames a reference inside a lookup transform (from.data)', () => {
+46 -55
View File
@@ -3,34 +3,35 @@
* (spec §09F → Cross-entity relationships; docs/architecture/07 §3.1, §6).
*
* Portable core: no browser APIs, no React, no store access. A Vega-Lite spec
* references named data through `{ "data": { "name": "MyDataset" } }`, which can
* appear at the top level, per-layer, inside `spec`/`facet`/concat children, or in
* a lookup transform's `from.data`. Rather than enumerate the grammar, we walk the
* spec recursively and collect every `{ data: { name } }` we find — the single
* source of truth for "what does this spec reference", which the renderer's
* resolution must agree with.
* references a library dataset through *named* data — `{ "data": { "name": "X" } }`
* — which can appear at the top level, per-layer, inside `spec`/`facet`/concat
* children, or in a lookup transform's `from.data`. Rather than enumerate the
* grammar, we walk the spec recursively and collect every library reference we
* find — the single source of truth for "what does this spec reference", which the
* renderer's resolution must agree with.
*
* What counts as a *library reference* is decided by `libraryRefName`
* (core/spec-data): named data (a string `name` with no `values`/`url`/generator)
* whose name the spec does not define for itself via a top-level `datasets` object.
* A `name` riding on inline `values` or a `url` is a Vega-Lite label, not a
* dependency, so it is left alone — keeping Astrolabe a strict extension of
* Vega-Lite. The renderer resolves through the same predicate, so the two cannot
* disagree.
*
* The walk recurses into every key EXCEPT two, which hold user data payloads
* rather than nested specs: a `data` object (its `name` is captured at the parent
* site; its `values`/`format` are payload, never a nested ref) and a top-level
* `datasets` map (the spec's own inline data). Pruning those is what keeps a data
* *row* that happens to carry a field literally named `data: { name: "x" }` from
* being misread as a library reference. The renderer's resolution prunes the same
* two keys so the two stay in lockstep.
* rather than nested specs: a `data` object (classified at the parent site; its
* `values`/`format` are payload, never a nested ref) and a top-level `datasets`
* map (the spec's own inline data). Pruning those is what keeps a data *row* that
* happens to carry a field literally named `data: { name: "x" }` from being misread
* as a library reference. The renderer's resolution prunes the same two keys.
*
* A spec may be stored as an **object** or as **JSON text** (see spec §09A); we
* normalize once at the boundary (unparseable text → no refs / unchanged spec) so
* the recursive walk never has to care.
*
* Refinement beyond the doc sketch: a spec may define its OWN inline named
* datasets via a top-level `datasets` object (e.g.
* `{ "datasets": { "foo": [...] }, "data": { "name": "foo" } }`). Names satisfied
* by the spec's own `datasets` are NOT library dependencies, so they are excluded
* from extraction and left untouched on rename — keeping extraction consistent
* with the renderer's resolution, which likewise must not treat self-defined
* names as library refs.
*/
import { libraryRefName, selfDefinedNames } from './spec-data';
type Json = unknown;
/** Parse a string spec; an unparseable draft simply has no resolvable refs. */
@@ -42,22 +43,10 @@ function safeParse(s: string): Json {
}
}
/** The set of dataset names a spec defines for itself via top-level `datasets`. */
function selfDefinedNames(spec: Json): Set<string> {
const names = new Set<string>();
if (spec && typeof spec === 'object' && !Array.isArray(spec)) {
const datasets = (spec as Record<string, Json>).datasets;
if (datasets && typeof datasets === 'object' && !Array.isArray(datasets)) {
for (const key of Object.keys(datasets)) names.add(key);
}
}
return names;
}
/**
* Collects every **library** dataset name referenced by `{ data: { name } }`
* anywhere in the spec, excluding names the spec defines for itself via a
* top-level `datasets` object. Accepts an object or JSON text.
* Collects every **library** dataset name referenced anywhere in the spec
* (`libraryRefName`), excluding names the spec defines for itself via a top-level
* `datasets` object. Accepts an object or JSON text.
*/
export function extractDatasetRefs(spec: Json): string[] {
const root = typeof spec === 'string' ? safeParse(spec) : spec;
@@ -71,12 +60,10 @@ export function extractDatasetRefs(spec: Json): string[] {
}
if (node && typeof node === 'object') {
const obj = node as Record<string, Json>;
const data = obj.data as Record<string, Json> | undefined;
if (data && typeof data === 'object' && typeof data.name === 'string') {
if (!selfDefined.has(data.name)) names.add(data.name);
}
const refName = libraryRefName(obj.data, selfDefined);
if (refName !== null) names.add(refName);
// Recurse into every key except the two that hold data payloads (`data` —
// captured above; `datasets` — the spec's own inline data). Pruning them
// classified above; `datasets` — the spec's own inline data). Pruning them
// keeps the walk out of user data rows, where a field named `data` would
// otherwise be misread as a reference. Kept in step with rendering.ts.
for (const key of Object.keys(obj)) {
@@ -96,12 +83,14 @@ export function recomputeDatasetRefs(spec: Json): string[] {
}
/**
* Returns a copy of `spec` with every `data.name === oldName` replaced by
* `newName`, recursing through arrays and objects. A name that is a top-level
* `datasets` key (self-defined) is left untouched. The input's stored shape is
* preserved: a string spec is parsed, rewritten, and re-serialized as pretty JSON
* text; an object spec returns an object. The generic `<T>` reflects this shape
* preservation. The input is never mutated.
* Returns a copy of `spec` with every library reference to `oldName` (per
* `libraryRefName`) renamed to `newName`, recursing through arrays and objects.
* Native Vega-Lite data that merely carries the name as a label (inline `values`,
* a `url`, a generator) and a self-defined top-level `datasets` key are left
* untouched. The input's stored shape is preserved: a string spec is parsed,
* rewritten, and re-serialized as pretty JSON text; an object spec returns an
* object. The generic `<T>` reflects this shape preservation; the input is never
* mutated.
*/
export function renameDatasetInSpec<T>(spec: T, oldName: string, newName: string): T {
const isString = typeof spec === 'string';
@@ -116,15 +105,17 @@ export function renameDatasetInSpec<T>(spec: T, oldName: string, newName: string
if (node && typeof node === 'object') {
const out: Record<string, Json> = {};
for (const [k, v] of Object.entries(node as Record<string, Json>)) {
// A `data` object is a reference site, not a container: rename a matching
// name and stop — never recurse into its payload. A `datasets` map is the
// spec's own inline data: leave it whole. Pruning both (mirroring
// extractDatasetRefs) keeps rename out of user data rows, where a field
// named `data` would otherwise be rewritten as if it were a reference.
if (k === 'data' && v && typeof v === 'object' && !Array.isArray(v)) {
const dv = v as Record<string, Json>;
out[k] = dv.name === oldName && !selfDefined.has(oldName) ? { ...dv, name: newName } : dv;
} else if (k === 'data' || k === 'datasets') {
// A `data` block is a reference site, not a container: rename it when it is
// a library reference to `oldName`, and never recurse into its payload. A
// `datasets` map is the spec's own inline data: leave it whole. Pruning
// both (mirroring extractDatasetRefs) keeps rename out of user data rows,
// where a field named `data` would otherwise be rewritten as a reference.
if (k === 'data') {
out[k] =
libraryRefName(v, selfDefined) === oldName
? { ...(v as Record<string, Json>), name: newName }
: v;
} else if (k === 'datasets') {
out[k] = v;
} else {
out[k] = rewrite(v);