dry-types 0.14.1 → 0.15.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.codeclimate.yml +15 -0
- data/.rubocop.yml +43 -0
- data/.travis.yml +12 -11
- data/CHANGELOG.md +115 -4
- data/Gemfile +2 -3
- data/benchmarks/hash_schemas.rb +5 -5
- data/dry-types.gemspec +1 -1
- data/lib/dry/types.rb +67 -45
- data/lib/dry/types/any.rb +11 -2
- data/lib/dry/types/array.rb +1 -4
- data/lib/dry/types/array/member.rb +2 -2
- data/lib/dry/types/builder.rb +23 -3
- data/lib/dry/types/builder_methods.rb +10 -11
- data/lib/dry/types/coercions/params.rb +2 -0
- data/lib/dry/types/compat.rb +0 -2
- data/lib/dry/types/compiler.rb +25 -33
- data/lib/dry/types/constrained.rb +5 -4
- data/lib/dry/types/constructor.rb +32 -11
- data/lib/dry/types/container.rb +2 -0
- data/lib/dry/types/core.rb +22 -12
- data/lib/dry/types/default.rb +4 -4
- data/lib/dry/types/enum.rb +10 -3
- data/lib/dry/types/errors.rb +1 -1
- data/lib/dry/types/extensions/maybe.rb +11 -1
- data/lib/dry/types/hash.rb +70 -63
- data/lib/dry/types/hash/constructor.rb +20 -0
- data/lib/dry/types/json.rb +7 -7
- data/lib/dry/types/map.rb +6 -1
- data/lib/dry/types/module.rb +115 -0
- data/lib/dry/types/{definition.rb → nominal.rb} +10 -4
- data/lib/dry/types/options.rb +2 -2
- data/lib/dry/types/params.rb +11 -11
- data/lib/dry/types/printable.rb +12 -0
- data/lib/dry/types/printer.rb +309 -0
- data/lib/dry/types/result.rb +2 -2
- data/lib/dry/types/safe.rb +4 -2
- data/lib/dry/types/schema.rb +298 -0
- data/lib/dry/types/schema/key.rb +130 -0
- data/lib/dry/types/spec/types.rb +12 -4
- data/lib/dry/types/sum.rb +14 -15
- data/lib/dry/types/version.rb +1 -1
- metadata +18 -8
- data/lib/dry/types/compat/form_types.rb +0 -27
- data/lib/dry/types/compat/int.rb +0 -14
- data/lib/dry/types/hash/schema.rb +0 -199
- data/lib/dry/types/hash/schema_builder.rb +0 -75
data/lib/dry/types/safe.rb
CHANGED
@@ -4,9 +4,11 @@ module Dry
|
|
4
4
|
module Types
|
5
5
|
class Safe
|
6
6
|
include Type
|
7
|
-
include Dry::Equalizer(:type)
|
8
7
|
include Decorator
|
9
8
|
include Builder
|
9
|
+
include Printable
|
10
|
+
include Dry::Equalizer(:type, inspect: false)
|
11
|
+
|
10
12
|
private :options, :meta
|
11
13
|
|
12
14
|
# @param [Object] input
|
@@ -36,7 +38,7 @@ module Dry
|
|
36
38
|
|
37
39
|
# @api public
|
38
40
|
#
|
39
|
-
# @see
|
41
|
+
# @see Nominal#to_ast
|
40
42
|
def to_ast(meta: true)
|
41
43
|
[:safe, [type.to_ast(meta: meta), EMPTY_HASH]]
|
42
44
|
end
|
@@ -0,0 +1,298 @@
|
|
1
|
+
require 'dry/types/fn_container'
|
2
|
+
|
3
|
+
module Dry
|
4
|
+
module Types
|
5
|
+
# The built-in Hash type can be defined in terms of keys and associated types
|
6
|
+
# its values can contain. Such definitions are named {Schema}s and defined
|
7
|
+
# as lists of {Key} types.
|
8
|
+
#
|
9
|
+
# @see Dry::Types::Schema::Key
|
10
|
+
#
|
11
|
+
# {Schema} evaluates default values for keys missing in input hash
|
12
|
+
#
|
13
|
+
# @see Dry::Types::Default#evaluate
|
14
|
+
# @see Dry::Types::Default::Callable#evaluate
|
15
|
+
#
|
16
|
+
# {Schema} implements Enumerable using its keys as collection.
|
17
|
+
class Schema < Hash
|
18
|
+
NO_TRANSFORM = Dry::Types::FnContainer.register { |x| x }
|
19
|
+
SYMBOLIZE_KEY = Dry::Types::FnContainer.register(:to_sym.to_proc)
|
20
|
+
|
21
|
+
include ::Enumerable
|
22
|
+
|
23
|
+
# @return [Array[Dry::Types::Schema::Key]]
|
24
|
+
attr_reader :keys
|
25
|
+
|
26
|
+
# @return [Hash[Symbol, Dry::Types::Schema::Key]]
|
27
|
+
attr_reader :name_key_map
|
28
|
+
|
29
|
+
# @return [#call]
|
30
|
+
attr_reader :transform_key
|
31
|
+
|
32
|
+
# @param [Class] _primitive
|
33
|
+
# @param [Hash] options
|
34
|
+
# @option options [Array[Dry::Types::Schema::Key]] :keys
|
35
|
+
# @option options [String] :key_transform_fn
|
36
|
+
def initialize(_primitive, **options)
|
37
|
+
@keys = options.fetch(:keys)
|
38
|
+
@name_key_map = keys.each_with_object({}) do |key, idx|
|
39
|
+
idx[key.name] = key
|
40
|
+
end
|
41
|
+
|
42
|
+
key_fn = options.fetch(:key_transform_fn, NO_TRANSFORM)
|
43
|
+
|
44
|
+
@transform_key = Dry::Types::FnContainer[key_fn]
|
45
|
+
|
46
|
+
super
|
47
|
+
end
|
48
|
+
|
49
|
+
# @param [Hash] hash
|
50
|
+
# @return [Hash{Symbol => Object}]
|
51
|
+
def call(hash)
|
52
|
+
coerce(hash)
|
53
|
+
end
|
54
|
+
alias_method :[], :call
|
55
|
+
|
56
|
+
# @param [Hash] hash
|
57
|
+
# @option options [Boolean] :skip_missing If true don't raise error if on missing keys
|
58
|
+
# @option options [Boolean] :resolve_defaults If false default value
|
59
|
+
# won't be evaluated for missing key
|
60
|
+
# @return [Hash{Symbol => Object}]
|
61
|
+
def apply(hash, options = EMPTY_HASH)
|
62
|
+
coerce(hash, options)
|
63
|
+
end
|
64
|
+
|
65
|
+
# @param [Hash] hash
|
66
|
+
# @yieldparam [Failure] failure
|
67
|
+
# @yieldreturn [Result]
|
68
|
+
# @return [Logic::Result]
|
69
|
+
# @return [Object] if coercion fails and a block is given
|
70
|
+
def try(hash)
|
71
|
+
if hash.is_a?(::Hash)
|
72
|
+
success = true
|
73
|
+
output = {}
|
74
|
+
|
75
|
+
begin
|
76
|
+
result = try_coerce(hash) do |key, key_result|
|
77
|
+
success &&= key_result.success?
|
78
|
+
output[key.name] = key_result.input
|
79
|
+
|
80
|
+
key_result
|
81
|
+
end
|
82
|
+
rescue ConstraintError, UnknownKeysError, SchemaError, MissingKeyError => e
|
83
|
+
success = false
|
84
|
+
result = e
|
85
|
+
end
|
86
|
+
else
|
87
|
+
success = false
|
88
|
+
output = hash
|
89
|
+
result = "#{hash} must be a hash"
|
90
|
+
end
|
91
|
+
|
92
|
+
if success
|
93
|
+
success(output)
|
94
|
+
else
|
95
|
+
failure = failure(output, result)
|
96
|
+
block_given? ? yield(failure) : failure
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# @param meta [Boolean] Whether to dump the meta to the AST
|
101
|
+
# @return [Array] An AST representation
|
102
|
+
def to_ast(meta: true)
|
103
|
+
if RUBY_VERSION >= "2.5"
|
104
|
+
opts = options.slice(:key_transform_fn, :type_transform_fn, :strict)
|
105
|
+
else
|
106
|
+
opts = options.select { |k, _|
|
107
|
+
k == :key_transform_fn || k == :type_transform_fn || k == :strict
|
108
|
+
}
|
109
|
+
end
|
110
|
+
|
111
|
+
[
|
112
|
+
:schema,
|
113
|
+
[
|
114
|
+
keys.map { |key| key.to_ast(meta: meta) },
|
115
|
+
opts,
|
116
|
+
meta ? self.meta : EMPTY_HASH
|
117
|
+
]
|
118
|
+
]
|
119
|
+
end
|
120
|
+
|
121
|
+
# @param [Hash] hash
|
122
|
+
# @return [Boolean]
|
123
|
+
def valid?(hash)
|
124
|
+
result = try(hash)
|
125
|
+
result.success?
|
126
|
+
end
|
127
|
+
alias_method :===, :valid?
|
128
|
+
|
129
|
+
# Whether the schema rejects unknown keys
|
130
|
+
# @return [Boolean]
|
131
|
+
def strict?
|
132
|
+
options.fetch(:strict, false)
|
133
|
+
end
|
134
|
+
|
135
|
+
# Make the schema intolerant to unknown keys
|
136
|
+
# @return [Schema]
|
137
|
+
def strict
|
138
|
+
with(strict: true)
|
139
|
+
end
|
140
|
+
|
141
|
+
# Injects a key transformation function
|
142
|
+
# @param [#call,nil] proc
|
143
|
+
# @param [#call,nil] block
|
144
|
+
# @return [Schema]
|
145
|
+
def with_key_transform(proc = nil, &block)
|
146
|
+
fn = proc || block
|
147
|
+
|
148
|
+
if fn.nil?
|
149
|
+
raise ArgumentError, "a block or callable argument is required"
|
150
|
+
end
|
151
|
+
|
152
|
+
handle = Dry::Types::FnContainer.register(fn)
|
153
|
+
with(key_transform_fn: handle)
|
154
|
+
end
|
155
|
+
|
156
|
+
# Whether the schema transforms input keys
|
157
|
+
# @return [Boolean]
|
158
|
+
# @api public
|
159
|
+
def trasform_keys?
|
160
|
+
!options[:key_transform_fn].nil?
|
161
|
+
end
|
162
|
+
|
163
|
+
# @overload schema(type_map, meta = EMPTY_HASH)
|
164
|
+
# @param [{Symbol => Dry::Types::Nominal}] type_map
|
165
|
+
# @param [Hash] meta
|
166
|
+
# @return [Dry::Types::Schema]
|
167
|
+
# @overload schema(keys)
|
168
|
+
# @param [Array<Dry::Types::Schema::Key>] key List of schema keys
|
169
|
+
# @param [Hash] meta
|
170
|
+
# @return [Dry::Types::Schema]
|
171
|
+
def schema(keys_or_map)
|
172
|
+
if keys_or_map.is_a?(::Array)
|
173
|
+
new_keys = keys_or_map
|
174
|
+
else
|
175
|
+
new_keys = build_keys(keys_or_map)
|
176
|
+
end
|
177
|
+
|
178
|
+
keys = merge_keys(self.keys, new_keys)
|
179
|
+
Schema.new(primitive, **options, keys: keys, meta: meta)
|
180
|
+
end
|
181
|
+
|
182
|
+
# Iterate over each key type
|
183
|
+
#
|
184
|
+
# @return [Array<Dry::Types::Schema::Key>,Enumerator]
|
185
|
+
def each(&block)
|
186
|
+
keys.each(&block)
|
187
|
+
end
|
188
|
+
|
189
|
+
# Whether the schema has the given key
|
190
|
+
#
|
191
|
+
# @param [Symbol] name Key name
|
192
|
+
# @return [Boolean]
|
193
|
+
def key?(name)
|
194
|
+
name_key_map.key?(name)
|
195
|
+
end
|
196
|
+
|
197
|
+
# Fetch key type by a key name.
|
198
|
+
# Behaves as ::Hash#fetch
|
199
|
+
#
|
200
|
+
# @overload key(name, fallback = Undefined)
|
201
|
+
# @param [Symbol] name Key name
|
202
|
+
# @param [Object] fallback Optional fallback, returned if key is missing
|
203
|
+
# @return [Dry::Types::Schema::Key,Object] key type or fallback if key is not in schema
|
204
|
+
#
|
205
|
+
# @overload key(name, &block)
|
206
|
+
# @param [Symbol] name Key name
|
207
|
+
# @param [Proc] block Fallback block, runs if key is missing
|
208
|
+
# @return [Dry::Types::Schema::Key,Object] key type or block value if key is not in schema
|
209
|
+
def key(name, fallback = Undefined, &block)
|
210
|
+
if Undefined.equal?(fallback)
|
211
|
+
name_key_map.fetch(name, &block)
|
212
|
+
else
|
213
|
+
name_key_map.fetch(name, fallback)
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
# @return [Boolean]
|
218
|
+
def constrained?
|
219
|
+
true
|
220
|
+
end
|
221
|
+
|
222
|
+
private
|
223
|
+
|
224
|
+
# @param [Array<Dry::Types::Schema::Keys>] keys
|
225
|
+
# @return [Dry::Types::Schema]
|
226
|
+
# @api private
|
227
|
+
def merge_keys(*keys)
|
228
|
+
keys.
|
229
|
+
flatten(1).
|
230
|
+
each_with_object({}) { |key, merged| merged[key.name] = key }.
|
231
|
+
values
|
232
|
+
end
|
233
|
+
|
234
|
+
def resolve(hash, options = EMPTY_HASH, &block)
|
235
|
+
result = {}
|
236
|
+
|
237
|
+
hash.each do |key, value|
|
238
|
+
k = transform_key.(key)
|
239
|
+
|
240
|
+
if name_key_map.key?(k)
|
241
|
+
result[k] = yield(name_key_map[k], value)
|
242
|
+
elsif strict?
|
243
|
+
raise UnknownKeysError.new(*unexpected_keys(hash.keys))
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
if result.size < keys.size
|
248
|
+
resolve_missing_keys(result, options, &block)
|
249
|
+
end
|
250
|
+
|
251
|
+
result
|
252
|
+
end
|
253
|
+
|
254
|
+
def resolve_missing_keys(result, options)
|
255
|
+
skip_missing = options.fetch(:skip_missing, false)
|
256
|
+
resolve_defaults = options.fetch(:resolve_defaults, true)
|
257
|
+
|
258
|
+
keys.each do |key|
|
259
|
+
next if result.key?(key.name)
|
260
|
+
|
261
|
+
if key.default? && resolve_defaults
|
262
|
+
result[key.name] = yield(key, Undefined)
|
263
|
+
elsif key.required? && !skip_missing
|
264
|
+
raise MissingKeyError, key.name
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
# @param keys [Array<Symbol>]
|
270
|
+
# @return [Array<Symbol>]
|
271
|
+
def unexpected_keys(keys)
|
272
|
+
keys.map(&transform_key) - name_key_map.keys
|
273
|
+
end
|
274
|
+
|
275
|
+
# @param [Hash] hash
|
276
|
+
# @return [Hash{Symbol => Object}]
|
277
|
+
def try_coerce(hash)
|
278
|
+
resolve(hash) do |key, value|
|
279
|
+
yield(key, key.try(value))
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
# @param [Hash] hash
|
284
|
+
# @return [Hash{Symbol => Object}]
|
285
|
+
def coerce(hash, options = EMPTY_HASH)
|
286
|
+
resolve(hash, options) do |key, value|
|
287
|
+
begin
|
288
|
+
key.(value)
|
289
|
+
rescue ConstraintError => e
|
290
|
+
raise SchemaError.new(key.name, value, e.result)
|
291
|
+
rescue TypeError, ArgumentError => e
|
292
|
+
raise SchemaError.new(key.name, value, e.message)
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
require 'dry/equalizer'
|
2
|
+
|
3
|
+
module Dry
|
4
|
+
module Types
|
5
|
+
class Schema < Hash
|
6
|
+
# Proxy type for schema keys. Contains only key name and
|
7
|
+
# whether it's required or not. All other calls deletaged
|
8
|
+
# to the wrapped type.
|
9
|
+
#
|
10
|
+
# @see Dry::Types::Schema
|
11
|
+
class Key
|
12
|
+
include Type
|
13
|
+
include Dry::Equalizer(:name, :type, :options, inspect: false)
|
14
|
+
include Decorator
|
15
|
+
include Builder
|
16
|
+
include Printable
|
17
|
+
|
18
|
+
# @return [Symbol]
|
19
|
+
attr_reader :name
|
20
|
+
|
21
|
+
# @api private
|
22
|
+
def initialize(type, name, required: Undefined, **options)
|
23
|
+
required = Undefined.default(required) do
|
24
|
+
type.meta.fetch(:required) { !type.meta.fetch(:omittable, false) }
|
25
|
+
end
|
26
|
+
|
27
|
+
super(type, name, required: required, **options)
|
28
|
+
@name = name
|
29
|
+
end
|
30
|
+
|
31
|
+
# @see Dry::Types::Nominal#call
|
32
|
+
def call(input, &block)
|
33
|
+
type.(input, &block)
|
34
|
+
end
|
35
|
+
|
36
|
+
# @see Dry::Types::Nominal#try
|
37
|
+
def try(input, &block)
|
38
|
+
type.try(input, &block)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Whether the key is required in schema input
|
42
|
+
#
|
43
|
+
# @return [Boolean]
|
44
|
+
def required?
|
45
|
+
options.fetch(:required)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Control whether the key is required
|
49
|
+
#
|
50
|
+
# @overload required
|
51
|
+
# @return [Boolean]
|
52
|
+
#
|
53
|
+
# @overload required(required)
|
54
|
+
# Change key's "requireness"
|
55
|
+
#
|
56
|
+
# @param [Boolean] required New value
|
57
|
+
# @return [Dry::Types::Schema::Key]
|
58
|
+
def required(required = Undefined)
|
59
|
+
if Undefined.equal?(required)
|
60
|
+
options.fetch(:required)
|
61
|
+
else
|
62
|
+
with(required: required)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Make key not required
|
67
|
+
#
|
68
|
+
# @return [Dry::Types::Schema::Key]
|
69
|
+
def omittable
|
70
|
+
required(false)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Construct a default type. Default values are
|
74
|
+
# evaluated/applied when key is absent in schema
|
75
|
+
# input.
|
76
|
+
#
|
77
|
+
# @see Dry::Types::Default
|
78
|
+
# @return [Dry::Types::Schema::Key]
|
79
|
+
def default(input = Undefined, &block)
|
80
|
+
new(type.default(input, &block))
|
81
|
+
end
|
82
|
+
|
83
|
+
# Replace the underlying type
|
84
|
+
# @param [Dry::Types::Type] type
|
85
|
+
# @return [Dry::Types::Schema::Key]
|
86
|
+
def new(type)
|
87
|
+
self.class.new(type, name, options)
|
88
|
+
end
|
89
|
+
|
90
|
+
# @see Dry::Types::Safe
|
91
|
+
# @return [Dry::Types::Schema::Key]
|
92
|
+
def safe
|
93
|
+
new(type.safe)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Dump to internal AST representation
|
97
|
+
#
|
98
|
+
# @return [Array]
|
99
|
+
def to_ast(meta: true)
|
100
|
+
[
|
101
|
+
:key,
|
102
|
+
[
|
103
|
+
name,
|
104
|
+
required,
|
105
|
+
type.to_ast(meta: meta)
|
106
|
+
]
|
107
|
+
]
|
108
|
+
end
|
109
|
+
|
110
|
+
# Get/set type metadata. The Key type doesn't have
|
111
|
+
# its out meta, it delegates these calls to the underlying
|
112
|
+
# type.
|
113
|
+
#
|
114
|
+
# @overload meta
|
115
|
+
# @return [Hash] metadata associated with type
|
116
|
+
#
|
117
|
+
# @overload meta(data)
|
118
|
+
# @param [Hash] new metadata to merge into existing metadata
|
119
|
+
# @return [Type] new type with added metadata
|
120
|
+
def meta(data = nil)
|
121
|
+
if data.nil?
|
122
|
+
type.meta
|
123
|
+
else
|
124
|
+
new(type.meta(data))
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|