cancancan 2.2.0 → 3.5.0

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 (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