lutaml-model 0.8.22 → 0.8.23
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/xml-mapping.adoc +31 -0
- data/lib/lutaml/json/adapter/oj_adapter.rb +7 -0
- data/lib/lutaml/json/adapter/standard_adapter.rb +17 -5
- data/lib/lutaml/json/generator_options.rb +62 -0
- data/lib/lutaml/jsonl/adapter/standard_adapter.rb +1 -1
- data/lib/lutaml/jsonld/adapter.rb +12 -4
- data/lib/lutaml/key_value/adapter/json/oj_adapter.rb +7 -0
- data/lib/lutaml/key_value/adapter/json/standard_adapter.rb +17 -5
- data/lib/lutaml/key_value/adapter/jsonl/standard_adapter.rb +1 -1
- data/lib/lutaml/model/collection.rb +5 -0
- data/lib/lutaml/model/serialization_adapter.rb +1 -1
- data/lib/lutaml/model/serialize/format_conversion.rb +38 -7
- data/lib/lutaml/model/serialize.rb +18 -0
- data/lib/lutaml/model/transformation.rb +5 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/mapping.rb +24 -0
- data/lib/lutaml/xml/model_transform.rb +52 -1
- data/lib/lutaml/xml/transformation.rb +29 -0
- data/spec/lutaml/json/adapter/standard_adapter_spec.rb +159 -0
- data/spec/lutaml/jsonld/adapter_spec.rb +19 -0
- data/spec/lutaml/model/collection_spec.rb +1 -1
- data/spec/lutaml/model/collection_state_nesting_spec.rb +44 -0
- data/spec/lutaml/model/generator_defaults_spec.rb +101 -0
- data/spec/lutaml/model/json_state_nesting_spec.rb +82 -0
- data/spec/lutaml/xml/shared_element_rules_spec.rb +64 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 22cdfc7334bab60076fc98d5153c5770789f2670dc41eeabfee4df3dc6265f99
|
|
4
|
+
data.tar.gz: fcb7ed45d08960bc739e7b0401771c96cc295a79f0d5236d60c0b8c7edfc54bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c2642f2aba35a1eff18d1cc69c3dd44c95f88c80ebb8b76937c10202f0f2527e9c8365be6730fdbd69ee34b44641707714e4d6e0bd2c1cb062176a8d735b7398
|
|
7
|
+
data.tar.gz: 5cb5c0079ac4d8cb578cab080641e2d4f3d6a6445c1f212080aba556133b8b707afdf86a8508a4df488c859b3fabbe52adf26f324ddaecd1988f4d79338dc2b9
|
|
@@ -717,6 +717,37 @@ end
|
|
|
717
717
|
----
|
|
718
718
|
====
|
|
719
719
|
|
|
720
|
+
===== Mapping several element names to one attribute
|
|
721
|
+
|
|
722
|
+
Several `map_element` rules may target the same collection attribute to
|
|
723
|
+
accept multiple spellings of one element (e.g. a legacy and a modern
|
|
724
|
+
name). Parsing merges the content of every spelling **in document
|
|
725
|
+
order**; serialization emits the value once, under the **first declared**
|
|
726
|
+
spelling.
|
|
727
|
+
|
|
728
|
+
[source,ruby]
|
|
729
|
+
----
|
|
730
|
+
class Editorial < Lutaml::Model::Serializable
|
|
731
|
+
attribute :groups, Group, collection: true
|
|
732
|
+
|
|
733
|
+
xml do
|
|
734
|
+
element "metadata"
|
|
735
|
+
map_element "editorial-group", to: :groups
|
|
736
|
+
map_element "editorialgroup", to: :groups # legacy spelling
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
Editorial.from_xml(
|
|
741
|
+
"<metadata><editorialgroup>…</editorialgroup><editorial-group>…</editorial-group></metadata>"
|
|
742
|
+
).groups
|
|
743
|
+
# => [group(legacy), group(modern)] — document order
|
|
744
|
+
----
|
|
745
|
+
|
|
746
|
+
NOTE: The merge applies to plain element rules on collection attributes.
|
|
747
|
+
Rules with custom deserialization methods, `raw`, `content`, or `cdata`
|
|
748
|
+
are not merged — each still assigns separately, last rule winning as
|
|
749
|
+
before.
|
|
750
|
+
|
|
720
751
|
===== Using elements in different namespaces
|
|
721
752
|
|
|
722
753
|
====== General
|
|
@@ -17,6 +17,13 @@ module Lutaml
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
# rubocop:disable Style/ArgumentsForwarding -- anonymous * requires Ruby 3.2+
|
|
20
|
+
# Oj has its own option namespace and its own escape_mode. Handing it
|
|
21
|
+
# the stdlib generator's options overrides that configuration, so it
|
|
22
|
+
# takes none of them -- which is what it received before.
|
|
23
|
+
def ignores_generator_options?
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
20
27
|
def to_json(*args)
|
|
21
28
|
require "oj"
|
|
22
29
|
# Handle KeyValueElement input (new symmetric architecture)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require_relative "../generator_options"
|
|
4
5
|
|
|
5
6
|
module Lutaml
|
|
6
7
|
module Json
|
|
@@ -9,12 +10,16 @@ module Lutaml
|
|
|
9
10
|
FORMAT_SYMBOL = :json
|
|
10
11
|
|
|
11
12
|
def self.parse(json, _options = {})
|
|
12
|
-
JSON.parse(json
|
|
13
|
+
JSON.parse(json)
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
# This adapter hands its payload to the stdlib generator, so a
|
|
17
|
+
# JSON::State carrying the outer formatting is meaningful here.
|
|
18
|
+
def accepts_generator_state?
|
|
19
|
+
true
|
|
20
|
+
end
|
|
17
21
|
|
|
22
|
+
def to_json(*args)
|
|
18
23
|
# Handle KeyValueElement input (new symmetric architecture)
|
|
19
24
|
attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
|
|
20
25
|
# Unwrap __root__ wrapper to get actual content
|
|
@@ -24,10 +29,17 @@ module Lutaml
|
|
|
24
29
|
@attributes
|
|
25
30
|
end
|
|
26
31
|
|
|
32
|
+
unless GeneratorOptions.lutaml_options?(args.first)
|
|
33
|
+
return JSON.generate(attributes_to_serialize, args.first)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
options = args.first || {}
|
|
37
|
+
generator_options = GeneratorOptions.filter(options)
|
|
38
|
+
|
|
27
39
|
if options[:pretty]
|
|
28
|
-
JSON.pretty_generate(attributes_to_serialize,
|
|
40
|
+
JSON.pretty_generate(attributes_to_serialize, generator_options)
|
|
29
41
|
else
|
|
30
|
-
JSON.generate(attributes_to_serialize,
|
|
42
|
+
JSON.generate(attributes_to_serialize, generator_options)
|
|
31
43
|
end
|
|
32
44
|
end
|
|
33
45
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module Json
|
|
7
|
+
# Filters the options LutaML threads through serialization down to the
|
|
8
|
+
# subset the JSON generator accepts.
|
|
9
|
+
#
|
|
10
|
+
# json 2.x silently ignored unknown generator options; json 3.0 raises
|
|
11
|
+
# ArgumentError on them, so LutaML's own options (:register, :pretty and
|
|
12
|
+
# any caller-supplied extras) have to be stripped before they reach
|
|
13
|
+
# JSON.generate.
|
|
14
|
+
module GeneratorOptions
|
|
15
|
+
# Options the generator accepts on both json 2.x and 3.x. Measured
|
|
16
|
+
# against 2.9.1, 2.15.2, 2.19.9, 2.20.0, 2.21.1, 2.21.2 and 3.0.0.
|
|
17
|
+
# :escape_slash is NOT here because json 3.0 removed it; on 2.x it still
|
|
18
|
+
# arrives via DERIVED_PERMITTED. :allow_duplicate_key IS here because the
|
|
19
|
+
# generator accepts it on every version while JSON::State never exposed
|
|
20
|
+
# it as an accessor, so the derived half alone would miss it.
|
|
21
|
+
BASE_PERMITTED = %i[
|
|
22
|
+
allow_duplicate_key allow_nan array_nl as_json ascii_only
|
|
23
|
+
buffer_initial_length depth indent max_nesting object_nl script_safe
|
|
24
|
+
sort_keys space space_before strict
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# Additive, so a json release that adds an option needs no change here.
|
|
28
|
+
# Opal's JSON shim has no State class, hence the guard.
|
|
29
|
+
DERIVED_PERMITTED =
|
|
30
|
+
if defined?(::JSON::State)
|
|
31
|
+
accessors = ::JSON::State.instance_methods(false).grep(/\A[a-z_][a-z0-9_]*=\z/)
|
|
32
|
+
accessors.map { |name| name.to_s.chomp("=").to_sym }
|
|
33
|
+
else
|
|
34
|
+
[]
|
|
35
|
+
end.freeze
|
|
36
|
+
|
|
37
|
+
PERMITTED = (BASE_PERMITTED | DERIVED_PERMITTED).freeze
|
|
38
|
+
|
|
39
|
+
# json 3.0 removed :escape_slash, which was only ever an alias of
|
|
40
|
+
# :script_safe. Dropping it would silently stop escaping slashes, so it
|
|
41
|
+
# is translated instead of discarded.
|
|
42
|
+
RENAMED = { escape_slash: :script_safe }.freeze
|
|
43
|
+
|
|
44
|
+
def self.filter(options)
|
|
45
|
+
return {} unless options.is_a?(::Hash)
|
|
46
|
+
|
|
47
|
+
options.each_with_object({}) do |(key, value), kept|
|
|
48
|
+
key = RENAMED.fetch(key, key) unless PERMITTED.include?(key)
|
|
49
|
+
kept[key] = value if PERMITTED.include?(key)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Ruby's JSON generator calls #to_json with a JSON::State whenever a
|
|
54
|
+
# document is nested inside another JSON.generate call. That is the
|
|
55
|
+
# generator's own state, not a LutaML options hash, and json 3.0 removed
|
|
56
|
+
# JSON::State#[], so it must never be read like one.
|
|
57
|
+
def self.lutaml_options?(argument)
|
|
58
|
+
argument.nil? || argument.is_a?(::Hash)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require_relative "../json/generator_options"
|
|
4
5
|
|
|
5
6
|
module Lutaml
|
|
6
7
|
module JsonLd
|
|
7
8
|
class Adapter < Lutaml::KeyValue::Document
|
|
8
9
|
def self.parse(jsonld_string, _options = {})
|
|
9
|
-
JSON.parse(jsonld_string
|
|
10
|
+
JSON.parse(jsonld_string)
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def to_jsonld(*args)
|
|
13
|
-
options = args.first || {}
|
|
14
14
|
data = @attributes
|
|
15
|
+
|
|
16
|
+
unless Lutaml::Json::GeneratorOptions.lutaml_options?(args.first)
|
|
17
|
+
return JSON.generate(data, args.first)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
options = args.first || {}
|
|
21
|
+
generator_options = Lutaml::Json::GeneratorOptions.filter(options)
|
|
22
|
+
|
|
15
23
|
if options[:pretty]
|
|
16
|
-
JSON.pretty_generate(data,
|
|
24
|
+
JSON.pretty_generate(data, generator_options)
|
|
17
25
|
else
|
|
18
|
-
JSON.generate(data,
|
|
26
|
+
JSON.generate(data, generator_options)
|
|
19
27
|
end
|
|
20
28
|
end
|
|
21
29
|
end
|
|
@@ -21,6 +21,13 @@ module Lutaml
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# rubocop:disable Style/ArgumentsForwarding -- anonymous * requires Ruby 3.2+
|
|
24
|
+
# Oj has its own option namespace and its own escape_mode. Handing it
|
|
25
|
+
# the stdlib generator's options overrides that configuration, so it
|
|
26
|
+
# takes none of them -- which is what it received before.
|
|
27
|
+
def ignores_generator_options?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
24
31
|
def to_json(*args)
|
|
25
32
|
require "oj"
|
|
26
33
|
# Handle KeyValueElement input (new symmetric architecture)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require_relative "../../../json/generator_options"
|
|
4
5
|
|
|
5
6
|
# Backward compatibility - delegates to Lutaml::Json::Adapter
|
|
6
7
|
# @deprecated Use Lutaml::Json::Adapter::StandardAdapter instead
|
|
@@ -13,12 +14,16 @@ module Lutaml
|
|
|
13
14
|
FORMAT_SYMBOL = :json
|
|
14
15
|
|
|
15
16
|
def self.parse(json, _options = {})
|
|
16
|
-
JSON.parse(json
|
|
17
|
+
JSON.parse(json)
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
# This adapter hands its payload to the stdlib generator, so a
|
|
21
|
+
# JSON::State carrying the outer formatting is meaningful here.
|
|
22
|
+
def accepts_generator_state?
|
|
23
|
+
true
|
|
24
|
+
end
|
|
21
25
|
|
|
26
|
+
def to_json(*args)
|
|
22
27
|
# Handle KeyValueElement input (new symmetric architecture)
|
|
23
28
|
attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
|
|
24
29
|
# Unwrap __root__ wrapper to get actual content
|
|
@@ -28,10 +33,17 @@ module Lutaml
|
|
|
28
33
|
@attributes
|
|
29
34
|
end
|
|
30
35
|
|
|
36
|
+
unless Lutaml::Json::GeneratorOptions.lutaml_options?(args.first)
|
|
37
|
+
return JSON.generate(attributes_to_serialize, args.first)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
options = args.first || {}
|
|
41
|
+
generator_options = Lutaml::Json::GeneratorOptions.filter(options)
|
|
42
|
+
|
|
31
43
|
if options[:pretty]
|
|
32
|
-
JSON.pretty_generate(attributes_to_serialize,
|
|
44
|
+
JSON.pretty_generate(attributes_to_serialize, generator_options)
|
|
33
45
|
else
|
|
34
|
-
JSON.generate(attributes_to_serialize,
|
|
46
|
+
JSON.generate(attributes_to_serialize, generator_options)
|
|
35
47
|
end
|
|
36
48
|
end
|
|
37
49
|
end
|
|
@@ -308,6 +308,10 @@ module Lutaml
|
|
|
308
308
|
end
|
|
309
309
|
|
|
310
310
|
def to(format, instance, options = {})
|
|
311
|
+
# Wrap before the branch, not inside it: only XML reaches the
|
|
312
|
+
# unwrapped path today, but a raw JSON::State must never survive
|
|
313
|
+
# into either arm.
|
|
314
|
+
options = Lutaml::Model::Serialize.wrap_generator_state(options)
|
|
311
315
|
mappings = mappings_for(format)
|
|
312
316
|
|
|
313
317
|
if mappings.no_root? && collection_unwrapped_to?(format)
|
|
@@ -456,6 +460,7 @@ lutaml_register: Lutaml::Model::Config.default_register)
|
|
|
456
460
|
end
|
|
457
461
|
|
|
458
462
|
def to_format(format, options = {})
|
|
463
|
+
options = Lutaml::Model::Serialize.wrap_generator_state(options)
|
|
459
464
|
super(format, options.merge(collection: true))
|
|
460
465
|
end
|
|
461
466
|
|
|
@@ -192,24 +192,55 @@ module Lutaml
|
|
|
192
192
|
# is always preserved when available, regardless of this option.
|
|
193
193
|
# @return [String] The serialized output
|
|
194
194
|
def to(format, instance, options = {})
|
|
195
|
+
# Ruby's JSON generator hands #to_json its own JSON::State rather
|
|
196
|
+
# than an options hash. It carries no LutaML options, but it does
|
|
197
|
+
# carry the surrounding indent context, so it is forwarded to the
|
|
198
|
+
# adapter unchanged instead of being read like a Hash -- json 3.0
|
|
199
|
+
# removed JSON::State#[].
|
|
200
|
+
options = Serialize.wrap_generator_state(options)
|
|
201
|
+
generator_state = options.delete(Serialize::GENERATOR_STATE_KEY)
|
|
202
|
+
|
|
195
203
|
Instrumentation.instrument(:to, model: name, format: format) do
|
|
196
|
-
adapter_override = options.
|
|
197
|
-
if adapter_override
|
|
198
|
-
options[:_adapter_override] =
|
|
199
|
-
true
|
|
200
|
-
end
|
|
204
|
+
adapter_override = options.delete(:adapter)
|
|
205
|
+
options[:_adapter_override] = true if adapter_override
|
|
201
206
|
value = public_send(:"as_#{format}", instance, options)
|
|
202
207
|
adapter = resolve_adapter(format, adapter_override)
|
|
203
208
|
|
|
204
209
|
# Hook for format-specific options preparation (e.g., XML prefix/namespace/declaration)
|
|
205
210
|
options = prepare_to_options(format, instance, options)
|
|
206
211
|
|
|
207
|
-
adapter.new(value, register: options[:register])
|
|
208
|
-
|
|
212
|
+
document = adapter.new(value, register: options[:register])
|
|
213
|
+
|
|
214
|
+
document.public_send(
|
|
215
|
+
:"to_#{format}",
|
|
216
|
+
forward_options(document, generator_state, options),
|
|
209
217
|
)
|
|
210
218
|
end
|
|
211
219
|
end
|
|
212
220
|
|
|
221
|
+
# Main's behaviour differs per ADAPTER, not per option, so this follows
|
|
222
|
+
# the adapter rather than trying to translate option names:
|
|
223
|
+
# stdlib honours script_safe / ascii_only / pretty -> give it the state
|
|
224
|
+
# Oj ignores them all and uses its own escape_mode -> give it none
|
|
225
|
+
# others reach the stdlib generator underneath -> give them the options
|
|
226
|
+
def forward_options(document, generator_state, options)
|
|
227
|
+
return options if generator_state.nil?
|
|
228
|
+
|
|
229
|
+
if declares?(document, :accepts_generator_state?)
|
|
230
|
+
generator_state
|
|
231
|
+
elsif declares?(document, :ignores_generator_options?)
|
|
232
|
+
options
|
|
233
|
+
elsif generator_state.respond_to?(:to_h)
|
|
234
|
+
options.merge(generator_state.to_h)
|
|
235
|
+
else
|
|
236
|
+
options
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def declares?(document, predicate)
|
|
241
|
+
document.respond_to?(predicate) && document.public_send(predicate)
|
|
242
|
+
end
|
|
243
|
+
|
|
213
244
|
# Hook for format-specific options preparation before serialization.
|
|
214
245
|
# XML overrides to handle prefix, namespace overrides, declaration plan.
|
|
215
246
|
#
|
|
@@ -225,7 +225,25 @@ module Lutaml
|
|
|
225
225
|
self.class.as_yaml(self)
|
|
226
226
|
end
|
|
227
227
|
|
|
228
|
+
# Ruby's JSON generator hands #to_json its own JSON::State instead of an
|
|
229
|
+
# options hash. It carries no LutaML options, but it does carry the
|
|
230
|
+
# surrounding indent context, so it travels IN BAND under a private key
|
|
231
|
+
# rather than replacing the options hash. Wrapping rather than returning
|
|
232
|
+
# early keeps every later step -- register propagation, root-mapping
|
|
233
|
+
# validation, Collection's `collection: true` merge -- working on a real
|
|
234
|
+
# Hash. json 3.0 removed JSON::State#[] and rejects unknown keys in
|
|
235
|
+
# State#merge, so nothing may treat it as a Hash.
|
|
236
|
+
GENERATOR_STATE_KEY = :_generator_state
|
|
237
|
+
|
|
238
|
+
def self.wrap_generator_state(options)
|
|
239
|
+
return options if options.is_a?(::Hash)
|
|
240
|
+
|
|
241
|
+
{ GENERATOR_STATE_KEY => options }
|
|
242
|
+
end
|
|
243
|
+
|
|
228
244
|
def to_format(format, options = {})
|
|
245
|
+
options = Lutaml::Model::Serialize.wrap_generator_state(options)
|
|
246
|
+
|
|
229
247
|
# Hook for format-specific validation (e.g., XML root mapping check)
|
|
230
248
|
validate_root_mapping!(format, options)
|
|
231
249
|
|
|
@@ -37,9 +37,14 @@ module Lutaml
|
|
|
37
37
|
@format = format
|
|
38
38
|
@register = register
|
|
39
39
|
@compiled_rules = compile_rules(mapping_dsl)
|
|
40
|
+
after_compile
|
|
40
41
|
freeze
|
|
41
42
|
end
|
|
42
43
|
|
|
44
|
+
# Hook for format subclasses to derive rule-set-level data after
|
|
45
|
+
# compilation, before the transformation is frozen and shared.
|
|
46
|
+
def after_compile; end
|
|
47
|
+
|
|
43
48
|
# Transform a model instance into format-specific representation
|
|
44
49
|
#
|
|
45
50
|
# @abstract Subclasses must implement this method
|
data/lib/lutaml/model/version.rb
CHANGED
data/lib/lutaml/xml/mapping.rb
CHANGED
|
@@ -1071,6 +1071,30 @@ module Lutaml
|
|
|
1071
1071
|
result
|
|
1072
1072
|
end
|
|
1073
1073
|
|
|
1074
|
+
# lutaml-model#765: plain element rules grouped by target attribute
|
|
1075
|
+
# name, for spelling-tolerance mapping (several element names feeding
|
|
1076
|
+
# one attribute). Cached per register so the parse hot path only pays
|
|
1077
|
+
# a hash lookup. Grouping uses rule-level properties only — the
|
|
1078
|
+
# transform applies the collection-attribute filter.
|
|
1079
|
+
#
|
|
1080
|
+
# @return [Hash{Symbol, String => Array<MappingRule>}]
|
|
1081
|
+
def plain_element_rules_by_attr(register_id = nil)
|
|
1082
|
+
reg_key = register_id || :default
|
|
1083
|
+
@plain_element_rules_by_attr ||= {}
|
|
1084
|
+
@plain_element_rules_by_attr[reg_key] ||= begin
|
|
1085
|
+
grouped = {}
|
|
1086
|
+
mappings(reg_key).each do |r|
|
|
1087
|
+
next if r.attribute? || r.raw_mapping? || r.content_mapping? ||
|
|
1088
|
+
r.cdata || r.has_custom_method_for_deserialization? ||
|
|
1089
|
+
(r.transform.is_a?(Hash) && !r.transform.empty?) ||
|
|
1090
|
+
r.transform.is_a?(Class)
|
|
1091
|
+
|
|
1092
|
+
(grouped[r.to] ||= []) << r
|
|
1093
|
+
end
|
|
1094
|
+
grouped
|
|
1095
|
+
end
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1074
1098
|
def importable_mappings
|
|
1075
1099
|
@importable_mappings ||= []
|
|
1076
1100
|
end
|
|
@@ -258,6 +258,18 @@ module Lutaml
|
|
|
258
258
|
end
|
|
259
259
|
end
|
|
260
260
|
|
|
261
|
+
# lutaml-model#765: several plain element rules may target one
|
|
262
|
+
# collection attribute (spelling tolerance, e.g. editorial-group vs
|
|
263
|
+
# editorialgroup). The first rule of such a group performs matching
|
|
264
|
+
# widened to every spelling so content merges in document order;
|
|
265
|
+
# the remaining rules are no-ops so their assignment cannot
|
|
266
|
+
# silently overwrite the merged value.
|
|
267
|
+
# Grouping is cached on the mapping DSL (per register); only the
|
|
268
|
+
# collection filter runs here.
|
|
269
|
+
grouped_plain_rules = xml_mapping.plain_element_rules_by_attr(
|
|
270
|
+
effective_register,
|
|
271
|
+
)
|
|
272
|
+
|
|
261
273
|
mappings.each do |rule|
|
|
262
274
|
# Performance: Cache rule properties accessed multiple times
|
|
263
275
|
rule.name
|
|
@@ -268,6 +280,11 @@ module Lutaml
|
|
|
268
280
|
attr = attribute_for_rule(rule)
|
|
269
281
|
next if attr&.derived?
|
|
270
282
|
|
|
283
|
+
if (group = grouped_plain_rules[rule_to]) &&
|
|
284
|
+
group.size > 1 && attr&.collection? && !group.first.equal?(rule)
|
|
285
|
+
next
|
|
286
|
+
end
|
|
287
|
+
|
|
271
288
|
raise "Attribute '#{rule_to}' not found in #{context}" unless valid_rule?(
|
|
272
289
|
rule, attr
|
|
273
290
|
)
|
|
@@ -285,6 +302,26 @@ module Lutaml
|
|
|
285
302
|
doc.root.inner_xml
|
|
286
303
|
elsif rule.content_mapping?
|
|
287
304
|
rule.cdata ? doc.cdata : doc.text
|
|
305
|
+
elsif (group = grouped_plain_rules[rule_to]) &&
|
|
306
|
+
group.size > 1 && attr&.collection?
|
|
307
|
+
# First rule of a #765 group: match every spelling of
|
|
308
|
+
# the group so the children merge in document order.
|
|
309
|
+
if child_names_set &&
|
|
310
|
+
group.drop(1).none? do |s|
|
|
311
|
+
child_matches_rule?(s, child_names_set,
|
|
312
|
+
default_namespace)
|
|
313
|
+
end && !child_matches_rule?(rule, child_names_set,
|
|
314
|
+
default_namespace)
|
|
315
|
+
::Lutaml::Model::UninitializedClass.instance
|
|
316
|
+
else
|
|
317
|
+
extra_names = group.drop(1).flat_map do |s|
|
|
318
|
+
resolve_rule_names_with_type(s, attr, new_opts,
|
|
319
|
+
effective_register,
|
|
320
|
+
attr&.type(effective_register))
|
|
321
|
+
end
|
|
322
|
+
value_for_rule(session, rule, new_opts, attr,
|
|
323
|
+
extra_names)
|
|
324
|
+
end
|
|
288
325
|
elsif child_names_set && !rule.attribute? &&
|
|
289
326
|
!child_matches_rule?(rule, child_names_set,
|
|
290
327
|
default_namespace)
|
|
@@ -551,7 +588,8 @@ _effective_register)
|
|
|
551
588
|
nil
|
|
552
589
|
end
|
|
553
590
|
|
|
554
|
-
def value_for_rule(session, rule, options, cached_attr = nil
|
|
591
|
+
def value_for_rule(session, rule, options, cached_attr = nil,
|
|
592
|
+
extra_rule_names = nil)
|
|
555
593
|
doc = session.doc
|
|
556
594
|
instance = session.instance
|
|
557
595
|
instance_is_serialize = session.instance_is_serialize
|
|
@@ -576,6 +614,12 @@ _effective_register)
|
|
|
576
614
|
resolve_rule_names_with_type(rule, attr, options,
|
|
577
615
|
effective_register, attr_type)
|
|
578
616
|
end
|
|
617
|
+
# lutaml-model#765: widened matching for the first rule of a
|
|
618
|
+
# shared-attribute group — children of sibling spellings must be
|
|
619
|
+
# selected too, in document order. Deduped: sibling resolution can
|
|
620
|
+
# yield the same spelling twice, and the child index would then
|
|
621
|
+
# return the same children again.
|
|
622
|
+
rule_names = rule_names.concat(extra_rule_names).uniq if extra_rule_names
|
|
579
623
|
|
|
580
624
|
return value_for_xml_attribute(doc, rule, rule_names) if rule.attribute?
|
|
581
625
|
|
|
@@ -643,6 +687,13 @@ _effective_register)
|
|
|
643
687
|
children = nil # fall through to select for alias matching
|
|
644
688
|
else
|
|
645
689
|
children = indexed.flatten(1)
|
|
690
|
+
# #765 widened matching: rule_names spans several spellings, so
|
|
691
|
+
# the flatten above yields rule-name order; restore document
|
|
692
|
+
# order for the merged group.
|
|
693
|
+
if extra_rule_names
|
|
694
|
+
position = element_children.each_with_index.to_h
|
|
695
|
+
children = children.sort_by { |c| position[c] }
|
|
696
|
+
end
|
|
646
697
|
end
|
|
647
698
|
else
|
|
648
699
|
children = nil
|
|
@@ -264,6 +264,8 @@ module Lutaml
|
|
|
264
264
|
root, model_instance, options,
|
|
265
265
|
compiled_rules, model_class, register_id
|
|
266
266
|
) do |action, rule, value, set_xsi_nil|
|
|
267
|
+
next if action == :apply_rule && duplicate_element_rules[rule]
|
|
268
|
+
|
|
267
269
|
rule_options = options.merge(current_model: model_instance)
|
|
268
270
|
case action
|
|
269
271
|
when :apply_rule
|
|
@@ -291,6 +293,32 @@ module Lutaml
|
|
|
291
293
|
# @param root [XmlElement] Root element
|
|
292
294
|
# @param model_instance [Object] The model instance
|
|
293
295
|
# @param options [Hash] Options
|
|
296
|
+
# Reader for the precomputed skip set (#765).
|
|
297
|
+
def duplicate_element_rules
|
|
298
|
+
@duplicate_element_rules
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# lutaml-model#765: several element rules may target one attribute
|
|
302
|
+
# (spelling tolerance, e.g. editorial-group vs editorialgroup). The
|
|
303
|
+
# extra spellings exist for parse tolerance only — serialization
|
|
304
|
+
# emits the value once, under the first declared spelling. Derived
|
|
305
|
+
# once at compile time (transformations are frozen and shared).
|
|
306
|
+
def after_compile
|
|
307
|
+
seen = {}
|
|
308
|
+
@duplicate_element_rules = {}
|
|
309
|
+
compiled_rules.each do |rule|
|
|
310
|
+
next unless rule.option(:mapping_type) == :element
|
|
311
|
+
|
|
312
|
+
key = rule.attribute_name.to_s
|
|
313
|
+
if seen.key?(key)
|
|
314
|
+
@duplicate_element_rules[rule] = true
|
|
315
|
+
else
|
|
316
|
+
seen[key] = true
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
@duplicate_element_rules.freeze
|
|
320
|
+
end
|
|
321
|
+
|
|
294
322
|
def apply_standard_rules(root, model_instance, options)
|
|
295
323
|
attr_order = model_instance.is_a?(Lutaml::Model::Serialize) &&
|
|
296
324
|
model_instance.attribute_order
|
|
@@ -303,6 +331,7 @@ module Lutaml
|
|
|
303
331
|
|
|
304
332
|
rules.each do |rule|
|
|
305
333
|
next unless valid_mapping?(rule, options)
|
|
334
|
+
next if duplicate_element_rules[rule]
|
|
306
335
|
|
|
307
336
|
rule_options = options.merge(current_model: model_instance)
|
|
308
337
|
apply_rule(root, rule, model_instance, rule_options, model_class,
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "lutaml/json/adapter/standard_adapter"
|
|
5
|
+
|
|
6
|
+
RSpec.describe Lutaml::Json::Adapter::StandardAdapter do
|
|
7
|
+
let(:attributes) { { "name" => "John", "age" => 30 } }
|
|
8
|
+
|
|
9
|
+
describe ".parse" do
|
|
10
|
+
# json 3.0 removed create_additions and now raises ArgumentError on any
|
|
11
|
+
# unknown keyword, so the parser must be called without it.
|
|
12
|
+
it "parses without passing removed generator keywords to the json gem" do
|
|
13
|
+
expect(described_class.parse('{"name":"John","age":30}'))
|
|
14
|
+
.to eq(attributes)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe "#to_json" do
|
|
19
|
+
subject(:document) { described_class.new(attributes) }
|
|
20
|
+
|
|
21
|
+
it "serializes with no options" do
|
|
22
|
+
expect(document.to_json).to eq('{"name":"John","age":30}')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# :register and :pretty are LutaML's own options. json 2.x ignored unknown
|
|
26
|
+
# options; json 3.0 raises ArgumentError, so they must be stripped before
|
|
27
|
+
# they reach JSON.generate.
|
|
28
|
+
it "strips LutaML options that the json generator does not accept" do
|
|
29
|
+
expect(document.to_json(register: :default_register))
|
|
30
|
+
.to eq('{"name":"John","age":30}')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "strips LutaML options when generating pretty output" do
|
|
34
|
+
expect(document.to_json(pretty: true, register: :default_register))
|
|
35
|
+
.to eq(%({\n "name": "John",\n "age": 30\n}))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# An ALLOWLIST and a denylist of LutaML's own option names behave
|
|
39
|
+
# identically for :register and :pretty. Only a key nobody enumerated
|
|
40
|
+
# tells them apart -- and json 3.0 raises on it.
|
|
41
|
+
it "strips a caller option nobody enumerated" do
|
|
42
|
+
expect(document.to_json(some_unknown_option: 1))
|
|
43
|
+
.to eq('{"name":"John","age":30}')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# json 3.0 removed :escape_slash. Dropping it would silently stop escaping
|
|
47
|
+
# slashes; it is the old alias of :script_safe, so it is translated.
|
|
48
|
+
it "honours escape_slash on every json version" do
|
|
49
|
+
expect(described_class.new({ "a" => "x/y" }).to_json(escape_slash: true))
|
|
50
|
+
.to eq('{"a":"x\\/y"}')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The filter is an allowlist, not a passthrough: genuine generator options
|
|
54
|
+
# must still reach JSON.generate.
|
|
55
|
+
it "forwards options the json generator does accept" do
|
|
56
|
+
expect(document.to_json(register: :default_register, space: " "))
|
|
57
|
+
.to eq('{"name": "John","age": 30}')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe "nesting inside another JSON.generate call" do
|
|
62
|
+
subject(:document) { described_class.new(attributes) }
|
|
63
|
+
|
|
64
|
+
# Ruby's generator hands #to_json a JSON::State, not an options hash.
|
|
65
|
+
# json 3.0 removed JSON::State#[], so reading :pretty off it raises.
|
|
66
|
+
it "serializes when nested in a Hash passed to JSON.generate" do
|
|
67
|
+
expect(JSON.generate({ "doc" => document }))
|
|
68
|
+
.to eq('{"doc":{"name":"John","age":30}}')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "serializes when nested in an Array passed to JSON.generate" do
|
|
72
|
+
expect(JSON.generate([document])).to eq('[{"name":"John","age":30}]')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A DEFAULT state renders compact, which is also what a DISCARDED state
|
|
76
|
+
# produces, so the two examples above cannot tell them apart. These carry
|
|
77
|
+
# formatting, so dropping the state changes the output.
|
|
78
|
+
it "inherits the outer indent when nested in a pretty Hash" do
|
|
79
|
+
expect(JSON.pretty_generate({ "doc" => document }))
|
|
80
|
+
.to eq(%({\n "doc": {\n "name": "John",\n "age": 30\n }\n}))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "inherits the outer indent when nested in a pretty Array" do
|
|
84
|
+
expect(JSON.pretty_generate([document]))
|
|
85
|
+
.to eq(%([\n {\n "name": "John",\n "age": 30\n }\n]))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "honours a configured JSON::State handed to #to_json directly" do
|
|
89
|
+
# A DEFAULT state renders compact, which is also what a discarded state
|
|
90
|
+
# produces -- so the state has to carry formatting for this to mean
|
|
91
|
+
# anything.
|
|
92
|
+
state = JSON::State.new(indent: " ", object_nl: "\n", space: " ")
|
|
93
|
+
expect(document.to_json(state))
|
|
94
|
+
.to eq(%({\n "name": "John",\n "age": 30\n}))
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
describe Lutaml::Json::GeneratorOptions do
|
|
99
|
+
# The derived half of the allowlist reads JSON::State, which Opal's JSON
|
|
100
|
+
# shim does not define. BASE_PERMITTED is what the filter falls back to
|
|
101
|
+
# there, so it has to stand on its own.
|
|
102
|
+
# BASE_PERMITTED is the whole allowlist on Opal, whose JSON shim has no
|
|
103
|
+
# State class for DERIVED_PERMITTED to read. So it has to stand alone
|
|
104
|
+
# against the real generator, not merely be a subset of something larger.
|
|
105
|
+
it "permits only options the running generator actually accepts" do
|
|
106
|
+
rejected = described_class::BASE_PERMITTED.reject do |key|
|
|
107
|
+
JSON.generate({ "a" => 1 }, key => nil)
|
|
108
|
+
true
|
|
109
|
+
rescue ArgumentError => e
|
|
110
|
+
# "unknown keyword" means the generator does not know this option at
|
|
111
|
+
# all. Any other complaint -- a type error about the nil value we
|
|
112
|
+
# passed -- means it knows the option and only dislikes the value.
|
|
113
|
+
!e.message.include?("unknown keyword")
|
|
114
|
+
rescue StandardError
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
expect(rejected).to eq([])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Opal's JSON shim defines no State class, so DERIVED_PERMITTED is empty
|
|
122
|
+
# there and the whole module must still load. Asserting that
|
|
123
|
+
# BASE_PERMITTED contains some symbols does not exercise the guard; only
|
|
124
|
+
# loading the file with JSON::State absent does.
|
|
125
|
+
it "loads and still permits the base set when JSON::State is absent" do
|
|
126
|
+
hide_const("JSON::State")
|
|
127
|
+
|
|
128
|
+
mod = Module.new
|
|
129
|
+
mod.module_eval(
|
|
130
|
+
File.read(File.expand_path("../../../../lib/lutaml/json/generator_options.rb", __dir__)),
|
|
131
|
+
)
|
|
132
|
+
permitted = mod.const_get(:Lutaml).const_get(:Json)
|
|
133
|
+
.const_get(:GeneratorOptions)::PERMITTED
|
|
134
|
+
|
|
135
|
+
expect(permitted).to eq(described_class::BASE_PERMITTED)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "carries the formatting options pretty output depends on" do
|
|
139
|
+
expect(described_class::BASE_PERMITTED)
|
|
140
|
+
.to include(:indent, :object_nl, :space, :space_before)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "rejects a non-Hash options argument" do
|
|
144
|
+
expect(described_class.filter(nil)).to eq({})
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "treats nil as LutaML options" do
|
|
148
|
+
expect(described_class.lutaml_options?(nil)).to be(true)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "treats a Hash as LutaML options" do
|
|
152
|
+
expect(described_class.lutaml_options?({ pretty: true })).to be(true)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "does not treat a JSON::State as LutaML options" do
|
|
156
|
+
expect(described_class.lutaml_options?(JSON::State.new)).to be(false)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -43,4 +43,23 @@ RSpec.describe Lutaml::JsonLd::Adapter do
|
|
|
43
43
|
round_tripped = JSON.parse(result)
|
|
44
44
|
expect(round_tripped).to eq(jsonld_hash.transform_keys(&:to_s))
|
|
45
45
|
end
|
|
46
|
+
|
|
47
|
+
describe "a JSON::State handed to #to_jsonld" do
|
|
48
|
+
subject(:document) { described_class.new({ "@id" => "urn:x", "n" => 1 }) }
|
|
49
|
+
|
|
50
|
+
# Ruby's generator only ever calls #to_json, so a state reaches #to_jsonld
|
|
51
|
+
# only from a direct caller. json 3.0 removed JSON::State#[], so the state
|
|
52
|
+
# must be forwarded to the payload rather than read as an options hash.
|
|
53
|
+
it "honours a state that carries formatting" do
|
|
54
|
+
state = JSON::State.new(indent: " ", object_nl: "\n", space: " ")
|
|
55
|
+
|
|
56
|
+
expect(document.to_jsonld(state))
|
|
57
|
+
.to eq(%({\n "@id": "urn:x",\n "n": 1\n}))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "renders compactly for a default state" do
|
|
61
|
+
expect(document.to_jsonld(JSON::State.new))
|
|
62
|
+
.to eq('{"@id":"urn:x","n":1}')
|
|
63
|
+
end
|
|
64
|
+
end
|
|
46
65
|
end
|
|
@@ -644,7 +644,7 @@ RSpec.describe Lutaml::Model::Collection do
|
|
|
644
644
|
collection = title_collection_class.from_json(json_data)
|
|
645
645
|
|
|
646
646
|
# JSON.parse should only be called once on the main array, not on individual strings
|
|
647
|
-
expect(JSON).to have_received(:parse).once.with(json_data
|
|
647
|
+
expect(JSON).to have_received(:parse).once.with(json_data)
|
|
648
648
|
expect(collection.titles.map(&:content)).to eq(["Title One", "Title Two",
|
|
649
649
|
"Title Three"])
|
|
650
650
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# A Collection overrides #to_format and merges `collection: true` into the
|
|
6
|
+
# options BEFORE the base implementation runs. JSON::State#merge is #configure,
|
|
7
|
+
# which rejects unknown keys on json 3.0, so the state has to be wrapped first.
|
|
8
|
+
RSpec.describe "a collection nested inside JSON.generate" do
|
|
9
|
+
before do
|
|
10
|
+
stub_const("NestedItem", Class.new(Lutaml::Model::Serializable) do
|
|
11
|
+
attribute :n, :string
|
|
12
|
+
end)
|
|
13
|
+
stub_const("NestedItems", Class.new(Lutaml::Model::Collection) do
|
|
14
|
+
instances :items, NestedItem
|
|
15
|
+
end)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
let(:collection) { NestedItems.new([NestedItem.new(n: "a")]) }
|
|
19
|
+
|
|
20
|
+
it "serializes inside a Hash" do
|
|
21
|
+
expect(JSON.generate({ "c" => collection })).to eq('{"c":[{"n":"a"}]}')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "inherits the outer indent inside a pretty Hash" do
|
|
25
|
+
expect(JSON.pretty_generate({ "c" => collection }))
|
|
26
|
+
.to eq(%({\n "c": [\n {\n "n": "a"\n }\n ]\n}))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The class-level entry point normalises separately from the instance one,
|
|
30
|
+
# and deleting that normaliser leaves every instance example green.
|
|
31
|
+
it "normalises a state passed to the class-level entry point" do
|
|
32
|
+
# array_nl is what puts the newlines in an array; without it the state
|
|
33
|
+
# is not a pretty state at all and the example proves nothing.
|
|
34
|
+
state = JSON::State.new(indent: " ", object_nl: "\n", array_nl: "\n",
|
|
35
|
+
space: " ")
|
|
36
|
+
|
|
37
|
+
expect(NestedItems.to_json(collection, state))
|
|
38
|
+
.to eq(%([\n {\n "n": "a"\n }\n]))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "still serializes directly" do
|
|
42
|
+
expect(collection.to_json).to eq('[{"n":"a"}]')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "oj"
|
|
5
|
+
require "multi_json"
|
|
6
|
+
|
|
7
|
+
# What a nested model should do with the outer generator's options differs per
|
|
8
|
+
# ADAPTER, not per option. Reference values are unmodified main on json 2.21.2.
|
|
9
|
+
#
|
|
10
|
+
# stdlib honours script_safe / ascii_only / pretty -> receives the state
|
|
11
|
+
# Oj ignores them and uses its own escape_mode -> receives none of them
|
|
12
|
+
#
|
|
13
|
+
# Four earlier attempts at this got it wrong by reasoning about option names
|
|
14
|
+
# instead of measuring each adapter, so every row here is a measured cell.
|
|
15
|
+
RSpec.describe "generator options reaching a nested model" do
|
|
16
|
+
before do
|
|
17
|
+
stub_const("EscapeProbe", Class.new(Lutaml::Model::Serializable) do
|
|
18
|
+
attribute :s, :string
|
|
19
|
+
end)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
let(:model) { EscapeProbe.new(s: "</script>é") }
|
|
23
|
+
|
|
24
|
+
describe "the stdlib adapter" do
|
|
25
|
+
it "honours script_safe" do
|
|
26
|
+
expect(JSON.generate({ "m" => model }, script_safe: true))
|
|
27
|
+
.to eq('{"m":{"s":"<\\/script>é"}}')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "honours ascii_only" do
|
|
31
|
+
expect(JSON.generate({ "m" => model }, ascii_only: true))
|
|
32
|
+
.to eq('{"m":{"s":"</script>\\u00e9"}}')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "honours the outer indent" do
|
|
36
|
+
expect(JSON.pretty_generate({ "m" => model }))
|
|
37
|
+
.to eq(%({\n "m": {\n "s": "</script>é"\n }\n}))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe "the Oj adapter" do
|
|
42
|
+
around do |example|
|
|
43
|
+
previous = Oj.default_options
|
|
44
|
+
Oj.default_options = { escape_mode: :xss_safe }
|
|
45
|
+
example.run
|
|
46
|
+
Oj.default_options = previous
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Oj must keep its OWN escaping. Forwarding the generator's options -- all
|
|
50
|
+
# of them, only the caller-set ones, or just ascii_only -- each reset
|
|
51
|
+
# escape_mode and silently emitted a literal </script>.
|
|
52
|
+
it "keeps its configured escape_mode when no option is passed" do
|
|
53
|
+
result = Lutaml::Model::Config.with_adapter(json: :oj) do
|
|
54
|
+
JSON.generate({ "m" => model })
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
expect(result).to eq('{"m":{"s":"\\u003c\\/script\\u003e\\u00e9"}}')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "keeps its configured escape_mode when ascii_only is passed" do
|
|
61
|
+
result = Lutaml::Model::Config.with_adapter(json: :oj) do
|
|
62
|
+
JSON.generate({ "m" => model }, ascii_only: true)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
expect(result).to eq('{"m":{"s":"\\u003c\\/script\\u003e\\u00e9"}}')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "keeps its configured escape_mode when script_safe is passed" do
|
|
69
|
+
result = Lutaml::Model::Config.with_adapter(json: :oj) do
|
|
70
|
+
JSON.generate({ "m" => model }, script_safe: true)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
expect(result).to eq('{"m":{"s":"\\u003c\\/script\\u003e\\u00e9"}}')
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# MultiJson's json_gem backend IS the stdlib generator, so unlike Oj it does
|
|
78
|
+
# honour these options and must still receive them. That BACKEND cannot run
|
|
79
|
+
# under json 3.0 -- it sends create_additions, which json removed -- so these
|
|
80
|
+
# are asserted on the versions where it works. multi_json itself is fine
|
|
81
|
+
# under json 3.0 on its Oj backend; only json_gem is affected.
|
|
82
|
+
describe "the MultiJson adapter", if: Gem::Version.new(JSON::VERSION) < Gem::Version.new("3.0.0") do
|
|
83
|
+
before { MultiJson.use(:json_gem) }
|
|
84
|
+
|
|
85
|
+
it "still receives script_safe" do
|
|
86
|
+
result = Lutaml::Model::Config.with_adapter(json: :multi_json) do
|
|
87
|
+
JSON.generate({ "m" => model }, script_safe: true)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
expect(result).to eq('{"m":{"s":"<\\/script>é"}}')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "still receives ascii_only" do
|
|
94
|
+
result = Lutaml::Model::Config.with_adapter(json: :multi_json) do
|
|
95
|
+
JSON.generate({ "m" => model }, ascii_only: true)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
expect(result).to eq('{"m":{"s":"</script>\\u00e9"}}')
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# json 3.0 removed JSON::State#[]. Ruby's generator passes a JSON::State to
|
|
6
|
+
# #to_json whenever a model is nested inside another JSON.generate call, so the
|
|
7
|
+
# model layer must not read option keys off it.
|
|
8
|
+
RSpec.describe "a model nested inside JSON.generate" do
|
|
9
|
+
before do
|
|
10
|
+
stub_const("JsonStateNestingModel", Class.new(Lutaml::Model::Serializable) do
|
|
11
|
+
attribute :name, :string
|
|
12
|
+
attribute :age, :integer
|
|
13
|
+
end)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
let(:model) { JsonStateNestingModel.new(name: "John", age: 30) }
|
|
17
|
+
|
|
18
|
+
it "serializes inside a Hash" do
|
|
19
|
+
expect(JSON.generate({ "person" => model }))
|
|
20
|
+
.to eq('{"person":{"name":"John","age":30}}')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "serializes inside an Array" do
|
|
24
|
+
expect(JSON.generate([model])).to eq('[{"name":"John","age":30}]')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The generator's JSON::State carries the surrounding indent context. It has
|
|
28
|
+
# to reach the adapter, not be discarded, or a nested model silently renders
|
|
29
|
+
# compact inside an otherwise pretty document. Reference values are the
|
|
30
|
+
# output of unmodified main on json 2.20.0.
|
|
31
|
+
it "inherits the surrounding indent inside a pretty Hash" do
|
|
32
|
+
expect(JSON.pretty_generate({ "person" => model }))
|
|
33
|
+
.to eq(%({\n "person": {\n "name": "John",\n "age": 30\n }\n}))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "inherits the surrounding indent inside a pretty Array" do
|
|
37
|
+
expect(JSON.pretty_generate([model]))
|
|
38
|
+
.to eq(%([\n {\n "name": "John",\n "age": 30\n }\n]))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "still honours LutaML's own options when called directly" do
|
|
42
|
+
expect(model.to_json(pretty: true))
|
|
43
|
+
.to eq(%({\n "name": "John",\n "age": 30\n}))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A non-Hash options argument must be replaced, not merely captured -- a
|
|
47
|
+
# falsy one (nil, false) previously stayed in place and the next line called
|
|
48
|
+
# .delete on it.
|
|
49
|
+
it "does not raise on a nil options argument" do
|
|
50
|
+
expect(model.to_json(nil)).to eq('{"name":"John","age":30}')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "does not raise on a false options argument" do
|
|
54
|
+
expect(model.to_json(false)).to eq('{"name":"John","age":30}')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A non-Hash options argument reaches every format, not just JSON, so the
|
|
58
|
+
# normalisation must not skip format-specific validation on the way past.
|
|
59
|
+
# A type-only model has no root mapping and must refuse to serialise alone.
|
|
60
|
+
it "still applies XML root validation to a non-Hash argument" do
|
|
61
|
+
stub_const("TypeOnlyModel", Class.new(Lutaml::Model::Serializable) do
|
|
62
|
+
attribute :n, :string
|
|
63
|
+
end)
|
|
64
|
+
|
|
65
|
+
expect { TypeOnlyModel.new(n: "a").to_xml(nil) }
|
|
66
|
+
.to raise_error(Lutaml::Model::TypeOnlyMappingError)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Every example above goes through an INSTANCE entry point. The class-level
|
|
70
|
+
# one normalises separately, and deleting that normaliser leaves them all
|
|
71
|
+
# green.
|
|
72
|
+
it "normalises a state passed to the class-level entry point" do
|
|
73
|
+
state = JSON::State.new(indent: " ", object_nl: "\n", space: " ")
|
|
74
|
+
|
|
75
|
+
expect(JsonStateNestingModel.to_json(model, state))
|
|
76
|
+
.to eq(%({\n "name": "John",\n "age": 30\n}))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "leaves non-JSON formats untouched" do
|
|
80
|
+
expect(model.to_yaml).to eq("---\nname: John\nage: 30\n")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# lutaml-model#765: two element rules mapped to one collection attribute
|
|
6
|
+
# (spelling tolerance, e.g. metanorma's editorial-group/editorialgroup).
|
|
7
|
+
# Parse must merge the spellings' content in document order — never drop
|
|
8
|
+
# the first silently — and serialization must emit the value once, under
|
|
9
|
+
# the first declared spelling.
|
|
10
|
+
module SharedElementRulesSpec
|
|
11
|
+
class Both < Lutaml::Model::Serializable
|
|
12
|
+
attribute :items, :string, collection: true
|
|
13
|
+
|
|
14
|
+
xml do
|
|
15
|
+
root "r"
|
|
16
|
+
map_element "a", to: :items
|
|
17
|
+
map_element "b", to: :items
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
RSpec.describe "multiple element rules on one attribute" do
|
|
23
|
+
it "appends both spellings in document order" do
|
|
24
|
+
expect(SharedElementRulesSpec::Both.from_xml("<r><a>one</a><b>two</b></r>").items)
|
|
25
|
+
.to eq(["one", "two"])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "appends in document order when the second spelling comes first" do
|
|
29
|
+
expect(SharedElementRulesSpec::Both.from_xml("<r><b>two</b><a>one</a></r>").items)
|
|
30
|
+
.to eq(["two", "one"])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "keeps document order across interleaved spellings" do
|
|
34
|
+
xml = "<r><a>1</a><b>2</b><a>3</a><b>4</b></r>"
|
|
35
|
+
expect(SharedElementRulesSpec::Both.from_xml(xml).items).to eq(%w[1 2 3 4])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "parses a document that only uses the first spelling" do
|
|
39
|
+
expect(SharedElementRulesSpec::Both.from_xml("<r><a>one</a></r>").items)
|
|
40
|
+
.to eq(["one"])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "parses a document that only uses the second spelling" do
|
|
44
|
+
expect(SharedElementRulesSpec::Both.from_xml("<r><b>two</b></r>").items)
|
|
45
|
+
.to eq(["two"])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "serializes the value once, under the first declared spelling" do
|
|
49
|
+
xml = SharedElementRulesSpec::Both.new(items: %w[one two]).to_xml
|
|
50
|
+
expect(xml.scan(/<(a|b)>/).flatten).to eq(%w[a a])
|
|
51
|
+
expect(xml).to include("<a>one</a>").and include("<a>two</a>")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "round-trips through both spellings without losing content" do
|
|
55
|
+
doc = SharedElementRulesSpec::Both.from_xml("<r><a>one</a><b>two</b></r>")
|
|
56
|
+
reparsed = SharedElementRulesSpec::Both.from_xml(doc.to_xml)
|
|
57
|
+
expect(reparsed.items).to eq(%w[one two])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "keeps the mapping usable when the attribute also has a default" do
|
|
61
|
+
both = SharedElementRulesSpec::Both.from_xml("<r/>")
|
|
62
|
+
expect(both.items).to eq([])
|
|
63
|
+
end
|
|
64
|
+
end
|
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.23
|
|
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-08
|
|
11
|
+
date: 2026-09-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -321,6 +321,7 @@ files:
|
|
|
321
321
|
- lib/lutaml/json/adapter/standard_adapter.rb
|
|
322
322
|
- lib/lutaml/json/adapter/transform.rb
|
|
323
323
|
- lib/lutaml/json/format.rb
|
|
324
|
+
- lib/lutaml/json/generator_options.rb
|
|
324
325
|
- lib/lutaml/json/schema.rb
|
|
325
326
|
- lib/lutaml/json/schema/json_schema.rb
|
|
326
327
|
- lib/lutaml/json/type/serializers.rb
|
|
@@ -1638,6 +1639,7 @@ files:
|
|
|
1638
1639
|
- spec/lutaml/integration/edge_cases_spec.rb
|
|
1639
1640
|
- spec/lutaml/integration/multi_format_spec.rb
|
|
1640
1641
|
- spec/lutaml/integration/round_trip_spec.rb
|
|
1642
|
+
- spec/lutaml/json/adapter/standard_adapter_spec.rb
|
|
1641
1643
|
- spec/lutaml/jsonld/adapter_spec.rb
|
|
1642
1644
|
- spec/lutaml/key_value/transformation/collection_serializer_spec.rb
|
|
1643
1645
|
- spec/lutaml/key_value/transformation/rule_compiler_spec.rb
|
|
@@ -1657,6 +1659,7 @@ files:
|
|
|
1657
1659
|
- spec/lutaml/model/collection_index_spec.rb
|
|
1658
1660
|
- spec/lutaml/model/collection_mutation_spec.rb
|
|
1659
1661
|
- spec/lutaml/model/collection_spec.rb
|
|
1662
|
+
- spec/lutaml/model/collection_state_nesting_spec.rb
|
|
1660
1663
|
- spec/lutaml/model/collection_validation_spec.rb
|
|
1661
1664
|
- spec/lutaml/model/comparable_model_spec.rb
|
|
1662
1665
|
- spec/lutaml/model/compiled_rule_spec.rb
|
|
@@ -1677,6 +1680,7 @@ files:
|
|
|
1677
1680
|
- spec/lutaml/model/enum_spec.rb
|
|
1678
1681
|
- spec/lutaml/model/except_spec.rb
|
|
1679
1682
|
- spec/lutaml/model/finalization_cache_spec.rb
|
|
1683
|
+
- spec/lutaml/model/generator_defaults_spec.rb
|
|
1680
1684
|
- spec/lutaml/model/global_context_spec.rb
|
|
1681
1685
|
- spec/lutaml/model/global_register_spec.rb
|
|
1682
1686
|
- spec/lutaml/model/group_spec.rb
|
|
@@ -1686,6 +1690,7 @@ files:
|
|
|
1686
1690
|
- spec/lutaml/model/inheritance_spec.rb
|
|
1687
1691
|
- spec/lutaml/model/json_adapter_spec.rb
|
|
1688
1692
|
- spec/lutaml/model/json_spec.rb
|
|
1693
|
+
- spec/lutaml/model/json_state_nesting_spec.rb
|
|
1689
1694
|
- spec/lutaml/model/jsonl/standard_adapter_spec.rb
|
|
1690
1695
|
- spec/lutaml/model/jsonl_spec.rb
|
|
1691
1696
|
- spec/lutaml/model/key_value_data_model/key_value_element_spec.rb
|
|
@@ -1916,6 +1921,7 @@ files:
|
|
|
1916
1921
|
- spec/lutaml/xml/schema/xsd/xsd_spec.rb
|
|
1917
1922
|
- spec/lutaml/xml/schema_primer_spec.rb
|
|
1918
1923
|
- spec/lutaml/xml/serializable_namespace_spec.rb
|
|
1924
|
+
- spec/lutaml/xml/shared_element_rules_spec.rb
|
|
1919
1925
|
- spec/lutaml/xml/transformation/custom_method_wrapper_spec.rb
|
|
1920
1926
|
- spec/lutaml/xml/transformation_spec.rb
|
|
1921
1927
|
- spec/lutaml/xml/type_namespace/collector_spec.rb
|