# frozen_string_literal: true module Composable module Core module ComposableDSL extend ActiveSupport::Concern include InheritableAttributes included do inheritable_attributes :composables, default: {} end module ClassMethods def composable(attribute, **options, &block) composables[attribute.to_sym] ||= Composable.new(attribute) composables[attribute.to_sym].evaluate(**options, &block) return if attributes.include?(attribute.to_sym) attribute attribute alias_method "#{attribute}_original_setter=", "#{attribute}=" define_method("#{attribute}=") do |value| send("#{attribute}_original_setter=", value) composables[attribute.to_sym].sync_attributes( self, composable_record_for(attribute), reverse: true ) end end end # In order to prevent attribute values passed in at initialize from being # overridden by composable_record values, composable_record mapping methods # have to be synced first. def initialize(options = {}) composable_keys.each do |attribute| value = options.delete(attribute) || options.delete(attribute.to_s) send("#{attribute}=", value) if value end super end def composables self.class.composables end def save_composables multi_db_transaction do sync_composable_records save_composable_records yield if block_given? end end private def sync_composable_records composables.each do |attribute, composable| record = composable_record_for(attribute) composable.sync_attributes(self, record) if composable.valid?(self, record) end end def save_composable_records composables.each do |attribute, composable| record = composable_record_for(attribute) record.save! if composable.valid?(self, record) end end # Ensures `save!` queries are wrapped within multiple db transactions. # It might be the case that our service is working with several composable # objects that are gonna be saved into diffent databases, if there is an # error, all transactions are gonna be rollback regardless of the DB. def multi_db_transaction(attribute: composable_keys.first, &block) return yield if attribute.nil? index = composable_keys.index(attribute) record = composable_record_for(attribute) multi_db_transaction(attribute: composable_keys[index + 1]) do record.respond_to?(:transaction) ? record.transaction(&block) : yield end end def composable_keys @composable_keys ||= composables.keys.freeze end def composable_record_for(attribute) send(attribute) or raise ArgumentError, "required composable argument: #{attribute}" end class Composable attr_reader :attribute, :mapping, :conditions def initialize(attribute) @attribute = attribute @mapping = {} @conditions = Conditions.new end def initialize_copy(original_object) @mapping = original_object.mapping.deep_dup @conditions = original_object.conditions.deep_dup end def evaluate(**options, &block) conditions.merge(**options) instance_eval(&block) if block end def sync_attributes(form, record, reverse: false) mapping.each_value do |instance| instance.sync(form, record, reverse: reverse) end end def valid?(...) conditions.valid?(...) end private # DSL method def sync(*args, to: nil, **options) args.extract_options! args.compact.uniq.each do |name| mapping[name.to_sym] ||= Sync.new(from: name) mapping[name.to_sym].merge(to: (to || name), **options) end end class Sync attr_reader :from, :to, :conditions def initialize(from:) @from = from.to_sym @conditions = Conditions.new end def initialize_copy(original_object) @conditions = original_object.conditions.deep_dup end def merge(to:, **options) @to = to.to_sym conditions.merge(**options) end def sync(form, record, reverse: false) if reverse form.send("#{from}=", record.send(to)) else value = form.send(from) record.send("#{to}=", value) if conditions.valid?(form, record) end end end class Conditions attr_reader :conditions def initialize @conditions = {} end def initialize_copy(original_object) @conditions = original_object.conditions.deep_dup end def merge(**options) options.symbolize_keys.slice(:if, :unless).each do |statement, condition| conditions[statement] = Condition.new(statement, condition) end end def valid?(form, record) conditions.values.all? { |condition| condition.call(form, record) } end end class Condition attr_reader :condition, :statement, :callable def initialize(statement, condition) @statement = statement.to_sym @condition = condition @callable = make_lambda end def call(form, record) callable.call(form, record).eql?(statement == :if) end private def make_lambda case condition when Symbol ->(form, _record) { form.send(condition) } when ::Proc lambda { |form, record| form.instance_exec(*[record][0...condition.arity], &condition) } else ->(*) { true } end end end end end end end