lutaml-store 0.2.1 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c26211ca36caae48ef419c2feb670e6d35ddfb958b97b27eb04173c16eaa804
4
- data.tar.gz: '06953ee0ec088797069476ccd0a2595aaae24b71fc295b3882ac628464b53da0'
3
+ metadata.gz: 4917f68f56456a19670424157dd5001d243b9bd6d97618f445d306a7998e2a9f
4
+ data.tar.gz: 7076d765cf18cc91bfa9cf252bb0117ea8870c915bf1e7421648d9490de7e6a5
5
5
  SHA512:
6
- metadata.gz: ac429b0436457ac91e52d18290c407598529d041294911c652a33fda075b4f24373965aea422568e85bcfa68e978a64fb4dc45b642fb9034555869a597ccb161
7
- data.tar.gz: fefac8e5929d619716a9cf890601928fb52738b3ecb5d6a861a90d7d28db7902624108a8d2054a230ea4810291001690455777e0cbcebaa912fd9c8f67f11591
6
+ metadata.gz: 897f40bf4972373587528a80afcc44ab2b0c159f2bba63a97f39cb997eff97d3633d7e34cabeb0dac37e1b337781f0aef60b9ffaae2b3a3426d5624b35fb7950
7
+ data.tar.gz: 1b79b56672ace97f0b6e57715b1b56b7f4638b1d426db57b8f57c5ed21eb461a7fd2ff4181f57afaa094d85c9b7da37f9e3d4771c04afecfcef0c268e86ddcd7
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2026-06-05 04:23:39 UTC using RuboCop version 1.87.0.
3
+ # on 2026-06-05 09:38:49 UTC using RuboCop version 1.87.0.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -16,7 +16,7 @@ Gemspec/RequiredRubyVersion:
16
16
  Metrics/AbcSize:
17
17
  Max: 112
18
18
 
19
- # Offense count: 50
19
+ # Offense count: 57
20
20
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
21
21
  # AllowedMethods: refine
22
22
  Metrics/BlockLength:
@@ -30,9 +30,9 @@ Metrics/ClassLength:
30
30
  # Offense count: 9
31
31
  # Configuration parameters: AllowedMethods, AllowedPatterns.
32
32
  Metrics/CyclomaticComplexity:
33
- Max: 11
33
+ Max: 12
34
34
 
35
- # Offense count: 80
35
+ # Offense count: 79
36
36
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
37
37
  Metrics/MethodLength:
38
38
  Max: 84
@@ -42,10 +42,10 @@ Metrics/MethodLength:
42
42
  Metrics/ParameterLists:
43
43
  Max: 8
44
44
 
45
- # Offense count: 5
45
+ # Offense count: 4
46
46
  # Configuration parameters: AllowedMethods, AllowedPatterns.
47
47
  Metrics/PerceivedComplexity:
48
- Max: 11
48
+ Max: 12
49
49
 
50
50
  # Offense count: 1
51
51
  Naming/AccessorMethodName:
@@ -84,7 +84,7 @@ Security/MarshalLoad:
84
84
  Exclude:
85
85
  - 'lib/lutaml/store/format/marshal_format.rb'
86
86
 
87
- # Offense count: 35
87
+ # Offense count: 36
88
88
  # Configuration parameters: AllowedConstants.
89
89
  Style/Documentation:
90
90
  Enabled: false
data/CLAUDE.md CHANGED
@@ -28,30 +28,43 @@ This is a Ruby gem (`lutaml-store`) providing a store-centric database-style API
28
28
 
29
29
  | Class | Role |
30
30
  |---|---|
31
- | `DatabaseStore` | High-level CRUD with model registry, composite models, polymorphism |
31
+ | `DatabaseStore` | High-level CRUD with model registry, composite models, polymorphism, file I/O |
32
32
  | `BasicStore` | Low-level key-value store with optional cache/monitor/events |
33
33
  | `CacheStore` | TTL-aware cache store extending `BasicStore` |
34
+ | `PackageStore` | Structured multi-model packages with directory/ZIP transport |
35
+ | `PackageDefinition` | Declarative schema for package structure (models, assets, metadata) |
34
36
  | `ModelRegistry` / `ModelRegistration` | Register models with their key fields and polymorphic config |
35
37
  | `CompositeModelHandler` | Stores nested registered models independently, restores references |
36
38
  | `AttributeUpdater` | Processes updates including dot-notation paths and block-based updates |
37
- | `ModelSerializer` | Single point of serialization/deserialization for Lutaml::Model objects |
38
- | `Config` | Parses and validates store configuration (adapter, cache, monitoring, compression) |
39
+ | `ModelSerializer` | Hash-based serialization/deserialization for key-value storage |
40
+ | `FormatSerializer` | Bridges any Format handler to ModelSerializer interface for DatabaseStore |
41
+ | `Format` | Multi-format file I/O (YAML, YAMLS, JSON, JSONL, Marshal, XML) |
42
+ | `Adapter` | Storage adapter registry and factory (Memory, FileSystem, SQLite) |
43
+
44
+ ### Format handlers (`lib/lutaml/store/format/`)
45
+
46
+ All inherit from `Format::Base`. Six formats: `Yaml`, `Yamls`, `Json`, `Jsonl`, `MarshalFormat`, `Xml`. Each implements `serialize`/`deserialize`, optional `serialize_many`/`deserialize_many`, `extension`, `glob_pattern`, and `binary?`. Registered in `Format::FORMATS` hash and resolved via `Format.resolve(:symbol)`.
39
47
 
40
48
  ### Storage adapters (`lib/lutaml/store/adapter/`)
41
49
 
42
- All inherit from `Adapter::Base`. Three backends: `Memory`, `FileSystem`, `SQLite`. The `DatabaseStore` creates the adapter internally via `BasicStore`; the adapter type is passed as `adapter: :memory`, `adapter: { type: :filesystem, path: "..." }`, etc.
50
+ All inherit from `Adapter::Base`. Three backends: `Memory`, `FileSystem`, `SQLite`. Registered in `Adapter` module and resolved via `Adapter.resolve(:type, options)`. New adapters can be added with `Adapter.register(:custom, CustomClass)` without modifying existing code (OCP).
43
51
 
44
- ### HTTP caching
52
+ ### PackageStore and transports
45
53
 
46
- `HttpCache` provides HTTP-aware caching with ETags, conditional requests (304), Cache-Control, and Vary header support. Used by lutaml-hal to avoid re-fetching HAL resources.
54
+ `PackageStore` provides structured multi-model persistence. `PackageDefinition` declares which models, assets, and metadata the package contains. Transports (`DirectoryTransport`, `ZipTransport`) handle reading/writing to disk. Format handlers determine serialization per model entry.
47
55
 
48
- ### Serialization & integrity
56
+ ### FormatSerializer pattern
57
+
58
+ `FormatSerializer` wraps a Format handler to implement the serializer interface (serialize/deserialize). This enables `DatabaseStore` to use any format (YAMLS, XML, Marshal, etc.) for key-value storage instead of the default hash serialization. Used for Glossarist-like YAMLS patterns.
59
+
60
+ ### HTTP caching
49
61
 
50
- `Compression` adds gzip support. `Integrity` provides SHA256 checksums for data verification (used by the FileSystem adapter).
62
+ `HttpCache` provides HTTP-aware caching with ETags, conditional requests (304), Cache-Control, and Vary header support. Uses `to_json`/`from_json` for model-driven serialization.
51
63
 
52
64
  ## Conventions
53
65
 
54
66
  - Double-quoted strings (Rubocop enforced)
55
67
  - Specs use `expect` syntax (no `should`)
56
- - Documentation is in AsciiDoc (README.adoc, plan.adoc)
68
+ - Documentation is in AsciiDoc (README.adoc)
69
+ - All library code uses Ruby `autoload` (no `require_relative` or internal `require`)
57
70
  - Error hierarchy: `Lutaml::Store::Error` → `ConfigurationError`, `BackendError`, `ModelNotRegisteredError`, `InvalidKeyError`, `PolymorphicUpdateError`, `CompositeModelError`
data/README.adoc CHANGED
@@ -309,40 +309,154 @@ store.destroy(model: User, user_id: "u1")
309
309
  == PackageStore
310
310
 
311
311
  `PackageStore` provides structured multi-model persistence with directory and ZIP
312
- transports. It is built on `PackageDefinition`, which declares the package schema
313
- declaratively.
312
+ transports. Use it when you need to bundle multiple model types, binary assets,
313
+ and metadata into a single loadable package.
314
+
315
+ The workflow is: define the package schema with `PackageDefinition`, then load,
316
+ query, modify, and save using `PackageStore`.
314
317
 
315
318
  === Define a package
316
319
 
320
+ `PackageDefinition` declares the package structure — which models, assets, and
321
+ metadata the package contains:
322
+
317
323
  [source,ruby]
318
324
  ----
319
- glossary = Lutaml::Store::PackageDefinition.new(name: "glossary") do |pkg|
325
+ require "lutaml/model"
326
+ require "lutaml/store"
327
+
328
+ # Define your models
329
+ class Concept < Lutaml::Model::Serializable
330
+ attribute :term, :string
331
+ attribute :definition, :string
332
+ end
333
+
334
+ class Author < Lutaml::Model::Serializable
335
+ attribute :name, :string
336
+ attribute :email, :string
337
+ end
338
+
339
+ class GlossaryInfo < Lutaml::Model::Serializable
340
+ attribute :title, :string
341
+ attribute :version, :string
342
+ end
343
+
344
+ # Declare the package schema
345
+ glossary = Lutaml::Store::PackageDefinition.new(
346
+ name: "glossary",
347
+ metadata_model: GlossaryInfo,
348
+ metadata_file: "glossary.yaml",
349
+ metadata_key: :title
350
+ ) do |pkg|
351
+ # Models stored in subdirectories, one file per instance
320
352
  pkg.model(model: Concept, key: :term, dir: "concepts", default_format: :yaml)
321
353
  pkg.model(model: Author, key: :name, dir: "authors", default_format: :json)
322
- pkg.asset("glossary.yaml", type: :file)
323
- pkg.metadata_model = GlossaryInfo
324
- pkg.metadata_file = "glossary.yaml"
354
+
355
+ # Binary assets bundled in the package
356
+ pkg.asset("logo.png", type: :file)
357
+ pkg.asset("attachments", type: :directory)
325
358
  end
326
359
  ----
327
360
 
328
- === Load and save packages
361
+ Each `pkg.model` call registers a model type with:
362
+
363
+ * `model:` — the `Lutaml::Model::Serializable` class
364
+ * `key:` — attribute used as the unique identifier (becomes the filename)
365
+ * `dir:` — subdirectory for this model type (use `file:` instead for a single-file model)
366
+ * `layout:` — `:separate` (one file per instance, default) or `:grouped` (multiple instances per file)
367
+ * `default_format:` — `:yaml`, `:json`, `:jsonl`, `:yamls`, or `:marshal`
368
+
369
+ === Directory structure
370
+
371
+ When saved to disk, the glossary package produces:
372
+
373
+ ----
374
+ my_glossary/
375
+ ├── glossary.yaml # metadata (GlossaryInfo)
376
+ ├── concepts/ # Concept model instances
377
+ │ ├── API.yaml
378
+ │ ├── REST.yaml
379
+ │ └── YAML.yaml
380
+ ├── authors/ # Author model instances
381
+ │ ├── john_doe.json
382
+ │ └── jane_smith.json
383
+ ├── logo.png # asset file
384
+ └── attachments/ # asset directory
385
+ └── spec.pdf
386
+ ----
387
+
388
+ === Load and query
329
389
 
330
390
  [source,ruby]
331
391
  ----
332
392
  # Load from directory
333
- store = Lutaml::Store::PackageStore.load(glossary, "./my_glossary", transport: :directory)
393
+ store = Lutaml::Store::PackageStore.load(glossary, "./my_glossary")
334
394
 
335
- # Load from ZIP
395
+ # Load from ZIP file
336
396
  store = Lutaml::Store::PackageStore.load(glossary, "./glossary.zip", transport: :zip)
337
397
 
398
+ # Access metadata
399
+ store.metadata # => GlossaryInfo instance
400
+
338
401
  # Query models
339
- concepts = store.models_for(Concept)
340
- store.model_count(Concept) # => 42
341
- store.fetch_model(Concept, "API")
402
+ concepts = store.models_for(Concept) # => array of Concept instances
403
+ concept = store.fetch_model(Concept, "API")
404
+ store.model_count(Concept) # => 3
405
+ store.model_exists?(Concept, "REST") # => true
406
+
407
+ # Access assets
408
+ store.asset("logo.png") # => binary content
409
+ store.asset_paths # => ["logo.png", "attachments/spec.pdf"]
410
+
411
+ # Package statistics
412
+ store.stats
413
+ # => { package: "glossary", models: { "Concept" => 3, "Author" => 2 },
414
+ # assets: 2, metadata: true }
415
+ ----
416
+
417
+ === Modify and save
418
+
419
+ [source,ruby]
420
+ ----
421
+ # Add models
422
+ store.add_model(Concept.new(term: "JSON", definition: "..."))
423
+ store.add_models([concept1, concept2])
424
+
425
+ # Remove models
426
+ store.remove_model(Concept, "deprecated_term")
427
+
428
+ # Add/remove assets
429
+ store.add_asset("diagram.svg", svg_content)
430
+ store.remove_asset("old_diagram.svg")
431
+
432
+ # Save to directory (default format per model)
433
+ store.save("./output")
434
+
435
+ # Save to ZIP with per-model format overrides
436
+ store.save("./glossary.zip", transport: :zip, formats: { Concept => :json })
437
+
438
+ # Save with global format override
439
+ store.save("./output", format: :yaml)
440
+
441
+ # Bulk operations
442
+ store.clear_models(Concept) # remove all Concept instances
443
+ store.clear_all # remove everything
444
+ ----
445
+
446
+ === Per-model format override
447
+
448
+ The `save` method accepts format overrides:
449
+
450
+ [source,ruby]
451
+ ----
452
+ # Global format for all models
453
+ store.save("./out", format: :json)
454
+
455
+ # Per-model format
456
+ store.save("./out", formats: { Concept => :yaml, Author => :jsonl })
342
457
 
343
- # Modify and save
344
- store.add_model(Concept.new(term: "REST", definition: "..."))
345
- store.save("./output", transport: :zip)
458
+ # Default: each model uses its default_format from the definition
459
+ store.save("./out")
346
460
  ----
347
461
 
348
462
  === Package transports
@@ -7,6 +7,23 @@ module Lutaml
7
7
  autoload :Memory, "lutaml/store/adapter/memory"
8
8
  autoload :FileSystem, "lutaml/store/adapter/filesystem"
9
9
  autoload :Sqlite, "lutaml/store/adapter/sqlite"
10
+
11
+ @registry = {
12
+ memory: "Memory",
13
+ filesystem: "FileSystem",
14
+ sqlite: "Sqlite"
15
+ }
16
+
17
+ def self.resolve(type, options = {})
18
+ entry = @registry[type.to_sym]
19
+ raise ConfigurationError, "Unknown adapter type: #{type}" unless entry
20
+
21
+ const_get(entry).new(options)
22
+ end
23
+
24
+ def self.register(type, adapter_class)
25
+ @registry[type.to_sym] = adapter_class.name
26
+ end
10
27
  end
11
28
  end
12
29
  end
@@ -167,16 +167,7 @@ module Lutaml
167
167
  end
168
168
 
169
169
  def create_adapter
170
- case @config.adapter_type
171
- when :memory
172
- Adapter::Memory.new(@config.adapter_options)
173
- when :filesystem
174
- Adapter::FileSystem.new(@config.adapter_options)
175
- when :sqlite
176
- Adapter::Sqlite.new(@config.adapter_options)
177
- else
178
- raise ConfigurationError, "Unknown adapter type: #{@config.adapter_type}"
179
- end
170
+ Adapter.resolve(@config.adapter_type, @config.adapter_options)
180
171
  end
181
172
 
182
173
  def create_cache
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "time"
4
5
 
5
6
  module Lutaml
6
7
  module Store
@@ -246,16 +247,7 @@ module Lutaml
246
247
  adapter_type = config[:adapter]&.dig(:type) || config[:adapter_type] || :memory
247
248
  adapter_options = config[:adapter]&.dig(:options) || config[:adapter_options] || {}
248
249
 
249
- case adapter_type.to_sym
250
- when :memory
251
- Adapter::Memory.new(adapter_options)
252
- when :filesystem
253
- Adapter::FileSystem.new(adapter_options)
254
- when :sqlite
255
- Adapter::Sqlite.new(adapter_options)
256
- else
257
- raise ConfigurationError, "Unknown adapter type: #{adapter_type}"
258
- end
250
+ Adapter.resolve(adapter_type, adapter_options)
259
251
  end
260
252
 
261
253
  def serialize_entry(entry)
@@ -207,7 +207,7 @@ module Lutaml
207
207
 
208
208
  content = fmt.serialize_many(models_array)
209
209
 
210
- File.write(path, content, encoding: "utf-8")
210
+ write_file(path, content, fmt)
211
211
  @store.emit_event(:model_export, count: models_array.size, path: path)
212
212
  path
213
213
  end
@@ -331,8 +331,8 @@ module Lutaml
331
331
  Dir.glob(glob).sort.each do |file_path|
332
332
  next unless File.file?(file_path)
333
333
 
334
- raw = File.read(file_path, encoding: "utf-8")
335
- next if raw.strip.empty?
334
+ raw = read_file(file_path, fmt)
335
+ next if !fmt.binary? && raw.strip.empty?
336
336
 
337
337
  begin
338
338
  model = fmt.deserialize(raw, model_class)
@@ -351,8 +351,8 @@ module Lutaml
351
351
  Dir.glob(glob).sort.each do |file_path|
352
352
  next unless File.file?(file_path)
353
353
 
354
- raw = File.read(file_path, encoding: "utf-8")
355
- next if raw.strip.empty?
354
+ raw = read_file(file_path, fmt)
355
+ next if !fmt.binary? && raw.strip.empty?
356
356
 
357
357
  begin
358
358
  loaded = fmt.deserialize_many(raw, model_class)
@@ -375,8 +375,7 @@ module Lutaml
375
375
  key = extract_model_key(model)
376
376
  filename = key || model.class.name.to_s.gsub("::", "_")
377
377
  file_path = File.join(dir, "#{filename}#{fmt.extension}")
378
- content = fmt.serialize(model)
379
- File.write(file_path, content, encoding: "utf-8")
378
+ write_file(file_path, fmt.serialize(model), fmt)
380
379
  model
381
380
  end
382
381
  end
@@ -391,8 +390,7 @@ module Lutaml
391
390
 
392
391
  grouped.map do |key, group|
393
392
  file_path = File.join(dir, "#{key}#{fmt.extension}")
394
- content = fmt.serialize_many(group)
395
- File.write(file_path, content, encoding: "utf-8")
393
+ write_file(file_path, fmt.serialize_many(group), fmt)
396
394
  group
397
395
  end.flatten
398
396
  end
@@ -420,6 +418,18 @@ module Lutaml
420
418
  basename = File.basename(file_path, ".*")
421
419
  model.public_send(:"#{registration.key_field}=", basename)
422
420
  end
421
+
422
+ def read_file(file_path, fmt)
423
+ fmt.binary? ? File.binread(file_path) : File.read(file_path, encoding: "utf-8")
424
+ end
425
+
426
+ def write_file(file_path, content, fmt)
427
+ if fmt.binary?
428
+ File.binwrite(file_path, content)
429
+ else
430
+ File.write(file_path, content, encoding: "utf-8")
431
+ end
432
+ end
423
433
  end
424
434
  end
425
435
  end
@@ -27,6 +27,10 @@ module Lutaml
27
27
  def deserialize_many(_data, _model_class)
28
28
  raise NotImplementedError, "#{self.class} does not support multi-document deserialization"
29
29
  end
30
+
31
+ def binary?
32
+ false
33
+ end
30
34
  end
31
35
  end
32
36
  end
@@ -31,6 +31,10 @@ module Lutaml
31
31
  hash_array = ::Marshal.load(data)
32
32
  hash_array.map { |h| model_class.from_hash(h) }
33
33
  end
34
+
35
+ def binary?
36
+ true
37
+ end
34
38
  end
35
39
  end
36
40
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ module Format
6
+ class Xml < Base
7
+ def extension
8
+ ".xml"
9
+ end
10
+
11
+ def glob_pattern
12
+ "*.xml"
13
+ end
14
+
15
+ def serialize(model)
16
+ model.to_xml
17
+ end
18
+
19
+ def deserialize(data, model_class)
20
+ model_class.from_xml(data)
21
+ end
22
+
23
+ def serialize_many(models)
24
+ inner = models.map(&:to_xml).join("\n")
25
+ "<items>\n#{inner}\n</items>"
26
+ end
27
+
28
+ def deserialize_many(data, model_class)
29
+ doc = Moxml.parse(data)
30
+ doc.root.children.select(&:element?).map do |child|
31
+ model_class.from_xml(child.to_xml)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -17,7 +17,7 @@ module Lutaml
17
17
  end
18
18
 
19
19
  def serialize_many(models)
20
- models.map(&:to_yamls).join
20
+ models.map(&:to_yamls).join("\n")
21
21
  end
22
22
 
23
23
  def deserialize(data, model_class)
@@ -13,13 +13,15 @@ module Lutaml
13
13
  autoload :Json, "lutaml/store/format/json"
14
14
  autoload :Jsonl, "lutaml/store/format/jsonl"
15
15
  autoload :MarshalFormat, "lutaml/store/format/marshal_format"
16
+ autoload :Xml, "lutaml/store/format/xml"
16
17
 
17
18
  FORMATS = {
18
19
  yaml: "Yaml",
19
20
  yamls: "Yamls",
20
21
  json: "Json",
21
22
  jsonl: "Jsonl",
22
- marshal: "MarshalFormat"
23
+ marshal: "MarshalFormat",
24
+ xml: "Xml"
23
25
  }.freeze
24
26
 
25
27
  def self.resolve(format)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ # Bridges a Format handler to the ModelSerializer interface.
6
+ # Enables DatabaseStore to use any format (yaml, json, xml, yamls, marshal)
7
+ # for key-value storage instead of the default hash serialization.
8
+ #
9
+ # Usage:
10
+ # serializer = FormatSerializer.new(:yamls)
11
+ # store = DatabaseStore.new(
12
+ # adapter: :sqlite,
13
+ # models: [{ model: ConceptDocument, key: :id, serializer: serializer }]
14
+ # )
15
+ class FormatSerializer
16
+ DATA_KEY = "_data"
17
+ CLASS_KEY = "_class"
18
+
19
+ def initialize(format)
20
+ @format = Format.resolve(format)
21
+ end
22
+
23
+ def serialize(model)
24
+ {
25
+ DATA_KEY => @format.serialize(model),
26
+ CLASS_KEY => model.class.name
27
+ }
28
+ end
29
+
30
+ def deserialize(data, model_class)
31
+ model_class = resolve_class(data[CLASS_KEY]) if data[CLASS_KEY]
32
+ @format.deserialize(data[DATA_KEY], model_class)
33
+ end
34
+
35
+ private
36
+
37
+ def resolve_class(class_name)
38
+ Object.const_get(class_name)
39
+ rescue NameError
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ end
@@ -42,6 +42,18 @@ module Lutaml
42
42
  def sanitize_filename(key)
43
43
  key.gsub(%r{[/:#?]}, "_")
44
44
  end
45
+
46
+ def read_file(file_path, fmt)
47
+ fmt.binary? ? File.binread(file_path) : File.read(file_path, encoding: "utf-8")
48
+ end
49
+
50
+ def write_file(file_path, content, fmt)
51
+ if fmt.binary?
52
+ File.binwrite(file_path, content)
53
+ else
54
+ File.write(file_path, content, encoding: "utf-8")
55
+ end
56
+ end
45
57
  end
46
58
  end
47
59
  end
@@ -47,7 +47,7 @@ module Lutaml
47
47
  return unless File.exist?(file_path)
48
48
 
49
49
  fmt = format_for_file(definition.metadata_file)
50
- raw = File.read(file_path, encoding: "utf-8")
50
+ raw = read_file(file_path, fmt)
51
51
  metadata = fmt.deserialize(raw, definition.metadata_model)
52
52
  package_store.metadata = metadata
53
53
  end
@@ -60,7 +60,7 @@ module Lutaml
60
60
  content = fmt.serialize(package_store.metadata)
61
61
  file_path = File.join(path, definition.metadata_file)
62
62
  FileUtils.mkdir_p(File.dirname(file_path))
63
- File.write(file_path, content, encoding: "utf-8")
63
+ write_file(file_path, content, fmt)
64
64
  end
65
65
 
66
66
  def read_model_entry(base_path, entry, package_store, fmt_name)
@@ -76,7 +76,7 @@ module Lutaml
76
76
  return unless File.exist?(file_path)
77
77
 
78
78
  fmt = resolve_format(fmt_name)
79
- raw = File.read(file_path, encoding: "utf-8")
79
+ raw = read_file(file_path, fmt)
80
80
  model = fmt.deserialize(raw, entry.model)
81
81
  package_store.add_model(model)
82
82
  end
@@ -91,8 +91,8 @@ module Lutaml
91
91
  Dir.glob(glob).sort.each do |file_path|
92
92
  next unless File.file?(file_path)
93
93
 
94
- raw = File.read(file_path, encoding: "utf-8")
95
- next if raw.strip.empty?
94
+ raw = read_file(file_path, fmt)
95
+ next if !fmt.binary? && raw.strip.empty?
96
96
 
97
97
  begin
98
98
  case entry.layout
@@ -127,7 +127,7 @@ module Lutaml
127
127
  content = entry.layout == :grouped ? fmt.serialize_many(models) : fmt.serialize(models.first)
128
128
  file_path = File.join(base_path, entry.file)
129
129
  FileUtils.mkdir_p(File.dirname(file_path))
130
- File.write(file_path, content, encoding: "utf-8")
130
+ write_file(file_path, content, fmt)
131
131
  end
132
132
 
133
133
  def write_directory_models(base_path, entry, models, fmt)
@@ -138,13 +138,13 @@ module Lutaml
138
138
  when :grouped
139
139
  models.group_by { |m| extract_key(m, entry) }.each do |key, group|
140
140
  file_path = File.join(dir, "#{sanitize_filename(key)}#{fmt.extension}")
141
- File.write(file_path, fmt.serialize_many(group), encoding: "utf-8")
141
+ write_file(file_path, fmt.serialize_many(group), fmt)
142
142
  end
143
143
  else
144
144
  models.each do |model|
145
145
  key = extract_key(model, entry)
146
146
  file_path = File.join(dir, "#{sanitize_filename(key)}#{fmt.extension}")
147
- File.write(file_path, fmt.serialize(model), encoding: "utf-8")
147
+ write_file(file_path, fmt.serialize(model), fmt)
148
148
  end
149
149
  end
150
150
  end
@@ -93,14 +93,19 @@ module Lutaml
93
93
  next if zip_entry.name == prefix || zip_entry.name.end_with?("/")
94
94
 
95
95
  raw = zip_entry.get_input_stream.read
96
- next if raw.strip.empty?
96
+ next if !fmt.binary? && raw.strip.empty?
97
97
 
98
98
  begin
99
- loaded = fmt.deserialize_many(raw, entry.model)
100
- loaded = [loaded] unless loaded.is_a?(Array)
101
- loaded.each do |m|
102
- set_key_from_zip_path(m, zip_entry.name, entry, prefix)
103
- package_store.add_model(m)
99
+ case entry.layout
100
+ when :grouped
101
+ fmt.deserialize_many(raw, entry.model).each do |m|
102
+ set_key_from_zip_path(m, zip_entry.name, entry, prefix)
103
+ package_store.add_model(m)
104
+ end
105
+ else
106
+ model = fmt.deserialize(raw, entry.model)
107
+ set_key_from_zip_path(model, zip_entry.name, entry, prefix)
108
+ package_store.add_model(model)
104
109
  end
105
110
  rescue StandardError => e
106
111
  warn "PackageStore: failed to load #{zip_entry.name}: #{e.message}"
@@ -18,7 +18,7 @@ module Lutaml
18
18
 
19
19
  def self.parse(string)
20
20
  str = string.to_s
21
- sep = str.rindex(/(?<!:):(?!:)/)
21
+ sep = str.index(/(?<!:):(?!:)/)
22
22
  return new("", str) unless sep
23
23
 
24
24
  new(str[0...sep], str[sep + 1..])
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Store
5
- VERSION = "0.2.1"
5
+ VERSION = "0.2.2"
6
6
  end
7
7
  end
data/lib/lutaml/store.rb CHANGED
@@ -25,6 +25,7 @@ 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 :FormatSerializer, "lutaml/store/format_serializer"
28
29
  autoload :PackageDefinition, "lutaml/store/package_definition"
29
30
  autoload :PackageStore, "lutaml/store/package_store"
30
31
  autoload :PackageTransport, "lutaml/store/package_transport"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-store
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ronald Tse
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-05 00:00:00.000000000 Z
11
+ date: 2026-06-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -75,8 +75,10 @@ files:
75
75
  - lib/lutaml/store/format/json.rb
76
76
  - lib/lutaml/store/format/jsonl.rb
77
77
  - lib/lutaml/store/format/marshal_format.rb
78
+ - lib/lutaml/store/format/xml.rb
78
79
  - lib/lutaml/store/format/yaml.rb
79
80
  - lib/lutaml/store/format/yamls.rb
81
+ - lib/lutaml/store/format_serializer.rb
80
82
  - lib/lutaml/store/http_cache.rb
81
83
  - lib/lutaml/store/http_cache_config.rb
82
84
  - lib/lutaml/store/http_cache_entry.rb