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

[Feature] Polymorphic relationships #706

Open
Langstra opened this issue Aug 4, 2020 · 16 comments
Open

[Feature] Polymorphic relationships #706

Langstra opened this issue Aug 4, 2020 · 16 comments
Labels
enhancement New feature or request

Comments

@Langstra
Copy link
Collaborator

Langstra commented Aug 4, 2020

In our project we are using a polymorphic many-to-many relationship. I have looked through the documentation and the issues and could not find anything on it.

It is a concept we have used before with the Laravel framework, https://laravel.com/docs/7.x/eloquent-relationships#many-to-many-polymorphic-relations

I was wondering if you have thought about something like this already and if it is something that could be added.

@B4nan
Copy link
Member

B4nan commented Aug 5, 2020

Not supported currently, sure we can have it too. I dont think there is a workaround.

@B4nan B4nan added this to the 4.x milestone Aug 5, 2020
@B4nan B4nan added the enhancement New feature or request label Aug 7, 2020
@TiesB
Copy link
Contributor

TiesB commented Oct 2, 2020

We would love this!

Do you already have ideas on how to implement it? Could we possibly help?

@B4nan
Copy link
Member

B4nan commented Oct 13, 2020

Originally I was thinking only about M:N, do you guys think we need also polymorphic 1:1 and m:1? It looks a bit weird to me, like upside down composite keys mixed with STI. How can FKs work this way? Or they simply don't?

I don't really have a clear idea of how it could be implemented, if you wanna help, feel free to clone the repository and start trying :] I'd say the first thing that is needed (and basically anyone can help) is to specify test cases that will define how the feature should actually work.

@Langstra
Copy link
Collaborator Author

Guess that 1:1 and 1:m are needed as well, but m:n defintely. We got across this in our project while connecting measures to causes and effects. Where we called the connection between them linkable where every cause and effect would be linked to one or more measures. However, I could imagine just as well that they'd get linked to exactly one measure.

Foreign key constraints would not work anymore in that case. Since a linkable will either pick a cause or effect from the respective table. The m:n table holds an id referencing the id from the causes or effects table and it also holds a string with the type of the id (either cause or effect).

@dvlalex
Copy link
Contributor

dvlalex commented Dec 4, 2020

Any updates on this?

I'm currently in progress to upgrading to v.4 and a change to the 'mappedBy' property on relationships broke a workaround I had in place for this.

@B4nan B4nan removed this from the 4.x milestone Nov 13, 2021
@B4nan
Copy link
Member

B4nan commented Nov 17, 2021

I just finished hacking the polymorphic embeddables support (#2426), and turns out it wasn't that difficult as I initially though (took less than 2 focused days). I don't want to pospone v5 release too much, but I am now more confident polymorphic relations will make it to some of the 5.x feature releases. If there would be some brave contributor that would like to give this a try, be sure to check out the PR, as the implementation could be very similar (but definitely more complex, as we will need to handle collections, propagations, autojoining for m:1/1:1 to know the type, etc).

It would greatly help if someone could prepare comprehensive test suite, so we know how things should behave in edge cases (never used this in the wild myself so not sure how it really works).

@vincentwinkel
Copy link

vincentwinkel commented Apr 26, 2022

I tried to dig into the code, but no accurate results for now. Basically, I'm trying to store users in 1 global table then some specific fields to Students and Teacher to 2 different tables, owned by User. So I need a 1:1 polymorphism.
I don't think the Embeddable solution fits my needs since it merges the embedded objects into to User class (tell me if I'm wrong, I only did read the doc).
I tried many different ways to do that, also using STI, uni/bi-directional mapping, etc.

This is my scenario:

// base.entity.ts

import { BaseEntity as BaseEntityOrig } from '@mikro-orm/core';
import { DbDate } from 'app/db/constants';

export class BaseEntity<T extends { id: id }> extends BaseEntityOrig<T, 'id'> {
  constructor(input = {}) {
    super();
    this.assign(input);
  }

  @PrimaryKey({ type: t.integer })
  id: id;

  @Property({ columnType: DbDate })
  createdAt: Date = new Date();

  @Property({ columnType: DbDate, onUpdate: () => new Date() })
  updatedAt: Date = new Date();
}
// user.entity.ts

import { BaseEntity } from 'app/db/entities/base.entity';
import { AbstractUser } from 'user/entities/abstract.user.entity';
import { Student } from 'user/entities/student.entity';
import { Teacher } from 'user/entities/teacher.entity';

export enum USER_TYPES {
  STUDENT = 1,
  TEACHER = 2,
}

@Entity()
@Unique({ properties: ['type', 'user'] })
export class User extends BaseEntity<User> {
  @Enum(() => USER_TYPES)
  type: USER_TYPES;

  @Property()
  @Unique()
  @IsEmail()
  email: string;

  // This doesn't work
  @OneToOne({ entity: () => AbstractUser })
  user: AbstractUser<Student | Teacher>;
}
// abstract.user.entity.ts

import { BaseEntity } from 'app/db/entities/base.entity';

// Here I also tried removing the decorator @Entity and / or the keyword `abstract`
@Entity()
export abstract class AbstractUser<T extends { id: id }> extends BaseEntity<T> {}
// student.entity.ts

import { AbstractUser } from 'user/entities/abstract.user.entity';

@Entity()
export class Student extends AbstractUser<Student> {
  @Property()
  firstname: string;

  @Property()
  lastname: string;

  // Some other student fields
}
// teacher.entity.ts

// Same as Student but with different fields

@vincentwinkel
Copy link

@B4nan maybe the "easiest" way could be to keep the syntax of STI, but adding a property merge: boolean (not embed to avoid confusion) which lets us merge the sub-entity (like STI already does) or keep 2 separate tables/collections.

@B4nan
Copy link
Member

B4nan commented Apr 27, 2022

This is much more complicated, if you think it's easy, send a PR 😉

@andrew-sol
Copy link

I worked with polymorphic relationships a lot in Laravel. I can explain my vision of a possible implementation in MikroORM.

Let's start with a simple entities model with integer PKs. A user can like both posts and comments:

@Entity()
export class User {
  @PrimaryKey()
  id: number;
}
@Entity()
export class UserLike {
  @PrimaryKey()
  id: number;

  @ManyToOne(() => User)
  user: User;

  @MorphTo(() => [Post, Comment])
  entity: Post | Comment; // this will create `entity_type` and `entity_id` DB columns by default; column names should be configurable in @MorphTo()
}
@Entity()
export class Post {
  @PrimaryKey()
  id: number;

  @MorphMany(() => UserLike, (userLike) => userLike.entity) // @MorphOne() decorator should also be implemented
  likes = new Collection<UserLike>(this);
}
@Entity()
export class Comment {
  @PrimaryKey()
  id: number;

  @MorphMany(() => UserLike, (userLike) => userLike.entity)
  likes = new Collection<UserLike>(this);
}

Post and Comment entities may have a common parent class if needed.

It should create a user_likes table with the following structure:

id: int
user_id: int
entity_type: varchar - this will contain either 'Post' or 'Comment'
entity_id: int - maybe this should also be varchar? Need to think about how to deal with composite primary keys here

So, we introduce 3 new decorators:

@MorphTo()
@MorphOne()
@MorphMany()

It might make sense to replace @MorphTo() with @MorphToOne()/@MorphToMany() for readability/validation.
ORM should not create any DB-level constraints since it's just not possible.

Let's see what UserLike entity contains when we load it without population:

{
  id: 1,
  user: { id: 54 },
  entity: { id: 2, type: 'Post' }
}

There shouldn't be any problems with wrap().assign() and orm.getReference() when assigning to @MorphTo() fields.

It's just a basic implementation proposal. Many edge cases still have to be covered yet.

@B4nan
Copy link
Member

B4nan commented May 5, 2022

Thanks for the write up. I guess the biggest question for me was how to handle FK constraints, but I already understood they are simply omitted, as its not possible to have FK targetting different tables. Then another question is how cascading works, given we won't have FK constraints, we won't have referential intgrity either (e.g. on delete cascade).

I don't think we need more decorators (and definitely not with a naming that does not match what we already have). This can all work on the existing relation decorators just fine, the only difference is that instead of @ManyToOne(() => User) we will have @ManyToOne(() => [UserA, UserB]) (plus there will need to be some additional options to be able to specify the type property name explicitly).

@andrew-sol
Copy link

andrew-sol commented May 5, 2022

Yep, there's no way for DB-level cascading to work without FKs. It all lies on ORM or a user (handling it manually, maybe using lifecycle hooks).

@vincentwinkel
Copy link

Thanks for the sumup @andrew-sol. I completely agree with your proposal (except the new decorators as explained above).
@B4nan in my (humble) opinion, there is indeed no cascading possible since there is no FK (maybe it can be managed js-side using some metadata?).

The proposal given above being a common practice in many sgbd (nosql, sql..), it could be really amazing to have it.

@dorin-musteata
Copy link

Any updates on this ?

@QuestionAndAnswer
Copy link

QuestionAndAnswer commented Feb 2, 2023

@B4nan

Thanks for the write up. I guess the biggest question for me was how to handle FK constraints, but I already understood they are simply omitted, as its not possible to have FK targetting different tables. Then another question is how cascading works, given we won't have FK constraints, we won't have referential intgrity either (e.g. on delete cascade).

I don't think we need more decorators (and definitely not with a naming that does not match what we already have). This can all work on the existing relation decorators just fine, the only difference is that instead of @ManyToOne(() => User) we will have @ManyToOne(() => [UserA, UserB]) (plus there will need to be some additional options to be able to specify the type property name explicitly).

I'd like add my 5 cents. To enable FK constraints, instead of using entity_type and entity_id fields, let's call it type field union representation (TFUR), you need to create denormalised version of {type}_id field with enabled on each FK constraint, let's call this approach denormalised type union representation (DTUR). It is also should be possible on database level to enable constraint like at least one is set, but there should be other pletoria of workarounds for that.

So, from the example above, instead of getting this:

id: int
user_id: int
entity_type: varchar
entity_id: int

we'll have this

id: int
user_id: int

// polymorphic fields section
post_id: int
comment_id: int

These then mapped or unmapped in runtime, with runtime or db level validations (it depends) that at least one field is set. Otherwise it is corrupted data (on write or read).
Adding/removing new type into the field equals adding/removing new {type}_id.

I'm using this approach with MikroORM in my app for a one-to-one polymorphic relationship. But I have to maintain the fields and runtime checks when mapping manually of course.

@wenerme
Copy link

wenerme commented Dec 6, 2023

I use polumorphic in db level, I want to know is this anti-pattern? should I use mikroorm like this ?

I found mikroorm will invote the id setter(accountId,contactId), I don't find any doc/faq about the how persist:false works.

  @ManyToOne(() => AccountEntity, { nullable: true, persist: false })
  account?: AccountEntity;
  @ManyToOne(() => ContactEntity, { nullable: true, persist: false })
  contact?: ContactEntity;

  @Property({ type: types.string, nullable: true })
  customerId?: string;
  @Property({ type: types.string, nullable: true })
  customerType?: string;

  @Property({ type: types.string, nullable: true, persist: false })
  get accountId() {
    return this.customerType === 'Account' ? this.customerId : undefined;
  }

  @Property({ type: types.string, nullable: true, persist: false })
  get contactId() {
    return this.customerType === 'Contact' ? this.customerId : undefined;
  }

  set accountId(value: string | undefined) {
    setCustomer(value, this);
  }

  set contactId(value: string | undefined) {
    setCustomer(value, this);
  }
    customer_id                      text,
    customer_type                    text,
    account_id                       text generated always as ( case customer_type when 'Account' then customer_id end ) stored,
    contact_id                       text generated always as ( case customer_type when 'Contact' then customer_id end ) stored,

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

9 participants