historiographer 4.1.13 → 4.1.16
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.
- checksums.yaml +4 -4
- data/.document +5 -0
- data/.rspec +1 -0
- data/.ruby-version +1 -0
- data/.standalone_migrations +6 -0
- data/Gemfile +33 -0
- data/Gemfile.lock +341 -0
- data/Guardfile +4 -0
- data/Rakefile +54 -0
- data/VERSION +1 -0
- data/historiographer-4.1.12.gem +0 -0
- data/historiographer-4.1.13.gem +0 -0
- data/historiographer-4.1.14.gem +0 -0
- data/historiographer.gemspec +144 -0
- data/init.rb +18 -0
- data/instructions/implementation.md +282 -0
- data/instructions/todo.md +96 -0
- data/lib/historiographer/history.rb +1 -7
- data/lib/historiographer/version.rb +1 -1
- data/lib/historiographer.rb +3 -0
- data/spec/db/database.yml +27 -0
- data/spec/db/migrate/20161121212228_create_posts.rb +19 -0
- data/spec/db/migrate/20161121212229_create_post_histories.rb +10 -0
- data/spec/db/migrate/20161121212230_create_authors.rb +13 -0
- data/spec/db/migrate/20161121212231_create_author_histories.rb +10 -0
- data/spec/db/migrate/20161121212232_create_users.rb +9 -0
- data/spec/db/migrate/20171011194624_create_safe_posts.rb +19 -0
- data/spec/db/migrate/20171011194715_create_safe_post_histories.rb +9 -0
- data/spec/db/migrate/20191024142304_create_thing_with_compound_index.rb +10 -0
- data/spec/db/migrate/20191024142352_create_thing_with_compound_index_history.rb +11 -0
- data/spec/db/migrate/20191024203106_create_thing_without_history.rb +7 -0
- data/spec/db/migrate/20221018204220_create_silent_posts.rb +21 -0
- data/spec/db/migrate/20221018204255_create_silent_post_histories.rb +9 -0
- data/spec/db/migrate/20241109182017_create_comments.rb +13 -0
- data/spec/db/migrate/20241109182020_create_comment_histories.rb +9 -0
- data/spec/db/migrate/20241118000000_add_type_to_posts.rb +6 -0
- data/spec/db/migrate/20241118000001_add_type_to_post_histories.rb +5 -0
- data/spec/db/migrate/20241118000002_create_ml_models.rb +19 -0
- data/spec/db/migrate/20241118000003_create_easy_ml_columns.rb +17 -0
- data/spec/db/migrate/20241119000000_create_datasets.rb +17 -0
- data/spec/db/schema.rb +308 -0
- data/spec/factories/post.rb +7 -0
- data/spec/historiographer_spec.rb +918 -0
- data/spec/models/application_record.rb +3 -0
- data/spec/models/author.rb +5 -0
- data/spec/models/author_history.rb +4 -0
- data/spec/models/comment.rb +5 -0
- data/spec/models/comment_history.rb +5 -0
- data/spec/models/dataset.rb +6 -0
- data/spec/models/dataset_history.rb +4 -0
- data/spec/models/easy_ml/column.rb +7 -0
- data/spec/models/easy_ml/column_history.rb +6 -0
- data/spec/models/easy_ml/encrypted_column.rb +10 -0
- data/spec/models/easy_ml/encrypted_column_history.rb +6 -0
- data/spec/models/ml_model.rb +6 -0
- data/spec/models/ml_model_history.rb +4 -0
- data/spec/models/post.rb +45 -0
- data/spec/models/post_history.rb +8 -0
- data/spec/models/private_post.rb +12 -0
- data/spec/models/private_post_history.rb +4 -0
- data/spec/models/safe_post.rb +5 -0
- data/spec/models/safe_post_history.rb +5 -0
- data/spec/models/silent_post.rb +3 -0
- data/spec/models/silent_post_history.rb +4 -0
- data/spec/models/thing_with_compound_index.rb +3 -0
- data/spec/models/thing_with_compound_index_history.rb +4 -0
- data/spec/models/thing_without_history.rb +2 -0
- data/spec/models/user.rb +2 -0
- data/spec/models/xgboost.rb +10 -0
- data/spec/models/xgboost_history.rb +4 -0
- data/spec/spec_helper.rb +105 -0
- metadata +70 -31
@@ -0,0 +1,918 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
# Helper method to handle Rails error expectations
|
6
|
+
def expect_rails_errors(errors, expected_errors)
|
7
|
+
actual_errors = errors.respond_to?(:to_hash) ? errors.to_hash : errors.to_h
|
8
|
+
# Ensure all error messages are arrays for compatibility
|
9
|
+
actual_errors.each { |key, value| actual_errors[key] = Array(value) }
|
10
|
+
expected_errors.each { |key, value| expected_errors[key] = Array(value) }
|
11
|
+
expect(actual_errors).to eq(expected_errors)
|
12
|
+
end
|
13
|
+
|
14
|
+
describe Historiographer do
|
15
|
+
before(:each) do
|
16
|
+
@now = Timecop.freeze
|
17
|
+
end
|
18
|
+
after(:each) do
|
19
|
+
Timecop.return
|
20
|
+
end
|
21
|
+
|
22
|
+
before(:all) do
|
23
|
+
Historiographer::Configuration.mode = :histories
|
24
|
+
end
|
25
|
+
|
26
|
+
after(:all) do
|
27
|
+
Timecop.return
|
28
|
+
end
|
29
|
+
|
30
|
+
let(:username) { 'Test User' }
|
31
|
+
|
32
|
+
let(:user) do
|
33
|
+
User.create(name: username)
|
34
|
+
end
|
35
|
+
|
36
|
+
let(:create_post) do
|
37
|
+
Post.create(
|
38
|
+
title: 'Post 1',
|
39
|
+
body: 'Great post',
|
40
|
+
author_id: 1,
|
41
|
+
history_user_id: user.id
|
42
|
+
)
|
43
|
+
end
|
44
|
+
|
45
|
+
let(:create_author) do
|
46
|
+
Author.create(
|
47
|
+
full_name: 'Breezy',
|
48
|
+
history_user_id: user.id
|
49
|
+
)
|
50
|
+
end
|
51
|
+
|
52
|
+
before(:each) do
|
53
|
+
Historiographer::Configuration.mode = :histories
|
54
|
+
end
|
55
|
+
|
56
|
+
describe 'History counting' do
|
57
|
+
it 'creates history on creation of primary model record' do
|
58
|
+
expect do
|
59
|
+
create_post
|
60
|
+
end.to change {
|
61
|
+
PostHistory.count
|
62
|
+
}.by 1
|
63
|
+
end
|
64
|
+
|
65
|
+
it 'appends new history on update' do
|
66
|
+
post = create_post
|
67
|
+
expect do
|
68
|
+
post.update(title: 'Better Title')
|
69
|
+
end.to change {
|
70
|
+
PostHistory.count
|
71
|
+
}.by 1
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'does not append new history if nothing has changed' do
|
75
|
+
post = create_post
|
76
|
+
|
77
|
+
expect do
|
78
|
+
post.update(title: post.title)
|
79
|
+
end.to_not change {
|
80
|
+
PostHistory.count
|
81
|
+
}
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
describe 'History recording' do
|
86
|
+
|
87
|
+
it 'records all fields from the parent' do
|
88
|
+
post = create_post
|
89
|
+
post_history = post.histories.first
|
90
|
+
|
91
|
+
expect(post_history.title).to eq post.title
|
92
|
+
expect(post_history.body).to eq post.body
|
93
|
+
expect(post_history.author_id).to eq post.author_id
|
94
|
+
expect(post_history.post_id).to eq post.id
|
95
|
+
expect(post_history.history_started_at).to be_within(1.second).of(@now.in_time_zone(Historiographer::UTC))
|
96
|
+
expect(post_history.history_ended_at).to be_nil
|
97
|
+
expect(post_history.history_user_id).to eq user.id
|
98
|
+
|
99
|
+
post.update(title: 'Better title')
|
100
|
+
post_histories = post.histories.reload.order('id asc')
|
101
|
+
first_history = post_histories.first
|
102
|
+
second_history = post_histories.second
|
103
|
+
|
104
|
+
expect(first_history.history_ended_at).to be_within(1.second).of(@now.in_time_zone(Historiographer::UTC))
|
105
|
+
expect(second_history.history_ended_at).to be_nil
|
106
|
+
end
|
107
|
+
|
108
|
+
it 'cannot create without history_user_id' do
|
109
|
+
post = Post.create(
|
110
|
+
title: 'Post 1',
|
111
|
+
body: 'Great post',
|
112
|
+
author_id: 1
|
113
|
+
)
|
114
|
+
|
115
|
+
# Use the helper method for error expectation
|
116
|
+
expect_rails_errors(post.errors, history_user_id: ['must be an integer'])
|
117
|
+
|
118
|
+
expect do
|
119
|
+
post.send(:record_history)
|
120
|
+
end.to raise_error(
|
121
|
+
Historiographer::HistoryUserIdMissingError
|
122
|
+
)
|
123
|
+
end
|
124
|
+
|
125
|
+
context 'When directly hitting the database via SQL' do
|
126
|
+
context '#update_all' do
|
127
|
+
it 'still updates histories' do
|
128
|
+
FactoryBot.create_list(:post, 3, history_user_id: 1)
|
129
|
+
|
130
|
+
posts = Post.all
|
131
|
+
expect(posts.count).to eq 3
|
132
|
+
expect(PostHistory.count).to eq 3
|
133
|
+
expect(posts.map(&:histories).map(&:count)).to all (eq 1)
|
134
|
+
|
135
|
+
posts.update_all(title: 'My New Post Title', history_user_id: 1)
|
136
|
+
|
137
|
+
expect(PostHistory.count).to eq 6
|
138
|
+
expect(PostHistory.current.count).to eq 3
|
139
|
+
expect(posts.map(&:histories).map(&:count)).to all(eq 2)
|
140
|
+
expect(posts.map(&:current_history).map(&:title)).to all (eq 'My New Post Title')
|
141
|
+
expect(Post.all).to respond_to :has_histories?
|
142
|
+
|
143
|
+
# It can update by sub-query
|
144
|
+
Post.where(id: [posts.first.id, posts.last.id]).update_all(title: "Brett's Post", history_user_id: 1)
|
145
|
+
posts = Post.all.reload.order(:id)
|
146
|
+
expect(posts.first.histories.count).to eq 3
|
147
|
+
expect(posts.second.histories.count).to eq 2
|
148
|
+
expect(posts.third.histories.count).to eq 3
|
149
|
+
expect(posts.first.title).to eq "Brett's Post"
|
150
|
+
expect(posts.second.title).to eq 'My New Post Title'
|
151
|
+
expect(posts.third.title).to eq "Brett's Post"
|
152
|
+
expect(posts.first.current_history.title).to eq "Brett's Post"
|
153
|
+
expect(posts.second.current_history.title).to eq 'My New Post Title'
|
154
|
+
expect(posts.third.current_history.title).to eq "Brett's Post"
|
155
|
+
|
156
|
+
# It does not update histories if nothing changed
|
157
|
+
Post.all.update_all(title: "Brett's Post", history_user_id: 1)
|
158
|
+
posts = Post.all.reload.order(:id)
|
159
|
+
expect(posts.map(&:histories).map(&:count)).to all(eq 3)
|
160
|
+
|
161
|
+
posts.update_all_without_history(title: 'Untracked')
|
162
|
+
expect(posts.first.histories.count).to eq 3
|
163
|
+
expect(posts.second.histories.count).to eq 3
|
164
|
+
expect(posts.third.histories.count).to eq 3
|
165
|
+
|
166
|
+
thing1 = ThingWithoutHistory.create(name: 'Thing 1')
|
167
|
+
thing2 = ThingWithoutHistory.create(name: 'Thing 2')
|
168
|
+
|
169
|
+
ThingWithoutHistory.all.update_all(name: 'Thing 3')
|
170
|
+
end
|
171
|
+
|
172
|
+
it 'respects safety' do
|
173
|
+
FactoryBot.create_list(:post, 3, history_user_id: 1)
|
174
|
+
|
175
|
+
posts = Post.all
|
176
|
+
expect(posts.count).to eq 3
|
177
|
+
expect(PostHistory.count).to eq 3
|
178
|
+
expect(posts.map(&:histories).map(&:count)).to all (eq 1)
|
179
|
+
|
180
|
+
expect do
|
181
|
+
posts.update_all(title: 'My New Post Title')
|
182
|
+
end.to raise_error
|
183
|
+
|
184
|
+
posts.reload.map(&:title).each do |title|
|
185
|
+
expect(title).to_not eq 'My New Post Title'
|
186
|
+
end
|
187
|
+
|
188
|
+
SafePost.create(
|
189
|
+
title: 'Post 1',
|
190
|
+
body: 'Great post',
|
191
|
+
author_id: 1
|
192
|
+
)
|
193
|
+
|
194
|
+
safe_posts = SafePost.all
|
195
|
+
|
196
|
+
expect do
|
197
|
+
safe_posts.update_all(title: 'New One')
|
198
|
+
end.to_not raise_error
|
199
|
+
|
200
|
+
expect(safe_posts.map(&:title)).to all(eq 'New One')
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
context '#delete_all' do
|
205
|
+
it 'includes histories when not paranoid' do
|
206
|
+
Timecop.freeze
|
207
|
+
authors = 3.times.map do
|
208
|
+
Author.create(full_name: 'Brett', history_user_id: 1)
|
209
|
+
end
|
210
|
+
Author.delete_all(history_user_id: 1)
|
211
|
+
expect(AuthorHistory.count).to eq 3
|
212
|
+
expect(AuthorHistory.current.count).to eq 0
|
213
|
+
expect(AuthorHistory.where.not(history_ended_at: nil).count).to eq 3
|
214
|
+
expect(Author.count).to eq 0
|
215
|
+
Timecop.return
|
216
|
+
end
|
217
|
+
|
218
|
+
it 'includes histories when paranoid' do
|
219
|
+
Timecop.freeze
|
220
|
+
posts = FactoryBot.create_list(:post, 3, history_user_id: 1)
|
221
|
+
Post.delete_all(history_user_id: 1)
|
222
|
+
expect(PostHistory.unscoped.count).to eq 6
|
223
|
+
expect(PostHistory.unscoped.current.count).to eq 3
|
224
|
+
expect(PostHistory.unscoped.current.map(&:deleted_at)).to all(eq Time.now)
|
225
|
+
expect(PostHistory.unscoped.current.map(&:history_user_id)).to all(eq 1)
|
226
|
+
expect(PostHistory.unscoped.where(deleted_at: nil).where.not(history_ended_at: nil).count).to eq 3
|
227
|
+
expect(PostHistory.unscoped.where(history_ended_at: nil).count).to eq 3
|
228
|
+
expect(Post.count).to eq 0
|
229
|
+
Timecop.return
|
230
|
+
end
|
231
|
+
|
232
|
+
it 'allows delete_all_without_history' do
|
233
|
+
authors = 3.times.map do
|
234
|
+
Author.create(full_name: 'Brett', history_user_id: 1)
|
235
|
+
end
|
236
|
+
Author.all.delete_all_without_history
|
237
|
+
expect(AuthorHistory.current.count).to eq 3
|
238
|
+
expect(Author.count).to eq 0
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
context '#destroy_all' do
|
243
|
+
it 'includes histories' do
|
244
|
+
Timecop.freeze
|
245
|
+
posts = FactoryBot.create_list(:post, 3, history_user_id: 1)
|
246
|
+
Post.destroy_all(history_user_id: 1)
|
247
|
+
expect(PostHistory.unscoped.count).to eq 6
|
248
|
+
expect(PostHistory.unscoped.current.count).to eq 3
|
249
|
+
expect(PostHistory.unscoped.current.map(&:deleted_at)).to all(eq Time.now)
|
250
|
+
expect(PostHistory.unscoped.current.map(&:history_user_id)).to all(eq 1)
|
251
|
+
expect(PostHistory.unscoped.where(deleted_at: nil).where.not(history_ended_at: nil).count).to eq 3
|
252
|
+
expect(PostHistory.unscoped.where(history_ended_at: nil).count).to eq 3
|
253
|
+
expect(Post.count).to eq 0
|
254
|
+
Timecop.return
|
255
|
+
end
|
256
|
+
|
257
|
+
it 'destroys without histories' do
|
258
|
+
Timecop.freeze
|
259
|
+
posts = FactoryBot.create_list(:post, 3, history_user_id: 1)
|
260
|
+
Post.all.destroy_all_without_history
|
261
|
+
expect(PostHistory.count).to eq 3
|
262
|
+
expect(PostHistory.current.count).to eq 3
|
263
|
+
expect(Post.count).to eq 0
|
264
|
+
Timecop.return
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
context 'When Safe mode' do
|
270
|
+
it 'creates history without history_user_id' do
|
271
|
+
expect(Rollbar).to receive(:error).with('history_user_id must be passed in order to save record with histories! If you are in a context with no history_user_id, explicitly call #save_without_history')
|
272
|
+
|
273
|
+
post = SafePost.create(
|
274
|
+
title: 'Post 1',
|
275
|
+
body: 'Great post',
|
276
|
+
author_id: 1
|
277
|
+
)
|
278
|
+
expect_rails_errors(post.errors, {})
|
279
|
+
expect(post).to be_persisted
|
280
|
+
expect(post.histories.count).to eq 1
|
281
|
+
expect(post.histories.first.history_user_id).to be_nil
|
282
|
+
end
|
283
|
+
|
284
|
+
it 'creates history with history_user_id' do
|
285
|
+
expect(Rollbar).to_not receive(:error)
|
286
|
+
|
287
|
+
post = SafePost.create(
|
288
|
+
title: 'Post 1',
|
289
|
+
body: 'Great post',
|
290
|
+
author_id: 1,
|
291
|
+
history_user_id: user.id
|
292
|
+
)
|
293
|
+
expect_rails_errors(post.errors, {})
|
294
|
+
expect(post).to be_persisted
|
295
|
+
expect(post.histories.count).to eq 1
|
296
|
+
expect(post.histories.first.history_user_id).to eq user.id
|
297
|
+
end
|
298
|
+
|
299
|
+
it 'skips history creation if desired' do
|
300
|
+
post = SafePost.new(
|
301
|
+
title: 'Post 1',
|
302
|
+
body: 'Great post',
|
303
|
+
author_id: 1
|
304
|
+
)
|
305
|
+
|
306
|
+
post.save_without_history
|
307
|
+
expect(post).to be_persisted
|
308
|
+
expect(post.histories.count).to eq 0
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
context 'When Silent mode' do
|
313
|
+
it 'creates history without history_user_id' do
|
314
|
+
expect(Rollbar).to_not receive(:error)
|
315
|
+
|
316
|
+
post = SilentPost.create(
|
317
|
+
title: 'Post 1',
|
318
|
+
body: 'Great post',
|
319
|
+
author_id: 1
|
320
|
+
)
|
321
|
+
|
322
|
+
expect_rails_errors(post.errors, {})
|
323
|
+
expect(post).to be_persisted
|
324
|
+
expect(post.histories.count).to eq 1
|
325
|
+
expect(post.histories.first.history_user_id).to be_nil
|
326
|
+
|
327
|
+
post.update(title: 'New Title')
|
328
|
+
post.reload
|
329
|
+
expect(post.title).to eq 'New Title' # No error was raised
|
330
|
+
end
|
331
|
+
|
332
|
+
it 'creates history with history_user_id' do
|
333
|
+
expect(Rollbar).to_not receive(:error)
|
334
|
+
|
335
|
+
post = SilentPost.create(
|
336
|
+
title: 'Post 1',
|
337
|
+
body: 'Great post',
|
338
|
+
author_id: 1,
|
339
|
+
history_user_id: user.id
|
340
|
+
)
|
341
|
+
expect_rails_errors(post.errors, {})
|
342
|
+
expect(post).to be_persisted
|
343
|
+
expect(post.histories.count).to eq 1
|
344
|
+
expect(post.histories.first.history_user_id).to eq user.id
|
345
|
+
end
|
346
|
+
|
347
|
+
it 'skips history creation if desired' do
|
348
|
+
post = SilentPost.new(
|
349
|
+
title: 'Post 1',
|
350
|
+
body: 'Great post',
|
351
|
+
author_id: 1
|
352
|
+
)
|
353
|
+
|
354
|
+
post.save_without_history
|
355
|
+
expect(post).to be_persisted
|
356
|
+
expect(post.histories.count).to eq 0
|
357
|
+
end
|
358
|
+
end
|
359
|
+
it 'can override without history_user_id' do
|
360
|
+
expect do
|
361
|
+
post = Post.new(
|
362
|
+
title: 'Post 1',
|
363
|
+
body: 'Great post',
|
364
|
+
author_id: 1
|
365
|
+
)
|
366
|
+
|
367
|
+
post.save_without_history
|
368
|
+
end.to_not raise_error
|
369
|
+
end
|
370
|
+
|
371
|
+
it 'can override without history_user_id' do
|
372
|
+
expect do
|
373
|
+
post = Post.new(
|
374
|
+
title: 'Post 1',
|
375
|
+
body: 'Great post',
|
376
|
+
author_id: 1
|
377
|
+
)
|
378
|
+
|
379
|
+
post.save_without_history!
|
380
|
+
end.to_not raise_error
|
381
|
+
end
|
382
|
+
|
383
|
+
it 'does not record histories when main model fails to save' do
|
384
|
+
class Post
|
385
|
+
after_save :raise_error, prepend: true
|
386
|
+
|
387
|
+
def raise_error
|
388
|
+
raise 'Oh no, db issue!'
|
389
|
+
end
|
390
|
+
end
|
391
|
+
|
392
|
+
expect { create_post }.to raise_error
|
393
|
+
expect(Post.count).to be 0
|
394
|
+
expect(PostHistory.count).to be 0
|
395
|
+
|
396
|
+
Post.skip_callback(:save, :after, :raise_error)
|
397
|
+
end
|
398
|
+
end
|
399
|
+
|
400
|
+
describe 'Method stubbing' do
|
401
|
+
it 'handles adding method appropriately' do
|
402
|
+
post = PrivatePost.create(title: 'Post 1', body: "Hello", author_id: 1, history_user_id: 1)
|
403
|
+
expect(post.formatted_title).to eq("Private — You cannot see!")
|
404
|
+
|
405
|
+
allow_any_instance_of(PrivatePost).to receive(:formatted_title).and_return("New Title")
|
406
|
+
expect(post.formatted_title).to eq("New Title")
|
407
|
+
|
408
|
+
# Ensure history still works
|
409
|
+
post.update(title: 'Updated Title', history_user_id: user.id)
|
410
|
+
expect(post.histories.count).to eq(2)
|
411
|
+
expect(post.histories.first.class).to eq(PrivatePostHistory) # Verify correct history class
|
412
|
+
end
|
413
|
+
end
|
414
|
+
|
415
|
+
describe 'Scopes' do
|
416
|
+
it 'finds current histories' do
|
417
|
+
post1 = create_post
|
418
|
+
post1.update(title: 'Better title')
|
419
|
+
|
420
|
+
post2 = create_post
|
421
|
+
post2.update(title: 'Better title')
|
422
|
+
|
423
|
+
expect(PostHistory.current.pluck(:title)).to all eq 'Better title'
|
424
|
+
expect(post1.current_history.title).to eq 'Better title'
|
425
|
+
end
|
426
|
+
end
|
427
|
+
|
428
|
+
describe 'Associations' do
|
429
|
+
it 'names associated records' do
|
430
|
+
post1 = create_post
|
431
|
+
expect(post1.histories.first).to be_a(PostHistory)
|
432
|
+
|
433
|
+
expect(post1.histories.first.post).to eq(post1)
|
434
|
+
|
435
|
+
author1 = create_author
|
436
|
+
expect(author1.histories.first).to be_a(AuthorHistory)
|
437
|
+
|
438
|
+
expect(author1.histories.first.author).to eq(author1)
|
439
|
+
end
|
440
|
+
end
|
441
|
+
|
442
|
+
describe 'Histories' do
|
443
|
+
it 'does not allow direct updates of histories' do
|
444
|
+
post1 = create_post
|
445
|
+
hist1 = post1.histories.first
|
446
|
+
|
447
|
+
expect(hist1.update(title: 'A different title')).to be false
|
448
|
+
expect(hist1.reload.title).to eq post1.title
|
449
|
+
|
450
|
+
expect(hist1.update!(title: 'A different title')).to be false
|
451
|
+
expect(hist1.reload.title).to eq post1.title
|
452
|
+
|
453
|
+
hist1.title = 'A different title'
|
454
|
+
expect(hist1.save).to be false
|
455
|
+
expect(hist1.reload.title).to eq post1.title
|
456
|
+
|
457
|
+
hist1.title = 'A different title'
|
458
|
+
expect(hist1.save!).to be false
|
459
|
+
expect(hist1.reload.title).to eq post1.title
|
460
|
+
end
|
461
|
+
|
462
|
+
it 'does not allow destroys of histories' do
|
463
|
+
post1 = create_post
|
464
|
+
hist1 = post1.histories.first
|
465
|
+
original_history_count = post1.histories.count
|
466
|
+
|
467
|
+
expect(hist1.destroy).to be false
|
468
|
+
expect(hist1.destroy!).to be false
|
469
|
+
|
470
|
+
expect(post1.histories.count).to be original_history_count
|
471
|
+
end
|
472
|
+
end
|
473
|
+
|
474
|
+
describe 'Deletion' do
|
475
|
+
it 'records deleted_at and history_user_id on primary and history if you use acts_as_paranoid' do
|
476
|
+
post = Post.create(
|
477
|
+
title: 'Post 1',
|
478
|
+
body: 'Great post',
|
479
|
+
author_id: 1,
|
480
|
+
history_user_id: user.id
|
481
|
+
)
|
482
|
+
|
483
|
+
expect do
|
484
|
+
post.destroy(history_user_id: 2)
|
485
|
+
end.to change {
|
486
|
+
PostHistory.unscoped.count
|
487
|
+
}.by 1
|
488
|
+
|
489
|
+
expect(Post.unscoped.where.not(deleted_at: nil).count).to eq 1
|
490
|
+
expect(Post.unscoped.where(deleted_at: nil).count).to eq 0
|
491
|
+
expect(PostHistory.unscoped.where.not(deleted_at: nil).count).to eq 1
|
492
|
+
expect(PostHistory.unscoped.last.history_user_id).to eq 2
|
493
|
+
end
|
494
|
+
|
495
|
+
it 'works with Historiographer::Safe' do
|
496
|
+
post = SafePost.create(title: 'HELLO', body: 'YO', author_id: 1)
|
497
|
+
|
498
|
+
expect do
|
499
|
+
post.destroy
|
500
|
+
end.to_not raise_error
|
501
|
+
|
502
|
+
expect(SafePost.unscoped.count).to eq 1
|
503
|
+
expect(post.deleted_at).to_not be_nil
|
504
|
+
expect(SafePostHistory.unscoped.count).to eq 2
|
505
|
+
expect(SafePostHistory.unscoped.current.last.deleted_at).to eq post.deleted_at
|
506
|
+
|
507
|
+
post2 = SafePost.create(title: 'HELLO', body: 'YO', author_id: 1)
|
508
|
+
|
509
|
+
expect do
|
510
|
+
post2.destroy!
|
511
|
+
end.to_not raise_error
|
512
|
+
|
513
|
+
expect(SafePost.count).to eq 0
|
514
|
+
expect(post2.deleted_at).to_not be_nil
|
515
|
+
expect(SafePostHistory.unscoped.count).to eq 4
|
516
|
+
expect(SafePostHistory.unscoped.current.where(safe_post_id: post2.id).last.deleted_at).to eq post2.deleted_at
|
517
|
+
end
|
518
|
+
end
|
519
|
+
|
520
|
+
describe 'Scopes' do
|
521
|
+
it 'finds current' do
|
522
|
+
post = create_post
|
523
|
+
post.update(title: 'New Title')
|
524
|
+
post.update(title: 'New Title 2')
|
525
|
+
|
526
|
+
expect(PostHistory.current.count).to be 1
|
527
|
+
end
|
528
|
+
end
|
529
|
+
|
530
|
+
describe 'User associations' do
|
531
|
+
it 'links to user' do
|
532
|
+
post = create_post
|
533
|
+
author = create_author
|
534
|
+
|
535
|
+
expect(post.current_history.user.name).to eq username
|
536
|
+
expect(author.current_history.user.name).to eq username
|
537
|
+
end
|
538
|
+
end
|
539
|
+
|
540
|
+
describe 'Migrations with compound indexes' do
|
541
|
+
it 'supports renaming compound indexes and migrating them to history tables' do
|
542
|
+
indices_sql = "
|
543
|
+
SELECT
|
544
|
+
DISTINCT(
|
545
|
+
ARRAY_TO_STRING(ARRAY(
|
546
|
+
SELECT pg_get_indexdef(idx.indexrelid, k + 1, true)
|
547
|
+
FROM generate_subscripts(idx.indkey, 1) as k
|
548
|
+
ORDER BY k
|
549
|
+
), ',')
|
550
|
+
) as indkey_names
|
551
|
+
FROM pg_class t,
|
552
|
+
pg_class i,
|
553
|
+
pg_index idx,
|
554
|
+
pg_attribute a,
|
555
|
+
pg_am am
|
556
|
+
WHERE t.oid = idx.indrelid
|
557
|
+
AND i.oid = idx.indexrelid
|
558
|
+
AND a.attrelid = t.oid
|
559
|
+
AND a.attnum = ANY(idx.indkey)
|
560
|
+
AND t.relkind = 'r'
|
561
|
+
AND t.relname = ?;
|
562
|
+
"
|
563
|
+
|
564
|
+
indices_query_array = [indices_sql, :thing_with_compound_index_histories]
|
565
|
+
indices_sanitized_query = ThingWithCompoundIndexHistory.send(:sanitize_sql_array, indices_query_array)
|
566
|
+
|
567
|
+
indexes = ThingWithCompoundIndexHistory.connection.execute(indices_sanitized_query).to_a.map(&:values).flatten.map { |i| i.split(',') }
|
568
|
+
|
569
|
+
expect(indexes).to include(['history_started_at'])
|
570
|
+
expect(indexes).to include(['history_ended_at'])
|
571
|
+
expect(indexes).to include(['history_user_id'])
|
572
|
+
expect(indexes).to include(['id'])
|
573
|
+
expect(indexes).to include(%w[key value])
|
574
|
+
expect(indexes).to include(['thing_with_compound_index_id'])
|
575
|
+
end
|
576
|
+
end
|
577
|
+
|
578
|
+
describe 'Reified Histories' do
|
579
|
+
let(:post) { create_post }
|
580
|
+
let(:post_history) { post.histories.first }
|
581
|
+
let(:author) { Author.create(full_name: 'Commenter Jones', history_user_id: user.id) }
|
582
|
+
let(:comment) { Comment.create(post: post, author: author, history_user_id: user.id) }
|
583
|
+
|
584
|
+
it 'responds to methods defined on the original class' do
|
585
|
+
expect(post_history).to respond_to(:summary)
|
586
|
+
expect(post_history.summary).to eq('This is a summary of the post.')
|
587
|
+
end
|
588
|
+
|
589
|
+
it 'behaves like the original class for attribute methods' do
|
590
|
+
expect(post_history.title).to eq(post.title)
|
591
|
+
expect(post_history.body).to eq(post.body)
|
592
|
+
end
|
593
|
+
|
594
|
+
it 'supports custom instance methods' do
|
595
|
+
expect(post_history).to respond_to(:formatted_title)
|
596
|
+
expect(post_history.formatted_title).to eq("Title: #{post.title}")
|
597
|
+
end
|
598
|
+
|
599
|
+
it "does not do things histories shouldn't do" do
|
600
|
+
post_history.update(title: "new title")
|
601
|
+
expect(post_history.reload.title).to eq "Post 1"
|
602
|
+
|
603
|
+
post_history.destroy
|
604
|
+
expect(post_history.reload.title).to eq "Post 1"
|
605
|
+
end
|
606
|
+
end
|
607
|
+
|
608
|
+
describe 'Snapshots' do
|
609
|
+
let(:post) { create_post }
|
610
|
+
let(:author) { Author.create(full_name: 'Commenter Jones', history_user_id: user.id) }
|
611
|
+
let(:comment) { Comment.create(body: "Mean comment! I hate you!", post: post, author: author, history_user_id: user.id) }
|
612
|
+
|
613
|
+
it 'creates a snapshot of the post and its associations' do
|
614
|
+
# Take a snapshot
|
615
|
+
comment # Make sure all records are created
|
616
|
+
post.snapshot
|
617
|
+
|
618
|
+
# Verify snapshot
|
619
|
+
snapshot_post = PostHistory.where.not(snapshot_id: nil).last
|
620
|
+
expect(snapshot_post.title).to eq post.title
|
621
|
+
expect(snapshot_post.formatted_title).to eq post.formatted_title
|
622
|
+
|
623
|
+
snapshot_comment = snapshot_post.comments.first
|
624
|
+
expect(snapshot_comment.body).to eq comment.body
|
625
|
+
expect(snapshot_comment.post_id).to eq post.id
|
626
|
+
expect(snapshot_comment.class.name.to_s).to eq "CommentHistory"
|
627
|
+
|
628
|
+
snapshot_author = snapshot_comment.author
|
629
|
+
expect(snapshot_author.full_name).to eq author.full_name
|
630
|
+
expect(snapshot_author.class.name.to_s).to eq "AuthorHistory"
|
631
|
+
|
632
|
+
# Snapshots do not allow change
|
633
|
+
expect(snapshot_post.update(title: "My title")).to eq false
|
634
|
+
expect(snapshot_post.reload.title).to eq post.title
|
635
|
+
end
|
636
|
+
|
637
|
+
it "allows override of methods on history class" do
|
638
|
+
post.snapshot
|
639
|
+
expect(post.latest_snapshot.locked_value).to eq "My Great Post v100"
|
640
|
+
expect(post.locked_value).to eq "My Great Post v1"
|
641
|
+
|
642
|
+
expect(post.complex_lookup).to eq "Here is a complicated value, it is: My Great Post v1 And another: Title: Post 1"
|
643
|
+
expect(post.latest_snapshot.complex_lookup).to eq "Here is a complicated value, it is: My Great Post v100 And another: Title: Post 1"
|
644
|
+
end
|
645
|
+
|
646
|
+
it "returns the latest snapshot" do
|
647
|
+
Timecop.freeze(Time.now)
|
648
|
+
# Take a snapshot
|
649
|
+
comment # Make sure all records are created
|
650
|
+
post.snapshot(history_user_id: user.id)
|
651
|
+
comment.destroy(history_user_id: user.id)
|
652
|
+
post.comments.create!(post: post, author: author, history_user_id: user.id, body: "Sorry man, didn't mean to post that")
|
653
|
+
|
654
|
+
expect(PostHistory.count).to eq 1
|
655
|
+
expect(CommentHistory.count).to eq 2
|
656
|
+
expect(AuthorHistory.count).to eq 1
|
657
|
+
|
658
|
+
Timecop.freeze(Time.now + 5.minutes)
|
659
|
+
post.snapshot(history_user_id: user.id)
|
660
|
+
|
661
|
+
expect(PostHistory.count).to eq 2
|
662
|
+
expect(CommentHistory.count).to eq 2
|
663
|
+
expect(AuthorHistory.count).to eq 2
|
664
|
+
|
665
|
+
# Verify snapshot
|
666
|
+
snapshot_post = post.latest_snapshot
|
667
|
+
expect(snapshot_post.title).to eq post.title
|
668
|
+
expect(snapshot_post.formatted_title).to eq post.formatted_title
|
669
|
+
|
670
|
+
snapshot_comment = snapshot_post.comments.first
|
671
|
+
expect(snapshot_post.comments.count).to eq 1
|
672
|
+
expect(snapshot_comment.body).to eq "Sorry man, didn't mean to post that"
|
673
|
+
expect(snapshot_comment.post_id).to eq post.id
|
674
|
+
expect(snapshot_comment.class.name.to_s).to eq "CommentHistory"
|
675
|
+
|
676
|
+
snapshot_author = snapshot_comment.author
|
677
|
+
expect(snapshot_author.full_name).to eq author.full_name
|
678
|
+
expect(snapshot_author.class.name.to_s).to eq "AuthorHistory"
|
679
|
+
|
680
|
+
# Snapshots do not allow change
|
681
|
+
expect(snapshot_post.update(title: "My title")).to eq false
|
682
|
+
expect(snapshot_post.reload.title).to eq post.title
|
683
|
+
|
684
|
+
Timecop.return
|
685
|
+
end
|
686
|
+
|
687
|
+
it "uses snapshot_only mode" do
|
688
|
+
Historiographer::Configuration.mode = :snapshot_only
|
689
|
+
|
690
|
+
comment # Make sure all records are created
|
691
|
+
post
|
692
|
+
expect(PostHistory.count).to eq 0
|
693
|
+
expect(CommentHistory.count).to eq 0
|
694
|
+
expect(AuthorHistory.count).to eq 0
|
695
|
+
|
696
|
+
post.snapshot
|
697
|
+
expect(PostHistory.count).to eq 1
|
698
|
+
expect(CommentHistory.count).to eq 1
|
699
|
+
expect(AuthorHistory.count).to eq 1
|
700
|
+
|
701
|
+
comment.destroy(history_user_id: user.id)
|
702
|
+
post.comments.create!(post: post, author: author, history_user_id: user.id, body: "Sorry man, didn't mean to post that")
|
703
|
+
|
704
|
+
expect(PostHistory.count).to eq 1
|
705
|
+
expect(CommentHistory.count).to eq 1
|
706
|
+
expect(AuthorHistory.count).to eq 1
|
707
|
+
|
708
|
+
Timecop.freeze(Time.now + 5.minutes)
|
709
|
+
post.snapshot
|
710
|
+
|
711
|
+
expect(PostHistory.count).to eq 2
|
712
|
+
expect(CommentHistory.count).to eq 2
|
713
|
+
expect(AuthorHistory.count).to eq 2
|
714
|
+
end
|
715
|
+
|
716
|
+
it "runs callbacks at the appropriate time" do
|
717
|
+
comment
|
718
|
+
post.snapshot # 1 comment
|
719
|
+
comment2 = comment.dup
|
720
|
+
comment2.body = "Hello there"
|
721
|
+
comment2.save
|
722
|
+
|
723
|
+
post.reload
|
724
|
+
expect(post.comment_count).to eq 2
|
725
|
+
expect(post.latest_snapshot.comment_count).to eq 1
|
726
|
+
end
|
727
|
+
end
|
728
|
+
|
729
|
+
describe 'Single Table Inheritance' do
|
730
|
+
let(:user) { User.create(name: 'Test User') }
|
731
|
+
let(:private_post) do
|
732
|
+
PrivatePost.create(
|
733
|
+
title: 'Private Post',
|
734
|
+
body: 'Test',
|
735
|
+
history_user_id: user.id,
|
736
|
+
author_id: 1
|
737
|
+
)
|
738
|
+
end
|
739
|
+
|
740
|
+
it 'maintains original class type on create' do
|
741
|
+
post_history = private_post.histories.first
|
742
|
+
expect(post_history.original_class).to eq(PrivatePost)
|
743
|
+
end
|
744
|
+
|
745
|
+
it 'maintains original class in history records' do
|
746
|
+
post_history = private_post.histories.first
|
747
|
+
expect(post_history.original_class).to eq(PrivatePost)
|
748
|
+
expect(post_history.title).to eq('Private — You cannot see!')
|
749
|
+
end
|
750
|
+
|
751
|
+
it 'maintains original class behavior when updating' do
|
752
|
+
private_post.update(title: 'Updated Private Post', history_user_id: user.id)
|
753
|
+
new_history = private_post.histories.current&.first
|
754
|
+
expect(new_history.original_class).to eq(PrivatePost)
|
755
|
+
expect(new_history.title).to eq('Private — You cannot see!')
|
756
|
+
end
|
757
|
+
|
758
|
+
it 'maintains original class behavior when reifying' do
|
759
|
+
private_post.update(title: 'Updated Private Post', history_user_id: user.id)
|
760
|
+
old_history = private_post.histories.first
|
761
|
+
reified = old_history
|
762
|
+
expect(reified.title).to eq('Private — You cannot see!')
|
763
|
+
expect(reified.original_class).to eq(PrivatePost)
|
764
|
+
end
|
765
|
+
end
|
766
|
+
|
767
|
+
describe 'Single Table Inheritance with Associations' do
|
768
|
+
let(:user) { User.create(name: 'Test User') }
|
769
|
+
|
770
|
+
it 'inherits associations in history classes' do
|
771
|
+
dataset = Dataset.create(name: "test_dataset", history_user_id: user.id)
|
772
|
+
model = XGBoost.create(name: "test_model", dataset: dataset, history_user_id: user.id)
|
773
|
+
model.snapshot
|
774
|
+
|
775
|
+
dataset.update(name: "new_dataset", history_user_id: user.id)
|
776
|
+
|
777
|
+
expect(dataset.ml_model).to eq model # This is still a live model
|
778
|
+
expect(model.dataset).to eq(dataset)
|
779
|
+
expect(model.histories.first).to respond_to(:dataset)
|
780
|
+
expect(model.histories.first.dataset).to be_a(DatasetHistory)
|
781
|
+
|
782
|
+
model_history = model.latest_snapshot
|
783
|
+
expect(model_history.dataset.name).to eq "test_dataset"
|
784
|
+
end
|
785
|
+
end
|
786
|
+
|
787
|
+
describe 'Single Table Inheritance with custom inheritance column' do
|
788
|
+
let(:user) { User.create(name: 'Test User') }
|
789
|
+
let(:xgboost) do
|
790
|
+
XGBoost.create(
|
791
|
+
name: 'My XGBoost Model',
|
792
|
+
parameters: { max_depth: 3, eta: 0.1 },
|
793
|
+
history_user_id: user.id
|
794
|
+
)
|
795
|
+
end
|
796
|
+
|
797
|
+
it 'creates history records with correct inheritance' do
|
798
|
+
model = xgboost
|
799
|
+
expect(model.model_name).to eq('XGBoost')
|
800
|
+
expect(model.current_history).to be_a(XGBoostHistory)
|
801
|
+
expect(model.current_history.model_name).to eq('XGBoostHistory')
|
802
|
+
end
|
803
|
+
|
804
|
+
it 'maintains inheritance through updates' do
|
805
|
+
model = xgboost
|
806
|
+
model.update(name: 'Updated XGBoost Model', history_user_id: user.id)
|
807
|
+
|
808
|
+
expect(model.histories.count).to eq(2)
|
809
|
+
expect(model.histories.all? { |h| h.is_a?(XGBoostHistory) }).to be true
|
810
|
+
end
|
811
|
+
|
812
|
+
it 'reifies with correct class' do
|
813
|
+
model = xgboost
|
814
|
+
original_name = model.name
|
815
|
+
model.update(name: 'Updated XGBoost Model', history_user_id: user.id)
|
816
|
+
model.snapshot
|
817
|
+
|
818
|
+
reified = model.latest_snapshot
|
819
|
+
expect(reified).to be_a(XGBoostHistory)
|
820
|
+
expect(reified.name).to eq("Updated XGBoost Model")
|
821
|
+
end
|
822
|
+
end
|
823
|
+
|
824
|
+
describe 'Class-level mode setting' do
|
825
|
+
before(:each) do
|
826
|
+
Historiographer::Configuration.mode = :histories
|
827
|
+
end
|
828
|
+
|
829
|
+
it "uses class-level snapshot_only mode" do
|
830
|
+
class Post < ApplicationRecord
|
831
|
+
historiographer_mode :snapshot_only
|
832
|
+
end
|
833
|
+
|
834
|
+
author = Author.create(full_name: 'Commenter Jones', history_user_id: user.id)
|
835
|
+
post = Post.create(title: 'Snapshot Only Post', body: 'Test', author_id: 1, history_user_id: user.id)
|
836
|
+
comment = Comment.create(post: post, author: author, history_user_id: user.id, body: "Initial comment")
|
837
|
+
|
838
|
+
expect(PostHistory.count).to eq 0
|
839
|
+
expect(CommentHistory.count).to eq 1 # Comment still uses default :histories mode
|
840
|
+
|
841
|
+
post.snapshot
|
842
|
+
expect(PostHistory.count).to eq 1
|
843
|
+
expect(CommentHistory.count).to eq 1
|
844
|
+
|
845
|
+
post.update(title: 'Updated Snapshot Only Post', history_user_id: user.id)
|
846
|
+
expect(PostHistory.count).to eq 1 # No new history created for update
|
847
|
+
expect(CommentHistory.count).to eq 1
|
848
|
+
|
849
|
+
Timecop.freeze(Time.now + 5.minutes)
|
850
|
+
post.snapshot
|
851
|
+
|
852
|
+
expect(PostHistory.count).to eq 2
|
853
|
+
expect(CommentHistory.count).to eq 2 # Comment creates a new history
|
854
|
+
|
855
|
+
Timecop.return
|
856
|
+
|
857
|
+
class Post < ApplicationRecord
|
858
|
+
historiographer_mode nil
|
859
|
+
end
|
860
|
+
end
|
861
|
+
end
|
862
|
+
|
863
|
+
describe 'Moduleized Classes' do
|
864
|
+
let(:user) { User.create(name: 'Test User') }
|
865
|
+
let(:column) do
|
866
|
+
EasyML::Column.create(
|
867
|
+
name: 'feature_1',
|
868
|
+
data_type: 'numeric',
|
869
|
+
history_user_id: user.id
|
870
|
+
)
|
871
|
+
end
|
872
|
+
|
873
|
+
it 'maintains proper namespacing in history class' do
|
874
|
+
expect(column).to be_a(EasyML::Column)
|
875
|
+
expect(column.histories.first).to be_a(EasyML::ColumnHistory)
|
876
|
+
expect(EasyML::Column.history_class).to eq(EasyML::ColumnHistory)
|
877
|
+
end
|
878
|
+
|
879
|
+
it 'establishes correct foreign key for history association' do
|
880
|
+
col_history = column.histories.first
|
881
|
+
expect(col_history.class.history_foreign_key).to eq('column_id')
|
882
|
+
expect(col_history).to be_a(EasyML::ColumnHistory)
|
883
|
+
end
|
884
|
+
|
885
|
+
it 'establishes correct associations for child classes' do
|
886
|
+
encrypted_col = EasyML::Column.create(
|
887
|
+
name: 'secret_feature',
|
888
|
+
data_type: 'numeric',
|
889
|
+
history_user_id: user.id,
|
890
|
+
column_type: "EasyML::EncryptedColumn"
|
891
|
+
)
|
892
|
+
|
893
|
+
# Verify the base record
|
894
|
+
expect(encrypted_col).to be_a(EasyML::EncryptedColumn)
|
895
|
+
expect(encrypted_col.encrypted?).to be true
|
896
|
+
|
897
|
+
# Verify history record
|
898
|
+
col_history = encrypted_col.histories.last
|
899
|
+
expect(col_history).to be_a(EasyML::EncryptedColumnHistory)
|
900
|
+
expect(col_history.class.history_foreign_key).to eq('column_id')
|
901
|
+
expect(col_history.encrypted?).to be true
|
902
|
+
end
|
903
|
+
|
904
|
+
it 'uses correct table names' do
|
905
|
+
expect(EasyML::Column.table_name).to eq('easy_ml_columns')
|
906
|
+
expect(EasyML::ColumnHistory.table_name).to eq('easy_ml_column_histories')
|
907
|
+
end
|
908
|
+
|
909
|
+
it 'creates and updates history records properly' do
|
910
|
+
original_name = column.name
|
911
|
+
column.update(name: 'feature_2', history_user_id: user.id)
|
912
|
+
|
913
|
+
expect(column.histories.count).to eq(2)
|
914
|
+
expect(column.histories.first.name).to eq(original_name)
|
915
|
+
expect(column.histories.last.name).to eq('feature_2')
|
916
|
+
end
|
917
|
+
end
|
918
|
+
end
|