typemap 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.
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean" # a default gem; `ruby --disable-did_you_mean` leaves it unloaded
4
+ require "monitor"
5
+ require_relative "mapper/wording"
6
+ require_relative "mapper/type"
7
+ require_relative "mapper/name_resolver"
8
+ require_relative "mapper/type_parser"
9
+ require_relative "mapper/type_builder"
10
+ require_relative "mapper/basic_types"
11
+ require_relative "mapper/rbs_types"
12
+ require_relative "mapper/data_shape"
13
+ require_relative "mapper/accessor_shape"
14
+ require_relative "mapper/limits"
15
+ require_relative "mapper/plan"
16
+
17
+ module Typemap
18
+ # Turns a parsed file — a Hash, from `Tomlrb.load_file`, `JSON.load_file` or
19
+ # `YAML.safe_load_file` — into an instance of a class you already wrote, and back:
20
+ #
21
+ # mapper = Typemap::Mapper.new
22
+ # config = mapper.load(Config, Tomlrb.load_file(path), source: :toml)
23
+ # state = mapper.load(State, JSON.load_file(path), source: :json, unknown: :ignore)
24
+ # File.write(path, JSON.pretty_generate(mapper.dump(state)))
25
+ #
26
+ # The class is a data class (`Data.define`) or an accessor class (public reader + writer
27
+ # pairs); its bare `new` is the default instance. A key the file must give is a data class's
28
+ # keyword without a default, or an accessor class's `constrain required: true`. Types are
29
+ # strict: `"70"` is not an Integer and `70.5` is not 70, though a whole `70.0` loads as 70 and
30
+ # `1` as a Float `1.0`. The first problem raises — a {DataError} with the offending value's
31
+ # path when the file does not fit, a {DefinitionError} when the class cannot be mapped, the
32
+ # latter as soon as the class is first read.
33
+ #
34
+ # Both `load` and `dump` validate the whole result once it is built, objects the file left out
35
+ # included, nested objects first: per object the limits, `required:`, then the object's own
36
+ # `validate`, if it has one.
37
+ #
38
+ # A mapper remembers what it learnt about each class; it is safe to share between threads.
39
+ class Mapper
40
+ # @return [#type_for] where property types come from: {BasicTypes} or {RBSTypes}
41
+ attr_reader :types
42
+
43
+ # @param types [#type_for] {BasicTypes}, the default, takes every type from
44
+ # `constrain type:`; {RBSTypes} reads `sig/*.rbs` and needs the `rbs` gem
45
+ def initialize(types: BasicTypes.new)
46
+ @types = types
47
+ @plans = {}
48
+ @lock = Monitor.new
49
+ end
50
+
51
+ # Builds an instance of `klass` from `hash`: each key present replaces the default, each key
52
+ # absent keeps it; a nested Hash loads onto the default's nested object. Then validates it.
53
+ #
54
+ # Every Array, Hash and String a data object holds comes back frozen, defaults included, and
55
+ # a String from `hash` is frozen in place; an accessor object's values stay mutable.
56
+ #
57
+ # @param klass [Class] a data class or an accessor class
58
+ # @param hash [Hash{String, Symbol => Object}] the parsed file
59
+ # @param source [Symbol] where `hash` came from, so an error words its fix that way: `:toml`
60
+ # says `write [[server]], not [server]`, `:ruby` says `wrap it in an array` and names the
61
+ # found value's class, `got Integer 70`; `:json` and `:yaml` likewise
62
+ # @param unknown [Symbol] `:raise` on a key the class has no property for, or `:ignore` it —
63
+ # say, a key a newer version of the app wrote; a class's own `unknown` wins over it
64
+ # @return [Object] an instance of `klass`
65
+ # @raise [DataError] if the data does not fit the class
66
+ # @raise [DefinitionError] if the class cannot be mapped
67
+ # @raise [ArgumentError] on an unknown `source:` or `unknown:`
68
+ def load(klass, hash, source: :ruby, unknown: :raise)
69
+ unless %i[raise ignore].include?(unknown)
70
+ raise ArgumentError, "unknown: must be :raise or :ignore, got #{unknown.inspect}"
71
+ end
72
+
73
+ session = Session.new(mapper: self, unknown: unknown, wording: Wording.for(source), parents: [])
74
+ object = session.load_object(klass, hash, "", nil)
75
+ freeze_object(object)
76
+ validate_object(object, "")
77
+ object
78
+ end
79
+
80
+ # Converts `object`, then validates it, so nothing comes back that a later {#load} would
81
+ # reject.
82
+ #
83
+ # @param object [Object] an instance of a data class or an accessor class
84
+ # @return [Hash{String => Object}] its properties, in declaration order, as plain data —
85
+ # String keys, nested objects as Hashes — ready for `JSON.generate`
86
+ # @raise [DataError] if a property holds a value its type rejects, a value outside its limits,
87
+ # `nil` where `required:`, or the object's `validate` raises
88
+ # @raise [DefinitionError] if the class cannot be mapped
89
+ def dump(object)
90
+ hash = dump_object(object, "")
91
+ validate_object(object, "")
92
+ hash
93
+ end
94
+
95
+ # @api private
96
+ # @param object [Object]
97
+ # @param path [String] where `object` sits, for messages
98
+ # @return [Hash{String => Object}]
99
+ def dump_object(object, path)
100
+ plan = plan_for(object.class)
101
+ plan.properties.to_h do |property|
102
+ child = Type.child(path, property.name)
103
+ [property.name.to_s, property.type.dump(plan.shape.read(object, property.name), self, child)]
104
+ end
105
+ end
106
+
107
+ # `object` as {#dump_object} gives it, less any property whose value won't dump: the `raw` an
108
+ # array entry's `entry_name` takes when the entry is an object, in validation and dump.
109
+ #
110
+ # @api private
111
+ # @param object [Object] an instance of a mapped class
112
+ # @return [Hash{String => Object}]
113
+ def entry_hash(object)
114
+ plan = plan_for(object.class)
115
+ plan.properties.each_with_object({}) do |property, hash|
116
+ hash[property.name.to_s] = property.type.dump(plan.shape.read(object, property.name), self, "")
117
+ rescue DataError
118
+ next # likely the very value being reported; the name does without it
119
+ end
120
+ end
121
+
122
+ # Validates a converted object, nested objects first; changes nothing. Types are not
123
+ # rechecked: conversion did that, and the limits assume it.
124
+ #
125
+ # @api private
126
+ # @param object [Object]
127
+ # @param path [String] where `object` sits, for messages
128
+ # @return [void]
129
+ # @raise [DataError]
130
+ def validate_object(object, path)
131
+ plan = plan_for(object.class)
132
+ values = plan.properties.to_h { |property| [property, plan.shape.read(object, property.name)] }
133
+ values.each do |property, value|
134
+ property.type.each_object(value, self, Type.child(path, property.name)) do |nested, at|
135
+ validate_object(nested, at)
136
+ end
137
+ end
138
+ check_values(values, path)
139
+ call_validate(object, path)
140
+ end
141
+
142
+ # What the mapper knows about `klass`, worked out and checked the first time it is asked,
143
+ # together with every class nested in it.
144
+ #
145
+ # @api private
146
+ # @param klass [Class]
147
+ # @return [Plan]
148
+ # @raise [DefinitionError]
149
+ def plan_for(klass)
150
+ @lock.synchronize do
151
+ return @plans[klass] if @plans.key?(klass)
152
+
153
+ plan = Plan.build(klass, types)
154
+ @plans[klass] = plan # before the nested classes, so a class nested in itself terminates
155
+ begin
156
+ plan.nested_classes.each { |nested| plan_for(nested) }
157
+ rescue DefinitionError
158
+ @plans.delete(klass)
159
+ raise
160
+ end
161
+ plan
162
+ end
163
+ end
164
+
165
+ private
166
+
167
+ # Freezes, in place, what each data object in the tree holds, through its shape; an accessor
168
+ # object freezes nothing but is walked, for a data object nested below. Only mapped properties
169
+ # are reached, so an `ignore`d Logger or Proc never is.
170
+ def freeze_object(object)
171
+ plan = plan_for(object.class)
172
+ plan.properties.each do |property|
173
+ value = plan.shape.read(object, property.name)
174
+ plan.shape.freeze_value(value)
175
+ property.type.each_object(value, self, "") { |nested, _path| freeze_object(nested) }
176
+ end
177
+ end
178
+
179
+ def check_values(values, path)
180
+ values.each do |property, value|
181
+ child = Type.child(path, property.name)
182
+ property.limits.check(value, child)
183
+ property.limits.check_unique(value, property.type, child, self)
184
+ raise DataError.new(child, "required") if property.required && value.nil?
185
+ end
186
+ end
187
+
188
+ def call_validate(object, path)
189
+ object.validate if object.respond_to?(:validate)
190
+ rescue Error
191
+ raise
192
+ rescue StandardError => e
193
+ raise DataError.new(path, e.message)
194
+ end
195
+
196
+ # One `load` call's conversion: the mapper, the `unknown:` policy and the source's wording
197
+ # every nested object shares, and the objects enclosing the one being loaded.
198
+ #
199
+ # @api private
200
+ # @!attribute [r] mapper
201
+ # @return [Mapper]
202
+ # @!attribute [r] unknown
203
+ # @return [Symbol] `:raise` or `:ignore`, for a class whose plan gives no `unknown`
204
+ # @!attribute [r] wording
205
+ # @return [Module] the {Wording} for the `source:`
206
+ # @!attribute [r] parents
207
+ # @return [Array<Array(Plan, String)>] each enclosing object's plan and path, outermost first
208
+ Session = Data.define(:mapper, :unknown, :wording, :parents) do
209
+ # @param klass [Class]
210
+ # @param raw [Object] what the file holds for this object, expected to be a Hash
211
+ # @param path [String] where it sits, `""` for the root
212
+ # @param base [Object, nil] the default to load onto; `nil` builds a fresh one
213
+ # @return [Object] an instance of `klass`
214
+ # @raise [DataError]
215
+ def load_object(klass, raw, path, base)
216
+ plan = mapper.plan_for(klass)
217
+ not_a_hash(plan, raw, path) unless raw.is_a?(Hash)
218
+ values = Type.string_keys(raw, path)
219
+ reject_unknown(plan, values, path)
220
+ inside = with(parents: [*parents, [plan, path]])
221
+ base, loaded = base ? [base, {}] : fresh_base(plan, values, path, inside)
222
+ plan.properties.each do |property|
223
+ key = property.name.to_s
224
+ next if !values.key?(key) || loaded.key?(property.name)
225
+
226
+ base_value = plan.shape.read(base, property.name)
227
+ loaded[property.name] = property.type.load(values[key], Type.child(path, key), inside, base_value)
228
+ end
229
+ plan.build(base, loaded)
230
+ end
231
+
232
+ private
233
+
234
+ # `expected Server, got an array of one Server; unwrap it` when the one entry is
235
+ # {Plan#entry_like?} — TOML's `[[server]]` for `[server]`.
236
+ #
237
+ # @raise [DataError] always
238
+ def not_a_hash(plan, raw, path)
239
+ name = Type.short_name(plan.klass)
240
+ if raw.is_a?(Array) && raw.size == 1 && plan.entry_like?(raw.first)
241
+ Type.mismatch(name, raw, path, found: "an array of one #{name}", hint: wording.unwrap(path))
242
+ end
243
+ Type.mismatch(name, raw, path, wording: wording)
244
+ end
245
+
246
+ # A bare `new`; or, for a data class with required keywords, `new` given just those, loaded
247
+ # first from the file — a nested one onto its own class's defaults, there being no other.
248
+ #
249
+ # @param inside [Session] the session for the object's own properties
250
+ # @return [Array(Object, Hash{Symbol => Object})] the base, and the values loaded for it
251
+ def fresh_base(plan, values, path, inside)
252
+ return [plan.klass.new, {}] if plan.required_keywords.empty?
253
+
254
+ loaded = plan.properties.select { |property| plan.required_keywords.include?(property.name) }.to_h do |property|
255
+ key = property.name.to_s
256
+ child = Type.child(path, key)
257
+ raise DataError.new(child, "required") unless values.key?(key)
258
+
259
+ [property.name, property.type.load(values[key], child, inside, nil)]
260
+ end
261
+ base = plan.klass.new(**loaded)
262
+ plan.check_default(base)
263
+ [base, loaded]
264
+ end
265
+
266
+ # `rasie_at_percent: unknown key, did you mean raise_at_percent?`; with no close match,
267
+ # `rasie: unknown key; Ballooning has raise_at_percent, shrink_at_percent`. A key an
268
+ # enclosing object has, the nearest first, reads `ui.sandbox: unknown key; sandbox belongs
269
+ # to the top level` — TOML's key written below a `[ui]` header, meant for above it.
270
+ #
271
+ # == Implementation details
272
+ #
273
+ # Enclosing objects only, never siblings or the rest of the tree: the chain either has the
274
+ # key or doesn't, while a search across the tree guesses. A key in the wrong sibling is told
275
+ # by the class's own key list.
276
+ def reject_unknown(plan, values, path)
277
+ return if (plan.unknown || unknown) == :ignore
278
+
279
+ names = plan.names.map(&:to_s)
280
+ key = (values.keys - names).first
281
+ return if key.nil?
282
+
283
+ raise DataError.new(Type.child(path, key), unknown_detail(key, names, plan, path))
284
+ end
285
+
286
+ # @return [String]
287
+ def unknown_detail(key, names, plan, path)
288
+ _, owner = parents.reverse.find { |parent, _| parent.names.any? { |name| name.to_s == key } }
289
+ return "unknown key; #{wording.misplaced(key, path, owner)}" unless owner.nil?
290
+
291
+ guesses = DidYouMean::SpellChecker.new(dictionary: names).correct(key)
292
+ return "unknown key, did you mean #{guesses.join(" or ")}?" unless guesses.empty?
293
+
294
+ "unknown key; #{Type.short_name(plan.klass)} has #{names.join(", ")}"
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typemap
4
+ # @return [String] the gem's version
5
+ VERSION = "0.1.0"
6
+ end
data/lib/typemap.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "typemap/version"
4
+ require_relative "typemap/error"
5
+ require_relative "typemap/constrain"
6
+ require_relative "typemap/mapper"
7
+
8
+ # Maps a Hash onto a Ruby class you already wrote, and back, checking every value on the way.
9
+ # {Typemap::Mapper} does the mapping; {Typemap::Constrain} is what a class extends to give its
10
+ # properties types and limits.
11
+ module Typemap
12
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: typemap
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Martin Vysny
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ Typemap maps a Hash — a parsed TOML, JSON or YAML file — onto an ordinary Ruby class, a Data
14
+ class or a plain class with accessors, and back, with strict types from `constrain` lines or
15
+ RBS signatures, and without the data ever naming a class. It does the mapping and nothing
16
+ else: reading files and finding them stay with the app.
17
+ email:
18
+ - martin@vysny.me
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - CHANGELOG.md
24
+ - LICENSE.txt
25
+ - README.md
26
+ - lib/typemap.rb
27
+ - lib/typemap/constrain.rb
28
+ - lib/typemap/error.rb
29
+ - lib/typemap/mapper.rb
30
+ - lib/typemap/mapper/accessor_shape.rb
31
+ - lib/typemap/mapper/basic_types.rb
32
+ - lib/typemap/mapper/data_shape.rb
33
+ - lib/typemap/mapper/limits.rb
34
+ - lib/typemap/mapper/name_resolver.rb
35
+ - lib/typemap/mapper/plan.rb
36
+ - lib/typemap/mapper/rbs_types.rb
37
+ - lib/typemap/mapper/type.rb
38
+ - lib/typemap/mapper/type_builder.rb
39
+ - lib/typemap/mapper/type_parser.rb
40
+ - lib/typemap/mapper/wording.rb
41
+ - lib/typemap/version.rb
42
+ homepage: https://github.com/mvysny/typemap
43
+ licenses:
44
+ - MIT
45
+ metadata:
46
+ homepage_uri: https://github.com/mvysny/typemap
47
+ changelog_uri: https://github.com/mvysny/typemap/blob/main/CHANGELOG.md
48
+ rubygems_mfa_required: 'true'
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: 3.3.0
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.6.7
64
+ specification_version: 4
65
+ summary: Maps a Hash onto a Ruby class you already wrote, and back, checking every
66
+ value.
67
+ test_files: []