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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 214cb14c7afa97c56711b7aaf4d0ed4c3001ba995578de0187432a6951ee8a80
4
- data.tar.gz: 72798e3577020e343768f3ba79cb93adb9c97f8636e3dd869eb6443683e2d2b0
3
+ metadata.gz: e23a65eb08dfc3ed99f0866620573db756d00a429ae392f2ff5b3795d0bce977
4
+ data.tar.gz: 0b5e8b3052f30faa2944029f487b27825aa6f183dc47a4b342626e1c8a419738
5
5
  SHA512:
6
- metadata.gz: 330a15dd3e253a01b366445442d4b3164b24bb2e557a7a3ed660459314deec0f847fdfdde0fc945ea9b6093943baf73b757e7faf8848f6b8695846a598b322f4
7
- data.tar.gz: f75841865ec536b0f9aaf2ead65260a8efefd7ffe6062928b8de1095e9d35ce2be97bff4f52e2f0fa52aa764d515ac2b12f33659e6c4089f6f1cf21c231f7e12
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.0.0...master)
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 use the following example code to generate your own diffs.
138
-
137
+ You can obtain the diff between two snapshots like this:
139
138
  ```ruby
140
- snapshot = post.snapshots.find_by!(identifier: "some-identifier")
141
-
142
- snapshot_item = snapshot.snapshot_items.find_by!(item_type: "Post")
143
-
144
- old_attrs = snapshot_item.object
145
- new_attrs = post.attributes # or could be another snapshot object
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
- attrs_not_changed = old_attrs.to_a.intersection(new_attrs.to_a).to_h
150
+ You can also obtain the diff between a snapshot and the current record:
151
+ ```ruby
152
+ from = post.snapshots.last
148
153
 
149
- attrs_changed = new_attrs.to_a - attrs_not_changed.to_a
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 = 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
- 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
- SnapshotItem.upsert_all(new_entries.map{|x| x.delete("id"); x }, returning: false)
19
+ new_entries = new_entries.map { |item| item.except("id").merge(created_at: snapshot.created_at, snapshot_id: snapshot.id) }
36
20
 
37
- snapshot.snapshot_items.reset # clear the association cache otherwise snapshot.valid? returns false
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
- @snapshot_children_proc = block
30
+ self.snapshot_children_proc = block
47
31
  else
48
- @snapshot_children_proc
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.attributes
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
- reified_item = si.item_type.constantize.new
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
@@ -1,3 +1,3 @@
1
1
  module ActiveSnapshot
2
- VERSION = "1.0.0".freeze
2
+ VERSION = "1.2.0".freeze
3
3
  end
@@ -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], unique: true
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.0.0
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: 2025-01-17 00:00:00.000000000 Z
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: '6.1'
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: '6.1'
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