Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
b85f3fb
Move esRangeValue
danamansana Dec 9, 2025
a02a058
Move parseParams
danamansana Dec 9, 2025
0beec4c
Refactor nyplSourc/id calculation and move nyplSourceAndId to utils'
danamansana Dec 9, 2025
a749878
Add bodybuilder methods for findByUri
danamansana Dec 9, 2025
683445f
Add nyplSourceAndId call to annotatedMarc
danamansana Dec 9, 2025
5ac57ee
Move itemsByFilter to utils
danamansana Dec 9, 2025
d50ea45
Move buildElasticQuery/Body to bodybuilder
danamansana Dec 9, 2025
2f6c887
Factor out body for search
danamansana Dec 9, 2025
1075d65
Move buildElasticAggregationsBody
danamansana Dec 9, 2025
a0237b7
Move aggregationQueriesForParams to bodybuilder
danamansana Dec 9, 2025
3340f5e
Move mergeAggregationsResposes to utils
danamansana Dec 9, 2025
e072d93
Factor out body for aggregation
danamansana Dec 9, 2025
054d1e4
Move findByUri to async/await
danamansana Dec 9, 2025
2d60b15
Make annotatedMarc async
danamansana Dec 9, 2025
2ec818a
Make deliveryLocationsByBarcode async
danamansana Dec 9, 2025
ba7fcf5
Pull search from promise chain in search
danamansana Dec 11, 2025
9189c1b
Pull massaged response from promise chain in resources#search
danamansana Dec 11, 2025
3297b63
Pull ResourceResultsSerializer.serialize from promise chain in search
danamansana Dec 11, 2025
03d57dc
Remove nested promise
danamansana Dec 11, 2025
1c34469
Remove promise from search
danamansana Dec 11, 2025
6e8af8d
Factor out relevance report
danamansana Dec 11, 2025
cf7edcd
Make aggregation endpoint async
danamansana Dec 11, 2025
eefeb44
Reorganize addInnerHits
danamansana Jan 9, 2026
d26553b
Reorganize bodyForFindByUri except for innerHits
danamansana Jan 9, 2026
7429ad6
Remove addInnerHits from findByUri
danamansana Jan 9, 2026
b386ec1
Reorganize buildElasticBody
danamansana Jan 9, 2026
36d56c5
Fix linting
danamansana Jan 13, 2026
d8249ac
Remove adding source to body in body for search
danamansana Jan 13, 2026
9353887
Add innerHits options
danamansana Jan 15, 2026
15a5e5c
Remove dependence on addInnerHits
danamansana Jan 15, 2026
b5530eb
Add tests for bodyForSearch; remove tests for addInnerHits
danamansana Jan 16, 2026
6cfbd49
Remove remaining references to addInnerHits
danamansana Jan 16, 2026
1c769c9
Add bodybuilder tests
danamansana Jan 16, 2026
c379ba5
Fix merge conflicts
danamansana Jan 20, 2026
a5e190e
Remove options from elastic-query
danamansana Jan 23, 2026
5990165
Remove irrelevant code
danamansana Feb 19, 2026
bd098d5
Merge branch 'main' into scc-5050-2
danamansana Feb 19, 2026
020c75d
Remove spurious merge code
danamansana Feb 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion lib/elasticsearch/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,47 @@ const AGGREGATIONS_SPEC = {
collection: { terms: { field: 'collectionIds' } }
}

const ITEM_FILTER_AGGREGATIONS = {
item_location: { nested: { path: 'items' }, aggs: { _nested: { terms: { size: 100, field: 'items.holdingLocation_packed' } } } },
item_status: { nested: { path: 'items' }, aggs: { _nested: { terms: { size: 100, field: 'items.status_packed' } } } },
item_format: { nested: { path: 'items' }, aggs: { _nested: { terms: { size: 100, field: 'items.formatLiteral' } } } }
}

// Configure sort fields:
const SORT_FIELDS = {
title: {
initialDirection: 'asc',
field: 'title_sort'
},
date: {
initialDirection: 'desc',
field: 'dateStartYear'
},
creator: {
initialDirection: 'asc',
field: 'creator_sort'
},
relevance: {}
}

// The following fields can be excluded from ES responses because we don't pass them to client:
const EXCLUDE_FIELDS = [
'uris',
'*_packed',
'*_sort',
'items.*_packed',
'contentsTitle',
'suppressed',
// Hide contributor and creator transformed fields:
'*WithoutDates',
'*Normalized'
]

module.exports = {
SEARCH_SCOPES,
FILTER_CONFIG,
AGGREGATIONS_SPEC
AGGREGATIONS_SPEC,
ITEM_FILTER_AGGREGATIONS,
EXCLUDE_FIELDS,
SORT_FIELDS
}
199 changes: 199 additions & 0 deletions lib/elasticsearch/elastic-body-builder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
const { EXCLUDE_FIELDS, ITEM_FILTER_AGGREGATIONS, SORT_FIELDS, AGGREGATIONS_SPEC } = require('./config')
const { innerHits, itemsQueryContext, itemsFilterContext } = require('./elastic-query-filter-builder')
const ApiRequest = require('../api-request')
const ElasticQueryBuilder = require('../elasticsearch/elastic-query-builder')

const bodyForFindByUri = function (recapBarcodesByStatus, params) {
const paramsIncludesItemLevelFiltering = Object.keys(params)
.filter((param) => param.startsWith('item_')).length > 0

const returnAllItems = params.all_items && !paramsIncludesItemLevelFiltering

const excludes = returnAllItems ? EXCLUDE_FIELDS.filter((field) => field !== '*_sort') : EXCLUDE_FIELDS.concat(['items'])

const aggregations = params.include_item_aggregations
? { aggregations: ITEM_FILTER_AGGREGATIONS }
: {}

const itemsOptions = {
size: params.items_size,
from: params.items_from,
merge_checkin_card_items: params.merge_checkin_card_items,
query: {
volume: params.item_volume,
date: params.item_date,
format: params.item_format,
location: params.item_location,
status: params.item_status,
itemUri: params.itemUri
},
unavailable_recap_barcodes: recapBarcodesByStatus['Not Available']
}

const queryFilter = { filter: !returnAllItems ? [innerHits(itemsOptions)] : [] }

const body = {
_source: {
excludes
},
size: 1,
query: {
bool: {
must: [
{
term: {
uri: params.uri
}
}
],
...queryFilter
}
},
...aggregations
}

return body
}

/**
* Given GET params, returns a plainobject suitable for use in a ES query.
*
* @param {object} params - A hash of request params including `filters`,
* `search_scope`, `q`
*
* @return {object} ES query object suitable to be POST'd to ES endpoint
*/
const buildElasticQuery = function (params, options = {}) {
const request = ApiRequest.fromParams(params)

const builder = ElasticQueryBuilder.forApiRequest(request, options)
return builder.query.toJson()
}

/**
* Given GET params, returns a plainobject with `from`, `size`, `query`,
* `sort`, and any other params necessary to perform the ES query based
* on the GET params.
*
* @return {object} An object that can be posted directly to ES
*/
const buildElasticBody = function (params, options = {}) {
// Apply sort:
let direction
let field

if (params.sort === 'relevance') {
field = '_score'
direction = 'desc'
} else {
field = SORT_FIELDS[params.sort].field || params.sort
direction = params.sort_direction || SORT_FIELDS[params.sort].initialDirection
}

const from = params.per_page && params.page ? { from: params.per_page * (params.page - 1) } : {}
const size = params.per_page ? { size: params.per_page } : {}

return {
...from,
...size,
query: buildElasticQuery(params, options),
sort: [{ [field]: direction }, { uri: 'asc' }]
}
}

const bodyForSearch = function (params) {
const itemsOptions = { merge_checkin_card_items: params.merge_checkin_card_items }

const body = Object.assign(
buildElasticBody(params, { items: itemsOptions }),
{
_source: {
excludes: EXCLUDE_FIELDS.concat(['items'])
}
}
)

return body
}

const buildElasticAggregationsBody = (params, aggregateProps) => {
// Add an `aggregations` entry to the ES body describing the aggretations
// we want. Set the `size` property to per_page (default 50) for each.
// https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-size
const aggregations = aggregateProps.reduce((aggs, prop) => {
aggs[prop] = AGGREGATIONS_SPEC[prop]
// Only set size for terms aggs for now:
if (aggs[prop].terms) {
aggs[prop].terms.size = params.per_page
}
return aggs
}, {})

return Object.assign(
buildElasticBody(params),
{ size: 0, aggregations }
)
}

/**
* Given a params hash, returns an array of ES queries for fetching relevant aggregations.
*/
const aggregationQueriesForParams = (params) => {
// Build the complete set of distinct aggregation queries we need to run
// depending on active filters. We want:
// - one agg representing the counts for all properties _not_ used in filter
// - one agg each for each property that is used in a filter, but counts should exclude that filter

// Build the standard aggregation:
const unfilteredAggregationProps = Object.keys(AGGREGATIONS_SPEC)
// Aggregate on all properties that aren't involved in filters:
.filter((prop) => !Object.keys(params.filters || {}).includes(prop))
const queries = [buildElasticAggregationsBody(params, unfilteredAggregationProps)]

// Now append all property-specific aggregation queries (one for each
// distinct property used in a filter):
return queries.concat(
Object.entries(params.filters || {})
// Only consider filters that are also aggregations:
.filter(([prop, values]) => Object.keys(AGGREGATIONS_SPEC).includes(prop))
.map(([prop, values]) => {
const aggFilters = structuredClone(params.filters)
// For this aggregation, don't filter on namesake property:
delete aggFilters[prop]

// Build query for single aggregation:
const modifiedParams = Object.assign({}, params, { filters: aggFilters })
return buildElasticAggregationsBody(modifiedParams, [prop])
})
)
}

const bodyForAggregation = (params) => {
const aggregations = {}
aggregations[params.field] = AGGREGATIONS_SPEC[params.field]

// If it's a terms agg, we can apply per_page:
if (aggregations[params.field].terms) {
aggregations[params.field].terms.size = params.per_page
}

return Object.assign(
buildElasticBody(params),
{
size: 0,
aggregations
}
)
}

module.exports = {
bodyForFindByUri,
itemsFilterContext,
itemsQueryContext,
buildElasticQuery,
buildElasticBody,
bodyForSearch,
buildElasticAggregationsBody,
aggregationQueriesForParams,
bodyForAggregation
}
11 changes: 8 additions & 3 deletions lib/elasticsearch/elastic-query-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const ElasticQuery = require('./elastic-query')
const ApiRequest = require('../api-request')
const { escapeQuery, namedQuery, prefixMatch, termMatch, phraseMatch } = require('./utils')
const { regexEscape } = require('../util')
const { innerHits } = require('./elastic-query-filter-builder')

const { FILTER_CONFIG, SEARCH_SCOPES } = require('./config')

Expand All @@ -11,7 +12,7 @@ const POPULARITY_BOOSTS = [
]

class ElasticQueryBuilder {
constructor (apiRequest) {
constructor (apiRequest, options = {}) {
this.request = apiRequest
this.query = new ElasticQuery()

Expand Down Expand Up @@ -44,6 +45,10 @@ class ElasticQueryBuilder {
// Add user filters:
this.applyFilters()

if (options.items) {
this.query.addFilter(innerHits(options.items))
}

// Apply global clauses:
// Hide specific nypl-sources when configured to do so:
this.applyHiddenNyplSources()
Expand Down Expand Up @@ -717,8 +722,8 @@ class ElasticQueryBuilder {
/**
* Create a ElasticQueryBuilder for given ApiRequest instance
*/
static forApiRequest (request) {
return new ElasticQueryBuilder(request)
static forApiRequest (request, options = {}) {
return new ElasticQueryBuilder(request, options)
}
}

Expand Down
Loading
Loading