Skip to content

Commit

Permalink
add relation type to railtie and compiler to generate it into rbis
Browse files Browse the repository at this point in the history
  • Loading branch information
stathis-alexander committed Dec 1, 2024
1 parent e80b65d commit a0ee861
Show file tree
Hide file tree
Showing 7 changed files with 109 additions and 13 deletions.
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
boba (0.0.12)
boba (0.0.13)
sorbet-static-and-runtime (~> 0.5)
tapioca (<= 0.16.5)

Expand Down
4 changes: 4 additions & 0 deletions History.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Boba History

## 0.0.13

- Add `RelationType` alias to railtie as well as `ActiveRecordRelationTypes` compiler to generate it into RBI files. Fix railtie constants.

## 0.0.12

- Rename `StateMachines` compiler back to `StateMachinesExtended` to avoid load order nonsense with Tapioca.
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ If you'd like to use relation types in your sigs that are less broad than `Activ
gem 'boba'
```

The railtie will automatically define the `PrivateRelation` constant on each model that inherits from `ActiveRecord::Base`. It can then be used in typing, like thus:
The railtie will automatically define the `PrivateRelation`, `PrivateAssociationRelation`, and `PrivateCollectionProxy` constants on each model that inherits from `ActiveRecord::Base`. These are defined as their corresponding private `ActiveRecord` classes, so runtime type checking works as expected. They can then be used in typing, like so:

```ruby
class Post < ::ActiveRecord::Base
scope :recent -> { where('created_at > ?', Date.current) }
Expand All @@ -49,7 +50,7 @@ sig { params(author: Author).returns(Post::PrivateRelation) }
def posts_from_author(author); end
```

and the following should not raise an error:
and the following should not raise a Sorbet error:

```ruby
sig { params(author: Author).returns(Post::PrivateRelation) }
Expand All @@ -58,6 +59,15 @@ def recent_posts_from_author(author)
end
```

Boba also defines a type alias `RelationType` on each such class, which is defined as the union of the three relation types. This is useful because the relation types are often used interchangeably and so you may expect to return or pass any of the three classes as an argument. To use this, you will also need to use the `ActiveRecordRelationTypes` compiler to generate the type alias in the signatures as well (or define them manually in shims).

```ruby
sig { params(author: Author).returns(Post::RelationType) }
def recent_posts_from_author(author)
posts_from_author(author).recent
end
```

## Contributing

Bugs and feature requests are welcome and should be [filed as issues on github](https://github.com/angellist/boba/issues).
Expand Down
23 changes: 19 additions & 4 deletions lib/boba/relations_railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,30 @@
class Boba::RelationsRailtie < Rails::Railtie
railtie_name(:boba)

initializer("boba.add_private_relation_constant") do
initializer("boba.add_private_relation_constants") do
ActiveSupport.on_load(:active_record) do
module AciveRecordInheritDefineRelationTypes
def inherited(child)
super(child)

child.const_set("PrivateRelation", Object)
child.const_set("PrivateAssociationRelation", Object)
child.const_set("PrivateCollectionProxy", Object)
# Tapioca defines these three classes for each active record model as proxies for the actual AR internal
# classes. In order to be able to use these as types in signatures, we need to expose them as actual constants
# at runtime. Tapioca intentionally obfuscates these classes because they're private, so exposing them is
# _slightly_ dangerous in that someone could do something naughty. But we're not super worried about it.
child.const_set("PrivateRelation", child.const_get(:ActiveRecord_Relation))
child.const_set("PrivateAssociationRelation", child.const_get(:ActiveRecord_AssociationRelation))
child.const_set("PrivateCollectionProxy", child.const_get(:ActiveRecord_Associations_CollectionProxy))

# Expose a common type so that signatures can be typed to the broader `RelationType` since the three are often
# used interchangeably.
relation_type = T.type_alias do
T.any(
child.const_get(:PrivateRelation),
child.const_get(:PrivateAssociationRelation),
child.const_get(:PrivateCollectionProxy),
)
end
child.const_set(:RelationType, relation_type)
end
end

Expand Down
2 changes: 1 addition & 1 deletion lib/boba/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
# frozen_string_literal: true

module Boba
VERSION = "0.0.12"
VERSION = "0.0.13"
end
67 changes: 67 additions & 0 deletions lib/tapioca/dsl/compilers/active_record_relation_types.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# typed: strict
# frozen_string_literal: true

return unless defined?(ActiveRecord::Base)

require "tapioca/dsl/helpers/active_record_constants_helper"
require "tapioca/dsl/compilers/active_record_relations"

return unless defined?(Tapioca::Dsl::Compilers::ActiveRecordRelations)

module Tapioca
module Dsl
module Compilers
# `Tapioca::Dsl::Compilers::ActiveRecordRelationTypes` extends `Tapioca::Dsl::Compilers::ActiveRecordRelationTypes`
# to generate a `RelationType` type alias for each class. This type alias is defined a runtime through the Boba
# railtie, and is useful for typing signatures to accept or return relations. For instance, with the following
# `ActiveRecord::Base` subclass:
# ~~~rb
# class Post < ApplicationRecord
# end
# ~~~
#
# This compiler will produce the RBI file `post.rbi` with the following content:
# ~~~rbi
# # post.rbi
# # typed: true
#
# class Post
# RelationType = T.any(PrivateRelation, PrivateAssociationRelation, PrivateCollectionProxy)
# end
# ~~~
# So that the following method will accept any of the private relation types as an argument:
# ~~~rb
# sig { params(posts: Post::RelationType).void }
# def process_posts(posts)
# # ...
# end
# ~~~
class ActiveRecordRelationTypes < Compiler
extend T::Sig

ConstantType = type_member { { fixed: T.class_of(::ActiveRecord::Base) } }

sig { override.void }
def decorate
root.create_path(constant) do |rbi_class|
relation_type_alias = "T.any(" \
"#{Tapioca::Dsl::Helpers::ActiveRecordConstantsHelper::RelationClassName}, " \
"#{Tapioca::Dsl::Helpers::ActiveRecordConstantsHelper::AssociationRelationClassName}, " \
"#{Tapioca::Dsl::Helpers::ActiveRecordConstantsHelper::AssociationsCollectionProxyClassName}" \
")"
rbi_class.create_type_variable("RelationType", type: "T.type_alias { #{relation_type_alias} }")
end
end

class << self
extend T::Sig

sig { override.returns(T::Enumerable[Module]) }
def gather_constants
Tapioca::Dsl::Compilers::ActiveRecordRelations.gather_constants
end
end
end
end
end
end
10 changes: 5 additions & 5 deletions lib/tapioca/dsl/compilers/attr_json.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# typed: true
# frozen_string_literal: true

return if !defined?(AttrJson::Record)
return unless defined?(AttrJson::Record)

module Tapioca
module Dsl
Expand Down Expand Up @@ -39,7 +39,7 @@ class AttrJson < Tapioca::Dsl::Compiler
# Class methods module is already defined in the gem rbi, so just reference it here.
ClassMethodsModuleName = "AttrJson::Record::ClassMethods"
InstanceMethodModuleName = "AttrJsonGeneratedMethods"
ConstantType = type_member {{ fixed: T.any(T.class_of(::AttrJson::Record), T.class_of(::AttrJson::Model)) }}
ConstantType = type_member { { fixed: T.any(T.class_of(::AttrJson::Record), T.class_of(::AttrJson::Model)) } }

class << self
extend T::Sig
Expand All @@ -65,7 +65,7 @@ def decorate
private

def decorate_attributes(rbi_scope)
T.unsafe(constant).attr_json_registry
constant.attr_json_registry
.definitions
.sort_by(&:name) # this is annoying, but we need to sort to force consistent ordering or the rbi checks fail
.each do |definition|
Expand Down Expand Up @@ -122,7 +122,7 @@ def sorbet_type(type_name, array: false, nilable: false)

sorbet_type = "::#{sorbet_type}"
sorbet_type = "T::Array[#{sorbet_type}]" if array
sorbet_type = "T.nilable(#{sorbet_type})" if nilable # todo: improve this
sorbet_type = "T.nilable(#{sorbet_type})" if nilable # TODO: improve this

sorbet_type
end
Expand Down

0 comments on commit a0ee861

Please sign in to comment.