Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ export {
mergeAggregations,
input_to_facet_filters,
parse_boolean_query,
buildFiltersQueryFromFacets,
normalizeRuntimeFacetConfig,
} from './utils/config.js';
88 changes: 84 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
},

/**
Expand All @@ -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,
);
},

/**
Expand Down
122 changes: 122 additions & 0 deletions src/utils/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
};
41 changes: 41 additions & 0 deletions tests/searchSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down