portolan 0.1.0.pre.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/LICENSE.txt +21 -0
- data/README.md +202 -0
- data/lib/portolan/dataset.rb +131 -0
- data/lib/portolan/dimension/bucket.rb +37 -0
- data/lib/portolan/dimension.rb +105 -0
- data/lib/portolan/measure.rb +51 -0
- data/lib/portolan/query/aggregate.rb +165 -0
- data/lib/portolan/query/base.rb +116 -0
- data/lib/portolan/query/filter.rb +126 -0
- data/lib/portolan/query/guard.rb +38 -0
- data/lib/portolan/query/result.rb +39 -0
- data/lib/portolan/registry.rb +66 -0
- data/lib/portolan/type/base.rb +17 -0
- data/lib/portolan/type/boolean.rb +13 -0
- data/lib/portolan/type/date.rb +33 -0
- data/lib/portolan/type/date_time.rb +15 -0
- data/lib/portolan/type/decimal.rb +24 -0
- data/lib/portolan/type/enum.rb +22 -0
- data/lib/portolan/type/integer.rb +27 -0
- data/lib/portolan/type/string.rb +13 -0
- data/lib/portolan/type.rb +17 -0
- data/lib/portolan/version.rb +5 -0
- data/lib/portolan.rb +32 -0
- metadata +108 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portolan
|
|
4
|
+
module Query
|
|
5
|
+
class Aggregate < Base
|
|
6
|
+
PARAMS = (Base::PARAMS + %w[measure group_by period]).freeze
|
|
7
|
+
MAX_GROUPS = 3
|
|
8
|
+
MAX_BUCKET_COMBINATIONS = 50
|
|
9
|
+
GROUP_LIMIT = 10_000
|
|
10
|
+
TOO_MANY_GROUPS = "more than #{GROUP_LIMIT} groups; filter or group by fewer dimensions".freeze
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
attr_reader :measure, :group_names, :group_dimensions, :time_dimension
|
|
15
|
+
|
|
16
|
+
def resolve_request
|
|
17
|
+
@group_names = parse_group_by
|
|
18
|
+
return unless dataset
|
|
19
|
+
|
|
20
|
+
@measure = dataset.measures[@params['measure']]
|
|
21
|
+
@group_dimensions = Array(group_names).filter_map { |name| dataset.dimensions[name] }
|
|
22
|
+
@time_dimension = @group_dimensions.find(&:time?)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def parse_group_by
|
|
26
|
+
case @params['group_by']
|
|
27
|
+
when nil then []
|
|
28
|
+
when ::String then [@params['group_by']]
|
|
29
|
+
when ::Array then @params['group_by'].map(&:to_s)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_request
|
|
34
|
+
validate_measure
|
|
35
|
+
validate_group_by
|
|
36
|
+
validate_period
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_measure
|
|
40
|
+
return if measure
|
|
41
|
+
|
|
42
|
+
problem = @params['measure'] ? "unknown measure `#{@params['measure']}`" : 'measure is required'
|
|
43
|
+
errors << "#{problem}; allowed: #{Portolan.quoted(dataset.measures.keys)}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_group_by
|
|
47
|
+
return errors << 'group_by must be a list of dimension names' unless group_names
|
|
48
|
+
|
|
49
|
+
(group_names - dataset.dimensions.keys).each do |name|
|
|
50
|
+
errors << "unknown dimension `#{name}` in group_by; allowed: #{Portolan.quoted(dataset.dimensions.keys)}"
|
|
51
|
+
end
|
|
52
|
+
group_names.tally.each { |name, count| errors << "group_by names `#{name}` twice" if count > 1 }
|
|
53
|
+
errors << "group_by takes at most #{MAX_GROUPS} dimensions" if group_names.size > MAX_GROUPS
|
|
54
|
+
group_dimensions.select(&:time?).drop(1).each do |dimension|
|
|
55
|
+
errors << "group_by takes at most one date dimension; filter on `#{dimension.name}` instead"
|
|
56
|
+
end
|
|
57
|
+
validate_bucket_combinations
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_bucket_combinations
|
|
61
|
+
combinations = group_dimensions.select(&:buckets?).map { |dimension| dimension.buckets.size }.reduce(1, :*)
|
|
62
|
+
return if combinations <= MAX_BUCKET_COMBINATIONS
|
|
63
|
+
|
|
64
|
+
errors << "group_by makes #{combinations} bucket combinations; at most #{MAX_BUCKET_COMBINATIONS} " \
|
|
65
|
+
'are allowed, so filter on a bucket dimension instead'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate_period
|
|
69
|
+
return errors << 'period only applies when grouping by a date dimension' if period && !time_dimension
|
|
70
|
+
return if time_dimension.nil? || time_dimension.periods.include?(period)
|
|
71
|
+
|
|
72
|
+
problem = period ? "period `#{period}` does not apply to" : 'period is required when grouping by'
|
|
73
|
+
errors << "#{problem} `#{time_dimension.name}`; allowed: #{Portolan.quoted(time_dimension.periods)}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def period = @params['period']
|
|
77
|
+
|
|
78
|
+
def answer(scope)
|
|
79
|
+
rows = measured_rows(scope)
|
|
80
|
+
raise Refused, TOO_MANY_GROUPS if rows.size > GROUP_LIMIT
|
|
81
|
+
|
|
82
|
+
rows = sorted(rows)
|
|
83
|
+
Result.new(request:, rows: trimmed(rows), definitions:, total_rows: rows.size)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def measured_rows(scope)
|
|
87
|
+
bucket_dimensions, column_dimensions = group_dimensions.partition(&:buckets?)
|
|
88
|
+
rows = []
|
|
89
|
+
|
|
90
|
+
bucket_combinations(bucket_dimensions).each do |combination|
|
|
91
|
+
narrowed = combination.reduce(scope) { |current, (_dimension, bucket)| bucket.apply(current) }
|
|
92
|
+
bucket_labels = combination.to_h { |dimension, bucket| [dimension.name, bucket.name] }
|
|
93
|
+
grouped_values(narrowed, column_dimensions).each do |keys, value|
|
|
94
|
+
rows << row(bucket_labels, column_dimensions.zip(keys), value)
|
|
95
|
+
end
|
|
96
|
+
break if rows.size > GROUP_LIMIT
|
|
97
|
+
end
|
|
98
|
+
rows
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def bucket_combinations(dimensions)
|
|
102
|
+
return [[]] if dimensions.empty?
|
|
103
|
+
|
|
104
|
+
lists = dimensions.map { |dimension| allowed_buckets(dimension).map { |bucket| [dimension, bucket] } }
|
|
105
|
+
lists.first.product(*lists.drop(1))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def allowed_buckets(dimension)
|
|
109
|
+
value_lists = filters.select { |filter| filter.dimension == dimension }.map(&:values)
|
|
110
|
+
names = value_lists.reduce(dimension.buckets.keys) { |kept, values| kept & values }
|
|
111
|
+
dimension.buckets.values_at(*names)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def grouped_values(scope, dimensions)
|
|
115
|
+
return [[[], measure.call(scope)]] if dimensions.empty?
|
|
116
|
+
|
|
117
|
+
grouped = dimensions.reduce(scope) do |current, dimension|
|
|
118
|
+
current = dimension.join(current)
|
|
119
|
+
current.group(group_expression(current, dimension))
|
|
120
|
+
end
|
|
121
|
+
measure.call(grouped.limit(GROUP_LIMIT + 1)).map { |keys, value| [dimensions.one? ? [keys] : keys, value] }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def group_expression(scope, dimension) = dimension.type.group(dimension.column(scope), period)
|
|
125
|
+
|
|
126
|
+
def row(bucket_labels, keyed, value)
|
|
127
|
+
labels = bucket_labels.merge(keyed.to_h { |dimension, key| [dimension.name, label(dimension, key)] })
|
|
128
|
+
grouped = group_dimensions.to_h { |dimension| [dimension.name, labels[dimension.name]] }
|
|
129
|
+
grouped.merge(measure.name => measure.label(value))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def label(dimension, key) = key.nil? ? nil : dimension.type.label(key, period)
|
|
133
|
+
|
|
134
|
+
def sorted(rows)
|
|
135
|
+
rows.sort_by { |row| [*period_order(row), -row[measure.name].to_f, row.values.map(&:to_s)] }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def period_order(row)
|
|
139
|
+
return [] unless time_dimension
|
|
140
|
+
|
|
141
|
+
label = row[time_dimension.name]
|
|
142
|
+
[label.nil? ? 0 : 1, label.to_s]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def trimmed(rows)
|
|
146
|
+
return rows.first(limit) unless time_dimension
|
|
147
|
+
|
|
148
|
+
periods = rows.chunk_while { |earlier, later| earlier[time_dimension.name] == later[time_dimension.name] }.to_a
|
|
149
|
+
kept = []
|
|
150
|
+
periods.reverse_each do |period_rows|
|
|
151
|
+
break if kept.size + period_rows.size > limit
|
|
152
|
+
|
|
153
|
+
kept = period_rows + kept
|
|
154
|
+
end
|
|
155
|
+
kept.empty? ? periods.last.to_a.first(limit) : kept
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def definitions
|
|
159
|
+
definitions_for(group_dimensions).merge(measure.name => { description: measure.description })
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def request = super.merge(measure: @params['measure'], group_by: Array(group_names).presence, period:)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portolan
|
|
4
|
+
module Query
|
|
5
|
+
class Base
|
|
6
|
+
PARAMS = %w[dataset filters limit].freeze
|
|
7
|
+
ROW_LIMIT = 50
|
|
8
|
+
UNSCOPED = %i[order select limit offset includes preload eager_load].freeze
|
|
9
|
+
INTEGER = Type::Integer.new
|
|
10
|
+
|
|
11
|
+
attr_reader :errors
|
|
12
|
+
|
|
13
|
+
def initialize(registry, params, context:)
|
|
14
|
+
@registry = registry
|
|
15
|
+
params = normalize(params)
|
|
16
|
+
@shape_error = !params.is_a?(Hash)
|
|
17
|
+
@params = @shape_error ? {} : params
|
|
18
|
+
@context = context
|
|
19
|
+
@filters = []
|
|
20
|
+
resolve
|
|
21
|
+
@errors = []
|
|
22
|
+
validate
|
|
23
|
+
@errors.freeze
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def valid? = errors.empty?
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
return Result.new(request:, errors:) unless valid?
|
|
31
|
+
|
|
32
|
+
relation = dataset.relation_for(@context).unscope(*UNSCOPED)
|
|
33
|
+
Guard.run(relation.klass) { answer(filtered(relation)) }
|
|
34
|
+
rescue Refused => e
|
|
35
|
+
Result.new(request:, errors: [e.message])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :dataset, :filters
|
|
41
|
+
|
|
42
|
+
def normalize(params)
|
|
43
|
+
params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
|
|
44
|
+
case params
|
|
45
|
+
when ::Hash then params.to_h { |key, value| [key.to_s, normalize(value)] }
|
|
46
|
+
when ::Array then params.map { |value| normalize(value) }
|
|
47
|
+
when ::Symbol then params.to_s
|
|
48
|
+
else params
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve
|
|
53
|
+
@dataset = @registry[@params['dataset']]
|
|
54
|
+
@filters = filter_params.map { |params| Filter.new(@dataset, params) } if @dataset
|
|
55
|
+
resolve_request
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve_request; end
|
|
59
|
+
|
|
60
|
+
def validate
|
|
61
|
+
validate_shape
|
|
62
|
+
validate_keys if valid?
|
|
63
|
+
validate_dataset if valid?
|
|
64
|
+
return unless valid?
|
|
65
|
+
|
|
66
|
+
validate_request
|
|
67
|
+
validate_filters
|
|
68
|
+
validate_limit
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_request; end
|
|
72
|
+
|
|
73
|
+
def validate_shape
|
|
74
|
+
errors << 'request must be an object' if @shape_error
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_keys
|
|
78
|
+
unknown = @params.keys - self.class::PARAMS
|
|
79
|
+
return if unknown.empty?
|
|
80
|
+
|
|
81
|
+
errors << "unknown key #{Portolan.quoted(unknown)}; allowed: #{Portolan.quoted(self.class::PARAMS)}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_dataset
|
|
85
|
+
return if dataset
|
|
86
|
+
|
|
87
|
+
problem = @params['dataset'] ? "unknown dataset `#{@params['dataset']}`" : 'dataset is required'
|
|
88
|
+
errors << "#{problem}; allowed: #{Portolan.quoted(@registry.names)}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_filters
|
|
92
|
+
return if @params['filters'].nil?
|
|
93
|
+
return errors << 'filters must be a list' unless @params['filters'].is_a?(::Array)
|
|
94
|
+
|
|
95
|
+
filters.each.with_index(1) do |filter, number|
|
|
96
|
+
filter.errors.each { |error| errors << "filter #{number}: #{error}" }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_limit
|
|
101
|
+
errors << "limit must be an integer from 1 to #{ROW_LIMIT}" unless (1..ROW_LIMIT).cover?(limit)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def filter_params = @params['filters'].is_a?(::Array) ? @params['filters'] : []
|
|
105
|
+
def limit = @params['limit'].nil? ? ROW_LIMIT : INTEGER.cast(@params['limit'])
|
|
106
|
+
|
|
107
|
+
def filtered(relation) = filters.reduce(relation) { |current, filter| filter.apply(current) }
|
|
108
|
+
|
|
109
|
+
def definitions_for(dimensions)
|
|
110
|
+
(dimensions + filters.map(&:dimension)).uniq.to_h { |dimension| [dimension.name, dimension.definition] }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def request = { dataset: @params['dataset'] }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portolan
|
|
4
|
+
module Query
|
|
5
|
+
class Filter
|
|
6
|
+
PARAMS = %w[dimension op value values].freeze
|
|
7
|
+
OPERATOR_ARITIES = {
|
|
8
|
+
'eq' => [1..1, 'one value'],
|
|
9
|
+
'gt' => [1..1, 'one value'],
|
|
10
|
+
'lt' => [1..1, 'one value'],
|
|
11
|
+
'in' => [1.., 'one or more values'],
|
|
12
|
+
'between' => [2..2, 'two values'],
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :dimension, :operator, :values, :errors
|
|
16
|
+
|
|
17
|
+
def initialize(dataset, params)
|
|
18
|
+
@dataset = dataset
|
|
19
|
+
@shape_error = !params.is_a?(Hash)
|
|
20
|
+
@params = @shape_error ? {} : params
|
|
21
|
+
@dimension = dataset.dimensions[@params['dimension']]
|
|
22
|
+
@operator = @params['op']
|
|
23
|
+
@errors = []
|
|
24
|
+
validate
|
|
25
|
+
@errors.freeze
|
|
26
|
+
@values = requested_values.map { |value| type.cast(value) }.freeze if valid?
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def valid? = errors.empty?
|
|
31
|
+
|
|
32
|
+
def apply(scope)
|
|
33
|
+
return apply_buckets(scope) if dimension.buckets?
|
|
34
|
+
|
|
35
|
+
scope = dimension.join(scope)
|
|
36
|
+
dimension.expression ? scope.where(expression_condition(scope)) : column_condition(scope)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def validate
|
|
42
|
+
validate_shape
|
|
43
|
+
validate_keys if valid?
|
|
44
|
+
validate_dimension if valid?
|
|
45
|
+
validate_operator if valid?
|
|
46
|
+
validate_arity if valid?
|
|
47
|
+
validate_values if valid?
|
|
48
|
+
validate_order if valid?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_shape
|
|
52
|
+
errors << 'must be an object with `dimension`, `op`, and `value` or `values`' if @shape_error
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_keys
|
|
56
|
+
unknown = @params.keys - PARAMS
|
|
57
|
+
errors << "unknown key #{Portolan.quoted(unknown)}; allowed: #{Portolan.quoted(PARAMS)}" if unknown.any?
|
|
58
|
+
errors << 'give `value` or `values`, not both' if @params.key?('value') && @params.key?('values')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_dimension
|
|
62
|
+
return if dimension
|
|
63
|
+
|
|
64
|
+
errors << "unknown dimension `#{@params['dimension']}`; allowed: #{Portolan.quoted(@dataset.dimensions.keys)}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_operator
|
|
68
|
+
return if dimension.operators.include?(operator)
|
|
69
|
+
|
|
70
|
+
errors << "operator `#{operator}` does not apply to `#{dimension.name}`; " \
|
|
71
|
+
"allowed: #{Portolan.quoted(dimension.operators)}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_arity
|
|
75
|
+
counts, words = OPERATOR_ARITIES.fetch(operator)
|
|
76
|
+
errors << "`#{operator}` takes #{words}" unless counts.cover?(requested_values.size)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_values
|
|
80
|
+
requested_values.each do |value|
|
|
81
|
+
type.errors_for(value).each { |error| errors << "`#{dimension.name}`: #{error}" }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_order
|
|
86
|
+
return unless operator == 'between'
|
|
87
|
+
return if type.cast(requested_values.first) <= type.cast(requested_values.last)
|
|
88
|
+
|
|
89
|
+
errors << '`between` needs the smaller value first'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def type = dimension.type
|
|
93
|
+
def requested_values = Array(@params.key?('values') ? @params['values'] : @params['value'])
|
|
94
|
+
|
|
95
|
+
def column_condition(scope)
|
|
96
|
+
case operator
|
|
97
|
+
when 'eq', 'in' then scope.where(dimension.name => values)
|
|
98
|
+
when 'gt' then scope.where.not(dimension.name => ..upper_bound)
|
|
99
|
+
when 'lt' then scope.where(dimension.name => ...lower_bound)
|
|
100
|
+
when 'between' then scope.where(dimension.name => lower_bound..upper_bound)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def expression_condition(scope)
|
|
105
|
+
expression = dimension.column(scope)
|
|
106
|
+
case operator
|
|
107
|
+
when 'eq', 'in' then expression.in(values.map { |value| bound(value) })
|
|
108
|
+
when 'gt' then expression.gt(bound(upper_bound))
|
|
109
|
+
when 'lt' then expression.lt(bound(lower_bound))
|
|
110
|
+
when 'between' then expression.gteq(bound(lower_bound)).and(expression.lteq(bound(upper_bound)))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def lower_bound = type.lower_bound(values.first)
|
|
115
|
+
def upper_bound = type.upper_bound(values.last)
|
|
116
|
+
|
|
117
|
+
def bound(value)
|
|
118
|
+
Arel::Nodes::BindParam.new(ActiveRecord::Relation::QueryAttribute.new(dimension.name, value, ActiveModel::Type::Value.new))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def apply_buckets(scope)
|
|
122
|
+
values.map { |name| dimension.buckets.fetch(name).apply(scope) }.reduce(:or)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portolan
|
|
4
|
+
module Query
|
|
5
|
+
class Refused < StandardError; end
|
|
6
|
+
|
|
7
|
+
module Guard
|
|
8
|
+
TIMEOUT_MS = 5_000
|
|
9
|
+
TIMEOUT_ERROR = 'the query took too long; filter to a shorter period or group by fewer dimensions'
|
|
10
|
+
|
|
11
|
+
def self.run(model, timeout_ms: TIMEOUT_MS)
|
|
12
|
+
result = nil
|
|
13
|
+
outermost = !model.connection.transaction_open?
|
|
14
|
+
model.connection_class_for_self.while_preventing_writes do
|
|
15
|
+
model.transaction(requires_new: true) do
|
|
16
|
+
model.connection.execute(settings(timeout_ms, outermost))
|
|
17
|
+
result = yield
|
|
18
|
+
raise ActiveRecord::Rollback
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
result
|
|
22
|
+
rescue ActiveRecord::QueryCanceled
|
|
23
|
+
raise Refused, TIMEOUT_ERROR
|
|
24
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
25
|
+
raise unless e.cause.is_a?(PG::DataException)
|
|
26
|
+
|
|
27
|
+
raise Refused, "the database could not use a value: #{e.cause.message.lines.first.sub(/\AERROR:\s*/, '').strip}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.settings(timeout_ms, outermost)
|
|
31
|
+
statements = ["SET LOCAL statement_timeout = #{Integer(timeout_ms)}", "SET LOCAL TIME ZONE 'UTC'"]
|
|
32
|
+
statements << 'SET LOCAL transaction_read_only = on' if outermost
|
|
33
|
+
statements.join('; ')
|
|
34
|
+
end
|
|
35
|
+
private_class_method :settings
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portolan
|
|
4
|
+
module Query
|
|
5
|
+
class Result
|
|
6
|
+
attr_reader :request, :rows, :definitions, :total_rows, :errors
|
|
7
|
+
|
|
8
|
+
def initialize(request:, rows: [], definitions: {}, total_rows: rows.size, errors: [])
|
|
9
|
+
@request = request.compact.freeze
|
|
10
|
+
@rows = rows.freeze
|
|
11
|
+
@definitions = definitions.freeze
|
|
12
|
+
@total_rows = total_rows
|
|
13
|
+
@errors = errors.freeze
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def success? = errors.empty?
|
|
18
|
+
def truncated? = total_rows > rows.size
|
|
19
|
+
|
|
20
|
+
def notes
|
|
21
|
+
messages = []
|
|
22
|
+
messages << truncation_note if truncated?
|
|
23
|
+
messages << 'Periods with no rows are omitted.' if request[:period] && success?
|
|
24
|
+
messages
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
details = { truncated: truncated?, notes:, definitions:, errors: }.compact_blank
|
|
29
|
+
success? ? { **request, rows:, **details } : { **request, **details }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def truncation_note
|
|
35
|
+
"Showing the #{request[:period] ? 'newest' : 'largest'} #{rows.size} of #{total_rows} rows."
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portolan
|
|
4
|
+
class Registry
|
|
5
|
+
NAME = /\A[a-z][a-z0-9_]*\z/
|
|
6
|
+
|
|
7
|
+
def initialize(datasets)
|
|
8
|
+
@datasets = build(datasets)
|
|
9
|
+
check_examples!
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def names = @datasets.keys
|
|
14
|
+
def [](name) = @datasets[name.to_s]
|
|
15
|
+
|
|
16
|
+
def list
|
|
17
|
+
@datasets.map { |name, dataset| { name:, description: dataset.description } }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def describe(name)
|
|
21
|
+
dataset = self[name]
|
|
22
|
+
{ name: name.to_s, **dataset.describe } if dataset
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def check!
|
|
26
|
+
@datasets.each_value(&:check_columns!)
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def aggregate(params, context:)
|
|
31
|
+
Query::Aggregate.new(self, params, context:).call
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build(datasets)
|
|
37
|
+
invalid!('needs a hash of names to datasets') unless datasets.is_a?(Hash) && datasets.any?
|
|
38
|
+
duplicate = datasets.keys.map(&:to_s).tally.find { |_, count| count > 1 }&.first
|
|
39
|
+
invalid!("dataset `#{duplicate}` is registered twice") if duplicate
|
|
40
|
+
|
|
41
|
+
datasets.to_h { |name, dataset| [name.to_s, checked(name.to_s, dataset)] }.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def check_examples!
|
|
45
|
+
@datasets.each do |name, dataset|
|
|
46
|
+
dataset.examples.each.with_index(1) do |example, number|
|
|
47
|
+
query = Query::Aggregate.new(self, example.query.merge(dataset: name), context: nil)
|
|
48
|
+
next if query.valid?
|
|
49
|
+
|
|
50
|
+
invalid!("dataset `#{name}`: example #{number} is not a valid request: #{query.errors.join('; ')}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def checked(name, dataset)
|
|
56
|
+
invalid!("dataset name `#{name}` must be lower case letters, digits and underscores") unless name.match?(NAME)
|
|
57
|
+
invalid!("dataset `#{name}` is not a Portolan::Dataset") unless dataset.is_a?(Class) && dataset < Dataset
|
|
58
|
+
dataset.check!
|
|
59
|
+
dataset
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def invalid!(problem)
|
|
63
|
+
raise ArgumentError, "registry: #{problem}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portolan
|
|
4
|
+
module Type
|
|
5
|
+
class Base
|
|
6
|
+
PERIODS = [].freeze
|
|
7
|
+
|
|
8
|
+
def operators = self.class::OPERATORS
|
|
9
|
+
def periods = self.class::PERIODS
|
|
10
|
+
def values = nil
|
|
11
|
+
def group(column, _period) = column
|
|
12
|
+
def lower_bound(value) = value
|
|
13
|
+
def upper_bound(value) = value
|
|
14
|
+
def label(value, _period = nil) = value
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portolan
|
|
4
|
+
module Type
|
|
5
|
+
class Boolean < Base
|
|
6
|
+
OPERATORS = %w[eq].freeze
|
|
7
|
+
|
|
8
|
+
def name = 'boolean'
|
|
9
|
+
def errors_for(value) = [true, false, 'true', 'false'].include?(value) ? [] : ["`#{value}` is not true or false"]
|
|
10
|
+
def cast(value) = [true, 'true'].include?(value)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portolan
|
|
4
|
+
module Type
|
|
5
|
+
class Date < Base
|
|
6
|
+
OPERATORS = %w[gt lt between].freeze
|
|
7
|
+
PERIODS = %w[day week month quarter year].freeze
|
|
8
|
+
|
|
9
|
+
def name = 'date'
|
|
10
|
+
def errors_for(value) = cast(value)&.year&.positive? ? [] : ["`#{value}` is not a date; use YYYY-MM-DD"]
|
|
11
|
+
def group(column, period) = truncate(column, period)
|
|
12
|
+
def truncate(column, period) = Arel::Nodes::NamedFunction.new('CAST', [date_trunc(column, period).as('date')])
|
|
13
|
+
def label(value, _period = nil) = value.strftime('%Y-%m-%d')
|
|
14
|
+
|
|
15
|
+
def cast(value)
|
|
16
|
+
return unless value.is_a?(::String)
|
|
17
|
+
|
|
18
|
+
date = ::Date.strptime(value, '%Y-%m-%d')
|
|
19
|
+
date if date.strftime('%Y-%m-%d') == value
|
|
20
|
+
rescue ::Date::Error
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def date_trunc(column, period)
|
|
27
|
+
raise ArgumentError, "period `#{period}` does not apply to #{name}" unless periods.include?(period)
|
|
28
|
+
|
|
29
|
+
Arel::Nodes::NamedFunction.new('date_trunc', [Arel::Nodes.build_quoted(period), column])
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portolan
|
|
4
|
+
module Type
|
|
5
|
+
class DateTime < Date
|
|
6
|
+
PERIODS = ['hour', *Date::PERIODS].freeze
|
|
7
|
+
|
|
8
|
+
def name = 'datetime'
|
|
9
|
+
def lower_bound(date) = ::Time.utc(date.year, date.month, date.day)
|
|
10
|
+
def upper_bound(date) = lower_bound(date).end_of_day
|
|
11
|
+
def truncate(column, period) = date_trunc(column, period)
|
|
12
|
+
def label(value, period = nil) = period == 'hour' ? value.utc.strftime('%Y-%m-%dT%H:00Z') : super(value.utc)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bigdecimal'
|
|
4
|
+
|
|
5
|
+
module Portolan
|
|
6
|
+
module Type
|
|
7
|
+
class Decimal < Base
|
|
8
|
+
OPERATORS = %w[eq in gt lt between].freeze
|
|
9
|
+
|
|
10
|
+
def name = 'decimal'
|
|
11
|
+
def errors_for(value) = cast(value) ? [] : ["`#{value}` is not a number"]
|
|
12
|
+
def label(value, _period = nil) = value.to_f
|
|
13
|
+
|
|
14
|
+
def cast(value)
|
|
15
|
+
number = case value
|
|
16
|
+
when ::Integer, ::BigDecimal then BigDecimal(value)
|
|
17
|
+
when ::Float then BigDecimal(value.to_s)
|
|
18
|
+
when ::String then BigDecimal(value, exception: false)
|
|
19
|
+
end
|
|
20
|
+
number if number&.finite?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Portolan
|
|
4
|
+
module Type
|
|
5
|
+
class Enum < String
|
|
6
|
+
attr_reader :values
|
|
7
|
+
|
|
8
|
+
def initialize(values)
|
|
9
|
+
super()
|
|
10
|
+
@values = values.map(&:to_s).freeze
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def errors_for(value)
|
|
15
|
+
errors = super
|
|
16
|
+
return errors if errors.any? || values.include?(value.to_s)
|
|
17
|
+
|
|
18
|
+
["unknown value `#{value}`; allowed: #{Portolan.quoted(values)}"]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|