low_card_tables 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.travis.yml +59 -0
- data/Gemfile +17 -0
- data/LICENSE +21 -0
- data/README.md +75 -0
- data/Rakefile +6 -0
- data/lib/low_card_tables.rb +72 -0
- data/lib/low_card_tables/active_record/base.rb +55 -0
- data/lib/low_card_tables/active_record/migrations.rb +223 -0
- data/lib/low_card_tables/active_record/relation.rb +35 -0
- data/lib/low_card_tables/active_record/scoping.rb +87 -0
- data/lib/low_card_tables/errors.rb +74 -0
- data/lib/low_card_tables/has_low_card_table/base.rb +114 -0
- data/lib/low_card_tables/has_low_card_table/low_card_association.rb +273 -0
- data/lib/low_card_tables/has_low_card_table/low_card_associations_manager.rb +143 -0
- data/lib/low_card_tables/has_low_card_table/low_card_dynamic_method_manager.rb +224 -0
- data/lib/low_card_tables/has_low_card_table/low_card_objects_manager.rb +80 -0
- data/lib/low_card_tables/low_card_table/base.rb +184 -0
- data/lib/low_card_tables/low_card_table/cache.rb +214 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/exponential_cache_expiration_policy.rb +151 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/fixed_cache_expiration_policy.rb +23 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/has_cache_expiration.rb +100 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/no_caching_expiration_policy.rb +13 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/unlimited_cache_expiration_policy.rb +13 -0
- data/lib/low_card_tables/low_card_table/row_collapser.rb +175 -0
- data/lib/low_card_tables/low_card_table/row_manager.rb +681 -0
- data/lib/low_card_tables/low_card_table/table_unique_index.rb +134 -0
- data/lib/low_card_tables/version.rb +4 -0
- data/lib/low_card_tables/version_support.rb +52 -0
- data/low_card_tables.gemspec +69 -0
- data/spec/low_card_tables/helpers/database_helper.rb +148 -0
- data/spec/low_card_tables/helpers/query_spy_helper.rb +47 -0
- data/spec/low_card_tables/helpers/system_helpers.rb +63 -0
- data/spec/low_card_tables/system/basic_system_spec.rb +254 -0
- data/spec/low_card_tables/system/bulk_system_spec.rb +334 -0
- data/spec/low_card_tables/system/caching_system_spec.rb +531 -0
- data/spec/low_card_tables/system/migrations_system_spec.rb +747 -0
- data/spec/low_card_tables/system/options_system_spec.rb +581 -0
- data/spec/low_card_tables/system/queries_system_spec.rb +142 -0
- data/spec/low_card_tables/system/validations_system_spec.rb +88 -0
- data/spec/low_card_tables/unit/active_record/base_spec.rb +53 -0
- data/spec/low_card_tables/unit/active_record/migrations_spec.rb +207 -0
- data/spec/low_card_tables/unit/active_record/relation_spec.rb +47 -0
- data/spec/low_card_tables/unit/active_record/scoping_spec.rb +101 -0
- data/spec/low_card_tables/unit/has_low_card_table/base_spec.rb +79 -0
- data/spec/low_card_tables/unit/has_low_card_table/low_card_association_spec.rb +287 -0
- data/spec/low_card_tables/unit/has_low_card_table/low_card_associations_manager_spec.rb +190 -0
- data/spec/low_card_tables/unit/has_low_card_table/low_card_dynamic_method_manager_spec.rb +234 -0
- data/spec/low_card_tables/unit/has_low_card_table/low_card_objects_manager_spec.rb +70 -0
- data/spec/low_card_tables/unit/low_card_table/base_spec.rb +207 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/exponential_cache_expiration_policy_spec.rb +128 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/fixed_cache_expiration_policy_spec.rb +25 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/has_cache_expiration_policy_spec.rb +100 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/no_caching_expiration_policy_spec.rb +14 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/unlimited_cache_expiration_policy_spec.rb +14 -0
- data/spec/low_card_tables/unit/low_card_table/cache_spec.rb +282 -0
- data/spec/low_card_tables/unit/low_card_table/row_collapser_spec.rb +109 -0
- data/spec/low_card_tables/unit/low_card_table/row_manager_spec.rb +918 -0
- data/spec/low_card_tables/unit/low_card_table/table_unique_index_spec.rb +117 -0
- metadata +206 -0
@@ -0,0 +1,13 @@
|
|
1
|
+
module LowCardTables
|
2
|
+
module LowCardTable
|
3
|
+
module CacheExpiration
|
4
|
+
# This is a very simple cache-expiration policy that disables caching entirely -- it makes the cache always
|
5
|
+
# stale, which means we will reload it from the database every single time.
|
6
|
+
class NoCachingExpirationPolicy
|
7
|
+
def stale?(cache_time, current_time)
|
8
|
+
true
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module LowCardTables
|
2
|
+
module LowCardTable
|
3
|
+
module CacheExpiration
|
4
|
+
# This is a very simple cache-expiration policy that makes the cache last forever -- it will never be reloaded
|
5
|
+
# from disk, unless you explicitly flush it.
|
6
|
+
class UnlimitedCacheExpirationPolicy
|
7
|
+
def stale?(cache_time, current_time)
|
8
|
+
false
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,175 @@
|
|
1
|
+
module LowCardTables
|
2
|
+
module LowCardTable
|
3
|
+
# The RowCollapser is an object that exists solely to contain the code required to collapse rows when someone
|
4
|
+
# removes a column from a low-card table in a migration. It's not a particularly well-defined object and resulted
|
5
|
+
# from an extraction from RowManager; however, it's still nicer to have this code in a separate object rather than
|
6
|
+
# making the RowManager even bigger than it already is.
|
7
|
+
#
|
8
|
+
# What are we trying to accomplish here? Well, imagine you have this:
|
9
|
+
#
|
10
|
+
# user_statuses
|
11
|
+
# id deleted donation_level gender
|
12
|
+
# 1 false 3 female
|
13
|
+
# 2 false 5 female
|
14
|
+
# 3 false 7 female
|
15
|
+
# 4 false 3 male
|
16
|
+
# 5 false 5 male
|
17
|
+
# 6 false 7 male
|
18
|
+
#
|
19
|
+
# ...and now imagine we decide to remove the +deceased+ column. If we do nothing, we'll end up with this:
|
20
|
+
#
|
21
|
+
# user_statuses
|
22
|
+
# id deleted gender
|
23
|
+
# 1 false female
|
24
|
+
# 2 false female
|
25
|
+
# 3 false female
|
26
|
+
# 4 false male
|
27
|
+
# 5 false male
|
28
|
+
# 6 false male
|
29
|
+
#
|
30
|
+
# ...but this violates the principle of low-card tables that they have only one row for each unique combination of
|
31
|
+
# values. What we need to do is reduce it to this...
|
32
|
+
#
|
33
|
+
# user_statuses
|
34
|
+
# id deleted gender
|
35
|
+
# 1 false female
|
36
|
+
# 4 false male
|
37
|
+
#
|
38
|
+
# ...and then update all columns in all tables that have a +user_status_id+ like so:
|
39
|
+
#
|
40
|
+
# UPDATE users SET user_status_id = 1 WHERE user_status_id IN (2, 3)
|
41
|
+
# UPDATE users SET user_status_id = 4 WHERE user_status_id IN (5, 6)
|
42
|
+
#
|
43
|
+
# That's the job of this class. LowCardTables::HasLowCardTable::LowCardAssociation is responsible for updating the
|
44
|
+
# referring tables themselves; however, this class is responsible for the fundamental operation.
|
45
|
+
#
|
46
|
+
# In this class, we often refer to the "collapse map"; in the above example, this would be:
|
47
|
+
#
|
48
|
+
# #<UserStatus id: 1> => [ #<UserStatus id: 2>, #<UserStatus id: 3> ]
|
49
|
+
# #<UserStatus id: 4> => [ #<UserStatus id: 5>, #<UserStatus id: 6> ]
|
50
|
+
#
|
51
|
+
# The keys are the rows of the table that have been collapsed _to_; the values are arrays of rows that have been
|
52
|
+
# collapsed _from_.
|
53
|
+
class RowCollapser
|
54
|
+
# Creates a new instance. +low_card_model+ is the ActiveRecord model class of the low-card table itself;
|
55
|
+
# +low_card_options+ is the set of options passed to whatever migration method (e.g., +remove_column+) was
|
56
|
+
# invoked to cause the need for a collapse. Options that we pay attention to are:
|
57
|
+
#
|
58
|
+
# [:low_card_collapse_rows] If present but +false+ or +nil+, then no row collapsing will happen due to the
|
59
|
+
# migration command; you'll be left with an invalid low-card table with no unique
|
60
|
+
# index, and will need to fix this problem yourself before you can use the table.
|
61
|
+
# [:low_card_referrers] Adds one or more models as "referring models" that will have any references to this
|
62
|
+
# model updated when the collapsing is done. Generally speaking, it should not be necessary
|
63
|
+
# to do this -- this code is aggressive about eagerly loading all models, and ensuring that
|
64
|
+
# any that refer to this table are used. But this is available in case you need it.
|
65
|
+
# [:low_card_update_referring_models] If present but +false+ or +nil+, then row collapsing will occur as normal,
|
66
|
+
# but no referring columns will be updated. You'll thus have dangling foreign
|
67
|
+
# keys in any referring models; you'll have to update them yourself.
|
68
|
+
def initialize(low_card_model, low_card_options)
|
69
|
+
unless low_card_model.respond_to?(:is_low_card_table?) && low_card_model.is_low_card_table?
|
70
|
+
raise ArgumentError, "You must supply a low-card AR model class, not: #{low_card_model.inspect}"
|
71
|
+
end
|
72
|
+
|
73
|
+
@low_card_model = low_card_model
|
74
|
+
@low_card_options = low_card_options
|
75
|
+
end
|
76
|
+
|
77
|
+
# This should be called after any migration operation on the table that may have caused it to now have
|
78
|
+
# duplicate rows. This method looks at the table, detects duplicate rows, picks out winners (and the
|
79
|
+
# corresponding losers), and updates rows and referring rows, contingent upon the +low_card_options+ passed
|
80
|
+
# in the constructor.
|
81
|
+
#
|
82
|
+
# Notably, you don't need to tell this method _what_ you did to the table; it simply looks at the current state
|
83
|
+
# of the table and deals with duplicate rows. It also means this method is perfectly safe to call on a table that
|
84
|
+
# has had no changes, or a table that has had migrations performed on it that don't result in duplicate rows;
|
85
|
+
# it will simply see that there are no duplicate rows in the table, and do nothing.
|
86
|
+
#
|
87
|
+
# This method returns the "collapse map"; see the comment on this class overall for more information. This allows
|
88
|
+
# you to do anything you want with the calculated collapse. Normally, you don't _have_ to do anything with it and
|
89
|
+
# can ignore it, but it can also be useful if you pass <tt>:low_card_update_referring_models => false</tt> in
|
90
|
+
# the +low_card_options+.
|
91
|
+
def collapse!
|
92
|
+
# :low_card_collapse_rows tells this method to do nothing at all.
|
93
|
+
return if low_card_options.has_key?(:low_card_collapse_rows) && (! low_card_options[:low_card_collapse_rows])
|
94
|
+
|
95
|
+
additional_referring_models = low_card_options[:low_card_referrers]
|
96
|
+
|
97
|
+
# First, we build a map. The keys are Hashes representing each unique combination of attributes found for
|
98
|
+
# the table; the value is an Array of all rows (model objects) for that key. (In a normal state, each value
|
99
|
+
# would have exactly one element in the array; however, because we may just have migrated the table into a
|
100
|
+
# state where we need to collapse the rows, this may not be true at the moment.)
|
101
|
+
attributes_to_rows_map = { }
|
102
|
+
low_card_model.all.sort_by(&:id).each do |row|
|
103
|
+
attributes = value_attributes(row)
|
104
|
+
|
105
|
+
attributes_to_rows_map[attributes] ||= [ ]
|
106
|
+
attributes_to_rows_map[attributes] << row
|
107
|
+
end
|
108
|
+
|
109
|
+
return { } if (! attributes_to_rows_map.values.detect { |a| a.length > 1 })
|
110
|
+
|
111
|
+
# Now we build the collapse_map, which is very similar to the attributes_to_rows_map, above. We pick the first
|
112
|
+
# of the values to be the winner in each case, which, because we've sorted the rows by ID, should be the
|
113
|
+
# duplicate row with the lowest ID -- this is as reasonable a way to pick winners as any.
|
114
|
+
collapse_map = { }
|
115
|
+
attributes_to_rows_map.each do |attributes, rows|
|
116
|
+
if rows.length > 1
|
117
|
+
winner = rows.shift
|
118
|
+
losers = rows
|
119
|
+
|
120
|
+
collapse_map[winner] = losers
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
# Figure out which rows we need to delete; this is just all the losers.
|
125
|
+
ids_to_delete = collapse_map.values.map { |row_array| row_array.map(&:id) }.flatten.sort
|
126
|
+
low_card_model.delete_all([ "id IN (:ids)", { :ids => ids_to_delete } ])
|
127
|
+
|
128
|
+
# Figure out what referring models we need to update.
|
129
|
+
all_referring_models = low_card_model.low_card_referring_models | (additional_referring_models || [ ])
|
130
|
+
|
131
|
+
# Run transactions on all of these, plus the low-card model as well.
|
132
|
+
#
|
133
|
+
# Why do we do this? Isn't just one transaction enough? Well, in default Rails configuration, yes, because all
|
134
|
+
# models live on the same database. However, it's so common to use gems (for example, +db_charmer_) that allow
|
135
|
+
# different models to live on different databases that we make sure to run transactions on all of them;
|
136
|
+
# running nested transactions on the same database is harmless.
|
137
|
+
transaction_models = all_referring_models + [ low_card_model ]
|
138
|
+
|
139
|
+
unless low_card_options.has_key?(:low_card_update_referring_models) && (! low_card_options[:low_card_update_referring_models])
|
140
|
+
transactions_on(transaction_models) do
|
141
|
+
all_referring_models.each do |referring_model|
|
142
|
+
referring_model._low_card_update_collapsed_rows(low_card_model, collapse_map)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Return the collapse_map.
|
148
|
+
collapse_map
|
149
|
+
end
|
150
|
+
|
151
|
+
private
|
152
|
+
attr_reader :low_card_options, :low_card_model
|
153
|
+
|
154
|
+
# Given a model object, extracts a Hash that maps each of the value-column names to the value this model object
|
155
|
+
# has for that value column.
|
156
|
+
def value_attributes(row)
|
157
|
+
attributes = row.attributes
|
158
|
+
out = { }
|
159
|
+
low_card_model.low_card_value_column_names.each { |n| out[n] = attributes[n] }
|
160
|
+
out
|
161
|
+
end
|
162
|
+
|
163
|
+
# Runs transactions on all of the specified models. Because of ActiveRecord's semantics for transactions (which
|
164
|
+
# for almost all other use cases are excellent), this has to be a recursive call.
|
165
|
+
def transactions_on(transaction_models, &block)
|
166
|
+
if transaction_models.length == 0
|
167
|
+
block.call
|
168
|
+
else
|
169
|
+
model = transaction_models.shift
|
170
|
+
model.transaction { transactions_on(transaction_models, &block) }
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -0,0 +1,681 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'activerecord-import'
|
3
|
+
require 'low_card_tables/low_card_table/cache'
|
4
|
+
require 'low_card_tables/low_card_table/table_unique_index'
|
5
|
+
require 'low_card_tables/low_card_table/row_collapser'
|
6
|
+
|
7
|
+
module LowCardTables
|
8
|
+
module LowCardTable
|
9
|
+
# In many ways, the RowManager is the beating heart of +low_card_tables+. It is responsible for finding and
|
10
|
+
# creating rows in low-card tables, as well as maintaining the unique index across all columns in the table and
|
11
|
+
# dealing with any needs from migrations.
|
12
|
+
#
|
13
|
+
# Because this class is quite complex, some pieces of functionality have been broken out into other classes.
|
14
|
+
# The TableUniqueIndex is responsible for maintaining the unique index across all columns in the table, and
|
15
|
+
# the RowCollapser handles the case where rows need to be collapsed (unified) because a column was removed from
|
16
|
+
# the low-card table.
|
17
|
+
#
|
18
|
+
# === Cache Notifications
|
19
|
+
#
|
20
|
+
# This class uses the ActiveSupport::Notifications interface to notify anyone who's interested of cache-related
|
21
|
+
# events. In particular, it fires the following events with the following payloads:
|
22
|
+
#
|
23
|
+
# [low_card_tables.cache_load] <tt>{ :low_card_model => <ActiveRecord model class> }</tt>; this is fired when
|
24
|
+
# the cache is loaded from the database, whether that's the first time after startup
|
25
|
+
# or after a cache flush.
|
26
|
+
# [low_card_tables.cache_flush] <tt>{ :low_card_model => <ActiveRecord model class>, :reason => <some reason> }</tt>;
|
27
|
+
# this is fired when there's a cache that is flushed. Additional payload depends on
|
28
|
+
# the +:reason+.
|
29
|
+
#
|
30
|
+
# Reasons for +low_card_tables.cache_flush+ include:
|
31
|
+
#
|
32
|
+
# [:manually_requested] You called +low_card_flush_cache!+ on the low-card model.
|
33
|
+
# [:id_not_found] You requested a low-card row by ID, and we didn't find that ID in the cache. We assume that the ID
|
34
|
+
# is likely valid and that it's simply been created since we retrieved the cache from the database,
|
35
|
+
# so we flush the cache and try again. +:ids+ is present in the payload, mapping to an array of
|
36
|
+
# one or more IDs -- the ID or IDs that weren't found in the cache.
|
37
|
+
# [:collapse_rows_and_update_referrers] The low-card table has been migrated and has had a column removed; we've
|
38
|
+
# collapsed any now-duplicate rows properly. As such, we need to flush the
|
39
|
+
# cache.
|
40
|
+
# [:schema_change] We have detected that the schema of the low-card table has changed, and need to flush the cache.
|
41
|
+
# [:creating_rows] We're about to create one or more new rows in the low-card table, because a set of attributes
|
42
|
+
# that has never been seen before was asked for. Before we actually go try to create them, we
|
43
|
+
# lock the table and flush the cache, so that, in the case where some other process has already
|
44
|
+
# created them, we simply pick them up now. Then, after we create them, we flush the cache again
|
45
|
+
# to pick up the newly-created rows. +:context+ is present in the payload, mapped to either
|
46
|
+
# +:before_import+ or +:after_import+ (corresponding to the two situations above). +:new_rows+ is
|
47
|
+
# also present in the payload, mapped to an array of one or more Hashes, each of which represents
|
48
|
+
# a unique combination of attributes to be created.
|
49
|
+
# [:stale] By far the most common case -- the cache is simply stale based upon the current cache-expiration policy,
|
50
|
+
# and needs to be reloaded. The payload will contain +:loaded+, which is the time that the cache was
|
51
|
+
# loaded, and +:now+, which is the time at which the cache was checked for validity. (+:now+ will always
|
52
|
+
# be very close to, but not after, the current time; any delay is just due to the time it took to
|
53
|
+
# receive the notification via ActiveSupport::Notifications.)
|
54
|
+
class RowManager
|
55
|
+
attr_reader :low_card_model
|
56
|
+
|
57
|
+
# Creates a new instance for the given low-card model.
|
58
|
+
def initialize(low_card_model)
|
59
|
+
unless low_card_model.respond_to?(:is_low_card_table?) && low_card_model.is_low_card_table?
|
60
|
+
raise ArgumentError, "You must supply a low-card AR model class, not: #{low_card_model.inspect}"
|
61
|
+
end
|
62
|
+
|
63
|
+
@low_card_model = low_card_model
|
64
|
+
@table_unique_index = LowCardTables::LowCardTable::TableUniqueIndex.new(low_card_model)
|
65
|
+
@referring_models = [ ]
|
66
|
+
end
|
67
|
+
|
68
|
+
attr_reader :referring_models
|
69
|
+
|
70
|
+
# Tells us that the low-card model we're operating on behalf of is referenced by the given +referring_model_class+.
|
71
|
+
# This +referring_model_class+ should be an ActiveRecord class that has declared 'has_low_card_table' on this
|
72
|
+
# low-card table.
|
73
|
+
#
|
74
|
+
# We keep track of this and expose it for a few reasons:
|
75
|
+
#
|
76
|
+
# * If we need to collapse the rows in this low-card table because a column has been removed, we use this list of
|
77
|
+
# referring models to know which columns have a foreign key to this table;
|
78
|
+
# * When someone calls #reset_column_information on the low-card table, we re-compute (and re-install) the set of
|
79
|
+
# delegated methods from all models that refer to this low-card table.
|
80
|
+
def referred_to_by(referring_model_class)
|
81
|
+
@referring_models |= [ referring_model_class ]
|
82
|
+
end
|
83
|
+
|
84
|
+
# Tells us that someone called #reset_column_information on the low-card table; we'll inform all referring models
|
85
|
+
# of that fact.
|
86
|
+
def column_information_reset!
|
87
|
+
@referring_models.each { |m| m._low_card_associations_manager.low_card_column_information_reset!(@low_card_model) }
|
88
|
+
end
|
89
|
+
|
90
|
+
# Returns all rows in the low-card table. This behaves semantically identically to simply calling ActiveRecord's
|
91
|
+
# #all method on the low-card table itself, but it returns the data from cache.
|
92
|
+
def all_rows
|
93
|
+
cache.all_rows
|
94
|
+
end
|
95
|
+
|
96
|
+
# Flushes the cache immediately (assuming we have any cached data at all).
|
97
|
+
def flush_cache!
|
98
|
+
flush!(:manually_requested)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Given a single primary-key ID of a low-card row, returns the row for that ID. Given an Array of one or more
|
102
|
+
# primary-key IDs, returns a Hash mapping each of those IDs to the corresponding row. Properly flushes the cache
|
103
|
+
# and tries again if given an ID that doesn't exist in cache.
|
104
|
+
def rows_for_ids(id_or_ids)
|
105
|
+
begin
|
106
|
+
cache.rows_for_ids(id_or_ids)
|
107
|
+
rescue LowCardTables::Errors::LowCardIdNotFoundError => lcinfe
|
108
|
+
flush!(:id_not_found, :ids => lcinfe.ids)
|
109
|
+
cache.rows_for_ids(id_or_ids)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# A synonym for #rows_for_ids.
|
114
|
+
def row_for_id(id)
|
115
|
+
rows_for_ids(id)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Given a single Hash specifying zero or more constraints for low-card rows (i.e., mapping zero or more columns
|
119
|
+
# of the low-card table to specific values for those columns), returns a (possibly empty) Array of IDs of
|
120
|
+
# low-card rows that match those constraints.
|
121
|
+
#
|
122
|
+
# Given an array of one or more Hashes, each of which specify zero or more constraints for low-card rows, returns
|
123
|
+
# a Hash mapping each of those Hashes to a (possibly empty) Array of IDs of low-card rows that match each
|
124
|
+
# Hash.
|
125
|
+
#
|
126
|
+
# Given a block (in which case no hashes may be passed), returns an Array of IDs of low-card rows that match the
|
127
|
+
# block. The block is passed an instance of the low-card model class, and the return value of the block (truthy
|
128
|
+
# or falsy) determines whether the ID of that row is included in the return value or not.
|
129
|
+
def ids_matching(hash_or_hashes = nil, &block)
|
130
|
+
do_matching(hash_or_hashes, block, :ids_matching)
|
131
|
+
end
|
132
|
+
|
133
|
+
# Given a single Hash specifying zero or more constraints for low-card rows (i.e., mapping zero or more columns
|
134
|
+
# of the low-card table to specific values for those columns), returns a (possibly empty) Array of
|
135
|
+
# low-card rows that match those constraints.
|
136
|
+
#
|
137
|
+
# Given an array of one or more Hashes, each of which specify zero or more constraints for low-card rows, returns
|
138
|
+
# a Hash mapping each of those Hashes to a (possibly empty) Array of low-card rows that match each
|
139
|
+
# Hash.
|
140
|
+
#
|
141
|
+
# Given a block (in which case no hashes may be passed), returns an Array of low-card rows that match the
|
142
|
+
# block. The block is passed an instance of the low-card model class, and the return value of the block (truthy
|
143
|
+
# or falsy) determines whether that row is included in the return value or not.
|
144
|
+
def rows_matching(hash_or_hashes = nil, &block)
|
145
|
+
do_matching(hash_or_hashes, block, :rows_matching)
|
146
|
+
end
|
147
|
+
|
148
|
+
# Given a single Hash specifying values for every column in the low-card table, returns an instance of the
|
149
|
+
# low-card table, already existing in the database, for that combination of values.
|
150
|
+
#
|
151
|
+
# Given an array of Hashes, each specifying values for every column in the low-card table, returns a Hash
|
152
|
+
# mapping each of those Hashes to an instance of the low-card table, already existing in the database, for that
|
153
|
+
# combination of values.
|
154
|
+
#
|
155
|
+
# If you request an instance for a combination of values that doesn't exist in the table, it will simply be
|
156
|
+
# mapped to +nil+. Under no circumstances will rows be added to the database.
|
157
|
+
def find_rows_for(hash_hashes_object_or_objects)
|
158
|
+
do_find_or_create(hash_hashes_object_or_objects, false)
|
159
|
+
end
|
160
|
+
|
161
|
+
# Given a single Hash specifying values for every column in the low-card table, returns an instance of the
|
162
|
+
# low-card table for that combination of values. The row in question will be created if it doesn't already
|
163
|
+
# exist.
|
164
|
+
#
|
165
|
+
# Given an array of Hashes, each specifying values for every column in the low-card table, returns a Hash
|
166
|
+
# mapping each of those Hashes to an instance of the low-card table for that combination of values. Rows for
|
167
|
+
# any missing combinations of values will be created. (Creation is done in bulk, using +activerecord_import+,
|
168
|
+
# so this method will be fast no matter how many rows need to be created.)
|
169
|
+
def find_or_create_rows_for(hash_hashes_object_or_objects)
|
170
|
+
do_find_or_create(hash_hashes_object_or_objects, true)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Behaves identically to #find_rows_for, except that it returns IDs instead of rows.
|
174
|
+
def find_ids_for(hash_hashes_object_or_objects)
|
175
|
+
row_map_to_id_map(find_rows_for(hash_hashes_object_or_objects))
|
176
|
+
end
|
177
|
+
|
178
|
+
# Behaves identically to #find_or_create_rows_for, except that it returns IDs instead of rows.
|
179
|
+
def find_or_create_ids_for(hash_hashes_object_or_objects)
|
180
|
+
row_map_to_id_map(find_or_create_rows_for(hash_hashes_object_or_objects))
|
181
|
+
end
|
182
|
+
|
183
|
+
# Returns the set of columns on the low-card table that we should consider "value columns" -- i.e., those that
|
184
|
+
# contain data values, rather than metadata, like the primary key, created_at/updated_at, and so on.
|
185
|
+
#
|
186
|
+
# Columns that are excluded:
|
187
|
+
#
|
188
|
+
# * The primary key
|
189
|
+
# * created_at and updated_at
|
190
|
+
# * Any additional columns specified using the +:exclude_column_names+ option when declaring +is_low_card_table+.
|
191
|
+
def value_column_names
|
192
|
+
value_columns.map(&:name)
|
193
|
+
end
|
194
|
+
|
195
|
+
# Iterates through this table, finding duplicate rows and collapsing them. See RowCollapser for far more
|
196
|
+
# information.
|
197
|
+
def collapse_rows_and_update_referrers!(low_card_options = { })
|
198
|
+
collapser = LowCardTables::LowCardTable::RowCollapser.new(@low_card_model, low_card_options)
|
199
|
+
collapse_map = collapser.collapse!
|
200
|
+
|
201
|
+
flush!(:collapse_rows_and_update_referrers)
|
202
|
+
collapse_map
|
203
|
+
end
|
204
|
+
|
205
|
+
# If this table already has the correct unique index across all value columns, does nothing.
|
206
|
+
#
|
207
|
+
# If this table does not have the correct unique index, and +create_if_needed+ is truthy, then creates the index.
|
208
|
+
# If this table does not have the correct unique index, and +create_if_needed+ is falsy, then raises
|
209
|
+
# LowCardTables::Errors::LowCardNoUniqueIndexError.
|
210
|
+
def ensure_has_unique_index!(create_if_needed = false)
|
211
|
+
@table_unique_index.ensure_present!(create_if_needed)
|
212
|
+
end
|
213
|
+
|
214
|
+
# If this table currently has a unique index across all value columns, removes it.
|
215
|
+
def remove_unique_index!
|
216
|
+
@table_unique_index.remove!
|
217
|
+
end
|
218
|
+
|
219
|
+
|
220
|
+
private
|
221
|
+
# Given a Hash that maps keys to instances of the low-card class, returns a Hash that is identical in every way
|
222
|
+
# except that rows are replaced with their IDs. This is what we use to implement, for example,
|
223
|
+
# #find_or_create_ids_for on top of #find_or_create_rows_for, trivially.
|
224
|
+
def row_map_to_id_map(m)
|
225
|
+
if m.kind_of?(Hash)
|
226
|
+
out = { }
|
227
|
+
m.each do |k,v|
|
228
|
+
if v
|
229
|
+
out[k] = v.id
|
230
|
+
else
|
231
|
+
out[k] = nil
|
232
|
+
end
|
233
|
+
end
|
234
|
+
out
|
235
|
+
else
|
236
|
+
m.id if m
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
# This is used to implement #rows_matching and #ids_matching on top of the Cache. +hash_or_hashes+ is a single
|
241
|
+
# Hash or an array of Hashes, +block+ is a callable block (and you're only allowed to pass +hash_or_hashes+ _or_
|
242
|
+
# +block+, not both), and +method_name+ is the name of the method on Cache that we should call to implement this
|
243
|
+
# method.
|
244
|
+
#
|
245
|
+
# Since Cache does most of the work for us, this method is basically responsible for sanitizing input/output, and
|
246
|
+
# detecting schema-change issues (as evidenced by LowCardColumnNotPresentError) and retrying, once. (This is so
|
247
|
+
# that if code starts using a column name that was not present on the table at the last time the cache was read,
|
248
|
+
# but since has been migrated in, we'll detect the change and react correctly.)
|
249
|
+
def do_matching(hash_or_hashes, block, method_name)
|
250
|
+
result = begin
|
251
|
+
hashes = to_array_of_partial_hashes(hash_or_hashes)
|
252
|
+
cache.send(method_name, hashes, &block)
|
253
|
+
rescue LowCardTables::Errors::LowCardColumnNotPresentError => lccnpe
|
254
|
+
flush!(:schema_change)
|
255
|
+
hashes = to_array_of_partial_hashes(hash_or_hashes)
|
256
|
+
cache.send(method_name, hashes, &block)
|
257
|
+
end
|
258
|
+
|
259
|
+
if hash_or_hashes.kind_of?(Array)
|
260
|
+
result
|
261
|
+
else
|
262
|
+
raise "We passed in #{hash_or_hashes.inspect}, but got back #{result.inspect}?" unless result.kind_of?(Hash) && result.size <= 1
|
263
|
+
result.values[0] if result.size > 0
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
# This is used to implement #find_rows_for and #find_or_create_rows_for; the two methods are very similar except
|
268
|
+
# in how they handle nonexistent rows, so we use this method to deal with both of them.
|
269
|
+
def do_find_or_create(hash_hashes_object_or_objects, do_create)
|
270
|
+
# Input manipulation...
|
271
|
+
input_to_complete_hash_map = map_input_to_complete_hashes(hash_hashes_object_or_objects)
|
272
|
+
complete_hashes = input_to_complete_hash_map.values
|
273
|
+
|
274
|
+
# Do the actual lookup in the cache.
|
275
|
+
existing = rows_matching(complete_hashes)
|
276
|
+
not_found = complete_hashes.reject { |h| existing[h].length > 0 }
|
277
|
+
|
278
|
+
# See if there's something we still don't have and if we need to create it.
|
279
|
+
if not_found.length > 0 && do_create
|
280
|
+
# We actually pass in _all_ the rows we want here, rather than just the ones that aren't found yet. Why?
|
281
|
+
# Under the covers, #flush_lock_and_create_rows_for! is at the heart of our transactional core -- it locks
|
282
|
+
# the table and checks again for which rows are present. We want to give it all of the data required, so that,
|
283
|
+
# once it acquires the exclusive table-level lock, it knows exactly which data it needs to ensure is present
|
284
|
+
# in the table.
|
285
|
+
#
|
286
|
+
# Passing data acquired outside a transaction into a transaction that's supposed to act on it is just asking
|
287
|
+
# for trouble.
|
288
|
+
existing = flush_lock_and_create_rows_for!(complete_hashes)
|
289
|
+
end
|
290
|
+
|
291
|
+
# Output manipulation and validation.
|
292
|
+
out = { }
|
293
|
+
input_to_complete_hash_map.each do |input, complete_hash|
|
294
|
+
values = existing[complete_hash]
|
295
|
+
|
296
|
+
if values.length == 0 && do_create
|
297
|
+
raise %{Whoa: we asked for a row for this hash: #{key.inspect};
|
298
|
+
since this has been asserted to be a complete key, we should only ever get back a single row,
|
299
|
+
and we should always get back one row since we will have created the row if necessary,
|
300
|
+
but we got back these rows:
|
301
|
+
|
302
|
+
#{values.inspect}}
|
303
|
+
end
|
304
|
+
|
305
|
+
out[input] = values[0]
|
306
|
+
end
|
307
|
+
|
308
|
+
if hash_hashes_object_or_objects.kind_of?(Array)
|
309
|
+
out
|
310
|
+
else
|
311
|
+
out[out.keys.first]
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
# Returns all of the ::ActiveRecord::ConnectionAdapters::Column objects for all of the value columns in this
|
316
|
+
# table.
|
317
|
+
#
|
318
|
+
# If this table doesn't exist yet, we return an empty set. This is important: this allows your Rails app to still
|
319
|
+
# pass through the boot phase if you have a model for a low-card model whose underlying database table hasn't
|
320
|
+
# actually been created yet. (If we didn't do this, then the traditional pattern of adding a migration and a model
|
321
|
+
# in the same commit would fail -- other developers would get the model but not have the table, and Rails wouldn't
|
322
|
+
# even be able to boot to migrate the table in.)
|
323
|
+
def value_columns
|
324
|
+
return [ ] unless @low_card_model.table_exists?
|
325
|
+
|
326
|
+
@low_card_model.columns.select do |column|
|
327
|
+
column_name = column.name.to_s.strip.downcase
|
328
|
+
|
329
|
+
use = true
|
330
|
+
use = false if column.primary
|
331
|
+
use = false if column_names_to_skip.include?(column_name)
|
332
|
+
use
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
# This simply raises an exception when we can't create new rows in the low-card table for some reason. We want
|
337
|
+
# to get a very nice, detailed message in return, so we have a method that composes something telling us exactly
|
338
|
+
# what happened.
|
339
|
+
#
|
340
|
+
# +exception+ is the exception we got upon creation, if any. +keys+ is the set of keys (names of columns) we
|
341
|
+
# passed to the #import call, and +failed_instances+ is the set of instances that +activerecord_import+ reported
|
342
|
+
# as failing.
|
343
|
+
#
|
344
|
+
# This method eventually raises a LowCardInvalidLowCardRowsError.
|
345
|
+
def could_not_create_new_rows!(exception, keys, failed_instances)
|
346
|
+
message = %{The low_card_tables gem was trying to create one or more new rows in
|
347
|
+
the low-card table '#{@low_card_model.table_name}', but, when we went to create those rows...
|
348
|
+
|
349
|
+
}
|
350
|
+
|
351
|
+
|
352
|
+
if exception
|
353
|
+
message << %{- The database refused to create them. This is usually because one or more of these rows
|
354
|
+
violates a database constraint -- like a NOT NULL or CHECK constraint.
|
355
|
+
|
356
|
+
The exception we got was:
|
357
|
+
|
358
|
+
(#{exception.class.name}) #{exception.message}
|
359
|
+
#{exception.backtrace.join("\n ")}}
|
360
|
+
elsif failed_instances
|
361
|
+
message << "- They failed validation."
|
362
|
+
end
|
363
|
+
|
364
|
+
if failed_instances.length > 0
|
365
|
+
message << %{Here's what we tried to import:
|
366
|
+
|
367
|
+
Keys: #{keys.inspect}
|
368
|
+
Values:
|
369
|
+
|
370
|
+
}
|
371
|
+
|
372
|
+
failed_instances.each do |failed_instance|
|
373
|
+
line = " #{failed_instance.inspect}"
|
374
|
+
|
375
|
+
if failed_instance.respond_to?(:errors)
|
376
|
+
line << " ERRORS: #{failed_instance.errors.full_messages.join("; ")}"
|
377
|
+
end
|
378
|
+
|
379
|
+
message << "#{line}\n"
|
380
|
+
end
|
381
|
+
end
|
382
|
+
|
383
|
+
raise LowCardTables::Errors::LowCardInvalidLowCardRowsError, message
|
384
|
+
end
|
385
|
+
|
386
|
+
# This method is called when someone has called #find_or_create_rows_for or #find_or_create_ids_for, and we've
|
387
|
+
# discovered that we do, in fact, need to create one or more rows in the database.
|
388
|
+
#
|
389
|
+
# Because we need to be careful of race conditions -- many other processes may be running the exact same code at
|
390
|
+
# the exact same time -- we do the following:
|
391
|
+
#
|
392
|
+
# * First, obtain an exclusive table lock for the database we're using. Exactly how this works is database-
|
393
|
+
# dependent and not something ActiveRecord knows how to handle for us.
|
394
|
+
# * Flush the cache and re-check if we still need to create rows. We do this because it's possible some other
|
395
|
+
# process created the rows in-between the time we checked and the time we locked the table. But, now, if we
|
396
|
+
# still are missing rows, we know we're the only process who can create them, since we have the exclusive
|
397
|
+
# table lock.
|
398
|
+
# * Create the rows in the database, raising a detailed exception if the database raises an exception or if
|
399
|
+
# +activerecord_import+ reports any failed instances.
|
400
|
+
# * Fire an ActiveSupport::Notifications event telling everybody that we just created rows.
|
401
|
+
# * Flush the cache again, since we just created rows in the database and so the cache is guaranteed to be
|
402
|
+
# out-of-date.
|
403
|
+
# * Return the rows that now should be present and match the input.
|
404
|
+
def flush_lock_and_create_rows_for!(input)
|
405
|
+
with_locked_table do
|
406
|
+
flush!(:creating_rows, :context => :before_import, :new_rows => input)
|
407
|
+
|
408
|
+
# because it's possible there was a schema modification that we just now picked up
|
409
|
+
input_to_hashes_map = map_input_to_complete_hashes(input)
|
410
|
+
hashes = input_to_hashes_map.values
|
411
|
+
|
412
|
+
existing = rows_matching(hashes)
|
413
|
+
still_not_found = hashes.reject { |h| existing[h].length > 0 }
|
414
|
+
|
415
|
+
if still_not_found.length > 0
|
416
|
+
keys = value_column_names
|
417
|
+
values = still_not_found.map do |hash|
|
418
|
+
keys.map { |k| hash[k] }
|
419
|
+
end
|
420
|
+
|
421
|
+
begin
|
422
|
+
import_result = @low_card_model.import(keys, values, :validate => true)
|
423
|
+
could_not_create_new_rows!(nil, keys, import_result.failed_instances) if import_result.failed_instances.length > 0
|
424
|
+
rescue ::ActiveRecord::StatementInvalid => si
|
425
|
+
could_not_create_new_rows!(si, keys, values)
|
426
|
+
end
|
427
|
+
|
428
|
+
instrument('rows_created', :keys => keys, :values => values)
|
429
|
+
end
|
430
|
+
|
431
|
+
flush!(:creating_rows, :context => :after_import, :new_rows => hashes)
|
432
|
+
|
433
|
+
existing = rows_matching(hashes)
|
434
|
+
still_not_found = hashes.reject { |h| existing[h].length > 0 }
|
435
|
+
|
436
|
+
if still_not_found.length > 0
|
437
|
+
raise %{You asked for low-card IDs for one or more hashes specifying rows that didn't exist,
|
438
|
+
but, when we tried to create them, even after an import that appeared to succeed, we couldn't
|
439
|
+
find the models that should've now existed. This should never happen, and may be indicative
|
440
|
+
of a bug in the low-card tables system. Here's what we tried to create, but then couldn't find:
|
441
|
+
|
442
|
+
#{still_not_found.join("\n")}}
|
443
|
+
end
|
444
|
+
|
445
|
+
existing
|
446
|
+
end
|
447
|
+
end
|
448
|
+
|
449
|
+
# Locks the table for this @low_card_model, using whatever database-specific code is required. This also surrounds
|
450
|
+
# the block passed with a transaction on the table in question.
|
451
|
+
def with_locked_table(&block)
|
452
|
+
@low_card_model.transaction do
|
453
|
+
with_database_exclusive_table_lock do
|
454
|
+
block.call
|
455
|
+
end
|
456
|
+
end
|
457
|
+
end
|
458
|
+
|
459
|
+
# Obtains an exclusive lock on the table for this low-card model. This is much, much stronger than the built-in
|
460
|
+
# ActiveRecord #lock! or #with_lock support (see ActiveRecord::Locking::Pessimistic); this should always lock
|
461
|
+
# the entire table against reading and writing.
|
462
|
+
#
|
463
|
+
# We could've handled this by injecting methods into the ActiveRecord connection adapters for each specific
|
464
|
+
# database type, but that's actually quite a bit more tricky metaprogramming (what if the adapters haven't been
|
465
|
+
# loaded yet when our Gem starts up, but will get loaded later? -- and we definitely don't want to make this
|
466
|
+
# Gem depend on the union of all supported database adapters!) than doing it this way.
|
467
|
+
#
|
468
|
+
# In other words, this may be a bit of a gross hack (groping the class name of the adapter in question), but it's
|
469
|
+
# arguably a lot more reliable and easier to understand than the other way of doing this.
|
470
|
+
def with_database_exclusive_table_lock(&block)
|
471
|
+
case @low_card_model.connection.class.name
|
472
|
+
when /postgresql/i then with_database_exclusive_table_lock_postgresql(&block)
|
473
|
+
when /mysql/i then with_database_exclusive_table_lock_mysql(&block)
|
474
|
+
when /sqlite/i then with_database_exclusive_table_lock_sqlite(&block)
|
475
|
+
else
|
476
|
+
raise LowCardTables::Errors::LowCardUnsupportedDatabaseError, %{You asked for low-card IDs for one or more hashes specifying rows that didn't exist,
|
477
|
+
but, when we went to create them, we discovered that we don't know how to exclusively
|
478
|
+
lock tables in your database. (This is very important so that we don't accidentally
|
479
|
+
create duplicate rows.)
|
480
|
+
|
481
|
+
Your database adapter's class name is '#{@low_card_model.connection.class.name}'; please submit at least
|
482
|
+
a bug report, or, even better, a patch. :) Adding support is quite easy, as long as you know the
|
483
|
+
equivalent of 'LOCK TABLE'(s) in your database.}
|
484
|
+
end
|
485
|
+
end
|
486
|
+
|
487
|
+
# Obtains an exclusive table lock for a PostgreSQL database. PostgreSQL releases all table locks at the end of
|
488
|
+
# the current transaction, so we just need to lock the table -- unlocking happens automatically when we release
|
489
|
+
# our transaction, above.
|
490
|
+
def with_database_exclusive_table_lock_postgresql(&block)
|
491
|
+
# If we just use the regular :sanitize_sql support, we get:
|
492
|
+
# LOCK TABLE 'foo'
|
493
|
+
# ...which, for whatever reason, PostgreSQL doesn't like. Escaping it this way works fine.
|
494
|
+
escaped = @low_card_model.connection.quote_table_name(@low_card_model.table_name)
|
495
|
+
run_sql("LOCK TABLE #{escaped}", { })
|
496
|
+
block.call
|
497
|
+
end
|
498
|
+
|
499
|
+
# Obtains an exclusive table lock for a SQLite database. There is no locking possible or needed, since SQLite is
|
500
|
+
# a single-user database.
|
501
|
+
def with_database_exclusive_table_lock_sqlite(&block)
|
502
|
+
block.call
|
503
|
+
end
|
504
|
+
|
505
|
+
# Obtains an exclusive table lock for a MySQL database. We need to make sure we unlock the table once the block
|
506
|
+
# is complete.
|
507
|
+
def with_database_exclusive_table_lock_mysql(&block)
|
508
|
+
begin
|
509
|
+
escaped = @low_card_model.connection.quote_table_name(@low_card_model.table_name)
|
510
|
+
run_sql("LOCK TABLES #{escaped} WRITE", { })
|
511
|
+
block.call
|
512
|
+
ensure
|
513
|
+
begin
|
514
|
+
run_sql("UNLOCK TABLES", { })
|
515
|
+
rescue ::ActiveRecord::StatementInvalid => si
|
516
|
+
# we tried our best!
|
517
|
+
end
|
518
|
+
end
|
519
|
+
end
|
520
|
+
|
521
|
+
# Runs a SQL statement, specified as a string with substitution parameters.
|
522
|
+
def run_sql(statement, params)
|
523
|
+
@low_card_model.connection.execute(@low_card_model.send(:sanitize_sql, [ statement, params ]))
|
524
|
+
end
|
525
|
+
|
526
|
+
# Names of columns in low-card tables that we should always skip, no matter what.
|
527
|
+
COLUMN_NAMES_TO_ALWAYS_SKIP = %w{created_at updated_at}
|
528
|
+
|
529
|
+
# Returns the names of all columns in this table that we should skip when determining what to treat as a value
|
530
|
+
# column for this table (as opposed to things like the primary key, created_at, updated_at, and so on, which are
|
531
|
+
# metadata and shouldn't play a direct role in the low-card system).
|
532
|
+
def column_names_to_skip
|
533
|
+
@column_names_to_skip ||= begin
|
534
|
+
COLUMN_NAMES_TO_ALWAYS_SKIP +
|
535
|
+
Array(@low_card_model.low_card_options[:exclude_column_names] || [ ]).map { |n| n.to_s.strip.downcase }
|
536
|
+
end
|
537
|
+
end
|
538
|
+
|
539
|
+
# Given something that can be a single Hash, an array of Hashes, a single instance of the @low_card_model class,
|
540
|
+
# or an array of instances of the @low_card_model class, returns a new Hash.
|
541
|
+
#
|
542
|
+
# This new Hash has, as keys, each of the inputs to this method, and, as values, a Hash for that input that is
|
543
|
+
# a complete, normalized Hash representing that input.
|
544
|
+
#
|
545
|
+
# This method will also raise an exception if any of the inputs do not include all of the necessary keys for the
|
546
|
+
# low-card table -- thus, this method can only be used for methods like #find_rows_for or #find_or_create_ids_for,
|
547
|
+
# where the input must each specify exactly one low-card row, rather than methods like
|
548
|
+
# #rows_matching/#ids_matching, where each input may match multiple low-card rows.
|
549
|
+
def map_input_to_complete_hashes(hash_hashes_object_or_objects)
|
550
|
+
# We can't use Array(), because that will turn a single Hash into an Array, and we definitely don't want
|
551
|
+
# to do that here! I kind of hate that behavior of Array()...
|
552
|
+
as_array = if hash_hashes_object_or_objects.kind_of?(Array) then hash_hashes_object_or_objects else [ hash_hashes_object_or_objects ] end
|
553
|
+
|
554
|
+
out = { }
|
555
|
+
as_array.uniq.each do |hash_or_object|
|
556
|
+
hash = nil
|
557
|
+
|
558
|
+
if hash_or_object.kind_of?(Hash)
|
559
|
+
# Allow us to use Strings or Symbols as indexes into the Hash
|
560
|
+
hash = hash_or_object.with_indifferent_access
|
561
|
+
elsif hash_or_object.kind_of?(@low_card_model)
|
562
|
+
hash = hash_or_object.attributes.dup.with_indifferent_access
|
563
|
+
hash.delete(@low_card_model.primary_key)
|
564
|
+
else
|
565
|
+
raise "Invalid input to this method -- this must be a Hash, or an instance of #{@low_card_model}: #{hash_or_object.inspect}"
|
566
|
+
end
|
567
|
+
|
568
|
+
hash = ensure_complete_key(hash)
|
569
|
+
out[hash_or_object] = hash
|
570
|
+
end
|
571
|
+
|
572
|
+
out
|
573
|
+
end
|
574
|
+
|
575
|
+
# Given a single Hash that should contain values for all value columns in the low-card table -- no less, no more --
|
576
|
+
# validates that the Hash contains no extra columns and no missing columns, and returns it. This method will allow
|
577
|
+
# you to skip any columns in the input that have defaults in the database, and will correctly fill in those defaults
|
578
|
+
# in the returned Hash.
|
579
|
+
#
|
580
|
+
# Because this requires all columns to be present in the input, it can only be used for methods like
|
581
|
+
# #find_rows_for or #find_or_create_ids_for that require fully-specified input hashes.
|
582
|
+
def ensure_complete_key(hash)
|
583
|
+
keys_as_strings = hash.keys.map(&:to_s)
|
584
|
+
missing = value_column_names - keys_as_strings
|
585
|
+
extra = keys_as_strings - value_column_names
|
586
|
+
|
587
|
+
missing = missing.select do |missing_column_name|
|
588
|
+
column = @low_card_model.columns.detect { |c| c.name.to_s.strip.downcase == missing_column_name.to_s.strip.downcase }
|
589
|
+
if column && column.default
|
590
|
+
hash[column.name] = column.default
|
591
|
+
false
|
592
|
+
else
|
593
|
+
true
|
594
|
+
end
|
595
|
+
end
|
596
|
+
|
597
|
+
if missing.length > 0
|
598
|
+
raise LowCardTables::Errors::LowCardColumnNotSpecifiedError, "The following is not a complete specification of all columns in low-card table '#{@low_card_model.table_name}'; it is missing these columns: #{missing.join(", ")}: #{hash.inspect}"
|
599
|
+
end
|
600
|
+
|
601
|
+
if extra.length > 0
|
602
|
+
raise LowCardTables::Errors::LowCardColumnNotPresentError, "The following specifies extra columns that are not present in low-card table '#{@low_card_model.table_name}'; these columns are not present in the underlying model: #{extra.join(", ")}: #{hash.inspect}"
|
603
|
+
end
|
604
|
+
|
605
|
+
hash
|
606
|
+
end
|
607
|
+
|
608
|
+
# Given something that may be a single Hash or an array of Hashes, returns an array of Hashes, and makes sure that
|
609
|
+
# the input (or each element of the input) is a valid partial key into the low-card table. A partial key is a Hash
|
610
|
+
# that specifies zero or more value columns from the low-card table -- but you're not allowed to specify anything
|
611
|
+
# in the Hash that isn't a value column in the low-card table.
|
612
|
+
def to_array_of_partial_hashes(array)
|
613
|
+
array = if array.kind_of?(Array) then array else [ array ] end
|
614
|
+
array.each { |h| assert_partial_key!(h) }
|
615
|
+
array
|
616
|
+
end
|
617
|
+
|
618
|
+
# Given a Hash, raises an error if that Hash is not a valid partial key into the low-card table -- i.e., if it
|
619
|
+
# contains keys that are not valid value columns in the low-card table.
|
620
|
+
def assert_partial_key!(hash)
|
621
|
+
keys_as_strings = hash.keys.map(&:to_s)
|
622
|
+
extra = keys_as_strings - value_column_names
|
623
|
+
|
624
|
+
if extra.length > 0
|
625
|
+
raise LowCardTables::Errors::LowCardColumnNotPresentError, "The following specifies extra columns that are not present in low-card table '#{@low_card_model.table_name}'; these columns are not present in the underlying model: #{extra.join(", ")}: #{hash.inspect}"
|
626
|
+
end
|
627
|
+
end
|
628
|
+
|
629
|
+
# Fetches the cache we should use. This takes care of creating a cache if one is not present; it also takes care
|
630
|
+
# of flushing the cache and creating a new one if the current cache is stale.
|
631
|
+
def cache
|
632
|
+
the_current_time = current_time
|
633
|
+
cache_loaded_at = @cache.loaded_at if @cache
|
634
|
+
|
635
|
+
if @cache && cache_expiration_policy_object.stale?(cache_loaded_at, the_current_time)
|
636
|
+
flush!(:stale, :loaded => cache_loaded_at, :now => the_current_time)
|
637
|
+
end
|
638
|
+
|
639
|
+
unless @cache
|
640
|
+
instrument('cache_load') do
|
641
|
+
@cache = LowCardTables::LowCardTable::Cache.new(@low_card_model, @low_card_model.low_card_options)
|
642
|
+
end
|
643
|
+
end
|
644
|
+
|
645
|
+
@cache
|
646
|
+
end
|
647
|
+
|
648
|
+
# Flushes the cache, for the reason given, and fires the appropriate ActiveSupport::Notification instrumentation.
|
649
|
+
# +reason+ is the reason given in the notification, and +notification_options+ are added to the payload for the
|
650
|
+
# notification.
|
651
|
+
#
|
652
|
+
# Whenever we flush the cache, we also ask ActiveRecord to purge its idea of what columns are on the table. This
|
653
|
+
# ensures that we'll stay in sync with any underlying schema changes, and hence adapt to an evolving schema on
|
654
|
+
# the fly, as best we can.
|
655
|
+
def flush!(reason, notification_options = { })
|
656
|
+
if @cache
|
657
|
+
instrument('cache_flush', notification_options.merge(:reason => reason)) do
|
658
|
+
@cache = nil
|
659
|
+
end
|
660
|
+
end
|
661
|
+
|
662
|
+
@low_card_model.reset_column_information
|
663
|
+
end
|
664
|
+
|
665
|
+
# A thin wrapper around ActiveSupport::Notifications.
|
666
|
+
def instrument(event, options = { }, &block)
|
667
|
+
::ActiveSupport::Notifications.instrument("low_card_tables.#{event}", options.merge(:low_card_model => @low_card_model), &block)
|
668
|
+
end
|
669
|
+
|
670
|
+
# Returns the correct cache-expiration policy object to use for the table in question.
|
671
|
+
def cache_expiration_policy_object
|
672
|
+
@low_card_model.low_card_cache_expiration_policy_object || LowCardTables.low_card_cache_expiration_policy_object
|
673
|
+
end
|
674
|
+
|
675
|
+
# Returns the current time. Broken out into a separate method so that we can easily override it in tests.
|
676
|
+
def current_time
|
677
|
+
Time.now
|
678
|
+
end
|
679
|
+
end
|
680
|
+
end
|
681
|
+
end
|