-
Notifications
You must be signed in to change notification settings - Fork 122
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
Suggestion: Implement <Model>::Persisted
opaque type to solve column nilability confusion
#1667
Comments
Gentle nudge on this one, I'm not pretending to see a PR to get this fixed, I merely want to kick off the discussion to understand if this makes sense or not and if you'd like to introduce it in |
The column can be nilable so we can't always use Would something like this work? Having # sorbet/rbi/dsl/persistent_user.rbi
class PersistedUser
sig { returns(Integer) }
def id; end
sig { returns(String) }
def email; end
end # models/user.rb
class User < ApplicationRecord
sig { returns(PersistedUser) }
def save
super
end
end |
I might have chosen the wrong names and created some confusion, but if I check the tapioca-generated RBIs for my project's models, they each have these methods specified (except for
I don't think this would work, because then the two classes would be completely different and not interchangeable. Let's not forget that |
Playing some more with this and I managed to get something to work in Sorbet's playground: see example I had to abandon the idea of a This effectively means we'd always deal with persisted records, except for when you call |
This is a really good idea and would be a huge improvement to Tapioca. How would you implement In any case, I think this would be a huge improvement to Tapioca's models, allowing 100% type safety without sacrificing the convenience of not having to assert everywhere. |
This is a really great idea! Would love to help however I can to try to make this more real, since it would definitely be a UX improvement. And I actually like the inversion to have Is there a path to land this sooner as we work it out if, instead of requiring the |
@stathis-alexander @alexgraffeocohen sorry I didn't mean to drop the ball on this, and I'm still very much interested in making this work. I'm just struggling to find the time at the moment 😅. If any of you would like to push this forward or make any progress, here is what a possible roadmap could look like:
I'm not sure I understand the need for narrowing the type. You don't need to pass a record as a parameter, so the signature would simply be
I don't think there is. Sorbet requires these signatures to be statically evaluated, meaning if we want the same object to behave differently, we'll effectively need 2 separate objects. |
As a temporary stop-gap solution, we were considering adding a new method like class ApplicationRecord
sig { returns(Integer) } # _not_ nilable
def id! = id or raise SomeError
end Creating a module that overrides the existing |
If I instantiate a new object:
it should by default have the Unpersisted type. Later, when I save that object, it needs to "narrow" into the persisted type.
I don't believe sorbet has any support for this kind of thing at the moment. |
@stathis-alexander Ah no I don't think that's possible at all! my_object = MyObject.new(**parameters)
reveal_type(my_object) # Unpersisted::MyObject
my_object = my_object.save!
reveal_type(my_object) # MyObject |
# create_table :comments, force: true do |t|
# t.integer :post_id, null: false
# end
comment = Comment.find(1)
comment.post_id = nil
comment.valid? This code is totally valid code and should still work and with what you are proposing it would not. Worse yet, something like this would explode at runtime but pass type checking with what is being proposed here. class Comment < ApplicationRecord
before_validation :transform_post_id
def transform_post_id
self.post_id = self.post_id + 1
end
end
comment = Comment.find(1)
comment.post_id = nil
comment.valid? I don't think the type system should lie about the runtime behavior of the system. |
Fair, but how common would that use-case be? And what if it could be solved with a
I'm not sure I follow this one, the last three lines are the same as the example above which we agreed would not pass type-checking, so I guess there's a typo in the code unless I'm missing something obvious 🤔 Anyway, I completely understand the "the type system shouldn't lie" position and honestly it's hard to argue against it. For example (which might relate to your second snippet above), I'd prefer dealing with an immutable object retrieved from the DB but that it gives me some guarantees, rather than having to deal with constant nullability. Now that I said that out loud, I wonder if this might pair well with |
I think tapioca as a community has already taken the position that accuracy is more important than ergonomics, for better or worse. I think the proper way to handle this dichotomy is to allow this as a configuration option. I think many would agree that the convenience here is worth the slightly less accurate typing, as evidenced by the number of questions, issues, etc that come up concerning the default Ultimately, sorbet was built to enable higher developer productivity and improved developer experience. |
Making this configurable would definitely make everyone happy, but as an OSS maintainer myself, I know that's the easiest way to get things harder to maintain. |
Rather than making another configuration option for this in Tapioca, can't you disable the default DSL compiler and provide your own with the behavior that suits you? |
@Morriar - Yes, that's of course an option, although... it requires a heavy lift and would need to be maintained in parallel with the default one. Instead, this change allows monkey patching existing behavior. I've got something worked out already for the
It's on my list of things to do to host a custom compilers repo that others can share ( |
Can be closed by #1888 perhaps? cc: @paracycle |
#1888 is a very much welcomed change and we've already started using it in our application! The solution provided by #1888 asks you to choose between the two cases and apply that everywhere, even where it doesn't really holds true (e.g. if you go |
UPDATE: the Alternative ProposalI've spent some additional time looking at it and I've come up with an updated proposal. I was able to get around this using We then have 2 options: # Option A: plain Model represents the UNPERSISTED state
class Model; end
const_set(:PersistedModel, Model)
## Option A RBI: we can just inherit from Model because PersistedModel is more "restrictive" than Model
class Model < ActiveRecord::Base
sig { returns(PersistedModel) }
def self.find; end
sig { returns(T.nilable(Integer)) }
def id; end
end
class PersistedModel < Model
sig { returns(Integer) }
def id; end
end
# Option B: plain Model represents the PERSISTED state
class Model; end
const_set(:UnpersistedModel, Model)
## Option B RBI: here we can't use inheritance, because otherwise we would be able to pass an unpersisted record to methods that expect a persisted one
class Model < ActiveRecord::Base
sig { returns(UnpersistedModel) }
def self.new; end
sig { returns(Integer) }
def id; end
end
class UnpersistedModel
sig { returns(T.nilable(Integer)) }
def id; end
end Issues
Dealing with helper (custom, non-columns) methods and diff in implementationRegardless of the solution, how do we deal with custom methods defined in Model that return a value based on the column? For example: # this doesn't make sense, but I need to do something with id that requires it to be non-nullable
def key
"Model##{id * 2}"
end In option A, In option B, ConclusionBased on the updated proposal and the limited time I could spend on it, I'd be inclined to take a closer look at option B. |
The Problem
The
tapioca
compiler for ActiveRecord models makes most columns nilable, ignoring database constraints.This is correct on paper, because when you call
Model.new
none of those would matter, until you eventually try to persist the record in the database.In practice though, this is a common pitfall and cause for confusion, especially for those just getting started with Sorbet.
This is not a new problem, it has in fact been discussed in the past both here and in the Sorbet Slack workspace:
ActiveRecord
columns are not nillable #1342Existing Solutions
There are currently two possible paths to address this:
T.must
whenever Sorbet complains about nilability and we know the record has been persisted, so the column cannot benil
.StrongTypeGeneration
module in the model to tell the compiler to generate the class following the database constraints (as explained in this comment). This will make the class work in most cases, but it won't allow you to call a non-nullable column on a newly instantiated model without throwing an error (e.g.Model.new.non_nilable_column
)The two solution seem mutually exclusive and present pros/cons to different cases, so developers will always need to weight in with additional
T.must
orT.unsafe
to "correct" Sorbet misunderstandings.Ideally, we'd like to teach sorbet exactly what to expect.
A proposal for an alternative solution
I recently stumbled across the concept of "opaque types" thanks to an article from Jez.
After reading the article, I began wondering if it wouldn't be possible to change the compiler so that on top of the
Model
class, aModel::Persisted
orPersistedModel
class would be generated as well.The two classes would be identical and they'd only differ by the fact the the latter will have stricter constraints on columns, reflecting database constraints.
Then, methods signatures can be changed to return one type or the other. For example:
cc @rafaelfranca @Morriar @KaanOzkan based on your past contributions to the topic 🙏
The text was updated successfully, but these errors were encountered: