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 +4 -4
- data/docs/_guides/gem-integration.adoc +85 -0
- data/docs/_guides/index.adoc +6 -1
- data/docs/_guides/native-engines.adoc +16 -5
- data/docs/_guides/type-registers-contexts.adoc +126 -0
- data/lib/lutaml/model/attribute.rb +10 -1
- data/lib/lutaml/model/schema/base_schema.rb +15 -0
- data/lib/lutaml/model/schema/generator/definitions_collection.rb +10 -0
- data/lib/lutaml/model/type/time.rb +6 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/leptris/plan_compiler.rb +28 -2
- data/lib/lutaml/xml/leptris/plan_serializer.rb +32 -12
- data/lib/lutaml/xml/leptris.rb +15 -0
- data/lib/lutaml/xml/namespace_collector.rb +17 -1
- data/lib/lutaml/xml/type/serializers.rb +8 -1
- data/spec/lutaml/model/schema/generate_many_spec.rb +61 -0
- data/spec/person_spec.rb +10 -9
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41d8d37a85f86f8d8f30d632c36526fe311ba0156f01a096a15717627f29ec72
|
|
4
|
+
data.tar.gz: f15221cd4017756a1045502f77b588df6ae8f3e62ff39b38ac10c8d6f4a68115
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/docs/_guides/index.adoc
CHANGED
|
@@ -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
|
-
==
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
data/lib/lutaml/model/version.rb
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
data/lib/lutaml/xml/leptris.rb
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
|
|
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
|
|
137
|
-
|
|
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.
|
|
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-
|
|
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
|