Skip to content

Commit

Permalink
feat(pipe): adds pure ablePure pipe in @casl/angular
Browse files Browse the repository at this point in the history
Closes #276
  • Loading branch information
stalniy committed May 19, 2020
1 parent fa3eecc commit 23c851c
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 126 deletions.
49 changes: 15 additions & 34 deletions docs-src/src/content/pages/package/casl-angular/en.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,53 +156,34 @@ Directive cannot be used to pass values into inputs of other components. For exa
<button [disabled]="!('create' | able: 'Post')">Add Post</button>
```

To track status of directive implementation, check [#276](https://github.com/stalniy/casl/issues/276)

## Performance considerations

Due to [open feature in Angular](https://github.com/angular/angular/issues/15041), pipes were designed to be [impure](https://angular.io/guide/pipes#impure-pipes). This should work pretty fine for majority of cases but may become a bottleneck if you have more than 50 rules (depending on application size and computer characteristics).
There are 2 pipes in `@casl/angular`:

Don't worry, there are several strategies which you can pick to make things fast when they become slower:
* `able` - impure pipe
* `ablePure` - pure pipe

* use memoization, either on `Ability#can` or on `AblePipe#transform` method
* if you use immutable objects, you can extend existing pipe and make it pure
* use `ChangeDectionStrategy.OnPush` on your components whenever possible
So, when should we use which?

To memoize results of `AblePipe`, you will need to create your own one and change its `transform` method to cache results. Also you will need to clear all memoized results when corresponding `Ability` instance is updated.
> If you are in doubt, then use `ablePure` for action and subject type checks, and `able` for all others
The similar strategy can be applied to `Ability` class. Don't forget to provide new pipe or `Ability` class in `AppModule`! For example
According to Angular documentation pure pipes are called only if their arguments are changed. This means that you **can't use mutable objects with pure pipes** because changes in that objects don't trigger pure pipe re-evaluation. But a good thing is that Angular creates only single instance of a pure pipe for the whole app and reuses it across components, this way it safes component instantiation time and memory footprint.

```ts
import { NgModule } from '@angular/core';
import { Ability } from '@casl/ability';
import { MemoizedAbility } from './ability';
Due to [open feature in Angular](https://github.com/angular/angular/issues/15041), we need to pass the result of `ablePure` pipe to `async` pipe. So, instead of

@NgModule({
// other configuration
providers: [
{ provide: Ability, useValue: new MemoizedAbility() },
{ provide: PureAbility, useExisting: Ability },
]
})
export class AppModule {}
```html
<div *ngIf="'create' | ablePure: 'Todo'">...</div>
```

or if you want to provide custom pipe:
we need to write:

```ts
import { AblePipe } from '@casl/angular'
```html
<div *ngIf="'create' | ablePure: 'Todo' | async">...</div>
```

@Pipe({ name: 'able', pure: true })
class PureAblePipe extends AblePipe {}
> `ablePure` pipe returns an `Observable<boolean>`, so `async` pipe can effectively unwrap it
@NgModule({
// other configuration
declarations: [
PureAblePipe
]
})
export class AppModule {}
```
For apps that mutate application state, we need to use impure `able` pipe as it can detect changes in object properties. Don't worry, checks by action and subject type are very fast and are done in O(1) time. The performance of checks by action and subject object are a bit slower and depend on the amount of rules for a particular subject type and used conditions but usually this won't become a bottle neck for the app.

## TypeScript support

Expand Down
5 changes: 3 additions & 2 deletions packages/casl-angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"build.es6": "TARGET=es2015 BUILD=es6 npm run rollup",
"build.umd": "TARGET=es5 BUILD=umd npm run rollup",
"build.types": "ngc -p tsconfig.types.json",
"rollup": "ngc --target $TARGET --outDir dist/$BUILD/tmp && LIB_MINIFY=false BUILD_TYPES=$BUILD USE_SRC_MAPS=1 rollup -c ../../tools/rollup.config.js -i dist/$BUILD/tmp/index.js -n casl.ng -g @angular/core:ng.core,@casl/ability:casl,tslib:tslib",
"rollup": "ngc --target $TARGET --outDir dist/$BUILD/tmp && LIB_MINIFY=false BUILD_TYPES=$BUILD USE_SRC_MAPS=1 rollup -c ../../tools/rollup.config.js -i dist/$BUILD/tmp/index.js -n casl.ng -g @angular/core:ng.core,@casl/ability:casl,tslib:tslib,rxjs:rxjs",
"postrollup": "rm -rf dist/$BUILD/tmp",
"test": "NODE_ENV=test jest --config ./jest.config.js",
"lint": "eslint --ext .ts,.js src/ spec/",
Expand All @@ -45,7 +45,8 @@
"peerDependencies": {
"@angular/core": "^9.0.0",
"tslib": "^1.9.0 || ^2.0.0",
"@casl/ability": "^2.0.0 || ^3.0.0 || ^4.0.0"
"@casl/ability": "^2.0.0 || ^3.0.0 || ^4.0.0",
"rxjs": "^6.0.0"
},
"devDependencies": {
"@abraham/reflection": "^0.7.0",
Expand Down
81 changes: 0 additions & 81 deletions packages/casl-angular/spec/module.spec.js

This file was deleted.

82 changes: 82 additions & 0 deletions packages/casl-angular/spec/pipes.e2e.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { PureAbility } from '@casl/ability'
import { TestBed } from '@angular/core/testing'
import { createApp, createComponent, configureTestingModule, Post } from './spec_helper'

const AppWithCanPipe = createApp('{{ post | can: \'read\' }}')
const AppWithAblePipe = createApp('{{ \'read\' | able: post }}')
const AppWithAblePurePipe = createApp('{{ \'read\' | ablePure: post | async }}')

describe('Ability pipes', () => {
let fixture
let ability
let post

afterEach(() => {
if (fixture) {
fixture.destroy()
}
})

describe('module', () => {
it('provides deprecated impure `can` pipe', () => {
configureTestingModule([AppWithCanPipe])
fixture = createComponent(AppWithCanPipe)
expect(fixture.nativeElement.textContent).to.equal('false')
})

it('provides impure `able` pipe', () => {
configureTestingModule([AppWithAblePipe])
fixture = createComponent(AppWithAblePipe)
expect(fixture.nativeElement.textContent).to.equal('false')
})
})

describe('`can` pipe', () => {
behavesLikeAbilityPipe(AppWithCanPipe)
})

describe('`able` pipe', () => {
behavesLikeAbilityPipe(AppWithAblePipe)
})

describe('`ablePure` pipe', () => {
behavesLikeAbilityPipe(AppWithAblePurePipe)
})

function behavesLikeAbilityPipe(App) {
beforeEach(() => {
configureTestingModule([App])
ability = TestBed.inject(PureAbility)
post = new Post({ author: 'me' })
})

it('updates template when `ability` is updated', () => {
fixture = createComponent(App, { post })
ability.update([{ subject: Post.name, action: 'read' }])
fixture.detectChanges()

expect(fixture.nativeElement.textContent).to.equal('true')
})

describe('when abilities depends on object attribute', () => {
beforeEach(() => {
ability.update([{ subject: Post.name, action: 'read', conditions: { author: 'me' } }])
fixture = createComponent(App, { post })
fixture.detectChanges()
})

it('returns `true` if object attribute equals to specified value', () => {
expect(fixture.nativeElement.textContent).to.equal('true')
})

if (App !== AppWithAblePurePipe) {
it('updates template when object attribute is changed', () => {
post.author = 'not me'
fixture.detectChanges()

expect(fixture.nativeElement.textContent).to.equal('false')
})
}
})
}
})
29 changes: 25 additions & 4 deletions packages/casl-angular/spec/spec_helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,22 @@ import {
} from '@angular/platform-browser-dynamic/testing'
import { TestBed } from '@angular/core/testing'
import { Component } from '@angular/core'
import { Ability, PureAbility } from '@casl/ability'
import { AbilityModule } from '../dist/es6'

TestBed.initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
)

export class App {
let appIndex = 0
export const createApp = template => class App {
static get annotations() {
return [
new Component({
selector: 'app-ability',
template: '{{ pipe === "able" ? ("read" | able: post) : (post | can: "read") }}',
inputs: ['post', 'pipe']
selector: `app-ability-${++appIndex}`,
template,
inputs: ['post']
})
]
}
Expand All @@ -33,3 +36,21 @@ export class Post {
Object.assign(this, attrs)
}
}

export function createComponent(Type, inputs) {
const cmp = TestBed.createComponent(Type)
Object.assign(cmp.componentInstance, inputs)
cmp.detectChanges()

return cmp
}

export function configureTestingModule(declarations) {
TestBed.configureTestingModule({
imports: [AbilityModule],
declarations,
providers: [
{ provide: PureAbility, useFactory: () => new Ability() }
]
})
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { NgModule } from '@angular/core';
import { CanPipe, AblePipe } from './can';
import { CanPipe, AblePipe, AblePurePipe } from './pipes';

@NgModule({
declarations: [
CanPipe,
AblePipe,
AblePurePipe,
],
exports: [
CanPipe,
AblePipe
AblePipe,
AblePurePipe,
],
})
export class AbilityModule {
Expand Down
4 changes: 2 additions & 2 deletions packages/casl-angular/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from './can';
export * from './module';
export * from './pipes';
export * from './AbilityModule';
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Pipe, ChangeDetectorRef, Inject, PipeTransform } from '@angular/core';
import { PureAbility, Unsubscribe, AnyAbility } from '@casl/ability';
import { Observable } from 'rxjs';

class AbilityPipe<T extends AnyAbility> {
protected _unsubscribeFromAbility?: Unsubscribe;
Expand All @@ -25,7 +26,6 @@ class AbilityPipe<T extends AnyAbility> {
}
}

// TODO: `pure` can be removed after https://github.com/angular/angular/issues/15041
@Pipe({ name: 'can', pure: false })
export class CanPipe<T extends AnyAbility> implements PipeTransform {
protected pipe: AbilityPipe<T>;
Expand Down Expand Up @@ -63,3 +63,21 @@ export class AblePipe<T extends AnyAbility> implements PipeTransform {
this.pipe.ngOnDestroy();
}
}

@Pipe({ name: 'ablePure' })
export class AblePurePipe<T extends AnyAbility> implements PipeTransform {
private _ability: T;

constructor(@Inject(PureAbility) ability: T) {
this._ability = ability;
}

// TODO: `Observable` can be removed after https://github.com/angular/angular/issues/15041
transform(...args: Parameters<T['can']>): Observable<boolean> {
return new Observable((s) => {
const emit = () => s.next(this._ability.can(...args));
emit();
return this._ability.on('updated', emit);
});
}
}

0 comments on commit 23c851c

Please sign in to comment.