Chart builder: escape ./[/] in built field names

This commit is contained in:
2026-06-11 19:21:28 +03:00
parent d666599a58
commit 983c052f3b
2 changed files with 31 additions and 7 deletions
+21
View File
@@ -674,6 +674,20 @@ describe('buildChartSpec', () => {
expect((spec.mark as { type: string }).type).toBe(mark); expect((spec.mark as { type: string }).type).toBe(mark);
} }
}); });
it('escapes `.`/`[`/`]` in encoded field names so they read as literal columns', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'user.age', type: 'quantitative' },
y: { field: 'cols[0]', type: 'quantitative' },
},
});
const enc = spec.encoding as Record<string, { field: string }>;
expect(enc.x.field).toBe('user\\.age');
expect(enc.y.field).toBe('cols\\[0\\]');
});
}); });
describe('transforms — aggregate / bin / timeUnit', () => { describe('transforms — aggregate / bin / timeUnit', () => {
@@ -940,6 +954,13 @@ describe('buildTransforms (predicate coercion + shape)', () => {
expect(t).toEqual([{ filter: 'datum.value > 0' }]); expect(t).toEqual([{ filter: 'datum.value > 0' }]);
}); });
it('escapes `.`/`[`/`]` in a predicate field so it reads as a literal column', () => {
const t = buildTransforms(
withFilters(filter({ field: 'user.age', fieldType: 'quantitative', op: 'gt', value: '10' })),
);
expect(t).toEqual([{ filter: { field: 'user\\.age', gt: 10 } }]);
});
it('skips incomplete entries (blank value, blank range bound, blank expression)', () => { it('skips incomplete entries (blank value, blank range bound, blank expression)', () => {
const t = buildTransforms( const t = buildTransforms(
withFilters( withFilters(
+10 -7
View File
@@ -27,6 +27,7 @@ import { DISTINCT_CAP } from './profile';
import type { ColumnType } from './type-inference'; import type { ColumnType } from './type-inference';
import { VEGA_LITE_SCHEMA_URL } from './snippet'; import { VEGA_LITE_SCHEMA_URL } from './snippet';
import { validateExpression } from './expr-validate'; import { validateExpression } from './expr-validate';
import { escapeVegaField } from './rendering';
/** The five mark types the builder offers, in selector order (spec §06). */ /** The five mark types the builder offers, in selector order (spec §06). */
export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle'] as const; export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle'] as const;
@@ -792,12 +793,12 @@ function coerceFilterValue(value: string, fieldType: FieldType): string | number
* predicate in a `{ not }` (Vega-Lite has no bare inequality predicate). * predicate in a `{ not }` (Vega-Lite has no bare inequality predicate).
*/ */
function predicateObject(filter: BuilderFilter): Record<string, unknown> | null { function predicateObject(filter: BuilderFilter): Record<string, unknown> | null {
const { field, op } = filter; const { op } = filter;
if (!field || !op) return null; if (!filter.field || !op) return null;
// TODO: the data-derived column name reaches `field:` unescaped here, as it also // Escape `.`/`[`/`]` so a column literally named e.g. `user.age` is read as that
// does in encodingObject. A column whose name contains `.`/`[`/`]` is then read as // field, not a nested-property accessor (same convention as encodingObject and the
// a nested-property accessor rather than a literal field. core/rendering.ts exports // renderer — docs/architecture/05 §4).
// escapeVegaField for exactly this but nothing currently wires it into the builder. const field = escapeVegaField(filter.field);
const type = filter.fieldType ?? 'nominal'; const type = filter.fieldType ?? 'nominal';
const value = filter.value ?? ''; const value = filter.value ?? '';
const coerce = (v: string) => coerceFilterValue(v, type); const coerce = (v: string) => coerceFilterValue(v, type);
@@ -938,7 +939,9 @@ function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
return { aggregate: 'count', type: 'quantitative' }; return { aggregate: 'count', type: 'quantitative' };
} }
const enc: Record<string, unknown> = {}; const enc: Record<string, unknown> = {};
if (mapping.field !== undefined) enc.field = mapping.field; // Escape `.`/`[`/`]` so a column literally named e.g. `user.age` is read as that
// field, not a nested-property accessor (docs/architecture/05 §4).
if (mapping.field !== undefined) enc.field = escapeVegaField(mapping.field);
enc.type = mapping.type; enc.type = mapping.type;
if (mapping.aggregate) enc.aggregate = mapping.aggregate; if (mapping.aggregate) enc.aggregate = mapping.aggregate;
if (mapping.bin) enc.bin = true; if (mapping.bin) enc.bin = true;