lutaml-model 0.8.126 → 0.8.128

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +60 -0
  3. data/docs/_guides/keyvalue-serialization.adoc +75 -0
  4. data/docs/_pages/value_types.adoc +102 -0
  5. data/lib/compat/opal/lutaml_model_boot.rb +15 -0
  6. data/lib/lutaml/key_value/transform.rb +238 -30
  7. data/lib/lutaml/model/error/unknown_mapping_error.rb +18 -0
  8. data/lib/lutaml/model/schema/generator/property.rb +60 -9
  9. data/lib/lutaml/model/serialize/format_conversion.rb +115 -2
  10. data/lib/lutaml/model/serialize/transformation_builder.rb +4 -3
  11. data/lib/lutaml/model/transform.rb +29 -4
  12. data/lib/lutaml/model/transformation_registry.rb +7 -5
  13. data/lib/lutaml/model/type/date_time_with_timezone.rb +28 -0
  14. data/lib/lutaml/model/type/date_with_timezone.rb +28 -0
  15. data/lib/lutaml/model/type/day_time_duration.rb +35 -0
  16. data/lib/lutaml/model/type/email_address.rb +28 -0
  17. data/lib/lutaml/model/type/hostname.rb +30 -0
  18. data/lib/lutaml/model/type/ip_v4_address.rb +27 -0
  19. data/lib/lutaml/model/type/ip_v6_address.rb +47 -0
  20. data/lib/lutaml/model/type/metaschema_pattern.rb +34 -0
  21. data/lib/lutaml/model/type/non_negative_integer.rb +25 -0
  22. data/lib/lutaml/model/type/positive_integer.rb +25 -0
  23. data/lib/lutaml/model/type/token.rb +25 -0
  24. data/lib/lutaml/model/type/uri_reference.rb +29 -0
  25. data/lib/lutaml/model/type/uuid.rb +25 -0
  26. data/lib/lutaml/model/type/value.rb +9 -0
  27. data/lib/lutaml/model/type/year_month_duration.rb +29 -0
  28. data/lib/lutaml/model/type.rb +27 -0
  29. data/lib/lutaml/model/version.rb +1 -1
  30. data/lib/lutaml/model.rb +2 -0
  31. data/lib/lutaml/xml/adapter/xml_serializer.rb +8 -6
  32. data/lib/lutaml/xml/model_transform.rb +8 -4
  33. data/lib/lutaml/xml/schema/xsd_schema.rb +30 -9
  34. data/lib/lutaml/xml/transformation.rb +3 -2
  35. data/spec/lutaml/key_value/child_map_plan_spec.rb +169 -0
  36. data/spec/lutaml/model/metaschema_types_spec.rb +367 -0
  37. data/spec/lutaml/model/serialization_profiles_spec.rb +276 -0
  38. metadata +20 -2
@@ -249,7 +249,9 @@ module Lutaml
249
249
  # Check if model class has a pre-compiled transformation
250
250
  model_class = model.class
251
251
  if model_class.is_a?(Class) && model_class.include?(::Lutaml::Model::Serialize)
252
- transformation = model_class.transformation_for(:xml, lutaml_register)
252
+ transformation = model_class.transformation_for(
253
+ :xml, lutaml_register, mapping: @lutaml_mapping_override
254
+ )
253
255
 
254
256
  # If transformation exists and is an Xml::Transformation, use it
255
257
  if transformation.is_a?(::Lutaml::Xml::Transformation)
@@ -271,11 +273,13 @@ module Lutaml
271
273
  instance_is_serialize = session.instance_is_serialize
272
274
 
273
275
  # Performance: Resolve xml_mapping once — used for both rules and metadata
274
- xml_mapping = mappings_for(:xml, effective_register)
275
- mappings = options[:mappings] || xml_mapping.mappings(effective_register)
276
+ # An explicit mapping (#176 profiles) governs the rules and the
277
+ # metadata alike; its own catch-all rule applies.
278
+ xml_mapping = options[:mappings] || mappings_for(:xml, effective_register)
279
+ mappings = xml_mapping.mappings(effective_register)
276
280
  # Catch-all rule (map_any_element): iterated like any other,
277
281
  # claiming every child no explicit rule matches.
278
- mappings += [xml_mapping.any_element_rule].compact if options[:mappings].nil?
282
+ mappings += [xml_mapping.any_element_rule].compact
279
283
  default_namespace = options[:default_namespace]
280
284
  ordered_option = options[:ordered]
281
285
  mixed_content_option = options[:mixed_content]
@@ -87,7 +87,14 @@ module Lutaml
87
87
 
88
88
  def self.generate(klass, options = {})
89
89
  register = extract_register_from(klass)
90
- xml_mapping = klass.mappings_for(:xml)
90
+ # A mapping profile (#176) generates the schema of its own
91
+ # projection; the base mapping remains the default.
92
+ xml_mapping = if options[:mapping]
93
+ klass.serialization_mapping_for(:xml,
94
+ options[:mapping])
95
+ else
96
+ klass.mappings_for(:xml)
97
+ end
91
98
 
92
99
  # Validate XSD types unless explicitly skipped
93
100
  validate_xsd_types!(klass, register) unless options[:skip_validation]
@@ -237,21 +244,29 @@ module Lutaml
237
244
  # Pattern 2: type_name only -> named complexType (no element)
238
245
  # Pattern 3: both element and type_name -> element + named complexType
239
246
 
247
+ # A mapping profile (#176) is a complete projection:
248
+ # attributes its mapping does not claim stay outside the
249
+ # generated schema.
250
+ skip_unmapped = !options[:mapping].nil?
251
+
240
252
  if element_name && type_name
241
253
  xs(xml, "element",
242
254
  { name: element_name,
243
255
  type: qualify_type_ref(type_name, target_prefix) })
244
256
  generate_complex_type(xml, klass, type_name, register,
245
- xml_mapping, ctx: ctx)
257
+ xml_mapping, ctx: ctx,
258
+ skip_unmapped: skip_unmapped)
246
259
  elsif type_name && !element_name
247
260
  generate_complex_type(xml, klass, type_name, register,
248
- xml_mapping, ctx: ctx)
261
+ xml_mapping, ctx: ctx,
262
+ skip_unmapped: skip_unmapped)
249
263
  else
250
264
  # Use class name as fallback element name if not specified
251
265
  elem_name = element_name || klass.name
252
266
  xs(xml, "element", { name: elem_name }) do
253
267
  generate_complex_type_content(xml, klass, register, xml_mapping,
254
- ctx: ctx)
268
+ ctx: ctx,
269
+ skip_unmapped: skip_unmapped)
255
270
  end
256
271
  end
257
272
 
@@ -491,11 +506,13 @@ module Lutaml
491
506
  attr.collection? && mapping&.type_name_value.nil?
492
507
  end
493
508
 
494
- def self.generate_complex_type_content(xml, klass, register, xml_mapping, ctx:)
509
+ def self.generate_complex_type_content(xml, klass, register,
510
+ xml_mapping, ctx:, skip_unmapped: false)
495
511
  xs(xml, "complexType") do
496
512
  if klass.attributes.any?
497
513
  xs(xml, "sequence") do
498
- generate_elements(xml, klass, register, xml_mapping, ctx: ctx)
514
+ generate_elements(xml, klass, register, xml_mapping, ctx: ctx,
515
+ skip_unmapped: skip_unmapped)
499
516
  end
500
517
  end
501
518
  if xml_mapping
@@ -505,23 +522,27 @@ module Lutaml
505
522
  end
506
523
  end
507
524
 
508
- def self.generate_complex_type(xml, klass, type_name, register, xml_mapping = nil, ctx:)
525
+ def self.generate_complex_type(xml, klass, type_name, register,
526
+ xml_mapping = nil, ctx:, skip_unmapped: false)
509
527
  xs(xml, "complexType", { name: type_name }) do
510
528
  if klass.attributes.any?
511
529
  xs(xml, "sequence") do
512
- generate_elements(xml, klass, register, xml_mapping, ctx: ctx)
530
+ generate_elements(xml, klass, register, xml_mapping, ctx: ctx,
531
+ skip_unmapped: skip_unmapped)
513
532
  end
514
533
  end
515
534
  generate_attributes(xml, klass, register, xml_mapping)
516
535
  end
517
536
  end
518
537
 
519
- def self.generate_elements(xml, klass, register, xml_mapping, ctx:)
538
+ def self.generate_elements(xml, klass, register, xml_mapping, ctx:,
539
+ skip_unmapped: false)
520
540
  klass.attributes.each do |name, attr|
521
541
  next if xml_mapping && attr_is_xml_attribute?(xml_mapping, name)
522
542
 
523
543
  # Find the mapping rule for this attribute
524
544
  mapping_rule = xml_mapping&.find_element(name)
545
+ next if skip_unmapped && xml_mapping && mapping_rule.nil?
525
546
 
526
547
  attr_type = attr.type(register)
527
548
 
@@ -28,8 +28,9 @@ module Lutaml
28
28
  # @param options [Hash] Transformation options
29
29
  # @return [::Lutaml::Xml::DataModel::XmlElement] The root XML element
30
30
  def transform(model_instance, options = {})
31
- # Get root element name from mapping
32
- mapping = model_class.mappings_for(:xml, register_id)
31
+ # Get root element name from mapping — an explicit mapping
32
+ # (#176 profiles) supplies its own root and root namespace.
33
+ mapping = options[:mappings] || model_class.mappings_for(:xml, register_id)
33
34
  root_name = options[:tag_name] ||
34
35
  mapping.root_element ||
35
36
  (model_class.name&.split("::")&.last || "anonymous")
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Compiled child-map walk plans (#986b): child_mappings rules ride the
6
+ # compiled dispatch (per-rule wire names, paths, and child type resolved
7
+ # once) instead of the per-node interpretive translate_mappings. Parity
8
+ # with the interpretive output is pinned here, including the
9
+ # keys-only merge rule (the merge tests the whole document hash, not
10
+ # each child) and the absent-key contract (the attribute stays
11
+ # uninitialized).
12
+ module ChildMapPlanSpec
13
+ class Schema < Lutaml::Model::Serializable
14
+ attribute :id, :string
15
+ attribute :path, :string
16
+ attribute :name, :string
17
+ end
18
+
19
+ class Schemas < Lutaml::Model::Serializable
20
+ attribute :type, :string
21
+ attribute :schemas, Schema, collection: true
22
+
23
+ key_value do
24
+ map "type", to: :type
25
+ map "schemas", to: :schemas,
26
+ child_mappings: {
27
+ id: :key,
28
+ path: %i[path link],
29
+ name: %i[path name],
30
+ }
31
+ end
32
+ end
33
+
34
+ class Entry < Lutaml::Model::Serializable
35
+ attribute :key, :string
36
+ attribute :value, :string
37
+ end
38
+
39
+ class EntryList < Lutaml::Model::Serializable
40
+ attribute :title, :string
41
+ attribute :entries, Entry, collection: true
42
+
43
+ key_value do
44
+ map "title", to: :title
45
+ map "entries", to: :entries,
46
+ child_mappings: {
47
+ key: :key,
48
+ value: %i[detail text],
49
+ }
50
+ end
51
+ end
52
+
53
+ class Tag < Lutaml::Model::Serializable
54
+ attribute :name, :string
55
+ attribute :count, :integer
56
+ end
57
+
58
+ class Tags < Lutaml::Model::Serializable
59
+ attribute :tags, Tag, collection: true
60
+
61
+ key_value do
62
+ map "tags", to: :tags,
63
+ child_mappings: {
64
+ name: :key,
65
+ count: :value,
66
+ }
67
+ end
68
+ end
69
+
70
+ class KeyedChild < Lutaml::Model::Serializable
71
+ attribute :id, :string
72
+ attribute :label, :string
73
+ attribute :extra, :hash
74
+
75
+ key_value do
76
+ map "id", to: :id
77
+ map "label", to: :label
78
+ map_unmapped to: :extra
79
+ end
80
+ end
81
+
82
+ class KeyOnly < Lutaml::Model::Serializable
83
+ attribute :items, KeyedChild, collection: true
84
+
85
+ key_value do
86
+ map "items", to: :items,
87
+ child_mappings: { id: :key }
88
+ end
89
+ end
90
+
91
+ RSpec.describe "compiled child_mappings walk plans" do
92
+ it "translates explicit-path child mappings into child models" do
93
+ model = Schemas.from_json(
94
+ '{"type":"object","schemas":{' \
95
+ '"sch1":{"path":{"link":"https://one","name":"one"}},' \
96
+ '"sch2":{"path":{"link":"https://two","name":"two"}}}}',
97
+ )
98
+
99
+ expect(model.schemas.map(&:id)).to eq(%w[sch1 sch2])
100
+ expect(model.schemas.first.path).to eq("https://one")
101
+ expect(model.schemas.last.name).to eq("two")
102
+ end
103
+
104
+ it "round-trips the translated wire form" do
105
+ json = '{"type":"object","schemas":{' \
106
+ '"sch1":{"path":{"link":"https://one","name":"one"}}}}'
107
+ model = Schemas.from_json(json)
108
+
109
+ expect(model.to_json).to eq(json)
110
+ expect(model.to_json).to eq(json) # warm-plan second pass
111
+ end
112
+
113
+ it "translates value paths" do
114
+ model = EntryList.from_json(
115
+ '{"title":"list","entries":{' \
116
+ '"e1":{"detail":{"text":"first"}},' \
117
+ '"e2":{"detail":{"text":"second"}}}}',
118
+ )
119
+
120
+ expect(model.entries.map(&:key)).to eq(%w[e1 e2])
121
+ expect(model.entries.first.value).to eq("first")
122
+ end
123
+
124
+ it "casts values through the child attribute types" do
125
+ model = Tags.from_json('{"tags":{"red":3,"blue":5}}')
126
+
127
+ expect(model.tags.map(&:name)).to eq(%w[red blue])
128
+ expect(model.tags.map(&:count)).to eq([3, 5])
129
+ end
130
+
131
+ it "keeps the attribute uninitialized when the key is absent" do
132
+ model = Schemas.from_json('{"type":"object"}')
133
+
134
+ expect(model.type).to eq("object")
135
+ expect(model.schemas).to be_nil
136
+ end
137
+
138
+ it "handles an empty mapping value" do
139
+ model = Schemas.from_json('{"type":"object","schemas":{}}')
140
+
141
+ expect(model.schemas).to eq([])
142
+ end
143
+
144
+ it "merges unmapped keys only when every value in the hash is a hash" do
145
+ model = KeyOnly.from_json(
146
+ '{"items":{"a":{"label":"A","extra":1},"b":{"label":"B"}}}',
147
+ )
148
+
149
+ expect(model.items.map(&:id)).to eq(%w[a b])
150
+ expect(model.items.first.label).to eq("A")
151
+ expect(model.items.first.extra).to eq("extra" => 1)
152
+ end
153
+
154
+ it "skips the merge when any value in the hash is not a hash" do
155
+ model = KeyOnly.from_json('{"items":{"a":"plain","b":{"label":"B"}}}')
156
+
157
+ expect(model.items.first.id).to eq("a")
158
+ expect(model.items.first.extra).to be_nil
159
+ expect(model.items.last.id).to eq("b")
160
+ expect(model.items.last.label).to be_nil
161
+ end
162
+
163
+ it "serializes a keyed collection through the compiled plan" do
164
+ model = Tags.from_json('{"tags":{"red":3}}')
165
+
166
+ expect(model.to_json).to eq('{"tags":{"red":3}}')
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # The NIST Metaschema simple data types (#107), per
6
+ # metaschema-datatypes.xsd and the sample values on
7
+ # pages.nist.gov/metaschema/specification/datatypes/.
8
+ RSpec.describe "NIST Metaschema simple data types" do
9
+ describe Lutaml::Model::Type::EmailAddress do
10
+ it "accepts addresses with an at-sign" do
11
+ expect(described_class.valid?("mailbox@example.com")).to be(true)
12
+ expect(described_class.cast("mailbox@example.com")).to eq("mailbox@example.com")
13
+ end
14
+
15
+ it "rejects strings without an at-sign" do
16
+ expect(described_class.valid?("mailbox.example.com")).to be(false)
17
+ expect(described_class.valid?("")).to be(false)
18
+ end
19
+ end
20
+
21
+ describe Lutaml::Model::Type::Hostname do
22
+ it "accepts non-empty trimmed strings" do
23
+ expect(described_class.valid?("example.com")).to be(true)
24
+ expect(described_class.valid?("localhost")).to be(true)
25
+ end
26
+
27
+ it "rejects empty and outer-whitespace values" do
28
+ expect(described_class.valid?("")).to be(false)
29
+ expect(described_class.valid?(" example.com ")).to be(false)
30
+ end
31
+ end
32
+
33
+ describe Lutaml::Model::Type::IpV4Address do
34
+ it "accepts dotted-quad addresses" do
35
+ expect(described_class.valid?("192.168.0.1")).to be(true)
36
+ expect(described_class.valid?("0.0.0.0")).to be(true)
37
+ expect(described_class.valid?("255.255.255.255")).to be(true)
38
+ end
39
+
40
+ it "rejects out-of-range octets and malformed forms" do
41
+ expect(described_class.valid?("256.0.0.1")).to be(false)
42
+ expect(described_class.valid?("192.168.0")).to be(false)
43
+ expect(described_class.valid?("192.168.0.1.5")).to be(false)
44
+ expect(described_class.valid?("example.com")).to be(false)
45
+ end
46
+ end
47
+
48
+ describe Lutaml::Model::Type::IpV6Address do
49
+ it "accepts full and compressed forms" do
50
+ expect(described_class.valid?("2001:db8:0:0:0:0:2:1")).to be(true)
51
+ expect(described_class.valid?("2001:db8::2:1")).to be(true)
52
+ expect(described_class.valid?("::1")).to be(true)
53
+ expect(described_class.valid?("::")).to be(true)
54
+ expect(described_class.valid?("fe80::1%eth0")).to be(true)
55
+ expect(described_class.valid?("::ffff:192.168.0.1")).to be(true)
56
+ end
57
+
58
+ it "rejects malformed forms" do
59
+ expect(described_class.valid?("2001:db8:::1")).to be(false)
60
+ expect(described_class.valid?("192.168.0.1")).to be(false)
61
+ expect(described_class.valid?("2001:db8:0:0:0:0:2")).to be(false)
62
+ end
63
+ end
64
+
65
+ describe Lutaml::Model::Type::Token do
66
+ it "accepts letters, digits, underscore and .-_ runs" do
67
+ expect(described_class.valid?("simple")).to be(true)
68
+ expect(described_class.valid?("_private.v2-final")).to be(true)
69
+ expect(described_class.valid?("label3")).to be(true)
70
+ end
71
+
72
+ it "rejects leading digits, spaces, and empty values" do
73
+ expect(described_class.valid?("3rd")).to be(false)
74
+ expect(described_class.valid?("with space")).to be(false)
75
+ expect(described_class.valid?("")).to be(false)
76
+ end
77
+ end
78
+
79
+ describe Lutaml::Model::Type::UriReference do
80
+ it "accepts relative and absolute references" do
81
+ expect(described_class.valid?("#example")).to be(true)
82
+ expect(described_class.valid?("relative/path")).to be(true)
83
+ expect(described_class.valid?("https://example.com/x")).to be(true)
84
+ end
85
+
86
+ it "rejects empty and whitespace-padded values" do
87
+ expect(described_class.valid?("")).to be(false)
88
+ expect(described_class.valid?(" no ")).to be(false)
89
+ end
90
+ end
91
+
92
+ describe Lutaml::Model::Type::Uuid do
93
+ it "accepts version 4 and 5 UUIDs" do
94
+ expect(described_class.valid?("a3bb189e-8bf9-4888-9912-ace4e6543002")).to be(true)
95
+ expect(described_class.valid?("5c0da4c0-6d4d-5e21-b0cd-3a5f9b1c9d01")).to be(true)
96
+ end
97
+
98
+ it "rejects other versions and malformed values" do
99
+ expect(described_class.valid?("a3bb189e-8bf9-1888-9912-ace4e6543002")).to be(false)
100
+ expect(described_class.valid?("a3bb189e-8bf9-4888-9912ace4e6543002")).to be(false)
101
+ expect(described_class.valid?("not-a-uuid")).to be(false)
102
+ end
103
+ end
104
+
105
+ describe Lutaml::Model::Type::NonNegativeInteger do
106
+ it "accepts zero and positive integers" do
107
+ expect(described_class.valid?(0)).to be(true)
108
+ expect(described_class.valid?("42")).to be(true)
109
+ expect(described_class.cast("42")).to eq(42)
110
+ end
111
+
112
+ it "rejects negative values and non-integers" do
113
+ expect(described_class.valid?(-1)).to be(false)
114
+ expect(described_class.valid?("abc")).to be(false)
115
+ end
116
+ end
117
+
118
+ describe Lutaml::Model::Type::PositiveInteger do
119
+ it "accepts integers of one and above" do
120
+ expect(described_class.valid?(1)).to be(true)
121
+ expect(described_class.valid?("7")).to be(true)
122
+ end
123
+
124
+ it "rejects zero, negatives, and non-integers" do
125
+ expect(described_class.valid?(0)).to be(false)
126
+ expect(described_class.valid?(-3)).to be(false)
127
+ expect(described_class.valid?("abc")).to be(false)
128
+ end
129
+ end
130
+
131
+ describe Lutaml::Model::Type::DateWithTimezone do
132
+ it "accepts dates with Z or an offset, per the page samples" do
133
+ expect(described_class.valid?("2019-09-28Z")).to be(true)
134
+ expect(described_class.valid?("2019-12-02-08:00")).to be(true)
135
+ end
136
+
137
+ it "rejects dates without a timezone" do
138
+ expect(described_class.valid?("2019-09-28")).to be(false)
139
+ end
140
+ end
141
+
142
+ describe Lutaml::Model::Type::DateTimeWithTimezone do
143
+ it "accepts date-times with Z or an offset, per the page samples" do
144
+ expect(described_class.valid?("2019-09-28T23:20:50.52Z")).to be(true)
145
+ expect(described_class.valid?("2019-12-02T16:39:57-08:00")).to be(true)
146
+ expect(described_class.valid?("2019-12-31T23:59:59Z")).to be(true)
147
+ end
148
+
149
+ it "rejects date-times without a timezone" do
150
+ expect(described_class.valid?("2019-09-28T23:20:50.52")).to be(false)
151
+ end
152
+ end
153
+
154
+ describe Lutaml::Model::Type::DayTimeDuration do
155
+ it "accepts day and time durations, per the page samples" do
156
+ expect(described_class.valid?("P1DT12H45M")).to be(true)
157
+ expect(described_class.valid?("-PT3H")).to be(true)
158
+ expect(described_class.valid?("PT0S")).to be(true)
159
+ expect(described_class.valid?("P3D")).to be(true)
160
+ expect(described_class.valid?("PT2M30.5S")).to be(true)
161
+ end
162
+
163
+ it "rejects year and month components" do
164
+ expect(described_class.valid?("P1Y6M")).to be(false)
165
+ expect(described_class.valid?("P1DT")).to be(false)
166
+ end
167
+ end
168
+
169
+ describe Lutaml::Model::Type::YearMonthDuration do
170
+ it "accepts year and month durations, per the page samples" do
171
+ expect(described_class.valid?("P1Y6M")).to be(true)
172
+ expect(described_class.valid?("-P9M")).to be(true)
173
+ expect(described_class.valid?("P10M")).to be(true)
174
+ expect(described_class.valid?("P2Y")).to be(true)
175
+ end
176
+
177
+ it "rejects time components and bare days" do
178
+ expect(described_class.valid?("P1DT12H")).to be(false)
179
+ expect(described_class.valid?("P3D")).to be(false)
180
+ end
181
+ end
182
+
183
+ it "round-trips a metaschema-typed model through JSON" do
184
+ stub_const("MetaNode", Class.new(Lutaml::Model::Serializable) do
185
+ attribute :id, :uuid
186
+ attribute :contact, :email_address
187
+ attribute :address, :ip_v4_address
188
+ attribute :retries, :non_negative_integer
189
+
190
+ json do
191
+ map "id", to: :id
192
+ map "contact", to: :contact
193
+ map "address", to: :address
194
+ map "retries", to: :retries
195
+ end
196
+ end)
197
+
198
+ model = MetaNode.from_json(
199
+ '{"id":"a3bb189e-8bf9-4888-9912-ace4e6543002",' \
200
+ '"contact":"ops@example.com","address":"10.0.0.7","retries":"3"}',
201
+ )
202
+
203
+ expect(model.id).to eq("a3bb189e-8bf9-4888-9912-ace4e6543002")
204
+ expect(model.contact).to eq("ops@example.com")
205
+ expect(model.address).to eq("10.0.0.7")
206
+ expect(model.retries).to eq(3)
207
+ expect(model.to_json).to eq(
208
+ '{"id":"a3bb189e-8bf9-4888-9912-ace4e6543002",' \
209
+ '"contact":"ops@example.com","address":"10.0.0.7","retries":3}',
210
+ )
211
+ end
212
+
213
+ it "declares the metaschema XSD bases for schema generation" do
214
+ expect(Lutaml::Model::Type::NonNegativeInteger.default_xsd_type)
215
+ .to eq("xs:nonNegativeInteger")
216
+ expect(Lutaml::Model::Type::PositiveInteger.default_xsd_type)
217
+ .to eq("xs:positiveInteger")
218
+ expect(Lutaml::Model::Type::DayTimeDuration.default_xsd_type)
219
+ .to eq("xs:dayTimeDuration")
220
+ expect(Lutaml::Model::Type::YearMonthDuration.default_xsd_type)
221
+ .to eq("xs:yearMonthDuration")
222
+ expect(Lutaml::Model::Type::Uuid.default_xsd_type).to eq("xs:string")
223
+ end
224
+
225
+ describe "wiring into validation and schema emission" do
226
+ it "declares the patterns and bounds as Layer-2 facets" do
227
+ expect(Lutaml::Model::Type::Uuid.facets[:pattern])
228
+ .to include(Lutaml::Model::Type::Uuid::PATTERN)
229
+ expect(Lutaml::Model::Type::NonNegativeInteger.facets[:min_inclusive])
230
+ .to eq(0)
231
+ expect(Lutaml::Model::Type::PositiveInteger.facets[:min_inclusive])
232
+ .to eq(1)
233
+ end
234
+
235
+ it "validates metaschema-typed attributes through model validation" do
236
+ stub_const("WiredNode", Class.new(Lutaml::Model::Serializable) do
237
+ attribute :id, :uuid
238
+ attribute :contact, :email_address
239
+ attribute :retries, :positive_integer
240
+
241
+ json do
242
+ map "id", to: :id
243
+ map "contact", to: :contact
244
+ map "retries", to: :retries
245
+ end
246
+ end)
247
+
248
+ good = WiredNode.new(id: "a3bb189e-8bf9-4888-9912-ace4e6543002",
249
+ contact: "ops@example.com", retries: 2)
250
+ expect(good.validate).to be_empty
251
+
252
+ bad = WiredNode.new(id: "not-a-uuid", contact: "no-at-sign", retries: 0)
253
+ messages = bad.validate.map(&:message)
254
+
255
+ expect(messages).to all(be_a(String))
256
+ expect(messages.join("\n")).to include("id")
257
+ expect(messages.join("\n")).to include("contact")
258
+ expect(messages.join("\n")).to include("retries")
259
+ end
260
+
261
+ it "exports the metaschema restrictions in generated XSD" do
262
+ stub_const("XsdNode", Class.new(Lutaml::Model::Serializable) do
263
+ attribute :id, :uuid
264
+ attribute :contact, :email_address
265
+ attribute :retries, :positive_integer
266
+
267
+ xml do
268
+ element "node"
269
+ map_attribute "id", to: :id
270
+ map_element "contact", to: :contact
271
+ map_element "retries", to: :retries
272
+ end
273
+ end)
274
+
275
+ xsd = Lutaml::Model::Schema.to_xsd(XsdNode)
276
+
277
+ expect(xsd).to include("[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[45]")
278
+ expect(xsd).to include(".+@.+")
279
+ expect(xsd).to include('minInclusive value="1"')
280
+ end
281
+
282
+ it "exports the NIST JSON representations in generated JSON and YAML schemas" do
283
+ stub_const("JsonWiredNode", Class.new(Lutaml::Model::Serializable) do
284
+ attribute :id, :uuid
285
+ attribute :contact, :email_address
286
+ attribute :address, :ip_v4_address
287
+ attribute :retries, :positive_integer
288
+ attribute :term, :year_month_duration
289
+
290
+ json do
291
+ map "id", to: :id
292
+ map "contact", to: :contact
293
+ map "address", to: :address
294
+ map "retries", to: :retries
295
+ map "term", to: :term
296
+ end
297
+ end)
298
+
299
+ properties = JSON.parse(Lutaml::Model::Schema.to_json(JsonWiredNode))
300
+ .fetch("$defs").fetch("JsonWiredNode").fetch("properties")
301
+
302
+ expect(properties.fetch("id").fetch("pattern"))
303
+ .to eq("^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[45][0-9A-Fa-f]{3}-" \
304
+ "[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$")
305
+ expect(properties.fetch("contact")).to include("format" => "email")
306
+ expect(properties.fetch("address")).to include("format" => "ipv4")
307
+ expect(Array(properties.fetch("retries").fetch("type")))
308
+ .to include("integer")
309
+ expect(properties.fetch("retries")).to include("minimum" => 1)
310
+ expect(properties.fetch("term")).to include("format" => "duration")
311
+
312
+ yaml_properties = YAML.safe_load(
313
+ Lutaml::Model::Schema.to_yaml(JsonWiredNode),
314
+ ).fetch("$defs").fetch("JsonWiredNode").fetch("properties")
315
+
316
+ expect(yaml_properties.fetch("id")["pattern"]).to include("[45]")
317
+ expect(yaml_properties.fetch("retries")["minimum"]).to eq(1)
318
+ end
319
+
320
+ it "round-trips metaschema-typed models through every format" do
321
+ stub_const("FormatNode", Class.new(Lutaml::Model::Serializable) do
322
+ attribute :id, :uuid
323
+ attribute :host, :hostname
324
+ attribute :address, :ip_v4_address
325
+ attribute :renewal, :year_month_duration
326
+
327
+ xml do
328
+ element "node"
329
+ map_attribute "id", to: :id
330
+ map_element "host", to: :host
331
+ map_element "address", to: :address
332
+ map_element "renewal", to: :renewal
333
+ end
334
+
335
+ json do
336
+ map "id", to: :id
337
+ map "host", to: :host
338
+ map "address", to: :address
339
+ map "renewal", to: :renewal
340
+ end
341
+ end)
342
+
343
+ node = FormatNode.new(id: "a3bb189e-8bf9-4888-9912-ace4e6543002",
344
+ host: "example.com", address: "10.0.0.7",
345
+ renewal: "P1Y6M")
346
+
347
+ xml_round_trip = FormatNode.from_xml(node.to_xml)
348
+ expect(xml_round_trip).to eq(node)
349
+
350
+ json_round_trip = FormatNode.from_json(node.to_json)
351
+ expect(json_round_trip).to eq(node)
352
+
353
+ yaml_round_trip = FormatNode.from_yaml(node.to_yaml)
354
+ expect(yaml_round_trip).to eq(node)
355
+
356
+ toml_round_trip = FormatNode.from_toml(node.to_toml)
357
+ expect(toml_round_trip).to eq(node)
358
+ end
359
+
360
+ it "keeps the metaschema pattern sources export-clean" do
361
+ [Lutaml::Model::Type::IpV6Address,
362
+ Lutaml::Model::Type::DayTimeDuration].each do |type|
363
+ expect(type::PATTERN.source).not_to match(/\s/)
364
+ end
365
+ end
366
+ end
367
+ end