From 4fe93e72242adbfbcd6917f8f21cf676ec67220c Mon Sep 17 00:00:00 2001 From: Mateusz Rzepa Date: Tue, 25 Nov 2025 08:22:55 +0100 Subject: [PATCH] Bump 2.4.1 and harden pagination inputs --- package-lock.json | 4 +-- package.json | 2 +- src/lib.js | 58 ++++++++++++++++++++++++++---- src/utils/facetsCore.js | 4 ++- tests/browserifySpec.js | 18 ++++++++++ tests/searchSpec.js | 80 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 155 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 30e4bb6..e22e768 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "itemsjs", - "version": "2.4.0", + "version": "2.4.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "itemsjs", - "version": "2.4.0", + "version": "2.4.1", "license": "Apache-2.0", "devDependencies": { "boolean-parser": "^0.0.2", diff --git a/package.json b/package.json index b0a0ce0..199776b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "itemsjs", - "version": "2.4.0", + "version": "2.4.1", "description": "Created to perform fast search on small json dataset (up to 1000 elements).", "type": "module", "scripts": { diff --git a/src/lib.js b/src/lib.js index 0c5ddfb..2c99efb 100644 --- a/src/lib.js +++ b/src/lib.js @@ -8,8 +8,28 @@ import { getBuckets, clone } from './helpers.js'; export function search(items, input, configuration, fulltext, facets) { input = input || Object.create(null); - const per_page = parseInt(input.per_page || 12); - const page = parseInt(input.page || 1); + const normalizeNumber = (value) => { + if (typeof value === 'number') { + return value; + } + const parsed = parseInt(value, 10); + return parsed; + }; + + let per_page = normalizeNumber(input.per_page); + if (!Number.isFinite(per_page) || per_page < 0) { + per_page = 12; + } + + let page = normalizeNumber(input.page); + if (!Number.isFinite(page) || page < 1) { + page = 1; + } + + // Allow per_page to be zero to support queries that only need aggregations + if (per_page === 0) { + page = 1; + } const is_all_filtered_items = input.is_all_filtered_items || false; if (configuration.native_search_enabled === false && input.query) { @@ -178,6 +198,7 @@ export function sorted_items(items, sort, sortings) { * useful for autocomplete or list all aggregation options */ export function similar(items, id, options) { + options = options || Object.create(null); const per_page = options.per_page || 10; const minimum = options.minimum || 0; const page = options.page || 1; @@ -191,6 +212,19 @@ export function similar(items, id, options) { } } + if (!item) { + return { + pagination: { + per_page: per_page, + page: page, + total: 0, + }, + data: { + items: [], + }, + }; + } + if (!options.field) { throw new Error('Please define field in options'); } @@ -203,9 +237,10 @@ export function similar(items, id, options) { const intersection = _intersection(item[field], items[i][field]); if (intersection.length >= minimum) { - sorted_items.push(items[i]); - sorted_items[sorted_items.length - 1].intersection_length = - intersection.length; + sorted_items.push({ + ...items[i], + intersection_length: intersection.length, + }); } } } @@ -250,9 +285,18 @@ export function aggregation(items, input, configuration, fulltext, facets) { throw new Error('field name is required'); } - configuration.aggregations[input.name].size = 10000; + const aggregationConfig = { + ...configuration, + aggregations: { + ...configuration.aggregations, + [input.name]: { + ...configuration.aggregations[input.name], + size: 10000, + }, + }, + }; - const result = search(items, search_input, configuration, fulltext, facets); + const result = search(items, search_input, aggregationConfig, fulltext, facets); const buckets = result.data.aggregations[input.name].buckets; return { diff --git a/src/utils/facetsCore.js b/src/utils/facetsCore.js index 0b40e6b..1d95cd8 100644 --- a/src/utils/facetsCore.js +++ b/src/utils/facetsCore.js @@ -135,7 +135,9 @@ export const matrix = function (facets, filters = []) { filters.forEach((filter) => { if (filter.length === 3 && filter[1] === '-') { const [filter_key, , filter_val] = filter; - const negative_bits = temp_facet.bits_data_temp[filter_key][filter_val].clone(); + const negative_bits = + temp_facet.bits_data_temp[filter_key]?.[filter_val]?.clone() || + new FastBitSet(); for (const key in temp_facet.bits_data_temp) { for (const key2 in temp_facet.bits_data_temp[key]) { diff --git a/tests/browserifySpec.js b/tests/browserifySpec.js index e49accc..681b196 100644 --- a/tests/browserifySpec.js +++ b/tests/browserifySpec.js @@ -335,6 +335,24 @@ describe('itemjs general tests', function () { done(); }); + it('returns empty list when similar base item is missing', function test(done) { + const itemsWithIds = items.map((item, idx) => ({ + ...item, + id: idx + 1, + })); + const itemsjs = itemsJS(itemsWithIds, { + aggregations: { + tags: {}, + }, + }); + + const result = itemsjs.similar(999, { field: 'tags', per_page: 5 }); + + assert.equal(result.pagination.total, 0); + assert.deepEqual(result.data.items, []); + done(); + }); + it('search by tags', function test(done) { const items = [ { diff --git a/tests/searchSpec.js b/tests/searchSpec.js index 9edc48b..b637a3f 100644 --- a/tests/searchSpec.js +++ b/tests/searchSpec.js @@ -200,6 +200,20 @@ describe('search', function () { done(); }); + it('ignores not filters for values not present in index', function test(done) { + const itemsjs = itemsJS(items, configuration); + + const result = itemsjs.search({ + not_filters: { + tags: ['not-existing'], + }, + }); + + assert.equal(result.data.items.length, 4); + + done(); + }); + it('marks boolean facets as selected', function test(done) { const dataset = [ { boolean: true, string: 'true' }, @@ -230,6 +244,72 @@ describe('search', function () { done(); }); + it('aggregation does not mutate configured facet size', function test(done) { + const smallDataset = [ + { id: 1, tags: ['a'] }, + { id: 2, tags: ['b'] }, + { id: 3, tags: ['c'] }, + ]; + + const itemsjs = itemsJS(smallDataset, { + aggregations: { + tags: { size: 1 }, + }, + }); + + const initial = itemsjs.search({}); + assert.equal(initial.data.aggregations.tags.buckets.length, 1); + + const aggResult = itemsjs.aggregation({ name: 'tags', per_page: 10 }); + assert.equal(aggResult.data.buckets.length, 3); + + const after = itemsjs.search({}); + assert.equal(after.data.aggregations.tags.buckets.length, 1); + + done(); + }); + + it('normalizes pagination values to safe defaults', function test(done) { + const dataset = Array.from({ length: 15 }, (_, idx) => ({ + id: idx + 1, + tags: ['t' + (idx % 3)], + })); + + const itemsjs = itemsJS(dataset, { + aggregations: { + tags: {}, + }, + }); + + const result = itemsjs.search({ + per_page: Infinity, + page: -5, + }); + + assert.equal(result.pagination.page, 1); + assert.equal(result.pagination.per_page, 12); + assert.equal(result.data.items.length, 12); + + const resultString = itemsjs.search({ + per_page: 'abc', + page: 'not-a-number', + }); + + assert.equal(resultString.pagination.page, 1); + assert.equal(resultString.pagination.per_page, 12); + assert.equal(resultString.data.items.length, 12); + + const zeroPerPage = itemsjs.search({ + per_page: 0, + page: 3, + }); + assert.equal(zeroPerPage.pagination.per_page, 0); + assert.equal(zeroPerPage.pagination.page, 1); + assert.equal(zeroPerPage.data.items.length, 0); + + done(); + }); + it('makes search with non existing filter value with conjunction true should return no results', function test(done) { const itemsjs = itemsJS(items, configuration);