active_snapshot 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE +21 -0
  4. data/README.md +145 -0
  5. data/Rakefile +52 -0
  6. data/lib/active_snapshot.rb +17 -0
  7. data/lib/active_snapshot/models/concerns/snapshots_concern.rb +111 -0
  8. data/lib/active_snapshot/models/snapshot.rb +104 -0
  9. data/lib/active_snapshot/models/snapshot_item.rb +40 -0
  10. data/lib/active_snapshot/version.rb +3 -0
  11. data/lib/generators/active_snapshot/install/install_generator.rb +22 -0
  12. data/lib/generators/active_snapshot/install/templates/create_snapshots_tables.rb.erb +21 -0
  13. data/lib/generators/active_snapshot/migration_generator.rb +76 -0
  14. data/test/dummy_app/Rakefile +7 -0
  15. data/test/dummy_app/app/assets/config/manifest.js +3 -0
  16. data/test/dummy_app/app/assets/javascripts/application.js +0 -0
  17. data/test/dummy_app/app/assets/stylesheets/application.css +3 -0
  18. data/test/dummy_app/app/controllers/application_controller.rb +3 -0
  19. data/test/dummy_app/app/models/application_record.rb +3 -0
  20. data/test/dummy_app/app/models/comment.rb +5 -0
  21. data/test/dummy_app/app/models/post.rb +13 -0
  22. data/test/dummy_app/app/models/volatile_post.rb +5 -0
  23. data/test/dummy_app/app/views/layouts/application.html.erb +14 -0
  24. data/test/dummy_app/config.ru +4 -0
  25. data/test/dummy_app/config/application.rb +61 -0
  26. data/test/dummy_app/config/boot.rb +10 -0
  27. data/test/dummy_app/config/database.yml +20 -0
  28. data/test/dummy_app/config/environment.rb +5 -0
  29. data/test/dummy_app/config/environments/development.rb +30 -0
  30. data/test/dummy_app/config/environments/production.rb +60 -0
  31. data/test/dummy_app/config/environments/test.rb +41 -0
  32. data/test/dummy_app/config/initializers/backtrace_silencers.rb +7 -0
  33. data/test/dummy_app/config/initializers/inflections.rb +10 -0
  34. data/test/dummy_app/config/initializers/mime_types.rb +5 -0
  35. data/test/dummy_app/config/initializers/secret_token.rb +11 -0
  36. data/test/dummy_app/config/initializers/session_store.rb +8 -0
  37. data/test/dummy_app/config/initializers/wrap_parameters.rb +14 -0
  38. data/test/dummy_app/config/locales/en.yml +5 -0
  39. data/test/dummy_app/config/routes.rb +6 -0
  40. data/test/dummy_app/config/secrets.yml +22 -0
  41. data/test/dummy_app/db/migrate/20210128155312_set_up_test_tables.rb +19 -0
  42. data/test/dummy_app/db/migrate/20210306070749_create_snapshots_tables.rb +21 -0
  43. data/test/dummy_app/log/development.log +9 -0
  44. data/test/dummy_app/log/test.log +46812 -0
  45. data/test/generators/active_snapshot/install_generator_test.rb +31 -0
  46. data/test/models/snapshot_item_test.rb +75 -0
  47. data/test/models/snapshot_test.rb +109 -0
  48. data/test/models/snapshots_concern_test.rb +111 -0
  49. data/test/test_helper.rb +71 -0
  50. data/test/unit/active_snapshot_test.rb +60 -0
  51. data/test/unit/errors_test.rb +12 -0
  52. metadata +231 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 59dcd6fb412c1bb5f9fdc5bd9c447e4af9811837e0523da9cf7db4c12ce857fa
4
+ data.tar.gz: 31942b2cc72329c428f3a322d18d934b531bdd29b7e8a3dc956433dd0e822483
5
+ SHA512:
6
+ metadata.gz: 367930717d8d578fe2ace8ddd3427a44f55fd52089e18862c7b0751dbc294ef6641c509d6b775fc3dc6e9f21e50ae9ef7b91e4b1c89a45b240da6b0343dbf7cf
7
+ data.tar.gz: ac0193a62d929e16ed19f8918b7fe1ad96b251819e98770add848d041ce7c4a7cad1771a158ca63130ed05e446ae53b2d0357cf4bb0a33a262c0e6de1543597e
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ CHANGELOG
2
+ ---------
3
+
4
+ - **Unreleased**
5
+ * [View Diff](https://github.com/westonganger/active_snapshot/compare/v0.1.0...master)
6
+ * Nothing yet
7
+
8
+ - **v0.1.0** - Mar 5, 2021
9
+ * [View Diff](https://github.com/westonganger/active_snapshot/compare/edbbfd3...v0.1.0)
10
+ * Gem Initial Release
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Weston Ganger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # ActiveSnapshot
2
+
3
+ <a href="https://badge.fury.io/rb/active_snapshot" target="_blank"><img height="21" style='border:0px;height:21px;' border='0' src="https://badge.fury.io/rb/active_snapshot.svg" alt="Gem Version"></a>
4
+ <a href='https://github.com/westonganger/active_snapshot/actions' target='_blank'><img src="https://github.com/westonganger/active_snapshot/workflows/Tests/badge.svg" style="max-width:100%;" height='21' style='border:0px;height:21px;' border='0' alt="CI Status"></a>
5
+ <a href='https://rubygems.org/gems/active_snapshot' target='_blank'><img height='21' style='border:0px;height:21px;' src='https://ruby-gem-downloads-badge.herokuapp.com/active_snapshot?label=rubygems&type=total&total_label=downloads&color=brightgreen' border='0' alt='RubyGems Downloads' /></a>
6
+
7
+ Simplified snapshots and restoration for ActiveRecord models and associations with a transparent white-box implementation.
8
+
9
+ Key Features:
10
+
11
+ - Create and Restore snapshots of a parent record and any specified child records
12
+ - Predictible and explicit behaviour provides much needed clarity to your restore logic
13
+ - Snapshots are created upon request only, we do not use any callbacks
14
+ - Tiny method footprint so its easy to completely override the logic later
15
+
16
+ Why This Library:
17
+
18
+ Model Versioning and Restoration require concious thought, design, and understanding. You should understand your versioning and restoration process completely. This gem's small API and fully understandable design fully supports this.
19
+
20
+ I do not recommend using paper_trail-association_tracking because it is mostly a blackbox solution which encourages you to set it up and then assume its Just Working<sup>TM</sup>. This makes for major data problems later. Dont fall into this trap. Instead read this gems brief source code completely before use OR copy the code straight into your codebase. Once you know it, then you are free.
21
+
22
+
23
+
24
+ # Installation
25
+
26
+ ```ruby
27
+ gem 'active_snapshot'
28
+ ```
29
+
30
+ Then generate and run the necessary migrations to setup the `snapshots` and `snapshot_items` tables.
31
+
32
+ ```
33
+ rails generate active_snapshot:install
34
+ rake db:migrate
35
+ ```
36
+
37
+ Then add `include ActiveSnapshot` to your ApplicationRecord or individual models.
38
+
39
+ ```ruby
40
+ class ApplicationRecord < ActiveRecord::Base
41
+ include ActiveSortOrder
42
+ end
43
+ ```
44
+
45
+ This defines the following associations on your models:
46
+
47
+ ```ruby
48
+ has_many :snapshots, as: :item, class_name: 'Snapshot'
49
+ has_many :snapshot_items, as: :item, class_name: 'SnapshotItem'
50
+ ```
51
+
52
+ It defines an optional extension to your model `has_snapshot_children`.
53
+
54
+ It defines one instance method to your model: `create_snapshot!`
55
+
56
+ # Basic Usage
57
+
58
+ You now have access to the following methods:
59
+
60
+ ```ruby
61
+ post = Post.first
62
+
63
+ # Create snapshot grouped by identifier, only :identifier argument is required, all others are optional
64
+ snapshot = post.create_snapshot!(
65
+ identifier: "snapshot_1", # Required
66
+ user: current_user,
67
+ metadata: {
68
+ foo: :bar
69
+ },
70
+ )
71
+
72
+ # Restore snapshot and all its child snapshots
73
+ snapshot.restore!
74
+
75
+ # Destroy snapshot and all its child snapshots
76
+ # must be performed manually, snapshots and snapshot items are NEVER destroyed automatically
77
+ snapshot.destroy!
78
+ ```
79
+
80
+ # Restoring Associated / Child Records
81
+
82
+ ```ruby
83
+ class Post < ActiveRecord::Base
84
+ include ActiveSnapshot
85
+
86
+ has_snapshot_children do
87
+ ### Executed in the context of the instance / self
88
+
89
+ ### In this example, we choose to do a fresh load from the database of the record and all associated records from the database
90
+ instance = self.class.includes(:comments, :ip_address).find(id)
91
+
92
+ ### Define the associated records that will be restored
93
+ {
94
+ comments: instance.comments,
95
+ tags: {
96
+ records: instance.tags
97
+ },
98
+ ip_address: {
99
+ record: instance.ip_address,
100
+ delete_method: ->(item){ item.release! }
101
+ }
102
+ }
103
+ end
104
+
105
+ end
106
+ ```
107
+
108
+ Now when you run `create_snapshot!` the associations will be tracked accordingly
109
+
110
+ # Reifying Snapshot Items
111
+
112
+ You can view all of the reified snapshot items by calling the following method. Its completely up to you on how to use this data.
113
+
114
+ ```ruby
115
+ reified_items = snapshot.fetch_reified_items
116
+ ```
117
+
118
+ As a safety these records have the `@readonly = true` attribute set on them. If you want to perform any write actions on the returned instances you will have to set `@readonly = nil`.
119
+
120
+ ```ruby
121
+ writable_reified_items = snapshot.fetch_reified_items.transform_values do |array|
122
+ array.map{|x| x.instance_variable_set("@readonly", false); x}
123
+ end
124
+ ```
125
+
126
+ # Key Models Provided & Additional Customizations
127
+
128
+ A key aspect of this library is its simplicity and small API. For major functionality customizations we encourage you to first delete this gem and then copy this gems code directly into your repository.
129
+
130
+ I strongly encourage you to read the code for this library to understand how it works within your project so that you are capable of customizing the functionality later.
131
+
132
+ - [SnapshotsConcern](./lib/active_snapshot/models/concerns/snapshots_concern.rb)
133
+ * Defines `snapshots` and `snapshot_items` has_many associations
134
+ * Defines `create_snapshot!` and `has_snapshot_children` methods
135
+ - [Snapshot](./lib/active_snapshot/models/snapshot.rb)
136
+ * Contains a unique `identifier` column
137
+ * `has_many :item_snapshots`
138
+ - [SnapshotItem](./lib/active_snapshot/models/snapshot_item.rb)
139
+ * Contains `object` column with yaml encoded model instance `attributes`
140
+ * `belongs_to :snapshot`
141
+
142
+
143
+ # Credits
144
+
145
+ Created & Maintained by [Weston Ganger](https://westonganger.com) - [@westonganger](https://github.com/westonganger)
data/Rakefile ADDED
@@ -0,0 +1,52 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/lib/active_snapshot/version.rb')
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task :db_prepare do
13
+ if ENV['CI'] != "true"
14
+ begin
15
+ require 'pg'
16
+
17
+ ### FOR LOCAL TESTING
18
+ `dropdb active_snapshot_test && createdb active_snapshot_test` rescue true
19
+ rescue LoadError
20
+ # Do nothing
21
+ end
22
+ end
23
+
24
+ Dir.chdir("test/dummy_app") do
25
+ ### Instantiates Rails
26
+ require File.expand_path("test/dummy_app/config/environment.rb", __dir__)
27
+
28
+ migration_path = "db/migrate"
29
+
30
+ ### Generate Migration
31
+ require "generators/active_snapshot/install/install_generator"
32
+
33
+ generator = ActiveSnapshot::InstallGenerator.new
34
+
35
+ Dir.glob(Rails.root.join(migration_path, "*#{generator.class::MIGRATION_NAME}.rb")).each do |f|
36
+ FileUtils.rm(f)
37
+ end
38
+
39
+ generator.create_migration_file
40
+ end ### END chdir
41
+ end
42
+
43
+ task default: [:db_prepare, :test]
44
+
45
+ task :console do
46
+ require 'active_snapshot'
47
+
48
+ require_relative 'test/dummy_app/app/models/post'
49
+
50
+ require 'irb'
51
+ binding.irb
52
+ end
@@ -0,0 +1,17 @@
1
+ require "active_record"
2
+ require "activerecord-import"
3
+
4
+ require "active_snapshot/version"
5
+
6
+ require "active_snapshot/models/snapshot"
7
+ require "active_snapshot/models/snapshot_item"
8
+
9
+ require "active_snapshot/models/concerns/snapshots_concern"
10
+
11
+ module ActiveSnapshot
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ include ActiveSnapshot::SnapshotsConcern
16
+ end
17
+ end
@@ -0,0 +1,111 @@
1
+ module ActiveSnapshot
2
+ module SnapshotsConcern
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ ### We do NOT mark these as dependent: :destroy, the developer must manually destroy the snapshots or individual snapshot items
7
+ has_many :snapshots, as: :item, class_name: 'ActiveSnapshot::Snapshot'
8
+ has_many :snapshot_items, as: :item, class_name: 'ActiveSnapshot::SnapshotItem'
9
+ end
10
+
11
+ def create_snapshot!(identifier, user: nil, metadata: nil)
12
+ snapshot = snapshots.create!({
13
+ identifier: identifier,
14
+ user_id: (user.id if user),
15
+ user_type: (user.class.name if user),
16
+ metadata: (metadata || {}),
17
+ })
18
+
19
+ snapshot_items = []
20
+
21
+ snapshot_items << snapshot.build_snapshot_item(self)
22
+
23
+ snapshot_children = self.children_to_snapshot
24
+
25
+ if snapshot_children
26
+ snapshot_children.each do |child_group_name, h|
27
+ h[:records].each do |child_item|
28
+ snapshot_items << snapshot.build_snapshot_item(child_item, child_group_name: child_group_name)
29
+ end
30
+ end
31
+ end
32
+
33
+ SnapshotItem.import(snapshot_items, validate: true)
34
+
35
+ snapshot
36
+ end
37
+
38
+ class_methods do
39
+
40
+ def has_snapshot_children(&block)
41
+ if !block_given? && !defined?(@snapshot_children_proc)
42
+ raise ArgumentError.new("Invalid `has_snapshot_children` requires block to be defined")
43
+ elsif block_given?
44
+ @snapshot_children_proc = block
45
+ else
46
+ @snapshot_children_proc
47
+ end
48
+ end
49
+
50
+ end
51
+
52
+ def children_to_snapshot
53
+ snapshot_children_proc = self.class.has_snapshot_children
54
+
55
+ if !snapshot_children_proc
56
+ raise ArgumentError.new("`has_snapshot_children` must be defined on your class")
57
+ else
58
+ records = self.instance_exec(&snapshot_children_proc)
59
+
60
+ if records.is_a?(Hash)
61
+ records = records.with_indifferent_access
62
+ else
63
+ raise ArgumentError.new("Invalid `has_snapshot_children` definition. Must return a Hash")
64
+ end
65
+
66
+ snapshot_children = {}.with_indifferent_access
67
+
68
+ records.each do |assoc_name, opts|
69
+ snapshot_children[assoc_name] = {}
70
+
71
+ if opts.is_a?(ActiveRecord::Relation) || opts.is_a?(Array)
72
+ snapshot_children[assoc_name][:records] = opts
73
+
74
+ elsif opts.is_a?(Hash)
75
+ opts = opts.with_indifferent_access
76
+
77
+ records = opts[:records] || opts[:record]
78
+
79
+ if records
80
+ if records.respond_to?(:to_a)
81
+ records = records.to_a
82
+ else
83
+ records = [records]
84
+ end
85
+
86
+ snapshot_children[assoc_name][:records] = records
87
+ else
88
+ raise ArgumentError.new("Invalid `has_snapshot_children` definition. Must define a :records key for each child association.")
89
+ end
90
+
91
+ delete_method = opts[:delete_method]
92
+
93
+ if delete_method.present? && delete_method.to_s != "default"
94
+ if delete_method.respond_to?(:call)
95
+ snapshot_children[assoc_name][:delete_method] = delete_method
96
+ else
97
+ raise ArgumentError.new("Invalid `has_snapshot_children` definition. Invalid :delete_method argument. Must be a Lambda / Proc")
98
+ end
99
+ end
100
+
101
+ else
102
+ raise ArgumentError.new("Invalid `has_snapshot_children` definition. Invalid :records argument. Must be a Hash or Array")
103
+ end
104
+
105
+ return snapshot_children
106
+ end
107
+ end
108
+ end
109
+
110
+ end
111
+ end
@@ -0,0 +1,104 @@
1
+ module ActiveSnapshot
2
+ class Snapshot < ActiveRecord::Base
3
+ self.table_name = "snapshots"
4
+
5
+ if defined?(ProtectedAttributes)
6
+ attr_accessible :item_id, :item_type, :identifier, :user_id, :user_type
7
+ end
8
+
9
+ belongs_to :user, polymorphic: true
10
+ belongs_to :item, polymorphic: true
11
+ has_many :snapshot_items, class_name: 'ActiveSnapshot::SnapshotItem', dependent: :destroy
12
+
13
+ validates :item_id, presence: true
14
+ validates :item_type, presence: true
15
+ validates :identifier, presence: true, uniqueness: { scope: [:item_id, :item_type] }
16
+ validates :user_type, presence: true, if: :user_id
17
+
18
+ def metadata
19
+ @metadata ||= self[:metadata].with_indifferent_access
20
+ end
21
+
22
+ def metadata=(val)
23
+ @metadata = nil
24
+ self[:metadata] = val
25
+ end
26
+
27
+ def build_snapshot_item(instance, child_group_name: nil)
28
+ self.snapshot_items.new({
29
+ object: instance.attributes,
30
+ item_id: instance.id,
31
+ item_type: instance.class.name,
32
+ child_group_name: child_group_name,
33
+ })
34
+ end
35
+
36
+ def restore!
37
+ ActiveRecord::Base.transaction do
38
+ ### Cache the child snapshots in a variable for re-use
39
+ cached_snapshot_items = snapshot_items.includes(:item)
40
+
41
+ existing_snapshot_children = item ? item.children_to_snapshot : []
42
+
43
+ if existing_snapshot_children.any?
44
+ children_to_keep = Set.new
45
+
46
+ cached_snapshot_items.each do |snapshot_item|
47
+ key = "#{snapshot_item.item_type} #{snapshot_item.item_id}"
48
+
49
+ children_to_keep << key
50
+ end
51
+
52
+ ### Destroy or Detach Items not included in this Snapshot's Items
53
+ ### We do this first in case you later decide to validate children in ItemSnapshot#restore_item! method
54
+ existing_snapshot_children.each do |child_group_name, h|
55
+ delete_method = h[:delete_method] || ->(child_record){ child_record.destroy! }
56
+
57
+ h[:records].each do |child_record|
58
+ child_record_id = child_record.send(child_record.class.send(:primary_key))
59
+
60
+ key = "#{child_record.class.name} #{child_record_id}"
61
+
62
+ if children_to_keep.exclude?(key)
63
+ delete_method.call(child_record)
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ ### Create or Update Items from Snapshot Items
70
+ cached_snapshot_items.each do |snapshot_item|
71
+ snapshot_item.restore_item!
72
+ end
73
+ end
74
+
75
+ return true
76
+ end
77
+
78
+ def fetch_reified_items
79
+ reified_children_hash = {}.with_indifferent_access
80
+
81
+ reified_parent = nil
82
+
83
+ snapshot_items.each do |si|
84
+ reified_item = si.item_type.constantize.new(si.object)
85
+
86
+ reified_item.readonly!
87
+
88
+ key = si.child_group_name
89
+
90
+ if key
91
+ reified_children_hash[key] ||= []
92
+
93
+ reified_children_hash[key] << reified_item
94
+
95
+ elsif [self.item_id, self.item_type] == [si.item_id, si.item_type]
96
+ reified_parent = reified_item
97
+ end
98
+ end
99
+
100
+ return [reified_parent, reified_children_hash]
101
+ end
102
+
103
+ end
104
+ end