Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Morph One #91

Merged
merged 22 commits into from
Nov 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ab1a5a8
Init morph_one_retrieve feature test
ryandialpad Oct 21, 2021
f7cc9a6
Init MorphOne from HasOne
ryandialpad Oct 21, 2021
9b32ea7
Correcting a test model
ryandialpad Oct 22, 2021
ef247eb
Implementing morph one model using the morph one retrieve test as suc…
ryandialpad Oct 22, 2021
0d5f772
Added test for morph one save. Refactored retreive test to try and DR…
ryandialpad Oct 22, 2021
e221491
Making adjustments using testing guideline feedback provided.
ryandialpad Oct 25, 2021
4caacd5
Adding save uid test for morph one
ryandialpad Oct 25, 2021
915501e
Added custom key test, removed TODO now that the morph one relation i…
ryandialpad Oct 25, 2021
3827af3
Updating a comment
ryandialpad Oct 25, 2021
7277165
Reverting a change to a morph one method comment
ryandialpad Oct 25, 2021
61747ab
Updating attach comment
ryandialpad Oct 25, 2021
1aa9575
Updating comments
ryandialpad Oct 25, 2021
82a1bbb
Refactoring imageable_id and imageable_type to use camel case
ryandialpad Oct 26, 2021
e7f66b6
Running test linters
ryandialpad Oct 26, 2021
533d309
Removing usages of Attr in tests
ryandialpad Nov 2, 2021
a30e3c7
Renaming a constant to be more generic in a test
ryandialpad Nov 2, 2021
15db11a
Fixing a typo in MorphOne relation comment and save test
ryandialpad Nov 2, 2021
2408151
Fixing up some more test descriptions
ryandialpad Nov 2, 2021
5c1a117
Correcting more test descriptions
ryandialpad Nov 2, 2021
1503279
removing as any from the morph one addEagerConstraints queries
ryandialpad Nov 2, 2021
b77fc77
test: add test for `make` method
kiaking Nov 8, 2021
10ba90e
Refactor id and type to morphId and morphType to improve readability
ryandialpad Nov 8, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export * from './model/decorators/attributes/relations/HasOne'
export * from './model/decorators/attributes/relations/BelongsTo'
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/Contracts'
export * from './model/decorators/NonEnumerable'
export * from './model/attributes/Attribute'
Expand All @@ -29,6 +30,7 @@ export { HasOne as HasOneAttr } from './model/attributes/relations/HasOne'
export { BelongsTo as BelongsToAttr } from './model/attributes/relations/BelongsTo'
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 * from './modules/RootModule'
export * from './modules/RootState'
export * from './modules/Module'
Expand Down Expand Up @@ -59,6 +61,7 @@ import { Relation } from './model/attributes/relations/Relation'
import { HasOne as HasOneAttr } from './model/attributes/relations/HasOne'
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 { Repository } from './repository/Repository'
import { Interpreter } from './interpreter/Interpreter'
import { Query } from './query/Query'
Expand All @@ -82,6 +85,7 @@ export default {
HasOneAttr,
HasManyAttr,
HasManyByAttr,
MorphOneAttr,
Repository,
Interpreter,
Query,
Expand Down
17 changes: 17 additions & 0 deletions src/model/Model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { HasOne } from './attributes/relations/HasOne'
import { BelongsTo } from './attributes/relations/BelongsTo'
import { HasMany } from './attributes/relations/HasMany'
import { HasManyBy } from './attributes/relations/HasManyBy'
import { MorphOne } from './attributes/relations/MorphOne'

export type ModelFields = Record<string, Attribute>
export type ModelSchemas = Record<string, ModelFields>
Expand Down Expand Up @@ -239,6 +240,22 @@ export class Model {
return new HasManyBy(this.newRawInstance(), instance, foreignKey, ownerKey)
}

/**
* Create a new MorphOne relation instance.
*/
static morphOne(
related: typeof Model,
id: string,
type: string,
localKey?: string
): MorphOne {
const model = this.newRawInstance()

localKey = localKey ?? model.$getLocalKey()

return new MorphOne(model, related.newRawInstance(), id, type, localKey)
}

/**
* Get the constructor for this model.
*/
Expand Down
100 changes: 100 additions & 0 deletions src/model/attributes/relations/MorphOne.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 MorphOne 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-one 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.one(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, results: Collection): void {
const dictionary = this.buildDictionary(results)

models.forEach((model) => {
const key = model[this.localKey]

dictionary[key]
? model.$setRelation(relation, dictionary[key][0])
: model.$setRelation(relation, null)
})
}

/**
* 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 a related model.
*/
make(element?: Element): Model | null {
return element ? this.related.$newInstance(element) : null
}
}
20 changes: 20 additions & 0 deletions src/model/decorators/attributes/relations/MorphOne.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Model } from '../../../Model'
import { PropertyDecorator } from '../../Contracts'

/**
* Create a morph-one attribute property decorator.
*/
export function MorphOne(
related: () => typeof Model,
id: string,
type: string,
localKey?: string
): PropertyDecorator {
return (target, propertyKey) => {
const self = target.$self()

self.setRegistry(propertyKey, () =>
self.morphOne(related(), id, type, localKey)
)
}
}
123 changes: 123 additions & 0 deletions test/feature/relations/morph_one_retrieve.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { createStore, fillState, assertModel } from 'test/Helpers'
import { Model, Str, Num, MorphOne } from '@/index'

describe('feature/relations/morph_one_retrieve', () => {
class Image extends Model {
static entity = 'images'

@Num(0) id!: number
@Str('') url!: string
@Num(0) imageableId!: number
@Str('') imageableType!: string
}

class User extends Model {
static entity = 'users'

@Num(0) id!: number
@Str('') name!: string

@MorphOne(() => Image, 'imageableId', 'imageableType')
image!: Image | null
}

class Post extends Model {
static entity = 'posts'

@Num(0) id!: number
@Str('') title!: string
@MorphOne(() => Image, 'imageableId', 'imageableType')
image!: Image | null
}

const ENTITIES = {
users: { 1: { id: 1, name: 'John Doe' } },
posts: {
1: { id: 1, title: 'Hello, world!' },
2: { id: 2, title: 'Hello, world! Again!' }
},
images: {
1: {
id: 1,
url: '/profile.jpg',
imageableId: 1,
imageableType: 'users'
},
2: {
id: 2,
url: '/post.jpg',
imageableId: 1,
imageableType: 'posts'
},
3: {
id: 3,
url: '/post2.jpg',
imageableId: 2,
imageableType: 'posts'
}
}
}

describe('when there are images', () => {
const store = createStore()

fillState(store, ENTITIES)

it('can eager load morph one relation for user', () => {
const user = store.$repo(User).with('image').first()!

expect(user).toBeInstanceOf(User)
expect(user.image).toBeInstanceOf(Image)
assertModel(user, {
id: 1,
name: 'John Doe',
image: {
id: 1,
url: '/profile.jpg',
imageableId: 1,
imageableType: 'users'
}
})
})

it('can eager load morph one relation for post', () => {
const post = store.$repo(Post).with('image').first()!

expect(post).toBeInstanceOf(Post)
expect(post.image).toBeInstanceOf(Image)
assertModel(post, {
id: 1,
title: 'Hello, world!',
image: {
id: 2,
url: '/post.jpg',
imageableId: 1,
imageableType: 'posts'
}
})
})
})

describe('when there are no images', () => {
const store = createStore()

fillState(store, {
users: {
1: { id: 1, name: 'John Doe' }
},
posts: {},
images: {}
})

it('can eager load missing relation as `null`', () => {
const user = store.$repo(User).with('image').first()!

expect(user).toBeInstanceOf(User)
assertModel(user, {
id: 1,
name: 'John Doe',
image: null
})
})
})
})
Loading