Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: fixed return type when withScore is passed + optimized readability and speed of search function + fixed bug when withScore is true and search text is empty #69

Merged
merged 1 commit into from
Sep 15, 2023
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
55 changes: 43 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
![npm](https://img.shields.io/npm/v/ss-search?style=flat-square)
![npm bundle size](https://img.shields.io/bundlephobia/minzip/ss-search?style=flat-square)
![Travis (.org)](https://img.shields.io/travis/yann510/ss-search?style=flat-square)
![build](https://github.com/yann510/ss-search/actions/workflows/publish-package.yml/badge.svg)
![Coveralls github](https://img.shields.io/coveralls/github/yann510/ss-search?style=flat-square)
[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg?style=flat-square)](http://commitizen.github.io/cz-cli/)
[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg?style=flat-square)](https://github.com/semantic-release/semantic-release)
Expand All @@ -11,22 +11,25 @@

### Stop searching, start finding.

- Easy to use; will return you appropriate results out of the box, no need to configure anything
- Search local array of objects as easily as never before
- Automatic indexing
- Will return exactly what you are looking for
- Very small size; only depends on 5 lodash functions which are extracted using rollup, which means we have a zero dependency library
- **Ease of Use**: Get appropriate results instantly without any configuration.
- **Local Array Search**: Effortlessly search through a local array of objects.
- **Automatic Indexing**: No manual indexing required.
- **Precision**: Always get exactly what you're looking for.
- **Lightweight**: Depends on just 5 lodash functions for a minimized size after tree shaking.

## Demo

![](demo.gif)

If you're not convinced yet, take a look at this interactive
[demo](https://ss-search.netlify.app/).
Still not convinced? Experience its power firsthand with this interactive [demo](https://ss-search.netlify.app/).

### Benchmark

How does it compare to other search libraries? Test out for yourself with this interactive [benchmark](https://ss-search.netlify.app/benchmark) ;)

## Install

ss-search is available on [npm](https://www.npmjs.com/package/ss-search). It can be installed with the following command:
ss-search is available on [npm](https://www.npmjs.com/package/ss-search). Install it with:

`npm install ss-search`

Expand Down Expand Up @@ -54,7 +57,7 @@ const results = search(data, searchKeys, searchText)
// results: [{ number: 1, text: "A search function should be fast" }]
```

Yes. It is that simple, no need to configure anything, it works out of the box.
It's that straightforward. No configurations, it just works.

### Data Types

Expand Down Expand Up @@ -137,9 +140,37 @@ const results = search(data, ['arrayObjects[arrayObjectProperty]'], 'arrayObject
// if we had searched for "value object" we would of had the original dataset
```

### Benchmark
### Options for the `search` function

How does it compare to other search libraries? Test out for yourself with this interactive [benchmark](https://ss-search.netlify.app/benchmark) ;)
Customize your search experience using the following options:

| Option parameter | Value | Description |
| ---------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `withScore` | `true` | When set to `true`, the search function will return an array of objects, each containing the matched element and its corresponding score. The score represents how closely the element matches the search text, with a higher score indicating a closer match. Even if the search doesn't match, it will return a score of 0. |
| `withScore` | `false` | When set to `false` or not provided, the function will return an array of matched elements without their scores. |

### Example Usage

Without `withScore` option:

```javascript
const data = [{ name: 'John' }, { name: 'Jane' }, { name: 'Doe' }]
const result = search(data, ['name'], 'John')
console.log(result) // [{ name: 'John' }]
```

With `withScore` option:

```javascript
const data = [{ name: 'John' }, { name: 'Jane' }, { name: 'Doe' }]
const result = search(data, ['name'], 'John', { withScore: true })
console.log(result)
// [
// { element: { name: 'John' }, score: 1 },
// { element: { name: 'Jane' }, score: 0 },
// { element: { name: 'Doe' }, score: 0 }
// ]
```

![](benchmark.gif)

Expand Down
2 changes: 1 addition & 1 deletion ss-search/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"assets": [],
"project": "ss-search/package.json",
"compiler": "swc",
"format": ["cjs", "esm"],
"format": ["cjs", "esm"]
}
},
"lint": {
Expand Down
15 changes: 15 additions & 0 deletions ss-search/src/lib/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,19 @@ describe('#search', () => {
// Assert
expect(actual[0]).toEqual(data[4])
})

test('Should return score property even when no search text is provided with withScore option', () => {
// Arrange
const data = dataset;
const keys = Object.keys(data[0]);
const searchText = '';

// Act
const actual = search(data, keys, searchText, { withScore: true });

// Assert
actual.forEach(result => {
expect(result).toHaveProperty('score');
});
});
})
53 changes: 20 additions & 33 deletions ss-search/src/lib/ss-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,45 +62,32 @@ export const getScore = (matchesAllSearchWords: boolean, searchWords: string[],
return round(1 - remainingTextAfterRemovingSearchWords.length / searchableDataStringWithoutNonWordCharacters.length, 4)
}

// Overload 1: No options provided or withScore is not true
export function search<T>(elements: T[], searchableKeys: string[], searchText: string): T[]
export type SearchResultWithScore<T> = { element: T; score: number }

// Overload 2: withScore is true
export function search<T>(
export function search<T, TWithScore extends boolean>(
elements: T[],
searchableKeys: string[],
searchText: string,
options: { withScore: true }
): { element: T; score: number }[]

// Implementation signature
export function search<T>(
elements: T[],
searchableKeys: string[],
searchText: string,
options?: { withScore?: boolean }
): T[] | { element: T; score: number }[] {
if (!searchText) {
return elements
}

options?: { withScore?: TWithScore }
): TWithScore extends true ? SearchResultWithScore<T>[] : T[]
{
const searchWords = tokenize(searchText)
const searchableDataStrings = convertToSearchableStrings(elements, searchableKeys)

if (options?.withScore) {
return searchableDataStrings
.map((x, i) => {
const matchesAllSearchWords = searchWords.filter((searchWord) => x.indexOf(searchWord) > -1).length === searchWords.length
const score = getScore(matchesAllSearchWords, searchWords, x)
return { element: elements[i], score }
})
.filter((x) => x)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return searchableDataStrings.reduce<any>((accumulator, x, i) => {
const matchesAllSearchWords = searchWords.every((searchWord) => x.includes(searchWord))
if (options?.withScore) {
const score = getScore(matchesAllSearchWords, searchWords, x)
accumulator.push({ element: elements[i], score })

return accumulator
}

if (matchesAllSearchWords) {
accumulator.push(elements[i])
}

return searchableDataStrings
.map((x, i) => {
const matchesAllSearchWords = searchWords.filter((searchWord) => x.indexOf(searchWord) > -1).length === searchWords.length
return matchesAllSearchWords ? elements[i] : null
})
.filter((x): x is T => x !== null)
return accumulator
}, [])
}
29 changes: 25 additions & 4 deletions web-app/src/components/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import TableFooter from '@mui/material/TableFooter'
import TablePagination from '@mui/material/TablePagination'
import Table from '@mui/material/Table'
import React, { ChangeEvent } from 'react'
import { SearchResultWithScore } from '@yann510/ss-search'

interface Props {
data: Data[]
data: Data[] | SearchResultWithScore<Data>[]
searchWords: string[]
}

Expand All @@ -26,6 +27,22 @@ function DataTable(props: Props) {
setPage(0)
}

const getDataKeys = (row: Data | SearchResultWithScore<Data>) => {
if (row.element) {
return Object.keys(row.element)
}

return Object.keys(row)
}

const getDataProperty = (row: Data | SearchResultWithScore<Data>, key: keyof Data) => {
if (row.element) {
return (row as SearchResultWithScore<Data>).element[key]
}

return (row as Data)[key]
}

return (
<Table size="small">
<TableHead>
Expand All @@ -34,17 +51,21 @@ function DataTable(props: Props) {
<TableCell>Name</TableCell>
<TableCell>Age</TableCell>
<TableCell>Address</TableCell>
<TableCell>Score</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((row) => {
return (
<TableRow key={row.id}>
{Object.keys(row).map((key) => (
<TableRow key={getDataProperty(row, "id")}>
{getDataKeys(row).map((key) => (
<TableCell key={key}>
<Highlighter searchWords={searchWords} textToHighlight={row[key].toString()} />
<Highlighter searchWords={searchWords} textToHighlight={getDataProperty(row, key)?.toString()} />
</TableCell>
))}
<TableCell>
{row?.score !== undefined ? row.score : "N/A"}
</TableCell>
</TableRow>
)
})}
Expand Down
58 changes: 44 additions & 14 deletions web-app/src/pages/demo-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import Grid from '@mui/material/Grid'
import Paper from '@mui/material/Paper'
import Typography from '@mui/material/Typography'
import TextField from '@mui/material/TextField'
import React from 'react'
import React, { useEffect } from 'react'
import { debounce } from 'lodash'
import { Data } from '../models/data'
import DataTable from '../components/data-table'
import { makeStyles } from 'tss-react/mui'
import { indexDocuments, search, tokenize } from '@yann510/ss-search'
import { indexDocuments, search, SearchResultWithScore, tokenize } from '@yann510/ss-search'
import Checkbox from '@mui/material/Checkbox'
import FormControlLabel from '@mui/material/FormControlLabel'

let startTime = performance.now()

Expand All @@ -16,11 +18,18 @@ const debouncedSearch = debounce(
(
searchText: string,
data: Data[],
setSearchResults: React.Dispatch<React.SetStateAction<Data[]>>,
setSearchTime: React.Dispatch<React.SetStateAction<number>>
setSearchResults: React.Dispatch<React.SetStateAction<Data[] | SearchResultWithScore<Data>[]>>,
setSearchTime: React.Dispatch<React.SetStateAction<number>>,
withScore: boolean
) => {
const searchResults = search(data, Object.keys(data[0]), searchText)
setSearchResults(searchResults)
const searchResults = search(data, Object.keys(data[0]), searchText, { withScore })
if (typeof searchResults[0]?.score === 'number') {
const filteredAndSortedResults = (searchResults as SearchResultWithScore<Data>[])
.sort((a, b) => b.score - a.score)
setSearchResults(filteredAndSortedResults)
} else {
setSearchResults(searchResults)
}

const endTime = performance.now()
setSearchTime(endTime - (startTime + debounceTime))
Expand All @@ -34,13 +43,12 @@ interface Props {

function DemoPage(props: Props) {
const { data } = props

const { classes } = useStyles()

const [searchText, setSearchText] = React.useState('')
const [searchResults, setSearchResults] = React.useState<Data[]>(data)
const [searchResults, setSearchResults] = React.useState<Data[] | SearchResultWithScore<Data>[]>(data)
const [searchWords, setSearchWords] = React.useState<string[]>([])
const [searchTime, setSearchTime] = React.useState(0)
const [withScore, setWithScore] = React.useState(true)

React.useEffect(() => {
if (data && data.length > 0) {
Expand All @@ -49,20 +57,42 @@ function DemoPage(props: Props) {
}
}, [data])

const handleSearch = (searchText: string) => {
startTime = performance.now()
useEffect(() => {
if (data) {
handleSearch(searchText)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data])

const handleSearch = (searchText: string, withScoreOverride?: boolean) => {
startTime = performance.now();

setSearchText(searchText)
setSearchWords(tokenize(searchText))
debouncedSearch(searchText, data, setSearchResults, setSearchTime)
setSearchText(searchText);
setSearchWords(tokenize(searchText));
debouncedSearch(searchText, data, setSearchResults, setSearchTime, withScoreOverride ?? withScore);
}


return (
<Grid container spacing={3}>
<Grid item xs={12}>
<Paper className={classes.paper}>
<Typography variant="body2" color="textSecondary" align="right">{`Execution time ${Math.round(searchTime)}ms`}</Typography>
<TextField className={classes.searchTextField} label="Search" value={searchText} onChange={(e) => handleSearch(e.target.value)} />
<FormControlLabel
control={
<Checkbox
checked={withScore}
onChange={(e) => {
setWithScore(e.target.checked)
handleSearch(searchText, e.target.checked)
}}
name="withScoreOption"
color="primary"
/>
}
label="With Score"
/>
<DataTable data={searchResults} searchWords={searchWords} />
</Paper>
</Grid>
Expand Down