lutaml-model 0.8.18 → 0.9.1
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.
Potentially problematic release.
This version of lutaml-model might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/.github/workflows/dependent-tests.yml +1 -1
- data/.github/workflows/rake.yml +1 -0
- data/.rubocop_todo.yml +133 -72
- data/bench/bench_common.rb +6 -3
- data/bench/bench_niso.rb +1 -2
- data/docs/_guides/builder-dsl.adoc +127 -0
- data/lib/compat/opal/generate_boot.rb +3 -1
- data/lib/lutaml/key_value/transform.rb +4 -1
- data/lib/lutaml/key_value/transformation/value_serializer.rb +0 -2
- data/lib/lutaml/model/attribute.rb +23 -2
- data/lib/lutaml/model/global_context.rb +4 -2
- data/lib/lutaml/model/schema/class_loader.rb +2 -1
- data/lib/lutaml/model/schema/definitions/namespace.rb +2 -1
- data/lib/lutaml/model/schema/file_writer.rb +5 -2
- data/lib/lutaml/model/schema/renderers/base.rb +2 -1
- data/lib/lutaml/model/schema/renderers/mappings.rb +9 -3
- data/lib/lutaml/model/schema/renderers/member_decls.rb +9 -3
- data/lib/lutaml/model/schema/renderers/model.rb +16 -4
- data/lib/lutaml/model/schema/renderers/required_files_calculator.rb +2 -1
- data/lib/lutaml/model/schema/renderers.rb +2 -1
- data/lib/lutaml/model/schema/rnc_compiler/source_resolver.rb +6 -2
- data/lib/lutaml/model/schema/rnc_compiler.rb +3 -1
- data/lib/lutaml/model/schema/rng_compiler/element_visitor.rb +35 -15
- data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +4 -2
- data/lib/lutaml/model/schema/rng_compiler.rb +10 -5
- data/lib/lutaml/model/schema/xml_compiler/element_order.rb +2 -1
- data/lib/lutaml/model/schema/xml_compiler/spec_builder/complex_types.rb +4 -2
- data/lib/lutaml/model/schema/xml_compiler/spec_builder/members.rb +39 -13
- data/lib/lutaml/model/schema/xml_compiler/spec_builder.rb +29 -10
- data/lib/lutaml/model/schema/xml_compiler.rb +10 -5
- data/lib/lutaml/model/serialize/attribute_definition.rb +10 -9
- data/lib/lutaml/model/serialize/builder.rb +55 -0
- data/lib/lutaml/model/serialize/initialization.rb +12 -3
- data/lib/lutaml/model/serialize/model_import.rb +1 -0
- data/lib/lutaml/model/serialize.rb +5 -4
- data/lib/lutaml/model/union.rb +5 -2
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/mapping_rule.rb +55 -1
- data/lib/lutaml/xml/model_transform.rb +53 -5
- data/lib/lutaml/xml/schema/xsd.rb +0 -2
- data/lib/lutaml/xml/schema/xsd_schema.rb +2 -1
- data/lib/lutaml/xml/transformation/element_builder.rb +2 -1
- data/lib/lutaml/xml/transformation/ordered_applier.rb +99 -4
- data/lutaml-model.gemspec +4 -1
- data/spec/lutaml/model/attribute_apply_value_map_spec.rb +26 -13
- data/spec/lutaml/model/attribute_spec.rb +8 -3
- data/spec/lutaml/model/custom_from_empty_values_spec.rb +74 -0
- data/spec/lutaml/model/defaults_spec.rb +45 -0
- data/spec/lutaml/model/json_spec.rb +1 -2
- data/spec/lutaml/model/ordered_content_spec.rb +18 -7
- data/spec/lutaml/model/serialize/builder_spec.rb +361 -0
- data/spec/lutaml/model/transform_apply_value_map_spec.rb +6 -3
- data/spec/lutaml/model/union_attribute_spec.rb +4 -2
- data/spec/lutaml/xml/attribute_namespace_disjointness_spec.rb +125 -0
- data/spec/lutaml/xml/schema/rnc_compiler_spec.rb +17 -6
- data/spec/lutaml/xml/schema/rng_compiler_spec.rb +15 -6
- data/spec/lutaml/xml/w3c_types_spec.rb +3 -6
- metadata +7 -3
|
@@ -18,7 +18,8 @@ RSpec.describe Lutaml::Model::Attribute, "#apply_value_map" do
|
|
|
18
18
|
|
|
19
19
|
it "maps nil through value_map[:nil] => :empty to [] (collection)" do
|
|
20
20
|
expect(
|
|
21
|
-
attribute(:string, collection: true).apply_value_map(nil,
|
|
21
|
+
attribute(:string, collection: true).apply_value_map(nil,
|
|
22
|
+
{ nil: :empty }),
|
|
22
23
|
).to eq([])
|
|
23
24
|
end
|
|
24
25
|
|
|
@@ -34,13 +35,15 @@ RSpec.describe Lutaml::Model::Attribute, "#apply_value_map" do
|
|
|
34
35
|
custom_collection_class = Class.new(Lutaml::Model::Collection) do
|
|
35
36
|
instances :items, member_class
|
|
36
37
|
end
|
|
37
|
-
attr = described_class.new("items", member_class,
|
|
38
|
+
attr = described_class.new("items", member_class,
|
|
39
|
+
collection: custom_collection_class)
|
|
38
40
|
result = attr.apply_value_map(nil, { nil: :empty })
|
|
39
41
|
expect(result).to be_a(custom_collection_class)
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
it "maps nil through value_map[:nil] => :omitted to UninitializedClass" do
|
|
43
|
-
expect(attribute(:string).apply_value_map(nil,
|
|
45
|
+
expect(attribute(:string).apply_value_map(nil,
|
|
46
|
+
{ nil: :omitted })).to eq(uninit)
|
|
44
47
|
end
|
|
45
48
|
end
|
|
46
49
|
|
|
@@ -50,12 +53,14 @@ RSpec.describe Lutaml::Model::Attribute, "#apply_value_map" do
|
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
it "maps '' through value_map[:empty] => :empty preserving '' for scalar" do
|
|
53
|
-
expect(attribute(:string).apply_value_map("",
|
|
56
|
+
expect(attribute(:string).apply_value_map("",
|
|
57
|
+
{ empty: :empty })).to eq("")
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
it "maps [] through value_map[:empty] => :empty preserving [] for collection" do
|
|
57
61
|
expect(
|
|
58
|
-
attribute(:string, collection: true).apply_value_map([],
|
|
62
|
+
attribute(:string, collection: true).apply_value_map([],
|
|
63
|
+
{ empty: :empty }),
|
|
59
64
|
).to eq([])
|
|
60
65
|
end
|
|
61
66
|
|
|
@@ -69,11 +74,13 @@ RSpec.describe Lutaml::Model::Attribute, "#apply_value_map" do
|
|
|
69
74
|
|
|
70
75
|
describe "omitted handling" do
|
|
71
76
|
it "maps UninitializedClass through value_map[:omitted] => :nil" do
|
|
72
|
-
expect(attribute(:string).apply_value_map(uninit,
|
|
77
|
+
expect(attribute(:string).apply_value_map(uninit,
|
|
78
|
+
{ omitted: :nil })).to be_nil
|
|
73
79
|
end
|
|
74
80
|
|
|
75
81
|
it "maps UninitializedClass through value_map[:omitted] => :empty" do
|
|
76
|
-
expect(attribute(:string).apply_value_map(uninit,
|
|
82
|
+
expect(attribute(:string).apply_value_map(uninit,
|
|
83
|
+
{ omitted: :empty })).to eq("")
|
|
77
84
|
end
|
|
78
85
|
|
|
79
86
|
it "does NOT route UninitializedClass through the :empty branch" do
|
|
@@ -81,18 +88,21 @@ RSpec.describe Lutaml::Model::Attribute, "#apply_value_map" do
|
|
|
81
88
|
# utils.rb:132-137. Ensure dispatch order matches the originals: uninit
|
|
82
89
|
# values go to :omitted, not :empty.
|
|
83
90
|
expect(
|
|
84
|
-
attribute(:string).apply_value_map(uninit,
|
|
91
|
+
attribute(:string).apply_value_map(uninit,
|
|
92
|
+
{ empty: :nil, omitted: :empty }),
|
|
85
93
|
).to eq("")
|
|
86
94
|
end
|
|
87
95
|
end
|
|
88
96
|
|
|
89
97
|
describe "Boolean bare-form (Boolean attribute type required)" do
|
|
90
98
|
it "returns false directly for Boolean attribute, value_map[:empty] = false" do
|
|
91
|
-
expect(attribute(:boolean).apply_value_map("",
|
|
99
|
+
expect(attribute(:boolean).apply_value_map("",
|
|
100
|
+
{ empty: false })).to be(false)
|
|
92
101
|
end
|
|
93
102
|
|
|
94
103
|
it "returns true directly for Boolean attribute, value_map[:omitted] = true" do
|
|
95
|
-
expect(attribute(:boolean).apply_value_map(uninit,
|
|
104
|
+
expect(attribute(:boolean).apply_value_map(uninit,
|
|
105
|
+
{ omitted: true })).to be(true)
|
|
96
106
|
end
|
|
97
107
|
|
|
98
108
|
it "does NOT apply bare-boolean form to a non-Boolean attribute (documented tightening)" do
|
|
@@ -106,15 +116,18 @@ RSpec.describe Lutaml::Model::Attribute, "#apply_value_map" do
|
|
|
106
116
|
|
|
107
117
|
describe "Boolean nested-form (value_map[:from][:empty] / [:omitted])" do
|
|
108
118
|
it "returns false from value_map[:from][:empty] unconditionally for Boolean attribute" do
|
|
109
|
-
expect(attribute(:boolean).apply_value_map("",
|
|
119
|
+
expect(attribute(:boolean).apply_value_map("",
|
|
120
|
+
{ from: { empty: false } })).to be(false)
|
|
110
121
|
end
|
|
111
122
|
|
|
112
123
|
it "returns true from value_map[:from][:omitted] unconditionally for Boolean attribute" do
|
|
113
|
-
expect(attribute(:boolean).apply_value_map(uninit,
|
|
124
|
+
expect(attribute(:boolean).apply_value_map(uninit,
|
|
125
|
+
{ from: { omitted: true } })).to be(true)
|
|
114
126
|
end
|
|
115
127
|
|
|
116
128
|
it "returns false from nested form even for non-Boolean attribute (no type gate)" do
|
|
117
|
-
expect(attribute(:string).apply_value_map("",
|
|
129
|
+
expect(attribute(:string).apply_value_map("",
|
|
130
|
+
{ from: { empty: false } })).to be(false)
|
|
118
131
|
end
|
|
119
132
|
end
|
|
120
133
|
|
|
@@ -358,7 +358,8 @@ RSpec.describe Lutaml::Model::Attribute do
|
|
|
358
358
|
it "executes the proc in the instance context (reads instance attribute)" do
|
|
359
359
|
instance = model_class.new(name: "Alice")
|
|
360
360
|
attribute = model_class.attributes[:greeting]
|
|
361
|
-
expect(attribute.default_value(register,
|
|
361
|
+
expect(attribute.default_value(register,
|
|
362
|
+
instance)).to eq("Hello, Alice")
|
|
362
363
|
end
|
|
363
364
|
end
|
|
364
365
|
end
|
|
@@ -413,7 +414,9 @@ RSpec.describe Lutaml::Model::Attribute do
|
|
|
413
414
|
end
|
|
414
415
|
instance = model_class.new(prefix: "Dr")
|
|
415
416
|
attr = model_class.attributes[:name]
|
|
416
|
-
expect
|
|
417
|
+
expect do
|
|
418
|
+
attr.validate_value!(nil, register, instance_object: instance)
|
|
419
|
+
end
|
|
417
420
|
.to raise_error(Lutaml::Model::InvalidValueError) do |error|
|
|
418
421
|
# The error carries the value that was used for validation.
|
|
419
422
|
# If instance_exec ran, value is "Dr_default"; if not, error
|
|
@@ -427,7 +430,9 @@ RSpec.describe Lutaml::Model::Attribute do
|
|
|
427
430
|
it "short-circuits without raising" do
|
|
428
431
|
uninit = Lutaml::Model::UninitializedClass.instance
|
|
429
432
|
attr = described_class.new("name", :string)
|
|
430
|
-
expect
|
|
433
|
+
expect do
|
|
434
|
+
attr.validate_value!(uninit, register, instance_object: model)
|
|
435
|
+
end
|
|
431
436
|
.not_to raise_error
|
|
432
437
|
end
|
|
433
438
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# lutaml-model#746: an empty value ("", [], {}) is a PRESENT value per
|
|
6
|
+
# docs/_guides/missing-values-handling.adoc and must reach custom `from`
|
|
7
|
+
# methods. Only nil (non-existent) and an omitted key (undefined) skip them.
|
|
8
|
+
module CustomFromEmptyValuesSpec
|
|
9
|
+
class Tagged < Lutaml::Model::Serializable
|
|
10
|
+
attribute :received, :string
|
|
11
|
+
|
|
12
|
+
key_value do
|
|
13
|
+
map "tags", to: :received, with: { to: :tags_to, from: :tags_from }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def tags_to(model, doc)
|
|
17
|
+
doc["tags"] = model.received
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tags_from(model, value)
|
|
21
|
+
model.received = value.inspect
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Mapped < Lutaml::Model::Serializable
|
|
26
|
+
attribute :received, :string
|
|
27
|
+
|
|
28
|
+
key_value do
|
|
29
|
+
map "fields", to: :received, with: { to: :fields_to, from: :fields_from }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fields_to(model, doc)
|
|
33
|
+
doc["fields"] = model.received
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fields_from(model, value)
|
|
37
|
+
model.received = value.inspect
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
RSpec.describe "custom from methods and empty values" do
|
|
43
|
+
it "receives an empty collection" do
|
|
44
|
+
expect(CustomFromEmptyValuesSpec::Tagged.from_yaml("tags: []").received)
|
|
45
|
+
.to eq("[]")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "receives an empty string" do
|
|
49
|
+
expect(CustomFromEmptyValuesSpec::Tagged.from_yaml('tags: ""').received)
|
|
50
|
+
.to eq("\"\"")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "receives an empty mapping" do
|
|
54
|
+
expect(CustomFromEmptyValuesSpec::Mapped.from_yaml("fields: {}").received)
|
|
55
|
+
.to eq("{}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "receives empty values from JSON too" do
|
|
59
|
+
expect(CustomFromEmptyValuesSpec::Tagged.from_json(%({"tags": []})).received)
|
|
60
|
+
.to eq("[]")
|
|
61
|
+
expect(CustomFromEmptyValuesSpec::Tagged.from_json(%({"tags": ""})).received)
|
|
62
|
+
.to eq("\"\"")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "still skips the method for a nil value" do
|
|
66
|
+
expect(CustomFromEmptyValuesSpec::Tagged.from_yaml("tags:").received)
|
|
67
|
+
.to be_nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "still skips the method for an omitted key" do
|
|
71
|
+
expect(CustomFromEmptyValuesSpec::Tagged.from_yaml("{}").received)
|
|
72
|
+
.to be_nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -146,6 +146,33 @@ module DefaultsSpec
|
|
|
146
146
|
|
|
147
147
|
class InstanceAwareGrandChild < InstanceAwareChild
|
|
148
148
|
end
|
|
149
|
+
|
|
150
|
+
# Class-method defaults must keep resolving as they did before 0.8.0
|
|
151
|
+
# (lutaml-model#745): default lambdas are evaluated against the instance,
|
|
152
|
+
# with the model class as fallback for methods the instance does not have.
|
|
153
|
+
class ClassMethodDefault < Lutaml::Model::Serializable
|
|
154
|
+
attribute :os, :string, default: -> { detect_os }
|
|
155
|
+
|
|
156
|
+
def self.detect_os
|
|
157
|
+
"from-class-method"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
class ClassMethodDefaultWithArgs < Lutaml::Model::Serializable
|
|
162
|
+
attribute :label, :string, default: -> { build_label("x") }
|
|
163
|
+
|
|
164
|
+
def self.build_label(prefix)
|
|
165
|
+
"#{prefix}-label"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
class InstanceMethodDefault < Lutaml::Model::Serializable
|
|
170
|
+
attribute :value, :string, default: -> { instance_helper }
|
|
171
|
+
|
|
172
|
+
def instance_helper
|
|
173
|
+
"from-instance-method"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
149
176
|
end
|
|
150
177
|
|
|
151
178
|
RSpec.describe DefaultsSpec::Glaze do
|
|
@@ -346,3 +373,21 @@ RSpec.describe DefaultsSpec::Glaze do
|
|
|
346
373
|
end
|
|
347
374
|
end
|
|
348
375
|
end
|
|
376
|
+
|
|
377
|
+
RSpec.describe DefaultsSpec::ClassMethodDefault do
|
|
378
|
+
it "resolves class methods from default lambdas" do
|
|
379
|
+
expect(described_class.new.os).to eq("from-class-method")
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
RSpec.describe DefaultsSpec::ClassMethodDefaultWithArgs do
|
|
384
|
+
it "passes arguments through to class methods" do
|
|
385
|
+
expect(described_class.new.label).to eq("x-label")
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
RSpec.describe DefaultsSpec::InstanceMethodDefault do
|
|
390
|
+
it "still resolves instance methods in default lambdas" do
|
|
391
|
+
expect(described_class.new.value).to eq("from-instance-method")
|
|
392
|
+
end
|
|
393
|
+
end
|
|
@@ -8,9 +8,8 @@ RSpec.describe Lutaml::Model::Json do
|
|
|
8
8
|
let(:lib_path) { File.expand_path("../../../lib", __dir__) }
|
|
9
9
|
|
|
10
10
|
it "loads require 'lutaml/json' without preloading lutaml/model" do
|
|
11
|
-
# rubocop:disable Style/CommandLiteral
|
|
11
|
+
# rubocop:disable-next Style/CommandLiteral
|
|
12
12
|
result = `#{RbConfig.ruby} -I#{lib_path} -e 'require "lutaml/json"; puts :ok' 2>&1`
|
|
13
|
-
# rubocop:enable Style/CommandLiteral
|
|
14
13
|
|
|
15
14
|
expect($?.success?).to be(true), result
|
|
16
15
|
expect(result).to include("ok")
|
|
@@ -148,7 +148,9 @@ RSpec.describe "OrderedContent" do
|
|
|
148
148
|
|
|
149
149
|
# Regression for issue #735: `ordered` builder must honour setter
|
|
150
150
|
# call order, not declaration order. The @__order_tracking__ flag
|
|
151
|
-
# was gated on mixed_content? instead of ordered?.
|
|
151
|
+
# was gated on mixed_content? instead of ordered?. Comprehensive
|
|
152
|
+
# coverage of the setter/appender equivalence lives in
|
|
153
|
+
# spec/lutaml/model/serialize/builder_spec.rb.
|
|
152
154
|
context "ordered builder honours setter call order" do
|
|
153
155
|
it "emits elements in builder-call order, not declaration order" do
|
|
154
156
|
klass = Class.new(Lutaml::Model::Serializable) do
|
|
@@ -163,16 +165,25 @@ RSpec.describe "OrderedContent" do
|
|
|
163
165
|
end
|
|
164
166
|
end
|
|
165
167
|
|
|
166
|
-
|
|
168
|
+
# Both the appender syntax and the direct setter syntax MUST
|
|
169
|
+
# honour call order and MUST NOT silently drop the other.
|
|
170
|
+
item_appender = klass.new do |i|
|
|
167
171
|
i.b "first"
|
|
168
172
|
i.a "second"
|
|
169
173
|
end
|
|
170
174
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
item_setter = klass.new do |i|
|
|
176
|
+
i.b = "first"
|
|
177
|
+
i.a = "second"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
[item_appender, item_setter].each do |item|
|
|
181
|
+
xml = item.to_xml
|
|
182
|
+
b_pos = xml.index("<b>first</b>")
|
|
183
|
+
a_pos = xml.index("<a>second</a>")
|
|
184
|
+
expect(b_pos).to be < a_pos
|
|
185
|
+
expect(item.element_order.map(&:name)).to eq(%w[b a])
|
|
186
|
+
end
|
|
176
187
|
end
|
|
177
188
|
end
|
|
178
189
|
end
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require_relative "../../../../lib/lutaml/model"
|
|
5
|
+
require "lutaml/xml/adapter/nokogiri_adapter"
|
|
6
|
+
|
|
7
|
+
# Specs for Lutaml::Model::Serialize::Builder — the module that gives
|
|
8
|
+
# Serializable the `Klass.new do |x| ... end` block syntax.
|
|
9
|
+
#
|
|
10
|
+
# These specs exist because of a regression where direct setters
|
|
11
|
+
# (`x.attr = v`) inside the builder block silently dropped attributes
|
|
12
|
+
# from serialized output while appender calls (`x.attr(v)`) worked.
|
|
13
|
+
# The root cause was that only getter-with-arg paths called track_order;
|
|
14
|
+
# generated setters did not. The fix centralises mutation recording via
|
|
15
|
+
# Builder#record_mutation / record_mutation_collection, and the
|
|
16
|
+
# serializer's apply_remaining_rules acts as a safety net so element
|
|
17
|
+
# values are never silently dropped.
|
|
18
|
+
#
|
|
19
|
+
# These specs cover the invariant ("no silent data loss"), call-order
|
|
20
|
+
# preservation, round-trip parity, equivalence between .tap and block,
|
|
21
|
+
# the no-op guarantee for non-ordered models, and the wholesale
|
|
22
|
+
# collection assignment case.
|
|
23
|
+
RSpec.describe "Lutaml::Model::Serialize::Builder" do
|
|
24
|
+
let(:ordered_klass) do
|
|
25
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
26
|
+
attribute :singular, :string
|
|
27
|
+
attribute :items, :string, collection: true
|
|
28
|
+
|
|
29
|
+
xml do
|
|
30
|
+
element "container"
|
|
31
|
+
ordered
|
|
32
|
+
map_element "singular", to: :singular
|
|
33
|
+
map_element "items", to: :items
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
let(:mixed_klass) do
|
|
39
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
40
|
+
attribute :singular, :string
|
|
41
|
+
attribute :items, :string, collection: true
|
|
42
|
+
|
|
43
|
+
xml do
|
|
44
|
+
element "container"
|
|
45
|
+
mixed_content
|
|
46
|
+
map_element "singular", to: :singular
|
|
47
|
+
map_element "items", to: :items
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
let(:plain_klass) do
|
|
53
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
54
|
+
attribute :singular, :string
|
|
55
|
+
attribute :items, :string, collection: true
|
|
56
|
+
|
|
57
|
+
xml do
|
|
58
|
+
element "container"
|
|
59
|
+
map_element "singular", to: :singular
|
|
60
|
+
map_element "items", to: :items
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
shared_examples "no silent data loss across mutation styles" do
|
|
66
|
+
it "appender-only: emits every call in order" do
|
|
67
|
+
obj = ordered_klass.new do |x|
|
|
68
|
+
x.singular "first"
|
|
69
|
+
x.items "a"
|
|
70
|
+
x.items "b"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
xml = obj.to_xml
|
|
74
|
+
expect(xml).to be_xml_equivalent_to(<<~XML)
|
|
75
|
+
<container>
|
|
76
|
+
<singular>first</singular>
|
|
77
|
+
<items>a</items>
|
|
78
|
+
<items>b</items>
|
|
79
|
+
</container>
|
|
80
|
+
XML
|
|
81
|
+
expect(obj.element_order.map(&:name)).to eq(%w[singular items items])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "direct setter for singular: still emits the element" do
|
|
85
|
+
obj = ordered_klass.new do |x|
|
|
86
|
+
x.singular = "first"
|
|
87
|
+
x.items "a"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
xml = obj.to_xml
|
|
91
|
+
expect(xml).to be_xml_equivalent_to(<<~XML)
|
|
92
|
+
<container>
|
|
93
|
+
<singular>first</singular>
|
|
94
|
+
<items>a</items>
|
|
95
|
+
</container>
|
|
96
|
+
XML
|
|
97
|
+
expect(obj.element_order.map(&:name)).to eq(%w[singular items])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "wholesale collection assignment: emits one element per item" do
|
|
101
|
+
obj = ordered_klass.new do |x|
|
|
102
|
+
x.singular "first"
|
|
103
|
+
x.items = %w[a b c]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
xml = obj.to_xml
|
|
107
|
+
expect(xml).to be_xml_equivalent_to(<<~XML)
|
|
108
|
+
<container>
|
|
109
|
+
<singular>first</singular>
|
|
110
|
+
<items>a</items>
|
|
111
|
+
<items>b</items>
|
|
112
|
+
<items>c</items>
|
|
113
|
+
</container>
|
|
114
|
+
XML
|
|
115
|
+
expect(obj.element_order.map(&:name)).to eq(%w[singular items items
|
|
116
|
+
items])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "all direct setters: emits in call order" do
|
|
120
|
+
obj = ordered_klass.new do |x|
|
|
121
|
+
x.singular = "first"
|
|
122
|
+
x.items = %w[a b]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
xml = obj.to_xml
|
|
126
|
+
expect(xml).to be_xml_equivalent_to(<<~XML)
|
|
127
|
+
<container>
|
|
128
|
+
<singular>first</singular>
|
|
129
|
+
<items>a</items>
|
|
130
|
+
<items>b</items>
|
|
131
|
+
</container>
|
|
132
|
+
XML
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "mixed style preserves call order across setters and appenders" do
|
|
136
|
+
obj = ordered_klass.new do |x|
|
|
137
|
+
x.items "a"
|
|
138
|
+
x.singular = "middle"
|
|
139
|
+
x.items "b"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
xml = obj.to_xml
|
|
143
|
+
expect(xml).to be_xml_equivalent_to(<<~XML)
|
|
144
|
+
<container>
|
|
145
|
+
<items>a</items>
|
|
146
|
+
<singular>middle</singular>
|
|
147
|
+
<items>b</items>
|
|
148
|
+
</container>
|
|
149
|
+
XML
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
describe "no silent data loss" do
|
|
154
|
+
it_behaves_like "no silent data loss across mutation styles"
|
|
155
|
+
|
|
156
|
+
it "mixed_content model emits setter-only attributes too" do
|
|
157
|
+
obj = mixed_klass.new do |x|
|
|
158
|
+
x.singular = "first"
|
|
159
|
+
x.items "a"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
xml = obj.to_xml
|
|
163
|
+
expect(xml).to include("<singular>first</singular>")
|
|
164
|
+
expect(xml).to include("<items>a</items>")
|
|
165
|
+
expect(obj.element_order.map(&:name)).to eq(%w[singular items])
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
describe "call order preservation" do
|
|
170
|
+
it "reverse declaration order is honoured via direct setters" do
|
|
171
|
+
obj = ordered_klass.new do |x|
|
|
172
|
+
x.items = %w[a b]
|
|
173
|
+
x.singular = "last"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
xml = obj.to_xml
|
|
177
|
+
items_pos = xml.index("<items>")
|
|
178
|
+
singular_pos = xml.index("<singular>")
|
|
179
|
+
expect(items_pos).to be < singular_pos
|
|
180
|
+
expect(obj.element_order.map(&:name)).to eq(%w[items items singular])
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it "reverse declaration order is honoured via appenders" do
|
|
184
|
+
obj = ordered_klass.new do |x|
|
|
185
|
+
x.items "a"
|
|
186
|
+
x.singular "last"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
xml = obj.to_xml
|
|
190
|
+
items_pos = xml.index("<items>")
|
|
191
|
+
singular_pos = xml.index("<singular>")
|
|
192
|
+
expect(items_pos).to be < singular_pos
|
|
193
|
+
expect(obj.element_order.map(&:name)).to eq(%w[items singular])
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
describe "round-trip parity" do
|
|
198
|
+
it "ordered: parses and re-emits equivalent XML" do
|
|
199
|
+
original = <<~XML
|
|
200
|
+
<container>
|
|
201
|
+
<items>a</items>
|
|
202
|
+
<singular>middle</singular>
|
|
203
|
+
<items>b</items>
|
|
204
|
+
</container>
|
|
205
|
+
XML
|
|
206
|
+
|
|
207
|
+
parsed = ordered_klass.from_xml(original)
|
|
208
|
+
expect(parsed.to_xml).to be_xml_equivalent_to(original)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "mixed_content: parses and re-emits equivalent XML" do
|
|
212
|
+
original = <<~XML
|
|
213
|
+
<container>before <items>a</items> mid <singular>x</singular> end</container>
|
|
214
|
+
XML
|
|
215
|
+
|
|
216
|
+
parsed = mixed_klass.from_xml(original)
|
|
217
|
+
roundtrip = parsed.to_xml
|
|
218
|
+
expect(roundtrip).to include("<items>a</items>")
|
|
219
|
+
expect(roundtrip).to include("<singular>x</singular>")
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
describe ".tap vs builder block equivalence" do
|
|
224
|
+
it "produces identical element_order presence and XML shape" do
|
|
225
|
+
via_tap = ordered_klass.new.tap do |x|
|
|
226
|
+
x.singular = "first"
|
|
227
|
+
x.items = %w[a b]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
via_block = ordered_klass.new do |x|
|
|
231
|
+
x.singular = "first"
|
|
232
|
+
x.items = %w[a b]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# .tap path does not enable tracking; both must still emit the same XML.
|
|
236
|
+
expect(via_tap.element_order).to be_nil
|
|
237
|
+
expect(via_block.element_order).not_to be_nil
|
|
238
|
+
expect(via_tap.to_xml).to be_xml_equivalent_to(via_block.to_xml)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
describe "setter return value contract" do
|
|
243
|
+
# Ruby's setter contract: `obj.foo = v` evaluates to `v`. Callers
|
|
244
|
+
# like `obj.foo || (obj.foo = [])` depend on this. Generated
|
|
245
|
+
# setters must continue to honour this contract regardless of
|
|
246
|
+
# whether order tracking is enabled.
|
|
247
|
+
it "singular setter returns the assigned value" do
|
|
248
|
+
obj = plain_klass.new
|
|
249
|
+
expect((obj.singular = "v")).to eq("v")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it "collection setter returns the assigned value" do
|
|
253
|
+
obj = plain_klass.new
|
|
254
|
+
expect((obj.items = %w[a b])).to eq(%w[a b])
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it "supports the `obj.foo || (obj.foo = [])` pattern from downstream consumers" do
|
|
258
|
+
obj = plain_klass.new
|
|
259
|
+
# Mirror Docbook::Elements::Para#try_add_inline, which relies on
|
|
260
|
+
# the setter returning the array (not nil) when the getter is nil.
|
|
261
|
+
# The bug: when the setter returned nil (because record_mutation
|
|
262
|
+
# returned nil with tracking disabled), `collection` was nil and
|
|
263
|
+
# `collection << element` failed with NoMethodError.
|
|
264
|
+
collection = obj.items || (obj.items = [])
|
|
265
|
+
expect(collection).to eq([])
|
|
266
|
+
expect { collection << "x" }.not_to raise_error
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
describe "no-op guarantee when not tracking" do
|
|
271
|
+
it "plain (non-ordered) models do not allocate element_order entries" do
|
|
272
|
+
obj = plain_klass.new do |x|
|
|
273
|
+
x.singular = "first"
|
|
274
|
+
x.items "a"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
expect(obj.element_order).to be_nil
|
|
278
|
+
expect { obj.to_xml }.not_to raise_error
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it "ordered models constructed without a block do not track" do
|
|
282
|
+
obj = ordered_klass.new
|
|
283
|
+
obj.singular = "first"
|
|
284
|
+
obj.items = %w[a b]
|
|
285
|
+
|
|
286
|
+
expect(obj.element_order).to be_nil
|
|
287
|
+
expect(obj.to_xml).to include("<singular>first</singular>")
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
describe "wholesale collection reassignment" do
|
|
292
|
+
it "tracks one entry per item across multiple assignments" do
|
|
293
|
+
obj = ordered_klass.new do |x|
|
|
294
|
+
x.items = %w[a b]
|
|
295
|
+
x.items = %w[c d e]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# element_order is a log of mutations, not a reflection of final state,
|
|
299
|
+
# so both assignments are recorded. The serializer emits the current
|
|
300
|
+
# value of the attribute via element_order in recorded order; the
|
|
301
|
+
# safety net guarantees no data loss.
|
|
302
|
+
expect(obj.element_order.map(&:name)).to eq(%w[items items items items
|
|
303
|
+
items])
|
|
304
|
+
xml = obj.to_xml
|
|
305
|
+
# The serialized output must contain all current items
|
|
306
|
+
expect(xml).to include("<items>c</items>")
|
|
307
|
+
expect(xml).to include("<items>d</items>")
|
|
308
|
+
expect(xml).to include("<items>e</items>")
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
describe "no-silent-drop invariant (cross-cutting)" do
|
|
313
|
+
# The bug class: any attribute with a non-default value MUST appear in
|
|
314
|
+
# the serialized output, regardless of how it was set. This spec is the
|
|
315
|
+
# regression guard for the whole class of bug, not just one instance.
|
|
316
|
+
shared_examples "no silent drop" do |description, mutation_block|
|
|
317
|
+
it "does not silently drop any attribute set via: #{description}" do
|
|
318
|
+
obj = ordered_klass.new(&mutation_block)
|
|
319
|
+
xml = obj.to_xml
|
|
320
|
+
|
|
321
|
+
if !obj.singular.nil? && !obj.singular.to_s.empty?
|
|
322
|
+
expect(xml).to include("<singular>"),
|
|
323
|
+
"singular was dropped from output"
|
|
324
|
+
end
|
|
325
|
+
return if obj.items.nil? || obj.items.empty?
|
|
326
|
+
|
|
327
|
+
obj.items.each do |item|
|
|
328
|
+
expect(xml).to include("<items>#{item}</items>"),
|
|
329
|
+
"items element for #{item.inspect} was dropped from output"
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
it_behaves_like "no silent drop",
|
|
335
|
+
"singular setter + wholesale items setter",
|
|
336
|
+
lambda { |x|
|
|
337
|
+
x.singular = "v"
|
|
338
|
+
x.items = %w[a b]
|
|
339
|
+
}
|
|
340
|
+
it_behaves_like "no silent drop",
|
|
341
|
+
"all appenders",
|
|
342
|
+
lambda { |x|
|
|
343
|
+
x.singular "v"
|
|
344
|
+
x.items "a"
|
|
345
|
+
x.items "b"
|
|
346
|
+
}
|
|
347
|
+
it_behaves_like "no silent drop",
|
|
348
|
+
"singular setter + items appender (the original bug)",
|
|
349
|
+
lambda { |x|
|
|
350
|
+
x.singular = "v"
|
|
351
|
+
x.items "a"
|
|
352
|
+
x.items "b"
|
|
353
|
+
}
|
|
354
|
+
it_behaves_like "no silent drop",
|
|
355
|
+
"singular appender + wholesale items setter",
|
|
356
|
+
lambda { |x|
|
|
357
|
+
x.singular "v"
|
|
358
|
+
x.items = %w[a b]
|
|
359
|
+
}
|
|
360
|
+
end
|
|
361
|
+
end
|