src/filters.js
'use strict'
var _ = require('./lodash')
var expressions = require('./expressions')
var aggregation = require('./aggregation')
module.exports = function (service) {
return {
filter: filter,
filterAll: filterAll,
applyFilters: applyFilters,
makeFunction: makeFunction,
scanForDynamicFilters: scanForDynamicFilters,
}
function filter(column, fil, isRange, replace) {
return getColumn(column)
.then(function (column) {
// Clone a copy of the new filters
var newFilters = _.assign({}, service.filters)
// Here we use the registered column key despite the filter key passed, just in case the filter key's ordering is ordered differently :)
var filterKey = column.key
if (column.complex === 'array') {
filterKey = JSON.stringify(column.key)
}
if (column.complex === 'function') {
filterKey = column.key.toString()
}
// Build the filter object
newFilters[filterKey] = buildFilterObject(fil, isRange, replace)
return applyFilters(newFilters)
})
}
function getColumn(column) {
var exists = service.column.find(column)
// If the filters dimension doesn't exist yet, try and create it
return new Promise(function (resolve, reject) {
try {
if (!exists) {
return resolve(service.column({
key: column,
temporary: true,
})
.then(function () {
// It was able to be created, so retrieve and return it
return service.column.find(column)
})
)
} else {
// It exists, so just return what we found
resolve(exists)
}
} catch (err) {
reject(err)
}
})
}
function filterAll(fils) {
// If empty, remove all filters
if (!fils) {
service.columns.forEach(function (col) {
col.dimension.filterAll()
})
return applyFilters({})
}
// Clone a copy for the new filters
var newFilters = _.assign({}, service.filters)
var ds = _.map(fils, function (fil) {
return getColumn(fil.column)
.then(function (column) {
// Here we use the registered column key despite the filter key passed, just in case the filter key's ordering is ordered differently :)
var filterKey = column.complex ? JSON.stringify(column.key) : column.key
// Build the filter object
newFilters[filterKey] = buildFilterObject(fil.value, fil.isRange, fil.replace)
})
})
return Promise.all(ds)
.then(function () {
return applyFilters(newFilters)
})
}
function buildFilterObject(fil, isRange, replace) {
if (_.isUndefined(fil)) {
return false
}
if (_.isFunction(fil)) {
return {
value: fil,
function: fil,
replace: true,
type: 'function',
}
}
if (_.isObject(fil)) {
return {
value: fil,
function: makeFunction(fil),
replace: true,
type: 'function',
}
}
if (_.isArray(fil)) {
return {
value: fil,
replace: isRange || replace,
type: isRange ? 'range' : 'inclusive',
}
}
return {
value: fil,
replace: replace,
type: 'exact',
}
}
function applyFilters(newFilters) {
var ds = _.map(newFilters, function (fil, i) {
var existing = service.filters[i]
// Filters are the same, so no change is needed on this column
if (fil === existing) {
return Promise.resolve()
}
var column
// Retrieve complex columns by decoding the column key as json
if (i.charAt(0) === '[') {
column = service.column.find(JSON.parse(i))
} else {
// Retrieve the column normally
column = service.column.find(i)
}
// Toggling a filter value is a bit different from replacing them
if (fil && existing && !fil.replace) {
newFilters[i] = fil = toggleFilters(fil, existing)
}
// If no filter, remove everything from the dimension
if (!fil) {
return Promise.resolve(column.dimension.filterAll())
}
if (fil.type === 'exact') {
return Promise.resolve(column.dimension.filterExact(fil.value))
}
if (fil.type === 'range') {
return Promise.resolve(column.dimension.filterRange(fil.value))
}
if (fil.type === 'inclusive') {
return Promise.resolve(column.dimension.filterFunction(function (d) {
return fil.value.indexOf(d) > -1
}))
}
if (fil.type === 'function') {
return Promise.resolve(column.dimension.filterFunction(fil.function))
}
// By default if something craps up, just remove all filters
return Promise.resolve(column.dimension.filterAll())
})
return Promise.all(ds)
.then(function () {
// Save the new filters satate
service.filters = newFilters
// Pluck and remove falsey filters from the mix
var tryRemoval = []
_.forEach(service.filters, function (val, key) {
if (!val) {
tryRemoval.push({
key: key,
val: val,
})
delete service.filters[key]
}
})
// If any of those filters are the last dependency for the column, then remove the column
return Promise.all(_.map(tryRemoval, function (v) {
var column = service.column.find((v.key.charAt(0) === '[') ? JSON.parse(v.key) : v.key)
if (column.temporary && !column.dynamicReference) {
return service.clear(column.key)
}
}))
})
.then(function () {
// Call the filterListeners and wait for their return
return Promise.all(_.map(service.filterListeners, function (listener) {
return listener()
}))
})
.then(function () {
return service
})
}
function toggleFilters(fil, existing) {
// Exact from Inclusive
if (fil.type === 'exact' && existing.type === 'inclusive') {
fil.value = _.xor([fil.value], existing.value)
} else if (fil.type === 'inclusive' && existing.type === 'exact') { // Inclusive from Exact
fil.value = _.xor(fil.value, [existing.value])
} else if (fil.type === 'inclusive' && existing.type === 'inclusive') { // Inclusive / Inclusive Merge
fil.value = _.xor(fil.value, existing.value)
} else if (fil.type === 'exact' && existing.type === 'exact') { // Exact / Exact
// If the values are the same, remove the filter entirely
if (fil.value === existing.value) {
return false
}
// They they are different, make an array
fil.value = [fil.value, existing.value]
}
// Set the new type based on the merged values
if (!fil.value.length) {
fil = false
} else if (fil.value.length === 1) {
fil.type = 'exact'
fil.value = fil.value[0]
} else {
fil.type = 'inclusive'
}
return fil
}
function scanForDynamicFilters(query) {
// Here we check to see if there are any relative references to the raw data
// being used in the filter. If so, we need to build those dimensions and keep
// them updated so the filters can be rebuilt if needed
// The supported keys right now are: $column, $data
var columns = []
walk(query.filter)
return columns
function walk(obj) {
_.forEach(obj, function (val, key) {
// find the data references, if any
var ref = findDataReferences(val, key)
if (ref) {
columns.push(ref)
}
// if it's a string
if (_.isString(val)) {
ref = findDataReferences(null, val)
if (ref) {
columns.push(ref)
}
}
// If it's another object, keep looking
if (_.isObject(val)) {
walk(val)
}
})
}
}
function findDataReferences(val, key) {
// look for the $data string as a value
if (key === '$data') {
return true
}
// look for the $column key and it's value as a string
if (key && key === '$column') {
if (_.isString(val)) {
return val
}
console.warn('The value for filter "$column" must be a valid column key', val)
return false
}
}
function makeFunction(obj, isAggregation) {
var subGetters
// Detect raw $data reference
if (_.isString(obj)) {
var dataRef = findDataReferences(null, obj)
if (dataRef) {
var data = service.cf.all()
return function () {
return data
}
}
}
if (_.isString(obj) || _.isNumber(obj) || _.isBoolean(obj)) {
return function (d) {
if (typeof d === 'undefined') {
return obj
}
return expressions.$eq(d, function () {
return obj
})
}
}
// If an array, recurse into each item and return as a map
if (_.isArray(obj)) {
subGetters = _.map(obj, function (o) {
return makeFunction(o, isAggregation)
})
return function (d) {
return subGetters.map(function (s) {
return s(d)
})
}
}
// If object, return a recursion function that itself, returns the results of all of the object keys
if (_.isObject(obj)) {
subGetters = _.map(obj, function (val, key) {
// Get the child
var getSub = makeFunction(val, isAggregation)
// Detect raw $column references
var dataRef = findDataReferences(val, key)
if (dataRef) {
var column = service.column.find(dataRef)
var data = column.values
return function () {
return data
}
}
// If expression, pass the parentValue and the subGetter
if (expressions[key]) {
return function (d) {
return expressions[key](d, getSub)
}
}
var aggregatorObj = aggregation.parseAggregatorParams(key)
if (aggregatorObj) {
// Make sure that any further operations are for aggregations
// and not filters
isAggregation = true
// here we pass true to makeFunction which denotes that
// an aggregatino chain has started and to stop using $AND
getSub = makeFunction(val, isAggregation)
// If it's an aggregation object, be sure to pass in the children, and then any additional params passed into the aggregation string
return function () {
return aggregatorObj.aggregator.apply(null, [getSub()].concat(aggregatorObj.params))
}
}
// It must be a string then. Pluck that string key from parent, and pass it as the new value to the subGetter
return function (d) {
d = d[key]
return getSub(d, getSub)
}
})
// All object expressions are basically AND's
// Return AND with a map of the subGetters
if (isAggregation) {
if (subGetters.length === 1) {
return function (d) {
return subGetters[0](d)
}
}
return function (d) {
return _.map(subGetters, function (getSub) {
return getSub(d)
})
}
}
return function (d) {
return expressions.$and(d, function (d) {
return _.map(subGetters, function (getSub) {
return getSub(d)
})
})
}
}
console.log('no expression found for ', obj)
return false
}
}