structure 4.4.0 → 4.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70c0f08018c0b2adccf83a0fd310719e21e79e2f0945d753fef9df5a08226537
4
- data.tar.gz: e62bbdc33e63db5845d816ba84bbde60433e4137d95a7466dbbcd1376891eccf
3
+ metadata.gz: 548fe77f526e4c9f238da59611f13c3f13614f3111f3cf466054bacb84883b18
4
+ data.tar.gz: f6bda78f70879a1d373c8b5aa1aeb0ef3475004b0475ac5100adf9b6bfa5e657
5
5
  SHA512:
6
- metadata.gz: 4acad52488ad58e454cf052b14727aa70f75bfb921ee5ffb92310287194b2be4019caa98c5afd995df64a9a6356eafde4f6ffcb33680dc504355a79c575ba801
7
- data.tar.gz: 9ed2e262bdf3488c5b074b9b1e8148049908883a92deaec90cd969715ff34879231017bbe7061b0a6d3ac9c812dc999497884f383754987e7bee9a64661e0027
6
+ metadata.gz: 84f01c87a3e85b73d364d3994b1c0af646aedd50db182e9966b48bb21cc77432448342986931600f843b62c58677202247888791f58c901e809d1830e944915e
7
+ data.tar.gz: 8d65b5847a2c6839f49cd66ad9a0d08afa7eda6adeba4930dcb79fcff97b8f580e639b0b9a1c13c1a64c4f31c16a72a9517201998793d2c16143e44af69a7d95
@@ -41,14 +41,15 @@ module Structure
41
41
  # @example Non-nullable attribute
42
42
  # attribute :id, String, null: false
43
43
  def attribute(name, type = nil, from: nil, default: nil, null: true, &block)
44
- mappings[name] = (from || name).to_s
44
+ key = from || name
45
+ mappings[name] = key.is_a?(Symbol) ? key.name : -key.to_s
45
46
  defaults[name] = default unless default.nil?
46
47
  @non_nullable.add(name) unless null
47
48
 
48
49
  if type && block
49
50
  raise ArgumentError, "cannot specify both type and block for :#{name}"
50
51
  else
51
- types[name] = type || block
52
+ types[name] = shareable(type) || shareable_block(block)
52
53
  end
53
54
  end
54
55
 
@@ -77,15 +78,22 @@ module Structure
77
78
 
78
79
  # Defines a callback to run after parsing
79
80
  #
81
+ # @param callable [#call, nil] Object that receives the parsed instance, such as a module with a call method
80
82
  # @yield [instance] Block that receives the parsed instance
81
83
  # @return [void]
84
+ # @raise [ArgumentError] If both a callable and a block are provided
82
85
  #
83
86
  # @example Validation
84
87
  # after_parse do |order|
85
88
  # raise "Invalid order" if order.total < 0
86
89
  # end
87
- def after_parse(&block)
88
- @after_parse_callback = block
90
+ #
91
+ # @example With a module, which any Ractor can call
92
+ # after_parse(OrderValidator)
93
+ def after_parse(callable = nil, &block)
94
+ raise ArgumentError, "cannot specify both a callable and a block for after_parse" if callable && block
95
+
96
+ @after_parse_callback = callable || shareable_block(block)
89
97
  end
90
98
 
91
99
  # @api private
@@ -104,7 +112,7 @@ module Structure
104
112
  def coercions(context = nil)
105
113
  @types.to_h do |attr, type|
106
114
  coercion = Types.coerce(type, context)
107
- [attr, coercion]
115
+ [attr, coercion] #: [Symbol, Proc]
108
116
  rescue ArgumentError => e
109
117
  raise ArgumentError, "#{e.message} for :#{attr}"
110
118
  end
@@ -118,5 +126,32 @@ module Structure
118
126
  end
119
127
  end.to_h
120
128
  end
129
+
130
+ private
131
+
132
+ # Before Ruby 4.1, Ractors that call the same shareable block share its $~
133
+ # (https://bugs.ruby-lang.org/issues/22216), so one that matches a Regexp would read another Ractor's match. A block
134
+ # is shared from 4.1 only, and only when Ruby can: one that uses a local variable from around it stays as it is.
135
+ if defined?(Ractor) && Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("4.1")
136
+ def shareable_block(block)
137
+ block && Ractor.shareable_proc(&block) # steep:ignore
138
+ rescue Ractor::IsolationError, ArgumentError
139
+ block
140
+ end
141
+ else
142
+ def shareable_block(block)
143
+ block
144
+ end
145
+ end
146
+
147
+ # Returns the type as another Ractor can read it: a frozen copy of an Array or of a class name. The caller's own
148
+ # object is left as it is.
149
+ def shareable(type)
150
+ case type
151
+ when Array then type.map { |element| shareable(element) }.freeze
152
+ when String then -type
153
+ else type
154
+ end
155
+ end
121
156
  end
122
157
  end
@@ -64,28 +64,39 @@ module Structure
64
64
  end
65
65
  end
66
66
 
67
+ # Makes an object shareable when Ruby can. A lambda that closes over a caller's unshareable object can't be,
68
+ # and comes back as it is. Where Ruby has no Ractor, nothing is shared.
69
+ if defined?(Ractor)
70
+ # @api private
71
+ def shareable(object)
72
+ Ractor.make_shareable(object)
73
+ rescue Ractor::IsolationError
74
+ object
75
+ end
76
+ else
77
+ # @api private
78
+ def shareable(object)
79
+ object
80
+ end
81
+ end
82
+
67
83
  private
68
84
 
69
85
  def boolean
70
- @boolean ||= ->(val) { BOOLEAN_TRUTHY.include?(val) }
86
+ @boolean ||= shareable(->(val) { BOOLEAN_TRUTHY.include?(val) })
71
87
  end
72
88
 
73
89
  def self_referential(context)
74
- ->(val) { context.parse(val) }
90
+ shareable(->(val) { context.parse(val) })
75
91
  end
76
92
 
77
93
  def lazy_class(class_name, context)
78
- resolved_class = nil
79
-
80
- ->(value) do
81
- resolved_class ||= resolve_class(class_name, context)
82
- # @type var resolved_class: untyped
83
- resolved_class.parse(value)
84
- end
94
+ key = shareable(Object.new)
95
+ shareable(->(value) { parse_as(resolved_class(key, class_name, context), value) })
85
96
  end
86
97
 
87
98
  def array(element_type, context = nil)
88
- case element_type
99
+ coercion = case element_type
89
100
  when :self
90
101
  lambda do |value|
91
102
  unless value.respond_to?(:map)
@@ -95,16 +106,14 @@ module Structure
95
106
  value.map { |element| context.parse(element) }
96
107
  end
97
108
  when String
98
- resolved_class = nil
99
-
109
+ key = shareable(Object.new)
100
110
  lambda do |value|
101
111
  unless value.respond_to?(:map)
102
112
  raise TypeError, "can't convert #{value.class} into Array"
103
113
  end
104
114
 
105
- resolved_class ||= resolve_class(element_type, context)
106
- # @type var resolved_class: untyped
107
- value.map { |element| resolved_class.parse(element) }
115
+ element_class = resolved_class(key, element_type, context)
116
+ value.map { |element| parse_as(element_class, element) }
108
117
  end
109
118
  else
110
119
  element_coercer = coerce(element_type, context)
@@ -116,16 +125,38 @@ module Structure
116
125
  value.map { |element| element_coercer.call(element) }
117
126
  end
118
127
  end
128
+
129
+ shareable(coercion)
119
130
  end
120
131
 
121
132
  def parseable(type)
122
133
  @parseable_cache ||= {} # : Hash[untyped, Proc]
123
- @parseable_cache[type] ||= ->(val) { type.parse(val) }
134
+ @parseable_cache[type] ||= shareable(->(val) { parse_as(type, val) })
135
+ end
136
+
137
+ # A method must make this call, not a lambda. A parser written in C, such as Date.parse, keeps its match in
138
+ # the caller's $~, and a lambda shared between Ractors shares its $~ too.
139
+ def parse_as(type, val)
140
+ type.parse(val)
124
141
  end
125
142
 
126
143
  def kernel(type)
127
144
  @kernel_cache ||= {} # : Hash[untyped, Proc]
128
- @kernel_cache[type] ||= ->(val) { Kernel.send(type.name, val) }
145
+ @kernel_cache[type] ||= shareable(->(val) { Kernel.send(type.name, val) })
146
+ end
147
+
148
+ # Resolves a class given by name once in each Ractor. A shareable lambda can't keep the class itself, so it
149
+ # keeps a key into a weak map in the Ractor's local storage. Where Ruby has no Ractor, the key keeps the class.
150
+ if defined?(Ractor)
151
+ def resolved_class(key, class_name, context)
152
+ classes = Ractor.current[:__structure_classes__] ||= ObjectSpace::WeakMap.new # steep:ignore
153
+ classes[key] ||= resolve_class(class_name, context)
154
+ end
155
+ else
156
+ def resolved_class(key, class_name, context)
157
+ key.instance_variable_get(:@resolved) ||
158
+ key.instance_variable_set(:@resolved, resolve_class(class_name, context))
159
+ end
129
160
  end
130
161
 
131
162
  def resolve_class(class_name, context)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Structure
4
- VERSION = "4.4.0"
4
+ VERSION = "4.5.0"
5
5
  end
data/lib/structure.rb CHANGED
@@ -4,7 +4,7 @@ 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 }
7
+ TO_H_CHECKER = Types.shareable(->(x) { x.respond_to?(:to_h) && x })
8
8
  private_constant :TO_H_CHECKER
9
9
 
10
10
  class << self
@@ -29,7 +29,7 @@ module Structure
29
29
  # @type var klass: untyped
30
30
  klass = Data.define(*builder.attributes)
31
31
 
32
- custom_methods_metadata = { instance: [], singleton: [] }.freeze # steep:ignore
32
+ custom_methods_metadata = { instance: [].freeze, singleton: [].freeze }.freeze # steep:ignore
33
33
 
34
34
  # Enable custom method definitions by evaluating block on the class
35
35
  if block
@@ -39,7 +39,7 @@ module Structure
39
39
  # Provide temporary dummy DSL methods to prevent NoMethodError during class_eval
40
40
  klass.define_singleton_method(:attribute) { |*args, **kwargs, &blk| }
41
41
  klass.define_singleton_method(:attribute?) { |*args, **kwargs, &blk| }
42
- klass.define_singleton_method(:after_parse) { |&blk| }
42
+ klass.define_singleton_method(:after_parse) { |*args, &blk| }
43
43
 
44
44
  # Evaluate block in class context for method definitions
45
45
  klass.class_eval(&block)
@@ -60,7 +60,7 @@ module Structure
60
60
 
61
61
  {
62
62
  name: name,
63
- parameters: klass.instance_method(name).parameters,
63
+ parameters: Types.shareable(klass.instance_method(name).parameters),
64
64
  }.freeze
65
65
  end
66
66
 
@@ -70,7 +70,7 @@ module Structure
70
70
 
71
71
  {
72
72
  name: name,
73
- parameters: singleton_class.instance_method(name).parameters,
73
+ parameters: Types.shareable(singleton_class.instance_method(name).parameters),
74
74
  }.freeze
75
75
  end
76
76
 
@@ -81,42 +81,46 @@ module Structure
81
81
  end
82
82
 
83
83
  # Override initialize to make optional attributes truly optional
84
- optional_attrs = builder.optional
84
+ optional_attrs = builder.optional.freeze
85
85
  unless optional_attrs.empty?
86
- klass.class_eval do
87
- alias_method(:__data_initialize__, :initialize)
86
+ klass.alias_method(:__data_initialize__, :initialize)
88
87
 
89
- # steep:ignore:start
90
- define_method(:initialize) do |**kwargs|
91
- optional_attrs.each do |attr|
92
- kwargs[attr] = nil unless kwargs.key?(attr)
93
- end
94
- __data_initialize__(**kwargs)
88
+ # steep:ignore:start
89
+ define_shareable_method(klass, :initialize) do |**kwargs|
90
+ optional_attrs.each do |attr|
91
+ kwargs[attr] = nil unless kwargs.key?(attr)
95
92
  end
96
- # steep:ignore:end
93
+ __data_initialize__(**kwargs)
97
94
  end
95
+ # steep:ignore:end
98
96
  end
99
97
 
100
98
  builder.predicate_methods.each do |pred, attr|
101
- klass.define_method(pred) { !!public_send(attr) }
99
+ define_common_method(klass, pred) { !!public_send(attr) }
102
100
  end
103
101
 
104
- # Store metadata on class to avoid closure capture (memory optimization)
105
- meta = {
106
- types: builder.types,
107
- defaults: builder.defaults,
108
- mappings: builder.mappings,
109
- coercions: builder.coercions(klass),
102
+ # Store metadata on class to avoid closure capture (memory optimization). What Structure builds of it is
103
+ # frozen, so the whole is shareable when the caller's own types, defaults and blocks are, and then any
104
+ # Ractor can parse.
105
+ klass.instance_variable_set(:@__structure_meta__, {
106
+ types: builder.types.freeze,
107
+ defaults: builder.defaults.freeze,
108
+ mappings: builder.mappings.freeze,
109
+ coercions: builder.coercions(klass).freeze,
110
110
  after_parse: builder.after_parse_callback,
111
- required: builder.required,
112
- non_nullable: builder.non_nullable,
111
+ required: builder.required.freeze,
112
+ non_nullable: builder.non_nullable.freeze,
113
113
  custom_methods: custom_methods_metadata,
114
- }.freeze
115
- klass.instance_variable_set(:@__structure_meta__, meta)
116
- klass.singleton_class.attr_reader(:__structure_meta__)
114
+ }.freeze)
115
+ define_common_method(klass.singleton_class, :__structure_meta__) do
116
+ @__structure_meta__
117
+ rescue Ractor::IsolationError
118
+ raise Ractor::IsolationError,
119
+ "#{self} parses only in the main Ractor: a type, block, default or after_parse callback is not shareable"
120
+ end
117
121
 
118
122
  # recursive to_h
119
- klass.define_method(:to_h) do
123
+ define_shareable_method(klass, :to_h) do
120
124
  klass.members.to_h do |m|
121
125
  v = public_send(m)
122
126
  value = case v
@@ -130,10 +134,9 @@ module Structure
130
134
 
131
135
  # parse accepts JSON-ish hashes + optional overrides hash
132
136
  # overrides is a positional arg (not **kwargs) to avoid hash allocation when unused
133
- #
134
- # @type self: singleton(Data) & _StructuredDataClass
135
- # @type var final: Hash[Symbol, untyped]
136
- klass.singleton_class.define_method(:parse) do |data = {}, overrides = nil|
137
+ define_common_method(klass.singleton_class, :parse) do |data = {}, overrides = nil|
138
+ # @type self: singleton(Data) & _StructuredDataClass
139
+ # @type var final: Hash[Symbol, untyped]
137
140
  return data if data.is_a?(self)
138
141
 
139
142
  unless data.respond_to?(:merge!)
@@ -143,12 +146,13 @@ module Structure
143
146
  overrides&.each { |k, v| data[k.to_s] = v }
144
147
 
145
148
  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]
149
+ meta = __structure_meta__
150
+ mappings = meta[:mappings]
151
+ defaults = meta[:defaults]
152
+ coercions = meta[:coercions]
153
+ after_parse = meta[:after_parse]
154
+ required = meta[:required]
155
+ non_nullable = meta[:non_nullable]
152
156
 
153
157
  # Check for missing required attributes
154
158
  required.each do |attr|
@@ -186,10 +190,25 @@ module Structure
186
190
  obj
187
191
  end
188
192
 
189
- klass.define_singleton_method(:load) { |data| parse(data) if data } # steep:ignore
190
- klass.define_singleton_method(:dump) { |value| value&.to_h }
193
+ define_common_method(klass.singleton_class, :load) { |data| parse(data) if data } # steep:ignore
194
+ define_common_method(klass.singleton_class, :dump) { |value| value&.to_h }
191
195
 
192
196
  klass
193
197
  end
198
+
199
+ private
200
+
201
+ # Defines a method that any Ractor can call, not only the one that defined it. The block may read only locals
202
+ # that hold shareable values and are assigned once.
203
+ def define_shareable_method(target, name, &block)
204
+ target.define_method(name, &Types.shareable(block))
205
+ end
206
+
207
+ # The same, for a method whose block is identical for every structure. The first block given for a name is
208
+ # made shareable once and reused; later ones are ignored.
209
+ def define_common_method(target, name, &block)
210
+ @common_methods ||= {} # : Hash[Symbol, Proc]
211
+ target.define_method(name, &(@common_methods[name] ||= Types.shareable(block)))
212
+ end
194
213
  end
195
214
  end
@@ -3,7 +3,7 @@ module Structure
3
3
  @mappings: Hash[Symbol, String]
4
4
  @types: Hash[Symbol, untyped]
5
5
  @defaults: Hash[Symbol, untyped]
6
- @after_parse_callback: Proc?
6
+ @after_parse_callback: untyped
7
7
  @optional: Set[Symbol]
8
8
  @non_nullable: Set[Symbol]
9
9
 
@@ -13,7 +13,7 @@ module Structure
13
13
  def attribute?: (Symbol name, untyped type, ?from: String | Symbol | nil, ?default: untyped, ?null: bool) ?{ (untyped) -> untyped } -> void
14
14
  | (Symbol name, ?from: String | Symbol | nil, ?default: untyped, ?null: bool) ?{ (untyped) -> untyped } -> void
15
15
 
16
- def after_parse: () { (Data) -> void } -> void
16
+ def after_parse: (?untyped callable) ?{ (Data) -> void } -> void
17
17
 
18
18
  def attributes: () -> Array[Symbol]
19
19
  def mappings: () -> Hash[Symbol, String]
@@ -24,7 +24,10 @@ module Structure
24
24
  def optional: () -> Array[Symbol]
25
25
  def required: () -> Array[Symbol]
26
26
  def non_nullable: () -> Array[Symbol]
27
- def after_parse_callback: () -> (Proc | nil)
27
+ def after_parse_callback: () -> untyped
28
+
29
+ private def shareable: (untyped type) -> untyped
30
+ private def shareable_block: (Proc? block) -> Proc?
28
31
 
29
32
  def method_missing: (Symbol, *untyped, **untyped) ?{ (*untyped, **untyped) -> untyped } -> untyped
30
33
  def respond_to_missing?: (Symbol, bool) -> bool
@@ -12,14 +12,17 @@ module Structure
12
12
  self.@string_class_cache: Hash[Array[untyped], Proc]
13
13
 
14
14
  def self.coerce: (untyped type, ?untyped? context) -> untyped
15
+ def self.shareable: [T] (T object) -> T
15
16
 
16
17
  private def self.boolean: () -> Proc
17
18
  private def self.self_referential: (_ParseableClass context) -> Proc
18
19
  private def self.lazy_class: (String class_name, untyped? context) -> Proc
19
20
  private def self.array: (untyped element_type, ?untyped? context) -> Proc
20
21
  private def self.parseable: (untyped type) -> Proc
22
+ private def self.parse_as: (untyped type, untyped val) -> untyped
21
23
  private def self.kernel: (Class type) -> Proc
22
24
  private def self.parse: (untyped val) -> untyped
25
+ private def self.resolved_class: (Object key, String class_name, untyped? context) -> _ParseableClass
23
26
  private def self.resolve_class: (String class_name, untyped? context) -> _ParseableClass
24
27
  end
25
28
  end
data/sig/structure.rbs CHANGED
@@ -1,6 +1,8 @@
1
1
  module Structure
2
2
  TO_H_CHECKER: Proc
3
3
 
4
+ self.@common_methods: Hash[Symbol, Proc]
5
+
4
6
  interface _StructuredDataClass
5
7
  def __structure_meta__: () -> {
6
8
  types: Hash[Symbol, untyped],
@@ -15,4 +17,7 @@ module Structure
15
17
  # Block evaluated twice: first for DSL methods (attribute calls), then for custom methods
16
18
  # Context set to Builder so DSL methods are recognized
17
19
  def self.new: () ?{ () [self: Structure::Builder] -> void } -> untyped
20
+
21
+ private def self.define_shareable_method: (untyped target, Symbol name) { (?) -> untyped } -> void
22
+ private def self.define_common_method: (untyped target, Symbol name) { (?) -> untyped } -> void
18
23
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: structure
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.4.0
4
+ version: 4.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hakan Ensari
@@ -37,14 +37,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '3.2'
40
+ version: '3.3'
41
41
  required_rubygems_version: !ruby/object:Gem::Requirement
42
42
  requirements:
43
43
  - - ">="
44
44
  - !ruby/object:Gem::Version
45
45
  version: '0'
46
46
  requirements: []
47
- rubygems_version: 4.0.3
47
+ rubygems_version: 4.0.20
48
48
  specification_version: 4
49
49
  summary: Structure your data
50
50
  test_files: []