lutaml-store 0.1.1 → 0.2.0

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.
@@ -0,0 +1,450 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+ require "fileutils"
5
+
6
+ module Lutaml
7
+ module Store
8
+ module PackageTransport
9
+ class Base
10
+ def read(path, package_store, format: nil)
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def write(path, package_store, formats: {})
15
+ raise NotImplementedError
16
+ end
17
+
18
+ private
19
+
20
+ def resolve_format(format_name)
21
+ Format.resolve(format_name)
22
+ end
23
+
24
+ def effective_format(entry, formats)
25
+ formats[entry.model] || entry.default_format
26
+ end
27
+
28
+ def format_for_file(filename)
29
+ ext = File.extname(filename)
30
+ case ext
31
+ when ".yaml", ".yml" then Format.resolve(:yaml)
32
+ when ".json" then Format.resolve(:json)
33
+ else Format.resolve(:yaml)
34
+ end
35
+ end
36
+ end
37
+
38
+ class DirectoryTransport < Base
39
+ def read(path, package_store, format: nil)
40
+ definition = package_store.definition
41
+ global_format = format
42
+
43
+ read_metadata(path, package_store)
44
+
45
+ definition.model_entries.each do |entry|
46
+ fmt_name = global_format || entry.default_format
47
+ read_model_entry(path, entry, package_store, fmt_name)
48
+ end
49
+
50
+ definition.asset_entries.each do |entry|
51
+ read_asset_entry(path, entry, package_store)
52
+ end
53
+ end
54
+
55
+ def write(path, package_store, formats: {})
56
+ definition = package_store.definition
57
+ FileUtils.mkdir_p(path)
58
+
59
+ write_metadata(path, package_store)
60
+
61
+ definition.model_entries.each do |entry|
62
+ fmt_name = effective_format(entry, formats)
63
+ fmt = resolve_format(fmt_name)
64
+ write_model_entry(path, entry, package_store, fmt)
65
+ end
66
+
67
+ definition.asset_entries.each do |entry|
68
+ write_asset_entry(path, entry, package_store)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ # ── Metadata ──
75
+
76
+ def read_metadata(path, package_store)
77
+ definition = package_store.definition
78
+ return unless definition.metadata_model && definition.metadata_file
79
+
80
+ file_path = File.join(path, definition.metadata_file)
81
+ return unless File.exist?(file_path)
82
+
83
+ fmt = format_for_file(definition.metadata_file)
84
+ raw = File.read(file_path, encoding: "utf-8")
85
+ metadata = fmt.deserialize(raw, definition.metadata_model)
86
+ package_store.metadata = metadata
87
+ end
88
+
89
+ def write_metadata(path, package_store)
90
+ return unless package_store.metadata
91
+
92
+ definition = package_store.definition
93
+ fmt = format_for_file(definition.metadata_file)
94
+ content = fmt.serialize(package_store.metadata)
95
+ file_path = File.join(path, definition.metadata_file)
96
+ FileUtils.mkdir_p(File.dirname(file_path))
97
+ File.write(file_path, content, encoding: "utf-8")
98
+ end
99
+
100
+ # ── Model entries ──
101
+
102
+ def read_model_entry(base_path, entry, package_store, fmt_name)
103
+ if entry.file
104
+ read_single_file_model(base_path, entry, package_store, fmt_name)
105
+ else
106
+ read_directory_models(base_path, entry, package_store, fmt_name)
107
+ end
108
+ end
109
+
110
+ def read_single_file_model(base_path, entry, package_store, fmt_name)
111
+ file_path = File.join(base_path, entry.file)
112
+ return unless File.exist?(file_path)
113
+
114
+ fmt = resolve_format(fmt_name)
115
+ raw = File.read(file_path, encoding: "utf-8")
116
+ model = fmt.deserialize(raw, entry.model)
117
+ package_store.add_model(model)
118
+ end
119
+
120
+ def read_directory_models(base_path, entry, package_store, fmt_name)
121
+ dir = entry.dir ? File.join(base_path, entry.dir) : base_path
122
+ return unless Dir.exist?(dir)
123
+
124
+ fmt = resolve_format(fmt_name)
125
+ glob = File.join(dir, fmt.glob_pattern)
126
+
127
+ Dir.glob(glob).sort.each do |file_path|
128
+ next unless File.file?(file_path)
129
+
130
+ raw = File.read(file_path, encoding: "utf-8")
131
+ next if raw.strip.empty?
132
+
133
+ begin
134
+ case entry.layout
135
+ when :grouped
136
+ loaded = fmt.deserialize_many(raw, entry.model)
137
+ loaded.each do |m|
138
+ set_key_from_filename(m, file_path, entry)
139
+ package_store.add_model(m)
140
+ end
141
+ else
142
+ model = fmt.deserialize(raw, entry.model)
143
+ set_key_from_filename(model, file_path, entry)
144
+ package_store.add_model(model)
145
+ end
146
+ rescue StandardError => e
147
+ warn "PackageStore: failed to load #{file_path}: #{e.message}"
148
+ end
149
+ end
150
+ end
151
+
152
+ def write_model_entry(base_path, entry, package_store, fmt)
153
+ models = package_store.models_for(entry.model)
154
+ return if models.empty?
155
+
156
+ if entry.file
157
+ write_single_file_model(base_path, entry, models, fmt)
158
+ else
159
+ write_directory_models(base_path, entry, models, fmt)
160
+ end
161
+ end
162
+
163
+ def write_single_file_model(base_path, entry, models, fmt)
164
+ content = case entry.layout
165
+ when :grouped
166
+ fmt.serialize_many(models)
167
+ else
168
+ fmt.serialize(models.first)
169
+ end
170
+ file_path = File.join(base_path, entry.file)
171
+ FileUtils.mkdir_p(File.dirname(file_path))
172
+ File.write(file_path, content, encoding: "utf-8")
173
+ end
174
+
175
+ def write_directory_models(base_path, entry, models, fmt)
176
+ dir = entry.dir ? File.join(base_path, entry.dir) : base_path
177
+ FileUtils.mkdir_p(dir)
178
+
179
+ case entry.layout
180
+ when :grouped
181
+ grouped = models.group_by { |m| extract_key(m, entry) }
182
+ grouped.each do |key, group|
183
+ file_path = File.join(dir, "#{sanitize_filename(key)}#{fmt.extension}")
184
+ content = fmt.serialize_many(group)
185
+ File.write(file_path, content, encoding: "utf-8")
186
+ end
187
+ else
188
+ models.each do |model|
189
+ key = extract_key(model, entry)
190
+ file_path = File.join(dir, "#{sanitize_filename(key)}#{fmt.extension}")
191
+ content = fmt.serialize(model)
192
+ File.write(file_path, content, encoding: "utf-8")
193
+ end
194
+ end
195
+ end
196
+
197
+ # ── Assets ──
198
+
199
+ def read_asset_entry(base_path, entry, package_store)
200
+ full_path = File.join(base_path, entry.path)
201
+ case entry.type
202
+ when :file
203
+ return unless File.exist?(full_path)
204
+
205
+ package_store.add_asset(entry.path, File.binread(full_path))
206
+ when :directory
207
+ return unless Dir.exist?(full_path)
208
+
209
+ Dir.glob(File.join(full_path, "**", "*")).each do |file|
210
+ next unless File.file?(file)
211
+
212
+ relative = file.sub(%r{\A#{Regexp.escape(base_path)}/}, "")
213
+ package_store.add_asset(relative, File.binread(file))
214
+ end
215
+ end
216
+ end
217
+
218
+ def write_asset_entry(base_path, entry, package_store)
219
+ case entry.type
220
+ when :file
221
+ content = package_store.asset(entry.path)
222
+ return unless content
223
+
224
+ file_path = File.join(base_path, entry.path)
225
+ FileUtils.mkdir_p(File.dirname(file_path))
226
+ File.binwrite(file_path, content)
227
+ when :directory
228
+ package_store.asset_paths
229
+ .select { |p| p.start_with?("#{entry.path}/") }
230
+ .each do |asset_path|
231
+ content = package_store.asset(asset_path)
232
+ next unless content
233
+
234
+ file_path = File.join(base_path, asset_path)
235
+ FileUtils.mkdir_p(File.dirname(file_path))
236
+ File.binwrite(file_path, content)
237
+ end
238
+ end
239
+ end
240
+
241
+ # ── Helpers ──
242
+
243
+ def set_key_from_filename(model, file_path, entry)
244
+ key_value = model.public_send(entry.key)
245
+ return if key_value
246
+
247
+ basename = File.basename(file_path, ".*")
248
+ model.public_send(:"#{entry.key}=", basename)
249
+ end
250
+
251
+ def extract_key(model, entry)
252
+ model.public_send(entry.key).to_s
253
+ end
254
+
255
+ def sanitize_filename(key)
256
+ key.gsub(%r{[/:#?]}, "_")
257
+ end
258
+ end
259
+
260
+ class ZipTransport < Base
261
+ def read(path, package_store, format: nil)
262
+ definition = package_store.definition
263
+ global_format = format
264
+
265
+ Zip::File.open(path) do |zf|
266
+ read_metadata_zip(zf, package_store)
267
+
268
+ definition.model_entries.each do |entry|
269
+ fmt_name = global_format || entry.default_format
270
+ read_model_entry_zip(zf, entry, package_store, fmt_name)
271
+ end
272
+
273
+ definition.asset_entries.each do |entry|
274
+ read_asset_entry_zip(zf, entry, package_store)
275
+ end
276
+ end
277
+ end
278
+
279
+ def write(path, package_store, formats: {})
280
+ definition = package_store.definition
281
+ FileUtils.mkdir_p(File.dirname(path))
282
+
283
+ Zip::File.open(path, create: true) do |zf|
284
+ write_metadata_zip(zf, package_store)
285
+
286
+ definition.model_entries.each do |entry|
287
+ fmt_name = effective_format(entry, formats)
288
+ fmt = resolve_format(fmt_name)
289
+ write_model_entry_zip(zf, entry, package_store, fmt)
290
+ end
291
+
292
+ write_assets_zip(zf, package_store)
293
+ end
294
+ end
295
+
296
+ private
297
+
298
+ # ── Metadata ──
299
+
300
+ def read_metadata_zip(zf, package_store)
301
+ definition = package_store.definition
302
+ return unless definition.metadata_model && definition.metadata_file
303
+
304
+ entry = zf.find_entry(definition.metadata_file)
305
+ return unless entry
306
+
307
+ fmt = format_for_file(definition.metadata_file)
308
+ raw = entry.get_input_stream.read
309
+ metadata = fmt.deserialize(raw, definition.metadata_model)
310
+ package_store.metadata = metadata
311
+ end
312
+
313
+ def write_metadata_zip(zf, package_store)
314
+ return unless package_store.metadata
315
+
316
+ definition = package_store.definition
317
+ fmt = format_for_file(definition.metadata_file)
318
+ content = fmt.serialize(package_store.metadata)
319
+ zf.get_output_stream(definition.metadata_file) { |f| f.write(content) }
320
+ end
321
+
322
+ # ── Model entries ──
323
+
324
+ def read_model_entry_zip(zf, entry, package_store, fmt_name)
325
+ fmt = resolve_format(fmt_name)
326
+
327
+ if entry.file
328
+ read_single_file_zip(zf, entry, package_store, fmt)
329
+ else
330
+ read_directory_zip(zf, entry, package_store, fmt)
331
+ end
332
+ end
333
+
334
+ def read_single_file_zip(zf, entry, package_store, fmt)
335
+ zip_entry = zf.find_entry(entry.file)
336
+ return unless zip_entry
337
+
338
+ raw = zip_entry.get_input_stream.read
339
+ model = fmt.deserialize(raw, entry.model)
340
+ package_store.add_model(model)
341
+ end
342
+
343
+ def read_directory_zip(zf, entry, package_store, fmt)
344
+ prefix = entry.dir ? "#{entry.dir}/" : ""
345
+
346
+ zf.entries.each do |zip_entry|
347
+ next unless zip_entry.name.start_with?(prefix)
348
+ next unless matches_format?(zip_entry.name, fmt)
349
+ next if zip_entry.name == prefix || zip_entry.name.end_with?("/")
350
+
351
+ raw = zip_entry.get_input_stream.read
352
+ next if raw.strip.empty?
353
+
354
+ begin
355
+ loaded = fmt.deserialize_many(raw, entry.model)
356
+ loaded = [loaded] unless loaded.is_a?(Array)
357
+ loaded.each do |m|
358
+ set_key_from_zip_path(m, zip_entry.name, entry, prefix)
359
+ package_store.add_model(m)
360
+ end
361
+ rescue StandardError => e
362
+ warn "PackageStore: failed to load #{zip_entry.name}: #{e.message}"
363
+ end
364
+ end
365
+ end
366
+
367
+ def write_model_entry_zip(zf, entry, package_store, fmt)
368
+ models = package_store.models_for(entry.model)
369
+ return if models.empty?
370
+
371
+ if entry.file
372
+ content = entry.layout == :grouped ? fmt.serialize_many(models) : fmt.serialize(models.first)
373
+ zf.get_output_stream(entry.file) { |f| f.write(content) }
374
+ else
375
+ write_directory_models_zip(zf, entry, models, fmt)
376
+ end
377
+ end
378
+
379
+ def write_directory_models_zip(zf, entry, models, fmt)
380
+ prefix = entry.dir ? "#{entry.dir}/" : ""
381
+
382
+ case entry.layout
383
+ when :grouped
384
+ grouped = models.group_by { |m| extract_key(m, entry) }
385
+ grouped.each do |key, group|
386
+ filename = "#{prefix}#{key}#{fmt.extension}"
387
+ content = fmt.serialize_many(group)
388
+ zf.get_output_stream(filename) { |f| f.write(content) }
389
+ end
390
+ else
391
+ models.each do |model|
392
+ key = extract_key(model, entry)
393
+ filename = "#{prefix}#{key}#{fmt.extension}"
394
+ content = fmt.serialize(model)
395
+ zf.get_output_stream(filename) { |f| f.write(content) }
396
+ end
397
+ end
398
+ end
399
+
400
+ # ── Assets ──
401
+
402
+ def read_asset_entry_zip(zf, entry, package_store)
403
+ case entry.type
404
+ when :file
405
+ zip_entry = zf.find_entry(entry.path)
406
+ package_store.add_asset(entry.path, zip_entry.get_input_stream.read) if zip_entry
407
+ when :directory
408
+ prefix = entry.path.end_with?("/") ? entry.path : "#{entry.path}/"
409
+ zf.entries.each do |zip_entry|
410
+ next unless zip_entry.name.start_with?(prefix)
411
+ next if zip_entry.name == prefix || zip_entry.name.end_with?("/")
412
+
413
+ package_store.add_asset(zip_entry.name, zip_entry.get_input_stream.read)
414
+ end
415
+ end
416
+ end
417
+
418
+ def write_assets_zip(zf, package_store)
419
+ package_store.asset_paths.each do |asset_path|
420
+ content = package_store.asset(asset_path)
421
+ zf.get_output_stream(asset_path) { |f| f.write(content) } if content
422
+ end
423
+ end
424
+
425
+ # ── Helpers ──
426
+
427
+ def set_key_from_zip_path(model, zip_path, entry, prefix)
428
+ key_value = model.public_send(entry.key)
429
+ return if key_value
430
+
431
+ filename = zip_path.sub(prefix, "")
432
+ basename = File.basename(filename, ".*")
433
+ model.public_send(:"#{entry.key}=", basename)
434
+ end
435
+
436
+ def extract_key(model, entry)
437
+ model.public_send(entry.key).to_s
438
+ end
439
+
440
+ def matches_format?(name, fmt)
441
+ ext = File.extname(name)
442
+ return true if ext == fmt.extension
443
+ return true if fmt.extension == ".yaml" && ext == ".yml"
444
+
445
+ false
446
+ end
447
+ end
448
+ end
449
+ end
450
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Store
5
- VERSION = "0.1.1"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/lutaml/store.rb CHANGED
@@ -25,6 +25,9 @@ module Lutaml
25
25
  autoload :CompositeModelHandler, "lutaml/store/composite_model_handler"
26
26
  autoload :AttributeUpdater, "lutaml/store/attribute_updater"
27
27
  autoload :DatabaseStore, "lutaml/store/database_store"
28
+ autoload :PackageDefinition, "lutaml/store/package_definition"
29
+ autoload :PackageStore, "lutaml/store/package_store"
30
+ autoload :PackageTransport, "lutaml/store/package_transport"
28
31
  autoload :Adapter, "lutaml/store/adapter"
29
32
  autoload :StorageKey, "lutaml/store/storage_key"
30
33
  autoload :Format, "lutaml/store/format"
data/lutaml-store.gemspec CHANGED
@@ -30,6 +30,7 @@ Gem::Specification.new do |spec|
30
30
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
31
31
 
32
32
  spec.add_dependency "lutaml-model", "~> 0.8.15"
33
+ spec.add_dependency "rubyzip", "~> 2.3"
33
34
 
34
35
  spec.metadata["rubygems_mfa_required"] = "true"
35
36
  end
@@ -154,12 +154,12 @@ RSpec.describe Lutaml::Store::CacheStore do
154
154
  it "returns existing value" do
155
155
  cache.set("key1", "value1")
156
156
 
157
- result = cache.fetch("key1") { "new_value" }
157
+ result = cache.fetch("key1", "new_value")
158
158
  expect(result).to eq("value1")
159
159
  end
160
160
 
161
161
  it "executes block for missing key" do
162
- result = cache.fetch("key1") { "new_value" }
162
+ result = cache.fetch("key1", "new_value")
163
163
  expect(result).to eq("new_value")
164
164
  expect(cache.get("key1")).to eq("new_value")
165
165
  end
@@ -140,20 +140,21 @@ RSpec.describe "Lutaml::Store file I/O" do
140
140
  end
141
141
 
142
142
  # ── YAMLS format ──
143
+ # Format::Yamls requires models with the yamls DSL (e.g. ConceptDocument).
144
+ # For simple models without yamls DSL, use Format::Yaml (:yaml) instead.
143
145
 
144
- describe "YAMLS grouped layout" do
146
+ describe "YAML multi-document via grouped layout" do
145
147
  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
+ store.save_all(items, path: tmpdir, format: :yaml, layout: :grouped)
148
149
 
149
150
  files = Dir.glob(File.join(items_dir, "*.{yaml,yml}")).sort
150
151
  expect(files.size).to eq(3)
151
152
  end
152
153
 
153
154
  it "round-trips models" do
154
- store.save_all(items, path: tmpdir, format: :yamls, layout: :grouped)
155
+ store.save_all(items, path: tmpdir, format: :yaml, layout: :grouped)
155
156
 
156
- loaded = store.load_all(FileTestItem, path: tmpdir, format: :yamls, layout: :grouped)
157
+ loaded = store.load_all(FileTestItem, path: tmpdir, format: :yaml, layout: :grouped)
157
158
  expect(loaded.size).to eq(3)
158
159
  expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
159
160
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/yamls_test_model"
5
+
6
+ RSpec.describe Lutaml::Store::Format::Yamls do
7
+ let(:fmt) { described_class.new }
8
+
9
+ let(:model) do
10
+ YamlsTestModel.new(
11
+ header: YamlsTestHeader.new(id: "test-1", name: "Test"),
12
+ parts: [
13
+ YamlsTestPart.new(label: "a", value: "1"),
14
+ YamlsTestPart.new(label: "b", value: "2")
15
+ ]
16
+ )
17
+ end
18
+
19
+ describe "#extension" do
20
+ it { expect(fmt.extension).to eq(".yaml") }
21
+ end
22
+
23
+ describe "#glob_pattern" do
24
+ it { expect(fmt.glob_pattern).to eq("*.{yaml,yml}") }
25
+ end
26
+
27
+ describe "#serialize" do
28
+ it "produces a YAML stream starting with ---" do
29
+ result = fmt.serialize(model)
30
+ expect(result).to start_with("---")
31
+ end
32
+
33
+ it "produces multiple --- separators for multi-part models" do
34
+ result = fmt.serialize(model)
35
+ separators = result.scan(/^---$/).length
36
+ expect(separators).to be >= 2
37
+ end
38
+ end
39
+
40
+ describe "#deserialize" do
41
+ it "reconstructs the model from a YAML stream" do
42
+ yaml = fmt.serialize(model)
43
+ loaded = fmt.deserialize(yaml, YamlsTestModel)
44
+
45
+ expect(loaded.header.id).to eq("test-1")
46
+ expect(loaded.header.name).to eq("Test")
47
+ end
48
+ end
49
+
50
+ describe "round-trip" do
51
+ it "preserves model data including parts" do
52
+ yaml = fmt.serialize(model)
53
+ loaded = fmt.deserialize(yaml, YamlsTestModel)
54
+
55
+ expect(loaded.header.id).to eq("test-1")
56
+ expect(loaded.header.name).to eq("Test")
57
+ expect(loaded.parts.length).to eq(2)
58
+ expect(loaded.parts.first.label).to eq("a")
59
+ expect(loaded.parts.last.value).to eq("2")
60
+ end
61
+ end
62
+
63
+ describe "#serialize_many" do
64
+ it "serializes a single model identically to #serialize" do
65
+ # In GCR, each file has exactly one concept (one group per key).
66
+ # serialize_many([single]) == serialize(single).
67
+ result = fmt.serialize_many([model])
68
+ expect(result).to eq(fmt.serialize(model))
69
+ end
70
+ end
71
+
72
+ describe "#deserialize_many" do
73
+ it "returns an array from a YAML stream" do
74
+ yaml = fmt.serialize(model)
75
+ result = fmt.deserialize_many(yaml, YamlsTestModel)
76
+ expect(result).to be_an(Array)
77
+ expect(result.first).to be_a(YamlsTestModel)
78
+ end
79
+ end
80
+ end
@@ -61,8 +61,8 @@ RSpec.describe "Format handler round-trips" do
61
61
  end
62
62
 
63
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
64
+ # Format::Yamls requires models with the yamls DSL (multi-document YAML stream).
65
+ # Tested separately in spec/lutaml/store/format/yamls_spec.rb with proper yamls models.
66
66
 
67
67
  it "produces multi-document YAML stream" do
68
68
  fmt = described_class.new