lutaml-model 0.8.94 → 0.8.96

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: '082f3275306fcd89a94f69f68b53d38b0c4c644de743dff261b6bc5166b12ec7'
4
- data.tar.gz: 968f9d94e179035695be49649bd9c0b0c2e7e83192661ba3f6d95bbd5803a813
3
+ metadata.gz: 7b4bd8b10dc92c3a53f615bb3e13c907d1cc4976eb547a5a2da41aa2837bef17
4
+ data.tar.gz: e1e5eacb6d80dc8108d65a5264d5e9d872d4259103672f6a88e0d912b437002f
5
5
  SHA512:
6
- metadata.gz: 4f06573be9b9da0d12a561522a63d2d14b771a3ff7357fdb92c8d06213fa997efe19470f0d6b6ab39d9c672038715cc6efa1d607cb77dce5487e2134b56335a1
7
- data.tar.gz: 275f2d2c74931c84024278dbc5bddcfe18cc267990c0516080605ce1fd2170c5513efcdd141ffbb72b9967cb91fd7ed74b338936a0e1a793aeba2411f8405d09
6
+ metadata.gz: efa1a2368c6f2f0352dd928eb33c4c45c9580a431ca3847fe709fcb1026f29deaf2f55e7ec52eafaebdac537ef0c5d55588b22d37d6f6bb27d332165ad9a795a
7
+ data.tar.gz: 5380981def9666c4ec1fa0f4cb7f977dcba141a5278003a96486d814e7c812d2083339baa3d9d8079c3029d7aef0e84226be40eb188315bd5270c974baed1dda
@@ -116,9 +116,16 @@ jobs:
116
116
  File.write("Gemfile", out.join)
117
117
  ' /tmp/main-checkout
118
118
  bundle install --jobs=4
119
- bundle exec ruby ${{ github.workspace }}/bench/bench_${{ matrix.downstream }}.rb || \
120
- { echo "::warning::baseline gate failed — re-running once under less contention (#834)"; \
121
- sleep 30; bundle exec ruby ${{ github.workspace }}/bench/bench_${{ matrix.downstream }}.rb; }
119
+ # lutaml-model#834: min-of-N across attempts — contention
120
+ # inflates single-shot timings, so run three attempts and let
121
+ # the merge keep the least-interfered measurement per fixture.
122
+ for i in 1 2 3; do
123
+ bundle exec ruby ${{ github.workspace }}/bench/bench_${{ matrix.downstream }}.rb || \
124
+ echo "::warning::baseline attempt $i errored (#834) — continuing with remaining attempts"
125
+ cp "$BENCH_JSON" "$BENCH_JSON.attempt$i" 2>/dev/null || true
126
+ done
127
+ ruby ${{ github.workspace }}/bench/bench_merge_attempts.rb \
128
+ "$BENCH_JSON" "$BENCH_JSON.attempt1" "$BENCH_JSON.attempt2" "$BENCH_JSON.attempt3"
122
129
 
123
130
  - name: Run current benchmark (PR branch)
124
131
  env:
@@ -146,9 +153,13 @@ jobs:
146
153
  File.write("Gemfile", out.join)
147
154
  ' ${{ github.workspace }}
148
155
  bundle install --jobs=4
149
- bundle exec ruby ${{ github.workspace }}/bench/bench_${{ matrix.downstream }}.rb || \
150
- { echo "::warning::gate failed — re-running once under less contention (#834)"; \
151
- sleep 30; bundle exec ruby ${{ github.workspace }}/bench/bench_${{ matrix.downstream }}.rb; }
156
+ for i in 1 2 3; do
157
+ bundle exec ruby ${{ github.workspace }}/bench/bench_${{ matrix.downstream }}.rb || \
158
+ echo "::warning::current attempt $i errored (#834) — continuing with remaining attempts"
159
+ cp "$BENCH_JSON" "$BENCH_JSON.attempt$i" 2>/dev/null || true
160
+ done
161
+ ruby ${{ github.workspace }}/bench/bench_merge_attempts.rb \
162
+ "$BENCH_JSON" "$BENCH_JSON.attempt1" "$BENCH_JSON.attempt2" "$BENCH_JSON.attempt3"
152
163
 
153
164
  - name: Compare baseline vs current
154
165
  run: |
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lutaml-model#834: merge N benchmark-attempt JSONs into one result set,
4
+ # taking the per-fixture minimum of every metric. Contention inflates
5
+ # timings; the minimum across attempts is the least-interfered
6
+ # measurement, so gates evaluated on merged output misfire only when
7
+ # EVERY attempt was slow.
8
+ #
9
+ # Usage: ruby bench_merge_attempts.rb <merged.json> <attempt1.json> [attempt2.json ...]
10
+ #
11
+ # Attempt files may be missing (crashed attempt): missing files are
12
+ # skipped; if none exist, nothing is written and the caller's compare
13
+ # step fails on the missing merged file.
14
+ if ARGV.length < 3
15
+ warn "Usage: #{$PROGRAM_NAME} <merged.json> <attempt1.json> [attempt2.json ...]"
16
+ exit 1
17
+ end
18
+
19
+ merged_path = ARGV[0]
20
+ attempt_paths = ARGV[1..]
21
+
22
+ require "json"
23
+
24
+ best = {}
25
+ attempt_paths.each do |path|
26
+ next unless File.exist?(path)
27
+
28
+ results = JSON.parse(File.read(path))
29
+ results.each do |label, metrics|
30
+ cur = best[label]
31
+ if cur
32
+ cur["avg_time"] = [cur["avg_time"], metrics["avg_time"]].min
33
+ cur["min_time"] = [cur["min_time"], metrics["min_time"]].min
34
+ cur["max_time"] = [cur["max_time"], metrics["max_time"]].min
35
+ cur["allocations"] = [cur["allocations"], metrics["allocations"]].min
36
+ cur["ips"] = [cur["ips"], metrics["ips"]].max
37
+ else
38
+ best[label] = metrics.dup
39
+ end
40
+ end
41
+ end
42
+
43
+ if best.nil? || best.empty?
44
+ warn "No attempt JSONs found among: #{attempt_paths.join(', ')}"
45
+ exit 1
46
+ end
47
+
48
+ File.write(merged_path, JSON.pretty_generate(best))
49
+ puts "Merged #{attempt_paths.size} attempt file(s) into #{merged_path} (#{best.size} fixtures)"
@@ -0,0 +1,69 @@
1
+ = Grammar-backed string formats
2
+
3
+ A serialization format whose input is a *grammar*, not a data structure:
4
+ identifier strings, query languages, DSLs — text that is parsed, not
5
+ decoded. lutaml-model supports registering these as first-class formats
6
+ through the same `FormatRegistry` extension point as xml/json/yaml; the
7
+ parse side is implemented by a grammar engine (the reference
8
+ implementation is https://github.com/parsanol/parsanol-ruby[Parsanol]'s
9
+ `Parsanol::PARG::Lutaml.register`, which consumes a compiled PARG
10
+ artifact plus its bindings).
11
+
12
+ == The contract
13
+
14
+ A grammar-backed format needs three things:
15
+
16
+ * a *parse* — text → attribute hash (the grammar engine's job)
17
+ * *bindings* — capture names → model attributes (the artifact's
18
+ bindings section)
19
+ * a *model class* — a regular `Serializable` with attributes; no
20
+ hand-written `from_h`/`to_h` anywhere
21
+
22
+ == Reference implementation (Parsanol)
23
+
24
+ [source,ruby]
25
+ ----
26
+ require "parsanol/parg/lutaml"
27
+
28
+ Parsanol::PARG::Lutaml.register(
29
+ IsoIdentifier,
30
+ format_name: :pubid_iso,
31
+ artifact: "artifacts/iso.json", # compiled grammar + bindings + tests
32
+ entry: "identifier",
33
+ )
34
+
35
+ IsoIdentifier.from_pubid_iso("ISO/IEC 12345-1:2020")
36
+ # => #<IsoIdentifier number: "12345-1", year: 2020, ...>
37
+ ----
38
+
39
+ What `register` does:
40
+
41
+ . loads the checksummed artifact (parse failures raise the engine's
42
+ error type — register it via `error_types:` so users can rescue it);
43
+ . registers the format through `FormatRegistry.register` with an
44
+ adapter whose `parse` is artifact-parse-then-bind;
45
+ . defines `from_<format>` on the model class.
46
+
47
+ The artifact is the parser of record: it carries the grammar, the
48
+ bindings, and the conformance tests (the grammar's own test block is
49
+ baked in and can be re-run by consumers), and its checksum pins the
50
+ pinned version of the language.
51
+
52
+ == Writing the grammar side
53
+
54
+ Grammars are authored in PARG (`.parg` — ABNF-flavoured surface, PEG
55
+ semantics, one rule per line) and compiled with the `parsanol` CLI:
56
+
57
+ ----
58
+ parsanol compile grammars/iso.parg --tables tables
59
+ ----
60
+
61
+ The compile-time lint rejects left recursion and shadowed
62
+ alternatives; the embedded test block runs at compile time, so a
63
+ grammar that stops accepting its documented inputs fails the build
64
+ rather than the consumer.
65
+
66
+ == See also
67
+
68
+ * parsanol-ruby `docs/pg-language-guide.md` — the PARG language
69
+ * `Parsanol::PARG::Lutaml.register` — the bridge (reference implementation)
@@ -26,6 +26,7 @@ Task-oriented guides for accomplishing specific goals with Lutaml::Model.
26
26
  == Advanced mapping
27
27
 
28
28
  * link:../advanced-mapping[Advanced Attribute Mapping] - Same-name dispatch on attribute values (`when_attribute`), grouped declarations, custom methods, delegation
29
+ * link:../grammar-backed-formats[Grammar-Backed String Formats] - Register a grammar engine as a serialization format (identifier strings, DSLs)
29
30
 
30
31
  == Performance
31
32
 
@@ -47,6 +48,7 @@ See link:../references/rdf-namespaces[RDF Namespaces] for namespace classes,
47
48
  * link:../value-transformations[Value Transformations] - Transform values during serialization
48
49
  * link:../missing-values-handling[Missing Values Handling] - Handle nil/empty/omitted
49
50
  * link:../advanced-mapping[Advanced Mapping] - Custom methods and delegation
51
+ * link:../grammar-backed-formats[Grammar-Backed String Formats] - Grammar engines as serialization formats
50
52
  * link:../schema-generation[Schema Generation] - Generate schemas from models
51
53
  * link:../creating-xsd[Creating XSD Schemas] - Complete reference for XSD generation from LutaML models
52
54
  * link:../schema-import[Schema Import] - Import schemas to create models
@@ -8,6 +8,24 @@ module Lutaml
8
8
  # Extracted from serialize.rb to improve code organization.
9
9
  # Provides methods for defining and validating model attributes.
10
10
  module AttributeDefinition
11
+ # Whether the per-class accessors and the state-defaults seed are
12
+ # compiled from Ruby source with a String class_eval (the fast
13
+ # path). Opal has no runtime compiler unless opal-parser is
14
+ # bundled, so there every such method is defined from a block
15
+ # with define_method instead.
16
+ # Core dispatch for the block-form methods, bound per call so an
17
+ # attribute or method a model defines with one of these names
18
+ # (send, __send__, instance_variable_get, const_get, ...) is never invoked;
19
+ # the compiled form reads ivars and calls methods directly.
20
+ IVAR_GET = ::Kernel.instance_method(:instance_variable_get)
21
+ IVAR_SET = ::Kernel.instance_method(:instance_variable_set)
22
+ SEND = ::BasicObject.instance_method(:__send__)
23
+ CONST_GET = ::Module.instance_method(:const_get)
24
+
25
+ def self.source_compilation?
26
+ !Lutaml::Model.opal?
27
+ end
28
+
11
29
  # Define attribute methods on the model class
12
30
  #
13
31
  # @param attr [Attribute] The attribute to define methods for
@@ -44,6 +62,26 @@ module Lutaml
44
62
  return
45
63
  end
46
64
 
65
+ unless AttributeDefinition.source_compilation?
66
+ seeds = attrs.map do |name, attr|
67
+ sentinel = if attr.collection?
68
+ Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION
69
+ else
70
+ Lutaml::Model::UninitializedClass.instance
71
+ end
72
+ [:"@#{name}", sentinel]
73
+ end
74
+ # Raw readers for every attribute the register sees, as the
75
+ # compiled form below does (#933).
76
+ attrs.each_key { |name| define_raw_state_reader(name) }
77
+ define_method(method_name) do
78
+ # Returns the last seeded value, as the compiled form's final
79
+ # assignment does.
80
+ seeds.reduce(nil) { |_, (ivar, sentinel)| IVAR_SET.bind_call(self, ivar, sentinel) }
81
+ end
82
+ return
83
+ end
84
+
47
85
  lines = attrs.map do |name, attr|
48
86
  if attr.collection?
49
87
  "@#{name} = Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION"
@@ -63,18 +101,14 @@ module Lutaml
63
101
  end
64
102
  RUBY
65
103
 
66
- # lutaml-model#922: raw readers for the deserializer's
67
- # unmatched-rule handling. A custom writer that assigns a
68
- # *different* attribute mid-parse (relaton's ICS Isoics
69
- # fallback; any derived-value pattern) is invisible to the
70
- # value_set tracker -- public setters don't mark it -- so the
71
- # tracked "still default" state lies. The deserializer needs
72
- # the raw stored state to know whether an attribute was
73
- # writer-populated: an attribute still holding its
74
- # initialized marker (sentinel / lazy collection) is unset;
75
- # anything else was written since init and must never be
76
- # overwritten by an unmatched rule's default assignment.
77
- raw_lines = attrs.map do |name, _attr|
104
+ # Raw readers compiled alongside the seeding so every
105
+ # attribute the register can see — including late-merged
106
+ # imports that bypass define_attribute_methods — carries
107
+ # one (#933). Idempotent with the declaration-time
108
+ # definition.
109
+ raw_lines = attrs.filter_map do |name, _attr|
110
+ next unless name.to_s.match?(PLAIN_NAME)
111
+
78
112
  <<~RUBY
79
113
  def __lutaml_raw_#{name}
80
114
  @#{name}
@@ -86,6 +120,14 @@ module Lutaml
86
120
  RUBY
87
121
  end
88
122
 
123
+ # Block-form raw-state reader (no String eval), defined on self.
124
+ def define_raw_state_reader(name)
125
+ ivar = :"@#{name}"
126
+ define_method(:"__lutaml_raw_#{name}") do
127
+ IVAR_GET.bind_call(self, ivar)
128
+ end
129
+ end
130
+
89
131
  # Historical getter shape for punctuation-named attributes and
90
132
  # any name the compiled form cannot express.
91
133
  def define_reflective_attribute_methods(name, attr)
@@ -126,6 +168,9 @@ module Lutaml
126
168
  end
127
169
  remove_method(compiled) if defined_now
128
170
  end
171
+ # The names cache must go with the methods: the memoized
172
+ # lookup answers stale names for removed methods otherwise.
173
+ @state_defaults_names.clear
129
174
  end
130
175
 
131
176
  def define_attribute_methods(attr, register = nil)
@@ -146,8 +191,17 @@ module Lutaml
146
191
  # the state-defaults compilation) so late declarations get it
147
192
  # too -- XMI's extension loader declares attributes after
148
193
  # first parse.
149
- if name.to_s.match?(PLAIN_NAME)
150
- model.class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
194
+ # Without a source compiler, compile_state_defaults! defines
195
+ # its raw readers from blocks; define one for every name here
196
+ # too (define_method takes any name, not only plain ones).
197
+ # Both forms define on self: a bare `model` receiver resolves
198
+ # through the host class's own namespace (omml's Base answers
199
+ # a Store model) and defined the reader on the wrong object
200
+ # (#933).
201
+ if !AttributeDefinition.source_compilation?
202
+ define_raw_state_reader(name)
203
+ elsif name.to_s.match?(PLAIN_NAME)
204
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
151
205
  def __lutaml_raw_#{name}
152
206
  @#{name}
153
207
  end
@@ -260,7 +314,9 @@ module Lutaml
260
314
  # Array on EVERY read — the largest per-call allocation source
261
315
  # on instance-heavy parses (TODO.max-perf/06). The optional-arg
262
316
  # form allocates nothing, and the read is a direct @ivar.
263
- if attr.collection?
317
+ if !AttributeDefinition.source_compilation?
318
+ define_block_attribute_getter(name, attr)
319
+ elsif attr.collection?
264
320
  # class_eval interpolates, e.g.:
265
321
  # def items(arg = Lutaml::Model::Serialize::NO_ARG)
266
322
  # if arg.equal?(Lutaml::Model::Serialize::NO_ARG)
@@ -316,7 +372,9 @@ module Lutaml
316
372
  end
317
373
  define_method(attr_reader_method) { attr } unless reader_defined
318
374
 
319
- if attr.collection?
375
+ if !AttributeDefinition.source_compilation?
376
+ define_block_attribute_setters(name, attr)
377
+ elsif attr.collection?
320
378
  # class_eval interpolates, e.g.:
321
379
  # def items=(value)
322
380
  # value_set_for(:items)
@@ -376,6 +434,77 @@ module Lutaml
376
434
  end
377
435
  end
378
436
 
437
+ # Block form of the compiled getter in define_regular_attribute_methods,
438
+ # for runtimes without a source compiler (see source_compilation?).
439
+ def define_block_attribute_getter(name, attr)
440
+ name = name.to_sym # the compiled form passes :name literals
441
+ ivar = :"@#{name}"
442
+ if attr.collection?
443
+ # The compiled getter resolves Array in the model class's scope.
444
+ scope = self
445
+ define_method(name) do |arg = Lutaml::Model::Serialize::NO_ARG|
446
+ if arg.equal?(Lutaml::Model::Serialize::NO_ARG)
447
+ materialize_lazy_collection(name)
448
+ else
449
+ current = IVAR_GET.bind_call(self, ivar) || []
450
+ new_value = current.is_a?(CONST_GET.bind_call(scope, :Array)) ? current + [arg] : arg
451
+ IVAR_SET.bind_call(self, ivar, new_value)
452
+ record_mutation(name, arg)
453
+ arg
454
+ end
455
+ end
456
+ else
457
+ define_method(name) do |arg = Lutaml::Model::Serialize::NO_ARG|
458
+ if arg.equal?(Lutaml::Model::Serialize::NO_ARG)
459
+ IVAR_GET.bind_call(self, ivar)
460
+ else
461
+ public_send(:"#{name}=", arg)
462
+ arg
463
+ end
464
+ end
465
+ end
466
+ end
467
+
468
+ # Block form of the compiled setters in define_regular_attribute_methods.
469
+ # The Attribute is read through the same hidden accessor the
470
+ # compiled form calls, not captured: a setter redefined over an
471
+ # enum shorthand keeps casting with the accessor's Attribute.
472
+ def define_block_attribute_setters(name, attr)
473
+ name = name.to_sym # the compiled form passes :name literals
474
+ ivar = :"@#{name}"
475
+ handle = :"__attribute_definition_#{name}"
476
+ if attr.collection?
477
+ assign = lambda do |model, value|
478
+ current = IVAR_GET.bind_call(model, ivar)
479
+ unless current.equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION) &&
480
+ (value.nil? || Lutaml::Model::Utils.uninitialized?(value))
481
+ IVAR_SET.bind_call(model, ivar, value)
482
+ end
483
+ SEND.bind_call(model, :record_mutation_collection, name, value)
484
+ end
485
+ define_method(:"#{name}=") do |value|
486
+ value_set_for(name)
487
+ assign.call(self, SEND.bind_call(self, handle).cast_value(value, lutaml_register))
488
+ end
489
+ define_method(:"__assign_parsed_#{name}=") do |value|
490
+ value_set_for(name)
491
+ assign.call(self, value)
492
+ end
493
+ else
494
+ define_method(:"#{name}=") do |value|
495
+ value_set_for(name)
496
+ value = SEND.bind_call(self, handle).cast_value(value, lutaml_register)
497
+ IVAR_SET.bind_call(self, ivar, value)
498
+ record_mutation(name, value)
499
+ end
500
+ define_method(:"__assign_parsed_#{name}=") do |value|
501
+ value_set_for(name)
502
+ IVAR_SET.bind_call(self, ivar, value)
503
+ record_mutation(name, value)
504
+ end
505
+ end
506
+ end
507
+
379
508
  # Define an attribute for the model
380
509
  #
381
510
  # @param name [Symbol] The attribute name
@@ -103,9 +103,14 @@ module Lutaml
103
103
 
104
104
  register = Lutaml::Model::Config.default_register
105
105
  plan = Lutaml::Xml::Leptris::PlanCompiler.compile(self, register)
106
- return nil unless plan && !plan[:namespaced] &&
106
+ return nil unless plan &&
107
107
  Lutaml::Xml::Leptris::PlanSerializer.serializable?(plan)
108
108
 
109
+ # Namespaced plans ride the plan serializer too: the DOM
110
+ # emission spells prefixed names and hoists declarations
111
+ # (the #847 xmlns drop is fixed in PlanSerializer); any
112
+ # unspellable shape answers nil there and falls back
113
+ # interpretively.
109
114
  Lutaml::Xml::Leptris::PlanSerializer.call(instance, plan)
110
115
  end
111
116
 
@@ -345,8 +345,20 @@ module Lutaml
345
345
  # @param register [Symbol, nil] The register context
346
346
  # @return [Object] The allocated instance
347
347
  def allocate_for_deserialization(register = nil)
348
- instance = allocate
349
348
  register_id = extract_register_id(register)
349
+ # Deferred imports (symbol-form import_model_attributes) must
350
+ # resolve before state defaults compile: the compiled seeding
351
+ # and the raw readers cover the post-import attribute set, and
352
+ # the transform's records already carry the mapping's rules
353
+ # for imported names — CTR's <m:br> parsed before resolution
354
+ # had no reader (#933). The constructor path ensures the same
355
+ # way (initialize_attributes). Guarded: deep object graphs
356
+ # (XSD schema trees) allocate recursively, and the ensure
357
+ # chain's frames per level pushed that recursion past the
358
+ # stack limit — models without pending deferred imports skip
359
+ # it entirely.
360
+ ensure_imports!(register_id) if finalized? && deferred_imports_pending?
361
+ instance = allocate
350
362
  instance.finalize_deserialization(register_id)
351
363
  instance
352
364
  end
@@ -60,6 +60,10 @@ module Lutaml
60
60
  model.attributes.each_value { |attr| define_attribute_methods(attr) }
61
61
  @attributes.merge!(Utils.deep_dup(model.attributes))
62
62
  @choice_attributes.concat(deep_duplicate_choice_attributes(model))
63
+ # Imported attributes grow the state-defaults surface (the
64
+ # raw readers live there) — a first use may already have
65
+ # compiled it for the pre-import attribute set (#933).
66
+ invalidate_state_defaults!
63
67
  # Ensure @models_imported is a hash; migrate from legacy nil/false state
64
68
  @models_imported ||= {}
65
69
  @models_imported[reg] = true
@@ -122,6 +126,9 @@ module Lutaml
122
126
  deep_duplicate_choice_attributes(model, register_id),
123
127
  )
124
128
  @merged_attributes_cache = nil
129
+ # See import_model_attributes: late-registered attributes
130
+ # must recompile the register's state defaults (#933).
131
+ invalidate_state_defaults!
125
132
  end
126
133
  private :register_only_import_model_attributes
127
134
 
@@ -153,6 +160,15 @@ module Lutaml
153
160
  # Ensure all model imports are resolved for a specific register
154
161
  #
155
162
  # @param register_id [Symbol, nil] The register context
163
+ # Whether deferred (symbol-form) imports are still pending —
164
+ # gates the allocation-time ensure chain (#933).
165
+ def deferred_imports_pending?
166
+ importable_models.any? { |_method, models| models.any? } ||
167
+ importable_choices.any? do |_choice, imports|
168
+ imports.any? { |_method, models| models.any? }
169
+ end || restrict_attributes.any?
170
+ end
171
+
156
172
  def ensure_model_imports!(register_id = nil)
157
173
  register_id ||= Lutaml::Model::Config.default_register
158
174
  @models_imported = {} if @models_imported.nil? || @models_imported == false
@@ -197,6 +213,7 @@ module Lutaml
197
213
  @choices_imported[register_id] = true
198
214
  all_resolved = true
199
215
 
216
+ resolved_any = false
200
217
  importable_choices.each do |choice, choice_imports|
201
218
  choice_imports.each do |method, models|
202
219
  models.uniq.each do |model|
@@ -216,11 +233,19 @@ module Lutaml
216
233
  end
217
234
 
218
235
  choice.public_send(method, model_class, register_id)
236
+ resolved_any = true
219
237
  end
220
238
  end
221
239
  end
222
240
 
223
241
  @choices_imported[register_id] = true if all_resolved
242
+ # Choice imports can add attributes without passing through
243
+ # define_attribute_methods — recompile state defaults so the
244
+ # seeding and raw readers cover them (#933). Only when an
245
+ # import actually resolved: ensure_choice_imports! runs on
246
+ # every ensure_imports! (allocation-time), and a recompile
247
+ # per allocation would churn.
248
+ invalidate_state_defaults! if resolved_any
224
249
  end
225
250
 
226
251
  # Ensure all restrict attributes are applied
@@ -243,6 +268,11 @@ module Lutaml
243
268
  # Uses TracePoint to detect when a class definition is complete,
244
269
  # allowing deferred import resolution.
245
270
  def setup_trace_point
271
+ # Opal has no TracePoint. Symbol imports still resolve lazily
272
+ # on first use (ensure_imports!), so there the class is marked
273
+ # finalized at once instead of at the end of its body.
274
+ return @finalized = true if Lutaml::Model.opal?
275
+
246
276
  @trace ||= TracePoint.new(:end) do |_tp|
247
277
  if include?(Lutaml::Model::Serialize)
248
278
  @finalized = true
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.94"
5
+ VERSION = "0.8.96"
6
6
  end
7
7
  end
data/lib/lutaml/model.rb CHANGED
@@ -294,3 +294,10 @@ require "#{__dir__}/xml/format"
294
294
  # Builder must be prepended AFTER XML so its initialize runs first
295
295
  # (Builder -> XML InstanceMethods -> Serialize)
296
296
  Lutaml::Model::Serialize.prepend(Lutaml::Model::Serialize::Builder)
297
+
298
+ # Opal does not propagate a module prepended into an already-included
299
+ # module to classes that included it earlier (see lib/lutaml/xml/format.rb).
300
+ # Serializable included Serialize at boot, so Builder goes onto it directly.
301
+ if Lutaml::Model.opal?
302
+ Lutaml::Model::Serializable.prepend(Lutaml::Model::Serialize::Builder)
303
+ end
@@ -243,9 +243,11 @@ Lutaml::Model::Serialize.prepend(
243
243
  #
244
244
  # Anonymous model classes (Class.new { include Serialize }) extend
245
245
  # Serialize::ClassMethods rather than inheriting from Serializable, so the
246
- # ModelImportExt override (root?) must land on ClassMethods too. The other
247
- # two (FormatConversion, InstanceMethods) are already prepended unconditionally
248
- # above, so Opal's no-double-prepend rule means we don't repeat them here.
246
+ # ModelImportExt override (root?) must land on ClassMethods too.
247
+ #
248
+ # On Opal (and TruffleRuby, below) the ClassMethods.prepend(FormatConversion)
249
+ # above does not reach Serializable's singleton chain, which extended
250
+ # ClassMethods before it ran, so it is prepended onto that singleton directly.
249
251
  #
250
252
  # TruffleRuby (34.0.1) has the same propagation gap, on both the include
251
253
  # side and the extend/singleton side — a module prepended into an
@@ -273,13 +275,9 @@ if Lutaml::Model.opal? || RUBY_ENGINE == "truffleruby"
273
275
  Lutaml::Xml::Serialization::ModelImportExt,
274
276
  )
275
277
 
276
- # TruffleRuby only: Opal's no-double-prepend rule makes the unconditional
277
- # ClassMethods.prepend(FormatConversion) above sufficient there.
278
- if RUBY_ENGINE == "truffleruby"
279
- Lutaml::Model::Serializable.singleton_class.prepend(
280
- Lutaml::Xml::Serialization::FormatConversion,
281
- )
282
- end
278
+ Lutaml::Model::Serializable.singleton_class.prepend(
279
+ Lutaml::Xml::Serialization::FormatConversion,
280
+ )
283
281
  end
284
282
 
285
283
  # Register XML-specific attribute override warning names