diff --git a/src/index.cjs.ts b/src/index.cjs.ts index 0abcf68..8f60e54 100644 --- a/src/index.cjs.ts +++ b/src/index.cjs.ts @@ -16,6 +16,7 @@ import { BelongsTo } from './model/decorators/attributes/relations/BelongsTo' import { HasMany } from './model/decorators/attributes/relations/HasMany' import { HasManyBy } from './model/decorators/attributes/relations/HasManyBy' import { MorphOne } from './model/decorators/attributes/relations/MorphOne' +import { MorphMany } from './model/decorators/attributes/relations/MorphMany' import { Attribute } from './model/attributes/Attribute' import { Type } from './model/attributes/types/Type' import { Attr as AttrAttr } from './model/attributes/types/Attr' @@ -29,6 +30,7 @@ import { BelongsTo as BelongsToAttr } from './model/attributes/relations/Belongs import { HasMany as HasManyAttr } from './model/attributes/relations/HasMany' import { HasManyBy as HasManyByAttr } from './model/attributes/relations/HasManyBy' import { MorphOne as MorphOneAttr } from './model/attributes/relations/MorphOne' +import { MorphMany as MorphManyAttr } from './model/attributes/relations/MorphMany' import { Repository } from './repository/Repository' import { Interpreter } from './interpreter/Interpreter' import { Query } from './query/Query' @@ -51,6 +53,7 @@ export default { HasMany, HasManyBy, MorphOne, + MorphMany, Attribute, Type, AttrAttr, @@ -64,6 +67,7 @@ export default { HasManyAttr, HasManyByAttr, MorphOneAttr, + MorphManyAttr, Repository, Interpreter, Query, diff --git a/src/index.ts b/src/index.ts index ca80412..4f2a69a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export * from './model/decorators/attributes/relations/HasMany' export * from './model/decorators/attributes/relations/HasManyBy' export * from './model/decorators/attributes/relations/MorphOne' export * from './model/decorators/attributes/relations/MorphTo' +export * from './model/decorators/attributes/relations/MorphMany' export * from './model/decorators/Contracts' export * from './model/decorators/NonEnumerable' export * from './model/attributes/Attribute' @@ -33,6 +34,7 @@ export { HasMany as HasManyAttr } from './model/attributes/relations/HasMany' export { HasManyBy as HasManyByAttr } from './model/attributes/relations/HasManyBy' export { MorphOne as MorphOneAttr } from './model/attributes/relations/MorphOne' export { MorphTo as MorphToAttr } from './model/attributes/relations/MorphTo' +export { MorphMany as MorphManyAttr } from './model/attributes/relations/MorphMany' export * from './modules/RootModule' export * from './modules/RootState' export * from './modules/Module' @@ -66,6 +68,7 @@ import { HasMany as HasManyAttr } from './model/attributes/relations/HasMany' import { HasManyBy as HasManyByAttr } from './model/attributes/relations/HasManyBy' import { MorphOne as MorphOneAttr } from './model/attributes/relations/MorphOne' import { MorphTo as MorphToAttr } from './model/attributes/relations/MorphTo' +import { MorphMany as MorphManyAttr } from './model/attributes/relations/MorphMany' import { Repository } from './repository/Repository' import { Interpreter } from './interpreter/Interpreter' import { Query } from './query/Query' @@ -92,6 +95,7 @@ export default { HasManyByAttr, MorphOneAttr, MorphToAttr, + MorphManyAttr, Repository, Interpreter, Query, diff --git a/src/model/Model.ts b/src/model/Model.ts index 87e31b2..1c87db9 100644 --- a/src/model/Model.ts +++ b/src/model/Model.ts @@ -13,6 +13,7 @@ import { HasMany } from './attributes/relations/HasMany' import { HasManyBy } from './attributes/relations/HasManyBy' import { MorphOne } from './attributes/relations/MorphOne' import { MorphTo } from './attributes/relations/MorphTo' +import { MorphMany } from './attributes/relations/MorphMany' export type ModelFields = Record export type ModelSchemas = Record @@ -263,6 +264,22 @@ export class Model { return new MorphTo(instance, relatedModels, id, type, ownerKey) } + /** + * Create a new MorphMany relation instance. + */ + static morphMany( + related: typeof Model, + id: string, + type: string, + localKey?: string + ): MorphMany { + const model = this.newRawInstance() + + localKey = localKey ?? model.$getLocalKey() + + return new MorphMany(model, related.newRawInstance(), id, type, localKey) + } + /** * Get the constructor for this model. */ diff --git a/src/model/attributes/relations/MorphMany.ts b/src/model/attributes/relations/MorphMany.ts new file mode 100644 index 0000000..882cdfe --- /dev/null +++ b/src/model/attributes/relations/MorphMany.ts @@ -0,0 +1,102 @@ +import { Schema as NormalizrSchema } from 'normalizr' +import { Schema } from '../../../schema/Schema' +import { Element, Collection } from '../../../data/Data' +import { Query } from '../../../query/Query' +import { Model } from '../../Model' +import { Relation, Dictionary } from './Relation' + +export class MorphMany extends Relation { + /** + * The field name that contains id of the parent model. + */ + protected morphId: string + + /** + * The field name that contains type of the parent model. + */ + protected morphType: string + + /** + * The local key of the model. + */ + protected localKey: string + + /** + * Create a new morph-many relation instance. + */ + constructor( + parent: Model, + related: Model, + morphId: string, + morphType: string, + localKey: string + ) { + super(parent, related) + this.morphId = morphId + this.morphType = morphType + this.localKey = localKey + } + + /** + * Get all related models for the relationship. + */ + getRelateds(): Model[] { + return [this.related] + } + + /** + * Define the normalizr schema for the relation. + */ + define(schema: Schema): NormalizrSchema { + return schema.many(this.related, this.parent) + } + + /** + * Attach the parent type and id to the given relation. + */ + attach(record: Element, child: Element): void { + child[this.morphId] = record[this.localKey] + child[this.morphType] = this.parent.$entity() + } + + /** + * Set the constraints for an eager load of the relation. + */ + addEagerConstraints(query: Query, models: Collection): void { + query.where(this.morphType, this.parent.$entity()) + query.whereIn(this.morphId, this.getKeys(models, this.localKey)) + } + + /** + * Match the eagerly loaded results to their parents. + */ + match(relation: string, models: Collection, query: Query): void { + const dictionary = this.buildDictionary(query.get()) + + models.forEach((model) => { + const key = model[this.localKey] + + dictionary[key] + ? model.$setRelation(relation, dictionary[key]) + : model.$setRelation(relation, []) + }) + } + + /** + * Build model dictionary keyed by the relation's foreign key. + */ + protected buildDictionary(results: Collection): Dictionary { + return this.mapToDictionary(results, (result) => { + return [result[this.morphId], result] + }) + } + + /** + * Make related models. + */ + make(elements?: Element[]): Model[] { + return elements + ? elements.map((element) => this.related.$newInstance(element)) + : [] + } +} diff --git a/src/model/decorators/attributes/relations/MorphMany.ts b/src/model/decorators/attributes/relations/MorphMany.ts new file mode 100644 index 0000000..fabf01a --- /dev/null +++ b/src/model/decorators/attributes/relations/MorphMany.ts @@ -0,0 +1,20 @@ +import { Model } from '../../../Model' +import { PropertyDecorator } from '../../Contracts' + +/** + * Create a morph-many attribute property decorator. + */ +export function MorphMany( + related: () => typeof Model, + id: string, + type: string, + localKey?: string +): PropertyDecorator { + return (target, propertyKey) => { + const self = target.$self() + + self.setRegistry(propertyKey, () => + self.morphMany(related(), id, type, localKey) + ) + } +} diff --git a/test/feature/relations/morph_many_retrieve.spec.ts b/test/feature/relations/morph_many_retrieve.spec.ts new file mode 100644 index 0000000..663294e --- /dev/null +++ b/test/feature/relations/morph_many_retrieve.spec.ts @@ -0,0 +1,184 @@ +import { + createStore, + fillState, + assertModel, + assertInstanceOf +} from 'test/Helpers' +import { Model, Attr, Str, Num, MorphMany } from '@/index' + +describe('feature/relations/morph_many_retrieve', () => { + class Comment extends Model { + static entity = 'comments' + + @Num(0) id!: number + @Str('') body!: string + @Attr(null) commentableId!: number | null + @Attr(null) commentableType!: string | null + } + + class Video extends Model { + static entity = 'videos' + + @Num(0) id!: number + @Str('') link!: string + + @MorphMany(() => Comment, 'commentableId', 'commentableType') + comments!: Comment[] + } + + class Post extends Model { + static entity = 'posts' + + @Num(0) id!: number + @Str('') title!: string + @MorphMany(() => Comment, 'commentableId', 'commentableType') + comments!: Comment[] + } + + const ENTITIES = { + videos: { 1: { id: 1, link: '/video.mp4' } }, + posts: { + 1: { id: 1, title: 'Hello, world!' }, + 2: { id: 2, title: 'Hello, world! Again!' } + }, + comments: { + 1: { + id: 1, + body: 'Cool Video!', + commentableId: 1, + commentableType: 'videos' + }, + 2: { + id: 2, + body: 'Cool Video Again!', + commentableId: 1, + commentableType: 'videos' + }, + 3: { + id: 3, + body: 'Cool Post!', + commentableId: 1, + commentableType: 'posts' + }, + 4: { + id: 4, + body: 'Cool Post 2!', + commentableId: 2, + commentableType: 'posts' + } + } + } + + describe('when there are comments', () => { + const store = createStore() + + beforeAll(() => { + fillState(store, ENTITIES) + }) + + it('can eager load morph many relation for video', () => { + const video = store.$repo(Video).with('comments').first()! + + expect(video).toBeInstanceOf(Video) + assertInstanceOf(video.comments, Comment) + assertModel(video, { + id: 1, + link: '/video.mp4', + comments: [ + { + id: 1, + body: 'Cool Video!', + commentableId: 1, + commentableType: 'videos' + }, + { + id: 2, + body: 'Cool Video Again!', + commentableId: 1, + commentableType: 'videos' + } + ] + }) + }) + + it('can eager load morph many relation for post', () => { + const post = store.$repo(Post).with('comments').first()! + + expect(post).toBeInstanceOf(Post) + assertInstanceOf(post.comments, Comment) + assertModel(post, { + id: 1, + title: 'Hello, world!', + comments: [ + { + id: 3, + body: 'Cool Post!', + commentableId: 1, + commentableType: 'posts' + } + ] + }) + }) + }) + + describe('when there are no comments', () => { + const store = createStore() + + beforeAll(() => { + fillState(store, { + videos: { + 1: { id: 1, link: '/video.mp4' } + }, + posts: {}, + comments: {} + }) + }) + + it('can eager load missing relation as empty array', () => { + const video = store.$repo(Video).with('comments').first()! + + expect(video).toBeInstanceOf(Video) + assertModel(video, { + id: 1, + link: '/video.mp4', + comments: [] + }) + }) + }) + + it('can revive "morph many" relations', () => { + const store = createStore() + + fillState(store, { + videos: { + 1: { id: 1, link: '/video.mp4' } + }, + comments: { + 1: { + id: 1, + commentableId: 1, + commentableType: 'videos', + body: 'Cool Video!' + }, + 2: { + id: 2, + commentableId: 1, + commentableType: 'videos', + body: 'Cool Video Again!' + } + } + }) + + const schema = { + id: '1', + comments: [{ id: 2 }, { id: 1 }] + } + + const video = store.$repo(Video).revive(schema)! + + expect(video.comments).toHaveLength(2) + assertInstanceOf(video.comments, Comment) + expect(video.comments[0].id).toBe(2) + expect(video.comments[1].id).toBe(1) + }) +}) diff --git a/test/feature/relations/morph_many_save.spec.ts b/test/feature/relations/morph_many_save.spec.ts new file mode 100644 index 0000000..dde2643 --- /dev/null +++ b/test/feature/relations/morph_many_save.spec.ts @@ -0,0 +1,127 @@ +import { createStore, fillState, assertState } from 'test/Helpers' +import { Model, Attr, Num, Str, MorphMany } from '@/index' + +describe('feature/relations/morph_many_save', () => { + class Comment extends Model { + static entity = 'comments' + + @Num(0) id!: number + @Str('') body!: string + @Attr(null) commentableId!: number | null + @Attr(null) commentableType!: string | null + } + + class Video extends Model { + static entity = 'videos' + + @Num(0) id!: number + @Str('') link!: string + + @MorphMany(() => Comment, 'commentableId', 'commentableType') + comments!: Comment[] + } + + it('saves a model to the store with "morph many" relation', () => { + const store = createStore() + + fillState(store, { + videos: {}, + comments: { + 1: { + id: 1, + commentableId: 1, + commentableType: 'videos', + body: 'Some Comment' + } + } + }) + + store.$repo(Video).save({ + id: 1, + link: '/video.mp4', + comments: [ + { + id: 1, + commentableId: 1, + commentableType: 'videos', + body: 'Cool Video!' + }, + { + id: 2, + commentableId: 1, + commentableType: 'videos', + body: 'Cool Video Again!' + } + ] + }) + + assertState(store, { + videos: { + 1: { id: 1, link: '/video.mp4' } + }, + comments: { + 1: { + id: 1, + commentableId: 1, + commentableType: 'videos', + body: 'Cool Video!' + }, + 2: { + id: 2, + commentableId: 1, + commentableType: 'videos', + body: 'Cool Video Again!' + } + } + }) + }) + + it('generates missing relational key', async () => { + const store = createStore() + + store.$repo(Video).save({ + id: 1, + link: '/video.mp4', + comments: [ + { id: 1, body: 'Cool Video!' }, + { id: 2, body: 'Cool Video Again!' } + ] + }) + + assertState(store, { + videos: { + 1: { id: 1, link: '/video.mp4' } + }, + comments: { + 1: { + id: 1, + commentableId: 1, + commentableType: 'videos', + body: 'Cool Video!' + }, + 2: { + id: 2, + commentableId: 1, + commentableType: 'videos', + body: 'Cool Video Again!' + } + } + }) + }) + + it('can insert a record with missing related data', async () => { + const store = createStore() + + store.$repo(Video).save({ + id: 1, + link: '/video.mp4' + }) + + assertState(store, { + videos: { + 1: { id: 1, link: '/video.mp4' } + }, + comments: {} + }) + }) +}) diff --git a/test/feature/relations/morph_many_save_custom_key.spec.ts b/test/feature/relations/morph_many_save_custom_key.spec.ts new file mode 100644 index 0000000..5bc16fa --- /dev/null +++ b/test/feature/relations/morph_many_save_custom_key.spec.ts @@ -0,0 +1,121 @@ +import { createStore, assertState } from 'test/Helpers' +import { Model, Attr, Num, Str, MorphMany } from '@/index' + +describe('feature/relations/morph_many_save_custom_key', () => { + beforeEach(() => { + Model.clearRegistries() + }) + + it('inserts "morph many" relation with custom primary key', () => { + class Comment extends Model { + static entity = 'comments' + + @Num(0) id!: number + @Str('') body!: string + @Attr(null) commentableId!: number | null + @Attr(null) commentableType!: string | null + } + + class Video extends Model { + static entity = 'videos' + + static primaryKey = 'morphableId' + + @Num(0) morphableId!: number + @Str('') link!: string + + @MorphMany(() => Comment, 'commentableId', 'commentableType') + comments!: Comment[] + } + + const store = createStore() + + store.$repo(Video).save({ + morphableId: 1, + link: '/video.mp4', + comments: [ + { id: 1, body: 'Cool Video!' }, + { id: 2, body: 'Cool Video Again!' } + ] + }) + + assertState(store, { + videos: { + 1: { morphableId: 1, link: '/video.mp4' } + }, + comments: { + 1: { + id: 1, + commentableId: 1, + commentableType: 'videos', + body: 'Cool Video!' + }, + 2: { + id: 2, + commentableId: 1, + commentableType: 'videos', + body: 'Cool Video Again!' + } + } + }) + }) + + it('inserts "morph many" relation with custom local key', () => { + class Comment extends Model { + static entity = 'comments' + + @Num(0) id!: number + @Str('') body!: string + @Attr(null) commentableId!: number | null + @Attr(null) commentableType!: string | null + } + + class Video extends Model { + static entity = 'videos' + + @Num(0) id!: number + @Num(0) morphableId!: number + @Str('') link!: string + + @MorphMany( + () => Comment, + 'commentableId', + 'commentableType', + 'morphableId' + ) + comments!: Comment[] + } + + const store = createStore() + + store.$repo(Video).save({ + id: 1, + morphableId: 2, + link: '/video.mp4', + comments: [ + { id: 1, body: 'Cool Video!' }, + { id: 2, body: 'Cool Video Again!' } + ] + }) + + assertState(store, { + videos: { + 1: { id: 1, morphableId: 2, link: '/video.mp4' } + }, + comments: { + 1: { + id: 1, + commentableId: 2, + commentableType: 'videos', + body: 'Cool Video!' + }, + 2: { + id: 2, + commentableId: 2, + commentableType: 'videos', + body: 'Cool Video Again!' + } + } + }) + }) +}) diff --git a/test/feature/relations/morph_many_save_uid.spec.ts b/test/feature/relations/morph_many_save_uid.spec.ts new file mode 100644 index 0000000..70e318e --- /dev/null +++ b/test/feature/relations/morph_many_save_uid.spec.ts @@ -0,0 +1,111 @@ +import { createStore, assertState, mockUid } from 'test/Helpers' +import { Model, Attr, Uid, Str, Num, MorphMany } from '@/index' + +describe('feature/relations/morph_many_save_uid', () => { + beforeEach(() => { + Model.clearRegistries() + }) + + it('inserts "morph many" relation with parent having "uid" field as the primary key', () => { + class Comment extends Model { + static entity = 'comments' + + @Num(0) id!: number + @Str('') body!: string + @Attr(null) commentableId!: string | null + @Attr(null) commentableType!: string | null + } + + class Video extends Model { + static entity = 'videos' + + @Uid() id!: string + @Str('') link!: string + + @MorphMany(() => Comment, 'commentableId', 'commentableType') + comments!: Comment[] + } + + mockUid(['uid1']) + + const store = createStore() + + store.$repo(Video).save({ + link: '/video.mp4', + comments: [ + { id: 1, body: 'Cool Video!' }, + { id: 2, body: 'Cool Video Again!' } + ] + }) + + assertState(store, { + videos: { + uid1: { id: 'uid1', link: '/video.mp4' } + }, + comments: { + 1: { + id: 1, + commentableId: 'uid1', + commentableType: 'videos', + body: 'Cool Video!' + }, + 2: { + id: 2, + commentableId: 'uid1', + commentableType: 'videos', + body: 'Cool Video Again!' + } + } + }) + }) + + it('inserts "morph many" relation with related having "uid" as the relational key', () => { + class Comment extends Model { + static entity = 'comments' + + @Uid() id!: number + @Str('') body!: string + @Attr(null) commentableId!: string | null + @Attr(null) commentableType!: string | null + } + + class Video extends Model { + static entity = 'videos' + + @Uid() id!: string + @Str('') link!: string + + @MorphMany(() => Comment, 'commentableId', 'commentableType') + comments!: Comment[] + } + + mockUid(['uid1', 'uid2', 'uid3']) + + const store = createStore() + + store.$repo(Video).save({ + link: '/video.mp4', + comments: [{ body: 'Cool Video!' }, { body: 'Cool Video Again!' }] + }) + + assertState(store, { + videos: { + uid1: { id: 'uid1', link: '/video.mp4' } + }, + comments: { + uid2: { + id: 'uid2', + commentableId: 'uid1', + commentableType: 'videos', + body: 'Cool Video!' + }, + uid3: { + id: 'uid3', + commentableId: 'uid1', + commentableType: 'videos', + body: 'Cool Video Again!' + } + } + }) + }) +}) diff --git a/test/unit/model/Model_Relations.spec.ts b/test/unit/model/Model_Relations.spec.ts index f10bceb..f539dcb 100644 --- a/test/unit/model/Model_Relations.spec.ts +++ b/test/unit/model/Model_Relations.spec.ts @@ -1,4 +1,4 @@ -import { createStore } from 'test/Helpers' +import { createStore, assertInstanceOf } from 'test/Helpers' import { Model, Attr, @@ -6,8 +6,9 @@ import { BelongsTo, HasMany, HasManyBy, + MorphOne, MorphTo, - MorphOne + MorphMany } from '@/index' describe('unit/model/Model_Relations', () => { @@ -32,6 +33,9 @@ describe('unit/model/Model_Relations', () => { @MorphOne(() => Image, 'imageableId', 'imageableType') image!: Image | null + + @MorphMany(() => Comment, 'commentableId', 'commentableType') + comments!: Comment[] } class Phone extends Model { @@ -71,6 +75,15 @@ describe('unit/model/Model_Relations', () => { imageable!: User | null } + class Comment extends Model { + static entity = 'comments' + + @Attr() id!: number + @Attr() body!: string + @Attr() commentableId!: string | null + @Attr() commentableType!: string | null + } + it('fills "has one" relation', () => { const store = createStore() @@ -158,4 +171,20 @@ describe('unit/model/Model_Relations', () => { expect(image.imageable!).toBeInstanceOf(User) expect(image.imageable!.id).toBe(2) }) + + it('fills "morph many" relation', () => { + const store = createStore() + + const user = store.$repo(User).make({ + id: 1, + comments: [ + { id: 2, body: 'Hey User!' }, + { id: 3, body: 'Hey User Again!' } + ] + }) + + assertInstanceOf(user.comments, Comment) + expect(user.comments[0].id).toBe(2) + expect(user.comments[1].id).toBe(3) + }) })