From 983c052f3bd36cac5ccfe4c2b78b9c16c2b50d57 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Thu, 11 Jun 2026 19:21:28 +0300 Subject: [PATCH] Chart builder: escape ./[/] in built field names --- src/core/chart-builder.test.ts | 21 +++++++++++++++++++++ src/core/chart-builder.ts | 17 ++++++++++------- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/core/chart-builder.test.ts b/src/core/chart-builder.test.ts index 484f5ad..03cedc9 100644 --- a/src/core/chart-builder.test.ts +++ b/src/core/chart-builder.test.ts @@ -674,6 +674,20 @@ describe('buildChartSpec', () => { 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; + expect(enc.x.field).toBe('user\\.age'); + expect(enc.y.field).toBe('cols\\[0\\]'); + }); }); describe('transforms — aggregate / bin / timeUnit', () => { @@ -940,6 +954,13 @@ describe('buildTransforms (predicate coercion + shape)', () => { 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)', () => { const t = buildTransforms( withFilters( diff --git a/src/core/chart-builder.ts b/src/core/chart-builder.ts index 09a9e06..dd595d4 100644 --- a/src/core/chart-builder.ts +++ b/src/core/chart-builder.ts @@ -27,6 +27,7 @@ import { DISTINCT_CAP } from './profile'; import type { ColumnType } from './type-inference'; import { VEGA_LITE_SCHEMA_URL } from './snippet'; import { validateExpression } from './expr-validate'; +import { escapeVegaField } from './rendering'; /** The five mark types the builder offers, in selector order (spec §06). */ 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). */ function predicateObject(filter: BuilderFilter): Record | 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 { op } = filter; + if (!filter.field || !op) return null; + // Escape `.`/`[`/`]` so a column literally named e.g. `user.age` is read as that + // field, not a nested-property accessor (same convention as encodingObject and the + // renderer — docs/architecture/05 §4). + const field = escapeVegaField(filter.field); const type = filter.fieldType ?? 'nominal'; const value = filter.value ?? ''; const coerce = (v: string) => coerceFilterValue(v, type); @@ -938,7 +939,9 @@ function encodingObject(mapping: ChannelMapping): Record { return { aggregate: 'count', type: 'quantitative' }; } const enc: Record = {}; - 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; if (mapping.aggregate) enc.aggregate = mapping.aggregate; if (mapping.bin) enc.bin = true;