Skip to content

Commit 428b6a4

Browse files
committed
Initial working version
1 parent cf950ab commit 428b6a4

16 files changed

+7433
-0
lines changed

.editorconfig

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
root = true
2+
3+
[*]
4+
indent_size = 2

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
dist
3+
.vscode
4+
.cache
5+
examples

README.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,111 @@
11
# filtersjs
2+
23
Simple faceted search solution for small JSON datasets.
4+
5+
## Installation
6+
7+
`yarn` or `npm install`
8+
9+
## Usage
10+
11+
```javascript
12+
// import or require filtersjs
13+
import { FiltersJS, Operation } from "filtersjs";
14+
15+
// data to filter
16+
const items = [
17+
{
18+
name: "Scot",
19+
gender: "Male",
20+
age: 18,
21+
card: ["jcb", "visa", "mastercard"]
22+
},
23+
{
24+
name: "Seana",
25+
gender: "Female",
26+
age: 21,
27+
card: ["jcb", "visa"]
28+
},
29+
{
30+
name: "Ken",
31+
gender: "Male",
32+
age: 45,
33+
card: ["jcb", "visa"]
34+
},
35+
{
36+
name: "Boony",
37+
gender: "Male",
38+
age: 47,
39+
card: ["visa"]
40+
},
41+
{
42+
name: "Mike",
43+
gender: "Male",
44+
age: 67,
45+
card: ["jcb", "visa"]
46+
}
47+
];
48+
49+
/**
50+
* Options object with filters definitions. By default values are converted to lower case strings before match, for custom
51+
* comparator provide a isMatching(val) function.
52+
*/
53+
const options = {
54+
filters: [
55+
{ key: "card", value: "jcb" },
56+
{ key: "card", value: "mastercard" },
57+
{ key: "card", value: "visa" },
58+
{ key: "gender", value: "male" },
59+
{ key: "gender", value: "female" },
60+
{
61+
key: "age",
62+
value: "18-25",
63+
isMatching: v => 18 <= v && v <= 25
64+
},
65+
{
66+
key: "age",
67+
value: "25-50",
68+
isMatching: v => 25 <= v && v <= 50
69+
},
70+
{ key: "age", value: ">50", isMatching: v => 50 < v }
71+
]
72+
};
73+
74+
// create a FiltersJS instance
75+
const filtersJS = new FiltersJS(items, options);
76+
77+
// call search by providing current filters values
78+
const { results, filters } = filtersJS.search({
79+
card: { values: ["jcb", "visa"], operation: Operation.And },
80+
gender: "male",
81+
age: ["18-25", "25-50"]
82+
});
83+
84+
// filtered results
85+
console.log(results);
86+
// Prints:
87+
// {
88+
// name: "Scot",
89+
// gender: "Male",
90+
// age: 18,
91+
// card: ["jcb", "visa", "mastercard"]
92+
// },
93+
// {
94+
// name: "Ken",
95+
// gender: "Male",
96+
// age: 45,
97+
// card: ["jcb", "visa"]
98+
// }
99+
100+
/**
101+
* Active filters with number of results for each filter (if it will be applied).
102+
* Can be used for basic facating and hiding filters that won't produce any results.
103+
*/
104+
console.log(filters);
105+
// Prints:
106+
// {
107+
// card: { mastercard: 3 },
108+
// gender: { female: 3 },
109+
// age: { ">50": 3 }
110+
// }
111+
```

jest.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module.exports = {
2+
preset: 'ts-jest',
3+
testEnvironment: 'node',
4+
};

package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "filtersjs",
3+
"version": "1.0.0",
4+
"description": "Simple faceted search solution for small JSON datasets.",
5+
"main": "src/index.ts",
6+
"repository": "git@github.com:ognus/filtersjs.git",
7+
"author": "Tomek Kolasa <kolasa.tomek@gmail.com>",
8+
"license": "MIT",
9+
"devDependencies": {
10+
"@types/jest": "^24.0.15",
11+
"jest": "^24.8.0",
12+
"parcel-bundler": "^1.12.3",
13+
"ts-jest": "^24.0.2",
14+
"typescript": "^3.5.3"
15+
},
16+
"scripts": {
17+
"dev": "parcel src/index.ts",
18+
"test": "jest"
19+
}
20+
}

src/DataProvider.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
type Item = object;
2+
3+
type WithId = [number, Item];
4+
5+
export type FilterCallback = (
6+
item: WithId,
7+
idx: number,
8+
array: IDataProvider
9+
) => boolean;
10+
11+
export type MapCallback = (
12+
item: WithId,
13+
idx: number,
14+
array: IDataProvider
15+
) => any;
16+
17+
export interface IDataProvider {
18+
length: number;
19+
get: (id: string | number) => Item;
20+
filter: (cb: FilterCallback) => IDataProvider;
21+
map: (cb: MapCallback) => any[];
22+
}

src/Factory.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Finder, EOperation } from "./Finder";
2+
import { IndexStore } from "./IndexStore";
3+
import { MutableItemsStore } from "./ItemsStore";
4+
import { IDataProvider } from "./DataProvider";
5+
import { ISearchProvider, TFilter } from "./SearchProvider";
6+
7+
export class Factory {
8+
private dataStore: IDataProvider;
9+
10+
constructor(items: object[]) {
11+
this.dataStore = new MutableItemsStore(items);
12+
}
13+
14+
getSearchProvider(filters: TFilter[]): ISearchProvider {
15+
const index = new IndexStore(this.dataStore);
16+
filters.forEach(filter => index.add(filter));
17+
return index;
18+
}
19+
20+
getFinder(index: ISearchProvider, operations: { [key: string]: EOperation }) {
21+
return new Finder(index, this.dataStore, operations);
22+
}
23+
}

src/Finder.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { ISearchProvider, TTerm } from "./SearchProvider";
2+
import { IDataProvider } from "./DataProvider";
3+
4+
export enum EOperation {
5+
And = "AND",
6+
Or = "OR"
7+
}
8+
9+
export class Finder {
10+
constructor(
11+
private search: ISearchProvider,
12+
private data: IDataProvider,
13+
private operations: { [key: string]: EOperation }
14+
) {}
15+
16+
private isMatching(
17+
itemId: number,
18+
propertyKey: string,
19+
values: (string | number)[]
20+
) {
21+
const operation = this.operations[propertyKey];
22+
23+
if (operation === EOperation.And) {
24+
return values.every(value =>
25+
this.search.isMatching(itemId, propertyKey, value)
26+
);
27+
}
28+
29+
return values.some(value =>
30+
this.search.isMatching(itemId, propertyKey, value)
31+
);
32+
}
33+
34+
private getGroupedTerms(terms: TTerm[]) {
35+
const termsByKey: { [key: string]: (string | number)[] } = terms.reduce(
36+
(byKey, { key, value }) => {
37+
byKey[key] = byKey[key] || [];
38+
byKey[key].push(value);
39+
return byKey;
40+
},
41+
{}
42+
);
43+
44+
return Object.entries(termsByKey);
45+
}
46+
47+
find(terms: TTerm[]): IDataProvider {
48+
const grouped = this.getGroupedTerms(terms);
49+
50+
return this.data.filter(([itemId]) => {
51+
return grouped.every(([key, values]) =>
52+
this.isMatching(itemId, key, values)
53+
);
54+
});
55+
}
56+
}

src/IndexStore.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { IDataProvider } from "./DataProvider";
2+
import {
3+
ISearchProvider,
4+
TTerm,
5+
TMatchingFunction,
6+
TFilter
7+
} from "./SearchProvider";
8+
9+
function defaultMatchingFunction(value: string | number) {
10+
return (
11+
new String(this.value.toLowerCase()).toLowerCase() ===
12+
new String(value).toLowerCase()
13+
);
14+
}
15+
16+
class FilterOption implements TTerm {
17+
private itemKey: string;
18+
private optionValue: string | number;
19+
private isMatchingFn: TMatchingFunction;
20+
21+
constructor(
22+
itemKey: string,
23+
optionValue: string | number,
24+
isMatchingFn?: TMatchingFunction
25+
) {
26+
this.itemKey = itemKey;
27+
this.optionValue = optionValue;
28+
this.isMatchingFn = isMatchingFn || defaultMatchingFunction.bind(this);
29+
}
30+
31+
private getValues(item = {}) {
32+
const value = item[this.itemKey];
33+
return Array.isArray(value) ? value : [value];
34+
}
35+
36+
isMatching(item: object) {
37+
return this.getValues(item).some(value => this.isMatchingFn(value));
38+
}
39+
40+
get value() {
41+
return this.optionValue;
42+
}
43+
44+
get key() {
45+
return this.itemKey;
46+
}
47+
}
48+
49+
export class IndexStore implements ISearchProvider {
50+
private index: {
51+
[itemKey: string]: { [optionValue: string]: IDataProvider };
52+
} = {};
53+
private options: FilterOption[] = [];
54+
55+
constructor(private items: IDataProvider) {}
56+
57+
add({ key, value, isMatching }: TFilter) {
58+
const option = new FilterOption(key, value, isMatching);
59+
this.options.push(option);
60+
this.index[key] = this.index[key] || {};
61+
this.index[key][value] = this.items.filter(([, item]) =>
62+
option.isMatching(item)
63+
);
64+
}
65+
66+
isMatching(
67+
itemId: string | number,
68+
propertyKey: string,
69+
optionValue: string | number
70+
) {
71+
return !!this.index[propertyKey][optionValue].get(itemId);
72+
}
73+
74+
getTerms(): TTerm[] {
75+
return this.options;
76+
}
77+
}

src/ItemsStore.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { IDataProvider, FilterCallback, MapCallback } from "./DataProvider";
2+
3+
export class ItemsStore implements IDataProvider {
4+
protected itemsById: { [id: number]: object } = {};
5+
protected items: [number, object][] = [];
6+
7+
constructor(items: [number, object][]) {
8+
this.items = items;
9+
this.itemsById = Object.fromEntries(items);
10+
}
11+
12+
get(id: string | number) {
13+
return this.itemsById[id];
14+
}
15+
16+
filter(precicate: FilterCallback) {
17+
return new ItemsStore(
18+
this.items.filter((itemWithId, idx) => precicate(itemWithId, idx, this))
19+
);
20+
}
21+
22+
map(iterator: MapCallback) {
23+
return this.items.map((itemWithId, idx) => iterator(itemWithId, idx, this));
24+
}
25+
26+
get length() {
27+
return this.items.length;
28+
}
29+
}
30+
31+
export class MutableItemsStore extends ItemsStore {
32+
private lastItemId = 0;
33+
34+
constructor(items: object[]) {
35+
super([]);
36+
items.forEach(item => this.add(item));
37+
}
38+
39+
add(item: object) {
40+
if (item) {
41+
const id = ++this.lastItemId;
42+
this.itemsById[id] = item;
43+
this.items.push([id, item]);
44+
return id;
45+
}
46+
47+
return -1;
48+
}
49+
}

0 commit comments

Comments
 (0)