[Search] Aggregator returns objects, providers return models

Search providers return search results as models for domain objects, as the
actual number of max results is enforced by the aggregator, and because the
individual providers store and return the models for their objects already.

This lowers the amount of resources consumed instantiating domain objects, and
also allows the individual search providers to implement function-based
filtering on domain object models, which is beneficial as it allows the search
filtering in the search controller to be done before paginating of results.
This commit is contained in:
Pete Richards 2015-10-16 15:26:04 -07:00
parent b5505f372f
commit 099591ad2e
2 changed files with 182 additions and 110 deletions

View File

@ -59,7 +59,7 @@
"provides": "searchService",
"type": "aggregator",
"implementation": "services/SearchAggregator.js",
"depends": [ "$q" ]
"depends": [ "$q", "objectService" ]
}
],
"workers": [

View File

@ -24,122 +24,194 @@
/**
* Module defining SearchAggregator. Created by shale on 07/16/2015.
*/
define(
[],
function () {
"use strict";
define([
var DEFUALT_TIMEOUT = 1000,
DEFAULT_MAX_RESULTS = 100;
/**
* Allows multiple services which provide search functionality
* to be treated as one.
*
* @constructor
* @param $q Angular's $q, for promise consolidation.
* @param {SearchProvider[]} providers The search providers to be
* aggregated.
*/
function SearchAggregator($q, providers) {
this.$q = $q;
this.providers = providers;
], function (
) {
"use strict";
var DEFAULT_TIMEOUT = 1000,
DEFAULT_MAX_RESULTS = 100;
/**
* Aggregates multiple search providers as a singular search provider.
* Search providers are expected to implement a `query` method which returns
* a promise for a `modelResults` object.
*
* The search aggregator combines the results from multiple providers,
* removes aggregates, and converts the results to domain objects.
*
* @constructor
* @param $q Angular's $q, for promise consolidation.
* @param objectService
* @param {SearchProvider[]} providers The search providers to be
* aggregated.
*/
function SearchAggregator($q, objectService, providers) {
this.$q = $q;
this.objectService = objectService;
this.providers = providers;
}
/**
* Sends a query to each of the providers. Returns a promise for
* a result object that has the format
* {hits: searchResult[], total: number, timedOut: boolean}
* where a searchResult has the format
* {id: string, object: domainObject, score: number}
*
* @param {String} inputText The text input that is the query.
* @param {Number} maxResults (optional) The maximum number of results
* that this function should return. If not provided, a
* default of 100 will be used.
* @param {Function} [filter] if provided, will be called for every
* potential modelResult. If it returns false, the model result will be
* excluded from the search results.
* @returns {Promise} A Promise for a search result object.
*/
SearchAggregator.prototype.query = function (
inputText,
maxResults,
filter
) {
var aggregator = this,
timestamp = Date.now(),
resultPromises;
if (!maxResults) {
maxResults = DEFAULT_MAX_RESULTS;
}
/**
* Sends a query to each of the providers. Returns a promise for
* a result object that has the format
* {hits: searchResult[], total: number, timedOut: boolean}
* where a searchResult has the format
* {id: string, object: domainObject, score: number}
*
* @param inputText The text input that is the query.
* @param maxResults (optional) The maximum number of results
* that this function should return. If not provided, a
* default of 100 will be used.
*/
SearchAggregator.prototype.query = function queryAll(inputText, maxResults) {
var $q = this.$q,
providers = this.providers,
i,
timestamp = Date.now(),
resultPromises = [];
resultPromises = this.providers.map(function (provider) {
return provider.query(
inputText,
timestamp,
maxResults,
DEFAULT_TIMEOUT
);
});
// Remove duplicate objects that have the same ID. Modifies the passed
// array, and returns the number that were removed.
function filterDuplicates(results, total) {
var ids = {},
numRemoved = 0,
i;
return this.$q
.all(resultPromises)
.then(function (providerResults) {
var modelResults = {
hits: [],
totals: 0,
timedOut: false
};
for (i = 0; i < results.length; i += 1) {
if (ids[results[i].id]) {
// If this result's ID is already there, remove the object
results.splice(i, 1);
numRemoved += 1;
// Reduce loop index because we shortened the array
i -= 1;
} else {
// Otherwise add the ID to the list of the ones we have seen
ids[results[i].id] = true;
}
}
return numRemoved;
}
// Order the objects from highest to lowest score in the array.
// Modifies the passed array, as well as returns the modified array.
function orderByScore(results) {
results.sort(function (a, b) {
if (a.score > b.score) {
return -1;
} else if (b.score > a.score) {
return 1;
} else {
return 0;
}
providerResults.forEach(function (providerResult) {
modelResults.hits =
modelResults.hits.concat(providerResult.hits);
modelResults.totals += providerResult.totals;
modelResults.timedOut =
modelResults.timedOut || providerResult.timedOut;
});
return results;
}
if (!maxResults) {
maxResults = DEFAULT_MAX_RESULTS;
}
aggregator.orderByScore(modelResults);
aggregator.applyFilter(modelResults, filter);
aggregator.removeDuplicates(modelResults);
// Send the query to all the providers
for (i = 0; i < providers.length; i += 1) {
resultPromises.push(
providers[i].query(inputText, timestamp, maxResults, DEFUALT_TIMEOUT)
);
}
// Get promises for results arrays
return $q.all(resultPromises).then(function (resultObjects) {
var results = [],
totalSum = 0,
i;
// Merge results
for (i = 0; i < resultObjects.length; i += 1) {
results = results.concat(resultObjects[i].hits);
totalSum += resultObjects[i].total;
}
// Order by score first, so that when removing repeats we keep the higher scored ones
orderByScore(results);
totalSum -= filterDuplicates(results, totalSum);
return {
hits: results,
total: totalSum,
timedOut: resultObjects.some(function (obj) {
return obj.timedOut;
})
};
return aggregator.asObjectResults(modelResults);
});
};
};
return SearchAggregator;
}
);
/**
* Order model results by score descending and return them.
*/
SearchAggregator.prototype.orderByScore = function (modelResults) {
modelResults.hits.sort(function (a, b) {
if (a.score > b.score) {
return -1;
} else if (b.score > a.score) {
return 1;
} else {
return 0;
}
});
return modelResults;
};
/**
* Apply a filter to each model result, removing it from search results
* if it does not match.
*/
SearchAggregator.prototype.applyFilter = function (modelResults, filter) {
if (!filter) {
return modelResults;
}
var initialLength = modelResults.hits.length,
finalLength,
removedByFilter;
modelResults.hits = modelResults.hits.filter(function (hit) {
return filter(hit.model);
});
finalLength = modelResults.hits;
removedByFilter = initialLength - finalLength;
modelResults.totals -= removedByFilter;
return modelResults;
};
/**
* Remove duplicate hits in a modelResults object, and decrement `totals`
* each time a duplicate is removed.
*/
SearchAggregator.prototype.removeDuplicates = function (modelResults) {
var includedIds = {};
modelResults.hits = modelResults
.hits
.filter(function alreadyInResults(hit) {
if (includedIds[hit.id]) {
modelResults.totals -= 1;
return false;
}
includedIds[hit.id] = true;
return true;
});
return modelResults;
};
/**
* Convert modelResults to objectResults by fetching them from the object
* service.
*
* @returns {Promise} for an objectResults object.
*/
SearchAggregator.prototype.asObjectResults = function (modelResults) {
var objectIds = modelResults.hits.map(function (modelResult) {
return modelResult.id;
});
return this
.objectService
.getObjects(objectIds)
.then(function (objects) {
var objectResults = {
totals: modelResults.totals,
timedOut: modelResults.timedOut
};
objectResults.hits = modelResults
.hits
.map(function asObjectResult(hit) {
return {
id: hit.id,
object: objects[hit.id],
score: hit.score
};
});
return objectResults;
});
};
return SearchAggregator;
});