require 'argon/version' require 'argon/error' require 'argon/invalid_transition_error' require 'argon/invalid_parameter_error' require 'active_support/concern' require 'active_support/inflector' require 'symbolic_enum' require 'pry-byebug' module Argon extend ActiveSupport::Concern extend ActiveSupport::Inflector include ActiveSupport::Inflector include SymbolicEnum module ClassMethods def state_machine(mapping) raise Argon::Error.new("state_machine() has to be called on a Hash") unless mapping.is_a?(Hash) raise Argon::Error.new("state_machine() has to specify a field and the mappings") unless mapping.keys.count == 1 && mapping.keys.first.is_a?(Symbol) && mapping.values.first.is_a?(Hash) raise Argon::Error.new("state_machine() should have (only) the following mappings: states, events, edges, parameters (optional), on_successful_transition, on_failed_transition") unless mapping.values.first.keys.to_set.subset?(%i(states events edges on_successful_transition on_failed_transition parameters).to_set) && %i(states events edges on_successful_transition on_failed_transition).to_set.subset?(mapping.values.first.keys.to_set) field = mapping.keys.first states_map = mapping.values.first[:states] events_list = mapping.values.first[:events] parameters = mapping.values.first[:parameters] edges_list = mapping.values.first[:edges] on_successful_transition = mapping.values.first[:on_successful_transition] on_failed_transition = mapping.values.first[:on_failed_transition] raise Argon::Error.new("`states` should be a Hash") unless states_map.is_a?(Hash) raise Argon::Error.new("`states` does not specify any states") if states_map.empty? raise Argon::Error.new("`states` must be a mapping of Symbols to unique Integers") unless states_map.keys.map(&:class).uniq == [Symbol] && states_map.values.map(&:class).uniq == [Integer] && states_map.values.uniq.sort == states_map.values.sort states_map.keys.each do |state_name| raise Argon::Error.new("`#{state_name}` is an invalid state name. `#{self.name}.#{state_name}` method already exists") if self.singleton_methods.include?(state_name) raise Argon::Error.new("`#{state_name}` is an invalid state name. `#{self.name}##{state_name}?` method already exists") if self.instance_methods.include?("#{state_name}?".to_sym) end raise Argon::Error.new("`events` should be an Array of Symbols") if !events_list.is_a?(Array) || (events_list.length > 0 && events_list.map(&:class).uniq != [Symbol]) events_list.each do |event_name| raise Argon::Error.new("`#{event_name}` is not a valid event name. `#{self.name}##{event_name}` method already exists") if self.instance_methods.include?(event_name) event_edges = edges_list.select{|e| !e[:on_events].nil? && e[:on_events].include?(event_name)} if event_edges.empty? raise Argon::Error.new("`on_#{event_name}(action:)` not found") if !self.instance_methods.include?("on_#{event_name}".to_sym) || self.instance_method("on_#{event_name}".to_sym).parameters.to_set != [[:keyreq, :action]].to_set raise Argon::Error.new("`after_#{event_name}(action:)` not found") if !self.instance_methods.include?("after_#{event_name}".to_sym) || self.instance_method("after_#{event_name}".to_sym).parameters.to_set != [[:keyreq, :action]].to_set else raise Argon::Error.new("Event `#{event_name}` is being used by edges (#{ event_edges.map{|e| "`#{e[:action]}`"}.join(", ") }) with mixed lists of parameters") if event_edges.map{|e| (e[:parameters] || []).to_set}.uniq.length > 1 expected_parameters = %i(action) if !event_edges[0][:parameters].nil? expected_parameters += parameters.values_at(*event_edges[0][:parameters]).map{|p| p[:name]} end raise Argon::Error.new("`on_#{event_name}(#{ expected_parameters.map{|p| "#{p}:"}.join(", ") })` not found") if !self.instance_methods.include?("on_#{event_name}".to_sym) || self.instance_method("on_#{event_name}".to_sym).parameters.to_set != expected_parameters.map{|name| [:keyreq, name]}.to_set raise Argon::Error.new("`after_#{event_name}(#{ expected_parameters.map{|p| "#{p}:"}.join(", ") })` not found") if !self.instance_methods.include?("after_#{event_name}".to_sym) || self.instance_method("after_#{event_name}".to_sym).parameters.to_set != expected_parameters.map{|name| [:keyreq, name]}.to_set end end raise Argon::Error.new("`parameters` should be a Hash with keys as the parameter identifier, with value as a Hash as {name: Symbol, check: lambda(object)}") if !parameters.nil? && !parameters.is_a?(Hash) && parameters.keys.map(&:class).to_set != [Symbol].to_set if !parameters.nil? parameters.each_pair do |param_name, param_details| raise Argon::Error.new("`parameters.#{param_name}` should be a Hash with keys as the parameter identifier, with value as a Hash as {name: Symbol, check: lambda(object)}") if param_details.keys.to_set != %i(name check).to_set raise Argon::Error.new("`parameters.#{param_name}.name` should be a Symbol") unless param_details[:name].is_a?(Symbol) raise Argon::Error.new("`parameters.#{param_name}.check` should be a lambda that takes one arg") if !param_details[:check].is_a?(Proc) || !(param_details[:check].parameters.length == 1 && param_details[:check].parameters[0].length == 2 && param_details[:check].parameters[0][0] == :req && param_details[:check].parameters[0][1].is_a?(Symbol)) end end raise Argon::Error.new("`edges` should be an Array of Hashes, with keys: from, to, action, callbacks{on: true/false, after: true/false}, on_events (optional), parameters (optional)") if !edges_list.is_a?(Array) || edges_list.map(&:class).to_set != [Hash].to_set edges_list.each_with_index do |edge_details, index| from = edge_details[:from] to = edge_details[:to] action = edge_details[:action] do_action = "#{action}!".to_sym check_action = "can_#{action}?".to_sym action_parameters = edge_details[:parameters] action_parameter_names = (action_parameters.nil? || parameters.nil?) ? [] : parameters.values_at(*action_parameters).compact.map{|p| p[:name]} raise Argon::Error.new("`edges` should be an Array of Hashes, with keys: from, to, action, callbacks{on: true/false, after: true/false}, on_events (optional), parameters (optional)") unless edge_details.keys.to_set.subset?([:from, :to, :action, :callbacks, :on_events, :parameters].to_set) && [:from, :to, :action, :callbacks].to_set.subset?(edge_details.keys.to_set) raise Argon::Error.new("`edges[#{index}].from` is not a valid state") unless states_map.keys.include?(from) raise Argon::Error.new("`edges[#{index}].to` is not a valid state") unless states_map.keys.include?(to) raise Argon::Error.new("`edges[#{index}].action` is not a Symbol") unless action.is_a?(Symbol) raise Argon::Error.new("`#{edge_details[:action]}` is an invalid action name. `#{self.name}##{do_action}` method already exists") if self.instance_methods.include?(do_action) raise Argon::Error.new("`#{edge_details[:action]}` is an invalid action name. `#{self.name}##{check_action}` method already exists") if self.instance_methods.include?(check_action) raise Argon::Error.new("`edges[#{index}].callbacks` must be {on: true/false, after: true/false}") if !edge_details[:callbacks].is_a?(Hash) || edge_details[:callbacks].keys.to_set != [:after, :on].to_set || !edge_details[:callbacks].values.to_set.subset?([true, false].to_set) if edge_details[:callbacks][:on] raise Argon::Error.new("`on_#{edge_details[:action]}(#{ action_parameter_names.map{|p| "#{p}:"}.join(", ") })` not found") if !self.instance_methods.include?("on_#{edge_details[:action]}".to_sym) || self.instance_method("on_#{edge_details[:action]}".to_sym).parameters.to_set != action_parameter_names.map{|name| [:keyreq, name]}.to_set end if edge_details[:callbacks][:after] raise Argon::Error.new("`after_#{edge_details[:action]}(#{ action_parameter_names.map{|p| "#{p}:"}.join(",") })` not found") if !self.instance_methods.include?("after_#{edge_details[:action]}".to_sym) || self.instance_method("after_#{edge_details[:action]}".to_sym).parameters.to_set != action_parameter_names.map{|name| [:keyreq, name]}.to_set end raise Argon::Error.new("`#{edge_details[:on_events]}` (`edges[#{index}].on_events`) is not a valid list of events") if !edge_details[:on_events].nil? && !edge_details[:on_events].is_a?(Array) unless edge_details[:on_events].nil? edge_details[:on_events].each_with_index do |event_name, event_index| raise Argon::Error.new("`#{ event_name }` (`edges[#{index}].on_events[#{event_index}]`) is not a registered event") unless events_list.include?(event_name) end end raise Argon::Error.new("`edges[#{index}].parameters` lists multiple parameters with the same name") if action_parameter_names.length != action_parameter_names.uniq.length unless edge_details[:parameters].nil? edge_details[:parameters].each_with_index do |param_name, param_index| raise Argon::Error.new("`#{ param_name }` (`edges[#{index}].parameters[#{param_index}]`) is not a registered parameter") unless parameters.keys.include?(param_name) end end end raise Argon::Error.new("`on_successful_transition` must be a lambda of signature `(record:, from:, to:)`") if !on_successful_transition.nil? && !on_successful_transition.is_a?(Proc) raise Argon::Error.new("`on_successful_transition` must be a lambda of signature `(record:, from:, to:)`") if on_successful_transition.parameters.to_set != [[:keyreq, :record],[:keyreq, :from],[:keyreq, :to]].to_set raise Argon::Error.new("`on_failed_transition` must be a lambda of signature `(record:, from:, to:)`") if !on_failed_transition.nil? && !on_failed_transition.is_a?(Proc) raise Argon::Error.new("`on_failed_transition` must be a lambda of signature `(record:, from:, to:)`") if on_failed_transition.parameters.to_set != [[:keyreq, :record],[:keyreq, :from],[:keyreq, :to]].to_set events_list.each do |event_name| end state_machines = {} begin state_machines = self.class_variable_get(:@@state_machines) rescue NameError end state_machines ||= {} state_machines = state_machines.merge(mapping) self.class_variable_set(:@@state_machines, state_machines) class << self attr_accessor :state_machines end define_singleton_method(:state_machines) do self.class_variable_get(:@@state_machines || {}) end symbolic_enum field => states_map, disable_setters: true edges_list.each do |edge_details| from = edge_details[:from] to = edge_details[:to] action = edge_details[:action] action_parameters = edge_details[:parameters] || [] on_lock_callback = "on_#{action}".to_sym if edge_details[:callbacks][:on] == true after_lock_callback = "after_#{action}".to_sym if edge_details[:callbacks][:after] == true define_method("can_#{action}?".to_sym) do self.send(field) == from end define_method("#{action}!".to_sym) do |**args, &block| required_keywords = !parameters.nil? ? parameters.values_at(*action_parameters).map{|p| p[:name]} : [] available_keywords = args.keys raise ArgumentError.new("wrong number of arguments (given 1, expected 0)") if required_keywords.to_set.empty? && !available_keywords.to_set.empty? raise ArgumentError.new("missing #{ pluralize("keyword", (required_keywords - available_keywords).length) }: #{ (required_keywords - available_keywords).join(", ") }") if !required_keywords.to_set.subset?(available_keywords.to_set) raise ArgumentError.new("unknown #{ pluralize("keyword", (available_keywords - required_keywords).length) }: #{ (available_keywords - required_keywords).join(", ") }") if !available_keywords.to_set.subset?(required_keywords.to_set) if !parameters.nil? && !action_parameters.empty? parameters.select{ |k,v| action_parameters.include?(k) }.each_pair do |param_name, param_details| raise Argon::InvalidParameterError.new("incorrect value for `#{ param_details[:name] }`") if !param_details[:check].call(args[param_details[:name]]) end end if self.send(field) != from on_failed_transition.call(record: self, from: self.send(field), to: to) raise Argon::InvalidTransitionError.new("Invalid state transition") end begin self.with_lock do self.update_column(field, self.class.send("#{ field.to_s.pluralize }").map{|v| [v[0],v[1]]}.to_h[to]) self.touch unless on_lock_callback.nil? if args.empty? self.send(on_lock_callback) else self.send(on_lock_callback, args) end end unless block.nil? block.call end end rescue => e on_failed_transition.call(record: self, from: self.send(field), to: to) raise e end on_successful_transition.call(record: self, from: from, to: to) unless after_lock_callback.nil? if args.empty? self.send(after_lock_callback) else self.send(after_lock_callback, args) end end end end events_list.each do |event_name| define_method("#{event_name}!".to_sym) do |**args| matching_edges = edges_list.select{ |edge| !edge[:on_events].nil? && edge[:on_events].to_set.include?(event_name) } matching_edges.each do |edge| action = edge[:action] if self.send("can_#{ action }?") if args.empty? self.send("#{ action }!") do self.send("on_#{ event_name }", {action: action}) end else self.send("#{ action }!", args) do self.send("on_#{ event_name }", {action: action}.merge(args)) end end self.send("after_#{ event_name }", {action: action}.merge(args)) return end end raise Argon::InvalidTransitionError.new("No valid transitions") end end end end end