Skip to content

Commit

Permalink
Initial working version
Browse files Browse the repository at this point in the history
  • Loading branch information
ognus committed Sep 6, 2019
1 parent cf950ab commit 428b6a4
Show file tree
Hide file tree
Showing 16 changed files with 7,433 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
root = true

[*]
indent_size = 2
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
dist
.vscode
.cache
examples
109 changes: 109 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,111 @@
# filtersjs

Simple faceted search solution for small JSON datasets.

## Installation

`yarn` or `npm install`

## Usage

```javascript
// import or require filtersjs
import { FiltersJS, Operation } from "filtersjs";

// data to filter
const items = [
{
name: "Scot",
gender: "Male",
age: 18,
card: ["jcb", "visa", "mastercard"]
},
{
name: "Seana",
gender: "Female",
age: 21,
card: ["jcb", "visa"]
},
{
name: "Ken",
gender: "Male",
age: 45,
card: ["jcb", "visa"]
},
{
name: "Boony",
gender: "Male",
age: 47,
card: ["visa"]
},
{
name: "Mike",
gender: "Male",
age: 67,
card: ["jcb", "visa"]
}
];

/**
* Options object with filters definitions. By default values are converted to lower case strings before match, for custom
* comparator provide a isMatching(val) function.
*/
const options = {
filters: [
{ key: "card", value: "jcb" },
{ key: "card", value: "mastercard" },
{ key: "card", value: "visa" },
{ key: "gender", value: "male" },
{ key: "gender", value: "female" },
{
key: "age",
value: "18-25",
isMatching: v => 18 <= v && v <= 25
},
{
key: "age",
value: "25-50",
isMatching: v => 25 <= v && v <= 50
},
{ key: "age", value: ">50", isMatching: v => 50 < v }
]
};

// create a FiltersJS instance
const filtersJS = new FiltersJS(items, options);

// call search by providing current filters values
const { results, filters } = filtersJS.search({
card: { values: ["jcb", "visa"], operation: Operation.And },
gender: "male",
age: ["18-25", "25-50"]
});

// filtered results
console.log(results);
// Prints:
// {
// name: "Scot",
// gender: "Male",
// age: 18,
// card: ["jcb", "visa", "mastercard"]
// },
// {
// name: "Ken",
// gender: "Male",
// age: 45,
// card: ["jcb", "visa"]
// }

/**
* Active filters with number of results for each filter (if it will be applied).
* Can be used for basic facating and hiding filters that won't produce any results.
*/
console.log(filters);
// Prints:
// {
// card: { mastercard: 3 },
// gender: { female: 3 },
// age: { ">50": 3 }
// }
```
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
20 changes: 20 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "filtersjs",
"version": "1.0.0",
"description": "Simple faceted search solution for small JSON datasets.",
"main": "src/index.ts",
"repository": "[email protected]:ognus/filtersjs.git",
"author": "Tomek Kolasa <[email protected]>",
"license": "MIT",
"devDependencies": {
"@types/jest": "^24.0.15",
"jest": "^24.8.0",
"parcel-bundler": "^1.12.3",
"ts-jest": "^24.0.2",
"typescript": "^3.5.3"
},
"scripts": {
"dev": "parcel src/index.ts",
"test": "jest"
}
}
22 changes: 22 additions & 0 deletions src/DataProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
type Item = object;

type WithId = [number, Item];

export type FilterCallback = (
item: WithId,
idx: number,
array: IDataProvider
) => boolean;

export type MapCallback = (
item: WithId,
idx: number,
array: IDataProvider
) => any;

export interface IDataProvider {
length: number;
get: (id: string | number) => Item;
filter: (cb: FilterCallback) => IDataProvider;
map: (cb: MapCallback) => any[];
}
23 changes: 23 additions & 0 deletions src/Factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Finder, EOperation } from "./Finder";
import { IndexStore } from "./IndexStore";
import { MutableItemsStore } from "./ItemsStore";
import { IDataProvider } from "./DataProvider";
import { ISearchProvider, TFilter } from "./SearchProvider";

export class Factory {
private dataStore: IDataProvider;

constructor(items: object[]) {
this.dataStore = new MutableItemsStore(items);
}

getSearchProvider(filters: TFilter[]): ISearchProvider {
const index = new IndexStore(this.dataStore);
filters.forEach(filter => index.add(filter));
return index;
}

getFinder(index: ISearchProvider, operations: { [key: string]: EOperation }) {
return new Finder(index, this.dataStore, operations);
}
}
56 changes: 56 additions & 0 deletions src/Finder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ISearchProvider, TTerm } from "./SearchProvider";
import { IDataProvider } from "./DataProvider";

export enum EOperation {
And = "AND",
Or = "OR"
}

export class Finder {
constructor(
private search: ISearchProvider,
private data: IDataProvider,
private operations: { [key: string]: EOperation }
) {}

private isMatching(
itemId: number,
propertyKey: string,
values: (string | number)[]
) {
const operation = this.operations[propertyKey];

if (operation === EOperation.And) {
return values.every(value =>
this.search.isMatching(itemId, propertyKey, value)
);
}

return values.some(value =>
this.search.isMatching(itemId, propertyKey, value)
);
}

private getGroupedTerms(terms: TTerm[]) {
const termsByKey: { [key: string]: (string | number)[] } = terms.reduce(
(byKey, { key, value }) => {
byKey[key] = byKey[key] || [];
byKey[key].push(value);
return byKey;
},
{}
);

return Object.entries(termsByKey);
}

find(terms: TTerm[]): IDataProvider {
const grouped = this.getGroupedTerms(terms);

return this.data.filter(([itemId]) => {
return grouped.every(([key, values]) =>
this.isMatching(itemId, key, values)
);
});
}
}
77 changes: 77 additions & 0 deletions src/IndexStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { IDataProvider } from "./DataProvider";
import {
ISearchProvider,
TTerm,
TMatchingFunction,
TFilter
} from "./SearchProvider";

function defaultMatchingFunction(value: string | number) {
return (
new String(this.value.toLowerCase()).toLowerCase() ===
new String(value).toLowerCase()
);
}

class FilterOption implements TTerm {
private itemKey: string;
private optionValue: string | number;
private isMatchingFn: TMatchingFunction;

constructor(
itemKey: string,
optionValue: string | number,
isMatchingFn?: TMatchingFunction
) {
this.itemKey = itemKey;
this.optionValue = optionValue;
this.isMatchingFn = isMatchingFn || defaultMatchingFunction.bind(this);
}

private getValues(item = {}) {
const value = item[this.itemKey];
return Array.isArray(value) ? value : [value];
}

isMatching(item: object) {
return this.getValues(item).some(value => this.isMatchingFn(value));
}

get value() {
return this.optionValue;
}

get key() {
return this.itemKey;
}
}

export class IndexStore implements ISearchProvider {
private index: {
[itemKey: string]: { [optionValue: string]: IDataProvider };
} = {};
private options: FilterOption[] = [];

constructor(private items: IDataProvider) {}

add({ key, value, isMatching }: TFilter) {
const option = new FilterOption(key, value, isMatching);
this.options.push(option);
this.index[key] = this.index[key] || {};
this.index[key][value] = this.items.filter(([, item]) =>
option.isMatching(item)
);
}

isMatching(
itemId: string | number,
propertyKey: string,
optionValue: string | number
) {
return !!this.index[propertyKey][optionValue].get(itemId);
}

getTerms(): TTerm[] {
return this.options;
}
}
49 changes: 49 additions & 0 deletions src/ItemsStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { IDataProvider, FilterCallback, MapCallback } from "./DataProvider";

export class ItemsStore implements IDataProvider {
protected itemsById: { [id: number]: object } = {};
protected items: [number, object][] = [];

constructor(items: [number, object][]) {
this.items = items;
this.itemsById = Object.fromEntries(items);
}

get(id: string | number) {
return this.itemsById[id];
}

filter(precicate: FilterCallback) {
return new ItemsStore(
this.items.filter((itemWithId, idx) => precicate(itemWithId, idx, this))
);
}

map(iterator: MapCallback) {
return this.items.map((itemWithId, idx) => iterator(itemWithId, idx, this));
}

get length() {
return this.items.length;
}
}

export class MutableItemsStore extends ItemsStore {
private lastItemId = 0;

constructor(items: object[]) {
super([]);
items.forEach(item => this.add(item));
}

add(item: object) {
if (item) {
const id = ++this.lastItemId;
this.itemsById[id] = item;
this.items.push([id, item]);
return id;
}

return -1;
}
}
Loading

0 comments on commit 428b6a4

Please sign in to comment.