lutaml-model 0.8.37 → 0.8.39

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: ec31a25915a5fd6ceeb31c2e61456c90ae223e565eab14051f8ddb40e373fe9e
4
- data.tar.gz: a2e6027e8684bfcc63e58650f979d880e43836ea5521f0ee0ba5e30205bd89cf
3
+ metadata.gz: 15f9489eb4db353b481f160703e848e35b730cef179d32874941fc537ed962d3
4
+ data.tar.gz: f0067a5ffc4d66c8312d9cc478d9bd7d1771d9b639111c3158956b2d98e4468e
5
5
  SHA512:
6
- metadata.gz: a101732ad5730070d1c7629b9bf0e72e6573d01a716cf165846b8474f5b7dca7e2415d80ed7a1f8d526fea61363ecbba414e9ecb819f8f2b42b9f09636e81d03
7
- data.tar.gz: 42e60f78f63d5d8033edba244858479c95dc1147cf9cc2698ab410d259bd8485a2d2df9825482dc3f895d673177212c1e686a143b5afdde2850d78a706241170
6
+ metadata.gz: 9f1372cbf492e141015524382a3ac98cdc5f484441d70c593b7ad67b9021bfa959732700679534bb3690f86c064e3a1a82269833c5c73794fe87af24f21d239b
7
+ data.tar.gz: '048d412becdba7a8a99070740663d0d0a33e88d02018708818ce816d1ba4669a1f26efbf08d0c67390818c5efede0e362ef8f1785536c4233a2fa6dadd07da61'
data/bench/gate_config.rb CHANGED
@@ -57,10 +57,11 @@ module GateConfig
57
57
  "iso-13849-1MB" => {
58
58
  alloc_ratio: 1.05,
59
59
  time_ratio: 1.15,
60
- # GH-hosted ubuntu runners run this fixture at ~12s min_time
61
- # (observed across moxml 0.1–0.5); 5.0 never passed there and
62
- # fired on every run. 15.0 keeps the 2x-catastrophe safety net.
63
- absolute_max: 15.0,
60
+ # Post-TODO.max-perf/01-03 the fixture sits at ~4.88M allocs and
61
+ # 14.5-16.5s on GH-hosted ubuntu runners (runner-variance band);
62
+ # the old 15.0 missed by 0.06s on a healthy run. 30.0 keeps the
63
+ # 2x-catastrophe safety net over the healthy band (#313).
64
+ absolute_max: 30.0,
64
65
  },
65
66
  "din-iso-1.1MB" => {
66
67
  alloc_ratio: 1.05,
@@ -0,0 +1,71 @@
1
+ = Custom serialization methods
2
+
3
+ :toc:
4
+
5
+ Lutaml::Model maps serialization constructs (`map_element`, `map_attribute`, `map_content`, key-value `map`) to *model attributes*. When the wire shape and the model shape disagree, custom methods bridge the gap at the mapping site instead of hand-rolled `to_h`/`from_h` code.
6
+
7
+ == `with:` — per-rule custom methods
8
+
9
+ Declare both directions on the rule. The methods live on the model (instance methods); the names are symbols.
10
+
11
+ [source,ruby]
12
+ ----
13
+ class Requirement
14
+ include Lutaml::Model::Serialize
15
+
16
+ attribute :guidance, :string
17
+ attribute :purpose, :string
18
+
19
+ xml do
20
+ element "requirement"
21
+ map_element "component", to: :guidance,
22
+ with: { from: :component_from_guidance, to: :component_to_guidance }
23
+ end
24
+
25
+ def component_from_guidance(model, value, ctx = nil)
26
+ # `from` receives (model, value[, context]); assign the model attribute
27
+ model.guidance = value
28
+ end
29
+
30
+ def component_to_guidance(model, parent, doc = nil, ctx = nil)
31
+ # `to` receives (model, parent[, doc[, context]]); write into parent/doc
32
+ parent.add_element("component", model.guidance, type: "guidance")
33
+ end
34
+ end
35
+ ----
36
+
37
+ Notes:
38
+
39
+ * The `from` method *assigns* the attribute (it receives the whole model); the `to` method *writes* the output document.
40
+ * The optional trailing `context` parameter receives the options hash passed to `Model.from_xml(data, context: {...})` / `model.to_xml(context: {...})` — it is forwarded when the method declares it (lutaml-model#550).
41
+ * Callables work in key-value mappings: `with: { from: ->(value) { ... } }` maps and assigns the return value; an exact-arity-2 callable receives the context as its second argument.
42
+
43
+ == `transform:` — value-level transforms
44
+
45
+ `transform:` converts values (not documents). A Hash form (`transform: { from: ->(v) {...}, to: ->(v) {...} }`) wraps the value in both directions without touching assignment; a `ValueTransformer` subclass receives `from(value, format)` / `to(value, format)` and is shared by every rule that references it.
46
+
47
+ == Type-level serialization
48
+
49
+ A *type class* (a `Lutaml::Model::Type::Value` subclass) can carry its own format behavior, so reuse lives on the type rather than the parent mapping:
50
+
51
+ [source,ruby]
52
+ ----
53
+ class XmiNsType < Lutaml::Model::Type::String
54
+ xml do
55
+ namespace XmiNs # bind the type's own namespace
56
+ end
57
+ end
58
+ ----
59
+
60
+ `Lutaml::Model::Type::Value.register_format_type_serializer(format, type_class, to:, from:)` registers per-format serialize/deserialize procs for a type class; resolution walks the hierarchy.
61
+
62
+ == `of_*` and the conversion entry points
63
+
64
+ `Model.of_<format>(parsed_document, options)` is the documented seam between adapter parsing and mapping application: the adapter (`from_<format>`) parses the wire data into a document object, and `of_<format>` (class method) applies the mappings to build instances. It is public API for tooling that already holds a parsed document — e.g. re-applying mappings to a reparsed tree — and is what `from_<format>` delegates to.
65
+
66
+ The per-instance counterparts are `to_<format>` (serialize through the mappings) and `as_<format>` (render without register-specific side effects). Prefer the generated `from_*/to_*` methods; reach for `of_*` only when you interpose between parse and mapping.
67
+
68
+ == Common recipes
69
+
70
+ * One wire element, several attributes dispatched by an attribute value (`component/@type`): one `with:` pair per attribute whose `from` inspects the dispatch attribute and whose `to` emits only for its type. (Polymorphic *class* dispatch has first-class support via `polymorphic:`.)
71
+ * Key remapping, nesting shifts, delimiter splitting: prefer `transform:`, `child_mappings`, and `root`/`prefix` options before custom methods — the mapping stays declarative and reviewable.
@@ -19,6 +19,7 @@ Task-oriented guides for accomplishing specific goals with Lutaml::Model.
19
19
 
20
20
  == Key-value serialization
21
21
 
22
+ * link:../custom-methods[Custom Serialization Methods] - with:/transform:, type-level serialization, of_* entry points
22
23
  * link:../keyvalue-serialization[Key-Value Serialization] - JSON/YAML/TOML/Hash
23
24
  * link:../collection-serialization[Collection Serialization] - JSONL and YAML Stream
24
25
 
@@ -146,6 +146,7 @@ require "lutaml/xml/encoding_normalizer"
146
146
  require "lutaml/xml/format"
147
147
  require "lutaml/xml/format_chooser"
148
148
  require "lutaml/xml/hoisting_algorithm"
149
+ require "lutaml/xml/html_entities"
149
150
  require "lutaml/xml/listener"
150
151
  require "lutaml/xml/mapping"
151
152
  require "lutaml/xml/mapping_rule"
@@ -68,10 +68,33 @@ module Lutaml
68
68
 
69
69
  def process_mapping_for_instance(instance, hash, format, rule, options)
70
70
  if rule.custom_methods[:to]
71
- return instance.public_send(rule.custom_methods[:to], instance, hash)
71
+ to_method = rule.custom_methods[:to]
72
+ # lutaml-model#550: custom methods may declare a third context
73
+ # parameter to receive the options passed to `to_*`.
74
+ if instance.method(to_method).parameters.size >= 3
75
+ return instance.public_send(to_method, instance, hash,
76
+ options[:context])
77
+ end
78
+
79
+ return instance.public_send(to_method, instance, hash)
72
80
  end
73
81
 
74
82
  attribute = attributes[rule.to]
83
+
84
+ # TODO.max-perf/10: plain scalar/collection rules take a fast
85
+ # lane — memoized wire name, shared render? semantics, one
86
+ # serialize dispatch — collapsing the interpretive branch
87
+ # probes below. Rules with any special feature stay on the
88
+ # full path.
89
+ if (plan = kv_serialize_plan(rule, attribute, instance))
90
+ value = instance.public_send(attribute.name)
91
+ if rule.render?(value, instance)
92
+ hash[plan.wire] = attribute.serialize(value, format,
93
+ lutaml_register, options)
94
+ end
95
+ return
96
+ end
97
+
75
98
  value = rule.serialize(instance)
76
99
 
77
100
  if rule.can_transform_to?(attribute, format)
@@ -90,7 +113,9 @@ module Lutaml
90
113
  end
91
114
 
92
115
  # Use the format parameter passed in instead of hardcoding to :json
93
- value = ExportTransformer.call(value, rule, attribute, format: format)
116
+ value = ExportTransformer.call(value, rule, attribute,
117
+ format: format,
118
+ context: options[:context])
94
119
 
95
120
  value = serialize_value(value, rule, attribute, format, options)
96
121
 
@@ -247,7 +272,7 @@ format)
247
272
  if (plan = kv_rule_plan(format, rule, attr)) &&
248
273
  (value = kv_fast_extract(doc, plan))
249
274
  rule.deserialize(instance, kv_fast_cast(value, plan, instance),
250
- attributes, self)
275
+ attributes, self, options[:context])
251
276
  return
252
277
  end
253
278
 
@@ -261,7 +286,8 @@ format)
261
286
  # only nil (non-existent) skips it (lutaml-model#746).
262
287
  return if value.nil?
263
288
 
264
- return rule.deserialize(instance, value, attributes, model_class)
289
+ return rule.deserialize(instance, value, attributes, model_class,
290
+ options[:context])
265
291
  end
266
292
 
267
293
  value = rule.transform_value(attr, value, :from, format)
@@ -279,7 +305,8 @@ format)
279
305
  if attr.collection? || !instance.is_a?(Lutaml::Model::Serialize)
280
306
  attr.valid_collection!(value, context)
281
307
  end
282
- rule.deserialize(instance, value, attributes, self)
308
+ rule.deserialize(instance, value, attributes, self,
309
+ options[:context])
283
310
  end
284
311
 
285
312
  # Compiled rule plans (TODO.perf/07): for plain scalar rules the
@@ -291,6 +318,25 @@ format)
291
318
  # unions, polymorphism) return nil and keep the interpretive path.
292
319
  KvRulePlan = ::Struct.new(:wire, :klass, :collection)
293
320
 
321
+ # Memoized (per transform) wire name for plain rules — the from
322
+ # spelling never changes after definition.
323
+ KvSerializePlan = ::Struct.new(:wire)
324
+
325
+ def kv_serialize_plan(rule, attr, _instance)
326
+ return nil if rule.delegate || rule.raw_mapping? || rule.root_mapping? ||
327
+ rule.hash_mappings || rule.child_mappings ||
328
+ rule.has_custom_method_for_serialization? ||
329
+ rule.multiple_mappings? || polymorphic_rule?(rule) ||
330
+ (rule.transform.is_a?(Hash) && !rule.transform.empty?) ||
331
+ rule.transform.is_a?(Class) || attr.nil? || attr.derived? ||
332
+ attr.union? || attr.polymorphic? || attr.custom_collection? ||
333
+ attr.transform ||
334
+ rule.value_map(:to) != Lutaml::Model::Serialize::DEFAULT_VALUE_MAP
335
+
336
+ @kv_serialize_plans ||= {}.compare_by_identity
337
+ @kv_serialize_plans[rule] ||= KvSerializePlan.new(rule_from_name(rule))
338
+ end
339
+
294
340
  def kv_rule_plan(format, rule, attr)
295
341
  @kv_rule_plans ||= {}
296
342
  @kv_rule_plans[[format, rule]] ||= build_kv_rule_plan(format, rule,
@@ -220,9 +220,17 @@ register = self.register)
220
220
 
221
221
  # Handle custom serialization methods (e.g., with: { to: ... })
222
222
  if rule.has_custom_methods? && rule.custom_methods[:to]
223
+ to_method = rule.custom_methods[:to]
224
+ # lutaml-model#550: custom methods may declare a third context
225
+ # parameter to receive the options passed to `to_*`.
226
+ if model_instance.method(to_method).parameters.size >= 3
227
+ return model_instance.public_send(to_method, model_instance,
228
+ parent, options[:context])
229
+ end
230
+
223
231
  # Call custom method which directly modifies the parent element
224
- return model_instance.public_send(rule.custom_methods[:to],
225
- model_instance, parent)
232
+ return model_instance.public_send(to_method, model_instance,
233
+ parent)
226
234
  end
227
235
 
228
236
  # Handle delegation - extract value from delegated object
@@ -471,7 +471,11 @@ module Lutaml
471
471
  @options[:ref_model_class], @options[:ref_key_attribute])
472
472
  end
473
473
 
474
- validate_attr_type!(resolved_type)
474
+ # The castability verdict is a class-hierarchy fact, immutable
475
+ # per resolved type — check once, not per value (TODO.max-perf/15).
476
+ checked = (@castable_types ||= {}.compare_by_identity)
477
+ validate_attr_type!(resolved_type) unless checked[resolved_type]
478
+ checked[resolved_type] = true
475
479
 
476
480
  resolved_type.cast(value)
477
481
  end
@@ -208,6 +208,15 @@ module Lutaml
208
208
  # @param args [Array] Arguments (ignored for options)
209
209
  # @param block [Proc] Block (ignored for options)
210
210
  # @return [Object] The option value or nil
211
+ # Static per rule (see MappingRule#custom_method_only?). Compiled
212
+ # rules are frozen, so the answer is recomputed with a single
213
+ # to_s instead of the historical two.
214
+ def custom_method_only?
215
+ name = attribute_name.to_s
216
+ (name.start_with?("__") && name.end_with?("__")) ||
217
+ (!custom_methods.empty? && attribute_type.nil?)
218
+ end
219
+
211
220
  def method_missing(method_name, *args, &block)
212
221
  # Check if this is an option key
213
222
  if options.key?(method_name)
@@ -23,7 +23,8 @@ module Lutaml
23
23
  # @param adapter_options [Hash, nil] { available: [...], default: :name }
24
24
  def register(format, mapping_class:, adapter_class:, transformer:,
25
25
  adapter_loader: nil, castable_type: nil, key_value: nil,
26
- rdf: nil, error_types: nil, adapter_options: nil)
26
+ rdf: nil, error_types: nil, adapter_options: nil,
27
+ stream_methods: false)
27
28
  validate_registration!(format, mapping_class, transformer)
28
29
 
29
30
  registered_formats[format] = {
@@ -44,6 +45,7 @@ module Lutaml
44
45
  ::Lutaml::Model::Serialize.register_format_mapping_method(format)
45
46
  ::Lutaml::Model::Serialize.register_from_format_method(format)
46
47
  ::Lutaml::Model::Serialize.register_to_format_method(format)
48
+ ::Lutaml::Model::Serialize.register_stream_methods(format) if stream_methods
47
49
 
48
50
  ::Lutaml::Model::Attribute.format_specific_warn_names.push(:"to_#{format}")
49
51
 
@@ -268,9 +268,16 @@ module Lutaml
268
268
  end
269
269
  end
270
270
 
271
- def serialize(model, parent = nil, doc = nil)
271
+ def serialize(model, parent = nil, doc = nil, context = nil)
272
272
  if custom_methods[:to]
273
- model.public_send(custom_methods[:to], model, parent, doc)
273
+ to_method = custom_methods[:to]
274
+ # lutaml-model#550: custom methods may declare a fourth
275
+ # context parameter to receive the options passed to `to_*`.
276
+ if model.method(to_method).parameters.size >= 4
277
+ model.public_send(to_method, model, parent, doc, context)
278
+ else
279
+ model.public_send(to_method, model, parent, doc)
280
+ end
274
281
  else
275
282
  value = to_value_for(model)
276
283
 
@@ -289,16 +296,29 @@ module Lutaml
289
296
  end
290
297
  end
291
298
 
292
- def deserialize(model, value, attributes, mapper_class = nil)
299
+ def deserialize(model, value, attributes, mapper_class = nil,
300
+ context = nil)
293
301
  if @needs_full_deserialize
294
- handle_custom_method(model, value, mapper_class) ||
302
+ handle_custom_method(model, value, mapper_class, context) ||
295
303
  handle_delegate(model, value, attributes) ||
296
- handle_transform_method(model, value, attributes)
304
+ handle_transform_method(model, value, attributes, context)
297
305
  else
298
- handle_transform_method(model, value, attributes)
306
+ handle_transform_method(model, value, attributes, context)
299
307
  end
300
308
  end
301
309
 
310
+ # Static per rule: placeholder `__name__` targets and inferred
311
+ # custom-method targets without an attribute type never change —
312
+ # the applier asked this per element and paid two to_s strings
313
+ # each time (240k allocations on the ISO-13849 serialize profile).
314
+ # Rules may be frozen, so no per-instance memo: one to_s instead
315
+ # of two is the frozen-safe diet.
316
+ def custom_method_only?
317
+ name = attribute_name.to_s
318
+ (name.start_with?("__") && name.end_with?("__")) ||
319
+ (has_custom_methods? && attribute_type.nil?)
320
+ end
321
+
302
322
  def has_custom_method_for_serialization?
303
323
  !custom_methods.empty? && custom_methods[:to]
304
324
  end
@@ -416,10 +436,29 @@ module Lutaml
416
436
  end
417
437
  end
418
438
 
419
- def handle_custom_method(model, value, mapper_class)
420
- return if !custom_methods[:from] || value.nil?
439
+ def handle_custom_method(model, value, mapper_class, context = nil)
440
+ custom = custom_methods[:from]
441
+ return if !custom || value.nil?
421
442
 
422
- mapper_class.new.public_send(custom_methods[:from], model, value)
443
+ if custom.is_a?(String) || custom.is_a?(Symbol)
444
+ target = mapper_class.new
445
+ # lutaml-model#550: custom methods may declare a third context
446
+ # parameter to receive the options passed to `from_*`.
447
+ if target.method(custom).parameters.size >= 3
448
+ target.public_send(custom, model, value, context)
449
+ else
450
+ target.public_send(custom, model, value)
451
+ end
452
+ else
453
+ # Callable (proc/lambda): transforms the value and the rule
454
+ # assigns it. Exact arity 2 receives the `from_*` context.
455
+ transformed = if custom.arity == 2
456
+ custom.call(value, context)
457
+ else
458
+ custom.call(value)
459
+ end
460
+ assign_value(model, transformed)
461
+ end
423
462
  true
424
463
  end
425
464
 
@@ -439,7 +478,7 @@ module Lutaml
439
478
  attributes[delegate].type(model.lutaml_register).new)
440
479
  end
441
480
 
442
- def handle_transform_method(model, value, attributes)
481
+ def handle_transform_method(model, value, attributes, context = nil)
443
482
  attr = attributes[to]
444
483
  # Fast path: no transforms at all (covers 95%+ of rules)
445
484
  # transform defaults to {} which is truthy but semantically empty
@@ -460,7 +499,8 @@ module Lutaml
460
499
  assign_value(model, value)
461
500
  else
462
501
  # Hash/proc transformers need ImportTransformer
463
- transformed = ImportTransformer.call(value, self, attr)
502
+ transformed = ImportTransformer.call(value, self, attr,
503
+ context: context)
464
504
  assign_value(model, transformed)
465
505
  end
466
506
  true
@@ -17,16 +17,28 @@ module Lutaml
17
17
  # LAZY_EMPTY_COLLECTION, scalars share the UninitializedClass
18
18
  # singleton. Compiled once instead of walked per instance — the
19
19
  # grammars-compile-don't-interpret rule applied to object state.
20
- # Compile (per class, on demand) the method that seeds every
21
- # attribute with its "no data arrived" sentinel: collections share
22
- # the frozen LAZY_EMPTY_COLLECTION, scalars share the
23
- # UninitializedClass singleton. Compiled once instead of walked
24
- # per instance — grammars compile, they don't interpret.
25
- def compile_state_defaults!(_register = nil)
26
- attrs = attributes
20
+ # Compile (per class and register, on demand) the method that
21
+ # seeds every attribute with its "no data arrived" sentinel:
22
+ # collections share the frozen LAZY_EMPTY_COLLECTION, scalars
23
+ # share the UninitializedClass singleton. Compiled once instead
24
+ # of walked per instance — grammars compile, they don't
25
+ # interpret.
26
+ def compiled_state_defaults_name!(register_id)
27
+ @state_defaults_names ||= {}
28
+ name = @state_defaults_names[register_id]
29
+ return name if name
30
+
31
+ method_name = :"__init_state_defaults_#{register_id}"
32
+ compile_state_defaults!(method_name, register_id)
33
+ @state_defaults_names[register_id] = method_name
34
+ method_name
35
+ end
36
+
37
+ def compile_state_defaults!(method_name, register_id = nil)
38
+ attrs = attributes(register_id)
27
39
 
28
40
  if attrs.empty?
29
- define_method(:__init_deserialized_state_defaults) do
41
+ define_method(method_name) do
30
42
  # no attributes to seed
31
43
  end
32
44
  return
@@ -41,28 +53,57 @@ module Lutaml
41
53
  end.join("\n")
42
54
 
43
55
  # class_eval interpolates per-attribute `@name = <sentinel>` lines:
44
- # def __init_deserialized_state_defaults
56
+ # def __init_state_defaults_default
45
57
  # @id = Lutaml::Model::UninitializedClass.instance
46
58
  # @items = Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION
47
59
  # end
48
60
  class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
49
- def __init_deserialized_state_defaults
61
+ def #{method_name}
50
62
  #{lines}
51
63
  end
52
64
  RUBY
53
65
  end
54
66
 
67
+ # Historical getter shape for punctuation-named attributes and
68
+ # any name the compiled form cannot express.
69
+ def define_reflective_attribute_methods(name, attr)
70
+ if attr.collection?
71
+ define_method(name) do |*args|
72
+ if args.empty?
73
+ materialize_lazy_collection(name)
74
+ else
75
+ # Builder-style: g.member(item) appends to collection
76
+ value = args.first
77
+ current = instance_variable_get(:"@#{name}") || []
78
+ new_value = current.is_a?(Array) ? current + [value] : value
79
+ instance_variable_set(:"@#{name}", new_value)
80
+ record_mutation(name, value)
81
+ value
82
+ end
83
+ end
84
+ else
85
+ define_method(name) do |*args|
86
+ if args.empty?
87
+ instance_variable_get(:"@#{name}")
88
+ else
89
+ public_send(:"#{name}=", args.first)
90
+ args.first
91
+ end
92
+ end
93
+ end
94
+ end
95
+
55
96
  def invalidate_state_defaults!
56
97
  # Opal's method_defined? takes no inherit flag (see the
57
98
  # setter_defined check in define_regular_attribute_methods).
58
- defined_now = if Lutaml::Model.opal?
59
- method_defined?(:__init_deserialized_state_defaults)
60
- else
61
- method_defined?(:__init_deserialized_state_defaults, false)
62
- end
63
- return unless defined_now
64
-
65
- remove_method(:__init_deserialized_state_defaults)
99
+ (@state_defaults_names ||= {}).each_key do |compiled|
100
+ defined_now = if Lutaml::Model.opal?
101
+ method_defined?(compiled)
102
+ else
103
+ method_defined?(compiled, false)
104
+ end
105
+ remove_method(compiled) if defined_now
106
+ end
66
107
  end
67
108
 
68
109
  def define_attribute_methods(attr, register = nil)
@@ -159,35 +200,54 @@ module Lutaml
159
200
  #
160
201
  # @param name [Symbol] The attribute name
161
202
  # @param attr [Attribute] The attribute definition
203
+ # Plain identifier names compile to `@name` reads; punctuation
204
+ # names (`mixed?`) keep the reflective path (@name is not valid
205
+ # Ruby for them).
206
+ PLAIN_NAME = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
207
+
162
208
  def define_regular_attribute_methods(name, attr)
163
- # For collection attributes, the getter accepts an optional argument
164
- # for builder-style syntax: g.member(item) appends to the collection
209
+ unless name.to_s.match?(PLAIN_NAME)
210
+ return define_reflective_attribute_methods(name, attr)
211
+ end
212
+
213
+ # Getters compile with a sentinel default argument instead of a
214
+ # `*args` splat: the splat allocated an (almost always empty)
215
+ # Array on EVERY read — the largest per-call allocation source
216
+ # on instance-heavy parses (TODO.max-perf/06). The optional-arg
217
+ # form allocates nothing, and the read is a direct @ivar.
165
218
  if attr.collection?
166
- define_method(name) do |*args|
167
- if args.empty?
168
- materialize_lazy_collection(name)
169
- else
170
- # Builder-style: g.member(item) appends to collection
171
- value = args.first
172
- current = instance_variable_get(:"@#{name}") || []
173
- new_value = current.is_a?(Array) ? current + [value] : value
174
- instance_variable_set(:"@#{name}", new_value)
175
- record_mutation(name, value)
176
- value
219
+ # class_eval interpolates, e.g.:
220
+ # def items(arg = Lutaml::Model::Serialize::NO_ARG)
221
+ # if arg.equal?(Lutaml::Model::Serialize::NO_ARG)
222
+ # materialize_lazy_collection(:items)
223
+ # else
224
+ # ... builder append ...
225
+ # end
226
+ # end
227
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
228
+ def #{name}(arg = Lutaml::Model::Serialize::NO_ARG)
229
+ if arg.equal?(Lutaml::Model::Serialize::NO_ARG)
230
+ materialize_lazy_collection(:#{name})
231
+ else
232
+ current = @#{name} || []
233
+ new_value = current.is_a?(Array) ? current + [arg] : arg
234
+ @#{name} = new_value
235
+ record_mutation(:#{name}, arg)
236
+ arg
237
+ end
177
238
  end
178
- end
239
+ RUBY
179
240
  else
180
- # For non-collection attributes, getter accepts optional argument
181
- # for builder-style syntax: g.description(value) sets the value.
182
- # Tracking happens inside the setter, so no duplicate call here.
183
- define_method(name) do |*args|
184
- if args.empty?
185
- instance_variable_get(:"@#{name}")
186
- else
187
- public_send(:"#{name}=", args.first)
188
- args.first
241
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
242
+ def #{name}(arg = Lutaml::Model::Serialize::NO_ARG)
243
+ if arg.equal?(Lutaml::Model::Serialize::NO_ARG)
244
+ @#{name}
245
+ else
246
+ public_send(:"#{name}=", arg)
247
+ arg
248
+ end
189
249
  end
190
- end
250
+ RUBY
191
251
  end
192
252
 
193
253
  enum_shorthand_names = instance_variable_get(:@__enum_shorthand_names__) || Set.new
@@ -199,35 +259,53 @@ module Lutaml
199
259
  else
200
260
  method_defined?(:"#{name}=", false)
201
261
  end
202
- unless setter_defined && !enum_shorthand_names.include?(name.to_s)
203
- if attr.collection?
204
- define_method(:"#{name}=") do |value|
205
- value_set_for(name)
206
- value = attr.cast_value(value, lutaml_register)
207
- # Preserve the frozen sentinel when the deserialization pipeline
208
- # would overwrite it with nil/UninitializedClass (meaning "no data
209
- # found for this collection"). This maintains the zero-allocation
210
- # guarantee for unused collections. The sentinel is replaced with
211
- # a real Array only when actual data is set.
212
- current = instance_variable_get(:"@#{name}")
213
- if current.equal?(LAZY_EMPTY_COLLECTION) &&
262
+ return if setter_defined && !enum_shorthand_names.include?(name.to_s)
263
+
264
+ # class_eval'd bodies cannot close over locals; a hidden
265
+ # define_method accessor holds the Attribute handle.
266
+ attr_reader_method = :"__attribute_definition_#{name}"
267
+ reader_defined = if Lutaml::Model.opal?
268
+ method_defined?(attr_reader_method)
269
+ else
270
+ method_defined?(attr_reader_method, false)
271
+ end
272
+ define_method(attr_reader_method) { attr } unless reader_defined
273
+
274
+ if attr.collection?
275
+ # class_eval interpolates, e.g.:
276
+ # def items=(value)
277
+ # value_set_for(:items)
278
+ # value = ATTR.cast_value(value, lutaml_register)
279
+ # current = @items
280
+ # ... sentinel preservation ...
281
+ # record_mutation_collection(:items, value)
282
+ # end
283
+ # The compiled body writes @items directly: the define_method
284
+ # form interpolated "@items" name strings per call — one per
285
+ # setter invocation on instance-heavy parses (TODO.max-perf/06).
286
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
287
+ def #{name}=(value)
288
+ value_set_for(:#{name})
289
+ value = __attribute_definition_#{name}.cast_value(value, lutaml_register)
290
+ current = @#{name}
291
+ if current.equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION) &&
214
292
  (value.nil? || Lutaml::Model::Utils.uninitialized?(value))
215
293
  # Sentinel stays — no allocation for truly empty collections
216
294
  else
217
- instance_variable_set(:"@#{name}", value)
295
+ @#{name} = value
218
296
  end
219
- # Track one entry per item so element_order reflects the
220
- # number of <name> elements that will be emitted.
221
- record_mutation_collection(name, value)
297
+ record_mutation_collection(:#{name}, value)
222
298
  end
223
- else
224
- define_method(:"#{name}=") do |value|
225
- value_set_for(name)
226
- value = attr.cast_value(value, lutaml_register)
227
- instance_variable_set(:"@#{name}", value)
228
- record_mutation(name, value)
299
+ RUBY
300
+ else
301
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
302
+ def #{name}=(value)
303
+ value_set_for(:#{name})
304
+ value = __attribute_definition_#{name}.cast_value(value, lutaml_register)
305
+ @#{name} = value
306
+ record_mutation(:#{name}, value)
229
307
  end
230
- end
308
+ RUBY
231
309
  end
232
310
  end
233
311