historiographer 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.
@@ -0,0 +1,19 @@
1
+ class CreateSafePosts < ActiveRecord::Migration[5.1]
2
+ def change
3
+ create_table :safe_posts do |t|
4
+ t.string :title, null: false
5
+ t.text :body, null: false
6
+ t.integer :author_id, null: false
7
+ t.boolean :enabled, default: false
8
+ t.datetime :live_at
9
+ t.datetime :deleted_at
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :safe_posts, :author_id
15
+ add_index :safe_posts, :enabled
16
+ add_index :safe_posts, :live_at
17
+ add_index :safe_posts, :deleted_at
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ require "historiographer/postgres_migration"
2
+
3
+ class CreateSafePostHistories < ActiveRecord::Migration[5.1]
4
+ def change
5
+ create_table :safe_post_histories do |t|
6
+ t.histories
7
+ end
8
+ end
9
+ end
data/spec/db/schema.rb ADDED
@@ -0,0 +1,121 @@
1
+ # This file is auto-generated from the current state of the database. Instead
2
+ # of editing this file, please use the migrations feature of Active Record to
3
+ # incrementally modify your database, and then regenerate this schema definition.
4
+ #
5
+ # Note that this schema.rb definition is the authoritative source for your
6
+ # database schema. If you need to create the application database on another
7
+ # system, you should be using db:schema:load, not running all the migrations
8
+ # from scratch. The latter is a flawed and unsustainable approach (the more migrations
9
+ # you'll amass, the slower it'll run and the greater likelihood for issues).
10
+ #
11
+ # It's strongly recommended that you check this file into your version control system.
12
+
13
+ ActiveRecord::Schema.define(version: 2017_10_11_194715) do
14
+
15
+ create_table "author_histories", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
16
+ t.integer "author_id", null: false
17
+ t.string "full_name", null: false
18
+ t.text "bio"
19
+ t.datetime "deleted_at"
20
+ t.datetime "created_at", null: false
21
+ t.datetime "updated_at", null: false
22
+ t.datetime "history_started_at", null: false
23
+ t.datetime "history_ended_at"
24
+ t.integer "history_user_id"
25
+ t.index ["author_id"], name: "index_author_histories_on_author_id"
26
+ t.index ["deleted_at"], name: "index_author_histories_on_deleted_at"
27
+ t.index ["history_ended_at"], name: "index_author_histories_on_history_ended_at"
28
+ t.index ["history_started_at"], name: "index_author_histories_on_history_started_at"
29
+ t.index ["history_user_id"], name: "index_author_histories_on_history_user_id"
30
+ end
31
+
32
+ create_table "authors", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
33
+ t.string "full_name", null: false
34
+ t.text "bio"
35
+ t.datetime "deleted_at"
36
+ t.datetime "created_at", null: false
37
+ t.datetime "updated_at", null: false
38
+ t.index ["deleted_at"], name: "index_authors_on_deleted_at"
39
+ end
40
+
41
+ create_table "post_histories", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
42
+ t.integer "post_id", null: false
43
+ t.string "title", null: false
44
+ t.text "body", null: false
45
+ t.integer "author_id", null: false
46
+ t.boolean "enabled", default: false
47
+ t.datetime "live_at"
48
+ t.datetime "deleted_at"
49
+ t.datetime "created_at", null: false
50
+ t.datetime "updated_at", null: false
51
+ t.datetime "history_started_at", null: false
52
+ t.datetime "history_ended_at"
53
+ t.integer "history_user_id"
54
+ t.index ["author_id"], name: "index_post_histories_on_author_id"
55
+ t.index ["deleted_at"], name: "index_post_histories_on_deleted_at"
56
+ t.index ["enabled"], name: "index_post_histories_on_enabled"
57
+ t.index ["history_ended_at"], name: "index_post_histories_on_history_ended_at"
58
+ t.index ["history_started_at"], name: "index_post_histories_on_history_started_at"
59
+ t.index ["history_user_id"], name: "index_post_histories_on_history_user_id"
60
+ t.index ["live_at"], name: "index_post_histories_on_live_at"
61
+ t.index ["post_id"], name: "index_post_histories_on_post_id"
62
+ end
63
+
64
+ create_table "posts", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
65
+ t.string "title", null: false
66
+ t.text "body", null: false
67
+ t.integer "author_id", null: false
68
+ t.boolean "enabled", default: false
69
+ t.datetime "live_at"
70
+ t.datetime "deleted_at"
71
+ t.datetime "created_at", null: false
72
+ t.datetime "updated_at", null: false
73
+ t.index ["author_id"], name: "index_posts_on_author_id"
74
+ t.index ["deleted_at"], name: "index_posts_on_deleted_at"
75
+ t.index ["enabled"], name: "index_posts_on_enabled"
76
+ t.index ["live_at"], name: "index_posts_on_live_at"
77
+ end
78
+
79
+ create_table "safe_post_histories", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
80
+ t.integer "safe_post_id", null: false
81
+ t.string "title", null: false
82
+ t.text "body", null: false
83
+ t.integer "author_id", null: false
84
+ t.boolean "enabled", default: false
85
+ t.datetime "live_at"
86
+ t.datetime "deleted_at"
87
+ t.datetime "created_at", null: false
88
+ t.datetime "updated_at", null: false
89
+ t.datetime "history_started_at", null: false
90
+ t.datetime "history_ended_at"
91
+ t.integer "history_user_id"
92
+ t.index ["author_id"], name: "index_safe_post_histories_on_author_id"
93
+ t.index ["deleted_at"], name: "index_safe_post_histories_on_deleted_at"
94
+ t.index ["enabled"], name: "index_safe_post_histories_on_enabled"
95
+ t.index ["history_ended_at"], name: "index_safe_post_histories_on_history_ended_at"
96
+ t.index ["history_started_at"], name: "index_safe_post_histories_on_history_started_at"
97
+ t.index ["history_user_id"], name: "index_safe_post_histories_on_history_user_id"
98
+ t.index ["live_at"], name: "index_safe_post_histories_on_live_at"
99
+ t.index ["safe_post_id"], name: "index_safe_post_histories_on_safe_post_id"
100
+ end
101
+
102
+ create_table "safe_posts", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
103
+ t.string "title", null: false
104
+ t.text "body", null: false
105
+ t.integer "author_id", null: false
106
+ t.boolean "enabled", default: false
107
+ t.datetime "live_at"
108
+ t.datetime "deleted_at"
109
+ t.datetime "created_at", null: false
110
+ t.datetime "updated_at", null: false
111
+ t.index ["author_id"], name: "index_safe_posts_on_author_id"
112
+ t.index ["deleted_at"], name: "index_safe_posts_on_deleted_at"
113
+ t.index ["enabled"], name: "index_safe_posts_on_enabled"
114
+ t.index ["live_at"], name: "index_safe_posts_on_live_at"
115
+ end
116
+
117
+ create_table "users", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
118
+ t.string "name"
119
+ end
120
+
121
+ end
data/spec/examples.txt ADDED
@@ -0,0 +1,21 @@
1
+ example_id | status | run_time |
2
+ --------------------------------------- | ------ | --------------- |
3
+ ./spec/historiographer_spec.rb[1:1:1] | passed | 0.00794 seconds |
4
+ ./spec/historiographer_spec.rb[1:1:2] | passed | 0.04572 seconds |
5
+ ./spec/historiographer_spec.rb[1:1:3] | passed | 0.03308 seconds |
6
+ ./spec/historiographer_spec.rb[1:2:1] | passed | 0.0742 seconds |
7
+ ./spec/historiographer_spec.rb[1:2:2] | passed | 0.00138 seconds |
8
+ ./spec/historiographer_spec.rb[1:2:3:1] | passed | 0.01369 seconds |
9
+ ./spec/historiographer_spec.rb[1:2:3:2] | passed | 0.059 seconds |
10
+ ./spec/historiographer_spec.rb[1:2:3:3] | passed | 0.00794 seconds |
11
+ ./spec/historiographer_spec.rb[1:2:4] | passed | 0.00348 seconds |
12
+ ./spec/historiographer_spec.rb[1:2:5] | passed | 0.03144 seconds |
13
+ ./spec/historiographer_spec.rb[1:2:6] | passed | 0.01239 seconds |
14
+ ./spec/historiographer_spec.rb[1:3:1] | passed | 0.04853 seconds |
15
+ ./spec/historiographer_spec.rb[1:4:1] | passed | 0.03917 seconds |
16
+ ./spec/historiographer_spec.rb[1:5:1] | passed | 0.03402 seconds |
17
+ ./spec/historiographer_spec.rb[1:5:2] | passed | 0.02494 seconds |
18
+ ./spec/historiographer_spec.rb[1:6:1] | passed | 0.0287 seconds |
19
+ ./spec/historiographer_spec.rb[1:7:1] | passed | 0.07329 seconds |
20
+ ./spec/historiographer_spec.rb[1:7:2] | passed | 0.01293 seconds |
21
+ ./spec/historiographer_spec.rb[1:8:1] | passed | 0.08211 seconds |
@@ -0,0 +1,395 @@
1
+ require "spec_helper"
2
+
3
+ class Post < ActiveRecord::Base
4
+ include Historiographer
5
+ end
6
+
7
+ class PostHistory < ActiveRecord::Base
8
+ end
9
+
10
+ class SafePost < ActiveRecord::Base
11
+ include Historiographer::Safe
12
+ end
13
+
14
+ class SafePostHistory < ActiveRecord::Base
15
+ end
16
+
17
+ class Author < ActiveRecord::Base
18
+ include Historiographer
19
+ end
20
+
21
+ class AuthorHistory < ActiveRecord::Base
22
+ end
23
+
24
+ class User < ActiveRecord::Base
25
+ end
26
+
27
+ describe Historiographer do
28
+ before(:all) do
29
+ @now = Timecop.freeze
30
+ end
31
+
32
+ after(:all) do
33
+ Timecop.return
34
+ end
35
+
36
+ let(:username) { "Test User" }
37
+
38
+ let(:user) do
39
+ User.create(name: username)
40
+ end
41
+
42
+ let(:create_post) do
43
+ Post.create(
44
+ title: "Post 1",
45
+ body: "Great post",
46
+ author_id: 1,
47
+ history_user_id: user.id
48
+ )
49
+ end
50
+
51
+ let(:create_author) do
52
+ Author.create(
53
+ full_name: "Breezy",
54
+ history_user_id: user.id
55
+ )
56
+ end
57
+
58
+ describe "History counting" do
59
+ it "creates history on creation of primary model record" do
60
+ expect {
61
+ create_post
62
+ }.to change {
63
+ PostHistory.count
64
+ }.by 1
65
+ end
66
+
67
+ it "appends new history on update" do
68
+ post = create_post
69
+
70
+ expect {
71
+ post.update(title: "Better Title")
72
+ }.to change {
73
+ PostHistory.count
74
+ }.by 1
75
+ end
76
+
77
+ it "does not append new history if nothing has changed" do
78
+ post = create_post
79
+
80
+ expect {
81
+ post.update(title: post.title)
82
+ }.to_not change {
83
+ PostHistory.count
84
+ }
85
+ end
86
+ end
87
+
88
+ describe "History recording" do
89
+ it "records all fields from the parent" do
90
+ post = create_post
91
+ post_history = post.histories.first
92
+
93
+ expect(post_history.title).to eq post.title
94
+ expect(post_history.body).to eq post.body
95
+ expect(post_history.author_id).to eq post.author_id
96
+ expect(post_history.post_id).to eq post.id
97
+ expect(post_history.history_started_at.to_s).to eq @now.in_time_zone(Historiographer::UTC).to_s
98
+ expect(post_history.history_ended_at).to be_nil
99
+ expect(post_history.history_user_id).to eq user.id
100
+
101
+ post.update(title: "Better title")
102
+ post_histories = post.histories.reload.order("id asc")
103
+ first_history = post_histories.first
104
+ second_history = post_histories.second
105
+
106
+ expect(first_history.history_ended_at.to_s).to eq @now.in_time_zone(Historiographer::UTC).to_s
107
+ expect(second_history.history_ended_at).to be_nil
108
+ end
109
+
110
+ it "cannot create without history_user_id" do
111
+ post = Post.create(
112
+ title: "Post 1",
113
+ body: "Great post",
114
+ author_id: 1,
115
+ )
116
+ expect(post.errors.to_h).to eq({ :history_user_id => "must be an integer" })
117
+
118
+ expect {
119
+ post.send(:record_history)
120
+ }.to raise_error(
121
+ Historiographer::HistoryUserIdMissingError
122
+ )
123
+ end
124
+
125
+ context "When Safe mode" do
126
+ it "creates history without history_user_id" do
127
+ 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")
128
+
129
+ post = SafePost.create(
130
+ title: "Post 1",
131
+ body: "Great post",
132
+ author_id: 1,
133
+ )
134
+ expect(post.errors.to_h.keys).to be_empty
135
+ expect(post).to be_persisted
136
+ expect(post.histories.count).to eq 1
137
+ expect(post.histories.first.history_user_id).to be_nil
138
+ end
139
+
140
+ it "creates history with history_user_id" do
141
+ expect(Rollbar).to_not receive(:error)
142
+
143
+ post = SafePost.create(
144
+ title: "Post 1",
145
+ body: "Great post",
146
+ author_id: 1,
147
+ history_user_id: user.id
148
+ )
149
+ expect(post.errors.to_h.keys).to be_empty
150
+ expect(post).to be_persisted
151
+ expect(post.histories.count).to eq 1
152
+ expect(post.histories.first.history_user_id).to eq user.id
153
+ end
154
+
155
+ it "skips history creation if desired" do
156
+ post = SafePost.new(
157
+ title: "Post 1",
158
+ body: "Great post",
159
+ author_id: 1
160
+ )
161
+
162
+ post.save_without_history
163
+ expect(post).to be_persisted
164
+ expect(post.histories.count).to eq 0
165
+ end
166
+ end
167
+
168
+ it "can override without history_user_id" do
169
+ expect {
170
+ post = Post.new(
171
+ title: "Post 1",
172
+ body: "Great post",
173
+ author_id: 1,
174
+ )
175
+
176
+ post.save_without_history
177
+ }.to_not raise_error
178
+ end
179
+
180
+ it "can override without history_user_id" do
181
+ expect {
182
+ post = Post.new(
183
+ title: "Post 1",
184
+ body: "Great post",
185
+ author_id: 1,
186
+ )
187
+
188
+ post.save_without_history!
189
+ }.to_not raise_error
190
+ end
191
+
192
+ it "does not record histories when main model fails to save" do
193
+ class Post
194
+ after_save :raise_error, prepend: true
195
+
196
+ def raise_error
197
+ raise "Oh no, db issue!"
198
+ end
199
+ end
200
+
201
+ expect { create_post }.to raise_error
202
+ expect(Post.count).to be 0
203
+ expect(PostHistory.count).to be 0
204
+
205
+ Post.skip_callback(:save, :after, :raise_error)
206
+ end
207
+ end
208
+
209
+ describe "Scopes" do
210
+ it "finds current histories" do
211
+ post1 = create_post
212
+ post1.update(title: "Better title")
213
+
214
+ post2 = create_post
215
+ post2.update(title: "Better title")
216
+
217
+ expect(PostHistory.current.pluck(:title)).to all eq "Better title"
218
+ expect(post1.current_history.title).to eq "Better title"
219
+ end
220
+ end
221
+
222
+ describe "Associations" do
223
+ it "names associated records" do
224
+ post1 = create_post
225
+ expect(post1.histories.first).to be_a(PostHistory)
226
+
227
+ expect(post1.histories.first.post).to be(post1)
228
+
229
+ author1 = create_author
230
+ expect(author1.histories.first).to be_a(AuthorHistory)
231
+
232
+ expect(author1.histories.first.author).to be(author1)
233
+ end
234
+ end
235
+
236
+ describe "Histories" do
237
+ it "does not allow direct updates of histories" do
238
+ post1 = create_post
239
+ hist1 = post1.histories.first
240
+
241
+ expect(hist1.update(title: "A different title")).to be false
242
+ expect(hist1.reload.title).to eq post1.title
243
+
244
+ expect(hist1.update!(title: "A different title")).to be false
245
+ expect(hist1.reload.title).to eq post1.title
246
+
247
+ hist1.title = "A different title"
248
+ expect(hist1.save).to be false
249
+ expect(hist1.reload.title).to eq post1.title
250
+
251
+ hist1.title = "A different title"
252
+ expect(hist1.save!).to be false
253
+ expect(hist1.reload.title).to eq post1.title
254
+ end
255
+
256
+ it "does not allow destroys of histories" do
257
+ post1 = create_post
258
+ hist1 = post1.histories.first
259
+ original_history_count = post1.histories.count
260
+
261
+ expect(hist1.destroy).to be false
262
+ expect(hist1.destroy!).to be false
263
+
264
+ expect(post1.histories.count).to be original_history_count
265
+ end
266
+ end
267
+
268
+ describe "Deletion" do
269
+ it "records deleted_at on primary and history if you use acts_as_paranoid" do
270
+ class Post
271
+ acts_as_paranoid
272
+ end
273
+
274
+ post = create_post
275
+
276
+ expect {
277
+ post.destroy
278
+ }.to change {
279
+ PostHistory.count
280
+ }.by 1
281
+
282
+ expect(Post.unscoped.where.not(deleted_at: nil).count).to eq 1
283
+ expect(Post.unscoped.where(deleted_at: nil).count).to eq 0
284
+ expect(PostHistory.where.not(deleted_at: nil).count).to eq 1
285
+ expect(PostHistory.where(deleted_at: nil).count).to eq 1
286
+ end
287
+ end
288
+
289
+ describe "Scopes" do
290
+ it "finds current" do
291
+ post = create_post
292
+ post.update(title: "New Title")
293
+ post.update(title: "New Title 2")
294
+
295
+ expect(PostHistory.current.count).to be 1
296
+ end
297
+
298
+ it "finds current even when the db is updated in an invalid way" do
299
+ postgresql = <<-SQL
300
+ INSERT INTO post_histories (
301
+ title,
302
+ body,
303
+ post_id,
304
+ author_id,
305
+ history_started_at,
306
+ history_ended_at
307
+ ) VALUES (
308
+ 'Post 1',
309
+ 'Text',
310
+ 1,
311
+ 1,
312
+ now() - INTERVAL '1 day',
313
+ NULL
314
+ ), (
315
+ 'Post 1',
316
+ 'Different text',
317
+ 1,
318
+ 1,
319
+ now() - INTERVAL '12 hours',
320
+ NULL
321
+ ), (
322
+ 'Post 1',
323
+ 'Even more different text',
324
+ 1,
325
+ 1,
326
+ now() - INTERVAL '12 hours',
327
+ NULL
328
+ )
329
+ SQL
330
+
331
+ mysql = <<-SQL
332
+ INSERT INTO post_histories (
333
+ title,
334
+ body,
335
+ post_id,
336
+ author_id,
337
+ created_at,
338
+ updated_at,
339
+ history_started_at,
340
+ history_ended_at
341
+ ) VALUES (
342
+ 'Post 1',
343
+ 'Text',
344
+ 1,
345
+ 1,
346
+ now(),
347
+ now(),
348
+ now() - INTERVAL 1 day,
349
+ NULL
350
+ ), (
351
+ 'Post 1',
352
+ 'Different text',
353
+ 1,
354
+ 1,
355
+ now(),
356
+ now(),
357
+ now() - INTERVAL 12 hour,
358
+ NULL
359
+ ), (
360
+ 'Post 1',
361
+ 'Even more different text',
362
+ 1,
363
+ 1,
364
+ now(),
365
+ now(),
366
+ now() - INTERVAL 12 hour,
367
+ NULL
368
+ )
369
+ SQL
370
+
371
+ sql = nil
372
+ case PostHistory.connection.instance_variable_get(:@config)[:adapter]
373
+ when "mysql2"
374
+ sql = mysql
375
+ when "postgresql"
376
+ sql = postgresql
377
+ end
378
+
379
+ PostHistory.connection.execute(sql)
380
+
381
+ expect(PostHistory.current.count).to be 1
382
+ expect(PostHistory.current.first.body).to eq "Even more different text"
383
+ end
384
+ end
385
+
386
+ describe "User associations" do
387
+ it "links to user" do
388
+ post = create_post
389
+ author = create_author
390
+
391
+ expect(post.current_history.user.name).to eq username
392
+ expect(author.current_history.user.name).to eq username
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,40 @@
1
+ ENV["HISTORIOGRAPHY_ENV"] = "test"
2
+
3
+ require_relative "../init.rb"
4
+ require "ostruct"
5
+
6
+ RSpec.configure do |config|
7
+ config.expect_with :rspec do |expectations|
8
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
9
+ end
10
+
11
+ config.mock_with :rspec do |mocks|
12
+ mocks.verify_partial_doubles = true
13
+ end
14
+
15
+ config.filter_run :focus
16
+ config.run_all_when_everything_filtered = true
17
+
18
+ config.example_status_persistence_file_path = "spec/examples.txt"
19
+
20
+ if config.files_to_run.one?
21
+ config.default_formatter = 'doc'
22
+ end
23
+
24
+ config.profile_examples = 10
25
+
26
+ config.order = :random
27
+
28
+ Kernel.srand config.seed
29
+
30
+ config.before(:suite) do
31
+ DatabaseCleaner.strategy = :transaction
32
+ DatabaseCleaner.clean_with(:truncation)
33
+ end
34
+
35
+ config.around(:each) do |example|
36
+ DatabaseCleaner.cleaning do
37
+ example.run
38
+ end
39
+ end
40
+ end