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