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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= rollup_class_name %> < Grain::Rollup
4
+ # The table the measures are read from and whose rows are counted. `where`
5
+ # narrows it, and may reach through a belongs_to.
6
+ #
7
+ # fact LineItem, where: { order: { state: "paid" } }
8
+ fact :TODO
9
+
10
+ # The column the rollup is partitioned by. Required: starting the key with the
11
+ # most selective column is what keeps reads and recomputes cheap.
12
+ #
13
+ # tenant :store_id, via: { order: :store_id }
14
+ tenant :TODO, via: :TODO
15
+
16
+ # Optional. Without it the rollup is a running total per dimension — a counter
17
+ # cache that can be verified instead of drifting.
18
+ #
19
+ # time :ordered_on, via: { order: :placed_at }, grain: :day
20
+
21
+ # Dimensions are resolved by following belongs_to associations upward from the
22
+ # fact, up to three hops. `via` can also name a column on the fact itself.
23
+ # Mark one immutable to promise it never changes after the row is created, and
24
+ # Grain will skip watching that table.
25
+ #
26
+ # dimension :product_id, via: :product_id
27
+ # dimension :category_id, via: { product: :category_id }
28
+ # dimension :currency, via: { order: :currency }, immutable: true
29
+
30
+ # count needs no type. Every other aggregate runs over SQL whose type cannot be
31
+ # inferred, and guessing would mean silently rounding your own numbers.
32
+ #
33
+ # measure :line_count, count: true
34
+ # measure :revenue_cents, sum: "quantity * unit_price_cents", type: :bigint
35
+
36
+ # Ratios are stored as their two parts and divided on read, so a rate stays
37
+ # correct at every grain rather than being frozen at the one it was computed for.
38
+ #
39
+ # ratio :average_unit_price, of: :revenue_cents, over: :units
40
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Grain
7
+ module Generators
8
+ # Builds the migration for a rollup that already exists: its table, and the
9
+ # triggers on every table its definition depends on.
10
+ #
11
+ # Run it again whenever the definition changes. The rollup's shape is derived
12
+ # from the class, so the generated migration always matches what the code
13
+ # currently declares.
14
+ class TableGenerator < Rails::Generators::NamedBase
15
+ include ActiveRecord::Generators::Migration
16
+
17
+ source_root File.expand_path("templates", __dir__)
18
+
19
+ desc "Generates the migration that creates NAME's rollup table and its triggers."
20
+
21
+ def create_table_migration
22
+ migration_template "create_rollup_table.rb.erb", "db/migrate/#{rollup_migration_basename}.rb"
23
+ end
24
+
25
+ def report_trigger_tables
26
+ say ""
27
+ say "Triggers will be attached to:", :green
28
+ triggers.specs.each do |spec|
29
+ scope = spec.narrowed? ? "updates of #{spec.update_columns.join(", ")}" : "all updates"
30
+ say " #{spec.table} (#{scope})"
31
+ end
32
+ say ""
33
+ end
34
+
35
+ private
36
+
37
+ def rollup
38
+ @rollup ||= resolve_rollup
39
+ end
40
+
41
+ # Thor prints the message and stops, which is the right behaviour for a
42
+ # command line; the rule itself lives in RollupLookup so it can be tested.
43
+ def resolve_rollup
44
+ Grain::RollupLookup.find!(name)
45
+ rescue Grain::RollupNotFoundError => e
46
+ raise Thor::Error, e.message
47
+ end
48
+
49
+ def definition
50
+ @definition ||= rollup.validate!
51
+ end
52
+
53
+ def migration
54
+ @migration ||= Grain::Migration.new(definition)
55
+ end
56
+
57
+ # Every rollup in the application, not just this one. The trigger on a
58
+ # table is shared, so narrowing it to what this rollup happens to need
59
+ # would silently break any other rollup that needs more.
60
+ def triggers
61
+ @triggers ||= Grain::Triggers.new(all_definitions)
62
+ end
63
+
64
+ def all_definitions
65
+ (Grain::Registry.all + [rollup]).uniq.map(&:definition)
66
+ end
67
+
68
+ # Deliberately not called migration_file_name or migration_class_name:
69
+ # Rails' own migration machinery calls methods by those names on the
70
+ # generator, and shadowing them breaks it from the inside.
71
+ def rollup_migration_basename
72
+ "create_#{migration.table_name}"
73
+ end
74
+
75
+ def rollup_migration_class_name
76
+ rollup_migration_basename.camelize
77
+ end
78
+
79
+ def migration_version
80
+ "[#{ActiveRecord::Migration.current_version}]"
81
+ end
82
+
83
+ def indented(text, spaces)
84
+ prefix = " " * spaces
85
+ text.each_line.map { |line| line.strip.empty? ? line : "#{prefix}#{line}" }.join
86
+ end
87
+
88
+ def table_body
89
+ indented(migration.up, 4).rstrip
90
+ end
91
+
92
+ def executed_statements(statements, spaces)
93
+ statements.map do |statement|
94
+ "#{" " * spaces}execute <<~SQL\n#{indented(statement, spaces + 2)}\n#{" " * spaces}SQL"
95
+ end.join("\n\n")
96
+ end
97
+
98
+ def trigger_up
99
+ executed_statements(triggers.up_statements, 4)
100
+ end
101
+
102
+ def trigger_down
103
+ executed_statements(triggers.down_statements, 4)
104
+ end
105
+
106
+ def drop_table_statement
107
+ migration.down
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Written by `rails generate grain:table <%= file_name %>` from
4
+ # <%= rollup.name %>.
5
+ #
6
+ # Regenerate this whenever that definition changes: the table's shape and the
7
+ # tables it watches are both derived from it.
8
+ class <%= rollup_migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
9
+ def up
10
+ <%= table_body %>
11
+
12
+ <%= trigger_up %>
13
+ end
14
+
15
+ def down
16
+ <%= trigger_down %>
17
+
18
+ <%= drop_table_statement %>
19
+ end
20
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Populates a rollup from data that already exists.
5
+ #
6
+ # A new rollup starts empty: its triggers only see what happens next. The
7
+ # backfill is what makes it true about the past.
8
+ #
9
+ # The work is sliced rather than batched by row. Rows belonging to one cell are
10
+ # scattered through the fact table, so a batch of rows would have to add to
11
+ # cells already written, which is the delta problem again with none of its
12
+ # safeguards. A slice — one day, or one tenant — is instead rebuilt whole with
13
+ # the same recompute the worker uses: idempotent, never leaving a cell showing a
14
+ # partial total, and safe to run while the worker is running.
15
+ #
16
+ # Resuming after an interruption is manual and deliberate: slices are processed
17
+ # in order, so passing `from:` the last one reported picks up where it stopped.
18
+ # Re-running an already-done slice is harmless either way.
19
+ class Backfill
20
+ attr_reader :rollup, :definition
21
+
22
+ def initialize(rollup, from: nil, pause: 0)
23
+ @rollup = rollup
24
+ @definition = rollup.definition.validate!
25
+ @projection = Projection.new(@definition)
26
+ @from = from
27
+ @pause = pause
28
+ end
29
+
30
+ # Yields each slice value as it completes, so a caller can report progress or
31
+ # record where to resume from. Returns the number of slices rebuilt.
32
+ def call
33
+ recompute = Recompute.new(definition)
34
+ slices.each_with_index do |value, index|
35
+ recompute.call_slice(slice_dimension, value)
36
+ yield(value, index + 1, slices.length) if block_given?
37
+ sleep(@pause) if @pause.positive?
38
+ end
39
+ slices.length
40
+ end
41
+
42
+ # The dimension the work is cut along: the time bucket when there is one,
43
+ # since a day is a naturally bounded unit of work, and the tenant otherwise.
44
+ def slice_dimension
45
+ definition.temporal? ? definition.time : definition.tenant
46
+ end
47
+
48
+ # Distinct slice values that actually have data, in order.
49
+ #
50
+ # This is the expensive part of a backfill: it reads the fact table to find
51
+ # them. It is one pass and far cheaper than the full aggregate, and using the
52
+ # distinct values rather than a min-to-max range skips every gap.
53
+ def slices
54
+ @slices ||= load_slices
55
+ end
56
+
57
+ private
58
+
59
+ def load_slices
60
+ expression = @projection.dimension_expression(slice_dimension)
61
+ sql = +"SELECT DISTINCT #{expression} AS slice FROM #{@projection.from_and_joins.join(" ")}"
62
+ conditions = @projection.filter_conditions
63
+ conditions << "#{expression} >= #{quote(@from)}" unless @from.nil?
64
+ sql << " WHERE #{conditions.join(" AND ")}" if conditions.any?
65
+ sql << " ORDER BY slice"
66
+ connection.select_values(sql)
67
+ end
68
+
69
+ def quote(value)
70
+ connection.quote(value)
71
+ end
72
+
73
+ def connection
74
+ ActiveRecord::Base.connection
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Finds the cells a change could have touched.
5
+ #
6
+ # The rule this is built on: recomputing a cell that did not need it is
7
+ # harmless, while missing one that did is the only unforgivable bug. So the
8
+ # search is deliberately generous — the fact's filter is applied when looking
9
+ # for where rows are now, and left off when looking for where they used to be.
10
+ class Cells
11
+ attr_reader :projection
12
+
13
+ def initialize(definition)
14
+ @definition = definition
15
+ @projection = Projection.new(definition)
16
+ end
17
+
18
+ # Cells the given fact rows belong to as things stand.
19
+ def live_for_facts(row_ids)
20
+ return [] if row_ids.empty?
21
+
22
+ select(conditions: [id_in(Projection::FACT, row_ids)] + projection.filter_conditions)
23
+ end
24
+
25
+ # The cell a fact row sat in before it changed, rebuilt from the log. Without
26
+ # this the row's contribution would sit in its old cell forever, because
27
+ # nothing points there any more.
28
+ def previous_for_fact(previous_json)
29
+ select(substitutions: { [] => previous_json })
30
+ end
31
+
32
+ # Cells reachable through a row of a watched table, as things stand.
33
+ def live_through(hops, row_ids)
34
+ return [] if row_ids.empty?
35
+
36
+ select(conditions: [id_in(projection.alias_for(hops), row_ids)] + projection.filter_conditions)
37
+ end
38
+
39
+ # ...and as they were, with that table's row rebuilt from the log.
40
+ def previous_through(hops, previous_json)
41
+ select(substitutions: { hops => previous_json })
42
+ end
43
+
44
+ private
45
+
46
+ def select(conditions: [], substitutions: {})
47
+ sql = +"SELECT DISTINCT #{selection} FROM #{projection.from_and_joins(substitutions).join(" ")}"
48
+ sql << " WHERE #{conditions.join(" AND ")}" if conditions.any?
49
+ connection.select_all(sql).to_a.map(&:symbolize_keys)
50
+ end
51
+
52
+ def selection
53
+ projection.key_columns.zip(projection.dimension_expressions)
54
+ .map { |name, expression| "#{expression} AS #{name}" }
55
+ .join(", ")
56
+ end
57
+
58
+ def id_in(table_alias, row_ids)
59
+ "#{table_alias}.id IN (#{row_ids.map { |id| connection.quote(id) }.join(", ")})"
60
+ end
61
+
62
+ def connection
63
+ ActiveRecord::Base.connection
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # The single table every trigger writes into, the trigger function they all
5
+ # share, and the vocabulary of what a trigger can record.
6
+ #
7
+ # One trigger per source table, never one per rollup: several rollups can read
8
+ # the same fact table, and triggers must not multiply with them. A trigger
9
+ # records that a row changed and nothing more. Deciding which rollups care, and
10
+ # which of their cells are affected, is the worker's job.
11
+ module ChangeLog
12
+ OPERATIONS = %i[insert update delete].freeze
13
+
14
+ FUNCTION_NAME = "grain_record_change"
15
+
16
+ COLUMNS = {
17
+ id: :bigserial,
18
+ source_table: :text,
19
+ # Text rather than bigint so that Grain works on integer, UUID and string
20
+ # primary keys alike. The worker casts it back when it reads the source, so
21
+ # the fact table's own index is still used.
22
+ row_id: :text,
23
+ operation: :text,
24
+ # The row as it was before the change, recorded for updates and deletes.
25
+ #
26
+ # Without it, the cell a row is leaving cannot be located: once the row
27
+ # carries its new values, nothing points back at where it used to be
28
+ # counted, and that cell would keep the departed row in its totals
29
+ # forever. This is why the change log is not merely a list of ids.
30
+ previous: :jsonb,
31
+ created_at: :timestamptz
32
+ }.freeze
33
+
34
+ class << self
35
+ def table_name
36
+ Grain.config.change_log_table
37
+ end
38
+
39
+ def operation!(name)
40
+ operation = name.to_s.downcase.to_sym
41
+ return operation if OPERATIONS.include?(operation)
42
+
43
+ raise Error, "unknown change log operation #{name.inspect}, expected #{OPERATIONS.inspect}"
44
+ end
45
+
46
+ # Updates and deletes need the previous row to locate the cell being left.
47
+ # An insert leaves nothing behind, so it needs none.
48
+ def previous_required?(operation)
49
+ operation!(operation) != :insert
50
+ end
51
+
52
+ # No secondary indexes: the worker reads forward by id and prunes by id, so
53
+ # the primary key already covers both. The log is drained continuously and
54
+ # is never meant to grow.
55
+ def table_definition
56
+ <<~RUBY
57
+ create_table :#{table_name} do |t|
58
+ t.text :source_table, null: false
59
+ t.text :row_id, null: false
60
+ t.text :operation, null: false
61
+ t.jsonb :previous
62
+ t.datetime :created_at, null: false
63
+ end
64
+ RUBY
65
+ end
66
+
67
+ # One function for every watched table. TG_TABLE_NAME tells the worker which
68
+ # table a row came from, so nothing here has to be generated per table.
69
+ #
70
+ # clock_timestamp() rather than now(): now() returns the transaction's start
71
+ # time, which would stamp every row written in one transaction identically.
72
+ def function_sql
73
+ <<~SQL
74
+ CREATE OR REPLACE FUNCTION #{FUNCTION_NAME}() RETURNS trigger
75
+ LANGUAGE plpgsql AS $$
76
+ BEGIN
77
+ IF TG_OP = 'INSERT' THEN
78
+ INSERT INTO #{table_name} (source_table, row_id, operation, previous, created_at)
79
+ VALUES (TG_TABLE_NAME, NEW.id::text, 'insert', NULL, clock_timestamp());
80
+ RETURN NEW;
81
+ ELSIF TG_OP = 'UPDATE' THEN
82
+ INSERT INTO #{table_name} (source_table, row_id, operation, previous, created_at)
83
+ VALUES (TG_TABLE_NAME, NEW.id::text, 'update', to_jsonb(OLD), clock_timestamp());
84
+ RETURN NEW;
85
+ ELSE
86
+ INSERT INTO #{table_name} (source_table, row_id, operation, previous, created_at)
87
+ VALUES (TG_TABLE_NAME, OLD.id::text, 'delete', to_jsonb(OLD), clock_timestamp());
88
+ RETURN OLD;
89
+ END IF;
90
+ END;
91
+ $$;
92
+ SQL
93
+ end
94
+
95
+ def drop_function_sql
96
+ "DROP FUNCTION IF EXISTS #{FUNCTION_NAME}();"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Application-wide settings. Defaults are chosen to be safe on a busy
5
+ # production database rather than fast on an idle one.
6
+ class Configuration
7
+ # Table that database triggers write change events into.
8
+ attr_accessor :change_log_table
9
+
10
+ # Rows per batch when backfilling or draining the change log. Small enough
11
+ # to keep transactions short and avoid long lock holds.
12
+ attr_accessor :batch_size
13
+
14
+ # Seconds a delta worker may run before yielding, so a large backlog cannot
15
+ # monopolise a job queue slot.
16
+ attr_accessor :max_run_seconds
17
+
18
+ # Time zone the day buckets are cut in. A timestamp has to be resolved to a
19
+ # calendar day in some zone, and leaving it to the database session would make
20
+ # the same row land in different buckets depending on who ran the query.
21
+ attr_accessor :time_zone
22
+
23
+ # Where Grain writes its own diagnostics.
24
+ attr_accessor :logger
25
+
26
+ def initialize
27
+ @change_log_table = "grain_change_log"
28
+ @batch_size = 1_000
29
+ @max_run_seconds = 30
30
+ @time_zone = "UTC"
31
+ @logger = nil
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Everything a Rollup subclass declares, plus what can be derived from it: the
5
+ # rollup table's name and key order, and which tables have to be watched to
6
+ # keep the aggregate correct.
7
+ class Definition
8
+ TABLE_PREFIX = "grain_"
9
+
10
+ attr_reader :owner, :fact, :dimensions, :measures, :ratios
11
+
12
+ def initialize(owner)
13
+ @owner = owner
14
+ @fact = nil
15
+ @dimensions = []
16
+ @measures = []
17
+ @ratios = []
18
+ end
19
+
20
+ def declare_fact(model, where: nil)
21
+ raise InvalidDefinitionError, "#{owner} already declares a fact" if fact
22
+
23
+ @fact = Fact.new(model, where: where)
24
+ end
25
+
26
+ def add_dimension(name, via:, role: :dimension, grain: nil, immutable: false)
27
+ dimension = Dimension.new(name: name, via: via, role: role, grain: grain, immutable: immutable)
28
+ reject_duplicate_name!(dimension.name)
29
+ reject_duplicate_role!(dimension)
30
+ @dimensions << dimension
31
+ dimension
32
+ end
33
+
34
+ def add_measure(name, options)
35
+ measure = Measure.from_options(name, options)
36
+ reject_duplicate_name!(measure.name)
37
+ @measures << measure
38
+ measure
39
+ end
40
+
41
+ def add_ratio(name, numerator:, denominator:)
42
+ ratio = Ratio.new(name: name, numerator: numerator, denominator: denominator)
43
+ reject_duplicate_name!(ratio.name)
44
+ @ratios << ratio
45
+ ratio
46
+ end
47
+
48
+ def tenant
49
+ dimensions.find(&:tenant?)
50
+ end
51
+
52
+ def time
53
+ dimensions.find(&:time?)
54
+ end
55
+
56
+ # False for a rollup that buckets by nothing but its dimensions — a counter
57
+ # cache. Reads on one cannot take a date range, and it has no late-arriving
58
+ # data to worry about.
59
+ def temporal?
60
+ !time.nil?
61
+ end
62
+
63
+ def plain_dimensions
64
+ dimensions.reject { |dimension| dimension.tenant? || dimension.time? }
65
+ end
66
+
67
+ # The rollup table's primary key, in a fixed order so that migrations,
68
+ # delta updates and reads all agree: tenant, then time bucket, then the
69
+ # plain dimensions in declaration order.
70
+ def key_dimensions
71
+ [tenant, time, *plain_dimensions].compact
72
+ end
73
+
74
+ def table_name
75
+ raise InvalidDefinitionError, "a rollup needs a name to derive its table from" if anonymous?
76
+
77
+ "#{TABLE_PREFIX}#{owner.name.underscore.pluralize}"
78
+ end
79
+
80
+ # Paths whose tables can move fact rows between cells.
81
+ def watched_paths
82
+ dimensions.select(&:watched?).map(&:path).uniq
83
+ end
84
+
85
+ # Every association hop along a watched path, not only the first.
86
+ #
87
+ # A change anywhere along the path moves fact rows between cells: if a
88
+ # dimension resolves as order then store then currency, changing that store's
89
+ # currency moves every line item on every order pointing at it. Watching only
90
+ # the root would let that drift silently, which is the worst failure this
91
+ # gem can have.
92
+ def watched_associations
93
+ watched_paths.flat_map(&:hops).uniq
94
+ end
95
+
96
+ # Associations the fact's filter reaches through. A change there adds or
97
+ # removes fact rows entirely, which no delta can express.
98
+ def filter_associations
99
+ return [] unless fact
100
+
101
+ fact.filter_associations
102
+ end
103
+
104
+ # True when a change somewhere other than the fact table can add or remove
105
+ # fact rows entirely, which no delta can express.
106
+ def invalidated_by_filter?
107
+ fact ? fact.filtered_through_association? : false
108
+ end
109
+
110
+ def measure_names
111
+ measures.map(&:name)
112
+ end
113
+
114
+ def validate!
115
+ DefinitionValidator.new(self).validate!
116
+ end
117
+
118
+ private
119
+
120
+ def anonymous?
121
+ owner.name.nil? || owner.name.empty?
122
+ end
123
+
124
+ def declared_names
125
+ dimensions.map(&:name) + measure_names + ratios.map(&:name)
126
+ end
127
+
128
+ def reject_duplicate_name!(name)
129
+ return unless declared_names.include?(name)
130
+
131
+ raise InvalidDefinitionError, "#{owner} already declares #{name.inspect}"
132
+ end
133
+
134
+ def reject_duplicate_role!(dimension)
135
+ return unless (dimension.tenant? && tenant) || (dimension.time? && time)
136
+
137
+ raise InvalidDefinitionError, "#{owner} already declares a #{dimension.role}"
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Judges whether a definition is complete and internally consistent.
5
+ #
6
+ # Kept apart from Definition so that accumulating declarations and deciding
7
+ # whether they add up stay separate concerns: the DSL builds, this rules.
8
+ class DefinitionValidator
9
+ # Parts a rollup cannot do without.
10
+ #
11
+ # A time dimension is deliberately absent: a rollup without one is a counter
12
+ # cache that cannot drift, which is a use case in its own right. A tenant is
13
+ # required, because starting the key with the most selective column is what
14
+ # makes reads and scoped recomputes cheap.
15
+ REQUIRED_PARTS = {
16
+ fact: "declares no fact",
17
+ tenant: "declares no tenant"
18
+ }.freeze
19
+
20
+ attr_reader :definition
21
+
22
+ def initialize(definition)
23
+ @definition = definition
24
+ end
25
+
26
+ def validate!
27
+ validate_required_parts!
28
+ validate_ratios!
29
+ definition
30
+ end
31
+
32
+ private
33
+
34
+ def owner
35
+ definition.owner
36
+ end
37
+
38
+ def validate_required_parts!
39
+ REQUIRED_PARTS.each do |reader, complaint|
40
+ raise InvalidDefinitionError, "#{owner} #{complaint}" if definition.public_send(reader).nil?
41
+ end
42
+
43
+ raise InvalidDefinitionError, "#{owner} declares no measures" if definition.measures.empty?
44
+ end
45
+
46
+ def validate_ratios!
47
+ definition.ratios.each { |ratio| validate_ratio!(ratio) }
48
+ end
49
+
50
+ def validate_ratio!(ratio)
51
+ [ratio.numerator, ratio.denominator].each do |part|
52
+ next if definition.measure_names.include?(part)
53
+
54
+ raise InvalidDefinitionError, "ratio #{ratio.name} refers to unknown measure #{part.inspect}"
55
+ end
56
+ end
57
+ end
58
+ end