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,214 @@
1
+ module LowCardTables
2
+ module LowCardTable
3
+ # This class is responsible for caching all the rows for a given low-card table, and then returning various subsets
4
+ # of those rows on demand.
5
+ #
6
+ # This class is actually pretty simple, and it's largely for one big reason: our cache is very simple -- we cache
7
+ # the entire contents of the table, in memory, and the only way we update it is to throw out the entire cache and
8
+ # create a brand-new one. As such, from the inside, this class has no mechanisms whatsoever for updating the cache
9
+ # (as this object is thrown away and a whole new Cache object created when the cache is invalidated) nor are there
10
+ # any methods for worrying about what should be in cache, doing a LRU, or anything like that.
11
+ class Cache
12
+ # Creates a new Cache for the given +model_class+, which must be an ActiveRecord::Base subclass that has declared
13
+ # +is_low_card_table+.
14
+ #
15
+ # +options+ can contain:
16
+ #
17
+ # [:max_row_count] By default, the cache will raise a fatal error if trying to cache a low-card table that contains
18
+ # more than DEFAULT_MAX_ROW_COUNT (5,000) rows. This is provided so that we detect early on if you
19
+ # start storing data via the low-card system that is not, in fact, of low cardinality. However,
20
+ # if you really are using a low-card table properly and just happen to have more than 5,000
21
+ # distinct combinations of values, you can increase this limit via this option.
22
+ def initialize(model_class, options = { })
23
+ unless model_class.respond_to?(:is_low_card_table?) && model_class.is_low_card_table?
24
+ raise ArgumentError, "You must supply a class that is a model class for a low-card table, not #{model_class.inspect}."
25
+ end
26
+
27
+ @model_class = model_class
28
+ @options = options
29
+
30
+ fill!
31
+ end
32
+
33
+ # At what time was this cache loaded? This is used (in conjunction with a cache-expiration policy object) to
34
+ # determine if the cache is stale or not.
35
+ def loaded_at
36
+ @rows_read_at
37
+ end
38
+
39
+ # This behaves identically to #rows_matching, except that, everywhere a low-card model object would be returned,
40
+ # a simple integer ID is returned instead.
41
+ def ids_matching(hash_or_hashes = nil, &block)
42
+ matching = rows_matching(hash_or_hashes, &block)
43
+
44
+ out = case matching
45
+ when Array then matching.map(&:id)
46
+ when Hash then
47
+ h = { }
48
+ matching.each { |k,v| h[k] = v.map(&:id) }
49
+ h
50
+ when nil then nil
51
+ else raise "Unknown return value from #rows_matching; this should never happen: #{matching.inspect}"
52
+ end
53
+
54
+ out
55
+ end
56
+
57
+ # Returns an Array of all models of the low-card table in the cache. The order is undefined.
58
+ def all_rows
59
+ @rows_by_id.values
60
+ end
61
+
62
+ # Given a single numeric ID of a low-card row, returns that low-card model object.
63
+ #
64
+ # Given an Array of one or more IDs of low-card rows, returns a Hash mapping each of those IDs to the
65
+ # corresponding low-card model object.
66
+ #
67
+ # Raises LowCardTables::Errors::LowCardIdNotFoundError if any of the supplied IDs are not found in the cache.
68
+ # (It is up to calling code -- in our case, the RowManager -- to flush the cache and try again, if desired.)
69
+ def rows_for_ids(id_or_ids)
70
+ ids = Array(id_or_ids)
71
+
72
+ missing_ids = [ ]
73
+ out = { }
74
+
75
+ ids.each do |id|
76
+ r = @rows_by_id[id]
77
+ if r
78
+ out[id] = r
79
+ else
80
+ missing_ids << id
81
+ end
82
+ end
83
+
84
+ unless missing_ids.length == 0
85
+ raise LowCardTables::Errors::LowCardIdNotFoundError.new("Can't find IDs for low-card table #{@model_class.table_name}: #{missing_ids.join(", ")}", missing_ids)
86
+ end
87
+
88
+ if id_or_ids.kind_of?(Array)
89
+ out
90
+ else
91
+ out[id_or_ids]
92
+ end
93
+ end
94
+
95
+ # Returns a subset of rows in the cache that match a specified set of conditions. (This can be all rows or no
96
+ # rows, depending on the conditions, or any set in between.)
97
+ #
98
+ # You can specify conditions in the following ways:
99
+ #
100
+ # * A single Hash, passed as an argument. The return value will be an Array that contains zero or more instances
101
+ # of the low-card model class. Only instances that have columns matching the specified Hash will be returned.
102
+ # * An array of one or more Hashes, passed as an argument. The return value will be a Hash; as keys, it will have
103
+ # exactly the Hashes you passed in. The value for each key will be an array of zero or more instances of the
104
+ # low-card model class, each of which matches the corresponding key.
105
+ # * A block, passed to the method as a normal Ruby block. The return value will be an Array that contains zero
106
+ # or more instances of the low-card model class. The block will be invoked with every instance of the low-card
107
+ # model class, and only those instances where the block returns a value that evaluates to true will be included.
108
+ #
109
+ # In the form where you pass in an array of one or more Hashes, it is possible for the same low-card row to show
110
+ # up in multiple values in the returned Hash, if it matches more than one of the supplied Hashes.
111
+ #
112
+ # Note that all matching is done via LowCardTables::LowCardTable::Base#_low_card_row_matches_any_hash?. See that
113
+ # method for more documentation -- by overriding it, you can change the behavior of this method.
114
+ def rows_matching(hash_or_hashes = nil, &block)
115
+ hashes = hash_or_hashes || [ ]
116
+ hashes = [ hashes ] unless hashes.kind_of?(Array)
117
+ hashes.each { |h| raise ArgumentError, "You must supply Hashes, not: #{h.inspect}" unless h.kind_of?(Hash) }
118
+
119
+ if block && hashes.length > 0
120
+ raise ArgumentError, "You can supply either one or more Hashes to match against OR a block, but not both. Hashes: #{hashes.inspect}; block: #{block.inspect}"
121
+ elsif (! block) && hashes.length == 0
122
+ raise ArgumentError, "You must supply either one or more Hashes to match against or a block; you supplied neither."
123
+ end
124
+
125
+
126
+ if hashes.length > 0
127
+ out = { }
128
+
129
+ hashes.each { |h| out[h] = [ ] }
130
+
131
+ @rows_by_id.each do |id,r|
132
+ hashes.each do |h|
133
+ out[h] << r if r._low_card_row_matches_any_hash?([ h.with_indifferent_access ])
134
+ end
135
+ end
136
+
137
+ if hash_or_hashes.kind_of?(Array)
138
+ out
139
+ else
140
+ out[hash_or_hashes]
141
+ end
142
+ else
143
+ @rows_by_id.values.select { |r| r._low_card_row_matches_block?(block) }
144
+ end
145
+ end
146
+
147
+ private
148
+ # Fills the cache. This can only ever be called once.
149
+ def fill!
150
+ # Explode if we don't have a unique index on the underlying table -- because then we have no way of
151
+ # guaranteeing that we won't create duplicate rows. (I am of the opinion that you'll never *truly* be certain
152
+ # that your data obeys rules unless they're in the database, as constraints; application code can be as
153
+ # careful as it wants to be, but, somehow, things always slip by unless you define constraints at the
154
+ # database layer.)
155
+ @model_class.low_card_ensure_has_unique_index!
156
+
157
+ raise "Cannot fill: we already have values!" if @rows_by_id
158
+
159
+ # We grab the time before we have fired off the query; this makes sure we don't create a race condition where
160
+ # we think the cache is just a tiny bit newer than it actually is.
161
+ read_rows_time = current_time
162
+
163
+ # We ask for one more than the number of rows we are willing to accept here; this is so that if we have
164
+ # too many rows, we can detect it, but we still won't do something crazy like try to load one million
165
+ # rows into memory.
166
+ raw_rows = @model_class.order("#{@model_class.primary_key} ASC").limit(max_row_count + 1).to_a
167
+ raise_too_many_rows_error if raw_rows.length > max_row_count
168
+
169
+ # Yes, we should never have duplicate IDs here -- it should be a primary key. But if it isn't for some reason,
170
+ # we want to explode right away, rather than failing in strange, unpredicatable, and awful ways later.
171
+ out = { }
172
+ raw_rows.each do |raw_row|
173
+ id = raw_row.id
174
+ raise_duplicate_id_error(id, out[id], raw_row) if out[id]
175
+ out[id] = raw_row
176
+ end
177
+
178
+ @rows_by_id = out
179
+ @rows_read_at = read_rows_time
180
+ end
181
+
182
+ # This is broken out into a separate method merely for ease of testing.
183
+ def current_time
184
+ Time.now
185
+ end
186
+
187
+ DEFAULT_MAX_ROW_COUNT = 5_000
188
+
189
+ def max_row_count
190
+ @options[:max_row_count] || DEFAULT_MAX_ROW_COUNT
191
+ end
192
+
193
+ def raise_too_many_rows_error
194
+ raise LowCardTables::Errors::LowCardTooManyRowsError, %{We tried to read in all the rows for low-card table '#{@model_class.table_name}', but there were
195
+ more rows that we are willing to handle -- there are at least #{max_row_count + 1}.
196
+ Most likely, something has gone horribly wrong with your low-card table (such as you
197
+ starting to store data that is not, in fact, low-cardinality at all). Alternatively,
198
+ perhaps you need to declare :max_row_count => (some larger value) in your
199
+ is_low_card_table declaration.}
200
+ end
201
+
202
+ def raise_duplicate_id_error(id, row_one, row_two)
203
+ raise %{Duplicate ID in low-card table for class #{@model_class}!
204
+ We have at least two rows with ID #{id.inspect}:
205
+
206
+ #{row_one}
207
+
208
+ and
209
+
210
+ #{row_two}}
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,151 @@
1
+ require 'active_support/time'
2
+
3
+ module LowCardTables
4
+ module LowCardTable
5
+ module CacheExpiration
6
+ # If you don't understand the low-card cache and why cache-expiration policies are interesting and important,
7
+ # please read the Github Wiki page at https://github.com/ageweke/low_card_tables/wiki/Caching for more
8
+ # background first.
9
+ #
10
+ # An ExponentialCacheExpirationPolicy is by far the most sophisticated cache-expiration policy allowed. It breaks
11
+ # apart the cache-expiration time into three separate regions. In order, they are:
12
+ #
13
+ # * <b>Zero Floor</b>, an intitial period of time (which can be zero in length) during which the cache expires
14
+ # immediately -- effectively meaning there is no cache;
15
+ # * <b>Exponential Increase</b>, where the cache-expiration time starts at a given minimum, and increases by
16
+ # some geometric factor at each expiration thereafter;
17
+ # * <b>Maximum</b>, where the cache-expiration time tops out at a particular value.
18
+ #
19
+ # The idea is that, in any system with a significant amount of production traffic, the stable state has basically
20
+ # no new low-cardinality values being created at all -- any combination that can be created, will have already
21
+ # been created. (This is true for a very large number of systems; but, of course, it all depends on _your_
22
+ # particular system. It's possible you have a very "long-tailed" set of low-card values.)
23
+ #
24
+ # As such, it's safe to cache low-card tables for very long periods of time in the steady state. However, after
25
+ # a deploy that introduces code that can create never-before-seen combinations of low-card values, there will be
26
+ # new values created relatively rapidly, with the creation rate tapering off over time until we reach a steady
27
+ # state where no new values are created at all. This fits exactly the model of the
28
+ # ExponentialCacheExpirationPolicy.
29
+ #
30
+ # A word about measured timings:
31
+ #
32
+ # Measured timings are completely deterministic and do not depend on when the cache is actually accessed. That is,
33
+ # one way of implementing this class would be to only check and advance what period we're in when someone calls
34
+ # #stale? on it. However, this would mean that thinking about how the class works is very difficult: what time
35
+ # period we're in depends on how often someone has asked us whether we're stale or not.
36
+ #
37
+ # Instead, the start time of this object (that is, the time when the +:zero_floor+ begins) is passed in to the
38
+ # constructor, as +:start_time+. All time periods involved start from this point and are measured back-to-back --
39
+ # that is, the first exponential time period begins immediately upon completion of the +:zero_floor+ period, the
40
+ # next one immediately after that, and so on. (No, this doesn't happen in real time; there's no thread waiting
41
+ # around just to update this object. Rather, when needed, we determine which period we're in on-demand.)
42
+ class ExponentialCacheExpirationPolicy
43
+ # Creates a new instance. +options+ must be a Hash that can must contain:
44
+ #
45
+ # * +:start_time+: The time at which this caching policy should start -- _i.e._, the start time for the
46
+ # zero floor. This must be a Time object.
47
+ #
48
+ # ...and can contain any of the following:
49
+ #
50
+ # * +:zero_floor_time+: The amount of time at the start that the cache will not cache anything; default is
51
+ # three minutes.
52
+ # * +:min_time+: Once the zero floor has completed, the initial period during which the cache will be valid;
53
+ # default is ten seconds.
54
+ # * +:exponent+: Once the initial +:min_time+ period has passed, subsequent periods will each geometrically
55
+ # increase by this exponent. (For example, if :+min_time+ is 3.0, and +:exponent+ is 1.5, then the first
56
+ # period will be 3.0 seconds; the second will be 3.0 * 1.5 = 4.5 seconds; the third will be 4.5 * 1.5 = 6.75
57
+ # seconds; and so on.) Default is 2.0, meaning the validity time doubles.
58
+ # * +:max_time+: Once the cache validity period reaches +:max_time+ seconds, it is pinned at this value, and
59
+ # will not increase further. Default is one hour.
60
+ def initialize(options)
61
+ options.assert_valid_keys(:zero_floor_time, :min_time, :exponent, :max_time, :start_time)
62
+
63
+ @start_time = options[:start_time]
64
+ raise ArgumentError, "start_time cannot be #{@start_time.inspect}" unless @start_time && @start_time.kind_of?(::Time)
65
+
66
+ @zero_floor = options[:zero_floor_time] || 3.minutes
67
+ @min_time = options[:min_time] || 10.seconds
68
+ @exponent = options[:exponent] || 2.0
69
+ @max_time = options[:max_time] || 1.hour
70
+
71
+ raise ArgumentError, "zero_floor_time cannot be #{@zero_floor.inspect}" unless @zero_floor.kind_of?(Numeric) && @zero_floor >= 0.0
72
+ raise ArgumentError, "min_time cannot be #{@min_time.inspect}" unless @min_time.kind_of?(Numeric) && @min_time > 1.0
73
+ raise ArgumentError, "exponent cannoot be #{@exponent.inspect}" unless @exponent.kind_of?(Numeric) && @exponent > 1.0
74
+ raise ArgumentError, "max_time cannot be #{@max_time.inspect}" unless @max_time.kind_of?(Numeric) && @max_time > 0.0 && @max_time > @min_time
75
+
76
+ # @segment_start_time is the time at which the current segment started.
77
+ # @segment_end_time is the time at which the current segment ends.
78
+ # @segment_expiration_time is the time at which the current cache will expire (absolute time, not relative);
79
+ # typically this will be the same as @segment_end_time, but it's different (zero) during the initial
80
+ # @min_time period.
81
+ @segment_start_time = @start_time
82
+
83
+ if @zero_floor > 0
84
+ @segment_end_time = @segment_start_time + @zero_floor
85
+ @segment_expiration_time = 0
86
+ else
87
+ @segment_end_time = @segment_start_time + @min_time
88
+ @segment_expiration_time = @min_time
89
+ end
90
+
91
+ # Let's just make sure the clock doesn't run backwards, shall we?
92
+ @last_seen_time = @start_time
93
+ end
94
+
95
+ attr_reader :zero_floor, :min_time, :exponent, :max_time
96
+
97
+ # Called by LowCardTables::LowCardTable::Cache; this indicates whether the cache is stale or not. In order to
98
+ # make testing easier, the time at which the cache was read (+cache_time+) and the current time (+current_time+)
99
+ # are passed in, rather than implied using +Time.now+.
100
+ #
101
+ # It is an error to call this method with a +current_time+ that is before a previously-seen +current_time+.
102
+ # (In other words, no, the clock can't run backwards.)
103
+ def stale?(cache_time, current_time)
104
+ if current_time < @last_seen_time
105
+ raise ArgumentError, "Our clock is running backwards?!? We have previously seen a time of #{@last_seen_time.to_f}, and now it's #{current_time.to_f}?"
106
+ elsif current_time > @last_seen_time
107
+ @last_seen_time = current_time
108
+ end
109
+
110
+ next_segment! until within_current_segment?(current_time)
111
+
112
+ out = if cache_time < @segment_start_time
113
+ true
114
+ else
115
+ (current_time - cache_time) >= @segment_expiration_time
116
+ end
117
+
118
+ out
119
+ end
120
+
121
+ private
122
+ attr_reader :start_time
123
+
124
+ # Are we within the current segment, according to @segment_start_time and @segment_end_time?
125
+ def within_current_segment?(cache_time)
126
+ cache_time < @segment_end_time
127
+ end
128
+
129
+ # Advances @segment_start_time, @segment_end_time, and @segment_expiration_time to the next segment.
130
+ def next_segment!
131
+ if @segment_expiration_time == 0
132
+ # We're in the initial @min_time segment -- advance to the first exponential.
133
+ @segment_start_time += @zero_floor
134
+ @segment_end_time = @segment_start_time + @min_time
135
+ @segment_expiration_time = @min_time
136
+ elsif @segment_expiration_time >= @max_time
137
+ # We're in the final @max_time -- simply create another period of that duration.
138
+ @segment_start_time = @segment_end_time
139
+ @segment_end_time = @segment_start_time + @max_time
140
+ @segment_expiration_time = @max_time
141
+ else
142
+ # We're in the exponential-increase period -- move to the next, longer, period.
143
+ @segment_start_time = @segment_end_time
144
+ @segment_expiration_time = [ @segment_expiration_time * @exponent, @max_time ].min
145
+ @segment_end_time = @segment_start_time + @segment_expiration_time
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,23 @@
1
+ module LowCardTables
2
+ module LowCardTable
3
+ module CacheExpiration
4
+ # A FixedCacheExpirationPolicy is a very simple kind of cache-expiration policy: the cache expires a certain
5
+ # amount of time after it is filled, every time.
6
+ class FixedCacheExpirationPolicy
7
+ def initialize(expiration_time)
8
+ unless expiration_time && expiration_time.kind_of?(Numeric) && expiration_time >= 0.0
9
+ raise ArgumentError, "Expiration time must be a nonnegative number, not: #{expiration_time.inspect}"
10
+ end
11
+
12
+ @expiration_time = expiration_time
13
+ end
14
+
15
+ def stale?(cache_time, current_time)
16
+ (current_time - cache_time) >= @expiration_time
17
+ end
18
+
19
+ attr_reader :expiration_time
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,100 @@
1
+ require File.join(File.dirname(__FILE__), 'exponential_cache_expiration_policy')
2
+ require File.join(File.dirname(__FILE__), 'fixed_cache_expiration_policy')
3
+ require File.join(File.dirname(__FILE__), 'unlimited_cache_expiration_policy')
4
+ require File.join(File.dirname(__FILE__), 'no_caching_expiration_policy')
5
+
6
+ module LowCardTables
7
+ module LowCardTable
8
+ module CacheExpiration
9
+ # This is a module that gets mixed in to LowCardTables::LowCardTable::Base. It provides the class it gets mixed
10
+ # into with a #low_card_cache_expiration method that can be called with various values (a number, zero, +:unlimited+,
11
+ # or +:exponential+ with options) to set the cache-expiration policy, or called with no arguments to return the
12
+ # cache-expiration policy as one of those same values.
13
+ #
14
+ # The value is stored on a class-by-class basis, and is inherited. This provides the behavior we want: if you set
15
+ # a policy on LowCardTables::LowCardTable::Base, it will be applied to all low-card tables; if you set a policy
16
+ # on an individual table, it will apply to only that table and will override any policy applied to the base.
17
+ module HasCacheExpiration
18
+ extend ActiveSupport::Concern
19
+
20
+ module ClassMethods
21
+ # Sets the cache-expiration policy of this class. You can pass:
22
+ #
23
+ # * A positive number -- sets the cache-expiration policy to be that many seconds.
24
+ # * Zero -- turns off caching entirely.
25
+ # * +:unlimited+ -- makes the cache last forever.
26
+ # * :+exponential+, optionally with an options hash as the second argument -- sets the cache-expiration policy
27
+ # to be exponential; see ExponentialCacheExpirationPolicy for more details.
28
+ #
29
+ # If you pass no arguments at all, you're returned the current cache-expiration policy.
30
+ #
31
+ # If you do not call this method at all, the cache-expiration policy will be undefined. You should always either
32
+ # call this method, or set up an inheritance chain using #low_card_cache_policy_inherits_from, to a class
33
+ # that does have it defined.
34
+ def low_card_cache_expiration(type_or_number = nil, options = { })
35
+ if type_or_number.nil?
36
+ @_low_card_cache_expiration_return_value || low_card_cache_expiration_inherited
37
+ else
38
+ if type_or_number == 0
39
+ @_low_card_cache_expiration_policy_object = LowCardTables::LowCardTable::CacheExpiration::NoCachingExpirationPolicy.new
40
+ @_low_card_cache_expiration_return_value = 0
41
+ elsif type_or_number.kind_of?(Numeric)
42
+ @_low_card_cache_expiration_policy_object = LowCardTables::LowCardTable::CacheExpiration::FixedCacheExpirationPolicy.new(type_or_number)
43
+ @_low_card_cache_expiration_return_value = type_or_number
44
+ elsif type_or_number == :unlimited
45
+ @_low_card_cache_expiration_policy_object = LowCardTables::LowCardTable::CacheExpiration::UnlimitedCacheExpirationPolicy.new
46
+ @_low_card_cache_expiration_return_value = :unlimited
47
+ elsif type_or_number == :exponential
48
+ @_low_card_cache_expiration_policy_object = LowCardTables::LowCardTable::CacheExpiration::ExponentialCacheExpirationPolicy.new(options.merge(:start_time => low_card_current_time))
49
+ if options.size > 0
50
+ @_low_card_cache_expiration_return_value = [ :exponential, options ]
51
+ else
52
+ @_low_card_cache_expiration_return_value = :exponential
53
+ end
54
+ else
55
+ raise ArgumentError, "Invalid cache expiration time argumnet '#{type_or_number.inspect}'; you must pass a number, :unlimited, or :exponential."
56
+ end
57
+ end
58
+ end
59
+
60
+ # For +low_card_tables+ internal use only. Returns the current cache-expiration policy object -- e.g., an
61
+ # instance of LowCardTables::LowCardTable::CacheExpiration::FixedCacheExpirationPolicy.
62
+ def low_card_cache_expiration_policy_object
63
+ @_low_card_cache_expiration_policy_object || low_card_cache_expiration_policy_object_inherited
64
+ end
65
+
66
+ # Declares that this class should inherit its cache-expiration policy from the specified other class, which
67
+ # must be a class that has had this module included in it (directly or indirectly) too. In other words, if
68
+ # you don't set a policy on this class, but have specified that it inherits its policy from the given other
69
+ # class, then it will use whatever policy the other class has set.
70
+ #
71
+ # We use this because low-card tables' classes all inherit directly from ::ActiveRecord::Base; they don't
72
+ # inherit from some parent "it's a low-card table" class. (This is because inheritance in ActiveRecord is
73
+ # used to denote data in common via STI, not what we're trying to do here.) So we use this to set up the
74
+ # default policy directly on the ::LowCardTables root module, and 'inherit' it into individual low-card tables
75
+ # (via the +included+ block on LowCardTables::LowCardTable::Base) this way.
76
+ def low_card_cache_policy_inherits_from(other_class)
77
+ @_low_card_cache_policy_inherits_from = other_class
78
+ end
79
+
80
+ private
81
+ # We break this into a separate method simply for ease of testing -- some of our specs override it.
82
+ def low_card_current_time
83
+ Time.now
84
+ end
85
+
86
+ # Returns the proper return value for #low_card_cache_expiration from the 'inherited' class, if there is one.
87
+ def low_card_cache_expiration_inherited
88
+ @_low_card_cache_policy_inherits_from.low_card_cache_expiration if @_low_card_cache_policy_inherits_from
89
+ end
90
+
91
+ # Returns the proper return value for #low_card_cache_expiration_policy_object from the 'inherited' class,
92
+ # if there is one.
93
+ def low_card_cache_expiration_policy_object_inherited
94
+ @_low_card_cache_policy_inherits_from.low_card_cache_expiration_policy_object if @_low_card_cache_policy_inherits_from
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end