structure 3.6.2 → 3.7.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: 49afeef61e56c6e3f65ad2eff0855af54552ee4dbcab2282a8d0194e30156362
4
- data.tar.gz: 1b7889d683dd5a129d35446773729212ea0f1348958688e4520f98fab8d90e8f
3
+ metadata.gz: 272646d259a42b8a13d4c0fbc29072022da5260f5a7948d2f8dbaddb9f625adf
4
+ data.tar.gz: a3045255ecb35adc801f827c48a5c620dc90943aee453f474bae6ab85bc8b58d
5
5
  SHA512:
6
- metadata.gz: 1c6ab2be186a6a1eec57cc1bdad3627cccbcd91c20ff803a69d789cd694568c128853431ccb14b7694980700ef95675555919e5e944bf5df54c13c11497c2a19
7
- data.tar.gz: 6d942ea5cc6bb8d012c8c7245480f7bb66857fb30ec20d8afd948183c7c29220baa9129abca1f17037c4db62c159e6860fe0a82c6c837639741c5c9ccec43c7a
6
+ metadata.gz: 4d5914cb2446f277c61369d156d8286cfc50b866423b7bc86523964216d76a677901848a3135a5f1061f0383569fb3ad03624c3806de9d7841281bd751e5aa16
7
+ data.tar.gz: 3684e0a22399da848c493eb6b837d0c8427f0dd6f24857a7f65812914366b448108da8af2b377fc937a08715ea13de633b3f538f6b843a288c65936123dd32b7
@@ -5,12 +5,14 @@ require "structure/types"
5
5
  module Structure
6
6
  # Builder class for accumulating attribute definitions
7
7
  class Builder
8
- attr_reader :mappings, :types, :defaults, :after_parse_callback
8
+ # @api private
9
+ attr_reader :mappings, :defaults, :types, :after_parse_callback
9
10
 
11
+ # @api private
10
12
  def initialize
11
13
  @mappings = {}
12
- @types = {}
13
14
  @defaults = {}
15
+ @types = {}
14
16
  end
15
17
 
16
18
  # DSL method for defining attributes with optional type coercion
@@ -56,14 +58,17 @@ module Structure
56
58
  @after_parse_callback = block
57
59
  end
58
60
 
61
+ # @api private
59
62
  def attributes
60
63
  @mappings.keys
61
64
  end
62
65
 
63
- def coercions(context_class = nil)
64
- @types.transform_values { |type| Types.coerce(type, context_class) }
66
+ # @api private
67
+ def coercions(context = nil)
68
+ @types.transform_values { |type| Types.coerce(type, context) }
65
69
  end
66
70
 
71
+ # @api private
67
72
  def predicate_methods
68
73
  @types.filter_map do |name, type|
69
74
  if type == :boolean
data/lib/structure/rbs.rb CHANGED
@@ -15,10 +15,13 @@ module Structure
15
15
  # @type var meta: Hash[Symbol, untyped]
16
16
  meta = klass.respond_to?(:__structure_meta__) ? klass.__structure_meta__ : {}
17
17
 
18
+ attributes = meta[:mappings] ? meta[:mappings].keys : klass.members
19
+ types = meta.fetch(:types, {}) # steep:ignore
20
+
18
21
  emit_rbs_content(
19
- class_name: class_name,
20
- attributes: meta.fetch(:attributes, klass.members),
21
- types: meta.fetch(:types, {}), # steep:ignore
22
+ class_name:,
23
+ attributes:,
24
+ types:,
22
25
  has_structure_modules: meta.any?,
23
26
  )
24
27
  end
@@ -66,7 +69,7 @@ module Structure
66
69
  lines << ""
67
70
 
68
71
  needs_parse_data = types.any? do |_attr, type|
69
- type == :self || type == [:self] || (type.is_a?(Array) && type.first == :array)
72
+ type == :self || type == [:self]
70
73
  end
71
74
 
72
75
  if needs_parse_data
@@ -111,13 +114,7 @@ module Structure
111
114
  when [:self]
112
115
  "Array[#{class_name} | parse_data]"
113
116
  when Array
114
- if type.first == :array && type.last == :self
115
- "Array[#{class_name} | parse_data]"
116
- elsif type.first == :array
117
- # For [:array, SomeType] format, use Array[untyped] since we coerce
118
- "Array[untyped]"
119
- elsif type.size == 1 && type.first == :self
120
- # [:self] is handled above, this shouldn't happen
117
+ if type.size == 1 && type.first == :self
121
118
  "Array[#{class_name} | parse_data]"
122
119
  elsif type.size == 1
123
120
  # Regular array type like [String], [Integer], etc.
@@ -136,16 +133,20 @@ module Structure
136
133
  def map_type_to_rbs(type, class_name)
137
134
  case type
138
135
  when Class
139
- type.name || "untyped"
136
+ if type == Array
137
+ "Array[untyped]"
138
+ elsif type == Hash
139
+ "Hash[untyped, untyped]"
140
+ else
141
+ type.name || "untyped"
142
+ end
143
+
140
144
  when :boolean
141
145
  "bool"
142
146
  when :self
143
147
  class_name || "untyped"
144
148
  when Array
145
- if type.size == 2 && type.first == :array
146
- element_type = map_type_to_rbs(type.last, class_name)
147
- "Array[#{element_type}]"
148
- elsif type.size == 1
149
+ if type.size == 1
149
150
  # Single element array means array of that type
150
151
  element_type = map_type_to_rbs(type.first, class_name)
151
152
  "Array[#{element_type}]"
@@ -11,130 +11,98 @@ module Structure
11
11
 
12
12
  # Main factory method for creating type coercers
13
13
  #
14
- # @param type [Class, Symbol, Array, String] Type specification
15
- # @return [Proc, Object] Coercion proc or the type itself if no coercion available
14
+ # @param type [Class, Symbol, Array, String, nil] Type specification
15
+ # @param context [Class, nil] Context class for lazy-loading and self-referential types
16
+ # @return [Proc, nil] Coercion proc or nil if no coercion needed
17
+ # @raise [ArgumentError] If type is a Hash instance (typed hashes not yet supported) or unsupported type
16
18
  #
17
19
  # @example Boolean type
18
20
  # coerce(:boolean) # => boolean proc
19
21
  #
22
+ # @example Self-referential types
23
+ # coerce(:self) # => proc that calls context.parse
24
+ #
20
25
  # @example Kernel types
21
26
  # coerce(Integer) # => proc that calls Kernel.Integer
22
27
  #
28
+ # @example Array types
29
+ # coerce([String]) # => proc that coerces array elements to String
30
+ #
23
31
  # @example Parseable types
24
32
  # coerce(Date) # => proc that calls Date.parse
25
33
  #
26
- # @example Array types
27
- # coerce([String]) # => proc that coerces array elements to String
34
+ # @example No coercion
35
+ # coerce(nil) # => nil
36
+ #
37
+ # @example Custom lambdas
38
+ # coerce(->(val) { val.upcase }) # => returns the lambda itself
28
39
  #
29
- # @example String class name (lazy resolved)
40
+ # @example Lazy-resolved classes
30
41
  # coerce("MyClass") # => proc that resolves and coerces to MyClass
31
- def coerce(type, context_class = nil)
42
+ def coerce(type, context = nil)
32
43
  case type
33
44
  when :boolean
34
45
  boolean
35
46
  when :self
36
- self_referential
47
+ self_referential(context)
48
+ when ->(t) { t.respond_to?(:name) && t.name && Kernel.respond_to?(t.name) }
49
+ kernel(type)
50
+ when ->(t) { t.is_a?(Array) && t.length == 1 }
51
+ array(type.first, context)
52
+ when ->(t) { t.respond_to?(:parse) }
53
+ parseable(type)
54
+ when nil
55
+ type
56
+ when ->(t) { t.respond_to?(:call) }
57
+ type
37
58
  when String
38
- string_class(type, context_class)
39
- when Array
40
- if type.length == 1
41
- array(type.first, context_class)
42
- else
43
- type
44
- end
59
+ lazy_class(type, context)
45
60
  else
46
- if type.respond_to?(:parse)
47
- parseable(type)
48
- elsif type.respond_to?(:name) && type.name && Kernel.respond_to?(type.name)
49
- kernel(type)
50
- else
51
- type
52
- end
61
+ raise ArgumentError, "Cannot specify #{type.inspect} as type"
53
62
  end
54
63
  end
55
64
 
56
- def resolve_class(class_name, context_class)
57
- if context_class && defined?(context_class.name)
58
- namespace = context_class.name.to_s.split("::")[0...-1]
59
- if namespace.any?
60
- begin
61
- namespace.reduce(Object) { |mod, name| mod.const_get(name) }.const_get(class_name)
62
- rescue NameError
63
- Object.const_get(class_name)
64
- end
65
- else
66
- Object.const_get(class_name)
67
- end
68
- else
69
- Object.const_get(class_name)
70
- end
71
- rescue NameError => e
72
- raise NameError, "Unable to resolve class '#{class_name}': #{e.message}"
73
- end
74
-
75
65
  private
76
66
 
77
67
  def boolean
78
- ->(val) { BOOLEAN_TRUTHY.include?(val) }
79
- end
80
-
81
- def self_referential
82
- proc { |val| parse(val) }
68
+ @boolean ||= ->(val) { BOOLEAN_TRUTHY.include?(val) }
83
69
  end
84
70
 
85
- def kernel(type)
86
- ->(val) { Kernel.send(type.name, val) }
87
- end
88
-
89
- def parseable(type)
90
- ->(val) { type.parse(val) }
71
+ def self_referential(context)
72
+ ->(val) { context.parse(val) }
91
73
  end
92
74
 
93
- def string_class(class_name, context_class)
75
+ def lazy_class(class_name, context)
94
76
  resolved_class = nil
95
- mutex = Mutex.new
96
77
 
97
- proc do |value|
98
- unless resolved_class
99
- mutex.synchronize do
100
- resolved_class ||= Structure::Types.resolve_class(class_name, context_class)
101
- end
102
- end
103
-
104
- if resolved_class.respond_to?(:parse)
105
- resolved_class.parse(value) # steep:ignore
106
- else
107
- value
108
- end
78
+ ->(value) do
79
+ resolved_class ||= resolve_class(class_name, context)
80
+ # @type var resolved_class: untyped
81
+ resolved_class.parse(value)
109
82
  end
110
83
  end
111
84
 
112
- def array(element_type, context_class = nil)
113
- if element_type == :self
114
- proc do |value|
85
+ def array(element_type, context = nil)
86
+ case element_type
87
+ when :self
88
+ lambda do |value|
115
89
  unless value.respond_to?(:map)
116
90
  raise TypeError, "can't convert #{value.class} into Array"
117
91
  end
118
92
 
119
- value.map { |element| parse(element) }
93
+ value.map { |element| context.parse(element) }
120
94
  end
121
- elsif element_type.is_a?(String)
122
- proc do |value|
95
+ when String
96
+ lambda do |value|
123
97
  unless value.respond_to?(:map)
124
98
  raise TypeError, "can't convert #{value.class} into Array"
125
99
  end
126
100
 
127
- resolved_class = Structure::Types.resolve_class(element_type, context_class)
128
- value.map do |element|
129
- if resolved_class.respond_to?(:parse)
130
- resolved_class.parse(element)
131
- else
132
- element
133
- end
134
- end
101
+ resolved_class = resolve_class(element_type, context)
102
+ value.map { |element| resolved_class.parse(element) }
135
103
  end
136
104
  else
137
- element_coercer = coerce(element_type, context_class)
105
+ element_coercer = coerce(element_type, context)
138
106
  lambda do |value|
139
107
  unless value.respond_to?(:map)
140
108
  raise TypeError, "can't convert #{value.class} into Array"
@@ -144,6 +112,35 @@ module Structure
144
112
  end
145
113
  end
146
114
  end
115
+
116
+ def parseable(type)
117
+ @parseable_cache ||= {} # : Hash[untyped, Proc]
118
+ @parseable_cache[type] ||= ->(val) { type.parse(val) }
119
+ end
120
+
121
+ def kernel(type)
122
+ @kernel_cache ||= {} # : Hash[untyped, Proc]
123
+ @kernel_cache[type] ||= ->(val) { Kernel.send(type.name, val) }
124
+ end
125
+
126
+ def resolve_class(class_name, context)
127
+ if context && defined?(context.name)
128
+ namespace = context.name.to_s.split("::")[0...-1]
129
+ if namespace.any?
130
+ begin
131
+ namespace.reduce(Object) { |mod, name| mod.const_get(name) }.const_get(class_name)
132
+ rescue NameError
133
+ Object.const_get(class_name)
134
+ end
135
+ else
136
+ Object.const_get(class_name)
137
+ end
138
+ else
139
+ Object.const_get(class_name)
140
+ end
141
+ rescue NameError => e
142
+ raise NameError, "Unable to resolve class '#{class_name}': #{e.message}"
143
+ end
147
144
  end
148
145
  end
149
146
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Structure
4
- VERSION = "3.6.2"
4
+ VERSION = "3.7.0"
5
5
  end
data/lib/structure.rb CHANGED
@@ -26,98 +26,72 @@ module Structure
26
26
  # @type var klass: untyped
27
27
  klass = Data.define(*builder.attributes)
28
28
 
29
- # capture all metadata and attach to class - no closure capture needed
30
- mappings = builder.mappings
31
- coercions = builder.coercions(klass)
32
- predicates = builder.predicate_methods
33
- after = builder.after_parse_callback
29
+ builder.predicate_methods.each do |pred, attr|
30
+ klass.define_method(pred) { !!public_send(attr) }
31
+ end
34
32
 
33
+ # Store metadata on class to avoid closure capture (memory optimization)
35
34
  meta = {
36
- attributes: builder.attributes.freeze,
37
- types: builder.types.freeze,
38
- defaults: builder.defaults.freeze,
39
- mappings: mappings.freeze,
40
- coercions: coercions.freeze,
41
- predicates: predicates.freeze,
42
- after: after,
35
+ types: builder.types,
36
+ defaults: builder.defaults,
37
+ mappings: builder.mappings,
38
+ coercions: builder.coercions(klass),
39
+ after_parse: builder.after_parse_callback,
43
40
  }.freeze
44
41
  klass.instance_variable_set(:@__structure_meta__, meta)
45
42
  klass.singleton_class.attr_reader(:__structure_meta__)
46
43
 
47
- # Define predicate methods
48
- predicates.each do |pred, attr|
49
- klass.define_method(pred) { !!public_send(attr) }
50
- end
51
-
52
44
  # recursive to_h
53
45
  klass.define_method(:to_h) do
54
- # @type var h: Hash[Symbol, untyped]
55
- h = {}
56
- klass.members.each do |m|
46
+ klass.members.to_h do |m|
57
47
  v = public_send(m)
58
- h[m] =
59
- case v
60
- when Array then v.map { |x| x.respond_to?(:to_h) && x ? x.to_h : x }
61
- when ->(x) { x.respond_to?(:to_h) && x } then v.to_h
62
- else v
63
- end
48
+ value = case v
49
+ when Array then v.map { |x| x.respond_to?(:to_h) && x ? x.to_h : x }
50
+ when ->(x) { x.respond_to?(:to_h) && x } then v.to_h
51
+ else v
52
+ end
53
+ [m, value]
64
54
  end
65
- h
66
55
  end
67
56
 
68
- # parse accepts JSON-ish hashes + kwargs override - using string eval to avoid closure capture
69
- klass.singleton_class.class_eval(<<~RUBY)
70
- def parse(data = {}, **kwargs)
71
- return data if data.is_a?(self)
57
+ # parse accepts JSON-ish hashes + optional overrides hash
58
+ # overrides is a positional arg (not **kwargs) to avoid hash allocation when unused
59
+ #
60
+ # @type self: singleton(Data) & _StructuredDataClass
61
+ # @type var final: Hash[Symbol, untyped]
62
+ klass.singleton_class.define_method(:parse) do |data = {}, overrides = nil|
63
+ return data if data.is_a?(self)
72
64
 
73
- unless data.respond_to?(:merge!)
74
- raise TypeError, "can't convert \#{data.class} into \#{self}"
75
- end
76
-
77
- # @type var kwargs: Hash[Symbol, untyped]
78
- string_kwargs = kwargs.transform_keys(&:to_s)
79
- data.merge!(string_kwargs)
80
- # @type self: singleton(Data) & _StructuredDataClass
81
- # @type var final: Hash[Symbol, untyped]
82
- final = {}
83
-
84
- # @type var meta: untyped
85
- meta = __structure_meta__
65
+ unless data.respond_to?(:merge!)
66
+ raise TypeError, "can't convert #{data.class} into #{self}"
67
+ end
86
68
 
87
- attributes = meta.fetch(:attributes)
88
- defaults = meta.fetch(:defaults)
89
- mappings = meta.fetch(:mappings)
90
- coercions = meta.fetch(:coercions)
91
- after = meta.fetch(:after)
69
+ overrides&.each { |k, v| data[k.to_s] = v }
92
70
 
93
- attributes.each do |attr|
94
- source = mappings[attr] || attr.to_s
95
- value =
96
- if data.key?(source) then data[source]
97
- elsif data.key?(source.to_sym) then data[source.to_sym]
98
- elsif defaults.key?(attr) then defaults[attr]
99
- end
71
+ final = {}
72
+ mappings = __structure_meta__[:mappings]
73
+ defaults = __structure_meta__[:defaults]
74
+ after_parse = __structure_meta__[:after_parse]
100
75
 
101
- coercion = coercions[attr]
102
- if coercion && !value.nil?
103
- # Procs (not lambdas) need class context for self-referential parsing
104
- # Lambdas and other callables use direct invocation
105
- value =
106
- if coercion.is_a?(Proc) && !coercion.lambda?
107
- instance_exec(value, &coercion) # steep:ignore
108
- else
109
- coercion.call(value)
110
- end
76
+ mappings.each do |attr, from|
77
+ value = data.fetch(from) do
78
+ data.fetch(from.to_sym) do
79
+ defaults[attr]
111
80
  end
81
+ end
112
82
 
113
- final[attr] = value
83
+ if value
84
+ coercion = __structure_meta__[:coercions][attr]
85
+ value = coercion.call(value) if coercion
114
86
  end
115
87
 
116
- obj = new(**final)
117
- after&.call(obj) if after
118
- obj
88
+ final[attr] = value
119
89
  end
120
- RUBY
90
+
91
+ obj = new(**final)
92
+ after_parse&.call(obj)
93
+ obj
94
+ end
121
95
 
122
96
  klass
123
97
  end
@@ -14,7 +14,7 @@ module Structure
14
14
  def mappings: () -> Hash[Symbol, String]
15
15
  def types: () -> Hash[Symbol, untyped]
16
16
  def defaults: () -> Hash[Symbol, untyped]
17
- def coercions: (?untyped? context_class) -> Hash[Symbol, Proc]
17
+ def coercions: (?untyped? context) -> Hash[Symbol, Proc]
18
18
  def predicate_methods: () -> Hash[Symbol, Symbol]
19
19
  def after_parse_callback: () -> (Proc | nil)
20
20
  end
@@ -1,18 +1,25 @@
1
1
  module Structure
2
2
  module Types
3
+ interface _ParseableClass
4
+ def parse: (untyped) -> untyped
5
+ end
6
+
3
7
  BOOLEAN_TRUTHY: Array[untyped]
4
8
 
5
9
  self.@boolean: Proc
10
+ self.@kernel_cache: Hash[untyped, Proc]
11
+ self.@parseable_cache: Hash[untyped, Proc]
12
+ self.@string_class_cache: Hash[Array[untyped], Proc]
6
13
 
7
- def self.coerce: (untyped type, ?untyped? context_class) -> untyped
8
- def self.resolve_class: (String class_name, untyped? context_class) -> untyped
14
+ def self.coerce: (untyped type, ?untyped? context) -> untyped
9
15
 
10
16
  private def self.boolean: () -> Proc
11
- private def self.self_referential: () -> Proc
12
- private def self.string_class: (String class_name, untyped? context_class) -> Proc
13
- private def self.array: (untyped element_type, ?untyped? context_class) -> Proc
17
+ private def self.self_referential: (_ParseableClass context) -> Proc
18
+ private def self.lazy_class: (String class_name, untyped? context) -> Proc
19
+ private def self.array: (untyped element_type, ?untyped? context) -> Proc
14
20
  private def self.parseable: (untyped type) -> Proc
15
21
  private def self.kernel: (Class type) -> Proc
16
22
  private def self.parse: (untyped val) -> untyped
23
+ private def self.resolve_class: (String class_name, untyped? context) -> _ParseableClass
17
24
  end
18
25
  end
data/sig/structure.rbs CHANGED
@@ -1,13 +1,11 @@
1
1
  module Structure
2
2
  interface _StructuredDataClass
3
3
  def __structure_meta__: () -> {
4
- attributes: Array[Symbol],
5
4
  types: Hash[Symbol, untyped],
6
5
  defaults: Hash[Symbol, untyped],
7
6
  mappings: Hash[Symbol, String],
8
7
  coercions: Hash[Symbol, untyped],
9
- predicates: Hash[Symbol, Symbol],
10
- after: untyped
8
+ after_parse: untyped
11
9
  }
12
10
  end
13
11
 
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: 3.6.2
4
+ version: 3.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hakan Ensari