-
-
Notifications
You must be signed in to change notification settings - Fork 721
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]: Support Polymorphic Association #1051
Comments
Is this a duplicate of #207? |
@luxaritas yes it is ! However I didn't find it because the title is not accurate and the issue doesn't have a description. |
I worked around this by having nullable FKs in the export const commentRelations = relations(comments, ({ one }) => ({
user: one(users, {
fields: [comments.user_id],
references: [users.id]
}),
article: one(articles, {
fields: [comments.article_id],
references: [articles.id]
}),
photo: one(photos, {
fields: [comments.photo_id],
references: [photos.id]
})
})); I'm curious, how would you do polymorphic association in plain SQL? |
The best approach I've seen which maintains the ability to have foreign keys is by adding a "commentable' table. That is, article and photo both have a foreign key (1:1 relation) to commentable, and comment has a foreign key (many:1) relation to commentable. |
I see what you mean, Comment would have a |
Yep, that's true - though the more possible tables you add, the sparser the table becomes - not necessarily desirable (namely in terms of data modeling/efficiency/ensuring integrity given the constraint exactly one must be not-null) |
Would it be possible to do something like Rails Polymorphic operations where you could have a type? https://guides.rubyonrails.org/association_basics.html#polymorphic-associations. |
Would the configuration of the column type need to be on the |
Just now realized that Drizzle doesn't yet seem to have first class support for polymorphism, while trying to get my schema going. I also have a |
The question I have for this feature request is what does it look like? I understand at the high level what is needed but I don't see what needs to happen in drizzle to support it? // User section
export const users = mysqlTable("users", {
id: int("id").autoincrement().primaryKey(),
name: text("name").notNull(),
createdAt: timestamp("created_at", { fsp: 3, mode: "string" }).default(
sql`current_timestamp(3)`,
),
commentType: varchar("comment_type", { length: 256 }).default(sql`'user'`),
});
export const usersRelations = relations(users, ({ many, one }) => ({
comments: many(comments),
}));
// Country section
export const countries = mysqlTable(
"countries",
{
id: int("id").autoincrement().primaryKey(),
name: varchar("name", { length: 256 }),
createdAt: datetime("created_at", { fsp: 3 }).default(
sql`current_timestamp(3)`,
),
commentType: varchar("comment_type", { length: 256 }).default(
sql`'country'`,
),
}
);
export const countriesRelations = relations(countries, ({ many }) => ({
comments: many(comments),
}));
// City section
export const cities = mysqlTable("cities", {
id: int("id").autoincrement().primaryKey(),
name: varchar("name", { length: 256 }),
createdAt: datetime("created_at", { fsp: 3 }).default(
sql`current_timestamp(3)`,
),
commentType: varchar("comment_type", { length: 256 }).default(sql`'city'`),
});
export const citiesRelations = relations(cities, ({ many }) => ({
comments: many(comments),
}));
// The important part the comments. Notice how the commentableType is a enum, will have intelisense
export const comments = mysqlTable("comments", {
id: int("id").autoincrement().primaryKey(),
content: text("content").notNull(),
commentableId: int("commentable_id").notNull(),
commentableType: mysqlEnum("commentable_type", ["user", "city", "country"]),
});
export const commentRelations = relations(comments, ({ one }) => ({
user: one(users, {
fields: [comments.commentableId, comments.commentableType],
references: [users.id, users.commentType],
}),
city: one(cities, {
fields: [comments.commentableId, comments.commentableType],
references: [cities.id, cities.commentType],
}),
country: one(countries, {
fields: [comments.commentableId, comments.commentableType],
references: [countries.id, countries.commentType],
}),
})); I tested insertion, selection and update, everything works as expected. const userWithComments = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
comments: true,
},
}); Both ways of doing it will have it's pros and cons, using this method, your If somebody knows a way to have different foreign keys on the same column, referencing different tables, I would like to know. |
My previous comment as well as my comments in #207 describe an alternative that maintains referential integrity without a sparse table, with the limitation of adding additional intermediate table(s) |
I didn't test that, but I also don't see what limitation Drizzle has that prevents anybody from implementing it. |
Right - I agree, I'm not sure if there's anything to do (aside from unions for some cases, which is coming) |
The only other thing that maybe comes to mind is if the RQB was able to support the type field implicitly at runtime without the additional "static" column |
I'm struggling to implement your solution. Because the commantable table does not hold a reference to the acticle (or user, or country, or whatever table is being commented), the Can you put together a quick example? I'll give another try tomorrow. |
I agree that the design you proposed should work and maintain referential integrity @luxaritas. I also think that the polymorphic relationship from Rails can be nicer if you're willing to give up referential integrity. @Angelelz it seems like your code in #1051 (comment) shows that Drizzle already supports polymorphic associations so maybe we can close this issue? I'm just curious why |
It is not necessary for this to work, but it's a nice way to let drizzle know what comments belong to what table. const userComments = await db.query.comments.findMany({
where: eq(comments.commentType, "user"),
}) This will not be database level reference, but a Drizzle reference. export const commentRelations = relations(comments, ({ one }) => ({
user: one(users, {
fields: [comments.commentableId, comments.commentableType],
references: [users.id, users.commentType],
}),
}); When Drizzle adds support for generated columns, this could be generated and you can just forget about it. |
Is this because the cardinality of Could I do this instead? export const commentRelations = relations(comments, ({ one }) => ({
user: one(users, {
fields: [comments.commentableId, "user"],
references: [users.id],
}),
}); |
Yes. And I tried passing a string but the API currently only accepts a column. |
Ok, so it seems like most of the opportunity here is making that API more ergonomic and possibly defining some sort of type safety going from comment to commentable? |
I do not believe a change to the API would be necessary, what would this allow? export const commentRelations = relations(comments, ({ one }) => ({
user: one(users, {
fields: [comments.commentableId, "user"],
references: [users.id],
}),
}); You can just get rid of the "user", and it would work exactly the same way. You would still need to insert the |
I assume you need the |
amazing workaround @Angelelz, I'm possibly missing something here, though my polymorphic tables let's say Also just in case, I actually don't care about referential integrity as Vitess doesn't support foreign keys anyways, which is what PlanetScale uses under the hood |
@CanRau I think that's the question chain from above. It seems like right now |
It's definitely not required. I'm starting to think it's actually kinda redundant. My idea to use it was to try and actually have referential integrity, but in all the tests I've done I haven't been able to make it work. I'll report again if I have some more ideas. I am yet to try @luxaritas approach successfully. |
How would it add referential integrity? |
It wouldn't. Like I said, I was investigating if I could find a way to do it.
No API changes are necessary. this is the implementation without // Country section
export const countries = mysqlTable(
"countries",
{
id: int("id").autoincrement().primaryKey(),
name: varchar("name", { length: 256 }),
createdAt: datetime("created_at", { fsp: 3 }).default(
sql`current_timestamp(3)`,
),
}
);
export const countriesRelations = relations(countries, ({ many }) => ({
comments: many(comments),
}));
// City section
export const cities = mysqlTable(
"cities",
{
id: int("id").autoincrement().primaryKey(),
name: varchar("name", { length: 256 }),
createdAt: datetime("created_at", { fsp: 3 }).default(
sql`current_timestamp(3)`,
),
},
);
export const citiesRelations = relations(cities, ({ many }) => ({
comments: many(comments),
}));
// User section
export const users = mysqlTable(
"users",
{
id: int("id").autoincrement().primaryKey(),
name: text("name").notNull(),
createdAt: timestamp("created_at", { fsp: 3, mode: "string" }).default(
sql`current_timestamp(3)`,
),
},
);
export const usersRelations = relations(users, ({ many, one }) => ({
comments: many(comments),
}));
// Comments section
export const comments = mysqlTable(
"comments",
{
id: int("id").autoincrement().primaryKey(),
content: text("content").notNull(),
commentableId: int("commentable_id").notNull(),
commentType: mysqlEnum("comment_type", ["user", "city", "country"]),
}
);
export const commentRelations = relations(comments, ({ one }) => ({
user: one(users, {
fields: [comments.commentableId],
references: [users.id],
}),
city: one(cities, {
fields: [comments.commentableId],
references: [cities.id],
}),
country: one(countries, {
fields: [comments.commentableId],
references: [countries.id],
}),
})); The only downside of doing it without the commentType on the commentable tables is that you would now need an extra where condition for the commentType on the comment table: const usersWithComments = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
comments: {
where: eq(comments.commentType, "user"),
},
},
}); If you don't include that |
Uuuh I see, of course makes sense, having to do this on every query wouldn't be optimal though hooks could solve that #1426 😃 |
I had a nice discussion with @dankochetov and him and the team think the best way to do this would be with an extra export const usersRelations = relations(users, ({ many }) => ({
comments: many(comments, {
where: eq(comments.commentType, 'user')
}),
})); See this comment for context. |
Oh interesting! That's the kind of API change I was thinking about. The other idea I had was: export const commentRelations = relations(comments, ({ one }) => ({
user: one(users, {
fields: [comments.commentableType, comments.commentableId],
references: ["users", users.id],
}),
}); |
We discussed that, but the |
Awesome! It seems clearer to me too. Thanks for discussing it with the team |
I'd like to suggest an alternative way of modelling polymorphic relationship with Drizzle, which works great in Postgres : Suppose you'd want a Like entity to belong to either a picture, a video or a song. You would want to have the following check on the Table : ALTER TABLE "Like"
ADD CONSTRAINT check_like_polymorphism
CHECK (num_nonnulls("pictureId", "videoId", "songId") = 1); Then in the model use nullables ids (and indexes if you want) : export const like = pgTable(
'Like',
{
id: text('id').primaryKey().default(uuid).notNull(),
createdAt: timestamp('createdAt', { precision: 3, mode: 'string' }).defaultNow().notNull(),
// Polymorphic Relationship
pictureId: text('pictureId').references(() => picture.id),
videoId: text('videoId').references(() => video.id),
songId: text('songId').references(() => song.id)
},
(table) => {
type Column = keyof typeof table;
const notNull = (column: Column) => sql`${table[column]} IS NOT NULL`;
const nameIndex = <N extends Column>(column: N) => `Like_${column}_idx` as const;
return {
pictureIdx: index(nameIndex('pictureId')).on(table.pictureId).where(notNull('pictureId')),
videoIdx: index(nameIndex('videoId')).on(table.videoId).where(notNull('videoId')),
songIdx: index(nameIndex('songId')).on(table.songId).where(notNull('songId'))
};
}
); This would enforce that only one of these 3 columns have a value while the other 2 must be null.
Overall in my opinion it's a much nicer way to deal with polymorphic relationship than the way most ORMs handle them. |
Many DBs such as Postgres or MySQL indexes NULL, so it takes more storage and memory pressure compared to the fixed two elements |
It's a tradeoff. Do you want referential integrity or do you want to have a few nullable columns in your table ? AFAIK having a null FK doesn't affect execution time. Edit : It's actually possible to add referential integrity to the (type, id) solution. There's some more potential solutions in this article https://www.cybertec-postgresql.com/en/conditional-foreign-keys-polymorphism-in-sql/ |
Not an advertisement, but Sequelize's document support three types of polymorphic association. Maybe Drizzle Team can take a look for reference? I believe |
Longtime Rails nerd here evaluating this library. This is the most straightforward way to do it. |
Coming from Rails also, and I loved reading this conversation. If you build it we will come, and... here we are, so I guess you built it. 👍 💯 I'll just add that the documentation currently has zero hits for Also, why is this issue still open? Is there more polymorphin' power rangers work coming? |
Coming from objectionjs and kysely... this is very similar to how those tackled the relationships... they allowed a 'where' to be built into the relationship. Was pretty flexible and would be great to see. |
Curious if anyone has a code example of achieving polymorphic models with drizzle or is the above the current best convention? I am confused because this issue (#207) was closed as completed and it is implied in that issue that "UNION support" is the same as polymorphic association. Do unions somehow enable polymorphic relations? Any help or links/resources appreciated, but reading through this and a couple other related issues and haven't been able to find a concrete example. |
I think the answer to the question of whether that issue has anything to do with polymorphic association is no. I'm similarly confused because the shade thrown at prisma implies some sort of significant victory, when it's clear the prisma issue is discussing unions as a subproblem of enabling polymorphism. Polymorphism is a much more popular use case than unions. The set operation use cases mentioned in the drizzle docs are extremely contrived. They're all basically "imagine you had very similar data in two tables for some reason". I don't mean to disparage anyones hard work. Obviously these set operations are a great feature, but there's a misunderstanding of what most people are asking for. |
From what I understand, this will be included in the Relational API v2, which is currently in the early stages of active development. |
uploads because I want to support image variants to resize original images into the desired size. I'm following a bit Rails ActiveStorage implementation but I saw they use polymorphic associations so I need to see how I can manage it in Drizzle. This issue has some nice options: drizzle-team/drizzle-orm#1051 (comment) I think is duable. Another consideration is in terms of adapters. Now is ultra harcoded to PostgreSQL + Drizzle. But if I would like to expand to MySQL I should do a kind of Adapter pattern like the one use Licia Auth or NextJS Auth. I like the one use Licia because looks simple enough. Under the hood all DB access is done with raw SQL which I think makes sense if I also want to decouple from Drizzle DSL. Not sure if it's worth it. In terms of API types I would like to keep something similar to what I had in previous commit where was something like const factory = AttachmentFactory({ dbAdapter: PostgrsqlAdapter({ client: db }) }) type Schema = typeof schema UserAttachment = factory.build<Shchema['users']>({ drive: disk, // DiskWrapper (filesystem or S3) table: schema['users'], // I would like something like this where relation is infered from // Drizzle schema relations // But this couple the thing with Drizzle which I'm kind of ok. // In this config also can go the variants config for the different // file sizes we want to generate. // In terms of variants Rails do on-deman variants when requesting // from the UI but it stores the variant after first request. // I'm not sure if I want to add Sharp (or something like it) to // support variants. Maybe this doesn't work in Vercel or Cloudflare // Edge. Pretty sure it doesn't // Other approach would to have a AWS Lambda that does the resize when // a file is addaded to the bucket. But this does the system DEV // friendly and also requires on the final user to implement the Lamda. I // can help here by implementing using IAC like SST ion // Both options have tradeoffs. attachments: [{ relation: 'avatar' }], }) const attachment = UserAttachment.new({ id: 'some-user-id' }) // Optional if I want to get the user from DB await user.getModel() // Now that we have the user model we have methods for add/delete/get/getUrl // This creates relations in DB // It also use Flydrive to store the file in Disk or S3 await attachment.addAvatar({ file: someFile })
Now that Drizzle supports checks you can do something like this : export const like = pgTable(
"Like",
{
id: uuid("id"),
createdAt: timestamp("createdAt", { precision: 3, mode: "string" }).defaultNow().notNull(),
// Polymorphic Relationship
pictureId: text("pictureId").references(() => picture.id),
videoId: text("videoId").references(() => video.id),
songId: text("songId").references(() => song.id),
likeType: text("likeType", { enum: ["picture", "video", "song"] })
},
(t) => {
return {
polymorphic: check(
"LikePolymorphic",
sql`(num_nonnulls(${t.pictureId}, ${t.videoId}, ${t.songId}) = 1);`
)
}
}
)
|
I think there's another solution that has not been mentioned yet: the use of custom ids that includes the resource type within the id. I am starting a new project, and I saw that some companies (like customer.io) are using ids for their primary key which looks like this: I don't know if that talks to anybody so here's what it looks like for my project 👇 To take the example from @Angelelz, you can remove the Thus, you can also remove the const usersWithComments = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
comments: {
where: eq(comments.commentType, "user"), // <--- this line can be removed
},
},
}); and have something that looks like this 👇 const userWithComments = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
comments: true,
},
}); Drawbacks:For started projects, this solution may be a big job to introduce but for not started/small ones, it could be a very nice solution to get rid of the |
Describe what you want
I'm looking for a Typesafe ORM that support for polymorphic associations.
To give a concrete example:
I have a
Comment
model. I need this model to be associated with bothArticle
andPhoto
models.Instead of creating separate tables or associations for comments on articles and comments on photos, I would prefer a unified approach where a single
Comment
can belong to either anArticle
or aPhoto
.Most ORMs (and query builders) except Prisma support polymorphic association.
Prisma is not taking interest in solving this issue: prisma/prisma#1644.
If you plan on implementing Prisma, that would be a killer feature that would be a reason in itself to use Drizzle.
A few teams have mentioned it explicitly in that issue.
Do you plan to support and document this feature in the near future ?
The text was updated successfully, but these errors were encountered: