Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -431,7 +431,12 @@ export function clauseList(
): SelectionClause {
field = asNode(field);
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, fields: [field], value, predicate };
}

Expand Down
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