Skip to content

Commit 2a15f5a

Browse files
committed
Add runtime facets support and bump 2.4.2
1 parent 516f614 commit 2a15f5a

File tree

7 files changed

+280
-7
lines changed

7 files changed

+280
-7
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,34 @@ Responsible for defining global configuration. Look for full example here - [con
230230

231231
- **`ids`** array of item identifiers to limit the results to. Useful when combining with external full-text search engines (e.g. MiniSearch).
232232

233+
#### Optional runtime facets (DX helper)
234+
235+
Instead of static `filters` you can pass `facets` with selections and runtime options (per-facet AND/OR, bucket size/sort):
236+
237+
```js
238+
const result = itemsjs.search({
239+
query: 'drama',
240+
facets: {
241+
tags: {
242+
selected: ['1980s', 'historical'],
243+
options: {
244+
conjunction: 'OR', // AND/OR for this facet only
245+
size: 30, // how many buckets to return
246+
sortBy: 'count', // 'count' | 'key'
247+
sortDir: 'desc', // 'asc' | 'desc'
248+
hideZero: true, // hide buckets with doc_count = 0
249+
chosenOnTop: true, // selected buckets first
250+
},
251+
},
252+
},
253+
});
254+
// response contains data.aggregations and an alias data.facets
255+
```
256+
257+
`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`.
258+
259+
Ideal for React/Vue/Next UIs that need runtime toggles (AND/OR, “show more”, bucket sorting) without recreating the engine.
260+
233261
### `itemsjs.aggregation(options)`
234262

235263
It returns full list of filters for specific aggregation

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "itemsjs",
3-
"version": "2.4.1",
3+
"version": "2.4.2",
44
"description": "Created to perform fast search on small json dataset (up to 1000 elements).",
55
"type": "module",
66
"scripts": {

src/helpers.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,6 @@ export {
1212
mergeAggregations,
1313
input_to_facet_filters,
1414
parse_boolean_query,
15+
buildFiltersQueryFromFacets,
16+
normalizeRuntimeFacetConfig,
1517
} from './utils/config.js';

src/index.js

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { search, similar, aggregation } from './lib.js';
2-
import { mergeAggregations } from './helpers.js';
2+
import {
3+
mergeAggregations,
4+
buildFiltersQueryFromFacets,
5+
normalizeRuntimeFacetConfig,
6+
} from './helpers.js';
37
import { Fulltext } from './fulltext.js';
48
import { Facets } from './facets.js';
59

@@ -28,12 +32,64 @@ function itemsjs(items, configuration) {
2832
search: function (input) {
2933
input = input || Object.create(null);
3034

35+
// allow runtime facet options via input.facets (alias for aggregations/filters)
36+
let effectiveConfiguration = configuration;
37+
if (input.facets) {
38+
const { aggregations, filters } = normalizeRuntimeFacetConfig(
39+
input.facets,
40+
configuration,
41+
);
42+
43+
effectiveConfiguration = {
44+
...configuration,
45+
aggregations,
46+
};
47+
48+
// merge filters so buckets can mark selected values
49+
if (filters) {
50+
input.filters = {
51+
...(input.filters || {}),
52+
...filters,
53+
};
54+
}
55+
56+
if (!input.filters_query) {
57+
const filters_query = buildFiltersQueryFromFacets(
58+
input.facets,
59+
effectiveConfiguration,
60+
);
61+
if (filters_query) {
62+
input.filters_query = filters_query;
63+
}
64+
}
65+
66+
// Facets instance keeps reference to config; update for this run
67+
facets.config = effectiveConfiguration.aggregations;
68+
} else {
69+
facets.config = configuration.aggregations;
70+
}
71+
3172
/**
3273
* merge configuration aggregation with user input
3374
*/
34-
input.aggregations = mergeAggregations(configuration.aggregations, input);
75+
input.aggregations = mergeAggregations(
76+
effectiveConfiguration.aggregations,
77+
input,
78+
);
79+
80+
const result = search(
81+
items,
82+
input,
83+
effectiveConfiguration,
84+
fulltext,
85+
facets,
86+
);
3587

36-
return search(items, input, configuration, fulltext, facets);
88+
if (result?.data?.aggregations && !result.data.facets) {
89+
result.data.facets = result.data.aggregations;
90+
}
91+
92+
return result;
3793
},
3894

3995
/**
@@ -52,7 +108,31 @@ function itemsjs(items, configuration) {
52108
* page
53109
*/
54110
aggregation: function (input) {
55-
return aggregation(items, input, configuration, fulltext, facets);
111+
let aggregationConfiguration = configuration;
112+
113+
if (input?.facets) {
114+
const { aggregations } = normalizeRuntimeFacetConfig(
115+
input.facets,
116+
configuration,
117+
);
118+
119+
aggregationConfiguration = {
120+
...configuration,
121+
aggregations,
122+
};
123+
124+
facets.config = aggregationConfiguration.aggregations;
125+
} else {
126+
facets.config = configuration.aggregations;
127+
}
128+
129+
return aggregation(
130+
items,
131+
input,
132+
aggregationConfiguration,
133+
fulltext,
134+
facets,
135+
);
56136
},
57137

58138
/**

src/utils/config.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,125 @@ export const parse_boolean_query = function (query) {
6767
}
6868
});
6969
};
70+
71+
/**
72+
* Builds a boolean query string from runtime facet selections.
73+
* Respects per-facet conjunction (AND/OR). Unknown facets are ignored.
74+
*/
75+
export const buildFiltersQueryFromFacets = function (facets, configuration) {
76+
if (!facets || typeof facets !== 'object') {
77+
return;
78+
}
79+
80+
const aggregations = (configuration && configuration.aggregations) || {};
81+
const expressions = [];
82+
83+
Object.keys(facets).forEach((facetName) => {
84+
if (!aggregations[facetName]) {
85+
return;
86+
}
87+
88+
const selected = facets[facetName]?.selected || [];
89+
if (!Array.isArray(selected) || selected.length === 0) {
90+
return;
91+
}
92+
93+
const conjunction =
94+
facets[facetName]?.options?.conjunction === 'OR' ? 'OR' : 'AND';
95+
96+
const parts = selected.map((val) => {
97+
const stringVal = String(val);
98+
if (stringVal.includes(' ') || stringVal.includes(':')) {
99+
return `${facetName}:"${stringVal.replace(/"/g, '\\"')}"`;
100+
}
101+
return `${facetName}:${stringVal}`;
102+
});
103+
104+
let expr;
105+
if (conjunction === 'OR') {
106+
expr = parts.length > 1 ? `(${parts.join(' OR ')})` : parts[0];
107+
} else {
108+
expr = parts.join(' AND ');
109+
}
110+
111+
expressions.push(expr);
112+
});
113+
114+
if (!expressions.length) {
115+
return;
116+
}
117+
118+
return expressions.join(' AND ');
119+
};
120+
121+
/**
122+
* Builds per-facet filters and temporary aggregation overrides based on runtime options.
123+
*/
124+
export const normalizeRuntimeFacetConfig = function (facets, configuration) {
125+
const baseAggregations = (configuration && configuration.aggregations) || {};
126+
const filters = Object.create(null);
127+
let hasFilters = false;
128+
129+
const newAggregations = { ...baseAggregations };
130+
131+
Object.keys(facets || {}).forEach((facetName) => {
132+
const facetConfig = baseAggregations[facetName];
133+
if (!facetConfig) {
134+
return;
135+
}
136+
137+
const selected = facets[facetName]?.selected;
138+
if (Array.isArray(selected) && selected.length) {
139+
filters[facetName] = selected;
140+
hasFilters = true;
141+
}
142+
143+
const options = facets[facetName]?.options;
144+
if (options) {
145+
const mapped = {};
146+
147+
if (options.conjunction) {
148+
mapped.conjunction = options.conjunction !== 'OR';
149+
}
150+
151+
if (typeof options.size === 'number') {
152+
mapped.size = options.size;
153+
}
154+
155+
if (options.sortBy === 'key') {
156+
mapped.sort = 'key';
157+
mapped.order = options.sortDir || facetConfig.order;
158+
} else if (options.sortBy === 'count') {
159+
mapped.sort = undefined;
160+
mapped.order = options.sortDir || facetConfig.order;
161+
} else if (options.sortDir) {
162+
mapped.order = options.sortDir;
163+
}
164+
165+
if (typeof options.hideZero === 'boolean') {
166+
mapped.hide_zero_doc_count = options.hideZero;
167+
}
168+
169+
if (typeof options.chosenOnTop === 'boolean') {
170+
mapped.chosen_filters_on_top = options.chosenOnTop;
171+
}
172+
173+
if (typeof options.showStats === 'boolean') {
174+
mapped.show_facet_stats = options.showStats;
175+
}
176+
177+
if (Object.keys(mapped).length) {
178+
newAggregations[facetName] = {
179+
...baseAggregations[facetName],
180+
...mapped,
181+
};
182+
}
183+
}
184+
});
185+
186+
return {
187+
hasFilters,
188+
filters: hasFilters ? filters : undefined,
189+
aggregations: newAggregations,
190+
};
191+
};

tests/searchSpec.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,47 @@ describe('search', function () {
310310
done();
311311
});
312312

313+
it('supports runtime facets overrides (OR + size, alias facets in response)', function test(done) {
314+
const itemsjs = itemsJS(items, configuration);
315+
316+
const result = itemsjs.search({
317+
facets: {
318+
tags: {
319+
selected: ['c', 'e'],
320+
options: {
321+
conjunction: 'OR',
322+
size: 2,
323+
sortBy: 'key',
324+
sortDir: 'asc',
325+
hideZero: true,
326+
chosenOnTop: true,
327+
},
328+
},
329+
},
330+
});
331+
332+
// default conjunction (AND) would return 0 for ['c','e'], OR should return matches
333+
assert.equal(result.pagination.total, 4);
334+
335+
// alias facets present
336+
assert.ok(result.data.facets);
337+
assert.equal(
338+
result.data.facets,
339+
result.data.aggregations
340+
);
341+
342+
const buckets = result.data.aggregations.tags.buckets;
343+
// size override applied
344+
assert.equal(buckets.length, 2);
345+
// selected value should be marked
346+
assert.equal(
347+
buckets.some((b) => b.key === 'c' && b.selected === true),
348+
true,
349+
);
350+
351+
done();
352+
});
353+
313354
it('makes search with non existing filter value with conjunction true should return no results', function test(done) {
314355
const itemsjs = itemsJS(items, configuration);
315356

0 commit comments

Comments
 (0)