structure 4.0.0 → 4.2.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 +4 -4
- data/lib/structure/builder.rb +24 -6
- data/lib/structure/rbs.rb +184 -29
- data/lib/structure/types.rb +8 -3
- data/lib/structure/version.rb +1 -1
- data/lib/structure.rb +78 -10
- data/sig/structure/builder.rbs +9 -4
- data/sig/structure/rbs.rbs +5 -1
- data/sig/structure/types.rbs +1 -1
- data/sig/structure.rbs +5 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d49fdb007e1c1690e686dc7c17498fd905b8e100c8dcf8fe3b374c99ec18c14
|
|
4
|
+
data.tar.gz: 3dbdc1a4e9ac4e6e70332084bda5612b4b17dc74a63a28e0eb084a2f49d440bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9d7c579f4e71000c4cabeaa4bd95deb3c01763b0ab5e426fb7544da65e7617a5e8ac7d32c7a3079f690a55c0021a57d97646732d516a25796cbc8999dbb6630a
|
|
7
|
+
data.tar.gz: '02791f8b7a9372c583839e35c7c20c6bb7ec03626256f92f9cda2ef11c5188d541f0f3b073a053d7ccf152da4a47c4af32eeb65b2e68a92ecc00e8494174ba7e'
|
data/lib/structure/builder.rb
CHANGED
|
@@ -14,6 +14,7 @@ module Structure
|
|
|
14
14
|
@defaults = {}
|
|
15
15
|
@types = {}
|
|
16
16
|
@optional = Set.new
|
|
17
|
+
@non_nullable = Set.new
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
# DSL method for defining attributes with optional type coercion
|
|
@@ -22,6 +23,7 @@ module Structure
|
|
|
22
23
|
# @param type [Class, Symbol, Array, nil] Type for coercion (e.g., String, :boolean, [String])
|
|
23
24
|
# @param from [String, nil] Source key in the data hash (defaults to name.to_s)
|
|
24
25
|
# @param default [Object, nil] Default value if attribute is missing
|
|
26
|
+
# @param null [Boolean] Whether nil values are allowed (default: true)
|
|
25
27
|
# @yield [value] Block for custom transformation
|
|
26
28
|
# @raise [ArgumentError] If both type and block are provided
|
|
27
29
|
#
|
|
@@ -35,12 +37,16 @@ module Structure
|
|
|
35
37
|
# attribute :price do |value|
|
|
36
38
|
# Money.new(value["amount"], value["currency"])
|
|
37
39
|
# end
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
#
|
|
41
|
+
# @example Non-nullable attribute
|
|
42
|
+
# attribute :id, String, null: false
|
|
43
|
+
def attribute(name, type = nil, from: nil, default: nil, null: true, &block)
|
|
44
|
+
mappings[name] = (from || name).to_s
|
|
40
45
|
defaults[name] = default unless default.nil?
|
|
46
|
+
@non_nullable.add(name) unless null
|
|
41
47
|
|
|
42
48
|
if type && block
|
|
43
|
-
raise ArgumentError, "
|
|
49
|
+
raise ArgumentError, "cannot specify both type and block for :#{name}"
|
|
44
50
|
else
|
|
45
51
|
types[name] = type || block
|
|
46
52
|
end
|
|
@@ -52,6 +58,7 @@ module Structure
|
|
|
52
58
|
# @param type [Class, Symbol, Array, nil] Type for coercion (e.g., String, :boolean, [String])
|
|
53
59
|
# @param from [String, nil] Source key in the data hash (defaults to name.to_s)
|
|
54
60
|
# @param default [Object, nil] Default value if attribute is missing
|
|
61
|
+
# @param null [Boolean] Whether nil values are allowed (default: true)
|
|
55
62
|
# @yield [value] Block for custom transformation
|
|
56
63
|
# @raise [ArgumentError] If both type and block are provided
|
|
57
64
|
#
|
|
@@ -60,8 +67,11 @@ module Structure
|
|
|
60
67
|
#
|
|
61
68
|
# @example Optional with default
|
|
62
69
|
# attribute? :status, String, default: "pending"
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
#
|
|
71
|
+
# @example Optional but non-nullable when present
|
|
72
|
+
# attribute? :name, String, null: false
|
|
73
|
+
def attribute?(name, type = nil, from: nil, default: nil, null: true, &block)
|
|
74
|
+
attribute(name, type, from: from, default: default, null: null, &block)
|
|
65
75
|
@optional.add(name)
|
|
66
76
|
end
|
|
67
77
|
|
|
@@ -87,9 +97,17 @@ module Structure
|
|
|
87
97
|
# @api private
|
|
88
98
|
def required = attributes - optional
|
|
89
99
|
|
|
100
|
+
# @api private
|
|
101
|
+
def non_nullable = @non_nullable.to_a
|
|
102
|
+
|
|
90
103
|
# @api private
|
|
91
104
|
def coercions(context = nil)
|
|
92
|
-
@types.
|
|
105
|
+
@types.to_h do |attr, type|
|
|
106
|
+
coercion = Types.coerce(type, context)
|
|
107
|
+
[attr, coercion]
|
|
108
|
+
rescue ArgumentError => e
|
|
109
|
+
raise ArgumentError, "#{e.message} for :#{attr}"
|
|
110
|
+
end
|
|
93
111
|
end
|
|
94
112
|
|
|
95
113
|
# @api private
|
data/lib/structure/rbs.rb
CHANGED
|
@@ -4,7 +4,14 @@ require "fileutils"
|
|
|
4
4
|
require "pathname"
|
|
5
5
|
|
|
6
6
|
module Structure
|
|
7
|
+
# Generates RBS type signatures for Structure classes
|
|
7
8
|
module RBS
|
|
9
|
+
# @type const EMPTY_CUSTOM_METHODS: { instance: Array[untyped], singleton: Array[untyped] }
|
|
10
|
+
empty_instance = _ = [] #: Array[untyped]
|
|
11
|
+
empty_singleton = _ = [] #: Array[untyped]
|
|
12
|
+
EMPTY_CUSTOM_METHODS = { instance: empty_instance, singleton: empty_singleton }.freeze
|
|
13
|
+
private_constant :EMPTY_CUSTOM_METHODS
|
|
14
|
+
|
|
8
15
|
class << self
|
|
9
16
|
def emit(klass)
|
|
10
17
|
return unless klass < Data
|
|
@@ -16,8 +23,12 @@ module Structure
|
|
|
16
23
|
meta = klass.respond_to?(:__structure_meta__) ? klass.__structure_meta__ : {}
|
|
17
24
|
|
|
18
25
|
attributes = meta[:mappings] ? meta[:mappings].keys : klass.members
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
# @type var types: Hash[Symbol, untyped]
|
|
27
|
+
default_types = _ = {} #: Hash[Symbol, untyped]
|
|
28
|
+
types = meta.fetch(:types, default_types)
|
|
29
|
+
# @type var required: Array[Symbol]
|
|
30
|
+
required = meta.fetch(:required, attributes)
|
|
31
|
+
custom_methods = meta.fetch(:custom_methods, EMPTY_CUSTOM_METHODS)
|
|
21
32
|
|
|
22
33
|
emit_rbs_content(
|
|
23
34
|
class_name:,
|
|
@@ -25,6 +36,7 @@ module Structure
|
|
|
25
36
|
types:,
|
|
26
37
|
required:,
|
|
27
38
|
has_structure_modules: meta.any?,
|
|
39
|
+
custom_methods:,
|
|
28
40
|
)
|
|
29
41
|
end
|
|
30
42
|
|
|
@@ -49,7 +61,7 @@ module Structure
|
|
|
49
61
|
|
|
50
62
|
private
|
|
51
63
|
|
|
52
|
-
def emit_rbs_content(class_name:, attributes:, types:, required:, has_structure_modules:)
|
|
64
|
+
def emit_rbs_content(class_name:, attributes:, types:, required:, has_structure_modules:, custom_methods:)
|
|
53
65
|
# @type var lines: Array[String]
|
|
54
66
|
lines = []
|
|
55
67
|
lines << "class #{class_name} < Data"
|
|
@@ -63,52 +75,147 @@ module Structure
|
|
|
63
75
|
[attr, rbs_type != "untyped" ? "#{rbs_type}?" : rbs_type]
|
|
64
76
|
end.to_h
|
|
65
77
|
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
# Sort keyword params: required first, then optional (with ? prefix)
|
|
79
|
+
# Within each group, maintain declaration order
|
|
80
|
+
required_params = required.map { |attr| "#{attr}: #{rbs_types[attr]}" }
|
|
81
|
+
optional_params = (attributes - required).map { |attr| "?#{attr}: #{rbs_types[attr]}" }
|
|
82
|
+
keyword_params = (required_params + optional_params).join(", ")
|
|
71
83
|
positional_params = attributes.map { |attr| rbs_types[attr] }.join(", ")
|
|
72
84
|
|
|
73
|
-
lines << " def self.new: (#{keyword_params}) -> #{class_name}"
|
|
74
|
-
lines << " | (#{positional_params}) -> #{class_name}"
|
|
75
|
-
lines << ""
|
|
76
|
-
|
|
77
85
|
needs_parse_data = types.any? do |_attr, type|
|
|
78
86
|
type == :self || type == [:self]
|
|
79
87
|
end
|
|
80
88
|
|
|
89
|
+
# Generate type alias first if needed (RBS::Sorter puts types at top)
|
|
81
90
|
if needs_parse_data
|
|
82
|
-
lines << " type parse_data = {"
|
|
83
|
-
attributes.each do |attr|
|
|
91
|
+
lines << " type parse_data = { " + attributes.map { |attr|
|
|
84
92
|
type = types.fetch(attr, nil)
|
|
85
93
|
parse_type = parse_data_type(type, class_name)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
"?#{attr}: #{parse_type}"
|
|
95
|
+
}.join(", ") + " }"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Build singleton methods list
|
|
99
|
+
# Note: `new` and `[]` are kept at top (RBS::Sorter convention)
|
|
100
|
+
special_singleton_methods = [
|
|
101
|
+
{
|
|
102
|
+
name: "new",
|
|
103
|
+
lines: [
|
|
104
|
+
" def self.new: (#{keyword_params}) -> #{class_name}",
|
|
105
|
+
" | (#{positional_params}) -> #{class_name}",
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "[]",
|
|
110
|
+
lines: [
|
|
111
|
+
" def self.[]: (#{keyword_params}) -> #{class_name}",
|
|
112
|
+
" | (#{positional_params}) -> #{class_name}",
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
# Regular singleton methods (to be sorted)
|
|
118
|
+
# @type var singleton_methods_list: Array[{ name: String, lines: Array[String] }]
|
|
119
|
+
singleton_methods_list = []
|
|
120
|
+
|
|
121
|
+
# Add custom singleton methods
|
|
122
|
+
# @type var custom_singleton: Array[untyped]
|
|
123
|
+
default_singleton = _ = [] #: Array[untyped]
|
|
124
|
+
custom_singleton = custom_methods.fetch(:singleton, default_singleton)
|
|
125
|
+
custom_singleton.each do |method_meta|
|
|
126
|
+
singleton_methods_list << {
|
|
127
|
+
name: method_meta[:name].to_s,
|
|
128
|
+
lines: [format_method_signature(method_meta, singleton: true)],
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Add standard singleton methods
|
|
133
|
+
members_tuple = attributes.map { |attr| ":#{attr}" }.join(", ")
|
|
134
|
+
singleton_methods_list << {
|
|
135
|
+
name: "members",
|
|
136
|
+
lines: [" def self.members: () -> [ #{members_tuple} ]"],
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
singleton_methods_list << if needs_parse_data
|
|
140
|
+
{
|
|
141
|
+
name: "parse",
|
|
142
|
+
lines: [
|
|
143
|
+
" def self.parse: (?parse_data data) -> #{class_name}",
|
|
144
|
+
" | (?Hash[String, untyped] data) -> #{class_name}",
|
|
145
|
+
],
|
|
146
|
+
}
|
|
93
147
|
else
|
|
94
|
-
|
|
95
|
-
|
|
148
|
+
{
|
|
149
|
+
name: "parse",
|
|
150
|
+
lines: [" def self.parse: (?Hash[String | Symbol, untyped], **untyped) -> #{class_name}"],
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Emit special singleton methods first (new, [])
|
|
155
|
+
special_singleton_methods.each do |method|
|
|
156
|
+
# @type var method_lines: Array[String]
|
|
157
|
+
method_lines = method[:lines]
|
|
158
|
+
method_lines.each { |line| lines << line }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Sort and emit other singleton methods
|
|
162
|
+
singleton_methods_list.sort_by { |m| m[:name] }.each do |method|
|
|
163
|
+
lines << "" # Blank line before each method group
|
|
164
|
+
# @type var method_lines: Array[String]
|
|
165
|
+
method_lines = method[:lines]
|
|
166
|
+
method_lines.each { |line| lines << line }
|
|
96
167
|
end
|
|
168
|
+
|
|
97
169
|
lines << ""
|
|
98
170
|
|
|
99
|
-
|
|
171
|
+
# Sort attr_reader lines alphabetically (RBS::Sorter does this)
|
|
172
|
+
attributes.sort.each do |attr|
|
|
100
173
|
lines << " attr_reader #{attr}: #{rbs_types[attr]}"
|
|
101
174
|
end
|
|
102
175
|
lines << ""
|
|
103
176
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
177
|
+
# Build instance methods list (standard + custom), then sort
|
|
178
|
+
# @type var instance_methods_list: Array[{ name: String, lines: Array[String] }]
|
|
179
|
+
instance_methods_list = []
|
|
180
|
+
|
|
181
|
+
# Add boolean predicates
|
|
182
|
+
boolean_predicates = types.sort.select { |attr, type| type == :boolean && !attr.to_s.end_with?("?") }
|
|
183
|
+
boolean_predicates.each do |attr, _type|
|
|
184
|
+
instance_methods_list << {
|
|
185
|
+
name: "#{attr}?",
|
|
186
|
+
lines: [" def #{attr}?: () -> bool"],
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Add custom instance methods
|
|
191
|
+
# @type var custom_instance: Array[untyped]
|
|
192
|
+
default_instance = _ = [] #: Array[untyped]
|
|
193
|
+
custom_instance = custom_methods.fetch(:instance, default_instance)
|
|
194
|
+
custom_instance.each do |method_meta|
|
|
195
|
+
instance_methods_list << {
|
|
196
|
+
name: method_meta[:name].to_s,
|
|
197
|
+
lines: [format_method_signature(method_meta, singleton: false)],
|
|
198
|
+
}
|
|
108
199
|
end
|
|
109
200
|
|
|
201
|
+
# Add standard instance methods
|
|
202
|
+
instance_methods_list << {
|
|
203
|
+
name: "members",
|
|
204
|
+
lines: [" def members: () -> [ #{members_tuple} ]"],
|
|
205
|
+
}
|
|
206
|
+
|
|
110
207
|
hash_type = attributes.map { |attr| "#{attr}: #{rbs_types[attr]}" }.join(", ")
|
|
111
|
-
|
|
208
|
+
instance_methods_list << {
|
|
209
|
+
name: "to_h",
|
|
210
|
+
lines: [" def to_h: () -> { #{hash_type} }"],
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
# Sort and emit instance methods
|
|
214
|
+
instance_methods_list.sort_by { |m| m[:name] }.each do |method|
|
|
215
|
+
# @type var method_lines: Array[String]
|
|
216
|
+
method_lines = method[:lines]
|
|
217
|
+
method_lines.each { |line| lines << line }
|
|
218
|
+
end
|
|
112
219
|
end
|
|
113
220
|
|
|
114
221
|
lines << "end"
|
|
@@ -163,6 +270,54 @@ module Structure
|
|
|
163
270
|
"untyped"
|
|
164
271
|
end
|
|
165
272
|
end
|
|
273
|
+
|
|
274
|
+
def format_method_signature(method_meta, singleton:)
|
|
275
|
+
name = method_meta.fetch(:name)
|
|
276
|
+
parameters = method_meta.fetch(:parameters, [])
|
|
277
|
+
|
|
278
|
+
params_str, block_str = build_parameters_fragment(parameters)
|
|
279
|
+
|
|
280
|
+
method_prefix = singleton ? "self." : ""
|
|
281
|
+
signature = " def #{method_prefix}#{name}: #{params_str}"
|
|
282
|
+
signature += " #{block_str}" if block_str
|
|
283
|
+
signature + " -> untyped"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def build_parameters_fragment(parameters)
|
|
287
|
+
# @type var parts: Array[String]
|
|
288
|
+
parts = []
|
|
289
|
+
block_required = false
|
|
290
|
+
|
|
291
|
+
parameters.each do |kind, param_name|
|
|
292
|
+
case kind
|
|
293
|
+
when :req
|
|
294
|
+
parts << "untyped"
|
|
295
|
+
when :opt
|
|
296
|
+
parts << "?untyped"
|
|
297
|
+
when :rest
|
|
298
|
+
parts << "*untyped"
|
|
299
|
+
when :keyreq
|
|
300
|
+
key_name = param_name || :arg
|
|
301
|
+
parts << "#{key_name}: untyped"
|
|
302
|
+
when :key
|
|
303
|
+
key_name = param_name || :arg
|
|
304
|
+
parts << "?#{key_name}: untyped"
|
|
305
|
+
when :keyrest
|
|
306
|
+
parts << "**untyped"
|
|
307
|
+
when :block
|
|
308
|
+
block_required = true
|
|
309
|
+
else
|
|
310
|
+
parts << "untyped"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
params_str = "(#{parts.join(", ")})"
|
|
315
|
+
params_str = "()" if parts.empty?
|
|
316
|
+
|
|
317
|
+
block_str = block_required ? "?{ (*untyped, **untyped) -> untyped }" : nil
|
|
318
|
+
|
|
319
|
+
[params_str, block_str]
|
|
320
|
+
end
|
|
166
321
|
end
|
|
167
322
|
end
|
|
168
323
|
end
|
data/lib/structure/types.rb
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
3
5
|
module Structure
|
|
4
6
|
# Type coercion methods for converting values to specific types
|
|
5
7
|
module Types
|
|
6
8
|
class << self
|
|
7
9
|
# Rails-style boolean truthy values
|
|
8
10
|
# Reference: https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html
|
|
9
|
-
BOOLEAN_TRUTHY = [true, 1, "1", "t", "T", "true", "TRUE", "on", "ON"].freeze
|
|
11
|
+
BOOLEAN_TRUTHY = Set.new([true, 1, "1", "t", "T", "true", "TRUE", "on", "ON"]).freeze
|
|
10
12
|
private_constant :BOOLEAN_TRUTHY
|
|
11
13
|
|
|
12
14
|
# Main factory method for creating type coercers
|
|
@@ -58,7 +60,7 @@ module Structure
|
|
|
58
60
|
when String
|
|
59
61
|
lazy_class(type, context)
|
|
60
62
|
else
|
|
61
|
-
raise ArgumentError, "
|
|
63
|
+
raise ArgumentError, "cannot specify #{type.inspect} as type"
|
|
62
64
|
end
|
|
63
65
|
end
|
|
64
66
|
|
|
@@ -93,12 +95,15 @@ module Structure
|
|
|
93
95
|
value.map { |element| context.parse(element) }
|
|
94
96
|
end
|
|
95
97
|
when String
|
|
98
|
+
resolved_class = nil
|
|
99
|
+
|
|
96
100
|
lambda do |value|
|
|
97
101
|
unless value.respond_to?(:map)
|
|
98
102
|
raise TypeError, "can't convert #{value.class} into Array"
|
|
99
103
|
end
|
|
100
104
|
|
|
101
|
-
resolved_class
|
|
105
|
+
resolved_class ||= resolve_class(element_type, context)
|
|
106
|
+
# @type var resolved_class: untyped
|
|
102
107
|
value.map { |element| resolved_class.parse(element) }
|
|
103
108
|
end
|
|
104
109
|
else
|
data/lib/structure/version.rb
CHANGED
data/lib/structure.rb
CHANGED
|
@@ -4,6 +4,9 @@ require "structure/builder"
|
|
|
4
4
|
|
|
5
5
|
# A library for parsing data into immutable Ruby Data objects with type coercion
|
|
6
6
|
module Structure
|
|
7
|
+
TO_H_CHECKER = ->(x) { x.respond_to?(:to_h) && x }
|
|
8
|
+
private_constant :TO_H_CHECKER
|
|
9
|
+
|
|
7
10
|
class << self
|
|
8
11
|
# Creates a new Data class with attribute definitions and type coercion
|
|
9
12
|
#
|
|
@@ -21,23 +24,76 @@ module Structure
|
|
|
21
24
|
# person.age # => 30
|
|
22
25
|
def new(&block)
|
|
23
26
|
builder = Builder.new
|
|
24
|
-
builder.instance_eval(&block) if block
|
|
27
|
+
builder.instance_eval(&block) if block # steep:ignore
|
|
25
28
|
|
|
26
29
|
# @type var klass: untyped
|
|
27
30
|
klass = Data.define(*builder.attributes)
|
|
28
31
|
|
|
32
|
+
custom_methods_metadata = { instance: [], singleton: [] }.freeze # steep:ignore
|
|
33
|
+
|
|
34
|
+
# Enable custom method definitions by evaluating block on the class
|
|
35
|
+
if block
|
|
36
|
+
before_instance_methods = klass.instance_methods(false)
|
|
37
|
+
before_singleton_methods = klass.singleton_methods(false)
|
|
38
|
+
|
|
39
|
+
# Provide temporary dummy DSL methods to prevent NoMethodError during class_eval
|
|
40
|
+
klass.define_singleton_method(:attribute) { |*args, **kwargs, &blk| }
|
|
41
|
+
klass.define_singleton_method(:attribute?) { |*args, **kwargs, &blk| }
|
|
42
|
+
klass.define_singleton_method(:after_parse) { |&blk| }
|
|
43
|
+
|
|
44
|
+
# Evaluate block in class context for method definitions
|
|
45
|
+
klass.class_eval(&block)
|
|
46
|
+
|
|
47
|
+
# Remove temporary DSL methods
|
|
48
|
+
klass.singleton_class.send(:remove_method, :attribute)
|
|
49
|
+
klass.singleton_class.send(:remove_method, :attribute?)
|
|
50
|
+
klass.singleton_class.send(:remove_method, :after_parse)
|
|
51
|
+
|
|
52
|
+
after_instance_methods = klass.instance_methods(false)
|
|
53
|
+
after_singleton_methods = klass.singleton_methods(false)
|
|
54
|
+
|
|
55
|
+
newly_defined_instance_methods = after_instance_methods - before_instance_methods
|
|
56
|
+
newly_defined_singleton_methods = after_singleton_methods - before_singleton_methods
|
|
57
|
+
|
|
58
|
+
instance_method_metadata = newly_defined_instance_methods.filter_map do |name|
|
|
59
|
+
next unless klass.public_method_defined?(name)
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
name: name,
|
|
63
|
+
parameters: klass.instance_method(name).parameters,
|
|
64
|
+
}.freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
singleton_class = klass.singleton_class
|
|
68
|
+
singleton_method_metadata = newly_defined_singleton_methods.filter_map do |name|
|
|
69
|
+
next unless singleton_class.public_method_defined?(name)
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
name: name,
|
|
73
|
+
parameters: singleton_class.instance_method(name).parameters,
|
|
74
|
+
}.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
custom_methods_metadata = {
|
|
78
|
+
instance: instance_method_metadata.freeze,
|
|
79
|
+
singleton: singleton_method_metadata.freeze,
|
|
80
|
+
}.freeze
|
|
81
|
+
end
|
|
82
|
+
|
|
29
83
|
# Override initialize to make optional attributes truly optional
|
|
30
84
|
optional_attrs = builder.optional
|
|
31
85
|
unless optional_attrs.empty?
|
|
32
86
|
klass.class_eval do
|
|
33
87
|
alias_method(:__data_initialize__, :initialize)
|
|
34
88
|
|
|
35
|
-
|
|
89
|
+
# steep:ignore:start
|
|
90
|
+
define_method(:initialize) do |**kwargs|
|
|
36
91
|
optional_attrs.each do |attr|
|
|
37
92
|
kwargs[attr] = nil unless kwargs.key?(attr)
|
|
38
93
|
end
|
|
39
|
-
__data_initialize__(**kwargs)
|
|
94
|
+
__data_initialize__(**kwargs)
|
|
40
95
|
end
|
|
96
|
+
# steep:ignore:end
|
|
41
97
|
end
|
|
42
98
|
end
|
|
43
99
|
|
|
@@ -53,6 +109,8 @@ module Structure
|
|
|
53
109
|
coercions: builder.coercions(klass),
|
|
54
110
|
after_parse: builder.after_parse_callback,
|
|
55
111
|
required: builder.required,
|
|
112
|
+
non_nullable: builder.non_nullable,
|
|
113
|
+
custom_methods: custom_methods_metadata,
|
|
56
114
|
}.freeze
|
|
57
115
|
klass.instance_variable_set(:@__structure_meta__, meta)
|
|
58
116
|
klass.singleton_class.attr_reader(:__structure_meta__)
|
|
@@ -63,7 +121,7 @@ module Structure
|
|
|
63
121
|
v = public_send(m)
|
|
64
122
|
value = case v
|
|
65
123
|
when Array then v.map { |x| x.respond_to?(:to_h) && x ? x.to_h : x }
|
|
66
|
-
when
|
|
124
|
+
when TO_H_CHECKER then v.to_h
|
|
67
125
|
else v
|
|
68
126
|
end
|
|
69
127
|
[m, value]
|
|
@@ -84,11 +142,13 @@ module Structure
|
|
|
84
142
|
|
|
85
143
|
overrides&.each { |k, v| data[k.to_s] = v }
|
|
86
144
|
|
|
87
|
-
final
|
|
88
|
-
mappings
|
|
89
|
-
defaults
|
|
90
|
-
|
|
91
|
-
|
|
145
|
+
final = {}
|
|
146
|
+
mappings = __structure_meta__[:mappings]
|
|
147
|
+
defaults = __structure_meta__[:defaults]
|
|
148
|
+
coercions = __structure_meta__[:coercions]
|
|
149
|
+
after_parse = __structure_meta__[:after_parse]
|
|
150
|
+
required = __structure_meta__[:required]
|
|
151
|
+
non_nullable = __structure_meta__[:non_nullable]
|
|
92
152
|
|
|
93
153
|
# Check for missing required attributes
|
|
94
154
|
required.each do |attr|
|
|
@@ -99,6 +159,8 @@ module Structure
|
|
|
99
159
|
end
|
|
100
160
|
|
|
101
161
|
mappings.each do |attr, from|
|
|
162
|
+
key_present = data.key?(from) || data.key?(from.to_sym)
|
|
163
|
+
|
|
102
164
|
value = data.fetch(from) do
|
|
103
165
|
data.fetch(from.to_sym) do
|
|
104
166
|
defaults[attr]
|
|
@@ -106,10 +168,16 @@ module Structure
|
|
|
106
168
|
end
|
|
107
169
|
|
|
108
170
|
if value
|
|
109
|
-
coercion =
|
|
171
|
+
coercion = coercions[attr]
|
|
110
172
|
value = coercion.call(value) if coercion
|
|
111
173
|
end
|
|
112
174
|
|
|
175
|
+
# Check non-null constraint after coercion
|
|
176
|
+
# Only check if key was present in data OR attribute has an explicit default
|
|
177
|
+
if value.nil? && non_nullable.include?(attr) && (key_present || defaults.key?(attr))
|
|
178
|
+
raise ArgumentError, "cannot be null: :#{attr}"
|
|
179
|
+
end
|
|
180
|
+
|
|
113
181
|
final[attr] = value
|
|
114
182
|
end
|
|
115
183
|
|
data/sig/structure/builder.rbs
CHANGED
|
@@ -5,12 +5,13 @@ module Structure
|
|
|
5
5
|
@defaults: Hash[Symbol, untyped]
|
|
6
6
|
@after_parse_callback: Proc?
|
|
7
7
|
@optional: Set[Symbol]
|
|
8
|
+
@non_nullable: Set[Symbol]
|
|
8
9
|
|
|
9
|
-
def attribute: (Symbol name, untyped type, ?from: String
|
|
10
|
-
| (Symbol name, ?from: String, ?default: untyped) ?{ (untyped) -> untyped } -> void
|
|
10
|
+
def attribute: (Symbol name, untyped type, ?from: String | Symbol | nil, ?default: untyped, ?null: bool) ?{ (untyped) -> untyped } -> void
|
|
11
|
+
| (Symbol name, ?from: String | Symbol | nil, ?default: untyped, ?null: bool) ?{ (untyped) -> untyped } -> void
|
|
11
12
|
|
|
12
|
-
def attribute?: (Symbol name, untyped type, ?from: String
|
|
13
|
-
| (Symbol name, ?from: String, ?default: untyped) ?{ (untyped) -> untyped } -> void
|
|
13
|
+
def attribute?: (Symbol name, untyped type, ?from: String | Symbol | nil, ?default: untyped, ?null: bool) ?{ (untyped) -> untyped } -> void
|
|
14
|
+
| (Symbol name, ?from: String | Symbol | nil, ?default: untyped, ?null: bool) ?{ (untyped) -> untyped } -> void
|
|
14
15
|
|
|
15
16
|
def after_parse: () { (Data) -> void } -> void
|
|
16
17
|
|
|
@@ -22,6 +23,10 @@ module Structure
|
|
|
22
23
|
def predicate_methods: () -> Hash[Symbol, Symbol]
|
|
23
24
|
def optional: () -> Array[Symbol]
|
|
24
25
|
def required: () -> Array[Symbol]
|
|
26
|
+
def non_nullable: () -> Array[Symbol]
|
|
25
27
|
def after_parse_callback: () -> (Proc | nil)
|
|
28
|
+
|
|
29
|
+
def method_missing: (Symbol, *untyped, **untyped) ?{ (*untyped, **untyped) -> untyped } -> untyped
|
|
30
|
+
def respond_to_missing?: (Symbol, bool) -> bool
|
|
26
31
|
end
|
|
27
32
|
end
|
data/sig/structure/rbs.rbs
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
module Structure
|
|
2
2
|
module RBS
|
|
3
|
+
EMPTY_CUSTOM_METHODS: { instance: Array[untyped], singleton: Array[untyped] }
|
|
4
|
+
|
|
3
5
|
def self.emit: (untyped klass) -> String?
|
|
4
6
|
def self.write: (untyped klass, ?dir: String) -> String?
|
|
5
7
|
|
|
6
|
-
private def self.emit_rbs_content: (class_name: String, attributes: Array[Symbol], types: Hash[Symbol, untyped], required: Array[Symbol], has_structure_modules: bool) -> String
|
|
8
|
+
private def self.emit_rbs_content: (class_name: String, attributes: Array[Symbol], types: Hash[Symbol, untyped], required: Array[Symbol], has_structure_modules: bool, custom_methods: untyped) -> String
|
|
7
9
|
private def self.parse_data_type: (untyped type, String class_name) -> String
|
|
8
10
|
private def self.map_type_to_rbs: (untyped type, String class_name) -> String
|
|
11
|
+
private def self.format_method_signature: (Hash[Symbol, untyped] method_meta, singleton: bool) -> String
|
|
12
|
+
private def self.build_parameters_fragment: (Array[[Symbol, untyped]] parameters) -> [String, String?]
|
|
9
13
|
end
|
|
10
14
|
end
|
data/sig/structure/types.rbs
CHANGED
data/sig/structure.rbs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
module Structure
|
|
2
|
+
TO_H_CHECKER: Proc
|
|
3
|
+
|
|
2
4
|
interface _StructuredDataClass
|
|
3
5
|
def __structure_meta__: () -> {
|
|
4
6
|
types: Hash[Symbol, untyped],
|
|
@@ -10,5 +12,7 @@ module Structure
|
|
|
10
12
|
}
|
|
11
13
|
end
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
# Block evaluated twice: first for DSL methods (attribute calls), then for custom methods
|
|
16
|
+
# Context set to Builder so DSL methods are recognized
|
|
17
|
+
def self.new: () ?{ () [self: Structure::Builder] -> void } -> untyped
|
|
14
18
|
end
|