diff --git a/query/venues.js b/query/venues.js new file mode 100644 index 00000000..e9212484 --- /dev/null +++ b/query/venues.js @@ -0,0 +1,165 @@ +const peliasQuery = require('pelias-query'); +const defaults = require('./search_defaults'); +const logger = require('pelias-logger').get('api'); +const _ = require('lodash'); +const check = require('check-types'); + +//------------------------------ +// general-purpose search query +//------------------------------ +const venuesQuery = new peliasQuery.layout.VenuesQuery(); + +// scoring boost +venuesQuery.score( peliasQuery.view.focus_only_function( peliasQuery.view.phrase ) ); +// -------------------------------- + +// non-scoring hard filters +venuesQuery.filter( peliasQuery.view.boundary_country ); +venuesQuery.filter( peliasQuery.view.boundary_circle ); +venuesQuery.filter( peliasQuery.view.boundary_rect ); +venuesQuery.filter( peliasQuery.view.sources ); +// -------------------------------- + +const adminLayers = ['neighbourhood', 'borough', 'city', 'county', 'state', 'country']; + +// This query is a departure from traditional Pelias queries where textual +// names of admin areas were looked up. This query uses the ids returned by +// placeholder for lookups which dramatically reduces the amount of information +// that ES has to store and allows us to have placeholder handle altnames on +// behalf of Pelias. +// +// For the happy path, an input like '30 West 26th Street, Manhattan' would result +// in: +// neighbourhood_id in [] +// borough_id in [421205771] +// locality_id in [85945171, 85940551, 85972655] +// localadmin_id in [404502889, 404499147, 404502891, 85972655] +// +// Where the ids are for all the various Manhattans. Each of those could +// conceivably be the Manhattan that the user was referring to so so all must be +// queried for at the same time. +// +// A counter example for this is '1 West Market Street, York, PA' where York, PA +// can be interpreted as a locality OR county. From experience, when there's +// ambiguity between locality and county for an input, the user is, with complete +// metaphysical certitude, referring to the city. If they were referring to the +// county, they would have entered 'York County, PA'. The point is that it's +// insufficient to just query for all ids because, in this case, '1 West Market Street' +// in other cities in York County, PA would be returned and would be both jarring +// to the user and almost certainly leads to incorrect results. For example, +// the following could be returned (all are towns in York County, PA): +// - 1 West Market Street, Dallastown, PA +// - 1 West Market Street, Fawn Grove, PA +// - 1 West Market Street, Shrewsbury, PA +// etc. +// +// To avoid this calamitous response, this query takes the approach of +// "granularity bands". That is, if there are any ids in the first set of any +// of these granularities: +// - neighbourhood +// - borough +// - locality +// - localadmin +// - region +// - macroregion +// - dependency +// - country +// +// then query for all ids in only those layers. Falling back, if there are +// no ids in those layers, query for the county/macrocounty layers. +// +// This methodology ensures that no happened-to-match-on-county results are returned. +// +// The decision was made to include all other layers in one to solve the issue +// where a country and city share a name, such as Mexico, which could be +// interpreted as a country AND city (in Missouri). The data itself will sort +// out which is correct. That is, it's unlikely that "11 Rock Springs Dr" exists +// in Mexico the country due to naming conventions and would be filtered out +// (though it could, but that's good because it's legitimate) + +/** + map request variables to query variables for all inputs + provided by this HTTP request. This function operates on res.data which is the + Document-ified placeholder repsonse. +**/ +function generateQuery( clean ){ + const vs = new peliasQuery.Vars( defaults ); + + const logParts = ['query:venues', 'parser:libpostal']; + + // sources + if( !_.isEmpty(clean.sources) ) { + vs.var( 'sources', clean.sources); + logParts.push('param:sources'); + } + + // size + if( clean.querySize ) { + vs.var( 'size', clean.querySize ); + logParts.push('param:querySize'); + } + + const mostGranularLayer = adminLayers.find(layer => { + return _.has(clean.parsed_text, layer); + }); + + if (mostGranularLayer) { + vs.var('input:query', clean.parsed_text[mostGranularLayer]); + } + + + // focus point + if( check.number(clean['focus.point.lat']) && + check.number(clean['focus.point.lon']) ){ + vs.set({ + 'focus:point:lat': clean['focus.point.lat'], + 'focus:point:lon': clean['focus.point.lon'] + }); + } + + // boundary rect + if( check.number(clean['boundary.rect.min_lat']) && + check.number(clean['boundary.rect.max_lat']) && + check.number(clean['boundary.rect.min_lon']) && + check.number(clean['boundary.rect.max_lon']) ){ + vs.set({ + 'boundary:rect:top': clean['boundary.rect.max_lat'], + 'boundary:rect:right': clean['boundary.rect.max_lon'], + 'boundary:rect:bottom': clean['boundary.rect.min_lat'], + 'boundary:rect:left': clean['boundary.rect.min_lon'] + }); + } + + // boundary circle + if( check.number(clean['boundary.circle.lat']) && + check.number(clean['boundary.circle.lon']) ){ + vs.set({ + 'boundary:circle:lat': clean['boundary.circle.lat'], + 'boundary:circle:lon': clean['boundary.circle.lon'] + }); + + if( check.number(clean['boundary.circle.radius']) ){ + vs.set({ + 'boundary:circle:radius': Math.round( clean['boundary.circle.radius'] ) + 'km' + }); + } + } + + // boundary country + if( check.string(clean['boundary.country']) ){ + vs.set({ + 'boundary:country': clean['boundary.country'] + }); + } + + // format the log parts into a single coherent string + logger.info(logParts.map(part => `[${part}]`).join(' ')); + + return { + type: 'fallback', + body: venuesQuery.render(vs) + }; + +} + +module.exports = generateQuery; diff --git a/test/unit/query/venues.js b/test/unit/query/venues.js new file mode 100644 index 00000000..e79d2a02 --- /dev/null +++ b/test/unit/query/venues.js @@ -0,0 +1,455 @@ +const generateQuery = require('../../../query/venues'); +const _ = require('lodash'); +const proxyquire = require('proxyquire').noCallThru(); +const mock_logger = require('pelias-mock-logger'); +const MockQuery = require('./MockQuery'); + +module.exports.tests = {}; + +module.exports.tests.interface = (test, common) => { + test('valid interface', (t) => { + t.ok(_.isFunction(generateQuery)); + t.end(); + }); +}; + +// helper for canned views +const views = { + focus_only_function: () => 'focus_only_function', + boundary_country: 'boundary_country view', + boundary_circle: 'boundary_circle view', + boundary_rect: 'boundary_rect view', + sources: 'sources view' +}; + +module.exports.tests.base_query = (test, common) => { + test('neighbourhood from parsed_text should be used for input:query when most granular', t => { + const clean = { + parsed_text: { + neighbourhood: 'neighbourhood value', + borough: 'borough value', + city: 'city value', + county: 'county value', + state: 'state value', + country: 'country value' + } + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.body.vs.var('input:query').toString(), 'neighbourhood value'); + t.end(); + + }); + + test('borough from parsed_text should be used for input:query when most granular', t => { + const clean = { + parsed_text: { + borough: 'borough value', + city: 'city value', + county: 'county value', + state: 'state value', + country: 'country value' + } + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.body.vs.var('input:query').toString(), 'borough value'); + t.end(); + + }); + + test('city from parsed_text should be used for input:query when most granular', t => { + const clean = { + parsed_text: { + city: 'city value', + county: 'county value', + state: 'state value', + country: 'country value' + } + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.body.vs.var('input:query').toString(), 'city value'); + t.end(); + + }); + + test('county from parsed_text should be used for input:query when most granular', t => { + const clean = { + parsed_text: { + county: 'county value', + state: 'state value', + country: 'country value' + } + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.body.vs.var('input:query').toString(), 'county value'); + t.end(); + + }); + + test('state from parsed_text should be used for input:query when most granular', t => { + const clean = { + parsed_text: { + state: 'state value', + country: 'country value' + } + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.body.vs.var('input:query').toString(), 'state value'); + t.end(); + + }); + + test('country from parsed_text should be used for input:query when most granular', t => { + const clean = { + parsed_text: { + country: 'country value' + } + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.body.vs.var('input:query').toString(), 'country value'); + t.end(); + + }); + + test('no input:query should be set when no admin layers in parsed_text', t => { + const clean = { + parsed_text: { + } + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.notOk(generatedQuery.body.vs.isset('input:query')); + t.end(); + + }); + + test('scores and filters should be added', t => { + const logger = mock_logger(); + + const clean = { + parsed_text: { + city: 'city value' + } + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-logger': logger, + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.type, 'fallback'); + + t.equals(generatedQuery.body.vs.var('input:query').toString(), 'city value'); + t.notOk(generatedQuery.body.vs.isset('sources')); + t.equals(generatedQuery.body.vs.var('size').toString(), 20); + + t.deepEquals(generatedQuery.body.score_functions, [ + 'focus_only_function' + ]); + + t.deepEquals(generatedQuery.body.filter_functions, [ + 'boundary_country view', + 'boundary_circle view', + 'boundary_rect view', + 'sources view' + ]); + + t.deepEquals(logger.getInfoMessages(), ['[query:venues] [parser:libpostal]']); + t.end(); + + }); +}; + +module.exports.tests.other_parameters = (test, common) => { + test('explicit size set', (t) => { + const logger = mock_logger(); + + const clean = { + parsed_text: { + city: 'city value' + }, + querySize: 'querySize value' + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-logger': logger, + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.body.vs.var('size').toString(), 'querySize value'); + t.deepEquals(logger.getInfoMessages(), ['[query:venues] [parser:libpostal] [param:querySize]']); + t.end(); + + }); + + test('explicit sources set', (t) => { + const logger = mock_logger(); + + const clean = { + parsed_text: { + city: 'city value' + }, + sources: ['source 1', 'source 2'] + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-logger': logger, + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.deepEquals(generatedQuery.body.vs.var('sources').toString(), ['source 1', 'source 2']); + t.deepEquals(logger.getInfoMessages(), ['[query:venues] [parser:libpostal] [param:sources]']); + t.end(); + + }); + +}; + +module.exports.tests.boundary_filters = (test, common) => { + test('boundary.country available should add to query', (t) => { + const logger = mock_logger(); + + const clean = { + parsed_text: { + city: 'city value' + }, + 'boundary.country': 'boundary.country value' + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-logger': logger, + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.body.vs.var('boundary:country').toString(), 'boundary.country value'); + + t.end(); + + }); + + test('focus.point.lat/lon w/both numbers should add to query', (t) => { + const logger = mock_logger(); + + const clean = { + parsed_text: { + city: 'city value' + }, + 'focus.point.lat': 12.121212, + 'focus.point.lon': 21.212121 + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-logger': logger, + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.body.vs.var('focus:point:lat').toString(), 12.121212); + t.equals(generatedQuery.body.vs.var('focus:point:lon').toString(), 21.212121); + + t.end(); + + }); + + test('boundary.rect with all numbers should add to query', (t) => { + const logger = mock_logger(); + + const clean = { + parsed_text: { + city: 'city value' + }, + 'boundary.rect.min_lat': 12.121212, + 'boundary.rect.max_lat': 13.131313, + 'boundary.rect.min_lon': 21.212121, + 'boundary.rect.max_lon': 31.313131 + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-logger': logger, + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.body.vs.var('boundary:rect:top').toString(), 13.131313); + t.equals(generatedQuery.body.vs.var('boundary:rect:right').toString(), 31.313131); + t.equals(generatedQuery.body.vs.var('boundary:rect:bottom').toString(), 12.121212); + t.equals(generatedQuery.body.vs.var('boundary:rect:left').toString(), 21.212121); + + t.end(); + + }); + + test('boundary circle without radius should set radius to default', (t) => { + const logger = mock_logger(); + + const clean = { + parsed_text: { + city: 'city value' + }, + 'boundary.circle.lat': 12.121212, + 'boundary.circle.lon': 21.212121 + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-logger': logger, + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.body.vs.var('boundary:circle:lat').toString(), 12.121212); + t.equals(generatedQuery.body.vs.var('boundary:circle:lon').toString(), 21.212121); + t.equals(generatedQuery.body.vs.var('boundary:circle:radius').toString(), '50km'); + + t.end(); + + }); + + test('boundary circle with radius set radius to that value rounded', (t) => { + const logger = mock_logger(); + + const clean = { + parsed_text: { + city: 'city value' + }, + 'boundary.circle.lat': 12.121212, + 'boundary.circle.lon': 21.212121, + 'boundary.circle.radius': 17.6 + }; + + const generatedQuery = proxyquire('../../../query/venues', { + 'pelias-logger': logger, + 'pelias-query': { + layout: { + VenuesQuery: MockQuery + }, + view: views, + Vars: require('pelias-query').Vars + } + })(clean); + + t.equals(generatedQuery.body.vs.var('boundary:circle:lat').toString(), 12.121212); + t.equals(generatedQuery.body.vs.var('boundary:circle:lon').toString(), 21.212121); + t.equals(generatedQuery.body.vs.var('boundary:circle:radius').toString(), '18km'); + + t.end(); + + }); + +}; + +module.exports.all = (tape, common) => { + function test(name, testFunction) { + return tape(`address_search_using_ids query ${name}`, testFunction); + } + + for( var testCase in module.exports.tests ){ + module.exports.tests[testCase](test, common); + } +};