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,14 @@
1
+ require 'low_card_tables'
2
+ require 'active_support/time'
3
+
4
+ describe LowCardTables::LowCardTable::CacheExpiration::NoCachingExpirationPolicy do
5
+ it "should always be stale" do
6
+ instance = LowCardTables::LowCardTable::CacheExpiration::NoCachingExpirationPolicy.new
7
+
8
+ start_time = 10.minutes.ago
9
+ instance.stale?(start_time, start_time).should be
10
+ instance.stale?(start_time, start_time + 1.minute).should be
11
+ instance.stale?(start_time, start_time + 10.minutes).should be
12
+ instance.stale?(start_time, start_time + 100.minutes).should be
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ require 'low_card_tables'
2
+ require 'active_support/time'
3
+
4
+ describe LowCardTables::LowCardTable::CacheExpiration::UnlimitedCacheExpirationPolicy do
5
+ it "should never be stale" do
6
+ instance = LowCardTables::LowCardTable::CacheExpiration::UnlimitedCacheExpirationPolicy.new
7
+
8
+ start_time = 10.minutes.ago
9
+ instance.stale?(start_time, start_time).should_not be
10
+ instance.stale?(start_time, start_time + 1.minute).should_not be
11
+ instance.stale?(start_time, start_time + 10.minutes).should_not be
12
+ instance.stale?(start_time, start_time + 100.minutes).should_not be
13
+ end
14
+ end
@@ -0,0 +1,282 @@
1
+ require 'low_card_tables'
2
+
3
+ describe LowCardTables::LowCardTable::Cache do
4
+ def klass
5
+ LowCardTables::LowCardTable::Cache
6
+ end
7
+
8
+ it "should require a low-card table class" do
9
+ mc = double("model_class")
10
+
11
+ lambda { klass.new(mc) }.should raise_error(ArgumentError)
12
+
13
+ allow(mc).to receive(:is_low_card_table?).and_return(false)
14
+
15
+ lambda { klass.new(mc) }.should raise_error(ArgumentError)
16
+ end
17
+
18
+ context "with an instance" do
19
+ before :each do
20
+ klass.class_eval do
21
+ class << self
22
+ def override_time=(x)
23
+ @override_time = x
24
+ end
25
+
26
+ def override_time
27
+ out = @override_time
28
+ @override_time = nil
29
+ out
30
+ end
31
+ end
32
+
33
+ def current_time
34
+ self.class.override_time || Time.now
35
+ end
36
+ end
37
+
38
+ @mc = double("model_class")
39
+ allow(@mc).to receive(:is_low_card_table?).and_return(true)
40
+ allow(@mc).to receive(:low_card_ensure_has_unique_index!)
41
+ allow(@mc).to receive(:primary_key).and_return("foobar")
42
+ allow(@mc).to receive(:table_name).and_return("barbaz")
43
+
44
+ im1 = double("im1")
45
+ expect(@mc).to receive(:order).once.with("foobar ASC").and_return(im1)
46
+ im2 = double("im2")
47
+ expect(im1).to receive(:limit).once.with(5001).and_return(im2)
48
+
49
+ @row1 = double("row1")
50
+ @row2 = double("row2")
51
+ @row3 = double("row3")
52
+
53
+ allow(@row1).to receive(:id).and_return(1234)
54
+ allow(@row2).to receive(:id).and_return(1235)
55
+ allow(@row3).to receive(:id).and_return(1238)
56
+
57
+ expect(im2).to receive(:to_a).once.and_return([ @row1, @row2, @row3 ])
58
+
59
+ @rows_read_at_time = double("rows_read_at_time")
60
+ klass.override_time = @rows_read_at_time
61
+
62
+ @cache = klass.new(@mc)
63
+ end
64
+
65
+ it "should correctly expose the time rows were read" do
66
+ @cache.loaded_at.should be(@rows_read_at_time)
67
+ end
68
+
69
+ it "should return all the rows" do
70
+ @cache.all_rows.sort_by(&:object_id).should == [ @row1, @row2, @row3 ].sort_by(&:object_id)
71
+ end
72
+
73
+ describe "#rows_matching" do
74
+ it "should fail if given no hashes" do
75
+ lambda { @cache.rows_matching([ ]) }.should raise_error(ArgumentError)
76
+ end
77
+
78
+ it "should fail if given something that isn't a Hash" do
79
+ lambda { @cache.rows_matching([ 12345 ]) }.should raise_error(ArgumentError)
80
+ end
81
+
82
+ it "should fail if neither a hash nor a block" do
83
+ lambda { @cache.rows_matching }.should raise_error(ArgumentError)
84
+ end
85
+
86
+ it "should fail if given both a hash and a block" do
87
+ lambda { @cache.rows_matching({ :foo => :bar }) { |x| true } }.should raise_error(ArgumentError)
88
+ end
89
+
90
+ it "should return a Hash if given an array of Hashes" do
91
+ h1_orig = { :a => :b, :c => :d }
92
+ h2_orig = { :foo => :bar }
93
+ h3_orig = { :bar => :baz }
94
+
95
+ h1 = h1_orig.with_indifferent_access
96
+ h2 = h2_orig.with_indifferent_access
97
+ h3 = h3_orig.with_indifferent_access
98
+
99
+ expect(@row1).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(true)
100
+ expect(@row1).to receive(:_low_card_row_matches_any_hash?).once.with([ h2 ]).and_return(true)
101
+ expect(@row1).to receive(:_low_card_row_matches_any_hash?).once.with([ h3 ]).and_return(false)
102
+ expect(@row2).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(false)
103
+ expect(@row2).to receive(:_low_card_row_matches_any_hash?).once.with([ h2 ]).and_return(true)
104
+ expect(@row2).to receive(:_low_card_row_matches_any_hash?).once.with([ h3 ]).and_return(false)
105
+ expect(@row3).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(false)
106
+ expect(@row3).to receive(:_low_card_row_matches_any_hash?).once.with([ h2 ]).and_return(false)
107
+ expect(@row3).to receive(:_low_card_row_matches_any_hash?).once.with([ h3 ]).and_return(false)
108
+
109
+ result = @cache.rows_matching([ h1_orig, h2_orig, h3_orig ])
110
+ result.class.should == Hash
111
+ result.size.should == 3
112
+
113
+ result[h1_orig].length.should == 1
114
+ result[h1_orig][0].should be(@row1)
115
+
116
+ result[h2_orig].length.should == 2
117
+ result[h2_orig].sort_by(&:object_id).should == [ @row1, @row2 ].sort_by(&:object_id)
118
+
119
+ result[h3_orig].class.should == Array
120
+ result[h3_orig].length.should == 0
121
+ end
122
+
123
+ it "should return a Hash if given an array of just one Hash" do
124
+ h1_orig = { :a => :b, :c => :d }
125
+ h1 = h1_orig.with_indifferent_access
126
+
127
+ expect(@row1).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(true)
128
+ expect(@row2).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(false)
129
+ expect(@row3).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(false)
130
+
131
+ result = @cache.rows_matching([ h1_orig ])
132
+ result.class.should == Hash
133
+ result.size.should == 1
134
+
135
+ result[h1_orig].length.should == 1
136
+ result[h1_orig][0].should be(@row1)
137
+ end
138
+
139
+ it "should return an Array if given a Hash" do
140
+ h1_orig = { :a => :b, :c => :d }
141
+ h1 = h1_orig.with_indifferent_access
142
+
143
+ expect(@row1).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(true)
144
+ expect(@row2).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(false)
145
+ expect(@row3).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(true)
146
+
147
+ result = @cache.rows_matching(h1_orig)
148
+ result.class.should == Array
149
+ result.sort_by(&:object_id).should == [ @row1, @row3 ].sort_by(&:object_id)
150
+ end
151
+
152
+ it "should return an Array if given a Block" do
153
+ block = lambda { }
154
+
155
+ expect(@row1).to receive(:_low_card_row_matches_block?).once.with(block).and_return(true)
156
+ expect(@row2).to receive(:_low_card_row_matches_block?).once.with(block).and_return(true)
157
+ expect(@row3).to receive(:_low_card_row_matches_block?).once.with(block).and_return(false)
158
+
159
+ result = @cache.rows_matching(&block)
160
+ result.class.should == Array
161
+ result.sort_by(&:object_id).should == [ @row1, @row2 ].sort_by(&:object_id)
162
+ end
163
+ end
164
+
165
+ describe "#ids_matching" do
166
+ it "should fail if given no hashes" do
167
+ lambda { @cache.ids_matching([ ]) }.should raise_error(ArgumentError)
168
+ end
169
+
170
+ it "should fail if given something that isn't a Hash" do
171
+ lambda { @cache.ids_matching([ 12345 ]) }.should raise_error(ArgumentError)
172
+ end
173
+
174
+ it "should fail if neither a hash nor a block" do
175
+ lambda { @cache.ids_matching }.should raise_error(ArgumentError)
176
+ end
177
+
178
+ it "should fail if given both a hash and a block" do
179
+ lambda { @cache.ids_matching({ :foo => :bar }) { |x| true } }.should raise_error(ArgumentError)
180
+ end
181
+
182
+ it "should return a Hash if given an array of Hashes" do
183
+ h1_orig = { :a => :b, :c => :d }
184
+ h2_orig = { :foo => :bar }
185
+ h3_orig = { :bar => :baz }
186
+
187
+ h1 = h1_orig.with_indifferent_access
188
+ h2 = h2_orig.with_indifferent_access
189
+ h3 = h3_orig.with_indifferent_access
190
+
191
+ expect(@row1).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(true)
192
+ expect(@row1).to receive(:_low_card_row_matches_any_hash?).once.with([ h2 ]).and_return(true)
193
+ expect(@row1).to receive(:_low_card_row_matches_any_hash?).once.with([ h3 ]).and_return(false)
194
+ expect(@row2).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(false)
195
+ expect(@row2).to receive(:_low_card_row_matches_any_hash?).once.with([ h2 ]).and_return(true)
196
+ expect(@row2).to receive(:_low_card_row_matches_any_hash?).once.with([ h3 ]).and_return(false)
197
+ expect(@row3).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(false)
198
+ expect(@row3).to receive(:_low_card_row_matches_any_hash?).once.with([ h2 ]).and_return(false)
199
+ expect(@row3).to receive(:_low_card_row_matches_any_hash?).once.with([ h3 ]).and_return(false)
200
+
201
+ result = @cache.ids_matching([ h1_orig, h2_orig, h3_orig ])
202
+ result.class.should == Hash
203
+ result.size.should == 3
204
+
205
+ result[h1_orig].length.should == 1
206
+ result[h1_orig][0].should == @row1.id
207
+
208
+ result[h2_orig].length.should == 2
209
+ result[h2_orig].sort.should == [ @row1.id, @row2.id ].sort
210
+
211
+ result[h3_orig].class.should == Array
212
+ result[h3_orig].length.should == 0
213
+ end
214
+
215
+ it "should return a Hash if given an array of just one Hash" do
216
+ h1_orig = { :a => :b, :c => :d }
217
+ h1 = h1_orig.with_indifferent_access
218
+
219
+ expect(@row1).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(true)
220
+ expect(@row2).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(false)
221
+ expect(@row3).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(false)
222
+
223
+ result = @cache.ids_matching([ h1_orig ])
224
+ result.class.should == Hash
225
+ result.size.should == 1
226
+
227
+ result[h1_orig].length.should == 1
228
+ result[h1_orig][0].should == @row1.id
229
+ end
230
+
231
+ it "should return an Array if given a Hash" do
232
+ h1_orig = { :a => :b, :c => :d }
233
+ h1 = h1_orig.with_indifferent_access
234
+
235
+ expect(@row1).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(true)
236
+ expect(@row2).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(false)
237
+ expect(@row3).to receive(:_low_card_row_matches_any_hash?).once.with([ h1 ]).and_return(true)
238
+
239
+ result = @cache.ids_matching(h1_orig)
240
+ result.class.should == Array
241
+ result.sort.should == [ @row1.id, @row3.id ].sort
242
+ end
243
+
244
+ it "should return an Array if given a Block" do
245
+ block = lambda { }
246
+
247
+ expect(@row1).to receive(:_low_card_row_matches_block?).once.with(block).and_return(true)
248
+ expect(@row2).to receive(:_low_card_row_matches_block?).once.with(block).and_return(true)
249
+ expect(@row3).to receive(:_low_card_row_matches_block?).once.with(block).and_return(false)
250
+
251
+ result = @cache.ids_matching(&block)
252
+ result.class.should == Array
253
+ result.sort.should == [ @row1.id, @row2.id ].sort
254
+ end
255
+ end
256
+
257
+ describe "#rows_for_ids" do
258
+ it "should return a single row for a single ID" do
259
+ @cache.rows_for_ids(@row2.id).should be(@row2)
260
+ end
261
+
262
+ it "should return a map for multiple IDs" do
263
+ result = @cache.rows_for_ids([ @row1.id, @row2.id ])
264
+ result.class.should == Hash
265
+ result.size.should == 2
266
+ result[@row1.id].should be(@row1)
267
+ result[@row2.id].should be(@row2)
268
+ end
269
+
270
+ it "should return a map for a single ID, passed in an array" do
271
+ result = @cache.rows_for_ids([ @row3.id ])
272
+ result.class.should == Hash
273
+ result.size.should == 1
274
+ result[@row3.id].should be(@row3)
275
+ end
276
+
277
+ it "should blow up if passed a missing ID" do
278
+ lambda { @cache.rows_for_ids([ @row1.id, 98765, @row3.id ]) }.should raise_error(LowCardTables::Errors::LowCardIdNotFoundError, /98765/)
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,109 @@
1
+ require 'low_card_tables'
2
+
3
+ describe LowCardTables::LowCardTable::RowCollapser do
4
+ def klass
5
+ LowCardTables::LowCardTable::RowCollapser
6
+ end
7
+
8
+ it "should require a low-card table to be created" do
9
+ low_card_model = double("low_card_model")
10
+
11
+ lambda { klass.new(low_card_model, { }) }.should raise_error(ArgumentError)
12
+
13
+ allow(low_card_model).to receive(:is_low_card_table?).and_return(false)
14
+ lambda { klass.new(low_card_model, { }) }.should raise_error(ArgumentError)
15
+ end
16
+
17
+ context "with an instance" do
18
+ before :each do
19
+ @low_card_model = double("low_card_model")
20
+ allow(@low_card_model).to receive(:is_low_card_table?).and_return(true)
21
+
22
+ allow(@low_card_model).to receive(:low_card_value_column_names).and_return(%w{foo bar})
23
+
24
+ @referring_model_1 = double("referring_model_1")
25
+ @referring_model_2 = double("referring_model_2")
26
+
27
+ allow(@low_card_model).to receive(:low_card_referring_models).and_return([ @referring_model_1, @referring_model_2 ])
28
+
29
+ @row1 = double("row_1")
30
+ @row2 = double("row_2")
31
+ @row3 = double("row_3")
32
+ @row4 = double("row_4")
33
+ @row5 = double("row_5")
34
+ @row6 = double("row_6")
35
+
36
+ allow(@row1).to receive(:attributes).and_return({ 'foo' => "a", 'bar' => 1, 'irrelevant' => 'yo1' })
37
+ allow(@row2).to receive(:attributes).and_return({ 'foo' => "a", 'bar' => 1, 'irrelevant' => 'yo2' })
38
+ allow(@row3).to receive(:attributes).and_return({ 'foo' => "a", 'bar' => 1, 'irrelevant' => 'yo3' })
39
+ allow(@row4).to receive(:attributes).and_return({ 'foo' => "b", 'bar' => 1, 'irrelevant' => 'yo4' })
40
+ allow(@row5).to receive(:attributes).and_return({ 'foo' => "b", 'bar' => 1, 'irrelevant' => 'yo5' })
41
+ allow(@row6).to receive(:attributes).and_return({ 'foo' => "b", 'bar' => 1, 'irrelevant' => 'yo6' })
42
+
43
+ allow(@row1).to receive(:id).and_return(1)
44
+ allow(@row2).to receive(:id).and_return(2)
45
+ allow(@row3).to receive(:id).and_return(3)
46
+ allow(@row4).to receive(:id).and_return(4)
47
+ allow(@row5).to receive(:id).and_return(5)
48
+ allow(@row6).to receive(:id).and_return(6)
49
+ end
50
+
51
+ def use(options)
52
+ @instance = klass.new(@low_card_model, options)
53
+ end
54
+
55
+ describe "#collapse!" do
56
+ it "should do nothing if :low_card_collapse_rows => false" do
57
+ use(:low_card_collapse_rows => false)
58
+
59
+ @instance.collapse!
60
+ end
61
+
62
+ it "should do nothing if there are no duplicate rows" do
63
+ use({ })
64
+ expect(@low_card_model).to receive(:all).and_return([ @row1, @row4 ])
65
+
66
+ @instance.collapse!
67
+ end
68
+
69
+ context "actual collapsing" do
70
+ before :each do
71
+ expect(@low_card_model).to receive(:all).and_return([ @row1, @row2, @row3, @row4, @row5, @row6 ])
72
+ expect(@low_card_model).to receive(:delete_all).once.with([ "id IN (:ids)", { :ids => [ 2, 3, 5, 6 ]} ])
73
+
74
+ @expected_collapse_map = { @row1 => [ @row2, @row3 ], @row4 => [ @row5, @row6 ]}
75
+ end
76
+
77
+ it "should skip updating referring models if asked to" do
78
+ use({ :low_card_update_referring_models => false })
79
+ @instance.collapse!.should == @expected_collapse_map
80
+ end
81
+
82
+ context "with referring models updated" do
83
+ before :each do
84
+ expect(@low_card_model).to receive(:transaction).once { |*args, &block| block.call }
85
+ expect(@referring_model_1).to receive(:transaction).once { |*args, &block| block.call }
86
+ expect(@referring_model_2).to receive(:transaction).once { |*args, &block| block.call }
87
+
88
+ expect(@referring_model_1).to receive(:_low_card_update_collapsed_rows).once.with(@low_card_model, @expected_collapse_map)
89
+ expect(@referring_model_2).to receive(:_low_card_update_collapsed_rows).once.with(@low_card_model, @expected_collapse_map)
90
+ end
91
+
92
+ it "should collapse duplicate rows properly" do
93
+ use({ })
94
+ @instance.collapse!.should == @expected_collapse_map
95
+ end
96
+
97
+ it "should add additional referring models if asked to" do
98
+ additional_referring_model = double("additional_referring_model")
99
+ expect(additional_referring_model).to receive(:transaction).once { |*args, &block| block.call }
100
+ expect(additional_referring_model).to receive(:_low_card_update_collapsed_rows).once.with(@low_card_model, @expected_collapse_map)
101
+
102
+ use({ :low_card_referrers => [ @referring_model_1, additional_referring_model ]})
103
+ @instance.collapse!.should == @expected_collapse_map
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,918 @@
1
+ require 'low_card_tables'
2
+
3
+ # Note: later on in this spec, there's a bunch of unexpected madness -- it looks like we're going well out of our way
4
+ # to validate arguments to methods in a roundabout way, using Array#detect and so on, instead of just setting
5
+ # expectations. But we're doing this for a very good reason: we can't guarantee order of arguments to a number of
6
+ # methods since Ruby < 1.9 doesn't guarantee hash order.
7
+ describe LowCardTables::LowCardTable::RowManager do
8
+ def klass
9
+ LowCardTables::LowCardTable::RowManager
10
+ end
11
+
12
+ def capture_exception(&block)
13
+ out = begin
14
+ block.call
15
+ nil
16
+ rescue Exception => e
17
+ e
18
+ end
19
+
20
+ raise "exception expected, but not raised" unless e
21
+ e
22
+ end
23
+
24
+ before :each do
25
+ klass.class_eval do
26
+ class << self
27
+ def next_time
28
+ if @next_time
29
+ @next_time += 1
30
+ @next_time
31
+ end
32
+ end
33
+
34
+ def next_time=(x)
35
+ @next_time = x
36
+ end
37
+ end
38
+
39
+ def current_time
40
+ self.class.next_time || Time.now
41
+ end
42
+ end
43
+
44
+ LowCardTables::LowCardTable::Cache.class_eval do
45
+ def current_time
46
+ LowCardTables::LowCardTable::RowManager.next_time || Time.now
47
+ end
48
+ end
49
+
50
+ klass.next_time = 0
51
+
52
+ @cache_loads = [ ]
53
+ cl = @cache_loads
54
+ ::ActiveSupport::Notifications.subscribe("low_card_tables.cache_load") do |name, start, finish, id, payload|
55
+ cl << payload
56
+ end
57
+
58
+ @cache_flushes = [ ]
59
+ cf = @cache_flushes
60
+ ::ActiveSupport::Notifications.subscribe("low_card_tables.cache_flush") do |name, start, finish, id, payload|
61
+ cf << payload
62
+ end
63
+
64
+ @rows_created = [ ]
65
+ rc = @rows_created
66
+ ::ActiveSupport::Notifications.subscribe("low_card_tables.rows_created") do |name, start, finish, id, payload|
67
+ rc << payload
68
+ end
69
+ end
70
+
71
+ after :each do
72
+ klass.next_time = nil
73
+ end
74
+
75
+ it "should require a low-card model for instantiation" do
76
+ low_card_model = double("low_card_model")
77
+ lambda { klass.new(low_card_model) }.should raise_error(ArgumentError)
78
+
79
+ allow(low_card_model).to receive(:is_low_card_table?).and_return(false)
80
+ lambda { klass.new(low_card_model) }.should raise_error(ArgumentError)
81
+ end
82
+
83
+ context "with an instance" do
84
+ before :each do
85
+ @low_card_model = double("low_card_model")
86
+ allow(@low_card_model).to receive(:is_low_card_table?).and_return(true)
87
+ allow(@low_card_model).to receive(:low_card_options).and_return({ :foo => :bar })
88
+ allow(@low_card_model).to receive(:reset_column_information)
89
+ allow(@low_card_model).to receive(:table_exists?).and_return(true)
90
+ allow(@low_card_model).to receive(:table_name).and_return("thetablename")
91
+
92
+ @table_unique_index = double("table_unique_index")
93
+ allow(LowCardTables::LowCardTable::TableUniqueIndex).to receive(:new).with(@low_card_model).and_return(@table_unique_index)
94
+
95
+ @column_id = double("column_id")
96
+ allow(@column_id).to receive(:name).and_return("id")
97
+ allow(@column_id).to receive(:primary).and_return(true)
98
+ @column_foo = double("column_foo")
99
+ allow(@column_foo).to receive(:name).and_return("foo")
100
+ allow(@column_foo).to receive(:primary).and_return(false)
101
+ allow(@column_foo).to receive(:default).and_return(nil)
102
+ @column_bar = double("column_bar")
103
+ allow(@column_bar).to receive(:name).and_return("bar")
104
+ allow(@column_bar).to receive(:primary).and_return(false)
105
+ allow(@column_bar).to receive(:default).and_return('yohoho')
106
+ allow(@low_card_model).to receive(:columns).and_return([ @column_id, @column_foo, @column_bar ])
107
+
108
+ @cache_expiration_policy = double("cache_expiration_policy")
109
+ allow(@low_card_model).to receive(:low_card_cache_expiration_policy_object).and_return(@cache_expiration_policy)
110
+
111
+ @instance = klass.new(@low_card_model)
112
+
113
+ @created_cache_count = 0
114
+ @expected_caches = [ ]
115
+ ec = @expected_caches
116
+
117
+ allow(LowCardTables::LowCardTable::Cache).to receive(:new) do
118
+ if ec.length > 0
119
+ @expected_caches.shift
120
+ else
121
+ raise "created a cache that we didn't expect to create"
122
+ end
123
+ end
124
+
125
+ @expected_stale_calls = [ ]
126
+ esc = @expected_stale_calls
127
+
128
+ allow(@cache_expiration_policy).to receive(:stale?) do |cache_time, current_time|
129
+ if esc.length > 0
130
+ (expected_cache_time, expected_current_time, stale) = esc.shift
131
+ if (cache_time != expected_cache_time) || (current_time != expected_current_time)
132
+ raise "incorrect call to stale?; cache time: expected #{expected_cache_time}, got #{cache_time}; current time: expected #{expected_current_time}, got #{current_time}"
133
+ end
134
+
135
+ stale
136
+ else
137
+ raise "unexpected call to stale? (#{cache_time}, #{current_time})"
138
+ end
139
+ end
140
+ end
141
+
142
+ after :each do
143
+ if @expected_caches.length > 0
144
+ raise "didn't create as many caches as expected; still have: #{@expected_caches.inspect}"
145
+ end
146
+
147
+ if @expected_stale_calls.length > 0
148
+ raise "didn't call stale? as many times as expected; still have: #{@expected_stale_calls.inspect}"
149
+ end
150
+ end
151
+
152
+ def expect_cache_creation
153
+ @created_cache_count += 1
154
+ cache = double("cache-#{@created_cache_count}")
155
+ @expected_caches << cache
156
+ cache
157
+ end
158
+
159
+ def expect_cache_validation(cache, expected_cache_time, expected_current_time, stale)
160
+ expect(cache).to receive(:loaded_at).once.and_return(expected_cache_time)
161
+ @expected_stale_calls << [ expected_cache_time, expected_current_time, stale ]
162
+ end
163
+
164
+ describe "referring models" do
165
+ before :each do
166
+ @referring_class_1 = double("referring_class_1")
167
+ @referring_class_2 = double("referring_class_2")
168
+ end
169
+
170
+ it "should have no referring models, by default" do
171
+ @instance.referring_models.should == [ ]
172
+ end
173
+
174
+ it "should unify and return referring models" do
175
+ @instance.referred_to_by(@referring_class_1)
176
+ @instance.referred_to_by(@referring_class_2)
177
+ @instance.referred_to_by(@referring_class_1)
178
+
179
+ @instance.referring_models.sort_by(&:object_id).should == [ @referring_class_1, @referring_class_2 ].sort_by(&:object_id)
180
+ end
181
+
182
+ it "should tell all referring models when column information is reset" do
183
+ @instance.referred_to_by(@referring_class_1)
184
+ @instance.referred_to_by(@referring_class_2)
185
+
186
+ lcam_1 = double("lcam_1")
187
+ lcam_2 = double("lcam_2")
188
+
189
+ expect(@referring_class_1).to receive(:_low_card_associations_manager).once.and_return(lcam_1)
190
+ expect(@referring_class_2).to receive(:_low_card_associations_manager).once.and_return(lcam_2)
191
+
192
+ expect(lcam_1).to receive(:low_card_column_information_reset!).once.with(@low_card_model)
193
+ expect(lcam_2).to receive(:low_card_column_information_reset!).once.with(@low_card_model)
194
+
195
+ @instance.column_information_reset!
196
+ end
197
+ end
198
+
199
+ describe "cache management" do
200
+ it "should return #all_rows directly from cache, and notify that it loaded a cache" do
201
+ cache = expect_cache_creation
202
+ expect(cache).to receive(:all_rows).once.and_return(:allrows)
203
+
204
+ @instance.all_rows.should == :allrows
205
+
206
+ @cache_loads.length.should == 1
207
+ @cache_loads[0].should == { :low_card_model => @low_card_model }
208
+ @cache_flushes.length.should == 0
209
+ end
210
+
211
+ it "should check the cache on the second call, and use that cache" do
212
+ cache = expect_cache_creation
213
+ expect(cache).to receive(:all_rows).twice.and_return(:allrows)
214
+ expect_cache_validation(cache, 1, 2, false)
215
+
216
+ @instance.all_rows.should == :allrows
217
+ @instance.all_rows.should == :allrows
218
+
219
+ @cache_loads.length.should == 1
220
+ @cache_loads[0].should == { :low_card_model => @low_card_model }
221
+ @cache_flushes.length.should == 0
222
+ end
223
+
224
+ it "should refresh the cache on the second call, if needed; also, reset column information when doing so, and fire both load and flush notifications" do
225
+ cache1 = expect_cache_creation
226
+ expect(cache1).to receive(:all_rows).once.and_return(:allrows1)
227
+ expect_cache_validation(cache1, 1, 2, true)
228
+
229
+ cache2 = expect_cache_creation
230
+ expect(cache2).to receive(:all_rows).once.and_return(:allrows2)
231
+
232
+ expect(@low_card_model).to receive(:reset_column_information).once
233
+
234
+ @instance.all_rows.should == :allrows1
235
+ @instance.all_rows.should == :allrows2
236
+
237
+ @cache_loads.length.should == 2
238
+ @cache_loads[0].should == { :low_card_model => @low_card_model }
239
+ @cache_loads[1].should == { :low_card_model => @low_card_model }
240
+ @cache_flushes.length.should == 1
241
+ @cache_flushes[0].should == { :reason => :stale, :low_card_model => @low_card_model, :now => 2, :loaded => 1 }
242
+ end
243
+
244
+ it "should only reset column information if asked to flush the cache when there isn't one, and not fire a notification" do
245
+ expect(@low_card_model).to receive(:reset_column_information).once
246
+
247
+ @instance.flush_cache!
248
+ @cache_loads.length.should == 0
249
+ @cache_flushes.length.should == 0
250
+ end
251
+
252
+ it "should flush the cache if asked to manually, and fire a notification" do
253
+ cache1 = expect_cache_creation
254
+ expect(cache1).to receive(:all_rows).once.and_return(:allrows1)
255
+
256
+ cache2 = expect_cache_creation
257
+ expect(cache2).to receive(:all_rows).once.and_return(:allrows2)
258
+
259
+ expect(@low_card_model).to receive(:reset_column_information).once
260
+
261
+ @instance.all_rows.should == :allrows1
262
+
263
+ @instance.flush_cache!
264
+
265
+ @instance.all_rows.should == :allrows2
266
+
267
+ @cache_loads.length.should == 2
268
+ @cache_loads[0].should == { :low_card_model => @low_card_model }
269
+ @cache_loads[1].should == { :low_card_model => @low_card_model }
270
+
271
+ @cache_flushes.length.should == 1
272
+ @cache_flushes[0].should == { :low_card_model => @low_card_model, :reason => :manually_requested }
273
+ end
274
+ end
275
+
276
+ %w{rows_for_ids row_for_id}.each do |method_name|
277
+ describe "##{method_name}" do
278
+ it "should return a single row from cache if present" do
279
+ row1 = double("row1")
280
+ cache = expect_cache_creation
281
+ expect(cache).to receive(:rows_for_ids).with(12345).and_return(row1)
282
+
283
+ @instance.send(method_name, 12345).should be(row1)
284
+
285
+ @cache_loads.length.should == 1
286
+ @cache_loads[0].should == { :low_card_model => @low_card_model }
287
+ @cache_flushes.length.should == 0
288
+ end
289
+
290
+ it "should flush the cache and try again if not present" do
291
+ row1 = double("row1")
292
+ cache1 = expect_cache_creation
293
+ expect(cache1).to receive(:rows_for_ids).with(12345).and_raise(LowCardTables::Errors::LowCardIdNotFoundError.new("not found yo", 12345))
294
+
295
+ cache2 = expect_cache_creation
296
+ expect(cache2).to receive(:rows_for_ids).with(12345).and_return(row1)
297
+
298
+ @instance.send(method_name, 12345).should be(row1)
299
+
300
+ @cache_loads.length.should == 2
301
+ @cache_loads[0].should == { :low_card_model => @low_card_model }
302
+ @cache_loads[1].should == { :low_card_model => @low_card_model }
303
+
304
+ @cache_flushes.length.should == 1
305
+ @cache_flushes[0].should == { :low_card_model => @low_card_model, :reason => :id_not_found, :ids => 12345 }
306
+ end
307
+ end
308
+ end
309
+
310
+ %w{ids_matching rows_matching}.each do |method_name|
311
+ describe "##{method_name}" do
312
+ it "should call through to the cache" do
313
+ hash = { :foo => 'bar' }
314
+
315
+ cache = expect_cache_creation
316
+ expect(cache).to receive(method_name).once.with([ { :foo => "bar" } ]).and_return({ { :foo => :bar } => :foobar })
317
+
318
+ @instance.send(method_name, hash).should == :foobar
319
+ end
320
+
321
+ it "should reject hashes containing invalid data, but only after flushing and trying again" do
322
+ cache = expect_cache_creation
323
+ expect(cache).to receive(:all_rows).once.and_return(:allrows)
324
+
325
+ @instance.all_rows.should == :allrows
326
+
327
+ lambda { @instance.send(method_name, { :quux => :a }) }.should raise_error(LowCardTables::Errors::LowCardColumnNotPresentError, /quux/)
328
+ @cache_flushes.length.should == 1
329
+ @cache_flushes[0].should == { :low_card_model => @low_card_model, :reason => :schema_change }
330
+ end
331
+
332
+ it "should accept hashes containing valid data, if it isn't found the first time through" do
333
+ column_baz = double("column_bar")
334
+ allow(column_baz).to receive(:name).and_return("baz")
335
+ allow(column_baz).to receive(:primary).and_return(false)
336
+
337
+ columns_to_return = [
338
+ [ @column_id, @column_foo, @column_bar ],
339
+ [ @column_id, @column_foo, @column_bar, column_baz ]
340
+ ]
341
+
342
+ allow(@low_card_model).to receive(:columns) do
343
+ columns_to_return.shift || raise("too many calls")
344
+ end
345
+
346
+ cache = expect_cache_creation
347
+ expect(cache).to receive(method_name).once.with([ { :baz => "bonk" } ]).and_return({ { :baz => 'bonk' } => :foobar })
348
+ @instance.send(method_name, { :baz => 'bonk' }).should == :foobar
349
+ end
350
+ end
351
+ end
352
+
353
+ describe "#find_rows_for" do
354
+ it "should require a complete Hash" do
355
+ lambda { @instance.find_rows_for({ :bar => 'baz' }) }.should raise_error(LowCardTables::Errors::LowCardColumnNotSpecifiedError)
356
+ lambda { @instance.find_rows_for({ :foo => 'bar', :quux => 'aa' }) }.should raise_error(LowCardTables::Errors::LowCardColumnNotPresentError)
357
+ end
358
+
359
+ it "should return a single row if a single Hash is specified" do
360
+ row = double("row")
361
+
362
+ cache = expect_cache_creation
363
+ expect(cache).to receive(:rows_matching).once.with([ { 'foo' => 'bar', 'bar' => 'baz' } ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ row ])
364
+
365
+ @instance.find_rows_for({ :foo => 'bar', :bar => 'baz' }).should be(row)
366
+ end
367
+
368
+ it "should return nil if no rows match" do
369
+ cache = expect_cache_creation
370
+ expect(cache).to receive(:rows_matching).once.with([ { 'foo' => 'bar', 'bar' => 'baz' } ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ ])
371
+
372
+ @instance.find_rows_for({ :foo => 'bar', :bar => 'baz' }).should == nil
373
+ end
374
+
375
+ it "should return a Hash if multiple Hashes are specified" do
376
+ row1 = double("row1")
377
+ row2 = double("row2")
378
+
379
+ cache = expect_cache_creation
380
+ rows_matching_args = [ ]
381
+ expect(cache).to receive(:rows_matching).once do |*args|
382
+ rows_matching_args << args
383
+ { { 'foo' => 'bar', 'bar' => 'baz' } => [ row1 ],
384
+ { 'foo' => 'a', 'bar' => 'b' } => [ row2 ],
385
+ { 'foo' => 'c', 'bar' => 'd' } => [ ] }
386
+ end
387
+
388
+ @instance.find_rows_for([ { :foo => 'bar', :bar => 'baz' }, { :foo => 'a', :bar => 'b' }, { :foo => 'c', :bar => 'd'} ]).should == {
389
+ { :foo => 'bar', :bar => 'baz' } => row1,
390
+ { :foo => 'a', :bar => 'b' } => row2,
391
+ { :foo => 'c', :bar => 'd' } => nil }
392
+
393
+ rows_matching_args.length.should == 1
394
+ call_1 = rows_matching_args[0]
395
+ call_1.length.should == 1
396
+ input_array = call_1[0]
397
+ input_array.length.should == 3
398
+
399
+ input_array.detect { |e| e == { 'foo' => 'bar', 'bar' => 'baz' } }.should be
400
+ input_array.detect { |e| e == { 'foo' => 'a', 'bar' => 'b' } }.should be
401
+ input_array.detect { |e| e == { 'foo' => 'c', 'bar' => 'd' } }.should be
402
+ end
403
+
404
+ it "should fill in default values correctly" do
405
+ row = double("row")
406
+
407
+ cache = expect_cache_creation
408
+ expect(cache).to receive(:rows_matching).once.with([ { 'foo' => 'bar', 'bar' => 'yohoho' } ]).and_return({ 'foo' => 'bar', 'bar' => 'yohoho' } => [ row ])
409
+
410
+ @instance.find_rows_for({ :foo => 'bar' }).should be(row)
411
+ end
412
+ end
413
+
414
+ describe "#find_ids_for" do
415
+ it "should require a complete Hash" do
416
+ lambda { @instance.find_ids_for({ :bar => 'baz' }) }.should raise_error(LowCardTables::Errors::LowCardColumnNotSpecifiedError)
417
+ lambda { @instance.find_ids_for({ :foo => 'bar', :quux => 'aa' }) }.should raise_error(LowCardTables::Errors::LowCardColumnNotPresentError)
418
+ end
419
+
420
+ it "should return a single row if a single Hash is specified" do
421
+ row = double("row")
422
+ allow(row).to receive(:id).and_return(123)
423
+
424
+ cache = expect_cache_creation
425
+ expect(cache).to receive(:rows_matching).once.with([ { 'foo' => 'bar', 'bar' => 'baz' } ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ row ])
426
+
427
+ @instance.find_ids_for({ :foo => 'bar', :bar => 'baz' }).should be(123)
428
+ end
429
+
430
+ it "should return nil if no rows match" do
431
+ cache = expect_cache_creation
432
+ expect(cache).to receive(:rows_matching).once.with([ { 'foo' => 'bar', 'bar' => 'baz' } ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ ])
433
+
434
+ @instance.find_ids_for({ :foo => 'bar', :bar => 'baz' }).should == nil
435
+ end
436
+
437
+ it "should return a Hash if multiple Hashes are specified" do
438
+ row1 = double("row1")
439
+ allow(row1).to receive(:id).and_return(123)
440
+ row2 = double("row2")
441
+ allow(row2).to receive(:id).and_return(345)
442
+
443
+ cache = expect_cache_creation
444
+ rows_matching_args = [ ]
445
+ expect(cache).to receive(:rows_matching).once do |*args|
446
+ rows_matching_args << args
447
+ { { 'foo' => 'bar', 'bar' => 'baz' } => [ row1 ],
448
+ { 'foo' => 'a', 'bar' => 'b' } => [ row2 ],
449
+ { 'foo' => 'c', 'bar' => 'd' } => [ ] }
450
+ end
451
+
452
+ @instance.find_ids_for([ { :foo => 'bar', :bar => 'baz' }, { :foo => 'a', :bar => 'b' }, { :foo => 'c', :bar => 'd'} ]).should == {
453
+ { :foo => 'bar', :bar => 'baz' } => 123,
454
+ { :foo => 'a', :bar => 'b' } => 345,
455
+ { :foo => 'c', :bar => 'd' } => nil }
456
+
457
+ rows_matching_args.length.should == 1
458
+ call_1 = rows_matching_args[0]
459
+ call_1.length.should == 1
460
+ input_array = call_1[0]
461
+ input_array.length.should == 3
462
+
463
+ input_array.detect { |e| e == { 'foo' => 'bar', 'bar' => 'baz' } }.should be
464
+ input_array.detect { |e| e == { 'foo' => 'a', 'bar' => 'b' } }.should be
465
+ input_array.detect { |e| e == { 'foo' => 'c', 'bar' => 'd' } }.should be
466
+ end
467
+
468
+ it "should fill in default values correctly" do
469
+ row = double("row")
470
+ allow(row).to receive(:id).and_return(123)
471
+
472
+ cache = expect_cache_creation
473
+ expect(cache).to receive(:rows_matching).once.with([ { 'foo' => 'bar', 'bar' => 'yohoho' } ]).and_return({ 'foo' => 'bar', 'bar' => 'yohoho' } => [ row ])
474
+
475
+ @instance.find_ids_for({ :foo => 'bar' }).should be(123)
476
+ end
477
+ end
478
+
479
+ describe "#find_or_create_rows_for" do
480
+ it "should require a complete Hash" do
481
+ lambda { @instance.find_or_create_rows_for({ :bar => 'baz' }) }.should raise_error(LowCardTables::Errors::LowCardColumnNotSpecifiedError)
482
+ lambda { @instance.find_or_create_rows_for({ :foo => 'bar', :quux => 'aa' }) }.should raise_error(LowCardTables::Errors::LowCardColumnNotPresentError)
483
+ end
484
+
485
+ it "should return a single row if a single Hash is specified" do
486
+ row = double("row")
487
+
488
+ cache = expect_cache_creation
489
+ expect(cache).to receive(:rows_matching).once.with([ { 'foo' => 'bar', 'bar' => 'baz' } ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ row ])
490
+
491
+ @instance.find_or_create_rows_for({ :foo => 'bar', :bar => 'baz' }).should be(row)
492
+ end
493
+
494
+ context "with setup to actually run import" do
495
+ before :each do
496
+ connection = double("connection")
497
+ allow(@low_card_model).to receive(:connection).and_return(connection)
498
+ allow(connection).to receive(:quote_table_name) { |tn| "<#{tn}>" }
499
+ connection_class = double("connection_class")
500
+ allow(connection_class).to receive(:name).and_return("some_postgresql_connection")
501
+ allow(connection).to receive(:class).and_return(connection_class)
502
+ allow(@low_card_model).to receive(:sanitize_sql).once.with([ "LOCK TABLE <thetablename>", { } ]).and_return("quoted-lock-tables")
503
+
504
+ expect(@low_card_model).to receive(:transaction).once { |*args, &block| block.call }
505
+ expect(connection).to receive(:execute).once.with("quoted-lock-tables")
506
+ end
507
+
508
+ it "should raise a LowCardInvalidLowCardRowsError if the database refuses to import the rows with an exception" do
509
+ cache1 = expect_cache_creation
510
+ expect(cache1).to receive(:rows_matching).once.with([ 'foo' => 'bar', 'bar' => 'baz' ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ ])
511
+
512
+ cache2 = expect_cache_creation
513
+ expect(cache2).to receive(:rows_matching).once.with([ 'foo' => 'bar', 'bar' => 'baz' ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ ])
514
+
515
+ expect(@low_card_model).to receive(:import).once.and_raise(ActiveRecord::StatementInvalid.new("boom"))
516
+
517
+ exception = capture_exception { @instance.find_or_create_rows_for([ :foo => 'bar', :bar => 'baz' ]) }
518
+ exception.class.should == LowCardTables::Errors::LowCardInvalidLowCardRowsError
519
+ exception.message.should match(/thetablename/i)
520
+ exception.message.should match(/ActiveRecord::StatementInvalid/i)
521
+ exception.message.should match(/foo.*bar/i)
522
+ exception.message.should match(/bar.*baz/i)
523
+
524
+ @rows_created.length.should == 0
525
+ end
526
+
527
+ it "should raise a LowCardInvalidLowCardRowsError if the database refuses to import the rows with an import failure" do
528
+ cache1 = expect_cache_creation
529
+ expect(cache1).to receive(:rows_matching).once.with([ 'foo' => 'bar', 'bar' => 'baz' ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ ])
530
+
531
+ cache2 = expect_cache_creation
532
+ expect(cache2).to receive(:rows_matching).once.with([ 'foo' => 'bar', 'bar' => 'baz' ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ ])
533
+
534
+ failed_instance_1 = double("failed_instance_1")
535
+ errors_1 = double("errors_1")
536
+ allow(failed_instance_1).to receive(:errors).and_return(errors_1)
537
+ allow(errors_1).to receive(:full_messages).and_return(%w{error1a error1b error1c})
538
+
539
+ failed_instance_2 = double("failed_instance_2")
540
+ errors_2 = double("errors_2")
541
+ allow(failed_instance_2).to receive(:errors).and_return(errors_2)
542
+ allow(errors_2).to receive(:full_messages).and_return(%w{error2a error2b error2c})
543
+
544
+ import_result = double("import_result")
545
+ expect(import_result).to receive(:failed_instances).at_least(:once).and_return([ failed_instance_1, failed_instance_2 ])
546
+ expect(@low_card_model).to receive(:import).once.and_return(import_result)
547
+
548
+ exception = capture_exception { @instance.find_or_create_rows_for([ :foo => 'bar', :bar => 'baz' ]) }
549
+ exception.class.should == LowCardTables::Errors::LowCardInvalidLowCardRowsError
550
+ exception.message.should match(/thetablename/i)
551
+ exception.message.should match(/failed_instance_1/i)
552
+ exception.message.should match(/failed_instance_2/i)
553
+ exception.message.should match(/foo.*bar/i)
554
+ exception.message.should match(/error1a.*error1b.*error1c/i)
555
+ exception.message.should match(/error2a.*error2b.*error2c/i)
556
+
557
+ @rows_created.length.should == 0
558
+ end
559
+
560
+ it "should create new rows if not present, with one import command, and apply defaults" do
561
+ cache1 = expect_cache_creation
562
+ expect(cache1).to receive(:all_rows).once.and_return(:allrows)
563
+ allow(cache1).to receive(:loaded_at).once.and_return(12345)
564
+ expect_cache_validation(cache1, 12345, 2, false)
565
+
566
+ @instance.all_rows.should == :allrows
567
+
568
+ existing_row = double("existing_row")
569
+ cache_input = [
570
+ { 'foo' => 'bar', 'bar' => 'baz' },
571
+ { 'foo' => 'a', 'bar' => 'b' },
572
+ { 'foo' => 'c', 'bar' => 'yohoho' }
573
+ ]
574
+ cache_output = {
575
+ { 'foo' => 'bar', 'bar' => 'baz' } => [ existing_row ],
576
+ { 'foo' => 'a', 'bar' => 'b' } => [ ],
577
+ { 'foo' => 'c', 'bar' => 'yohoho' } => [ ]
578
+ }
579
+
580
+ cache_1_input = [ ]
581
+ expect(cache1).to receive(:rows_matching).once { |*args| cache_1_input << args; cache_output }
582
+
583
+ cache2 = expect_cache_creation
584
+ cache_2_input = [ ]
585
+ expect(cache2).to receive(:rows_matching).once { |*args| cache_2_input << args; cache_output }
586
+
587
+ import_result = double("import_result")
588
+ expect(import_result).to receive(:failed_instances).and_return([ ])
589
+ import_args = [ ]
590
+ expect(@low_card_model).to receive(:import).once { |*args| import_args << args; import_result }
591
+
592
+ new_row_1 = double("new_row_1")
593
+ new_row_2 = double("new_row_2")
594
+ cache3 = expect_cache_creation
595
+ cache_3_input = [ ]
596
+ expect(cache3).to receive(:rows_matching).once do |*args|
597
+ cache_3_input << args
598
+
599
+ { { 'foo' => 'bar', 'bar' => 'baz' } => [ existing_row ],
600
+ { 'foo' => 'a', 'bar' => 'b' } => [ new_row_1 ],
601
+ { 'foo' => 'c', 'bar' => 'yohoho' } => [ new_row_2 ] }
602
+ end
603
+
604
+ result = @instance.find_or_create_rows_for([ { :foo => 'bar', :bar => 'baz' }, { :foo => 'a', :bar => 'b' }, { :foo => 'c' } ])
605
+ result.size.should == 3
606
+ result[{ :foo => 'bar', :bar => 'baz' }].should be(existing_row)
607
+ result[{ :foo => 'a', :bar => 'b' }].should be(new_row_1)
608
+ result[{ :foo => 'c' }].should be(new_row_2)
609
+
610
+ cache_1_input.length.should == 1
611
+ cache_1_input[0].length.should == 1
612
+ cache_1_input[0][0].length.should == 3
613
+ cache_1_input[0][0].detect { |x| x == { 'foo' => 'bar', 'bar' => 'baz' }}.should be
614
+ cache_1_input[0][0].detect { |x| x == { 'foo' => 'a', 'bar' => 'b' }}.should be
615
+ cache_1_input[0][0].detect { |x| x == { 'foo' => 'c', 'bar' => 'yohoho' }}.should be
616
+
617
+ cache_2_input.length.should == 1
618
+ cache_2_input[0].length.should == 1
619
+ cache_2_input[0][0].length.should == 3
620
+ cache_2_input[0][0].detect { |x| x == { 'foo' => 'bar', 'bar' => 'baz' }}.should be
621
+ cache_2_input[0][0].detect { |x| x == { 'foo' => 'a', 'bar' => 'b' }}.should be
622
+ cache_2_input[0][0].detect { |x| x == { 'foo' => 'c', 'bar' => 'yohoho' }}.should be
623
+
624
+ cache_3_input.length.should == 1
625
+ cache_3_input[0].length.should == 1
626
+ cache_3_input[0][0].length.should == 3
627
+ cache_3_input[0][0].detect { |x| x == { 'foo' => 'bar', 'bar' => 'baz' }}.should be
628
+ cache_3_input[0][0].detect { |x| x == { 'foo' => 'a', 'bar' => 'b' }}.should be
629
+ cache_3_input[0][0].detect { |x| x == { 'foo' => 'c', 'bar' => 'yohoho' }}.should be
630
+
631
+ import_args.length.should == 1
632
+ import_args[0].length.should == 3
633
+ import_args[0][0].should == [ 'foo', 'bar' ]
634
+ import_args[0][1].length.should == 2
635
+ import_args[0][1].detect { |a| a == ['a', 'b'] }.should be
636
+ import_args[0][1].detect { |a| a == ['c', 'yohoho'] }.should be
637
+ import_args[0][2].should == { :validate => true }
638
+
639
+ @cache_flushes.length.should == 2
640
+
641
+ @cache_flushes[0][:reason].should == :creating_rows
642
+ @cache_flushes[0][:low_card_model].should be(@low_card_model)
643
+ @cache_flushes[0][:context].should == :before_import
644
+ @cache_flushes[0][:new_rows].length.should == 3
645
+ @cache_flushes[0][:new_rows].detect { |e| e == { 'foo' => 'bar', 'bar' => 'baz' } }.should be
646
+ @cache_flushes[0][:new_rows].detect { |e| e == { 'foo' => 'a', 'bar' => 'b' } }.should be
647
+ @cache_flushes[0][:new_rows].detect { |e| e == { 'foo' => 'c', 'bar' => 'yohoho' } }.should be
648
+
649
+ @cache_flushes[1][:reason].should == :creating_rows
650
+ @cache_flushes[1][:low_card_model].should be(@low_card_model)
651
+ @cache_flushes[1][:context].should == :after_import
652
+ @cache_flushes[1][:new_rows].length.should == 3
653
+ @cache_flushes[1][:new_rows].detect { |e| e == { 'foo' => 'bar', 'bar' => 'baz' } }.should be
654
+ @cache_flushes[1][:new_rows].detect { |e| e == { 'foo' => 'a', 'bar' => 'b' } }.should be
655
+ @cache_flushes[1][:new_rows].detect { |e| e == { 'foo' => 'c', 'bar' => 'yohoho' } }.should be
656
+
657
+ @rows_created.length.should == 1
658
+ @rows_created[0][:keys].should == %w{foo bar}
659
+ @rows_created[0][:low_card_model].should be(@low_card_model)
660
+ @rows_created[0][:values].length.should == 2
661
+ @rows_created[0][:values].detect { |e| e == %w{a b} }.should be
662
+ @rows_created[0][:values].detect { |e| e == %w{c yohoho} }.should be
663
+ end
664
+ end
665
+ end
666
+
667
+ describe "#find_or_create_ids_for" do
668
+ it "should require a complete Hash" do
669
+ lambda { @instance.find_or_create_ids_for({ :bar => 'baz' }) }.should raise_error(LowCardTables::Errors::LowCardColumnNotSpecifiedError)
670
+ lambda { @instance.find_or_create_ids_for({ :foo => 'bar', :quux => 'aa' }) }.should raise_error(LowCardTables::Errors::LowCardColumnNotPresentError)
671
+ end
672
+
673
+ it "should return a single ID if a single Hash is specified" do
674
+ row = double("row")
675
+ allow(row).to receive(:id).and_return(123)
676
+
677
+ cache = expect_cache_creation
678
+ expect(cache).to receive(:rows_matching).once.with([ { 'foo' => 'bar', 'bar' => 'baz' } ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ row ])
679
+
680
+ @instance.find_or_create_ids_for({ :foo => 'bar', :bar => 'baz' }).should be(123)
681
+ end
682
+
683
+ context "with setup to actually run import" do
684
+ before :each do
685
+ connection = double("connection")
686
+ allow(@low_card_model).to receive(:connection).and_return(connection)
687
+ allow(connection).to receive(:quote_table_name) { |tn| "<#{tn}>" }
688
+ connection_class = double("connection_class")
689
+ allow(connection_class).to receive(:name).and_return("some_postgresql_connection")
690
+ allow(connection).to receive(:class).and_return(connection_class)
691
+ allow(@low_card_model).to receive(:sanitize_sql).once.with([ "LOCK TABLE <thetablename>", { } ]).and_return("quoted-lock-tables")
692
+
693
+ expect(@low_card_model).to receive(:transaction).once { |*args, &block| block.call }
694
+ expect(connection).to receive(:execute).once.with("quoted-lock-tables")
695
+ end
696
+
697
+ it "should raise a LowCardInvalidLowCardRowsError if the database refuses to import the rows with an exception" do
698
+ cache1 = expect_cache_creation
699
+ expect(cache1).to receive(:rows_matching).once.with([ 'foo' => 'bar', 'bar' => 'baz' ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ ])
700
+
701
+ cache2 = expect_cache_creation
702
+ expect(cache2).to receive(:rows_matching).once.with([ 'foo' => 'bar', 'bar' => 'baz' ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ ])
703
+
704
+ expect(@low_card_model).to receive(:import).once.and_raise(ActiveRecord::StatementInvalid.new("boom"))
705
+
706
+ exception = capture_exception { @instance.find_or_create_ids_for([ :foo => 'bar', :bar => 'baz' ]) }
707
+ exception.class.should == LowCardTables::Errors::LowCardInvalidLowCardRowsError
708
+ exception.message.should match(/thetablename/i)
709
+ exception.message.should match(/ActiveRecord::StatementInvalid/i)
710
+ exception.message.should match(/foo.*bar/i)
711
+ exception.message.should match(/bar.*baz/i)
712
+
713
+ @rows_created.length.should == 0
714
+ end
715
+
716
+ it "should raise a LowCardInvalidLowCardRowsError if the database refuses to import the rows with an import failure" do
717
+ cache1 = expect_cache_creation
718
+ expect(cache1).to receive(:rows_matching).once.with([ 'foo' => 'bar', 'bar' => 'baz' ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ ])
719
+
720
+ cache2 = expect_cache_creation
721
+ expect(cache2).to receive(:rows_matching).once.with([ 'foo' => 'bar', 'bar' => 'baz' ]).and_return({ 'foo' => 'bar', 'bar' => 'baz' } => [ ])
722
+
723
+ failed_instance_1 = double("failed_instance_1")
724
+ errors_1 = double("errors_1")
725
+ allow(failed_instance_1).to receive(:errors).and_return(errors_1)
726
+ allow(errors_1).to receive(:full_messages).and_return(%w{error1a error1b error1c})
727
+
728
+ failed_instance_2 = double("failed_instance_2")
729
+ errors_2 = double("errors_2")
730
+ allow(failed_instance_2).to receive(:errors).and_return(errors_2)
731
+ allow(errors_2).to receive(:full_messages).and_return(%w{error2a error2b error2c})
732
+
733
+ import_result = double("import_result")
734
+ expect(import_result).to receive(:failed_instances).at_least(:once).and_return([ failed_instance_1, failed_instance_2 ])
735
+ expect(@low_card_model).to receive(:import).once.and_return(import_result)
736
+
737
+ exception = capture_exception { @instance.find_or_create_ids_for([ :foo => 'bar', :bar => 'baz' ]) }
738
+ exception.class.should == LowCardTables::Errors::LowCardInvalidLowCardRowsError
739
+ exception.message.should match(/thetablename/i)
740
+ exception.message.should match(/failed_instance_1/i)
741
+ exception.message.should match(/failed_instance_2/i)
742
+ exception.message.should match(/foo.*bar/i)
743
+ exception.message.should match(/error1a.*error1b.*error1c/i)
744
+ exception.message.should match(/error2a.*error2b.*error2c/i)
745
+
746
+ @rows_created.length.should == 0
747
+ end
748
+
749
+ it "should create new rows if not present, with one import command, and apply defaults" do
750
+ cache1 = expect_cache_creation
751
+ expect(cache1).to receive(:all_rows).once.and_return(:allrows)
752
+ allow(cache1).to receive(:loaded_at).once.and_return(12345)
753
+ expect_cache_validation(cache1, 12345, 2, false)
754
+
755
+ @instance.all_rows.should == :allrows
756
+
757
+ existing_row = double("existing_row")
758
+ allow(existing_row).to receive(:id).and_return(123)
759
+ cache_input = [
760
+ { 'foo' => 'bar', 'bar' => 'baz' },
761
+ { 'foo' => 'a', 'bar' => 'b' },
762
+ { 'foo' => 'c', 'bar' => 'yohoho' }
763
+ ]
764
+ cache_output = {
765
+ { 'foo' => 'bar', 'bar' => 'baz' } => [ existing_row ],
766
+ { 'foo' => 'a', 'bar' => 'b' } => [ ],
767
+ { 'foo' => 'c', 'bar' => 'yohoho' } => [ ]
768
+ }
769
+
770
+ cache_1_input = [ ]
771
+ expect(cache1).to receive(:rows_matching).once { |*args| cache_1_input << args; cache_output }
772
+
773
+ cache2 = expect_cache_creation
774
+ cache_2_input = [ ]
775
+ expect(cache2).to receive(:rows_matching).once { |*args| cache_2_input << args; cache_output }
776
+
777
+ import_result = double("import_result")
778
+ expect(import_result).to receive(:failed_instances).and_return([ ])
779
+ import_args = [ ]
780
+ expect(@low_card_model).to receive(:import).once { |*args| import_args << args; import_result }
781
+
782
+ new_row_1 = double("new_row_1")
783
+ allow(new_row_1).to receive(:id).and_return(345)
784
+ new_row_2 = double("new_row_2")
785
+ allow(new_row_2).to receive(:id).and_return(567)
786
+ cache3 = expect_cache_creation
787
+ cache_3_input = [ ]
788
+ expect(cache3).to receive(:rows_matching).once do |*args|
789
+ cache_3_input << args
790
+
791
+ { { 'foo' => 'bar', 'bar' => 'baz' } => [ existing_row ],
792
+ { 'foo' => 'a', 'bar' => 'b' } => [ new_row_1 ],
793
+ { 'foo' => 'c', 'bar' => 'yohoho' } => [ new_row_2 ] }
794
+ end
795
+
796
+ result = @instance.find_or_create_ids_for([ { :foo => 'bar', :bar => 'baz' }, { :foo => 'a', :bar => 'b' }, { :foo => 'c' } ])
797
+ result.size.should == 3
798
+ result[{ :foo => 'bar', :bar => 'baz' }].should be(123)
799
+ result[{ :foo => 'a', :bar => 'b' }].should be(345)
800
+ result[{ :foo => 'c' }].should be(567)
801
+
802
+ cache_1_input.length.should == 1
803
+ cache_1_input[0].length.should == 1
804
+ cache_1_input[0][0].length.should == 3
805
+ cache_1_input[0][0].detect { |x| x == { 'foo' => 'bar', 'bar' => 'baz' }}.should be
806
+ cache_1_input[0][0].detect { |x| x == { 'foo' => 'a', 'bar' => 'b' }}.should be
807
+ cache_1_input[0][0].detect { |x| x == { 'foo' => 'c', 'bar' => 'yohoho' }}.should be
808
+
809
+ cache_2_input.length.should == 1
810
+ cache_2_input[0].length.should == 1
811
+ cache_2_input[0][0].length.should == 3
812
+ cache_2_input[0][0].detect { |x| x == { 'foo' => 'bar', 'bar' => 'baz' }}.should be
813
+ cache_2_input[0][0].detect { |x| x == { 'foo' => 'a', 'bar' => 'b' }}.should be
814
+ cache_2_input[0][0].detect { |x| x == { 'foo' => 'c', 'bar' => 'yohoho' }}.should be
815
+
816
+ cache_3_input.length.should == 1
817
+ cache_3_input[0].length.should == 1
818
+ cache_3_input[0][0].length.should == 3
819
+ cache_3_input[0][0].detect { |x| x == { 'foo' => 'bar', 'bar' => 'baz' }}.should be
820
+ cache_3_input[0][0].detect { |x| x == { 'foo' => 'a', 'bar' => 'b' }}.should be
821
+ cache_3_input[0][0].detect { |x| x == { 'foo' => 'c', 'bar' => 'yohoho' }}.should be
822
+
823
+ import_args.length.should == 1
824
+ import_args[0].length.should == 3
825
+ import_args[0][0].should == [ 'foo', 'bar' ]
826
+ import_args[0][1].length.should == 2
827
+ import_args[0][1].detect { |a| a == ['a', 'b'] }.should be
828
+ import_args[0][1].detect { |a| a == ['c', 'yohoho'] }.should be
829
+ import_args[0][2].should == { :validate => true }
830
+
831
+ @cache_flushes.length.should == 2
832
+
833
+ @cache_flushes[0][:reason].should == :creating_rows
834
+ @cache_flushes[0][:low_card_model].should be(@low_card_model)
835
+ @cache_flushes[0][:context].should == :before_import
836
+ @cache_flushes[0][:new_rows].length.should == 3
837
+ @cache_flushes[0][:new_rows].detect { |e| e == { 'foo' => 'bar', 'bar' => 'baz' } }.should be
838
+ @cache_flushes[0][:new_rows].detect { |e| e == { 'foo' => 'a', 'bar' => 'b' } }.should be
839
+ @cache_flushes[0][:new_rows].detect { |e| e == { 'foo' => 'c', 'bar' => 'yohoho' } }.should be
840
+
841
+ @cache_flushes[1][:reason].should == :creating_rows
842
+ @cache_flushes[1][:low_card_model].should be(@low_card_model)
843
+ @cache_flushes[1][:context].should == :after_import
844
+ @cache_flushes[1][:new_rows].length.should == 3
845
+ @cache_flushes[1][:new_rows].detect { |e| e == { 'foo' => 'bar', 'bar' => 'baz' } }.should be
846
+ @cache_flushes[1][:new_rows].detect { |e| e == { 'foo' => 'a', 'bar' => 'b' } }.should be
847
+ @cache_flushes[1][:new_rows].detect { |e| e == { 'foo' => 'c', 'bar' => 'yohoho' } }.should be
848
+
849
+ @rows_created.length.should == 1
850
+ @rows_created[0][:keys].should == %w{foo bar}
851
+ @rows_created[0][:low_card_model].should be(@low_card_model)
852
+ @rows_created[0][:values].length.should == 2
853
+ @rows_created[0][:values].detect { |e| e == %w{a b} }.should be
854
+ @rows_created[0][:values].detect { |e| e == %w{c yohoho} }.should be
855
+ end
856
+ end
857
+ end
858
+
859
+ describe "#value_column_names" do
860
+ it "should return nothing if the table doesn't exist" do
861
+ allow(@low_card_model).to receive(:table_exists?).and_return(false)
862
+
863
+ @instance.value_column_names.should == [ ]
864
+ end
865
+
866
+ it "should exclude primary keys, created/updated_at, and options-specified column names" do
867
+ column_created_at = double("column_created_at")
868
+ allow(column_created_at).to receive(:primary).and_return(false)
869
+ allow(column_created_at).to receive(:name).and_return("created_at")
870
+
871
+ column_updated_at = double("column_updated_at")
872
+ allow(column_updated_at).to receive(:primary).and_return(false)
873
+ allow(column_updated_at).to receive(:name).and_return("updated_at")
874
+
875
+ column_skipped = double("column_skipped")
876
+ allow(column_skipped).to receive(:primary).and_return(false)
877
+ allow(column_skipped).to receive(:name).and_return("FooFle")
878
+
879
+ columns = [ @column_id, @column_foo, @column_bar, @column_created_at, @column_updated_at, @column_skipped ]
880
+
881
+ allow(@low_card_model).to receive(:low_card_options).and_return({ :exclude_column_names => :fooFLe })
882
+
883
+ @instance.value_column_names.should == %w{foo bar}
884
+ end
885
+ end
886
+
887
+ it "should call through to RowCollapser on #collapse_rows_and_update_referrers!" do
888
+ cache = expect_cache_creation
889
+ expect(cache).to receive(:all_rows).once.and_return(:allrows)
890
+
891
+ @instance.all_rows.should == :allrows
892
+
893
+ collapser = double("collapser")
894
+ expect(LowCardTables::LowCardTable::RowCollapser).to receive(:new).once.with(@low_card_model, { :abc => :def }).and_return(collapser)
895
+
896
+ collapse_map = double("collapse_map")
897
+ expect(collapser).to receive(:collapse!).once.with().and_return(collapse_map)
898
+
899
+ @instance.collapse_rows_and_update_referrers!(:abc => :def).should be(collapse_map)
900
+
901
+ @cache_flushes.length.should == 1
902
+ @cache_flushes[0].should == { :reason => :collapse_rows_and_update_referrers, :low_card_model => @low_card_model }
903
+ end
904
+
905
+ it "should call through to the TableUniqueIndex on #ensure_has_unique_index!" do
906
+ expect(@table_unique_index).to receive(:ensure_present!).once.with(false)
907
+ @instance.ensure_has_unique_index!
908
+
909
+ expect(@table_unique_index).to receive(:ensure_present!).once.with(true)
910
+ @instance.ensure_has_unique_index!(true)
911
+ end
912
+
913
+ it "should call through to the TableUniqueIndex on #remove_unique_index!" do
914
+ expect(@table_unique_index).to receive(:remove!).once
915
+ @instance.remove_unique_index!
916
+ end
917
+ end
918
+ end