compassql
Version:
CompassQL visualization query language
539 lines (479 loc) • 17.6 kB
text/typescript
import {isString} from 'datalib/src/util';
import {isAggregateOp} from 'vega-lite/build/src/aggregate';
import {Channel, isChannel} from 'vega-lite/build/src/channel';
import {Mark} from 'vega-lite/build/src/mark';
import {FacetedUnitSpec} from 'vega-lite/build/src/spec';
import {StackProperties} from 'vega-lite/build/src/stack';
import {isTimeUnit} from 'vega-lite/build/src/timeunit';
import * as TYPE from 'vega-lite/build/src/type';
import {getFullName} from 'vega-lite/build/src/type';
import {
DEFAULT_PROP_PRECEDENCE,
EncodingNestedChildProp,
getEncodingNestedProp,
isEncodingNestedParent,
Property,
SORT_PROPS,
VIEW_PROPS
} from '../property';
import {PropIndex} from '../propindex';
import {Dict, isArray, isBoolean, keys} from '../util';
import {isShortWildcard, isWildcard, SHORT_WILDCARD} from '../wildcard';
import {
EncodingQuery,
FieldQuery,
FieldQueryBase,
isAutoCountQuery,
isDisabledAutoCountQuery,
isEnabledAutoCountQuery,
isFieldQuery,
isValueQuery
} from './encoding';
import {fromSpec, getVlStack, SpecQuery} from './spec';
export type Replacer = (s: string) => string;
export function getReplacerIndex(replaceIndex: PropIndex<Dict<string>>): PropIndex<Replacer> {
return replaceIndex.map(r => getReplacer(r));
}
export function getReplacer(replace: Dict<string>): Replacer {
return (s: string) => {
if (replace[s] !== undefined) {
return replace[s];
}
return s;
};
}
export function value(v: any, replacer: Replacer): any {
if (isWildcard(v)) {
// Return the enum array if it's a full wildcard, or just return SHORT_WILDCARD for short ones.
if (!isShortWildcard(v) && v.enum) {
return SHORT_WILDCARD + JSON.stringify(v.enum);
} else {
return SHORT_WILDCARD;
}
}
if (replacer) {
return replacer(v);
}
return v;
}
export function replace(v: any, replacer: Replacer): any {
if (replacer) {
return replacer(v);
}
return v;
}
export const REPLACE_NONE = new PropIndex<Replacer>();
export const INCLUDE_ALL: PropIndex<boolean> =
// FIXME: remove manual TRANSFORM concat once we really support enumerating transform.
[]
.concat(DEFAULT_PROP_PRECEDENCE, SORT_PROPS, [Property.TRANSFORM, Property.STACK], VIEW_PROPS)
.reduce((pi, prop: Property) => pi.set(prop, true), new PropIndex<boolean>());
export function vlSpec(
vlspec: FacetedUnitSpec,
include: PropIndex<boolean> = INCLUDE_ALL,
replace: PropIndex<Replacer> = REPLACE_NONE
) {
const specQ = fromSpec(vlspec);
return spec(specQ, include, replace);
}
export const PROPERTY_SUPPORTED_CHANNELS = {
axis: {x: true, y: true, row: true, column: true},
legend: {color: true, opacity: true, size: true, shape: true},
scale: {x: true, y: true, color: true, opacity: true, row: true, column: true, size: true, shape: true},
sort: {x: true, y: true, path: true, order: true},
stack: {x: true, y: true}
};
/**
* Returns a shorthand for a spec query
* @param specQ a spec query
* @param include Dict Set listing property types (key) to be included in the shorthand
* @param replace Dictionary of replace function for values of a particular property type (key)
*/
export function spec(
specQ: SpecQuery,
include: PropIndex<boolean> = INCLUDE_ALL,
replace: PropIndex<Replacer> = REPLACE_NONE
): string {
const parts: string[] = [];
if (include.get(Property.MARK)) {
parts.push(value(specQ.mark, replace.get(Property.MARK)));
}
if (specQ.transform && specQ.transform.length > 0) {
parts.push('transform:' + JSON.stringify(specQ.transform));
}
let stack: StackProperties;
if (include.get(Property.STACK)) {
stack = getVlStack(specQ);
}
if (specQ.encodings) {
const encodings = specQ.encodings
.reduce((encQs, encQ) => {
// Exclude encoding mapping with autoCount=false as they are basically disabled.
if (!isDisabledAutoCountQuery(encQ)) {
let str;
if (!!stack && encQ.channel === stack.fieldChannel) {
str = encoding({...encQ, stack: stack.offset}, include, replace);
} else {
str = encoding(encQ, include, replace);
}
if (str) {
// only add if the shorthand isn't an empty string.
encQs.push(str);
}
}
return encQs;
}, [])
.sort() // sort at the end to ignore order
.join('|');
if (encodings) {
parts.push(encodings);
}
}
for (let viewProp of VIEW_PROPS) {
const propString = viewProp.toString();
if (include.get(viewProp) && !!specQ[propString]) {
const value = specQ[propString];
parts.push(`${propString}=${JSON.stringify(value)}`);
}
}
return parts.join('|');
}
/**
* Returns a shorthand for an encoding query
* @param encQ an encoding query
* @param include Dict Set listing property types (key) to be included in the shorthand
* @param replace Dictionary of replace function for values of a particular property type (key)
*/
export function encoding(
encQ: EncodingQuery,
include: PropIndex<boolean> = INCLUDE_ALL,
replace: PropIndex<Replacer> = REPLACE_NONE
): string {
const parts = [];
if (include.get(Property.CHANNEL)) {
parts.push(value(encQ.channel, replace.get(Property.CHANNEL)));
}
if (isFieldQuery(encQ)) {
const fieldDefStr = fieldDef(encQ, include, replace);
if (fieldDefStr) {
parts.push(fieldDefStr);
}
} else if (isValueQuery(encQ)) {
parts.push(encQ.value);
} else if (isAutoCountQuery(encQ)) {
parts.push('autocount()');
}
return parts.join(':');
}
/**
* Returns a field definition shorthand for an encoding query
* @param encQ an encoding query
* @param include Dict Set listing property types (key) to be included in the shorthand
* @param replace Dictionary of replace function for values of a particular property type (key)
*/
export function fieldDef(
encQ: EncodingQuery,
include: PropIndex<boolean> = INCLUDE_ALL,
replacer: PropIndex<Replacer> = REPLACE_NONE
): string {
if (include.get(Property.AGGREGATE) && isDisabledAutoCountQuery(encQ)) {
return '-';
}
const fn = func(encQ, include, replacer);
const props = fieldDefProps(encQ, include, replacer);
let fieldAndParams;
if (isFieldQuery(encQ)) {
// field
fieldAndParams = include.get('field') ? value(encQ.field, replacer.get('field')) : '...';
// type
if (include.get(Property.TYPE)) {
if (isWildcard(encQ.type)) {
fieldAndParams += ',' + value(encQ.type, replacer.get(Property.TYPE));
} else {
const typeShort = ((encQ.type || TYPE.QUANTITATIVE) + '').substr(0, 1);
fieldAndParams += ',' + value(typeShort, replacer.get(Property.TYPE));
}
}
// encoding properties
fieldAndParams += props
.map(p => {
let val = p.value instanceof Array ? '[' + p.value + ']' : p.value;
return ',' + p.key + '=' + val;
})
.join('');
} else if (isAutoCountQuery(encQ)) {
fieldAndParams = '*,q';
}
if (!fieldAndParams) {
return null;
}
if (fn) {
let fnPrefix = isString(fn) ? fn : SHORT_WILDCARD + (keys(fn).length > 0 ? JSON.stringify(fn) : '');
return fnPrefix + '(' + fieldAndParams + ')';
}
return fieldAndParams;
}
/**
* Return function part of
*/
function func(fieldQ: FieldQuery, include: PropIndex<boolean>, replacer: PropIndex<Replacer>): string | Object {
if (include.get(Property.AGGREGATE) && fieldQ.aggregate && !isWildcard(fieldQ.aggregate)) {
return replace(fieldQ.aggregate, replacer.get(Property.AGGREGATE));
} else if (include.get(Property.AGGREGATE) && isEnabledAutoCountQuery(fieldQ)) {
// autoCount is considered a part of aggregate
return replace('count', replacer.get(Property.AGGREGATE));
} else if (include.get(Property.TIMEUNIT) && fieldQ.timeUnit && !isWildcard(fieldQ.timeUnit)) {
return replace(fieldQ.timeUnit, replacer.get(Property.TIMEUNIT));
} else if (include.get(Property.BIN) && fieldQ.bin && !isWildcard(fieldQ.bin)) {
return 'bin';
} else {
let fn: any = null;
for (const prop of [Property.AGGREGATE, Property.AUTOCOUNT, Property.TIMEUNIT, Property.BIN]) {
const val = fieldQ[prop];
if (include.get(prop) && fieldQ[prop] && isWildcard(val)) {
// assign fnEnumIndex[prop] = array of enum values or just "?" if it is SHORT_WILDCARD
fn = fn || {};
fn[prop] = isShortWildcard(val) ? val : val.enum;
}
}
if (fn && fieldQ.hasFn) {
fn.hasFn = true;
}
return fn;
}
}
/**
* Return key-value of parameters of field defs
*/
function fieldDefProps(fieldQ: FieldQuery, include: PropIndex<boolean>, replacer: PropIndex<Replacer>) {
/** Encoding properties e.g., Scale, Axis, Legend */
const props: {key: string; value: boolean | Object}[] = [];
// Parameters of function such as bin will be just top-level properties
if (!isBoolean(fieldQ.bin) && !isShortWildcard(fieldQ.bin)) {
const bin = fieldQ.bin;
for (const child in bin) {
const prop = getEncodingNestedProp('bin', child as EncodingNestedChildProp);
if (prop && include.get(prop) && bin[child] !== undefined) {
props.push({
key: child,
value: value(bin[child], replacer.get(prop))
});
}
}
// Sort to make sure that parameter are ordered consistently
props.sort((a, b) => a.key.localeCompare(b.key));
}
for (const parent of [Property.SCALE, Property.SORT, Property.STACK, Property.AXIS, Property.LEGEND]) {
if (!isWildcard(fieldQ.channel) && !PROPERTY_SUPPORTED_CHANNELS[parent][fieldQ.channel as Channel]) {
continue;
}
if (include.get(parent) && fieldQ[parent] !== undefined) {
const parentValue = fieldQ[parent];
if (isBoolean(parentValue) || parentValue === null) {
// `scale`, `axis`, `legend` can be false/null.
props.push({
key: parent + '',
value: parentValue || false // return true or false (false if null)
});
} else if (isString(parentValue)) {
// `sort` can be a string (ascending/descending).
props.push({
key: parent + '',
value: replace(JSON.stringify(parentValue), replacer.get(parent))
});
} else {
let nestedPropChildren = [];
for (const child in parentValue) {
const nestedProp = getEncodingNestedProp(parent, child as EncodingNestedChildProp);
if (nestedProp && include.get(nestedProp) && parentValue[child] !== undefined) {
nestedPropChildren.push({
key: child,
value: value(parentValue[child], replacer.get(nestedProp))
});
}
}
if (nestedPropChildren.length > 0) {
const nestedPropObject = nestedPropChildren
.sort((a, b) => a.key.localeCompare(b.key))
.reduce((o, item) => {
o[item.key] = item.value;
return o;
}, {});
// Sort to make sure that parameter are ordered consistently
props.push({
key: parent + '',
value: JSON.stringify(nestedPropObject)
});
}
}
}
}
return props;
}
export function parse(shorthand: string): SpecQuery {
// TODO(https://github.com/uwdata/compassql/issues/259):
// Do not split directly, but use an upgraded version of `getClosingBraceIndex()`
let splitShorthand = shorthand.split('|');
let specQ: SpecQuery = {
mark: splitShorthand[0] as Mark,
encodings: [] as EncodingQuery[]
};
for (let i = 1; i < splitShorthand.length; i++) {
let part = splitShorthand[i];
const splitPart = splitWithTail(part, ':', 1);
const splitPartKey = splitPart[0];
const splitPartValue = splitPart[1];
if (isChannel(splitPartKey) || splitPartKey === '?') {
const encQ = shorthandParser.encoding(splitPartKey, splitPartValue);
specQ.encodings.push(encQ);
continue;
}
if (splitPartKey === 'transform') {
specQ.transform = JSON.parse(splitPartValue);
continue;
}
}
return specQ;
}
/**
* Split a string n times into substrings with the specified delimiter and return them as an array.
* @param str The string to be split
* @param delim The delimiter string used to separate the string
* @param number The value used to determine how many times the string is split
*/
export function splitWithTail(str: string, delim: string, count: number): string[] {
let result = [];
let lastIndex = 0;
for (let i = 0; i < count; i++) {
let indexOfDelim = str.indexOf(delim, lastIndex);
if (indexOfDelim !== -1) {
result.push(str.substring(lastIndex, indexOfDelim));
lastIndex = indexOfDelim + 1;
} else {
break;
}
}
result.push(str.substr(lastIndex));
// If the specified count is greater than the number of delimiters that exist in the string,
// an empty string will be pushed count minus number of delimiter occurence times.
if (result.length !== count + 1) {
while (result.length !== count + 1) {
result.push('');
}
}
return result;
}
export namespace shorthandParser {
export function encoding(channel: Channel | SHORT_WILDCARD, fieldDefShorthand: string): EncodingQuery {
let encQMixins =
fieldDefShorthand.indexOf('(') !== -1
? fn(fieldDefShorthand)
: rawFieldDef(splitWithTail(fieldDefShorthand, ',', 2));
return {
channel,
...encQMixins
};
}
export function rawFieldDef(fieldDefPart: string[]): FieldQueryBase {
const fieldQ: FieldQueryBase = {};
fieldQ.field = fieldDefPart[0];
fieldQ.type = getFullName(fieldDefPart[1].toUpperCase()) || '?';
let partParams = fieldDefPart[2];
let closingBraceIndex = 0;
let i = 0;
while (i < partParams.length) {
let propEqualSignIndex = partParams.indexOf('=', i);
let parsedValue;
if (propEqualSignIndex !== -1) {
let prop = partParams.substring(i, propEqualSignIndex);
if (partParams[i + prop.length + 1] === '{') {
let openingBraceIndex = i + prop.length + 1;
closingBraceIndex = getClosingIndex(openingBraceIndex, partParams, '}');
const value = partParams.substring(openingBraceIndex, closingBraceIndex + 1);
parsedValue = JSON.parse(value);
// index after next comma
i = closingBraceIndex + 2;
} else if (partParams[i + prop.length + 1] === '[') {
// find closing square bracket
let openingBracketIndex = i + prop.length + 1;
let closingBracketIndex = getClosingIndex(openingBracketIndex, partParams, ']');
const value = partParams.substring(openingBracketIndex, closingBracketIndex + 1);
parsedValue = JSON.parse(value);
// index after next comma
i = closingBracketIndex + 2;
} else {
let propIndex = i;
// Substring until the next comma (or end of the string)
let nextCommaIndex = partParams.indexOf(',', i + prop.length);
if (nextCommaIndex === -1) {
nextCommaIndex = partParams.length;
}
// index after next comma
i = nextCommaIndex + 1;
parsedValue = JSON.parse(partParams.substring(propIndex + prop.length + 1, nextCommaIndex));
}
if (isEncodingNestedParent(prop)) {
fieldQ[prop] = parsedValue;
} else {
// prop is a property of the aggregation function such as bin
fieldQ.bin = fieldQ.bin || {};
fieldQ.bin[prop] = parsedValue;
}
} else {
// something is wrong with the format of the partParams
// exits loop if don't have then infintie loop
break;
}
}
return fieldQ;
}
export function getClosingIndex(openingBraceIndex: number, str: string, closingChar: string): number {
for (let i = openingBraceIndex; i < str.length; i++) {
if (str[i] === closingChar) {
return i;
}
}
}
export function fn(fieldDefShorthand: string): FieldQueryBase {
const fieldQ: FieldQueryBase = {};
// Aggregate, Bin, TimeUnit as wildcard case
if (fieldDefShorthand[0] === '?') {
let closingBraceIndex = getClosingIndex(1, fieldDefShorthand, '}');
let fnEnumIndex = JSON.parse(fieldDefShorthand.substring(1, closingBraceIndex + 1));
for (let encodingProperty in fnEnumIndex) {
if (isArray(fnEnumIndex[encodingProperty])) {
fieldQ[encodingProperty] = {enum: fnEnumIndex[encodingProperty]};
} else {
// Definitely a `SHORT_WILDCARD`
fieldQ[encodingProperty] = fnEnumIndex[encodingProperty];
}
}
return {
...fieldQ,
...rawFieldDef(
splitWithTail(fieldDefShorthand.substring(closingBraceIndex + 2, fieldDefShorthand.length - 1), ',', 2)
)
};
} else {
let func = fieldDefShorthand.substring(0, fieldDefShorthand.indexOf('('));
let insideFn = fieldDefShorthand.substring(func.length + 1, fieldDefShorthand.length - 1);
let insideFnParts = splitWithTail(insideFn, ',', 2);
if (isAggregateOp(func)) {
return {
aggregate: func,
...rawFieldDef(insideFnParts)
};
} else if (isTimeUnit(func)) {
return {
timeUnit: func,
...rawFieldDef(insideFnParts)
};
} else if (func === 'bin') {
return {
bin: {},
...rawFieldDef(insideFnParts)
};
}
}
}
}