lutaml-model 0.8.18 → 0.8.22
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/dependent-tests.yml +1 -1
- data/.github/workflows/rake.yml +1 -0
- data/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +133 -72
- data/bench/bench_common.rb +8 -5
- data/bench/bench_niso.rb +1 -2
- data/bench/gate_config.rb +4 -1
- data/benchmark/serialization_benchmark.rb +4 -4
- data/docs/_guides/builder-dsl.adoc +127 -0
- data/docs/_guides/value-transformations.adoc +27 -0
- data/lib/compat/opal/generate_boot.rb +3 -1
- data/lib/compat/opal/lutaml_model_boot.rb +10 -7
- data/lib/compat/opal/moxml_boot.rb +6 -1
- data/lib/lutaml/hash_format/format.rb +29 -0
- data/lib/lutaml/hash_format.rb +5 -27
- data/lib/lutaml/json/format.rb +61 -0
- data/lib/lutaml/json.rb +4 -59
- data/lib/lutaml/jsonl/format.rb +22 -0
- data/lib/lutaml/jsonl.rb +5 -19
- data/lib/lutaml/key_value/format.rb +26 -0
- data/lib/lutaml/key_value/transform.rb +4 -1
- data/lib/lutaml/key_value/transformation/value_serializer.rb +0 -2
- data/lib/lutaml/key_value.rb +4 -21
- data/lib/lutaml/model/attribute.rb +38 -4
- data/lib/lutaml/model/collection.rb +2 -2
- data/lib/lutaml/model/compiled_rule.rb +1 -1
- data/lib/lutaml/model/config.rb +2 -2
- data/lib/lutaml/model/consolidation_map.rb +2 -2
- data/lib/lutaml/model/consolidation_rule/dispatch_block.rb +2 -2
- data/lib/lutaml/model/errors.rb +2 -2
- data/lib/lutaml/model/global_context.rb +4 -2
- data/lib/lutaml/model/mapping/mapping_rule.rb +4 -1
- data/lib/lutaml/model/mapping/model_mapping.rb +2 -2
- data/lib/lutaml/model/runtime_compatibility.rb +2 -2
- data/lib/lutaml/model/schema/class_loader.rb +2 -1
- data/lib/lutaml/model/schema/decorators/definition_collection.rb +4 -4
- 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 +4 -3
- 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 +18 -6
- 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/supported_data_types.rb +2 -2
- data/lib/lutaml/model/schema/xml_compiler.rb +10 -5
- data/lib/lutaml/model/sequence.rb +4 -4
- data/lib/lutaml/model/serialize/attribute_definition.rb +10 -9
- data/lib/lutaml/model/serialize/builder.rb +55 -0
- data/lib/lutaml/model/serialize/format_conversion.rb +2 -2
- 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 +4 -3
- data/lib/lutaml/model/type/uninitialized_class_guard.rb +1 -1
- data/lib/lutaml/model/type.rb +9 -0
- data/lib/lutaml/model/type_registry.rb +2 -2
- data/lib/lutaml/model/uninitialized_class.rb +1 -1
- data/lib/lutaml/model/union.rb +5 -2
- data/lib/lutaml/model/utils.rb +2 -2
- data/lib/lutaml/model/value_policy.rb +27 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +17 -16
- data/lib/lutaml/rdf/mapping.rb +2 -2
- data/lib/lutaml/rdf/namespace_set.rb +2 -2
- data/lib/lutaml/rdf/transform.rb +2 -2
- data/lib/lutaml/toml/format.rb +48 -0
- data/lib/lutaml/toml.rb +5 -46
- data/lib/lutaml/xml/adapter/base_adapter.rb +2 -2
- data/lib/lutaml/xml/builder/base.rb +2 -2
- data/lib/lutaml/xml/format.rb +331 -0
- data/lib/lutaml/xml/mapping.rb +2 -2
- data/lib/lutaml/xml/mapping_rule.rb +62 -3
- data/lib/lutaml/xml/model_transform.rb +132 -55
- data/lib/lutaml/xml/namespace_collector.rb +3 -3
- data/lib/lutaml/xml/parse_session.rb +59 -0
- data/lib/lutaml/xml/parsed_namespace_set.rb +2 -2
- data/lib/lutaml/xml/schema/builder.rb +5 -5
- data/lib/lutaml/xml/schema/xsd/schema_location_mapping.rb +2 -2
- data/lib/lutaml/xml/schema/xsd.rb +0 -2
- data/lib/lutaml/xml/schema/xsd_schema.rb +2 -1
- data/lib/lutaml/xml/serialization/format_conversion.rb +3 -3
- data/lib/lutaml/xml/transformation/element_builder.rb +2 -1
- data/lib/lutaml/xml/transformation/ordered_applier.rb +99 -4
- data/lib/lutaml/xml/xml_element.rb +2 -0
- data/lib/lutaml/xml.rb +4 -328
- data/lib/lutaml/yaml/format.rb +30 -0
- data/lib/lutaml/yaml.rb +5 -28
- data/lib/lutaml/yamls/adapter/mapping.rb +2 -2
- data/lib/lutaml/yamls/format.rb +22 -0
- data/lib/lutaml/yamls.rb +5 -19
- data/lib/tasks/benchmark_runner.rb +2 -2
- data/lutaml-model.gemspec +1 -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_bibtex_adapter_spec.rb +2 -2
- data/spec/lutaml/model/custom_from_empty_values_spec.rb +74 -0
- data/spec/lutaml/model/custom_vobject_adapter_spec.rb +8 -8
- 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/store_spec.rb +3 -1
- 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/model/value_policy_spec.rb +82 -0
- data/spec/lutaml/require_load_order_spec.rb +25 -0
- data/spec/lutaml/xml/attribute_element_namespace_disjoint_spec.rb +88 -0
- data/spec/lutaml/xml/attribute_namespace_disjointness_spec.rb +125 -0
- data/spec/lutaml/xml/out_of_namespace_parsing_spec.rb +123 -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 +22 -4
|
@@ -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
|
|
@@ -201,8 +201,8 @@ module CustomVobjectAdapterSpec
|
|
|
201
201
|
@objects
|
|
202
202
|
end
|
|
203
203
|
|
|
204
|
-
def map(&)
|
|
205
|
-
@objects.map(&)
|
|
204
|
+
def map(&block)
|
|
205
|
+
@objects.map(&block)
|
|
206
206
|
end
|
|
207
207
|
|
|
208
208
|
private
|
|
@@ -260,16 +260,16 @@ module CustomVobjectAdapterSpec
|
|
|
260
260
|
@element_type = element_type
|
|
261
261
|
end
|
|
262
262
|
|
|
263
|
-
def map_value(to:, **)
|
|
264
|
-
add_mapping(:value, to, type: :simple, **)
|
|
263
|
+
def map_value(to:, **options)
|
|
264
|
+
add_mapping(:value, to, type: :simple, **options)
|
|
265
265
|
end
|
|
266
266
|
|
|
267
|
-
def map_property(name, to:, type: :simple, **)
|
|
268
|
-
add_mapping(name.downcase, to, type: type, **)
|
|
267
|
+
def map_property(name, to:, type: :simple, **options)
|
|
268
|
+
add_mapping(name.downcase, to, type: type, **options)
|
|
269
269
|
end
|
|
270
270
|
|
|
271
|
-
def map_component(name, to:, **)
|
|
272
|
-
add_mapping(name.downcase, to, type: :component, **)
|
|
271
|
+
def map_component(name, to:, **options)
|
|
272
|
+
add_mapping(name.downcase, to, type: :component, **options)
|
|
273
273
|
end
|
|
274
274
|
|
|
275
275
|
def map_field_set(count:, item_type:, item_options: {})
|
|
@@ -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
|
|
@@ -43,7 +43,9 @@ RSpec.describe Lutaml::Model::Store do
|
|
|
43
43
|
|
|
44
44
|
describe "indexed lookup" do
|
|
45
45
|
it "builds index lazily on first resolve" do
|
|
46
|
-
|
|
46
|
+
# Hold strong references: Store keeps WeakRefs, so unreferenced
|
|
47
|
+
# objects can be collected before resolve runs.
|
|
48
|
+
_objects = Array.new(5) { |i| model_class.new(id: "obj-#{i}") }
|
|
47
49
|
|
|
48
50
|
result = described_class.resolve(model_class, :id, "obj-3")
|
|
49
51
|
expect(result.id).to eq("obj-3")
|
|
@@ -33,7 +33,8 @@ RSpec.describe Lutaml::Model::Transform, "#apply_value_map (nil attr)" do
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
it "passes initialized values through unchanged" do
|
|
36
|
-
expect(transform.apply_value_map("hello", default_vmap,
|
|
36
|
+
expect(transform.apply_value_map("hello", default_vmap,
|
|
37
|
+
nil)).to eq("hello")
|
|
37
38
|
end
|
|
38
39
|
end
|
|
39
40
|
|
|
@@ -50,11 +51,13 @@ RSpec.describe Lutaml::Model::Transform, "#apply_value_map (nil attr)" do
|
|
|
50
51
|
# custom method runs), and resolved :nil/:empty consistently for empty inputs.
|
|
51
52
|
describe "non-:nil options with nil attr" do
|
|
52
53
|
it "maps a nil value to uninitialized via nil: :omitted" do
|
|
53
|
-
expect(transform.apply_value_map(nil, { nil: :omitted },
|
|
54
|
+
expect(transform.apply_value_map(nil, { nil: :omitted },
|
|
55
|
+
nil)).to eq(uninit)
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
it "maps an empty value to uninitialized via empty: :omitted" do
|
|
57
|
-
expect(transform.apply_value_map("", { empty: :omitted },
|
|
59
|
+
expect(transform.apply_value_map("", { empty: :omitted },
|
|
60
|
+
nil)).to eq(uninit)
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
it "maps an empty value to nil via empty: :nil" do
|
|
@@ -311,7 +311,8 @@ RSpec.describe "Union-typed attributes (issue #190)" do
|
|
|
311
311
|
it "serializes a model member built in plain Ruby identically to a deserialized one" do
|
|
312
312
|
built = UnionAttributeSpec::Ceramic.new(
|
|
313
313
|
firing_temperature:
|
|
314
|
-
UnionAttributeSpec::TemperatureWithUnit.new(number: 1300.0,
|
|
314
|
+
UnionAttributeSpec::TemperatureWithUnit.new(number: 1300.0,
|
|
315
|
+
unit: "C"),
|
|
315
316
|
)
|
|
316
317
|
round = UnionAttributeSpec::Ceramic.from_yaml(built.to_yaml)
|
|
317
318
|
expect(round.firing_temperature)
|
|
@@ -424,7 +425,8 @@ RSpec.describe "Union-typed attributes (issue #190)" do
|
|
|
424
425
|
end
|
|
425
426
|
|
|
426
427
|
it "rejects unsupported scalar member types at definition time" do
|
|
427
|
-
%i[hash symbol time time_without_date date
|
|
428
|
+
%i[hash symbol time time_without_date date
|
|
429
|
+
date_time].each do |unsupported|
|
|
428
430
|
expect do
|
|
429
431
|
Class.new(Lutaml::Model::Serializable) do
|
|
430
432
|
attribute :bad, [unsupported, :string]
|