grain 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +447 -0
- data/Rakefile +12 -0
- data/lib/generators/grain/install/install_generator.rb +78 -0
- data/lib/generators/grain/install/templates/create_grain_change_log.rb.erb +22 -0
- data/lib/generators/grain/install/templates/initializer.rb.erb +17 -0
- data/lib/generators/grain/rollup/rollup_generator.rb +39 -0
- data/lib/generators/grain/rollup/templates/rollup.rb.erb +40 -0
- data/lib/generators/grain/table/table_generator.rb +111 -0
- data/lib/generators/grain/table/templates/create_rollup_table.rb.erb +20 -0
- data/lib/grain/backfill.rb +77 -0
- data/lib/grain/cells.rb +66 -0
- data/lib/grain/change_log.rb +100 -0
- data/lib/grain/configuration.rb +34 -0
- data/lib/grain/definition.rb +140 -0
- data/lib/grain/definition_validator.rb +58 -0
- data/lib/grain/dimension.rb +62 -0
- data/lib/grain/discrepancy.rb +48 -0
- data/lib/grain/errors.rb +25 -0
- data/lib/grain/fact.rb +46 -0
- data/lib/grain/join_graph.rb +90 -0
- data/lib/grain/measure.rb +100 -0
- data/lib/grain/migration.rb +93 -0
- data/lib/grain/path.rb +93 -0
- data/lib/grain/projection.rb +116 -0
- data/lib/grain/query.rb +147 -0
- data/lib/grain/query_sql.rb +100 -0
- data/lib/grain/railtie.rb +21 -0
- data/lib/grain/ratio.rb +28 -0
- data/lib/grain/recompute.rb +101 -0
- data/lib/grain/registry.rb +84 -0
- data/lib/grain/rollup.rb +90 -0
- data/lib/grain/rollup_lookup.rb +41 -0
- data/lib/grain/schema.rb +63 -0
- data/lib/grain/triggers.rb +99 -0
- data/lib/grain/type_resolver.rb +83 -0
- data/lib/grain/verification.rb +73 -0
- data/lib/grain/verification_query.rb +134 -0
- data/lib/grain/verification_report.rb +48 -0
- data/lib/grain/version.rb +5 -0
- data/lib/grain/watched_columns.rb +70 -0
- data/lib/grain/worker.rb +109 -0
- data/lib/grain.rb +60 -0
- data/lib/tasks/grain.rake +33 -0
- data/sig/grain.rbs +4 -0
- metadata +125 -0
|
@@ -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
|
data/lib/grain/errors.rb
ADDED
|
@@ -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
|