Skip to content

A Rails extension that expands support for sorbet

License

espago/rails-on-sorbet

Repository files navigation

Rails::On::Sorbet

This gem is a Rails extension that enhances sorbet support in Rails.

Installation

Install the gem and add to the application's Gemfile by executing:

bundle add rails-on-sorbet

If bundler is not being used to manage dependencies, install the gem by executing:

gem install rails-on-sorbet

It is recommended to disable strict typechecking for this gem's RBI files generated by tapioca.

This can be done by adding these lines to sorbet/tapioca/config.yml:

gem:
  typed_overrides:
    rails-on-sorbet: "false"

Usage

This gem adds additional signatures to Rails types and builtin Ruby types that make using sorbet easier in Rails projects.

It also adds some new utility types and modifies certain Rails DSLs to make it possible to strictly type methods defined through metaprogramming.

Timelike

Rails and Ruby lack a unified type for representing datetime. We have Time, DateTime and ActiveSupport::TimeWithZone which basically cover really similar use cases but have no common ancestor. This makes working with time incredibly frustrating.

We introduced a new utility type called Timelike to alleviate this burden.

Definition:

Timelike = T.type_alias { T.any(Time, DateTime, ActiveSupport::TimeWithZone) }

Map

Rails introduced two hashlike types: ActionController::Parameters, ActiveSupport::HashWithIndifferentAccess. For the most part they behave like Hash but they don't inherit from it. This causes a lot of issues in sorbet.

Because of that we introduced the Map interface. It defines all methods that are common to Hash, ActionController::Parameters and ActiveSupport::HashWithIndifferentAccess.

Right now there is a bug in sorbet that causes the typechecker to hang and crash when you include a generic interface/module in Hash, Array, Set, Range etc. So we couldn't implement Map directly in Hash :(

Instead, we introduced a casting function.

Example:

# Use the `Map` type in a signature
#
#: (Map[String, Integer]) -> void
def foo(m)
  m["foo"] #=> Integer?
end

hash = { "foo" => 4 } #: Hash[String, Integer]
m = Map(hash) #=> Map[String, Integer]
foo(m) # OK

hash = { "foo" => 4 }.with_indifferent_access # => ActiveSupport::HashWithIndifferentAccess
m = Map(hash) #=> Map[String, untyped]
foo(m) # OK

params = ActionController::Parameters.new # => ActionController::Parameters
m = Map(params) #=> Map[String, untyped]
foo(m) # OK

There is a Map::Params alias for the result of converting ActionController::Parameters and ActiveSupport::HashWithIndifferentAccess to Map: Map[String | Symbol, untyped].

TypedRelation

Sorbet lacks proper generic handling of ActiveRecord::Relation. The class itself is not generic and tapioca generates a bunch of classes like X::PrivateRelation, X::PrivateAssociationRelation, X::PrivateCollectionProxy per model but these classes have no common generic ancestors which makes them incredibly finnicky to work with.

Because of that we introduced a bunch of interfaces:

  • TypedRelation is included by per-model classes like X::PrivateRelation
  • TypedAssociation::Relation is included by per-model classes like X::PrivateAssociationRelation
  • TypedAssociation::CollectionProxy is included by per-model classes like X::PrivateCollectionProxy

Example:

class Foo < ActiveRecord::Base
  has_many :bars
end

foo = Foo.new
foo.bars #=> Bar::PrivateCollectionProxy

#: (TypedAssociation::CollectionProxy[Bar]) -> void
def do_smth(rel); end

do_smth(foo.bars) # ok!

One common use case is working on child/parent model associations.

class Foo < ActiveRecord::Base
  belongs_to :user
end

class Bar < Foo
  belongs_to :manager
end

class User < ActiveRecord::Base
  has_many :foos
end

class Manager < ActiveRecord::Base
  has_many :bars
end

#: (TypedAssociation::CollectionProxy[Foo]) -> void
def do_smth_on_foos(rel); end

user = User.new
user.foos #=> Foo::PrivateCollectionProxy
do_smth_on_foos(user.foos) # ok!

user = Manager.new
user.bars #=> Bar::PrivateCollectionProxy
do_smth_on_foos(user.bars) # ok!

ActiveRecord::Base::alias_association

This gem adds a new method called alias_association on ActiveRecord classes. It lets you define aliases for getters and setters of belongs_to and has_one associations. There is also a tapioca compiler that makes sorbet aware of these aliases.

Example:

class Foo < ApplicationRecord
  belongs_to :user
  alias_association :owner, :user
end

f = Foo.last
f.owner == f.user #=> true

Rails::On::Sorbet::CurrentAttributes

This is a subclass of ActiveSupport::CurrentAttributes with tapioca/sorbet support.

New optional keyword arguments have been added to attribute:

  • type: the sorbet type of the getter/setter
  • doc: a docstring whose content will be used to generate a comment above the rbi signature created by tapioca

Example:

class Current < Rails::On::Sorbet::CurrentAttributes
  attribute :session_counter, type: T.nilable(Integer), doc: <<~DOC
    A counter that gets incremented when a new session is created.
  DOC
end

The tapioca compiler will generate an RBI file like this:

# typed: true

# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `Current`.
# Please instead update this file by running `bin/tapioca dsl Current`.

class Current
  include RailsOnSorbetCurrentAttributeMethods
  extend RailsOnSorbetCurrentAttributeMethods

  module RailsOnSorbetCurrentAttributeMethods
    # A counter that gets incremented when a new session is created.
    sig { returns(T.nilable(::Integer)) }
    def session_counter; end

    # A counter that gets incremented when a new session is created.
    sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
    def session_counter=(value); end
  end
end

ActiveRecord::Base::serialize

Tapioca now has the ability to generate strictly typed and accurate getters and setters for Rails serializers.

New optional keyword arguments have been added to ActiveRecord::Base::serialize:

  • return_type: the sorbet type returned by the getter
  • setter_type: the sorbet type of the parameter of the setter
  • doc: a docstring whose content will be used to generate a comment above the rbi signature created by tapioca

If no return_type or setter_type is given in the arguments, tapioca will try to call return_type and setter_type on the coder given to the serializer.

Example:

module IntegerCoder
  class << self
    def return_type = Integer

    #: (String?) -> Integer?
    def load(val)
      val&.to_i
    end

    #: (Integer?) -> String?
    def dump(val)
      val&.to_s
    end
  end
end

class Foo < ActiveRecord::Base
  serialize :bar, coder: IntegerCoder
  serialize :baz, coder: YAML, return_type: T::Hash[String, String], doc: <<~DOC
    Additional BAR data for finalizing orders.
  DOC
end

The tapioca compiler will generate the following RBI file

# typed: true

# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `Foo`.
# Please instead update this file by running `bin/tapioca dsl Foo`.

class Foo
  sig { returns(T.nilable(Integer)) }
  def bar; end

  sig { params(value: T.nilable(Integer)).returns(T.nilable(Integer)) }
  def bar=(value); end

  # Additional BAR data for finalizing orders.
  sig { returns(T.nilable(T::Hash[::String, ::String])) }
  def baz; end

  # Additional BAR data for finalizing orders.
  sig { params(value: T.nilable(T::Hash[::String, ::String])).returns(T.nilable(T::Hash[::String, ::String])) }
  def baz=(value); end
end

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/espago/rails-on-sorbet.

About

A Rails extension that expands support for sorbet

Resources

License

Stars

Watchers

Forks

Packages

No packages published