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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +79 -275
- data/Gemfile.lock +11 -4
- data/TODO.impl/1-lutaml-hal-migration.md +54 -18
- data/lib/lutaml/store/attribute_updater.rb +12 -3
- data/lib/lutaml/store/basic_store.rb +4 -0
- data/lib/lutaml/store/cache_store.rb +1 -1
- data/lib/lutaml/store/database_store.rb +1 -1
- data/lib/lutaml/store/format/yamls.rb +2 -2
- data/lib/lutaml/store/http_cache.rb +4 -1
- data/lib/lutaml/store/model_registry.rb +4 -1
- data/lib/lutaml/store/package_definition.rb +62 -0
- data/lib/lutaml/store/package_store.rb +149 -0
- data/lib/lutaml/store/package_transport.rb +450 -0
- data/lib/lutaml/store/version.rb +1 -1
- data/lib/lutaml/store.rb +3 -0
- data/lutaml-store.gemspec +1 -0
- data/spec/lutaml/store/cache_store_spec.rb +2 -2
- data/spec/lutaml/store/file_io_spec.rb +6 -5
- data/spec/lutaml/store/format/yamls_spec.rb +80 -0
- data/spec/lutaml/store/format_round_trip_spec.rb +2 -2
- data/spec/lutaml/store/package_definition_spec.rb +89 -0
- data/spec/lutaml/store/package_store_spec.rb +153 -0
- data/spec/lutaml/store/package_transport/directory_transport_spec.rb +139 -0
- data/spec/lutaml/store/package_transport/zip_transport_spec.rb +85 -0
- data/spec/support/simple_test_model.rb +15 -0
- data/spec/support/yamls_test_model.rb +35 -0
- metadata +25 -1
|
@@ -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
|
data/lib/lutaml/store/version.rb
CHANGED
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"
|
|
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"
|
|
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 "
|
|
146
|
+
describe "YAML multi-document via grouped layout" do
|
|
145
147
|
it "writes multi-document YAML files" do
|
|
146
|
-
|
|
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: :
|
|
155
|
+
store.save_all(items, path: tmpdir, format: :yaml, layout: :grouped)
|
|
155
156
|
|
|
156
|
-
loaded = store.load_all(FileTestItem, path: tmpdir, format: :
|
|
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
|
-
|
|
65
|
-
|
|
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
|