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
Add delegated type to Active Record #39341
Conversation
Co-authored-by: Aaron Patterson <aaron.patterson@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two small nits I saw reading through the docs. Super cool!
Co-authored-by: Jason Meller <jason@kolide.co>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Really nice! I just leaved some suggestions for your consideration.
Is the name "delegated type" up for review? I don't see any delegation happening in the code. It looks more like a "subtype", or "secondary type", or something like that. |
It seems like a subclass/subtype. Django calls it Multi-table inheritance |
What's the story for creating records? Are you intending to use nested attributes? Also do you start with the |
@nwjlyons Yeah, that's similar, at least in terms of the table backing. It's a bit different in the sense that this is forming a hierarchy with composition and delegation rather than inheritance. But would be good to reference it. @pixeltrix Good point. In both BC3 and HEY, we have factories creating the entry/entryable (or recording/recordable) combos, which need to do a lot more work than just creating the link. But the basic pattern is just |
@tomdalling The type is delegated through the named role. So Entry delegates its type to entryable, which is then answered by Message or Comment. I mean, it's good to recognize that we're doing these gymnastics because of the impedance mismatch. If this was pure OO, we wouldn't bother. |
This is very interesting! From the text as it is currently written, though, it is not entirely clear what the advantage would be of this new technique vs. using plain composition. |
I like the documentation, but I don't completely understand what the query methods return. Does Based on reading the code it's the first, but it is a bit hard to understand from the documentation. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's unclear to me what the db tables look like in this case. Can you provide an example of the schema for the Entry
, Message
and Comment
tables?
It seems that polymorphic associations are still utilized? I like that this approach here addresses the drawbacks of STI and Abstract Models, but the lack of referential integrity in polymorphic associations is still concerning.
There is already an
|
@victorperez Thanks. I missed that. @dhh For me, maintaining data integrity in the db is usually more important than utilizing polymorphism here. Is there a way to accomplish the delegated type while allowing the db to enforce referential integrity? |
@mstroming There is not. This relies on polymorphic associations, and you're not going to get vanilla foreign key constraints on those. But you could probably setup a stored procedure somehow to enforce it, if you really wanted to. |
That was my initial reaction too. I think because we are used to talking about delegating behavior, whereas this is delegating subtyping. Or in other words, delegating the ability to be extended with specialized behavior. I feel like the following API could help make the gymnastics more clear: class Entry < ApplicationRecord
delegates_specialization_to :entryable
end
class Message < ApplicationRecord
specialization_of :entryable
end
class Comment < ApplicationRecord
specialization_of :entryable
end In the above code, |
I don't know if I got it right. But how does it differ from the polymorphic association? I always thought that bc3 recordable pattern used it. |
I like "specialization", as in "an Entry has a specialization" and "a Comment is a specialization of an Entry".
|
My suggestion for naming class Entry < ApplicationRecord
describes_an :entryable, types: %w[ Message Comment ]
# `describes_a` would also be a valid keyword if you wanted to say
# `describes_a :readable` or something else that doesn't start with a vowel.
end |
Reading my comment again, I can see how It might be more clear as |
I think I might inadvertently have shared plans for a bike shed and opened the floor to which color it should be painted. My sincerest apologies 😄 "Entry delegates it's type to the entryable role, of which we currently have Message and Comment". The delegation methods, like entry.message?, is a type predicate that's answered by the entryable (even if we shortcut it for performance by going straight to the polymorphic class column). I shall expand the example to show how half the point here is to erect a delegation scheme. For example, in HEY, we'd delegate #content to the entryable, so Entry#content ends up delegating to Entryable#content, which is answered by Message using the rich text, and by Comment using plain text (you need to make sure they duck adequately, of course). Anyway, I appreciate the feedback, but I didn't find any of the suggestions offered more compelling than what we have here. I'll get this finished up this weekend! ✌️ |
I'll leave my suggested naming (using "specialization") here: https://gist.github.com/tomdalling/bc585d824466d102e635d5d03502e095 I appreciate that your time is precious @dhh so no need to reply, and that the decision is ultimately yours, of course. I just thought that if there was any time to improve the naming it would be now, before rolling it out to thousands of devs/projects. I don't think of that as bikeshedding, personally. |
Co-authored-by: Víctor Pérez <v.rodriguez@volcanic.co.uk>
Co-authored-by: Víctor Pérez <v.rodriguez@volcanic.co.uk>
Obviously the bikeshed should be Ruby red 😄🙌 Woot, nice! 🤘🥳 |
In the final example around adding further delegation, with title for entryables, Message and Comment, could we add the This is in order for the class that's signing up to include this "virtual interface" to be responsible for implementing the delegated :title message, right? |
Since this is currently in |
I think so. But the value here is really the pattern, less the code. You could copy/paste this code into your ApplicationRecord and use it right now 👍
On Sun, May 24, 2020 at 1:08 AM "github.com" <notifications@github.com> wrote:
“
Since this is currently in master, this'll be picked up for Rails 6.1, right?”
—
You are receiving this because you modified the open/close state.
Reply to this email directly, view it on GitHub, or unsubscribe.
|
# You create a new record that uses delegated typing by creating the delegator and delegatee at the same time, | ||
# like so: | ||
# | ||
# Entry.create! message: Comment.new(content: "Hello!"), creator: Current.user |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Entry.create! message: Comment.new…
is kinda confusing, did you mean Entry.create! comment: Comment.new…
?
Also, does it dynamically imply and set the entryable_id
and entryable_type: “Comment”
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Further up in the thread, @dhh mentions the format as being: Entry.create! entryable: Message.new(subject: "hello!"), creator: Current.user
which seems to make more sense.
@dhh Definitely! I'm really glad that you released this design pattern. I was able to extract redundant columns out of a database table. Refactoring this made me sleep easier at night, so thanks for that! I did a spike to come up with a PoC for introducing this into the codebase of a product that I'm working on (https://github.com/Respondo/respondo/pull/225) by monkey-patching |
I've published a Backport gem for Rails >= 5 mirror source code: https://github.com/robertomiranda/delegated_type |
I've been achieving this in psql using HSTORE, then JSONB, columns and STI for heterogeneous attributes for a very long time. I'm happy to have official support for this in Rails as I’d always prefer and off-the-shelf over a homegrown solution, but would love to to hear thoughts on multi-table vs JSON to avoid sparsely populated tables. Keep I mind this gem was only built to support my original needs and is nowhere near as polished as the solution above. The only knee jerk curiosity I have is the efficiency of unioning tables vs querying a single table when retrieving a heterogeneous collection of subtypes but without experimenting a little. The API for this PR is definitely nicer in many regards, but at the physical layer I'm not so sure. Thanks for this and STI to begin with. Due to JSON columns, I overuse and defend that feature to a point I'm not even sure I should talk about publicly 😇 https://github.com/madeintandem/jsonb_accessor https://madeintandem.com/blog/2013-3-single-table-inheritance-hstore-lovely-combination/ |
@dhh From the perspective of mapping objects and tables, "class-table inheritance" mentioned here is actually a Concrete Table Inheritance, Delegated types newly introduced here looks like a Class Table Inheritance (CTI). I'm very excited to use this great feature! |
In case anybody finds this helpful, a few extra pieces that fit nicely with the delegated types concept: N+1 prevention DSL sugar module Entryable
TYPES = %w[ List Spot ]
end class Entry < ApplicationRecord
delegated_type :entryable, types: Entryable::TYPES
scope :with_entryables, -> { includes(:entryable) }
end Automatically matching entry's url helper based on the Rails.application.routes.draw do
direct :entry do |model|
route_for model.entryable_name, model
end
end class SpotsController < ApplicationController
def create
@entry = Entry.create! entryable: Spot.new(params.require(:spot).permit(:address))
redirect_to @entry # Redirects to e.g. /spots/47, with 47 being the newly created Entry id.
end
end |
Coming late to this I'm afraid, but referential integrity cannot be enforced with stored procedures/triggers etc. Those approaches are always subject to race conditions because user sessions can only see other sessions' committed changes. |
In the schema outlined in the original post, I could have an In order to attempt to make illegal states unrepresentable, having an |
The existence of polymorphic associations does not allow the database to enforce referential integrity, however, because no foreign keys can be defined. |
If |
True, but it's a fundamentally different relationship in terms of the possible cardinalities of the join in that case. |
Good point. In my example, cardinalities would be fundamentally different: an |
Class hierarchies can map to relational database tables in many ways. Active Record, for example, offers purely abstract classes, where the superclass doesn't persist any attributes, and single-table inheritance, where all attributes from all levels of the hierarchy are represented in a single table. Both have their places, but neither are without their drawbacks.
The problem with purely abstract classes is that all concrete subclasses must persist all the shared attributes themselves in their own tables (also known as class-table inheritance). This makes it hard to do queries across the hierarchy. For example, imagine you have the following hierarchy:
How do you show a feed that has both
Message
andComment
records, which can be easily paginated? Well, you can't! Messages are backed by a messages table and comments by a comments table. You can't pull from both tables at once and use a consistent OFFSET/LIMIT scheme.You can get around the pagination problem by using single-table inheritance, but now you're forced into a single mega table with all the attributes from all subclasses. No matter how divergent. If a Message has a subject, but the comment does not, well, now the comment does anyway! So STI works best when there's little divergence between the subclasses and their attributes.
But there's a third way: Delegated types. With this approach, the "superclass" is a concrete class that is represented by its own table, where all the superclass attributes that are shared amongst all the "subclasses" are stored. And then each of the subclasses have their own individual tables for additional attributes that are particular to their implementation. This is similar to what's called multi-table inheritance in Django, but instead of actual inheritance, this approach uses delegation to form the hierarchy and share responsibilities.
Let's look at that entry/message/comment example using delegated types:
As you can see, neither
Message
norComment
are meant to stand alone. Crucial metadata for both classes resides in theEntry
"superclass". But theEntry
absolutely can stand alone in terms of querying capacity in particular. You can now easily do things like:Account.entries.order(created_at: :desc).limit(50)
.Which is exactly what you want when displaying both comments and messages together. The entry itself can be rendered as its delegated type easily, like so:
Sharing behavior with concerns and controllers
The entry "superclass" also serves as a perfect place to put all that shared logic that applies to both messages and comments, and which acts primarily on the shared attributes. Imagine:
Which allows you to have controllers for things like
ForwardsController
andRedeliverableController
that both act on entries, and thus provide the shared functionality to both messages and comments.Creating new records
You create a new record that uses delegated typing by creating the delegator and delegatee at the same time, like so:
If you need more complicated composition, or you need to perform dependent validation, you should build a factory method or class to take care of the complicated needs. This could be as simple as:
Adding further delegation
The delegated type shouldn't just answer the question of what the underlying class is called. In fact, that's an anti-pattern most of the time. The reason you're building this hierarchy is to take advantage of polymorphism. So here's a simple example of that:
Now you can list a bunch of entries, call
Entry#title
, and polymorphism will provide you with the answer.