grain 0.0.1
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 +7 -0
- data/CHANGELOG.md +27 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +447 -0
- data/Rakefile +12 -0
- data/lib/generators/grain/install/install_generator.rb +78 -0
- data/lib/generators/grain/install/templates/create_grain_change_log.rb.erb +22 -0
- data/lib/generators/grain/install/templates/initializer.rb.erb +17 -0
- data/lib/generators/grain/rollup/rollup_generator.rb +39 -0
- data/lib/generators/grain/rollup/templates/rollup.rb.erb +40 -0
- data/lib/generators/grain/table/table_generator.rb +111 -0
- data/lib/generators/grain/table/templates/create_rollup_table.rb.erb +20 -0
- data/lib/grain/backfill.rb +77 -0
- data/lib/grain/cells.rb +66 -0
- data/lib/grain/change_log.rb +100 -0
- data/lib/grain/configuration.rb +34 -0
- data/lib/grain/definition.rb +140 -0
- data/lib/grain/definition_validator.rb +58 -0
- data/lib/grain/dimension.rb +62 -0
- data/lib/grain/discrepancy.rb +48 -0
- data/lib/grain/errors.rb +25 -0
- data/lib/grain/fact.rb +46 -0
- data/lib/grain/join_graph.rb +90 -0
- data/lib/grain/measure.rb +100 -0
- data/lib/grain/migration.rb +93 -0
- data/lib/grain/path.rb +93 -0
- data/lib/grain/projection.rb +116 -0
- data/lib/grain/query.rb +147 -0
- data/lib/grain/query_sql.rb +100 -0
- data/lib/grain/railtie.rb +21 -0
- data/lib/grain/ratio.rb +28 -0
- data/lib/grain/recompute.rb +101 -0
- data/lib/grain/registry.rb +84 -0
- data/lib/grain/rollup.rb +90 -0
- data/lib/grain/rollup_lookup.rb +41 -0
- data/lib/grain/schema.rb +63 -0
- data/lib/grain/triggers.rb +99 -0
- data/lib/grain/type_resolver.rb +83 -0
- data/lib/grain/verification.rb +73 -0
- data/lib/grain/verification_query.rb +134 -0
- data/lib/grain/verification_report.rb +48 -0
- data/lib/grain/version.rb +5 -0
- data/lib/grain/watched_columns.rb +70 -0
- data/lib/grain/worker.rb +109 -0
- data/lib/grain.rb +60 -0
- data/lib/tasks/grain.rake +33 -0
- data/sig/grain.rbs +4 -0
- metadata +125 -0
data/lib/grain/query.rb
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Reads a rollup.
|
|
5
|
+
#
|
|
6
|
+
# OrderRevenueRollup.for(store: current_store)
|
|
7
|
+
# .between(1.month.ago, Date.current)
|
|
8
|
+
# .by(:product_id)
|
|
9
|
+
# .revenue_cents
|
|
10
|
+
#
|
|
11
|
+
# Narrowing returns a new query rather than changing this one, so a base query
|
|
12
|
+
# can be handed around and reused.
|
|
13
|
+
class Query
|
|
14
|
+
attr_reader :rollup, :definition
|
|
15
|
+
|
|
16
|
+
def initialize(rollup, filters: {}, range: nil, groups: {})
|
|
17
|
+
@rollup = rollup
|
|
18
|
+
@definition = rollup.definition.validate!
|
|
19
|
+
@filters = filters
|
|
20
|
+
@range = range
|
|
21
|
+
@groups = groups
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Filters on any dimension. A value may be an id, an ActiveRecord object, an
|
|
25
|
+
# array, or nil.
|
|
26
|
+
#
|
|
27
|
+
# for(store: current_store) # same as for(store_id: current_store.id)
|
|
28
|
+
# for(product_id: [1, 2, 3])
|
|
29
|
+
def for(**filters)
|
|
30
|
+
merge(filters: @filters.merge(normalise(filters)))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def between(from, to = nil)
|
|
34
|
+
raise Error, "#{rollup} has no time dimension to range over" unless definition.temporal?
|
|
35
|
+
|
|
36
|
+
merge(range: to.nil? ? from : (from..to))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Groups by dimensions, optionally coarsening the time bucket.
|
|
40
|
+
#
|
|
41
|
+
# by(:product_id)
|
|
42
|
+
# by(ordered_on: :month)
|
|
43
|
+
def by(*names, **coarse)
|
|
44
|
+
groups = names.to_h { |name| [dimension_name(name), nil] }
|
|
45
|
+
.merge(coarse.transform_keys { |key| dimension_name(key) })
|
|
46
|
+
merge(groups: @groups.merge(groups))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def sql
|
|
50
|
+
QuerySql.new(definition, filters: @filters, range: @range, groups: @groups).to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Every measure and ratio at once, one row per group.
|
|
54
|
+
def rows
|
|
55
|
+
@rows ||= typed_rows.map { |row| with_ratios(row) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Keyed by group value, or the single row when nothing is grouped.
|
|
59
|
+
def to_h
|
|
60
|
+
return rows.first || empty_row if @groups.empty?
|
|
61
|
+
|
|
62
|
+
rows.to_h { |row| [group_key(row), row] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def value(name)
|
|
66
|
+
name = name.to_sym
|
|
67
|
+
return rows.to_h { |row| [group_key(row), row[name]] } unless @groups.empty?
|
|
68
|
+
|
|
69
|
+
(rows.first || empty_row)[name]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def respond_to_missing?(name, include_private = false)
|
|
73
|
+
readable?(name) || super
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def method_missing(name, *args)
|
|
77
|
+
return value(name) if readable?(name) && args.empty?
|
|
78
|
+
|
|
79
|
+
super
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def merge(**changes)
|
|
85
|
+
self.class.new(rollup, filters: @filters, range: @range, groups: @groups, **changes)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def readable?(name)
|
|
89
|
+
(definition.measures.map(&:name) + definition.ratios.map(&:name)).include?(name.to_sym)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalise(filters)
|
|
93
|
+
filters.to_h { |key, value| [dimension_name(key), identify(value)] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Accepts a dimension's own name, or an association-shaped one: `store` finds
|
|
97
|
+
# `store_id` when that is what the rollup is keyed on.
|
|
98
|
+
def dimension_name(key)
|
|
99
|
+
key = key.to_sym
|
|
100
|
+
names = definition.key_dimensions.map(&:name)
|
|
101
|
+
return key if names.include?(key)
|
|
102
|
+
|
|
103
|
+
suffixed = :"#{key}_id"
|
|
104
|
+
return suffixed if names.include?(suffixed)
|
|
105
|
+
|
|
106
|
+
raise Error, "#{rollup} has no dimension #{key.inspect}; it has #{names.inspect}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def identify(value)
|
|
110
|
+
return value.map { |item| identify(item) } if value.is_a?(Array)
|
|
111
|
+
|
|
112
|
+
value.respond_to?(:id) && !value.is_a?(Numeric) ? value.id : value
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# to_a hands back the driver's strings — a date arrives as "2026-08-10" —
|
|
116
|
+
# while the result object carries the adapter's own type map. Zipping the
|
|
117
|
+
# columns against cast_values applies it, so callers get Dates and Integers
|
|
118
|
+
# rather than having to parse what came out of a dashboard query.
|
|
119
|
+
def typed_rows
|
|
120
|
+
result = ActiveRecord::Base.connection.select_all(sql)
|
|
121
|
+
names = result.columns.map(&:to_sym)
|
|
122
|
+
values = result.columns.one? ? result.cast_values.map { |value| [value] } : result.cast_values
|
|
123
|
+
values.map { |row| names.zip(row).to_h }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Ratios are never stored. They are divided here from two measures that are,
|
|
127
|
+
# so a rate is correct at whatever grain it is read at instead of frozen at
|
|
128
|
+
# the one it was computed for. A rate over nothing is nil rather than zero:
|
|
129
|
+
# there is no rate, which is not the same as a rate of none.
|
|
130
|
+
def with_ratios(row)
|
|
131
|
+
definition.ratios.each_with_object(row) do |ratio, result|
|
|
132
|
+
denominator = result[ratio.denominator].to_f
|
|
133
|
+
result[ratio.name] = denominator.zero? ? nil : result[ratio.numerator] / denominator
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def empty_row
|
|
138
|
+
definition.measures.to_h { |measure| [measure.name, measure.coarsens_with == :sum ? 0 : nil] }
|
|
139
|
+
.merge(definition.ratios.to_h { |ratio| [ratio.name, nil] })
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def group_key(row)
|
|
143
|
+
keys = @groups.keys.map { |name| row[name] }
|
|
144
|
+
keys.length == 1 ? keys.first : keys
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Builds the statement a read runs.
|
|
5
|
+
#
|
|
6
|
+
# Any dimension left out of the grouping is aggregated away, which is the
|
|
7
|
+
# property the whole design rests on: a day rolls up into a month by addition,
|
|
8
|
+
# so one stored grain answers questions at every coarser one.
|
|
9
|
+
class QuerySql
|
|
10
|
+
COARSER = %i[day week month quarter year].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :definition
|
|
13
|
+
|
|
14
|
+
def initialize(definition, filters: {}, range: nil, groups: {})
|
|
15
|
+
@definition = definition
|
|
16
|
+
@filters = filters
|
|
17
|
+
@range = range
|
|
18
|
+
@groups = groups
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_s
|
|
22
|
+
<<~SQL.strip
|
|
23
|
+
SELECT #{selection.join(", ")}
|
|
24
|
+
FROM #{definition.table_name}#{where_clause}#{group_clause}#{order_clause}
|
|
25
|
+
SQL
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def selection
|
|
31
|
+
@groups.map { |name, coarse| "#{group_expression(name, coarse)} AS #{quote_column(name)}" } +
|
|
32
|
+
definition.measures.map { |measure| "#{measure_expression(measure)} AS #{measure.name}" }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A sum over nothing is zero, so it is coalesced. An extreme over nothing is
|
|
36
|
+
# left null on purpose: there is no largest value, which is not the same as a
|
|
37
|
+
# largest value of zero.
|
|
38
|
+
#
|
|
39
|
+
# Cast back to the measure's declared type for the same reason the write side
|
|
40
|
+
# does: SUM over a bigint yields numeric, and without this a column stored as
|
|
41
|
+
# an integer would read back as a BigDecimal.
|
|
42
|
+
def measure_expression(measure)
|
|
43
|
+
aggregate = "#{measure.coarsens_with.to_s.upcase}(#{quote_column(measure.name)})"
|
|
44
|
+
aggregate = "COALESCE(#{aggregate}, 0)" if measure.coarsens_with == :sum
|
|
45
|
+
"#{aggregate}::#{connection.type_to_sql(measure.type)}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def group_expression(name, coarse)
|
|
49
|
+
column = quote_column(name)
|
|
50
|
+
return column if coarse.nil?
|
|
51
|
+
raise Error, "unknown grain #{coarse.inspect}, expected one of #{COARSER.inspect}" unless COARSER.include?(coarse)
|
|
52
|
+
|
|
53
|
+
"(DATE_TRUNC('#{coarse}', #{column})::date)"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def where_clause
|
|
57
|
+
conditions = @filters.map { |name, value| condition_for(name, value) } + [range_condition].compact
|
|
58
|
+
conditions.empty? ? "" : "\nWHERE #{conditions.join(" AND ")}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def condition_for(name, value)
|
|
62
|
+
column = quote_column(name)
|
|
63
|
+
case value
|
|
64
|
+
when nil then "#{column} IS NULL"
|
|
65
|
+
when Array then "#{column} IN (#{value.map { |item| quote(item) }.join(", ")})"
|
|
66
|
+
else "#{column} = #{quote(value)}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def range_condition
|
|
71
|
+
return nil if @range.nil?
|
|
72
|
+
|
|
73
|
+
"#{quote_column(definition.time.name)} BETWEEN #{quote(@range.first)} AND #{quote(@range.last)}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def group_clause
|
|
77
|
+
@groups.empty? ? "" : "\nGROUP BY #{group_positions}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def order_clause
|
|
81
|
+
@groups.empty? ? "" : "\nORDER BY #{group_positions}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def group_positions
|
|
85
|
+
(1..@groups.length).to_a.join(", ")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def quote_column(name)
|
|
89
|
+
connection.quote_column_name(name)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def quote(value)
|
|
93
|
+
connection.quote(value)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def connection
|
|
97
|
+
ActiveRecord::Base.connection
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Grain
|
|
6
|
+
# Hooks Grain into a Rails application: rake tasks, autoloading of
|
|
7
|
+
# app/rollups, and the default logger.
|
|
8
|
+
class Railtie < ::Rails::Railtie
|
|
9
|
+
initializer "grain.logger" do
|
|
10
|
+
Grain.config.logger ||= Rails.logger
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
rake_tasks do
|
|
14
|
+
load File.expand_path("../tasks/grain.rake", __dir__)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer "grain.autoload_rollups" do |app|
|
|
18
|
+
app.config.autoload_paths << app.root.join("app/rollups") if app.root.join("app/rollups").exist?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/grain/ratio.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# A derived value kept as its two parts and divided on read.
|
|
5
|
+
#
|
|
6
|
+
# Ratios are never stored pre-divided: averaging averages is wrong, and a
|
|
7
|
+
# stored rate cannot be rolled up from day to month. Numerator and denominator
|
|
8
|
+
# both sum, so their quotient stays correct at every grain.
|
|
9
|
+
class Ratio
|
|
10
|
+
attr_reader :name, :numerator, :denominator
|
|
11
|
+
|
|
12
|
+
def initialize(name:, numerator:, denominator:)
|
|
13
|
+
@name = name.to_sym
|
|
14
|
+
@numerator = numerator.to_sym
|
|
15
|
+
@denominator = denominator.to_sym
|
|
16
|
+
validate!
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def validate!
|
|
23
|
+
return unless numerator == denominator
|
|
24
|
+
|
|
25
|
+
raise InvalidDefinitionError, "ratio #{name} divides #{numerator.inspect} by itself"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Rebuilds cells from the source: the primitive everything else falls back to,
|
|
5
|
+
# and the only operation that is correct no matter what happened.
|
|
6
|
+
#
|
|
7
|
+
# Delete and then re-insert rather than upsert, because a cell can legitimately
|
|
8
|
+
# become empty. An upsert would leave the old numbers standing when the last
|
|
9
|
+
# source row for a cell goes away.
|
|
10
|
+
#
|
|
11
|
+
# Being complete rather than incremental has a useful consequence: a recompute
|
|
12
|
+
# cannot be half-applied and cannot be applied out of order, so a backfill and
|
|
13
|
+
# the worker can run at the same time without coordinating.
|
|
14
|
+
class Recompute
|
|
15
|
+
attr_reader :definition, :projection
|
|
16
|
+
|
|
17
|
+
def initialize(definition)
|
|
18
|
+
@definition = definition
|
|
19
|
+
@projection = Projection.new(definition)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Rebuilds a known list of cells.
|
|
23
|
+
def call(cells)
|
|
24
|
+
cells = cells.uniq
|
|
25
|
+
return 0 if cells.empty?
|
|
26
|
+
|
|
27
|
+
rebuild(matches(cells) { |key| quote_column(key) }, matches(cells) { |key| expression_for(key) })
|
|
28
|
+
cells.length
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Rebuilds every cell in a slice, for when the cells are not known in advance
|
|
32
|
+
# and all that is known is which slice has to be right — a backfill working
|
|
33
|
+
# through one day or one tenant at a time.
|
|
34
|
+
def call_slice(dimension, value)
|
|
35
|
+
rebuild(
|
|
36
|
+
"#{quote_column(dimension.name)} IS NOT DISTINCT FROM #{quote(value)}",
|
|
37
|
+
"#{expression_for(dimension.name)} IS NOT DISTINCT FROM #{quote(value)}"
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def rebuild(stored_where, source_where)
|
|
44
|
+
connection.execute("DELETE FROM #{table_name} WHERE #{stored_where}")
|
|
45
|
+
connection.execute(insert_sql(source_where))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def insert_sql(source_where)
|
|
49
|
+
<<~SQL
|
|
50
|
+
INSERT INTO #{table_name} (#{columns.join(", ")})
|
|
51
|
+
SELECT #{expressions.join(", ")}
|
|
52
|
+
FROM #{projection.from_and_joins.join(" ")}
|
|
53
|
+
WHERE #{(projection.filter_conditions + [source_where]).join(" AND ")}
|
|
54
|
+
GROUP BY #{projection.dimension_expressions.join(", ")}
|
|
55
|
+
SQL
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def table_name
|
|
59
|
+
definition.table_name
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def columns
|
|
63
|
+
projection.key_columns + projection.measure_columns
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def expressions
|
|
67
|
+
projection.dimension_expressions + projection.aggregate_expressions
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def matches(cells, &naming)
|
|
71
|
+
"(#{cells.map { |cell| match(cell, &naming) }.join(" OR ")})"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# IS NOT DISTINCT FROM rather than =, because a dimension resolved from a
|
|
75
|
+
# nullable column has null as a legitimate coordinate, and null = null is
|
|
76
|
+
# never true.
|
|
77
|
+
def match(cell)
|
|
78
|
+
parts = projection.key_columns.map do |key|
|
|
79
|
+
"#{yield(key)} IS NOT DISTINCT FROM #{quote(cell[key])}"
|
|
80
|
+
end
|
|
81
|
+
"(#{parts.join(" AND ")})"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def expression_for(key)
|
|
85
|
+
@expressions ||= projection.key_columns.zip(projection.dimension_expressions).to_h
|
|
86
|
+
@expressions.fetch(key)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def quote_column(key)
|
|
90
|
+
connection.quote_column_name(key)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def quote(value)
|
|
94
|
+
connection.quote(value)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def connection
|
|
98
|
+
ActiveRecord::Base.connection
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Which rollups exist, and which of them care about a given table.
|
|
5
|
+
#
|
|
6
|
+
# A trigger records only that a row in some table changed, so the worker needs
|
|
7
|
+
# the reverse mapping: table to the rollups that read it, and in what capacity.
|
|
8
|
+
# A table can be a rollup's fact, one of the tables it resolves dimensions
|
|
9
|
+
# through, or both.
|
|
10
|
+
module Registry
|
|
11
|
+
class << self
|
|
12
|
+
# Rollups are found rather than self-registered: with autoloading, a class
|
|
13
|
+
# that nobody has referenced yet does not exist, so a registry populated by
|
|
14
|
+
# `inherited` would be empty in exactly the process that needs it.
|
|
15
|
+
def all
|
|
16
|
+
@all ||= load_rollups
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reset!
|
|
20
|
+
@all = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def register(*rollups)
|
|
24
|
+
(@all ||= []).concat(rollups).uniq!
|
|
25
|
+
all
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Rollups whose fact table this is: a change here is a change to what is
|
|
29
|
+
# being counted.
|
|
30
|
+
def facts_for(table)
|
|
31
|
+
all.select { |rollup| fact_table(rollup) == table }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Rollups that resolve a dimension or a filter through this table: a change
|
|
35
|
+
# here can move fact rows between cells without touching the facts.
|
|
36
|
+
def watchers_for(table)
|
|
37
|
+
all.select { |rollup| watched_tables(rollup).include?(table) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def for_table(table)
|
|
41
|
+
(facts_for(table) + watchers_for(table)).uniq
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def load_rollups
|
|
47
|
+
eager_load_rollups
|
|
48
|
+
Rollup.subclasses.select { |rollup| rollup.name && valid?(rollup) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def eager_load_rollups
|
|
52
|
+
return unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
53
|
+
|
|
54
|
+
Dir[Rails.root.join("app/rollups/**/*.rb")].sort.each { |path| require path }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A rollup that cannot be used is left out rather than allowed to take the
|
|
58
|
+
# whole worker down with it, but never silently: everything else it shares
|
|
59
|
+
# a log with would go stale while the log kept draining.
|
|
60
|
+
def valid?(rollup)
|
|
61
|
+
rollup.definition.validate!
|
|
62
|
+
rollup.definition.fact.model
|
|
63
|
+
true
|
|
64
|
+
rescue InvalidDefinitionError, NameError => e
|
|
65
|
+
warn_about(rollup, e)
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def warn_about(rollup, error)
|
|
70
|
+
message = "Grain is skipping #{rollup}: #{error.message}"
|
|
71
|
+
logger = Grain.config.logger
|
|
72
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fact_table(rollup)
|
|
76
|
+
rollup.definition.fact.model.table_name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def watched_tables(rollup)
|
|
80
|
+
WatchedColumns.new(rollup.definition).to_h.keys
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/grain/rollup.rb
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Base class for rollup definitions. A subclass declares the shape of one
|
|
5
|
+
# pre-aggregated table. Nothing here touches the database.
|
|
6
|
+
#
|
|
7
|
+
# class AssessmentResultRollup < Grain::Rollup
|
|
8
|
+
# fact TestingSectionStudent, where: { user: { role: "student" } }
|
|
9
|
+
#
|
|
10
|
+
# tenant :school_id, via: { testing_section: :school_id }
|
|
11
|
+
# time :assessed_on,
|
|
12
|
+
# via: { testing_section: { assessment_window: :starts_on } },
|
|
13
|
+
# grain: :day
|
|
14
|
+
# dimension :grade_id, via: { testing_section: :grade_id }
|
|
15
|
+
# dimension :window_id, via: { testing_section: :assessment_window_id },
|
|
16
|
+
# immutable: true
|
|
17
|
+
#
|
|
18
|
+
# measure :attempts, count: true
|
|
19
|
+
# measure :passed_count, sum: "CASE WHEN score >= 60 THEN 1 ELSE 0 END"
|
|
20
|
+
# ratio :pass_rate, of: :passed_count, over: :attempts
|
|
21
|
+
# end
|
|
22
|
+
class Rollup
|
|
23
|
+
class << self
|
|
24
|
+
def definition
|
|
25
|
+
@definition ||= Definition.new(self)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fact(model, where: nil)
|
|
29
|
+
definition.declare_fact(model, where: where)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tenant(name, via:)
|
|
33
|
+
definition.add_dimension(name, via: via, role: :tenant)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def time(name, via:, grain:)
|
|
37
|
+
definition.add_dimension(name, via: via, role: :time, grain: grain)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dimension(name, via:, immutable: false)
|
|
41
|
+
definition.add_dimension(name, via: via, immutable: immutable)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def measure(name, **options)
|
|
45
|
+
definition.add_measure(name, options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ratio(name, of:, over:)
|
|
49
|
+
definition.add_ratio(name, numerator: of, denominator: over)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def table_name
|
|
53
|
+
definition.table_name
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate!
|
|
57
|
+
definition.validate!
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def query
|
|
61
|
+
Query.new(self)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Reading entry points. Each returns a query that can be narrowed further.
|
|
65
|
+
def for(**filters)
|
|
66
|
+
query.for(**filters)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def between(from, to = nil)
|
|
70
|
+
query.between(from, to)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def by(*names, **coarse)
|
|
74
|
+
query.by(*names, **coarse)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Populates the rollup from data that already exists. A new rollup is empty
|
|
78
|
+
# until this runs: its triggers only see what happens next.
|
|
79
|
+
def backfill(from: nil, pause: 0, &progress)
|
|
80
|
+
Backfill.new(self, from: from, pause: pause).call(&progress)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Recomputes from the source and reports every cell that disagrees. Pass
|
|
84
|
+
# repair: true to rebuild the ones that do.
|
|
85
|
+
def verify(tenant: nil, between: nil, repair: false)
|
|
86
|
+
Verification.new(self, tenant: tenant, between: between).call(repair: repair)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# Finds a rollup class from the name given on a command line, accepting it with
|
|
5
|
+
# or without the Rollup suffix.
|
|
6
|
+
#
|
|
7
|
+
# Kept out of the generator so the rule is testable on its own: Thor catches the
|
|
8
|
+
# errors a generator raises and prints them rather than letting them out, which
|
|
9
|
+
# makes assertions about failure unreliable at that level.
|
|
10
|
+
module RollupLookup
|
|
11
|
+
class << self
|
|
12
|
+
def candidates(name)
|
|
13
|
+
base = name.to_s.camelize
|
|
14
|
+
[base, "#{base.delete_suffix("Rollup")}Rollup"].uniq
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def find(name)
|
|
18
|
+
candidates(name).filter_map(&:safe_constantize).find { |constant| rollup?(constant) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def find!(name)
|
|
22
|
+
find(name) || raise(RollupNotFoundError, message_for(name))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def rollup?(constant)
|
|
26
|
+
constant.is_a?(Class) && constant < Rollup ? true : false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def message_for(name)
|
|
30
|
+
"Could not find a Grain::Rollup named #{candidates(name).join(" or ")}. " \
|
|
31
|
+
"Run `rails generate grain:rollup #{suggested_argument(name)}` first."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def suggested_argument(name)
|
|
37
|
+
name.to_s.underscore.delete_suffix("_rollup")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/grain/schema.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grain
|
|
4
|
+
# The shape of the physical table a rollup is stored in, derived from its
|
|
5
|
+
# definition: column names, their order, and the primary key.
|
|
6
|
+
#
|
|
7
|
+
# Column types are not here on purpose. A dimension's type has to match the
|
|
8
|
+
# source column it is resolved from, which means reading the database. That
|
|
9
|
+
# belongs to the migration generator; this class stays pure so the shape can be
|
|
10
|
+
# reasoned about and tested without a connection.
|
|
11
|
+
class Schema
|
|
12
|
+
attr_reader :definition
|
|
13
|
+
|
|
14
|
+
def initialize(definition)
|
|
15
|
+
@definition = definition.validate!
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def table_name
|
|
20
|
+
definition.table_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Key columns in a fixed order: tenant, then the time bucket if the rollup
|
|
24
|
+
# has one, then the plain dimensions in declaration order.
|
|
25
|
+
#
|
|
26
|
+
# The order is part of the contract, not an implementation detail. Reads
|
|
27
|
+
# filter on a prefix of it (tenant, or tenant and a date range), and so does
|
|
28
|
+
# every scoped recompute, so both ride the primary key index.
|
|
29
|
+
def key_columns
|
|
30
|
+
definition.key_dimensions.map(&:name)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def measure_columns
|
|
34
|
+
definition.measures.map(&:name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Every column of the rollup table.
|
|
38
|
+
#
|
|
39
|
+
# Ratios are absent by design: they are divided on read from two measures
|
|
40
|
+
# that are stored, so a rate stays correct at every grain instead of being
|
|
41
|
+
# frozen at the one it was computed for.
|
|
42
|
+
def columns
|
|
43
|
+
key_columns + measure_columns
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def primary_key
|
|
47
|
+
key_columns
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# No secondary indexes in the first release, and that is a decision rather
|
|
51
|
+
# than an omission. Every index slows down the writes that keep the rollup
|
|
52
|
+
# fresh, and Grain cannot know an application's read patterns; the primary
|
|
53
|
+
# key already covers the dominant ones. Extra indexes are the application's
|
|
54
|
+
# call, added in its own migration.
|
|
55
|
+
def indexes
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def temporal?
|
|
60
|
+
definition.temporal?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|