bm-typed 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 28fd1838d9a1659a003182955c8bb7d72e15cd231a0d68ab536373b188d200dc
4
+ data.tar.gz: f40f3685b87a1271f2d516ca9b0718f5543da5422f106bf27d81f42404bf6dfc
5
+ SHA512:
6
+ metadata.gz: d9d8e1a676753700e5301b84ebc14d0c63593958b6ff71e997e08d9053049956a34700bd566441b96e0048918bd6ad95bae7fae723be1d9c031edc36c59dee50
7
+ data.tar.gz: e28ef39291fb44dd1f75e72773288c12d61c77aeab0d0d6be97aa4567e342bc0491dbbb731410a406113f23fbe191b7654f97730502f9dd41b8d951eaa3e1e22
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-logic'
4
+ require 'dry/logic/rule_compiler'
5
+ require 'dry/logic/predicates'
6
+
7
+ module Typed
8
+ module Builder
9
+ # Entrypoint
10
+ def self.any
11
+ AnyHandler.instance
12
+ end
13
+
14
+ Result = ::Struct.new(:ok, :value, :message)
15
+ class Result
16
+ attr_reader :ok, :value
17
+
18
+ def initialize(ok, value, message)
19
+ @ok = ok
20
+ @value = value
21
+ @failure_block = message
22
+ end
23
+
24
+ def message
25
+ @message ||= @failure_block.call
26
+ end
27
+
28
+ class << self
29
+ def success(value)
30
+ new(true, value, nil)
31
+ end
32
+
33
+ def failure(&failure_block)
34
+ new(false, nil, failure_block)
35
+ end
36
+ end
37
+ end
38
+
39
+ module BaseType
40
+ def nullable
41
+ Typed.null | self
42
+ end
43
+
44
+ def missable
45
+ Typed.value(Undefined) | self
46
+ end
47
+
48
+ def default(new_value = Typed::Undefined, &block)
49
+ call(new_value) unless block
50
+ block ||= -> { new_value }
51
+ DefaultType.new(self) { call(block.call) }
52
+ end
53
+
54
+ def instance(expected_class)
55
+ dry_constrained(type: expected_class)
56
+ end
57
+
58
+ def enum(*values)
59
+ dry_constrained(included_in: values.map { |value| call(value) })
60
+ end
61
+
62
+ def |(other)
63
+ expected_type other
64
+
65
+ SumType.new(self, other)
66
+ end
67
+
68
+ def constructor(input: Typed.any, swallow: [], &block)
69
+ expected_type(input)
70
+ return self unless block_given?
71
+
72
+ CoerceType.new(input, self, swallow, &block)
73
+ end
74
+
75
+ def dry_constrained(**options)
76
+ predicate = ::Dry::Logic::RuleCompiler.new(::Dry::Logic::Predicates).call(
77
+ options.map { |key, val|
78
+ ::Dry::Logic::Rule::Predicate.new(
79
+ ::Dry::Logic::Predicates[:"#{key}?"]
80
+ ).curry(val).to_ast
81
+ }
82
+ ).reduce(:and)
83
+
84
+ constrained do |value|
85
+ "#{value.inspect} violates #{predicate}" unless predicate.call(value).success?
86
+ end
87
+ end
88
+
89
+ def constrained(&constraint)
90
+ return self unless constraint
91
+
92
+ ConstrainedType.new(self, &constraint)
93
+ end
94
+
95
+ def call(*args)
96
+ result = process((args + [Typed::Undefined]).first)
97
+ return result.value if result.ok
98
+
99
+ raise InvalidValue, result.message
100
+ end
101
+
102
+ def process(value)
103
+ Typed::Builder::Result.success(value)
104
+ end
105
+
106
+ private
107
+
108
+ def expected_type(type)
109
+ raise InvalidType, "Not a Typed type: #{type.inspect}" unless type.is_a?(BaseType)
110
+ end
111
+ end
112
+
113
+ class ArrayType
114
+ include BaseType
115
+
116
+ def initialize(element_type)
117
+ @element_type = element_type
118
+ end
119
+
120
+ def process(value)
121
+ return Result.failure { "Invalid collection: #{value.inspect}" } unless value.respond_to?(:each)
122
+
123
+ new_value = []
124
+
125
+ value.each do |element|
126
+ element_result = element_type.process(element)
127
+ return element_result unless element_result.ok
128
+
129
+ new_value << element_result.value
130
+ end
131
+
132
+ Result.success(new_value)
133
+ end
134
+
135
+ private
136
+
137
+ attr_reader :base_type, :element_type
138
+ end
139
+
140
+ class ConstrainedType
141
+ include BaseType
142
+
143
+ def initialize(base_type, &constraint)
144
+ @base_type = base_type
145
+ @constraint = constraint
146
+ end
147
+
148
+ def process(value)
149
+ result = base_type.process(value)
150
+ return result unless result.ok
151
+
152
+ error = constraint.call(result.value)
153
+ return result unless error
154
+
155
+ Result.failure { error }
156
+ end
157
+
158
+ private
159
+
160
+ attr_reader :base_type, :constraint
161
+ end
162
+
163
+ class DefaultType
164
+ include BaseType
165
+
166
+ def initialize(base_type, &default_value)
167
+ @base_type = base_type
168
+ @default_value = default_value
169
+ end
170
+
171
+ def process(value)
172
+ new_value = Typed::Undefined.equal?(value) ? default_value.call : value
173
+ base_type.process(new_value)
174
+ end
175
+
176
+ private
177
+
178
+ attr_reader :default_value, :base_type
179
+ end
180
+
181
+ class SumType
182
+ include BaseType
183
+
184
+ def initialize(type_a, type_b)
185
+ @type_a = type_a
186
+ @type_b = type_b
187
+ end
188
+
189
+ def process(value)
190
+ result = type_a.process(value)
191
+ return result if result.ok
192
+
193
+ type_b.process(value)
194
+ end
195
+
196
+ private
197
+
198
+ attr_reader :type_a, :type_b
199
+ end
200
+
201
+ class CoerceType
202
+ include BaseType
203
+
204
+ def initialize(input_type, return_type, swallow, &coercion)
205
+ @input_type = input_type
206
+ @return_type = return_type
207
+ @coercion = coercion
208
+ @swallow = swallow
209
+ end
210
+
211
+ def process(value)
212
+ # No coercion needed
213
+ passthrough_result = return_type.process(value)
214
+ return passthrough_result if passthrough_result.ok
215
+
216
+ # Check input_type enables this coercion
217
+ input_result = input_type.process(value)
218
+
219
+ if input_result.ok
220
+ coerced_value =
221
+ begin
222
+ coercion.call(input_result.value)
223
+ rescue *swallow
224
+ input_result.value
225
+ end
226
+ return return_type.process(coerced_value)
227
+ end
228
+
229
+ passthrough_result
230
+ end
231
+
232
+ private
233
+
234
+ attr_reader :input_type, :return_type, :coercion, :swallow
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typed
4
+ class Struct
5
+ # TODO: This has nothing to do in this gem, should be moved to application
6
+ class Updater
7
+ attr_reader :params, :target
8
+
9
+ def initialize(target, params)
10
+ @target = target
11
+ @params = params
12
+ end
13
+
14
+ def assign(from, to: from, &value_builder)
15
+ check_from(from)
16
+ return unless params.key?(from)
17
+
18
+ input_value = params[from]
19
+ default_getter = proc { input_value }
20
+ processed_value = (value_builder || default_getter).call(input_value)
21
+ target.send("#{to}=", processed_value)
22
+ end
23
+
24
+ private
25
+
26
+ def check_from(from)
27
+ return if params.class.schema.key?(from)
28
+
29
+ raise "Key #{from.inspect} does not exist on #{params.class}"
30
+ end
31
+ end
32
+
33
+ class << self
34
+ include Builder::BaseType
35
+
36
+ def attribute(name, type = Typed.any)
37
+ expected_type(type)
38
+
39
+ name = name.to_sym
40
+
41
+ raise Typed::InvalidType, "Property already defined: #{name}" if typed_attributes.key?(name)
42
+
43
+ typed_attributes[name] = type
44
+ define_method(name) { @_data.fetch(name) { Typed::Undefined } }
45
+ end
46
+
47
+ def schema
48
+ @schema ||= ancestors.select { |a| Typed::Struct > a }.reverse.reduce({}) { |acc, clazz|
49
+ acc.merge(clazz.typed_attributes)
50
+ }.freeze
51
+ end
52
+
53
+ def allow_extra_keys(new_flag)
54
+ define_singleton_method(:allow_extra_keys?) { new_flag }
55
+ end
56
+
57
+ def allow_extra_keys?
58
+ true
59
+ end
60
+
61
+ def typed_attributes
62
+ @typed_attributes ||= {}
63
+ end
64
+
65
+ def process(data)
66
+ result = parse_as_hash(data)
67
+ result.ok ? Typed::Builder::Result.success(new(result)) : result
68
+ end
69
+
70
+ def parse_as_hash(input_data)
71
+ return Typed::Builder::Result.success(input_data.to_h) if input_data.is_a?(self)
72
+
73
+ # TODO: remove this hack
74
+ unless input_data.is_a?(::Hash) || input_data.class.name == 'ActionController::Parameters'
75
+ return Typed::Builder::Result.failure { "Expected Hash, got #{input_data.inspect}" }
76
+ end
77
+
78
+ # Start by creating a new "clean" hash from input
79
+ # This way, we can easily handle some variants (ActionController::Parameters, ...)
80
+ clean_data = Hash.new { ::Typed::Undefined }
81
+ input_data.each { |key, value| clean_data[key.to_sym] = value }
82
+
83
+ # Check presence of extra keys
84
+ extra_property = (clean_data.keys - schema.keys).first
85
+ if extra_property && !allow_extra_keys?
86
+ return Typed::Builder::Result
87
+ .failure("Unknown property '#{extra_property}' of #{inspect}")
88
+ end
89
+
90
+ # Construct the final hash which will be stored internally to represent
91
+ # Struct's data.
92
+ output = schema.each_with_object({}) { |(name, type), acc|
93
+ result = type.process(clean_data[name])
94
+
95
+ unless result.ok
96
+ return Typed::Builder::Result.failure {
97
+ "Invalid property '#{name}' of #{inspect}: #{result.message}"
98
+ }
99
+ end
100
+
101
+ acc[name] = result.value unless Typed::Undefined.equal?(result.value)
102
+ }.freeze
103
+
104
+ Typed::Builder::Result.success(output)
105
+ end
106
+ end
107
+
108
+ def updater(target)
109
+ Updater.new(target, self)
110
+ end
111
+
112
+ def inspect
113
+ attrs = self.class.schema.keys.map { |key| " #{key}=#{@_data[key].inspect}" }.join
114
+ "#<#{self.class.name || self.class.inspect}#{attrs}>"
115
+ end
116
+
117
+ def to_h
118
+ @_data
119
+ end
120
+
121
+ def [](key)
122
+ raise Typed::InvalidType, "Unknown property: #{key.inspect}" unless self.class.schema.key?(key)
123
+
124
+ @_data.fetch(key) { Typed::Undefined }
125
+ end
126
+
127
+ def ==(other)
128
+ return true if other.equal?(self)
129
+ return false unless other.instance_of?(self.class)
130
+
131
+ @_data == other.instance_variable_get(:@_data)
132
+ end
133
+
134
+ def hash
135
+ @_data.hash
136
+ end
137
+
138
+ def key?(key)
139
+ @_data.key?(key)
140
+ end
141
+
142
+ def initialize(input_data = {})
143
+ case input_data
144
+ when Typed::Builder::Result then initialize_from_result(input_data)
145
+ else initialize_from_result(self.class.parse_as_hash(input_data))
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ def initialize_from_result(result)
152
+ raise Typed::InvalidValue, result.message unless result.ok
153
+
154
+ @_data = result.value
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typed
4
+ VERSION = '0.1.0'
5
+ end
data/lib/typed.rb ADDED
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed/builder'
4
+ require 'typed/struct'
5
+ require 'typed/version'
6
+ require 'uri'
7
+ require 'active_support/time'
8
+
9
+ module Typed
10
+ class InvalidValue < TypeError; end
11
+ class InvalidType < TypeError; end
12
+
13
+ class << self
14
+ include Typed::Builder::BaseType
15
+
16
+ def array(element_type = Typed.any)
17
+ expected_type(element_type)
18
+
19
+ Typed::Builder::ArrayType.new(element_type)
20
+ end
21
+
22
+ def any
23
+ self
24
+ end
25
+
26
+ def null
27
+ value(nil)
28
+ end
29
+
30
+ def value(expected_value)
31
+ dry_constrained(eql: call(expected_value))
32
+ end
33
+ end
34
+
35
+ # Undefined is both:
36
+ # - A placeholder used to represent an undefined value.
37
+ # - The type used to represent this placeholder.
38
+ module Undefined
39
+ class << self
40
+ include Typed::Builder::BaseType
41
+
42
+ def process(value)
43
+ if Undefined.equal?(value)
44
+ Typed::Builder::Result.success(value)
45
+ else
46
+ Typed::Builder::Result.failure { 'Expected value undefined' }
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ module Strict
53
+ String = Typed.instance(::String)
54
+ Symbol = Typed.instance(::Symbol)
55
+ Int = Typed.instance(::Integer)
56
+ Float = Typed.instance(::Float)
57
+ Date = Typed.instance(::Date)
58
+ True = Typed.value(true)
59
+ False = Typed.value(false)
60
+ Boolean = True | False
61
+ Time = Typed.instance(::Time)
62
+ DateTime = Typed.instance(::DateTime)
63
+ end
64
+
65
+ String = Strict::String.constructor(input: Strict::Int | Strict::Float | Strict::Symbol, &:to_s)
66
+
67
+ Float = Strict::Float.constructor(
68
+ input: Strict::String | Strict::Int,
69
+ swallow: [TypeError, ArgumentError]
70
+ ) { |value| Float(value) }
71
+
72
+ Int = Strict::Int
73
+ .constructor(
74
+ input: Strict::String,
75
+ swallow: [TypeError, ArgumentError]
76
+ ) { |value| Integer(value) }
77
+ .constructor(
78
+ input: Float,
79
+ swallow: [TypeError, ArgumentError]
80
+ ) { |value|
81
+ parsed = Integer(value)
82
+ parsed == value ? parsed : value
83
+ }
84
+
85
+ Date = Strict::Date
86
+ .constructor(
87
+ input: String,
88
+ swallow: [TypeError, ArgumentError, RangeError]
89
+ ) { |value| ::Date.parse(value) }
90
+ .constructor(input: Typed.instance(::Time), &:to_date)
91
+
92
+ Boolean = Strict::Boolean.constructor(input: String) { |value|
93
+ { 'true' => true, 'false' => false }.fetch(value) { value }
94
+ }
95
+
96
+ Time = (Strict::DateTime | Strict::Time)
97
+ .constructor(input: String, swallow: [TypeError, ArgumentError]) { |value|
98
+ ::ActiveSupport::TimeZone['UTC'].parse(value)
99
+ }
100
+ .constructor(input: Int | Float, swallow: [TypeError, ArgumentError]) { |value| ::Time.at(value) }
101
+
102
+ UUID = String.dry_constrained(format: /\A[a-f\d]{8}(-[a-f\d]{4}){3}-[a-f\d]{12}\z/)
103
+ .constructor(input: String, &:downcase)
104
+
105
+ URL = String.dry_constrained(format: URI::DEFAULT_PARSER.make_regexp(%w[http https]))
106
+ end
metadata ADDED
@@ -0,0 +1,134 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bm-typed
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Frederic Terrazzoni
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-10-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: coveralls
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.8'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: pry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - '='
60
+ - !ruby/object:Gem::Version
61
+ version: 0.59.2
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - '='
67
+ - !ruby/object:Gem::Version
68
+ version: 0.59.2
69
+ - !ruby/object:Gem::Dependency
70
+ name: activesupport
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '5.2'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '5.2'
83
+ - !ruby/object:Gem::Dependency
84
+ name: dry-logic
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 0.4.2
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 0.4.2
97
+ description: A dry-types/dry-struct alternative making the difference between undefined
98
+ and nil
99
+ email:
100
+ - frederic.terrazzoni@gmail.com
101
+ executables: []
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - lib/typed.rb
106
+ - lib/typed/builder.rb
107
+ - lib/typed/struct.rb
108
+ - lib/typed/version.rb
109
+ homepage: https://github.com/getbannerman/typed
110
+ licenses:
111
+ - MIT
112
+ metadata: {}
113
+ post_install_message:
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubyforge_project:
129
+ rubygems_version: 2.7.6
130
+ signing_key:
131
+ specification_version: 4
132
+ summary: A dry-types/dry-struct alternative making the difference between undefined
133
+ and nil
134
+ test_files: []