mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: escape ./[/] in built field names
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user