Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/boba.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
module Boba
require "boba/version"
require "boba/relations_railtie" if defined?(Rails)
require "boba/enums_railtie" if defined?(Rails)
end
95 changes: 95 additions & 0 deletions lib/boba/enums_railtie.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# typed: true
# frozen_string_literal: true

require "rails/railtie"

module Boba
class EnumsRailtie < Rails::Railtie
railtie_name(:boba)

initializer("boba.add_enum_classes") do
ActiveSupport.on_load(:active_record) do
next if defined?(ActiveRecordTypedEnum)

module ActiveRecordTypedEnum
extend T::Sig
extend T::Helpers

module ClassMethods
extend T::Sig
extend T::Helpers
include Kernel

abstract!

sig { abstract.returns(T::Hash[String, T::Hash[String, T.untyped]]) }
def defined_enums; end

sig { abstract.params(name: String, value: T.untyped).returns(T.untyped) }
def const_set(name, value)
end

sig { abstract.params(name: String, block: T.proc.params(args: T.untyped).void).returns(T.untyped) }
def define_method(name, &block)
end

sig { params(args: T.untyped, kwargs: T.untyped).returns(T.untyped) }
def enum(*args, **kwargs)
# Call the original enum method first
result = super

# Extract definitions based on how enum was called
definitions = if args.first.is_a?(Hash)
# Rails 6 style: enum(status: {draft: 0, published: 1})
args.first
elsif args.length >= 2
# Rails 7 style: enum(:status, {draft: 0, published: 1})
{ args[0] => args[1] }
elsif kwargs.any?
# Keyword style: enum(status: {draft: 0, published: 1})
kwargs
else
{}
end

# Add typed enum methods for each enum
definitions.each_key do |name|
enum_values = defined_enums.fetch(name.to_s, nil)
next if enum_values.nil?

enum_class = Class.new(T::Enum) do
enums { enum_values.each { |key, _| const_set(key.to_s.camelize, new(key)) } }
end

const_set(name.to_s.camelize, enum_class)

# Define typed getter method
define_method("typed_#{name}") do
value = send(name)
return if value.nil?

enum_class.try_deserialize(value)
end

# Define typed setter method
define_method("typed_#{name}=") do |typed_value|
send("#{name}=", typed_value&.serialize)
end
end

result
end
end

mixes_in_class_methods(ClassMethods)
end

module ::ActiveRecord
class Base
include ActiveRecordTypedEnum
end
end
end
end
end
end
120 changes: 120 additions & 0 deletions lib/tapioca/dsl/compilers/active_record_typed_enum.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# typed: true
# frozen_string_literal: true

module Tapioca
module Dsl
module Compilers
# `Tapioca::Dsl::Compilers::ActiveRecordTypedEnum` generates type-safe enum classes and methods
# for ActiveRecord models that use the built-in enum feature.
#
# For each enum defined in a model, this compiler:
# 1. Creates a `T::Enum` subclass with all the enum values
# 2. Generates `typed_<enum_name>` getter method that returns the enum instance
# 3. Generates `typed_<enum_name>=` setter method that accepts the enum instance
#
# The compiler respects the nullability of the enum attribute based on the database schema.
#
# For example, with the following ActiveRecord model:
#
# ~~~rb
# class Order < ActiveRecord::Base
# enum status: { pending: 0, processing: 1, completed: 2, cancelled: 3 }
# enum priority: { low: 0, medium: 1, high: 2 }, _prefix: true
# end
# ~~~
#
# This compiler will produce the following RBI:
#
# ~~~rbi
# class Order
# class Status < T::Enum
# enums do
# Pending = new(0)
# Processing = new(1)
# Completed = new(2)
# Cancelled = new(3)
# end
# end
#
# class Priority < T::Enum
# enums do
# Low = new(0)
# Medium = new(1)
# High = new(2)
# end
# end
#
# sig { returns(Order::Status) }
# def typed_status; end
#
# sig { params(value: Order::Status).returns(void) }
# def typed_status=(value); end
#
# sig { returns(Order::Priority) }
# def typed_priority; end
#
# sig { params(value: Order::Priority).returns(void) }
# def typed_priority=(value); end
# end
# ~~~
class ActiveRecordTypedEnum < Tapioca::Dsl::Compiler
extend T::Sig

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

class << self
extend T::Sig

sig { override.returns(T::Enumerable[Module]) }
def gather_constants
ActiveRecord::Base.descendants.select do |klass|
klass.respond_to?(:defined_enums) && klass.defined_enums.any? && klass.table_exists?
end
end
end

sig { override.void }
def decorate
return unless constant.respond_to?(:defined_enums)

constant.defined_enums.each do |enum_name, enum_values|
# Create the enum class
enum_class_name = enum_name.camelize

root.create_path(constant) do |model|
# Create the T::Enum using RBI::TEnum
enum_class =
RBI::TEnum.new(enum_class_name) do |tenum|
# Try to create a TEnumBlock for the enums do block
enum_block =
RBI::TEnumBlock.new do |block|
enum_values.each do |key, _|
block.create_constant(key.to_s.camelize, value: "new('#{key}')")
end
end

tenum << enum_block
end

model << enum_class

nullability = Boba::ActiveRecord::AttributeService.nilable_attribute?(constant, enum_name)
base_enum_type = "#{constant}::#{enum_class_name}"
enum_type = nullability ? "T.nilable(#{base_enum_type})" : base_enum_type

# Generate typed getter
model.create_method("typed_#{enum_name}", return_type: enum_type)

# Generate typed setter
model.create_method(
"typed_#{enum_name}=",
parameters: [create_param("value", type: enum_type)],
return_type: "void",
)
end
end
end
end
end
end
end
55 changes: 55 additions & 0 deletions manual/compiler_activerecordtypedenum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
## ActiveRecordTypedEnum

`Tapioca::Dsl::Compilers::ActiveRecordTypedEnum` generates type-safe enum classes and methods
for ActiveRecord models that use the built-in enum feature.

For each enum defined in a model, this compiler:
1. Creates a `T::Enum` subclass with all the enum values
2. Generates `typed_<enum_name>` getter method that returns the enum instance
3. Generates `typed_<enum_name>=` setter method that accepts the enum instance

The compiler respects the nullability of the enum attribute based on the database schema.

For example, with the following ActiveRecord model:

~~~rb
class Order < ActiveRecord::Base
enum status: { pending: 0, processing: 1, completed: 2, cancelled: 3 }
enum priority: { low: 0, medium: 1, high: 2 }, _prefix: true
end
~~~

This compiler will produce the following RBI:

~~~rbi
class Order
class Status < T::Enum
enums do
Pending = new(0)
Processing = new(1)
Completed = new(2)
Cancelled = new(3)
end
end

class Priority < T::Enum
enums do
Low = new(0)
Medium = new(1)
High = new(2)
end
end

sig { returns(Order::Status) }
def typed_status; end

sig { params(value: Order::Status).returns(void) }
def typed_status=(value); end

sig { returns(Order::Priority) }
def typed_priority; end

sig { params(value: Order::Priority).returns(void) }
def typed_priority=(value); end
end
~~~
1 change: 1 addition & 0 deletions manual/compilers.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This list is an evergeen list of currently available compilers.
<!-- START_COMPILER_LIST -->
* [ActiveRecordAssociationsPersisted](compiler_activerecordassociationspersisted.md)
* [ActiveRecordColumnsPersisted](compiler_activerecordcolumnspersisted.md)
* [ActiveRecordTypedEnum](compiler_activerecordtypedenum.md)
* [AttrJson](compiler_attrjson.md)
* [FlagShihTzu](compiler_flagshihtzu.md)
* [Kaminari](compiler_kaminari.md)
Expand Down
Loading