Class: PaperTrailDiff::ScopedRootSelection

Inherits:
Object
  • Object
show all
Defined in:
lib/paper_trail_diff/scoped_root_selection.rb,
sig/generated/paper_trail_diff/scoped_root_selection.rbs

Overview

Chooses the roots a batch will analyze from a relation instead of from an array the caller assembled, so a reporting page does not reimplement the root selection this gem already performs.

Two populations are deliberately kept apart, because conflating them would make the report wrong in a way nothing announces:

  • A root whose live row does not satisfy the relation is filtered out. That is what the caller asked for, and it needs no reporting.
  • A root whose live row is gone cannot be filtered at all. The relation's conditions read the live table, and a destroyed root has nothing there to read -- even though its history is intact, and the state it held when it was destroyed may well have satisfied those conditions. Reifying every candidate to find out would cost the batched query plan this class exists to provide.

So the second population is returned by name rather than dropped. A caller that does not care can ignore it; one auditing deletions is told where to look instead of silently coming up short.

Note that a relation filters on current state, not on state during the window. where(status: 'published') selects what is published now, which is not the same set as what was published while the window was open.

Defined Under Namespace

Classes: Result

Instance Method Summary collapse

Constructor Details

#initialize(scope, time_range:, limit:) ⇒ ScopedRootSelection

: (untyped, time_range: TimeRange?, limit: Integer) -> void

Parameters:

  • (Object)
  • time_range: (TimeRange, nil)
  • limit: (Integer)


44
45
46
47
48
# File 'lib/paper_trail_diff/scoped_root_selection.rb', line 44

def initialize(scope, time_range:, limit:)
  @scope = normalize_scope(scope)
  @time_range = time_range
  @limit = validate_limit(limit)
end

Instance Method Details

#base_classObject

: () -> untyped

Returns:

  • (Object)


134
135
136
# File 'lib/paper_trail_diff/scoped_root_selection.rb', line 134

def base_class
  @scope.model.base_class
end

#callResult

: () -> Result

Returns:



51
52
53
54
55
56
57
58
59
60
# File 'lib/paper_trail_diff/scoped_root_selection.rb', line 51

def call
  candidates = versioned_ids
  return Result.new(records: [], unreachable: []) if candidates.empty?

  live = live_ids(candidates)
  Result.new(
    records: selected_records(live),
    unreachable: (candidates - live).map { |id| identity(id) }.freeze
  )
end

#identity(id) ⇒ Array[String]

: (String) -> Array

Parameters:

  • (String)

Returns:

  • (Array[String])


124
125
126
# File 'lib/paper_trail_diff/scoped_root_selection.rb', line 124

def identity(id)
  [item_type, id].freeze
end

#item_typeString

: () -> String

Returns:

  • (String)


129
130
131
# File 'lib/paper_trail_diff/scoped_root_selection.rb', line 129

def item_type
  base_class.name.to_s
end

#live_ids(candidates) ⇒ Array[String]

Which of those candidates still have a row, asked without the relation's conditions so that "filtered out" and "no longer exists" stay separable. : (Array) -> Array

Parameters:

  • (Array[String])

Returns:

  • (Array[String])


105
106
107
# File 'lib/paper_trail_diff/scoped_root_selection.rb', line 105

def live_ids(candidates)
  base_class.unscoped.where(primary_key => candidates).pluck(primary_key).map(&:to_s)
end

#normalize_scope(scope) ⇒ Object

Accepts a model class as readily as a relation: Article and Article.where(...) both name a population, and all is what makes them the same kind of thing. : (untyped) -> untyped

Parameters:

  • (Object)

Returns:

  • (Object)


72
73
74
75
76
77
78
79
80
81
82
# File 'lib/paper_trail_diff/scoped_root_selection.rb', line 72

def normalize_scope(scope)
  unless scope.respond_to?(:all) && scope.respond_to?(:where)
    raise ConfigurationError, 'scope: must be an ActiveRecord relation or model class'
  end

  relation = scope.all
  return relation if Support.versioned?(relation.model)

  raise UnversionedAssociationError,
        "scope: #{relation.model.name} is not versioned, so it has no history to select from"
end

#primary_keyObject

: () -> untyped

Returns:

  • (Object)


139
140
141
# File 'lib/paper_trail_diff/scoped_root_selection.rb', line 139

def primary_key
  @scope.model.primary_key
end

#selected_records(live) ⇒ Array[untyped]

Loading one past the limit is what turns an oversized page into an error rather than a silently truncated report. : (Array) -> Array

Parameters:

  • (Array[String])

Returns:

  • (Array[untyped])


112
113
114
115
116
117
118
119
120
121
# File 'lib/paper_trail_diff/scoped_root_selection.rb', line 112

def selected_records(live)
  return [] if live.empty?

  records = @scope.where(primary_key => live).limit(@limit + 1).to_a
  return records.freeze unless records.length > @limit

  raise BatchLimitExceededError,
        "scope: selected more than #{@limit} roots; narrow the window or the " \
        'relation, or raise limit: to the page size you intend to analyze'
end

#validate_limit(limit) ⇒ Integer

: (Integer) -> Integer

Parameters:

  • (Integer)

Returns:

  • (Integer)


85
86
87
88
89
# File 'lib/paper_trail_diff/scoped_root_selection.rb', line 85

def validate_limit(limit)
  return limit if limit.is_a?(Integer) && limit.positive?

  raise ConfigurationError, 'limit: must be a positive Integer'
end

#version_classObject

: () -> untyped

Returns:

  • (Object)


144
145
146
# File 'lib/paper_trail_diff/scoped_root_selection.rb', line 144

def version_class
  @scope.model.paper_trail.version_class
end

#versioned_idsArray[String]

Every root whose history moved inside the window, destroyed ones included. This is the gem's own notion of "which roots changed", answered from the version table alone so that it does not depend on rows still existing. : () -> Array

Returns:

  • (Array[String])


95
96
97
98
99
100
# File 'lib/paper_trail_diff/scoped_root_selection.rb', line 95

def versioned_ids
  relation = version_class.where(item_type: item_type)
  range = @time_range
  relation = range.scope(relation) if range
  relation.distinct.pluck(:item_id).map(&:to_s).uniq
end