declarative_authorization-dta 0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. data/CHANGELOG +148 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.rdoc +504 -0
  4. data/Rakefile +35 -0
  5. data/app/controllers/authorization_rules_controller.rb +259 -0
  6. data/app/controllers/authorization_usages_controller.rb +23 -0
  7. data/app/helpers/authorization_rules_helper.rb +218 -0
  8. data/app/views/authorization_rules/_change.erb +58 -0
  9. data/app/views/authorization_rules/_show_graph.erb +37 -0
  10. data/app/views/authorization_rules/_suggestions.erb +48 -0
  11. data/app/views/authorization_rules/change.html.erb +169 -0
  12. data/app/views/authorization_rules/graph.dot.erb +68 -0
  13. data/app/views/authorization_rules/graph.html.erb +40 -0
  14. data/app/views/authorization_rules/index.html.erb +17 -0
  15. data/app/views/authorization_usages/index.html.erb +36 -0
  16. data/authorization_rules.dist.rb +20 -0
  17. data/config/routes.rb +10 -0
  18. data/garlic_example.rb +20 -0
  19. data/init.rb +5 -0
  20. data/lib/declarative_authorization.rb +17 -0
  21. data/lib/declarative_authorization/authorization.rb +687 -0
  22. data/lib/declarative_authorization/development_support/analyzer.rb +252 -0
  23. data/lib/declarative_authorization/development_support/change_analyzer.rb +253 -0
  24. data/lib/declarative_authorization/development_support/change_supporter.rb +620 -0
  25. data/lib/declarative_authorization/development_support/development_support.rb +243 -0
  26. data/lib/declarative_authorization/helper.rb +60 -0
  27. data/lib/declarative_authorization/in_controller.rb +623 -0
  28. data/lib/declarative_authorization/in_model.new.rb +298 -0
  29. data/lib/declarative_authorization/in_model.rb +463 -0
  30. data/lib/declarative_authorization/maintenance.rb +212 -0
  31. data/lib/declarative_authorization/obligation_scope.rb +354 -0
  32. data/lib/declarative_authorization/rails_legacy.rb +22 -0
  33. data/lib/declarative_authorization/railsengine.rb +6 -0
  34. data/lib/declarative_authorization/reader.rb +521 -0
  35. data/lib/tasks/authorization_tasks.rake +82 -0
  36. data/test/authorization_test.rb +1065 -0
  37. data/test/controller_filter_resource_access_test.rb +511 -0
  38. data/test/controller_test.rb +465 -0
  39. data/test/dsl_reader_test.rb +178 -0
  40. data/test/helper_test.rb +172 -0
  41. data/test/maintenance_test.rb +46 -0
  42. data/test/model_test.rb +2216 -0
  43. data/test/schema.sql +62 -0
  44. data/test/test_helper.rb +152 -0
  45. metadata +108 -0
@@ -0,0 +1,212 @@
1
+ # Authorization::Maintenance
2
+ require File.dirname(__FILE__) + '/authorization.rb'
3
+
4
+ module Authorization
5
+ # Provides a few maintenance methods for modifying data without enforcing
6
+ # authorization.
7
+ module Maintenance
8
+ # Disables access control for the given block. Appropriate for
9
+ # maintenance operation at the Rails console or in test case setup.
10
+ #
11
+ # For use in the Rails console:
12
+ # require "vendor/plugins/declarative_authorization/lib/maintenance"
13
+ # include Authorization::Maintenance
14
+ #
15
+ # without_access_control do
16
+ # SomeModel.find(:first).save
17
+ # end
18
+ def without_access_control (&block)
19
+ Authorization::Maintenance.without_access_control(&block)
20
+ end
21
+
22
+ # A class method variant of without_access_control. Thus, one can call
23
+ # Authorization::Maintenance::without_access_control do
24
+ # ...
25
+ # end
26
+ def self.without_access_control
27
+ previous_state = Authorization.ignore_access_control
28
+ begin
29
+ Authorization.ignore_access_control(true)
30
+ yield
31
+ ensure
32
+ Authorization.ignore_access_control(previous_state)
33
+ end
34
+ end
35
+
36
+ # Sets the current user for the declarative authorization plugin to the
37
+ # given one for the execution of the supplied block. Suitable for tests
38
+ # on certain users.
39
+ def with_user (user, &block)
40
+ Authorization::Maintenance.with_user(user, &block)
41
+ end
42
+
43
+ def self.with_user (user)
44
+ prev_user = Authorization.current_user
45
+ Authorization.current_user = user
46
+ yield
47
+ ensure
48
+ Authorization.current_user = prev_user
49
+ end
50
+
51
+ # Module for grouping usage-related helper methods
52
+ module Usage
53
+ # Delivers a hash of {ControllerClass => usage_info_hash},
54
+ # where usage_info_hash has the form of
55
+ def self.usages_by_controller
56
+ # load each application controller
57
+ begin
58
+ Dir.foreach(File.join(::Rails.root, %w{app controllers})) do |entry|
59
+ if entry =~ /^\w+_controller\.rb$/
60
+ require File.join(::Rails.root, %w{app controllers}, entry)
61
+ end
62
+ end
63
+ rescue Errno::ENOENT
64
+ end
65
+ controllers = []
66
+ ObjectSpace.each_object(Class) do |obj|
67
+ controllers << obj if obj.ancestors.include?(ActionController::Base) and
68
+ !%w{ActionController::Base ApplicationController}.include?(obj.name)
69
+ end
70
+
71
+ controllers.inject({}) do |memo, controller|
72
+ catchall_permissions = []
73
+ permission_by_action = {}
74
+ controller.all_filter_access_permissions.each do |controller_permissions|
75
+ catchall_permissions << controller_permissions if controller_permissions.actions.include?(:all)
76
+ controller_permissions.actions.reject {|action| action == :all}.each do |action|
77
+ permission_by_action[action] = controller_permissions
78
+ end
79
+ end
80
+
81
+ actions = controller.public_instance_methods(false) - controller.hidden_actions.to_a
82
+ memo[controller] = actions.inject({}) do |actions_memo, action|
83
+ action_sym = action.to_sym
84
+ actions_memo[action_sym] =
85
+ if permission_by_action[action_sym]
86
+ {
87
+ :privilege => permission_by_action[action_sym].privilege,
88
+ :context => permission_by_action[action_sym].context,
89
+ :controller_permissions => [permission_by_action[action_sym]]
90
+ }
91
+ elsif !catchall_permissions.empty?
92
+ {
93
+ :privilege => catchall_permissions[0].privilege,
94
+ :context => catchall_permissions[0].context,
95
+ :controller_permissions => catchall_permissions
96
+ }
97
+ else
98
+ {}
99
+ end
100
+ actions_memo
101
+ end
102
+ memo
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ # TestHelper provides assert methods and controller request methods which
109
+ # take authorization into account and set the current user to a specific
110
+ # one.
111
+ #
112
+ # Defines get_with, post_with, get_by_xhr_with etc. for methods
113
+ # get, post, put, delete each with the signature
114
+ # get_with(user, action, params = {}, session = {}, flash = {})
115
+ #
116
+ # Use it by including it in your TestHelper:
117
+ # require File.expand_path(File.dirname(__FILE__) +
118
+ # "/../vendor/plugins/declarative_authorization/lib/maintenance")
119
+ # class Test::Unit::TestCase
120
+ # include Authorization::TestHelper
121
+ # ...
122
+ #
123
+ # def admin
124
+ # # create admin user
125
+ # end
126
+ # end
127
+ #
128
+ # class SomeControllerTest < ActionController::TestCase
129
+ # def test_should_get_index
130
+ # ...
131
+ # get_with admin, :index, :param_1 => "param value"
132
+ # ...
133
+ # end
134
+ # end
135
+ #
136
+ # Note: get_with etc. do two things to set the user for the request:
137
+ # Authorization.current_user is set and session[:user], session[:user_id]
138
+ # are set appropriately. If you determine the current user in a different
139
+ # way, these methods might not work for you.
140
+ module TestHelper
141
+ include Authorization::Maintenance
142
+
143
+ # Analogue to the Ruby's assert_raise method, only executing the block
144
+ # in the context of the given user.
145
+ def assert_raise_with_user (user, *args, &block)
146
+ assert_raise(*args) do
147
+ with_user(user, &block)
148
+ end
149
+ end
150
+
151
+ # Test helper to test authorization rules.
152
+ # with_user a_normal_user do
153
+ # should_not_be_allowed_to :update, :conferences
154
+ # should_not_be_allowed_to :read, an_unpublished_conference
155
+ # should_be_allowed_to :read, a_published_conference
156
+ # end
157
+ #
158
+ # If the objects class name does not match the controller name, you can set the object and context manually
159
+ # should_be_allowed_to :create, :object => car, :context => :vehicles
160
+ #
161
+ # If you use specify the object and context manually, you can also specify the user manually, skipping the with_user block:
162
+ # should_be_allowed_to :create, :object => car, :context => :vehicles, :user => a_normal_user
163
+ def should_be_allowed_to (privilege, *args)
164
+ options = {}
165
+ if(args.first.class == Hash)
166
+ options = args.extract_options!
167
+ else
168
+ options[args[0].is_a?(Symbol) ? :context : :object] = args[0]
169
+ end
170
+ assert_nothing_raised do
171
+ Authorization::Engine.instance.permit!(privilege, options)
172
+ end
173
+ end
174
+
175
+ # See should_be_allowed_to
176
+ def should_not_be_allowed_to (privilege, *args)
177
+ options = {}
178
+ if(args.first.class == Hash)
179
+ options = args.extract_options!
180
+ else
181
+ options[args[0].is_a?(Symbol) ? :context : :object] = args[0]
182
+ end
183
+ assert !Authorization::Engine.instance.permit?(privilege, options)
184
+ end
185
+
186
+ def request_with (user, method, xhr, action, params = {},
187
+ session = {}, flash = {})
188
+ session = session.merge({:user => user, :user_id => user && user.id})
189
+ with_user(user) do
190
+ if xhr
191
+ xhr method, action, params, session, flash
192
+ else
193
+ send method, action, params, session, flash
194
+ end
195
+ end
196
+ end
197
+
198
+ def self.included (base)
199
+ [:get, :post, :put, :delete].each do |method|
200
+ base.class_eval <<-EOV, __FILE__, __LINE__
201
+ def #{method}_with (user, *args)
202
+ request_with(user, #{method.inspect}, false, *args)
203
+ end
204
+
205
+ def #{method}_by_xhr_with (user, *args)
206
+ request_with(user, #{method.inspect}, true, *args)
207
+ end
208
+ EOV
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,354 @@
1
+ module Authorization
2
+ # The +ObligationScope+ class parses any number of obligations into joins and conditions.
3
+ #
4
+ # In +ObligationScope+ parlance, "association paths" are one-dimensional arrays in which each
5
+ # element represents an attribute or association (or "step"), and "leads" to the next step in the
6
+ # association path.
7
+ #
8
+ # Suppose we have this path defined in the context of model Foo:
9
+ # +{ :bar => { :baz => { :foo => { :attr => is { user } } } } }+
10
+ #
11
+ # To parse this path, +ObligationScope+ evaluates each step in the context of the preceding step.
12
+ # The first step is evaluated in the context of the parent scope, the second step is evaluated in
13
+ # the context of the first, and so forth. Every time we encounter a step representing an
14
+ # association, we make note of the fact by storing the path (up to that point), assigning it a
15
+ # table alias intended to match the one that will eventually be chosen by ActiveRecord when
16
+ # executing the +find+ method on the scope.
17
+ #
18
+ # +@table_aliases = {
19
+ # [] => 'foos',
20
+ # [:bar] => 'bars',
21
+ # [:bar, :baz] => 'bazzes',
22
+ # [:bar, :baz, :foo] => 'foos_bazzes' # Alias avoids collisions with 'foos' (already used)
23
+ # }+
24
+ #
25
+ # At the "end" of each path, we expect to find a comparison operation of some kind, generally
26
+ # comparing an attribute of the most recent association with some other value (such as an ID,
27
+ # constant, or array of values). When we encounter a step representing a comparison, we make
28
+ # note of the fact by storing the path (up to that point) and the comparison operation together.
29
+ # (Note that individual obligations' conditions are kept separate, to allow their conditions to
30
+ # be OR'ed together in the generated scope options.)
31
+ #
32
+ # +@obligation_conditions[<obligation>][[:bar, :baz, :foo]] = [
33
+ # [ :attr, :is, <user.id> ]
34
+ # ]+
35
+ #
36
+ # TODO update doc for Relations:
37
+ # After successfully parsing an obligation, all of the stored paths and conditions are converted
38
+ # into scope options (stored in +proxy_options+ as +:joins+ and +:conditions+). The resulting
39
+ # scope may then be used to find all scoped objects for which at least one of the parsed
40
+ # obligations is fully met.
41
+ #
42
+ # +@proxy_options[:joins] = { :bar => { :baz => :foo } }
43
+ # @proxy_options[:conditions] = [ 'foos_bazzes.attr = :foos_bazzes__id_0', { :foos_bazzes__id_0 => 1 } ]+
44
+ #
45
+ class ObligationScope < (Rails.version < "3" ? ActiveRecord::NamedScope::Scope : ActiveRecord::Relation)
46
+ def initialize (model, options)
47
+ @finder_options = {}
48
+ if Rails.version < "3"
49
+ super(model, options)
50
+ else
51
+ super(model, model.table_name)
52
+ end
53
+ end
54
+
55
+ def scope
56
+ if Rails.version < "3"
57
+ self
58
+ else
59
+ # for Rails < 3: scope, after setting proxy_options
60
+ self.klass.scoped(@finder_options)
61
+ end
62
+ end
63
+
64
+ # Consumes the given obligation, converting it into scope join and condition options.
65
+ def parse!( obligation )
66
+ @current_obligation = obligation
67
+ @join_table_joins = Set.new
68
+ obligation_conditions[@current_obligation] ||= {}
69
+ follow_path( obligation )
70
+
71
+ rebuild_condition_options!
72
+ rebuild_join_options!
73
+ end
74
+
75
+ protected
76
+
77
+ # Parses the next step in the association path. If it's an association, we advance down the
78
+ # path. Otherwise, it's an attribute, and we need to evaluate it as a comparison operation.
79
+ def follow_path( steps, past_steps = [] )
80
+ if steps.is_a?( Hash )
81
+ steps.each do |step, next_steps|
82
+ path_to_this_point = [past_steps, step].flatten
83
+ reflection = reflection_for( path_to_this_point ) rescue nil
84
+ if reflection
85
+ follow_path( next_steps, path_to_this_point )
86
+ else
87
+ follow_comparison( next_steps, past_steps, step )
88
+ end
89
+ end
90
+ elsif steps.is_a?( Array ) && steps.length == 2
91
+ if reflection_for( past_steps )
92
+ follow_comparison( steps, past_steps, :id )
93
+ else
94
+ follow_comparison( steps, past_steps[0..-2], past_steps[-1] )
95
+ end
96
+ else
97
+ raise "invalid obligation path #{[past_steps, steps].inspect}"
98
+ end
99
+ end
100
+
101
+ def top_level_model
102
+ if Rails.version < "3"
103
+ @proxy_scope
104
+ else
105
+ self.klass
106
+ end
107
+ end
108
+
109
+ def finder_options
110
+ Rails.version < "3" ? @proxy_options : @finder_options
111
+ end
112
+
113
+ # At the end of every association path, we expect to see a comparison of some kind; for
114
+ # example, +:attr => [ :is, :value ]+.
115
+ #
116
+ # This method parses the comparison and creates an obligation condition from it.
117
+ def follow_comparison( steps, past_steps, attribute )
118
+ operator = steps[0]
119
+ value = steps[1..-1]
120
+ value = value[0] if value.length == 1
121
+
122
+ add_obligation_condition_for( past_steps, [attribute, operator, value] )
123
+ end
124
+
125
+ # Adds the given expression to the current obligation's indicated path's conditions.
126
+ #
127
+ # Condition expressions must follow the format +[ <attribute>, <operator>, <value> ]+.
128
+ def add_obligation_condition_for( path, expression )
129
+ raise "invalid expression #{expression.inspect}" unless expression.is_a?( Array ) && expression.length == 3
130
+ add_obligation_join_for( path )
131
+ obligation_conditions[@current_obligation] ||= {}
132
+ ( obligation_conditions[@current_obligation][path] ||= Set.new ) << expression
133
+ end
134
+
135
+ # Adds the given path to the list of obligation joins, if we haven't seen it before.
136
+ def add_obligation_join_for( path )
137
+ map_reflection_for( path ) if reflections[path].nil?
138
+ end
139
+
140
+ # Returns the model associated with the given path.
141
+ def model_for (path)
142
+ reflection = reflection_for(path)
143
+
144
+ if reflection.respond_to?(:proxy_reflection)
145
+ reflection.proxy_reflection.klass
146
+ elsif reflection.respond_to?(:klass)
147
+ reflection.klass
148
+ else
149
+ reflection
150
+ end
151
+ end
152
+
153
+ # Returns the reflection corresponding to the given path.
154
+ def reflection_for(path, for_join_table_only = false)
155
+ @join_table_joins << path if for_join_table_only and !reflections[path]
156
+ reflections[path] ||= map_reflection_for( path )
157
+ end
158
+
159
+ # Returns a proper table alias for the given path. This alias may be used in SQL statements.
160
+ def table_alias_for( path )
161
+ table_aliases[path] ||= map_table_alias_for( path )
162
+ end
163
+
164
+ # Attempts to map a reflection for the given path. Raises if already defined.
165
+ def map_reflection_for( path )
166
+ raise "reflection for #{path.inspect} already exists" unless reflections[path].nil?
167
+
168
+ reflection = path.empty? ? top_level_model : begin
169
+ parent = reflection_for( path[0..-2] )
170
+ if !parent.respond_to?(:proxy_reflection) and parent.respond_to?(:klass)
171
+ parent.klass.reflect_on_association( path.last )
172
+ else
173
+ parent.reflect_on_association( path.last )
174
+ end
175
+ rescue
176
+ parent.reflect_on_association( path.last )
177
+ end
178
+ raise "invalid path #{path.inspect}" if reflection.nil?
179
+
180
+ reflections[path] = reflection
181
+ map_table_alias_for( path ) # Claim a table alias for the path.
182
+
183
+ # Claim alias for join table
184
+ # TODO change how this is checked
185
+ if !reflection.respond_to?(:proxy_reflection) and !reflection.respond_to?(:proxy_scope) and reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
186
+ join_table_path = path[0..-2] + [reflection.options[:through]]
187
+ reflection_for(join_table_path, true)
188
+ end
189
+
190
+ reflection
191
+ end
192
+
193
+ # Attempts to map a table alias for the given path. Raises if already defined.
194
+ def map_table_alias_for( path )
195
+ return "table alias for #{path.inspect} already exists" unless table_aliases[path].nil?
196
+
197
+ reflection = reflection_for( path )
198
+ table_alias = reflection.table_name
199
+ if table_aliases.values.include?( table_alias )
200
+ max_length = reflection.active_record.connection.table_alias_length
201
+ # Rails seems to pluralize reflection names
202
+ table_alias = "#{reflection.name.to_s.pluralize}_#{reflection.active_record.table_name}".to(max_length-1)
203
+ end
204
+ while table_aliases.values.include?( table_alias )
205
+ if table_alias =~ /\w(_\d+?)$/
206
+ table_index = $1.succ
207
+ table_alias = "#{table_alias[0..-(table_index.length+1)]}_#{table_index}"
208
+ else
209
+ table_alias = "#{table_alias[0..(max_length-3)]}_2"
210
+ end
211
+ end
212
+ table_aliases[path] = table_alias
213
+ end
214
+
215
+ # Returns a hash mapping obligations to zero or more condition path sets.
216
+ def obligation_conditions
217
+ @obligation_conditions ||= {}
218
+ end
219
+
220
+ # Returns a hash mapping paths to reflections.
221
+ def reflections
222
+ # lets try to get the order of joins right
223
+ @reflections ||= ActiveSupport::OrderedHash.new
224
+ end
225
+
226
+ # Returns a hash mapping paths to proper table aliases to use in SQL statements.
227
+ def table_aliases
228
+ @table_aliases ||= {}
229
+ end
230
+
231
+ # Parses all of the defined obligation conditions and defines the scope's :conditions option.
232
+ def rebuild_condition_options!
233
+ conds = []
234
+ binds = {}
235
+ used_paths = Set.new
236
+ delete_paths = Set.new
237
+ obligation_conditions.each_with_index do |array, obligation_index|
238
+ obligation, conditions = array
239
+ obligation_conds = []
240
+ conditions.each do |path, expressions|
241
+ model = model_for( path )
242
+ table_alias = table_alias_for(path)
243
+ parent_model = (path.length > 1 ? model_for(path[0..-2]) : top_level_model)
244
+ expressions.each do |expression|
245
+ attribute, operator, value = expression
246
+ # prevent unnecessary joins:
247
+ if attribute == :id and operator == :is and parent_model.columns_hash["#{path.last}_id"]
248
+ attribute_name = :"#{path.last}_id"
249
+ attribute_table_alias = table_alias_for(path[0..-2])
250
+ used_paths << path[0..-2]
251
+ delete_paths << path
252
+ else
253
+ attribute_name = model.columns_hash["#{attribute}_id"] && :"#{attribute}_id" ||
254
+ model.columns_hash[attribute.to_s] && attribute ||
255
+ :id
256
+ attribute_table_alias = table_alias
257
+ used_paths << path
258
+ end
259
+ bindvar = "#{attribute_table_alias}__#{attribute_name}_#{obligation_index}".to_sym
260
+
261
+ sql_attribute = "#{parent_model.connection.quote_table_name(attribute_table_alias)}." +
262
+ "#{parent_model.connection.quote_table_name(attribute_name)}"
263
+ if value.nil? and [:is, :is_not].include?(operator)
264
+ obligation_conds << "#{sql_attribute} IS #{[:contains, :is].include?(operator) ? '' : 'NOT '}NULL"
265
+ else
266
+ attribute_operator = case operator
267
+ when :contains, :is then "= :#{bindvar}"
268
+ when :does_not_contain, :is_not then "<> :#{bindvar}"
269
+ when :is_in, :intersects_with then "IN (:#{bindvar})"
270
+ when :is_not_in then "NOT IN (:#{bindvar})"
271
+ when :lt then "< :#{bindvar}"
272
+ when :lte then "<= :#{bindvar}"
273
+ when :gt then "> :#{bindvar}"
274
+ when :gte then ">= :#{bindvar}"
275
+ else raise AuthorizationUsageError, "Unknown operator: #{operator}"
276
+ end
277
+ obligation_conds << "#{sql_attribute} #{attribute_operator}"
278
+ binds[bindvar] = attribute_value(value)
279
+ end
280
+ end
281
+ end
282
+ obligation_conds << "1=1" if obligation_conds.empty?
283
+ conds << "(#{obligation_conds.join(' AND ')})"
284
+ end
285
+ (delete_paths - used_paths).each {|path| reflections.delete(path)}
286
+
287
+ finder_options[:conditions] = [ conds.join( " OR " ), binds ]
288
+ end
289
+
290
+ def attribute_value (value)
291
+ value.class.respond_to?(:descends_from_active_record?) && value.class.descends_from_active_record? && value.id ||
292
+ value.is_a?(Array) && value[0].class.respond_to?(:descends_from_active_record?) && value[0].class.descends_from_active_record? && value.map( &:id ) ||
293
+ value
294
+ end
295
+
296
+ # Parses all of the defined obligation joins and defines the scope's :joins or :includes option.
297
+ # TODO: Support non-linear association paths. Right now, we just break down the longest path parsed.
298
+ def rebuild_join_options!
299
+ joins = (finder_options[:joins] || []) + (finder_options[:includes] || [])
300
+
301
+ reflections.keys.each do |path|
302
+ next if path.empty? or @join_table_joins.include?(path)
303
+
304
+ existing_join = joins.find do |join|
305
+ existing_path = join_to_path(join)
306
+ min_length = [existing_path.length, path.length].min
307
+ existing_path.first(min_length) == path.first(min_length)
308
+ end
309
+
310
+ if existing_join
311
+ if join_to_path(existing_join).length < path.length
312
+ joins[joins.index(existing_join)] = path_to_join(path)
313
+ end
314
+ else
315
+ joins << path_to_join(path)
316
+ end
317
+ end
318
+
319
+ case obligation_conditions.length
320
+ when 0 then
321
+ # No obligation conditions means we don't have to mess with joins or includes at all.
322
+ when 1 then
323
+ finder_options[:joins] = joins
324
+ finder_options.delete( :include )
325
+ else
326
+ finder_options.delete( :joins )
327
+ finder_options[:include] = joins
328
+ end
329
+ end
330
+
331
+ def path_to_join (path)
332
+ case path.length
333
+ when 0 then nil
334
+ when 1 then path[0]
335
+ else
336
+ hash = { path[-2] => path[-1] }
337
+ path[0..-3].reverse.each do |elem|
338
+ hash = { elem => hash }
339
+ end
340
+ hash
341
+ end
342
+ end
343
+
344
+ def join_to_path (join)
345
+ case join
346
+ when Symbol
347
+ [join]
348
+ when Hash
349
+ [join.keys.first] + join_to_path(join[join.keys.first])
350
+ end
351
+ end
352
+ end
353
+ end
354
+