historiographer 4.1.14 → 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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.document +5 -0
  3. data/.rspec +1 -0
  4. data/.ruby-version +1 -0
  5. data/.standalone_migrations +6 -0
  6. data/Gemfile +33 -0
  7. data/Gemfile.lock +341 -0
  8. data/Guardfile +4 -0
  9. data/Rakefile +54 -0
  10. data/VERSION +1 -0
  11. data/historiographer-4.1.12.gem +0 -0
  12. data/historiographer-4.1.13.gem +0 -0
  13. data/historiographer-4.1.14.gem +0 -0
  14. data/historiographer.gemspec +144 -0
  15. data/init.rb +18 -0
  16. data/instructions/implementation.md +282 -0
  17. data/instructions/todo.md +96 -0
  18. data/lib/historiographer/history.rb +1 -7
  19. data/lib/historiographer/version.rb +1 -1
  20. data/spec/db/database.yml +27 -0
  21. data/spec/db/migrate/20161121212228_create_posts.rb +19 -0
  22. data/spec/db/migrate/20161121212229_create_post_histories.rb +10 -0
  23. data/spec/db/migrate/20161121212230_create_authors.rb +13 -0
  24. data/spec/db/migrate/20161121212231_create_author_histories.rb +10 -0
  25. data/spec/db/migrate/20161121212232_create_users.rb +9 -0
  26. data/spec/db/migrate/20171011194624_create_safe_posts.rb +19 -0
  27. data/spec/db/migrate/20171011194715_create_safe_post_histories.rb +9 -0
  28. data/spec/db/migrate/20191024142304_create_thing_with_compound_index.rb +10 -0
  29. data/spec/db/migrate/20191024142352_create_thing_with_compound_index_history.rb +11 -0
  30. data/spec/db/migrate/20191024203106_create_thing_without_history.rb +7 -0
  31. data/spec/db/migrate/20221018204220_create_silent_posts.rb +21 -0
  32. data/spec/db/migrate/20221018204255_create_silent_post_histories.rb +9 -0
  33. data/spec/db/migrate/20241109182017_create_comments.rb +13 -0
  34. data/spec/db/migrate/20241109182020_create_comment_histories.rb +9 -0
  35. data/spec/db/migrate/20241118000000_add_type_to_posts.rb +6 -0
  36. data/spec/db/migrate/20241118000001_add_type_to_post_histories.rb +5 -0
  37. data/spec/db/migrate/20241118000002_create_ml_models.rb +19 -0
  38. data/spec/db/migrate/20241118000003_create_easy_ml_columns.rb +17 -0
  39. data/spec/db/migrate/20241119000000_create_datasets.rb +17 -0
  40. data/spec/db/schema.rb +308 -0
  41. data/spec/factories/post.rb +7 -0
  42. data/spec/historiographer_spec.rb +918 -0
  43. data/spec/models/application_record.rb +3 -0
  44. data/spec/models/author.rb +5 -0
  45. data/spec/models/author_history.rb +4 -0
  46. data/spec/models/comment.rb +5 -0
  47. data/spec/models/comment_history.rb +5 -0
  48. data/spec/models/dataset.rb +6 -0
  49. data/spec/models/dataset_history.rb +4 -0
  50. data/spec/models/easy_ml/column.rb +7 -0
  51. data/spec/models/easy_ml/column_history.rb +6 -0
  52. data/spec/models/easy_ml/encrypted_column.rb +10 -0
  53. data/spec/models/easy_ml/encrypted_column_history.rb +6 -0
  54. data/spec/models/ml_model.rb +6 -0
  55. data/spec/models/ml_model_history.rb +4 -0
  56. data/spec/models/post.rb +45 -0
  57. data/spec/models/post_history.rb +8 -0
  58. data/spec/models/private_post.rb +12 -0
  59. data/spec/models/private_post_history.rb +4 -0
  60. data/spec/models/safe_post.rb +5 -0
  61. data/spec/models/safe_post_history.rb +5 -0
  62. data/spec/models/silent_post.rb +3 -0
  63. data/spec/models/silent_post_history.rb +4 -0
  64. data/spec/models/thing_with_compound_index.rb +3 -0
  65. data/spec/models/thing_with_compound_index_history.rb +4 -0
  66. data/spec/models/thing_without_history.rb +2 -0
  67. data/spec/models/user.rb +2 -0
  68. data/spec/models/xgboost.rb +10 -0
  69. data/spec/models/xgboost_history.rb +4 -0
  70. data/spec/spec_helper.rb +105 -0
  71. 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