lutaml-model 0.8.97 → 0.8.99

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: 41d8d37a85f86f8d8f30d632c36526fe311ba0156f01a096a15717627f29ec72
4
+ data.tar.gz: f15221cd4017756a1045502f77b588df6ae8f3e62ff39b38ac10c8d6f4a68115
5
5
  SHA512:
6
- metadata.gz: 7efe4b0bc24f20b9d48e37c33b5e23cf23af7397900a7d788912275a4b396318d916c721b21bc6373de53e99f3f4f0dc29e459c94bba2e2baaf8c4da67232c14
7
- data.tar.gz: 59d5412393469b1904c4c6e29bc0ff58b0c6333b5e3710e531d9e7a8ae465e7762993288f04e6147a12a204a8fb581059a24491d9dc90d7f309e78b1bb39b97a
6
+ metadata.gz: 509fc9f6462f37013113fdcb7a582ca70e9ce0ff021b51c780f9afdd66c03ec2c62213bd0c76a680dc993db138182b7260ed821757b37946b2b61941e2b9b066
7
+ data.tar.gz: e01bb47f5085c77f6ce840f31ac4f9f243271846267f1083c2c099cfae7f2ced2979f73a61a3a44ea0b0d609b9176a6e8d64e8841994c794382fd0a16c0880f2
@@ -0,0 +1,85 @@
1
+ ---
2
+ title: Integrating lutaml-model in a Gem
3
+ nav_order: 21
4
+ ---
5
+
6
+ = Integrating lutaml-model in a gem
7
+
8
+ For gem authors, the integration contract is simple: **depend on
9
+ `lutaml-model` and nothing else format-specific.** Every XML, YAML,
10
+ JSON, and TOML engine is resolved at runtime, and the standard
11
+ adapters (Nokogiri through moxml, Psych, the `json` gem, toml-rb
12
+ compatible engines) cover a working default everywhere.
13
+
14
+ == Declaring the dependency
15
+
16
+ [source,ruby]
17
+ ----
18
+ spec.add_dependency "lutaml-model", "~> 0.8"
19
+ ----
20
+
21
+ Do not add Nokogiri, Ox, Oga, leptris, yeptris, or teptris to your
22
+ gemspec. All engines are optional at runtime:
23
+
24
+ * when an engine gem is in the end user's bundle, the matching
25
+ adapters prefer it (see the
26
+ link:../native-engines[native engines guide]);
27
+ * when it is not, adapters fall back to the standard engine for
28
+ that format — parsing and generation semantics are identical,
29
+ error classes are re-raised as their standard-library equivalents.
30
+
31
+ There is no code path to write, no require order to respect, and no
32
+ capability probe to perform in your gem.
33
+
34
+ == Testing across adapters
35
+
36
+ Your test suite should exercise the adapters your users may run.
37
+ Lutaml::Model's own suite parameterizes the XML adapter through an
38
+ environment variable; the same shape works downstream:
39
+
40
+ [source,ruby]
41
+ ----
42
+ ADAPTER = ENV.fetch("XML_ADAPTER", "nokogiri").to_sym
43
+
44
+ RSpec.configure do |config|
45
+ config.before(:suite) do
46
+ Lutaml::Model::Config.xml_adapter_type = ADAPTER
47
+ end
48
+ end
49
+ ----
50
+
51
+ Run the matrix in CI — one leg per adapter the standard runners can
52
+ install:
53
+
54
+ [source,sh]
55
+ ----
56
+ XML_ADAPTER=nokogiri bundle exec rspec
57
+ XML_ADAPTER=ox bundle exec rspec
58
+ XML_ADAPTER=leptris bundle exec rspec # requires the leptris gem in that leg's bundle
59
+ ----
60
+
61
+ Engine gems belong in the CI *Gemfile* (a `group :test` entry or a
62
+ separate CI gemfile), never in the gemspec. Start with the primary
63
+ adapter (Nokogiri) and enable further legs incrementally as they
64
+ pass — the same "adapter-first" strategy this library's own suite
65
+ uses.
66
+
67
+ == Semantics your users can rely on
68
+
69
+ * Adapter choice never changes results: the plan fast path and the
70
+ interpretive pipeline produce identical output; on engines below
71
+ a capability floor the affected models parse interpretively
72
+ rather than degrade (see the version-floor note in the
73
+ link:../native-engines[native engines guide]).
74
+ * Parse failures raise the standard error classes wrapped as
75
+ `Lutaml::Model::InvalidFormatError` on every engine — code that
76
+ rescues those classes works regardless of the bundle.
77
+
78
+ == Rules of thumb
79
+
80
+ * Gemspec: `lutaml-model` only. CI gemfiles: the engines you test.
81
+ * Expose format options you want to forward (pretty printing,
82
+ encoding) — never the adapter itself.
83
+ * If you ship models for multiple schema versions, see
84
+ link:../type-registers-contexts[type registers and contexts] for
85
+ per-version trees and substitution.
@@ -28,8 +28,13 @@ Task-oriented guides for accomplishing specific goals with Lutaml::Model.
28
28
  * link:../advanced-mapping[Advanced Attribute Mapping] - Same-name dispatch on attribute values (`when_attribute`), grouped declarations, custom methods, delegation
29
29
  * link:../grammar-backed-formats[Grammar-Backed String Formats] - Register a grammar engine as a serialization format (identifier strings, DSLs)
30
30
 
31
- == Performance
31
+ == Model architecture and integration
32
+
33
+ * link:../type-registers-contexts[Type Registers and Contexts] - Multi-version class trees, derived contexts, and model substitution
34
+ * link:../gem-integration[Integrating lutaml-model in a Gem] - Dependency contract, adapter deferral, cross-adapter testing
32
35
 
36
+ == Performance
37
+ /
33
38
  * link:../native-engines[Native engines (leptris / yeptris / teptris)] - Drop-in XML/YAML/JSON/TOML engine upgrades via bundle opt-in
34
39
 
35
40
  == Linked Data serialization
@@ -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:
@@ -0,0 +1,126 @@
1
+ ---
2
+ title: Type Registers and Contexts
3
+ nav_order: 20
4
+ ---
5
+
6
+ = Type registers and contexts
7
+
8
+ Lutaml::Model resolves every attribute type — Symbol or class —
9
+ through a per-application *register*: a named table of types scoped
10
+ so that different class trees can share mapping definitions without
11
+ colliding. Registers are the mechanism behind multi-version model
12
+ suites and model substitution.
13
+
14
+ == Registers
15
+
16
+ A register maps type ids to classes. Classes declare the register
17
+ they belong to:
18
+
19
+ [source,ruby]
20
+ ----
21
+ module MyModels
22
+ class Mi < Lutaml::Model::Serializable
23
+ def self.lutaml_default_register
24
+ :my_models
25
+ end
26
+ end
27
+ end
28
+ ----
29
+
30
+ Symbol attribute types resolve in the declaring class's register,
31
+ so the same symbol can mean different classes per tree — the
32
+ multi-version pattern below builds on this.
33
+
34
+ == Contexts: derived registers with substitutions
35
+
36
+ A *context* is a derived type-resolution scope: it carries a
37
+ registry, fallback registers, and — the part that powers model
38
+ substitution — a list of type substitutions. Contexts are created
39
+ through the global registry:
40
+
41
+ [source,ruby]
42
+ ----
43
+ Lutaml::Model::GlobalContext.create_context(
44
+ id: :custom_models,
45
+ fallback_to: [:my_models],
46
+ substitutions: [
47
+ { from_type: MyModels::Mover, to_type: Overset },
48
+ ],
49
+ )
50
+
51
+ instance = MyModels::Math.from_xml(xml, context: :custom_models)
52
+ instance.mover_value.first # => #<Overset>
53
+ ----
54
+
55
+ Every attribute whose type resolves to `MyModels::Mover` — at any
56
+ depth of the tree — hydrates `Overset` in that context. Substitution
57
+ is type-based, so "swap this class everywhere it is referenced" and
58
+ "swap it only inside this one path" are the same operation scoped by
59
+ context: create two contexts with different substitution lists.
60
+
61
+ Contexts are cheap and disposable:
62
+
63
+ [source,ruby]
64
+ ----
65
+ Lutaml::Model::GlobalContext.unregister_context(:custom_models)
66
+ ----
67
+
68
+ === The custom-models convenience
69
+
70
+ A whole-table convenience reads exactly like a class-swap hash and
71
+ derives the context for you:
72
+
73
+ [source,ruby]
74
+ ----
75
+ models = { MyModels::Mover => Overset, MyModels::Mi => BoldMi }
76
+ Lutaml::Model::GlobalContext.create_context(
77
+ id: :custom, fallback_to: [:my_models],
78
+ substitutions: models.map { |from, to| { from_type: from, to_type: to } },
79
+ )
80
+ MyModels::Math.from_xml(xml, context: :custom)
81
+ ----
82
+
83
+ Substitute classes should inherit from the class they replace (or
84
+ provide its full attribute surface) so mappings and serialization
85
+ keep working.
86
+
87
+ === Substituting a symbol entry instead of a class
88
+
89
+ Registers accept direct class registrations under a type id — useful
90
+ when a tree declares `attribute :row, :row` and you want the symbol
91
+ to resolve to your own class within a derived register:
92
+
93
+ [source,ruby]
94
+ ----
95
+ derived = Lutaml::Model::Register.new(:derived, fallback: [:my_models])
96
+ derived.register_model(NewRow, id: :row)
97
+ MyModels::Doc.from_xml(xml, register: derived)
98
+ ----
99
+
100
+ (`register:` is the pre-context spelling; prefer `context:` with
101
+ substitutions in new code.)
102
+
103
+ == Multi-version class trees
104
+
105
+ The flagship pattern combines registers with per-version classes
106
+ and shared mapping modules:
107
+
108
+ * a `Base::Mrow` module holds the mapping DSL once;
109
+ * each version declares `class Mrow` mixing the module in and
110
+ pointing `lutaml_default_register` at its version's register;
111
+ * attributes use symbol types, so `attribute :mrow, :mrow` means
112
+ the right class per tree;
113
+ * a substitution context swaps individual classes for one call
114
+ without re-opening or redefining anything.
115
+
116
+ Nothing re-opens generated classes, and each version's tree stays
117
+ independent — no hidden delegation.
118
+
119
+ == Rules of thumb
120
+
121
+ * Prefer contexts over global configuration: substitutions scoped
122
+ to a context cannot leak into other resolutions.
123
+ * Substitute classes should inherit the original; substitution then
124
+ preserves every mapping declared on the base.
125
+ * Clean up exploratory contexts with `unregister_context` in test
126
+ teardowns — contexts are global state.
@@ -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.99"
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.99
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
@@ -207,6 +207,7 @@ files:
207
207
  - docs/_guides/creating-xsd.adoc
208
208
  - docs/_guides/custom-methods.adoc
209
209
  - docs/_guides/document-validation.adoc
210
+ - docs/_guides/gem-integration.adoc
210
211
  - docs/_guides/grammar-backed-formats.adoc
211
212
  - docs/_guides/index.adoc
212
213
  - docs/_guides/jsonld-serialization.adoc
@@ -220,6 +221,7 @@ files:
220
221
  - docs/_guides/schema-generation.adoc
221
222
  - docs/_guides/schema-import.adoc
222
223
  - docs/_guides/turtle-serialization.adoc
224
+ - docs/_guides/type-registers-contexts.adoc
223
225
  - docs/_guides/value-transformations.adoc
224
226
  - docs/_guides/xml-mapping.adoc
225
227
  - docs/_guides/xml-mappings-guide.adoc
@@ -1796,6 +1798,7 @@ files:
1796
1798
  - spec/lutaml/model/root_mappings_spec.rb
1797
1799
  - spec/lutaml/model/rule_value_extractor_spec.rb
1798
1800
  - spec/lutaml/model/runtime_compatibility_spec.rb
1801
+ - spec/lutaml/model/schema/generate_many_spec.rb
1799
1802
  - spec/lutaml/model/schema/generation_regressions_spec.rb
1800
1803
  - spec/lutaml/model/schema/generator/definitions_collection_spec.rb
1801
1804
  - spec/lutaml/model/schema/json_schema_spec.rb