Skip to content

Commit

Permalink
generate non-nil associations when validation on association or fk
Browse files Browse the repository at this point in the history
  • Loading branch information
stathis-alexander committed Oct 27, 2024
1 parent da6b0bb commit d5ba230
Show file tree
Hide file tree
Showing 13 changed files with 25,066 additions and 40,854 deletions.
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.8

- `ActiveRecordAssocationsPersisted` generate non-nilable types when there's an unconditional validation on the association, an unconditional validation on the foreign key for the association, or when there's a non-`null` db constraint on the foreign key.

## 0.0.7

- Fix bug in `ActiveRecordColumnsPersisted` where `@column_type_option` can be `nil`.
Expand Down
38 changes: 38 additions & 0 deletions lib/boba/active_record/attribute_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# typed: strict
# frozen_string_literal: true

module Boba
module ActiveRecord
module AttributeService
class << self
extend T::Sig

sig { params(constant: T.class_of(::ActiveRecord::Base), attribute: String).returns(T::Boolean) }
def has_unconditional_presence_validator?(constant, attribute)
return false unless constant.respond_to?(:validators_on)

constant.validators_on(attribute).any? do |validator|
next false unless validator.is_a?(::ActiveRecord::Validations::PresenceValidator)

!validator.options.key?(:if) && !validator.options.key?(:unless) && !validator.options.key?(:on)
end
end

sig { params(constant: T.class_of(::ActiveRecord::Base), column_name: String).returns(T::Boolean) }
def has_non_null_database_constraint?(constant, column_name)
column = constant.columns_hash[column_name]
return false if column.nil?

!column.null
rescue StandardError
false
end

sig { params(constant: T.class_of(::ActiveRecord::Base), column_name: String).returns(T::Boolean) }
def virtual_attribute?(constant, column_name)
constant.columns_hash[column_name].nil?
end
end
end
end
end
69 changes: 69 additions & 0 deletions lib/boba/active_record/reflection_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# typed: strict
# frozen_string_literal: true

require_relative("attribute_service")

module Boba
module ActiveRecord
module ReflectionService
class << self
extend T::Sig

ReflectionType = T.type_alias do
T.any(::ActiveRecord::Reflection::ThroughReflection, ::ActiveRecord::Reflection::AssociationReflection)
end

sig { params(reflection: ReflectionType).returns(T::Boolean) }
def has_one_and_required_reflection?(reflection)
return false unless reflection.has_one?
return true if !!reflection.options[:required]
return true if reflection_required_by_database_constraint?(reflection)

reflection_required_by_validation?(reflection)
end

sig { params(reflection: ReflectionType).returns(T::Boolean) }
def belongs_to_and_non_optional_reflection?(reflection)
return false unless reflection.belongs_to?

optional = if reflection.options.key?(:required)
!reflection.options[:required]
else
reflection.options[:optional]
end
return !optional unless optional.nil?
return true if reflection_required_by_database_constraint?(reflection)
return true if reflection_required_by_validation?(reflection)

# nothing defined, so fall back to the default active record config
!!reflection.active_record.belongs_to_required_by_default
end

private

# check for non-nullable database constraint on the foreign key
sig { params(reflection: ReflectionType).returns(T::Boolean) }
def reflection_required_by_database_constraint?(reflection)
Boba::ActiveRecord::AttributeService.has_non_null_database_constraint?(
reflection.active_record,
reflection.foreign_key,
)
end

# check for presence validator on the foreign key or on the association
sig { params(reflection: ReflectionType).returns(T::Boolean) }
def reflection_required_by_validation?(reflection)
return true if Boba::ActiveRecord::AttributeService.has_unconditional_presence_validator?(
reflection.active_record,
reflection.foreign_key,
)

Boba::ActiveRecord::AttributeService.has_unconditional_presence_validator?(
reflection.active_record,
reflection.name,
)
end
end
end
end
end
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.7"
VERSION = "0.0.8"
end
33 changes: 5 additions & 28 deletions lib/tapioca/dsl/compilers/active_record_associations_persisted.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

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

require "boba/active_record/reflection_service"

module Tapioca
module Dsl
module Compilers
Expand Down Expand Up @@ -188,39 +190,14 @@ def single_association_type_for(reflection)
association_class = type_for(reflection)
return as_nilable_type(association_class) unless association_type_option.persisted?

if has_one_and_required_reflection?(reflection) || belongs_to_and_non_optional_reflection?(reflection)
if Boba::ActiveRecord::ReflectionService.has_one_and_required_reflection?(reflection)
association_class
elsif Boba::ActiveRecord::ReflectionService.belongs_to_and_non_optional_reflection?(reflection)
association_class
else
as_nilable_type(association_class)
end
end

# Note - one can do more here. If the association's attribute has an unconditional presence validation, it
# should also be considered required.
sig { params(reflection: ReflectionType).returns(T::Boolean) }
def has_one_and_required_reflection?(reflection)
reflection.has_one? && !!reflection.options[:required]
end

# Note - one can do more here. If the FK defining the belongs_to association is non-nullable at the DB level, or
# if the association's attribute has an unconditional presence validation, it should also be considered
# non-optional.
sig { params(reflection: ReflectionType).returns(T::Boolean) }
def belongs_to_and_non_optional_reflection?(reflection)
return false unless reflection.belongs_to?

optional = if reflection.options.key?(:required)
!reflection.options[:required]
else
reflection.options[:optional]
end

if optional.nil?
!!reflection.active_record.belongs_to_required_by_default
else
!optional
end
end
end
end
end
Expand Down
38 changes: 11 additions & 27 deletions lib/tapioca/dsl/compilers/active_record_columns_persisted.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

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

require "boba/active_record/attribute_service"
require "tapioca/dsl/helpers/active_record_column_type_helper"

module Tapioca
Expand Down Expand Up @@ -152,8 +153,14 @@ def type_for(attribute_name, column_name = attribute_name)
def column_type_for(column_name)
return ["T.untyped", "T.untyped"] if column_type_option.untyped?

nilable_column = !has_non_null_database_constraint?(column_name) &&
!has_unconditional_presence_validator?(column_name)
nilable_column = !Boba::ActiveRecord::AttributeService.has_non_null_database_constraint?(
@constant,
column_name,
)
nilable_column &&= !Boba::ActiveRecord::AttributeService.has_unconditional_presence_validator?(
@constant,
column_name,
)

column_type = @constant.attribute_types[column_name]
getter_type = column_type_helper.send(
Expand All @@ -169,7 +176,8 @@ def column_type_for(column_name)
getter_type
end

if column_type_option.persisted? && (virtual_attribute?(column_name) || !nilable_column)
virtual_attribute = Boba::ActiveRecord::AttributeService.virtual_attribute?(@constant, column_name)
if column_type_option.persisted? && (virtual_attribute || !nilable_column)
[getter_type, setter_type]
else
getter_type = as_nilable_type(getter_type) unless column_type_helper.send(
Expand All @@ -180,30 +188,6 @@ def column_type_for(column_name)
end
end

sig { params(column_name: String).returns(T::Boolean) }
def virtual_attribute?(column_name)
@constant.columns_hash[column_name].nil?
end

sig { params(column_name: String).returns(T::Boolean) }
def has_non_null_database_constraint?(column_name)
column = @constant.columns_hash[column_name]
return false if column.nil?

!column.null
end

sig { params(column_name: String).returns(T::Boolean) }
def has_unconditional_presence_validator?(column_name)
return false unless @constant.respond_to?(:validators_on)

@constant.validators_on(column_name).any? do |validator|
next false unless validator.is_a?(ActiveRecord::Validations::PresenceValidator)

!validator.options.key?(:if) && !validator.options.key?(:unless) && !validator.options.key?(:on)
end
end

sig do
params(
klass: RBI::Scope,
Expand Down
1 change: 1 addition & 0 deletions sorbet/rbi/dsl/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**/*.rbi linguist-generated=true
23 changes: 23 additions & 0 deletions sorbet/rbi/dsl/active_support/callbacks.rbi

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit d5ba230

Please sign in to comment.