paper_trail_diff 0.13.1 → 0.14.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 +287 -0
- data/QUICKSTART.md +9 -6
- data/README.md +84 -9
- data/docs/architecture.md +32 -9
- data/docs/performance.md +106 -13
- data/docs/reference.md +242 -29
- data/docs/testing.md +34 -11
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +19 -9
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +11 -1
- data/lib/paper_trail_diff/activity_collection_record_updater.rb +16 -2
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +20 -1
- data/lib/paper_trail_diff/activity_event_route_finder.rb +2 -1
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +1 -1
- data/lib/paper_trail_diff/activity_range.rb +14 -2
- data/lib/paper_trail_diff/activity_relationship.rb +12 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +5 -2
- data/lib/paper_trail_diff/activity_target_candidate_loader.rb +131 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +30 -24
- data/lib/paper_trail_diff/activity_version_collector.rb +31 -20
- data/lib/paper_trail_diff/association_activity_edge.rb +236 -0
- data/lib/paper_trail_diff/association_activity_selection.rb +197 -0
- data/lib/paper_trail_diff/association_discovery.rb +8 -13
- data/lib/paper_trail_diff/association_traversal.rb +169 -45
- data/lib/paper_trail_diff/batch_safety.rb +23 -15
- data/lib/paper_trail_diff/batched_root_versions.rb +21 -39
- data/lib/paper_trail_diff/comparison_batch.rb +37 -10
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +34 -16
- data/lib/paper_trail_diff/diagnostics.rb +26 -8
- data/lib/paper_trail_diff/direct_record_state_loader.rb +10 -1
- data/lib/paper_trail_diff/errors.rb +2 -1
- data/lib/paper_trail_diff/habtm_transaction_owners.rb +68 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +32 -2
- data/lib/paper_trail_diff/historical_snapshot_store.rb +6 -9
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +35 -2
- data/lib/paper_trail_diff/live_graph_collector.rb +1 -1
- data/lib/paper_trail_diff/paper_trail_adapter.rb +22 -6
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +1 -1
- data/lib/paper_trail_diff/prepared_activity_index.rb +110 -36
- data/lib/paper_trail_diff/prepared_edge_loader.rb +29 -45
- data/lib/paper_trail_diff/prepared_history.rb +63 -54
- data/lib/paper_trail_diff/prepared_history_loader.rb +33 -43
- data/lib/paper_trail_diff/prepared_record_index.rb +26 -0
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +1 -1
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +119 -29
- data/lib/paper_trail_diff/recorded_changes.rb +83 -0
- data/lib/paper_trail_diff/recorded_class.rb +53 -0
- data/lib/paper_trail_diff/scope_condition.rb +331 -0
- data/lib/paper_trail_diff/scope_ranking.rb +196 -0
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +4 -3
- data/lib/paper_trail_diff/scoped_root_selection.rb +93 -43
- data/lib/paper_trail_diff/snapshot_normalizer.rb +10 -3
- data/lib/paper_trail_diff/support.rb +9 -4
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -1
- data/lib/paper_trail_diff/time_range.rb +9 -12
- data/lib/paper_trail_diff/time_version_range.rb +5 -5
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +13 -3
- data/lib/paper_trail_diff/version_range.rb +8 -15
- data/lib/paper_trail_diff/version_scope_filter.rb +11 -3
- data/lib/paper_trail_diff.rb +31 -2
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +10 -2
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +5 -0
- data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +5 -0
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +7 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_relationship.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +2 -0
- data/sig/generated/paper_trail_diff/activity_target_candidate_loader.rbs +79 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +11 -5
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +10 -4
- data/sig/generated/paper_trail_diff/association_activity_edge.rbs +148 -0
- data/sig/generated/paper_trail_diff/association_activity_selection.rbs +112 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +3 -5
- data/sig/generated/paper_trail_diff/association_traversal.rbs +84 -9
- data/sig/generated/paper_trail_diff/batch_safety.rbs +10 -4
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +2 -8
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +22 -4
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +16 -4
- data/sig/generated/paper_trail_diff/diagnostics.rbs +7 -2
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +6 -0
- data/sig/generated/paper_trail_diff/errors.rbs +2 -1
- data/sig/generated/paper_trail_diff/habtm_transaction_owners.rbs +40 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +13 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +3 -2
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +17 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +5 -0
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +52 -10
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +4 -12
- data/sig/generated/paper_trail_diff/prepared_history.rbs +33 -22
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +15 -13
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +15 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +56 -11
- data/sig/generated/paper_trail_diff/recorded_changes.rbs +47 -0
- data/sig/generated/paper_trail_diff/recorded_class.rbs +22 -0
- data/sig/generated/paper_trail_diff/scope_condition.rbs +185 -0
- data/sig/generated/paper_trail_diff/scope_ranking.rbs +117 -0
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +41 -7
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +6 -0
- data/sig/generated/paper_trail_diff/support.rbs +5 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +4 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -4
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +7 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +4 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -0
- data/sig/generated/paper_trail_diff.rbs +21 -2
- metadata +20 -4
|
@@ -2,71 +2,161 @@
|
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
|
-
# Reads plain
|
|
5
|
+
# Reads plain edges without creating AR records for their members: direct
|
|
6
|
+
# edges, polymorphic `belongs_to`, and `has_many :through` built from them.
|
|
6
7
|
class PreparedStateAssociationReader
|
|
7
|
-
#: (PreparedRecordIndex, untyped) -> void
|
|
8
|
-
def initialize(records, edges)
|
|
8
|
+
#: (PreparedRecordIndex, untyped, ScopeCondition::Cache) -> void
|
|
9
|
+
def initialize(records, edges, conditions)
|
|
9
10
|
@records = records
|
|
10
11
|
@edges = edges
|
|
12
|
+
@conditions = conditions
|
|
11
13
|
end
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
# Unranked, as a part of a has_many through is read, it returns every row
|
|
16
|
+
# the part's conditions admit, a record reached twice appearing twice.
|
|
17
|
+
#: (PreparedRecordState, untyped, untyped, ?ranked: bool) -> [bool, Array[PreparedRecordState]]
|
|
18
|
+
def call(owner, reflection, boundary, ranked: true)
|
|
19
|
+
return [false, []] unless supported?(reflection, ranked)
|
|
20
|
+
return through(owner, reflection, boundary, ranked) if reflection.options[:through]
|
|
21
|
+
return target(owner, reflection, boundary, ranked) if reflection.belongs_to?
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
id = owner.attributes[reflection.foreign_key.to_s]
|
|
19
|
-
return [true, []] if id.nil?
|
|
20
|
-
|
|
21
|
-
handled, state = @records.snapshot_state_before(reflection.klass, id, boundary)
|
|
22
|
-
return [handled, state ? [state] : []]
|
|
23
|
-
end
|
|
24
|
-
children(owner, reflection, boundary)
|
|
23
|
+
children(owner, reflection, boundary, ranked)
|
|
25
24
|
end
|
|
26
25
|
|
|
27
26
|
private
|
|
28
27
|
|
|
29
28
|
# @rbs @records: PreparedRecordIndex
|
|
30
29
|
# @rbs @edges: untyped
|
|
30
|
+
# @rbs @conditions: ScopeCondition::Cache
|
|
31
|
+
|
|
32
|
+
# A polymorphic target's class is known only once a record names it, so
|
|
33
|
+
# `target` reads its scope for each record instead.
|
|
34
|
+
#: (untyped, bool) -> bool
|
|
35
|
+
def supported?(reflection, ranked)
|
|
36
|
+
!reflection.foreign_key.is_a?(Array) &&
|
|
37
|
+
%i[belongs_to has_one has_many].include?(reflection.macro) &&
|
|
38
|
+
(reflection.polymorphic? || !@conditions.call(reflection, ranked: ranked).nil?)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
#: (PreparedRecordState, untyped, bool, ?untyped) -> bool
|
|
42
|
+
def in_scope?(state, reflection, ranked, model_class = reflection.klass)
|
|
43
|
+
condition = @conditions.call(reflection, model_class, ranked: ranked) #: ScopeCondition
|
|
44
|
+
condition.matches?(state.attributes)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A type that names no class, or a class whose scope cannot be read as
|
|
48
|
+
# conditions, is left to the record path, which raises for the first and
|
|
49
|
+
# reads the second through its association.
|
|
50
|
+
#: (PreparedRecordState, untyped, untyped, bool) -> [bool, Array[PreparedRecordState]]
|
|
51
|
+
def target(owner, reflection, boundary, ranked)
|
|
52
|
+
id = owner.attributes[reflection.foreign_key.to_s]
|
|
53
|
+
return [true, []] if id.nil?
|
|
54
|
+
|
|
55
|
+
model_class = target_class(owner, reflection)
|
|
56
|
+
return [false, []] unless model_class &&
|
|
57
|
+
@conditions.call(reflection, model_class, ranked: ranked)
|
|
31
58
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
!reflection.options[:through] && !reflection.polymorphic? && reflection.scope.nil? &&
|
|
35
|
-
!reflection.klass.default_scopes? && !reflection.foreign_key.is_a?(Array) &&
|
|
36
|
-
%i[belongs_to has_one has_many].include?(reflection.macro)
|
|
59
|
+
handled, state = @records.snapshot_state_before(model_class, id, boundary)
|
|
60
|
+
[handled, state && in_scope?(state, reflection, ranked, model_class) ? [state] : []]
|
|
37
61
|
end
|
|
38
62
|
|
|
39
|
-
#: (PreparedRecordState, untyped
|
|
40
|
-
def
|
|
63
|
+
#: (PreparedRecordState, untyped) -> untyped
|
|
64
|
+
def target_class(owner, reflection)
|
|
65
|
+
return reflection.klass unless reflection.polymorphic?
|
|
66
|
+
|
|
67
|
+
AssociationTraversal.named_class(owner.attributes[reflection.foreign_type].to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The members of each join record the owner had at the boundary, as
|
|
71
|
+
# PreparedHistory#through_records reads them from records. Under
|
|
72
|
+
# `source_type:`, only join records of that type count. A has_many
|
|
73
|
+
# through reads its parts unranked, as its query does.
|
|
74
|
+
#: (PreparedRecordState, untyped, untyped, bool) -> [bool, Array[PreparedRecordState]]
|
|
75
|
+
def through(owner, reflection, boundary, ranked)
|
|
76
|
+
parts_ranked = ranked && !reflection.collection?
|
|
77
|
+
handled, joins = call(owner, reflection.through_reflection, boundary, ranked: parts_ranked)
|
|
78
|
+
return [false, []] unless handled
|
|
79
|
+
|
|
80
|
+
rows = [] #: Array[PreparedRecordState]
|
|
81
|
+
joins.each do |join|
|
|
82
|
+
next unless typed_join?(reflection, join)
|
|
83
|
+
|
|
84
|
+
handled, states = call(join, reflection.source_reflection, boundary, ranked: parts_ranked)
|
|
85
|
+
return [false, []] unless handled
|
|
86
|
+
|
|
87
|
+
rows.concat(states)
|
|
88
|
+
end
|
|
89
|
+
[true, through_members(reflection, rows, ranked)]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The through's own scope applies after its parts' scopes, to each row,
|
|
93
|
+
# and a ranked read then keeps a record reached twice as one member.
|
|
94
|
+
#: (untyped, Array[PreparedRecordState], bool) -> Array[PreparedRecordState]
|
|
95
|
+
def through_members(reflection, rows, ranked)
|
|
96
|
+
condition = @conditions.call(reflection, ranked: ranked) #: ScopeCondition
|
|
97
|
+
members = condition.members(rows)
|
|
98
|
+
return members unless ranked
|
|
99
|
+
|
|
100
|
+
first_if_one(reflection, members.uniq { |state| identity(state) })
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A has_one keeps the first of its records.
|
|
104
|
+
#: (untyped, Array[PreparedRecordState]) -> Array[PreparedRecordState]
|
|
105
|
+
def first_if_one(reflection, states)
|
|
106
|
+
reflection.collection? ? states : states.first(1)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
#: (untyped, PreparedRecordState) -> bool
|
|
110
|
+
def typed_join?(reflection, join)
|
|
111
|
+
AssociationTraversal.source_type_member?(reflection, join.attributes[reflection.foreign_type])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A record reached through two join records is one member.
|
|
115
|
+
#: (PreparedRecordState) -> Array[untyped]
|
|
116
|
+
def identity(state)
|
|
117
|
+
model = state.model_class
|
|
118
|
+
[model.base_class.name, *Array(model.primary_key).map { |key| state.attributes[key].to_s }]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
#: (PreparedRecordState, untyped, untyped, bool) -> [bool, Array[PreparedRecordState]]
|
|
122
|
+
def children(owner, reflection, boundary, ranked)
|
|
41
123
|
selected = [] #: Array[PreparedRecordState]
|
|
42
124
|
@edges.call(owner.model_class, reflection).each_value do |group|
|
|
43
125
|
ids_for(group, owner).each do |id|
|
|
44
126
|
handled, child = @records.snapshot_state_before(group.fetch(:model), id, boundary)
|
|
45
127
|
return [false, []] unless handled
|
|
46
128
|
|
|
47
|
-
selected << child if child && member?(child, owner, reflection)
|
|
129
|
+
selected << child if child && member?(child, owner, reflection, ranked)
|
|
48
130
|
end
|
|
49
131
|
end
|
|
50
|
-
[true,
|
|
132
|
+
return [true, selected] unless ranked
|
|
133
|
+
|
|
134
|
+
condition = @conditions.call(reflection) #: ScopeCondition
|
|
135
|
+
[true, first_if_one(reflection, condition.rank(selected))]
|
|
51
136
|
end
|
|
52
137
|
|
|
53
138
|
#: (untyped, PreparedRecordState) -> Array[untyped]
|
|
54
139
|
def ids_for(group, owner)
|
|
55
140
|
owners = group[:owners]
|
|
56
|
-
id = owner.attributes[owner.model_class.primary_key].to_s
|
|
57
141
|
empty = [] #: Array[untyped]
|
|
58
|
-
owners ? owners.fetch(
|
|
142
|
+
owners ? owners.fetch(owner_id(owner), empty) : group.fetch(:ids)
|
|
59
143
|
end
|
|
60
144
|
|
|
61
|
-
#: (PreparedRecordState, PreparedRecordState, untyped) -> bool
|
|
62
|
-
def member?(child, owner, reflection)
|
|
63
|
-
|
|
64
|
-
return false unless
|
|
145
|
+
#: (PreparedRecordState, PreparedRecordState, untyped, bool) -> bool
|
|
146
|
+
def member?(child, owner, reflection, ranked)
|
|
147
|
+
return false unless child.attributes[reflection.foreign_key.to_s].to_s == owner_id(owner)
|
|
148
|
+
return false unless AssociationTraversal.sti_member?(reflection.klass, child.attributes)
|
|
149
|
+
return false unless in_scope?(child, reflection, ranked)
|
|
65
150
|
return true unless reflection.options[:as]
|
|
66
151
|
|
|
67
152
|
matching_type?(child, owner.model_class, reflection)
|
|
68
153
|
end
|
|
69
154
|
|
|
155
|
+
#: (PreparedRecordState) -> String
|
|
156
|
+
def owner_id(owner)
|
|
157
|
+
owner.attributes[owner.model_class.primary_key].to_s
|
|
158
|
+
end
|
|
159
|
+
|
|
70
160
|
#: (PreparedRecordState, untyped, untyped) -> bool
|
|
71
161
|
def matching_type?(child, model, reflection)
|
|
72
162
|
types = [model.name, model.base_class.name,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# The changes PaperTrail wrote with one version, typed by the version's model.
|
|
6
|
+
#
|
|
7
|
+
# A step's diff compares reconstructed states, so it also holds whatever was
|
|
8
|
+
# saved without a version before the next one was read. This is the other
|
|
9
|
+
# account of the same event: only what PaperTrail recorded for it. Comparing
|
|
10
|
+
# the two is how a caller tells an unrecorded write from the event's own edit.
|
|
11
|
+
#
|
|
12
|
+
# PaperTrail's `version.changeset` finds the model through `version.item`,
|
|
13
|
+
# which is a query per version and nothing at all once the record is deleted.
|
|
14
|
+
# The version already names its model, so neither is needed here.
|
|
15
|
+
class RecordedChanges
|
|
16
|
+
IGNORE_NAMES_REQUIRED = 'ignore: must be an array of strings or symbols; a version ' \
|
|
17
|
+
'has no association path for paths: to match'
|
|
18
|
+
private_constant :IGNORE_NAMES_REQUIRED
|
|
19
|
+
|
|
20
|
+
#: (untyped, ignore: untyped) -> Hash[String, ValueChange]?
|
|
21
|
+
def self.call(version, ignore:)
|
|
22
|
+
new(version, ignore: ignore).call
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
#: (untyped, ignore: untyped) -> void
|
|
26
|
+
def initialize(version, ignore:)
|
|
27
|
+
@version = version
|
|
28
|
+
@ignore = ignore
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
#: () -> Hash[String, ValueChange]?
|
|
32
|
+
def call
|
|
33
|
+
raise InvalidEndpointError, 'expected a PaperTrail version' unless Endpoint.version?(@version)
|
|
34
|
+
|
|
35
|
+
ignored = ignored_names
|
|
36
|
+
return unless recorded?
|
|
37
|
+
|
|
38
|
+
model_class = Endpoint.model_class(@version)
|
|
39
|
+
changes = ActivityEventRecordResolver.new.deserialized_changeset(@version, model_class)
|
|
40
|
+
return if changes.nil?
|
|
41
|
+
|
|
42
|
+
value_changes(changes, Array(model_class.primary_key) + ignored)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# @rbs @version: untyped
|
|
48
|
+
# @rbs @ignore: untyped
|
|
49
|
+
|
|
50
|
+
# Validated before anything is read, so a bad option fails the same way
|
|
51
|
+
# whether or not this version happened to record changes. Subclasses of
|
|
52
|
+
# Array and String pass, as in other methods' `ignore:`; Symbol has none.
|
|
53
|
+
#: () -> Array[String]
|
|
54
|
+
def ignored_names
|
|
55
|
+
valid = @ignore.is_a?(Array) && @ignore.all? do |name|
|
|
56
|
+
name.is_a?(String) || name.instance_of?(Symbol)
|
|
57
|
+
end
|
|
58
|
+
raise ConfigurationError, IGNORE_NAMES_REQUIRED unless valid
|
|
59
|
+
|
|
60
|
+
@ignore.map(&:to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Without the column, or with it left empty for this version, nothing was
|
|
64
|
+
# recorded. That is not the same answer as a recorded empty changeset.
|
|
65
|
+
#: () -> bool
|
|
66
|
+
def recorded?
|
|
67
|
+
@version.class.column_names.include?('object_changes') && !@version.object_changes.nil?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Primary keys identify the record rather than describe it, as in a diff.
|
|
71
|
+
#: (untyped, Array[String]) -> Hash[String, ValueChange]
|
|
72
|
+
def value_changes(changes, excluded)
|
|
73
|
+
recorded = {} #: Hash[String, ValueChange]
|
|
74
|
+
changes.each do |name, values|
|
|
75
|
+
key = name.to_s
|
|
76
|
+
next if excluded.include?(key)
|
|
77
|
+
|
|
78
|
+
recorded[key] = ValueChange.new(from: values.first, to: values.last)
|
|
79
|
+
end
|
|
80
|
+
recorded.sort.to_h.freeze
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# The class a record had at one version, which decides the associations it
|
|
6
|
+
# can have. Without an `item_subtype` column PaperTrail stores only the base
|
|
7
|
+
# class, so an STI subclass is read from the stored inheritance column: the
|
|
8
|
+
# state before the change, or for a create, the value it was created with.
|
|
9
|
+
# A version that stored neither stays on the base class.
|
|
10
|
+
module RecordedClass
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
#: (untyped) -> untyped
|
|
14
|
+
def for(version)
|
|
15
|
+
model = Endpoint.model_class(version)
|
|
16
|
+
return model unless sti_base?(model)
|
|
17
|
+
|
|
18
|
+
type = stored_type(version, model).to_s
|
|
19
|
+
type.empty? ? model : model.sti_class_for(type)
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
raise unless subclass_not_found?(e)
|
|
22
|
+
|
|
23
|
+
raise InvalidEndpointError, "cannot resolve endpoint model: #{type}", cause: e
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
#: (untyped) -> bool
|
|
27
|
+
def sti_base?(model)
|
|
28
|
+
model.base_class == model && model.columns_hash.key?(model.inheritance_column.to_s)
|
|
29
|
+
end
|
|
30
|
+
private_class_method :sti_base?
|
|
31
|
+
|
|
32
|
+
#: (untyped, untyped) -> untyped
|
|
33
|
+
def stored_type(version, model)
|
|
34
|
+
column = model.inheritance_column.to_s
|
|
35
|
+
if version.object
|
|
36
|
+
attributes = version.object_deserialized
|
|
37
|
+
return attributes.is_a?(Hash) ? attributes[column] || attributes[column.to_sym] : nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
changes = ActivityEventRecordResolver.new.deserialized_changeset(version, model)
|
|
41
|
+
values = changes[column] if changes.respond_to?(:key?) && changes.key?(column)
|
|
42
|
+
values.last if values.is_a?(Array)
|
|
43
|
+
end
|
|
44
|
+
private_class_method :stored_type
|
|
45
|
+
|
|
46
|
+
#: (StandardError) -> bool
|
|
47
|
+
def subclass_not_found?(error)
|
|
48
|
+
active_record = Object.const_get(:ActiveRecord) #: untyped
|
|
49
|
+
error.is_a?(active_record.const_get(:SubclassNotFound))
|
|
50
|
+
end
|
|
51
|
+
private_class_method :subclass_not_found?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# An association scope, or its target's default scope, read as conditions
|
|
6
|
+
# on stored attributes, so membership at a boundary can be decided from each
|
|
7
|
+
# record's state there rather than by a query. A limit, offset or has_one
|
|
8
|
+
# order then ranks the records that meet the conditions (ScopeRanking). It
|
|
9
|
+
# reads the relation the scope builds once and never runs application code
|
|
10
|
+
# per record. A scope it cannot read exactly builds no condition, and the
|
|
11
|
+
# caller keeps its slower path.
|
|
12
|
+
class ScopeCondition
|
|
13
|
+
# Relation parts that leave membership to the where clause and the
|
|
14
|
+
# ranking alone.
|
|
15
|
+
RELATION_VALUES = %i[where order reordering limit offset readonly strict_loading
|
|
16
|
+
extending].freeze
|
|
17
|
+
# Types whose stored values compare in Ruby as the database compares them,
|
|
18
|
+
# named rather than referenced so that loading needs no Active Model.
|
|
19
|
+
VALUE_TYPES = %w[ActiveModel::Type::Integer ActiveModel::Type::Boolean
|
|
20
|
+
ActiveModel::Type::ImmutableString].freeze
|
|
21
|
+
TEXT_TYPE = 'ActiveModel::Type::ImmutableString'
|
|
22
|
+
ENUM_TYPE = 'ActiveRecord::Enum::EnumType'
|
|
23
|
+
BIND_PARAM = 'Arel::Nodes::BindParam'
|
|
24
|
+
ATTRIBUTE = 'Arel::Attributes::Attribute'
|
|
25
|
+
# Adapters that compare strings exactly by default. MySQL and SQL Server
|
|
26
|
+
# ignore case and trailing spaces by default, which Ruby cannot follow.
|
|
27
|
+
EXACT_TEXT_ADAPTERS = %w[sqlite3 postgresql].freeze
|
|
28
|
+
|
|
29
|
+
# Raised while reading a node that has no exact plain-Ruby reading.
|
|
30
|
+
class Unreadable < StandardError; end
|
|
31
|
+
private_constant :Unreadable
|
|
32
|
+
|
|
33
|
+
# Whether the reflection filters its target beyond its keys at all.
|
|
34
|
+
#: (untyped) -> bool
|
|
35
|
+
def self.scoped?(reflection)
|
|
36
|
+
!reflection.scope.nil? || reflection.klass.default_scopes?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A through also filters by the scopes of each reflection it passes.
|
|
40
|
+
#: (untyped) -> bool
|
|
41
|
+
def self.scoped_path?(reflection)
|
|
42
|
+
return !reflection.scope.nil? if reflection.polymorphic?
|
|
43
|
+
return true if scoped?(reflection)
|
|
44
|
+
return false unless reflection.through_reflection?
|
|
45
|
+
|
|
46
|
+
scoped_path?(reflection.through_reflection) || scoped_path?(reflection.source_reflection)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns nil when the scope cannot be read as plain conditions and an
|
|
50
|
+
# exact ranking. A part of a has_many through is read unranked: the
|
|
51
|
+
# through's query keeps only its conditions.
|
|
52
|
+
#: (untyped, ?untyped, ?ranked: bool) -> ScopeCondition?
|
|
53
|
+
def self.build(reflection, model_class = reflection.klass, ranked: true)
|
|
54
|
+
return if ranked && ranked_through?(reflection)
|
|
55
|
+
|
|
56
|
+
relation = scoped_relation(reflection, model_class)
|
|
57
|
+
return unless relation && plain_relation?(relation)
|
|
58
|
+
|
|
59
|
+
clause = relation.where_clause - model_class.unscoped.where_clause
|
|
60
|
+
predicate = Reader.new(model_class).call(clause.ast)
|
|
61
|
+
new(model_class, predicate, (ranking(relation, reflection, model_class) if ranked))
|
|
62
|
+
rescue Unreadable
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# nil when the relation picks its members by conditions alone.
|
|
67
|
+
#: (untyped, untyped, untyped) -> ScopeRanking?
|
|
68
|
+
def self.ranking(relation, reflection, model_class)
|
|
69
|
+
return unless ScopeRanking.ranks?(relation, reflection)
|
|
70
|
+
|
|
71
|
+
ScopeRanking.build(relation, reflection, model_class) || raise(Unreadable)
|
|
72
|
+
end
|
|
73
|
+
private_class_method :ranking
|
|
74
|
+
|
|
75
|
+
# A has_one through orders its rows by its own order and then its parts',
|
|
76
|
+
# and takes the first, so one that passes a ranked part is not read. A
|
|
77
|
+
# has_many through keeps only its parts' conditions.
|
|
78
|
+
#: (untyped) -> bool
|
|
79
|
+
def self.ranked_through?(reflection)
|
|
80
|
+
return false if reflection.collection?
|
|
81
|
+
|
|
82
|
+
ranked_parts?(reflection)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Whether any part of a through, at any depth, has a limit, an offset or
|
|
86
|
+
# a has_one order.
|
|
87
|
+
#: (untyped) -> bool
|
|
88
|
+
def self.ranked_parts?(reflection)
|
|
89
|
+
return false unless reflection.through_reflection?
|
|
90
|
+
|
|
91
|
+
[reflection.through_reflection, reflection.source_reflection].any? do |part|
|
|
92
|
+
ranked_part?(part) || ranked_parts?(part)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A polymorphic part's class, and so its scope, is known only per record.
|
|
97
|
+
#: (untyped) -> bool
|
|
98
|
+
def self.ranked_part?(part)
|
|
99
|
+
return false if part.polymorphic?
|
|
100
|
+
|
|
101
|
+
relation = scoped_relation(part, part.klass)
|
|
102
|
+
!relation.nil? && ScopeRanking.ranks?(relation, part)
|
|
103
|
+
end
|
|
104
|
+
private_class_method :ranked_part?
|
|
105
|
+
|
|
106
|
+
# A scope that takes the owner differs per record, so it is never read.
|
|
107
|
+
#: (untyped, untyped) -> untyped
|
|
108
|
+
def self.scoped_relation(reflection, model_class)
|
|
109
|
+
scope = reflection.scope
|
|
110
|
+
relation = model_class.default_scoped
|
|
111
|
+
return relation unless scope
|
|
112
|
+
return unless scope.arity.zero?
|
|
113
|
+
|
|
114
|
+
reflection.scope_for(relation)
|
|
115
|
+
end
|
|
116
|
+
private_class_method :scoped_relation
|
|
117
|
+
|
|
118
|
+
# Some parts, such as the having clause `or` sets, may be present but
|
|
119
|
+
# empty.
|
|
120
|
+
#: (untyped) -> bool
|
|
121
|
+
def self.plain_relation?(relation)
|
|
122
|
+
extra = relation.values.except(*RELATION_VALUES).values
|
|
123
|
+
extra.all? { |value| value.respond_to?(:empty?) && value.empty? }
|
|
124
|
+
end
|
|
125
|
+
private_class_method :plain_relation?
|
|
126
|
+
|
|
127
|
+
#: (untyped, Array[untyped], ?ScopeRanking?) -> void
|
|
128
|
+
def initialize(model_class, predicate, ranking = nil)
|
|
129
|
+
@model_class = model_class
|
|
130
|
+
@predicate = predicate
|
|
131
|
+
@ranking = ranking
|
|
132
|
+
freeze
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
#: (Hash[String, untyped]) -> bool
|
|
136
|
+
def matches?(attributes)
|
|
137
|
+
evaluate(@predicate, attributes)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The members among records that are each the owner's by key: those that
|
|
141
|
+
# meet the conditions, then ranked.
|
|
142
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
143
|
+
def members(records)
|
|
144
|
+
rank(records.select { |record| matches?(record.attributes) })
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Records that meet the conditions, as ranked; unchanged without a
|
|
148
|
+
# ranking.
|
|
149
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
150
|
+
def rank(records)
|
|
151
|
+
ranking = @ranking
|
|
152
|
+
ranking ? ranking.call(records) : records
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# SQL compares NULL with nothing, so only an IS NULL test, an equality
|
|
156
|
+
# with nil, matches it.
|
|
157
|
+
COMPARISONS = {
|
|
158
|
+
eq: ->(value, expected) { value == expected },
|
|
159
|
+
not_eq: ->(value, expected) { !value.nil? && value != expected },
|
|
160
|
+
in: ->(value, expected) { !value.nil? && expected.include?(value) },
|
|
161
|
+
not_in: ->(value, expected) { !value.nil? && !expected.include?(value) }
|
|
162
|
+
}.freeze
|
|
163
|
+
private_constant :COMPARISONS
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
# @rbs @model_class: untyped
|
|
168
|
+
# @rbs @predicate: Array[untyped]
|
|
169
|
+
# @rbs @ranking: ScopeRanking?
|
|
170
|
+
|
|
171
|
+
#: (Array[untyped], Hash[String, untyped]) -> bool
|
|
172
|
+
def evaluate(predicate, attributes)
|
|
173
|
+
operator, first, second = predicate
|
|
174
|
+
case operator
|
|
175
|
+
when :and then first.all? { |part| evaluate(part, attributes) }
|
|
176
|
+
when :or then first.any? { |part| evaluate(part, attributes) }
|
|
177
|
+
else COMPARISONS.fetch(operator).call(stored(first, attributes), second)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The form the database holds, as the scope's own values were cast.
|
|
182
|
+
#: (String, Hash[String, untyped]) -> untyped
|
|
183
|
+
def stored(name, attributes)
|
|
184
|
+
@model_class.type_for_attribute(name).serialize(attributes[name])
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Builds each reflection's condition once per target class.
|
|
188
|
+
class Cache
|
|
189
|
+
#: () -> void
|
|
190
|
+
def initialize
|
|
191
|
+
@conditions = {} #: Hash[Array[untyped], ScopeCondition?]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# nil when the scope cannot be read from stored attributes.
|
|
195
|
+
#: (untyped, ?untyped, ?ranked: bool) -> ScopeCondition?
|
|
196
|
+
def call(reflection, model_class = reflection.klass, ranked: true)
|
|
197
|
+
key = [reflection, model_class, ranked]
|
|
198
|
+
return @conditions.fetch(key) if @conditions.key?(key)
|
|
199
|
+
|
|
200
|
+
@conditions[key] = ScopeCondition.build(reflection, model_class, ranked: ranked)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The records the reflection's scope admits, as it ranks them. Its
|
|
204
|
+
# condition must exist.
|
|
205
|
+
#: (untyped, Array[untyped], ?untyped, ranked: bool) -> Array[untyped]
|
|
206
|
+
def select(reflection, records, model_class = reflection.klass, ranked:)
|
|
207
|
+
condition = call(reflection, model_class, ranked: ranked) #: ScopeCondition
|
|
208
|
+
condition.members(records)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# @rbs @conditions: Hash[Array[untyped], ScopeCondition?]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Compiles a where clause into nested arrays of plain comparisons.
|
|
215
|
+
class Reader
|
|
216
|
+
#: (untyped) -> void
|
|
217
|
+
def initialize(model_class)
|
|
218
|
+
@model_class = model_class
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
#: (untyped) -> Array[untyped]
|
|
222
|
+
def call(node)
|
|
223
|
+
case node.class.name
|
|
224
|
+
when 'Arel::Nodes::And' then [:and, node.children.map { |child| call(child) }]
|
|
225
|
+
when 'Arel::Nodes::Or' then [:or, either(node).map { |child| call(child) }]
|
|
226
|
+
when 'Arel::Nodes::Grouping' then call(node.expr)
|
|
227
|
+
else comparison(node)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
# @rbs @model_class: untyped
|
|
234
|
+
|
|
235
|
+
# An Or is binary before Rails 7.2 and n-ary from it.
|
|
236
|
+
#: (untyped) -> Array[untyped]
|
|
237
|
+
def either(node)
|
|
238
|
+
node.respond_to?(:children) ? node.children : [node.left, node.right]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
#: (untyped) -> Array[untyped]
|
|
242
|
+
def comparison(node)
|
|
243
|
+
case node.class.name
|
|
244
|
+
when 'Arel::Nodes::Equality' then binary(:eq, node)
|
|
245
|
+
when 'Arel::Nodes::NotEqual' then binary(:not_eq, node)
|
|
246
|
+
when 'Arel::Nodes::HomogeneousIn' then homogeneous_in(node)
|
|
247
|
+
when 'Arel::Nodes::In' then list(:in, node.left, values(node.right))
|
|
248
|
+
when 'Arel::Nodes::NotIn' then list(:not_in, node.left, values(node.right))
|
|
249
|
+
else raise Unreadable
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
#: (Symbol, untyped) -> Array[untyped]
|
|
254
|
+
def binary(operator, node)
|
|
255
|
+
[operator, column(node.left), value(node.right)]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
#: (untyped) -> Array[untyped]
|
|
259
|
+
def homogeneous_in(node)
|
|
260
|
+
list(node.type == :in ? :in : :not_in, node.attribute, node.casted_values)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Active Record writes an empty NOT IN as a condition that is always
|
|
264
|
+
# true, and a NOT IN list holding NULL matches no row.
|
|
265
|
+
#: (Symbol, untyped, Array[untyped]) -> Array[untyped]
|
|
266
|
+
def list(operator, attribute, expected)
|
|
267
|
+
name = column(attribute)
|
|
268
|
+
return [operator, name, expected] if operator == :in
|
|
269
|
+
return [:and, []] if expected.empty?
|
|
270
|
+
|
|
271
|
+
expected.include?(nil) ? [:or, []] : [operator, name, expected]
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Ruby comparison stands in for SQL only on the record's own column of
|
|
275
|
+
# a type both compare alike.
|
|
276
|
+
#: (untyped) -> String
|
|
277
|
+
def column(attribute)
|
|
278
|
+
raise Unreadable unless ATTRIBUTE.eql?(attribute.class.name)
|
|
279
|
+
|
|
280
|
+
table = attribute.relation
|
|
281
|
+
raise Unreadable unless table.name == @model_class.table_name && table.table_alias.nil?
|
|
282
|
+
|
|
283
|
+
name = attribute.name
|
|
284
|
+
raise Unreadable unless comparable?(name)
|
|
285
|
+
|
|
286
|
+
name
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
#: (String) -> bool
|
|
290
|
+
def comparable?(name)
|
|
291
|
+
column = @model_class.columns_hash[name]
|
|
292
|
+
kinds = value_type(@model_class.type_for_attribute(name)).class.ancestors.map(&:name)
|
|
293
|
+
return false unless column && VALUE_TYPES.intersect?(kinds)
|
|
294
|
+
return true unless kinds.include?(TEXT_TYPE)
|
|
295
|
+
|
|
296
|
+
EXACT_TEXT_ADAPTERS.include?(@model_class.connection_db_config.adapter) &&
|
|
297
|
+
column.collation.nil?
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# An enum compares the values it stores, which its subtype describes. A
|
|
301
|
+
# wrapper such as a serialized or encrypted type is none of these.
|
|
302
|
+
#: (untyped) -> untyped
|
|
303
|
+
def value_type(type)
|
|
304
|
+
ENUM_TYPE.eql?(type.class.name) ? type.subtype : type
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# A list that is not plain values, such as a subquery, is unreadable.
|
|
308
|
+
#: (untyped) -> Array[untyped]
|
|
309
|
+
def values(nodes)
|
|
310
|
+
raise Unreadable unless nodes.instance_of?(Array)
|
|
311
|
+
|
|
312
|
+
nodes.map { |node| value(node) }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# A value out of the column's range makes Active Record write a
|
|
316
|
+
# constant condition instead, so it is left unread. Active Record
|
|
317
|
+
# writes IS NULL for a value that is nil before casting, but `= NULL`,
|
|
318
|
+
# which matches nothing, for one that only casts to nil.
|
|
319
|
+
#: (untyped) -> untyped
|
|
320
|
+
def value(node)
|
|
321
|
+
node = node.value if BIND_PARAM.eql?(node.class.name)
|
|
322
|
+
raise Unreadable unless node.respond_to?(:value_for_database)
|
|
323
|
+
raise Unreadable if node.respond_to?(:unboundable?) && node.unboundable?
|
|
324
|
+
return if node.nil?
|
|
325
|
+
|
|
326
|
+
node.value_for_database.tap { |value| raise Unreadable if value.nil? }
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
private_constant :Reader
|
|
330
|
+
end
|
|
331
|
+
end
|