paper_trail-related_changes 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +22 -0
  5. data/app/assets/config/paper_trail_changelog_manifest.js +1 -0
  6. data/app/assets/stylesheets/paper_trail/changelog/application.css +15 -0
  7. data/app/controllers/paper_trail/related_changes/application_controller.rb +9 -0
  8. data/app/controllers/paper_trail/related_changes/base_controller.rb +25 -0
  9. data/app/helpers/paper_trail/changelog/application_helper.rb +6 -0
  10. data/app/jobs/paper_trail/changelog/application_job.rb +6 -0
  11. data/app/mailers/paper_trail/changelog/application_mailer.rb +8 -0
  12. data/app/models/paper_trail/related_changes/application_record.rb +7 -0
  13. data/app/views/layouts/paper_trail/related_changes/application.html.erb +15 -0
  14. data/config/routes.rb +3 -0
  15. data/lib/paper_trail/related_changes.rb +35 -0
  16. data/lib/paper_trail/related_changes/attribute.rb +11 -0
  17. data/lib/paper_trail/related_changes/build_changes.rb +83 -0
  18. data/lib/paper_trail/related_changes/change.rb +40 -0
  19. data/lib/paper_trail/related_changes/engine.rb +15 -0
  20. data/lib/paper_trail/related_changes/grouped_by_request_id.rb +154 -0
  21. data/lib/paper_trail/related_changes/hierarchy.rb +117 -0
  22. data/lib/paper_trail/related_changes/hierarchy/query.rb +78 -0
  23. data/lib/paper_trail/related_changes/relationally_independent.rb +14 -0
  24. data/lib/paper_trail/related_changes/serializer.rb +117 -0
  25. data/lib/paper_trail/related_changes/serializer/belongs_to.rb +73 -0
  26. data/lib/paper_trail/related_changes/serializer/diff.rb +23 -0
  27. data/lib/paper_trail/related_changes/serializer/polymorphic.rb +19 -0
  28. data/lib/paper_trail/related_changes/serializer/skippable.rb +17 -0
  29. data/lib/paper_trail/related_changes/version.rb +5 -0
  30. data/lib/paper_trail/related_changes/version_model.rb +35 -0
  31. data/lib/tasks/paper_trail/changelog_tasks.rake +4 -0
  32. metadata +130 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9765e2d2318f809d303c5d36168083ce3abcd9abd0470862c39e1778ff546a5a
4
+ data.tar.gz: 70b70b6b660f2cc1eb54545ee994325b4e22c5379f03389a94aa80ba44dc0ea3
5
+ SHA512:
6
+ metadata.gz: bcdf9b171bfe8a25009f6f4f0bcfb7646aae39370410fbf13bdd3bd4a8646f3d6203ae999d3990158fd79a719cb63562a183a62a138337981033b6b493b95b62
7
+ data.tar.gz: 2e9e3e5839c709b8ef7dca6ebc3df17b1238339abcd65906d96600410191a6ecc4a2a727b6c6eafa627f4534d6f235c8f1c82fc2c56c01467eb9aabbc813f06f
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2020 Dustin Zeisler
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # PaperTrail::RelatedChanges
2
+ Short description and motivation.
3
+
4
+ ## Usage
5
+ How to use my plugin.
6
+
7
+ ## Installation
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem 'paper_trail-related_changes'
12
+ ```
13
+
14
+ And then execute:
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+ ```bash
21
+ $ gem install paper_trail-related_changes
22
+ ```
23
+
24
+ ## Contributing
25
+ Contribution directions go here.
26
+
27
+ ## License
28
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,22 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'PaperTrail::RelatedChanges'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
18
+ load 'rails/tasks/engine.rake'
19
+
20
+ load 'rails/tasks/statistics.rake'
21
+
22
+ require 'bundler/gem_tasks'
@@ -0,0 +1 @@
1
+ //= link_directory ../stylesheets/paper_trail/related_changes .css
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,9 @@
1
+ module PaperTrail
2
+ module RelatedChanges
3
+ class ApplicationController < ActionController::Base
4
+ protect_from_forgery with: :exception
5
+
6
+
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,25 @@
1
+ module PaperTrail
2
+ module RelatedChanges
3
+ class BaseController < PaperTrail::RelatedChanges::ApplicationController
4
+ def show
5
+ render json: { data: versions.to_a, meta: {} }
6
+ end
7
+
8
+ private
9
+
10
+ def versions
11
+ RelatedChanges::GroupedByRequestId.new(
12
+ limit: limit,
13
+ **params.permit!.to_h.symbolize_keys.slice(
14
+ :type,
15
+ :id,
16
+ )
17
+ )
18
+ end
19
+
20
+ def limit
21
+ params[:limit] || params.dig('page', 'size')
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,6 @@
1
+ module PaperTrail
2
+ module RelatedChanges
3
+ module ApplicationHelper
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module PaperTrail
2
+ module RelatedChanges
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ module PaperTrail
2
+ module RelatedChanges
3
+ class ApplicationMailer < ActionMailer::Base
4
+ default from: 'from@example.com'
5
+ layout 'mailer'
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module PaperTrail
2
+ module RelatedChanges
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Paper trail related_changes</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= stylesheet_link_tag "paper_trail/related_changes/application", media: "all" %>
9
+ </head>
10
+ <body>
11
+
12
+ <%= yield %>
13
+
14
+ </body>
15
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ PaperTrail::RelatedChanges::Engine.routes.draw do
2
+ root 'base#show'
3
+ end
@@ -0,0 +1,35 @@
1
+ require 'paper_trail/frameworks/active_record/models/paper_trail/version'
2
+ require 'paper_trail/related_changes/relationally_independent'
3
+ require 'paper_trail/related_changes/version_model'
4
+ require "paper_trail/related_changes/engine"
5
+ require "paper_trail/related_changes/serializer"
6
+ require "paper_trail/related_changes/grouped_by_request_id"
7
+ require "paper_trail/related_changes/hierarchy"
8
+ require "paper_trail/related_changes/build_changes"
9
+ require "paper_trail/related_changes/version"
10
+
11
+ module PaperTrail
12
+ module RelatedChanges
13
+ def self.serializers
14
+ @serializers ||= [
15
+ Serializer::Skippable,
16
+ Serializer::BelongsTo,
17
+ Serializer::Polymorphic
18
+ ]
19
+ end
20
+
21
+ def self.insert_after_serializer(serializer, after_serializer)
22
+ serializer_index = serializers.index(serializer)
23
+ @serializers = serializers.insert(serializer_index + 1, after_serializer)
24
+ end
25
+
26
+ def self.insert_before_serializer(serializer, after_serializer)
27
+ serializer_index = serializers.index(serializer)
28
+ @serializers = serializers.insert(serializer_index, after_serializer)
29
+ end
30
+
31
+ def self.user_class
32
+ User if defined? User
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,11 @@
1
+ module PaperTrail::RelatedChanges
2
+ Attribute = Struct.new(:name, :diff, :version, :request_type, keyword_init: true) do
3
+ def to_s
4
+ name.to_s
5
+ end
6
+
7
+ def to_sym
8
+ name.to_sym
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,83 @@
1
+ module PaperTrail
2
+ module RelatedChanges
3
+ class BuildChanges
4
+ attr_reader :results,
5
+ :model_type_children,
6
+ :item_type,
7
+ :item_id
8
+
9
+ def initialize(results, model_type_children, item_type, item_id)
10
+ @results = results
11
+ @model_type_children = model_type_children
12
+ @item_type = item_type
13
+ @item_id = item_id
14
+ end
15
+
16
+ def call
17
+ changes = results.map do |root_version, versions, request_id|
18
+ versions_serialized = versions_serialized(versions)
19
+ root_change = change_with_root(request_id, root_version, versions_serialized)
20
+ root_change.change.children = children_versions(versions_serialized).map(&:change)
21
+ root_change.change
22
+ end.compact
23
+
24
+ remove_duplicate_changes(changes)
25
+ end
26
+
27
+ private
28
+
29
+ # Given a case where a change is represented by a previous change do not show it.
30
+ def remove_duplicate_changes(changes)
31
+ changes.reject.with_index do |change, index|
32
+ stop_iteration = false
33
+ changes[(index + 1)..-1].each do |previous_change|
34
+ change.diffs = change.diffs.reject do |current_change|
35
+ next false if stop_iteration
36
+ previous_matched_change = previous_change
37
+ .diffs
38
+ .detect { |previous_change| previous_change.attribute == current_change.attribute }
39
+ stop_iteration = true if previous_matched_change # As soon as their is match stop
40
+ previous_matched_change.eql?(current_change)
41
+ end
42
+ end
43
+
44
+ change.empty?
45
+ end
46
+ end
47
+
48
+ def change_with_root(request_id, root_version, versions_serialized)
49
+ s = Serializer.new(root_version, item_type: root_version.item_type, root_type: item_type)
50
+ s.change.diffs = [*s.change.diffs, *attribute_version_changes(versions_serialized)]
51
+ s.change.version_id = request_id || root_version.request_id
52
+ s
53
+ end
54
+
55
+ def children_versions(versions_serialized)
56
+ versions_serialized.reject { |vr| vr.change.merge_into_root }
57
+ end
58
+
59
+ def attribute_version_changes(versions_serialized)
60
+ versions_serialized.select { |vr| vr.change.merge_into_root }.map(&:change).flat_map(&:diffs)
61
+ end
62
+
63
+ def versions_serialized(versions)
64
+ versions.flat_map do |parent_type, versions_with_child_type|
65
+ versions_with_child_type.flat_map do |_type, versions_of_child|
66
+ versions_of_child.map do |version|
67
+ Serializer.new(
68
+ version,
69
+ item_type: parent_type,
70
+ model_to_include_name: model_to_include_name,
71
+ root_type: item_type
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ def model_to_include_name
79
+ model_type_children.each_with_object({}) { |(n, r), h| h[r.klass.name] = n }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,40 @@
1
+ module PaperTrail::RelatedChanges
2
+ Change = Struct.new(:version_id,
3
+ :user,
4
+ :event,
5
+ :resource,
6
+ :description,
7
+ :resource_id,
8
+ :diffs,
9
+ :timestamp,
10
+ :requested_root,
11
+ :children,
12
+ :merge_into_root,
13
+ keyword_init: true) do
14
+ def initialize(diffs: [], merge_into_root: false, **args)
15
+ super
16
+ end
17
+
18
+ def to_h(*)
19
+ self.diffs = diffs
20
+ .group_by(&:source)
21
+ .map { |k, g| [k, g.sort_by(&:source_rank)] } # ie. segments can be in display order
22
+ .sort_by { |_, g| g[0].rank } # Direct attributes shown first
23
+ .flat_map(&:last)
24
+ .uniq
25
+ results = super().except(:merge_into_root).map { |k, v| [k, v.is_a?(Array) ? v.map(&:to_h) : v] }.to_h
26
+ results.delete(:children) if results[:children].nil?
27
+ results
28
+ end
29
+
30
+ alias_method :as_json, :to_h
31
+
32
+ def empty?
33
+ diffs.count.zero? && children.count.zero?
34
+ end
35
+
36
+ def add_diff(args)
37
+ self.diffs << PaperTrail::RelatedChanges::Serializer::Diff.new(args)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,15 @@
1
+ module PaperTrail
2
+ module RelatedChanges
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace PaperTrail::RelatedChanges
5
+
6
+ ActiveSupport.on_load(:active_record) do
7
+ extend PaperTrail::RelatedChanges::RelationallyIndependent
8
+ end
9
+
10
+ config.after_initialize do
11
+ PaperTrail::Version.include(PaperTrail::VersionModel)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,154 @@
1
+ module PaperTrail
2
+ module RelatedChanges
3
+ # Goal of class is to group versions rows into groups that represent a user event.
4
+ # When a user saves a resource that may have many associated resources and we want to see that as one event.
5
+ # Use ActiveRecord#reflections build a tree of downward relationships and query the versions.object and version.object_changes
6
+ # Group by the request_id to collect all actions into an event.
7
+ class GroupedByRequestId
8
+ attr_reader :item_id,
9
+ :item_type,
10
+ :limit
11
+
12
+ def initialize(item_type: nil,
13
+ type: nil,
14
+ item_id: nil,
15
+ id: nil,
16
+ limit: nil)
17
+ @item_type = (item_type || type).underscore.classify
18
+ @item_id = item_id || id
19
+ @limit = Integer([limit, 1_000].reject(&:blank?).first)
20
+ @append_root = true
21
+ end
22
+
23
+ def to_a
24
+ BuildChanges.new(
25
+ raw_records,
26
+ hierarchy.model_type_children,
27
+ model_name,
28
+ item_id
29
+ ).call.take(limit) # appending the last root version can cause limit+1 sized results.
30
+ end
31
+
32
+ def raw_records
33
+ results.map do |result|
34
+ root_version, versions = build_versions(result)
35
+ [root_version, group_versions(versions), result["request_id"]]
36
+ end.concat(append_root_version)
37
+ end
38
+
39
+ private
40
+
41
+ # When a limit is used you may not have a root record. A root record will collapse many versions into it's self.
42
+ # Without a root record you will see different types of records that won't be seen when no limit is set.
43
+ def append_root_version
44
+ return [] unless @append_root
45
+ [[last_root, []]]
46
+ end
47
+
48
+ def results
49
+ conn = ActiveRecord::Base.connection
50
+ conn.execute(<<~SQL)
51
+ SELECT json_agg(hierarchy_versions ORDER BY (rank, item_type, item_id, id)) AS versions,
52
+ MIN(hierarchy_versions.created_at) as created_at,
53
+ request_id
54
+ FROM (#{hierarchy.find_by_id(item_id)}) AS hierarchy_versions
55
+ GROUP BY request_id
56
+ ORDER BY created_at DESC
57
+ LIMIT #{conn.quote(limit)}
58
+ SQL
59
+ end
60
+
61
+ def last_root
62
+ @last_root ||= PaperTrail::Version.where(
63
+ item_type: item_type,
64
+ item_id: item_id
65
+ ).order(
66
+ created_at: :desc
67
+ ).limit(1).first
68
+ end
69
+
70
+ def build_versions(result)
71
+ requested_root_version = nil
72
+ versions = JSON.parse(result["versions"]).map do |version|
73
+ record = convert_to_record(version)
74
+ requested_root_version = record if record.item_id == item_id.to_s && record.item_type == model_name
75
+ record
76
+ end
77
+ @append_root = false if requested_root_version == last_root
78
+ root_version = requested_root_version || find_root(versions, result["request_id"])
79
+ versions.delete(root_version)
80
+ [root_version, versions]
81
+ end
82
+
83
+ def find_root(versions, request_id)
84
+ return versions.first if versions.count == 1 && versions.first.model_class.relationally_independent?
85
+ shared_relation = hierarchy.shared_relation(versions)
86
+ root_version = versions.detect { |version| version.item_type == shared_relation.dig(:relation).class_name }
87
+
88
+ return build_sudo_root(shared_relation, request_id, versions) unless root_version
89
+ root_version
90
+ end
91
+
92
+ def merge_event(versions)
93
+ if versions.map(&:event).uniq.count == 1
94
+ versions.map(&:event).uniq.first
95
+ else
96
+ 'update'
97
+ end
98
+ end
99
+
100
+ def convert_to_record(version)
101
+ PaperTrail::Version.new(
102
+ **version.except('created_at', 'rank').symbolize_keys,
103
+ created_at: ActiveSupport::TimeZone["UTC"].parse(version['created_at'])
104
+ )
105
+ end
106
+
107
+ def group_versions(versions)
108
+ versions.each_with_object({}) do |version, hash|
109
+ sources = hierarchy.search_hierarchy(version.item_type)
110
+
111
+ next unless (relation_to_root = sources.min_by { |s| s[:name].length }) # Prefer the shortest name ie. note vs notes
112
+ hash[relation_to_root[:parent][:type]] ||= {}
113
+ hash[relation_to_root[:parent][:type]][relation_to_root[:name]] ||= []
114
+ hash[relation_to_root[:parent][:type]][relation_to_root[:name]] << version
115
+ end
116
+ end
117
+
118
+ def build_sudo_root(shared_relation, request_id, versions)
119
+ # Assigning the parent_id will allow the description.name to be populated.
120
+ extracted_reference_keys = versions.map { |version| hierarchy.search_hierarchy(version.item_type)&.first&.fetch(:relation) }.map { |v| [v.foreign_key, v.type].compact }
121
+ parent_id = versions.flat_map { |version| extracted_reference_keys.map { |keys| version.extract(*keys) } }.flatten.select { |t| t.class == Integer }.first
122
+
123
+ PaperTrail::Version.new(
124
+ item_type: shared_relation.dig(:relation).class_name,
125
+ item_id: parent_id,
126
+ request_id: request_id,
127
+ event: merge_event(versions),
128
+ created_at: versions.first.created_at,
129
+ whodunnit: versions.first.whodunnit
130
+ )
131
+ end
132
+
133
+ # This might happen if a developer did a mass edit in the console of unrelated items.
134
+ # If there are competing versions that match the request type only include the one with the matching id
135
+ def remove_stowaways(requested_root_version, versions)
136
+ versions.reject do |version|
137
+ version.item_type == requested_root_version.item_type && version.item_id != requested_root_version.item_id
138
+ end
139
+ end
140
+
141
+ def model_class
142
+ model_name.constantize
143
+ end
144
+
145
+ def model_name
146
+ item_type.underscore.classify
147
+ end
148
+
149
+ def hierarchy
150
+ @hierarchy ||= PaperTrail::RelatedChanges::Hierarchy.new(model_class)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,117 @@
1
+ module PaperTrail
2
+ module RelatedChanges
3
+ class Hierarchy
4
+ require "paper_trail/related_changes/hierarchy/query"
5
+
6
+ class << self
7
+ # Builds downward relational hierarchy with 4 generations
8
+ def build(*args)
9
+ self.new(*args).send(:build_source)
10
+ end
11
+
12
+ # Does filtering of relations
13
+ def model_type_children(model)
14
+ self.new(model).model_type_children
15
+ end
16
+ end
17
+
18
+ attr_reader :model
19
+
20
+ def initialize(model)
21
+ @model = model
22
+ end
23
+
24
+ def find_by_id(id)
25
+ Query.call(model, id)
26
+ end
27
+
28
+ # Find the node that matches the item_type
29
+ # The result has a parent reference instead of a child reference.
30
+ def search_hierarchy(item_type, source: send(:source), parent: nil)
31
+ parent_without_children = (parent || source).except(:children)
32
+ return [source.except(:children).merge(parent: parent_without_children)] if source[:type] == item_type
33
+ return if source[:children].empty?
34
+ source[:children].flat_map { |child| search_hierarchy(item_type, source: child, parent: source) }.compact
35
+ end
36
+
37
+ # Finds a common root between versions
38
+ def shared_relation(versions)
39
+ result = versions.map do |version|
40
+ [
41
+ version.item_type, # It can be it's self
42
+ *search_hierarchy(version.item_type).map { |s| s.fetch(:parent, {}).fetch(:type, s[:type]) }
43
+ ].uniq
44
+ end.inject(:&)&.last
45
+
46
+ search_hierarchy(result)&.first
47
+ end
48
+
49
+ def model_type_children
50
+ @model_type_children ||= model.reflections.each_with_object({}) do |(name, rel), result|
51
+ next if source_reflection(rel).belongs_to? # Only Looking for downward relations. (Children not parents)
52
+ next if name == PaperTrail::Version.table_name
53
+ result[name.to_sym] = source_reflection(rel)
54
+ end
55
+ end
56
+
57
+ def source
58
+ @source ||= build_source
59
+ end
60
+
61
+ private
62
+
63
+ def build_source
64
+ top_relation = SelfRelation.new(foreign_key: :id, class_name: model.name, name: model.name)
65
+ root = Node.new(type: model.name, name: model.name, relation: top_relation, children: [])
66
+ model_type_children.each do |n1, r1|
67
+ next if r1.klass == model
68
+
69
+ r1_branch = Node.new(type: r1.klass.name, name: n1, relation: r1, children: [])
70
+ root.children << r1_branch
71
+
72
+ self.class.model_type_children(r1.klass).each do |n2, r2|
73
+ next if r2.klass == model
74
+
75
+ r2_branch = Node.new(type: r2.klass.name, name: n2, relation: r2, children: [])
76
+ r1_branch.children << r2_branch
77
+
78
+ self.class.model_type_children(r2.klass).each do |n3, r3|
79
+ next if r3.klass == model
80
+
81
+ r3_branch = Node.new(type: r3.klass.name, name: n3, relation: r3, children: [])
82
+ r2_branch.children << r3_branch
83
+ end
84
+ end
85
+ end
86
+ root
87
+ end
88
+
89
+ def source_reflection(v)
90
+ v.through_reflection? ? source_reflection(v.through_reflection) : v
91
+ end
92
+
93
+ def include_parent?(name)
94
+ include_parent_as_child.include?(name.to_sym)
95
+ end
96
+
97
+ def whitelisted_child?(name)
98
+ return true unless only_include_children
99
+
100
+ only_include_children.include?(name.to_sym)
101
+ end
102
+
103
+ SelfRelation = Struct.new(:foreign_key, :class_name, :name, keyword_init: true)
104
+ Node = Struct.new(:type, :name, :relation, :children, keyword_init: true) do
105
+ def to_simple
106
+ base = to_h.except(:relation, :children)
107
+ return { **base, children: children.map(&:to_simple) } unless children.empty?
108
+ base
109
+ end
110
+
111
+ def except(*args)
112
+ to_h.except(*args)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,78 @@
1
+ module PaperTrail::RelatedChanges::Hierarchy::Query
2
+ class << self
3
+ # Builds a UNION joined set of queries that finds related versions to the 4th generation.
4
+ def call(model, id)
5
+ parent_query_r0 = parent(model.name, id)
6
+ hierarchy = PaperTrail::RelatedChanges::Hierarchy.model_type_children(model).each_with_object([]) do |(_n, r1), col|
7
+ parent_query_r1 = call_query(parent_query_r0, r1)
8
+ col << parent_query_r1
9
+ PaperTrail::RelatedChanges::Hierarchy.model_type_children(r1.klass).each do |_n, r2|
10
+ parent_query_r2 = call_query(parent_query_r1, r2)
11
+ col << parent_query_r2
12
+ PaperTrail::RelatedChanges::Hierarchy.model_type_children(r2.klass).each do |_n, r3|
13
+ parent_query_r3 = call_query(parent_query_r2, r3)
14
+ col << parent_query_r3
15
+ end
16
+ end
17
+ end
18
+ final = [
19
+ parent_query_r0,
20
+ *hierarchy.uniq
21
+ ].join("\nUNION\n")
22
+
23
+ <<~SQL
24
+ SELECT #{columns('hierarchy')},
25
+ CASE event
26
+ WHEN 'create' THEN 1
27
+ WHEN 'update' THEN 2
28
+ WHEN 'destroy' THEN 3
29
+ ELSE 4 END as rank
30
+ FROM (#{final}) hierarchy
31
+ SQL
32
+ end
33
+
34
+ private
35
+
36
+ def call_query(parent_query, r)
37
+ if r.type
38
+ nth_polymorphic_children(parent_query, r.type, r.foreign_key)
39
+ else
40
+ nth_children(parent_query, r.foreign_key)
41
+ end
42
+ end
43
+
44
+ def parent(item_type, item_id)
45
+ <<~SQL
46
+ SELECT '#{item_type}=>#{item_id}' as tree, #{columns('versions')}
47
+ FROM versions
48
+ WHERE versions.item_type = '#{item_type}'
49
+ AND versions.item_id = '#{item_id}'
50
+ SQL
51
+ end
52
+
53
+ def nth_children(parent_query, parent_foreign_key)
54
+ <<~SQL
55
+ SELECT parents.tree || '.' || children.item_type || '=>' || children.item_id as tree, #{columns('children')}
56
+ FROM (#{parent_query}) parents
57
+ JOIN versions children ON parents.item_id = #{query_changes(parent_foreign_key)}
58
+ SQL
59
+ end
60
+
61
+ def nth_polymorphic_children(parent_query, parent_type_foreign_key, parent_id_foreign_key)
62
+ <<~SQL
63
+ SELECT parents.tree || '.' || children.item_type || '=>' || children.item_id as tree, #{columns('children')}
64
+ FROM (#{parent_query}) parents
65
+ JOIN versions children ON parents.item_id = #{query_changes(parent_id_foreign_key)}
66
+ AND parents.item_type = #{query_changes(parent_type_foreign_key)}
67
+ SQL
68
+ end
69
+
70
+ def query_changes(key)
71
+ "COALESCE((children.object_changes -> '#{key}' ->> 1)::TEXT, (children.object ->> '#{key}')::TEXT)"
72
+ end
73
+
74
+ def columns(prefix)
75
+ %w(id item_type item_id event whodunnit object object_changes request_id created_at).map { |c| "#{prefix}.#{c}" }.join(", ")
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,14 @@
1
+ module PaperTrail::RelatedChanges::RelationallyIndependent
2
+ def relationally_independent=(value)
3
+ @relationally_independent = value
4
+ end
5
+
6
+ def relationally_independent?
7
+ if instance_variable_defined? :@relationally_independent
8
+ @relationally_independent
9
+ else
10
+ true
11
+ end
12
+ end
13
+ end
14
+
@@ -0,0 +1,117 @@
1
+ require "paper_trail/related_changes/serializer/polymorphic"
2
+ require "paper_trail/related_changes/serializer/belongs_to"
3
+ require "paper_trail/related_changes/serializer/skippable"
4
+ require "paper_trail/related_changes/attribute"
5
+ require "paper_trail/related_changes/serializer/diff"
6
+ require "paper_trail/related_changes/change"
7
+
8
+ module PaperTrail
9
+ module RelatedChanges
10
+ class Serializer
11
+ def initialize(record, item_type:, model_to_include_name: {}, root_type: nil)
12
+ @record = record
13
+ @item_type = item_type
14
+ @model_to_include_name = model_to_include_name
15
+ @root_type = root_type
16
+ end
17
+
18
+ delegate :to_h, to: :change
19
+
20
+ def change
21
+ @change ||= change_template
22
+ build_changes
23
+ @change
24
+ end
25
+
26
+ private
27
+
28
+ def change_template
29
+ Change.new(
30
+ version_id: record.id,
31
+ user: user,
32
+ event: record.event,
33
+ resource: record.item_type,
34
+ description: {
35
+ name: resource_title,
36
+ value: record.name
37
+ },
38
+ resource_id: record.item_id,
39
+ timestamp: record.created_at,
40
+ requested_root: !included?
41
+ )
42
+ end
43
+
44
+ attr_reader :record,
45
+ :item_type,
46
+ :model_to_include_name,
47
+ :root_type
48
+
49
+ def included?
50
+ return record.item_type != root_type unless root_type.nil?
51
+ record.item_type != item_type.classify
52
+ end
53
+
54
+ def resource_title
55
+ model_to_include_name.fetch(
56
+ record.item_type,
57
+ record.item_type.underscore.split('/').last
58
+ ).to_s.singularize.titleize
59
+ end
60
+
61
+ def user
62
+ PaperTrail::RelatedChanges.user_class.find_by(id: record.whodunnit)&.name || "system"
63
+ end
64
+
65
+ def build_changes
66
+ @build_changes ||= record.changeset.each do |attr, diff|
67
+ BuildDiffs.new(attr, diff, record, item_type, @change).call
68
+ end
69
+ end
70
+
71
+ class BuildDiffs
72
+ def initialize(attr, diff, record, request_type, change)
73
+ @attr = attr.to_sym
74
+ @diff = diff
75
+ @record = record
76
+ @request_type = request_type
77
+ @change = change
78
+ end
79
+
80
+ def call
81
+ return call_serializer if custom_serializer
82
+
83
+ change.diffs << default_diff
84
+ end
85
+
86
+ private
87
+
88
+ attr_reader :diff,
89
+ :attr,
90
+ :record,
91
+ :request_type,
92
+ :change
93
+
94
+ def default_diff
95
+ Diff.new(attribute: attr, old: diff[0], new: diff[1], rank: 0, meta: record, source: :default)
96
+ end
97
+
98
+ def attribute
99
+ @attribute ||= Attribute.new(
100
+ name: attr,
101
+ diff: diff,
102
+ version: record,
103
+ request_type: request_type
104
+ )
105
+ end
106
+
107
+ def custom_serializer
108
+ RelatedChanges.serializers.detect { |serializer| serializer.match(attribute) }
109
+ end
110
+
111
+ def call_serializer
112
+ custom_serializer.serialize(attribute, change)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,73 @@
1
+ class PaperTrail::RelatedChanges::Serializer
2
+ class BelongsTo
3
+
4
+ def self.match(attribute)
5
+ attribute.version
6
+ .model_class
7
+ .reflections
8
+ .values
9
+ .select(&:belongs_to?)
10
+ .reject(&:polymorphic?)
11
+ .any? { |r| r.join_foreign_key.to_sym == attribute.to_sym }
12
+ end
13
+
14
+ def self.serialize(attribute, change)
15
+ new(attribute, change).serialize
16
+ end
17
+
18
+ attr_reader :attribute,
19
+ :item_type,
20
+ :change
21
+
22
+ def initialize(attribute, change)
23
+ @attribute = attribute
24
+ @item_type = attribute.version.item_type
25
+ @change = change
26
+ end
27
+
28
+ def serialize
29
+ return if association.name.to_s.underscore.singularize == attribute.request_type.to_s.underscore.singularize
30
+ change.merge_into_root = true unless model_class.relationally_independent?
31
+ change.add_diff(
32
+ attribute: attribute_name,
33
+ old: find_record(attribute.diff[0]),
34
+ new: find_record(attribute.diff[1]),
35
+ rank: 3,
36
+ source: self.class.name
37
+ )
38
+ end
39
+
40
+ def attribute_name
41
+ association.name
42
+ end
43
+
44
+ def association
45
+ @association ||= item_type
46
+ .constantize
47
+ .reflections
48
+ .detect { |_, a| a.foreign_key.to_sym == attribute.to_sym }&.fetch(1)
49
+ end
50
+
51
+ def find_record(id)
52
+ return unless id
53
+ find_associated_version(id).try(:name) || find_current_record(id).try(:name) || "Record no longer exists"
54
+ end
55
+
56
+ def model_class
57
+ @model_class ||= association.klass
58
+ end
59
+
60
+ def find_current_record(id)
61
+ model_class.find_by(id: id)
62
+ end
63
+
64
+ def find_associated_version(id)
65
+ PaperTrail::Version.where(
66
+ PaperTrail::Version.arel_table[:created_at].lt(attribute.version.created_at)
67
+ ).where(
68
+ item_id: id,
69
+ item_type: model_class.name
70
+ ).order(created_at: :desc).first
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,23 @@
1
+ class PaperTrail::RelatedChanges::Serializer
2
+ Diff = Struct.new(:attribute, :old, :new, :rank, :source_rank, :source, :meta, keyword_init: true) do
3
+ def initialize(rank: 1, source_rank: 1, **args)
4
+ super
5
+ end
6
+
7
+ def to_h
8
+ if ENV['RELATED_CHANGES_DEBUG']
9
+ super
10
+ else
11
+ super.except(:rank, :source, :source_rank, :meta)
12
+ end
13
+ end
14
+
15
+ def eql?(other)
16
+ attribute == other.attribute && new == other.new && old == other.old
17
+ end
18
+
19
+ def hash
20
+ [attribute, new, old].hash
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ class PaperTrail::RelatedChanges::Serializer
2
+ module Polymorphic
3
+ def self.match(attribute)
4
+ attribute.version.item_type.constantize.reflections.detect { |_, r| r.polymorphic? && r.belongs_to? }
5
+ end
6
+
7
+ def self.serialize(attribute, change)
8
+ return if [/_id/, /_type/].any? { |r| attribute.to_s =~ r } # Catch all non-associative attributes
9
+ change.merge_into_root = true unless attribute.version.model_class.relationally_independent?
10
+ change.add_diff(
11
+ attribute: attribute.version.item_type.titleize,
12
+ old: attribute.diff[0],
13
+ new: attribute.diff[1],
14
+ rank: 2,
15
+ source: self.name
16
+ )
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ class PaperTrail::RelatedChanges::Serializer
2
+ class Skippable
3
+ def self.match(attribute)
4
+ !!SKIP_COLUMNS.detect do |sk|
5
+ sk == attribute.to_sym
6
+ end
7
+ end
8
+
9
+ SKIP_COLUMNS = [
10
+ :id,
11
+ :created_at,
12
+ :updated_at,
13
+ ].freeze
14
+
15
+ def self.serialize(*); end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ module PaperTrail
2
+ module RelatedChanges
3
+ VERSION = '1.0.0'
4
+ end
5
+ end
@@ -0,0 +1,35 @@
1
+ module PaperTrail::VersionModel
2
+ DISPLAY_NAME_METHODS = [
3
+ :title,
4
+ :name,
5
+ :code
6
+ ].freeze
7
+
8
+ def name
9
+ call_by_name(self.next&.reify || live_record)
10
+ rescue StandardError
11
+ call_by_name(live_record)
12
+ end
13
+
14
+ def model_class
15
+ item_type.constantize
16
+ end
17
+
18
+ def extract(*keys)
19
+ result = keys.map do |key|
20
+ (object_changes || {})[key.to_s]&.last || (object || {})[key.to_s]
21
+ end
22
+ return result if keys.count > 1
23
+ result.first
24
+ end
25
+
26
+ private
27
+
28
+ def live_record
29
+ model_class.find_by(id: item_id)
30
+ end
31
+
32
+ def call_by_name(record)
33
+ DISPLAY_NAME_METHODS.map { |meth| record.try(meth) }.compact.first
34
+ end
35
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :paper_trail_related_changes do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: paper_trail-related_changes
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Dustin Zeisler
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-03-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 6.0.1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 6.0.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: paper_trail
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 10.3.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 10.3.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: pg
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec-rails
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description: Find all child ActiveRecord relationships from a given resource and groups
70
+ thems by request_id.
71
+ email:
72
+ - dustin@zeisler.net
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - MIT-LICENSE
78
+ - README.md
79
+ - Rakefile
80
+ - app/assets/config/paper_trail_changelog_manifest.js
81
+ - app/assets/stylesheets/paper_trail/changelog/application.css
82
+ - app/controllers/paper_trail/related_changes/application_controller.rb
83
+ - app/controllers/paper_trail/related_changes/base_controller.rb
84
+ - app/helpers/paper_trail/changelog/application_helper.rb
85
+ - app/jobs/paper_trail/changelog/application_job.rb
86
+ - app/mailers/paper_trail/changelog/application_mailer.rb
87
+ - app/models/paper_trail/related_changes/application_record.rb
88
+ - app/views/layouts/paper_trail/related_changes/application.html.erb
89
+ - config/routes.rb
90
+ - lib/paper_trail/related_changes.rb
91
+ - lib/paper_trail/related_changes/attribute.rb
92
+ - lib/paper_trail/related_changes/build_changes.rb
93
+ - lib/paper_trail/related_changes/change.rb
94
+ - lib/paper_trail/related_changes/engine.rb
95
+ - lib/paper_trail/related_changes/grouped_by_request_id.rb
96
+ - lib/paper_trail/related_changes/hierarchy.rb
97
+ - lib/paper_trail/related_changes/hierarchy/query.rb
98
+ - lib/paper_trail/related_changes/relationally_independent.rb
99
+ - lib/paper_trail/related_changes/serializer.rb
100
+ - lib/paper_trail/related_changes/serializer/belongs_to.rb
101
+ - lib/paper_trail/related_changes/serializer/diff.rb
102
+ - lib/paper_trail/related_changes/serializer/polymorphic.rb
103
+ - lib/paper_trail/related_changes/serializer/skippable.rb
104
+ - lib/paper_trail/related_changes/version.rb
105
+ - lib/paper_trail/related_changes/version_model.rb
106
+ - lib/tasks/paper_trail/changelog_tasks.rake
107
+ homepage: https://github.com/zeisler/paper_trail-related_changes
108
+ licenses:
109
+ - MIT
110
+ metadata: {}
111
+ post_install_message:
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ requirements: []
126
+ rubygems_version: 3.1.2
127
+ signing_key:
128
+ specification_version: 4
129
+ summary: Groups and formats related changes that are recorded with PaperTrail
130
+ test_files: []