paper_trail_diff 0.1.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 +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE +22 -0
- data/README.md +473 -0
- data/lib/paper_trail_diff/activity_boundary.rb +82 -0
- data/lib/paper_trail_diff/activity_event.rb +128 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +493 -0
- data/lib/paper_trail_diff/activity_range.rb +58 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +156 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +161 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +177 -0
- data/lib/paper_trail_diff/activity_version_collector.rb +196 -0
- data/lib/paper_trail_diff/analysis.rb +26 -0
- data/lib/paper_trail_diff/association_discovery.rb +132 -0
- data/lib/paper_trail_diff/association_traversal.rb +131 -0
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +191 -0
- data/lib/paper_trail_diff/configuration.rb +170 -0
- data/lib/paper_trail_diff/diagnostics.rb +242 -0
- data/lib/paper_trail_diff/endpoint.rb +87 -0
- data/lib/paper_trail_diff/engine.rb +180 -0
- data/lib/paper_trail_diff/errors.rb +34 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +103 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +144 -0
- data/lib/paper_trail_diff/live_association_reader.rb +10 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +182 -0
- data/lib/paper_trail_diff/prepared_association_reifier.rb +48 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +155 -0
- data/lib/paper_trail_diff/prepared_history.rb +217 -0
- data/lib/paper_trail_diff/prepared_history_loader.rb +136 -0
- data/lib/paper_trail_diff/prepared_record_index.rb +154 -0
- data/lib/paper_trail_diff/snapshot.rb +120 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +182 -0
- data/lib/paper_trail_diff/step.rb +28 -0
- data/lib/paper_trail_diff/support.rb +80 -0
- data/lib/paper_trail_diff/timeline_builder.rb +60 -0
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -0
- data/lib/paper_trail_diff/value_objects.rb +134 -0
- data/lib/paper_trail_diff/version.rb +6 -0
- data/lib/paper_trail_diff/version_range.rb +81 -0
- data/lib/paper_trail_diff.rb +131 -0
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +43 -0
- data/sig/generated/paper_trail_diff/activity_event.rbs +64 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +98 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +36 -0
- data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +54 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +71 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +61 -0
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +71 -0
- data/sig/generated/paper_trail_diff/analysis.rbs +18 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +63 -0
- data/sig/generated/paper_trail_diff/association_traversal.rbs +54 -0
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +56 -0
- data/sig/generated/paper_trail_diff/configuration.rbs +67 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +109 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +30 -0
- data/sig/generated/paper_trail_diff/engine.rbs +54 -0
- data/sig/generated/paper_trail_diff/errors.rbs +43 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +37 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +50 -0
- data/sig/generated/paper_trail_diff/live_association_reader.rbs +9 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +72 -0
- data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +27 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +65 -0
- data/sig/generated/paper_trail_diff/prepared_history.rbs +93 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +67 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +84 -0
- data/sig/generated/paper_trail_diff/snapshot.rbs +69 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +76 -0
- data/sig/generated/paper_trail_diff/step.rbs +18 -0
- data/sig/generated/paper_trail_diff/support.rbs +30 -0
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +31 -0
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -0
- data/sig/generated/paper_trail_diff/value_objects.rbs +87 -0
- data/sig/generated/paper_trail_diff/version.rbs +5 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +35 -0
- data/sig/generated/paper_trail_diff.rbs +56 -0
- metadata +144 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Endpoint and root-checkpoint timeline results built from one normalized history.
|
|
6
|
+
class Analysis
|
|
7
|
+
attr_reader :diff #: Diff
|
|
8
|
+
attr_reader :timeline #: Array[Step]
|
|
9
|
+
attr_reader :activity_timeline #: Array[ActivityStep]?
|
|
10
|
+
|
|
11
|
+
#: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
|
|
12
|
+
def initialize(diff:, timeline:, activity_timeline: nil)
|
|
13
|
+
@diff = diff
|
|
14
|
+
@timeline = timeline.dup.freeze
|
|
15
|
+
@activity_timeline = activity_timeline&.dup&.freeze
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
#: () -> Hash[Symbol, untyped]
|
|
20
|
+
def to_h
|
|
21
|
+
value = { diff: diff.to_h, timeline: Support.serialize(timeline) }
|
|
22
|
+
value[:activity_timeline] = Support.serialize(activity_timeline) if activity_timeline
|
|
23
|
+
value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# One discoverable ActiveRecord association path.
|
|
6
|
+
class AssociationDescriptor
|
|
7
|
+
attr_reader :path #: String
|
|
8
|
+
attr_reader :kind #: Symbol
|
|
9
|
+
attr_reader :target_type #: String?
|
|
10
|
+
attr_reader :through #: String?
|
|
11
|
+
attr_reader :polymorphic #: bool
|
|
12
|
+
attr_reader :cycle #: bool
|
|
13
|
+
|
|
14
|
+
#: (path: String, kind: Symbol, target_type: String?, ?through: String?, ?flags: Array[Symbol]) -> void
|
|
15
|
+
def initialize(path:, kind:, target_type:, through: nil, flags: [])
|
|
16
|
+
@path = Support.immutable_copy(path)
|
|
17
|
+
@kind = kind
|
|
18
|
+
@target_type = Support.immutable_copy(target_type)
|
|
19
|
+
@through = Support.immutable_copy(through)
|
|
20
|
+
@polymorphic = flags.include?(:polymorphic)
|
|
21
|
+
@cycle = flags.include?(:cycle)
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
#: () -> Hash[Symbol, untyped]
|
|
26
|
+
def to_h
|
|
27
|
+
{
|
|
28
|
+
path: path,
|
|
29
|
+
kind: kind,
|
|
30
|
+
target_type: target_type,
|
|
31
|
+
through: through,
|
|
32
|
+
polymorphic: polymorphic,
|
|
33
|
+
cycle: cycle
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Discovers finite, supported association paths for configuration UIs.
|
|
39
|
+
class AssociationDiscovery
|
|
40
|
+
#: (untyped, max_depth: Integer) -> void
|
|
41
|
+
def initialize(model_or_record, max_depth:)
|
|
42
|
+
unless max_depth.is_a?(Integer) && max_depth.positive?
|
|
43
|
+
raise ConfigurationError, 'max_depth: must be a positive integer'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@model_class = model_or_record.is_a?(Class) ? model_or_record : model_or_record.class
|
|
47
|
+
@max_depth = max_depth
|
|
48
|
+
return if @model_class.respond_to?(:reflect_on_all_associations)
|
|
49
|
+
|
|
50
|
+
raise ConfigurationError, 'expected an ActiveRecord model class or record'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
#: () -> Array[AssociationDescriptor]
|
|
54
|
+
def call
|
|
55
|
+
discover(@model_class, prefix: '', ancestry: [@model_class], depth: 1).freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# @rbs @model_class: untyped
|
|
61
|
+
# @rbs @max_depth: Integer
|
|
62
|
+
|
|
63
|
+
#: (untyped, prefix: String, ancestry: Array[untyped], depth: Integer) -> Array[AssociationDescriptor]
|
|
64
|
+
def discover(model_class, prefix:, ancestry:, depth:)
|
|
65
|
+
supported_reflections(model_class).flat_map do |reflection|
|
|
66
|
+
describe_and_descend(reflection, prefix: prefix, ancestry: ancestry, depth: depth)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#: (untyped, prefix: String, ancestry: Array[untyped], depth: Integer) -> Array[AssociationDescriptor]
|
|
71
|
+
def describe_and_descend(reflection, prefix:, ancestry:, depth:)
|
|
72
|
+
path = association_path(prefix, reflection.name.to_s)
|
|
73
|
+
target_class = reflection_target(reflection)
|
|
74
|
+
cycle = target_class ? ancestry.include?(target_class) : false
|
|
75
|
+
descriptor = descriptor_for(reflection, path, target_class, cycle)
|
|
76
|
+
return [descriptor] unless target_class && !cycle && depth < @max_depth
|
|
77
|
+
|
|
78
|
+
descendants = discover(
|
|
79
|
+
target_class,
|
|
80
|
+
prefix: path,
|
|
81
|
+
ancestry: [*ancestry, target_class],
|
|
82
|
+
depth: depth + 1
|
|
83
|
+
)
|
|
84
|
+
[descriptor, *descendants]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
#: (untyped, String, untyped, bool) -> AssociationDescriptor
|
|
88
|
+
def descriptor_for(reflection, path, target_class, cycle)
|
|
89
|
+
flags = [] #: Array[Symbol]
|
|
90
|
+
flags << :polymorphic if reflection.polymorphic?
|
|
91
|
+
flags << :cycle if cycle
|
|
92
|
+
AssociationDescriptor.new(
|
|
93
|
+
path: path,
|
|
94
|
+
kind: reflection.macro,
|
|
95
|
+
target_type: target_class&.name || reflection_target_name(reflection),
|
|
96
|
+
through: reflection.options[:through]&.to_s,
|
|
97
|
+
flags: flags
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: (untyped) -> Array[untyped]
|
|
102
|
+
def supported_reflections(model_class)
|
|
103
|
+
model_class.reflect_on_all_associations
|
|
104
|
+
.select { |reflection| SUPPORTED_ASSOCIATION_MACROS.include?(reflection.macro) }
|
|
105
|
+
.reject { |reflection| paper_trail_versions?(model_class, reflection) }
|
|
106
|
+
.sort_by { |reflection| reflection.name.to_s }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
#: (untyped, untyped) -> bool
|
|
110
|
+
def paper_trail_versions?(model_class, reflection)
|
|
111
|
+
model_class.respond_to?(:versions_association_name) &&
|
|
112
|
+
reflection.name.to_s == model_class.versions_association_name.to_s
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
#: (untyped) -> untyped
|
|
116
|
+
def reflection_target(reflection)
|
|
117
|
+
reflection.klass unless reflection.polymorphic?
|
|
118
|
+
rescue NameError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
#: (untyped) -> String?
|
|
123
|
+
def reflection_target_name(reflection)
|
|
124
|
+
reflection.class_name.to_s unless reflection.polymorphic?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
#: (String, String) -> String
|
|
128
|
+
def association_path(prefix, name)
|
|
129
|
+
prefix.empty? ? name : "#{prefix}.#{name}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Resolves and validates ActiveRecord reflections for a bounded association tree.
|
|
6
|
+
class AssociationTraversal
|
|
7
|
+
#: (AssociationTree) -> void
|
|
8
|
+
def initialize(tree)
|
|
9
|
+
@tree = tree
|
|
10
|
+
@reflection_cache = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
#: (untyped) -> void
|
|
14
|
+
def validate!(model_class)
|
|
15
|
+
validate_tree!(model_class, @tree, path: '')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: (untyped) -> Array[Array[untyped]]
|
|
19
|
+
def selected_reflections(model_class)
|
|
20
|
+
collect_reflections(model_class, @tree, path: '').freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
#: (untyped, untyped) -> void
|
|
24
|
+
def ensure_habtm_history!(model_class, version)
|
|
25
|
+
paths = habtm_paths(model_class)
|
|
26
|
+
return if paths.empty?
|
|
27
|
+
return if version.respond_to?(:transaction_id) && version.transaction_id
|
|
28
|
+
|
|
29
|
+
message = "HABTM history is incomplete at version #{version.id}: #{paths.join(', ')}"
|
|
30
|
+
raise IncompleteAssociationHistoryError, message
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
#: (untyped) -> Array[String]
|
|
34
|
+
def habtm_paths(model_class)
|
|
35
|
+
selected_reflections(model_class).filter_map do |path, reflection|
|
|
36
|
+
path if habtm_reflection?(reflection)
|
|
37
|
+
end.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: (untyped, AssociationTree, path: String) -> Array[untyped]
|
|
41
|
+
def reflections_for(model_class, tree, path:)
|
|
42
|
+
key = [model_class.name.to_s, path, tree.object_id]
|
|
43
|
+
@reflection_cache[key] ||= requested_reflections(model_class, tree, path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#: (untyped) -> Array[String]
|
|
47
|
+
def incoming_relationship_columns(reflection)
|
|
48
|
+
direct = %i[has_one has_many].include?(reflection.macro) && !reflection.options[:through]
|
|
49
|
+
return [] unless direct
|
|
50
|
+
|
|
51
|
+
foreign_keys = Array(reflection.foreign_key)
|
|
52
|
+
# @type var foreign_keys: Array[untyped]
|
|
53
|
+
columns = foreign_keys.map do |column| # rubocop:disable Style/SymbolProc
|
|
54
|
+
column.to_s
|
|
55
|
+
end
|
|
56
|
+
columns << reflection.type.to_s if reflection.options[:as]
|
|
57
|
+
columns
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# @rbs @tree: AssociationTree
|
|
63
|
+
# @rbs @reflection_cache: Hash[Array[untyped], Array[untyped]]
|
|
64
|
+
|
|
65
|
+
#: (untyped, AssociationTree, path: String) -> Array[Array[untyped]]
|
|
66
|
+
def collect_reflections(model_class, tree, path:)
|
|
67
|
+
reflections_for(model_class, tree, path: path).flat_map do |reflection|
|
|
68
|
+
child_path = join_path(path, reflection.name.to_s)
|
|
69
|
+
result = [[child_path, reflection]]
|
|
70
|
+
subtree = tree.child(reflection.name)
|
|
71
|
+
next result unless subtree && !subtree.empty? && !reflection.polymorphic?
|
|
72
|
+
|
|
73
|
+
result + collect_reflections(reflection.klass, subtree, path: child_path)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
#: (untyped, AssociationTree, path: String) -> void
|
|
78
|
+
def validate_tree!(model_class, tree, path:)
|
|
79
|
+
reflections_for(model_class, tree, path: path).each do |reflection|
|
|
80
|
+
subtree = tree.child(reflection.name)
|
|
81
|
+
next unless subtree && !subtree.empty? && !reflection.polymorphic?
|
|
82
|
+
|
|
83
|
+
child_path = join_path(path, reflection.name.to_s)
|
|
84
|
+
validate_tree!(reflection.klass, subtree, path: child_path)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
#: (untyped, AssociationTree, String) -> Array[untyped]
|
|
89
|
+
def requested_reflections(model_class, tree, path)
|
|
90
|
+
tree.children.map do |name, _subtree|
|
|
91
|
+
resolve_reflection(model_class, name, join_path(path, name))
|
|
92
|
+
end.freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
#: (untyped, String, String) -> untyped
|
|
96
|
+
def resolve_reflection(model_class, name, full_path)
|
|
97
|
+
reflection = model_class.reflect_on_association(name.to_sym)
|
|
98
|
+
raise UnknownAssociationError, "unknown association: #{full_path}" unless reflection
|
|
99
|
+
|
|
100
|
+
if paper_trail_versions?(model_class, reflection)
|
|
101
|
+
message = "unsupported association #{full_path}: PaperTrail versions"
|
|
102
|
+
raise UnsupportedAssociationError, message
|
|
103
|
+
end
|
|
104
|
+
unless SUPPORTED_ASSOCIATION_MACROS.include?(reflection.macro)
|
|
105
|
+
raise UnsupportedAssociationError,
|
|
106
|
+
"unsupported association #{full_path}: #{reflection.macro}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
reflection
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
#: (untyped, untyped) -> bool
|
|
113
|
+
def paper_trail_versions?(model_class, reflection)
|
|
114
|
+
model_class.respond_to?(:versions_association_name) &&
|
|
115
|
+
reflection.name.to_s == model_class.versions_association_name.to_s
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
#: (untyped) -> bool
|
|
119
|
+
def habtm_reflection?(reflection)
|
|
120
|
+
return true if reflection.macro == :has_and_belongs_to_many
|
|
121
|
+
return false unless reflection.options[:through]
|
|
122
|
+
|
|
123
|
+
habtm_reflection?(reflection.through_reflection)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
#: (String, String) -> String
|
|
127
|
+
def join_path(parent, name)
|
|
128
|
+
parent.empty? ? name : "#{parent}.#{name}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Reconstructs selected root branches and merges them into a prior immutable snapshot.
|
|
6
|
+
class BranchSnapshotRefresher
|
|
7
|
+
#: (tree: AssociationTree, ignore_policy: IgnorePolicy, traversal: AssociationTraversal, pool: SnapshotPool, normalizer: SnapshotNormalizer, full_snapshotter: untyped, partial_snapshotter: untyped, association_reader: untyped) -> void
|
|
8
|
+
def initialize( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
9
|
+
tree:,
|
|
10
|
+
ignore_policy:,
|
|
11
|
+
traversal:,
|
|
12
|
+
pool:,
|
|
13
|
+
normalizer:,
|
|
14
|
+
full_snapshotter:,
|
|
15
|
+
partial_snapshotter:,
|
|
16
|
+
association_reader:
|
|
17
|
+
)
|
|
18
|
+
@tree = tree
|
|
19
|
+
@ignore_policy = ignore_policy
|
|
20
|
+
@traversal = traversal
|
|
21
|
+
@pool = pool
|
|
22
|
+
@full_snapshotter = full_snapshotter
|
|
23
|
+
@partial_snapshotter = partial_snapshotter
|
|
24
|
+
@components = {} #: Hash[Array[String], [AssociationTree, SnapshotNormalizer]]
|
|
25
|
+
@event_refresher = ActivityEventSnapshotRefresher.new(
|
|
26
|
+
traversal: traversal,
|
|
27
|
+
pool: pool,
|
|
28
|
+
components: method(:components),
|
|
29
|
+
association_reader: association_reader
|
|
30
|
+
)
|
|
31
|
+
@root_refresher = ActivityRootSnapshotRefresher.new(
|
|
32
|
+
tree: tree,
|
|
33
|
+
traversal: traversal,
|
|
34
|
+
normalizer: normalizer,
|
|
35
|
+
record_after: @event_refresher.method(:record_after),
|
|
36
|
+
changeset: @event_refresher.method(:deserialized_changeset),
|
|
37
|
+
partial_snapshotter: method(:partial_snapshot)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
#: (untyped, untyped, RecordSnapshot?, event: ActivityEvent) -> RecordSnapshot?
|
|
42
|
+
def advance_root(root_endpoint, context_endpoint, previous_snapshot, event:)
|
|
43
|
+
handled, incremental = @root_refresher.call(
|
|
44
|
+
root_endpoint,
|
|
45
|
+
context_endpoint,
|
|
46
|
+
previous_snapshot,
|
|
47
|
+
event
|
|
48
|
+
)
|
|
49
|
+
return incremental if handled
|
|
50
|
+
|
|
51
|
+
full_snapshot(root_endpoint, context_endpoint)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#: (untyped, untyped, RecordSnapshot?, Array[String], ?event: ActivityEvent?, ?isolated: bool) -> RecordSnapshot?
|
|
55
|
+
def call( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
56
|
+
root_endpoint,
|
|
57
|
+
context_endpoint,
|
|
58
|
+
previous_snapshot,
|
|
59
|
+
branches,
|
|
60
|
+
event: nil,
|
|
61
|
+
isolated: false
|
|
62
|
+
)
|
|
63
|
+
return full_snapshot(root_endpoint, context_endpoint) unless previous_snapshot
|
|
64
|
+
if isolated && event && branches.length > 1
|
|
65
|
+
return refresh_independent_branches(
|
|
66
|
+
root_endpoint,
|
|
67
|
+
context_endpoint,
|
|
68
|
+
previous_snapshot,
|
|
69
|
+
branches,
|
|
70
|
+
event
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
endpoints = [root_endpoint, context_endpoint]
|
|
75
|
+
handled, incremental = incremental_event(
|
|
76
|
+
endpoints,
|
|
77
|
+
previous_snapshot,
|
|
78
|
+
branches,
|
|
79
|
+
event,
|
|
80
|
+
isolated
|
|
81
|
+
)
|
|
82
|
+
return incremental if handled
|
|
83
|
+
|
|
84
|
+
partial = partial_snapshot(root_endpoint, context_endpoint, branches)
|
|
85
|
+
return full_snapshot(root_endpoint, context_endpoint) unless partial
|
|
86
|
+
|
|
87
|
+
merge(previous_snapshot, partial)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# @rbs @tree: AssociationTree
|
|
93
|
+
# @rbs @ignore_policy: IgnorePolicy
|
|
94
|
+
# @rbs @traversal: AssociationTraversal
|
|
95
|
+
# @rbs @pool: SnapshotPool
|
|
96
|
+
# @rbs @full_snapshotter: untyped
|
|
97
|
+
# @rbs @partial_snapshotter: untyped
|
|
98
|
+
# @rbs @components: Hash[Array[String], [AssociationTree, SnapshotNormalizer]]
|
|
99
|
+
# @rbs @event_refresher: ActivityEventSnapshotRefresher
|
|
100
|
+
# @rbs @root_refresher: ActivityRootSnapshotRefresher
|
|
101
|
+
|
|
102
|
+
#: (Array[untyped], RecordSnapshot, Array[String], ActivityEvent?, bool) -> [bool, RecordSnapshot?]
|
|
103
|
+
def incremental_event(endpoints, previous, branches, event, isolated)
|
|
104
|
+
return [false, nil] unless isolated
|
|
105
|
+
|
|
106
|
+
@event_refresher.call(
|
|
107
|
+
endpoints.fetch(0),
|
|
108
|
+
endpoints.fetch(1),
|
|
109
|
+
previous,
|
|
110
|
+
branches,
|
|
111
|
+
event
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
#: (untyped, untyped, RecordSnapshot, Array[String], ActivityEvent) -> RecordSnapshot?
|
|
116
|
+
def refresh_independent_branches( # rubocop:disable Metrics/MethodLength
|
|
117
|
+
root_endpoint,
|
|
118
|
+
context_endpoint,
|
|
119
|
+
previous_snapshot,
|
|
120
|
+
branches,
|
|
121
|
+
event
|
|
122
|
+
)
|
|
123
|
+
snapshot = previous_snapshot
|
|
124
|
+
remaining = [] #: Array[String]
|
|
125
|
+
branches.each do |branch|
|
|
126
|
+
handled, incremental = @event_refresher.call(
|
|
127
|
+
root_endpoint,
|
|
128
|
+
context_endpoint,
|
|
129
|
+
snapshot,
|
|
130
|
+
[branch],
|
|
131
|
+
event
|
|
132
|
+
)
|
|
133
|
+
if handled
|
|
134
|
+
snapshot = incremental || snapshot
|
|
135
|
+
else
|
|
136
|
+
remaining << branch
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
return snapshot if remaining.empty?
|
|
140
|
+
|
|
141
|
+
partial = partial_snapshot(root_endpoint, context_endpoint, remaining)
|
|
142
|
+
return full_snapshot(root_endpoint, context_endpoint) unless partial
|
|
143
|
+
|
|
144
|
+
merge(snapshot, partial)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
#: (untyped, untyped, Array[String]) -> RecordSnapshot?
|
|
148
|
+
def partial_snapshot(root_endpoint, context_endpoint, branches)
|
|
149
|
+
tree, normalizer = components(branches)
|
|
150
|
+
@partial_snapshotter.call(
|
|
151
|
+
root_endpoint,
|
|
152
|
+
context_endpoint,
|
|
153
|
+
tree: tree,
|
|
154
|
+
normalizer: normalizer
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
#: (Array[String]) -> [AssociationTree, SnapshotNormalizer]
|
|
159
|
+
def components(branches)
|
|
160
|
+
key = branches.sort.freeze
|
|
161
|
+
@components[key] ||= build_components(key)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
#: (Array[String]) -> [AssociationTree, SnapshotNormalizer]
|
|
165
|
+
def build_components(branches)
|
|
166
|
+
tree = @tree.select(branches)
|
|
167
|
+
normalizer = SnapshotNormalizer.new(
|
|
168
|
+
tree: tree,
|
|
169
|
+
ignore_policy: @ignore_policy,
|
|
170
|
+
traversal: @traversal,
|
|
171
|
+
pool: @pool
|
|
172
|
+
)
|
|
173
|
+
[tree, normalizer]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
#: (untyped, untyped) -> RecordSnapshot?
|
|
177
|
+
def full_snapshot(root_endpoint, context_endpoint)
|
|
178
|
+
@full_snapshotter.call(root_endpoint, context_endpoint)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
#: (RecordSnapshot, RecordSnapshot) -> RecordSnapshot
|
|
182
|
+
def merge(previous, partial)
|
|
183
|
+
RecordSnapshot.new(
|
|
184
|
+
type: previous.type,
|
|
185
|
+
id: previous.id,
|
|
186
|
+
attributes: previous.attributes,
|
|
187
|
+
associations: previous.associations.merge(partial.associations)
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# An immutable, bounded tree built from explicit association paths.
|
|
6
|
+
class AssociationTree
|
|
7
|
+
attr_reader :children #: Hash[String, AssociationTree]
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
#: (untyped) -> AssociationTree
|
|
11
|
+
def build(values)
|
|
12
|
+
unless valid_values?(values)
|
|
13
|
+
raise ConfigurationError, 'associations: must be an array of strings or symbols'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
tree = {} #: Hash[String, untyped]
|
|
17
|
+
values.each { |value| add_path(tree, value.to_s) }
|
|
18
|
+
from_hash(tree)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
#: (Hash[String, untyped], String) -> void
|
|
24
|
+
def add_path(tree, path)
|
|
25
|
+
segments = path.split('.', -1)
|
|
26
|
+
if segments.empty? || segments.any?(&:empty?) || path == '$'
|
|
27
|
+
raise ConfigurationError, "invalid association path: #{path.inspect}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
segments.inject(tree) do |branch, segment|
|
|
31
|
+
branch[segment] ||= {} #: Hash[String, untyped]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
#: (Hash[String, untyped]) -> AssociationTree
|
|
36
|
+
def from_hash(tree)
|
|
37
|
+
children = tree.sort.to_h do |name, branch|
|
|
38
|
+
[name, from_hash(branch)]
|
|
39
|
+
end
|
|
40
|
+
new(children)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (untyped) -> bool
|
|
44
|
+
def valid_values?(values)
|
|
45
|
+
values.is_a?(Array) && values.all? do |value|
|
|
46
|
+
value.is_a?(String) || value.is_a?(Symbol)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
#: (Hash[String, AssociationTree]) -> void
|
|
52
|
+
def initialize(children)
|
|
53
|
+
@children = children.dup.freeze
|
|
54
|
+
freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
#: () -> bool
|
|
58
|
+
def empty?
|
|
59
|
+
children.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#: (String | Symbol) -> AssociationTree?
|
|
63
|
+
def child(name)
|
|
64
|
+
children[name.to_s]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
#: (Array[String]) -> AssociationTree
|
|
68
|
+
def select(names)
|
|
69
|
+
self.class.new(children.slice(*names))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
#: (?prefix: String) -> Array[String]
|
|
73
|
+
def paths(prefix: '')
|
|
74
|
+
children.flat_map do |name, subtree|
|
|
75
|
+
path = prefix.empty? ? name : "#{prefix}.#{name}"
|
|
76
|
+
[path, *subtree.paths(prefix: path)]
|
|
77
|
+
end.freeze
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Immutable rules for globally and path-specifically excluded attributes.
|
|
82
|
+
class IgnorePolicy
|
|
83
|
+
ROOT_PATH = '$'
|
|
84
|
+
VALID_KEYS = %w[all paths].freeze
|
|
85
|
+
private_constant :VALID_KEYS
|
|
86
|
+
|
|
87
|
+
class << self
|
|
88
|
+
#: (untyped, association_paths: Array[String]) -> IgnorePolicy
|
|
89
|
+
def build(value, association_paths:)
|
|
90
|
+
return new(all: normalize_names(value, context: 'ignore'), paths: {}) if value.is_a?(Array)
|
|
91
|
+
|
|
92
|
+
unless value.is_a?(Hash)
|
|
93
|
+
raise ConfigurationError, 'ignore: must be an array or a hash with all: and paths:'
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
options = normalize_options(value)
|
|
97
|
+
empty_paths = {} #: Hash[untyped, untyped]
|
|
98
|
+
new(
|
|
99
|
+
all: normalize_names(options.fetch('all', []), context: 'ignore[:all]'),
|
|
100
|
+
paths: normalize_paths(options.fetch('paths', empty_paths), association_paths)
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
#: (Hash[untyped, untyped]) -> Hash[String, untyped]
|
|
107
|
+
def normalize_options(value)
|
|
108
|
+
options = {} #: Hash[String, untyped]
|
|
109
|
+
value.each do |key, item|
|
|
110
|
+
name = key.to_s
|
|
111
|
+
unless VALID_KEYS.include?(name) && !options.key?(name)
|
|
112
|
+
raise ConfigurationError, "unknown or duplicate ignore option: #{key.inspect}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
options[name] = item
|
|
116
|
+
end
|
|
117
|
+
options
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
#: (untyped, Array[String]) -> Hash[String, Array[String]]
|
|
121
|
+
def normalize_paths(value, association_paths)
|
|
122
|
+
raise ConfigurationError, 'ignore[:paths]: must be a hash' unless value.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
allowed_paths = [ROOT_PATH, *association_paths]
|
|
125
|
+
paths = {} #: Hash[String, Array[String]]
|
|
126
|
+
value.each do |path, names|
|
|
127
|
+
add_path_rule(paths, path, names, allowed_paths)
|
|
128
|
+
end
|
|
129
|
+
paths.sort.to_h.freeze
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
#: (Hash[String, Array[String]], untyped, untyped, Array[String]) -> void
|
|
133
|
+
def add_path_rule(paths, path, names, allowed_paths)
|
|
134
|
+
normalized_path = path.to_s
|
|
135
|
+
unless allowed_paths.include?(normalized_path) && !paths.key?(normalized_path)
|
|
136
|
+
raise ConfigurationError, "unknown or duplicate ignore path: #{path.inspect}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
context = "ignore[:paths][#{normalized_path.inspect}]"
|
|
140
|
+
paths[normalized_path] = normalize_names(names, context: context)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
#: (untyped, context: String) -> Array[String]
|
|
144
|
+
def normalize_names(values, context:)
|
|
145
|
+
valid = values.is_a?(Array) && values.all? do |value|
|
|
146
|
+
value.is_a?(String) || value.is_a?(Symbol)
|
|
147
|
+
end
|
|
148
|
+
raise ConfigurationError, "#{context}: must be an array of strings or symbols" unless valid
|
|
149
|
+
|
|
150
|
+
values.map(&:to_s).uniq.sort.freeze
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
attr_reader :all #: Array[String]
|
|
155
|
+
attr_reader :paths #: Hash[String, Array[String]]
|
|
156
|
+
|
|
157
|
+
#: (all: Array[String], paths: Hash[String, Array[String]]) -> void
|
|
158
|
+
def initialize(all:, paths:)
|
|
159
|
+
@all = all.dup.freeze
|
|
160
|
+
@paths = paths.dup.freeze
|
|
161
|
+
freeze
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
#: (String) -> Array[String]
|
|
165
|
+
def attributes_for(path)
|
|
166
|
+
external_path = path.empty? ? ROOT_PATH : path
|
|
167
|
+
(all + paths.fetch(external_path, [])).uniq.freeze
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|