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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/.github/scripts/test_installed_gem.rb +58 -0
  3. data/.github/workflows/downstream-performance.yml +28 -3
  4. data/.github/workflows/js-pr-check.yml +4 -0
  5. data/.github/workflows/js-sync.yml +4 -0
  6. data/.github/workflows/opal.yml +9 -0
  7. data/.github/workflows/rake.yml +6 -41
  8. data/.github/workflows/release.yml +41 -0
  9. data/Gemfile +17 -3
  10. data/bench/bench_alloc_audit.rb +56 -0
  11. data/bench/bench_common.rb +11 -0
  12. data/bench/bench_merge_attempts.rb +49 -0
  13. data/docs/_guides/gem-integration.adoc +85 -0
  14. data/docs/_guides/grammar-backed-formats.adoc +69 -0
  15. data/docs/_guides/index.adoc +8 -1
  16. data/docs/_guides/native-engines.adoc +23 -5
  17. data/docs/_guides/schema-generation.adoc +25 -0
  18. data/docs/_guides/type-registers-contexts.adoc +126 -0
  19. data/lib/compat/opal/lutaml_model_boot.rb +7 -5
  20. data/lib/lutaml/json/adapter/yeptris_adapter.rb +24 -1
  21. data/lib/lutaml/key_value/data_model/element.rb +5 -1
  22. data/lib/lutaml/key_value/mapping_rule.rb +7 -0
  23. data/lib/lutaml/key_value/transform.rb +29 -24
  24. data/lib/lutaml/key_value/transformation/collection_serializer.rb +12 -29
  25. data/lib/lutaml/key_value/transformation/value_serializer.rb +29 -6
  26. data/lib/lutaml/key_value/transformation.rb +64 -116
  27. data/lib/lutaml/model/adapter_resolver.rb +7 -3
  28. data/lib/lutaml/model/attribute.rb +74 -15
  29. data/lib/lutaml/model/cached_type_resolver.rb +13 -6
  30. data/lib/lutaml/model/configuration.rb +13 -0
  31. data/lib/lutaml/model/mapping/mapping.rb +13 -0
  32. data/lib/lutaml/model/mapping/mapping_rule.rb +6 -1
  33. data/lib/lutaml/model/render_policy.rb +26 -8
  34. data/lib/lutaml/model/schema/base_schema.rb +15 -0
  35. data/lib/lutaml/model/schema/generator/definition.rb +52 -8
  36. data/lib/lutaml/model/schema/generator/definitions_collection.rb +35 -14
  37. data/lib/lutaml/model/schema.rb +4 -2
  38. data/lib/lutaml/model/serializable.rb +33 -0
  39. data/lib/lutaml/model/serialize/attribute_definition.rb +196 -2
  40. data/lib/lutaml/model/serialize/builder.rb +22 -0
  41. data/lib/lutaml/model/serialize/conversion_caching.rb +5 -0
  42. data/lib/lutaml/model/serialize/format_conversion.rb +49 -41
  43. data/lib/lutaml/model/serialize/initialization.rb +54 -7
  44. data/lib/lutaml/model/serialize/model_import.rb +60 -0
  45. data/lib/lutaml/model/serialize.rb +14 -6
  46. data/lib/lutaml/model/type/time.rb +6 -0
  47. data/lib/lutaml/model/type/value.rb +9 -7
  48. data/lib/lutaml/model/type_resolver.rb +7 -5
  49. data/lib/lutaml/model/utils.rb +18 -5
  50. data/lib/lutaml/model/version.rb +1 -1
  51. data/lib/lutaml/model.rb +7 -0
  52. data/lib/lutaml/xml/adapter/xml_parser.rb +5 -2
  53. data/lib/lutaml/xml/adapter_loader.rb +5 -2
  54. data/lib/lutaml/xml/format.rb +18 -16
  55. data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
  56. data/lib/lutaml/xml/leptris/plan_compiler.rb +650 -0
  57. data/lib/lutaml/xml/leptris/plan_hydrator.rb +748 -0
  58. data/lib/lutaml/xml/leptris/plan_order.rb +55 -0
  59. data/lib/lutaml/xml/leptris/plan_serializer.rb +702 -0
  60. data/lib/lutaml/xml/leptris/plan_walk.rb +36 -0
  61. data/lib/lutaml/xml/leptris.rb +46 -0
  62. data/lib/lutaml/xml/mapping.rb +71 -18
  63. data/lib/lutaml/xml/model_transform.rb +231 -8
  64. data/lib/lutaml/xml/namespace.rb +18 -8
  65. data/lib/lutaml/xml/namespace_collector.rb +17 -1
  66. data/lib/lutaml/xml/namespace_needs.rb +77 -49
  67. data/lib/lutaml/xml/parse_session.rb +7 -19
  68. data/lib/lutaml/xml/schema/xsd/schema.rb +2 -1
  69. data/lib/lutaml/xml/serialization/format_conversion.rb +5 -1
  70. data/lib/lutaml/xml/transformation/ordered_applier.rb +21 -15
  71. data/lib/lutaml/xml/transformation.rb +15 -1
  72. data/lib/lutaml/xml/type/serializers.rb +8 -1
  73. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +34 -2
  74. data/lib/tasks/performance_probe_plan_walk.rb +1 -1
  75. data/lutaml-model.gemspec +7 -4
  76. data/spec/lutaml/json/yeptris_adapter_spec.rb +7 -2
  77. data/spec/lutaml/key_value/transformation/value_serializer_spec.rb +9 -5
  78. data/spec/lutaml/key_value/transformation_spec.rb +87 -21
  79. data/spec/lutaml/model/any_element_mapping_spec.rb +41 -0
  80. data/spec/lutaml/model/collection_validation_spec.rb +265 -0
  81. data/spec/lutaml/model/desc_spec.rb +50 -0
  82. data/spec/lutaml/model/late_attribute_declaration_spec.rb +35 -0
  83. data/spec/lutaml/model/model_import_reentry_spec.rb +46 -0
  84. data/spec/lutaml/model/opal_smoke_spec.rb +17 -0
  85. data/spec/lutaml/model/schema/generate_many_spec.rb +61 -0
  86. data/spec/lutaml/model/schema/generation_regressions_spec.rb +139 -0
  87. data/spec/lutaml/model/schema/json_schema_spec.rb +1 -13
  88. data/spec/lutaml/model/schema/yaml_schema_spec.rb +0 -11
  89. data/spec/lutaml/model/serialize/attribute_definition_block_form_spec.rb +244 -0
  90. data/spec/lutaml/model/store_spec.rb +6 -2
  91. data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
  92. data/spec/lutaml/model/writer_assigned_attribute_spec.rb +62 -0
  93. data/spec/lutaml/model/yeptris_error_parity_spec.rb +95 -0
  94. data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
  95. data/spec/lutaml/xml/adapter/over_cap_parse_spec.rb +105 -0
  96. data/spec/lutaml/xml/adapter_loader_spec.rb +14 -0
  97. data/spec/lutaml/xml/adapter_metadata_default_spec.rb +16 -0
  98. data/spec/lutaml/xml/attribute_ambiguity_surface_spec.rb +5 -0
  99. data/spec/lutaml/xml/clear_parse_state_spec.rb +6 -1
  100. data/spec/lutaml/xml/compiled_rule_namespace_spec.rb +5 -0
  101. data/spec/lutaml/xml/cross_register_plan_spec.rb +75 -0
  102. data/spec/lutaml/xml/doubly_defined_namespace_spec.rb +16 -8
  103. data/spec/lutaml/xml/element_order_tracking_spec.rb +37 -0
  104. data/spec/lutaml/xml/leptris_string_encoding_spec.rb +65 -0
  105. data/spec/lutaml/xml/opal_xml_spec.rb +47 -0
  106. data/spec/lutaml/xml/parse_dom_retention_spec.rb +34 -0
  107. data/spec/lutaml/xml/plan_fast_path_spec.rb +122 -14
  108. data/spec/lutaml/xml/plan_namespaced_serialize_spec.rb +157 -0
  109. data/spec/lutaml/xml/plan_ns_less_document_spec.rb +86 -0
  110. data/spec/lutaml/xml/plan_partition_spec.rb +1 -1
  111. data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +131 -0
  112. data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
  113. data/spec/lutaml/xml/plan_walk_spec.rb +6 -6
  114. data/spec/lutaml/xml/plan_wildcard_any_spec.rb +96 -0
  115. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +29 -0
  116. data/spec/person_spec.rb +8 -0
  117. metadata +48 -17
  118. data/lib/lutaml/xml/plan_compiler.rb +0 -332
  119. data/lib/lutaml/xml/plan_hydrator.rb +0 -367
  120. data/lib/lutaml/xml/plan_order.rb +0 -53
  121. data/lib/lutaml/xml/plan_serializer.rb +0 -252
  122. 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(Lutaml::KeyValue::DataModel::Element)
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.to_hash
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.to_hash
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.to_hash
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.to_hash
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.to_hash
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.to_hash
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.to_hash
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.to_hash
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.to_hash
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.to_hash
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 "produces OOP data structures, not raw hashes" do
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
- # Result should be KeyValueElement, not Hash
245
- expect(result).to be_a(Lutaml::KeyValue::DataModel::Element)
246
- expect(result).not_to be_a(Hash)
247
-
248
- # Children should also be KeyValueElements
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.to_hash
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