Skip to content

Commit

Permalink
perf: correctly memoize category selectors (#284)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhhyi authored Jul 1, 2020
1 parent 9c46eec commit afc69ed
Show file tree
Hide file tree
Showing 29 changed files with 629 additions and 303 deletions.
16 changes: 14 additions & 2 deletions src/app/core/facades/shopping.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { debounce, debounceTime, filter, map, switchMap, tap } from 'rxjs/operat
import { ProductListingID } from 'ish-core/models/product-listing/product-listing.model';
import { ProductCompletenessLevel, ProductHelper } from 'ish-core/models/product/product.model';
import { addProductToBasket } from 'ish-core/store/customer/basket';
import { getCategoryLoading, getSelectedCategory, getTopLevelCategories } from 'ish-core/store/shopping/categories';
import {
getCategory,
getCategoryLoading,
getNavigationCategories,
getSelectedCategory,
} from 'ish-core/store/shopping/categories';
import {
addToCompare,
getCompareProducts,
Expand Down Expand Up @@ -50,10 +55,17 @@ export class ShoppingFacade {

// CATEGORY

topLevelCategories$ = this.store.pipe(select(getTopLevelCategories));
selectedCategory$ = this.store.pipe(select(getSelectedCategory));
selectedCategoryLoading$ = this.store.pipe(select(getCategoryLoading), debounceTime(500));

category$(uniqueId: string) {
return this.store.pipe(select(getCategory(uniqueId)));
}

navigationCategories$(uniqueId?: string) {
return this.store.pipe(select(getNavigationCategories(uniqueId)));
}

// PRODUCT

selectedProduct$ = this.store.pipe(select(getSelectedProduct));
Expand Down
185 changes: 185 additions & 0 deletions src/app/core/models/category-tree/category-tree.helper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as using from 'jasmine-data-provider';
import { CategoryData } from 'ish-core/models/category/category.interface';
import { CategoryMapper } from 'ish-core/models/category/category.mapper';
import { Category } from 'ish-core/models/category/category.model';
import { categoryTree } from 'ish-core/utils/dev/test-data-utils';

import { CategoryTreeHelper } from './category-tree.helper';
import { CategoryTree } from './category-tree.model';
Expand Down Expand Up @@ -458,4 +459,188 @@ describe('Category Tree Helper', () => {
}
);
});

describe('subTree', () => {
let combined: CategoryTree;

beforeEach(() => {
combined = categoryTree([
{ uniqueId: 'A', categoryPath: ['A'] },
{ uniqueId: 'A.1', categoryPath: ['A', 'A.1'] },
{ uniqueId: 'A.1.a', categoryPath: ['A', 'A.1', 'A.1.a'] },
{ uniqueId: 'A.2', categoryPath: ['A', 'A.2'] },
{ uniqueId: 'A.1.b', categoryPath: ['A', 'A.1', 'A.1.b'] },
{ uniqueId: 'B', categoryPath: ['B'] },
{ uniqueId: 'B.1', categoryPath: ['B', 'B.1'] },
{ uniqueId: 'B.1.a', categoryPath: ['B', 'B.1', 'B.1.a'] },
{ uniqueId: 'B.2', categoryPath: ['B', 'B.2'] },
] as Category[]);
});

it('should be created', () => {
expect(combined).toMatchInlineSnapshot(`
├─ A
│ ├─ A.1
│ │ ├─ A.1.a
│ │ └─ A.1.b
│ └─ A.2
└─ B
├─ B.1
│ └─ B.1.a
└─ B.2
`);

expect(combined.rootIds).toMatchInlineSnapshot(`
Array [
"A",
"B",
]
`);
expect(Object.keys(combined.nodes)).toMatchInlineSnapshot(`
Array [
"A",
"A.1",
"A.1.a",
"A.2",
"A.1.b",
"B",
"B.1",
"B.1.a",
"B.2",
]
`);
expect(Object.keys(combined.edges)).toMatchInlineSnapshot(`
Array [
"A",
"A.1",
"B",
"B.1",
]
`);
});

it('should return an empty tree if selected uniqueId is not part of the tree', () => {
const tree = CategoryTreeHelper.subTree(combined, 'C');
expect(tree.rootIds).toBeEmpty();
expect(tree.edges).toBeEmpty();
expect(tree.nodes).toBeEmpty();
});

it('should return copy of tree if selector is undefined', () => {
const tree = CategoryTreeHelper.subTree(combined, undefined);
expect(CategoryTreeHelper.equals(tree, combined)).toBeTrue();
});

it('should extract sub tree for A', () => {
const tree = CategoryTreeHelper.subTree(combined, 'A');

expect(tree).toMatchInlineSnapshot(`
└─ A
├─ A.1
│ ├─ A.1.a
│ └─ A.1.b
└─ A.2
`);

expect(tree.rootIds).toMatchInlineSnapshot(`
Array [
"A",
]
`);
expect(Object.keys(tree.nodes)).toMatchInlineSnapshot(`
Array [
"A",
"A.1",
"A.1.a",
"A.2",
"A.1.b",
]
`);
expect(Object.keys(tree.edges)).toMatchInlineSnapshot(`
Array [
"A",
"A.1",
]
`);
});

it('should extract sub tree for A.1', () => {
const tree = CategoryTreeHelper.subTree(combined, 'A.1');

expect(tree).toMatchInlineSnapshot(`
└─ dangling
└─ A.1
├─ A.1.a
└─ A.1.b
`);

expect(tree.rootIds).toBeEmpty();
expect(Object.keys(tree.nodes)).toMatchInlineSnapshot(`
Array [
"A.1",
"A.1.a",
"A.1.b",
]
`);
expect(Object.keys(tree.edges)).toMatchInlineSnapshot(`
Array [
"A.1",
]
`);
});

it('should extract sub tree for A.1.a', () => {
const tree = CategoryTreeHelper.subTree(combined, 'A.1.a');

expect(tree).toMatchInlineSnapshot(`
└─ dangling
└─ A.1.a
`);

expect(tree.rootIds).toBeEmpty();
expect(Object.keys(tree.nodes)).toMatchInlineSnapshot(`
Array [
"A.1.a",
]
`);
expect(Object.keys(tree.edges)).toBeEmpty();
});
});

describe('equals', () => {
const catA = { uniqueId: 'A', categoryPath: ['A'] } as Category;
const catB = { uniqueId: 'B', categoryPath: ['B'] } as Category;

it('should return true for simple equal trees', () => {
const tree1 = categoryTree([catA]);
const tree2 = categoryTree([catA]);

expect(CategoryTreeHelper.equals(tree1, tree2)).toBeTrue();
});

it('should return true if category was copied', () => {
const tree1 = categoryTree([catA]);
const tree2 = categoryTree([{ ...catA }]);

expect(CategoryTreeHelper.equals(tree1, tree2)).toBeTrue();
});

it('should return false for simple unequal trees', () => {
const tree1 = categoryTree([catA]);
const tree2 = categoryTree([catB]);

expect(CategoryTreeHelper.equals(tree1, tree2)).toBeFalse();
});

it('should return true for simple unordered trees', () => {
const tree1 = categoryTree([catA, catB]);
const tree2 = categoryTree([catB, catA]);

expect(CategoryTreeHelper.equals(tree1, tree2)).toBeTrue();
});
});
});
49 changes: 49 additions & 0 deletions src/app/core/models/category-tree/category-tree.helper.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isEqual, pick } from 'lodash-es';

import { Category } from 'ish-core/models/category/category.model';

import { CategoryTree } from './category-tree.model';
Expand Down Expand Up @@ -131,4 +133,51 @@ export class CategoryTreeHelper {
const singleCategoryTree = CategoryTreeHelper.single(category);
return CategoryTreeHelper.merge(tree, singleCategoryTree);
}

/**
* Extract a sub tree.
*/
static subTree(tree: CategoryTree, uniqueId: string): CategoryTree {
if (!uniqueId) {
return tree;
}

const select = (e: string) => e.startsWith(uniqueId);
return {
rootIds: tree.rootIds.filter(select),
edges: pick(tree.edges, ...Object.keys(tree.edges).filter(select)),
nodes: pick(tree.nodes, ...Object.keys(tree.nodes).filter(select)),
};
}

private static rootIdsEqual(t1: string[], t2: string[]) {
return t1.length === t2.length && t1.every(e => t2.includes(e));
}

private static edgesEqual(t1: { [id: string]: string[] }, t2: { [id: string]: string[] }) {
return isEqual(t1, t2);
}

private static categoriesEqual(t1: { [id: string]: Category }, t2: { [id: string]: Category }) {
const keys1 = Object.keys(t1);
const keys2 = Object.keys(t2);
return (
keys1.length === keys2.length &&
keys1.every(id => keys2.includes(id)) &&
keys1.every(id => isEqual(t1[id], t2[id]))
);
}

/**
* Perform check for equality. Order of items is ignored.
*/
static equals(tree1: CategoryTree, tree2: CategoryTree): boolean {
return (
tree1 &&
tree2 &&
CategoryTreeHelper.rootIdsEqual(tree1.rootIds, tree2.rootIds) &&
CategoryTreeHelper.edgesEqual(tree1.edges, tree2.edges) &&
CategoryTreeHelper.categoriesEqual(tree1.nodes, tree2.nodes)
);
}
}
32 changes: 7 additions & 25 deletions src/app/core/models/category-view/category-view.model.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ describe('Category View Model', () => {
]);

const view = createCategoryView(tree, '123');
expect(view.hasChildren()).toBeFalse();
expect(view.children()).toBeEmpty();
expect(view.hasChildren).toBeFalse();
expect(view.children).toBeEmpty();
});

const cat1 = {
Expand All @@ -60,34 +60,16 @@ describe('Category View Model', () => {
const tree = categoryTree([cat1, cat11]);

const view = createCategoryView(tree, '123');
expect(view.hasChildren()).toBeTrue();
expect(view.children()).toHaveLength(1);
expect(view.hasChildren).toBeTrue();
expect(view.children).toHaveLength(1);

expect(view.children()[0].uniqueId).toEqual('123.456');
expect(view.children[0]).toEqual('123.456');
});

it('should provide methods to check if a node in a deep complex tree has children', () => {
const tree = categoryTree([cat1, cat11, cat111]);

const view = createCategoryView(tree, '123');
expect(view.hasChildren()).toBeTrue();
expect(view.children()).toHaveLength(1);

const subCategory = view.children()[0];
expect(subCategory.uniqueId).toEqual('123.456');
expect(subCategory.hasChildren()).toBeTrue();
expect(subCategory.children()).toHaveLength(1);

const subSubCategory = subCategory.children()[0];
expect(subSubCategory.uniqueId).toEqual('123.456.789');
expect(subSubCategory.hasChildren()).toBeFalse();
expect(subSubCategory.children()).toBeEmpty();
});

it('should provide acces to the category path of a category', () => {
it('should provide access to the category path of a category', () => {
const tree = categoryTree([cat1, cat11, cat111]);
const view = createCategoryView(tree, '123.456.789');

expect(view.pathCategories().map(v => v.uniqueId)).toEqual(['123', '123.456', '123.456.789']);
expect(view.categoryPath).toEqual(['123', '123.456', '123.456.789']);
});
});
10 changes: 4 additions & 6 deletions src/app/core/models/category-view/category-view.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ import { Category } from 'ish-core/models/category/category.model';
* View on a {@link Category} with additional methods for navigating to sub categories or category path
*/
export interface CategoryView extends Category {
children(): CategoryView[];
hasChildren(): boolean;
pathCategories(): CategoryView[];
children: string[];
hasChildren: boolean;
}

export function createCategoryView(tree: CategoryTree, uniqueId: string): CategoryView {
Expand All @@ -20,8 +19,7 @@ export function createCategoryView(tree: CategoryTree, uniqueId: string): Catego

return {
...tree.nodes[uniqueId],
hasChildren: () => !!tree.edges[uniqueId] && !!tree.edges[uniqueId].length,
children: () => (tree.edges[uniqueId] || []).map(id => createCategoryView(tree, id)),
pathCategories: () => tree.nodes[uniqueId].categoryPath.map(id => createCategoryView(tree, id)),
children: tree.edges[uniqueId] || [],
hasChildren: !!tree.edges[uniqueId] && !!tree.edges[uniqueId].length,
};
}
Loading

0 comments on commit afc69ed

Please sign in to comment.