Skip to content

Commit ba0496a

Browse files
ryandialpadkiaking
andauthored
feat: add MorphOne relation (#90) (#91)
close #90 Co-authored-by: Kia King Ishii <[email protected]>
1 parent 6797a58 commit ba0496a

File tree

9 files changed

+666
-1
lines changed

9 files changed

+666
-1
lines changed

src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './model/decorators/attributes/relations/HasOne'
1515
export * from './model/decorators/attributes/relations/BelongsTo'
1616
export * from './model/decorators/attributes/relations/HasMany'
1717
export * from './model/decorators/attributes/relations/HasManyBy'
18+
export * from './model/decorators/attributes/relations/MorphOne'
1819
export * from './model/decorators/Contracts'
1920
export * from './model/decorators/NonEnumerable'
2021
export * from './model/attributes/Attribute'
@@ -29,6 +30,7 @@ export { HasOne as HasOneAttr } from './model/attributes/relations/HasOne'
2930
export { BelongsTo as BelongsToAttr } from './model/attributes/relations/BelongsTo'
3031
export { HasMany as HasManyAttr } from './model/attributes/relations/HasMany'
3132
export { HasManyBy as HasManyByAttr } from './model/attributes/relations/HasManyBy'
33+
export { MorphOne as MorphOneAttr } from './model/attributes/relations/MorphOne'
3234
export * from './modules/RootModule'
3335
export * from './modules/RootState'
3436
export * from './modules/Module'
@@ -59,6 +61,7 @@ import { Relation } from './model/attributes/relations/Relation'
5961
import { HasOne as HasOneAttr } from './model/attributes/relations/HasOne'
6062
import { HasMany as HasManyAttr } from './model/attributes/relations/HasMany'
6163
import { HasManyBy as HasManyByAttr } from './model/attributes/relations/HasManyBy'
64+
import { MorphOne as MorphOneAttr } from './model/attributes/relations/MorphOne'
6265
import { Repository } from './repository/Repository'
6366
import { Interpreter } from './interpreter/Interpreter'
6467
import { Query } from './query/Query'
@@ -82,6 +85,7 @@ export default {
8285
HasOneAttr,
8386
HasManyAttr,
8487
HasManyByAttr,
88+
MorphOneAttr,
8589
Repository,
8690
Interpreter,
8791
Query,

src/model/Model.ts

+17
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { HasOne } from './attributes/relations/HasOne'
1414
import { BelongsTo } from './attributes/relations/BelongsTo'
1515
import { HasMany } from './attributes/relations/HasMany'
1616
import { HasManyBy } from './attributes/relations/HasManyBy'
17+
import { MorphOne } from './attributes/relations/MorphOne'
1718

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

243+
/**
244+
* Create a new MorphOne relation instance.
245+
*/
246+
static morphOne(
247+
related: typeof Model,
248+
id: string,
249+
type: string,
250+
localKey?: string
251+
): MorphOne {
252+
const model = this.newRawInstance()
253+
254+
localKey = localKey ?? model.$getLocalKey()
255+
256+
return new MorphOne(model, related.newRawInstance(), id, type, localKey)
257+
}
258+
242259
/**
243260
* Get the constructor for this model.
244261
*/
+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Schema as NormalizrSchema } from 'normalizr'
2+
import { Schema } from '../../../schema/Schema'
3+
import { Element, Collection } from '../../../data/Data'
4+
import { Query } from '../../../query/Query'
5+
import { Model } from '../../Model'
6+
import { Relation, Dictionary } from './Relation'
7+
8+
export class MorphOne extends Relation {
9+
/**
10+
* The field name that contains id of the parent model.
11+
*/
12+
protected morphId: string
13+
14+
/**
15+
* The field name that contains type of the parent model.
16+
*/
17+
protected morphType: string
18+
19+
/**
20+
* The local key of the model.
21+
*/
22+
protected localKey: string
23+
24+
/**
25+
* Create a new morph-one relation instance.
26+
*/
27+
constructor(
28+
parent: Model,
29+
related: Model,
30+
morphId: string,
31+
morphType: string,
32+
localKey: string
33+
) {
34+
super(parent, related)
35+
this.morphId = morphId
36+
this.morphType = morphType
37+
this.localKey = localKey
38+
}
39+
40+
/**
41+
* Get all related models for the relationship.
42+
*/
43+
getRelateds(): Model[] {
44+
return [this.related]
45+
}
46+
47+
/**
48+
* Define the normalizr schema for the relation.
49+
*/
50+
define(schema: Schema): NormalizrSchema {
51+
return schema.one(this.related, this.parent)
52+
}
53+
54+
/**
55+
* Attach the parent type and id to the given relation.
56+
*/
57+
attach(record: Element, child: Element): void {
58+
child[this.morphId] = record[this.localKey]
59+
child[this.morphType] = this.parent.$entity()
60+
}
61+
62+
/**
63+
* Set the constraints for an eager load of the relation.
64+
*/
65+
addEagerConstraints(query: Query, models: Collection): void {
66+
query.where(this.morphType, this.parent.$entity())
67+
query.whereIn(this.morphId, this.getKeys(models, this.localKey))
68+
}
69+
70+
/**
71+
* Match the eagerly loaded results to their parents.
72+
*/
73+
match(relation: string, models: Collection, results: Collection): void {
74+
const dictionary = this.buildDictionary(results)
75+
76+
models.forEach((model) => {
77+
const key = model[this.localKey]
78+
79+
dictionary[key]
80+
? model.$setRelation(relation, dictionary[key][0])
81+
: model.$setRelation(relation, null)
82+
})
83+
}
84+
85+
/**
86+
* Build model dictionary keyed by the relation's foreign key.
87+
*/
88+
protected buildDictionary(results: Collection): Dictionary {
89+
return this.mapToDictionary(results, (result) => {
90+
return [result[this.morphId], result]
91+
})
92+
}
93+
94+
/**
95+
* Make a related model.
96+
*/
97+
make(element?: Element): Model | null {
98+
return element ? this.related.$newInstance(element) : null
99+
}
100+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Model } from '../../../Model'
2+
import { PropertyDecorator } from '../../Contracts'
3+
4+
/**
5+
* Create a morph-one attribute property decorator.
6+
*/
7+
export function MorphOne(
8+
related: () => typeof Model,
9+
id: string,
10+
type: string,
11+
localKey?: string
12+
): PropertyDecorator {
13+
return (target, propertyKey) => {
14+
const self = target.$self()
15+
16+
self.setRegistry(propertyKey, () =>
17+
self.morphOne(related(), id, type, localKey)
18+
)
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { createStore, fillState, assertModel } from 'test/Helpers'
2+
import { Model, Str, Num, MorphOne } from '@/index'
3+
4+
describe('feature/relations/morph_one_retrieve', () => {
5+
class Image extends Model {
6+
static entity = 'images'
7+
8+
@Num(0) id!: number
9+
@Str('') url!: string
10+
@Num(0) imageableId!: number
11+
@Str('') imageableType!: string
12+
}
13+
14+
class User extends Model {
15+
static entity = 'users'
16+
17+
@Num(0) id!: number
18+
@Str('') name!: string
19+
20+
@MorphOne(() => Image, 'imageableId', 'imageableType')
21+
image!: Image | null
22+
}
23+
24+
class Post extends Model {
25+
static entity = 'posts'
26+
27+
@Num(0) id!: number
28+
@Str('') title!: string
29+
@MorphOne(() => Image, 'imageableId', 'imageableType')
30+
image!: Image | null
31+
}
32+
33+
const ENTITIES = {
34+
users: { 1: { id: 1, name: 'John Doe' } },
35+
posts: {
36+
1: { id: 1, title: 'Hello, world!' },
37+
2: { id: 2, title: 'Hello, world! Again!' }
38+
},
39+
images: {
40+
1: {
41+
id: 1,
42+
url: '/profile.jpg',
43+
imageableId: 1,
44+
imageableType: 'users'
45+
},
46+
2: {
47+
id: 2,
48+
url: '/post.jpg',
49+
imageableId: 1,
50+
imageableType: 'posts'
51+
},
52+
3: {
53+
id: 3,
54+
url: '/post2.jpg',
55+
imageableId: 2,
56+
imageableType: 'posts'
57+
}
58+
}
59+
}
60+
61+
describe('when there are images', () => {
62+
const store = createStore()
63+
64+
fillState(store, ENTITIES)
65+
66+
it('can eager load morph one relation for user', () => {
67+
const user = store.$repo(User).with('image').first()!
68+
69+
expect(user).toBeInstanceOf(User)
70+
expect(user.image).toBeInstanceOf(Image)
71+
assertModel(user, {
72+
id: 1,
73+
name: 'John Doe',
74+
image: {
75+
id: 1,
76+
url: '/profile.jpg',
77+
imageableId: 1,
78+
imageableType: 'users'
79+
}
80+
})
81+
})
82+
83+
it('can eager load morph one relation for post', () => {
84+
const post = store.$repo(Post).with('image').first()!
85+
86+
expect(post).toBeInstanceOf(Post)
87+
expect(post.image).toBeInstanceOf(Image)
88+
assertModel(post, {
89+
id: 1,
90+
title: 'Hello, world!',
91+
image: {
92+
id: 2,
93+
url: '/post.jpg',
94+
imageableId: 1,
95+
imageableType: 'posts'
96+
}
97+
})
98+
})
99+
})
100+
101+
describe('when there are no images', () => {
102+
const store = createStore()
103+
104+
fillState(store, {
105+
users: {
106+
1: { id: 1, name: 'John Doe' }
107+
},
108+
posts: {},
109+
images: {}
110+
})
111+
112+
it('can eager load missing relation as `null`', () => {
113+
const user = store.$repo(User).with('image').first()!
114+
115+
expect(user).toBeInstanceOf(User)
116+
assertModel(user, {
117+
id: 1,
118+
name: 'John Doe',
119+
image: null
120+
})
121+
})
122+
})
123+
})

0 commit comments

Comments
 (0)