lutaml-model 0.8.127 → 0.8.129

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: e11de7c2a53b5c720a4d8066af0a1b0bfef2107e6c853bfb441d478b32c0d794
4
- data.tar.gz: 6f8e67b8df0ab6ee2635b3b2c83a7dc86332e7883d951df45f8422f303b1ca61
3
+ metadata.gz: 6903f41ec929cb64ce2450b6979f832e0a832f8afbbe82a134af3db157d2fdd8
4
+ data.tar.gz: 3d2f61efc2e609ade7d440994d318c1b190255c243f33ec96538de2a4cda5774
5
5
  SHA512:
6
- metadata.gz: 447cbac13172eab2c4fcfa38f03f6777a164dfd31108e9aa3ce46ca8eed631863c9bd584f88a83dae005d80836c6be5ee4ff046676a77492a8c52f509b2cb4e8
7
- data.tar.gz: 7b13afe6b4d08b89b4e79143599ac90da0fb6536583f8e315e829f66a6579e6fd22ed8aa87acc46359e06741eada456f7e5ee97db897df5e172fbcb4c43705db
6
+ metadata.gz: 7f82b33a1e8ac555aa6c756007da45a99dc3e506fd58c1f780446c112397314b3a76b0978566dc3c3279651e76701f0537b87dd1c700accaeb77f789497e20dc
7
+ data.tar.gz: 1100e41da62097636496a5fe31d1ac6a77f55ac491a8a3313eb96dfa4ceda55c52ba92166eda902f07bceae1ace17fd2224c2190c9b9792c5ca76ff4ce29293f
data/README.adoc CHANGED
@@ -857,8 +857,16 @@ Every type has a corresponding Ruby class and a serialization format type.
857
857
 
858
858
  The NIST Metaschema simple data types are supported as first-class value
859
859
  types (https://pages.nist.gov/metaschema/specification/datatypes/). Each
860
- type carries the normative metaschema-datatypes pattern and answers
861
- `valid?` for a value; casting follows the lenient base-type behavior.
860
+ type carries the normative metaschema-datatypes pattern, answers `valid?`
861
+ for a value, and declares it as a restriction facet: attributes typed
862
+ with these types are validated by `model.validate`, and schema
863
+ generation emits the pattern/bounds as restriction facets in every
864
+ supported schema language — `xs:pattern`/`xs:minInclusive` in generated
865
+ XSD, `pattern`/`minimum` plus the NIST `format` keywords (`ipv4`,
866
+ `ipv6`, `email`, `uri-reference`, `duration`, `date`, `date-time`) in
867
+ generated JSON and YAML schemas. Casting follows the lenient
868
+ base-type behavior; TOML has no schema language, so its surface for
869
+ these types is the cast/serialize lane.
862
870
 
863
871
  .Source types
864
872
  [cols="3,4,2"]
@@ -35,6 +35,19 @@ Every type has a corresponding Ruby class and a serialization format type.
35
35
  | `:qname` | `Lutaml::Model::Type::QName` | `xs:QName` | `string` | `string` | `"prefix:localName"`
36
36
  | `:base64_binary` | `String` | `xs:base64Binary` | `string` | `string` | `"SGVsbG8gV29ybGQ="`
37
37
  | `:hex_binary` | `String` | `xs:hexBinary` | `string` | `string` | `"48656c6c6f"`
38
+ | `:email_address` | `String` | `xs:string` | `string` | `string` | `"user@example.com"`
39
+ | `:hostname` | `String` | `xs:string` | `string` | `string` | `"example.com"`
40
+ | `:ip_v4_address` | `String` | `xs:string` | `string` | `string` | `"192.168.0.1"`
41
+ | `:ip_v6_address` | `String` | `xs:string` | `string` | `string` | `"2001:db8::2:1"`
42
+ | `:token` | `String` | `xs:string` | `string` | `string` | `"label_3.v2"`
43
+ | `:uri_reference` | `String` | `xs:anyURI` | `string` | `string` | `"path/to/resource"`
44
+ | `:uuid` | `String` | `xs:string` | `string` | `string` | `"a3bb189e-8bf9-4888-9912-ace4e6543002"`
45
+ | `:non_negative_integer` | `Integer` | `xs:nonNegativeInteger` | `number` | `integer` | `7`
46
+ | `:positive_integer` | `Integer` | `xs:positiveInteger` | `number` | `integer` | `7`
47
+ | `:date_with_timezone` | `Date`/`DateTime` | `xs:date` | `string` | `string` | `"2019-12-02-08:00"`
48
+ | `:date_time_with_timezone` | `DateTime` | `xs:dateTime` | `string` | `string` | `"2019-12-02T16:39:57-08:00"`
49
+ | `:day_time_duration` | `Lutaml::Model::Type::Duration` | `xs:dayTimeDuration` | `string` | `string` | `"P1DT12H45M"`
50
+ | `:year_month_duration` | `Lutaml::Model::Type::Duration` | `xs:yearMonthDuration` | `string` | `string` | `"P1Y6M"`
38
51
  | `:hash` | `Hash` | complex element | object | map | `{key: "value"}`
39
52
  | (nil value) | `nil` | `xs:anyType` | `null` | `null` | `null`
40
53
  | `{ ref: [Model, :attr] }` | `Reference` | `xs:string` | `string` | `string` | `"model-id"`
@@ -120,6 +133,56 @@ puts event.start_date.class
120
133
 
121
134
 
122
135
  [[xml-namespace-types]]
136
+ === NIST Metaschema types
137
+
138
+ The NIST Metaschema simple data types are supported as first-class value
139
+ types (https://pages.nist.gov/metaschema/specification/datatypes/). Each
140
+ type carries the normative metaschema-datatypes pattern, answers `valid?`
141
+ for a value, and declares it as a restriction facet: attributes typed
142
+ with these types are validated by `model.validate`, and schema
143
+ generation emits the pattern/bounds as restriction facets in every
144
+ supported schema language — `xs:pattern`/`xs:minInclusive` in generated
145
+ XSD, `pattern`/`minimum` plus the NIST `format` keywords (`ipv4`,
146
+ `ipv6`, `email`, `uri-reference`, `duration`, `date`, `date-time`) in
147
+ generated JSON and YAML schemas. Casting follows the lenient
148
+ base-type behavior; TOML has no schema language, so its surface for
149
+ these types is the cast/serialize lane.
150
+
151
+ .Metaschema type codes and their restrictions
152
+ [cols="3,4,2"]
153
+ |===
154
+ | Type code | Metaschema datatype | Restriction
155
+
156
+ | `:email_address` | `email-address` | contains an at-sign
157
+ | `:hostname` | `hostname` | non-empty, trimmed
158
+ | `:ip_v4_address` | `ip-v4-address` | dotted-quad IPv4
159
+ | `:ip_v6_address` | `ip-v6-address` | RFC3513 IPv6 (incl. zones, embedded IPv4)
160
+ | `:token` | `token` | NMTOKEN-like letter/number/punctuation run
161
+ | `:uri_reference` | `uri-reference` | relative or absolute URI reference
162
+ | `:uuid` | `uuid` | RFC4122 UUID, version 4 or 5
163
+ | `:non_negative_integer` | `non-negative-integer` | integer >= 0
164
+ | `:positive_integer` | `positive-integer` | integer >= 1
165
+ | `:date_with_timezone` | `date-with-timezone` | date with `Z` or offset
166
+ | `:date_time_with_timezone` | `date-time-with-timezone` | date-time with `Z` or offset
167
+ | `:day_time_duration` | `day-time-duration` | duration without years/months
168
+ | `:year_month_duration` | `year-month-duration` | duration without time components
169
+ |===
170
+
171
+ .Using metaschema type attributes
172
+ [example]
173
+ ====
174
+ [source,ruby]
175
+ ----
176
+ class Asset < Lutaml::Model::Serializable
177
+ attribute :id, :uuid
178
+ attribute :owner, :email_address
179
+ attribute :host, :hostname
180
+ attribute :address, :ip_v4_address
181
+ attribute :renewal, :year_month_duration
182
+ end
183
+ ----
184
+ ====
185
+
123
186
  === Additional XSD types
124
187
 
125
188
  Lutaml::Model supports additional XSD types for specialized data handling:
@@ -1325,6 +1388,45 @@ All built-in types have default XSD type mappings:
1325
1388
  | [`Type::HexBinary`](lib/lutaml/model/type/hex_binary.rb)
1326
1389
  | `xs:hexBinary`
1327
1390
 
1391
+ | [`Type::EmailAddress`](lib/lutaml/model/type/email_address.rb)
1392
+ | `xs:string`
1393
+
1394
+ | [`Type::Hostname`](lib/lutaml/model/type/hostname.rb)
1395
+ | `xs:string`
1396
+
1397
+ | [`Type::IpV4Address`](lib/lutaml/model/type/ip_v4_address.rb)
1398
+ | `xs:string`
1399
+
1400
+ | [`Type::IpV6Address`](lib/lutaml/model/type/ip_v6_address.rb)
1401
+ | `xs:string`
1402
+
1403
+ | [`Type::Token`](lib/lutaml/model/type/token.rb)
1404
+ | `xs:string`
1405
+
1406
+ | [`Type::UriReference`](lib/lutaml/model/type/uri_reference.rb)
1407
+ | `xs:anyURI`
1408
+
1409
+ | [`Type::Uuid`](lib/lutaml/model/type/uuid.rb)
1410
+ | `xs:string`
1411
+
1412
+ | [`Type::NonNegativeInteger`](lib/lutaml/model/type/non_negative_integer.rb)
1413
+ | `xs:nonNegativeInteger`
1414
+
1415
+ | [`Type::PositiveInteger`](lib/lutaml/model/type/positive_integer.rb)
1416
+ | `xs:positiveInteger`
1417
+
1418
+ | [`Type::DateWithTimezone`](lib/lutaml/model/type/date_with_timezone.rb)
1419
+ | `xs:date`
1420
+
1421
+ | [`Type::DateTimeWithTimezone`](lib/lutaml/model/type/date_time_with_timezone.rb)
1422
+ | `xs:dateTime`
1423
+
1424
+ | [`Type::DayTimeDuration`](lib/lutaml/model/type/day_time_duration.rb)
1425
+ | `xs:dayTimeDuration`
1426
+
1427
+ | [`Type::YearMonthDuration`](lib/lutaml/model/type/year_month_duration.rb)
1428
+ | `xs:yearMonthDuration`
1429
+
1328
1430
  | [`Type::Hash`](lib/lutaml/model/type/hash.rb)
1329
1431
  | `xs:anyType`
1330
1432
 
@@ -215,18 +215,26 @@ module Lutaml
215
215
 
216
216
  mapping = model_class.mappings_for(format, register)
217
217
  return false if mapping.nil?
218
- # The catch-all collect runs on the interpretive walk only —
219
- # the compiled hydration must not skip unknown keys silently.
220
- return false if mapping.unmapped_attribute
221
218
 
222
219
  attrs = model_class.attributes(register)
220
+ # The catch-all compiles as a row: unclaimed document keys are
221
+ # collected into the target attribute after the rules hydrate,
222
+ # mirroring the interpretive collect's position and merge. A
223
+ # typo'd target raises here, exactly as the walk raises at
224
+ # first parse.
225
+ unmapped_target = mapping.unmapped_attribute
226
+ if unmapped_target && !attrs.key?(unmapped_target)
227
+ raise ArgumentError,
228
+ "map_unmapped target :#{unmapped_target} is not a " \
229
+ "declared attribute of #{model_class}"
230
+ end
223
231
  rows = nil
232
+ claimed = nil
224
233
  mapping.mappings(register).each do |rule|
225
234
  eligible = !rule.name.nil? && !rule.multiple_mappings? &&
226
235
  rule.delegate.nil? &&
227
236
  !rule.has_custom_method_for_deserialization? &&
228
237
  !rule.raw_mapping? && !rule.root_mapping? &&
229
- !rule.hash_mappings && rule.child_mappings.nil? &&
230
238
  rule.when_attribute.empty? &&
231
239
  !(if rule.polymorphic.is_a?(::Hash)
232
240
  !rule.polymorphic.empty?
@@ -252,6 +260,24 @@ module Lutaml
252
260
  return false if Lutaml::Model::GlobalContext.context(register)
253
261
  .substitution_for(type).any?
254
262
 
263
+ if rule.hash_mappings
264
+ # #1003: compiled child-map row. The compiled ChildMapPlan
265
+ # reproduces translate_mappings' per-child lookups; shapes
266
+ # the compile cannot cover (unmappable child attribute,
267
+ # singular target, non-model child type) keep the whole
268
+ # model interpretive.
269
+ return false unless attr.collection? &&
270
+ type.is_a?(Class) && type.include?(Lutaml::Model::Serialize)
271
+
272
+ child_plan = compile_group_child_plan(type, format, register,
273
+ rule.hash_mappings)
274
+ return false unless child_plan
275
+
276
+ rows ||= []
277
+ rows << [rule, attr, :child_map, child_plan, nil, nil]
278
+ next
279
+ end
280
+
255
281
  if type.is_a?(Class) && type.include?(Lutaml::Model::Serialize)
256
282
  child_register = Lutaml::Model::Register
257
283
  .resolve_for_child(type, register)
@@ -272,10 +298,45 @@ module Lutaml
272
298
  return false
273
299
  end
274
300
  end
301
+ if unmapped_target
302
+ claimed = {}
303
+ mapping.mappings(register).each do |rule|
304
+ Array(rule.name).each { |name| claimed[name.to_s] = true } if rule.name
305
+ end
306
+ claimed.freeze
307
+ (rows ||= []) << [nil, nil, :unmapped, unmapped_target, claimed,
308
+ nil]
309
+ end
275
310
  # No eligible rows at all (empty mapping) — nothing to gain.
276
311
  rows
277
312
  end
278
313
 
314
+ # Class-level twin of the walk's child-map compile: the registry
315
+ # lookups (child rules, per-attribute wire names, path casts)
316
+ # resolve once per plan instead of once per node per child. nil
317
+ # keeps the model interpretive (preserving the walk's raise
318
+ # timing for declaration errors).
319
+ def self.compile_group_child_plan(child_class, format, register,
320
+ child_mappings)
321
+ child_rules = child_class.mappings_for(format, register)
322
+ wire_names = {}
323
+ paths = {}
324
+ child_mappings.each do |attr_name, path|
325
+ attr_rule = child_rules.find_by_to!(attr_name)
326
+ wire_names[attr_name] = attr_rule.from.to_s
327
+ paths[attr_name] = case path
328
+ when :key then :key
329
+ when :value then :value
330
+ else Array(path).map(&:to_s).freeze
331
+ end
332
+ end
333
+ ChildMapPlan.new(child_mappings, wire_names, paths,
334
+ child_mappings.values == [:key], child_rules,
335
+ child_class)
336
+ rescue Lutaml::Model::NoMappingFoundError
337
+ nil
338
+ end
339
+
279
340
  # Build values bottom-up, then ONE instance per model: present
280
341
  # keys through the casting setters, ABSENT rules through the real
281
342
  # per-rule walk (their extractor/defaults/sentinel semantics are
@@ -289,7 +350,13 @@ module Lutaml
289
350
  # Lazy: the present-key hot path never pushes, and this runs
290
351
  # once per hydrated model instance.
291
352
  absent = nil
353
+ unmapped = nil
292
354
  rows.each do |rule, attr, kind, type, child_rows, child_register|
355
+ if kind == :unmapped
356
+ unmapped = [type, child_rows]
357
+ next
358
+ end
359
+
293
360
  # One hash pass per row: fetch with a sentinel distinguishes
294
361
  # absent keys from present-nil values (both read as nil), so
295
362
  # the separate string_or_symbol_key? probe goes away.
@@ -299,6 +366,33 @@ module Lutaml
299
366
  next
300
367
  end
301
368
 
369
+ if kind == :child_map
370
+ # Compiled translate_mappings (#1003): per-child lookups
371
+ # are plan constants. Children hydrate through their own
372
+ # compiled walk (the mappings: option routes them there);
373
+ # parent/root wiring rides the shared children pass. A
374
+ # non-Hash value falls back to the walk, which owns that
375
+ # error.
376
+ return nil unless v.is_a?(::Hash)
377
+
378
+ built = []
379
+ keys_only = type.keys_only && v.values.all?(::Hash)
380
+ v.each do |child_key, child_value|
381
+ child_hash = {}
382
+ type.child_mappings.each_key do |attr_name|
383
+ child_hash[type.wire_names[attr_name]] =
384
+ extract_compiled_child_value(type.paths[attr_name],
385
+ child_key, child_value)
386
+ end
387
+ child_hash.update(child_value) if keys_only
388
+ built << self.class.data_to_model(type.attr_type,
389
+ child_hash, format, { mappings: type.child_rules })
390
+ end
391
+ setters << [rule.kv_setter, built]
392
+ children.concat(built)
393
+ next
394
+ end
395
+
302
396
  if kind == :scalar
303
397
  setters << [rule.kv_setter, v]
304
398
  next
@@ -345,6 +439,20 @@ module Lutaml
345
439
  child.lutaml_parent = instance
346
440
  child.lutaml_root ||= instance.lutaml_root || instance
347
441
  end
442
+ if unmapped
443
+ target, claimed = unmapped
444
+ extras = nil
445
+ doc.each do |key, value|
446
+ next if claimed.key?(key.to_s)
447
+
448
+ (extras ||= {})[key] = value
449
+ end
450
+ if extras
451
+ existing = instance.public_send(target)
452
+ merged = existing.is_a?(::Hash) ? existing.merge(extras) : extras
453
+ instance.public_send(:"#{target}=", merged)
454
+ end
455
+ end
348
456
  instance
349
457
  end
350
458
 
@@ -96,8 +96,35 @@ module Lutaml
96
96
  def get_constraints(attr)
97
97
  constraints = {}
98
98
 
99
- # Add pattern validation
100
- constraints["pattern"] = attr.pattern.source if attr.pattern
99
+ # Add pattern validation (attribute option conjunctive with the
100
+ # resolved type's restriction facets; a single JSON Schema
101
+ # pattern cannot express conjunction, mirroring the XSD emitter)
102
+ patterns = constraint_patterns(attr)
103
+ if patterns.size > 1
104
+ raise Lutaml::Model::Error,
105
+ "Cannot export #{patterns.size} conjunctive patterns on " \
106
+ "#{attr.name} to a single JSON Schema pattern: sibling " \
107
+ "patterns are alternatives (OR), and JSON Schema cannot " \
108
+ "express conjunction (AND) without allOf, which is not " \
109
+ "yet supported."
110
+ end
111
+ # Attribute-option patterns export verbatim (author contract);
112
+ # type-facet patterns carry Ruby \A/\z anchoring, which ECMA
113
+ # regexes lack — express the full-match intent with ^...$.
114
+ if (pattern, from_facet = patterns.first)
115
+ constraints["pattern"] =
116
+ from_facet ? json_schema_pattern(pattern) : pattern.source
117
+ end
118
+
119
+ # Add the resolved type's restriction facets and format
120
+ type = attr.type(register)
121
+ if type.is_a?(Class) && type <= Lutaml::Model::Type::Value
122
+ facets = type.facets
123
+ constraints["minimum"] = facets[:min_inclusive] if facets[:min_inclusive]
124
+ constraints["maximum"] = facets[:max_inclusive] if facets[:max_inclusive]
125
+ constraints["exclusiveMinimum"] = facets[:min_exclusive] if facets[:min_exclusive]
126
+ constraints["exclusiveMaximum"] = facets[:max_exclusive] if facets[:max_exclusive]
127
+ end
101
128
 
102
129
  # Add default value
103
130
  resolved = attr.default_value(register, nil)
@@ -108,21 +135,45 @@ module Lutaml
108
135
  # Add enumeration values
109
136
  constraints["enum"] = attr.enum_values if attr.enum?
110
137
 
138
+ # Add the format keyword for types that declare one (e.g. the
139
+ # NIST Metaschema types: ipv4, ipv6, email, uri-reference)
140
+ format = type.json_schema_format if type.is_a?(Class) &&
141
+ type <= Lutaml::Model::Type::Value
142
+ constraints["format"] = format if format
143
+
111
144
  constraints
112
145
  end
113
146
 
147
+ def constraint_patterns(attr)
148
+ patterns = []
149
+ patterns << [attr.pattern, false] if attr.pattern
150
+ type = attr.type(register)
151
+ if type.is_a?(Class) && type <= Lutaml::Model::Type::Value
152
+ type.facets[:pattern].to_a.each { |re| patterns << [re, true] }
153
+ end
154
+ patterns
155
+ end
156
+
157
+ # JSON Schema (ECMA) regexes have no \A/\z; full-match intent is
158
+ # expressed with ^...$ anchoring (the NIST JSON forms do the same).
159
+ def json_schema_pattern(regexp)
160
+ source = regexp.source
161
+ source = source.delete_prefix('\\A').delete_suffix('\\z')
162
+ "^#{source}$"
163
+ end
164
+
114
165
  def polymorphic?(attr)
115
166
  Utils.present?(attr.options[:polymorphic])
116
167
  end
117
168
 
118
169
  def get_type(type)
119
- {
120
- Lutaml::Model::Type::String => "string",
121
- Lutaml::Model::Type::Integer => "integer",
122
- Lutaml::Model::Type::Boolean => "boolean",
123
- Lutaml::Model::Type::Float => "number",
124
- Lutaml::Model::Type::Hash => "object",
125
- }[type] || "string" # Default to string for unknown types
170
+ return "integer" if type.is_a?(Class) && type <= Lutaml::Model::Type::Integer
171
+ return "boolean" if type.is_a?(Class) && type <= Lutaml::Model::Type::Boolean
172
+ return "number" if type.is_a?(Class) && type <= Lutaml::Model::Type::Float
173
+ return "number" if type.is_a?(Class) && type <= Lutaml::Model::Type::Decimal
174
+ return "object" if type.is_a?(Class) && type <= Lutaml::Model::Type::Hash
175
+
176
+ "string" # Strings and all other scalar types
126
177
  end
127
178
  end
128
179
  end
@@ -71,9 +71,10 @@ module Lutaml
71
71
  end
72
72
 
73
73
  def as_models(schema, options: {})
74
- unless Config.xml_adapter.name.end_with?("NokogiriAdapter")
75
- raise Error, XML_ADAPTER_NOT_SET_MESSAGE
76
- end
74
+ # The schema builder resolves its own DOM builder from the
75
+ # configured adapter type (falling back to nokogiri); a stale
76
+ # adapter-class name check here rejected every caller after
77
+ # Config.xml_adapter became symbol-based (#251).
77
78
 
78
79
  parsed_schema = Lutaml::Xml::Schema::Xsd.parse(
79
80
  schema, location: options[:location]
@@ -15,6 +15,10 @@ module Lutaml
15
15
  value.is_a?(::String) ? value.match?(TIMEZONE) : !cast(value).nil?
16
16
  end
17
17
 
18
+ def self.json_schema_format
19
+ "date-time"
20
+ end
21
+
18
22
  def self.default_xsd_type
19
23
  "xs:dateTime"
20
24
  end
@@ -15,6 +15,10 @@ module Lutaml
15
15
  value.is_a?(::String) ? value.match?(TIMEZONE) : !cast(value).nil?
16
16
  end
17
17
 
18
+ def self.json_schema_format
19
+ "date"
20
+ end
21
+
18
22
  def self.default_xsd_type
19
23
  "xs:date"
20
24
  end
@@ -5,23 +5,27 @@ module Lutaml
5
5
  module Type
6
6
  # NIST Metaschema day-time-duration datatype (#107): an ISO 8601
7
7
  # duration expressed in days, hours, minutes, and seconds (no
8
- # years or months), per the xs:dayTimeDuration form.
8
+ # years or months), per the xs:dayTimeDuration form. Built by
9
+ # interpolation with no formatting whitespace, so the pattern's
10
+ # source is export-clean for xs:pattern facets.
9
11
  #
10
12
  # @example Using DayTimeDuration type
11
13
  # attribute :elapsed, :day_time_duration
12
14
  class DayTimeDuration < Duration
13
15
  ELAPSED_TIME =
14
- /(?:[0-9]+H(?:[0-9]+M)?(?:[0-9]+(?:\.[0-9]+)?S)?
15
- |[0-9]+M(?:[0-9]+(?:\.[0-9]+)?S)?
16
- |[0-9]+(?:\.[0-9]+)?S)/x
17
- PATTERN = /
18
- \A-?P(?:[0-9]+D(?:T#{ELAPSED_TIME.source})?|T#{ELAPSED_TIME.source})\z
19
- /x
16
+ "(?:[0-9]+H(?:[0-9]+M)?(?:[0-9]+(?:\\.[0-9]+)?S)?" \
17
+ "|[0-9]+M(?:[0-9]+(?:\\.[0-9]+)?S)?" \
18
+ "|[0-9]+(?:\\.[0-9]+)?S)"
19
+ PATTERN = /\A-?P(?:[0-9]+D(?:T#{ELAPSED_TIME})?|T#{ELAPSED_TIME})\z/
20
20
 
21
21
  def self.valid?(value)
22
22
  value.is_a?(::String) ? value.match?(PATTERN) : !cast(value).nil?
23
23
  end
24
24
 
25
+ def self.json_schema_format
26
+ "duration"
27
+ end
28
+
25
29
  def self.default_xsd_type
26
30
  "xs:dayTimeDuration"
27
31
  end
@@ -13,6 +13,12 @@ module Lutaml
13
13
 
14
14
  PATTERN = /\A.+@.+\z/
15
15
 
16
+ pattern PATTERN
17
+
18
+ def self.json_schema_format
19
+ "email"
20
+ end
21
+
16
22
  def self.default_xsd_type
17
23
  "xs:string"
18
24
  end
@@ -15,6 +15,12 @@ module Lutaml
15
15
 
16
16
  PATTERN = /\A\S(.*\S)?\z/
17
17
 
18
+ pattern PATTERN
19
+
20
+ def self.json_schema_format
21
+ "idn-hostname"
22
+ end
23
+
18
24
  def self.default_xsd_type
19
25
  "xs:string"
20
26
  end
@@ -14,6 +14,10 @@ module Lutaml
14
14
  OCTET = /(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])/
15
15
  PATTERN = /\A#{OCTET.source}\.#{OCTET.source}\.#{OCTET.source}\.#{OCTET.source}\z/
16
16
 
17
+ def self.json_schema_format
18
+ "ipv4"
19
+ end
20
+
17
21
  def self.default_xsd_type
18
22
  "xs:string"
19
23
  end
@@ -6,7 +6,9 @@ module Lutaml
6
6
  # NIST Metaschema ip-v6-address datatype (#107): RFC3513 IPv6 text
7
7
  # form, per the metaschema-datatypes pattern (the generalized
8
8
  # StackOverflow IPv6 expression with metaschema customizations,
9
- # including link-local zones and embedded IPv4).
9
+ # including link-local zones and embedded IPv4). Built by
10
+ # interpolation with no formatting whitespace, so the pattern's
11
+ # source is export-clean for xs:pattern facets.
10
12
  #
11
13
  # @example Using IpV6Address type
12
14
  # attribute :address, :ip_v6_address
@@ -16,22 +18,25 @@ module Lutaml
16
18
  HEX4 = "[0-9a-fA-F]{1,4}"
17
19
  OCTET = "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])"
18
20
  EMBEDDED_V4 = "(#{OCTET}\\.){3,3}#{OCTET}".freeze
19
- PATTERN = /
20
- \A(
21
- (#{HEX4}:){7,7}#{HEX4}
22
- |([0-9a-fA-F]{1,4}:){1,7}:
23
- |([0-9a-fA-F]{1,4}:){1,6}:#{HEX4}
24
- |([0-9a-fA-F]{1,4}:){1,5}(:#{HEX4}){1,2}
25
- |([0-9a-fA-F]{1,4}:){1,4}(:#{HEX4}){1,3}
26
- |([0-9a-fA-F]{1,4}:){1,3}(:#{HEX4}){1,4}
27
- |([0-9a-fA-F]{1,4}:){1,2}(:#{HEX4}){1,5}
28
- |#{HEX4}:((:#{HEX4}){1,6})
29
- |:((:#{HEX4}){1,7}|:)
30
- |[fF][eE]80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}
31
- |::([fF]{4}(:0{1,4}){0,1}:){0,1}#{EMBEDDED_V4}
32
- |([0-9a-fA-F]{1,4}:){1,4}:#{EMBEDDED_V4}
33
- )\z
34
- /x
21
+ GROUPS = "(#{HEX4}:){7,7}#{HEX4}" \
22
+ "|([0-9a-fA-F]{1,4}:){1,7}:" \
23
+ "|([0-9a-fA-F]{1,4}:){1,6}:#{HEX4}" \
24
+ "|([0-9a-fA-F]{1,4}:){1,5}(:#{HEX4}){1,2}" \
25
+ "|([0-9a-fA-F]{1,4}:){1,4}(:#{HEX4}){1,3}" \
26
+ "|([0-9a-fA-F]{1,4}:){1,3}(:#{HEX4}){1,4}" \
27
+ "|([0-9a-fA-F]{1,4}:){1,2}(:#{HEX4}){1,5}" \
28
+ "|#{HEX4}:((:#{HEX4}){1,6})" \
29
+ "|:((:#{HEX4}){1,7}|:)" \
30
+ "|[fF][eE]80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}" \
31
+ "|::([fF]{4}(:0{1,4}){0,1}:){0,1}#{EMBEDDED_V4}" \
32
+ "|([0-9a-fA-F]{1,4}:){1,4}:#{EMBEDDED_V4}".freeze
33
+ PATTERN = /\A(#{GROUPS})\z/
34
+
35
+ pattern PATTERN
36
+
37
+ def self.json_schema_format
38
+ "ipv6"
39
+ end
35
40
 
36
41
  def self.default_xsd_type
37
42
  "xs:string"
@@ -4,17 +4,15 @@ module Lutaml
4
4
  module Model
5
5
  module Type
6
6
  # Pattern machinery shared by the NIST Metaschema string types (#107).
7
- # Each including class defines a PATTERN regexp; values cast to
8
- # strings and validate full-match against it.
7
+ # Each including class defines a PATTERN regexp (also declared as the
8
+ # type's `pattern` facet, feeding runtime validation and XSD
9
+ # restriction emission); values cast to strings and `valid?` checks
10
+ # full-match against it.
9
11
  module MetaschemaPattern
10
- def pattern
11
- self::PATTERN
12
- end
13
-
14
12
  def valid?(value)
15
13
  return false if value.nil?
16
14
 
17
- pattern.match?(value.to_s)
15
+ self::PATTERN.match?(value.to_s)
18
16
  end
19
17
 
20
18
  def cast(value, _options = {})
@@ -9,6 +9,7 @@ module Lutaml
9
9
  # @example Using NonNegativeInteger type
10
10
  # attribute :index, :non_negative_integer
11
11
  class NonNegativeInteger < Integer
12
+ inclusive(min: 0)
12
13
  def self.valid?(value)
13
14
  cast_value = cast(value)
14
15
 
@@ -9,6 +9,7 @@ module Lutaml
9
9
  # @example Using PositiveInteger type
10
10
  # attribute :ordinal, :positive_integer
11
11
  class PositiveInteger < Integer
12
+ inclusive(min: 1)
12
13
  def self.valid?(value)
13
14
  cast_value = cast(value)
14
15
 
@@ -14,6 +14,8 @@ module Lutaml
14
14
 
15
15
  PATTERN = /\A(\p{L}|_)(\p{L}|\p{N}|[.\-_])*\z/
16
16
 
17
+ pattern PATTERN
18
+
17
19
  def self.default_xsd_type
18
20
  "xs:string"
19
21
  end
@@ -14,6 +14,12 @@ module Lutaml
14
14
 
15
15
  PATTERN = /\A\S(.*\S)?\z/
16
16
 
17
+ pattern PATTERN
18
+
19
+ def self.json_schema_format
20
+ "uri-reference"
21
+ end
22
+
17
23
  def self.default_xsd_type
18
24
  "xs:anyURI"
19
25
  end
@@ -14,6 +14,8 @@ module Lutaml
14
14
 
15
15
  PATTERN = %r{\A[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[45][0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}\z}
16
16
 
17
+ pattern PATTERN
18
+
17
19
  def self.default_xsd_type
18
20
  "xs:string"
19
21
  end
@@ -126,6 +126,15 @@ from: nil)
126
126
  "conflicting `#{key}` facets: #{existing} and #{incoming}"
127
127
  end
128
128
 
129
+ # The JSON Schema `format` keyword for this type, or nil. The NIST
130
+ # Metaschema types declare the format the metaschema JSON
131
+ # representations use (ipv4, ipv6, email, uri-reference, duration,
132
+ # date, date-time); core types default to nil and their schemas
133
+ # carry no format keyword.
134
+ def json_schema_format
135
+ nil
136
+ end
137
+
129
138
  # Constrain ordered values via min/maxInclusive.
130
139
  def inclusive(min: nil, max: nil)
131
140
  min = cast_facet_value(min)
@@ -16,6 +16,10 @@ module Lutaml
16
16
  value.is_a?(::String) ? value.match?(PATTERN) : !cast(value).nil?
17
17
  end
18
18
 
19
+ def self.json_schema_format
20
+ "duration"
21
+ end
22
+
19
23
  def self.default_xsd_type
20
24
  "xs:yearMonthDuration"
21
25
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.127"
5
+ VERSION = "0.8.129"
6
6
  end
7
7
  end
@@ -18,6 +18,7 @@ module Lutaml
18
18
  map_attribute :id, to: :id
19
19
  map_attribute :itemType, to: :item_type
20
20
  map_element :annotation, to: :annotation
21
+ map_element :simpleType, to: :simple_type
21
22
  end
22
23
 
23
24
  Lutaml::Xml::Schema::Xsd.register_model(self, :list)
@@ -221,4 +221,147 @@ RSpec.describe "NIST Metaschema simple data types" do
221
221
  .to eq("xs:yearMonthDuration")
222
222
  expect(Lutaml::Model::Type::Uuid.default_xsd_type).to eq("xs:string")
223
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
224
367
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/model/schema"
5
+ require "tmpdir"
6
+
7
+ # Runtime XSD-to-classes compilation (#251): Schema.from_xml compiles a
8
+ # schema into generated Serializable classes, and with load_classes: the
9
+ # classes are available in-process for direct parsing and serialization —
10
+ # no generated files on disk.
11
+ RSpec.describe "runtime XSD class loading" do
12
+ let(:xsd) do
13
+ <<~XSD
14
+ <?xml version="1.0" encoding="UTF-8"?>
15
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
16
+ <xs:complexType name="EntryType">
17
+ <xs:sequence>
18
+ <xs:element name="name" type="xs:string"/>
19
+ <xs:element name="year" type="xs:integer"/>
20
+ </xs:sequence>
21
+ <xs:attribute name="id" type="xs:string" use="required"/>
22
+ </xs:complexType>
23
+ <xs:element name="entry" type="EntryType"/>
24
+ </xs:schema>
25
+ XSD
26
+ end
27
+
28
+ let(:instance_xml) do
29
+ <<~XML
30
+ <entry id="e1"><name>Alpha</name><year>2024</year></entry>
31
+ XML
32
+ end
33
+
34
+ it "compiles a schema, loads the classes, and round-trips an instance" do
35
+ Dir.mktmpdir do |dir|
36
+ Lutaml::Model::Schema.from_xml(
37
+ xsd, output_dir: File.join(dir, "runtime_pilot_models"),
38
+ load_classes: true
39
+ )
40
+
41
+ doc = RuntimePilotModels::EntryType.from_xml(instance_xml)
42
+
43
+ expect(doc.name).to eq("Alpha")
44
+ expect(doc.year).to eq(2024)
45
+ expect(doc.id).to eq("e1")
46
+
47
+ expect(doc.to_xml).to include("<name>Alpha</name>")
48
+ expect(doc.to_xml).to include("<year>2024</year>")
49
+ expect(doc.to_xml).to include('id="e1"')
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spec_helper"
4
+
5
+ RSpec.describe "xs:list nested simpleType mapping" do
6
+ it "maps the inline simpleType of a list from the parsed XSD" do
7
+ xsd = <<~XSD
8
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
9
+ <xs:element name="r">
10
+ <xs:simpleType>
11
+ <xs:list>
12
+ <xs:simpleType>
13
+ <xs:restriction base="xs:string">
14
+ <xs:enumeration value="a"/>
15
+ </xs:restriction>
16
+ </xs:simpleType>
17
+ </xs:list>
18
+ </xs:simpleType>
19
+ </xs:element>
20
+ </xs:schema>
21
+ XSD
22
+
23
+ schema = Lutaml::Xml::Schema::Xsd.parse(xsd)
24
+ list = schema.element.first.simple_type.list
25
+
26
+ expect(list.simple_type).to be_a(Lutaml::Xml::Schema::Xsd::SimpleType)
27
+ values = Array(list.simple_type.restriction.enumeration).map(&:value)
28
+ expect(values).to eq(["a"])
29
+ end
30
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.127
4
+ version: 0.8.129
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -1837,6 +1837,7 @@ files:
1837
1837
  - spec/lutaml/model/schema/renderers/namespace_spec.rb
1838
1838
  - spec/lutaml/model/schema/renderers/restricted_type_spec.rb
1839
1839
  - spec/lutaml/model/schema/renderers/union_spec.rb
1840
+ - spec/lutaml/model/schema/xml_compiler_runtime_spec.rb
1840
1841
  - spec/lutaml/model/schema/xsd_schema_spec.rb
1841
1842
  - spec/lutaml/model/schema/yaml_schema_spec.rb
1842
1843
  - spec/lutaml/model/sequence_choice_compositor_spec.rb
@@ -2054,6 +2055,7 @@ files:
2054
2055
  - spec/lutaml/xml/schema/xsd/liquid_methods/element_spec.rb
2055
2056
  - spec/lutaml/xml/schema/xsd/liquid_methods/schema_spec.rb
2056
2057
  - spec/lutaml/xml/schema/xsd/liquid_methods/simple_content_spec.rb
2058
+ - spec/lutaml/xml/schema/xsd/list_simple_type_spec.rb
2057
2059
  - spec/lutaml/xml/schema/xsd/namespace_uri_remapping_spec.rb
2058
2060
  - spec/lutaml/xml/schema/xsd/schema_helper_methods_spec.rb
2059
2061
  - spec/lutaml/xml/schema/xsd/schema_mapping_spec.rb