bm-typed 0.1.0

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 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: []