lutaml-model 0.8.22 → 0.8.24

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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/performance.yml +1 -1
  3. data/Gemfile +6 -0
  4. data/README.adoc +16 -7
  5. data/docs/_guides/index.adoc +4 -0
  6. data/docs/_guides/native-engines.adoc +61 -0
  7. data/docs/_guides/xml-mapping.adoc +31 -0
  8. data/docs/_pages/configuration.adoc +11 -7
  9. data/docs/_pages/serialization_adapters.adoc +14 -5
  10. data/lib/compat/opal/lutaml_model_boot.rb +2 -0
  11. data/lib/lutaml/json/adapter/multi_json_adapter.rb +12 -1
  12. data/lib/lutaml/json/adapter/oj_adapter.rb +7 -0
  13. data/lib/lutaml/json/adapter/standard_adapter.rb +17 -5
  14. data/lib/lutaml/json/adapter/yeptris_adapter.rb +21 -0
  15. data/lib/lutaml/json/adapter.rb +1 -0
  16. data/lib/lutaml/json/format.rb +1 -1
  17. data/lib/lutaml/json/generator_options.rb +62 -0
  18. data/lib/lutaml/jsonl/adapter/standard_adapter.rb +1 -1
  19. data/lib/lutaml/jsonld/adapter.rb +12 -4
  20. data/lib/lutaml/key_value/adapter/json/multi_json_adapter.rb +16 -1
  21. data/lib/lutaml/key_value/adapter/json/oj_adapter.rb +7 -0
  22. data/lib/lutaml/key_value/adapter/json/standard_adapter.rb +17 -5
  23. data/lib/lutaml/key_value/adapter/json/yeptris_adapter.rb +15 -0
  24. data/lib/lutaml/key_value/adapter/jsonl/standard_adapter.rb +1 -1
  25. data/lib/lutaml/key_value/adapter/toml/teptris_adapter.rb +42 -0
  26. data/lib/lutaml/key_value/adapter/toml.rb +1 -0
  27. data/lib/lutaml/key_value/adapter/yaml/yeptris_adapter.rb +15 -0
  28. data/lib/lutaml/key_value/transform.rb +82 -0
  29. data/lib/lutaml/key_value/transformation/value_serializer.rb +25 -0
  30. data/lib/lutaml/key_value/transformation.rb +25 -1
  31. data/lib/lutaml/model/adapter_resolver.rb +23 -6
  32. data/lib/lutaml/model/attribute.rb +31 -6
  33. data/lib/lutaml/model/collection.rb +5 -0
  34. data/lib/lutaml/model/configuration.rb +5 -0
  35. data/lib/lutaml/model/error/unknown_adapter_type_error.rb +4 -4
  36. data/lib/lutaml/model/serialization_adapter.rb +1 -1
  37. data/lib/lutaml/model/serialize/format_conversion.rb +75 -7
  38. data/lib/lutaml/model/serialize.rb +18 -0
  39. data/lib/lutaml/model/toml.rb +10 -6
  40. data/lib/lutaml/model/transformation.rb +5 -0
  41. data/lib/lutaml/model/type/date_time.rb +2 -2
  42. data/lib/lutaml/model/type/value.rb +24 -2
  43. data/lib/lutaml/model/utils.rb +6 -5
  44. data/lib/lutaml/model/version.rb +1 -1
  45. data/lib/lutaml/toml/adapter/teptris_adapter.rb +14 -0
  46. data/lib/lutaml/toml/adapter.rb +1 -0
  47. data/lib/lutaml/toml/format.rb +1 -1
  48. data/lib/lutaml/xml/adapter/leptris_adapter.rb +15 -0
  49. data/lib/lutaml/xml/adapter_element.rb +111 -3
  50. data/lib/lutaml/xml/builder/base.rb +3 -1
  51. data/lib/lutaml/xml/builder/leptris.rb +13 -0
  52. data/lib/lutaml/xml/builder/nokogiri.rb +3 -0
  53. data/lib/lutaml/xml/builder.rb +1 -0
  54. data/lib/lutaml/xml/format.rb +4 -1
  55. data/lib/lutaml/xml/leptris/element.rb +34 -0
  56. data/lib/lutaml/xml/mapping.rb +24 -0
  57. data/lib/lutaml/xml/model_transform.rb +52 -1
  58. data/lib/lutaml/xml/plan_compiler.rb +139 -0
  59. data/lib/lutaml/xml/plan_hydrator.rb +80 -0
  60. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +8 -0
  61. data/lib/lutaml/xml/transformation.rb +29 -0
  62. data/lib/lutaml/xml/xml_element.rb +6 -2
  63. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +40 -0
  64. data/lib/lutaml/yaml/adapter.rb +5 -0
  65. data/lib/lutaml/yaml/format.rb +1 -1
  66. data/lib/tasks/performance.rake +5 -0
  67. data/lib/tasks/performance_comparator.rb +19 -3
  68. data/lib/tasks/performance_probe.rake +33 -0
  69. data/lib/tasks/performance_probe.rb +106 -0
  70. data/lib/tasks/performance_probe_xml.rb +54 -0
  71. data/lutaml-model.gemspec +1 -1
  72. data/spec/lutaml/json/adapter/standard_adapter_spec.rb +159 -0
  73. data/spec/lutaml/json/yeptris_adapter_spec.rb +63 -0
  74. data/spec/lutaml/jsonld/adapter_spec.rb +19 -0
  75. data/spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb +71 -0
  76. data/spec/lutaml/model/collection_spec.rb +1 -1
  77. data/spec/lutaml/model/collection_state_nesting_spec.rb +44 -0
  78. data/spec/lutaml/model/generator_defaults_spec.rb +101 -0
  79. data/spec/lutaml/model/json_adapter_spec.rb +6 -0
  80. data/spec/lutaml/model/json_state_nesting_spec.rb +82 -0
  81. data/spec/lutaml/model/toml_adapter_spec.rb +8 -0
  82. data/spec/lutaml/model/toml_spec.rb +36 -1
  83. data/spec/lutaml/xml/plan_fast_path_spec.rb +96 -0
  84. data/spec/lutaml/xml/shared_element_rules_spec.rb +64 -0
  85. data/spec/lutaml/xml/xml_spec.rb +13 -0
  86. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +63 -0
  87. data/spec/spec_helper.rb +7 -1
  88. metadata +29 -4
@@ -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
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ begin
5
+ require "lutaml/json/adapter/yeptris_adapter"
6
+ rescue LoadError
7
+ # yeptris ships no platform gem here (e.g. Windows): every example skips.
8
+ end
9
+
10
+ YEPTRIS_AVAILABLE = defined?(Yeptris)
11
+
12
+ RSpec.describe(YEPTRIS_AVAILABLE ? Lutaml::Json::Adapter::YeptrisAdapter : Object) do
13
+ before { skip "yeptris is not available on this platform" unless YEPTRIS_AVAILABLE }
14
+
15
+ let(:attributes) { { "name" => "John", "age" => 30, "roles" => %w[admin dev] } }
16
+
17
+ describe ".parse" do
18
+ it "parses a JSON document into a hash" do
19
+ expect(described_class.parse('{"name":"John","age":30,"roles":["admin","dev"]}'))
20
+ .to eq(attributes)
21
+ end
22
+
23
+ it "parses scalars and nulls like JSON.parse" do
24
+ expect(described_class.parse('{"a":1.5,"b":null,"c":true,"d":"1e3"}'))
25
+ .to eq("a" => 1.5, "b" => nil, "c" => true, "d" => "1e3")
26
+ end
27
+
28
+ it "raises on invalid JSON" do
29
+ expect { described_class.parse("{nope") }
30
+ .to raise_error(Yeptris::JSON::ParseError)
31
+ end
32
+ end
33
+
34
+ describe "#to_json" do
35
+ subject(:document) { described_class.new(attributes) }
36
+
37
+ it "serializes via the inherited json generator" do
38
+ expect(document.to_json)
39
+ .to eq('{"name":"John","age":30,"roles":["admin","dev"]}')
40
+ end
41
+ end
42
+
43
+ describe "round trip through a model" do
44
+ let(:model_class) do
45
+ Class.new(Lutaml::Model::Serializable) do
46
+ attribute :name, :string
47
+ attribute :roles, :string, collection: true
48
+
49
+ key_value do
50
+ map "name", to: :name
51
+ map "roles", to: :roles
52
+ end
53
+ end
54
+ end
55
+
56
+ it "deserializes via the yeptris engine and serializes back" do
57
+ model = model_class.from_json('{"name":"John","roles":["admin","dev"]}')
58
+ expect(model.name).to eq("John")
59
+ expect(model.roles).to eq(%w[admin dev])
60
+ expect(model_class.from_json(model.to_json).roles).to eq(%w[admin dev])
61
+ end
62
+ end
63
+ 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
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/key_value/adapter/toml/teptris_adapter"
5
+ begin
6
+ require "teptris"
7
+ rescue LoadError
8
+ # teptris is not resolvable here (absent or broken native library):
9
+ # every example skips.
10
+ end
11
+
12
+ TEPTRIS_AVAILABLE = defined?(Teptris)
13
+
14
+ RSpec.describe(TEPTRIS_AVAILABLE ? Lutaml::KeyValue::Adapter::Toml::TeptrisAdapter : Object) do
15
+ before { skip "teptris is not available" unless TEPTRIS_AVAILABLE }
16
+
17
+ describe ".parse" do
18
+ it "parses a TOML document into a hash" do
19
+ expect(described_class.parse("name = \"John\"\nage = 30\n"))
20
+ .to eq("name" => "John", "age" => 30)
21
+ end
22
+
23
+ it "parses nested tables and arrays" do
24
+ toml = <<~TOML
25
+ title = "cfg"
26
+ [owner]
27
+ name = "t"
28
+ tags = ["a", "b"]
29
+ TOML
30
+ expect(described_class.parse(toml))
31
+ .to eq("title" => "cfg", "owner" => { "name" => "t", "tags" => %w[a b] })
32
+ end
33
+
34
+ it "materializes dates and datetimes like tomlib" do
35
+ require "date"
36
+ doc = described_class.parse("d = 1979-05-27\ndt = 1979-05-27T07:32:00Z\n")
37
+ expect(doc["d"]).to be_a(Date)
38
+ expect(doc["dt"]).to be_a(Time)
39
+ end
40
+
41
+ it "reports parse errors with line and column" do
42
+ expect { described_class.parse("a = [1,") }
43
+ .to raise_error(Teptris::ParseError) do |e|
44
+ expect(e.line).to eq(1)
45
+ expect(e.column).to eq(8)
46
+ end
47
+ end
48
+ end
49
+
50
+ describe "#to_toml" do
51
+ it "dumps a hash to TOML" do
52
+ attributes = { "name" => "x", "nested" => { "a" => [1, 2] } }
53
+ expect(described_class.new(attributes).to_toml)
54
+ .to include("name", "[nested]", "a = [1, 2]")
55
+ end
56
+
57
+ it "unwraps the __root__ KeyValueElement wrapper" do
58
+ element = Lutaml::KeyValue::DataModel::Element.new("__root__")
59
+ name_child = Lutaml::KeyValue::DataModel::Element.new("name")
60
+ name_child.value = "x"
61
+ element.add_child(name_child)
62
+ expect(described_class.new(element).to_toml).to include("name")
63
+ end
64
+ end
65
+
66
+ it "round-trips through the model layer" do
67
+ attributes = { "name" => "John", "age" => 30 }
68
+ adapter_toml = described_class.new(attributes).to_toml
69
+ expect(described_class.parse(adapter_toml)).to eq(attributes)
70
+ end
71
+ 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, anything)
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
@@ -1,4 +1,5 @@
1
1
  require "spec_helper"
2
+ require "multi_json"
2
3
  require "lutaml/key_value/adapter/json/standard_adapter"
3
4
  require "lutaml/key_value/adapter/json/multi_json_adapter"
4
5
  require "lutaml/key_value/adapter/json/oj_adapter"
@@ -37,6 +38,11 @@ RSpec.describe "JsonAdapter" do
37
38
  end
38
39
 
39
40
  describe Lutaml::KeyValue::Adapter::Json::MultiJsonAdapter do
41
+ # multi_json 1.21's json_gem adapter passes options positionally to
42
+ # JSON.parse, which json >= 3 removed (intridea/multi_json#227).
43
+ if Gem::Version.new(JSON::VERSION) >= Gem::Version.new("3")
44
+ before { skip "multi_json is incompatible with json #{JSON::VERSION} (intridea/multi_json#227)" }
45
+ end
40
46
  it_behaves_like "a JSON adapter", described_class
41
47
  end
42
48
 
@@ -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
@@ -17,6 +17,8 @@ RSpec.describe "TomlAdapter" do
17
17
  TomlRB.dump(attributes)
18
18
  elsif adapter_class == Lutaml::Model::Toml::TomlibAdapter
19
19
  Tomlib.dump(attributes)
20
+ elsif adapter_class == Lutaml::Model::Toml::TeptrisAdapter && defined?(Teptris)
21
+ Teptris::TOML.dump(attributes)
20
22
  end
21
23
  end
22
24
 
@@ -45,4 +47,10 @@ RSpec.describe "TomlAdapter" do
45
47
  it_behaves_like "a TOML adapter", described_class
46
48
  end
47
49
  end
50
+
51
+ if defined?(Teptris)
52
+ describe Lutaml::Model::Toml::TeptrisAdapter do
53
+ it_behaves_like "a TOML adapter", described_class
54
+ end
55
+ end
48
56
  end
@@ -10,7 +10,7 @@ RSpec.describe Lutaml::Model::Toml do
10
10
  end
11
11
 
12
12
  it "does not autoload native TOML adapters on Opal" do
13
- %i[TomlRbAdapter TomlibAdapter].each do |constant_name|
13
+ %i[TomlRbAdapter TomlibAdapter TeptrisAdapter].each do |constant_name|
14
14
  hide_const("Lutaml::Toml::Adapter::#{constant_name}")
15
15
  end
16
16
 
@@ -19,12 +19,14 @@ RSpec.describe Lutaml::Model::Toml do
19
19
 
20
20
  expect(Lutaml::Toml::Adapter.autoload?(:TomlRbAdapter)).to be_nil
21
21
  expect(Lutaml::Toml::Adapter.autoload?(:TomlibAdapter)).to be_nil
22
+ expect(Lutaml::Toml::Adapter.autoload?(:TeptrisAdapter)).to be_nil
22
23
  end
23
24
  end
24
25
 
25
26
  describe ".detect_toml_adapter" do
26
27
  before do
27
28
  # Hide any existing constants first
29
+ hide_const("Teptris") if Object.const_defined?(:Teptris)
28
30
  hide_const("Tomlib") if Object.const_defined?(:Tomlib)
29
31
  hide_const("TomlRb") if Object.const_defined?(:TomlRb)
30
32
 
@@ -32,6 +34,39 @@ RSpec.describe Lutaml::Model::Toml do
32
34
  allow(Lutaml::Model::Utils).to receive(:require).and_return(true)
33
35
  end
34
36
 
37
+ context "when teptris is available" do
38
+ before do
39
+ stub_const("Teptris", Module.new)
40
+ end
41
+
42
+ it "returns :teptris on non-Windows platforms (tomlib absent)" do
43
+ allow(Gem).to receive(:win_platform?).and_return(false)
44
+ expect(described_class.detect_toml_adapter).to eq(:teptris)
45
+ end
46
+
47
+ it "returns :teptris on Windows (native TOML without tomlib segfaults)" do
48
+ allow(Gem).to receive(:win_platform?).and_return(true)
49
+ expect(described_class.detect_toml_adapter).to eq(:teptris)
50
+ end
51
+ end
52
+
53
+ context "when teptris and tomlib are both available" do
54
+ before do
55
+ stub_const("Teptris", Module.new)
56
+ stub_const("Tomlib", Module.new)
57
+ end
58
+
59
+ it "prefers teptris on non-Windows (0.2.12+ beats tomlib both directions)" do
60
+ allow(Gem).to receive(:win_platform?).and_return(false)
61
+ expect(described_class.detect_toml_adapter).to eq(:teptris)
62
+ end
63
+
64
+ it "prefers teptris on Windows" do
65
+ allow(Gem).to receive(:win_platform?).and_return(true)
66
+ expect(described_class.detect_toml_adapter).to eq(:teptris)
67
+ end
68
+ end
69
+
35
70
  context "when Tomlib is available" do
36
71
  before do
37
72
  stub_const("Tomlib", Module.new)