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.
- checksums.yaml +4 -4
- data/.github/workflows/performance.yml +1 -1
- data/Gemfile +6 -0
- data/README.adoc +16 -7
- data/docs/_guides/index.adoc +4 -0
- data/docs/_guides/native-engines.adoc +61 -0
- data/docs/_guides/xml-mapping.adoc +31 -0
- data/docs/_pages/configuration.adoc +11 -7
- data/docs/_pages/serialization_adapters.adoc +14 -5
- data/lib/compat/opal/lutaml_model_boot.rb +2 -0
- data/lib/lutaml/json/adapter/multi_json_adapter.rb +12 -1
- data/lib/lutaml/json/adapter/oj_adapter.rb +7 -0
- data/lib/lutaml/json/adapter/standard_adapter.rb +17 -5
- data/lib/lutaml/json/adapter/yeptris_adapter.rb +21 -0
- data/lib/lutaml/json/adapter.rb +1 -0
- data/lib/lutaml/json/format.rb +1 -1
- 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/multi_json_adapter.rb +16 -1
- 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/json/yeptris_adapter.rb +15 -0
- data/lib/lutaml/key_value/adapter/jsonl/standard_adapter.rb +1 -1
- data/lib/lutaml/key_value/adapter/toml/teptris_adapter.rb +42 -0
- data/lib/lutaml/key_value/adapter/toml.rb +1 -0
- data/lib/lutaml/key_value/adapter/yaml/yeptris_adapter.rb +15 -0
- data/lib/lutaml/key_value/transform.rb +82 -0
- data/lib/lutaml/key_value/transformation/value_serializer.rb +25 -0
- data/lib/lutaml/key_value/transformation.rb +25 -1
- data/lib/lutaml/model/adapter_resolver.rb +23 -6
- data/lib/lutaml/model/attribute.rb +31 -6
- data/lib/lutaml/model/collection.rb +5 -0
- data/lib/lutaml/model/configuration.rb +5 -0
- data/lib/lutaml/model/error/unknown_adapter_type_error.rb +4 -4
- data/lib/lutaml/model/serialization_adapter.rb +1 -1
- data/lib/lutaml/model/serialize/format_conversion.rb +75 -7
- data/lib/lutaml/model/serialize.rb +18 -0
- data/lib/lutaml/model/toml.rb +10 -6
- data/lib/lutaml/model/transformation.rb +5 -0
- data/lib/lutaml/model/type/date_time.rb +2 -2
- data/lib/lutaml/model/type/value.rb +24 -2
- data/lib/lutaml/model/utils.rb +6 -5
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/toml/adapter/teptris_adapter.rb +14 -0
- data/lib/lutaml/toml/adapter.rb +1 -0
- data/lib/lutaml/toml/format.rb +1 -1
- data/lib/lutaml/xml/adapter/leptris_adapter.rb +15 -0
- data/lib/lutaml/xml/adapter_element.rb +111 -3
- data/lib/lutaml/xml/builder/base.rb +3 -1
- data/lib/lutaml/xml/builder/leptris.rb +13 -0
- data/lib/lutaml/xml/builder/nokogiri.rb +3 -0
- data/lib/lutaml/xml/builder.rb +1 -0
- data/lib/lutaml/xml/format.rb +4 -1
- data/lib/lutaml/xml/leptris/element.rb +34 -0
- data/lib/lutaml/xml/mapping.rb +24 -0
- data/lib/lutaml/xml/model_transform.rb +52 -1
- data/lib/lutaml/xml/plan_compiler.rb +139 -0
- data/lib/lutaml/xml/plan_hydrator.rb +80 -0
- data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +8 -0
- data/lib/lutaml/xml/transformation.rb +29 -0
- data/lib/lutaml/xml/xml_element.rb +6 -2
- data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +40 -0
- data/lib/lutaml/yaml/adapter.rb +5 -0
- data/lib/lutaml/yaml/format.rb +1 -1
- data/lib/tasks/performance.rake +5 -0
- data/lib/tasks/performance_comparator.rb +19 -3
- data/lib/tasks/performance_probe.rake +33 -0
- data/lib/tasks/performance_probe.rb +106 -0
- data/lib/tasks/performance_probe_xml.rb +54 -0
- data/lutaml-model.gemspec +1 -1
- data/spec/lutaml/json/adapter/standard_adapter_spec.rb +159 -0
- data/spec/lutaml/json/yeptris_adapter_spec.rb +63 -0
- data/spec/lutaml/jsonld/adapter_spec.rb +19 -0
- data/spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb +71 -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_adapter_spec.rb +6 -0
- data/spec/lutaml/model/json_state_nesting_spec.rb +82 -0
- data/spec/lutaml/model/toml_adapter_spec.rb +8 -0
- data/spec/lutaml/model/toml_spec.rb +36 -1
- data/spec/lutaml/xml/plan_fast_path_spec.rb +96 -0
- data/spec/lutaml/xml/shared_element_rules_spec.rb +64 -0
- data/spec/lutaml/xml/xml_spec.rb +13 -0
- data/spec/lutaml/yaml/yeptris_adapter_spec.rb +63 -0
- data/spec/spec_helper.rb +7 -1
- 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
|
|
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)
|