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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +27 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +447 -0
  6. data/Rakefile +12 -0
  7. data/lib/generators/grain/install/install_generator.rb +78 -0
  8. data/lib/generators/grain/install/templates/create_grain_change_log.rb.erb +22 -0
  9. data/lib/generators/grain/install/templates/initializer.rb.erb +17 -0
  10. data/lib/generators/grain/rollup/rollup_generator.rb +39 -0
  11. data/lib/generators/grain/rollup/templates/rollup.rb.erb +40 -0
  12. data/lib/generators/grain/table/table_generator.rb +111 -0
  13. data/lib/generators/grain/table/templates/create_rollup_table.rb.erb +20 -0
  14. data/lib/grain/backfill.rb +77 -0
  15. data/lib/grain/cells.rb +66 -0
  16. data/lib/grain/change_log.rb +100 -0
  17. data/lib/grain/configuration.rb +34 -0
  18. data/lib/grain/definition.rb +140 -0
  19. data/lib/grain/definition_validator.rb +58 -0
  20. data/lib/grain/dimension.rb +62 -0
  21. data/lib/grain/discrepancy.rb +48 -0
  22. data/lib/grain/errors.rb +25 -0
  23. data/lib/grain/fact.rb +46 -0
  24. data/lib/grain/join_graph.rb +90 -0
  25. data/lib/grain/measure.rb +100 -0
  26. data/lib/grain/migration.rb +93 -0
  27. data/lib/grain/path.rb +93 -0
  28. data/lib/grain/projection.rb +116 -0
  29. data/lib/grain/query.rb +147 -0
  30. data/lib/grain/query_sql.rb +100 -0
  31. data/lib/grain/railtie.rb +21 -0
  32. data/lib/grain/ratio.rb +28 -0
  33. data/lib/grain/recompute.rb +101 -0
  34. data/lib/grain/registry.rb +84 -0
  35. data/lib/grain/rollup.rb +90 -0
  36. data/lib/grain/rollup_lookup.rb +41 -0
  37. data/lib/grain/schema.rb +63 -0
  38. data/lib/grain/triggers.rb +99 -0
  39. data/lib/grain/type_resolver.rb +83 -0
  40. data/lib/grain/verification.rb +73 -0
  41. data/lib/grain/verification_query.rb +134 -0
  42. data/lib/grain/verification_report.rb +48 -0
  43. data/lib/grain/version.rb +5 -0
  44. data/lib/grain/watched_columns.rb +70 -0
  45. data/lib/grain/worker.rb +109 -0
  46. data/lib/grain.rb +60 -0
  47. data/lib/tasks/grain.rake +33 -0
  48. data/sig/grain.rbs +4 -0
  49. metadata +125 -0
@@ -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
@@ -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
@@ -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
@@ -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