From 2a15f5a63bbcd7f776162cfe7a80df4cdad194e8 Mon Sep 17 00:00:00 2001 From: Mateusz Rzepa Date: Tue, 25 Nov 2025 09:33:40 +0100 Subject: [PATCH] Add runtime facets support and bump 2.4.2 --- README.md | 28 ++++++++++ package-lock.json | 4 +- package.json | 2 +- src/helpers.js | 2 + src/index.js | 88 ++++++++++++++++++++++++++++++-- src/utils/config.js | 122 ++++++++++++++++++++++++++++++++++++++++++++ tests/searchSpec.js | 41 +++++++++++++++ 7 files changed, 280 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2ce3bf8..974a5f6 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,34 @@ Responsible for defining global configuration. Look for full example here - [con - **`ids`** array of item identifiers to limit the results to. Useful when combining with external full-text search engines (e.g. MiniSearch). +#### Optional runtime facets (DX helper) + +Instead of static `filters` you can pass `facets` with selections and runtime options (per-facet AND/OR, bucket size/sort): + +```js +const result = itemsjs.search({ + query: 'drama', + facets: { + tags: { + selected: ['1980s', 'historical'], + options: { + conjunction: 'OR', // AND/OR for this facet only + size: 30, // how many buckets to return + sortBy: 'count', // 'count' | 'key' + sortDir: 'desc', // 'asc' | 'desc' + hideZero: true, // hide buckets with doc_count = 0 + chosenOnTop: true, // selected buckets first + }, + }, + }, +}); +// response contains data.aggregations and an alias data.facets +``` + +`facets` is an alias/helper: under the hood it builds `filters_query` per facet (AND/OR) and applies bucket options. If you also pass legacy params, priority is: `filters_query` > `facets` > `filters`. + +Ideal for React/Vue/Next UIs that need runtime toggles (AND/OR, “show more”, bucket sorting) without recreating the engine. + ### `itemsjs.aggregation(options)` It returns full list of filters for specific aggregation diff --git a/package-lock.json b/package-lock.json index e22e768..4b1cdf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "itemsjs", - "version": "2.4.1", + "version": "2.4.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "itemsjs", - "version": "2.4.1", + "version": "2.4.2", "license": "Apache-2.0", "devDependencies": { "boolean-parser": "^0.0.2", diff --git a/package.json b/package.json index 199776b..1331c2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "itemsjs", - "version": "2.4.1", + "version": "2.4.2", "description": "Created to perform fast search on small json dataset (up to 1000 elements).", "type": "module", "scripts": { diff --git a/src/helpers.js b/src/helpers.js index 4f57ef4..1d6148b 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -12,4 +12,6 @@ export { mergeAggregations, input_to_facet_filters, parse_boolean_query, + buildFiltersQueryFromFacets, + normalizeRuntimeFacetConfig, } from './utils/config.js'; diff --git a/src/index.js b/src/index.js index 5ce8ea6..5925d7b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,9 @@ import { search, similar, aggregation } from './lib.js'; -import { mergeAggregations } from './helpers.js'; +import { + mergeAggregations, + buildFiltersQueryFromFacets, + normalizeRuntimeFacetConfig, +} from './helpers.js'; import { Fulltext } from './fulltext.js'; import { Facets } from './facets.js'; @@ -28,12 +32,64 @@ function itemsjs(items, configuration) { search: function (input) { input = input || Object.create(null); + // allow runtime facet options via input.facets (alias for aggregations/filters) + let effectiveConfiguration = configuration; + if (input.facets) { + const { aggregations, filters } = normalizeRuntimeFacetConfig( + input.facets, + configuration, + ); + + effectiveConfiguration = { + ...configuration, + aggregations, + }; + + // merge filters so buckets can mark selected values + if (filters) { + input.filters = { + ...(input.filters || {}), + ...filters, + }; + } + + if (!input.filters_query) { + const filters_query = buildFiltersQueryFromFacets( + input.facets, + effectiveConfiguration, + ); + if (filters_query) { + input.filters_query = filters_query; + } + } + + // Facets instance keeps reference to config; update for this run + facets.config = effectiveConfiguration.aggregations; + } else { + facets.config = configuration.aggregations; + } + /** * merge configuration aggregation with user input */ - input.aggregations = mergeAggregations(configuration.aggregations, input); + input.aggregations = mergeAggregations( + effectiveConfiguration.aggregations, + input, + ); + + const result = search( + items, + input, + effectiveConfiguration, + fulltext, + facets, + ); - return search(items, input, configuration, fulltext, facets); + if (result?.data?.aggregations && !result.data.facets) { + result.data.facets = result.data.aggregations; + } + + return result; }, /** @@ -52,7 +108,31 @@ function itemsjs(items, configuration) { * page */ aggregation: function (input) { - return aggregation(items, input, configuration, fulltext, facets); + let aggregationConfiguration = configuration; + + if (input?.facets) { + const { aggregations } = normalizeRuntimeFacetConfig( + input.facets, + configuration, + ); + + aggregationConfiguration = { + ...configuration, + aggregations, + }; + + facets.config = aggregationConfiguration.aggregations; + } else { + facets.config = configuration.aggregations; + } + + return aggregation( + items, + input, + aggregationConfiguration, + fulltext, + facets, + ); }, /** diff --git a/src/utils/config.js b/src/utils/config.js index c821d07..1ef039c 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -67,3 +67,125 @@ export const parse_boolean_query = function (query) { } }); }; + +/** + * Builds a boolean query string from runtime facet selections. + * Respects per-facet conjunction (AND/OR). Unknown facets are ignored. + */ +export const buildFiltersQueryFromFacets = function (facets, configuration) { + if (!facets || typeof facets !== 'object') { + return; + } + + const aggregations = (configuration && configuration.aggregations) || {}; + const expressions = []; + + Object.keys(facets).forEach((facetName) => { + if (!aggregations[facetName]) { + return; + } + + const selected = facets[facetName]?.selected || []; + if (!Array.isArray(selected) || selected.length === 0) { + return; + } + + const conjunction = + facets[facetName]?.options?.conjunction === 'OR' ? 'OR' : 'AND'; + + const parts = selected.map((val) => { + const stringVal = String(val); + if (stringVal.includes(' ') || stringVal.includes(':')) { + return `${facetName}:"${stringVal.replace(/"/g, '\\"')}"`; + } + return `${facetName}:${stringVal}`; + }); + + let expr; + if (conjunction === 'OR') { + expr = parts.length > 1 ? `(${parts.join(' OR ')})` : parts[0]; + } else { + expr = parts.join(' AND '); + } + + expressions.push(expr); + }); + + if (!expressions.length) { + return; + } + + return expressions.join(' AND '); +}; + +/** + * Builds per-facet filters and temporary aggregation overrides based on runtime options. + */ +export const normalizeRuntimeFacetConfig = function (facets, configuration) { + const baseAggregations = (configuration && configuration.aggregations) || {}; + const filters = Object.create(null); + let hasFilters = false; + + const newAggregations = { ...baseAggregations }; + + Object.keys(facets || {}).forEach((facetName) => { + const facetConfig = baseAggregations[facetName]; + if (!facetConfig) { + return; + } + + const selected = facets[facetName]?.selected; + if (Array.isArray(selected) && selected.length) { + filters[facetName] = selected; + hasFilters = true; + } + + const options = facets[facetName]?.options; + if (options) { + const mapped = {}; + + if (options.conjunction) { + mapped.conjunction = options.conjunction !== 'OR'; + } + + if (typeof options.size === 'number') { + mapped.size = options.size; + } + + if (options.sortBy === 'key') { + mapped.sort = 'key'; + mapped.order = options.sortDir || facetConfig.order; + } else if (options.sortBy === 'count') { + mapped.sort = undefined; + mapped.order = options.sortDir || facetConfig.order; + } else if (options.sortDir) { + mapped.order = options.sortDir; + } + + if (typeof options.hideZero === 'boolean') { + mapped.hide_zero_doc_count = options.hideZero; + } + + if (typeof options.chosenOnTop === 'boolean') { + mapped.chosen_filters_on_top = options.chosenOnTop; + } + + if (typeof options.showStats === 'boolean') { + mapped.show_facet_stats = options.showStats; + } + + if (Object.keys(mapped).length) { + newAggregations[facetName] = { + ...baseAggregations[facetName], + ...mapped, + }; + } + } + }); + + return { + hasFilters, + filters: hasFilters ? filters : undefined, + aggregations: newAggregations, + }; +}; diff --git a/tests/searchSpec.js b/tests/searchSpec.js index b637a3f..96b6309 100644 --- a/tests/searchSpec.js +++ b/tests/searchSpec.js @@ -310,6 +310,47 @@ describe('search', function () { done(); }); + it('supports runtime facets overrides (OR + size, alias facets in response)', function test(done) { + const itemsjs = itemsJS(items, configuration); + + const result = itemsjs.search({ + facets: { + tags: { + selected: ['c', 'e'], + options: { + conjunction: 'OR', + size: 2, + sortBy: 'key', + sortDir: 'asc', + hideZero: true, + chosenOnTop: true, + }, + }, + }, + }); + + // default conjunction (AND) would return 0 for ['c','e'], OR should return matches + assert.equal(result.pagination.total, 4); + + // alias facets present + assert.ok(result.data.facets); + assert.equal( + result.data.facets, + result.data.aggregations + ); + + const buckets = result.data.aggregations.tags.buckets; + // size override applied + assert.equal(buckets.length, 2); + // selected value should be marked + assert.equal( + buckets.some((b) => b.key === 'c' && b.selected === true), + true, + ); + + done(); + }); + it('makes search with non existing filter value with conjunction true should return no results', function test(done) { const itemsjs = itemsJS(items, configuration);