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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +287 -0
  3. data/QUICKSTART.md +9 -6
  4. data/README.md +84 -9
  5. data/docs/architecture.md +32 -9
  6. data/docs/performance.md +106 -13
  7. data/docs/reference.md +242 -29
  8. data/docs/testing.md +34 -11
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +19 -9
  10. data/lib/paper_trail_diff/activity_collection_event_applier.rb +11 -1
  11. data/lib/paper_trail_diff/activity_collection_record_updater.rb +16 -2
  12. data/lib/paper_trail_diff/activity_event_record_resolver.rb +20 -1
  13. data/lib/paper_trail_diff/activity_event_route_finder.rb +2 -1
  14. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +1 -1
  15. data/lib/paper_trail_diff/activity_range.rb +14 -2
  16. data/lib/paper_trail_diff/activity_relationship.rb +12 -0
  17. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +5 -2
  18. data/lib/paper_trail_diff/activity_target_candidate_loader.rb +131 -0
  19. data/lib/paper_trail_diff/activity_timeline_builder.rb +30 -24
  20. data/lib/paper_trail_diff/activity_version_collector.rb +31 -20
  21. data/lib/paper_trail_diff/association_activity_edge.rb +236 -0
  22. data/lib/paper_trail_diff/association_activity_selection.rb +197 -0
  23. data/lib/paper_trail_diff/association_discovery.rb +8 -13
  24. data/lib/paper_trail_diff/association_traversal.rb +169 -45
  25. data/lib/paper_trail_diff/batch_safety.rb +23 -15
  26. data/lib/paper_trail_diff/batched_root_versions.rb +21 -39
  27. data/lib/paper_trail_diff/comparison_batch.rb +37 -10
  28. data/lib/paper_trail_diff/deleted_analysis_batch.rb +34 -16
  29. data/lib/paper_trail_diff/diagnostics.rb +26 -8
  30. data/lib/paper_trail_diff/direct_record_state_loader.rb +10 -1
  31. data/lib/paper_trail_diff/errors.rb +2 -1
  32. data/lib/paper_trail_diff/habtm_transaction_owners.rb +68 -0
  33. data/lib/paper_trail_diff/historical_association_reifier.rb +32 -2
  34. data/lib/paper_trail_diff/historical_snapshot_store.rb +6 -9
  35. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +35 -2
  36. data/lib/paper_trail_diff/live_graph_collector.rb +1 -1
  37. data/lib/paper_trail_diff/paper_trail_adapter.rb +22 -6
  38. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +1 -1
  39. data/lib/paper_trail_diff/prepared_activity_index.rb +110 -36
  40. data/lib/paper_trail_diff/prepared_edge_loader.rb +29 -45
  41. data/lib/paper_trail_diff/prepared_history.rb +63 -54
  42. data/lib/paper_trail_diff/prepared_history_loader.rb +33 -43
  43. data/lib/paper_trail_diff/prepared_record_index.rb +26 -0
  44. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +1 -1
  45. data/lib/paper_trail_diff/prepared_state_association_reader.rb +119 -29
  46. data/lib/paper_trail_diff/recorded_changes.rb +83 -0
  47. data/lib/paper_trail_diff/recorded_class.rb +53 -0
  48. data/lib/paper_trail_diff/scope_condition.rb +331 -0
  49. data/lib/paper_trail_diff/scope_ranking.rb +196 -0
  50. data/lib/paper_trail_diff/scoped_analysis_builder.rb +4 -3
  51. data/lib/paper_trail_diff/scoped_root_selection.rb +93 -43
  52. data/lib/paper_trail_diff/snapshot_normalizer.rb +10 -3
  53. data/lib/paper_trail_diff/support.rb +9 -4
  54. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -1
  55. data/lib/paper_trail_diff/time_range.rb +9 -12
  56. data/lib/paper_trail_diff/time_version_range.rb +5 -5
  57. data/lib/paper_trail_diff/version.rb +1 -1
  58. data/lib/paper_trail_diff/version_association_candidate_scope.rb +13 -3
  59. data/lib/paper_trail_diff/version_range.rb +8 -15
  60. data/lib/paper_trail_diff/version_scope_filter.rb +11 -3
  61. data/lib/paper_trail_diff.rb +31 -2
  62. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +10 -2
  63. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +5 -0
  64. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +5 -0
  65. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +7 -0
  66. data/sig/generated/paper_trail_diff/activity_range.rbs +6 -2
  67. data/sig/generated/paper_trail_diff/activity_relationship.rbs +3 -0
  68. data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +2 -0
  69. data/sig/generated/paper_trail_diff/activity_target_candidate_loader.rbs +79 -0
  70. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +11 -5
  71. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +10 -4
  72. data/sig/generated/paper_trail_diff/association_activity_edge.rbs +148 -0
  73. data/sig/generated/paper_trail_diff/association_activity_selection.rbs +112 -0
  74. data/sig/generated/paper_trail_diff/association_discovery.rbs +3 -5
  75. data/sig/generated/paper_trail_diff/association_traversal.rbs +84 -9
  76. data/sig/generated/paper_trail_diff/batch_safety.rbs +10 -4
  77. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +2 -8
  78. data/sig/generated/paper_trail_diff/comparison_batch.rbs +22 -4
  79. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +16 -4
  80. data/sig/generated/paper_trail_diff/diagnostics.rbs +7 -2
  81. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +6 -0
  82. data/sig/generated/paper_trail_diff/errors.rbs +2 -1
  83. data/sig/generated/paper_trail_diff/habtm_transaction_owners.rbs +40 -0
  84. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +13 -0
  85. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +3 -2
  86. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +17 -0
  87. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +5 -0
  88. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +52 -10
  89. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +4 -12
  90. data/sig/generated/paper_trail_diff/prepared_history.rbs +33 -22
  91. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +15 -13
  92. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +15 -0
  93. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +56 -11
  94. data/sig/generated/paper_trail_diff/recorded_changes.rbs +47 -0
  95. data/sig/generated/paper_trail_diff/recorded_class.rbs +22 -0
  96. data/sig/generated/paper_trail_diff/scope_condition.rbs +185 -0
  97. data/sig/generated/paper_trail_diff/scope_ranking.rbs +117 -0
  98. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +2 -2
  99. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +41 -7
  100. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +6 -0
  101. data/sig/generated/paper_trail_diff/support.rbs +5 -2
  102. data/sig/generated/paper_trail_diff/time_range.rbs +4 -0
  103. data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -4
  104. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +7 -0
  105. data/sig/generated/paper_trail_diff/version_range.rbs +4 -2
  106. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -0
  107. data/sig/generated/paper_trail_diff.rbs +21 -2
  108. metadata +20 -4
@@ -2,71 +2,161 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- # Reads plain direct edges without creating AR records for their members.
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
- #: (PreparedRecordState, untyped, untyped) -> [bool, Array[PreparedRecordState]]
14
- def call(owner, reflection, boundary)
15
- return [false, []] unless supported?(reflection)
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
- if reflection.belongs_to?
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
- #: (untyped) -> bool
33
- def supported?(reflection)
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, untyped) -> [bool, Array[PreparedRecordState]]
40
- def children(owner, reflection, boundary)
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, reflection.collection? ? selected : selected.first(1)]
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(id, empty) : group.fetch(:ids)
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
- owner_id = owner.attributes[owner.model_class.primary_key].to_s
64
- return false unless child.attributes[reflection.foreign_key.to_s].to_s == owner_id
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