low_card_tables 1.0.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 (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