lutaml-store 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +27 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +10 -0
  6. data/.rubocop_todo.yml +450 -0
  7. data/CLAUDE.md +57 -0
  8. data/CODE_OF_CONDUCT.md +132 -0
  9. data/CORRECTED_HTTP_CACHE_IMPLEMENTATION.md +209 -0
  10. data/CORRECTED_HTTP_CACHE_PLAN.md +164 -0
  11. data/Gemfile +15 -0
  12. data/Gemfile.lock +220 -0
  13. data/README.adoc +1430 -0
  14. data/Rakefile +12 -0
  15. data/TODO.impl/0-lutaml-store-self-quality.md +112 -0
  16. data/TODO.impl/1-lutaml-hal-migration.md +60 -0
  17. data/TODO.impl/2-glossarist-migration.md +359 -0
  18. data/TODO.impl/3-lutaml-jsonschema-migration.md +273 -0
  19. data/bin/console +11 -0
  20. data/bin/setup +8 -0
  21. data/demo/Gemfile +15 -0
  22. data/demo/Gemfile.lock +61 -0
  23. data/demo/README.adoc +301 -0
  24. data/demo/data/vcards/co/contact_10_thompson.data +1 -0
  25. data/demo/data/vcards/co/contact_10_thompson.meta +1 -0
  26. data/demo/data/vcards/co/contact_1_doe.data +1 -0
  27. data/demo/data/vcards/co/contact_1_doe.meta +1 -0
  28. data/demo/data/vcards/co/contact_2_smith.data +1 -0
  29. data/demo/data/vcards/co/contact_2_smith.meta +1 -0
  30. data/demo/data/vcards/co/contact_3_johnson.data +1 -0
  31. data/demo/data/vcards/co/contact_3_johnson.meta +1 -0
  32. data/demo/data/vcards/co/contact_4_garcia.data +1 -0
  33. data/demo/data/vcards/co/contact_4_garcia.meta +1 -0
  34. data/demo/data/vcards/co/contact_5_wilson.data +1 -0
  35. data/demo/data/vcards/co/contact_5_wilson.meta +1 -0
  36. data/demo/data/vcards/co/contact_6_brown.data +1 -0
  37. data/demo/data/vcards/co/contact_6_brown.meta +1 -0
  38. data/demo/data/vcards/co/contact_7_davis.data +1 -0
  39. data/demo/data/vcards/co/contact_7_davis.meta +1 -0
  40. data/demo/data/vcards/co/contact_8_anderson.data +1 -0
  41. data/demo/data/vcards/co/contact_8_anderson.meta +1 -0
  42. data/demo/data/vcards/co/contact_9_taylor.data +1 -0
  43. data/demo/data/vcards/co/contact_9_taylor.meta +1 -0
  44. data/demo/data/vcards.db +0 -0
  45. data/demo/pottery_class_demo.rb +164 -0
  46. data/demo/vcard_models.rb +140 -0
  47. data/demo/vcard_store_demo.rb +526 -0
  48. data/lib/lutaml/store/adapter/base.rb +65 -0
  49. data/lib/lutaml/store/adapter/filesystem.rb +288 -0
  50. data/lib/lutaml/store/adapter/memory.rb +225 -0
  51. data/lib/lutaml/store/adapter/sqlite.rb +193 -0
  52. data/lib/lutaml/store/adapter.rb +12 -0
  53. data/lib/lutaml/store/attribute_updater.rb +198 -0
  54. data/lib/lutaml/store/basic_store.rb +190 -0
  55. data/lib/lutaml/store/cache.rb +108 -0
  56. data/lib/lutaml/store/cache_store.rb +282 -0
  57. data/lib/lutaml/store/composite_model_handler.rb +169 -0
  58. data/lib/lutaml/store/compression.rb +137 -0
  59. data/lib/lutaml/store/config.rb +178 -0
  60. data/lib/lutaml/store/database_store.rb +425 -0
  61. data/lib/lutaml/store/events.rb +92 -0
  62. data/lib/lutaml/store/format/base.rb +33 -0
  63. data/lib/lutaml/store/format/json.rb +25 -0
  64. data/lib/lutaml/store/format/jsonl.rb +37 -0
  65. data/lib/lutaml/store/format/marshal_format.rb +37 -0
  66. data/lib/lutaml/store/format/yaml.rb +29 -0
  67. data/lib/lutaml/store/format/yamls.rb +35 -0
  68. data/lib/lutaml/store/format.rb +33 -0
  69. data/lib/lutaml/store/http_cache.rb +279 -0
  70. data/lib/lutaml/store/http_cache_config.rb +53 -0
  71. data/lib/lutaml/store/http_cache_entry.rb +69 -0
  72. data/lib/lutaml/store/http_header_processor.rb +175 -0
  73. data/lib/lutaml/store/integrity.rb +102 -0
  74. data/lib/lutaml/store/model_registration.rb +75 -0
  75. data/lib/lutaml/store/model_registry.rb +123 -0
  76. data/lib/lutaml/store/model_serializer.rb +69 -0
  77. data/lib/lutaml/store/monitor.rb +192 -0
  78. data/lib/lutaml/store/storage_key.rb +40 -0
  79. data/lib/lutaml/store/version.rb +7 -0
  80. data/lib/lutaml/store.rb +41 -0
  81. data/lutaml-store.gemspec +35 -0
  82. data/plan.adoc +606 -0
  83. data/sig/lutaml/store.rbs +6 -0
  84. data/spec/lutaml/store/adapter_interface_spec.rb +89 -0
  85. data/spec/lutaml/store/anti_pattern_guard_spec.rb +35 -0
  86. data/spec/lutaml/store/anti_pattern_spec.rb +78 -0
  87. data/spec/lutaml/store/autoload_spec.rb +34 -0
  88. data/spec/lutaml/store/cache_store_spec.rb +271 -0
  89. data/spec/lutaml/store/compression_spec.rb +78 -0
  90. data/spec/lutaml/store/config_enhanced_spec.rb +158 -0
  91. data/spec/lutaml/store/corrected_http_cache_integration_spec.rb +336 -0
  92. data/spec/lutaml/store/custom_serializer_spec.rb +108 -0
  93. data/spec/lutaml/store/database_store_spec.rb +279 -0
  94. data/spec/lutaml/store/file_io_spec.rb +219 -0
  95. data/spec/lutaml/store/format_round_trip_spec.rb +110 -0
  96. data/spec/lutaml/store/format_spec.rb +70 -0
  97. data/spec/lutaml/store/http_cache_entry_spec.rb +203 -0
  98. data/spec/lutaml/store/http_cache_hal_integration_spec.rb +404 -0
  99. data/spec/lutaml/store/http_cache_spec.rb +422 -0
  100. data/spec/lutaml/store/http_header_processor_spec.rb +290 -0
  101. data/spec/lutaml/store/import_spec.rb +90 -0
  102. data/spec/lutaml/store/integrity_spec.rb +157 -0
  103. data/spec/lutaml/store/key_collision_serializer_spec.rb +98 -0
  104. data/spec/lutaml/store/load_save_spec.rb +107 -0
  105. data/spec/lutaml/store/lutaml_model_integration_spec.rb +291 -0
  106. data/spec/lutaml/store/model_serializer_spec.rb +140 -0
  107. data/spec/lutaml/store/store_spec.rb +182 -0
  108. data/spec/lutaml/store_spec.rb +21 -0
  109. data/spec/spec_helper.rb +16 -0
  110. metadata +166 -0
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module DatabaseStoreTestModels
6
+ class TestStudio < Lutaml::Model::Serializable
7
+ attribute :studio_key, :string
8
+ attribute :name, :string
9
+ attribute :location, :string
10
+ attribute :_class, :string, default: -> { "TestStudio" }, polymorphic_class: true
11
+ end
12
+
13
+ class TestCeramicStudio < TestStudio
14
+ attribute :clay_type, :string
15
+ attribute :_class, :string, default: -> { "TestCeramicStudio" }
16
+ end
17
+
18
+ class TestPotteryClass < Lutaml::Model::Serializable
19
+ attribute :class_id, :string
20
+ attribute :description, :string
21
+ attribute :studio, TestStudio, polymorphic: true
22
+ end
23
+ end
24
+
25
+ RSpec.describe Lutaml::Store::DatabaseStore do
26
+ let(:config) { { adapter_type: :memory } }
27
+
28
+ let(:models) do
29
+ [
30
+ { model: DatabaseStoreTestModels::TestPotteryClass, key: :class_id },
31
+ { model: DatabaseStoreTestModels::TestStudio, key: :studio_key, polymorphic_class_key: :_class },
32
+ { model: DatabaseStoreTestModels::TestCeramicStudio, key: :studio_key, polymorphic_class_key: :_class }
33
+ ]
34
+ end
35
+
36
+ let(:store) { described_class.new(adapter: :memory, models: models) }
37
+
38
+ describe "#initialize" do
39
+ it "creates a database store with models" do
40
+ expect(store).to be_a(described_class)
41
+ expect(store.registry.count).to eq(3)
42
+ end
43
+
44
+ it "raises error when no models provided" do
45
+ expect do
46
+ described_class.new(adapter: :memory, models: [])
47
+ end.to raise_error(Lutaml::Store::ConfigurationError, /No models registered/)
48
+ end
49
+ end
50
+
51
+ describe "#save and #fetch" do
52
+ let(:studio) { DatabaseStoreTestModels::TestStudio.new(studio_key: "test_studio", name: "Test Studio") }
53
+ let(:pottery_class) do
54
+ DatabaseStoreTestModels::TestPotteryClass.new(
55
+ class_id: "pottery_101",
56
+ description: "Basic pottery",
57
+ studio: studio
58
+ )
59
+ end
60
+
61
+ it "saves and fetches a model with composite relationships" do
62
+ store.save(pottery_class)
63
+
64
+ fetched = store.fetch(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101")
65
+ expect(fetched).to be_a(DatabaseStoreTestModels::TestPotteryClass)
66
+ expect(fetched.class_id).to eq("pottery_101")
67
+ expect(fetched.description).to eq("Basic pottery")
68
+ expect(fetched.studio).to be_a(DatabaseStoreTestModels::TestStudio)
69
+ expect(fetched.studio.studio_key).to eq("test_studio")
70
+ expect(fetched.studio.name).to eq("Test Studio")
71
+ end
72
+
73
+ it "handles polymorphic models correctly" do
74
+ ceramic_studio = DatabaseStoreTestModels::TestCeramicStudio.new(
75
+ studio_key: "ceramic_studio",
76
+ name: "Ceramic Studio",
77
+ clay_type: "Porcelain"
78
+ )
79
+
80
+ store.save(ceramic_studio)
81
+
82
+ fetched = store.fetch(model: DatabaseStoreTestModels::TestStudio, studio_key: "ceramic_studio")
83
+ expect(fetched).to be_a(DatabaseStoreTestModels::TestCeramicStudio)
84
+ expect(fetched.clay_type).to eq("Porcelain")
85
+ end
86
+
87
+ it "returns nil for non-existent models" do
88
+ result = store.fetch(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "nonexistent")
89
+ expect(result).to be_nil
90
+ end
91
+ end
92
+
93
+ describe "#update" do
94
+ let(:studio) { DatabaseStoreTestModels::TestStudio.new(studio_key: "test_studio", name: "Test Studio") }
95
+ let(:pottery_class) do
96
+ DatabaseStoreTestModels::TestPotteryClass.new(
97
+ class_id: "pottery_101",
98
+ description: "Basic pottery",
99
+ studio: studio
100
+ )
101
+ end
102
+
103
+ before { store.save(pottery_class) }
104
+
105
+ it "updates with block" do
106
+ updated = store.update(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101") do |model|
107
+ model.description = "Advanced pottery"
108
+ model
109
+ end
110
+
111
+ expect(updated.description).to eq("Advanced pottery")
112
+
113
+ fetched = store.fetch(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101")
114
+ expect(fetched.description).to eq("Advanced pottery")
115
+ end
116
+
117
+ it "updates with hash including dot notation" do
118
+ updated = store.update(
119
+ model: DatabaseStoreTestModels::TestPotteryClass,
120
+ class_id: "pottery_101",
121
+ attributes: { "studio.location" => "Downtown" }
122
+ )
123
+
124
+ expect(updated.studio.location).to eq("Downtown")
125
+
126
+ fetched = store.fetch(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101")
127
+ expect(fetched.studio.location).to eq("Downtown")
128
+ end
129
+ end
130
+
131
+ describe "#destroy" do
132
+ let(:studio) { DatabaseStoreTestModels::TestStudio.new(studio_key: "test_studio", name: "Test Studio") }
133
+ let(:pottery_class) do
134
+ DatabaseStoreTestModels::TestPotteryClass.new(
135
+ class_id: "pottery_101",
136
+ description: "Basic pottery",
137
+ studio: studio
138
+ )
139
+ end
140
+
141
+ before { store.save(pottery_class) }
142
+
143
+ it "destroys a model and its composite relationships" do
144
+ result = store.destroy(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101")
145
+ expect(result).to be true
146
+
147
+ fetched = store.fetch(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101")
148
+ expect(fetched).to be_nil
149
+
150
+ studio_fetched = store.fetch(model: DatabaseStoreTestModels::TestStudio, studio_key: "test_studio")
151
+ expect(studio_fetched).to be_nil
152
+ end
153
+
154
+ it "returns false for non-existent models" do
155
+ result = store.destroy(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "nonexistent")
156
+ expect(result).to be false
157
+ end
158
+ end
159
+
160
+ describe "#where" do
161
+ let(:studio1) { DatabaseStoreTestModels::TestStudio.new(studio_key: "studio1", name: "Studio One") }
162
+ let(:studio2) { DatabaseStoreTestModels::TestStudio.new(studio_key: "studio2", name: "Studio Two") }
163
+ let(:pottery1) do
164
+ DatabaseStoreTestModels::TestPotteryClass.new(
165
+ class_id: "pottery_101",
166
+ description: "Basic pottery",
167
+ studio: studio1
168
+ )
169
+ end
170
+ let(:pottery2) do
171
+ DatabaseStoreTestModels::TestPotteryClass.new(
172
+ class_id: "pottery_201",
173
+ description: "Advanced pottery",
174
+ studio: studio2
175
+ )
176
+ end
177
+
178
+ before do
179
+ store.save([pottery1, pottery2])
180
+ end
181
+
182
+ it "finds models by criteria" do
183
+ results = store.where(model: DatabaseStoreTestModels::TestPotteryClass, description: "Basic pottery")
184
+ expect(results.size).to eq(1)
185
+ expect(results.first.class_id).to eq("pottery_101")
186
+ end
187
+ end
188
+
189
+ describe "#all" do
190
+ let(:studio1) { DatabaseStoreTestModels::TestStudio.new(studio_key: "studio1", name: "Studio One") }
191
+ let(:studio2) { DatabaseStoreTestModels::TestStudio.new(studio_key: "studio2", name: "Studio Two") }
192
+ let(:pottery1) do
193
+ DatabaseStoreTestModels::TestPotteryClass.new(
194
+ class_id: "pottery_101",
195
+ description: "Basic pottery",
196
+ studio: studio1
197
+ )
198
+ end
199
+ let(:pottery2) do
200
+ DatabaseStoreTestModels::TestPotteryClass.new(
201
+ class_id: "pottery_201",
202
+ description: "Advanced pottery",
203
+ studio: studio2
204
+ )
205
+ end
206
+
207
+ before do
208
+ store.save([pottery1, pottery2])
209
+ end
210
+
211
+ it "returns all models of a specific type" do
212
+ results = store.all(model: DatabaseStoreTestModels::TestPotteryClass)
213
+ expect(results.size).to eq(2)
214
+ expect(results.map(&:class_id)).to contain_exactly("pottery_101", "pottery_201")
215
+ end
216
+ end
217
+
218
+ describe "#exists?" do
219
+ let(:studio) { DatabaseStoreTestModels::TestStudio.new(studio_key: "test_studio", name: "Test Studio") }
220
+ let(:pottery_class) do
221
+ DatabaseStoreTestModels::TestPotteryClass.new(
222
+ class_id: "pottery_101",
223
+ description: "Basic pottery",
224
+ studio: studio
225
+ )
226
+ end
227
+
228
+ before { store.save(pottery_class) }
229
+
230
+ it "returns true for existing models" do
231
+ expect(store.exists?(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101")).to be true
232
+ end
233
+
234
+ it "returns false for non-existent models" do
235
+ expect(store.exists?(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "nonexistent")).to be false
236
+ end
237
+ end
238
+
239
+ describe "#count" do
240
+ let(:studio1) { DatabaseStoreTestModels::TestStudio.new(studio_key: "studio1", name: "Studio One") }
241
+ let(:studio2) { DatabaseStoreTestModels::TestStudio.new(studio_key: "studio2", name: "Studio Two") }
242
+ let(:pottery1) do
243
+ DatabaseStoreTestModels::TestPotteryClass.new(
244
+ class_id: "pottery_101",
245
+ description: "Basic pottery",
246
+ studio: studio1
247
+ )
248
+ end
249
+ let(:pottery2) do
250
+ DatabaseStoreTestModels::TestPotteryClass.new(
251
+ class_id: "pottery_201",
252
+ description: "Advanced pottery",
253
+ studio: studio2
254
+ )
255
+ end
256
+
257
+ before do
258
+ store.save([pottery1, pottery2])
259
+ end
260
+
261
+ it "returns count of models" do
262
+ expect(store.count(model: DatabaseStoreTestModels::TestPotteryClass)).to eq(2)
263
+ expect(store.count(model: DatabaseStoreTestModels::TestStudio)).to eq(2)
264
+ end
265
+ end
266
+
267
+ describe "#stats" do
268
+ it "provides statistics" do
269
+ stats = store.stats
270
+ expect(stats).to include(:models_registered, :registered_models, :total_models)
271
+ expect(stats[:models_registered]).to eq(3)
272
+ expect(stats[:registered_models]).to include(
273
+ "DatabaseStoreTestModels::TestPotteryClass",
274
+ "DatabaseStoreTestModels::TestStudio",
275
+ "DatabaseStoreTestModels::TestCeramicStudio"
276
+ )
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+ require "json"
6
+
7
+ class FileTestItem < Lutaml::Model::Serializable
8
+ attribute :name, :string
9
+ attribute :value, :string
10
+
11
+ key_value do
12
+ map :name, to: :name
13
+ map :value, to: :value
14
+ end
15
+ end
16
+
17
+ RSpec.describe "Lutaml::Store file I/O" do
18
+ let(:tmpdir) { Dir.mktmpdir }
19
+ after { FileUtils.rm_rf(tmpdir) }
20
+
21
+ let(:store) do
22
+ Lutaml::Store.new(
23
+ adapter: :memory,
24
+ models: [{ model: FileTestItem, key: :name, dir: "items" }]
25
+ )
26
+ end
27
+
28
+ let(:items) do
29
+ [
30
+ FileTestItem.new(name: "alpha", value: "first"),
31
+ FileTestItem.new(name: "beta", value: "second"),
32
+ FileTestItem.new(name: "gamma", value: "third")
33
+ ]
34
+ end
35
+
36
+ def items_dir
37
+ File.join(tmpdir, "items")
38
+ end
39
+
40
+ # ── YAML format ──
41
+
42
+ describe "YAML separate layout" do
43
+ it "round-trips models" do
44
+ store.save_all(items, path: tmpdir, format: :yaml, layout: :separate)
45
+
46
+ expect(Dir.glob(File.join(items_dir, "*.{yaml,yml}")).size).to eq(3)
47
+
48
+ loaded = store.load_all(FileTestItem, path: tmpdir, format: :yaml, layout: :separate)
49
+ expect(loaded.size).to eq(3)
50
+ expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
51
+ expect(loaded.map(&:value).sort).to eq(%w[first second third])
52
+ end
53
+
54
+ it "writes one file per model named by key" do
55
+ store.save_all(items, path: tmpdir, format: :yaml, layout: :separate)
56
+
57
+ expect(File.exist?(File.join(items_dir, "alpha.yaml"))).to be true
58
+ expect(File.exist?(File.join(items_dir, "beta.yaml"))).to be true
59
+ expect(File.exist?(File.join(items_dir, "gamma.yaml"))).to be true
60
+ end
61
+ end
62
+
63
+ describe "YAML grouped layout" do
64
+ it "writes one file per key" do
65
+ store.save_all(items, path: tmpdir, format: :yaml, layout: :grouped)
66
+
67
+ files = Dir.glob(File.join(items_dir, "*.yaml")).sort
68
+ expect(files.size).to eq(3)
69
+ end
70
+
71
+ it "round-trips models" do
72
+ store.save_all(items, path: tmpdir, format: :yaml, layout: :grouped)
73
+
74
+ loaded = store.load_all(FileTestItem, path: tmpdir, format: :yaml, layout: :grouped)
75
+ expect(loaded.size).to eq(3)
76
+ expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
77
+ end
78
+ end
79
+
80
+ # ── JSON format ──
81
+
82
+ describe "JSON separate layout" do
83
+ it "round-trips models" do
84
+ store.save_all(items, path: tmpdir, format: :json, layout: :separate)
85
+
86
+ expect(Dir.glob(File.join(items_dir, "*.json")).size).to eq(3)
87
+
88
+ loaded = store.load_all(FileTestItem, path: tmpdir, format: :json, layout: :separate)
89
+ expect(loaded.size).to eq(3)
90
+ expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
91
+ end
92
+
93
+ it "writes valid JSON files" do
94
+ store.save_all(items, path: tmpdir, format: :json, layout: :separate)
95
+
96
+ Dir.glob(File.join(items_dir, "*.json")).each do |path|
97
+ expect { JSON.parse(File.read(path, encoding: "utf-8")) }.not_to raise_error
98
+ end
99
+ end
100
+ end
101
+
102
+ # ── JSONL format ──
103
+
104
+ describe "JSONL export/import" do
105
+ it "exports models to JSONL file" do
106
+ export_path = File.join(tmpdir, "data.jsonl")
107
+ store.export(items, path: export_path, format: :jsonl)
108
+
109
+ expect(File.exist?(export_path)).to be true
110
+ lines = File.read(export_path, encoding: "utf-8").lines.reject { |l| l.strip.empty? }
111
+ expect(lines.size).to eq(3)
112
+ lines.each { |line| expect { JSON.parse(line) }.not_to raise_error }
113
+ end
114
+
115
+ it "round-trips through JSONL file" do
116
+ export_path = File.join(tmpdir, "data.jsonl")
117
+ store.export(items, path: export_path, format: :jsonl)
118
+
119
+ fmt = Lutaml::Store::Format.resolve(:jsonl)
120
+ data = File.read(export_path, encoding: "utf-8")
121
+ loaded = fmt.deserialize_many(data, FileTestItem)
122
+
123
+ expect(loaded.size).to eq(3)
124
+ expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
125
+ end
126
+ end
127
+
128
+ # ── Marshal format ──
129
+
130
+ describe "Marshal separate layout" do
131
+ it "round-trips models" do
132
+ store.save_all(items, path: tmpdir, format: :marshal, layout: :separate)
133
+
134
+ expect(Dir.glob(File.join(items_dir, "*.bin")).size).to eq(3)
135
+
136
+ loaded = store.load_all(FileTestItem, path: tmpdir, format: :marshal, layout: :separate)
137
+ expect(loaded.size).to eq(3)
138
+ expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
139
+ end
140
+ end
141
+
142
+ # ── YAMLS format ──
143
+
144
+ describe "YAMLS grouped layout" do
145
+ it "writes multi-document YAML files" do
146
+ # Grouped YAMLS: each group file contains multiple docs
147
+ store.save_all(items, path: tmpdir, format: :yamls, layout: :grouped)
148
+
149
+ files = Dir.glob(File.join(items_dir, "*.{yaml,yml}")).sort
150
+ expect(files.size).to eq(3)
151
+ end
152
+
153
+ it "round-trips models" do
154
+ store.save_all(items, path: tmpdir, format: :yamls, layout: :grouped)
155
+
156
+ loaded = store.load_all(FileTestItem, path: tmpdir, format: :yamls, layout: :grouped)
157
+ expect(loaded.size).to eq(3)
158
+ expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
159
+ end
160
+ end
161
+
162
+ # ── Edge cases ──
163
+
164
+ describe "empty directory" do
165
+ it "returns empty array from load_all" do
166
+ FileUtils.mkdir_p(items_dir)
167
+
168
+ loaded = store.load_all(FileTestItem, path: tmpdir, format: :yaml, layout: :separate)
169
+ expect(loaded).to eq([])
170
+ end
171
+ end
172
+
173
+ describe "empty model list" do
174
+ it "returns empty array from save_all" do
175
+ result = store.save_all([], path: tmpdir, format: :yaml, layout: :separate)
176
+ expect(result).to eq([])
177
+ end
178
+ end
179
+
180
+ describe "missing directory" do
181
+ it "raises BackendError for load_all" do
182
+ expect do
183
+ store.load_all(FileTestItem, path: "/nonexistent/path", format: :yaml, layout: :separate)
184
+ end.to raise_error(Lutaml::Store::BackendError, /Directory not found/)
185
+ end
186
+ end
187
+
188
+ describe "no path provided" do
189
+ it "raises BackendError for save_all without path" do
190
+ items_with_no_dir = [
191
+ FileTestItem.new(name: "x", value: "y")
192
+ ]
193
+ # No dir in registration and no path
194
+ bare_store = Lutaml::Store.new(
195
+ adapter: :memory,
196
+ models: [{ model: FileTestItem, key: :name }]
197
+ )
198
+ expect do
199
+ bare_store.save_all(items_with_no_dir, format: :yaml, layout: :separate)
200
+ end.to raise_error(Lutaml::Store::BackendError, /No directory specified/)
201
+ end
202
+ end
203
+
204
+ describe "unknown layout" do
205
+ it "raises ConfigurationError" do
206
+ expect do
207
+ store.save_all(items, path: tmpdir, format: :yaml, layout: :unknown)
208
+ end.to raise_error(Lutaml::Store::ConfigurationError, /Unknown layout/)
209
+ end
210
+ end
211
+
212
+ describe "unknown format" do
213
+ it "raises UnsupportedFormatError" do
214
+ expect do
215
+ store.save_all(items, path: tmpdir, format: :csv, layout: :separate)
216
+ end.to raise_error(Lutaml::Store::Format::UnsupportedFormatError)
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "json"
5
+
6
+ class FormatTestItem < Lutaml::Model::Serializable
7
+ attribute :name, :string
8
+ attribute :value, :string
9
+ attribute :count, :integer
10
+
11
+ key_value do
12
+ map :name, to: :name
13
+ map :value, to: :value
14
+ map :count, to: :count
15
+ end
16
+ end
17
+
18
+ RSpec.describe "Format handler round-trips" do
19
+ let(:item) { FormatTestItem.new(name: "alpha", value: "first", count: 42) }
20
+ let(:items) do
21
+ [
22
+ FormatTestItem.new(name: "alpha", value: "first", count: 1),
23
+ FormatTestItem.new(name: "beta", value: "second", count: 2)
24
+ ]
25
+ end
26
+
27
+ shared_examples "single-model round-trip" do |format_sym|
28
+ it "round-trips a single model through #{format_sym}" do
29
+ fmt = Lutaml::Store::Format.resolve(format_sym)
30
+ serialized = fmt.serialize(item)
31
+ restored = fmt.deserialize(serialized, FormatTestItem)
32
+
33
+ expect(restored.name).to eq("alpha")
34
+ expect(restored.value).to eq("first")
35
+ expect(restored.count).to eq(42)
36
+ end
37
+ end
38
+
39
+ shared_examples "multi-model round-trip" do |format_sym|
40
+ it "round-trips multiple models through #{format_sym}" do
41
+ fmt = Lutaml::Store::Format.resolve(format_sym)
42
+ serialized = fmt.serialize_many(items)
43
+ restored = fmt.deserialize_many(serialized, FormatTestItem)
44
+
45
+ expect(restored.size).to eq(2)
46
+ expect(restored.map(&:name).sort).to eq(%w[alpha beta])
47
+ expect(restored.map(&:count).sort).to eq([1, 2])
48
+ end
49
+ end
50
+
51
+ describe Lutaml::Store::Format::Yaml do
52
+ it_behaves_like "single-model round-trip", :yaml
53
+ it_behaves_like "multi-model round-trip", :yaml
54
+
55
+ it "produces valid YAML" do
56
+ fmt = described_class.new
57
+ output = fmt.serialize(item)
58
+ expect(output).to include("name:")
59
+ expect(output).to include("value:")
60
+ end
61
+ end
62
+
63
+ describe Lutaml::Store::Format::Yamls do
64
+ it_behaves_like "single-model round-trip", :yamls
65
+ it_behaves_like "multi-model round-trip", :yamls
66
+
67
+ it "produces multi-document YAML stream" do
68
+ fmt = described_class.new
69
+ output = fmt.serialize_many(items)
70
+ expect(output.scan(/^---/).size).to be >= 2
71
+ end
72
+ end
73
+
74
+ describe Lutaml::Store::Format::Json do
75
+ it_behaves_like "single-model round-trip", :json
76
+
77
+ it "produces valid JSON" do
78
+ fmt = described_class.new
79
+ output = fmt.serialize(item)
80
+ parsed = JSON.parse(output)
81
+ expect(parsed["name"]).to eq("alpha")
82
+ expect(parsed["count"]).to eq(42)
83
+ end
84
+ end
85
+
86
+ describe Lutaml::Store::Format::Jsonl do
87
+ it_behaves_like "single-model round-trip", :jsonl
88
+ it_behaves_like "multi-model round-trip", :jsonl
89
+
90
+ it "produces line-delimited JSON" do
91
+ fmt = described_class.new
92
+ output = fmt.serialize_many(items)
93
+ lines = output.lines.reject { |l| l.strip.empty? }
94
+ expect(lines.size).to eq(2)
95
+ lines.each { |line| expect { JSON.parse(line) }.not_to raise_error }
96
+ end
97
+ end
98
+
99
+ describe Lutaml::Store::Format::MarshalFormat do
100
+ it_behaves_like "single-model round-trip", :marshal
101
+ it_behaves_like "multi-model round-trip", :marshal
102
+
103
+ it "produces binary data" do
104
+ fmt = described_class.new
105
+ output = fmt.serialize(item)
106
+ expect(output).to be_a(String)
107
+ expect(output.encoding).to eq(Encoding::ASCII_8BIT)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+
6
+ RSpec.describe Lutaml::Store::Format do
7
+ describe ".resolve" do
8
+ it "resolves :yaml format" do
9
+ fmt = described_class.resolve(:yaml)
10
+ expect(fmt).to be_a(Lutaml::Store::Format::Yaml)
11
+ end
12
+
13
+ it "resolves :yamls format" do
14
+ fmt = described_class.resolve(:yamls)
15
+ expect(fmt).to be_a(Lutaml::Store::Format::Yamls)
16
+ end
17
+
18
+ it "resolves :json format" do
19
+ fmt = described_class.resolve(:json)
20
+ expect(fmt).to be_a(Lutaml::Store::Format::Json)
21
+ end
22
+
23
+ it "resolves :jsonl format" do
24
+ fmt = described_class.resolve(:jsonl)
25
+ expect(fmt).to be_a(Lutaml::Store::Format::Jsonl)
26
+ end
27
+
28
+ it "resolves :marshal format" do
29
+ fmt = described_class.resolve(:marshal)
30
+ expect(fmt).to be_a(Lutaml::Store::Format::MarshalFormat)
31
+ end
32
+
33
+ it "raises for unknown format" do
34
+ expect { described_class.resolve(:unknown) }
35
+ .to raise_error(Lutaml::Store::Format::UnsupportedFormatError)
36
+ end
37
+ end
38
+
39
+ describe Lutaml::Store::Format::Yaml do
40
+ let(:fmt) { described_class.new }
41
+
42
+ it "returns .yaml extension" do
43
+ expect(fmt.extension).to eq(".yaml")
44
+ end
45
+
46
+ it "returns glob pattern" do
47
+ expect(fmt.glob_pattern).to eq("*.{yaml,yml}")
48
+ end
49
+ end
50
+
51
+ describe Lutaml::Store::Format::Json do
52
+ let(:fmt) { described_class.new }
53
+
54
+ it "returns .json extension" do
55
+ expect(fmt.extension).to eq(".json")
56
+ end
57
+
58
+ it "returns glob pattern" do
59
+ expect(fmt.glob_pattern).to eq("*.json")
60
+ end
61
+ end
62
+
63
+ describe Lutaml::Store::Format::MarshalFormat do
64
+ let(:fmt) { described_class.new }
65
+
66
+ it "returns .bin extension" do
67
+ expect(fmt.extension).to eq(".bin")
68
+ end
69
+ end
70
+ end