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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +419 -0
- data/lib/typemap/constrain.rb +199 -0
- data/lib/typemap/error.rb +46 -0
- data/lib/typemap/mapper/accessor_shape.rb +74 -0
- data/lib/typemap/mapper/basic_types.rb +24 -0
- data/lib/typemap/mapper/data_shape.rb +88 -0
- data/lib/typemap/mapper/limits.rb +183 -0
- data/lib/typemap/mapper/name_resolver.rb +46 -0
- data/lib/typemap/mapper/plan.rb +174 -0
- data/lib/typemap/mapper/rbs_types.rb +174 -0
- data/lib/typemap/mapper/type.rb +471 -0
- data/lib/typemap/mapper/type_builder.rb +89 -0
- data/lib/typemap/mapper/type_parser.rb +157 -0
- data/lib/typemap/mapper/wording.rb +212 -0
- data/lib/typemap/mapper.rb +298 -0
- data/lib/typemap/version.rb +6 -0
- data/lib/typemap.rb +12 -0
- metadata +67 -0
|
@@ -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
|
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: []
|