# typed: strict
# frozen_string_literal: true
return unless defined?(ActiveRecord::Base)
require "tapioca/dsl/helpers/active_record_column_type_helper"
require "tapioca/dsl/helpers/active_record_constants_helper"
module Tapioca
module Dsl
module Compilers
# `Tapioca::Dsl::Compilers::ActiveRecordColumns` refines RBI files for subclasses of
# [`ActiveRecord::Base`](https://api.rubyonrails.org/classes/ActiveRecord/Base.html).
# This compiler is only responsible for defining the attribute methods that would be
# created for columns and virtual attributes that are defined in the Active Record
# model.
#
# This compiler accepts a `ActiveRecordColumnTypes` option that can be used to specify
# how the types of the column related methods should be generated. The option can be one of the following:
# - `persisted` (_default_): The methods will be generated with the type that matches the actual database
# column type as the return type. This means that if the column is a string, the method return type
# will be `String`, but if the column is also nullable, then the return type will be `T.nilable(String)`. This
# mode basically treats each model as if it was a valid and persisted model. Note that this makes typing
# Active Record models easier, but does not match the behaviour of non-persisted or invalid models, which can
# have all kinds of non-sensical values in their column attributes.
# - `nilable`: All column methods will be generated with `T.nilable` return types. This is strictly the most
# correct way to type the methods, but it can make working with the models more cumbersome, as you will have to
# handle the `nil` cases explicitly using `T.must` or the safe navigation operator `&.`, even for valid
# persisted models.
# - `untyped`: The methods will be generated with `T.untyped` return types. This mode is practical if you are not
# ready to start typing your models strictly yet, but still want to generate RBI files for them.
#
# For example, with the following model class:
# ~~~rb
# class Post < ActiveRecord::Base
# end
# ~~~
#
# and the following database schema:
#
# ~~~rb
# # db/schema.rb
# create_table :posts do |t|
# t.string :title, null: false
# t.string :body
# t.boolean :published
# t.timestamps
# end
# ~~~
#
# this compiler will, by default, produce the following methods in the RBI file
# `post.rbi`:
#
# ~~~rbi
# # post.rbi
# # typed: true
# class Post
# include GeneratedAttributeMethods
#
# module GeneratedAttributeMethods
# sig { returns(T.nilable(::String)) }
# def body; end
#
# sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) }
# def body=; end
#
# sig { returns(T::Boolean) }
# def body?; end
#
# sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
# def created_at; end
#
# sig { params(value: ::ActiveSupport::TimeWithZone).returns(::ActiveSupport::TimeWithZone) }
# def created_at=; end
#
# sig { returns(T::Boolean) }
# def created_at?; end
#
# sig { returns(T.nilable(T::Boolean)) }
# def published; end
#
# sig { params(value: T::Boolean).returns(T::Boolean) }
# def published=; end
#
# sig { returns(T::Boolean) }
# def published?; end
#
# sig { returns(::String) }
# def title; end
#
# sig { params(value: ::String).returns(::String) }
# def title=(value); end
#
# sig { returns(T::Boolean) }
# def title?; end
#
# sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
# def updated_at; end
#
# sig { params(value: ::ActiveSupport::TimeWithZone).returns(::ActiveSupport::TimeWithZone) }
# def updated_at=; end
#
# sig { returns(T::Boolean) }
# def updated_at?; end
#
# ## Also the methods added by https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Dirty.html
# ## Also the methods added by https://api.rubyonrails.org/classes/ActiveModel/Dirty.html
# ## Also the methods added by https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/BeforeTypeCast.html
# end
# end
# ~~~
#
# However, if `ActiveRecordColumnTypes` is set to `nilable`, the `title` method will be generated as:
# ~~~rbi
# sig { returns(T.nilable(::String)) }
# def title; end
# ~~~
# and if the option is set to `untyped`, the `title` method will be generated as:
# ~~~rbi
# sig { returns(T.untyped) }
# def title; end
# ~~~
class ActiveRecordColumns < Compiler
extend T::Sig
include Helpers::ActiveRecordConstantsHelper
ConstantType = type_member { { fixed: T.class_of(ActiveRecord::Base) } }
sig { override.void }
def decorate
return unless constant.table_exists?
# We need to call this to ensure that some attribute aliases are defined, e.g.
# `id_value` as an alias for `id`.
# I think this is a regression on Rails 7.1, but we are where we are.
constant.define_attribute_methods
root.create_path(constant) do |model|
model.create_module(AttributeMethodsModuleName) do |mod|
(constant.attribute_names + ["id"]).uniq.each do |attribute_name|
add_methods_for_attribute(mod, attribute_name)
end
constant.attribute_aliases.each do |attribute_name, column_name|
attribute_name = attribute_name.to_s
column_name = column_name.to_s
patterns = if constant.respond_to?(:attribute_method_patterns)
# https://github.com/rails/rails/pull/44367
constant.attribute_method_patterns
else
T.unsafe(constant).attribute_method_matchers
end
new_method_names = patterns.map { |m| m.method_name(attribute_name) }
old_method_names = patterns.map { |m| m.method_name(column_name) }
methods_to_add = new_method_names - old_method_names
add_methods_for_attribute(mod, attribute_name, column_name, methods_to_add)
end
end
model.create_include(AttributeMethodsModuleName)
end
end
class << self
extend T::Sig
sig { override.returns(T::Enumerable[Module]) }
def gather_constants
descendants_of(::ActiveRecord::Base).reject(&:abstract_class?)
end
end
private
ColumnTypeOption = Helpers::ActiveRecordColumnTypeHelper::ColumnTypeOption
sig { returns(ColumnTypeOption) }
def column_type_option
@column_type_option ||= T.let(
ColumnTypeOption.from_options(options) do |value, default_column_type_option|
add_error(<<~MSG.strip)
Unknown value for compiler option `ActiveRecordColumnTypes` given: `#{value}`.
Proceeding with the default value: `#{default_column_type_option.serialize}`.
MSG
end,
T.nilable(ColumnTypeOption),
)
end
sig do
params(
klass: RBI::Scope,
name: String,
methods_to_add: T.nilable(T::Array[String]),
return_type: String,
parameters: T::Array[RBI::TypedParam],
).void
end
def add_method(klass, name, methods_to_add, return_type: "void", parameters: [])
klass.create_method(
name,
parameters: parameters,
return_type: return_type,
) if methods_to_add.nil? || methods_to_add.include?(name)
end
sig do
params(
klass: RBI::Scope,
attribute_name: String,
column_name: String,
methods_to_add: T.nilable(T::Array[String]),
).void
end
def add_methods_for_attribute(klass, attribute_name, column_name = attribute_name, methods_to_add = nil)
getter_type, setter_type = Helpers::ActiveRecordColumnTypeHelper
.new(constant, column_type_option: column_type_option)
.type_for(attribute_name, column_name)
# Added by ActiveRecord::AttributeMethods::Read
#
add_method(
klass,
attribute_name.to_s,
methods_to_add,
return_type: getter_type,
)
# Added by ActiveRecord::AttributeMethods::Write
#
add_method(
klass,
"#{attribute_name}=",
methods_to_add,
parameters: [create_param("value", type: setter_type)],
return_type: setter_type,
)
# Added by ActiveRecord::AttributeMethods::Query
#
add_method(
klass,
"#{attribute_name}?",
methods_to_add,
return_type: "T::Boolean",
)
# Added by ActiveRecord::AttributeMethods::Dirty
#
add_method(
klass,
"#{attribute_name}_before_last_save",
methods_to_add,
return_type: as_nilable_type(getter_type),
)
add_method(
klass,
"#{attribute_name}_change_to_be_saved",
methods_to_add,
return_type: "T.nilable([#{getter_type}, #{getter_type}])",
)
add_method(
klass,
"#{attribute_name}_in_database",
methods_to_add,
return_type: as_nilable_type(getter_type),
)
add_method(
klass,
"saved_change_to_#{attribute_name}",
methods_to_add,
return_type: "T.nilable([#{getter_type}, #{getter_type}])",
)
add_method(
klass,
"saved_change_to_#{attribute_name}?",
methods_to_add,
return_type: "T::Boolean",
)
add_method(
klass,
"will_save_change_to_#{attribute_name}?",
methods_to_add,
return_type: "T::Boolean",
)
# Added by ActiveModel::Dirty
#
add_method(
klass,
"#{attribute_name}_change",
methods_to_add,
return_type: "T.nilable([#{getter_type}, #{getter_type}])",
)
add_method(
klass,
"#{attribute_name}_changed?",
methods_to_add,
return_type: "T::Boolean",
parameters: [
create_kw_opt_param("from", type: setter_type, default: "T.unsafe(nil)"),
create_kw_opt_param("to", type: setter_type, default: "T.unsafe(nil)"),
],
)
add_method(
klass,
"#{attribute_name}_will_change!",
methods_to_add,
)
add_method(
klass,
"#{attribute_name}_was",
methods_to_add,
return_type: as_nilable_type(getter_type),
)
add_method(
klass,
"#{attribute_name}_previous_change",
methods_to_add,
return_type: "T.nilable([#{getter_type}, #{getter_type}])",
)
add_method(
klass,
"#{attribute_name}_previously_changed?",
methods_to_add,
return_type: "T::Boolean",
parameters: [
create_kw_opt_param("from", type: setter_type, default: "T.unsafe(nil)"),
create_kw_opt_param("to", type: setter_type, default: "T.unsafe(nil)"),
],
)
add_method(
klass,
"#{attribute_name}_previously_was",
methods_to_add,
return_type: as_nilable_type(getter_type),
)
add_method(
klass,
"restore_#{attribute_name}!",
methods_to_add,
)
# Added by ActiveRecord::AttributeMethods::BeforeTypeCast
#
add_method(
klass,
"#{attribute_name}_before_type_cast",
methods_to_add,
return_type: "T.untyped",
)
add_method(
klass,
"#{attribute_name}_came_from_user?",
methods_to_add,
return_type: "T::Boolean",
)
end
end
end
end
end