low_card_tables 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +18 -0
  3. data/.travis.yml +59 -0
  4. data/Gemfile +17 -0
  5. data/LICENSE +21 -0
  6. data/README.md +75 -0
  7. data/Rakefile +6 -0
  8. data/lib/low_card_tables.rb +72 -0
  9. data/lib/low_card_tables/active_record/base.rb +55 -0
  10. data/lib/low_card_tables/active_record/migrations.rb +223 -0
  11. data/lib/low_card_tables/active_record/relation.rb +35 -0
  12. data/lib/low_card_tables/active_record/scoping.rb +87 -0
  13. data/lib/low_card_tables/errors.rb +74 -0
  14. data/lib/low_card_tables/has_low_card_table/base.rb +114 -0
  15. data/lib/low_card_tables/has_low_card_table/low_card_association.rb +273 -0
  16. data/lib/low_card_tables/has_low_card_table/low_card_associations_manager.rb +143 -0
  17. data/lib/low_card_tables/has_low_card_table/low_card_dynamic_method_manager.rb +224 -0
  18. data/lib/low_card_tables/has_low_card_table/low_card_objects_manager.rb +80 -0
  19. data/lib/low_card_tables/low_card_table/base.rb +184 -0
  20. data/lib/low_card_tables/low_card_table/cache.rb +214 -0
  21. data/lib/low_card_tables/low_card_table/cache_expiration/exponential_cache_expiration_policy.rb +151 -0
  22. data/lib/low_card_tables/low_card_table/cache_expiration/fixed_cache_expiration_policy.rb +23 -0
  23. data/lib/low_card_tables/low_card_table/cache_expiration/has_cache_expiration.rb +100 -0
  24. data/lib/low_card_tables/low_card_table/cache_expiration/no_caching_expiration_policy.rb +13 -0
  25. data/lib/low_card_tables/low_card_table/cache_expiration/unlimited_cache_expiration_policy.rb +13 -0
  26. data/lib/low_card_tables/low_card_table/row_collapser.rb +175 -0
  27. data/lib/low_card_tables/low_card_table/row_manager.rb +681 -0
  28. data/lib/low_card_tables/low_card_table/table_unique_index.rb +134 -0
  29. data/lib/low_card_tables/version.rb +4 -0
  30. data/lib/low_card_tables/version_support.rb +52 -0
  31. data/low_card_tables.gemspec +69 -0
  32. data/spec/low_card_tables/helpers/database_helper.rb +148 -0
  33. data/spec/low_card_tables/helpers/query_spy_helper.rb +47 -0
  34. data/spec/low_card_tables/helpers/system_helpers.rb +63 -0
  35. data/spec/low_card_tables/system/basic_system_spec.rb +254 -0
  36. data/spec/low_card_tables/system/bulk_system_spec.rb +334 -0
  37. data/spec/low_card_tables/system/caching_system_spec.rb +531 -0
  38. data/spec/low_card_tables/system/migrations_system_spec.rb +747 -0
  39. data/spec/low_card_tables/system/options_system_spec.rb +581 -0
  40. data/spec/low_card_tables/system/queries_system_spec.rb +142 -0
  41. data/spec/low_card_tables/system/validations_system_spec.rb +88 -0
  42. data/spec/low_card_tables/unit/active_record/base_spec.rb +53 -0
  43. data/spec/low_card_tables/unit/active_record/migrations_spec.rb +207 -0
  44. data/spec/low_card_tables/unit/active_record/relation_spec.rb +47 -0
  45. data/spec/low_card_tables/unit/active_record/scoping_spec.rb +101 -0
  46. data/spec/low_card_tables/unit/has_low_card_table/base_spec.rb +79 -0
  47. data/spec/low_card_tables/unit/has_low_card_table/low_card_association_spec.rb +287 -0
  48. data/spec/low_card_tables/unit/has_low_card_table/low_card_associations_manager_spec.rb +190 -0
  49. data/spec/low_card_tables/unit/has_low_card_table/low_card_dynamic_method_manager_spec.rb +234 -0
  50. data/spec/low_card_tables/unit/has_low_card_table/low_card_objects_manager_spec.rb +70 -0
  51. data/spec/low_card_tables/unit/low_card_table/base_spec.rb +207 -0
  52. data/spec/low_card_tables/unit/low_card_table/cache_expiration/exponential_cache_expiration_policy_spec.rb +128 -0
  53. data/spec/low_card_tables/unit/low_card_table/cache_expiration/fixed_cache_expiration_policy_spec.rb +25 -0
  54. data/spec/low_card_tables/unit/low_card_table/cache_expiration/has_cache_expiration_policy_spec.rb +100 -0
  55. data/spec/low_card_tables/unit/low_card_table/cache_expiration/no_caching_expiration_policy_spec.rb +14 -0
  56. data/spec/low_card_tables/unit/low_card_table/cache_expiration/unlimited_cache_expiration_policy_spec.rb +14 -0
  57. data/spec/low_card_tables/unit/low_card_table/cache_spec.rb +282 -0
  58. data/spec/low_card_tables/unit/low_card_table/row_collapser_spec.rb +109 -0
  59. data/spec/low_card_tables/unit/low_card_table/row_manager_spec.rb +918 -0
  60. data/spec/low_card_tables/unit/low_card_table/table_unique_index_spec.rb +117 -0
  61. metadata +206 -0
@@ -0,0 +1,143 @@
1
+ require 'low_card_tables/has_low_card_table/low_card_association'
2
+ require 'low_card_tables/errors'
3
+
4
+ module LowCardTables
5
+ module HasLowCardTable
6
+ # The LowCardAssociationsManager is a relatively simple object; it manages the LowCardAssociation objects for a
7
+ # given model class that refers to at least one low-card table. Conceptually, it does little more than maintain
8
+ # an Array of such associations.
9
+ #
10
+ # (Note that storing this data as an Array is important: part of the contract of the low-card system is that
11
+ # later calls to +has_low_card_table+ supersede earlier calls, so order is key. Yes, Ruby hashes are ordered in
12
+ # recent Ruby versions...but we support 1.8.7, too.)
13
+ class LowCardAssociationsManager
14
+ # Returns an Array of all LowCardAssociation objects for the given +model_class+.
15
+ attr_reader :associations
16
+
17
+ # Creates a new instance. You should only ever create one instance per model class.
18
+ def initialize(model_class)
19
+ if (! superclasses(model_class).include?(::ActiveRecord::Base))
20
+ raise ArgumentError, "You must supply an ActiveRecord model, not: #{model_class}"
21
+ elsif model_class.is_low_card_table?
22
+ raise ArgumentError, "A low-card table can't itself have low-card associations: #{model_class}"
23
+ end
24
+
25
+ @model_class = model_class
26
+ @associations = [ ]
27
+ @collapsing_update_scheme = :default
28
+
29
+ install_methods!
30
+ end
31
+
32
+ # Called when +has_low_card_table+ is declared within the model class; this does all the actual work of
33
+ # setting up the association. This removes any previous associations with the same name; again, we have a
34
+ # 'last caller wins' policy.
35
+ def has_low_card_table(association_name, options = { })
36
+ unless association_name.kind_of?(Symbol) || (association_name.kind_of?(String) && association_name.strip.length > 0)
37
+ raise ArgumentError, "You must supply an association name, not: #{association_name.inspect}"
38
+ end
39
+
40
+ association_name = association_name.to_s.strip.downcase
41
+ @associations.delete_if { |a| a.association_name.to_s.strip.downcase == association_name }
42
+
43
+ @associations << LowCardTables::HasLowCardTable::LowCardAssociation.new(@model_class, association_name, options)
44
+
45
+ @model_class._low_card_dynamic_method_manager.sync_methods!
46
+ end
47
+
48
+ # Called when someone has called ::ActiveRecord::Base#reset_column_information on the low-card model in question.
49
+ # This simply tells the LowCardDynamicMethodManager to sync the methods on this model class, thus updating the
50
+ # set of delegated methods to match the new columns.
51
+ def low_card_column_information_reset!(low_card_model)
52
+ @model_class._low_card_dynamic_method_manager.sync_methods!
53
+ end
54
+
55
+ # Retrieves the low-card association with the given name. Raises LowCardTables::Errors::LowCardAssociationNotFoundError
56
+ # if not found.
57
+ def _low_card_association(name)
58
+ maybe_low_card_association(name) || (raise LowCardTables::Errors::LowCardAssociationNotFoundError, "There is no low-card association named '#{name}' for #{@model_class.name}; there are associations named: #{@associations.map(&:association_name).sort.join(", ")}.")
59
+ end
60
+
61
+ # Just like _low_card_association, but returns nil when an association is not found, rather than raising an error.
62
+ def maybe_low_card_association(name)
63
+ @associations.detect { |a| a.association_name.to_s.strip.downcase == name.to_s.strip.downcase }
64
+ end
65
+
66
+ # Updates all foreign keys that the given model_instance has to their correct values, given the set of attributes
67
+ # that are associated with that model instance.
68
+ def low_card_update_foreign_keys!(model_instance)
69
+ ensure_correct_class!(model_instance)
70
+
71
+ @associations.each do |association|
72
+ association.update_foreign_key!(model_instance)
73
+ end
74
+ end
75
+
76
+ DEFAULT_COLLAPSING_UPDATE_VALUE = 10_000
77
+
78
+ # Gets, or sets, the current scheme for updating rows in this table when a low-card table has a column removed and
79
+ # thus needs to have rows collapsed. Passing +nil+ or no arguments retrieves the current scheme; passing anything
80
+ # else sets it. You can set this to:
81
+ #
82
+ # [:default] Rows will be updated in chunks of +DEFAULT_COLLAPSING_UPDATE_VALUE+ rows.
83
+ # [:none] Nothing will be done; you are entirely on your own, and will have dangling foreign keys.
84
+ # [a positive integer] Rows will be updated in chunks of this many rows at once.
85
+ # [something that responds to :call] This object will have #call invoked on it when rows need to be updated; it
86
+ # will be passed the map of 'winners' to 'losers', and is responsible for
87
+ # updating rows any way you want.
88
+ def low_card_value_collapsing_update_scheme(new_scheme = nil)
89
+ if (! new_scheme)
90
+ @collapsing_update_scheme
91
+ elsif new_scheme == :default || new_scheme == :none
92
+ @collapsing_update_scheme = new_scheme
93
+ elsif new_scheme.kind_of?(Integer)
94
+ raise ArgumentError, "You must specify an integer >= 1, not #{new_scheme.inspect}" unless new_scheme >= 1
95
+ @collapsing_update_scheme = new_scheme
96
+ elsif new_scheme.respond_to?(:call)
97
+ @collapsing_update_scheme = new_scheme
98
+ else
99
+ raise ArgumentError, "Invalid collapsing update scheme: #{new_scheme.inspect}"
100
+ end
101
+ end
102
+
103
+ # Called when a low-card model has just collapsed rows, presumably because it has had a column removed. This is
104
+ # responsible for updating each foreign-key column, using the proper low_card_value_collapsing_update_scheme.
105
+ def _low_card_update_collapsed_rows(low_card_model, collapse_map)
106
+ update_scheme = @collapsing_update_scheme
107
+ update_scheme = DEFAULT_COLLAPSING_UPDATE_VALUE if update_scheme == :default
108
+
109
+ @associations.each do |association|
110
+ if association.low_card_class == low_card_model
111
+ association.update_collapsed_rows(collapse_map, update_scheme)
112
+ end
113
+ end
114
+ end
115
+
116
+ private
117
+ # Makes sure that +model_instance+ is an instance of the model class this LowCardAssociationsManager is for.
118
+ def ensure_correct_class!(model_instance)
119
+ unless model_instance.kind_of?(@model_class)
120
+ raise ArgumentError, %{Somehow, you passed #{model_instance}, an instance of #{model_instance.class}, to the LowCardAssociationsManager for #{@model_class}.}
121
+ end
122
+ end
123
+
124
+ # Installs any methods we need on the model class -- right now, this is just our +before_save+ hook.
125
+ def install_methods!
126
+ @model_class.send(:before_save, :low_card_update_foreign_keys!)
127
+ end
128
+
129
+ # Fetches the entire superclass chain of a Class, up to, but not including, Object.
130
+ def superclasses(c)
131
+ out = [ ]
132
+
133
+ c = c.superclass
134
+ while c != Object
135
+ out << c
136
+ c = c.superclass
137
+ end
138
+
139
+ out
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,224 @@
1
+ module LowCardTables
2
+ module HasLowCardTable
3
+ # This object is responsible for maintaining the set of methods that get automatically delegated when you declare
4
+ # +has_low_card_table+ on an ::ActiveRecord model -- it both maintains the set of methods defined on the
5
+ # _low_card_dynamic_methods_module for that class, and directs the calls in the right place at runtime.
6
+ #
7
+ # Secondarily, it also is responsible for transforming query specifications -- #scope_from_query takes the set of
8
+ # constraints you passed, as a Hash, to ::ActiveRecord::Relation#where, and transforms them using low-card
9
+ # information. So:
10
+ #
11
+ # { :deleted => true, :deceased => false }
12
+ #
13
+ # might become
14
+ #
15
+ # { :user_status_id => [ 1, 3, 9, 17 ] }
16
+ #
17
+ # While it might seem odd for that functionality to live in this class, it actually makes sense; this is the
18
+ # class that knows what method names in the low-card class those keys map to, after all.
19
+ class LowCardDynamicMethodManager
20
+ def initialize(model_class)
21
+ @model_class = model_class
22
+ @method_delegation_map = { }
23
+ end
24
+
25
+ # Given an instance of the model class we're maintaining methods for, the name of a method to invoke, and
26
+ # arguments passed to that method, runs the correct method. This is therefore a dispatcher -- rather than attempt
27
+ # to define the methods on the _low_card_dynamic_methods_module at all times to directly call the right low-card
28
+ # object, we simply have them all call through here, instead, and do the dispatch at runtime. This simplifies
29
+ # the nature of the dynamic methods considerably.
30
+ def run_low_card_method(object, method_name, args)
31
+ ensure_correct_class!(object)
32
+
33
+ method_data = @method_delegation_map[method_name.to_s]
34
+ unless method_data
35
+ 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}"
36
+ end
37
+
38
+ (association, association_method_name) = method_data
39
+
40
+ if association_method_name == :_low_card_object
41
+ # e.g., my_user.status
42
+ object._low_card_objects_manager.object_for(association)
43
+ elsif association_method_name == :_low_card_foreign_key
44
+ # e.g., my_user.user_status_id
45
+ object._low_card_objects_manager.foreign_key_for(association)
46
+ elsif association_method_name == :_low_card_foreign_key=
47
+ # e.g., my_user.user_status_id =
48
+ object._low_card_objects_manager.set_foreign_key_for(association, *args)
49
+ else
50
+ # e.g., my_user.deleted =
51
+ low_card_object = object.send(association.association_name)
52
+ low_card_object.send(association_method_name, *args)
53
+ end
54
+ end
55
+
56
+ # Given a base ::ActiveRecord::Relation scope (which can of course just be a model class itself), and a set of
57
+ # query constraints as passed into ::ActiveRecord::Relation#where (which must be a Hash -- for the other forms
58
+ # of #where, our override of ::ActiveRecord::Relation#where doesn't call this method but just passes through to
59
+ # the underlying method), returns a new scope that is the result of applying those constraints correctly to
60
+ # the +base_scope+.
61
+ #
62
+ # The constraints in the query_hash need not all be, or even any be, constraints on a low-card table; any non-
63
+ # low-card constraints are simply passed through verbatim. But constraints on a low-card table -- whether they're
64
+ # implicit, like
65
+ #
66
+ # User.where(:deleted => false)
67
+ #
68
+ # or explicit, like
69
+ #
70
+ # User.where(:status => { :deleted => false })
71
+ #
72
+ # ...are transformed into explicit references to the low-card foreign-key column:
73
+ #
74
+ # User.where(:user_status_id => [ 1, 3, 4, 7, 8, 10 ])
75
+ def scope_from_query(base_scope, query_hash)
76
+ non_low_card_constraints = { }
77
+ low_card_association_to_constraint_map = { }
78
+
79
+ # We iterate through the query hash, building up two hashes:
80
+ #
81
+ # * non_low_card_constraints is the set of all constraints that have nothing to do with a low-card table;
82
+ # * low_card_association_to_constraint_map maps low-card association names to a Hash of the constraints applied
83
+ # to that association; the constraints in the Hash use key names that are the actual low-card column names
84
+ # (i.e., we translate them from whatever delegated method names were present in the referring class)
85
+ query_hash.each do |query_key, query_value|
86
+ low_card_delegation = @method_delegation_map[query_key.to_s]
87
+
88
+ # Does this constraint even mention a low-card column or association name?
89
+ if low_card_delegation
90
+ (association, method) = low_card_delegation
91
+
92
+ # e.g., User.where(:status => { ... })
93
+ if method == :_low_card_object
94
+ if (! query_value.kind_of?(Hash))
95
+ raise ArgumentError, %{You are trying to constrain on #{@model_class.name}.#{query_key}, which is a low-card association,
96
+ but the value you passed, #{query_value.inspect}, is not a Hash. Either pass a Hash,
97
+ or constrain on #{association.foreign_key_column_name} explicitly, and find IDs
98
+ yourself, using #{association.low_card_class.name}#ids_matching.}
99
+ end
100
+
101
+ low_card_association_to_constraint_map[association] ||= { }
102
+ low_card_association_to_constraint_map[association].merge!(query_value)
103
+ # e.g., User.where(:user_status_id => ...)
104
+ elsif method == :_low_card_foreign_key
105
+ non_low_card_constraints[query_key] = query_value
106
+ # e.g., User.where(:deleted => false)
107
+ else
108
+ low_card_association_to_constraint_map[association] ||= { }
109
+ low_card_association_to_constraint_map[association][method] = query_value
110
+ end
111
+ else
112
+ # e.g., User.where(:name => ...)
113
+ non_low_card_constraints[query_key] = query_value
114
+ end
115
+ end
116
+
117
+ out = base_scope
118
+ # See the comment in LowCardTables::ActiveRecord::Relation -- this is so that when we call #where, below,
119
+ # we don't end up creating infinite mutual recursion. +_low_card_direct+ is our 'escape hatch'.
120
+ out = base_scope.where(non_low_card_constraints.merge(:_low_card_direct => true)) if non_low_card_constraints.size > 0
121
+
122
+ # This is gross. In ActiveRecord v3, doing something like this:
123
+ #
124
+ # Model.where(:x => [ 1, 2, 3 ]).where(:x => [ 3, 4, 5 ])
125
+ #
126
+ # ...results in "... WHERE x IN (3, 4, 5)" -- i.e., it's last-clause wins, and the first one is totally
127
+ # ignored. While this sucks in general (in my opinion), it's genuinely a problem for our system; we need to
128
+ # be able to say Model.where(:deleted => false).where(:deceased => false) and only get back non-deleted, alive
129
+ # users -- and, underneath, both those queries transform to conditions on :user_status_id.
130
+ #
131
+ # Our workaround is to instead use text-based queries for these conditions, because:
132
+ #
133
+ # Model.where("x IN :ids", :ids => [ 1, 2, 3 ]).where("x IN :ids", :ids => [ 3, 4, 5 ])
134
+ #
135
+ # ...results in "... WHERE x IN (1, 2, 3) AND x IN (3, 4, 5)", which gives us the right value. (ActiveRecord
136
+ # doesn't ever parse SQL you hand to it, so it has no way of knowing these are conditions on the same column --
137
+ # so it keeps both clauses.)
138
+ #
139
+ # ActiveRecord 4 does the right thing here (IMHO) and behaves identically whether you pass in a Hash or a text
140
+ # clause. However, our hack works fine with both versions, so we'll keep it for now.
141
+ low_card_association_to_constraint_map.each do |association, constraints|
142
+ ids = association.low_card_class.low_card_ids_matching(constraints)
143
+ out = out.where("#{association.foreign_key_column_name} IN (:ids)", :ids => ids)
144
+ end
145
+
146
+
147
+ out
148
+ end
149
+
150
+ # This method is responsible for doing two things:
151
+ #
152
+ # * Most importantly, it sets up @method_delegation_map. This maps the name of every dynamic method that can be
153
+ # invoked on an instance of the model class to the low-card method that it should delegate to. (It calls
154
+ # LowCardTables::HasLowCardTable::LowCardAssociation#class_method_name_to_low_card_method_name_map to figure
155
+ # out what methods should be delegated for each association.) There are a few 'special' method names:
156
+ # +:_low_card_object+ means 'return the associated low-card object itself' (e.g., my_user.status);
157
+ # +:_low_card_foreign_key+ means 'return the associated low-card foreign key' (e.g., my_user.user_status_id);
158
+ # +:_low_card_foreign_key=+ means 'set the associated low-card foreign key'.
159
+ # * Secondly, it makes sure that, for each of these methods, the _low_card_dynamic_methods_module has installed
160
+ # a method that delegates to #run_low_card_method on this object -- and that no other methods are installed
161
+ # on that module.
162
+ #
163
+ # This method implements the 'last association wins' policy, by simply going through the asssociations in
164
+ # order of definition and letting them overwrite previous associations' method names, if they collide.
165
+ #
166
+ # Rather than trying to dynamically add and remove methods as associations are added, columns are removed, etc.,
167
+ # it is _far_ simpler to do what we do here: simply rebuild the map from scratch on each call -- and then apply
168
+ # the differences to the _low_card_dynamic_methods_module.
169
+ def sync_methods!
170
+ currently_delegated_methods = @method_delegation_map.keys
171
+
172
+ @method_delegation_map = { }
173
+
174
+ associations.each do |association|
175
+ @method_delegation_map[association.association_name.to_s] = [ association, :_low_card_object ]
176
+ @method_delegation_map[association.foreign_key_column_name.to_s] = [ association, :_low_card_foreign_key ]
177
+ @method_delegation_map[association.foreign_key_column_name.to_s + "="] = [ association, :_low_card_foreign_key= ]
178
+
179
+ association.class_method_name_to_low_card_method_name_map.each do |desired_name, association_method_name|
180
+ desired_name = desired_name.to_s
181
+ @method_delegation_map[desired_name] = [ association, association_method_name ]
182
+ end
183
+ end
184
+
185
+ remove_delegated_methods!(currently_delegated_methods - @method_delegation_map.keys)
186
+ add_delegated_methods!(@method_delegation_map.keys - currently_delegated_methods)
187
+ end
188
+
189
+ private
190
+ # Returns all associations that should be used for this object.
191
+ def associations
192
+ @model_class._low_card_associations_manager.associations
193
+ end
194
+
195
+ # Makes sure the given object is an instance of the class we're handling dynamic methods for.
196
+ def ensure_correct_class!(object)
197
+ unless object.kind_of?(@model_class)
198
+ raise ArgumentError, "You passed #{object.inspect}, an instance of #{object.class.name}, to the LowCardDynamicMethodManager for #{@model_class}."
199
+ end
200
+ end
201
+
202
+ # Removes all methods with any of the specified names from the _low_card_dynamic_methods_module.
203
+ def remove_delegated_methods!(method_names)
204
+ mod = @model_class._low_card_dynamic_methods_module
205
+
206
+ method_names.each do |method_name|
207
+ mod.module_eval("remove_method :#{method_name}")
208
+ end
209
+ end
210
+
211
+ # Adds delegated methods for all the given names to the _low_card_dynamic_methods_module.
212
+ def add_delegated_methods!(method_names)
213
+ mod = @model_class._low_card_dynamic_methods_module
214
+
215
+ method_names.each do |delegated_method|
216
+ mod.module_eval(%{
217
+ def #{delegated_method}(*args)
218
+ self.class._low_card_dynamic_method_manager.run_low_card_method(self, :#{delegated_method}, args)
219
+ end})
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,80 @@
1
+ module LowCardTables
2
+ module HasLowCardTable
3
+ # Unlike the LowCardAssociationsManager, the LowCardObjectsManager belongs to a particular _instance_ of a model
4
+ # class that refers to a low-card table. Its responsibility is straightforward: it holds onto the actual instances
5
+ # of the low-card class that we return in response to someone accessing a low-card association. (More concretely:
6
+ # when you say my_user.status, you get back an instance of UserStatus; if you say my_user.status again, you get back
7
+ # the same instance of UserStatus. This class is responsible for creating that object in the first place, and
8
+ # holding onto it so that the same one gets returned all the time.)
9
+ #
10
+ # In an ordinary Rails association, you'd get back live, normal instances of the associated class -- just like if
11
+ # you'd said, say, <tt>UserStatus.find(...)</tt> in the first place. However, this is inappropriate for low-card
12
+ # associations, because the whole 'trick' of the low-card system is that changing the attributes of the
13
+ # conceptually-associated +UserStatus+ object actually just changes <em>which +UserStatus+ object you're pointing
14
+ # at</em>, rather than actually changing a +UserStatus+ row at all.
15
+ #
16
+ # We perform a simple trick here instead, composed of three parts:
17
+ #
18
+ # * Returned objects from low-card associations are actually clones (Object#dup) of the normal low-card objects
19
+ # you'd ordinarily get; this is necessary so that clients can assign attributes to them in any way they want,
20
+ # and there's no "crosstalk".
21
+ # * Returned objects have their ID removed (through a simple <tt>object.id = nil</tt>); this prevents a whole class
22
+ # of common coding mistakes. Say you retrieve the low-card object associated with a particular referring object,
23
+ # and then modify some of its attributes. Without this change, you'd now be in a situation where you have an
24
+ # associated ActiveRecord object (the low-card object) that has a particular set of attributes, and a particular
25
+ # ID...and yet, when you call #save -- which will succeed! -- that particular ID doesn't have that set of
26
+ # attributes at all. It would be way too easy to write code that looks completely correct, and yet fails; for
27
+ # example, you could grab the ID of that associated object and assign it to other referring rows. So we strip the
28
+ # ID off completely.
29
+ # * Returned objects cannot be directly saved at all; if you call #save or #save! on them, you'll get an exception,
30
+ # telling you not to do that. The low-card system itself needs to be in complete control of what rows get created,
31
+ # and be able to change referring IDs instead.
32
+ #
33
+ # These tricks actually happen in LowCardTables::HasLowCardTable::LowCardAssociation#create_low_card_object_for
34
+ # and LowCardTables::LowCardTable::Base#_low_card_disable_save_when_needed!, but it makes sense to document them
35
+ # here, since this class is most clearly given this responsibility.
36
+ class LowCardObjectsManager
37
+ # Creates a new instance of the LowCardObjectsManager, tied to a particular _instance_ of a low-card model.
38
+ # That is, +model_instance+ should be an instance of +UserStatus+ or something similar.
39
+ def initialize(model_instance)
40
+ @model_instance = model_instance
41
+ @objects = { }
42
+ end
43
+
44
+ # Returns the low-card object that corresponds to the given LowCardAssociation for this model instance.
45
+ def object_for(association)
46
+ association_name = association.association_name.to_s.strip.downcase
47
+ @objects[association_name] ||= begin
48
+ association = model_instance.class._low_card_associations_manager._low_card_association(association_name)
49
+ association.create_low_card_object_for(model_instance)
50
+ end
51
+ end
52
+
53
+ # Returns the foreign key for the given LowCardAssociation for this model instance. This wouldn't really have to
54
+ # go through this class for any great reason right now, but, given that #set_foreign_key_for does, it makes a lot
55
+ # of sense to keep the logic here.
56
+ def foreign_key_for(association)
57
+ model_instance[association.foreign_key_column_name]
58
+ end
59
+
60
+ # Sets the foreign key for the given LowCardAssociation for this model instance. We allow users to do this
61
+ # directly (e.g., <tt>my_user.user_status_id = 17</tt>) so that they can, for example, store +UserStatus+ IDs
62
+ # out-of-band (in memcache, Redis, or whatever) for any reasons they want. When they do assign the foreign-key
63
+ # value, we need to invalidate the associated low-card object, since any previous data there is now no longer
64
+ # valid.
65
+ def set_foreign_key_for(association, new_value)
66
+ model_instance[association.foreign_key_column_name] = new_value
67
+ invalidate_object_for(association)
68
+ new_value
69
+ end
70
+
71
+ private
72
+ # Removes the mapped object for a given association -- this simply deletes the object from our hash.
73
+ def invalidate_object_for(association)
74
+ @objects.delete(association.association_name)
75
+ end
76
+
77
+ attr_reader :model_instance
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,184 @@
1
+ require 'active_support/concern'
2
+ require 'low_card_tables/low_card_table/cache_expiration/has_cache_expiration'
3
+ require 'low_card_tables/low_card_table/row_manager'
4
+
5
+ module LowCardTables
6
+ module LowCardTable
7
+ # LowCardTables::LowCardTable::Base is the module that's included into any ActiveRecord model that you declare
8
+ # +is_low_card_table+ on. As such, it defines the API that's available on low-card tables. (The standard
9
+ # ActiveRecord API does, of course, still remain available, so you can also use that if you want.)
10
+ #
11
+ # Be careful of the distinction between the ClassMethods and instance methods here. ClassMethods are available on
12
+ # the low-card table as a whole; instance methods apply, of course, to each row individually.
13
+ module Base
14
+ extend ActiveSupport::Concern
15
+
16
+ # All low-card tables can have their cache-expiration policy set individually.
17
+ include LowCardTables::LowCardTable::CacheExpiration::HasCacheExpiration
18
+
19
+ # Set up cache-policy inheritance -- see HasCacheExpiration for more details.
20
+ included do
21
+ low_card_cache_policy_inherits_from ::LowCardTables
22
+ end
23
+
24
+ # This method is a critical entry point from the rest of the low-card system. For example, given our usual
25
+ # User/UserStatus example -- when you save a User object, the low-card system grabs the associated UserStatus
26
+ # object and creates a hash, mapping all of the columns in UserStatus to their corresponding values. Next, it
27
+ # iterates through its in-memory cache, using this method to determine which of the rows in the cache matches
28
+ # the hash it extracted -- and, when it finds one, that's the row it uses to get the low-card ID to assign
29
+ # in the associated table.
30
+ #
31
+ # *IMPORTANT*: this is not the _only_ context in which this method is used, but merely one example.
32
+ #
33
+ # It's possible to override this method to alter behavior; for example, you could use this to translate symbols
34
+ # to integers, pin values, or otherwise transform data. But be extremely careful when you do this, as you're
35
+ # playing with a very low-level part of the low-card system.
36
+ #
37
+ # Note that the hashes supplied can be partial or complete; that is, they may specify any subset of the values
38
+ # in this table, or all of them. This method must work accordingly -- if the hashes are partial, then, if this
39
+ # row's values for the keys that are specified match, then it should return true.
40
+ #
41
+ # This is the highest-level, most 'bulk' method -- it asks whether this row matches _any_ of the hashes in the
42
+ # supplied array.
43
+ def _low_card_row_matches_any_hash?(hashes)
44
+ hashes.detect { |hash| _low_card_row_matches_hash?(hash) }
45
+ end
46
+
47
+ # This is called by #_low_card_row_matches_any_hash?, in a loop; it asks whether this row matches the hash
48
+ # provided. See #_low_card_row_matches_any_hash? for more details. You can override this method instead of that
49
+ # one, if its semantics work better for your purposes, since its behavior will affect that of
50
+ # #_low_card_row_matches_any_hash?.
51
+ def _low_card_row_matches_hash?(hash)
52
+ hash.keys.all? { |key| _low_card_column_matches?(key, hash[key]) }
53
+ end
54
+
55
+ # This is called by _low_card_row_matches_hash?, in a loop; it asks whether the given column (+key+) matches
56
+ # the given value (+value+). See #_low_card_row_matches_any_hash? for more details. You can override this method
57
+ # instead of #_low_card_row_matches_any_hash? or #_low_card_row_matches_hash?, if its semantics work better for
58
+ # your purposes, since its behavior will affect those methods as well.
59
+ def _low_card_column_matches?(key, value)
60
+ self[key.to_s] == value
61
+ end
62
+
63
+ # This method is called from methods like #low_card_rows_matching, when passed a block -- its job is simply to
64
+ # see if this row is matched by the given block. It's hard to imagine a different implementation than this one,
65
+ # but it's here in case you want to override it.
66
+ def _low_card_row_matches_block?(block)
67
+ block.call(self)
68
+ end
69
+
70
+ module ClassMethods
71
+ # Declares that this is a low-card table. This should only ever be used on tables that are,
72
+ # in fact, low-card tables.
73
+ #
74
+ # options can contain:
75
+ #
76
+ # [:exclude_column_names] Excludes the specified Array of column names from being treated
77
+ # as low-card columns; this happens by default to created_at and
78
+ # updated_at. These columns will not be touched by the low-card
79
+ # code, meaning they have to be nullable or have defaults.
80
+ # [:max_row_count] The low-card system has a check built in to start raising errors if you
81
+ # appear to be storing data in a low-card table that is, in fact, not actually
82
+ # of low cardinality. The effect that doing this has is to explode the number
83
+ # of rows in the low-card table, so the check simply tests the total number
84
+ # of rows in the table. This defaults to 5,000
85
+ # (in LowCardTables::LowCardTable::Cache::DEFAULT_MAX_ROW_COUNT). If you really
86
+ # do have a valid low-card table with more than this number of rows, you can
87
+ # override that limit here.
88
+ def is_low_card_table(options = { })
89
+ self.low_card_options = options
90
+ _low_card_disable_save_when_needed!
91
+ end
92
+
93
+ # See LowCardTables::HasLowCardTable::LowCardObjectsManager for more details. In short, you should never be
94
+ # saving low-card objects directly; you should rather let the low-card Gem create such rows for you
95
+ # automatically, based on the attributes you assign to the model.
96
+ #
97
+ # This method is invoked only once, when #is_low_card_table is called.
98
+ def _low_card_disable_save_when_needed!
99
+ send(:define_method, :save_low_card_row!) do |*args|
100
+ begin
101
+ @_low_card_saves_allowed = true
102
+ save!(*args)
103
+ ensure
104
+ @_low_card_saves_allowed = false
105
+ end
106
+ end
107
+
108
+ send(:define_method, :save_low_card_row) do |*args|
109
+ begin
110
+ @_low_card_saves_allowed = true
111
+ save(*args)
112
+ ensure
113
+ @_low_card_saves_allowed = false
114
+ end
115
+ end
116
+
117
+ %w{save save!}.each do |method_name|
118
+ send(:define_method, method_name) do |*args|
119
+ if @_low_card_saves_allowed
120
+ super(*args)
121
+ else
122
+ raise LowCardTables::Errors::LowCardCannotSaveAssociatedLowCardObjectsError, %{You just tried to save a model that represents a row in a low-card table.
123
+ You can't do this, because the entire low-card system relies on the fact that low-card rows
124
+ are immutable once created. Changing this row would therefore change the logical state of
125
+ many, many rows that are associated with this one, and that is almost certainly not what
126
+ you want.
127
+
128
+ Instead, simply modify the low-card attributes directly -- typically on the associated object
129
+ (e.g., my_user.deleted = true), or on the low-card object (my_user.status.deleted = true),
130
+ and then save the associated object instead (my_user.save!). This will trigger the low-card
131
+ system to recompute which low-card row the object should be associated with, and update it
132
+ as needed, which is almost certainly what you actually want.
133
+
134
+ If you are absolutely certain you know what you're doing, you can call #save_low_card_row!
135
+ on this object, and it will save, but make sure you understand ALL the implications first.}
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ # Is this a low-card table? Since this module has been included into the class in question (which happens via
142
+ # #is_low_card_table), then the answer is always, 'yes'.
143
+ def is_low_card_table?
144
+ true
145
+ end
146
+
147
+ # This is a method provided by ActiveRecord::Base. When the set of columns on a low-card table has changed, we
148
+ # need to tell the row manager, so that it can flush its caches.
149
+ def reset_column_information
150
+ out = super
151
+ _low_card_row_manager.column_information_reset!
152
+ out
153
+ end
154
+
155
+ # This returns the set of low-card options specified for this class in #is_low_card_table.
156
+ def low_card_options
157
+ @_low_card_options ||= { }
158
+ end
159
+
160
+ # This sets the set of low-card options.
161
+ def low_card_options=(options)
162
+ @_low_card_options = options
163
+ end
164
+
165
+ # Returns the associated LowCardTables::LowCardTable::RowManager object for this class, which is where an awful
166
+ # lot of the real work happens.
167
+ def _low_card_row_manager
168
+ @_low_card_row_manager ||= LowCardTables::LowCardTable::RowManager.new(self)
169
+ end
170
+
171
+ # All of these methods get delegated to the LowCardRowManager, which does most of the actual work. We prefix
172
+ # them all with +low_card_+ in order to ensure that we can't possibly collide with methods provided by other
173
+ # Gems or by client code, since many of the names are pretty generic (e.g., +all_rows+).
174
+ [ :all_rows, :row_for_id, :rows_for_ids, :rows_matching, :ids_matching, :find_ids_for, :find_or_create_ids_for,
175
+ :find_rows_for, :find_or_create_rows_for, :flush_cache!, :referring_models, :value_column_names, :referred_to_by,
176
+ :collapse_rows_and_update_referrers!, :ensure_has_unique_index!, :remove_unique_index! ].each do |delegated_method_name|
177
+ define_method("low_card_#{delegated_method_name}") do |*args|
178
+ _low_card_row_manager.send(delegated_method_name, *args)
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end