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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Works out which tables need a trigger, which columns of each are worth
5
+ # reacting to, and renders the SQL that attaches them.
6
+ #
7
+ # Built from every rollup that touches a table, not from one at a time. The
8
+ # trigger name is per table because the function it calls is shared, so a
9
+ # migration that considered only its own rollup would narrow a trigger another
10
+ # rollup depends on and leave that one drifting in silence. The column list is
11
+ # therefore the union across all of them.
12
+ class Triggers
13
+ # `update_columns` narrows the UPDATE trigger to the columns that can move a
14
+ # row between cells; nil means every update has to be logged.
15
+ Spec = Struct.new(:table, :update_columns, keyword_init: true) do
16
+ def trigger_name
17
+ "grain_#{table}_changed"
18
+ end
19
+
20
+ def narrowed?
21
+ !update_columns.nil?
22
+ end
23
+ end
24
+
25
+ attr_reader :definitions
26
+
27
+ def initialize(definitions)
28
+ @definitions = Array(definitions).map(&:validate!).uniq
29
+ end
30
+
31
+ # Fact tables first, then the tables reached through them.
32
+ def specs
33
+ fact_specs + related_specs
34
+ end
35
+
36
+ # One statement per entry, so a migration can execute them individually
37
+ # instead of relying on the adapter accepting several at once.
38
+ def up_statements
39
+ specs.flat_map { |spec| [drop_sql(spec), create_sql(spec)] }
40
+ end
41
+
42
+ def down_statements
43
+ specs.map { |spec| drop_sql(spec) }
44
+ end
45
+
46
+ def up
47
+ up_statements.join("\n")
48
+ end
49
+
50
+ def down
51
+ down_statements.join("\n")
52
+ end
53
+
54
+ private
55
+
56
+ # Measures aggregate arbitrary SQL, so which of a fact table's columns feed
57
+ # them cannot be known. Narrowing here risks missing an update and letting a
58
+ # rollup drift, so every update on a fact table is logged. Being a fact for
59
+ # any one rollup is enough to disqualify the table from narrowing.
60
+ def fact_specs
61
+ fact_tables.map { |table| Spec.new(table: table, update_columns: nil) }
62
+ end
63
+
64
+ def fact_tables
65
+ definitions.map { |definition| TypeResolver.new(definition).fact_table }.uniq
66
+ end
67
+
68
+ def related_specs
69
+ union_of_related_columns.reject { |table, _| fact_tables.include?(table) }
70
+ .map { |table, columns| Spec.new(table: table, update_columns: columns.sort) }
71
+ end
72
+
73
+ def union_of_related_columns
74
+ definitions.each_with_object({}) do |definition, union|
75
+ WatchedColumns.new(definition).to_h.each do |table, columns|
76
+ union[table] = ((union[table] || []) + columns).uniq
77
+ end
78
+ end
79
+ end
80
+
81
+ def create_sql(spec)
82
+ <<~SQL.strip
83
+ CREATE TRIGGER #{spec.trigger_name}
84
+ AFTER INSERT OR #{update_clause(spec)} OR DELETE ON #{spec.table}
85
+ FOR EACH ROW EXECUTE FUNCTION #{ChangeLog::FUNCTION_NAME}();
86
+ SQL
87
+ end
88
+
89
+ def update_clause(spec)
90
+ return "UPDATE" unless spec.narrowed?
91
+
92
+ "UPDATE OF #{spec.update_columns.join(", ")}"
93
+ end
94
+
95
+ def drop_sql(spec)
96
+ "DROP TRIGGER IF EXISTS #{spec.trigger_name} ON #{spec.table};"
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Works out the column type of every column of a rollup table by walking each
5
+ # dimension's declared path to the model that owns the source column.
6
+ #
7
+ # This is the one part of the schema layer that needs the application's models,
8
+ # which is why it is separate from Schema: the shape of a rollup can be reasoned
9
+ # about without a database, its types cannot.
10
+ class TypeResolver
11
+ # A time dimension does not store the source timestamp, it stores the bucket
12
+ # the row falls into, so its type comes from the grain rather than the source.
13
+ BUCKET_TYPES = { day: :date }.freeze
14
+
15
+ attr_reader :definition
16
+
17
+ def initialize(definition)
18
+ @definition = definition
19
+ end
20
+
21
+ def dimension_type(dimension)
22
+ return BUCKET_TYPES.fetch(dimension.grain) if dimension.time?
23
+
24
+ source_column(dimension).type
25
+ end
26
+
27
+ # The type of the column a dimension is read from, before any bucketing. A
28
+ # time dimension needs this to know whether the source is already a calendar
29
+ # day or a timestamp that has to be resolved to one.
30
+ def source_type(dimension)
31
+ source_column(dimension).type
32
+ end
33
+
34
+ # Whether the source column can be null, which decides whether the rollup can
35
+ # use a plain composite primary key: Postgres will not accept a null in one.
36
+ def dimension_nullable?(dimension)
37
+ return false if dimension.time?
38
+
39
+ source_column(dimension).null
40
+ end
41
+
42
+ def measure_type(measure)
43
+ measure.type
44
+ end
45
+
46
+ def fact_table
47
+ definition.fact.model.table_name
48
+ end
49
+
50
+ def nullable_dimensions
51
+ definition.key_dimensions.select { |dimension| dimension_nullable?(dimension) }
52
+ end
53
+
54
+ # The validated belongs_to reflection for one hop. Public because working out
55
+ # which columns to watch needs the same walk and the same complaints.
56
+ def reflection!(model, hop, context)
57
+ reflection = model.reflect_on_association(hop)
58
+ raise InvalidDefinitionError, "#{model} has no association #{hop.inspect} (via #{context})" if reflection.nil?
59
+ raise InvalidDefinitionError, one_value_per_dimension(model, hop, context) unless reflection.belongs_to?
60
+
61
+ reflection
62
+ end
63
+
64
+ private
65
+
66
+ def one_value_per_dimension(model, hop, context)
67
+ "#{model}##{hop} is not a belongs_to (via #{context}). A fact row must resolve to one " \
68
+ "value per dimension, or it would be counted in several cells at once."
69
+ end
70
+
71
+ def source_column(dimension)
72
+ model = walk(definition.fact.model, dimension.path)
73
+ model.columns_hash.fetch(dimension.path.column.to_s) do
74
+ raise InvalidDefinitionError,
75
+ "#{dimension.name} resolves to #{model}##{dimension.path.column}, which does not exist"
76
+ end
77
+ end
78
+
79
+ def walk(model, path)
80
+ path.hops.reduce(model) { |current, hop| reflection!(current, hop, path).klass }
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Recomputes a rollup from its source and reports every cell that disagrees.
5
+ #
6
+ # This is the point of the whole gem, not a diagnostic bolted on afterwards.
7
+ # Nobody puts an aggregation layer in front of numbers that matter without a way
8
+ # to prove it still tells the truth, so the obstacle Grain has to clear is never
9
+ # speed — it is doubt.
10
+ #
11
+ # A full verification is an aggregate scan of the source, which is a maintenance
12
+ # operation rather than something to run per request. Scope it by tenant or by
13
+ # date range on a large rollup.
14
+ class Verification
15
+ attr_reader :rollup, :definition
16
+
17
+ def initialize(rollup, tenant: nil, between: nil)
18
+ @rollup = rollup
19
+ @definition = rollup.definition.validate!
20
+ @scope = { tenant: tenant, between: between }
21
+ validate_scope!
22
+ end
23
+
24
+ def call(repair: false)
25
+ report = VerificationReport.new(rollup: rollup, discrepancies: discrepancies)
26
+ return report unless repair && !report.clean?
27
+
28
+ report.with_repaired(Recompute.new(definition).call(report.discrepancies.map(&:cell)))
29
+ end
30
+
31
+ def discrepancies
32
+ connection.select_all(query.to_s).to_a.map { |row| build_discrepancy(row.symbolize_keys) }
33
+ end
34
+
35
+ def query
36
+ VerificationQuery.new(definition, **@scope)
37
+ end
38
+
39
+ private
40
+
41
+ def key_columns
42
+ definition.key_dimensions.map(&:name)
43
+ end
44
+
45
+ def measure_columns
46
+ definition.measures.map(&:name)
47
+ end
48
+
49
+ def build_discrepancy(row)
50
+ Discrepancy.new(
51
+ cell: key_columns.to_h { |key| [key, row[key]] },
52
+ stored: side(row, "stored"),
53
+ expected: side(row, "expected")
54
+ )
55
+ end
56
+
57
+ def side(row, name)
58
+ return nil unless row[:"in_#{name}"].to_i.positive?
59
+
60
+ measure_columns.to_h { |measure| [measure, row[:"#{name}_#{measure}"]] }
61
+ end
62
+
63
+ def validate_scope!
64
+ return if @scope[:between].nil? || definition.temporal?
65
+
66
+ raise Error, "#{rollup} has no time dimension, so it cannot be verified over a date range"
67
+ end
68
+
69
+ def connection
70
+ ActiveRecord::Base.connection
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Builds the single statement that compares a rollup against its source.
5
+ #
6
+ # Stored and expected are stacked with UNION ALL and folded with GROUP BY rather
7
+ # than joined. A FULL OUTER JOIN would be the obvious shape, but Postgres only
8
+ # supports one over merge-joinable or hash-joinable conditions, and matching
9
+ # cells needs IS NOT DISTINCT FROM: a dimension resolved from a nullable column
10
+ # has null as a legitimate coordinate, and null = null is never true. Grouping
11
+ # sidesteps the limitation and gets the semantics for free, since GROUP BY
12
+ # already treats nulls as equal.
13
+ class VerificationQuery
14
+ attr_reader :definition, :projection
15
+
16
+ def initialize(definition, tenant: nil, between: nil)
17
+ @definition = definition
18
+ @projection = Projection.new(definition)
19
+ @tenant = tenant
20
+ @between = between
21
+ end
22
+
23
+ def to_s
24
+ <<~SQL
25
+ WITH combined AS (
26
+ #{stored_side}
27
+ UNION ALL
28
+ #{expected_side}
29
+ ), folded AS (
30
+ SELECT #{folded_selection}
31
+ FROM combined
32
+ GROUP BY #{key_positions}
33
+ )
34
+ SELECT * FROM folded
35
+ WHERE #{disagreement}
36
+ ORDER BY #{key_positions}
37
+ SQL
38
+ end
39
+
40
+ private
41
+
42
+ def key_columns
43
+ projection.key_columns
44
+ end
45
+
46
+ def measure_columns
47
+ projection.measure_columns
48
+ end
49
+
50
+ def key_positions
51
+ (1..key_columns.length).to_a.join(", ")
52
+ end
53
+
54
+ def stored_side
55
+ columns = (key_columns + measure_columns).map { |column| quote_column(column) }.join(", ")
56
+ conditions = scope_conditions { |column| quote_column(column) }
57
+ where = conditions.empty? ? "" : " WHERE #{conditions.join(" AND ")}"
58
+ "SELECT #{columns}, 1 AS grain_side FROM #{definition.table_name}#{where}"
59
+ end
60
+
61
+ def expected_side
62
+ <<~SQL.rstrip
63
+ SELECT #{expected_selection}, 2 AS grain_side
64
+ FROM #{projection.from_and_joins.join(" ")}#{expected_where}
65
+ GROUP BY #{projection.dimension_expressions.join(", ")}
66
+ SQL
67
+ end
68
+
69
+ def expected_selection
70
+ pairs = key_columns.zip(projection.dimension_expressions) +
71
+ measure_columns.zip(projection.aggregate_expressions)
72
+ pairs.map { |name, expression| "#{expression} AS #{quote_column(name)}" }.join(", ")
73
+ end
74
+
75
+ def expected_where
76
+ conditions = projection.filter_conditions + scope_conditions { |column| dimension_expression(column) }
77
+ conditions.empty? ? "" : "\n WHERE #{conditions.join(" AND ")}"
78
+ end
79
+
80
+ # At most one row per side per cell, so MAX picks that row's value and the
81
+ # counts say whether the side contributed a row at all — which is what
82
+ # distinguishes "this side had nothing" from "this side had a null".
83
+ def folded_selection
84
+ (key_columns.map { |key| quote_column(key) } + measure_pickers + presence_counts).join(", ")
85
+ end
86
+
87
+ def measure_pickers
88
+ sides.flat_map do |side, index|
89
+ measure_columns.map do |measure|
90
+ "MAX(CASE WHEN grain_side = #{index} THEN #{quote_column(measure)} END) AS #{side}_#{measure}"
91
+ end
92
+ end
93
+ end
94
+
95
+ def presence_counts
96
+ sides.map { |side, index| "COUNT(*) FILTER (WHERE grain_side = #{index}) AS in_#{side}" }
97
+ end
98
+
99
+ def sides
100
+ { "stored" => 1, "expected" => 2 }
101
+ end
102
+
103
+ # A cell survives if one side never produced a row, or any measure differs.
104
+ def disagreement
105
+ absent = ["in_stored = 0", "in_expected = 0"]
106
+ differing = measure_columns.map { |measure| "stored_#{measure} IS DISTINCT FROM expected_#{measure}" }
107
+ (absent + differing).join(" OR ")
108
+ end
109
+
110
+ def scope_conditions
111
+ conditions = []
112
+ conditions << "#{yield(definition.tenant.name)} = #{quote(@tenant)}" unless @tenant.nil?
113
+ conditions << between_condition(yield(definition.time.name)) unless @between.nil?
114
+ conditions
115
+ end
116
+
117
+ def between_condition(column)
118
+ "#{column} BETWEEN #{quote(@between.first)} AND #{quote(@between.last)}"
119
+ end
120
+
121
+ def dimension_expression(name)
122
+ @dimension_expressions ||= key_columns.zip(projection.dimension_expressions).to_h
123
+ @dimension_expressions.fetch(name)
124
+ end
125
+
126
+ def quote_column(name)
127
+ ActiveRecord::Base.connection.quote_column_name(name)
128
+ end
129
+
130
+ def quote(value)
131
+ ActiveRecord::Base.connection.quote(value)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # What a verification found, in a shape that suits both a person reading a
5
+ # terminal and a build deciding whether to fail.
6
+ class VerificationReport
7
+ attr_reader :rollup, :discrepancies, :repaired
8
+
9
+ def initialize(rollup:, discrepancies:, repaired: 0)
10
+ @rollup = rollup
11
+ @discrepancies = discrepancies.freeze
12
+ @repaired = repaired
13
+ end
14
+
15
+ def clean?
16
+ discrepancies.empty?
17
+ end
18
+
19
+ def count
20
+ discrepancies.length
21
+ end
22
+
23
+ def count_of(kind)
24
+ discrepancies.count { |discrepancy| discrepancy.kind == kind }
25
+ end
26
+
27
+ def with_repaired(number)
28
+ self.class.new(rollup: rollup, discrepancies: discrepancies, repaired: number)
29
+ end
30
+
31
+ def to_s
32
+ return "#{rollup}: agrees with its source" if clean?
33
+
34
+ [summary, *discrepancies.map { |discrepancy| " #{discrepancy}" }].join("\n")
35
+ end
36
+
37
+ private
38
+
39
+ def summary
40
+ tally = Discrepancy::KINDS.filter_map do |kind|
41
+ found = count_of(kind)
42
+ "#{found} #{kind}" if found.positive?
43
+ end
44
+ repair_note = repaired.positive? ? ", #{repaired} repaired" : ""
45
+ "#{rollup}: #{count} cells disagree (#{tally.join(", ")})#{repair_note}"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Which columns of which tables can move a fact row between cells, and so are
5
+ # worth waking a trigger for.
6
+ #
7
+ # Unlike the fact table these can be narrowed exactly: at each hop along a
8
+ # dimension's path the only column that matters is the foreign key leading to
9
+ # the next hop, or the dimension's own column at the end. Narrowing is not an
10
+ # optimisation to add later — without it a single busy table would write a log
11
+ # row on every update it ever takes.
12
+ class WatchedColumns
13
+ attr_reader :definition, :resolver
14
+
15
+ def initialize(definition)
16
+ @definition = definition
17
+ @resolver = TypeResolver.new(definition)
18
+ end
19
+
20
+ # { table name => [column names] }, with the fact table left out: its measures
21
+ # aggregate arbitrary SQL, so which of its columns feed them cannot be known
22
+ # and every update on it has to be logged.
23
+ def to_h
24
+ collected = {}
25
+ definition.watched_paths.each { |path| add_path(path, collected) }
26
+ definition.filter_associations.each { |association| add_filter(association, collected) }
27
+ collected.delete(resolver.fact_table)
28
+ collected
29
+ end
30
+
31
+ private
32
+
33
+ def add_path(path, into)
34
+ path.hops.each_with_index.reduce(definition.fact.model) do |model, (hop, index)|
35
+ add_hop(model, hop, path, index, into)
36
+ end
37
+ end
38
+
39
+ # Two columns matter at each step: the foreign key on the table being left,
40
+ # and — once the walk reaches the end — the dimension's own column.
41
+ def add_hop(model, hop, path, index, into)
42
+ reflection = resolver.reflection!(model, hop, path)
43
+ add(into, model.table_name, reflection.foreign_key)
44
+ reflection.klass.tap do |next_model|
45
+ add(into, next_model.table_name, path.column) if last_hop?(path, index)
46
+ end
47
+ end
48
+
49
+ def last_hop?(path, index)
50
+ index == path.hops.length - 1
51
+ end
52
+
53
+ def add_filter(association, into)
54
+ reflection = resolver.reflection!(definition.fact.model, association, association)
55
+ add(into, definition.fact.model.table_name, reflection.foreign_key)
56
+ conditions(association).each_key { |column| add(into, reflection.klass.table_name, column) }
57
+ end
58
+
59
+ def conditions(association)
60
+ where = definition.fact.where
61
+ where[association] || where[association.to_s] || {}
62
+ end
63
+
64
+ def add(into, table, column)
65
+ list = (into[table] ||= [])
66
+ list << column.to_s
67
+ list.uniq!
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Drains the change log and brings the affected cells back in line with the
5
+ # source.
6
+ #
7
+ # Claiming and applying happen in one transaction: the log rows are deleted and
8
+ # the rollups are rewritten together, so a crash rolls the deletions back and
9
+ # the work is simply done again. Claiming uses SKIP LOCKED, so several workers
10
+ # can drain the same log without waiting on each other or doing the same work
11
+ # twice.
12
+ class Worker
13
+ Entry = Struct.new(:source_table, :row_id, :operation, :previous, keyword_init: true)
14
+
15
+ class << self
16
+ # Drains until the log is empty or the time budget runs out. Returns the
17
+ # number of log entries applied.
18
+ def drain(limit: Grain.config.batch_size, max_seconds: Grain.config.max_run_seconds)
19
+ applied = 0
20
+ deadline = monotonic + max_seconds
21
+ loop do
22
+ batch = new(limit: limit).call
23
+ applied += batch
24
+ break if batch.zero? || monotonic >= deadline
25
+ end
26
+ applied
27
+ end
28
+
29
+ def monotonic
30
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+ end
32
+ end
33
+
34
+ attr_reader :limit
35
+
36
+ def initialize(limit: Grain.config.batch_size)
37
+ @limit = limit
38
+ end
39
+
40
+ def call
41
+ connection.transaction do
42
+ entries = claim
43
+ next 0 if entries.empty?
44
+
45
+ apply(entries)
46
+ entries.length
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # Deleting with RETURNING is the claim: inside the transaction the rows are
53
+ # gone, so no other worker can pick them up, and if this transaction fails
54
+ # they come back.
55
+ def claim
56
+ connection.select_all(claim_sql).to_a.map { |row| Entry.new(**row.symbolize_keys) }
57
+ end
58
+
59
+ def claim_sql
60
+ <<~SQL
61
+ DELETE FROM #{ChangeLog.table_name}
62
+ WHERE id IN (
63
+ SELECT id FROM #{ChangeLog.table_name}
64
+ ORDER BY id
65
+ LIMIT #{limit.to_i}
66
+ FOR UPDATE SKIP LOCKED
67
+ )
68
+ RETURNING source_table, row_id, operation, previous
69
+ SQL
70
+ end
71
+
72
+ def apply(entries)
73
+ entries.group_by(&:source_table).each do |table, group|
74
+ Registry.for_table(table).each { |rollup| refresh(rollup, table, group) }
75
+ end
76
+ end
77
+
78
+ def refresh(rollup, table, entries)
79
+ cells = affected_cells(rollup, table, entries)
80
+ Recompute.new(rollup.definition).call(cells)
81
+ end
82
+
83
+ def affected_cells(rollup, table, entries)
84
+ cells = Cells.new(rollup.definition)
85
+ as_fact(cells, table, entries) + as_watched(cells, table, entries)
86
+ end
87
+
88
+ def as_fact(cells, table, entries)
89
+ return [] unless cells.projection.fact_table == table
90
+
91
+ cells.live_for_facts(entries.map(&:row_id)) + previous_cells(entries) { |json| cells.previous_for_fact(json) }
92
+ end
93
+
94
+ def as_watched(cells, table, entries)
95
+ cells.projection.hops_for_table(table).flat_map do |hops|
96
+ cells.live_through(hops, entries.map(&:row_id)) +
97
+ previous_cells(entries) { |json| cells.previous_through(hops, json) }
98
+ end
99
+ end
100
+
101
+ def previous_cells(entries, &block)
102
+ entries.filter_map(&:previous).flat_map(&block)
103
+ end
104
+
105
+ def connection
106
+ ActiveRecord::Base.connection
107
+ end
108
+ end
109
+ end
data/lib/grain.rb ADDED
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ require_relative "grain/version"
6
+ require_relative "grain/errors"
7
+ require_relative "grain/configuration"
8
+ require_relative "grain/path"
9
+ require_relative "grain/dimension"
10
+ require_relative "grain/measure"
11
+ require_relative "grain/ratio"
12
+ require_relative "grain/fact"
13
+ require_relative "grain/definition_validator"
14
+ require_relative "grain/definition"
15
+ require_relative "grain/rollup"
16
+ require_relative "grain/rollup_lookup"
17
+ require_relative "grain/schema"
18
+ require_relative "grain/change_log"
19
+ require_relative "grain/type_resolver"
20
+ require_relative "grain/migration"
21
+ require_relative "grain/watched_columns"
22
+ require_relative "grain/triggers"
23
+ require_relative "grain/registry"
24
+ require_relative "grain/join_graph"
25
+ require_relative "grain/projection"
26
+ require_relative "grain/cells"
27
+ require_relative "grain/recompute"
28
+ require_relative "grain/query_sql"
29
+ require_relative "grain/query"
30
+ require_relative "grain/backfill"
31
+ require_relative "grain/worker"
32
+ require_relative "grain/discrepancy"
33
+ require_relative "grain/verification_report"
34
+ require_relative "grain/verification_query"
35
+ require_relative "grain/verification"
36
+
37
+ # Grain keeps dashboard aggregates pre-computed and incrementally up to date
38
+ # inside the application's own Postgres database.
39
+ #
40
+ # What exists so far is the definition layer: a rollup declares its fact, its
41
+ # key dimensions and its measures, and Grain derives the rollup table's shape
42
+ # and the set of tables that have to be watched. Nothing writes to a database
43
+ # yet. See README.md for the design.
44
+ module Grain
45
+ class << self
46
+ def config
47
+ @config ||= Configuration.new
48
+ end
49
+
50
+ def configure
51
+ yield config
52
+ end
53
+
54
+ def reset_config!
55
+ @config = Configuration.new
56
+ end
57
+ end
58
+ end
59
+
60
+ require_relative "grain/railtie" if defined?(Rails::Railtie)