Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/mosaic/core/src/SelectionClause.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,11 @@ export function clauseList(
}
): SelectionClause {
const listFn = listMatch === 'all' ? listHasAll : listHasAny;
const predicate = value !== undefined ? listFn(field, literal(value)) : null;
// arrays pass through directly so they are quoted as a list, not stringified
const predicate = value === undefined || (Array.isArray(value) && value.length === 0)
? null
: Array.isArray(value)
? listFn(field, value as ExprValue[])
: listFn(field, literal(value));
return { source, clients, value, predicate };
}
48 changes: 48 additions & 0 deletions packages/mosaic/core/test/selection-clause.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { clauseList } from '../src/index.js';

describe('clauseList', () => {
const source = {};

// Regression tests for https://github.com/uwdata/mosaic/issues/977.

it('quotes list values that contain spaces', () => {
// previously unquoted, which triggered a DuckDB Parser Error at the space
const clause = clauseList('tags', ['Drafting Lines'], { source });
expect(String(clause.predicate)).toBe(
`list_has_any("tags", ['Drafting Lines'])`
);
});

it('quotes single-word list values as literals, not identifiers', () => {
// previously produced a bare word, which DuckDB read as a Binder Error
const clause = clauseList('tags', ['Markers'], { source });
expect(String(clause.predicate)).toBe(`list_has_any("tags", ['Markers'])`);
});

it('supports multiple values', () => {
const clause = clauseList('tags', ['Roads', 'Lakes'], { source });
expect(String(clause.predicate)).toBe(
`list_has_any("tags", ['Roads', 'Lakes'])`
);
});

it('supports listMatch "all"', () => {
const clause = clauseList('tags', ['Roads', 'Lakes'], {
source,
listMatch: 'all'
});
expect(String(clause.predicate)).toBe(
`list_has_all("tags", ['Roads', 'Lakes'])`
);
});

it('produces a null predicate for undefined values', () => {
expect(clauseList('tags', undefined, { source }).predicate).toBe(null);
});

it('produces a null predicate for an empty array (clears selection)', () => {
// an empty list must clear the selection, not filter out every row
expect(clauseList('tags', [], { source }).predicate).toBe(null);
});
});
24 changes: 16 additions & 8 deletions packages/vgplot/plot/src/interactors/Nearest.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @import { ClauseSource } from '@uwdata/mosaic-core' */
import { clausePoint, clausePoints, isSelection } from '@uwdata/mosaic-core';
import { clauseList, clausePoint, clausePoints, isSelection } from '@uwdata/mosaic-core';
import { select, pointer, min } from 'd3';
import { getField } from './util/get-field.js';

Expand All @@ -13,12 +13,14 @@ export class Nearest {
pointer,
channels,
fields,
maxRadius = 40
maxRadius = 40,
listMatch
}) {
this.mark = mark;
this.selection = selection;
this.clients = new Set().add(mark);
this.pointer = pointer;
this.listMatch = listMatch;
this.channels = channels || (
pointer === 'x' ? ['x'] : pointer === 'y' ? ['y'] : ['x', 'y']
);
Expand All @@ -28,13 +30,19 @@ export class Nearest {
}

clause(value) {
const { clients, fields } = this;
const { clients, fields, mark } = this;
const opt = { source: /** @type {ClauseSource} */(this), clients };
// if only one field, use a simpler clause that passes the value
// this allows a single field selection value to act like a param
return fields.length > 1
? clausePoints(fields, value ? [value] : value, opt)
: clausePoint(fields[0], value?.[0], opt);
if (fields.length === 1) {
// unnested field: use a list-membership predicate instead of point equality
if (mark.isUnnested(fields[0])) {
const list = value != null ? [value[0]] : undefined;
return clauseList(fields[0], list, { ...opt, listMatch: this.listMatch });
}
// if only one field, use a simpler clause that passes the value
// this allows a single field selection value to act like a param
return clausePoint(fields[0], value?.[0], opt);
}
return clausePoints(fields, value ? [value] : value, opt);
}

init(svg) {
Expand Down
20 changes: 14 additions & 6 deletions packages/vgplot/plot/src/interactors/Toggle.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @import { ClauseSource } from '@uwdata/mosaic-core' */
import { clausePoints } from '@uwdata/mosaic-core';
import { clauseList, clausePoints } from '@uwdata/mosaic-core';
import { getDatum } from './util/get-datum.js';
import { neq, neqSome } from './util/neq.js';

Expand All @@ -15,12 +15,14 @@ export class Toggle {
constructor(mark, {
selection,
channels,
peers = true
peers = true,
listMatch
}) {
this.mark = mark;
this.value = null;
this.selection = selection;
this.peers = peers;
this.listMatch = listMatch;
const fields = this.fields = [];
const as = this.as = [];
channels.forEach(c => {
Expand All @@ -42,10 +44,16 @@ export class Toggle {

clause(value) {
const { fields, mark } = this;
return clausePoints(fields, value, {
source: /** @type {ClauseSource} */(this),
clients: this.peers ? mark.plot.markSet : new Set().add(mark)
});
const clients = this.peers ? mark.plot.markSet : new Set().add(mark);
const opt = { source: /** @type {ClauseSource} */(this), clients };

// unnested fields use a list-membership predicate instead of point equality
if (fields.length === 1 && mark.isUnnested(fields[0])) {
const list = value?.length ? value.map(v => v[0]) : undefined;
return clauseList(fields[0], list, { ...opt, listMatch: this.listMatch });
}

return clausePoints(fields, value, opt);
}

init(svg, selector, accessor) {
Expand Down
39 changes: 37 additions & 2 deletions packages/vgplot/plot/src/marks/Mark.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @import { SelectQuery } from '@uwdata/mosaic-sql' */
import { isParam, MosaicClient, queryFieldInfo, toDataColumns } from '@uwdata/mosaic-core';
import { Query, collectParams, column, isAggregateExpression, isColumnParam, isColumnRef, isNode, isParamLike } from '@uwdata/mosaic-sql';
import { Query, collectParams, column, isAggregateExpression, isColumnParam, isColumnRef, isNode, isParamLike, unnest } from '@uwdata/mosaic-sql';
import { isColor } from './util/is-color.js';
import { isConstantOption } from './util/is-constant-option.js';
import { isSymbol } from './util/is-symbol.js';
Expand All @@ -9,6 +9,12 @@ import { Transform } from '../symbols.js';
const isColorChannel = channel => channel === 'stroke' || channel === 'fill';
const isOpacityChannel = channel => /opacity$/i.test(channel);
const isSymbolChannel = channel => channel === 'symbol';

// column name for a plain column reference or name string, else null
const unnestableColumn = field =>
isColumnRef(field) ? field.column
: typeof field === 'string' ? field
: null;
const isFieldObject = (channel, field) => {
return channel !== 'sort' && channel !== 'tip'
&& field != null && !Array.isArray(field);
Expand Down Expand Up @@ -104,6 +110,25 @@ export class Mark extends MosaicClient {
return table ? (isParam(table) ? table.value : table) : null;
}

/**
* The source field names to unnest, as declared by the `unnest` option.
* @returns {string[]}
*/
get unnestFields() {
const { unnest } = this.source?.options ?? {};
return unnest == null ? [] : [unnest].flat();
}

/**
* Check if the given field is unnested by this mark's source.
* @param {*} field A field reference or column name.
* @returns {boolean}
*/
isUnnested(field) {
const name = unnestableColumn(field);
return name != null && this.unnestFields.includes(name);
}

hasOwnData() {
return this.source == null || isDataArray(this.source);
}
Expand Down Expand Up @@ -160,7 +185,17 @@ export class Mark extends MosaicClient {
*/
query(filter = []) {
if (this.hasOwnData()) return null;
return markQuery(this.channels, this.sourceTable()).where(filter);
const { unnestFields } = this;
// wrap unnested column fields in UNNEST() so arrays expand into rows
const channels = unnestFields.length === 0
? this.channels
: this.channels.map(c => {
const name = unnestableColumn(c.field);
return name != null && unnestFields.includes(name)
? { ...c, field: unnest(c.field) }
: c;
});
return markQuery(channels, this.sourceTable()).where(filter);
}

queryPending() {
Expand Down