lutaml-model 0.8.97 → 0.8.98

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: 2dcefe56e003b241812971d37fc9d76d6b6945cb5a138a9fa15f9ce2aa4fbd7d
4
- data.tar.gz: 835dfecf768169c9fbc6ab772c2e11dbe593f3f5e9deb7016b09c8088706117c
3
+ metadata.gz: e9f7cb26a9f87fbfe0feb461b25dd3e8b50e32d64bbbd32b23f848e96355965b
4
+ data.tar.gz: 1961e58a64424ca1d69181f3e873d107e8f1d8762f1d1155c1de953c883ceaa1
5
5
  SHA512:
6
- metadata.gz: 7efe4b0bc24f20b9d48e37c33b5e23cf23af7397900a7d788912275a4b396318d916c721b21bc6373de53e99f3f4f0dc29e459c94bba2e2baaf8c4da67232c14
7
- data.tar.gz: 59d5412393469b1904c4c6e29bc0ff58b0c6333b5e3710e531d9e7a8ae465e7762993288f04e6147a12a204a8fb581059a24491d9dc90d7f309e78b1bb39b97a
6
+ metadata.gz: 8624d39f5b4d556f6c72e7f76fa6ab262490a1583d5776756373dd4ea220bb1ba89e3f57f7774efa8f5f8c16f631f312ac149ed93ed651c15223068265bea629
7
+ data.tar.gz: 1249cac19b6f80880f5eb0c29cb0b2df17ff5813021573f6c2b63c923f6b2d63d2f579b9d14ebf18b8dae774df208787cef61cbfc507848fd99afffbc28b1404
@@ -67,11 +67,22 @@ When the XML adapter resolves to leptris, XML (de)serialization
67
67
  compiles the model's mapping into a leptris descriptor plan and runs
68
68
  the whole document through one native pass — measured 6.7x parse and
69
69
  4x serialize with -76% / -80% allocations on the plan-path corpus.
70
- Models the plan cannot express exactly (same-name discriminator
71
- partitions, namespace-qualified models with deferred-rule shapes) and
72
- shapes with custom semantics (custom methods, polymorphism, unions,
73
- ordered children) hydrate through the interpretive pipeline or a
74
- per-subtree fragment of it, unchanged.
70
+ Namespace-qualified models ride the path too: serialization spells
71
+ prefixed element and attribute names through the same namespace
72
+ planning the interpretive writer uses, and every `(prefix, uri)`
73
+ pair is declared once on the output root. Models the plan cannot
74
+ express exactly (same-name discriminator partitions, deferred-rule
75
+ shapes) and shapes with custom semantics (custom methods,
76
+ polymorphism, unions, ordered children) hydrate through the
77
+ interpretive pipeline or a per-subtree fragment of it, unchanged.
78
+
79
+ Version floor note: leptris 1.9.320+ retains child-row namespace
80
+ URIs engine-side and matches plain attribute rows against
81
+ namespace-qualified wire spellings. On older engines, models whose
82
+ plans carry exact-URI child rows or plain attribute rows under a
83
+ namespaced chain parse interpretively for correctness — a per-model
84
+ fallback, not an error. Bundles resolving leptris 1.9.320+ get the
85
+ full fast path.
75
86
 
76
87
  The path is on by default and is a no-op without leptris (standard
77
88
  adapters keep the classic pipeline). Opt out for the whole process:
@@ -1240,7 +1240,16 @@ instance_object = nil)
1240
1240
  # instead of three per serialized scalar. Reference and
1241
1241
  # custom-serializer rows keep the wrapped path.
1242
1242
  if resolved_type <= Type::Reference ||
1243
- Type::Value.format_type_serializer_for(format, resolved_type)&.dig(:to)
1243
+ (registered_to = Type::Value.format_type_serializer_for(format, resolved_type)&.dig(:to))
1244
+ # String→String under the default :xml serializer is the
1245
+ # identity — the wrapper mint per string value on the XML
1246
+ # serialize path is pure waste. A consumer override of the
1247
+ # serializer disables the fast path via the proc comparison.
1248
+ if resolved_type == Type::String && value.is_a?(::String) &&
1249
+ registered_to == Lutaml::Xml::Type::Serializers::STRING_XML_TO
1250
+ return value
1251
+ end
1252
+
1244
1253
  value = wrap_in_type_if_needed(value, resolved_type)
1245
1254
  return value.public_send(TO_FORMAT_METHODS[format] || :"to_#{format}")
1246
1255
  end
@@ -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
@@ -14,6 +14,16 @@ module Lutaml
14
14
  collection
15
15
  end
16
16
 
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
25
+ end
26
+
17
27
  def process_attributes(collection, klass, seen = {})
18
28
  register = extract_register_from(klass)
19
29
  klass.attributes.each_value do |attribute|
@@ -13,6 +13,12 @@ module Lutaml
13
13
  case value
14
14
  when ::Time then value
15
15
  when ::DateTime then value.to_time
16
+ when ::Integer
17
+ # YAML 1.1 sexagesimal: psych resolves an unquoted hh:mm:ss
18
+ # scalar to seconds since midnight (25200 for "07:00:00").
19
+ # Feeding that integer through Time.parse yields a garbage
20
+ # date — reconstruct the wall-clock time of the current day.
21
+ ::Date.today.to_time + value
16
22
  else ::Time.parse(value.to_s)
17
23
  end
18
24
  rescue ArgumentError
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.97"
5
+ VERSION = "0.8.98"
6
6
  end
7
7
  end
@@ -48,6 +48,12 @@ module Lutaml
48
48
  end
49
49
  end.cache
50
50
 
51
+ # Name-matched element row kinds eligible for the :unqualified
52
+ # ns form (leptris#1560). Text rows (content) match no element
53
+ # name and wildcard rows carry their own :any.
54
+ ROW_KINDS_NS_UNQUALIFIED = %i[scalar collection nested callback
55
+ raw].freeze
56
+
51
57
  class << self
52
58
  def compile(model_class, register)
53
59
  # Version gate, not a capability probe: the plan path needs
@@ -74,9 +80,13 @@ module Lutaml
74
80
  # is THREAD-LOCAL (a shared in-progress set races: a concurrent
75
81
  # same-key compile would cache false permanently — the #828
76
82
  # lesson); the nil at the cycle point is NOT cached — the
77
- # outermost build completes and caches the real verdict.
83
+ # outermost build completes and caches the real verdict. The
84
+ # :cycle stat makes the guard visible in plan_stats — without
85
+ # it a cyclic graph (uniword pict↔shape, sdt→sdt) reads as
86
+ # "nil with an empty histogram", indistinguishable from a
87
+ # silent build failure.
78
88
  stack = (Thread.current[:plan_compiler_stack] ||= [])
79
- return nil if stack.include?(key)
89
+ return opt_out!(:cycle) if stack.include?(key)
80
90
 
81
91
  stack.push(key)
82
92
  begin
@@ -323,6 +333,22 @@ module Lutaml
323
333
  # inherit the default xmlns, so the strict no-URI default
324
334
  # dropped every child of a namespaced document.
325
335
  flags << :ns_lenient
336
+
337
+ # Namespace-less model child rows bind the strict unwritten
338
+ # spelling (bare, or inherited default xmlns) on engines
339
+ # with the leptris#1560 :unqualified form — the interpretive
340
+ # matcher's exact no-namespace semantic, refusing prefixed
341
+ # spellings where the ns_lenient superset bound them.
342
+ # Rows with an explicit form (rule ns, type ns, :none, :any)
343
+ # are untouched; name-matched element row kinds only.
344
+ if model_ns.nil? && Leptris.ns_unqualified_rows_compatible?
345
+ rows.each do |r|
346
+ next if r[:ns]
347
+
348
+ r[:ns] = :unqualified if ROW_KINDS_NS_UNQUALIFIED.include?(r[:kind])
349
+ end
350
+ end
351
+
326
352
  tree = { name: mapping.root_element.to_s,
327
353
  attributes: plan_attrs, children: rows }
328
354
  tree[:ns] = model_ns if model_ns
@@ -186,6 +186,8 @@ module Lutaml
186
186
  inherited_ns = nil)
187
187
  reg ||= register
188
188
  write_attributes(element, instance, plan, reg, ns_ctx)
189
+ ns_index = ns_index_for(plan)
190
+ nested_plans = nested_plans_for(plan)
189
191
  plan[:rows].each do |rule, attr, kind, spelling, delegate|
190
192
  value = value_of(instance, attr, delegate)
191
193
  next unless rule.render?(value, instance)
@@ -196,9 +198,7 @@ module Lutaml
196
198
  # several occurrences serializes one element per item,
197
199
  # as the interpretive writer does. A plain scalar skips
198
200
  # the Array() wrap — the common case.
199
- row_ns = plan[:tree][:children]&.find do |r|
200
- r[:name] == rule.name.to_s
201
- end&.[](:ns) || inherited_ns
201
+ row_ns = ns_index[row_name(rule, spelling)] || inherited_ns
202
202
  if value.is_a?(::Array)
203
203
  value.each do |item|
204
204
  add_leaf(element,
@@ -213,9 +213,7 @@ module Lutaml
213
213
  attrs: rule.when_attribute)
214
214
  end
215
215
  when :collection_native, :collection_cb
216
- row_ns = plan[:tree][:children]&.find do |r|
217
- r[:name] == rule.name.to_s
218
- end&.[](:ns) || inherited_ns
216
+ row_ns = ns_index[rule.name.to_s] || inherited_ns
219
217
  Array(value).each do |item|
220
218
  add_leaf(element,
221
219
  ns_ctx ? ns_ctx.spell(rule.name.to_s, row_ns) : rule.name.to_s,
@@ -224,7 +222,8 @@ module Lutaml
224
222
  end
225
223
  when :nested, :ordered_deferred
226
224
  Array(value).each do |item|
227
- child_plan = PlanCompiler.compile(item.class, reg)
225
+ child_plan = nested_plans[item.class] ||
226
+ (nested_plans[item.class] = PlanCompiler.compile(item.class, reg))
228
227
  child_ns = child_plan[:tree][:ns] || inherited_ns
229
228
  child = element.create_child(
230
229
  ns_ctx ? ns_ctx.spell(child_plan[:tree][:name], child_ns) : child_plan[:tree][:name],
@@ -246,6 +245,25 @@ module Lutaml
246
245
  end
247
246
  end
248
247
 
248
+ # Per-plan lazy indexes: the name→ns lookup and the
249
+ # per-class child-plan resolution are per-instance calls on
250
+ # the serialize hot path — memoized once per compiled plan
251
+ # they cost nothing per row (the old per-row children#find
252
+ # plus PlanCompiler key arrays were ~4 allocs/row).
253
+ def ns_index_for(plan)
254
+ plan[:ns_index] ||= begin
255
+ h = {}
256
+ (plan[:tree][:children] || []).each do |r|
257
+ h[r[:name]] = r[:ns] unless h.key?(r[:name])
258
+ end
259
+ h
260
+ end
261
+ end
262
+
263
+ def nested_plans_for(plan)
264
+ plan[:nested_plans] ||= {}
265
+ end
266
+
249
267
  # Element_order-driven emission (the interpretive order
250
268
  # applier's contract): text runs and comments verbatim, each
251
269
  # element entry consuming the next item of its collection
@@ -327,7 +345,8 @@ module Lutaml
327
345
  end
328
346
  next unless item
329
347
 
330
- child_plan = PlanCompiler.compile(item.class, reg)
348
+ child_plan = nested_plans_for(plan)[item.class] ||
349
+ (nested_plans_for(plan)[item.class] = PlanCompiler.compile(item.class, reg))
331
350
  child = element.create_child(child_plan[:tree][:name])
332
351
  if child_plan[:ordered]
333
352
  return nil unless build_ordered(child, item, child_plan,
@@ -351,8 +370,9 @@ module Lutaml
351
370
  def write_attributes(element, instance, plan, reg = nil,
352
371
  ns_ctx = nil)
353
372
  recorded = instance.attribute_order
354
- attr_prefixes = {}
355
- if ns_ctx
373
+ attr_prefixes = nil
374
+ if ns_ctx && !(plan[:tree][:attributes] || []).empty?
375
+ attr_prefixes = {}
356
376
  (plan[:tree][:attributes] || []).each do |a|
357
377
  form = a[:ns].is_a?(Hash) ? a[:ns] : nil
358
378
  # Namespaced attributes always spell prefixed (W3C:
@@ -376,13 +396,13 @@ module Lutaml
376
396
  plan[:attr_rows].map { |rule, _a, _s, _d| rule.name.to_s }
377
397
  names.each do |name|
378
398
  write_attribute(element, instance, name, by_name[name],
379
- reg, attr_prefixes[name])
399
+ reg, attr_prefixes && attr_prefixes[name])
380
400
  end
381
401
  else
382
402
  plan[:attr_rows].each do |rule, attr, _sp, delegate|
383
403
  write_attribute(element, instance, rule.name.to_s,
384
404
  [rule, attr, delegate], reg,
385
- attr_prefixes[rule.name.to_s])
405
+ attr_prefixes && attr_prefixes[rule.name.to_s])
386
406
  end
387
407
  end
388
408
  end
@@ -42,6 +42,14 @@ module Lutaml
42
42
  # through PlanCompiler.engine_walk_safe?.
43
43
  MIN_LEPTRIS_WALK_NS_SAFETY_VERSION = "1.9.320.0"
44
44
 
45
+ # The :unqualified child-row ns form (leptris#1560: matches only
46
+ # the UNWRITTEN spelling — bare or inherited-default-ns — and
47
+ # refuses prefixed spellings, the interpretive matcher's exact
48
+ # no-namespace semantic) ships in binding 1.9.323.0. Older
49
+ # engines keep namespace-less model rows on the ns_lenient
50
+ # superset (local-name matching under any qualification).
51
+ MIN_LEPTRIS_NS_UNQUALIFIED_VERSION = "1.9.323.0"
52
+
45
53
  def self.plan_path_compatible?
46
54
  return false unless defined?(Gem)
47
55
 
@@ -82,6 +90,13 @@ module Lutaml
82
90
  !!(spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WALK_NS_SAFETY_VERSION))
83
91
  end
84
92
 
93
+ def self.ns_unqualified_rows_compatible?
94
+ return false unless defined?(Gem)
95
+
96
+ spec = Gem.loaded_specs["leptris"]
97
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_NS_UNQUALIFIED_VERSION)
98
+ end
99
+
85
100
  autoload :PlanCompiler, "lutaml/xml/leptris/plan_compiler"
86
101
  autoload :PlanHydrator, "lutaml/xml/leptris/plan_hydrator"
87
102
  autoload :PlanOrder, "lutaml/xml/leptris/plan_order"
@@ -409,9 +409,25 @@ module Lutaml
409
409
  # Handle collections and arrays
410
410
  if child_instance.is_a?(Array) || child_instance.is_a?(Lutaml::Model::Collection)
411
411
  instances = child_instance.is_a?(Lutaml::Model::Collection) ? child_instance.collection : child_instance
412
- # Collect needs from all instances and merge
412
+ # Collect needs from the FIRST instance of each distinct
413
+ # class and merge once — instances of one class share the
414
+ # mapping graph, so re-collecting every member re-walks
415
+ # identical structure (row-heavy models spent most of
416
+ # their serialize budget here). Value-dependent divergences
417
+ # in later members surface as unspellable forms on the
418
+ # serializer and fall back interpretively, never as wrong
419
+ # output. Merge-once is equivalent to merge-N: every read
420
+ # path of the merged needs is any?/set-based.
413
421
  merged_needs = NamespaceNeeds.new
422
+ seen_classes = nil
414
423
  instances.each do |item|
424
+ item_class = item.class
425
+ if seen_classes
426
+ next if seen_classes[item_class]
427
+ else
428
+ seen_classes = {}
429
+ end
430
+ seen_classes[item_class] = true
415
431
  item_ns_prefix = if item.is_a?(::Lutaml::Model::Serialize)
416
432
  item.xml_namespace_prefix
417
433
  end
@@ -8,13 +8,20 @@ module Lutaml
8
8
  module Serializers
9
9
  module_function
10
10
 
11
+ # The default :xml String serializer is the identity for String
12
+ # values. Attribute#serialize_value fast-paths String→String
13
+ # through an identity comparison with this exact proc, so a
14
+ # consumer override of the serializer disables that fast path
15
+ # automatically.
16
+ STRING_XML_TO = ->(inst) { inst.value&.to_s }.freeze
17
+
11
18
  def register_all!
12
19
  v = Lutaml::Model::Type::Value
13
20
 
14
21
  # String — value&.to_s
15
22
  v.register_format_type_serializer(
16
23
  :xml, Lutaml::Model::Type::String,
17
- to: ->(inst) { inst.value&.to_s },
24
+ to: STRING_XML_TO,
18
25
  from: ->(val) { Lutaml::Model::Type::String.cast(val) }
19
26
  )
20
27
 
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module GenerateManySpec
6
+ class Source < Lutaml::Model::Serializable
7
+ attribute :name, :string
8
+ end
9
+
10
+ class Other < Lutaml::Model::Serializable
11
+ attribute :label, :string
12
+ end
13
+
14
+ class Consumer < Lutaml::Model::Serializable
15
+ attribute :inner, Source
16
+ end
17
+ end
18
+
19
+ RSpec.describe Lutaml::Model::Schema::BaseSchema do
20
+ describe ".generate_many" do
21
+ it "covers every root in one document without a top-level $ref" do
22
+ parsed = JSON.parse(
23
+ Lutaml::Model::Schema::JsonSchema.generate_many(
24
+ [GenerateManySpec::Source, GenerateManySpec::Other],
25
+ ),
26
+ )
27
+
28
+ expect(parsed).not_to have_key("$ref")
29
+ expect(parsed["$defs"]).to have_key("GenerateManySpec_Source")
30
+ expect(parsed["$defs"]).to have_key("GenerateManySpec_Other")
31
+ end
32
+
33
+ it "registers a class shared by several roots once" do
34
+ parsed = JSON.parse(
35
+ Lutaml::Model::Schema::JsonSchema.generate_many(
36
+ [GenerateManySpec::Source, GenerateManySpec::Consumer],
37
+ ),
38
+ )
39
+
40
+ expect(parsed["$defs"].keys.size).to eq(2)
41
+ expect(parsed["$defs"]).to have_key("GenerateManySpec_Consumer")
42
+ end
43
+
44
+ it "formats YAML output" do
45
+ yaml = Lutaml::Model::Schema::YamlSchema.generate_many(
46
+ [GenerateManySpec::Source, GenerateManySpec::Other],
47
+ )
48
+
49
+ expect(yaml).to start_with("%YAML")
50
+ end
51
+ end
52
+
53
+ describe "Generator::DefinitionsCollection.from_classes" do
54
+ it "builds one collection covering all roots" do
55
+ collection = Lutaml::Model::Schema::Generator::DefinitionsCollection
56
+ .from_classes([GenerateManySpec::Source, GenerateManySpec::Consumer])
57
+
58
+ expect(collection.to_schema.keys.size).to eq(2)
59
+ end
60
+ end
61
+ end
data/spec/person_spec.rb CHANGED
@@ -115,13 +115,14 @@ RSpec.describe Person do
115
115
  expect(model.to_yaml).to eq(attributes_yaml.to_yaml)
116
116
  end
117
117
 
118
- # yeptris 0.6.29.x regressed the time-only scalar anchor: "07:00:00"
119
- # comes back as a fixed 2025-07-19 00:00 instead of today-07:00
120
- # (psych answers exactly; verified on the psych path). Re-pended
121
- # until the yeptris typed-materialize ships the fix — evidence on
122
- # moxml#336. 873a67bd un-pended these on the premise that the
123
- # 0.6.28.x behavior held; it did not survive the 0.6.29 cut.
124
- xit "deserializes from YAML" do # rubocop:disable RSpec/PendingWithoutReason -- yeptris time-only misparse; engine fix pending.
118
+ # Root cause of the long-pended time-only misparse: psych resolves an
119
+ # unquoted hh:mm:ss scalar as a YAML 1.1 sexagesimal integer (25200
120
+ # for "07:00:00"), and the :time cast fed that integer through
121
+ # Time.parse, which reads "25200" as a date (the fixed 2025-07-19
122
+ # garbage). The cast now reads sexagesimal integers as seconds since
123
+ # midnight — both the psych and yeptris paths answer today-07:00.
124
+ # Evidence chain: moxml#336, yeptris-ruby#265.
125
+ it "deserializes from YAML" do
125
126
  yaml = attributes_yaml.to_yaml
126
127
  person = described_class.from_yaml(yaml)
127
128
  expect(person.first_name).to eq("John")
@@ -133,8 +134,8 @@ RSpec.describe Person do
133
134
  expect(person.active).to be true
134
135
  end
135
136
 
136
- # Same yeptris time-only scalar misparse as above.
137
- xit "deserializes from YAML array" do # rubocop:disable RSpec/PendingWithoutReason -- yeptris time-only misparse; engine fix pending.
137
+ # Same sexagesimal time-only scalar as above.
138
+ it "deserializes from YAML array" do
138
139
  yaml = [attributes_yaml.dup, attributes_yaml.dup].to_yaml
139
140
 
140
141
  persons = described_class.from_yaml(yaml)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.97
4
+ version: 0.8.98
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-08 00:00:00.000000000 Z
11
+ date: 2026-10-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -1796,6 +1796,7 @@ files:
1796
1796
  - spec/lutaml/model/root_mappings_spec.rb
1797
1797
  - spec/lutaml/model/rule_value_extractor_spec.rb
1798
1798
  - spec/lutaml/model/runtime_compatibility_spec.rb
1799
+ - spec/lutaml/model/schema/generate_many_spec.rb
1799
1800
  - spec/lutaml/model/schema/generation_regressions_spec.rb
1800
1801
  - spec/lutaml/model/schema/generator/definitions_collection_spec.rb
1801
1802
  - spec/lutaml/model/schema/json_schema_spec.rb