lutaml-model 0.8.56 → 0.8.106

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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/.github/scripts/test_installed_gem.rb +58 -0
  3. data/.github/workflows/downstream-performance.yml +28 -3
  4. data/.github/workflows/js-pr-check.yml +4 -0
  5. data/.github/workflows/js-sync.yml +4 -0
  6. data/.github/workflows/opal.yml +9 -0
  7. data/.github/workflows/rake.yml +6 -41
  8. data/.github/workflows/release.yml +41 -0
  9. data/Gemfile +17 -3
  10. data/bench/bench_alloc_audit.rb +56 -0
  11. data/bench/bench_common.rb +11 -0
  12. data/bench/bench_merge_attempts.rb +49 -0
  13. data/docs/_guides/gem-integration.adoc +85 -0
  14. data/docs/_guides/grammar-backed-formats.adoc +69 -0
  15. data/docs/_guides/index.adoc +8 -1
  16. data/docs/_guides/native-engines.adoc +23 -5
  17. data/docs/_guides/schema-generation.adoc +25 -0
  18. data/docs/_guides/type-registers-contexts.adoc +126 -0
  19. data/lib/compat/opal/lutaml_model_boot.rb +7 -5
  20. data/lib/lutaml/json/adapter/yeptris_adapter.rb +24 -1
  21. data/lib/lutaml/key_value/data_model/element.rb +5 -1
  22. data/lib/lutaml/key_value/mapping_rule.rb +7 -0
  23. data/lib/lutaml/key_value/transform.rb +29 -24
  24. data/lib/lutaml/key_value/transformation/collection_serializer.rb +12 -29
  25. data/lib/lutaml/key_value/transformation/value_serializer.rb +29 -6
  26. data/lib/lutaml/key_value/transformation.rb +64 -116
  27. data/lib/lutaml/model/adapter_resolver.rb +7 -3
  28. data/lib/lutaml/model/attribute.rb +74 -15
  29. data/lib/lutaml/model/cached_type_resolver.rb +13 -6
  30. data/lib/lutaml/model/configuration.rb +13 -0
  31. data/lib/lutaml/model/mapping/mapping.rb +13 -0
  32. data/lib/lutaml/model/mapping/mapping_rule.rb +6 -1
  33. data/lib/lutaml/model/render_policy.rb +26 -8
  34. data/lib/lutaml/model/schema/base_schema.rb +15 -0
  35. data/lib/lutaml/model/schema/generator/definition.rb +52 -8
  36. data/lib/lutaml/model/schema/generator/definitions_collection.rb +35 -14
  37. data/lib/lutaml/model/schema.rb +4 -2
  38. data/lib/lutaml/model/serializable.rb +33 -0
  39. data/lib/lutaml/model/serialize/attribute_definition.rb +196 -2
  40. data/lib/lutaml/model/serialize/builder.rb +22 -0
  41. data/lib/lutaml/model/serialize/conversion_caching.rb +5 -0
  42. data/lib/lutaml/model/serialize/format_conversion.rb +49 -41
  43. data/lib/lutaml/model/serialize/initialization.rb +54 -7
  44. data/lib/lutaml/model/serialize/model_import.rb +60 -0
  45. data/lib/lutaml/model/serialize.rb +14 -6
  46. data/lib/lutaml/model/type/time.rb +6 -0
  47. data/lib/lutaml/model/type/value.rb +9 -7
  48. data/lib/lutaml/model/type_resolver.rb +7 -5
  49. data/lib/lutaml/model/utils.rb +18 -5
  50. data/lib/lutaml/model/version.rb +1 -1
  51. data/lib/lutaml/model.rb +7 -0
  52. data/lib/lutaml/xml/adapter/xml_parser.rb +5 -2
  53. data/lib/lutaml/xml/adapter_loader.rb +5 -2
  54. data/lib/lutaml/xml/format.rb +18 -16
  55. data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
  56. data/lib/lutaml/xml/leptris/plan_compiler.rb +650 -0
  57. data/lib/lutaml/xml/leptris/plan_hydrator.rb +748 -0
  58. data/lib/lutaml/xml/leptris/plan_order.rb +55 -0
  59. data/lib/lutaml/xml/leptris/plan_serializer.rb +702 -0
  60. data/lib/lutaml/xml/leptris/plan_walk.rb +36 -0
  61. data/lib/lutaml/xml/leptris.rb +46 -0
  62. data/lib/lutaml/xml/mapping.rb +71 -18
  63. data/lib/lutaml/xml/model_transform.rb +231 -8
  64. data/lib/lutaml/xml/namespace.rb +18 -8
  65. data/lib/lutaml/xml/namespace_collector.rb +17 -1
  66. data/lib/lutaml/xml/namespace_needs.rb +77 -49
  67. data/lib/lutaml/xml/parse_session.rb +7 -19
  68. data/lib/lutaml/xml/schema/xsd/schema.rb +2 -1
  69. data/lib/lutaml/xml/serialization/format_conversion.rb +5 -1
  70. data/lib/lutaml/xml/transformation/ordered_applier.rb +21 -15
  71. data/lib/lutaml/xml/transformation.rb +15 -1
  72. data/lib/lutaml/xml/type/serializers.rb +8 -1
  73. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +34 -2
  74. data/lib/tasks/performance_probe_plan_walk.rb +1 -1
  75. data/lutaml-model.gemspec +7 -4
  76. data/spec/lutaml/json/yeptris_adapter_spec.rb +7 -2
  77. data/spec/lutaml/key_value/transformation/value_serializer_spec.rb +9 -5
  78. data/spec/lutaml/key_value/transformation_spec.rb +87 -21
  79. data/spec/lutaml/model/any_element_mapping_spec.rb +41 -0
  80. data/spec/lutaml/model/collection_validation_spec.rb +265 -0
  81. data/spec/lutaml/model/desc_spec.rb +50 -0
  82. data/spec/lutaml/model/late_attribute_declaration_spec.rb +35 -0
  83. data/spec/lutaml/model/model_import_reentry_spec.rb +46 -0
  84. data/spec/lutaml/model/opal_smoke_spec.rb +17 -0
  85. data/spec/lutaml/model/schema/generate_many_spec.rb +61 -0
  86. data/spec/lutaml/model/schema/generation_regressions_spec.rb +139 -0
  87. data/spec/lutaml/model/schema/json_schema_spec.rb +1 -13
  88. data/spec/lutaml/model/schema/yaml_schema_spec.rb +0 -11
  89. data/spec/lutaml/model/serialize/attribute_definition_block_form_spec.rb +244 -0
  90. data/spec/lutaml/model/store_spec.rb +6 -2
  91. data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
  92. data/spec/lutaml/model/writer_assigned_attribute_spec.rb +62 -0
  93. data/spec/lutaml/model/yeptris_error_parity_spec.rb +95 -0
  94. data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
  95. data/spec/lutaml/xml/adapter/over_cap_parse_spec.rb +105 -0
  96. data/spec/lutaml/xml/adapter_loader_spec.rb +14 -0
  97. data/spec/lutaml/xml/adapter_metadata_default_spec.rb +16 -0
  98. data/spec/lutaml/xml/attribute_ambiguity_surface_spec.rb +5 -0
  99. data/spec/lutaml/xml/clear_parse_state_spec.rb +6 -1
  100. data/spec/lutaml/xml/compiled_rule_namespace_spec.rb +5 -0
  101. data/spec/lutaml/xml/cross_register_plan_spec.rb +75 -0
  102. data/spec/lutaml/xml/doubly_defined_namespace_spec.rb +16 -8
  103. data/spec/lutaml/xml/element_order_tracking_spec.rb +37 -0
  104. data/spec/lutaml/xml/leptris_string_encoding_spec.rb +65 -0
  105. data/spec/lutaml/xml/opal_xml_spec.rb +47 -0
  106. data/spec/lutaml/xml/parse_dom_retention_spec.rb +34 -0
  107. data/spec/lutaml/xml/plan_fast_path_spec.rb +122 -14
  108. data/spec/lutaml/xml/plan_namespaced_serialize_spec.rb +157 -0
  109. data/spec/lutaml/xml/plan_ns_less_document_spec.rb +86 -0
  110. data/spec/lutaml/xml/plan_partition_spec.rb +1 -1
  111. data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +131 -0
  112. data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
  113. data/spec/lutaml/xml/plan_walk_spec.rb +6 -6
  114. data/spec/lutaml/xml/plan_wildcard_any_spec.rb +96 -0
  115. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +29 -0
  116. data/spec/person_spec.rb +8 -0
  117. metadata +48 -17
  118. data/lib/lutaml/xml/plan_compiler.rb +0 -332
  119. data/lib/lutaml/xml/plan_hydrator.rb +0 -367
  120. data/lib/lutaml/xml/plan_order.rb +0 -53
  121. data/lib/lutaml/xml/plan_serializer.rb +0 -252
  122. data/lib/lutaml/xml/plan_walk.rb +0 -30
@@ -12,6 +12,14 @@ module Lutaml
12
12
  # options are normalized into value_map entries at MappingRule construction
13
13
  # time, so downstream code never needs to re-interpret them.
14
14
  module RenderPolicy
15
+ # Isolated holder: host transformations freeze after compile; an
16
+ # ivar cache on them raises FrozenError (learned the hard way).
17
+ SKIP_PLANS = ::Class.new do
18
+ def self.cache
19
+ @cache ||= {}.compare_by_identity
20
+ end
21
+ end.cache
22
+
15
23
  def self.derived_attribute_for?(context_obj, attr_name)
16
24
  return false unless context_obj.is_a?(Lutaml::Model::Serialize) &&
17
25
  context_obj.class.is_a?(Class) &&
@@ -52,16 +60,26 @@ module Lutaml
52
60
  # @param context_obj [Object] The context object (model_instance or delegate_obj)
53
61
  # @return [Boolean] true if should skip
54
62
  def check_skip_logic?(value, rule, context_obj)
55
- attr_name = extract_attribute_name(rule)
56
-
57
- to_map = to_value_map(rule)
58
-
59
- case value
60
- when nil
63
+ # Rule-constant parts (attribute name, :to value map) are frozen
64
+ # per rule; recomputing them per value showed in the to_yaml
65
+ # wall profile. The host transformation freezes itself after
66
+ # compile, so the cache lives in an isolated holder (TypeProbeCache
67
+ # precedent): host object -> identity-keyed rule plans.
68
+ per_host = (SKIP_PLANS[self] ||= {}.compare_by_identity)
69
+ skip_plan = (per_host[rule] ||= [extract_attribute_name(rule), to_value_map(rule)])
70
+ attr_name, to_map = skip_plan
71
+
72
+ # Plain conditionals, not `when ->(v)` literals: each lambda
73
+ # literal in a case/when allocates on every call — two per
74
+ # value here, 16% of to_yaml's allocations in the object-mode
75
+ # profile.
76
+ if value.nil?
61
77
  return to_map[:nil] == :omitted
62
- when ->(v) { Lutaml::Model::Utils.empty?(v) }
78
+ end
79
+ if Lutaml::Model::Utils.empty?(value)
63
80
  return to_map[:empty] == :omitted
64
- when ->(v) { Lutaml::Model::Utils.uninitialized?(v) }
81
+ end
82
+ if Lutaml::Model::Utils.uninitialized?(value)
65
83
  return to_map[:omitted] == :omitted || to_map[:omitted].nil?
66
84
  end
67
85
 
@@ -16,6 +16,21 @@ module Lutaml
16
16
 
17
17
  format_schema(schema, options)
18
18
  end
19
+
20
+ # Generate one schema document covering several model classes.
21
+ # The roots share a single $defs map and no top-level $ref is
22
+ # emitted; each root stays addressable under $defs by its class
23
+ # name. Use generate for a single root with a top-level $ref.
24
+ def generate_many(klasses, options = {})
25
+ schema = {
26
+ "$schema" => options[:schema],
27
+ "$id" => options[:id],
28
+ "description" => options[:description],
29
+ "$defs" => Generator::DefinitionsCollection.from_classes(klasses).to_schema,
30
+ }.compact
31
+
32
+ format_schema(schema, options)
33
+ end
19
34
  end
20
35
 
21
36
  attr_reader :schema, :klass
@@ -23,9 +23,23 @@ module Lutaml
23
23
  },
24
24
  }
25
25
 
26
- # Add choice validation if present
26
+ # Choice validation follows the serializer's contract: unset
27
+ # or empty members are omitted from output, and instances that
28
+ # leave a whole group empty are accepted (#869). Members are
29
+ # therefore never `required`; a max:1 group contributes an
30
+ # at-most-one constraint over its members, and independent
31
+ # groups combine conjunctively under allOf. Groups whose range
32
+ # admits several members impose no serialization constraint.
27
33
  if type.choice_attributes.any?
28
- @schema[name]["oneOf"] = generate_choice_attributes(type)
34
+ groups = type.choice_attributes
35
+ .select { |choice| choice.max == 1 }
36
+ .map { |choice| { "oneOf" => exclusive_branches(choice) } }
37
+
38
+ if groups.one?
39
+ @schema[name].merge!(groups.first)
40
+ elsif groups.any?
41
+ @schema[name]["allOf"] = groups
42
+ end
29
43
  end
30
44
 
31
45
  @schema
@@ -33,15 +47,45 @@ module Lutaml
33
47
 
34
48
  private
35
49
 
36
- def generate_choice_attributes(type)
37
- type.choice_attributes.map do |choice|
50
+ # Leaves of the group (nested choices collapse into it), as
51
+ # [name, attribute] pairs.
52
+ def choice_leaves(choice)
53
+ choice.attributes.flat_map do |member|
54
+ if member.is_a?(Lutaml::Model::Choice)
55
+ choice_leaves(member)
56
+ else
57
+ [[member.name.to_s, member]]
58
+ end
59
+ end
60
+ end
61
+
62
+ # Exactly-one-present construction over the leaves: no leaf, or
63
+ # exactly one leaf present without the others. Accepts the
64
+ # all-empty document the serializer emits; rejects documents
65
+ # with two members of the group at once (max:1).
66
+ def exclusive_branches(choice)
67
+ leaves = choice_leaves(choice)
68
+ names = leaves.map(&:first)
69
+ none = {
70
+ "not" => {
71
+ "anyOf" => names.map { |n| { "required" => [n] } },
72
+ },
73
+ }
74
+ singles = leaves.map do |name, attribute|
75
+ others = names - [name]
38
76
  {
39
- "type" => "object",
40
- "properties" => PropertiesCollection.from_attributes(
41
- choice.attributes, extract_register_from(type)
42
- ).to_schema,
77
+ "allOf" => (
78
+ [{
79
+ "type" => "object",
80
+ "properties" => PropertiesCollection.from_attributes(
81
+ [attribute], extract_register_from(type)
82
+ ).to_schema,
83
+ "required" => [name],
84
+ }] + others.map { |o| { "not" => { "required" => [o] } } }
85
+ ),
43
86
  }
44
87
  end
88
+ [none] + singles
45
89
  end
46
90
 
47
91
  def properties_to_schema(type)
@@ -9,20 +9,28 @@ module Lutaml
9
9
  include SharedMethods
10
10
 
11
11
  def from_class(klass)
12
- new.tap do |collection|
13
- collection << Definition.new(klass)
12
+ collection = new
13
+ register_definition(collection, klass)
14
+ collection
15
+ end
14
16
 
15
- process_attributes(collection, klass)
16
- end
17
+ # Build one collection covering several roots. The seen-set
18
+ # is shared across roots so a class referenced by more than
19
+ # one of them registers once.
20
+ def from_classes(klasses)
21
+ collection = new
22
+ seen = {}
23
+ klasses.each { |klass| register_definition(collection, klass, seen) }
24
+ collection
17
25
  end
18
26
 
19
- def process_attributes(collection, klass)
27
+ def process_attributes(collection, klass, seen = {})
20
28
  register = extract_register_from(klass)
21
29
  klass.attributes.each_value do |attribute|
22
30
  if attribute.union?
23
- process_union_members(collection, attribute)
31
+ process_union_members(collection, attribute, seen)
24
32
  elsif attribute.serializable?(register)
25
- process_attribute(collection, attribute, register)
33
+ process_attribute(collection, attribute, register, seen)
26
34
  end
27
35
  end
28
36
  end
@@ -30,31 +38,44 @@ module Lutaml
30
38
  # A union attribute's type is Type::Union, not a model, so collect
31
39
  # definitions for each of its Serializable members directly (a
32
40
  # union schema emits a $ref per model member).
33
- def process_union_members(collection, attribute)
41
+ def process_union_members(collection, attribute, seen = {})
34
42
  attribute.union_member_types.each do |member|
35
43
  next unless member.is_a?(::Class) &&
36
44
  member.include?(Lutaml::Model::Serialize)
37
45
 
38
- collection.merge(DefinitionsCollection.from_class(member))
46
+ register_definition(collection, member, seen)
39
47
  end
40
48
  end
41
49
 
42
- def process_attribute(collection, attribute, register)
50
+ def process_attribute(collection, attribute, register, seen = {})
43
51
  attr_type = Lutaml::Model::GlobalContext.resolve_type(
44
52
  attribute.type, register
45
53
  )
46
- collection.merge(DefinitionsCollection.from_class(attr_type))
54
+ register_definition(collection, attr_type, seen)
47
55
 
48
- process_polymorphic_types(collection, attribute)
56
+ process_polymorphic_types(collection, attribute, seen)
49
57
  end
50
58
 
51
- def process_polymorphic_types(collection, attribute)
59
+ def process_polymorphic_types(collection, attribute, seen = {})
52
60
  return unless attribute.options&.[](:polymorphic)
53
61
 
54
62
  attribute.options[:polymorphic].each do |child|
55
- collection.merge(DefinitionsCollection.from_class(child))
63
+ register_definition(collection, child, seen)
56
64
  end
57
65
  end
66
+
67
+ # Recursive models (Node#child → Node, or longer include
68
+ # cycles) are valid; the seen-set registers each class once
69
+ # and lets property references point at the shared $defs
70
+ # entry instead of recursing (#863).
71
+ def register_definition(collection, klass, seen = {})
72
+ return collection if seen[klass]
73
+
74
+ seen[klass] = true
75
+ collection << Definition.new(klass)
76
+ process_attributes(collection, klass, seen)
77
+ collection
78
+ end
58
79
  end
59
80
 
60
81
  attr_reader :definitions
@@ -52,11 +52,13 @@ module Lutaml
52
52
  end
53
53
 
54
54
  def self.to_json(klass, options = {})
55
- JsonSchema.generate(klass, options)
55
+ # The generator signatures are keyword-only; splat so callers may
56
+ # pass an options hash positionally (#866).
57
+ JsonSchema.generate(klass, **options)
56
58
  end
57
59
 
58
60
  def self.to_yaml(klass, options = {})
59
- YamlSchema.generate(klass, options)
61
+ YamlSchema.generate(klass, **options)
60
62
  end
61
63
 
62
64
  # XML-specific methods (to_xsd, to_relaxng, from_xml) are registered
@@ -8,6 +8,39 @@ module Lutaml
8
8
  # model classes actually inherit from (lutaml-model#18).
9
9
  include Serialize
10
10
  extend ComparableModel::ClassMethods
11
+
12
+ # Ivars that must never take part in inspect output: they either
13
+ # walk the whole graph (@lutaml_parent climbs to the root, and
14
+ # parent-child mutual recursion never terminates under the
15
+ # default Object#inspect) or are bulky parse bookkeeping.
16
+ INSPECT_EXCLUDED_IVARS = %i[
17
+ @lutaml_parent @lutaml_root @lutaml_register @using_default
18
+ @element_order @attribute_order @encoding
19
+ ].freeze
20
+
21
+ # Bounded, Struct-like inspect: scalars by value, collections by
22
+ # size, child models by class name. The default Object#inspect
23
+ # recurses through @lutaml_parent and re-enters children forever
24
+ # on real parsed documents.
25
+ def inspect
26
+ parts = (instance_variables - INSPECT_EXCLUDED_IVARS).map do |iv|
27
+ "#{iv}=#{self.class.inspect_value(instance_variable_get(iv))}"
28
+ end
29
+ attrs = " #{parts.join(', ')}" unless parts.empty?
30
+ "#<#{self.class.name}#{attrs}>"
31
+ end
32
+
33
+ class << self
34
+ def inspect_value(value)
35
+ case value
36
+ when nil, true, false, Numeric, Symbol then value.inspect
37
+ when String then value.inspect.length > 48 ? "#{value.inspect[0, 45]}...".inspect : value.inspect
38
+ when Array then "(#{value.size} items)"
39
+ when Hash then "{#{value.size} keys}"
40
+ else "##{value.class.name}"
41
+ end
42
+ end
43
+ end
11
44
  end
12
45
  end
13
46
  end
@@ -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"
@@ -62,6 +100,32 @@ module Lutaml
62
100
  #{lines}
63
101
  end
64
102
  RUBY
103
+
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
+
112
+ <<~RUBY
113
+ def __lutaml_raw_#{name}
114
+ @#{name}
115
+ end
116
+ RUBY
117
+ end.join
118
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
119
+ #{raw_lines}
120
+ RUBY
121
+ end
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
65
129
  end
66
130
 
67
131
  # Historical getter shape for punctuation-named attributes and
@@ -104,12 +168,46 @@ module Lutaml
104
168
  end
105
169
  remove_method(compiled) if defined_now
106
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
107
174
  end
108
175
 
109
176
  def define_attribute_methods(attr, register = nil)
110
177
  name = attr.name
111
178
  register_id = extract_register_id(register)
112
179
 
180
+ # lutaml-model#922: raw-state reader for the deserializer's
181
+ # unmatched-rule handling. A custom writer that assigns a
182
+ # *different* attribute mid-parse (relaton's ICS Isoics
183
+ # fallback; any derived-value pattern) is invisible to the
184
+ # value_set tracker -- public setters don't mark it -- so the
185
+ # tracked "still default" state lies. The deserializer needs
186
+ # the raw stored state to know whether an attribute was
187
+ # writer-populated: still holding the initialized marker
188
+ # (sentinel / lazy collection) means unset; anything else was
189
+ # written since init and must never be overwritten by an
190
+ # unmatched rule's default assignment. Declared here (not in
191
+ # the state-defaults compilation) so late declarations get it
192
+ # too -- XMI's extension loader declares attributes after
193
+ # first parse.
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
205
+ def __lutaml_raw_#{name}
206
+ @#{name}
207
+ end
208
+ RUBY
209
+ end
210
+
113
211
  if attr.enum?
114
212
  add_enum_methods_to_model(
115
213
  model,
@@ -216,7 +314,9 @@ module Lutaml
216
314
  # Array on EVERY read — the largest per-call allocation source
217
315
  # on instance-heavy parses (TODO.max-perf/06). The optional-arg
218
316
  # form allocates nothing, and the read is a direct @ivar.
219
- if attr.collection?
317
+ if !AttributeDefinition.source_compilation?
318
+ define_block_attribute_getter(name, attr)
319
+ elsif attr.collection?
220
320
  # class_eval interpolates, e.g.:
221
321
  # def items(arg = Lutaml::Model::Serialize::NO_ARG)
222
322
  # if arg.equal?(Lutaml::Model::Serialize::NO_ARG)
@@ -272,7 +372,9 @@ module Lutaml
272
372
  end
273
373
  define_method(attr_reader_method) { attr } unless reader_defined
274
374
 
275
- if attr.collection?
375
+ if !AttributeDefinition.source_compilation?
376
+ define_block_attribute_setters(name, attr)
377
+ elsif attr.collection?
276
378
  # class_eval interpolates, e.g.:
277
379
  # def items=(value)
278
380
  # value_set_for(:items)
@@ -332,6 +434,93 @@ module Lutaml
332
434
  end
333
435
  end
334
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
+
508
+ # Class-level documentation metadata (#356): `desc "text"` on
509
+ # the model class; a zero-argument call reads it back.
510
+ # Subclasses inherit the nearest ancestor's description — an
511
+ # explicit `desc` overrides. Consumed by documentation and
512
+ # schema tooling; the serialization paths never read it.
513
+ def desc(text = nil)
514
+ return @lutaml_desc = text if text
515
+
516
+ return @lutaml_desc if defined?(@lutaml_desc)
517
+
518
+ if superclass.is_a?(::Class) &&
519
+ superclass < ::Lutaml::Model::Serializable
520
+ superclass.desc
521
+ end
522
+ end
523
+
335
524
  # Define an attribute for the model
336
525
  #
337
526
  # @param name [Symbol] The attribute name
@@ -354,6 +543,11 @@ module Lutaml
354
543
  attr = Attribute.new(name, type, options)
355
544
  @attributes[name] = attr
356
545
  @merged_attributes_cache = nil
546
+ # Attributes can be declared after the model has been
547
+ # instantiated (forward references, post-hoc declarations);
548
+ # the interned writer table from the first instantiation must
549
+ # not outlive the attribute set it was built from.
550
+ @instantiate_writers = nil
357
551
  invalidate_state_defaults!
358
552
  define_attribute_methods(attr)
359
553
 
@@ -84,6 +84,26 @@ module Lutaml
84
84
  @__order_tracking__ ? true : false
85
85
  end
86
86
 
87
+ # Whether any writer setter ran on this instance. Parsed
88
+ # instances start pristine — every set element value is covered
89
+ # by the parse-built element_order, so ordered serialization
90
+ # can skip order reconciliation; the first writer-set marks the
91
+ # instance and reconciliation engages (writer-added values sit
92
+ # outside the parse order).
93
+ # @return [Boolean]
94
+ def mutated_after_parse?
95
+ @__mutated_after_parse__ ? true : false
96
+ end
97
+
98
+ # Construction-time reset: hydration routes every attribute
99
+ # through the generated writers (which mark mutations); a
100
+ # freshly built instance is definitionally not
101
+ # mutated-after-parse.
102
+ # @return [void]
103
+ def clear_mutated_after_parse!
104
+ @__mutated_after_parse__ = false
105
+ end
106
+
87
107
  # Record a singular attribute mutation in element_order.
88
108
  #
89
109
  # No-op unless order tracking is enabled (i.e. the instance was
@@ -102,6 +122,7 @@ module Lutaml
102
122
  # content for content-mapped attributes, ignored otherwise
103
123
  # @return [Object] the passed value
104
124
  def record_mutation(attribute_name, value = nil)
125
+ @__mutated_after_parse__ = true
105
126
  return value unless @__order_tracking__
106
127
 
107
128
  track_order(attribute_name, value, nil)
@@ -122,6 +143,7 @@ module Lutaml
122
143
  # @param value [Object, nil] The value assigned to the collection
123
144
  # @return [Object] the passed value
124
145
  def record_mutation_collection(attribute_name, value)
146
+ @__mutated_after_parse__ = true
125
147
  return value unless @__order_tracking__
126
148
  return value if value.nil? || Lutaml::Model::Utils.uninitialized?(value)
127
149
 
@@ -19,6 +19,11 @@ module Lutaml
19
19
  # not emit Instrumentation events — the work did not happen.
20
20
  #
21
21
  # Semantics (deliberate):
22
+ # - Growth is unbounded by default: every distinct input digs its
23
+ # own entry, and nothing evicts until the store does. Bound the
24
+ # store — lutaml-store's memory adapter accepts max_entries —
25
+ # via Config.conversion_cache before enabling this in
26
+ # long-lived batch processes.
22
27
  # - `from_*` hits return the same cached instance for identical
23
28
  # input — across callers and threads. Treat results as read-only;
24
29
  # classes whose callers mutate parse results must not opt in.