UNPKG

compassql

Version:

CompassQL visualization query language

429 lines (412 loc) 16.9 kB
import * as CHANNEL from 'vega-lite/build/src/channel'; import {Channel} from 'vega-lite/build/src/channel'; import {channelCompatibility, TypedFieldDef} from 'vega-lite/build/src/channeldef'; import { channelScalePropertyIncompatability, hasDiscreteDomain, Scale, ScaleType, scaleTypeSupportProperty } from 'vega-lite/build/src/scale'; import {isLocalSingleTimeUnit, isUtcSingleTimeUnit} from 'vega-lite/build/src/timeunit'; import * as TYPE from 'vega-lite/build/src/type'; import {QueryConfig} from '../config'; import {getEncodingNestedProp, Property, SCALE_PROPS} from '../property'; import {PropIndex} from '../propindex'; import {AutoCountQuery, FieldQuery, isFieldQuery, ScaleQuery, scaleType, toFieldDef} from '../query/encoding'; import {ExpandedType, isDiscrete} from '../query/expandedtype'; import {PrimitiveType, Schema} from '../schema'; import {contains} from '../util'; import {isWildcard, Wildcard} from '../wildcard'; import {EncodingConstraint, EncodingConstraintModel} from './base'; export const FIELD_CONSTRAINTS: EncodingConstraintModel<FieldQuery>[] = [ { name: 'aggregateOpSupportedByType', description: 'Aggregate function should be supported by data type.', properties: [Property.TYPE, Property.AGGREGATE], allowWildcardForProperties: false, strict: true, satisfy: (fieldQ: FieldQuery, _: Schema, __: PropIndex<Wildcard<any>>, ___: QueryConfig) => { if (fieldQ.aggregate) { return !isDiscrete(fieldQ.type); } // TODO: some aggregate function are actually supported by ordinal return true; // no aggregate is okay with any type. } }, { name: 'asteriskFieldWithCountOnly', description: 'Field="*" should be disallowed except aggregate="count"', properties: [Property.FIELD, Property.AGGREGATE], allowWildcardForProperties: false, strict: true, satisfy: (fieldQ: FieldQuery, _: Schema, __: PropIndex<Wildcard<any>>, ___: QueryConfig) => { return (fieldQ.field === '*') === (fieldQ.aggregate === 'count'); } }, { name: 'minCardinalityForBin', description: 'binned quantitative field should not have too low cardinality', properties: [Property.BIN, Property.FIELD, Property.TYPE], allowWildcardForProperties: false, strict: true, satisfy: (fieldQ: FieldQuery, schema: Schema, _: PropIndex<Wildcard<any>>, opt: QueryConfig) => { if (fieldQ.bin && fieldQ.type === TYPE.QUANTITATIVE) { // We remove bin so schema can infer the raw unbinned cardinality. let fieldQwithoutBin: FieldQuery = { channel: fieldQ.channel, field: fieldQ.field, type: fieldQ.type }; return schema.cardinality(fieldQwithoutBin) >= opt.minCardinalityForBin; } return true; } }, { name: 'binAppliedForQuantitative', description: 'bin should be applied to quantitative field only.', properties: [Property.TYPE, Property.BIN], allowWildcardForProperties: false, strict: true, // FIXME VL2.0 actually support ordinal type for bin satisfy: (fieldQ: FieldQuery, _: Schema, __: PropIndex<Wildcard<any>>, ___: QueryConfig) => { if (fieldQ.bin) { // If binned, the type must be quantitative return fieldQ.type === TYPE.QUANTITATIVE; } return true; } }, { name: 'channelFieldCompatible', description: `encoding channel's range type be compatible with channel type.`, properties: [Property.CHANNEL, Property.TYPE, Property.BIN, Property.TIMEUNIT], allowWildcardForProperties: false, strict: true, satisfy: (fieldQ: FieldQuery, schema: Schema, encWildcardIndex: PropIndex<Wildcard<any>>, opt: QueryConfig) => { const fieldDef: TypedFieldDef<string> = { field: 'f', // actual field doesn't really matter here ...toFieldDef(fieldQ, {schema, props: ['bin', 'timeUnit', 'type']}) }; const {compatible} = channelCompatibility(fieldDef, fieldQ.channel as Channel); if (compatible) { return true; } else { // In VL, facet's field def must be discrete (O/N), but in CompassQL we can relax this a bit. const isFacet = fieldQ.channel === 'row' || fieldQ.channel === 'column'; if (isFacet && (isLocalSingleTimeUnit(fieldDef.timeUnit) || isUtcSingleTimeUnit(fieldDef.timeUnit))) { return true; } return false; } } }, { name: 'hasFn', description: 'A field with as hasFn flag should have one of aggregate, timeUnit, or bin.', properties: [Property.AGGREGATE, Property.BIN, Property.TIMEUNIT], allowWildcardForProperties: true, strict: true, satisfy: (fieldQ: FieldQuery, _: Schema, __: PropIndex<Wildcard<any>>, ___: QueryConfig) => { if (fieldQ.hasFn) { return !!fieldQ.aggregate || !!fieldQ.bin || !!fieldQ.timeUnit; } return true; } }, { name: 'omitScaleZeroWithBinnedField', description: 'Do not use scale zero with binned field', properties: [Property.SCALE, getEncodingNestedProp('scale', 'zero'), Property.BIN], allowWildcardForProperties: false, strict: true, satisfy: (fieldQ: FieldQuery, _: Schema, __: PropIndex<Wildcard<any>>, ___: QueryConfig) => { if (fieldQ.bin && fieldQ.scale) { if ((fieldQ.scale as ScaleQuery).zero === true) { return false; } } return true; } }, { name: 'onlyOneTypeOfFunction', description: 'Only of of aggregate, autoCount, timeUnit, or bin should be applied at the same time.', properties: [Property.AGGREGATE, Property.AUTOCOUNT, Property.TIMEUNIT, Property.BIN], allowWildcardForProperties: true, strict: true, satisfy: (fieldQ: FieldQuery | AutoCountQuery, _: Schema, __: PropIndex<Wildcard<any>>, ___: QueryConfig) => { if (isFieldQuery(fieldQ)) { const numFn = (!isWildcard(fieldQ.aggregate) && !!fieldQ.aggregate ? 1 : 0) + (!isWildcard(fieldQ.bin) && !!fieldQ.bin ? 1 : 0) + (!isWildcard(fieldQ.timeUnit) && !!fieldQ.timeUnit ? 1 : 0); return numFn <= 1; } // For autoCount there is always only one type of function return true; } }, { name: 'timeUnitAppliedForTemporal', description: 'Time unit should be applied to temporal field only.', properties: [Property.TYPE, Property.TIMEUNIT], allowWildcardForProperties: false, strict: true, satisfy: (fieldQ: FieldQuery, _: Schema, __: PropIndex<Wildcard<any>>, ___: QueryConfig) => { if (fieldQ.timeUnit && fieldQ.type !== TYPE.TEMPORAL) { return false; } return true; } }, { name: 'timeUnitShouldHaveVariation', description: 'A particular time unit should be applied only if they produce unique values.', properties: [Property.TIMEUNIT, Property.TYPE], allowWildcardForProperties: false, strict: false, satisfy: (fieldQ: FieldQuery, schema: Schema, encWildcardIndex: PropIndex<Wildcard<any>>, opt: QueryConfig) => { if (fieldQ.timeUnit && fieldQ.type === TYPE.TEMPORAL) { if (!encWildcardIndex.has('timeUnit') && !opt.constraintManuallySpecifiedValue) { // Do not have to check this as this is manually specified by users. return true; } return schema.timeUnitHasVariation(fieldQ); } return true; } }, { name: 'scalePropertiesSupportedByScaleType', description: 'Scale properties must be supported by correct scale type', properties: [].concat(SCALE_PROPS, [Property.SCALE, Property.TYPE]), allowWildcardForProperties: true, strict: true, satisfy: (fieldQ: FieldQuery, _: Schema, __: PropIndex<Wildcard<any>>, ___: QueryConfig) => { if (fieldQ.scale) { const scale: ScaleQuery = fieldQ.scale as ScaleQuery; // If fieldQ.type is an Wildcard and scale.type is undefined, it is equivalent // to scale type is Wildcard. If scale type is an Wildcard, we do not yet know // what the scale type is, and thus can ignore the constraint. const sType = scaleType(fieldQ); if (sType === undefined || sType === null) { // If still ambiguous, doesn't check the constraint return true; } for (let scaleProp in scale) { if (scaleProp === 'type' || scaleProp === 'name' || scaleProp === 'enum') { // ignore type and properties of wildcards continue; } const sProp = scaleProp as keyof Scale; if (sType === 'point') { // HACK: our current implementation of scaleType() can return point // when the scaleType is a band since we didn't pass all parameter to Vega-Lite's scale type method. if (!scaleTypeSupportProperty('point', sProp) && !scaleTypeSupportProperty('band', sProp)) { return false; } } else if (!scaleTypeSupportProperty(sType, sProp)) { return false; } } } return true; } }, { name: 'scalePropertiesSupportedByChannel', description: 'Not all scale properties are supported by all encoding channels', properties: [].concat(SCALE_PROPS, [Property.SCALE, Property.CHANNEL]), allowWildcardForProperties: true, strict: true, satisfy: (fieldQ: FieldQuery, _: Schema, __: PropIndex<Wildcard<any>>, ___: QueryConfig) => { if (fieldQ) { let channel: Channel = fieldQ.channel as Channel; let scale: ScaleQuery = fieldQ.scale as ScaleQuery; if (channel && !isWildcard(channel) && scale) { if (channel === 'row' || channel === 'column') { // row / column do not have scale return false; } for (let scaleProp in scale) { if (!scale.hasOwnProperty(scaleProp)) continue; if (scaleProp === 'type' || scaleProp === 'name' || scaleProp === 'enum') { // ignore type and properties of wildcards continue; } let isSupported = channelScalePropertyIncompatability(channel, scaleProp as keyof Scale) === undefined; if (!isSupported) { return false; } } } } return true; } }, { name: 'typeMatchesPrimitiveType', description: "Data type should be supported by field's primitive type.", properties: [Property.FIELD, Property.TYPE], allowWildcardForProperties: false, strict: true, satisfy: (fieldQ: FieldQuery, schema: Schema, encWildcardIndex: PropIndex<Wildcard<any>>, opt: QueryConfig) => { if (fieldQ.field === '*') { return true; } const primitiveType = schema.primitiveType(fieldQ.field as string); const type = fieldQ.type; if (!encWildcardIndex.has('field') && !encWildcardIndex.has('type') && !opt.constraintManuallySpecifiedValue) { // Do not have to check this as this is manually specified by users. return true; } switch (primitiveType) { case PrimitiveType.BOOLEAN: case PrimitiveType.STRING: return type !== TYPE.QUANTITATIVE && type !== TYPE.TEMPORAL; case PrimitiveType.NUMBER: case PrimitiveType.INTEGER: return type !== TYPE.TEMPORAL; case PrimitiveType.DATETIME: // TODO: add NOMINAL, ORDINAL support after we support this in Vega-Lite return type === TYPE.TEMPORAL; case null: // field does not exist in the schema return false; } throw new Error('Not implemented'); } }, { name: 'typeMatchesSchemaType', description: "Enumerated data type of a field should match the field's type in the schema.", properties: [Property.FIELD, Property.TYPE], allowWildcardForProperties: false, strict: false, satisfy: (fieldQ: FieldQuery, schema: Schema, encWildcardIndex: PropIndex<Wildcard<any>>, opt: QueryConfig) => { if (!encWildcardIndex.has('field') && !encWildcardIndex.has('type') && !opt.constraintManuallySpecifiedValue) { // Do not have to check this as this is manually specified by users. return true; } if (fieldQ.field === '*') { return fieldQ.type === TYPE.QUANTITATIVE; } return schema.vlType(fieldQ.field as string) === fieldQ.type; } }, { name: 'maxCardinalityForCategoricalColor', description: 'Categorical channel should not have too high cardinality', properties: [Property.CHANNEL, Property.FIELD], allowWildcardForProperties: false, strict: false, satisfy: (fieldQ: FieldQuery, schema: Schema, _: PropIndex<Wildcard<any>>, opt: QueryConfig) => { // TODO: missing case where ordinal / temporal use categorical color // (once we do so, need to add Property.BIN, Property.TIMEUNIT) if (fieldQ.channel === CHANNEL.COLOR && (fieldQ.type === TYPE.NOMINAL || fieldQ.type === ExpandedType.KEY)) { return schema.cardinality(fieldQ) <= opt.maxCardinalityForCategoricalColor; } return true; // other channel is irrelevant to this constraint } }, { name: 'maxCardinalityForFacet', description: 'Row/column channel should not have too high cardinality', properties: [Property.CHANNEL, Property.FIELD, Property.BIN, Property.TIMEUNIT], allowWildcardForProperties: false, strict: false, satisfy: (fieldQ: FieldQuery, schema: Schema, _: PropIndex<Wildcard<any>>, opt: QueryConfig) => { if (fieldQ.channel === CHANNEL.ROW || fieldQ.channel === CHANNEL.COLUMN) { return schema.cardinality(fieldQ) <= opt.maxCardinalityForFacet; } return true; // other channel is irrelevant to this constraint } }, { name: 'maxCardinalityForShape', description: 'Shape channel should not have too high cardinality', properties: [Property.CHANNEL, Property.FIELD, Property.BIN, Property.TIMEUNIT], allowWildcardForProperties: false, strict: false, satisfy: (fieldQ: FieldQuery, schema: Schema, _: PropIndex<Wildcard<any>>, opt: QueryConfig) => { if (fieldQ.channel === CHANNEL.SHAPE) { return schema.cardinality(fieldQ) <= opt.maxCardinalityForShape; } return true; // other channel is irrelevant to this constraint } }, { name: 'dataTypeAndFunctionMatchScaleType', description: 'Scale type must match data type', properties: [ Property.TYPE, Property.SCALE, getEncodingNestedProp('scale', 'type'), Property.TIMEUNIT, Property.BIN ], allowWildcardForProperties: false, strict: true, satisfy: (fieldQ: FieldQuery, _: Schema, __: PropIndex<Wildcard<any>>, ___: QueryConfig) => { if (fieldQ.scale) { const type = fieldQ.type; const sType = scaleType(fieldQ); if (isDiscrete(type)) { return sType === undefined || hasDiscreteDomain(sType); } else if (type === TYPE.TEMPORAL) { if (!fieldQ.timeUnit) { return contains([ScaleType.TIME, ScaleType.UTC, undefined], sType); } else { return contains([ScaleType.TIME, ScaleType.UTC, undefined], sType) || hasDiscreteDomain(sType); } } else if (type === TYPE.QUANTITATIVE) { if (fieldQ.bin) { return contains([ScaleType.LINEAR, undefined], sType); } else { return contains( [ ScaleType.LOG, ScaleType.POW, ScaleType.SQRT, ScaleType.QUANTILE, ScaleType.QUANTIZE, ScaleType.LINEAR, undefined ], sType ); } } } return true; } }, { name: 'stackIsOnlyUsedWithXY', description: 'stack should only be allowed for x and y channels', properties: [Property.STACK, Property.CHANNEL], allowWildcardForProperties: false, strict: true, satisfy: (fieldQ: FieldQuery, _: Schema, __: PropIndex<Wildcard<any>>, ___: QueryConfig) => { if (!!fieldQ.stack) { return fieldQ.channel === CHANNEL.X || fieldQ.channel === CHANNEL.Y; } return true; } } ].map((ec: EncodingConstraint<FieldQuery>) => new EncodingConstraintModel<FieldQuery>(ec)); export const FIELD_CONSTRAINT_INDEX: { [name: string]: EncodingConstraintModel<FieldQuery | AutoCountQuery>; } = FIELD_CONSTRAINTS.reduce((m, ec: EncodingConstraintModel<FieldQuery | AutoCountQuery>) => { m[ec.name()] = ec; return m; }, {}); export const FIELD_CONSTRAINTS_BY_PROPERTY = FIELD_CONSTRAINTS.reduce((index, c) => { for (const prop of c.properties()) { // Initialize array and use it index.set(prop, index.get(prop) || []); index.get(prop).push(c); } return index; }, new PropIndex<EncodingConstraintModel<FieldQuery>[]>());