Skip to content

Commit

Permalink
Merge pull request #43 from BookingSync/rename-form-base
Browse files Browse the repository at this point in the history
Backwards-compatible rename Operations::Form to Operations::Form::Base
  • Loading branch information
pyromaniac authored Dec 24, 2023
2 parents ce74535 + cf6e927 commit 0e2f84d
Show file tree
Hide file tree
Showing 11 changed files with 659 additions and 608 deletions.
11 changes: 8 additions & 3 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2023-12-21 02:20:34 UTC using RuboCop version 1.59.0.
# on 2023-12-24 10:13:24 UTC using RuboCop version 1.59.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
Expand All @@ -19,10 +19,15 @@ Gemspec/DevelopmentDependencies:
Metrics/AbcSize:
Max: 28

# Offense count: 2
# Offense count: 1
# Configuration parameters: CountComments, CountAsOne.
Metrics/ClassLength:
Max: 164
Max: 163

# Offense count: 1
# Configuration parameters: CountComments, CountAsOne.
Metrics/ModuleLength:
Max: 105

# Offense count: 1
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
Expand Down
1 change: 1 addition & 0 deletions lib/operations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
require "operations/command"
require "operations/result"
require "operations/form"
require "operations/form/base"
require "operations/form/attribute"
require "operations/form/builder"

Expand Down
8 changes: 4 additions & 4 deletions lib/operations/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,10 @@ class Operations::Command
COMPONENTS = %i[contract policies idempotency preconditions operation on_success on_failure].freeze
FORM_HYDRATOR = ->(_form_class, params, **_context) { params }

extend Dry::Initializer
include Dry::Monads[:result]
include Dry::Monads::Do.for(:call_monad, :callable_monad, :validate_monad, :execute_operation)
include Dry::Equalizer(*COMPONENTS)
extend Dry::Initializer

# Provides message and meaningful sentry context for failed operations
class OperationFailed < StandardError
Expand Down Expand Up @@ -164,7 +164,7 @@ def sentry_context
),
Operations::Types::String
), default: proc { {} }
option :form_base, Operations::Types::Class, default: proc { ::Operations::Form }
option :form_base, Operations::Types::Class, default: proc { ::Operations::Form::Base }
option :form_class, Operations::Types::Class.optional, default: proc {}, reader: false
option :form_hydrator, Operations::Types.Interface(:call), default: proc { FORM_HYDRATOR }
option :configuration, Operations::Configuration, default: proc { Operations.default_config }
Expand Down Expand Up @@ -404,9 +404,9 @@ def build_form_class
.new(base_class: form_base)
.build(
key_map: contract.class.schema.key_map,
model_map: form_model_map,
namespace: operation.class,
class_name: form_class_name,
model_map: form_model_map
class_name: form_class_name
)
end

Expand Down
198 changes: 14 additions & 184 deletions lib/operations/form.rb
Original file line number Diff line number Diff line change
@@ -1,194 +1,24 @@
# frozen_string_literal: true

# This class implements Rails form object compatibility layer
# It is possible to configure form object attributes automatically
# basing on Dry Schema user {Operations::Form::Builder}
# @example
#
# class AuthorForm < Operations::Form
# attribute :name
# end
#
# class PostForm < Operations::Form
# attribute :title
# attribute :tags, collection: true
# attribute :author, form: AuthorForm
# end
#
# PostForm.new({ tags: ["foobar"], author: { name: "Batman" } })
# # => #<PostForm attributes={:title=>nil, :tags=>["foobar"], :author=>#<AuthorForm attributes={:name=>"Batman"}>}>
#
# @see Operations::Form::Builder
# Configures and defines a form object factory.
class Operations::Form
extend Dry::Initializer
include Dry::Equalizer(:attributes, :errors)
# We need to make deprecated inheritance from Operations::Form act exactly the
# same way as from Operations::Form::Base. In order to do this, we are encapsulating all the
# inheritable functionality in 2 modules and removing methods defined in Operations::Form
# from the result class.
def self.inherited(subclass)
super

param :data,
type: Operations::Types::Hash.map(Operations::Types::Symbol, Operations::Types::Any),
default: proc { {} },
reader: :private
option :messages,
type: Operations::Types::Hash.map(
Operations::Types::Nil | Operations::Types::Coercible::Symbol,
Operations::Types::Any
),
default: proc { {} },
reader: :private
return unless self == Operations::Form

class_attribute :attributes, instance_accessor: false, default: {}
ActiveSupport::Deprecation.new.warn("Inheritance from Operations::Form is deprecated and will be " \
"removed in 1.0.0. Please inherit from Operations::Form::Base instead")

def self.attribute(name, **options)
attribute = Operations::Form::Attribute.new(name, **options)

self.attributes = attributes.merge(
attribute.name => attribute
)
end

def self.human_attribute_name(name, options = {})
if attributes[name.to_sym]
attributes[name.to_sym].model_human_name(options)
else
name.to_s.humanize
end
end

def self.validators_on(name)
attributes[name.to_sym]&.model_validators || []
end

def type_for_attribute(name)
self.class.attributes[name.to_sym].model_type
end

def localized_attr_name_for(name, locale)
self.class.attributes[name.to_sym].model_localized_attr_name(locale)
end

def has_attribute?(name) # rubocop:disable Naming/PredicateName
self.class.attributes.key?(name.to_sym)
end

def attributes
self.class.attributes.keys.to_h do |name|
[name, read_attribute(name)]
end
end

def assigned_attributes
(self.class.attributes.keys & data.keys).to_h do |name|
[name, read_attribute(name)]
end
end

def method_missing(name, *)
read_attribute(name)
end

def respond_to_missing?(name, *)
self.class.attributes.key?(name)
end

def model_name
ActiveModel::Name.new(self.class)
end

# This should return false if we want to use POST.
# Now it is going to generate PATCH form.
def persisted?
true
end

# Probably can be always nil, it is used in automated URL derival.
# We can make it work later but it will require additional concepts.
def to_key
nil
end

def errors
@errors ||= ActiveModel::Errors.new(self).tap do |errors|
self.class.attributes.each do |name, attribute|
add_messages(errors, name, messages[name])
add_messages_to_collection(errors, name, messages[name]) if attribute.collection
end

add_messages(errors, :base, messages[nil])
end
end

def valid?
errors.empty?
end

def read_attribute(name)
cached_attribute(name) do |value, attribute|
if attribute.collection && attribute.form
wrap_collection([name], value, attribute.form)
elsif attribute.form
wrap_object([name], value, attribute.form)
elsif attribute.collection
value.nil? ? [] : value
else
value
end
end
end

private

def add_messages(errors, key, messages)
return unless messages.is_a?(Array)

messages.each do |message|
message = message[:text] if message.is_a?(Hash) && message.key?(:text)
errors.add(key, message)
end
end

def add_messages_to_collection(errors, key, messages)
return unless messages.is_a?(Hash)

read_attribute(key).size.times do |i|
add_messages(errors, "#{key}[#{i}]", messages[i])
end
end

def cached_attribute(name)
name = name.to_sym
return unless self.class.attributes.key?(name)

nested_name = :"#{name}_attributes"
value = data.key?(nested_name) ? data[nested_name] : data[name]

(@attributes_cache ||= {})[name] ||= yield(value, self.class.attributes[name])
end

def wrap_collection(path, collection, form)
collection = [] if collection.nil?

case collection
when Hash
collection.values.map.with_index do |data, i|
wrap_object(path + [i], data, form)
end
when Array
collection.map.with_index do |data, i|
wrap_object(path + [i], data, form)
end
else
collection
(Operations::Form.instance_methods - Object.instance_methods).each do |method|
subclass.undef_method(method)
end
end

def wrap_object(path, data, form)
data = {} if data.nil?

if data.is_a?(Hash)
nested_messages = messages.dig(*path)
nested_messages = {} unless nested_messages.is_a?(Hash)
form.new(data, messages: nested_messages)
else
data
end
subclass.extend Operations::Form::Base::ClassMethods
subclass.include Operations::Form::Base::InstanceMethods
end
end
Loading

0 comments on commit 0e2f84d

Please sign in to comment.