feat: add support for defining hasOne with FK (#10441)

This commit is contained in:
Harminder Virk
2024-12-05 14:15:13 +05:30
committed by GitHub
parent 1cb885d566
commit 223bcff379
11 changed files with 826 additions and 15 deletions

View File

@@ -2887,6 +2887,690 @@ describe("Entity builder", () => {
})
})
describe("Entity builder | hasOneWithFK", () => {
test("define hasOne relationship with FK enabled", () => {
const email = model.define("email", {
email: model.text(),
isVerified: model.boolean(),
})
const user = model.define("user", {
id: model.number(),
username: model.text(),
email: model.hasOne(() => email, {
foreignKey: true,
}),
})
const User = toMikroORMEntity(user)
expectTypeOf(new User()).toEqualTypeOf<{
id: number
username: string
email_id: string
created_at: Date
updated_at: Date
deleted_at: Date | null
email: {
email: string
isVerified: boolean
created_at: Date
updated_at: Date
deleted_at: Date | null
}
}>()
const metaData = MetadataStorage.getMetadataFromDecorator(User)
expect(metaData.className).toEqual("User")
expect(metaData.path).toEqual("User")
expect(metaData.properties).toEqual({
id: {
reference: "scalar",
type: "number",
columnType: "integer",
name: "id",
fieldName: "id",
nullable: false,
getter: false,
setter: false,
},
username: {
reference: "scalar",
type: "string",
columnType: "text",
name: "username",
fieldName: "username",
nullable: false,
getter: false,
setter: false,
},
email: {
reference: "1:1",
name: "email",
entity: "Email",
nullable: false,
},
email_id: {
columnType: "text",
type: "string",
reference: "scalar",
name: "email_id",
nullable: false,
persist: true,
getter: false,
setter: false,
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
fieldName: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
fieldName: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
fieldName: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
})
test("mark hasOne with FK enabled relationship as nullable", () => {
const email = model.define("email", {
email: model.text(),
isVerified: model.boolean(),
})
const user = model.define("user", {
id: model.number(),
username: model.text(),
emails: model
.hasOne(() => email, {
foreignKey: true,
})
.nullable(),
})
const User = toMikroORMEntity(user)
expectTypeOf(new User().emails_id).toEqualTypeOf<string | null>()
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
deleted_at: Date | null
emails_id: string | null
emails: {
email: string
isVerified: boolean
deleted_at: Date | null
} | null
}>()
const metaData = MetadataStorage.getMetadataFromDecorator(User)
expect(metaData.className).toEqual("User")
expect(metaData.path).toEqual("User")
expect(metaData.properties).toEqual({
id: {
reference: "scalar",
type: "number",
columnType: "integer",
name: "id",
fieldName: "id",
nullable: false,
getter: false,
setter: false,
},
username: {
reference: "scalar",
type: "string",
columnType: "text",
name: "username",
fieldName: "username",
nullable: false,
getter: false,
setter: false,
},
emails: {
reference: "1:1",
name: "emails",
entity: "Email",
nullable: true,
},
emails_id: {
columnType: "text",
type: "string",
reference: "scalar",
name: "emails_id",
nullable: true,
persist: true,
getter: false,
setter: false,
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
fieldName: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
fieldName: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
fieldName: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
})
test("define custom mappedBy key for relationship", () => {
const email = model.define("email", {
email: model.text(),
isVerified: model.boolean(),
})
const user = model.define("user", {
id: model.number(),
username: model.text(),
email: model.hasOne(() => email, {
mappedBy: "owner",
foreignKey: true,
}),
})
const User = toMikroORMEntity(user)
expectTypeOf(new User().email_id).toEqualTypeOf<string>()
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
email: { email: string; isVerified: boolean }
}>()
const metaData = MetadataStorage.getMetadataFromDecorator(User)
expect(metaData.className).toEqual("User")
expect(metaData.path).toEqual("User")
expect(metaData.properties).toEqual({
id: {
reference: "scalar",
type: "number",
columnType: "integer",
name: "id",
fieldName: "id",
nullable: false,
getter: false,
setter: false,
},
username: {
reference: "scalar",
type: "string",
columnType: "text",
name: "username",
fieldName: "username",
nullable: false,
getter: false,
setter: false,
},
email: {
reference: "1:1",
name: "email",
entity: "Email",
nullable: false,
mappedBy: "owner",
},
email_id: {
columnType: "text",
type: "string",
reference: "scalar",
name: "email_id",
nullable: false,
persist: true,
getter: false,
setter: false,
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
fieldName: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
fieldName: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
fieldName: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
})
test("define delete cascades for the entity", () => {
const email = model.define("email", {
email: model.text(),
isVerified: model.boolean(),
})
const user = model
.define("user", {
id: model.number(),
username: model.text(),
email: model.hasOne(() => email, {
foreignKey: true,
}),
})
.cascades({
delete: ["email"],
})
const User = toMikroORMEntity(user)
expectTypeOf(new User().email_id).toEqualTypeOf<string>()
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
email: { email: string; isVerified: boolean }
}>()
const metaData = MetadataStorage.getMetadataFromDecorator(User)
expect(metaData.className).toEqual("User")
expect(metaData.path).toEqual("User")
expect(metaData.properties).toEqual({
id: {
reference: "scalar",
type: "number",
columnType: "integer",
name: "id",
fieldName: "id",
nullable: false,
getter: false,
setter: false,
},
username: {
reference: "scalar",
type: "string",
columnType: "text",
name: "username",
fieldName: "username",
nullable: false,
getter: false,
setter: false,
},
email: {
reference: "1:1",
name: "email",
entity: "Email",
nullable: false,
cascade: ["persist", "soft-remove"],
},
email_id: {
columnType: "text",
type: "string",
reference: "scalar",
name: "email_id",
nullable: false,
persist: true,
getter: false,
setter: false,
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
fieldName: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
fieldName: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
fieldName: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
const Email = toMikroORMEntity(email)
const emailMetaData = MetadataStorage.getMetadataFromDecorator(Email)
expect(emailMetaData.className).toEqual("Email")
expect(emailMetaData.path).toEqual("Email")
expect(emailMetaData.properties).toEqual({
email: {
reference: "scalar",
type: "string",
columnType: "text",
name: "email",
fieldName: "email",
nullable: false,
getter: false,
setter: false,
},
isVerified: {
reference: "scalar",
type: "boolean",
columnType: "boolean",
name: "isVerified",
fieldName: "isVerified",
nullable: false,
getter: false,
setter: false,
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
fieldName: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
fieldName: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
fieldName: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
})
test("define delete cascades with belongsTo on the other end", () => {
const email = model.define("email", {
email: model.text(),
isVerified: model.boolean(),
user: model.belongsTo(() => user),
})
const user = model
.define("user", {
id: model.number(),
username: model.text(),
email: model.hasOne(() => email, {
foreignKey: true,
}),
})
.cascades({
delete: ["email"],
})
const User = toMikroORMEntity(user)
expectTypeOf(new User().email_id).toEqualTypeOf<string>()
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
email: {
email: string
isVerified: boolean
user: {
id: number
username: string
}
}
}>()
const metaData = MetadataStorage.getMetadataFromDecorator(User)
expect(metaData.className).toEqual("User")
expect(metaData.path).toEqual("User")
expect(metaData.properties).toEqual({
id: {
reference: "scalar",
type: "number",
columnType: "integer",
name: "id",
fieldName: "id",
nullable: false,
getter: false,
setter: false,
},
username: {
reference: "scalar",
type: "string",
columnType: "text",
name: "username",
fieldName: "username",
nullable: false,
getter: false,
setter: false,
},
email: {
reference: "1:1",
name: "email",
entity: "Email",
nullable: false,
cascade: ["persist", "soft-remove"],
},
email_id: {
columnType: "text",
type: "string",
reference: "scalar",
name: "email_id",
nullable: false,
persist: true,
getter: false,
setter: false,
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
fieldName: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
fieldName: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
fieldName: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
const Email = toMikroORMEntity(email)
const emailMetaData = MetadataStorage.getMetadataFromDecorator(Email)
expect(emailMetaData.className).toEqual("Email")
expect(emailMetaData.path).toEqual("Email")
expect(emailMetaData.properties).toEqual({
email: {
reference: "scalar",
type: "string",
columnType: "text",
name: "email",
fieldName: "email",
nullable: false,
getter: false,
setter: false,
},
isVerified: {
reference: "scalar",
type: "boolean",
columnType: "boolean",
name: "isVerified",
fieldName: "isVerified",
nullable: false,
getter: false,
setter: false,
},
user: {
entity: "User",
mappedBy: "email",
name: "user",
nullable: false,
onDelete: "cascade",
owner: true,
reference: "1:1",
},
user_id: {
columnType: "text",
getter: false,
name: "user_id",
nullable: false,
reference: "scalar",
setter: false,
type: "string",
persist: false,
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
fieldName: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
fieldName: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
fieldName: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
})
})
describe("Entity builder | indexes", () => {
test("should define indexes for an entity", () => {
const group = model.define("group", {

View File

@@ -1,6 +1,7 @@
import { expectTypeOf } from "expect-type"
import { TextProperty } from "../properties/text"
import { HasOne } from "../relations/has-one"
import { HasOneWithForeignKey } from "../relations/has-one-fk"
describe("HasOne relationship", () => {
test("define hasOne relationship", () => {
@@ -57,4 +58,23 @@ describe("HasOne relationship", () => {
expect(HasOne.isHasOne(relationship)).toEqual(false)
})
test("enable foreign keys for has one relationship", () => {
const user = {
username: new TextProperty(),
}
const entityRef = () => user
const relationship = new HasOneWithForeignKey(entityRef, {})
expectTypeOf(relationship["$dataType"]).toEqualTypeOf<() => typeof user>()
expect(relationship.parse("user")).toEqual({
name: "user",
type: "hasOneWithFK",
nullable: false,
options: {},
searchable: false,
entity: entityRef,
})
})
})

View File

@@ -25,6 +25,7 @@ import { BelongsTo } from "./relations/belongs-to"
import { HasMany } from "./relations/has-many"
import { HasOne } from "./relations/has-one"
import { ManyToMany } from "./relations/many-to-many"
import { HasOneWithForeignKey } from "./relations/has-one-fk"
/**
* The implicit properties added by EntityBuilder in every schema
@@ -345,7 +346,27 @@ export class EntityBuilder {
*
* @customNamespace Relationship Methods
*/
hasOne<T>(entityBuilder: T, options?: RelationshipOptions) {
hasOne<T>(
entityBuilder: T,
options: RelationshipOptions & {
foreignKey: true
}
): HasOneWithForeignKey<T>
hasOne<T>(
entityBuilder: T,
options?: RelationshipOptions & {
foreignKey?: false
}
): HasOne<T>
hasOne<T>(
entityBuilder: T,
options?: RelationshipOptions & {
foreignKey?: boolean
}
): HasOneWithForeignKey<T> | HasOne<T> {
if (options?.foreignKey) {
return new HasOneWithForeignKey<T>(entityBuilder, options || {})
}
return new HasOne<T>(entityBuilder, options || {})
}

View File

@@ -139,7 +139,7 @@ export class DmlEntity<
*/
cascades(
options: EntityCascades<
ExtractEntityRelations<Schema, "hasOne" | "hasMany">
ExtractEntityRelations<Schema, "hasOne" | "hasOneWithFK" | "hasMany">
>
) {
const childToParentCascades = options.delete?.filter((relationship) => {

View File

@@ -22,6 +22,7 @@ import { parseEntityName } from "./parse-entity-name"
import { camelToSnakeCase, pluralize } from "../../../common"
import { applyEntityIndexes } from "../mikro-orm/apply-indexes"
import { ManyToMany as DmlManyToMany } from "../../relations/many-to-many"
import { HasOneWithForeignKey } from "../../relations/has-one-fk"
type Context = {
MANY_TO_MANY_TRACKED_RELATIONS: Record<string, boolean>
@@ -150,6 +151,43 @@ export function defineHasOneRelationship(
})(MikroORMEntity.prototype, relationship.name)
}
/**
* Defines has one relationship with Foreign key on the MikroORM
* entity
*/
export function defineHasOneWithFKRelationship(
MikroORMEntity: EntityConstructor<any>,
relationship: RelationshipMetadata,
{ relatedModelName }: { relatedModelName: string },
cascades: EntityCascades<string[]>
) {
const foreignKeyName = camelToSnakeCase(`${relationship.name}Id`)
const shouldRemoveRelated = !!cascades.delete?.includes(relationship.name)
let mappedBy: string | undefined
if ("mappedBy" in relationship) {
mappedBy = relationship.mappedBy
} else {
mappedBy = camelToSnakeCase(MikroORMEntity.name)
}
OneToOne({
entity: relatedModelName,
nullable: relationship.nullable,
...(mappedBy ? { mappedBy } : {}),
cascade: shouldRemoveRelated
? (["persist", "soft-remove"] as any)
: undefined,
} as any)(MikroORMEntity.prototype, relationship.name)
Property({
type: "string",
columnType: "text",
nullable: relationship.nullable,
persist: true,
})(MikroORMEntity.prototype, foreignKeyName)
}
/**
* Defines has many relationship on the Mikro ORM entity
*/
@@ -225,7 +263,10 @@ export function defineBelongsToRelationship(
* to associate a relation (through the relation or the foreign key) we need to handle it
* specifically
*/
if (HasOne.isHasOne(otherSideRelation)) {
if (
HasOne.isHasOne(otherSideRelation) ||
HasOneWithForeignKey.isHasOneWithForeignKey(otherSideRelation)
) {
const relationMeta = this.__meta.relations.find(
(relation) => relation.name === relationship.name
).targetMeta
@@ -317,7 +358,10 @@ export function defineBelongsToRelationship(
/**
* Otherside is a has one. Hence we should defined a OneToOne
*/
if (HasOne.isHasOne(otherSideRelation)) {
if (
HasOne.isHasOne(otherSideRelation) ||
HasOneWithForeignKey.isHasOneWithForeignKey(otherSideRelation)
) {
const foreignKeyName = camelToSnakeCase(`${relationship.name}Id`)
OneToOne({
@@ -600,6 +644,14 @@ export function defineRelationship(
cascades
)
break
case "hasOneWithFK":
defineHasOneWithFKRelationship(
MikroORMEntity,
relationship,
relatedEntityInfo,
cascades
)
break
case "hasMany":
defineHasManyRelationship(
MikroORMEntity,

View File

@@ -56,8 +56,6 @@ export abstract class BaseRelationship<T> implements RelationshipType<T> {
* })
*
* export default Product
*
* @customNamespace Property Configuration Methods
*/
searchable() {
this.#searchable = true

View File

@@ -3,6 +3,7 @@ import { RelationNullableModifier } from "./nullable"
export class BelongsTo<T> extends BaseRelationship<T> {
type = "belongsTo" as const
declare $foreignKey: true
static isBelongsTo<T>(relationship: any): relationship is BelongsTo<T> {
return relationship?.type === "belongsTo"
@@ -12,6 +13,6 @@ export class BelongsTo<T> extends BaseRelationship<T> {
* Apply nullable modifier on the schema
*/
nullable() {
return new RelationNullableModifier<T, BelongsTo<T>>(this)
return new RelationNullableModifier<T, BelongsTo<T>, true>(this)
}
}

View File

@@ -0,0 +1,30 @@
import { BaseRelationship } from "./base"
import { RelationNullableModifier } from "./nullable"
/**
* HasOne relationship defines a relationship between two entities
* where the owner of the relationship has exactly one instance
* of the related entity.
*
* For example: A user HasOne profile
*
* You may use the "BelongsTo" relationship to define the inverse
* of the "HasOne" relationship
*/
export class HasOneWithForeignKey<T> extends BaseRelationship<T> {
type = "hasOneWithFK" as const
declare $foreignKey: true
static isHasOneWithForeignKey<T>(
relationship: any
): relationship is HasOneWithForeignKey<T> {
return relationship?.type === "hasOneWithFK"
}
/**
* Apply nullable modifier on the schema
*/
nullable() {
return new RelationNullableModifier<T, HasOneWithForeignKey<T>, true>(this)
}
}

View File

@@ -22,6 +22,6 @@ export class HasOne<T> extends BaseRelationship<T> {
* Apply nullable modifier on the schema
*/
nullable() {
return new RelationNullableModifier<T, HasOne<T>>(this)
return new RelationNullableModifier<T, HasOne<T>, false>(this)
}
}

View File

@@ -6,15 +6,18 @@ const IsNullableModifier = Symbol.for("isNullableModifier")
/**
* Nullable modifier marks a schema node as nullable
*/
export class RelationNullableModifier<T, Relation extends RelationshipType<T>>
implements RelationshipType<T | null>
export class RelationNullableModifier<
T,
Relation extends RelationshipType<T>,
ForeignKey extends boolean
> implements RelationshipType<T | null>
{
[IsNullableModifier]: true = true;
[IsRelationship]: true = true
static isNullableModifier<T>(
modifier: any
): modifier is RelationNullableModifier<T, any> {
): modifier is RelationNullableModifier<T, any, any> {
return !!modifier?.[IsNullableModifier]
}
@@ -25,6 +28,7 @@ export class RelationNullableModifier<T, Relation extends RelationshipType<T>>
* of the schema property
*/
declare $dataType: T | null
declare $foreignKey: ForeignKey
/**
* The parent schema on which the nullable modifier is