lutaml-model 0.8.52 → 0.8.53

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: 92e0193fa58094112522b5a7717aed2f568d77096eb0ae3bc1a246be28c05bf0
4
- data.tar.gz: 20dcd124c22c695cc0a8fca65d04422ac84f468515ef153d909e5a6dcf5eac67
3
+ metadata.gz: 15a10289cc84edab04063cf83dac41f6923500898b0b65e2c72db51421f8a2ad
4
+ data.tar.gz: 4e6941d70effad354e4798013e27413eb0fd8d8b85643ca5621acb1e009b9247
5
5
  SHA512:
6
- metadata.gz: 0cc3e71dceb6ae87db00453efbd905de28dc10d528cd38426b29c2c0da1a67d64d41a59095e45a9da1daa97888a4d001c8211b0ad64a3a011ef46949e71206a6
7
- data.tar.gz: c444800a986c0e34bc1502fd671f53d7ff4776377d89b62225b857c923f4b97058042094145caf0d0a54db37576bedd546a733e114a444118ff2e13241092d83
6
+ metadata.gz: 149a8dc04992492d770567285e43fdea303035e93a340554966cf0ff5c844cfb83c03023f214cbc1f50fe3f9e4a20bbce9e540656ed97092876c0b450924e4bd
7
+ data.tar.gz: 82fc85442bef8b6f0014f03ac10373b03dd99b5be30631f45fbd2e924d02cc6baafbcd75fbb17249c648f9e8711609ba2d5d11494e80f9c6ab734d3cba517bf9
@@ -13,9 +13,11 @@ on:
13
13
  repository_dispatch:
14
14
  types: [ do-release ]
15
15
 
16
+ # Least-privilege ceiling for the called rubygems-release.yml: its release job
17
+ # needs contents:write (git tag push) and id-token:write (OIDC Trusted Publishing).
18
+ # packages:write is not required for RubyGems Trusted Publishing.
16
19
  permissions:
17
20
  contents: write
18
- packages: write
19
21
  id-token: write
20
22
 
21
23
  jobs:
@@ -24,5 +26,10 @@ jobs:
24
26
  with:
25
27
  next_version: ${{ github.event.inputs.next_version }}
26
28
  secrets:
27
- rubygems-api-key: ${{ secrets.LUTAML_CI_RUBYGEMS_API_KEY }}
29
+ # OMIT rubygems-api-key: metanorma/ci switches to OIDC Trusted Publishing
30
+ # when the secret is empty (USE_OIDC = rubygems-api-key == ''). Trusted
31
+ # Publishing is already configured for lutaml gems on rubygems.org —
32
+ # do not re-introduce LUTAML_CI_RUBYGEMS_API_KEY here.
33
+ # pat_token is optional (private-gem checkout + GH Packages). Keep if
34
+ # the org still uses private deps; drop once pure public.
28
35
  pat_token: ${{ secrets.LUTAML_CI_PAT_TOKEN }}
@@ -613,6 +613,14 @@ as `collection: true` when the element repeats. A rule without
613
613
  `when_attribute` that shares the name only claims occurrences no
614
614
  discriminator rule claimed, so every occurrence is captured exactly once.
615
615
 
616
+ On the key-value formats the same coexistence applies to
617
+ `when_attribute` rules sharing one key, regardless of declaration order.
618
+ A rule *without* `when_attribute` is an unconditional claim on the bare
619
+ slot: declaring one replaces prior bare rules on that key — including a
620
+ parent class's rule the subclass remaps to a different attribute — while
621
+ `when_attribute` partitions on the key survive. A partition rule replaces
622
+ only a prior partition with the same target; otherwise it accumulates.
623
+
616
624
  By default an occurrence no rule claims — an uncovered discriminator
617
625
  value, or a missing discriminator attribute — is silently dropped.
618
626
  Declare `unmatched: :raise` on a discriminator rule to fail the parse
@@ -148,17 +148,40 @@ module Lutaml
148
148
  serialize: serialize,
149
149
  )
150
150
  # lutaml-model#88: rules may share a wire key (when_attribute
151
- # partitions) — store per-key arrays, like the XML mapping. A
152
- # rule redefining the same target (`to:`) replaces its
153
- # predecessor, so subclass and redeclaration overrides keep the
154
- # pre-array replace semantics.
151
+ # partitions) — store per-key arrays, like the XML mapping.
152
+ # Replacement policy, MECE by rule kind:
153
+ # * a bare rule (no when_attribute) replaces prior BARE rules
154
+ # on the same wire key — subclass remaps of a key to a
155
+ # different attribute (e.g. base `ref → Ref`, subclass
156
+ # `ref → text`) must not leave both rules active — while
157
+ # when_attribute partitions on the key survive regardless
158
+ # of declaration order;
159
+ # * a when_attribute rule replaces only a prior partition
160
+ # with the same target (`to:`), otherwise it accumulates.
155
161
  existing = @mappings[mapping_name]
156
162
  if existing.nil?
157
163
  @mappings[mapping_name] = [rule]
158
- elsif (index = existing.index { |r| r.to == rule.to })
159
- existing[index] = rule
164
+ elsif rule.when_attribute.empty?
165
+ # Bare claim: prior bare rules on the key are superseded;
166
+ # partitions survive regardless of declaration order.
167
+ @mappings[mapping_name] =
168
+ existing.reject { |r| r.when_attribute.empty? } + [rule]
160
169
  else
161
- existing << rule
170
+ index = kv_partition_replace_index(existing, rule)
171
+ if index
172
+ existing[index] = rule
173
+ else
174
+ existing << rule
175
+ end
176
+ end
177
+ end
178
+
179
+ # Index of the prior partition a when_attribute rule replaces
180
+ # (same target), or nil. Extracted: a block in an elsif condition
181
+ # mis-binds (the known do/end trap).
182
+ def kv_partition_replace_index(existing, rule)
183
+ existing.index do |r|
184
+ r.to == rule.to && !r.when_attribute.empty?
162
185
  end
163
186
  end
164
187
 
@@ -161,6 +161,18 @@ module Lutaml
161
161
  )
162
162
  end
163
163
 
164
+ # Resolution that never materializes deferred model classes:
165
+ # raises UnknownTypeError for a not-yet-loaded class name.
166
+ # Mapping-time validation uses this so finalizing one mapping
167
+ # cannot fire an autoload that re-enters another model file
168
+ # mid-definition.
169
+ def loaded_type(context_or_register = nil)
170
+ return if unresolved_type.nil?
171
+
172
+ context = normalize_context(context_or_register)
173
+ resolver.resolve(unresolved_type, context, materialize: false)
174
+ end
175
+
164
176
  def type(context_or_register = nil)
165
177
  return if unresolved_type.nil?
166
178
 
@@ -62,7 +62,9 @@ module Lutaml
62
62
  # @param context [TypeContext] The resolution context
63
63
  # @return [Class] The resolved type class
64
64
  # @raise [UnknownTypeError] If type cannot be resolved
65
- def resolve(name, context)
65
+ def resolve(name, context, materialize: true)
66
+ return @delegate.resolve(name, context, materialize: false) unless materialize
67
+
66
68
  cache_key = if name.is_a?(Class)
67
69
  [context.id, name]
68
70
  else
@@ -204,6 +204,7 @@ module Lutaml
204
204
 
205
205
  # Clear memoized attribute merge (see .attributes)
206
206
  @merged_attributes_cache = nil
207
+ @instantiate_writers = nil
207
208
 
208
209
  # Clear per-Attribute type caches (stale entries from GC'd TypeContext objects)
209
210
  class_attributes.each_value(&:clear_type_cache)
@@ -368,22 +369,23 @@ module Lutaml
368
369
  def instantiate(attrs = {}, register = nil)
369
370
  instance = allocate_for_deserialization(register)
370
371
  register_id = instance.lutaml_register
371
- given = {}
372
- attrs.each do |key, value|
373
- name = key.to_sym
374
- next if name == :lutaml_register
375
-
376
- unless attributes(register_id).key?(name)
377
- raise Error, "unknown attribute '#{name}' for #{self}"
378
- end
379
-
380
- given[name] = value
381
- end
382
- attributes(register_id).each do |name, attr|
372
+ attrs_by = attributes(register_id)
373
+ writers = instantiate_writers(register_id)
374
+ given = 0
375
+ attrs_by.each do |name, attr|
383
376
  next if attr.derived?
384
377
 
385
- if given.key?(name)
386
- instance.public_send(:"#{name}=", given[name])
378
+ # Symbol keys hit directly (native-extension callers);
379
+ # string keys fall back with one allocated name.
380
+ key = if attrs.key?(name)
381
+ given += 1
382
+ name
383
+ elsif (str_name = name.to_s) && attrs.key?(str_name)
384
+ given += 1
385
+ str_name
386
+ end
387
+ if key
388
+ instance.public_send(writers[name], attrs[key])
387
389
  else
388
390
  # Absent keys seed their default explicitly, mirroring
389
391
  # initialize_attributes: every mapped attribute ends up
@@ -396,13 +398,29 @@ module Lutaml
396
398
  else
397
399
  attr.cast_value(default, register_id)
398
400
  end
399
- instance.public_send(:"#{name}=", value)
401
+ instance.public_send(writers[name], value)
400
402
  instance.using_default_for(name)
401
403
  end
402
404
  end
405
+ reserved = (attrs.key?(:lutaml_register) ? 1 : 0) +
406
+ (attrs.key?("lutaml_register") ? 1 : 0)
407
+ if given + reserved < attrs.size
408
+ known = attrs_by.keys
409
+ unknown = attrs.keys.find { |k| !known.include?(k.to_sym) }
410
+ raise Error, "unknown attribute '#{unknown}' for #{self}"
411
+ end
403
412
  instance
404
413
  end
405
414
 
415
+ # Writer method symbols for instantiate, memoized per register
416
+ # alongside the attribute merge (cleared with it).
417
+ def instantiate_writers(register_id = nil)
418
+ (@instantiate_writers ||= {})[register_id] ||= attributes(register_id)
419
+ .each_with_object({}) do |(name, _attr), memo|
420
+ memo[name] = :"#{name}="
421
+ end
422
+ end
423
+
406
424
  # Define register-specific attribute methods on the class itself.
407
425
  #
408
426
  # Called once per (class, register) combination. Replaces per-instance
@@ -51,7 +51,11 @@ module Lutaml
51
51
  # @example
52
52
  # TypeResolver.resolve(:string, context) #=> Lutaml::Model::Type::String
53
53
  # TypeResolver.resolve(MyClass, context) #=> MyClass (pass-through)
54
- def self.resolve(name, context)
54
+ # materialize: false resolves only what the registries know
55
+ # without const_get — definition-time validation must not fire
56
+ # autoloads (a pending autoload re-enters the model file being
57
+ # defined and re-evaluates its mapping on the populated state).
58
+ def self.resolve(name, context, materialize: true)
55
59
  # Apply substitutions even if already a class
56
60
  # This is important for type substitution (e.g., Glaze -> RegisterGlaze)
57
61
  if name.is_a?(Class)
@@ -79,8 +83,9 @@ module Lutaml
79
83
  return apply_substitutions(type, context) if type
80
84
 
81
85
  # 4. Try Type.const_get for CamelCase type names (e.g., "Decimal" -> Type::Decimal)
82
- # This maintains backward compatibility with old Register behavior
83
- if name.is_a?(String)
86
+ # This maintains backward compatibility with old Register behavior.
87
+ # Skipped for non-materializing resolution: const_get fires autoloads.
88
+ if materialize && name.is_a?(String)
84
89
  begin
85
90
  type = Lutaml::Model::Type.const_get(name)
86
91
  return apply_substitutions(type, context) if type
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.52"
5
+ VERSION = "0.8.53"
6
6
  end
7
7
  end
@@ -1112,7 +1112,7 @@ module Lutaml
1112
1112
  next unless target
1113
1113
 
1114
1114
  begin
1115
- resolved = target.type(Lutaml::Model::Config.default_register)
1115
+ resolved = target.loaded_type(Lutaml::Model::Config.default_register)
1116
1116
  rescue Lutaml::Model::UnknownTypeError
1117
1117
  next
1118
1118
  end
@@ -110,6 +110,11 @@ module Lutaml
110
110
 
111
111
  if rule.attribute?
112
112
  return nil unless scalar_type?(attr, register)
113
+ # Attribute plan rows are local-name keyed; a type-level
114
+ # namespace makes the attribute (URI, local)-identified
115
+ # (lutaml-model#744) — the interpretive matcher owns it
116
+ # until plan rows carry namespace identity.
117
+ return nil if attr.type_namespace_class(register)
113
118
 
114
119
  attr_rows << [rule, attr]
115
120
  plan_attrs << { name: rule.name.to_s }
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeferHost
4
+ class NeverLoadedModel < Lutaml::Model::Serializable
5
+ attribute :value, :string
6
+
7
+ xml do
8
+ element "never"
9
+ map_element "value", to: :value
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # A subclass that remaps a parent wire key onto a different attribute
6
+ # must replace the parent's rule, not accumulate beside it. Pre-#88
7
+ # (hash-keyed by wire name) this was automatic; the #88 array form
8
+ # only replaced on matching `to:`, leaving both rules active and
9
+ # breaking consumers like glossarist's V2::Citation (base maps
10
+ # `ref → Ref`, V2 remaps `ref → text`).
11
+ RSpec.describe "Key-value subclass wire-key remap" do
12
+ it "replaces the parent rule when the subclass remaps the same key to a different attribute" do
13
+ base = Class.new(Lutaml::Model::Serializable) do
14
+ attribute :ref, :string
15
+ attribute :label, :string
16
+
17
+ key_value do
18
+ map :ref, to: :ref
19
+ end
20
+ end
21
+
22
+ child = Class.new(base) do
23
+ key_value do
24
+ map :ref, to: :label
25
+ end
26
+ end
27
+
28
+ ref_spellings = [:ref, "ref"].freeze
29
+ rules = child.mappings_for(:yaml).mappings.select do |r|
30
+ ref_spellings.include?(r.name)
31
+ end
32
+ # Only the child's rule must remain
33
+ expect(rules.map(&:to)).to eq([:label])
34
+ end
35
+
36
+ it "still accumulates when_attribute partitions of the same wire key" do
37
+ klass = Class.new(Lutaml::Model::Serializable) do
38
+ attribute :a, :string
39
+ attribute :b, :string
40
+
41
+ key_value do
42
+ map :body, to: :a, when_attribute: { "type" => "a" }
43
+ map :body, to: :b, when_attribute: { "type" => "b" }
44
+ end
45
+ end
46
+
47
+ body_spellings = [:body, "body"].freeze
48
+ expected_targets = %i[a b].freeze
49
+ rules = klass.mappings_for(:yaml).mappings.select do |r|
50
+ body_spellings.include?(r.name)
51
+ end
52
+ expect(rules.map(&:to)).to match_array(expected_targets)
53
+ end
54
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Definition-time attribute-type validation (lutaml-model#296) must
6
+ # resolve types WITHOUT materializing deferred model classes. Eager
7
+ # resolution const_get-fires pending autoloads, which re-enters the
8
+ # model file being defined, re-opens its classes, and re-evaluates an
9
+ # xml mapping block against the already-populated mapping — rejected
10
+ # for map_all_content ("map_all is not allowed ... with map_all").
11
+ RSpec.describe "Definition-time type validation" do
12
+ # The probe REQUIRES const mechanics (Object.const_set + autoload):
13
+ # stub_const would define a plain constant and defeat the test.
14
+ it "leaves a pending autoload unfired while validating a mapping" do
15
+ probe = Module.new
16
+ Object.const_set(:DeferHostProbe, probe)
17
+ probe.autoload(:NeverLoadedModel,
18
+ File.expand_path("../../fixtures/autoload_probe/never_loaded_model", __dir__))
19
+
20
+ expect do
21
+ Class.new(Lutaml::Model::Serializable) do
22
+ attribute :ref, "DeferHostProbe::NeverLoadedModel"
23
+
24
+ xml do
25
+ element "doc"
26
+ map_element "ref", to: :ref
27
+ end
28
+ end
29
+ end.not_to raise_error
30
+
31
+ registered = probe.autoload?(:NeverLoadedModel)
32
+ expect(registered).to be_truthy,
33
+ "mapping validation materialized the deferred type — it must " \
34
+ "stay pending for deferred-import resolution"
35
+ ensure
36
+ # rubocop:disable-next RSpec/RemoveConst -- unloading the autoload probe
37
+ Object.send(:remove_const, :DeferHostProbe) if Object.const_defined?(:DeferHostProbe)
38
+ end
39
+ end
@@ -64,6 +64,39 @@ RSpec.describe "XML plan fast path" do
64
64
  expect(parsed).to be_a(node)
65
65
  end
66
66
 
67
+ # lutaml-model#744: attribute plan rows are local-name keyed — a
68
+ # type-namespaced attribute (xmi:type vs type) must take the
69
+ # interpretive matcher, or the namespaced value is silently lost.
70
+ it "opts models with type-namespaced attributes out to the interpretive path" do
71
+ xmi_ns = Class.new(Lutaml::Xml::Namespace) do
72
+ uri "http://www.omg.org/spec/XMI/20131001"
73
+ prefix_default "xmi"
74
+ end
75
+ xmi_type = Class.new(Lutaml::Model::Type::String) do
76
+ xml { namespace xmi_ns }
77
+ end
78
+ parameter = Class.new(Lutaml::Model::Serializable) do
79
+ attribute :xmi_type, xmi_type
80
+ attribute :classifier_type, :string
81
+
82
+ xml do
83
+ element "ownedParameter"
84
+ map_attribute "type", to: :xmi_type
85
+ map_attribute "type", to: :classifier_type
86
+ end
87
+ end
88
+ stub_const("PlanFastPath::NamespacedParam", parameter)
89
+
90
+ parsed = parameter.from_xml(
91
+ '<ownedParameter xmlns:xmi="http://www.omg.org/spec/XMI/20131001" ' \
92
+ 'xmi:type="uml:Parameter" type="EAnone_void"/>',
93
+ )
94
+
95
+ expect(parsed.xmi_type).to eq("uml:Parameter")
96
+ expect(parsed.classifier_type).to eq("EAnone_void")
97
+ expect(Lutaml::Xml::PlanCompiler.compile(parameter, :default)).to be_nil
98
+ end
99
+
67
100
  it "is on by default and can be disabled" do
68
101
  expect(Lutaml::Model::Configuration.new.xml_plan_fast_path).to be(true)
69
102
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.52
4
+ version: 0.8.53
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -977,6 +977,7 @@ files:
977
977
  - spec/benchmarks/xml_parsing_benchmark_spec.rb
978
978
  - spec/ceramic_spec.rb
979
979
  - spec/fixtures/address.rb
980
+ - spec/fixtures/autoload_probe/never_loaded_model.rb
980
981
  - spec/fixtures/ceramic.rb
981
982
  - spec/fixtures/geolexica_v2_concept.rb
982
983
  - spec/fixtures/geolexica_v2_sample.yaml
@@ -1685,6 +1686,7 @@ files:
1685
1686
  - spec/lutaml/jsonld/adapter_spec.rb
1686
1687
  - spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb
1687
1688
  - spec/lutaml/key_value/group_instantiate_spec.rb
1689
+ - spec/lutaml/key_value/subclass_remap_spec.rb
1688
1690
  - spec/lutaml/key_value/transformation/collection_serializer_spec.rb
1689
1691
  - spec/lutaml/key_value/transformation/rule_compiler_spec.rb
1690
1692
  - spec/lutaml/key_value/transformation/value_serializer_spec.rb
@@ -1729,6 +1731,7 @@ files:
1729
1731
  - spec/lutaml/model/custom_vobject_adapter_spec.rb
1730
1732
  - spec/lutaml/model/default_register_spec.rb
1731
1733
  - spec/lutaml/model/defaults_spec.rb
1734
+ - spec/lutaml/model/deferred_type_no_materialize_spec.rb
1732
1735
  - spec/lutaml/model/delegation_spec.rb
1733
1736
  - spec/lutaml/model/derived_attribute_serialization_spec.rb
1734
1737
  - spec/lutaml/model/dynamic_attribute_spec.rb