From 917340e2e7ea7d44fd52f62411cdb328f6b1a4c5 Mon Sep 17 00:00:00 2001 From: Nick Presta Date: Tue, 12 Apr 2022 17:46:11 -0400 Subject: [PATCH 1/3] Add types to generated RBI files for FrozenRecord --- lib/tapioca/dsl/compilers/frozen_record.rb | 10 +- .../dsl/compilers/frozen_record_spec.rb | 105 ++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/lib/tapioca/dsl/compilers/frozen_record.rb b/lib/tapioca/dsl/compilers/frozen_record.rb index b765435ee..ee0df7dce 100644 --- a/lib/tapioca/dsl/compilers/frozen_record.rb +++ b/lib/tapioca/dsl/compilers/frozen_record.rb @@ -76,9 +76,17 @@ def decorate module_name = "FrozenRecordAttributeMethods" record.create_module(module_name) do |mod| + extra_methods = constant.instance_methods(false) - attributes.to_a.map(&:to_sym) attributes.each do |attribute| + return_type = compile_method_return_type_to_rbi(constant.instance_method(attribute)) mod.create_method("#{attribute}?", return_type: "T::Boolean") - mod.create_method(attribute.to_s, return_type: "T.untyped") + mod.create_method(attribute.to_s, return_type: return_type) + end + extra_methods.each do |method| + method_def = constant.instance_method(method) + parameters = compile_method_parameters_to_rbi(method_def) + return_type = compile_method_return_type_to_rbi(method_def) + mod.create_method(method.to_s, return_type: return_type, parameters: parameters) end end diff --git a/spec/tapioca/dsl/compilers/frozen_record_spec.rb b/spec/tapioca/dsl/compilers/frozen_record_spec.rb index 2fa27b331..239143bf3 100644 --- a/spec/tapioca/dsl/compilers/frozen_record_spec.rb +++ b/spec/tapioca/dsl/compilers/frozen_record_spec.rb @@ -97,6 +97,111 @@ def last_name?; end assert_equal(expected, rbi_for(:Student)) end + it "can handle annotated fields" do + add_ruby_file("student.rb", <<~RUBY) + # typed: strong + + class Student < FrozenRecord::Base + extend(T::Sig) + + self.base_path = __dir__ + + sig { returns(String) } + def first_name + super + end + + sig { returns(String) } + def last_name + super + end + + sig { returns(String) } + def location + super + end + + sig { returns(Integer) } + def age + return super + 5 + end + + sig { params(grain: Symbol).returns(String) } + def area(grain:) + parts = location.split(',').map(&:strip) + case grain + when :city + parts[0] + when :province + parts[1] + when :country + parts[2] + else + location + end + end + end + RUBY + + add_content_file("students.yml", <<~YAML) + - id: 1 + first_name: John + last_name: Smith + age: 19 + location: Ottawa, Ontario, Canada + - id: 2 + first_name: Dan + last_name: Lord + age: 20 + location: Toronto, Ontario, Canada + YAML + + expected = <<~RBI + # typed: strong + + class Student + include FrozenRecordAttributeMethods + + module FrozenRecordAttributeMethods + sig { returns(::Integer) } + def age; end + + sig { returns(T::Boolean) } + def age?; end + + sig { params(grain: ::Symbol).returns(::String) } + def area(grain:); end + + sig { returns(::String) } + def first_name; end + + sig { returns(T::Boolean) } + def first_name?; end + + sig { returns(T.untyped) } + def id; end + + sig { returns(T::Boolean) } + def id?; end + + sig { returns(::String) } + def last_name; end + + sig { returns(T::Boolean) } + def last_name?; end + + sig { returns(::String) } + def location; end + + sig { returns(T::Boolean) } + def location?; end + end + end + RBI + + assert_equal(expected, rbi_for(:Student)) + end + it "can handle frozen record scopes" do add_ruby_file("student.rb", <<~RUBY) class Student < FrozenRecord::Base From 3d9b2090938b69418ba9872ec0ce89746ff42cce Mon Sep 17 00:00:00 2001 From: Nick Presta Date: Wed, 13 Apr 2022 11:01:39 -0400 Subject: [PATCH 2/3] Infer types from the first record --- lib/tapioca/dsl/compilers/frozen_record.rb | 12 +-- .../dsl/compilers/frozen_record_spec.rb | 93 ++++++++++++------- 2 files changed, 66 insertions(+), 39 deletions(-) diff --git a/lib/tapioca/dsl/compilers/frozen_record.rb b/lib/tapioca/dsl/compilers/frozen_record.rb index ee0df7dce..80300be69 100644 --- a/lib/tapioca/dsl/compilers/frozen_record.rb +++ b/lib/tapioca/dsl/compilers/frozen_record.rb @@ -72,22 +72,18 @@ def decorate attributes = constant.attributes return if attributes.empty? + instance = constant.first + root.create_path(constant) do |record| module_name = "FrozenRecordAttributeMethods" record.create_module(module_name) do |mod| - extra_methods = constant.instance_methods(false) - attributes.to_a.map(&:to_sym) attributes.each do |attribute| - return_type = compile_method_return_type_to_rbi(constant.instance_method(attribute)) + return_type = instance.attributes[attribute].class.name + return_type = "T::Boolean" if ["FalseClass", "TrueClass"].include?(return_type) mod.create_method("#{attribute}?", return_type: "T::Boolean") mod.create_method(attribute.to_s, return_type: return_type) end - extra_methods.each do |method| - method_def = constant.instance_method(method) - parameters = compile_method_parameters_to_rbi(method_def) - return_type = compile_method_return_type_to_rbi(method_def) - mod.create_method(method.to_s, return_type: return_type, parameters: parameters) - end end record.create_include(module_name) diff --git a/spec/tapioca/dsl/compilers/frozen_record_spec.rb b/spec/tapioca/dsl/compilers/frozen_record_spec.rb index 239143bf3..95a489b31 100644 --- a/spec/tapioca/dsl/compilers/frozen_record_spec.rb +++ b/spec/tapioca/dsl/compilers/frozen_record_spec.rb @@ -73,19 +73,19 @@ class Student include FrozenRecordAttributeMethods module FrozenRecordAttributeMethods - sig { returns(T.untyped) } + sig { returns(String) } def first_name; end sig { returns(T::Boolean) } def first_name?; end - sig { returns(T.untyped) } + sig { returns(Integer) } def id; end sig { returns(T::Boolean) } def id?; end - sig { returns(T.untyped) } + sig { returns(String) } def last_name; end sig { returns(T::Boolean) } @@ -106,25 +106,7 @@ class Student < FrozenRecord::Base self.base_path = __dir__ - sig { returns(String) } - def first_name - super - end - - sig { returns(String) } - def last_name - super - end - - sig { returns(String) } - def location - super - end - - sig { returns(Integer) } - def age - return super + 5 - end + self.default_attributes = { shirt_size: :large } sig { params(grain: Symbol).returns(String) } def area(grain:) @@ -149,11 +131,27 @@ def area(grain:) last_name: Smith age: 19 location: Ottawa, Ontario, Canada + is_cool_person: no + birth_date: 1867-07-01 + updated_at: 2014-02-24T19:08:06-05:00 + favourite_foods: + - Pizza + skills: + backend: Ruby + frontend: HTML - id: 2 first_name: Dan last_name: Lord age: 20 location: Toronto, Ontario, Canada + is_cool_person: yes + birth_date: 1967-07-01 + updated_at: 2015-02-24T19:08:06-05:00 + favourite_foods: + - Tacos + skills: + backend: Ruby + frontend: CSS YAML expected = <<~RBI @@ -163,38 +161,71 @@ class Student include FrozenRecordAttributeMethods module FrozenRecordAttributeMethods - sig { returns(::Integer) } + sig { returns(Integer) } def age; end sig { returns(T::Boolean) } def age?; end - sig { params(grain: ::Symbol).returns(::String) } - def area(grain:); end + sig { returns(Date) } + def birth_date; end - sig { returns(::String) } + sig { returns(T::Boolean) } + def birth_date?; end + + sig { returns(Array) } + def favourite_foods; end + + sig { returns(T::Boolean) } + def favourite_foods?; end + + sig { returns(String) } def first_name; end sig { returns(T::Boolean) } def first_name?; end - sig { returns(T.untyped) } + sig { returns(Integer) } def id; end sig { returns(T::Boolean) } def id?; end - sig { returns(::String) } + sig { returns(T::Boolean) } + def is_cool_person; end + + sig { returns(T::Boolean) } + def is_cool_person?; end + + sig { returns(String) } def last_name; end sig { returns(T::Boolean) } def last_name?; end - sig { returns(::String) } + sig { returns(String) } def location; end sig { returns(T::Boolean) } def location?; end + + sig { returns(Symbol) } + def shirt_size; end + + sig { returns(T::Boolean) } + def shirt_size?; end + + sig { returns(Hash) } + def skills; end + + sig { returns(T::Boolean) } + def skills?; end + + sig { returns(Time) } + def updated_at; end + + sig { returns(T::Boolean) } + def updated_at?; end end end RBI @@ -226,13 +257,13 @@ class Student extend GeneratedRelationMethods module FrozenRecordAttributeMethods - sig { returns(T.untyped) } + sig { returns(String) } def course; end sig { returns(T::Boolean) } def course?; end - sig { returns(T.untyped) } + sig { returns(Integer) } def id; end sig { returns(T::Boolean) } From 0aa466d00744daf987279f7818a4a55e436ffaa1 Mon Sep 17 00:00:00 2001 From: Nick Presta Date: Tue, 19 Apr 2022 15:04:55 -0400 Subject: [PATCH 3/3] Use ActiveModel::Attributes to define fields --- lib/tapioca/dsl/compilers/frozen_record.rb | 49 +++++++++- .../dsl/compilers/frozen_record_spec.rb | 89 +++++++++++++++---- 2 files changed, 116 insertions(+), 22 deletions(-) diff --git a/lib/tapioca/dsl/compilers/frozen_record.rb b/lib/tapioca/dsl/compilers/frozen_record.rb index 80300be69..405e4bae4 100644 --- a/lib/tapioca/dsl/compilers/frozen_record.rb +++ b/lib/tapioca/dsl/compilers/frozen_record.rb @@ -72,15 +72,21 @@ def decorate attributes = constant.attributes return if attributes.empty? - instance = constant.first - root.create_path(constant) do |record| module_name = "FrozenRecordAttributeMethods" record.create_module(module_name) do |mod| attributes.each do |attribute| - return_type = instance.attributes[attribute].class.name - return_type = "T::Boolean" if ["FalseClass", "TrueClass"].include?(return_type) + return_type = "T.untyped" + if constant.respond_to?(:attribute_types) + attribute_type = T.let( + T.unsafe(constant).attribute_types[attribute], + ActiveModel::Type::Value + ) + has_default = T.let(constant.default_attributes.key?(attribute), T::Boolean) + return_type = type_for(attribute_type, has_default) + end + mod.create_method("#{attribute}?", return_type: "T::Boolean") mod.create_method(attribute.to_s, return_type: return_type) end @@ -99,6 +105,41 @@ def self.gather_constants private + sig { params(attribute_type_value: ::ActiveModel::Type::Value, has_default: T::Boolean).returns(::String) } + def type_for(attribute_type_value, has_default) + type = case attribute_type_value + when ActiveModel::Type::Boolean + "T::Boolean" + when ActiveModel::Type::Date + "::Date" + when ActiveModel::Type::DateTime, ActiveModel::Type::Time + "::DateTime" + when ActiveModel::Type::Decimal + "::BigDecimal" + when ActiveModel::Type::Float + "::Float" + when ActiveModel::Type::Integer + "::Integer" + when ActiveModel::Type::String + "::String" + else + other_type = attribute_type_value.type + case other_type + when :array + "::Array" + when :hash + "::Hash" + when :symbol + "::Symbol" + else + # we don't want untyped to be wrapped by T.nilable, so just return early + return "T.untyped" + end + end + + has_default ? type : as_nilable_type(type) + end + sig { params(record: RBI::Scope).void } def decorate_scopes(record) scopes = T.unsafe(constant).__tapioca_scope_names diff --git a/spec/tapioca/dsl/compilers/frozen_record_spec.rb b/spec/tapioca/dsl/compilers/frozen_record_spec.rb index 95a489b31..e5af5323f 100644 --- a/spec/tapioca/dsl/compilers/frozen_record_spec.rb +++ b/spec/tapioca/dsl/compilers/frozen_record_spec.rb @@ -73,19 +73,19 @@ class Student include FrozenRecordAttributeMethods module FrozenRecordAttributeMethods - sig { returns(String) } + sig { returns(T.untyped) } def first_name; end sig { returns(T::Boolean) } def first_name?; end - sig { returns(Integer) } + sig { returns(T.untyped) } def id; end sig { returns(T::Boolean) } def id?; end - sig { returns(String) } + sig { returns(T.untyped) } def last_name; end sig { returns(T::Boolean) } @@ -101,13 +101,66 @@ def last_name?; end add_ruby_file("student.rb", <<~RUBY) # typed: strong + class ArrayOfType < ActiveModel::Type::Value + attr_reader :element_type + + def initialize(element_type:) + super() + @element_type = element_type + end + + def type + :array + end + end + + class HashOfType < ActiveModel::Type::Value + attr_reader :key_type + attr_reader :value_type + + def initialize(key_type:, value_type:) + super() + @key_type = key_type + @value_type = value_type + end + + def type + :hash + end + end + + class SymbolType < ActiveModel::Type::Value + def type + :symbol + end + end + + ActiveModel::Type.register(:array_of_type, ArrayOfType) + ActiveModel::Type.register(:hash_of_type, HashOfType) + ActiveModel::Type.register(:symbol, SymbolType) + class Student < FrozenRecord::Base - extend(T::Sig) + extend T::Sig + include ActiveModel::Attributes + + # specifically missing the id field, should be untyped + attribute :first_name, :string + attribute :last_name, :string + attribute :age, :integer + attribute :location, :string + attribute :is_cool_person, :boolean + attribute :birth_date, :date + attribute :updated_at, :time + # custom attribute types + attribute :favourite_foods, :array_of_type, element_type: :string + attribute :skills, :hash_of_type, key_type: :symbol, value_type: :string + # attribute with a default, shouldn't be nilable + attribute :shirt_size, :symbol self.base_path = __dir__ - self.default_attributes = { shirt_size: :large } + # Explicit method, shouldn't be in the RBI output sig { params(grain: Symbol).returns(String) } def area(grain:) parts = location.split(',').map(&:strip) @@ -161,67 +214,67 @@ class Student include FrozenRecordAttributeMethods module FrozenRecordAttributeMethods - sig { returns(Integer) } + sig { returns(T.nilable(::Integer)) } def age; end sig { returns(T::Boolean) } def age?; end - sig { returns(Date) } + sig { returns(T.nilable(::Date)) } def birth_date; end sig { returns(T::Boolean) } def birth_date?; end - sig { returns(Array) } + sig { returns(T.nilable(::Array)) } def favourite_foods; end sig { returns(T::Boolean) } def favourite_foods?; end - sig { returns(String) } + sig { returns(T.nilable(::String)) } def first_name; end sig { returns(T::Boolean) } def first_name?; end - sig { returns(Integer) } + sig { returns(T.untyped) } def id; end sig { returns(T::Boolean) } def id?; end - sig { returns(T::Boolean) } + sig { returns(T.nilable(T::Boolean)) } def is_cool_person; end sig { returns(T::Boolean) } def is_cool_person?; end - sig { returns(String) } + sig { returns(T.nilable(::String)) } def last_name; end sig { returns(T::Boolean) } def last_name?; end - sig { returns(String) } + sig { returns(T.nilable(::String)) } def location; end sig { returns(T::Boolean) } def location?; end - sig { returns(Symbol) } + sig { returns(::Symbol) } def shirt_size; end sig { returns(T::Boolean) } def shirt_size?; end - sig { returns(Hash) } + sig { returns(T.nilable(::Hash)) } def skills; end sig { returns(T::Boolean) } def skills?; end - sig { returns(Time) } + sig { returns(T.nilable(::DateTime)) } def updated_at; end sig { returns(T::Boolean) } @@ -257,13 +310,13 @@ class Student extend GeneratedRelationMethods module FrozenRecordAttributeMethods - sig { returns(String) } + sig { returns(T.untyped) } def course; end sig { returns(T::Boolean) } def course?; end - sig { returns(Integer) } + sig { returns(T.untyped) } def id; end sig { returns(T::Boolean) }