Skip to content
Merged
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
26 changes: 26 additions & 0 deletions lib/anchor/concerns/typeable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
module Anchor
module Typeable
extend ActiveSupport::Concern

included do
def object(...) = Anchor::Types::Object.new(...)
def property(...) = Anchor::Types::Property.new(...)
def maybe(...) = Anchor::Types::Maybe.new(...)
def array(...) = Anchor::Types::Array.new(...)
def union(...) = Anchor::Types::Union.new(...)
def literal(...) = Anchor::Types::Literal.new(...)
def literals(values) = union(values.map { |value| literal(value) })
def reference(...) = Anchor::Types::Reference.new(...)
def references(names) = union(names.map { |name| reference(name) })
def record(value_type = Anchor::Types::Unknown) = Anchor::Types::Record.new(value_type)

def boolean = Anchor::Types::Boolean
def null = Anchor::Types::Null
def unknown = Anchor::Types::Unknown
def string = Anchor::Types::String
def float = Anchor::Types::Float
def integer = Anchor::Types::Integer
def big_decimal = Anchor::Types::BigDecimal
end
end
end
4 changes: 3 additions & 1 deletion lib/anchor/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ class Config
:array_bracket_notation,
:infer_default_as_non_null,
:ar_comment_to_string,
:infer_ar_enums
:infer_ar_enums,
:rbs

def initialize
@ar_column_to_type = nil
Expand All @@ -26,6 +27,7 @@ def initialize
@infer_default_as_non_null = nil
@ar_comment_to_string = nil
@infer_ar_enums = nil
@rbs = "off"
end
end
end
8 changes: 8 additions & 0 deletions lib/anchor/inference/active_record/infer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module Anchor
module Inference
module ActiveRecord
module Infer
end
end
end
end
12 changes: 12 additions & 0 deletions lib/anchor/inference/active_record/infer/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module Anchor::Inference::ActiveRecord::Infer
class Base
include Anchor::Typeable

def initialize(klass)
@klass = klass
end

def self.infer(...) = new(...).infer
def infer = raise NotImplementedError
end
end
42 changes: 42 additions & 0 deletions lib/anchor/inference/active_record/infer/columns.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module Anchor::Inference::ActiveRecord::Infer
class Columns < Base
def infer = object(properties)

private

def properties
@klass.columns_hash.map do |name, column|
next property(name, Anchor.config.ar_column_to_type.call(column)) if Anchor.config.ar_column_to_type
metadata_type = from_sql_type_metadata(column.sql_type_metadata)
column_type = from_column_type(column.type)

type = [metadata_type, column_type, unknown].compact.first
type = column.null ? maybe(type) : type
property(name, type)
end
end

def from_sql_type_metadata(sql_type_metadata)
case sql_type_metadata.sql_type
when "character varying[]", "text[]" then array(string)
end
end

def from_column_type(type)
case type
when :boolean then boolean
when :date then string
when :datetime then string
when :decimal then big_decimal
when :float then float
when :integer then integer
when :json then record
when :jsonb then record
when :string then string
when :text then string
when :time then string
when :uuid then string
end
end
end
end
17 changes: 17 additions & 0 deletions lib/anchor/inference/active_record/infer/enums.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module Anchor::Inference::ActiveRecord::Infer
class Enums < Base
def infer = object(properties)

private

def properties
@klass.columns_hash.slice(*defined_enums.keys).merge(defined_enums) do |name, column, enum|
property(name, column.null ? maybe(enum) : enum)
end.values
end

def defined_enums
@defined_enums ||= @klass.defined_enums.transform_values { |enum| literals(enum.values) }
end
end
end
64 changes: 64 additions & 0 deletions lib/anchor/inference/active_record/infer/model.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# TODO: Is attribute_types.keys ⊅ columns_hash.keys possible?
# def superset?(klass) = klass.attribute_types.keys.to_set.superset?(klass.columns_hash.keys.to_set)
# !ActiveRecord::Base.descendants.reject(&:abstract_class?).all? { |k| superset?(k) }
module Anchor::Inference::ActiveRecord::Infer
class Model < Base
module T
include Anchor::Inference::ActiveRecord::Types
end

def infer
res = [serialized, overridden, presence_required, defaulted, column_comments].compact.reduce(columns) do |acc, elem|
elem.wrap(acc)
end

res.overwrite(
rbs.pick(
res.pick_by_value(unknown.singleton_class).keys,
),
keep_description: :left,
)
end

private

def columns
Columns.infer(@klass).overwrite(enums, keep_description: :left)
end

def enums
return object([]) unless Anchor.config.infer_ar_enums
@enum_types ||= Enums.infer(@klass)
end

def column_comments
return unless Anchor.config.use_active_record_comment
T::ColumnComments.new(@klass)
end

def rbs
return @rbs if defined?(@rbs)
return object([]) unless Anchor::Types::Inference::RBS.enabled?
Anchor::Types::Inference::RBS.validate!
@rbs = RBS.infer(@klass)
end

def serialized
T::Serialized.new(@klass)
end

def overridden
T::Overridden.new(@klass)
end

def presence_required
return unless Anchor.config.use_active_record_validations
T::PresenceRequired.new(@klass)
end

def defaulted
return unless Anchor.config.infer_default_as_non_null
T::Defaulted.new(@klass)
end
end
end
30 changes: 30 additions & 0 deletions lib/anchor/inference/active_record/infer/rbs.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
module Anchor::Inference::ActiveRecord::Infer
class RBS < Base
def infer = object(properties)

private

def properties
included_attributes.map do |method_name|
type = rbs.from_rbs_type(instance.methods[method_name].method_types.first.type.return_type)
Anchor::Types::Property.new(method_name.to_s, type)
end
end

def included_attributes
instance.methods.filter_map do |method_name, method_def|
next if method_def&.method_types&.length != 1
method_name
end
end

def instance
return @instance if defined?(@instance)
@instance ||= rbs.build_instance(@klass)
end

def rbs
@rbs ||= Anchor::Types::Inference::RBS
end
end
end
8 changes: 8 additions & 0 deletions lib/anchor/inference/active_record/types.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module Anchor
module Inference
module ActiveRecord
module Types
end
end
end
end
9 changes: 9 additions & 0 deletions lib/anchor/inference/active_record/types/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module Anchor::Inference::ActiveRecord::Types
class Base
def initialize(klass)
@klass = klass
end

def wrap(t) = raise NotImplementedError
end
end
29 changes: 29 additions & 0 deletions lib/anchor/inference/active_record/types/column_comments.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
module Anchor::Inference::ActiveRecord::Types
class ColumnComments < Base
include Anchor::Typeable

def wrap(t) = object(add_comments(t))

private

def add_comments(t)
t.properties.map do |prop|
prop.dup(description: comments[prop.name] || prop.description)
end
end

def comments
@comments ||= @klass.columns_hash.filter_map do |name, column|
next unless column.comment
description = serialize_comment(column.comment)
[name, description]
end.to_h
end

def serialize_comment(comment)
return comment unless Anchor.config.ar_comment_to_string

Anchor.config.ar_comment_to_string.call(comment)
end
end
end
17 changes: 17 additions & 0 deletions lib/anchor/inference/active_record/types/defaulted.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module Anchor::Inference::ActiveRecord::Types
class Defaulted < Base
def wrap(t) = t.pick(names).nonnullable + t.omit(names)

private

def names
@klass.columns_hash.filter_map do |name, column|
name if has_default?(column)
end
end

def has_default?(column)
column.default.present? || column.default_function.present? && column.instance_variable_get(:@generated).blank?
end
end
end
23 changes: 23 additions & 0 deletions lib/anchor/inference/active_record/types/overridden.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
module Anchor::Inference::ActiveRecord::Types
class Overridden < Base
def wrap(t) = t.untype(names)

private

def names
@klass.attribute_types.keys.filter do |name|
next unless @klass.method_defined?(name.to_sym)
expected_generators.none? do |generator|
@klass.instance_method(name.to_sym).owner.is_a?(generator)
end
end
end

def expected_generators
@expected_generators ||= [
ActiveRecord::AttributeMethods::PrimaryKey,
ActiveRecord::AttributeMethods::GeneratedAttributeMethods,
]
end
end
end
33 changes: 33 additions & 0 deletions lib/anchor/inference/active_record/types/presence_required.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
module Anchor::Inference::ActiveRecord::Types
class PresenceRequired < Base
def wrap(t) = t.pick(names).nonnullable + t.omit(names)

private

def names
@klass.attribute_types.keys.filter do |name|
presence_required_for?(name)
end
end

def presence_required_for?(attribute)
@klass.validators_on(attribute).any? do |validator|
case validator
when ActiveRecord::Validations::NumericalityValidator then numericality_presence_required?(validator)
when ActiveRecord::Validations::PresenceValidator then presence_required?(validator)
else false
end
end
end

def numericality_presence_required?(validator)
opts = validator.options.with_indifferent_access
!(opts[:allow_nil] || opts[:if] || opts[:unless] || opts[:on])
end

def presence_required?(validator)
opts = validator.options.with_indifferent_access
!(opts[:if] || opts[:unless] || opts[:on])
end
end
end
13 changes: 13 additions & 0 deletions lib/anchor/inference/active_record/types/serialized.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module Anchor::Inference::ActiveRecord::Types
class Serialized < Base
def wrap(t) = t.untype(names)

private

def names
@klass.attribute_types.filter_map do |name, type|
name if type.respond_to?(:coder)
end
end
end
end
8 changes: 8 additions & 0 deletions lib/anchor/inference/jsonapi/infer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module Anchor
module Inference
module JSONAPI
module Infer
end
end
end
end
Loading