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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # One key column of a rollup table: the tenant, the time bucket, or a plain
5
+ # dimension. +via+ says how to resolve it from a fact row.
6
+ class Dimension
7
+ ROLES = %i[tenant time dimension].freeze
8
+ TIME_GRAINS = %i[day].freeze
9
+
10
+ attr_reader :name, :path, :role, :grain
11
+
12
+ def initialize(name:, via:, role: :dimension, grain: nil, immutable: false)
13
+ @name = name.to_sym
14
+ @path = Path.parse(via)
15
+ @role = role.to_sym
16
+ @grain = grain&.to_sym
17
+ @immutable = immutable
18
+ validate!
19
+ freeze
20
+ end
21
+
22
+ def tenant?
23
+ role == :tenant
24
+ end
25
+
26
+ def time?
27
+ role == :time
28
+ end
29
+
30
+ def immutable?
31
+ @immutable
32
+ end
33
+
34
+ # A dimension resolved through an association moves fact rows into a
35
+ # different cell when the associated row changes, so its table needs a
36
+ # trigger. Declaring it immutable trades that safety for speed.
37
+ def watched?
38
+ !path.local? && !immutable?
39
+ end
40
+
41
+ private
42
+
43
+ def validate!
44
+ raise InvalidDefinitionError, "unknown dimension role #{role.inspect}" unless ROLES.include?(role)
45
+
46
+ time? ? validate_time_grain! : validate_no_grain!
47
+ end
48
+
49
+ def validate_time_grain!
50
+ return if TIME_GRAINS.include?(grain)
51
+
52
+ raise InvalidDefinitionError,
53
+ "time dimension #{name} needs a grain in #{TIME_GRAINS.inspect}, got #{grain.inspect}"
54
+ end
55
+
56
+ def validate_no_grain!
57
+ return if grain.nil?
58
+
59
+ raise InvalidDefinitionError, "grain only applies to a time dimension, #{name} is a #{role}"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # One cell where the rollup and its source disagree.
5
+ #
6
+ # Three kinds, and all three matter. `wrong` is the obvious one. `missing` is a
7
+ # cell the source has and the rollup never got. `extra` is a cell the rollup
8
+ # still holds after its last source row went away — the one a design built on
9
+ # upserts can never find, because there is nothing left to upsert against.
10
+ class Discrepancy
11
+ KINDS = %i[wrong missing extra].freeze
12
+
13
+ attr_reader :cell, :stored, :expected
14
+
15
+ def initialize(cell:, stored:, expected:)
16
+ @cell = cell.freeze
17
+ @stored = stored&.freeze
18
+ @expected = expected&.freeze
19
+ freeze
20
+ end
21
+
22
+ def kind
23
+ return :missing if stored.nil?
24
+ return :extra if expected.nil?
25
+
26
+ :wrong
27
+ end
28
+
29
+ def to_s
30
+ "#{kind}: #{format_cell}#{differences}"
31
+ end
32
+
33
+ private
34
+
35
+ def format_cell
36
+ cell.map { |name, value| "#{name}=#{value.inspect}" }.join(" ")
37
+ end
38
+
39
+ def differences
40
+ return "" unless kind == :wrong
41
+
42
+ changed = expected.filter_map do |measure, value|
43
+ "#{measure} #{stored[measure].inspect} should be #{value.inspect}" if stored[measure] != value
44
+ end
45
+ changed.empty? ? "" : " — #{changed.join(", ")}"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Base class for everything Grain raises, so applications can rescue Grain
5
+ # failures without catching unrelated errors.
6
+ class Error < StandardError; end
7
+
8
+ # A rollup definition is invalid: missing source, unknown dimension, a measure
9
+ # that cannot be maintained incrementally, and so on.
10
+ class InvalidDefinitionError < Error; end
11
+
12
+ # The rollup table does not match its definition — usually a definition
13
+ # changed without a backfill.
14
+ class StaleSchemaError < Error; end
15
+
16
+ # `verify` found rows where the rollup disagrees with its source.
17
+ class VerificationError < Error; end
18
+
19
+ # No rollup class matches the name a command was given.
20
+ class RollupNotFoundError < Error; end
21
+
22
+ # The change log has grown past the point where applying deltas is cheaper
23
+ # than a backfill.
24
+ class ChangeLogOverflowError < Error; end
25
+ end
data/lib/grain/fact.rb ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # The table a rollup counts rows from and reads its measures off.
5
+ #
6
+ # The model is stored by name and resolved lazily, so a definition can be
7
+ # loaded and inspected before the application's models are.
8
+ class Fact
9
+ attr_reader :model_name, :where
10
+
11
+ def initialize(model, where: nil)
12
+ @model_name = model.is_a?(Class) ? model.name.to_s : model.to_s
13
+ @where = where
14
+ validate!
15
+ freeze
16
+ end
17
+
18
+ def model
19
+ model_name.constantize
20
+ end
21
+
22
+ # A filter reaching through an association adds or removes whole rows when
23
+ # the associated row changes. That is not a delta on a cell, it is an
24
+ # invalidation of every cell the row belonged to.
25
+ def filtered_through_association?
26
+ filter_associations.any?
27
+ end
28
+
29
+ # The association names the filter reaches through, each of which is a table
30
+ # whose changes can add or remove fact rows.
31
+ def filter_associations
32
+ return [] unless where.is_a?(Hash)
33
+
34
+ where.select { |_, value| value.is_a?(Hash) }.keys.map(&:to_sym)
35
+ end
36
+
37
+ private
38
+
39
+ def validate!
40
+ raise InvalidDefinitionError, "fact needs a model" if model_name.empty?
41
+ return if where.nil? || where.is_a?(Hash)
42
+
43
+ raise InvalidDefinitionError, "fact where takes a hash, got #{where.class}"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # The aliases and joins that reach every table a rollup needs.
5
+ #
6
+ # Registration is recursive, so two dimensions resolved through the same
7
+ # association share one join instead of duplicating it, and a parent is always
8
+ # registered — and therefore emitted — before anything reached through it.
9
+ class JoinGraph
10
+ # The fact table's alias. Measure expressions are the user's own SQL inserted
11
+ # verbatim, so this is the name they can qualify columns with.
12
+ FACT = "f"
13
+
14
+ Joined = Struct.new(:name, :model, :on, keyword_init: true)
15
+
16
+ attr_reader :definition, :types
17
+
18
+ def initialize(definition)
19
+ @definition = definition
20
+ @types = TypeResolver.new(definition)
21
+ @joined = {}
22
+ register_all
23
+ end
24
+
25
+ def fact_model
26
+ definition.fact.model
27
+ end
28
+
29
+ def fact_table
30
+ fact_model.table_name
31
+ end
32
+
33
+ def alias_for(hops)
34
+ hops.empty? ? FACT : register(hops).name
35
+ end
36
+
37
+ # The FROM and JOIN block, in registration order.
38
+ #
39
+ # `substitutions` maps an alias's hops to a JSON row, replacing that table with
40
+ # the row as it was before a change. That is how a cell a row has since left
41
+ # can still be found: the live table no longer points at it.
42
+ def from_and_joins(substitutions = {})
43
+ [table_expression([], FACT, substitutions)] +
44
+ @joined.map { |hops, joined| "JOIN #{table_expression(hops, joined.name, substitutions)} ON #{joined.on}" }
45
+ end
46
+
47
+ # Hop paths that reach a given table, so a change there can be traced back to
48
+ # the alias it arrived through.
49
+ def hops_for_table(table)
50
+ @joined.select { |_, joined| joined.model.table_name == table }.keys
51
+ end
52
+
53
+ private
54
+
55
+ def register_all
56
+ definition.key_dimensions.reject { |dimension| dimension.path.local? }
57
+ .each { |dimension| register(dimension.path.hops) }
58
+ definition.filter_associations.each { |association| register([association]) }
59
+ end
60
+
61
+ # A local column needs no join, so an empty path is a caller's mistake rather
62
+ # than a base case: treating it as one recurses forever.
63
+ def register(hops)
64
+ hops = hops.map(&:to_sym)
65
+ raise InvalidDefinitionError, "a local column needs no join" if hops.empty?
66
+
67
+ @joined[hops] ||= join_for(hops, parent_of(hops))
68
+ end
69
+
70
+ def join_for(hops, parent)
71
+ reflection = types.reflection!(parent.model, hops.last, hops.join("."))
72
+ name = "j#{@joined.size}"
73
+ Joined.new(name: name, model: reflection.klass, on: "#{name}.id = #{parent.name}.#{reflection.foreign_key}")
74
+ end
75
+
76
+ def parent_of(hops)
77
+ return Joined.new(name: FACT, model: fact_model, on: nil) if hops.length == 1
78
+
79
+ register(hops[0..-2])
80
+ end
81
+
82
+ def table_expression(hops, name, substitutions)
83
+ table = hops.empty? ? fact_table : @joined[hops].model.table_name
84
+ json = substitutions[hops]
85
+ return "#{table} #{name}" if json.nil?
86
+
87
+ "jsonb_populate_record(NULL::#{table}, #{ActiveRecord::Base.connection.quote(json)}::jsonb) #{name}"
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # A pre-aggregated column of a rollup table.
5
+ #
6
+ # measure :line_count, count: true
7
+ # measure :units, sum: "quantity", type: :bigint
8
+ # measure :revenue_cents, sum: "quantity * unit_price_cents", type: :bigint
9
+ class Measure
10
+ AGGREGATES = %i[count sum min max].freeze
11
+
12
+ # count and sum can be undone by subtracting. min and max cannot: removing
13
+ # the current extreme means the next one has to be found in the source, so a
14
+ # delete forces recomputing the whole cell.
15
+ REVERSIBLE = %i[count sum].freeze
16
+
17
+ # A count of rows is always a whole number, so its type is not worth asking
18
+ # for. Every other aggregate runs over an arbitrary SQL expression whose type
19
+ # Grain cannot infer, and guessing would mean silently rounding somebody's
20
+ # revenue. The declaration is one word, and it is cheap insurance.
21
+ COUNT_TYPE = :bigint
22
+
23
+ attr_reader :name, :aggregate, :expression, :type
24
+
25
+ def self.from_options(name, options)
26
+ options = options.dup
27
+ type = options.delete(:type)
28
+ reject_ambiguous_aggregate!(name, options)
29
+
30
+ aggregate, value = options.first
31
+ new(name: name, aggregate: aggregate, expression: value == true ? nil : value, type: type)
32
+ end
33
+
34
+ def self.reject_ambiguous_aggregate!(name, options)
35
+ return if options.size == 1
36
+
37
+ raise InvalidDefinitionError,
38
+ "measure #{name} takes exactly one aggregate, got #{options.keys.inspect}"
39
+ end
40
+ private_class_method :reject_ambiguous_aggregate!
41
+
42
+ def initialize(name:, aggregate:, expression: nil, type: nil)
43
+ @name = name.to_sym
44
+ @aggregate = aggregate.to_sym
45
+ @expression = expression
46
+ @type = (type || (@aggregate == :count ? COUNT_TYPE : nil))&.to_sym
47
+ validate!
48
+ freeze
49
+ end
50
+
51
+ # How the measure combines when a read asks for a coarser grain than the one
52
+ # stored. Counts and sums add up; an extreme collapses to the extreme of the
53
+ # extremes, which is why min and max are storable at all despite not being
54
+ # reversible.
55
+ COARSENING = { count: :sum, sum: :sum, min: :min, max: :max }.freeze
56
+
57
+ def coarsens_with
58
+ COARSENING.fetch(aggregate)
59
+ end
60
+
61
+ # A bare count of fact rows, needing no expression to evaluate per row.
62
+ def counts_rows?
63
+ aggregate == :count && expression.nil?
64
+ end
65
+
66
+ # False means a delete cannot be applied as a delta and the cell has to be
67
+ # recomputed from the source instead.
68
+ def reversible?
69
+ REVERSIBLE.include?(aggregate)
70
+ end
71
+
72
+ private
73
+
74
+ def validate!
75
+ validate_aggregate!
76
+ validate_expression!
77
+ validate_type!
78
+ end
79
+
80
+ def validate_aggregate!
81
+ return if AGGREGATES.include?(aggregate)
82
+
83
+ raise InvalidDefinitionError,
84
+ "measure #{name} uses #{aggregate.inspect}, supported aggregates are #{AGGREGATES.inspect}"
85
+ end
86
+
87
+ def validate_expression!
88
+ return if aggregate == :count || !expression.nil?
89
+
90
+ raise InvalidDefinitionError, "measure #{name} aggregates with #{aggregate} and needs an expression"
91
+ end
92
+
93
+ def validate_type!
94
+ return unless type.nil?
95
+
96
+ raise InvalidDefinitionError,
97
+ "measure #{name} needs an explicit type, as in `#{aggregate}: ..., type: :bigint`"
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # Renders the migration that creates a rollup's table.
5
+ #
6
+ # Grain emits a migration file for the application to read and run, rather than
7
+ # creating tables at runtime. A rollup table is part of the schema like any
8
+ # other: it belongs in version control, in code review, and in schema.rb.
9
+ class Migration
10
+ # Postgres caps identifiers at 63 bytes, and a long rollup name plus a
11
+ # suffix passes that easily.
12
+ MAX_IDENTIFIER = 63
13
+
14
+ attr_reader :schema, :types
15
+
16
+ def initialize(definition)
17
+ @schema = Schema.new(definition)
18
+ @types = TypeResolver.new(definition)
19
+ end
20
+
21
+ def table_name
22
+ schema.table_name
23
+ end
24
+
25
+ # True when a dimension resolves to a nullable column. Postgres rejects nulls
26
+ # in a primary key, so the rollup falls back to a surrogate key plus a unique
27
+ # index that treats nulls as equal.
28
+ def surrogate_key?
29
+ types.nullable_dimensions.any?
30
+ end
31
+
32
+ def up
33
+ [create_table, uniqueness_index].compact.join("\n\n")
34
+ end
35
+
36
+ def down
37
+ "drop_table :#{table_name}"
38
+ end
39
+
40
+ def uniqueness_index_name
41
+ truncate("index_#{table_name}_uniqueness")
42
+ end
43
+
44
+ private
45
+
46
+ def create_table
47
+ lines = ["create_table :#{table_name}#{primary_key_option} do |t|"]
48
+ lines.concat(key_column_lines)
49
+ lines << ""
50
+ lines.concat(measure_column_lines)
51
+ lines << "end"
52
+ lines.join("\n")
53
+ end
54
+
55
+ # No `id: false` alongside a composite primary key. Rails accepts the pair
56
+ # and then creates no primary key at all, silently, which would leave the
57
+ # rollup table with nothing enforcing one row per cell.
58
+ def primary_key_option
59
+ return ", id: :bigint" if surrogate_key?
60
+
61
+ ", primary_key: #{schema.primary_key.inspect}"
62
+ end
63
+
64
+ def key_column_lines
65
+ schema.definition.key_dimensions.map do |dimension|
66
+ type = types.dimension_type(dimension)
67
+ nullable = types.dimension_nullable?(dimension)
68
+ " t.#{type} :#{dimension.name}#{nullable ? "" : ", null: false"}"
69
+ end
70
+ end
71
+
72
+ # Measures default to zero and are never null. A null measure would poison
73
+ # every delta applied to it afterwards, since null plus one is null.
74
+ def measure_column_lines
75
+ schema.definition.measures.map do |measure|
76
+ " t.#{types.measure_type(measure)} :#{measure.name}, null: false, default: 0"
77
+ end
78
+ end
79
+
80
+ def uniqueness_index
81
+ return nil unless surrogate_key?
82
+
83
+ "add_index :#{table_name}, #{schema.key_columns.inspect}, unique: true,\n" \
84
+ " nulls_not_distinct: true, name: #{uniqueness_index_name.inspect}"
85
+ end
86
+
87
+ def truncate(identifier)
88
+ return identifier if identifier.bytesize <= MAX_IDENTIFIER
89
+
90
+ identifier[0, MAX_IDENTIFIER]
91
+ end
92
+ end
93
+ end
data/lib/grain/path.rb ADDED
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # A declared route from the fact table to a value: zero or more belongs_to
5
+ # association hops ending in a column.
6
+ #
7
+ # Path.parse(:user_id)
8
+ # # hops [], column :user_id
9
+ #
10
+ # Path.parse(testing_section: :school_id)
11
+ # # hops [:testing_section], column :school_id
12
+ #
13
+ # Path.parse(testing_section: { assessment_window: :starts_on })
14
+ # # hops [:testing_section, :assessment_window], column :starts_on
15
+ #
16
+ # Only belongs_to chains are allowed. Every fact row has to resolve to exactly
17
+ # one value per dimension: cross a has_many and a single row would land in
18
+ # several cells at once, silently doubling every count.
19
+ class Path
20
+ MAX_HOPS = 3
21
+
22
+ attr_reader :hops, :column
23
+
24
+ def self.parse(via)
25
+ hops, column = walk(via, [])
26
+ new(hops, column)
27
+ end
28
+
29
+ def self.walk(via, hops)
30
+ case via
31
+ when Symbol, String then [hops, via.to_sym]
32
+ when Hash then walk_hash(via, hops)
33
+ else
34
+ raise InvalidDefinitionError,
35
+ "via takes a column name or a nested hash of associations, got #{via.inspect}"
36
+ end
37
+ end
38
+ private_class_method :walk
39
+
40
+ def self.walk_hash(via, hops)
41
+ unless via.size == 1
42
+ raise InvalidDefinitionError,
43
+ "each via hop takes exactly one association, got #{via.keys.inspect}"
44
+ end
45
+
46
+ association, rest = via.first
47
+ walk(rest, hops + [association.to_sym])
48
+ end
49
+ private_class_method :walk_hash
50
+
51
+ def initialize(hops, column)
52
+ @hops = hops.map(&:to_sym).freeze
53
+ @column = column.to_sym
54
+ validate!
55
+ freeze
56
+ end
57
+
58
+ # A local path reads a column straight off the fact table, so no other table
59
+ # has to be watched to keep it correct.
60
+ def local?
61
+ hops.empty?
62
+ end
63
+
64
+ def root_hop
65
+ hops.first
66
+ end
67
+
68
+ def to_s
69
+ (hops + [column]).join(".")
70
+ end
71
+
72
+ def inspect
73
+ "#<Grain::Path #{self}>"
74
+ end
75
+
76
+ def ==(other)
77
+ other.is_a?(Path) && other.hops == hops && other.column == column
78
+ end
79
+ alias eql? ==
80
+
81
+ def hash
82
+ [hops, column].hash
83
+ end
84
+
85
+ private
86
+
87
+ def validate!
88
+ return if hops.size <= MAX_HOPS
89
+
90
+ raise InvalidDefinitionError, "#{self} crosses #{hops.size} associations, the limit is #{MAX_HOPS}"
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grain
4
+ # The SQL fragments every statement needs: the expression that produces each
5
+ # dimension's value, the aggregate that produces each measure, and the
6
+ # conditions the fact's filter imposes.
7
+ #
8
+ # Reaching the tables those expressions live on is JoinGraph's job.
9
+ class Projection
10
+ FACT = JoinGraph::FACT
11
+
12
+ attr_reader :definition, :types, :joins
13
+
14
+ def initialize(definition)
15
+ @definition = definition
16
+ @types = TypeResolver.new(definition)
17
+ @joins = JoinGraph.new(definition)
18
+ end
19
+
20
+ def fact_table
21
+ joins.fact_table
22
+ end
23
+
24
+ def alias_for(hops)
25
+ joins.alias_for(hops)
26
+ end
27
+
28
+ def from_and_joins(substitutions = {})
29
+ joins.from_and_joins(substitutions)
30
+ end
31
+
32
+ def hops_for_table(table)
33
+ joins.hops_for_table(table)
34
+ end
35
+
36
+ def key_columns
37
+ definition.key_dimensions.map(&:name)
38
+ end
39
+
40
+ def measure_columns
41
+ definition.measures.map(&:name)
42
+ end
43
+
44
+ def dimension_expressions
45
+ definition.key_dimensions.map { |dimension| dimension_expression(dimension) }
46
+ end
47
+
48
+ def dimension_expression(dimension)
49
+ column = qualified(dimension.path)
50
+ dimension.time? ? bucket(column, dimension) : column
51
+ end
52
+
53
+ def aggregate_expressions
54
+ definition.measures.map { |measure| aggregate_expression(measure) }
55
+ end
56
+
57
+ # Cast to the measure's declared type, and not for tidiness.
58
+ #
59
+ # SUM over a bigint yields numeric, so a recompute would insert a numeric into
60
+ # a bigint column and let Postgres truncate it, while a verification compared
61
+ # the untruncated value and reported a difference. A sum over anything with a
62
+ # fractional part would have disagreed with itself forever, and repair could
63
+ # never have settled it. Computing at the stored type makes both agree.
64
+ def aggregate_expression(measure)
65
+ "#{raw_aggregate(measure)}::#{sql_type(measure.type)}"
66
+ end
67
+
68
+ # Conditions from the fact's filter, already qualified. Equality only in the
69
+ # first release; anything richer belongs in the fact's own scope.
70
+ def filter_conditions
71
+ return [] unless definition.fact.where
72
+
73
+ definition.fact.where.flat_map { |key, value| conditions_for(key, value) }
74
+ end
75
+
76
+ private
77
+
78
+ def raw_aggregate(measure)
79
+ return "COUNT(*)" if measure.counts_rows?
80
+
81
+ "COALESCE(#{measure.aggregate.to_s.upcase}(#{measure.expression}), 0)"
82
+ end
83
+
84
+ def qualified(path)
85
+ %(#{alias_for(path.hops)}."#{path.column}")
86
+ end
87
+
88
+ # A calendar day already is a bucket. A timestamp is not, and resolving it
89
+ # needs an explicit zone: left to the session, the same row would land in
90
+ # different buckets for different callers.
91
+ def bucket(column, dimension)
92
+ return column if types.source_type(dimension) == :date
93
+
94
+ "((#{column} AT TIME ZONE '#{Grain.config.time_zone}')::date)"
95
+ end
96
+
97
+ def conditions_for(key, value)
98
+ return ["#{FACT}.\"#{key}\" = #{quote(value)}"] unless value.is_a?(Hash)
99
+
100
+ name = alias_for([key.to_sym])
101
+ value.map { |column, expected| "#{name}.\"#{column}\" = #{quote(expected)}" }
102
+ end
103
+
104
+ def quote(value)
105
+ connection.quote(value)
106
+ end
107
+
108
+ def sql_type(type)
109
+ connection.type_to_sql(type)
110
+ end
111
+
112
+ def connection
113
+ ActiveRecord::Base.connection
114
+ end
115
+ end
116
+ end