active_snapshot 1.0.0 → 1.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -1
- data/README.md +15 -10
- data/lib/active_snapshot/models/concerns/snapshots_concern.rb +10 -27
- data/lib/active_snapshot/models/snapshot.rb +119 -11
- data/lib/active_snapshot/models/snapshot_item.rb +1 -1
- data/lib/active_snapshot/version.rb +1 -1
- data/lib/active_snapshot.rb +11 -0
- data/lib/generators/active_snapshot/install/templates/create_snapshots_tables.rb.erb +1 -1
- metadata +8 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e23a65eb08dfc3ed99f0866620573db756d00a429ae392f2ff5b3795d0bce977
|
|
4
|
+
data.tar.gz: 0b5e8b3052f30faa2944029f487b27825aa6f183dc47a4b342626e1c8a419738
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 82346ff73dc868ed37fb94b28dea73e6b228e8047c2a52f36a6d14e4b911995801fccec23effe56de353042fc991b26db3c48dd1c7f563e5c829eb0b00f31820
|
|
7
|
+
data.tar.gz: 883e5df0beb0e4ff2288ef9fe0bcff1970b49ff5cae6858be53f5d65acae7324d96c8f43dea7d51f4a832bc5ddcd8780cbf354a8c6cbfa3d36690ff4f0a35513
|
data/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,23 @@ CHANGELOG
|
|
|
2
2
|
---------
|
|
3
3
|
|
|
4
4
|
- **Unreleased**
|
|
5
|
-
* [View Diff](https://github.com/westonganger/active_snapshot/compare/v1.
|
|
5
|
+
* [View Diff](https://github.com/westonganger/active_snapshot/compare/v1.2.0...master)
|
|
6
6
|
* Nothing yet
|
|
7
7
|
|
|
8
|
+
- **v1.2.0** - Jun 25, 2026
|
|
9
|
+
* [View Diff](https://github.com/westonganger/active_snapshot/compare/v1.1.0...v1.2.0)
|
|
10
|
+
* [#80](https://github.com/westonganger/active_snapshot/pull/80)
|
|
11
|
+
* Encrypted fields are now also encrypted in snapshots.
|
|
12
|
+
* Drop support for rails < 7.0
|
|
13
|
+
|
|
14
|
+
- **v1.1.0** - Dec 28 2025
|
|
15
|
+
* [View Diff](https://github.com/westonganger/active_snapshot/compare/v1.0.0...v1.1.0)
|
|
16
|
+
* [#77](https://github.com/westonganger/active_snapshot/pull/77) - Remove uniqueness constraint from `snapshot_items` table migration
|
|
17
|
+
- Upgrade Instructions: Create a DB migration with `remove_index :snapshot_items, [:snapshot_id, :item_id, :item_type], unique: true`
|
|
18
|
+
* [#76](https://github.com/westonganger/active_snapshot/pull/76) - Add full STI support (inherit snapshot children definition from base class, and allow overriding in STI child classes)
|
|
19
|
+
* [#74](https://github.com/westonganger/active_snapshot/pull/74) - Ensure no exception is raised when class does not have method defined_enums
|
|
20
|
+
* [#72](https://github.com/westonganger/active_snapshot/pull/72) - Adds `ActiveSnapshot::Snapshot.diff(from, to)` to get the difference between two snapshots or a snapshot and the current record.
|
|
21
|
+
|
|
8
22
|
- **v1.0.0** - Jan 17 2025
|
|
9
23
|
* [View Diff](https://github.com/westonganger/active_snapshot/compare/v0.5.2...v1.0.0)
|
|
10
24
|
* There are no functional changes. This release v1.0.0 is to signal that its stable and ready for widespread usage.
|
data/README.md
CHANGED
|
@@ -134,19 +134,24 @@ reified_children_hash.first.instance_variable_set("@readonly", false)
|
|
|
134
134
|
|
|
135
135
|
# Diffing Versions
|
|
136
136
|
|
|
137
|
-
You can
|
|
138
|
-
|
|
137
|
+
You can obtain the diff between two snapshots like this:
|
|
139
138
|
```ruby
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
139
|
+
from = post.snapshots.first
|
|
140
|
+
to = post.snapshots.second
|
|
141
|
+
|
|
142
|
+
ActiveSnapshot::Snapshot.diff(from, to)
|
|
143
|
+
# [
|
|
144
|
+
# {action: :update, item_type: "Post", item_id: 1, changes: {name: ["Old Name", "New Name"]}},
|
|
145
|
+
# {action: :destroy, item_type: "Comment", item_id: 1, changes: {id: [1, nil], content: ["Some Content", nil]}},
|
|
146
|
+
# {action: :create, item_type: "Comment", item_id: 2, changes: {id: [nil, 1], content: [nil, "New Content"]}}
|
|
147
|
+
# ]
|
|
148
|
+
```
|
|
146
149
|
|
|
147
|
-
|
|
150
|
+
You can also obtain the diff between a snapshot and the current record:
|
|
151
|
+
```ruby
|
|
152
|
+
from = post.snapshots.last
|
|
148
153
|
|
|
149
|
-
|
|
154
|
+
ActiveSnapshot::Snapshot.diff(from, post)
|
|
150
155
|
```
|
|
151
156
|
|
|
152
157
|
# Important Data Considerations / Warnings
|
|
@@ -6,35 +6,19 @@ module ActiveSnapshot
|
|
|
6
6
|
### We do NOT mark these as dependent: :destroy, the developer must manually destroy the snapshots or individual snapshot items
|
|
7
7
|
has_many :snapshots, as: :item, class_name: 'ActiveSnapshot::Snapshot'
|
|
8
8
|
has_many :snapshot_items, as: :item, class_name: 'ActiveSnapshot::SnapshotItem'
|
|
9
|
+
|
|
10
|
+
class_attribute :snapshot_children_proc
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
def create_snapshot!(identifier: nil, user: nil, metadata: nil)
|
|
12
|
-
snapshot =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
metadata: (metadata || {}),
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
new_entries = []
|
|
20
|
-
|
|
21
|
-
current_time = Time.now
|
|
22
|
-
|
|
23
|
-
new_entries << snapshot.build_snapshot_item(self).attributes.merge(created_at: current_time)
|
|
24
|
-
|
|
25
|
-
snapshot_children = self.children_to_snapshot
|
|
26
|
-
|
|
27
|
-
if snapshot_children
|
|
28
|
-
snapshot_children.each do |child_group_name, h|
|
|
29
|
-
h[:records].each do |child_item|
|
|
30
|
-
new_entries << snapshot.build_snapshot_item(child_item, child_group_name: child_group_name).attributes.merge(created_at: current_time)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
14
|
+
snapshot = Snapshot.build_snapshot(self, identifier: identifier, user: user, metadata: metadata)
|
|
15
|
+
new_entries = snapshot.snapshot_items.map(&:attributes)
|
|
16
|
+
snapshot.snapshot_items.reset # clear the association cache otherwise snapshot.valid? returns false
|
|
17
|
+
snapshot.save!
|
|
34
18
|
|
|
35
|
-
|
|
19
|
+
new_entries = new_entries.map { |item| item.except("id").merge(created_at: snapshot.created_at, snapshot_id: snapshot.id) }
|
|
36
20
|
|
|
37
|
-
|
|
21
|
+
SnapshotItem.upsert_all(new_entries, returning: false)
|
|
38
22
|
|
|
39
23
|
snapshot
|
|
40
24
|
end
|
|
@@ -43,9 +27,9 @@ module ActiveSnapshot
|
|
|
43
27
|
|
|
44
28
|
def has_snapshot_children(&block)
|
|
45
29
|
if block_given?
|
|
46
|
-
|
|
30
|
+
self.snapshot_children_proc = block
|
|
47
31
|
else
|
|
48
|
-
|
|
32
|
+
self.snapshot_children_proc
|
|
49
33
|
end
|
|
50
34
|
end
|
|
51
35
|
|
|
@@ -122,6 +106,5 @@ module ActiveSnapshot
|
|
|
122
106
|
return snapshot_children
|
|
123
107
|
end
|
|
124
108
|
end
|
|
125
|
-
|
|
126
109
|
end
|
|
127
110
|
end
|
|
@@ -15,6 +15,121 @@ module ActiveSnapshot
|
|
|
15
15
|
validates :identifier, uniqueness: { scope: [:item_id, :item_type], allow_nil: true}
|
|
16
16
|
validates :user_type, presence: true, if: :user_id
|
|
17
17
|
|
|
18
|
+
class << self
|
|
19
|
+
def build_snapshot(resource, identifier: nil, user: nil, metadata: nil)
|
|
20
|
+
snapshot = resource.snapshots.build({
|
|
21
|
+
identifier: identifier,
|
|
22
|
+
user_id: (user.id if user),
|
|
23
|
+
user_type: (user.class.name if user),
|
|
24
|
+
metadata: (metadata || {}),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
snapshot.build_snapshot_item(resource)
|
|
28
|
+
|
|
29
|
+
snapshot_children = resource.children_to_snapshot
|
|
30
|
+
|
|
31
|
+
snapshot_children&.each do |child_group_name, h|
|
|
32
|
+
h[:records].each do |child_item|
|
|
33
|
+
snapshot.build_snapshot_item(child_item, child_group_name: child_group_name)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
snapshot
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def diff(from, to)
|
|
41
|
+
if !from.is_a?(Snapshot)
|
|
42
|
+
raise ArgumentError.new("'from' must be an ActiveSnapshot::Snapshot")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
to_item_id, to_item_type = to.is_a?(Snapshot) ? [to.item_id, to.item_type] : [to.id, to.class.polymorphic_name]
|
|
46
|
+
|
|
47
|
+
if from.item_id != to_item_id || from.item_type != to_item_type
|
|
48
|
+
raise ArgumentError.new("Both records must reference the same item")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if to.is_a?(Snapshot) && from.created_at > to.created_at
|
|
52
|
+
raise ArgumentError.new("'to' must be a newer snapshot than 'from'")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
from_snapshot = from
|
|
56
|
+
to_snapshot = to.is_a?(Snapshot) ? to : build_snapshot(to)
|
|
57
|
+
|
|
58
|
+
from_snapshot_items = from_snapshot.snapshot_items
|
|
59
|
+
to_snapshot_items = to_snapshot.snapshot_items
|
|
60
|
+
|
|
61
|
+
diffs = []
|
|
62
|
+
|
|
63
|
+
from_snapshot_items.each do |from_snapshot_item|
|
|
64
|
+
to_snapshot_item = to_snapshot_items.find do |item|
|
|
65
|
+
item.item_id == from_snapshot_item.item_id && item.item_type == from_snapshot_item.item_type
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if to_snapshot_item.nil?
|
|
69
|
+
diffs << {
|
|
70
|
+
action: :destroy,
|
|
71
|
+
item_id: from_snapshot_item.item_id,
|
|
72
|
+
item_type: from_snapshot_item.item_type,
|
|
73
|
+
changes: snapshot_item_changes(from_snapshot_item, nil)
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
changes = snapshot_item_changes(from_snapshot_item, to_snapshot_item)
|
|
77
|
+
|
|
78
|
+
next if changes.empty?
|
|
79
|
+
|
|
80
|
+
diffs << {
|
|
81
|
+
action: :update,
|
|
82
|
+
item_id: from_snapshot_item.item_id,
|
|
83
|
+
item_type: from_snapshot_item.item_type,
|
|
84
|
+
changes: changes
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
to_snapshot_items.each do |to_snapshot_item|
|
|
90
|
+
from_snapshot_item = from_snapshot_items.find do |item|
|
|
91
|
+
item.item_id == to_snapshot_item.item_id && item.item_type == to_snapshot_item.item_type
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
next if from_snapshot_item.present?
|
|
95
|
+
|
|
96
|
+
diffs << {
|
|
97
|
+
action: :create,
|
|
98
|
+
item_id: to_snapshot_item.item_id,
|
|
99
|
+
item_type: to_snapshot_item.item_type,
|
|
100
|
+
changes: snapshot_item_changes(nil, to_snapshot_item)
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
diffs
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def snapshot_item_changes(from, to)
|
|
110
|
+
from_object = from ? from.object : {}
|
|
111
|
+
to_object = to ? to.object : {}
|
|
112
|
+
|
|
113
|
+
item_type = (from || to).item_type
|
|
114
|
+
item_class = item_type.constantize
|
|
115
|
+
|
|
116
|
+
keys = (from_object.keys + to_object.keys).uniq
|
|
117
|
+
|
|
118
|
+
changes = {}
|
|
119
|
+
|
|
120
|
+
keys.each do |key|
|
|
121
|
+
from_value = ActiveSnapshot.get_deserialized_value(item_class, key: key, value: from_object[key])
|
|
122
|
+
to_value = ActiveSnapshot.get_deserialized_value(item_class, key: key, value: to_object[key])
|
|
123
|
+
|
|
124
|
+
next if to_value == from_value
|
|
125
|
+
|
|
126
|
+
changes[key.to_sym] = [from_value, to_value]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
changes
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
18
133
|
def metadata
|
|
19
134
|
return @metadata if @metadata
|
|
20
135
|
|
|
@@ -50,15 +165,7 @@ module ActiveSnapshot
|
|
|
50
165
|
end
|
|
51
166
|
|
|
52
167
|
def build_snapshot_item(instance, child_group_name: nil)
|
|
53
|
-
attrs = instance.
|
|
54
|
-
|
|
55
|
-
if instance.class.defined_enums.any?
|
|
56
|
-
instance.class.defined_enums.slice(*attrs.keys).each do |enum_col_name, enum_mapping|
|
|
57
|
-
val = attrs.fetch(enum_col_name)
|
|
58
|
-
next if val.nil?
|
|
59
|
-
attrs[enum_col_name] = enum_mapping.fetch(val)
|
|
60
|
-
end
|
|
61
|
-
end
|
|
168
|
+
attrs = instance.attributes_for_database
|
|
62
169
|
|
|
63
170
|
self.snapshot_items.new({
|
|
64
171
|
object: attrs,
|
|
@@ -116,11 +223,12 @@ module ActiveSnapshot
|
|
|
116
223
|
reified_parent = nil
|
|
117
224
|
|
|
118
225
|
snapshot_items.each do |si|
|
|
119
|
-
|
|
226
|
+
item_class = si.item_type.constantize
|
|
227
|
+
reified_item = item_class.new
|
|
120
228
|
|
|
121
229
|
si.object.each do |k,v|
|
|
122
230
|
if reified_item.respond_to?("#{k}=")
|
|
123
|
-
reified_item[k] = v
|
|
231
|
+
reified_item[k] = ActiveSnapshot.get_deserialized_value(item_class, key: k, value: v)
|
|
124
232
|
else
|
|
125
233
|
# database column was likely dropped since the snapshot was created
|
|
126
234
|
end
|
|
@@ -55,7 +55,7 @@ module ActiveSnapshot
|
|
|
55
55
|
|
|
56
56
|
object.each do |k,v|
|
|
57
57
|
if item.respond_to?("#{k}=")
|
|
58
|
-
item[k] = v
|
|
58
|
+
item[k] = ActiveSnapshot.get_deserialized_value(item.class, key: k, value: v)
|
|
59
59
|
else
|
|
60
60
|
# database column was likely dropped since the snapshot was created
|
|
61
61
|
end
|
data/lib/active_snapshot.rb
CHANGED
|
@@ -13,6 +13,17 @@ module ActiveSnapshot
|
|
|
13
13
|
return @@config
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
|
+
|
|
17
|
+
def self.get_deserialized_value(klass, key:, value:)
|
|
18
|
+
return value if value.nil?
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
klass.attribute_types[key].deserialize(value)
|
|
22
|
+
rescue ActiveRecord::Encryption::Errors::Base
|
|
23
|
+
# handle columns which are changed from non-encrypted to encrypted
|
|
24
|
+
value
|
|
25
|
+
end
|
|
26
|
+
end
|
|
16
27
|
end
|
|
17
28
|
|
|
18
29
|
ActiveSupport.on_load(:active_record) do
|
|
@@ -16,7 +16,7 @@ class <%= migration_name %> < ActiveRecord::Migration::Current
|
|
|
16
16
|
create_table :snapshot_items<%= table_options %> do |t|
|
|
17
17
|
t.belongs_to :snapshot, null: false, index: true
|
|
18
18
|
t.belongs_to :item, polymorphic: true, null: false, index: true
|
|
19
|
-
t.index [:snapshot_id, :item_id, :item_type]
|
|
19
|
+
t.index [:snapshot_id, :item_id, :item_type]
|
|
20
20
|
|
|
21
21
|
t.json :object, null: false
|
|
22
22
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_snapshot
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Weston Ganger
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-06-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -16,14 +16,14 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
19
|
+
version: '7'
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
26
|
+
version: '7'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: railties
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -56,16 +56,16 @@ dependencies:
|
|
|
56
56
|
name: minitest
|
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
|
58
58
|
requirements:
|
|
59
|
-
- - "
|
|
59
|
+
- - "~>"
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: '0'
|
|
61
|
+
version: '5.0'
|
|
62
62
|
type: :development
|
|
63
63
|
prerelease: false
|
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
|
-
- - "
|
|
66
|
+
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: '0'
|
|
68
|
+
version: '5.0'
|
|
69
69
|
- !ruby/object:Gem::Dependency
|
|
70
70
|
name: minitest-reporters
|
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|