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.
@@ -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