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_toits parent and a thinbulk_record!(parent:, …)that builds
row hashes and delegates the upsert to DataPointMetrics.upsert_data_points!
(naming its own foreign key)- optionally an
INVERTED_METRICSconstant 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
-
#metric_type ⇒ String
Name of the metric this point samples.
-
#period ⇒ Range<Date>
Daterange the sample covers.
-
#value ⇒ BigDecimal
Sampled value for the metric.
Class Method Summary collapse
-
.by_period_start ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are by period start.
-
.by_recorded ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are by recorded.
-
.containing_date ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are containing date.
-
.for_metric ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are for metric.
-
.for_reference ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are for reference.
-
.inverted_metrics ⇒ Array<String>
Metrics where a lower value is better.
-
.latest ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are latest.
-
.latest_values ⇒ Hash{String => BigDecimal}
Latest value per metric type in one query.
-
.overlapping ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are overlapping.
-
.period_comparison(metric_type) ⇒ Hash?
Compares the two most recent data points for a metric.
-
.recent ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are recent.
-
.trend(metric_type, months: 6) ⇒ Array<Hash>
Time series for a metric, oldest → newest.
-
.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".
-
.upsert_data_points!(foreign_key:, rows:) ⇒ void
Idempotent bulk upsert of prebuilt row hashes onto this model's table.
Instance Method Summary collapse
-
#inverted_metric? ⇒ Boolean
Whether lower values are better for this metric.
-
#period_days ⇒ Integer?
Number of days the period spans.
-
#period_end ⇒ Date?
Exclusive end of the covered period.
-
#period_start ⇒ Date?
Inclusive start of the covered period.
Instance Attribute Details
#metric_type ⇒ String
Returns name of the metric this point samples.
27 |
# File 'app/concerns/models/data_point_metrics.rb', line 27 validates :metric_type, presence: true |
#period ⇒ Range<Date>
Returns daterange the sample covers.
33 |
# File 'app/concerns/models/data_point_metrics.rb', line 33 validates :period, presence: true |
#value ⇒ BigDecimal
Returns 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_start ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are by period start. Active Record Scope
45 |
# File 'app/concerns/models/data_point_metrics.rb', line 45 scope :by_period_start, -> { order(Arel.sql('lower(period) ASC')) } |
.by_recorded ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are by recorded. Active Record Scope
44 |
# File 'app/concerns/models/data_point_metrics.rb', line 44 scope :by_recorded, -> { order(recorded_at: :desc) } |
.containing_date ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are containing date. Active Record Scope
46 |
# File 'app/concerns/models/data_point_metrics.rb', line 46 scope :containing_date, ->(date) { where('period @> ?::date', date) } |
.for_metric ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are for metric. Active Record Scope
36 |
# File 'app/concerns/models/data_point_metrics.rb', line 36 scope :for_metric, ->(type) { where(metric_type: type) } |
.for_reference ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are for reference. Active Record Scope
38 |
# File 'app/concerns/models/data_point_metrics.rb', line 38 scope :for_reference, ->(ref) { where(reference: ref) } |
.inverted_metrics ⇒ Array<String>
Metrics where a lower value is better. Overridden per model via an
INVERTED_METRICS constant.
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 |
.latest ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are latest. Active Record Scope
53 |
# File 'app/concerns/models/data_point_metrics.rb', line 53 scope :latest, -> { by_recorded.limit(1) } |
.latest_values ⇒ Hash{String => BigDecimal}
Latest value per metric type in one query.
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 |
.overlapping ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are overlapping. Active Record Scope
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.
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 |
.recent ⇒ ActiveRecord::Relation<Models::DataPointMetrics>
A relation of Models::DataPointMetrics that are recent. Active Record Scope
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.
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".
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.
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.
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_days ⇒ Integer?
Returns 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_end ⇒ Date?
Returns 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_start ⇒ Date?
Returns 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 |