Commit 78ff2f3a authored by Robert Knight's avatar Robert Knight

Add tests for parsing of empty queries and remove redundant checks

This commit started out as a fix for a typecheck error in TypeScript 4.9.3 in
`search-filter.js` that led to a discovery of missing tests for handling of
empty inputs and redundant checks for empty inputs in the implementation.

Add tests for empty queries and clean up the implementation.
parent dd9ffa02
...@@ -34,55 +34,54 @@ function splitTerm(term) { ...@@ -34,55 +34,54 @@ function splitTerm(term) {
} }
/** /**
* Tokenize a search query.
*
* Splits `searchText` into tokens, separated by spaces.
* Quoted phrases in `searchText` are returned as a single token.
*
* @param {string} searchText
* @return {string[]}
*/
function tokenize(searchText) {
if (!searchText) {
return [];
}
/**
* Remove a quote character from the beginning and end of the string, but * Remove a quote character from the beginning and end of the string, but
* only if they match. ie: * only if they match. ie:
* *
- * 'foo' -> foo -* 'foo' -> foo
- * "bar" -> bar -* "bar" -> bar
- * 'foo" -> 'foo" -* 'foo" -> 'foo"
- * bar" -> bar" -* bar" -> bar"
* *
* @param {string} text * @param {string} text
*/ */
const removeQuoteCharacter = text => { function removeSurroundingQuotes(text) {
const start = text.slice(0, 1); const start = text.slice(0, 1);
const end = text.slice(-1); const end = text.slice(-1);
if ((start === '"' || start === "'") && start === end) { if ((start === '"' || start === "'") && start === end) {
text = text.slice(1, text.length - 1); text = text.slice(1, text.length - 1);
} }
return text; return text;
}; }
let tokens = searchText.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
// Cut the opening and closing quote characters /**
tokens = tokens.map(removeQuoteCharacter); * Tokenize a search query.
*
* Split `searchText` into an array of non-empty tokens. Terms not contained
* within quotes are split on whitespace. Terms inside single or double quotes
* are returned as whole tokens, with the surrounding quotes removed.
*
* @param {string} searchText
* @return {string[]}
*/
function tokenize(searchText) {
const tokenMatches = searchText.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g);
if (!tokenMatches) {
return [];
}
// Remove quotes for power search. return tokenMatches
// I.e. 'tag:"foo bar"' -> 'tag:foo bar' .map(removeSurroundingQuotes)
for (let index = 0; index < tokens.length; index++) { .filter(token => token.length > 0)
const token = tokens[index]; .map(token => {
// Strip quotes from field values.
// eg. `tag:"foo bar"` => `tag:foo bar`.
const [filter, data] = splitTerm(token); const [filter, data] = splitTerm(token);
if (filter) { if (filter) {
tokens[index] = filter + ':' + removeQuoteCharacter(data); return filter + ':' + removeSurroundingQuotes(data);
} } else {
return token;
} }
});
return tokens;
} }
/** /**
...@@ -98,7 +97,6 @@ export function toObject(searchText) { ...@@ -98,7 +97,6 @@ export function toObject(searchText) {
/** @param {string} field */ /** @param {string} field */
const backendFilter = field => (field === 'tag' ? 'tags' : field); const backendFilter = field => (field === 'tag' ? 'tags' : field);
if (searchText) {
const terms = tokenize(searchText); const terms = tokenize(searchText);
for (const term of terms) { for (const term of terms) {
let [field, data] = splitTerm(term); let [field, data] = splitTerm(term);
...@@ -114,7 +112,7 @@ export function toObject(searchText) { ...@@ -114,7 +112,7 @@ export function toObject(searchText) {
obj[backendField] = [data]; obj[backendField] = [data];
} }
} }
}
return obj; return obj;
} }
...@@ -142,7 +140,6 @@ export function toObject(searchText) { ...@@ -142,7 +140,6 @@ export function toObject(searchText) {
* @return {Record<string,Facet>} * @return {Record<string,Facet>}
*/ */
export function generateFacetedFilter(searchText, focusFilters = {}) { export function generateFacetedFilter(searchText, focusFilters = {}) {
let terms;
const any = []; const any = [];
const quote = []; const quote = [];
const since = []; const since = [];
...@@ -150,8 +147,9 @@ export function generateFacetedFilter(searchText, focusFilters = {}) { ...@@ -150,8 +147,9 @@ export function generateFacetedFilter(searchText, focusFilters = {}) {
const text = []; const text = [];
const uri = []; const uri = [];
const user = focusFilters.user ? [focusFilters.user] : []; const user = focusFilters.user ? [focusFilters.user] : [];
if (searchText) {
terms = tokenize(searchText); const terms = tokenize(searchText);
for (const term of terms) { for (const term of terms) {
const filter = term.slice(0, term.indexOf(':')); const filter = term.slice(0, term.indexOf(':'));
const fieldValue = term.slice(filter.length + 1); const fieldValue = term.slice(filter.length + 1);
...@@ -201,7 +199,6 @@ export function generateFacetedFilter(searchText, focusFilters = {}) { ...@@ -201,7 +199,6 @@ export function generateFacetedFilter(searchText, focusFilters = {}) {
any.push(term); any.push(term);
} }
} }
}
return { return {
any: { any: {
......
import * as searchFilter from '../search-filter'; import * as searchFilter from '../search-filter';
describe('sidebar/util/search-filter', () => { describe('sidebar/util/search-filter', () => {
function isEmptyFilter(filter) {
return Object.values(filter).every(value => value.length === 0);
}
describe('toObject', () => { describe('toObject', () => {
it('puts a simple search string under the any filter', () => { it('puts a simple search string under the "any" filter', () => {
const query = 'foo'; const query = 'foo';
const result = searchFilter.toObject(query); const result = searchFilter.toObject(query);
assert.equal(result.any[0], query); assert.equal(result.any[0], query);
}); });
it('returns an empty filter if input query is empty', () => {
// Verify `isEmptyFilter` returns false for non-empty query.
assert.isFalse(isEmptyFilter(searchFilter.toObject('some query')));
// Now check various queries which should produce empty filters
for (let emptyQuery of ['', '""', "''", ' ']) {
const result = searchFilter.toObject(emptyQuery);
assert.isTrue(
isEmptyFilter(result),
`expected "${emptyQuery}" to produce empty filter`
);
}
});
it('uses the filters as keys in the result object', () => { it('uses the filters as keys in the result object', () => {
const query = 'user:john text:foo quote:bar group:agroup other'; const query = 'user:john text:foo quote:bar group:agroup other';
const result = searchFilter.toObject(query); const result = searchFilter.toObject(query);
...@@ -74,6 +92,25 @@ describe('sidebar/util/search-filter', () => { ...@@ -74,6 +92,25 @@ describe('sidebar/util/search-filter', () => {
describe('generateFacetedFilter', () => { describe('generateFacetedFilter', () => {
[ [
// Empty queries.
{
query: '',
expectedFilter: {},
},
{
query: ' ',
expectedFilter: {},
},
{
query: '""',
expectedFilter: {},
},
{
query: "''",
expectedFilter: {},
},
// Simple queries without term filters.
{ {
query: 'one two three', query: 'one two three',
expectedFilter: { expectedFilter: {
...@@ -83,6 +120,8 @@ describe('sidebar/util/search-filter', () => { ...@@ -83,6 +120,8 @@ describe('sidebar/util/search-filter', () => {
}, },
}, },
}, },
// Queries with term filters.
{ {
query: 'tag:foo tag:bar', query: 'tag:foo tag:bar',
expectedFilter: { expectedFilter: {
...@@ -185,7 +224,7 @@ describe('sidebar/util/search-filter', () => { ...@@ -185,7 +224,7 @@ describe('sidebar/util/search-filter', () => {
}); });
it('filters to a focused user', () => { it('filters to a focused user', () => {
const filter = searchFilter.generateFacetedFilter(null, { const filter = searchFilter.generateFacetedFilter('', {
user: 'fakeusername', user: 'fakeusername',
}); });
// Remove empty facets. // Remove empty facets.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment