Skip to content

Commit

Permalink
Merge pull request #423 from NYPL/SCC-4346/non-roman-2
Browse files Browse the repository at this point in the history
Scc 4346/non roman 2
  • Loading branch information
charmingduchess authored Dec 10, 2024
2 parents def2e6a + cab620e commit 2a4f75b
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 47 deletions.
28 changes: 14 additions & 14 deletions lib/elasticsearch/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,20 @@ const SEARCH_SCOPES = {
}

const FILTER_CONFIG = {
recordType: { operator: 'match', field: 'recordTypeId', repeatable: true },
owner: { operator: 'match', field: 'items.owner_packed', repeatable: true, path: 'items' },
subjectLiteral: { operator: 'match', field: 'subjectLiteral_exploded', repeatable: true },
holdingLocation: { operator: 'match', field: 'items.holdingLocation_packed', repeatable: true, path: 'items' },
buildingLocation: { operator: 'match', field: 'buildingLocationIds', repeatable: true },
language: { operator: 'match', field: 'language_packed', repeatable: true },
materialType: { operator: 'match', field: 'materialType_packed', repeatable: true },
mediaType: { operator: 'match', field: 'mediaType_packed', repeatable: true },
carrierType: { operator: 'match', field: 'carrierType_packed', repeatable: true },
publisher: { operator: 'match', field: 'publisherLiteral.raw', repeatable: true },
contributorLiteral: { operator: 'match', field: 'contributorLiteral.raw', repeatable: true },
creatorLiteral: { operator: 'match', field: 'creatorLiteral.raw', repeatable: true },
issuance: { operator: 'match', field: 'issuance_packed', repeatable: true },
createdYear: { operator: 'match', field: 'createdYear', repeatable: true },
recordType: { operator: 'match', field: ['recordTypeId'], repeatable: true },
owner: { operator: 'match', field: ['items.owner.id', 'items.owner.label'], repeatable: true, path: 'items' },
subjectLiteral: { operator: 'match', field: ['subjectLiteral_exploded'], repeatable: true },
holdingLocation: { operator: 'match', field: ['items.holdingLocation.id', 'items.holdingLocation.label'], repeatable: true, path: 'items' },
buildingLocation: { operator: 'match', field: ['buildingLocationIds'], repeatable: true },
language: { operator: 'match', field: ['language.id', 'language.label'], repeatable: true },
materialType: { operator: 'match', field: ['materialType.id', 'materialType.label'], repeatable: true },
mediaType: { operator: 'match', field: ['mediaType.id', 'mediaType.label'], repeatable: true },
carrierType: { operator: 'match', field: ['carrierType.id', 'carrierType.label'], repeatable: true },
publisher: { operator: 'match', field: ['publisherLiteral.raw'], repeatable: true },
contributorLiteral: { operator: 'match', field: ['contributorLiteral.raw', 'parallelContributor.raw'], repeatable: true },
creatorLiteral: { operator: 'match', field: ['creatorLiteral.raw', 'parallelCreatorLiteral.raw'], repeatable: true },
issuance: { operator: 'match', field: ['issuance.id', 'issuance.label'], repeatable: true },
createdYear: { operator: 'match', field: ['createdYear'], repeatable: true },
dateAfter: {
operator: 'custom',
type: 'int'
Expand Down
69 changes: 36 additions & 33 deletions lib/elasticsearch/elastic-query-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,40 @@ class ElasticQueryBuilder {
}
}

buildMultiFieldClause (value, fields) {
return {
bool:
{ should: fields.map(field => ({ term: { [field]: value } })) }
}
}

// This builds a filter cause from the value:
buildFilterClause (value, fieldsToMatchOn) {
const filterMatchesOnMoreThanOneField = fieldsToMatchOn.length > 1
if (filterMatchesOnMoreThanOneField) {
return this.buildMultiFieldClause(value, fieldsToMatchOn)
} else {
const field = fieldsToMatchOn[0]
return { term: { [field]: value } }
}
}

buildMatchOperatorFilterQueries (filtersWithMatchOperators) {
return filtersWithMatchOperators.map((prop) => {
const config = FILTER_CONFIG[prop]
let value = this.request.params.filters[prop]

// If multiple values given, let's join them with 'should', causing it to operate as a boolean OR
// Note: using 'must' here makes it a boolean AND
const booleanOperator = 'should'

if (Array.isArray(value) && value.length === 1) value = value.shift()
const clause = (Array.isArray(value)) ? { bool: { [booleanOperator]: value.map((value) => this.buildFilterClause(value, config.field)) } } : this.buildFilterClause(value, config.field)

return { path: config.path, clause }
})
}

/**
* Examine request for user-filters. When found, add them to query.
*/
Expand All @@ -502,41 +536,10 @@ class ElasticQueryBuilder {
)

// Collect those filters that use a simple term match
const simpleMatchFilters = Object.keys(this.request.params.filters)
const filtersWithMatchOperators = Object.keys(this.request.params.filters)
.filter((k) => FILTER_CONFIG[k].operator === 'match')

filterClausesWithPaths = filterClausesWithPaths.concat(simpleMatchFilters.map((prop) => {
const config = FILTER_CONFIG[prop]

let value = this.request.params.filters[prop]

// This builds a filter cause from the value:
const buildClause = (value) => {
// If filtering on a packed field and value isn't a packed value:
if (config.operator === 'match' && value.indexOf('||') < 0 && config.field.match(/_packed$/)) {
// Figure out the base property (e.g. 'owner')
const baseField = config.field.replace(/_packed$/, '')
// Allow supplied val to match against either id or value:
return {
bool: {
should: [
{ term: { [`${baseField}.id`]: value } },
{ term: { [`${baseField}.label`]: value } }
]
}
}
} else if (config.operator === 'match') return { term: { [config.field]: value } }
}

// If multiple values given, let's join them with 'should', causing it to operate as a boolean OR
// Note: using 'must' here makes it a boolean AND
const booleanOperator = 'should'
// If only one value given, don't wrap it in a useless bool:
if (Array.isArray(value) && value.length === 1) value = value.shift()
const clause = (Array.isArray(value)) ? { bool: { [booleanOperator]: value.map(buildClause) } } : buildClause(value)

return { path: config.path, clause }
}))
filterClausesWithPaths = filterClausesWithPaths.concat(this.buildMatchOperatorFilterQueries(filtersWithMatchOperators))

// Gather root (not nested) filters:
let filterClauses = filterClausesWithPaths
Expand Down
Empty file.
105 changes: 105 additions & 0 deletions test/elastic-query-builder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,111 @@ const ElasticQueryBuilder = require('../lib/elasticsearch/elastic-query-builder'
const ApiRequest = require('../lib/api-request')

describe('ElasticQueryBuilder', () => {
describe('buildFilterClause', () => {
it('can handle multiple fields', () => {
expect(ElasticQueryBuilder.prototype.buildFilterClause('value', ['field', 'parallelField']))
.to.deep.equal({
bool:
{
should: [
{ term: { field: 'value' } },
{ term: { parallelField: 'value' } }]
}
})
})
it('can handle the simple case', () => {
expect(ElasticQueryBuilder.prototype.buildFilterClause('value', ['field']))
.to.deep.equal({ term: { field: 'value' } })
})
})
describe('buildMatchOperatorFilterQueries', () => {
const mockQueryBuilderFactory = (request) => ({
request,
buildMultiFieldClause: ElasticQueryBuilder.prototype.buildMultiFieldClause,
buildMatchOperatorFilterQueries: ElasticQueryBuilder.prototype.buildMatchOperatorFilterQueries,
buildFilterClause: ElasticQueryBuilder.prototype.buildFilterClause
})
it('can handle (multiple) single value, single match field filters, as arrays', () => {
const request = new ApiRequest({ filters: { buildingLocation: ['toast'], subjectLiteral: ['spaghetti'] } })
const mockQueryBuilder = mockQueryBuilderFactory(request)
const simpleMatchFilters = mockQueryBuilder.buildMatchOperatorFilterQueries(['buildingLocation', 'subjectLiteral'])
expect(simpleMatchFilters).to.deep.equal([
{
path: undefined,
clause: { term: { buildingLocationIds: 'toast' } }
},
{
path: undefined,
clause: { term: { subjectLiteral_exploded: 'spaghetti' } }
}
])
})
it('can handle (multiple) single value, single match field filters, strings', () => {
const request = new ApiRequest({ filters: { buildingLocation: 'toast', subjectLiteral: 'spaghetti' } })
const mockQueryBuilder = mockQueryBuilderFactory(request)
const simpleMatchFilters = mockQueryBuilder.buildMatchOperatorFilterQueries(['buildingLocation', 'subjectLiteral'])
expect(simpleMatchFilters).to.deep.equal([
{
path: undefined,
clause: { term: { buildingLocationIds: 'toast' } }
},
{
path: undefined,
clause: { term: { subjectLiteral_exploded: 'spaghetti' } }
}
])
})
it('can handle multiple values', () => {
const request = new ApiRequest({ filters: { subjectLiteral: ['spaghetti', 'meatballs'] } })
const mockQueryBuilder = mockQueryBuilderFactory(request)
const simpleMatchFilters = mockQueryBuilder.buildMatchOperatorFilterQueries(['subjectLiteral'])
expect(simpleMatchFilters).to.deep.equal([
{
path: undefined,
clause: {
bool: {
should: [
{ term: { subjectLiteral_exploded: 'spaghetti' } },
{ term: { subjectLiteral_exploded: 'meatballs' } }
]
}
}
}
])
})
it('can handle packed values', () => {
const request = new ApiRequest({ filters: { language: ['spanish', 'finnish'] } })
const mockQueryBuilder = mockQueryBuilderFactory(request)
const simpleMatchFilters = mockQueryBuilder.buildMatchOperatorFilterQueries(['language'])
expect(simpleMatchFilters).to.deep.equal([
{
path: undefined,
clause: {
bool: {
should: [
{
bool: {
should: [
{ term: { 'language.id': 'spanish' } },
{ term: { 'language.label': 'spanish' } }
]
}
},
{
bool: {
should: [
{ term: { 'language.id': 'finnish' } },
{ term: { 'language.label': 'finnish' } }
]
}
}
]
}
}
}
])
})
})
describe('search_scope all', () => {
it('generates an "all" query', () => {
const request = new ApiRequest({ q: 'toast' })
Expand Down

0 comments on commit 2a4f75b

Please sign in to comment.