Module: Models::DataPointMetrics

Extended by:
ActiveSupport::Concern
Included in:
CatalogDataPoint, EmailTemplateDataPoint, SiteMapDataPoint, SourceDataPoint
Defined in:
app/concerns/models/data_point_metrics.rb

Overview

Time-series metric storage shared by SiteMapDataPoint (SEO metrics keyed by
site_map_id) and CatalogDataPoint (retailer-compliance counts keyed by
catalog_id). Each row is one (parent, metric_type, period) sample carrying
a decimal value; rows upsert on a functional unique index so re-running a
daily sync is idempotent.

The including model supplies the parent-specific bits:

  • belongs_to its parent and a thin bulk_record!(parent:, …) that builds
    row hashes and delegates the upsert to DataPointMetrics.upsert_data_points!
    (naming its own foreign key)
  • optionally an INVERTED_METRICS constant listing metrics where a lower
    value is better (position, cost, violation counts, …) so
    DataPointMetrics.trend_direction reads the sign correctly; absent → nothing is inverted.

Everything parent-agnostic — the read/trend API, scopes, period helpers, and
the upsert mechanism — lives here.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#metric_typeString

Returns name of the metric this point samples.

Returns:

  • (String)

    name of the metric this point samples



27
# File 'app/concerns/models/data_point_metrics.rb', line 27

validates :metric_type, presence: true

#periodRange<Date>

Returns daterange the sample covers.

Returns:

  • (Range<Date>)

    daterange the sample covers



33
# File 'app/concerns/models/data_point_metrics.rb', line 33

validates :period, presence: true

#valueBigDecimal

Returns sampled value for the metric.

Returns:

  • (BigDecimal)

    sampled value for the metric



30
# File 'app/concerns/models/data_point_metrics.rb', line 30

validates :value, presence: true, numericality: true

Class Method Details

.by_period_startActiveRecord::Relation<Models::DataPointMetrics>

A relation of Models::DataPointMetrics that are by period start. Active Record Scope

Returns:

See Also:



45
# File 'app/concerns/models/data_point_metrics.rb', line 45

scope :by_period_start, -> { order(Arel.sql('lower(period) ASC')) }

.by_recordedActiveRecord::Relation<Models::DataPointMetrics>

A relation of Models::DataPointMetrics that are by recorded. Active Record Scope

Returns:

See Also:



44
# File 'app/concerns/models/data_point_metrics.rb', line 44

scope :by_recorded, -> { order(recorded_at: :desc) }

.containing_dateActiveRecord::Relation<Models::DataPointMetrics>

A relation of Models::DataPointMetrics that are containing date. Active Record Scope

Returns:

See Also:



46
# File 'app/concerns/models/data_point_metrics.rb', line 46

scope :containing_date, ->(date) { where('period @> ?::date', date) }

.for_metricActiveRecord::Relation<Models::DataPointMetrics>

A relation of Models::DataPointMetrics that are for metric. Active Record Scope

Returns:

See Also:



36
# File 'app/concerns/models/data_point_metrics.rb', line 36

scope :for_metric, ->(type) { where(metric_type: type) }

.for_referenceActiveRecord::Relation<Models::DataPointMetrics>

A relation of Models::DataPointMetrics that are for reference. Active Record Scope

Returns:

See Also:



38
# File 'app/concerns/models/data_point_metrics.rb', line 38

scope :for_reference, ->(ref) { where(reference: ref) }

.inverted_metricsArray<String>

Metrics where a lower value is better. Overridden per model via an
INVERTED_METRICS constant.

Returns:

  • (Array<String>)


61
62
63
# File 'app/concerns/models/data_point_metrics.rb', line 61

def inverted_metrics
  const_defined?(:INVERTED_METRICS) ? const_get(:INVERTED_METRICS) : []
end

.latestActiveRecord::Relation<Models::DataPointMetrics>

A relation of Models::DataPointMetrics that are latest. Active Record Scope

Returns:

See Also:



53
# File 'app/concerns/models/data_point_metrics.rb', line 53

scope :latest, -> { by_recorded.limit(1) }

.latest_valuesHash{String => BigDecimal}

Latest value per metric type in one query.

Returns:

  • (Hash{String => BigDecimal})

    metric_type => value



131
132
133
134
135
# File 'app/concerns/models/data_point_metrics.rb', line 131

def latest_values
  select('DISTINCT ON (metric_type) metric_type, value, recorded_at')
    .order(:metric_type, recorded_at: :desc)
    .to_h { |point| [point.metric_type, point.value] }
end

.overlappingActiveRecord::Relation<Models::DataPointMetrics>

A relation of Models::DataPointMetrics that are overlapping. Active Record Scope

Returns:

See Also:



51
# File 'app/concerns/models/data_point_metrics.rb', line 51

scope :overlapping, ->(start_date, end_date) { where("period && daterange(?, ?, '[]')", start_date, end_date) }

.period_comparison(metric_type) ⇒ Hash?

Compares the two most recent data points for a metric.

Parameters:

  • metric_type (Symbol, String)

    the metric to compare

Returns:

  • (Hash, nil)

    { current:, previous:, change_percent: } or nil if
    there are fewer than two points or the previous value is zero



116
117
118
119
120
121
122
123
124
125
126
# File 'app/concerns/models/data_point_metrics.rb', line 116

def period_comparison(metric_type)
  recent_points = for_metric(metric_type).by_recorded.limit(2).pluck(:value)
  return nil if recent_points.size < 2

  current, previous = recent_points
  return nil if previous.zero?

  change_pct = ((current - previous) / previous * 100).round(1)

  { current:, previous:, change_percent: change_pct }
end

.recentActiveRecord::Relation<Models::DataPointMetrics>

A relation of Models::DataPointMetrics that are recent. Active Record Scope

Returns:

See Also:



43
# File 'app/concerns/models/data_point_metrics.rb', line 43

scope :recent, ->(months = 6) { where("period && daterange(?, ?, '[]')", months.months.ago.to_date, Date.current) }

.trend(metric_type, months: 6) ⇒ Array<Hash>

Time series for a metric, oldest → newest.

Parameters:

  • metric_type (Symbol, String)

    the metric to analyze

  • months (Integer) (defaults to: 6)

    how many months of data to include

Returns:

  • (Array<Hash>)

    { recorded_at:, period_start:, period_end:, value: }



70
71
72
73
74
75
76
77
78
# File 'app/concerns/models/data_point_metrics.rb', line 70

def trend(metric_type, months: 6)
  for_metric(metric_type)
    .recent(months)
    .by_period_start
    .pluck(:recorded_at, :period, :value)
    .map do |recorded_at, period, value|
      { recorded_at:, period_start: period.begin, period_end: period.end, value: }
    end
end

.trend_direction(metric_type, months: 3) ⇒ Symbol

Coarse direction of a metric over time, inverting metrics where lower is
better so ":growing" always means "improving".

Parameters:

  • metric_type (Symbol, String)

    the metric to analyze

  • months (Integer) (defaults to: 3)

    window to compare across

Returns:

  • (Symbol)

    :growing, :declining, :stable, or :unknown



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'app/concerns/models/data_point_metrics.rb', line 86

def trend_direction(metric_type, months: 3)
  data = for_metric(metric_type).recent(months).by_period_start.pluck(:value)
  return :unknown if data.size < 2

  midpoint = data.size / 2
  first_half = data.first(midpoint)
  second_half = data.last(midpoint)

  first_avg = first_half.sum.to_f / first_half.size
  second_avg = second_half.sum.to_f / second_half.size

  return :stable if first_avg.zero?

  change_pct = ((second_avg - first_avg) / first_avg * 100)
  change_pct = -change_pct if inverted_metrics.include?(metric_type.to_s)

  if change_pct > 10
    :growing
  elsif change_pct < -10
    :declining
  else
    :stable
  end
end

.upsert_data_points!(foreign_key:, rows:) ⇒ void

This method returns an undefined value.

Idempotent bulk upsert of prebuilt row hashes onto this model's table.
Callers (each model's bulk_record!) name their own parent foreign key
and build the rows; the gnarly ON CONFLICT SQL lives here once. Raw SQL is
required because the unique index is functional (COALESCE(reference, '')),
which upsert_all can't target.

Parameters:

  • foreign_key (Symbol)

    parent FK column, e.g. :site_map_id

  • rows (Array<Hash>)

    each { <foreign_key> =>, metric_type:, value:,
    period:, reference:, source_batch_id:, recorded_at: }



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'app/concerns/models/data_point_metrics.rb', line 147

def upsert_data_points!(foreign_key:, rows:)
  return if rows.empty?

  values = rows.map do |r|
    sanitize_sql_array([
                         '(?, ?, ?, ?::daterange, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)',
                         r[foreign_key], r[:metric_type], r[:value], r[:period],
                         r[:reference], r[:source_batch_id], r[:recorded_at]
                       ])
  end.join(', ')

  lease_connection.execute(<<~SQL.squish)
    INSERT INTO #{table_name}
      (#{foreign_key}, metric_type, value, period, reference, source_batch_id, recorded_at, created_at, updated_at)
    VALUES #{values}
    ON CONFLICT (#{foreign_key}, metric_type, period, COALESCE(reference, ''))
    DO UPDATE SET
      value = EXCLUDED.value,
      source_batch_id = EXCLUDED.source_batch_id,
      recorded_at = EXCLUDED.recorded_at,
      updated_at = CURRENT_TIMESTAMP
  SQL
end

Instance Method Details

#inverted_metric?Boolean

Returns whether lower values are better for this metric.

Returns:

  • (Boolean)

    whether lower values are better for this metric



190
191
192
# File 'app/concerns/models/data_point_metrics.rb', line 190

def inverted_metric?
  self.class.inverted_metrics.include?(metric_type.to_s)
end

#period_daysInteger?

Returns number of days the period spans.

Returns:

  • (Integer, nil)

    number of days the period spans



183
184
185
186
187
# File 'app/concerns/models/data_point_metrics.rb', line 183

def period_days
  return nil unless period

  (period.end - period.begin).to_i + 1
end

#period_endDate?

Returns exclusive end of the covered period.

Returns:

  • (Date, nil)

    exclusive end of the covered period



178
179
180
# File 'app/concerns/models/data_point_metrics.rb', line 178

def period_end
  period&.end
end

#period_startDate?

Returns inclusive start of the covered period.

Returns:

  • (Date, nil)

    inclusive start of the covered period



173
174
175
# File 'app/concerns/models/data_point_metrics.rb', line 173

def period_start
  period&.begin
end