cancancan 2.2.0 → 3.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/cancancan.gemspec +6 -5
  3. data/init.rb +2 -0
  4. data/lib/cancan/ability/actions.rb +2 -0
  5. data/lib/cancan/ability/rules.rb +20 -9
  6. data/lib/cancan/ability/strong_parameter_support.rb +41 -0
  7. data/lib/cancan/ability.rb +54 -24
  8. data/lib/cancan/class_matcher.rb +30 -0
  9. data/lib/cancan/conditions_matcher.rb +72 -18
  10. data/lib/cancan/config.rb +101 -0
  11. data/lib/cancan/controller_additions.rb +9 -4
  12. data/lib/cancan/controller_resource.rb +7 -1
  13. data/lib/cancan/controller_resource_builder.rb +2 -0
  14. data/lib/cancan/controller_resource_finder.rb +2 -0
  15. data/lib/cancan/controller_resource_loader.rb +4 -0
  16. data/lib/cancan/controller_resource_name_finder.rb +2 -0
  17. data/lib/cancan/controller_resource_sanitizer.rb +2 -0
  18. data/lib/cancan/exceptions.rb +21 -2
  19. data/lib/cancan/matchers.rb +7 -2
  20. data/lib/cancan/model_adapters/abstract_adapter.rb +22 -1
  21. data/lib/cancan/model_adapters/active_record_4_adapter.rb +26 -25
  22. data/lib/cancan/model_adapters/active_record_5_adapter.rb +17 -26
  23. data/lib/cancan/model_adapters/active_record_adapter.rb +134 -45
  24. data/lib/cancan/model_adapters/conditions_extractor.rb +75 -0
  25. data/lib/cancan/model_adapters/conditions_normalizer.rb +49 -0
  26. data/lib/cancan/model_adapters/default_adapter.rb +2 -0
  27. data/lib/cancan/model_adapters/sti_normalizer.rb +47 -0
  28. data/lib/cancan/model_adapters/strategies/base.rb +40 -0
  29. data/lib/cancan/model_adapters/strategies/joined_alias_each_rule_as_exists_subquery.rb +93 -0
  30. data/lib/cancan/model_adapters/strategies/joined_alias_exists_subquery.rb +31 -0
  31. data/lib/cancan/model_adapters/strategies/left_join.rb +11 -0
  32. data/lib/cancan/model_adapters/strategies/subquery.rb +18 -0
  33. data/lib/cancan/model_additions.rb +6 -2
  34. data/lib/cancan/parameter_validators.rb +9 -0
  35. data/lib/cancan/relevant.rb +29 -0
  36. data/lib/cancan/rule.rb +76 -20
  37. data/lib/cancan/rules_compressor.rb +23 -0
  38. data/lib/cancan/sti_detector.rb +12 -0
  39. data/lib/cancan/unauthorized_message_resolver.rb +24 -0
  40. data/lib/cancan/version.rb +3 -1
  41. data/lib/cancan.rb +13 -0
  42. data/lib/cancancan.rb +2 -0
  43. data/lib/generators/cancan/ability/ability_generator.rb +3 -1
  44. data/lib/generators/cancan/ability/templates/ability.rb +9 -9
  45. metadata +39 -24
  46. data/lib/cancan/model_adapters/can_can/model_adapters/active_record_adapter/joins.rb +0 -39
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'controller_resource_loader.rb'
2
4
  module CanCan
3
5
  # Handle the load and authorization controller logic
@@ -34,6 +36,7 @@ module CanCan
34
36
 
35
37
  def authorize_resource
36
38
  return if skip?(:authorize)
39
+
37
40
  @controller.authorize!(authorization_action, resource_instance || resource_class_with_parent)
38
41
  end
39
42
 
@@ -43,6 +46,7 @@ module CanCan
43
46
 
44
47
  def skip?(behavior)
45
48
  return false unless (options = @controller.class.cancan_skipper[behavior][@name])
49
+
46
50
  options == {} ||
47
51
  options[:except] && !action_exists_in?(options[:except]) ||
48
52
  action_exists_in?(options[:only])
@@ -50,7 +54,7 @@ module CanCan
50
54
 
51
55
  protected
52
56
 
53
- # Returns the class used for this resource. This can be overriden by the :class option.
57
+ # Returns the class used for this resource. This can be overridden by the :class option.
54
58
  # If +false+ is passed in it will use the resource name as a symbol in which case it should
55
59
  # only be used for authorization, not loading since there's no class to load through.
56
60
  def resource_class
@@ -90,6 +94,7 @@ module CanCan
90
94
 
91
95
  def resource_instance
92
96
  return unless load_instance? && @controller.instance_variable_defined?("@#{instance_name}")
97
+
93
98
  @controller.instance_variable_get("@#{instance_name}")
94
99
  end
95
100
 
@@ -99,6 +104,7 @@ module CanCan
99
104
 
100
105
  def collection_instance
101
106
  return unless @controller.instance_variable_defined?("@#{instance_name.to_s.pluralize}")
107
+
102
108
  @controller.instance_variable_get("@#{instance_name.to_s.pluralize}")
103
109
  end
104
110
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  module ControllerResourceBuilder
3
5
  protected
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  module ControllerResourceFinder
3
5
  protected
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'controller_resource_finder.rb'
2
4
  require_relative 'controller_resource_name_finder.rb'
3
5
  require_relative 'controller_resource_builder.rb'
@@ -11,6 +13,7 @@ module CanCan
11
13
 
12
14
  def load_resource
13
15
  return if skip?(:load)
16
+
14
17
  if load_instance?
15
18
  self.resource_instance ||= load_resource_instance
16
19
  elsif load_collection?
@@ -26,6 +29,7 @@ module CanCan
26
29
 
27
30
  def resource_params_by_key(key)
28
31
  return unless @options[key] && @params.key?(extract_key(@options[key]))
32
+
29
33
  @params[extract_key(@options[key])]
30
34
  end
31
35
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  module ControllerResourceNameFinder
3
5
  protected
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  module ControllerResourceSanitizer
3
5
  protected
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  # A general CanCan exception
3
5
  class Error < StandardError; end
@@ -8,9 +10,18 @@ module CanCan
8
10
  # Raised when removed code is called, an alternative solution is provided in message.
9
11
  class ImplementationRemoved < Error; end
10
12
 
11
- # Raised when using check_authorization without calling authorized!
13
+ # Raised when using check_authorization without calling authorize!
12
14
  class AuthorizationNotPerformed < Error; end
13
15
 
16
+ # Raised when a rule is created with both a block and a hash of conditions
17
+ class BlockAndConditionsError < Error; end
18
+
19
+ # Raised when an unexpected argument is passed as an attribute
20
+ class AttributeArgumentError < Error; end
21
+
22
+ # Raised when using a wrong association name
23
+ class WrongAssociationName < Error; end
24
+
14
25
  # This error is raised when a user isn't allowed to access a given controller action.
15
26
  # This usually happens within a call to ControllerAdditions#authorize! but can be
16
27
  # raised manually.
@@ -30,7 +41,7 @@ module CanCan
30
41
  # exception.default_message = "Default error message"
31
42
  # exception.message # => "Default error message"
32
43
  #
33
- # See ControllerAdditions#authorized! for more information on rescuing from this exception
44
+ # See ControllerAdditions#authorize! for more information on rescuing from this exception
34
45
  # and customizing the message using I18n.
35
46
  class AccessDenied < Error
36
47
  attr_reader :action, :subject, :conditions
@@ -47,5 +58,13 @@ module CanCan
47
58
  def to_s
48
59
  @message || @default_message
49
60
  end
61
+
62
+ def inspect
63
+ details = %i[action subject conditions message].map do |attribute|
64
+ value = instance_variable_get "@#{attribute}"
65
+ "#{attribute}: #{value.inspect}" if value.present?
66
+ end.compact.join(', ')
67
+ "#<#{self.class.name} #{details}>"
68
+ end
50
69
  end
51
70
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  rspec_module = defined?(RSpec::Core) ? 'RSpec' : 'Spec' # RSpec 1 compatability
2
4
 
3
5
  if rspec_module == 'RSpec'
@@ -11,8 +13,11 @@ Kernel.const_get(rspec_module)::Matchers.define :be_able_to do |*args|
11
13
  match do |ability|
12
14
  actions = args.first
13
15
  if actions.is_a? Array
14
- break false if actions.empty?
15
- actions.all? { |action| ability.can?(action, *args[1..-1]) }
16
+ if actions.empty?
17
+ false
18
+ else
19
+ actions.all? { |action| ability.can?(action, *args[1..-1]) }
20
+ end
16
21
  else
17
22
  ability.can?(*args)
18
23
  end
@@ -1,9 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  module ModelAdapters
3
5
  class AbstractAdapter
6
+ attr_reader :model_class
7
+
4
8
  def self.inherited(subclass)
5
9
  @subclasses ||= []
6
- @subclasses << subclass
10
+ @subclasses.insert(0, subclass)
7
11
  end
8
12
 
9
13
  def self.adapter_class(model_class)
@@ -31,6 +35,23 @@ module CanCan
31
35
  raise NotImplemented, 'This model adapter does not support matching on a conditions hash.'
32
36
  end
33
37
 
38
+ # Override if parent condition could be under a different key in conditions
39
+ def self.parent_condition_name(parent, _child)
40
+ parent.class.name.downcase.to_sym
41
+ end
42
+
43
+ # Used above override_conditions_hash_matching to determine if this model adapter will override the
44
+ # matching behavior for nested subject.
45
+ # If this returns true then nested_subject_matches_conditions? will be called.
46
+ def self.override_nested_subject_conditions_matching?(_parent, _child, _all_conditions)
47
+ false
48
+ end
49
+
50
+ # Override if override_nested_subject_conditions_matching? returns true
51
+ def self.nested_subject_matches_conditions?(_parent, _child, _all_conditions)
52
+ raise NotImplemented, 'This model adapter does not support matching on a nested subject.'
53
+ end
54
+
34
55
  # Used to determine if this model adapter will override the matching behavior for a specific condition.
35
56
  # If this returns true then matches_condition? will be called. See Rule#matches_conditions_hash
36
57
  def self.override_condition_matching?(_subject, _name, _value)
@@ -1,27 +1,30 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  module ModelAdapters
3
- class ActiveRecord4Adapter < AbstractAdapter
4
- include ActiveRecordAdapter
5
- def self.for_class?(model_class)
6
- ActiveRecord::VERSION::MAJOR == 4 && model_class <= ActiveRecord::Base
7
- end
5
+ class ActiveRecord4Adapter < ActiveRecordAdapter
6
+ AbstractAdapter.inherited(self)
8
7
 
9
- # TODO: this should be private
10
- def self.override_condition_matching?(subject, name, _value)
11
- subject.class.defined_enums.include?(name.to_s)
12
- end
8
+ class << self
9
+ def for_class?(model_class)
10
+ version_lower?('5.0.0') && model_class <= ActiveRecord::Base
11
+ end
13
12
 
14
- # TODO: this should be private
15
- def self.matches_condition?(subject, name, value)
16
- # Get the mapping from enum strings to values.
17
- enum = subject.class.send(name.to_s.pluralize)
18
- # Get the value of the attribute as an integer.
19
- attribute = enum[subject.send(name)]
20
- # Check to see if the value matches the condition.
21
- if value.is_a?(Enumerable)
22
- value.include? attribute
23
- else
24
- attribute == value
13
+ def override_condition_matching?(subject, name, _value)
14
+ subject.class.defined_enums.include?(name.to_s)
15
+ end
16
+
17
+ def matches_condition?(subject, name, value)
18
+ # Get the mapping from enum strings to values.
19
+ enum = subject.class.send(name.to_s.pluralize)
20
+ # Get the value of the attribute as an integer.
21
+ attribute = enum[subject.send(name)]
22
+ # Check to see if the value matches the condition.
23
+ if value.is_a?(Enumerable)
24
+ value.include? attribute
25
+ else
26
+ attribute == value
27
+ end
25
28
  end
26
29
  end
27
30
 
@@ -31,15 +34,13 @@ module CanCan
31
34
  # look inside the where clause to decide to outer join tables
32
35
  # you're using in the where. Instead, `references()` is required
33
36
  # in addition to `includes()` to force the outer join.
34
- def build_relation(*where_conditions)
35
- relation = @model_class.where(*where_conditions)
36
- relation = relation.includes(joins).references(joins) if joins.present?
37
- relation
37
+ def build_joins_relation(relation, *_where_conditions)
38
+ relation.includes(joins).references(joins)
38
39
  end
39
40
 
40
41
  # Rails 4.2 deprecates `sanitize_sql_hash_for_conditions`
41
42
  def sanitize_sql(conditions)
42
- if ActiveRecord::VERSION::MINOR >= 2 && conditions.is_a?(Hash)
43
+ if self.class.version_greater_or_equal?('4.2.0') && conditions.is_a?(Hash)
43
44
  sanitize_sql_activerecord4(conditions)
44
45
  else
45
46
  @model_class.send(:sanitize_sql, conditions)
@@ -1,10 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanCan
2
4
  module ModelAdapters
3
5
  class ActiveRecord5Adapter < ActiveRecord4Adapter
4
6
  AbstractAdapter.inherited(self)
5
7
 
6
8
  def self.for_class?(model_class)
7
- ActiveRecord::VERSION::MAJOR == 5 && model_class <= ActiveRecord::Base
9
+ version_greater_or_equal?('5.0.0') && model_class <= ActiveRecord::Base
8
10
  end
9
11
 
10
12
  # rails 5 is capable of using strings in enum
@@ -13,26 +15,21 @@ module CanCan
13
15
  return super if Array.wrap(value).all? { |x| x.is_a? Integer }
14
16
 
15
17
  attribute = subject.send(name)
16
- if value.is_a?(Enumerable)
17
- value.map(&:to_s).include? attribute
18
- else
19
- attribute == value.to_s
20
- end
18
+ raw_attribute = subject.class.send(name.to_s.pluralize)[attribute]
19
+ !(Array(value).map(&:to_s) & [attribute, raw_attribute]).empty?
21
20
  end
22
21
 
23
22
  private
24
23
 
25
- # As of rails 4, `includes()` no longer causes active record to
26
- # look inside the where clause to decide to outer join tables
27
- # you're using in the where. Instead, `references()` is required
28
- # in addition to `includes()` to force the outer join.
29
- def build_relation(*where_conditions)
30
- relation = @model_class.where(*where_conditions)
31
- relation = relation.includes(joins).references(joins) if joins.present?
32
- relation
24
+ def build_joins_relation(relation, *where_conditions)
25
+ strategy_class.new(adapter: self, relation: relation, where_conditions: where_conditions).execute!
26
+ end
27
+
28
+ def strategy_class
29
+ strategy_class_name = CanCan.accessible_by_strategy.to_s.camelize
30
+ CanCan::ModelAdapters::Strategies.const_get(strategy_class_name)
33
31
  end
34
32
 
35
- # Rails 4.2 deprecates `sanitize_sql_hash_for_conditions`
36
33
  def sanitize_sql(conditions)
37
34
  if conditions.is_a?(Hash)
38
35
  sanitize_sql_activerecord5(conditions)
@@ -46,23 +43,17 @@ module CanCan
46
43
  table_metadata = ActiveRecord::TableMetadata.new(@model_class, table)
47
44
  predicate_builder = ActiveRecord::PredicateBuilder.new(table_metadata)
48
45
 
49
- conditions = predicate_builder.resolve_column_aliases(conditions)
50
-
51
- conditions.stringify_keys!
52
-
53
- predicate_builder.build_from_hash(conditions).map do |b|
54
- visit_nodes(b)
55
- end.join(' AND ')
46
+ predicate_builder.build_from_hash(conditions.stringify_keys).map { |b| visit_nodes(b) }.join(' AND ')
56
47
  end
57
48
 
58
- def visit_nodes(b)
49
+ def visit_nodes(node)
59
50
  # Rails 5.2 adds a BindParam node that prevents the visitor method from properly compiling the SQL query
60
- if ActiveRecord::VERSION::MINOR >= 2
51
+ if self.class.version_greater_or_equal?('5.2.0')
61
52
  connection = @model_class.send(:connection)
62
53
  collector = Arel::Collectors::SubstituteBinds.new(connection, Arel::Collectors::SQLString.new)
63
- connection.visitor.accept(b, collector).value
54
+ connection.visitor.accept(node, collector).value
64
55
  else
65
- @model_class.send(:connection).visitor.compile(b)
56
+ @model_class.send(:connection).visitor.compile(node)
66
57
  end
67
58
  end
68
59
  end
@@ -1,8 +1,95 @@
1
- require_relative 'can_can/model_adapters/active_record_adapter/joins.rb'
1
+ # frozen_string_literal: true
2
+
2
3
  module CanCan
3
4
  module ModelAdapters
4
- module ActiveRecordAdapter
5
- include CanCan::ModelAdapters::ActiveRecordAdapter::Joins
5
+ class ActiveRecordAdapter < AbstractAdapter
6
+ def self.version_greater_or_equal?(version)
7
+ Gem::Version.new(ActiveRecord.version).release >= Gem::Version.new(version)
8
+ end
9
+
10
+ def self.version_lower?(version)
11
+ Gem::Version.new(ActiveRecord.version).release < Gem::Version.new(version)
12
+ end
13
+
14
+ attr_reader :compressed_rules
15
+
16
+ def initialize(model_class, rules)
17
+ super
18
+ @compressed_rules = if CanCan.rules_compressor_enabled
19
+ RulesCompressor.new(@rules.reverse).rules_collapsed.reverse
20
+ else
21
+ @rules
22
+ end
23
+ StiNormalizer.normalize(@compressed_rules)
24
+ ConditionsNormalizer.normalize(model_class, @compressed_rules)
25
+ end
26
+
27
+ class << self
28
+ # When belongs_to parent_id is a condition for a model,
29
+ # we want to check the parent when testing ability for a hash {parent => model}
30
+ def override_nested_subject_conditions_matching?(parent, child, all_conditions)
31
+ parent_child_conditions(parent, child, all_conditions).present?
32
+ end
33
+
34
+ # parent_id condition can be an array of integer or one integer, we check the parent against this
35
+ def nested_subject_matches_conditions?(parent, child, all_conditions)
36
+ id_condition = parent_child_conditions(parent, child, all_conditions)
37
+ return id_condition.include?(parent.id) if id_condition.is_a? Array
38
+ return id_condition == parent.id if id_condition.is_a? Integer
39
+
40
+ false
41
+ end
42
+
43
+ def parent_child_conditions(parent, child, all_conditions)
44
+ child_class = child.is_a?(Class) ? child : child.class
45
+ parent_class = parent.is_a?(Class) ? parent : parent.class
46
+
47
+ foreign_key = child_class.reflect_on_all_associations(:belongs_to).find do |association|
48
+ # Do not match on polymorphic associations or it will throw an error (klass cannot be determined)
49
+ !association.polymorphic? && association.klass == parent.class
50
+ end&.foreign_key&.to_sym
51
+
52
+ # Search again in case of polymorphic associations, this time matching on the :has_many side
53
+ # via the :as option, as well as klass
54
+ foreign_key ||= parent_class.reflect_on_all_associations(:has_many).find do |has_many_assoc|
55
+ !matching_parent_child_polymorphic_association(has_many_assoc, child_class).nil?
56
+ end&.foreign_key&.to_sym
57
+
58
+ foreign_key.nil? ? nil : all_conditions[foreign_key]
59
+ end
60
+
61
+ def matching_parent_child_polymorphic_association(parent_assoc, child_class)
62
+ return nil unless parent_assoc.klass == child_class
63
+ return nil if parent_assoc&.options[:as].nil?
64
+
65
+ child_class.reflect_on_all_associations(:belongs_to).find do |child_assoc|
66
+ # Only match this way for polymorphic associations
67
+ child_assoc.polymorphic? && child_assoc.name == parent_assoc.options[:as]
68
+ end
69
+ end
70
+
71
+ def child_association_to_parent(parent, child)
72
+ child_class = child.is_a?(Class) ? child : child.class
73
+ parent_class = parent.is_a?(Class) ? parent : parent.class
74
+
75
+ association = child_class.reflect_on_all_associations(:belongs_to).find do |association|
76
+ # Do not match on polymorphic associations or it will throw an error (klass cannot be determined)
77
+ !association.polymorphic? && association.klass == parent.class
78
+ end
79
+
80
+ return association unless association.nil?
81
+
82
+ parent_class.reflect_on_all_associations(:has_many).each do |has_many_assoc|
83
+ association ||= matching_parent_child_polymorphic_association(has_many_assoc, child_class)
84
+ end
85
+
86
+ association
87
+ end
88
+
89
+ def parent_condition_name(parent, child)
90
+ child_association_to_parent(parent, child)&.name || parent.class.name.downcase.to_sym
91
+ end
92
+ end
6
93
 
7
94
  # Returns conditions intended to be used inside a database query. Normally you will not call this
8
95
  # method directly, but instead go through ModelAdditions#accessible_by.
@@ -20,47 +107,18 @@ module CanCan
20
107
  # query(:manage, User).conditions # => "not (self_managed = 't') AND ((manager_id = 1) OR (id = 1))"
21
108
  #
22
109
  def conditions
23
- if @rules.size == 1 && @rules.first.base_behavior
110
+ conditions_extractor = ConditionsExtractor.new(@model_class)
111
+ if @compressed_rules.size == 1 && @compressed_rules.first.base_behavior
24
112
  # Return the conditions directly if there's just one definition
25
- tableized_conditions(@rules.first.conditions).dup
113
+ conditions_extractor.tableize_conditions(@compressed_rules.first.conditions).dup
26
114
  else
27
- extract_multiple_conditions
28
- end
29
- end
30
-
31
- def extract_multiple_conditions
32
- @rules.reverse.inject(false_sql) do |sql, rule|
33
- merge_conditions(sql, tableized_conditions(rule.conditions).dup, rule.base_behavior)
34
- end
35
- end
36
-
37
- def tableized_conditions(conditions, model_class = @model_class)
38
- return conditions unless conditions.is_a? Hash
39
- conditions.each_with_object({}) do |(name, value), result_hash|
40
- calculate_result_hash(model_class, name, result_hash, value)
115
+ extract_multiple_conditions(conditions_extractor, @compressed_rules)
41
116
  end
42
117
  end
43
118
 
44
- def calculate_result_hash(model_class, name, result_hash, value)
45
- if value.is_a? Hash
46
- association_class = model_class.reflect_on_association(name).klass.name.constantize
47
- nested_resulted = calculate_nested(model_class, name, result_hash, value.dup)
48
- result_hash.merge!(tableized_conditions(nested_resulted, association_class))
49
- else
50
- result_hash[name] = value
51
- end
52
- result_hash
53
- end
54
-
55
- def calculate_nested(model_class, name, result_hash, value)
56
- value.each_with_object({}) do |(k, v), nested|
57
- if v.is_a? Hash
58
- value.delete(k)
59
- nested[k] = v
60
- else
61
- result_hash[model_class.reflect_on_association(name).table_name.to_sym] = value
62
- end
63
- nested
119
+ def extract_multiple_conditions(conditions_extractor, rules)
120
+ rules.reverse.inject(false_sql) do |sql, rule|
121
+ merge_conditions(sql, conditions_extractor.tableize_conditions(rule.conditions).dup, rule.base_behavior)
64
122
  end
65
123
  end
66
124
 
@@ -68,29 +126,60 @@ module CanCan
68
126
  if override_scope
69
127
  @model_class.where(nil).merge(override_scope)
70
128
  elsif @model_class.respond_to?(:where) && @model_class.respond_to?(:joins)
71
- mergeable_conditions? ? build_relation(conditions) : build_relation(*@rules.map(&:conditions))
129
+ build_relation(conditions)
72
130
  else
73
131
  @model_class.all(conditions: conditions, joins: joins)
74
132
  end
75
133
  end
76
134
 
135
+ def build_relation(*where_conditions)
136
+ relation = @model_class.where(*where_conditions)
137
+ return relation unless joins.present?
138
+
139
+ # subclasses must implement `build_joins_relation`
140
+ build_joins_relation(relation, *where_conditions)
141
+ end
142
+
143
+ # Returns the associations used in conditions for the :joins option of a search.
144
+ # See ModelAdditions#accessible_by
145
+ def joins
146
+ joins_hash = {}
147
+ @compressed_rules.reverse_each do |rule|
148
+ deep_merge(joins_hash, rule.associations_hash)
149
+ end
150
+ deep_clean(joins_hash) unless joins_hash.empty?
151
+ end
152
+
77
153
  private
78
154
 
79
- def mergeable_conditions?
80
- @rules.find(&:unmergeable?).blank?
155
+ # Removes empty hashes and moves everything into arrays.
156
+ def deep_clean(joins_hash)
157
+ joins_hash.map { |name, nested| nested.empty? ? name : { name => deep_clean(nested) } }
158
+ end
159
+
160
+ # Takes two hashes and does a deep merge.
161
+ def deep_merge(base_hash, added_hash)
162
+ added_hash.each do |key, value|
163
+ if base_hash[key].is_a?(Hash)
164
+ deep_merge(base_hash[key], value) unless value.empty?
165
+ else
166
+ base_hash[key] = value
167
+ end
168
+ end
81
169
  end
82
170
 
83
171
  def override_scope
84
- conditions = @rules.map(&:conditions).compact
172
+ conditions = @compressed_rules.map(&:conditions).compact
85
173
  return unless conditions.any? { |c| c.is_a?(ActiveRecord::Relation) }
86
174
  return conditions.first if conditions.size == 1
175
+
87
176
  raise_override_scope_error
88
177
  end
89
178
 
90
179
  def raise_override_scope_error
91
- rule_found = @rules.detect { |rule| rule.conditions.is_a?(ActiveRecord::Relation) }
180
+ rule_found = @compressed_rules.detect { |rule| rule.conditions.is_a?(ActiveRecord::Relation) }
92
181
  raise Error,
93
- 'Unable to merge an Active Record scope with other conditions. '\
182
+ 'Unable to merge an Active Record scope with other conditions. ' \
94
183
  "Instead use a hash or SQL for #{rule_found.actions.first} #{rule_found.subjects.first} ability."
95
184
  end
96
185
 
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # this class is responsible of converting the hash of conditions
4
+ # in "where conditions" to generate the sql query
5
+ # it consists of a names_cache that helps calculating the next name given to the association
6
+ # it tries to reflect the behavior of ActiveRecord when generating aliases for tables.
7
+ module CanCan
8
+ module ModelAdapters
9
+ class ConditionsExtractor
10
+ def initialize(model_class)
11
+ @names_cache = { model_class.table_name => [] }.with_indifferent_access
12
+ @root_model_class = model_class
13
+ end
14
+
15
+ def tableize_conditions(conditions, model_class = @root_model_class, path_to_key = 0)
16
+ return conditions unless conditions.is_a? Hash
17
+
18
+ conditions.each_with_object({}) do |(key, value), result_hash|
19
+ if value.is_a? Hash
20
+ result_hash.merge!(calculate_result_hash(key, model_class, path_to_key, result_hash, value))
21
+ else
22
+ result_hash[key] = value
23
+ end
24
+ result_hash
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def calculate_result_hash(key, model_class, path_to_key, result_hash, value)
31
+ reflection = model_class.reflect_on_association(key)
32
+ nested_resulted = calculate_nested(model_class, result_hash, key, value.dup, path_to_key)
33
+ association_class = reflection.klass.name.constantize
34
+ tableize_conditions(nested_resulted, association_class, "#{path_to_key}_#{key}")
35
+ end
36
+
37
+ def calculate_nested(model_class, result_hash, relation_name, value, path_to_key)
38
+ value.each_with_object({}) do |(k, v), nested|
39
+ if v.is_a? Hash
40
+ value.delete(k)
41
+ nested[k] = v
42
+ else
43
+ table_alias = generate_table_alias(model_class, relation_name, path_to_key)
44
+ result_hash[table_alias] = value
45
+ end
46
+ nested
47
+ end
48
+ end
49
+
50
+ def generate_table_alias(model_class, relation_name, path_to_key)
51
+ table_alias = model_class.reflect_on_association(relation_name).table_name.to_sym
52
+
53
+ if already_used?(table_alias, relation_name, path_to_key)
54
+ table_alias = "#{relation_name.to_s.pluralize}_#{model_class.table_name}".to_sym
55
+
56
+ index = 1
57
+ while already_used?(table_alias, relation_name, path_to_key)
58
+ table_alias = "#{table_alias}_#{index += 1}".to_sym
59
+ end
60
+ end
61
+ add_to_cache(table_alias, relation_name, path_to_key)
62
+ end
63
+
64
+ def already_used?(table_alias, relation_name, path_to_key)
65
+ @names_cache[table_alias].try(:exclude?, "#{path_to_key}_#{relation_name}")
66
+ end
67
+
68
+ def add_to_cache(table_alias, relation_name, path_to_key)
69
+ @names_cache[table_alias] ||= []
70
+ @names_cache[table_alias] << "#{path_to_key}_#{relation_name}"
71
+ table_alias
72
+ end
73
+ end
74
+ end
75
+ end