Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cleaner ActiveRecordQueries mixin #358

Merged
merged 3 commits into from
Sep 3, 2019
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
59 changes: 22 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,22 +76,16 @@ Then, link it to your model:

```ruby
class Order < ActiveRecord::Base
include Statesman::Adapters::ActiveRecordQueries

has_many :order_transitions, autosave: false

include Statesman::Adapters::ActiveRecordQueries[
transition_class: OrderTransition,
initial_state: :pending
]

def state_machine
@state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
end

def self.transition_class
OrderTransition
end

def self.initial_state
:pending
end
private_class_method :initial_state
end
```

Expand Down Expand Up @@ -328,43 +322,34 @@ callback code throws an exception, it will not be caught.)

A mixin is provided for the ActiveRecord adapter which adds scopes to easily
find all models currently in (or not in) a given state. Include it into your
model and define `transition_class` and `initial_state` class methods:
model and passing in `transition_class` and `initial_state` as options.

In 4.1.1 and below, these two options had to be defined as methods on the model,
but 4.2.0 and above allow this style of configuration as well. The old method
pollutes the model with extra class methods, and is deprecated, to be removed
in 5.0.0.

```ruby
class Order < ActiveRecord::Base
include Statesman::Adapters::ActiveRecordQueries

def self.transition_class
OrderTransition
end
private_class_method :transition_class

def self.initial_state
OrderStateMachine.initial_state
end
private_class_method :initial_state
has_many :order_transitions, autosave: false
include Statesman::Adapters::ActiveRecordQueries[
transition_class: OrderTransition,
initial_state: OrderStateMachine.initial_state
]
end
```

If the transition class-name differs from the association name, you will also
need to define a corresponding `transition_name` class method:
need to pass `transition_name` as an option:

```ruby
class Order < ActiveRecord::Base
has_many :transitions, class_name: "OrderTransition", autosave: false

def self.transition_name
:transitions
end

def self.transition_class
OrderTransition
end

def self.initial_state
OrderStateMachine.initial_state
end
private_class_method :initial_state
include Statesman::Adapters::ActiveRecordQueries[
transition_class: OrderTransition,
initial_state: OrderStateMachine.initial_state,
transition_name: :transitions
]
end
```

Expand Down
130 changes: 96 additions & 34 deletions lib/statesman/adapters/active_record_queries.rb
Original file line number Diff line number Diff line change
@@ -1,51 +1,122 @@
module Statesman
module Adapters
module ActiveRecordQueries
def self.check_missing_methods!(base)
missing_methods = %i[transition_class initial_state].
reject { |_method| base.respond_to?(:method) }
return if missing_methods.none?

raise NotImplementedError,
"#{missing_methods.join(', ')} method(s) should be defined on " \
"the model. Alternatively, use the new form of `extend " \
"Statesman::Adapters::ActiveRecordQueries[" \
"transition_class: MyTransition, " \
"initial_state: :some_state]`"
end

def self.included(base)
base.extend(ClassMethods)
check_missing_methods!(base)

base.include(
ClassMethods.new(
transition_class: base.transition_class,
initial_state: base.initial_state,
most_recent_transition_alias: base.try(:most_recent_transition_alias),
transition_name: base.try(:transition_name),
),
)
end

module ClassMethods
def in_state(*states)
states = states.flatten.map(&:to_s)
def self.[](**args)
ClassMethods.new(**args)
end

class ClassMethods < Module
def initialize(**args)
@args = args
end

def included(base)
ensure_inheritance(base)

query_builder = QueryBuilder.new(base, **@args)

base.define_singleton_method(:most_recent_transition_join) do
query_builder.most_recent_transition_join
end

define_in_state(base, query_builder)
define_not_in_state(base, query_builder)
end

joins(most_recent_transition_join).
where(states_where(most_recent_transition_alias, states), states)
private

def ensure_inheritance(base)
klass = self
existing_inherited = base.method(:inherited)
base.define_singleton_method(:inherited) do |subclass|
existing_inherited.call(subclass)
subclass.send(:include, klass)
end
end

def not_in_state(*states)
states = states.flatten.map(&:to_s)
def define_in_state(base, query_builder)
base.define_singleton_method(:in_state) do |*states|
states = states.flatten.map(&:to_s)

joins(most_recent_transition_join).
where("NOT (#{states_where(most_recent_transition_alias, states)})",
states)
joins(most_recent_transition_join).
where(query_builder.states_where(states), states)
end
end

def define_not_in_state(base, query_builder)
base.define_singleton_method(:not_in_state) do |*states|
states = states.flatten.map(&:to_s)

joins(most_recent_transition_join).
where("NOT (#{query_builder.states_where(states)})", states)
end
end
end

class QueryBuilder
def initialize(model, transition_class:, initial_state:,
most_recent_transition_alias: nil,
transition_name: nil)
@model = model
@transition_class = transition_class
@initial_state = initial_state
@most_recent_transition_alias = most_recent_transition_alias
@transition_name = transition_name
end

def states_where(states)
if initial_state.to_s.in?(states.map(&:to_s))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can remove .map(&:to_s) here since you know the define_ methods above have already done that.

"#{most_recent_transition_alias}.to_state IN (?) OR " \
"#{most_recent_transition_alias}.to_state IS NULL"
else
"#{most_recent_transition_alias}.to_state IN (?) AND " \
"#{most_recent_transition_alias}.to_state IS NOT NULL"
end
end

def most_recent_transition_join
"LEFT OUTER JOIN #{model_table} AS #{most_recent_transition_alias}
ON #{table_name}.id =
ON #{model.table_name}.id =
#{most_recent_transition_alias}.#{model_foreign_key}
AND #{most_recent_transition_alias}.most_recent = #{db_true}"
end

private

def transition_class
raise NotImplementedError, "A transition_class method should be " \
"defined on the model"
end

def initial_state
raise NotImplementedError, "An initial_state method should be " \
"defined on the model"
end
attr_reader :model, :transition_class, :initial_state

def transition_name
transition_class.table_name.to_sym
@transition_name || transition_class.table_name.to_sym
end

def transition_reflection
reflect_on_all_associations(:has_many).each do |value|
model.reflect_on_all_associations(:has_many).each do |value|
return value if value.klass == transition_class
end

Expand All @@ -62,18 +133,9 @@ def model_table
transition_reflection.table_name
end

def states_where(temporary_table_name, states)
if initial_state.to_s.in?(states.map(&:to_s))
"#{temporary_table_name}.to_state IN (?) OR " \
"#{temporary_table_name}.to_state IS NULL"
else
"#{temporary_table_name}.to_state IN (?) AND " \
"#{temporary_table_name}.to_state IS NOT NULL"
end
end

def most_recent_transition_alias
"most_recent_#{transition_name.to_s.singularize}"
@most_recent_transition_alias ||
"most_recent_#{transition_name.to_s.singularize}"
end

def db_true
Expand Down
Loading