Class: LowCardTables::HasLowCardTable::LowCardDynamicMethodManager

Inherits:
Object
  • Object
show all
Defined in:
lib/low_card_tables/has_low_card_table/low_card_dynamic_method_manager.rb

Overview

This object is responsible for maintaining the set of methods that get automatically delegated when you declare has_low_card_table on an ::ActiveRecord model – it both maintains the set of methods defined on the _low_card_dynamic_methods_module for that class, and directs the calls in the right place at runtime.

Secondarily, it also is responsible for transforming query specifications – #scope_from_query takes the set of constraints you passed, as a Hash, to ::ActiveRecord::Relation#where, and transforms them using low-card information. So:

{ :deleted => true, :deceased => false }

might become

{ :user_status_id => [ 1, 3, 9, 17 ] }

While it might seem odd for that functionality to live in this class, it actually makes sense; this is the class that knows what method names in the low-card class those keys map to, after all.

Instance Method Summary collapse

Constructor Details

#initialize(model_class) ⇒ LowCardDynamicMethodManager

Returns a new instance of LowCardDynamicMethodManager.



20
21
22
23
# File 'lib/low_card_tables/has_low_card_table/low_card_dynamic_method_manager.rb', line 20

def initialize(model_class)
  @model_class = model_class
  @method_delegation_map = { }
end

Instance Method Details

#run_low_card_method(object, method_name, args) ⇒ Object

Given an instance of the model class we’re maintaining methods for, the name of a method to invoke, and arguments passed to that method, runs the correct method. This is therefore a dispatcher – rather than attempt to define the methods on the _low_card_dynamic_methods_module at all times to directly call the right low-card object, we simply have them all call through here, instead, and do the dispatch at runtime. This simplifies the nature of the dynamic methods considerably.



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/low_card_tables/has_low_card_table/low_card_dynamic_method_manager.rb', line 30

def run_low_card_method(object, method_name, args)
  ensure_correct_class!(object)

  method_data = @method_delegation_map[method_name.to_s]
  unless method_data
    raise NameError, "Whoa -- we're trying to call a delegated low-card method #{method_name.inspect} on #{object}, of class #{object.class}, but somehow the LowCardDynamicMethodManager has no knowledge of that method?!? We know about: #{@method_delegation_map.keys.sort.inspect}"
  end

  (association, association_method_name) = method_data

  if association_method_name == :_low_card_object
    # e.g., my_user.status
    object._low_card_objects_manager.object_for(association)
  elsif association_method_name == :_low_card_foreign_key
    # e.g., my_user.user_status_id
    object._low_card_objects_manager.foreign_key_for(association)
  elsif association_method_name == :_low_card_foreign_key=
    # e.g., my_user.user_status_id =
    object._low_card_objects_manager.set_foreign_key_for(association, *args)
  else
    # e.g., my_user.deleted =
    low_card_object = object.send(association.association_name)
    low_card_object.send(association_method_name, *args)
  end
end

#scope_from_query(base_scope, query_hash) ⇒ Object

Given a base ::ActiveRecord::Relation scope (which can of course just be a model class itself), and a set of query constraints as passed into ::ActiveRecord::Relation#where (which must be a Hash – for the other forms of #where, our override of ::ActiveRecord::Relation#where doesn’t call this method but just passes through to the underlying method), returns a new scope that is the result of applying those constraints correctly to the base_scope.

The constraints in the query_hash need not all be, or even any be, constraints on a low-card table; any non- low-card constraints are simply passed through verbatim. But constraints on a low-card table – whether they’re implicit, like

User.where(:deleted => false)

or explicit, like

User.where(:status => { :deleted => false })

…are transformed into explicit references to the low-card foreign-key column:

User.where(:user_status_id => [ 1, 3, 4, 7, 8, 10 ])


75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/low_card_tables/has_low_card_table/low_card_dynamic_method_manager.rb', line 75

def scope_from_query(base_scope, query_hash)
  non_low_card_constraints = { }
  low_card_association_to_constraint_map = { }

  # We iterate through the query hash, building up two hashes:
  #
  # * non_low_card_constraints is the set of all constraints that have nothing to do with a low-card table;
  # * low_card_association_to_constraint_map maps low-card association names to a Hash of the constraints applied
  #   to that association; the constraints in the Hash use key names that are the actual low-card column names
  #   (i.e., we translate them from whatever delegated method names were present in the referring class)
  query_hash.each do |query_key, query_value|
    low_card_delegation = @method_delegation_map[query_key.to_s]

    # Does this constraint even mention a low-card column or association name?
    if low_card_delegation
      (association, method) = low_card_delegation

      # e.g., User.where(:status => { ... })
      if method == :_low_card_object
        if (! query_value.kind_of?(Hash))
          raise ArgumentError, %{You are trying to constrain on #{@model_class.name}.#{query_key}, which is a low-card association,
but the value you passed, #{query_value.inspect}, is not a Hash. Either pass a Hash,
or constrain on #{association.foreign_key_column_name} explicitly, and find IDs
yourself, using #{association.low_card_class.name}#ids_matching.}
        end

        low_card_association_to_constraint_map[association] ||= { }
        low_card_association_to_constraint_map[association].merge!(query_value)
      # e.g., User.where(:user_status_id => ...)
      elsif method == :_low_card_foreign_key
        non_low_card_constraints[query_key] = query_value
      # e.g., User.where(:deleted => false)
      else
        low_card_association_to_constraint_map[association] ||= { }
        low_card_association_to_constraint_map[association][method] = query_value
      end
    else
      # e.g., User.where(:name => ...)
      non_low_card_constraints[query_key] = query_value
    end
  end

  out = base_scope
  # See the comment in LowCardTables::ActiveRecord::Relation -- this is so that when we call #where, below,
  # we don't end up creating infinite mutual recursion. +_low_card_direct+ is our 'escape hatch'.
  out = base_scope.where(non_low_card_constraints.merge(:_low_card_direct => true)) if non_low_card_constraints.size > 0

  # This is gross. In ActiveRecord v3, doing something like this:
  #
  #    Model.where(:x => [ 1, 2, 3 ]).where(:x => [ 3, 4, 5 ])
  #
  # ...results in "... WHERE x IN (3, 4, 5)" -- i.e., it's last-clause wins, and the first one is totally
  # ignored. While this sucks in general (in my opinion), it's genuinely a problem for our system; we need to
  # be able to say Model.where(:deleted => false).where(:deceased => false) and only get back non-deleted, alive
  # users -- and, underneath, both those queries transform to conditions on :user_status_id.
  #
  # Our workaround is to instead use text-based queries for these conditions, because:
  #
  #    Model.where("x IN :ids", :ids => [ 1, 2, 3 ]).where("x IN :ids", :ids => [ 3, 4, 5 ])
  #
  # ...results in "... WHERE x IN (1, 2, 3) AND x IN (3, 4, 5)", which gives us the right value. (ActiveRecord
  # doesn't ever parse SQL you hand to it, so it has no way of knowing these are conditions on the same column --
  # so it keeps both clauses.)
  #
  # ActiveRecord 4 does the right thing here (IMHO) and behaves identically whether you pass in a Hash or a text
  # clause. However, our hack works fine with both versions, so we'll keep it for now.
  low_card_association_to_constraint_map.each do |association, constraints|
    ids = association.low_card_class.low_card_ids_matching(constraints)
    out = out.where("#{association.foreign_key_column_name} IN (:ids)", :ids => ids)
  end


  out
end

#sync_methods!Object

This method is responsible for doing two things:

  • Most importantly, it sets up @method_delegation_map. This maps the name of every dynamic method that can be invoked on an instance of the model class to the low-card method that it should delegate to. (It calls LowCardTables::HasLowCardTable::LowCardAssociation#class_method_name_to_low_card_method_name_map to figure out what methods should be delegated for each association.) There are a few ‘special’ method names: :_low_card_object means ‘return the associated low-card object itself’ (e.g., my_user.status); :_low_card_foreign_key means ‘return the associated low-card foreign key’ (e.g., my_user.user_status_id); :_low_card_foreign_key= means ‘set the associated low-card foreign key’.

  • Secondly, it makes sure that, for each of these methods, the _low_card_dynamic_methods_module has installed a method that delegates to #run_low_card_method on this object – and that no other methods are installed on that module.

This method implements the ‘last association wins’ policy, by simply going through the asssociations in order of definition and letting them overwrite previous associations’ method names, if they collide.

Rather than trying to dynamically add and remove methods as associations are added, columns are removed, etc., it is far simpler to do what we do here: simply rebuild the map from scratch on each call – and then apply the differences to the _low_card_dynamic_methods_module.



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/low_card_tables/has_low_card_table/low_card_dynamic_method_manager.rb', line 169

def sync_methods!
  currently_delegated_methods = @method_delegation_map.keys

  @method_delegation_map = { }

  associations.each do |association|
    @method_delegation_map[association.association_name.to_s] = [ association, :_low_card_object ]
    @method_delegation_map[association.foreign_key_column_name.to_s] = [ association, :_low_card_foreign_key ]
    @method_delegation_map[association.foreign_key_column_name.to_s + "="] = [ association, :_low_card_foreign_key= ]

    association.class_method_name_to_low_card_method_name_map.each do |desired_name, association_method_name|
      desired_name = desired_name.to_s
      @method_delegation_map[desired_name] = [ association, association_method_name ]
    end
  end

  remove_delegated_methods!(currently_delegated_methods - @method_delegation_map.keys)
  add_delegated_methods!(@method_delegation_map.keys - currently_delegated_methods)
end