(function() {
'use strict';
// capture injected services for access throughout this module
var $q = null,
$location = null,
mlRest = null,
qb = null;
angular.module('ml.search')
.factory('MLSearchFactory', MLSearchFactory);
MLSearchFactory.$inject = ['$q', '$location', 'MLRest', 'MLQueryBuilder'];
/**
* @class MLSearchFactory
* @classdesc angular factory for creating instances of {@link MLSearchContext}
*
* @param {Object} $q - angular promise service
* @param {Object} $location - angular location service
* @param {MLRest} MLRest - low-level ML REST API wrapper (from {@link https://github.com/joemfb/ml-common-ng})
* @param {MLQueryBuilder} MLQueryBuilder - structured query builder (from {@link https://github.com/joemfb/ml-common-ng})
*/
// jscs:disable checkParamNames
function MLSearchFactory($injectQ, $injectLocation, $injectMlRest, $injectQb) {
$q = $injectQ;
$location = $injectLocation;
mlRest = $injectMlRest;
qb = $injectQb;
return {
/**
* returns a new instance of {@link MLSearchContext}
* @method MLSearchFactory#newContext
*
* @param {Object} options (to override {@link MLSearchContext.defaults})
* @returns {MLSearchContext}
*/
newContext: function newContext(options) {
return new MLSearchContext(options);
}
};
}
/**
* @class MLSearchContext
* @classdesc class for maintaining and manipulating the state of a search context
*
* @param {Object} options - provided object properties will override {@link MLSearchContext.defaults}
*
* @prop {MLQueryBuilder} qb - query builder service from `ml.common`
* @prop {Object} results - search results
* @prop {Object} storedOptions - cache stored search options by name
* @prop {Object} activeFacets - active facet selections
* @prop {Object} namespaces - namespace prefix-to-URI mappings
* @prop {Object[]} boostQueries - boosting queries to be added to the current query
* @prop {Object[]} additionalQueries - additional queries to be added to the current query
* @prop {String} searchTransform - search results transformation name
* @prop {String} qtext - current search phrase
* @prop {Number} start - current pagination offset
* @prop {Object} options - configuration options
*/
function MLSearchContext(options) {
this.qb = qb;
this.results = {};
this.storedOptions = {};
this.activeFacets = {};
this.namespaces = {};
this.boostQueries = [];
this.additionalQueries = [];
this.searchTransform = null;
this.qtext = null;
this.start = 1;
// TODO: validate options
this.options = _.merge( _.cloneDeep(this.defaults), options );
}
angular.extend(MLSearchContext.prototype, {
/* ******************************************************** */
/* ************** MLSearchContext properties ************** */
/* ******************************************************** */
/**
* pass an object to `new MLSearchContext()` or {@link MLSearchFactory#newContext}
* with any of these properties to override their values
*
* @type object
* @memberof MLSearchContext
* @static
*
* @prop {String} defaults.queryOptions - stored search options name (`'all'`)
* @prop {String} defaults.suggestOptions - stored search options name for suggestions (`same as queryOptions`)
* @prop {Number} defaults.pageLength - results page length (`10`)
* @prop {String} defaults.snippet - results transform operator state-name (`'compact'`)
* @prop {String} defaults.sort - sort operator state-name (`null`)
* @prop {String} defaults.facetMode - determines if facets are combined in an `and-query` or an `or-query` (`and`)
* @prop {Boolean} defaults.includeProperties - include document properties in queries (`false`)
* @prop {Boolean} defaults.includeAggregates - automatically get aggregates for facets (`false`)
* @prop {Object} defaults.params - URL params settings
* @prop {String} defaults.params.separator - constraint-name and value separator (`':'`)
* @prop {String} defaults.params.qtext - qtext parameter name (`'qtext'`)
* @prop {String} defaults.params.facets - facets parameter name (`'f'`)
* @prop {String} defaults.params.sort - sort parameter name (`'s'`)
* @prop {String} defaults.params.page - page parameter name (`'p'`)
* @prop {String} defaults.params.prefix - optional string prefix for each parameter name (`null`)
* @prop {String} defaults.params.prefixSeparator - separator for prefix and parameter name. (`null`) <br>if `null`, `options.params.separator` is used as the prefix separator
*/
defaults: {
queryOptions: 'all',
suggestOptions: null,
pageLength: 10,
snippet: 'compact',
sort: null,
facetMode: 'and',
includeProperties: false,
includeAggregates: false,
params: {
separator: ':',
qtext: 'q',
facets: 'f',
negatedFacets: 'n',
sort: 's',
page: 'p',
prefix: null,
prefixSeparator: null
// TODO: queryOptions?
}
},
/* ******************************************************** */
/* ****** MLSearchContext instance getters/setters ******** */
/* ******************************************************** */
/**
* Gets the object repesenting active facet selections
* @method MLSearchContext#getActiveFacets
*
* @return {Object} `this.activeFacets`
*/
getActiveFacets: function getActiveFacets() {
return this.activeFacets;
},
/**
* Get namspace prefix by URI
* @method MLSearchContext#getNamespacePrefix
*
* @param {String} uri
* @return {String} prefix
*/
getNamespacePrefix: function getNamespacePrefix(uri) {
return this.namespaces[ uri ];
},
/**
* Get namspace URI by prefix
* @method MLSearchContext#getNamespaceUri
*
* @param {String} prefix
* @return {String} uri
*/
getNamespaceUri: function getNamespacePrefix(prefix) {
return _.chain(this.namespaces)
.map(function(nsPrefix, uri) {
if (prefix === nsPrefix) {
return uri;
}
})
.compact()
.first()
.value();
},
/**
* Gets namespace prefix-to-URI mappings
* @method MLSearchContext#getNamespaces
*
* @return {Object[]} namespace prefix-to-URI mapping objects
*/
getNamespaces: function getNamespaces() {
var namespaces = [];
_.forIn(this.namespaces, function(prefix, uri) {
namespaces.push({ prefix: prefix, uri: uri });
});
return namespaces;
},
/**
* Sets namespace prefix->URI mappings
* @method MLSearchContext#setNamespaces
*
* @param {Object[]} namespaces - objects with `uri` and `prefix` properties
* @return {MLSearchContext} `this`
*/
setNamespaces: function setNamespaces(namespaces) {
// TODO: this.clearNamespaces() first?
_.each(namespaces, this.addNamespace, this);
return this;
},
/**
* Adds a namespace prefix->URI mapping
* @method MLSearchContext#addNamespace
*
* @param {Object} namespace object with `uri` and `prefix` properties
* @return {MLSearchContext} `this`
*/
addNamespace: function addNamespace(namespace) {
this.namespaces[ namespace.uri ] = namespace.prefix;
return this;
},
/**
* Clears namespace prefix->URI mappings
* @method MLSearchContext#clearNamespaces
*
* @return {MLSearchContext} `this`
*/
clearNamespaces: function clearNamespaces() {
this.namespaces = {};
return this;
},
/**
* Gets the boost queries
* @method MLSearchContext#getBoostQueries
*
* @return {Array} `this.boostQueries`
*/
getBoostQueries: function getBoostQueries() {
return this.boostQueries;
},
/**
* Adds a boost query to `this.boostQueries`
* @method MLSearchContext#addBoostQuery
*
* @param {Object} query - boost query
* @return {MLSearchContext} `this`
*/
addBoostQuery: function addBoostQuery(query) {
this.boostQueries.push(query);
return this;
},
/**
* Clears the boost queries
* @method MLSearchContext#clearBoostQueries
*
* @return {MLSearchContext} `this`
*/
clearBoostQueries: function clearBoostQueries() {
this.boostQueries = [];
return this;
},
/**
* Gets the additional queries
* @method MLSearchContext#getAdditionalQueries
*
* @return {Object} `this.additionalQueries`
*/
getAdditionalQueries: function getAdditionalQueries() {
return this.additionalQueries;
},
/**
* Adds an additional query to `this.additionalQueries`
* @method MLSearchContext#addAdditionalQuery
*
* @param {Object} query - additional query
* @return {MLSearchContext} `this`
*/
addAdditionalQuery: function addAdditionalQuery(query) {
this.additionalQueries.push(query);
return this;
},
/**
* Clears the additional queries
* @method MLSearchContext#clearAdditionalQueries
*
* @return {MLSearchContext} `this`
*/
clearAdditionalQueries: function clearAdditionalQueries() {
this.additionalQueries = [];
return this;
},
/**
* Gets the search transform name
* @method MLSearchContext#getTransform
*
* @return {String} transform name
*/
getTransform: function getTransform(transform) {
return this.searchTransform;
},
/**
* Sets the search transform name
* @method MLSearchContext#setTransform
*
* @param {String} transform - transform name
* @return {MLSearchContext} `this`
*/
setTransform: function setTransform(transform) {
this.searchTransform = transform;
return this;
},
/**
* Gets the current search phrase
* @method MLSearchContext#getText
*
* @return {String} search phrase
*/
getText: function getText() {
return this.qtext;
},
/**
* Sets the current search phrase
* @method MLSearchContext#setText
*
* @param {String} text - search phrase
* @return {MLSearchContext} `this`
*/
setText: function setText(text) {
if (text !== '') {
this.qtext = text;
} else {
this.qtext = null;
}
return this;
},
/**
* Gets the current search page
* @method MLSearchContext#getPage
*
* @return {Number} search page
*/
getPage: function getPage() {
// TODO: $window.Math
var page = Math.floor(this.start / this.options.pageLength) + 1;
return page;
},
/**
* Sets the search results page
* @method MLSearchContext#setPage
*
* @param {Number} page - the desired search results page
* @return {MLSearchContext} `this`
*/
setPage: function setPage(page) {
page = parseInt(page, 10) || 1;
this.start = 1 + (page - 1) * this.options.pageLength;
return this;
},
/* ******************************************************** */
/* ******* MLSearchContext options getters/setters ******** */
/* ******************************************************** */
/**
* Gets the current queryOptions (name of stored params)
* @method MLSearchContext#getQueryOptions
*
* @return {String} queryOptions
*/
getQueryOptions: function getQueryOptions() {
return this.options.queryOptions;
},
/**
* Gets the current suggestOptions (name of stored params for suggestions)
* @method MLSearchContext#getSuggestOptions
*
* @return {String} suggestOptions
*/
getSuggestOptions: function getSuggestOptions() {
return this.options.suggestOptions;
},
/**
* Gets the current page length
* @method MLSearchContext#getPageLength
*
* @return {Number} page length
*/
getPageLength: function getPageLength() {
return this.options.pageLength;
},
/**
* Sets the current page length
* @method MLSearchContext#setPageLength
*
* @param {Number} pageLength - page length
* @return {MLSearchContext} `this`
*/
setPageLength: function setPageLength(pageLength) {
this.options.pageLength = pageLength;
return this;
},
/**
* Gets the current results transform operator state name
* @method MLSearchContext#getSnippet
*
* @return {String} operator state name
*/
getSnippet: function getSnippet() {
return this.options.snippet;
},
/**
* Sets the current results transform operator state name
* @method MLSearchContext#setSnippet
*
* @param {String} snippet - operator state name
* @return {MLSearchContext} `this`
*/
setSnippet: function setSnippet(snippet) {
this.options.snippet = snippet;
return this;
},
/**
* Clears the results transform operator (resets it to its default value)
* @method MLSearchContext#clearSnippet
*
* @return {MLSearchContext} `this`
*/
clearSnippet: function clearSnippet() {
this.options.snippet = this.defaults.snippet;
return this;
},
/**
* Gets the current sort operator state name
* @method MLSearchContext#getSort
*
* @return {String} sort operator state name
*/
getSort: function getSort() {
return this.options.sort;
},
/**
* Sets the current sort operator state name
* @method MLSearchContext#setSort
*
* @param {String} sort - sort operator state name
* @return {MLSearchContext} `this`
*/
setSort: function setSort(sort) {
this.options.sort = sort;
return this;
},
/**
* Clears the sort operator state name (resets it to its default value)
* @method MLSearchContext#clearSort
*
* @return {MLSearchContext} `this`
*/
clearSort: function clearSort() {
this.options.sort = this.defaults.sort;
return this;
},
/**
* Gets the current facet mode (determines if facet values are combined in an `and-query` or an `or-query`)
* @method MLSearchContext#getFacetMode
*
* @return {String} facet mode
*/
getFacetMode: function getFacetMode() {
return this.options.facetMode;
},
/**
* Sets the current facet mode (`and`|`or`). (determines if facet values are combined in an `and-query` or an `or-query`)
* @method MLSearchContext#setFacetMode
*
* @param {String} facetMode - 'and' or 'or'
* @return {MLSearchContext} `this`
*/
setFacetMode: function setFacetMode(facetMode) {
// TODO: validate facetMode
this.options.facetMode = facetMode;
return this;
},
/**
* Gets the current URL params config object
* @method MLSearchContext#getParamsConfig
*
* @return {Object} params config
*/
getParamsConfig: function getParamsConfig() {
return this.options.params;
},
/**
* Gets the key of the enabled URL params
* @method MLSearchContext#getParamsKeys
*
* @return {Array<String>} URL params keys
*/
getParamsKeys: function getParamsKeys() {
var prefix = this.getParamsPrefix();
return _.chain( this.options.params )
.omit(['separator', 'prefix', 'prefixSeparator'])
.map(function(value) {
return prefix + value;
})
.compact()
.value();
},
/**
* Gets the URL params prefix
* @method MLSearchContext#getParamsPrefix
*
* @return {String} the defined params prefix + separator
*/
getParamsPrefix: function getParamsPrefix() {
var prefix = '';
if ( this.options.params.prefix !== null ) {
prefix = this.options.params.prefix + (
this.options.params.prefixSeparator ||
this.options.params.separator
);
}
return prefix;
},
// TODO: setParamsConfig ?
/* ******************************************************** */
/* ************ MLSearchContext query builders ************ */
/* ******************************************************** */
/**
* Constructs a structured query from the current state
* @method MLSearchContext#getQuery
*
* @return {Object} a structured query object
*/
getQuery: function getQuery() {
var query = qb.and();
if ( _.keys(this.activeFacets).length ) {
query = this.getFacetQuery();
}
if ( this.boostQueries.length ) {
query = qb.boost(query, this.boostQueries);
}
if ( this.additionalQueries.length ) {
query = qb.and(query, this.additionalQueries);
}
if ( this.options.includeProperties ) {
query = qb.or(query, qb.propertiesFragment(query));
}
query = qb.where(query);
if ( this.options.sort ) {
// TODO: this assumes that the sort operator is called "sort", but
// that isn't necessarily true. Properly done, we'd get the options
// from the server and find the operator that contains sort-order
// elements
query.query.queries.push( qb.ext.operatorState('sort', this.options.sort) );
}
if ( this.options.snippet ) {
// same problem as `sort`
query.query.queries.push( qb.ext.operatorState('results', this.options.snippet) );
}
return query;
},
/**
* constructs a structured query from the current active facets
* @method MLSearchContext#getFacetQuery
*
* @return {Object} a structured query object
*/
getFacetQuery: function getFacetQuery() {
var self = this,
queries = [],
query = {},
constraintFn;
_.forIn( self.activeFacets, function(facet, facetName) {
if ( facet.values.length ) {
constraintFn = function(facetValueObject) {
var constraintQuery = qb.ext.constraint( facet.type )( facetName, facetValueObject.value );
if (facetValueObject.negated === true) {
constraintQuery = qb.not(constraintQuery);
}
return constraintQuery;
};
queries = queries.concat( _.map(facet.values, constraintFn) );
}
});
if ( self.options.facetMode === 'or' ) {
query = qb.or(queries);
} else {
query = qb.and(queries);
}
return query;
},
/**
* Construct a combined query from the current state (excluding stored options)
* @method MLSearchContext#getCombinedQuerySync
*
* @param {Object} [options] - optional search options object
*
* @return {Object} - combined query
*/
getCombinedQuerySync: function getCombinedQuerySync(options) {
return qb.ext.combined( this.getQuery(), this.getText(), options );
},
/**
* Construct a combined query from the current state
* @method MLSearchContext#getCombinedQuery
*
* @param {Boolean} [includeOptions] - if `true`, get and include the stored search options (defaults to `false`)
*
* @return {Promise} - a promise resolved with the combined query
*/
getCombinedQuery: function getCombinedQuery(includeOptions) {
var combined = this.getCombinedQuerySync();
if ( !includeOptions ) {
return $q.resolve(combined);
}
return this.getStoredOptions()
.then(function(data) {
combined.search.options = data.options;
return combined;
});
},
/* ******************************************************** */
/* ************ MLSearchContext facet methods ************* */
/* ******************************************************** */
/**
* Check if the facet/value combination is already selected
* @method MLSearchContext#isFacetActive
*
* @param {String} name - facet name
* @param {String} value - facet value
* @return {Boolean} isSelected
*/
isFacetActive: function isFacetActive(name, value) {
var active = this.activeFacets[name];
return !!active && !!_.find(active.values, { value: value });
},
/**
* Check if the facet/value combination selected & negated
* @method MLSearchContext#isFacetNegated
*
* @param {String} name - facet name
* @param {String} value - facet value
* @return {Boolean} isNegated
*/
isFacetNegated: function isFacetNegated(name, value) {
var active = this.activeFacets[name];
if (!active) {
return false;
}
var facet = _.find(active.values, { value: value });
if (facet) {
return facet.negated;
} else {
return false;
}
},
/**
* Add the facet/value/type combination to the activeFacets list
* @method MLSearchContext#selectFacet
*
* @param {String} name - facet name
* @param {String} value - facet value
* @param {String} type - facet type
* @param {Boolean} isNegated - facet negated (default to false)
* @return {MLSearchContext} `this`
*/
selectFacet: function selectFacet(name, value, type, isNegated) {
if (/^"(.*)"$/.test(value)) {
value = value.replace(/^"(.*)"$/, '$1');
}
var active = this.activeFacets[name],
negated = isNegated || false,
valueObject = { value: value, negated: negated };
if (active && !this.isFacetActive(name, value) ) {
active.values.push(valueObject);
} else {
this.activeFacets[name] = { type: type, values: [valueObject] };
}
return this;
},
/**
* Removes the facet/value combination from the activeFacets list
* @method MLSearchContext#clearFacet
*
* @param {String} name - facet name
* @param {String} value - facet value
* @return {MLSearchContext} `this`
*/
clearFacet: function clearFacet(name, value) {
var active = this.activeFacets[name];
active.values = _.filter( active.values, function(facetValueObject) {
return facetValueObject.value !== value;
});
if ( !active.values.length ) {
delete this.activeFacets[name];
}
return this;
},
/**
* If facet/value combination is active, remove it from the activeFacets list
* otherwise, find it's type, and add it.
* @method MLSearchContext#toggleFacet
*
* @param {String} name - facet name
* @param {String} value - facet value
* @param {Boolean} isNegated - facet negated
* @return {MLSearchContext} `this`
*/
toggleFacet: function toggleFacet(name, value, isNegated) {
var config;
if ( this.isFacetActive(name, value) ) {
this.clearFacet(name, value);
} else {
config = this.getFacetConfig(name);
this.selectFacet(name, value, config.type, isNegated);
}
return this;
},
/**
* Clears the activeFacets list
* @method MLSearchContext#clearAllFacets
*
* @return {MLSearchContext} `this`
*/
clearAllFacets: function clearAllFacets() {
this.activeFacets = {};
return this;
},
/**
* Retrieve additional values for the provided `facet` object,
* appending them to the facet's `facetValues` array. Sets `facet.displayingAll = true`
* once no more values are available.
*
* @method MLSearchContext#showMoreFacets
*
* @param {Object} facet - a facet object returned from {@link MLSearchContext#search}
* @param {String} facetName - facet name
* @param {Number} [step] - the number of additional facet values to retrieve (defaults to `5`)
*
* @return {Promise} a promise resolved once additional facets have been retrieved
*/
showMoreFacets: function showMoreFacets(facet, facetName, step) {
if (facet.displayingAll) {
return $q.resolve();
}
step = step || 5;
var start = facet.facetValues.length + 1;
var limit = start + step - 1;
return this.valuesFromConstraint(facetName, { start: start, limit: limit })
.then(function(resp) {
var newFacets = resp && resp['values-response'] && resp['values-response']['distinct-value'];
facet.displayingAll = (!newFacets || newFacets.length < (limit - start));
_.each(newFacets, function(newFacetValue) {
facet.facetValues.push({
name: newFacetValue._value,
value: newFacetValue._value,
count: newFacetValue.frequency
});
});
return facet;
});
},
/* ******************************************************** */
/* ********** MLSearchContext URL params methods ********** */
/* ******************************************************** */
/**
* Construct a URL query params object from the current state
* @method MLSearchContext#getParams
*
* @return {Object} params - a URL query params object
*/
getParams: function getParams() {
var page = this.getPage(),
facetParams = this.getFacetParams(),
facets = facetParams.facets,
negated = facetParams.negatedFacets,
params = {},
prefix = this.getParamsPrefix();
if ( facets.length && this.options.params.facets !== null ) {
params[ prefix + this.options.params.facets ] = facets;
}
if ( negated.length && this.options.params.negatedFacets !== null ) {
params[ prefix + this.options.params.negatedFacets ] = negated;
}
if ( page > 1 && this.options.params.page !== null ) {
params[ prefix + this.options.params.page ] = page;
}
if ( this.qtext && this.options.params.qtext !== null ) {
params[ prefix + this.options.params.qtext ] = this.qtext;
}
if ( this.options.sort && this.options.params.sort !== null ) {
params[ prefix + this.options.params.sort ] = this.options.sort;
}
return params;
},
/**
* Construct an array of facet selections (`name` `separator` `value`) from `this.activeFacets` for use in a URL query params object
* @method MLSearchContext#getFacetParams
*
* @return {Array<String>} an array of facet URL query param values
*/
getFacetParams: function getFacetParams() {
var self = this,
facetQuery = self.getFacetQuery(),
queries = [],
facets = { facets: [], negatedFacets: [] };
queries = ( facetQuery['or-query'] || facetQuery['and-query'] ).queries;
_.each(queries, function(query) {
var queryType = _.keys(query)[0],
constraint,
name,
arrayToPushTo;
if (queryType === 'not-query') {
constraint = query[queryType][_.keys(query[queryType])[0]];
arrayToPushTo = facets.negatedFacets;
} else {
constraint = query[ queryType ];
arrayToPushTo = facets.facets;
}
name = constraint['constraint-name'];
_.each( constraint.value || constraint.uri || constraint.text, function(value) {
// quote values with spaces
if (/\s+/.test(value) && !/^"(.*)"$/.test(value)) {
value = '"' + value + '"';
}
arrayToPushTo.push( name + self.options.params.separator + value );
});
});
return facets;
},
/**
* Gets the current search related URL params (excluding any params not controlled by {@link MLSearchContext})
* @method MLSearchContext#getCurrentParams
*
* @param {Object} [params] - URL params (defaults to `$location.search()`)
* @return {Object} search-related URL params
*/
getCurrentParams: function getCurrentParams(params) {
var prefix = this.getParamsPrefix();
params = _.pick(
params || $location.search(),
this.getParamsKeys()
);
_.chain(this.options.params)
.pick(['facets', 'negatedFacets'])
.values()
.each(function(key) {
var name = prefix + key;
if ( params[ name ] ) {
params[ name ] = asArray(params[ name ]);
}
})
.value();
return params;
},
/**
* Updates the current state based on the URL query params
* @method MLSearchContext#fromParams
*
* @param {Object} [params] - a URL query params object (defaults to `$location.search()`)
* @return {Promise} a promise resolved once the params have been applied
*/
fromParams: function fromParams(params) {
var self = this,
paramsConf = this.options.params,
facets = null,
negatedFacets = null,
optionPromise = null;
params = this.getCurrentParams( params );
this.fromParam( paramsConf.qtext, params,
this.setText.bind(this),
this.setText.bind(this, null)
);
this.fromParam( paramsConf.page, params,
this.setPage.bind(this),
this.setPage.bind(this, 1)
);
this.fromParam( paramsConf.sort, params,
this.setSort.bind(this)
);
self.clearAllFacets();
// _.identity returns it's argument, fromParam returns the callback result
facets = this.fromParam( paramsConf.facets, params, _.identity );
negatedFacets = this.fromParam( paramsConf.negatedFacets, params, _.identity );
if ( !(facets || negatedFacets) ) {
return $q.resolve();
}
// if facet type information is available, options can be undefined
optionPromise = self.results.facets ?
$q.resolve(undefined) :
self.getStoredOptions();
return optionPromise.then(function(options) {
if ( facets ) {
self.fromFacetParam(facets, options);
}
if ( negatedFacets ) {
self.fromFacetParam(negatedFacets, options, true);
}
});
},
/**
* Get the value for the given type of URL param, handling prefixes
* @method MLSearchContext#fromParam
* @private
*
* @param {String} name - URL param name
* @param {Object} params - URL params
* @param {Function} callback - callback invoked with the value of the URL param
* @param {Function} defaultCallback - callback invoked if params are un-prefix'd and no value is provided
*/
fromParam: function fromParam(name, params, callback, defaultCallback) {
var prefixedName = this.getParamsPrefix() + name,
value = params[ prefixedName ];
if ( name === null ) {
return;
}
if ( !value ) {
if ( defaultCallback ) {
defaultCallback.call(this);
}
return;
}
if ( _.isString(value) ) {
value = decodeParam(value);
}
return callback.call( this, value );
},
/**
* Updates the current active facets based on the provided facet URL query params
* @method MLSearchContext#fromFacetParam
* @private
*
* @param {Array|String} param - facet URL query params
* @param {Object} [storedOptions] - a searchOptions object
* @param {Boolean} isNegated - whether the facet should be negated (defaults to false)
*/
fromFacetParam: function fromFacetParam(param, storedOptions, isNegated) {
var self = this,
values = _.map( asArray(param), decodeParam ),
negated = isNegated || false;
_.each(values, function(value) {
var tokens = value.split( self.options.params.separator ),
facetName = tokens[0],
facetValue = tokens[1],
facetInfo = self.getFacetConfig( facetName, storedOptions ) || {};
if ( !facetInfo.type ) {
console.error('don\'t have facets or options for \'' + facetName +
'\', falling back to un-typed range queries');
}
self.selectFacet( facetName, facetValue, facetInfo.type, negated );
});
},
/**
* Gets the "facet config": either a facet response or a constraint definition object
*
* (this function is called in a tight loop, so loading the options async won't work)
*
* @method MLSearchContext#getFacetConfig
* @private
*
* @param {String} name - facet name
* @param {Object} [storedOptions] - a searchOptions object
* @return {Object} facet config
*/
getFacetConfig: function getFacetConfig(name, storedOptions) {
var config = null;
if ( storedOptions ) {
config = _.chain( storedOptions.options.constraint )
.where({ name: name })
.first()
.clone()
.value();
config.type = config.collection ? 'collection' :
config.custom ? 'custom' :
config.range.type;
} else if ( !!this.results.facets && this.results.facets[ name ] ) {
config = this.results.facets[ name ];
}
return config;
},
/**
* Examines the current state, and determines if a new search is needed.
* (intended to be triggered on `$locationChangeSuccess`)
* @method MLSearchContext#locationChange
*
* @param {String} newUrl - the target URL of a location change
* @param {String} oldUrl - the original URL of a location change
* @param {Object} params - the search params of the target URL
*
* @return {Promise} a promise resolved after calling {@link MLSearchContext#fromParams} (if a new search is needed)
*/
locationChange: function locationChange(newUrl, oldUrl, params) {
params = this.getCurrentParams( params );
// still on the search page, but there's a new query
var shouldUpdate = pathsEqual(newUrl, oldUrl) &&
!_.isEqual( this.getParams(), params );
if ( !shouldUpdate ) {
return $q.reject();
}
return this.fromParams(params);
},
/* ******************************************************** */
/* ******** MLSearchContext data retrieval methods ******** */
/* ******************************************************** */
/**
* Retrieves stored search options, caching the result in `this.storedOptions`
* @method MLSearchContext#getStoredOptions
*
* @param {String} [name] - the name of the options to retrieve (defaults to `this.getQueryOptions()`)
* @return {Promise} a promise resolved with the stored options
*/
getStoredOptions: function getStoredOptions(name) {
var self = this;
name = name || this.getQueryOptions();
if ( this.storedOptions[name] ) {
return $q.resolve( this.storedOptions[name] );
}
return mlRest.queryConfig(name)
.then(function(response) {
// TODO: transform?
self.storedOptions[name] = response.data;
return self.storedOptions[name];
});
},
/**
* Retrieves stored search options, caching the result in `this.storedOptions`
* @method MLSearchContext#getAllStoredOptions
*
* @param {String[]} names - the names of the options to retrieve
* @return {Promise} a promise resolved with an object containing the requested search options, keyed by name
*/
getAllStoredOptions: function getAllStoredOptions(names) {
var self = this,
result = {};
// cache any options not already loaded
return $q.all( _.map(names, self.getStoredOptions.bind(self)) ).then(function() {
// return only the names requested
_.each(names, function(name) {
result[name] = self.storedOptions[name];
});
return result;
});
},
/**
* Retrieves search phrase suggestions based on the current state
* @method MLSearchContext#suggest
*
* @param {String} qtext - the partial-phrase to match
* @param {String|Object} [options] - string options name (to override suggestOptions), or object for adhoc combined query options
* @return {Promise} a promise resolved with search phrase suggestions
*/
suggest: function suggest(qtext, options) {
var params = {
'partial-q': qtext,
format: 'json',
options: (_.isString(options) && options) || this.getSuggestOptions() || this.getQueryOptions()
};
var combined = this.getCombinedQuerySync();
if ( _.isObject(options) ) {
combined.search.options = options;
}
return mlRest.suggest(params, combined)
.then(function(response) {
return response.data;
});
},
/**
* Retrieves values from a lexicon (based on a constraint definition)
* @method MLSearchContext#valuesFromConstraint
*
* @param {String} name - the name of a search `constraint` definition
* @param {Object} [params] - URL params
* @return {Promise} a promise resolved with values
*/
valuesFromConstraint: function valuesFromConstraint(name, params) {
var self = this;
return this.getStoredOptions()
.then(function(storedOptions) {
var constraint = getConstraint(storedOptions, name);
if ( !constraint ) {
return $q.reject(new Error('No constraint exists matching ' + name));
}
if (constraint.range && (constraint.range.bucket || constraint.range['computed-bucket'])) {
return $q.reject(new Error('Can\'t get values for bucketed constraint ' + name));
}
if (constraint.custom) {
return $q.reject(new Error('Can\'t get values for custom constraint ' + name));
}
var newOptions = valueOptionsFromConstraint(constraint);
return self.values(name, params, newOptions);
});
},
/**
* Retrieves values or tuples from 1-or-more lexicons
* @method MLSearchContext#values
*
* @param {String} name - the name of a `value-option` definition
* @param {Object} [params] - URL params
* @param {Object} [options] - search options, used in a combined query
* @return {Promise} a promise resolved with values
*/
values: function values(name, params, options) {
var self = this,
combined = this.getCombinedQuerySync();
if ( !options && params && params.options && !(params.start || params.limit) ) {
options = params;
params = null;
}
params = params || {};
params.start = params.start !== undefined ? params.start : 1;
params.limit = params.limit !== undefined ? params.limit : 20;
params.options = self.getQueryOptions();
if ( _.isObject(options) ) {
combined.search.options = options;
}
return mlRest.values(name, params, combined)
.then(function(response) {
return response.data;
});
},
/**
* Retrieves search results based on the current state
*
* If an object is passed as the `adhoc` parameter, the search will be run as a `POST`
* with a combined query, and the results will not be saved to `MLSearchContext.results`.
*
* @method MLSearchContext#search
*
* @param {Object} [adhoc] - structured query || combined query || partial search options object
* @return {Promise} a promise resolved with search results
*/
search: function search(adhoc) {
var self = this;
var params = {
start: this.start,
pageLength: this.getPageLength(),
transform: this.getTransform(),
options: this.getQueryOptions()
};
if ( adhoc ) {
var combined = this.getCombinedQuerySync();
if ( adhoc.search ) {
combined.search = adhoc.search;
} else if ( adhoc.options ) {
combined.search.options = adhoc.options;
} else if ( adhoc.query ) {
combined.search.query = adhoc.query;
} else {
combined.search.options = adhoc;
}
} else {
params.structuredQuery = this.getQuery();
params.q = this.getText();
}
return mlRest.search(params, combined)
.then(function(response) {
var results = response.data;
// the results of adhoc queries aren't preserved
if ( !combined ) {
self.results = results;
}
self.transformMetadata(results.results);
self.annotateActiveFacets(results.facets);
if (self.options.includeAggregates) {
return self.getAggregates(results.facets)
.then(function() {
return results;
});
}
return results;
});
},
/**
* Annotates facets (from a search response object) with the selections from `this.activeFacets`
* @method MLSearchContext#annotateActiveFacets
*
* @param {Object} facets - facets object from a search response
*/
annotateActiveFacets: function annotateActiveFacets(facets) {
var self = this;
_.forIn( facets, function(facet, name) {
var selected = self.activeFacets[name];
if ( selected ) {
_.chain(facet.facetValues)
.filter(function(value) {
return self.isFacetActive(name, value.name);
})
.each(function(value) {
facet.selected = value.selected = true;
value.negated = self.isFacetNegated(name, value.name);
})
.value(); // thwart lazy evaluation
}
if ( facet.type === 'bucketed' || facet.type === 'custom' ) {
facet.displayingAll = true;
}
});
},
/**
* Gets aggregates for facets (from a search response object) based on facet type
* @method MLSearchContext#getAggregates
*
* @param {Object} facets - facets object from a search response
* @return {Promise} a promise resolved once facet aggregates have been retrieved
*/
getAggregates: function getAggregates(facets) {
var self = this;
return self.getStoredOptions()
.then(function(storedOptions) {
var promises = [];
try {
_.forIn( facets, function(facet, facetName) {
var facetType = facet.type,
constraint = getConstraint(storedOptions, facetName);
if ( !constraint ) {
throw new Error('No constraint exists matching ' + facetName);
}
var newOptions = valueOptionsFromConstraint(constraint);
// TODO: update facetType from constraint ?
// TODO: make the choice of aggregates configurable
// these work for all index types
newOptions.values.aggregate = [
{ apply: 'count' },
{ apply: 'min' },
{ apply: 'max' }
];
// TODO: move the scalar-type -> aggregate mappings to MLRest (see https://gist.github.com/joemfb/b682504c7c19cd6fae11)
var numberTypes = [
'xs:int',
'xs:unsignedInt',
'xs:long',
'xs:unsignedLong',
'xs:float',
'xs:double',
'xs:decimal'
];
if ( _.contains(numberTypes, facetType) ) {
newOptions.values.aggregate = newOptions.values.aggregate.concat([
{ apply: 'sum' },
{ apply: 'avg' }
// TODO: allow enabling these from config?
// { apply: 'median' },
// { apply: 'stddev' },
// { apply: 'stddev-population' },
// { apply: 'variance' },
// { apply: 'variance-population' }
]);
}
promises.push(
self.values(facetName, { start: 1, limit: 0 }, newOptions)
.then(function(resp) {
var aggregates = resp && resp['values-response'] && resp['values-response']['aggregate-result'];
_.each( aggregates, function(aggregate) {
facet[aggregate.name] = aggregate._value;
});
})
);
});
} catch (err) {
return $q.reject(err);
}
return $q.all(promises);
});
},
/**
* Transforms the metadata array in each search response result object to an object, key'd by `metadata-type`
* @method MLSearchContext#transformMetadata
*
* @param {Object} result - results array from a search response (or one result object from the array)
*/
transformMetadata: function transformMetadata(result) {
var self = this,
metadata;
if ( _.isArray(result) ) {
_.each(result, this.transformMetadata, self);
return;
}
metadata = result.metadata;
result.metadata = {};
_.each(metadata, function(obj) {
var key = _.without(_.keys(obj), 'metadata-type')[0],
type = obj[ 'metadata-type' ],
value = obj[ key ],
shortKey = null,
prefix = null,
ns = null;
ns = key.replace(/^\{([^}]+)\}.*$/, '$1');
prefix = self.getNamespacePrefix(ns);
if ( prefix ) {
shortKey = key.replace(/\{[^}]+\}/, prefix + ':');
} else {
shortKey = key;
}
if ( !result.metadata[ shortKey ] ) {
result.metadata[ shortKey ] = { 'metadata-type': type, values: [] };
}
result.metadata[ shortKey ].values.push(value);
});
},
/**
* @method MLSearchContext#getStructuredQuery
* @deprecated
*
* @see MLSearchContext#getQuery
*/
getStructuredQuery: function getStructuredQuery() {
console.log(
'Warning, MLSearchContext.getStructuredQuery is deprecated, and will be removed in the next release!\n' +
'Use MLSearchContext.getQuery in it\'s place'
);
return this.getQuery.apply(this, arguments);
},
/**
* @method MLSearchContext#serializeStructuredQuery
* @deprecated
*
* @see MLSearchContext#getParams
*/
serializeStructuredQuery: function serializeStructuredQuery() {
console.log(
'Warning, MLSearchContext.serializeStructuredQuery is deprecated, and will be removed in the next release!\n' +
'Use MLSearchContext.getParams in it\'s place'
);
return this.getParams.apply(this, arguments);
}
});
function decodeParam(value) {
return decodeURIComponent(value.replace(/\+/g, '%20'));
}
function pathsEqual(newUrl, oldUrl) {
// TODO: use $$urlUtils.urlResolve(), once it's available
// see: https://github.com/angular/angular.js/pull/3302
// from: https://stackoverflow.com/questions/21516891
function pathName(href) {
var x = document.createElement('a');
x.href = href;
return x.pathname;
}
return pathName(newUrl) === pathName(oldUrl);
}
function getConstraint(storedOptions, name) {
return storedOptions && storedOptions.options && storedOptions.options.constraint &&
_.where(asArray(storedOptions.options.constraint), { name: name })[0];
}
function valueOptionsFromConstraint(constraint) {
var options = { constraint: asArray(constraint), values: asArray(_.cloneDeep(constraint)) };
// TODO: error if constraint.custom || constraint.range.bucket
options.values[0]['values-option'] = constraint.range && constraint.range['facet-option'];
return options;
}
// TODO: move to util module
function asArray() {
var args;
/* istanbul ignore else */
if ( arguments.length === 1) {
if (Array.isArray( arguments[0] )) {
args = arguments[0];
} else {
args = [ arguments[0] ];
}
} else {
args = [].slice.call(arguments);
}
return args;
}
})();