lutaml-model 0.8.56 → 0.8.106
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/scripts/test_installed_gem.rb +58 -0
- data/.github/workflows/downstream-performance.yml +28 -3
- data/.github/workflows/js-pr-check.yml +4 -0
- data/.github/workflows/js-sync.yml +4 -0
- data/.github/workflows/opal.yml +9 -0
- data/.github/workflows/rake.yml +6 -41
- data/.github/workflows/release.yml +41 -0
- data/Gemfile +17 -3
- data/bench/bench_alloc_audit.rb +56 -0
- data/bench/bench_common.rb +11 -0
- data/bench/bench_merge_attempts.rb +49 -0
- data/docs/_guides/gem-integration.adoc +85 -0
- data/docs/_guides/grammar-backed-formats.adoc +69 -0
- data/docs/_guides/index.adoc +8 -1
- data/docs/_guides/native-engines.adoc +23 -5
- data/docs/_guides/schema-generation.adoc +25 -0
- data/docs/_guides/type-registers-contexts.adoc +126 -0
- data/lib/compat/opal/lutaml_model_boot.rb +7 -5
- data/lib/lutaml/json/adapter/yeptris_adapter.rb +24 -1
- data/lib/lutaml/key_value/data_model/element.rb +5 -1
- data/lib/lutaml/key_value/mapping_rule.rb +7 -0
- data/lib/lutaml/key_value/transform.rb +29 -24
- data/lib/lutaml/key_value/transformation/collection_serializer.rb +12 -29
- data/lib/lutaml/key_value/transformation/value_serializer.rb +29 -6
- data/lib/lutaml/key_value/transformation.rb +64 -116
- data/lib/lutaml/model/adapter_resolver.rb +7 -3
- data/lib/lutaml/model/attribute.rb +74 -15
- data/lib/lutaml/model/cached_type_resolver.rb +13 -6
- data/lib/lutaml/model/configuration.rb +13 -0
- data/lib/lutaml/model/mapping/mapping.rb +13 -0
- data/lib/lutaml/model/mapping/mapping_rule.rb +6 -1
- data/lib/lutaml/model/render_policy.rb +26 -8
- data/lib/lutaml/model/schema/base_schema.rb +15 -0
- data/lib/lutaml/model/schema/generator/definition.rb +52 -8
- data/lib/lutaml/model/schema/generator/definitions_collection.rb +35 -14
- data/lib/lutaml/model/schema.rb +4 -2
- data/lib/lutaml/model/serializable.rb +33 -0
- data/lib/lutaml/model/serialize/attribute_definition.rb +196 -2
- data/lib/lutaml/model/serialize/builder.rb +22 -0
- data/lib/lutaml/model/serialize/conversion_caching.rb +5 -0
- data/lib/lutaml/model/serialize/format_conversion.rb +49 -41
- data/lib/lutaml/model/serialize/initialization.rb +54 -7
- data/lib/lutaml/model/serialize/model_import.rb +60 -0
- data/lib/lutaml/model/serialize.rb +14 -6
- data/lib/lutaml/model/type/time.rb +6 -0
- data/lib/lutaml/model/type/value.rb +9 -7
- data/lib/lutaml/model/type_resolver.rb +7 -5
- data/lib/lutaml/model/utils.rb +18 -5
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +7 -0
- data/lib/lutaml/xml/adapter/xml_parser.rb +5 -2
- data/lib/lutaml/xml/adapter_loader.rb +5 -2
- data/lib/lutaml/xml/format.rb +18 -16
- data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
- data/lib/lutaml/xml/leptris/plan_compiler.rb +650 -0
- data/lib/lutaml/xml/leptris/plan_hydrator.rb +748 -0
- data/lib/lutaml/xml/leptris/plan_order.rb +55 -0
- data/lib/lutaml/xml/leptris/plan_serializer.rb +702 -0
- data/lib/lutaml/xml/leptris/plan_walk.rb +36 -0
- data/lib/lutaml/xml/leptris.rb +46 -0
- data/lib/lutaml/xml/mapping.rb +71 -18
- data/lib/lutaml/xml/model_transform.rb +231 -8
- data/lib/lutaml/xml/namespace.rb +18 -8
- data/lib/lutaml/xml/namespace_collector.rb +17 -1
- data/lib/lutaml/xml/namespace_needs.rb +77 -49
- data/lib/lutaml/xml/parse_session.rb +7 -19
- data/lib/lutaml/xml/schema/xsd/schema.rb +2 -1
- data/lib/lutaml/xml/serialization/format_conversion.rb +5 -1
- data/lib/lutaml/xml/transformation/ordered_applier.rb +21 -15
- data/lib/lutaml/xml/transformation.rb +15 -1
- data/lib/lutaml/xml/type/serializers.rb +8 -1
- data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +34 -2
- data/lib/tasks/performance_probe_plan_walk.rb +1 -1
- data/lutaml-model.gemspec +7 -4
- data/spec/lutaml/json/yeptris_adapter_spec.rb +7 -2
- data/spec/lutaml/key_value/transformation/value_serializer_spec.rb +9 -5
- data/spec/lutaml/key_value/transformation_spec.rb +87 -21
- data/spec/lutaml/model/any_element_mapping_spec.rb +41 -0
- data/spec/lutaml/model/collection_validation_spec.rb +265 -0
- data/spec/lutaml/model/desc_spec.rb +50 -0
- data/spec/lutaml/model/late_attribute_declaration_spec.rb +35 -0
- data/spec/lutaml/model/model_import_reentry_spec.rb +46 -0
- data/spec/lutaml/model/opal_smoke_spec.rb +17 -0
- data/spec/lutaml/model/schema/generate_many_spec.rb +61 -0
- data/spec/lutaml/model/schema/generation_regressions_spec.rb +139 -0
- data/spec/lutaml/model/schema/json_schema_spec.rb +1 -13
- data/spec/lutaml/model/schema/yaml_schema_spec.rb +0 -11
- data/spec/lutaml/model/serialize/attribute_definition_block_form_spec.rb +244 -0
- data/spec/lutaml/model/store_spec.rb +6 -2
- data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
- data/spec/lutaml/model/writer_assigned_attribute_spec.rb +62 -0
- data/spec/lutaml/model/yeptris_error_parity_spec.rb +95 -0
- data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
- data/spec/lutaml/xml/adapter/over_cap_parse_spec.rb +105 -0
- data/spec/lutaml/xml/adapter_loader_spec.rb +14 -0
- data/spec/lutaml/xml/adapter_metadata_default_spec.rb +16 -0
- data/spec/lutaml/xml/attribute_ambiguity_surface_spec.rb +5 -0
- data/spec/lutaml/xml/clear_parse_state_spec.rb +6 -1
- data/spec/lutaml/xml/compiled_rule_namespace_spec.rb +5 -0
- data/spec/lutaml/xml/cross_register_plan_spec.rb +75 -0
- data/spec/lutaml/xml/doubly_defined_namespace_spec.rb +16 -8
- data/spec/lutaml/xml/element_order_tracking_spec.rb +37 -0
- data/spec/lutaml/xml/leptris_string_encoding_spec.rb +65 -0
- data/spec/lutaml/xml/opal_xml_spec.rb +47 -0
- data/spec/lutaml/xml/parse_dom_retention_spec.rb +34 -0
- data/spec/lutaml/xml/plan_fast_path_spec.rb +122 -14
- data/spec/lutaml/xml/plan_namespaced_serialize_spec.rb +157 -0
- data/spec/lutaml/xml/plan_ns_less_document_spec.rb +86 -0
- data/spec/lutaml/xml/plan_partition_spec.rb +1 -1
- data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +131 -0
- data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
- data/spec/lutaml/xml/plan_walk_spec.rb +6 -6
- data/spec/lutaml/xml/plan_wildcard_any_spec.rb +96 -0
- data/spec/lutaml/yaml/yeptris_adapter_spec.rb +29 -0
- data/spec/person_spec.rb +8 -0
- metadata +48 -17
- data/lib/lutaml/xml/plan_compiler.rb +0 -332
- data/lib/lutaml/xml/plan_hydrator.rb +0 -367
- data/lib/lutaml/xml/plan_order.rb +0 -53
- data/lib/lutaml/xml/plan_serializer.rb +0 -252
- data/lib/lutaml/xml/plan_walk.rb +0 -30
|
@@ -60,9 +60,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
60
60
|
|
|
61
61
|
result = transformation.transform(model)
|
|
62
62
|
|
|
63
|
-
expect(result).to be_a(
|
|
64
|
-
expect(result.key).to eq("__root__")
|
|
65
|
-
expect(result.children.length).to eq(3)
|
|
63
|
+
expect(result).to be_a(Hash)
|
|
66
64
|
end
|
|
67
65
|
|
|
68
66
|
it "creates proper key-value structure" do
|
|
@@ -71,7 +69,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
71
69
|
transformation = described_class.new(KVSimpleModel, mapping, :json, nil)
|
|
72
70
|
|
|
73
71
|
result = transformation.transform(model)
|
|
74
|
-
hash = result
|
|
72
|
+
hash = { "__root__" => result }
|
|
75
73
|
|
|
76
74
|
# Remove __root__ wrapper
|
|
77
75
|
expect(hash["__root__"]).to eq({
|
|
@@ -87,7 +85,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
87
85
|
transformation = described_class.new(KVSimpleModel, mapping, :json, nil)
|
|
88
86
|
|
|
89
87
|
result = transformation.transform(model)
|
|
90
|
-
hash = result
|
|
88
|
+
hash = { "__root__" => result }
|
|
91
89
|
|
|
92
90
|
# nil values should be rendered by default
|
|
93
91
|
expect(hash["__root__"]["age"]).to be_nil
|
|
@@ -103,7 +101,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
103
101
|
transformation = described_class.new(KVPerson, mapping, :json, nil)
|
|
104
102
|
|
|
105
103
|
result = transformation.transform(person)
|
|
106
|
-
hash = result
|
|
104
|
+
hash = { "__root__" => result }
|
|
107
105
|
|
|
108
106
|
expect(hash["__root__"]).to eq({
|
|
109
107
|
"name" => "John",
|
|
@@ -121,7 +119,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
121
119
|
transformation = described_class.new(KVPerson, mapping, :json, nil)
|
|
122
120
|
|
|
123
121
|
result = transformation.transform(person)
|
|
124
|
-
hash = result
|
|
122
|
+
hash = { "__root__" => result }
|
|
125
123
|
|
|
126
124
|
# nil nested model should be in output (based on default render_nil)
|
|
127
125
|
expect(hash["__root__"]["address"]).to be_nil
|
|
@@ -137,7 +135,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
137
135
|
transformation = described_class.new(KVTeam, mapping, :json, nil)
|
|
138
136
|
|
|
139
137
|
result = transformation.transform(team)
|
|
140
|
-
hash = result
|
|
138
|
+
hash = { "__root__" => result }
|
|
141
139
|
|
|
142
140
|
expect(hash["__root__"]).to eq({
|
|
143
141
|
"name" => "Dev Team",
|
|
@@ -152,7 +150,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
152
150
|
transformation = described_class.new(KVTeam, mapping, :json, nil)
|
|
153
151
|
|
|
154
152
|
result = transformation.transform(team)
|
|
155
|
-
hash = result
|
|
153
|
+
hash = { "__root__" => result }
|
|
156
154
|
|
|
157
155
|
# Empty collections are not rendered by default (render_empty defaults to false)
|
|
158
156
|
expect(hash["__root__"]["members"]).to be_nil
|
|
@@ -164,7 +162,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
164
162
|
transformation = described_class.new(KVTeam, mapping, :json, nil)
|
|
165
163
|
|
|
166
164
|
result = transformation.transform(team)
|
|
167
|
-
hash = result
|
|
165
|
+
hash = { "__root__" => result }
|
|
168
166
|
|
|
169
167
|
# nil collection should be nil by default
|
|
170
168
|
expect(hash["__root__"]["members"]).to be_nil
|
|
@@ -190,7 +188,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
190
188
|
nil)
|
|
191
189
|
|
|
192
190
|
result = transformation.transform(model)
|
|
193
|
-
hash = result
|
|
191
|
+
hash = { "__root__" => result }
|
|
194
192
|
|
|
195
193
|
expect(hash["__root__"]["name"]).to eq("JOHN")
|
|
196
194
|
end
|
|
@@ -213,7 +211,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
213
211
|
transformation = described_class.new(KVRenderModel, mapping, :json, nil)
|
|
214
212
|
|
|
215
213
|
result = transformation.transform(model)
|
|
216
|
-
hash = result
|
|
214
|
+
hash = { "__root__" => result }
|
|
217
215
|
|
|
218
216
|
# optional should be omitted
|
|
219
217
|
expect(hash["__root__"].keys).not_to include("optional")
|
|
@@ -225,7 +223,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
225
223
|
transformation = described_class.new(KVRenderModel, mapping, :json, nil)
|
|
226
224
|
|
|
227
225
|
result = transformation.transform(model)
|
|
228
|
-
hash = result
|
|
226
|
+
hash = { "__root__" => result }
|
|
229
227
|
|
|
230
228
|
# defaulted should be omitted
|
|
231
229
|
expect(hash["__root__"].keys).not_to include("defaulted")
|
|
@@ -234,22 +232,22 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
234
232
|
end
|
|
235
233
|
|
|
236
234
|
describe "architecture compliance" do
|
|
237
|
-
it "
|
|
235
|
+
it "emits the presentation hash directly (TODO.perf 19)" do
|
|
238
236
|
model = KVSimpleModel.new(name: "John", age: 30, active: true)
|
|
239
237
|
mapping = KVSimpleModel.mappings_for(:json)
|
|
240
238
|
transformation = described_class.new(KVSimpleModel, mapping, :json, nil)
|
|
241
239
|
|
|
242
240
|
result = transformation.transform(model)
|
|
243
241
|
|
|
244
|
-
#
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
expect(result.children).to all(be_a(Lutaml::KeyValue::DataModel::Element))
|
|
242
|
+
# Hash-direct emission: the __root__ Element tree (write-only in
|
|
243
|
+
# production, immediately flattened by every consumer) is gone;
|
|
244
|
+
# transform produces the output hash itself.
|
|
245
|
+
expect(result).to be_a(Hash)
|
|
246
|
+
expect(result).to eq("name" => "John", "age" => 30, "active" => true)
|
|
250
247
|
end
|
|
251
248
|
|
|
252
249
|
it "separates content (KeyValueElement) from presentation (Hash)" do
|
|
250
|
+
skip "superseded by hash-direct emission (TODO.perf 19)"
|
|
253
251
|
model = KVSimpleModel.new(name: "John", age: 30, active: true)
|
|
254
252
|
mapping = KVSimpleModel.mappings_for(:json)
|
|
255
253
|
transformation = described_class.new(KVSimpleModel, mapping, :json, nil)
|
|
@@ -259,7 +257,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
259
257
|
expect(result).to be_a(Lutaml::KeyValue::DataModel::Element)
|
|
260
258
|
|
|
261
259
|
# Presentation happens via to_hash, not during transformation
|
|
262
|
-
hash = result
|
|
260
|
+
hash = { "__root__" => result }
|
|
263
261
|
expect(hash).to be_a(Hash)
|
|
264
262
|
end
|
|
265
263
|
end
|
|
@@ -292,4 +290,72 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
292
290
|
end.not_to raise_error
|
|
293
291
|
end
|
|
294
292
|
end
|
|
293
|
+
|
|
294
|
+
describe "custom to: methods under hash-direct emission (pubid regression)" do
|
|
295
|
+
before do
|
|
296
|
+
stub_const("PubidRegress", Module.new)
|
|
297
|
+
stub_const("PubidRegress::EmptyCustom", empty_custom_class)
|
|
298
|
+
stub_const("PubidRegress::ValueWriter", value_writer_class)
|
|
299
|
+
stub_const("PubidRegress::ReturnOnly", return_only_class)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
let(:empty_custom_class) do
|
|
303
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
304
|
+
attribute :code, :string
|
|
305
|
+
key_value do
|
|
306
|
+
map "code", to: :code, with: { to: :to_code }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# pubid 2.0's shape: inspects the doc and may return without
|
|
310
|
+
# writing anything — must not crash the emission.
|
|
311
|
+
def to_code(_model, doc); end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
let(:value_writer_class) do
|
|
316
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
317
|
+
attribute :code, :string
|
|
318
|
+
key_value do
|
|
319
|
+
map "code", to: :code, with: { to: :to_code }
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def to_code(model, doc)
|
|
323
|
+
doc.value["code"] = model.code
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
let(:return_only_class) do
|
|
329
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
330
|
+
attribute :code, :string
|
|
331
|
+
key_value do
|
|
332
|
+
map "code", to: :code, with: { to: :to_code }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Returns its value without touching the doc — a shape the
|
|
336
|
+
# mutation-only contract must serialize as nothing (#892).
|
|
337
|
+
def to_code(_model, _doc)
|
|
338
|
+
{ "code" => "RETURNED" }
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
it "tolerates a custom method that writes nothing to the doc" do
|
|
344
|
+
model = PubidRegress::EmptyCustom.new(code: "X")
|
|
345
|
+
expect(model.to_hash).to eq({})
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
it "merges a custom method writing through doc.value" do
|
|
349
|
+
model = PubidRegress::ValueWriter.new(code: "X")
|
|
350
|
+
expect(model.to_hash).to eq("code" => "X")
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
it "ignores a custom method's return value (mutation-only contract)" do
|
|
354
|
+
# A method returning a hash without touching the doc serializes
|
|
355
|
+
# as nothing — honoring returns merged child content at the
|
|
356
|
+
# wrong level (lutaml-model#892).
|
|
357
|
+
model = PubidRegress::ReturnOnly.new(code: "X")
|
|
358
|
+
expect(model.to_hash).to eq({})
|
|
359
|
+
end
|
|
360
|
+
end
|
|
295
361
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
class AnyElementNode < Lutaml::Model::Serializable
|
|
6
|
+
attribute :id, :string
|
|
7
|
+
attribute :children, "AnyElementNode", collection: true
|
|
8
|
+
|
|
9
|
+
xml do
|
|
10
|
+
element "root"
|
|
11
|
+
map_element "id", to: :id
|
|
12
|
+
map_any_element to: :children
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
RSpec.describe AnyElementNode do
|
|
17
|
+
it "routes unmapped child elements to the catch-all collection" do
|
|
18
|
+
doc = described_class.from_xml(<<~XML)
|
|
19
|
+
<root>
|
|
20
|
+
<id>r-1</id>
|
|
21
|
+
<semantic__p>hello</semantic__p>
|
|
22
|
+
<semantic__title>Nested</semantic__title>
|
|
23
|
+
<semantic__p><semantic__em>deep</semantic__em></semantic__p>
|
|
24
|
+
</root>
|
|
25
|
+
XML
|
|
26
|
+
|
|
27
|
+
expect(doc.id).to eq("r-1")
|
|
28
|
+
expect(doc.children.size).to eq(3)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "keeps explicit rules ahead of the catch-all" do
|
|
32
|
+
doc = described_class.from_xml("<root><id>x</id></root>")
|
|
33
|
+
expect(doc.id).to eq("x")
|
|
34
|
+
expect(doc.children).to be_empty
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "nests catch-all children recursively" do
|
|
38
|
+
doc = described_class.from_xml("<root><semantic__p><semantic__em>deep</semantic__em></semantic__p></root>")
|
|
39
|
+
expect(doc.children.first.children.size).to eq(1)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -732,5 +732,270 @@ RSpec.describe Lutaml::Model::Collection do
|
|
|
732
732
|
end
|
|
733
733
|
end
|
|
734
734
|
end
|
|
735
|
+
|
|
736
|
+
describe "Conditional validation chaining with context" do
|
|
737
|
+
context "with context sharing between validations" do
|
|
738
|
+
it "allows validations to read results from previous validations" do
|
|
739
|
+
chain_executed = []
|
|
740
|
+
|
|
741
|
+
chained_collection = Class.new(Lutaml::Model::Collection) do
|
|
742
|
+
instances :publications, CollectionValidationTests::Publication
|
|
743
|
+
|
|
744
|
+
validates_uniqueness_of :id
|
|
745
|
+
|
|
746
|
+
validate_collection do |_collection, _errors, ctx|
|
|
747
|
+
chain_executed << :first
|
|
748
|
+
ctx[:duplicates_found] = !ctx[:duplicates_of_id].nil? && ctx[:duplicates_of_id].any?
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
validate_collection do |_collection, errors, ctx|
|
|
752
|
+
chain_executed << :second
|
|
753
|
+
if ctx[:duplicates_found]
|
|
754
|
+
errors.add(:collection, "Cannot proceed with duplicates")
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# Test with duplicates
|
|
760
|
+
collection = chained_collection.new([science_publication, duplicate_id_publication])
|
|
761
|
+
expect { collection.validate! }.to raise_error(Lutaml::Model::ValidationError) do |error|
|
|
762
|
+
expect(error.message).to include("Cannot proceed with duplicates")
|
|
763
|
+
end
|
|
764
|
+
expect(chain_executed).to eq(%i[first second])
|
|
765
|
+
|
|
766
|
+
# Test without duplicates
|
|
767
|
+
chain_executed.clear
|
|
768
|
+
collection = chained_collection.new([science_publication, fiction_publication])
|
|
769
|
+
expect { collection.validate! }.not_to raise_error
|
|
770
|
+
expect(chain_executed).to eq(%i[first second])
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
it "stores duplicate values in context for downstream validations" do
|
|
774
|
+
chained_collection = Class.new(Lutaml::Model::Collection) do
|
|
775
|
+
instances :publications, CollectionValidationTests::Publication
|
|
776
|
+
|
|
777
|
+
validates_uniqueness_of :id
|
|
778
|
+
|
|
779
|
+
validate_collection do |_collection, errors, ctx|
|
|
780
|
+
if ctx[:duplicates_of_id]&.include?("1")
|
|
781
|
+
errors.add(:collection, "Found duplicate ID: 1")
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
collection = chained_collection.new([science_publication, duplicate_id_publication])
|
|
787
|
+
expect { collection.validate! }.to raise_error(Lutaml::Model::ValidationError) do |error|
|
|
788
|
+
expect(error.message).to include("Found duplicate ID: 1")
|
|
789
|
+
end
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
it "stores missing count in context for validates_all_present" do
|
|
793
|
+
chained_collection = Class.new(Lutaml::Model::Collection) do
|
|
794
|
+
instances :publications, CollectionValidationTests::Publication
|
|
795
|
+
|
|
796
|
+
validates_all_present :author
|
|
797
|
+
|
|
798
|
+
validate_collection do |_collection, errors, ctx|
|
|
799
|
+
if ctx[:missing_author_count].to_i > 0
|
|
800
|
+
errors.add(:collection, "#{ctx[:missing_author_count]} items missing author")
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
collection = chained_collection.new([science_publication, publication_without_author])
|
|
806
|
+
expect { collection.validate! }.to raise_error(Lutaml::Model::ValidationError) do |error|
|
|
807
|
+
expect(error.message).to include("1 items missing author")
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
context "with conditional validation using :if_cond option" do
|
|
813
|
+
it "only runs validation when condition is met" do
|
|
814
|
+
chained_collection = Class.new(Lutaml::Model::Collection) do
|
|
815
|
+
instances :publications, CollectionValidationTests::Publication
|
|
816
|
+
|
|
817
|
+
validates_uniqueness_of :id
|
|
818
|
+
|
|
819
|
+
validate_collection(if_cond: ->(ctx) { ctx[:duplicates_of_id]&.any? }) do |_collection, errors, _ctx|
|
|
820
|
+
errors.add(:collection, "Validation ran because duplicates exist")
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
# With duplicates - conditional validation should run
|
|
825
|
+
collection = chained_collection.new([science_publication, duplicate_id_publication])
|
|
826
|
+
expect { collection.validate! }.to raise_error(Lutaml::Model::ValidationError) do |error|
|
|
827
|
+
expect(error.message).to include("Validation ran because duplicates exist")
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Without duplicates - conditional validation should not run
|
|
831
|
+
collection = chained_collection.new([science_publication, fiction_publication])
|
|
832
|
+
expect { collection.validate! }.not_to raise_error
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
it "can skip validation based on context state" do
|
|
836
|
+
expensive_validation_ran = false
|
|
837
|
+
|
|
838
|
+
chained_collection = Class.new(Lutaml::Model::Collection) do
|
|
839
|
+
instances :publications, CollectionValidationTests::Publication
|
|
840
|
+
|
|
841
|
+
validates_uniqueness_of :id
|
|
842
|
+
|
|
843
|
+
validate_collection(if_cond: ->(ctx) { !ctx[:duplicates_of_id]&.any? }) do |_collection, _errors, _ctx|
|
|
844
|
+
expensive_validation_ran = true
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
# With duplicates - the uniqueness error raises (landed
|
|
849
|
+
# semantics) AND the expensive validation still skipped
|
|
850
|
+
collection = chained_collection.new([science_publication, duplicate_id_publication])
|
|
851
|
+
expect { collection.validate! }.to raise_error(Lutaml::Model::ValidationError,
|
|
852
|
+
/unique across collection/)
|
|
853
|
+
expect(expensive_validation_ran).to be false
|
|
854
|
+
|
|
855
|
+
# Without duplicates - expensive validation should run
|
|
856
|
+
expensive_validation_ran = false
|
|
857
|
+
collection = chained_collection.new([science_publication, fiction_publication])
|
|
858
|
+
collection.validate!
|
|
859
|
+
expect(expensive_validation_ran).to be true
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
context "with conditional validation using :unless_cond option" do
|
|
864
|
+
it "skips validation when condition is met" do
|
|
865
|
+
ran_count = 0
|
|
866
|
+
|
|
867
|
+
chained_collection = Class.new(Lutaml::Model::Collection) do
|
|
868
|
+
instances :publications, CollectionValidationTests::Publication
|
|
869
|
+
|
|
870
|
+
validates_uniqueness_of :id
|
|
871
|
+
|
|
872
|
+
validate_collection(unless_cond: ->(ctx) { ctx[:duplicates_of_id]&.any? }) do |_collection, _errors, _ctx|
|
|
873
|
+
ran_count += 1
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# With duplicates - the uniqueness error raises (landed
|
|
878
|
+
# semantics) AND the conditional validation still skipped
|
|
879
|
+
collection = chained_collection.new([science_publication, duplicate_id_publication])
|
|
880
|
+
expect { collection.validate! }.to raise_error(Lutaml::Model::ValidationError,
|
|
881
|
+
/unique across collection/)
|
|
882
|
+
expect(ran_count).to be 0
|
|
883
|
+
|
|
884
|
+
# Without duplicates - should run
|
|
885
|
+
collection = chained_collection.new([science_publication, fiction_publication])
|
|
886
|
+
collection.validate!
|
|
887
|
+
expect(ran_count).to be 1
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
context "with ctx.stop!" do
|
|
892
|
+
it "stops validation chain when ctx.stop! is called" do
|
|
893
|
+
chain_order = []
|
|
894
|
+
|
|
895
|
+
chained_collection = Class.new(Lutaml::Model::Collection) do
|
|
896
|
+
instances :publications, CollectionValidationTests::Publication
|
|
897
|
+
|
|
898
|
+
validate_collection do |_collection, _errors, ctx|
|
|
899
|
+
chain_order << :first
|
|
900
|
+
ctx.stop!
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
validate_collection do |_collection, _errors, ctx|
|
|
904
|
+
chain_order << :second
|
|
905
|
+
ctx.stop!
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
validate_collection do |_collection, _errors, _ctx|
|
|
909
|
+
chain_order << :third
|
|
910
|
+
end
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
collection = chained_collection.new([science_publication])
|
|
914
|
+
collection.validate!
|
|
915
|
+
expect(chain_order).to eq([:first])
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
context "with backwards compatible block signatures" do
|
|
920
|
+
it "works with 1-argument blocks" do
|
|
921
|
+
ran = false
|
|
922
|
+
|
|
923
|
+
collection_class = Class.new(Lutaml::Model::Collection) do
|
|
924
|
+
instances :publications, CollectionValidationTests::Publication
|
|
925
|
+
|
|
926
|
+
validate_collection do |collection|
|
|
927
|
+
ran = true if !collection.empty?
|
|
928
|
+
end
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
collection = collection_class.new([science_publication])
|
|
932
|
+
collection.validate!
|
|
933
|
+
expect(ran).to be true
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
it "works with 2-argument blocks (original signature)" do
|
|
937
|
+
ran_args = []
|
|
938
|
+
|
|
939
|
+
collection_class = Class.new(Lutaml::Model::Collection) do
|
|
940
|
+
instances :publications, CollectionValidationTests::Publication
|
|
941
|
+
|
|
942
|
+
validate_collection do |collection, errors|
|
|
943
|
+
ran_args << [collection.size, errors.class.name]
|
|
944
|
+
errors.add(:collection, "Test error") if !collection.empty?
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
collection = collection_class.new([science_publication])
|
|
949
|
+
errors = collection.validate
|
|
950
|
+
expect(ran_args.first.first).to eq 1
|
|
951
|
+
expect(ran_args.first.last).to include("Errors")
|
|
952
|
+
expect(errors.map(&:message).first).to include("Test error")
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
it "works with 3-argument blocks (new signature with context)" do
|
|
956
|
+
received_context = nil
|
|
957
|
+
|
|
958
|
+
collection_class = Class.new(Lutaml::Model::Collection) do
|
|
959
|
+
instances :publications, CollectionValidationTests::Publication
|
|
960
|
+
|
|
961
|
+
validate_collection do |_collection, _errors, ctx|
|
|
962
|
+
received_context = ctx
|
|
963
|
+
ctx[:test_value] = "from_validation"
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
collection = collection_class.new([science_publication])
|
|
968
|
+
collection.validate
|
|
969
|
+
expect(received_context).to be_a(Lutaml::Model::ValidationContext)
|
|
970
|
+
expect(received_context[:test_value]).to eq("from_validation")
|
|
971
|
+
end
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
context "with ctx.failed? helper" do
|
|
975
|
+
it "returns true when errors have been added" do
|
|
976
|
+
result = nil
|
|
977
|
+
|
|
978
|
+
chained_collection = Class.new(Lutaml::Model::Collection) do
|
|
979
|
+
instances :publications, CollectionValidationTests::Publication
|
|
980
|
+
|
|
981
|
+
validates_uniqueness_of :id
|
|
982
|
+
|
|
983
|
+
validate_collection do |_collection, _errors, ctx|
|
|
984
|
+
result = ctx.failed?
|
|
985
|
+
end
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
# Without errors
|
|
989
|
+
collection = chained_collection.new([science_publication, fiction_publication])
|
|
990
|
+
collection.validate
|
|
991
|
+
expect(result).to be false
|
|
992
|
+
|
|
993
|
+
# With errors
|
|
994
|
+
collection = chained_collection.new([science_publication, duplicate_id_publication])
|
|
995
|
+
collection.validate
|
|
996
|
+
expect(result).to be true
|
|
997
|
+
end
|
|
998
|
+
end
|
|
999
|
+
end
|
|
735
1000
|
end
|
|
736
1001
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Documentation metadata (desc)" do
|
|
6
|
+
let(:widget_class) do
|
|
7
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
8
|
+
desc "A widget."
|
|
9
|
+
|
|
10
|
+
attribute :name, :string, desc: "The widget name."
|
|
11
|
+
attribute :size, :integer
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "stores and reads the class-level description" do
|
|
16
|
+
expect(widget_class.desc).to eq("A widget.")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "returns nil when no description is set" do
|
|
20
|
+
plain = Class.new(Lutaml::Model::Serializable)
|
|
21
|
+
expect(plain.desc).to be_nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "inherits the description on subclasses" do
|
|
25
|
+
child = Class.new(widget_class)
|
|
26
|
+
expect(child.desc).to eq("A widget.")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "lets a subclass override the description" do
|
|
30
|
+
child = Class.new(widget_class) do
|
|
31
|
+
desc "A special widget."
|
|
32
|
+
end
|
|
33
|
+
expect(child.desc).to eq("A special widget.")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "does not leak an override back to the superclass" do
|
|
37
|
+
Class.new(widget_class) { desc "Child only." }
|
|
38
|
+
expect(widget_class.desc).to eq("A widget.")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "stores the attribute-level description" do
|
|
42
|
+
expect(widget_class.attributes[:name].desc).to eq("The widget name.")
|
|
43
|
+
expect(widget_class.attributes[:size].desc).to be_nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "keeps serialization untouched" do
|
|
47
|
+
widget = widget_class.new(name: "w", size: 3)
|
|
48
|
+
expect(widget.to_hash).to eq("name" => "w", "size" => 3)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# Attributes can be declared after the model has been instantiated
|
|
6
|
+
# (forward references, post-hoc declarations). The interned writer
|
|
7
|
+
# table built during the first instantiation must not outlive the
|
|
8
|
+
# attribute set it was built from.
|
|
9
|
+
RSpec.describe "attributes declared after first instantiation" do
|
|
10
|
+
it "initializes attributes added after an earlier instantiation" do
|
|
11
|
+
klass = Class.new(Lutaml::Model::Serializable) do
|
|
12
|
+
attribute :name, :string
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
klass.new(name: "first")
|
|
16
|
+
|
|
17
|
+
klass.attribute :size, :string
|
|
18
|
+
instance = klass.new(name: "second", size: "L")
|
|
19
|
+
|
|
20
|
+
expect(instance.name).to eq("second")
|
|
21
|
+
expect(instance.size).to eq("L")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "keeps earlier instances unaffected" do
|
|
25
|
+
klass = Class.new(Lutaml::Model::Serializable) do
|
|
26
|
+
attribute :name, :string
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
first = klass.new(name: "first")
|
|
30
|
+
klass.attribute :size, :string
|
|
31
|
+
|
|
32
|
+
expect(first.name).to eq("first")
|
|
33
|
+
expect(first.respond_to?(:size)).to be(true)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# Issue #938: a deferred (symbol-form) import that cannot resolve leaves
|
|
6
|
+
# the import pass with all_resolved = false. record_import_attempt then
|
|
7
|
+
# calls attributes(), which re-enters ensure_imports!; the completion
|
|
8
|
+
# marker stored false before that call, so the re-entrant pass ran the
|
|
9
|
+
# import loop again and recursed to SystemStackError. Consumers with
|
|
10
|
+
# large mutually-referencing type graphs (uniword OOXML sets) hit this
|
|
11
|
+
# on load.
|
|
12
|
+
module ModelImportReentry938
|
|
13
|
+
class OrphanSource < Lutaml::Model::Serializable
|
|
14
|
+
attribute :label, :string
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class PendingImporter < Lutaml::Model::Serializable
|
|
18
|
+
attribute :name, :string
|
|
19
|
+
import_model_attributes :NoSuchModelAnywhere938
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class ResolvableImporter < Lutaml::Model::Serializable
|
|
23
|
+
attribute :name, :string
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Lutaml::Model::GlobalRegister
|
|
27
|
+
.lookup(Lutaml::Model::Config.default_register)
|
|
28
|
+
.register_model(OrphanSource, id: :ModelImportReentry938OrphanSource)
|
|
29
|
+
ResolvableImporter
|
|
30
|
+
.import_model_attributes(:ModelImportReentry938OrphanSource)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
RSpec.describe "deferred model import re-entry" do
|
|
34
|
+
it "does not recurse when a deferred import cannot resolve" do
|
|
35
|
+
expect { ModelImportReentry938::PendingImporter.attributes(:default) }
|
|
36
|
+
.not_to raise_error
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "resolves a deferred import without recursing on re-access" do
|
|
40
|
+
importer = ModelImportReentry938::ResolvableImporter
|
|
41
|
+
3.times { importer.attributes(:default) }
|
|
42
|
+
|
|
43
|
+
expect(importer.attributes(:default)).to have_key(:name)
|
|
44
|
+
expect(importer.attributes(:default)).to have_key(:label)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -147,6 +147,23 @@ RSpec.describe "Opal compatibility", if: RUBY_ENGINE == "opal" do
|
|
|
147
147
|
expect(instance.port).to eq(8080)
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
# Opal has no TracePoint; a symbol import is resolved on first use.
|
|
151
|
+
it "resolves a deferred symbol import on first use" do
|
|
152
|
+
register = Lutaml::Model::GlobalRegister
|
|
153
|
+
.lookup(Lutaml::Model::Config.default_register)
|
|
154
|
+
importer = Class.new(Lutaml::Model::Serializable) do
|
|
155
|
+
import_model :opal_deferred_import
|
|
156
|
+
end
|
|
157
|
+
imported = Class.new(Lutaml::Model::Serializable) do
|
|
158
|
+
attribute :title, :string
|
|
159
|
+
end
|
|
160
|
+
register.register_model(imported, id: :opal_deferred_import)
|
|
161
|
+
|
|
162
|
+
instance = importer.from_json('{"title":"Deferred"}')
|
|
163
|
+
expect(instance.title).to eq("Deferred")
|
|
164
|
+
expect(instance.to_hash).to eq({ "title" => "Deferred" })
|
|
165
|
+
end
|
|
166
|
+
|
|
150
167
|
it "RuntimeCompatibility detects Opal" do
|
|
151
168
|
expect(Lutaml::Model::RuntimeCompatibility.opal?).to be true
|
|
152
169
|
end
|