Skip to content

Commit

Permalink
feat: allow to use mongoose typings without extending Document (#639)
Browse files Browse the repository at this point in the history
Co-authored-by: Serhii Stotskyi <[email protected]>

BREAKING CHANGE: increased mongoose version to 6.0.13 in order to use 4 generic types in `Model` type
  • Loading branch information
qqilihq authored Jul 29, 2022
1 parent e174b87 commit f5273e3
Show file tree
Hide file tree
Showing 8 changed files with 152 additions and 77 deletions.
4 changes: 2 additions & 2 deletions packages/casl-mongoose/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@
"license": "MIT",
"peerDependencies": {
"@casl/ability": "^3.0.0 || ^4.0.0 || ^5.1.0 || ^6.0.0",
"mongoose": "^6.0.0"
"mongoose": "^6.0.13"
},
"devDependencies": {
"@casl/ability": "^6.0.0",
"@casl/dx": "workspace:^1.0.0",
"@types/jest": "^26.0.22",
"chai": "^4.1.0",
"chai-spies": "^1.0.0",
"mongoose": "^6.0.0"
"mongoose": "^6.0.13"
},
"files": [
"dist",
Expand Down
22 changes: 12 additions & 10 deletions packages/casl-mongoose/spec/accessible_fields.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { defineAbility, Ability } from '@casl/ability'
import mongoose from 'mongoose'
import { AccessibleFieldsModel, accessibleFieldsPlugin, AccessibleFieldsDocument } from '../src'
import { accessibleFieldsPlugin, AccessibleFieldsModel } from '../src'

describe('Accessible fields plugin', () => {
interface Post extends AccessibleFieldsDocument {
interface Post {
title: string;
state: string;
}
let PostSchema: mongoose.Schema<Post>

type PostModel = AccessibleFieldsModel<Post>
let PostSchema: mongoose.Schema<Post, PostModel>

beforeEach(() => {
PostSchema = new mongoose.Schema<Post>({
Expand All @@ -21,7 +23,7 @@ describe('Accessible fields plugin', () => {
})

it('adds static and instace `accessibleFieldsBy` method', () => {
const Post = mongoose.model<Post, AccessibleFieldsModel<Post>>(
const Post = mongoose.model<Post, PostModel>(
'Post',
PostSchema.plugin(accessibleFieldsPlugin)
)
Expand All @@ -32,12 +34,12 @@ describe('Accessible fields plugin', () => {
})

describe('`accessibleFieldsBy` method', () => {
let Post: AccessibleFieldsModel<Post>
let Post: PostModel

describe('by default', () => {
beforeEach(() => {
PostSchema.plugin(accessibleFieldsPlugin)
Post = mongoose.model<Post, AccessibleFieldsModel<Post>>('Post', PostSchema)
Post = mongoose.model<Post, PostModel>('Post', PostSchema)
})

it('returns empty array for empty `Ability` instance', () => {
Expand Down Expand Up @@ -86,28 +88,28 @@ describe('Accessible fields plugin', () => {

it('returns fields provided in `only` option specified as string', () => {
PostSchema.plugin(accessibleFieldsPlugin, { only: 'title' })
Post = mongoose.model<Post, AccessibleFieldsModel<Post>>('Post', PostSchema)
Post = mongoose.model<Post, PostModel>('Post', PostSchema)

expect(Post.accessibleFieldsBy(ability)).toEqual(['title'])
})

it('returns fields provided in `only` option specified as array', () => {
PostSchema.plugin(accessibleFieldsPlugin, { only: ['title', 'state'] })
Post = mongoose.model<Post, AccessibleFieldsModel<Post>>('Post', PostSchema)
Post = mongoose.model<Post, PostModel>('Post', PostSchema)

expect(Post.accessibleFieldsBy(ability)).toEqual(['title', 'state'])
})

it('returns all fields except one specified in `except` option as string', () => {
PostSchema.plugin(accessibleFieldsPlugin, { except: '_id' })
Post = mongoose.model<Post, AccessibleFieldsModel<Post>>('Post', PostSchema)
Post = mongoose.model<Post, PostModel>('Post', PostSchema)

expect(Post.accessibleFieldsBy(ability)).toEqual(['title', 'state', '__v'])
})

it('returns all fields except specified in `except` option as array', () => {
PostSchema.plugin(accessibleFieldsPlugin, { except: ['_id', '__v'] })
Post = mongoose.model<Post, AccessibleFieldsModel<Post>>('Post', PostSchema)
Post = mongoose.model<Post, PostModel>('Post', PostSchema)

expect(Post.accessibleFieldsBy(ability)).toEqual(['title', 'state'])
})
Expand Down
35 changes: 23 additions & 12 deletions packages/casl-mongoose/spec/accessible_records.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import mongoose from 'mongoose'
import { AccessibleRecordModel, accessibleRecordsPlugin, toMongoQuery } from '../src'

describe('Accessible Records Plugin', () => {
interface Post extends mongoose.Document {
interface Post {
title: string;
state: string;
}
Expand Down Expand Up @@ -79,8 +79,19 @@ describe('Accessible Records Plugin', () => {
Post.accessibleBy(ability).where({ title: /test/ }).accessibleBy(ability, 'delete')
})

it('has proper typing for `accessibleBy` methods', () => {
let expectedQueryType: mongoose.Query<mongoose.HydratedDocument<Post>[], any>
expectedQueryType = Post.find()
expectedQueryType = Post.find().accessibleBy(ability)
expectedQueryType = Post.find().accessibleBy(ability).accessibleBy(ability, 'update')
expectedQueryType = Post.accessibleBy(ability).where({ title: /test/ }).accessibleBy(ability, 'delete')
expectedQueryType = Post.accessibleBy(ability).find()
expectedQueryType = Post.accessibleBy(ability).accessibleBy(ability, 'update').find()
expect(expectedQueryType).not.toBeUndefined()
})

describe('when ability disallow to perform an action', () => {
let query: mongoose.QueryWithHelpers<Post, Post>
let query: mongoose.QueryWithHelpers<Post, Post, any, any>

beforeEach(() => {
query = Post.find().accessibleBy(ability, 'notAllowedAction')
Expand All @@ -89,7 +100,7 @@ describe('Accessible Records Plugin', () => {
it('throws `ForbiddenError` for collection request', async () => {
await query.exec()
.then(() => fail('should not execute'))
.catch((error) => {
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
Expand All @@ -98,7 +109,7 @@ describe('Accessible Records Plugin', () => {
it('throws `ForbiddenError` when `find` is called', async () => {
await query.find()
.then(() => fail('should not execute'))
.catch((error) => {
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
Expand All @@ -107,7 +118,7 @@ describe('Accessible Records Plugin', () => {
it('throws `ForbiddenError` when callback is passed to `exec`', async () => {
await wrapInPromise(cb => query.exec(cb))
.then(() => fail('should not execute'))
.catch((error) => {
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
Expand All @@ -116,7 +127,7 @@ describe('Accessible Records Plugin', () => {
it('throws `ForbiddenError` for item request', async () => {
await query.findOne().exec()
.then(() => fail('should not execute'))
.catch((error) => {
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
Expand All @@ -125,7 +136,7 @@ describe('Accessible Records Plugin', () => {
it('throws `ForbiddenError` when `findOne` is called', async () => {
await query.findOne()
.then(() => fail('should not execute'))
.catch((error) => {
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
Expand All @@ -134,7 +145,7 @@ describe('Accessible Records Plugin', () => {
it('throws `ForbiddenError` for item request when callback is passed to `exec`', async () => {
await wrapInPromise(cb => query.findOne().exec(cb))
.then(() => fail('should not execute'))
.catch((error) => {
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
Expand All @@ -143,7 +154,7 @@ describe('Accessible Records Plugin', () => {
it('throws `ForbiddenError` for count request', async () => {
await query.count()
.then(() => fail('should not execute'))
.catch((error) => {
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
Expand All @@ -152,7 +163,7 @@ describe('Accessible Records Plugin', () => {
it('throws `ForbiddenError` for count request', async () => {
await query.count()
.then(() => fail('should not execute'))
.catch((error) => {
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
Expand All @@ -161,7 +172,7 @@ describe('Accessible Records Plugin', () => {
it('throws `ForbiddenError` for `countDocuments` request', async () => {
await query.countDocuments()
.then(() => fail('should not execute'))
.catch((error) => {
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
Expand All @@ -170,7 +181,7 @@ describe('Accessible Records Plugin', () => {
it('throws `ForbiddenError` for `countDocuments` request', async () => {
await query.estimatedDocumentCount()
.then(() => fail('should not execute'))
.catch((error) => {
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
Expand Down
21 changes: 16 additions & 5 deletions packages/casl-mongoose/src/accessible_fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,31 @@ function fieldsOf(schema: Schema<Document>, options: Partial<AccessibleFieldsOpt
return fields.filter(field => excludedFields.indexOf(field) === -1);
}

type GetAccessibleFields<T extends AccessibleFieldsDocument> = <U extends AnyMongoAbility>(
type GetAccessibleFields<T> = <U extends AnyMongoAbility>(
this: Model<T> | T,
ability: U,
action?: Normalize<Generics<U>['abilities']>[0]
) => string[];

export interface AccessibleFieldsModel<T extends AccessibleFieldsDocument> extends Model<T> {
export interface AccessibleFieldsModel<
T,
TQueryHelpers = {},
TMethods = {},
TVirtuals = {}
> extends Model<T, TQueryHelpers, TMethods & AccessibleFieldDocumentMethods<T>, TVirtuals> {
accessibleFieldsBy: GetAccessibleFields<T>
}

export interface AccessibleFieldsDocument extends Document {
accessibleFieldsBy: GetAccessibleFields<AccessibleFieldsDocument>
export interface AccessibleFieldDocumentMethods<T = Document> {
accessibleFieldsBy: GetAccessibleFields<T>
}

/**
* @deprecated Mongoose recommends against `extends Document`, prefer to use `AccessibleFieldsModel` instead.
* See here: https://mongoosejs.com/docs/typescript.html#using-extends-document
*/
export interface AccessibleFieldsDocument extends Document, AccessibleFieldDocumentMethods {}

function modelFieldsGetter() {
let fieldsFrom: PermittedFieldsOptions<AnyMongoAbility>['fieldsFrom'];
return (schema: Schema<any>, options: Partial<AccessibleFieldsOptions>) => {
Expand All @@ -52,7 +63,7 @@ function modelFieldsGetter() {
export function accessibleFieldsPlugin(
schema: Schema<any>,
rawOptions?: Partial<AccessibleFieldsOptions>
) {
): void {
const options = { getFields: getSchemaPaths, ...rawOptions };
const fieldsFrom = modelFieldsGetter();
type ModelOrDoc = Model<AccessibleFieldsDocument> | AccessibleFieldsDocument;
Expand Down
40 changes: 30 additions & 10 deletions packages/casl-mongoose/src/accessible_records.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Normalize, AnyMongoAbility, Generics, ForbiddenError, getDefaultErrorMessage } from '@casl/ability';
import type { Schema, QueryWithHelpers, Model, Document } from 'mongoose';
import type { Schema, QueryWithHelpers, Model, Document, HydratedDocument } from 'mongoose';
import mongoose from 'mongoose';
import { toMongoQuery } from './mongo';

Expand Down Expand Up @@ -49,21 +49,41 @@ function accessibleBy<T extends AnyMongoAbility>(
return this instanceof mongoose.Query ? this.and([query]) : this.where({ $and: [query] });
}

type GetAccessibleRecords<T extends Document> = <U extends AnyMongoAbility>(
type GetAccessibleRecords<T, TQueryHelpers, TMethods, TVirtuals> = <U extends AnyMongoAbility>(
ability: U,
action?: Normalize<Generics<U>['abilities']>[0]
) => QueryWithHelpers<T, T, QueryHelpers<T>>;
) => QueryWithHelpers<
Array<T>,
T,
AccessibleRecordQueryHelpers<T, TQueryHelpers, TMethods, TVirtuals>
>;

type QueryHelpers<T extends Document> = {
accessibleBy: GetAccessibleRecords<T>
export type AccessibleRecordQueryHelpers<T, TQueryHelpers = {}, TMethods = {}, TVirtuals = {}> = {
accessibleBy: GetAccessibleRecords<
HydratedDocument<T, TMethods, TVirtuals>,
TQueryHelpers,
TMethods,
TVirtuals
>
};
export interface AccessibleRecordModel<
T extends Document, K = unknown
> extends Model<T, K & QueryHelpers<T>> {
accessibleBy: GetAccessibleRecords<T>
T,
TQueryHelpers = {},
TMethods = {},
TVirtuals = {}
> extends Model<T,
TQueryHelpers & AccessibleRecordQueryHelpers<T, TQueryHelpers, TMethods, TVirtuals>,
TMethods,
TVirtuals> {
accessibleBy: GetAccessibleRecords<
HydratedDocument<T, TMethods, TVirtuals>,
TQueryHelpers,
TMethods,
TVirtuals
>
}

export function accessibleRecordsPlugin(schema: Schema<any>) {
schema.query.accessibleBy = accessibleBy;
export function accessibleRecordsPlugin(schema: Schema<any>): void {
(schema.query as Record<string, unknown>).accessibleBy = accessibleBy;
schema.statics.accessibleBy = accessibleBy;
}
33 changes: 26 additions & 7 deletions packages/casl-mongoose/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
import { AccessibleFieldsModel, AccessibleFieldsDocument } from './accessible_fields';
import { AccessibleRecordModel } from './accessible_records';
import { AccessibleFieldDocumentMethods, AccessibleFieldsModel } from './accessible_fields';
import { AccessibleRecordModel, AccessibleRecordQueryHelpers } from './accessible_records';

export type AccessibleModel<T extends AccessibleFieldsDocument> =
AccessibleRecordModel<T> & AccessibleFieldsModel<T>;
export interface AccessibleModel<
T,
TQueryHelpers = unknown,
TMethods = unknown,
TVirtuals = unknown
>
extends
AccessibleRecordModel<T, TQueryHelpers, TMethods & AccessibleFieldDocumentMethods<T>, TVirtuals>,
AccessibleFieldsModel<T, TQueryHelpers & AccessibleRecordQueryHelpers<
T,
TQueryHelpers,
TMethods & AccessibleFieldDocumentMethods<T>,
TVirtuals
>, TMethods, TVirtuals>
{}

export * from './accessible_records';
export * from './accessible_fields';
export * from './mongo';
export { accessibleRecordsPlugin } from './accessible_records';
export type { AccessibleRecordModel } from './accessible_records';
export { getSchemaPaths, accessibleFieldsPlugin } from './accessible_fields';
export type {
AccessibleFieldsModel,
AccessibleFieldsDocument,
AccessibleFieldsOptions
} from './accessible_fields';
export { toMongoQuery } from './mongo';
4 changes: 2 additions & 2 deletions packages/casl-mongoose/src/mongo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AnyMongoAbility } from '@casl/ability';
import { rulesToQuery } from '@casl/ability/extra';
import { AbilityQuery, rulesToQuery } from '@casl/ability/extra';

function convertToMongoQuery(rule: AnyMongoAbility['rules'][number]) {
const conditions = rule.conditions!;
Expand All @@ -10,6 +10,6 @@ export function toMongoQuery<T extends AnyMongoAbility>(
ability: T,
subjectType: Parameters<T['rulesFor']>[1],
action: Parameters<T['rulesFor']>[0] = 'read'
) {
): AbilityQuery | null {
return rulesToQuery(ability, action, subjectType, convertToMongoQuery);
}
Loading

0 comments on commit f5273e3

Please sign in to comment.