lutaml-store 0.2.1 → 0.2.4

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.
@@ -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)
@@ -2,9 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Store
5
- # Store-centric API with model registry and database-style operations
5
+ # Store-centric API with model registry and database-style operations.
6
+ # Provides CRUD, querying, file I/O, transactions, and events.
6
7
  class DatabaseStore
7
- attr_reader :store, :registry, :composite_handler, :attribute_updater
8
+ attr_reader :store, :registry, :composite_handler, :attribute_updater, :serializer
8
9
 
9
10
  def initialize(adapter:, models: [], **options)
10
11
  @store = BasicStore.new(adapter_type: adapter, **options)
@@ -12,11 +13,13 @@ module Lutaml
12
13
  @serializer = ModelSerializer.new
13
14
  @composite_handler = CompositeModelHandler.new(@registry, @store, self, serializer: @serializer)
14
15
  @attribute_updater = AttributeUpdater.new(@registry, @composite_handler)
16
+ @scopes = {}
15
17
 
16
18
  validate_configuration!
17
19
  end
18
20
 
19
- # Save single model or array of models
21
+ # ── CRUD ──
22
+
20
23
  def save(models)
21
24
  models_array = Array(models)
22
25
  saved_models = models_array.map { |model| save_single_model(model) }
@@ -25,7 +28,6 @@ module Lutaml
25
28
  models.is_a?(Array) ? saved_models : saved_models.first
26
29
  end
27
30
 
28
- # Fetch model by class and key field
29
31
  def fetch(model:, **key_params)
30
32
  registration = @registry.registration_for(model)
31
33
  key_field = registration.key_field
@@ -35,20 +37,12 @@ module Lutaml
35
37
  stored_data = find_stored_data(registration, model, key_value)
36
38
  return nil unless stored_data
37
39
 
38
- model_instance = @serializer.deserialize(stored_data, model, registration)
39
-
40
- composite_references = stored_data["_composite_models"]
41
- if composite_references
42
- model_instance = @composite_handler.restore_composite_models(
43
- model_instance, composite_references
44
- )
45
- end
40
+ model_instance = deserialize_record(stored_data, model, registration)
46
41
 
47
42
  @store.emit_event(:model_fetch, model: model_instance, key: key_value, source: :backend)
48
43
  model_instance
49
44
  end
50
45
 
51
- # Update model with attributes array or block
52
46
  def update(model:, attributes: nil, **key_params, &block)
53
47
  current_model = fetch(model: model, **key_params)
54
48
  raise ModelNotRegisteredError, "Model not found" unless current_model
@@ -75,7 +69,6 @@ module Lutaml
75
69
  updated_model
76
70
  end
77
71
 
78
- # Destroy model by class and key field
79
72
  def destroy(model:, **key_params)
80
73
  registration = @registry.registration_for(model)
81
74
  key_field = registration.key_field
@@ -94,54 +87,96 @@ module Lutaml
94
87
  deleted
95
88
  end
96
89
 
97
- # Query operations
90
+ # ── Query API ──
91
+
92
+ def query(model_class)
93
+ Query.new(self, model_class)
94
+ end
95
+
98
96
  def where(model:, **conditions)
99
- all(model: model).select do |model_instance|
100
- conditions.all? { |field, value| model_instance.public_send(field) == value }
101
- end
97
+ query(model).where(**conditions)
102
98
  end
103
99
 
104
- # Get all models of a specific type
105
100
  def all(model:)
106
- registration = @registry.registration_for(model)
101
+ query(model)
102
+ end
107
103
 
108
- models = []
109
- @store.each_key do |storage_key|
110
- parsed = StorageKey.parse(storage_key.to_s)
111
- next unless parsed.class_name == model.name
104
+ def count(model:)
105
+ query(model).count
106
+ end
112
107
 
113
- stored_data = @store.get(storage_key)
114
- next unless stored_data
108
+ def exists?(model:, **key_params)
109
+ !fetch(model: model, **key_params).nil?
110
+ end
115
111
 
116
- begin
117
- model_instance = @serializer.deserialize(stored_data, model, registration)
112
+ def find_by(model:, **conditions)
113
+ query(model).find_by(**conditions)
114
+ end
118
115
 
119
- composite_references = stored_data["_composite_models"]
120
- if composite_references
121
- model_instance = @composite_handler.restore_composite_models(
122
- model_instance, composite_references
123
- )
124
- end
116
+ def find_by!(model:, **conditions)
117
+ query(model).find_by!(**conditions)
118
+ end
125
119
 
126
- models << model_instance
127
- rescue StandardError => e
128
- @store.emit_event(:deserialization_error, key: storage_key, error: e)
129
- end
120
+ # ── Transactions ──
121
+
122
+ def transaction(&block)
123
+ adapter.transaction(&block)
124
+ end
125
+
126
+ # ── Scopes ──
127
+
128
+ def scope(name, body)
129
+ @scopes[name.to_sym] = body
130
+ end
131
+
132
+ def scope_for(name)
133
+ @scopes[name.to_sym] || raise(ArgumentError, "Unknown scope: #{name}")
134
+ end
135
+
136
+ # ── Query execution (called by Query) ──
137
+
138
+ def execute_query(query)
139
+ registration = @registry.registration_for(query.model_class)
140
+
141
+ if custom_serializer?(registration)
142
+ execute_with_post_filter(query, registration)
143
+ else
144
+ execute_with_pre_filter(query, registration)
130
145
  end
146
+ end
131
147
 
132
- models
148
+ def count_query(query)
149
+ registration = @registry.registration_for(query.model_class)
150
+
151
+ if custom_serializer?(registration)
152
+ execute_with_post_filter(query, registration).size
153
+ else
154
+ count_with_pre_filter(query, registration)
155
+ end
133
156
  end
134
157
 
135
- def exists?(model:, **key_params)
136
- !fetch(model: model, **key_params).nil?
158
+ def fetch_batch(query, after: nil, limit: 1000)
159
+ scan_query = Query.new(self, query.model_class)
160
+ raw_batch = adapter.batch_query(scan_query, after: after, limit: limit)
161
+ if raw_batch
162
+ models = deserialize_results(raw_batch, query.model_class)
163
+ query.predicates.empty? ? models : models.select { |m| query.predicates.all? { |p| p.match?(m) } }
164
+ else
165
+ fallback_batch(query, after: after, limit: limit)
166
+ end
137
167
  end
138
168
 
139
- def count(model:)
140
- all(model: model).size
169
+ def last_storage_key_from(models, model_class)
170
+ return nil if models.empty?
171
+
172
+ registration = @registry.registration_for(model_class)
173
+ last_model = models.last
174
+ key_value = registration.extract_key(last_model)
175
+ registration.generate_storage_key_from_value(key_value).to_s
141
176
  end
142
177
 
143
- # Load all models of a type from a directory using format-specific serialization.
144
- # Bypasses the key-value layer and reads files directly using the format handler.
178
+ # ── File I/O ──
179
+
145
180
  def load_all(model_class, path: nil, format: :yaml, layout: :separate)
146
181
  fmt = Format.resolve(format)
147
182
  dir = resolve_model_dir(model_class, path)
@@ -161,8 +196,6 @@ module Lutaml
161
196
  end
162
197
  end
163
198
 
164
- # Load from directory and store into the key-value backend.
165
- # Returns the loaded models and makes them available via fetch/where/all.
166
199
  def import_all(model_class, path: nil, format: :yaml, layout: :separate)
167
200
  models = load_all(model_class, path: path, format: format, layout: layout)
168
201
  models.each { |model| save(model) }
@@ -170,7 +203,6 @@ module Lutaml
170
203
  models
171
204
  end
172
205
 
173
- # Save all models to a directory using format-specific serialization.
174
206
  def save_all(models, path: nil, format: :yaml, layout: :separate)
175
207
  fmt = Format.resolve(format)
176
208
  models_array = Array(models)
@@ -198,7 +230,6 @@ module Lutaml
198
230
  saved
199
231
  end
200
232
 
201
- # Export models to a single file or directory.
202
233
  def export(models, path:, format: :yaml)
203
234
  fmt = Format.resolve(format)
204
235
  models_array = Array(models)
@@ -207,11 +238,13 @@ module Lutaml
207
238
 
208
239
  content = fmt.serialize_many(models_array)
209
240
 
210
- File.write(path, content, encoding: "utf-8")
241
+ write_file(path, content, fmt)
211
242
  @store.emit_event(:model_export, count: models_array.size, path: path)
212
243
  path
213
244
  end
214
245
 
246
+ # ── Events ──
247
+
215
248
  def on(event, &block)
216
249
  @store.on(event, &block)
217
250
  end
@@ -235,6 +268,226 @@ module Lutaml
235
268
 
236
269
  private
237
270
 
271
+ def adapter
272
+ @store.adapter
273
+ end
274
+
275
+ def custom_serializer?(registration)
276
+ registration&.serializer
277
+ end
278
+
279
+ # Pre-deserialization filtering: predicates evaluated on raw hash data.
280
+ # Used when the serializer stores model fields as direct hash keys.
281
+ def execute_with_pre_filter(query, registration)
282
+ adapter_results = adapter.execute_query(query)
283
+
284
+ if adapter_results
285
+ deserialize_results(adapter_results, query.model_class)
286
+ else
287
+ fallback_execute(query, registration)
288
+ end
289
+ end
290
+
291
+ def count_with_pre_filter(query, registration)
292
+ adapter_count = adapter.count_query(query)
293
+ return adapter_count unless adapter_count.nil?
294
+
295
+ adapter_results = adapter.execute_query(query)
296
+ if adapter_results
297
+ adapter_results.size
298
+ else
299
+ fallback_count(query, registration)
300
+ end
301
+ end
302
+
303
+ # Post-deserialization filtering: scan by class name only, deserialize
304
+ # everything, then filter predicates on model instances.
305
+ # Used when custom serializers store data in non-standard hash format.
306
+ def execute_with_post_filter(query, _registration)
307
+ scan_query = Query.new(self, query.model_class)
308
+ adapter_results = adapter.execute_query(scan_query)
309
+
310
+ raw_results = adapter_results || scan_all_by_class(query.model_class)
311
+
312
+ models = deserialize_results(raw_results, query.model_class)
313
+ results = apply_model_predicates(models, query.predicates)
314
+ results = apply_model_sort(results, query.orders)
315
+ apply_model_pagination(results, query.limit_value, query.offset_value)
316
+ end
317
+
318
+ def scan_all_by_class(model_class)
319
+ results = []
320
+ @store.each_key do |storage_key|
321
+ parsed = StorageKey.parse(storage_key.to_s)
322
+ next unless parsed.class_name == model_class.name
323
+
324
+ stored_data = @store.get(storage_key)
325
+ results << [storage_key, stored_data] if stored_data
326
+ rescue StandardError
327
+ next
328
+ end
329
+ results
330
+ end
331
+
332
+ def apply_model_predicates(models, predicates)
333
+ return models if predicates.empty?
334
+
335
+ models.select { |m| predicates.all? { |p| p.match?(m) } }
336
+ end
337
+
338
+ def apply_model_sort(models, orders)
339
+ return models if orders.empty?
340
+
341
+ models.sort do |a, b|
342
+ orders.reduce(0) do |cmp, o|
343
+ break cmp unless cmp.zero?
344
+
345
+ va = a.public_send(o.field)
346
+ vb = b.public_send(o.field)
347
+
348
+ cmp_val = if va.nil? && vb.nil?
349
+ 0
350
+ elsif va.nil?
351
+ 1
352
+ elsif vb.nil?
353
+ -1
354
+ else
355
+ va <=> vb || 0
356
+ end
357
+
358
+ o.direction == :desc ? -cmp_val : cmp_val
359
+ end
360
+ end
361
+ end
362
+
363
+ def apply_model_pagination(models, limit, offset)
364
+ start = offset || 0
365
+ models = models[start..] || []
366
+ models = models.first(limit) if limit
367
+ models
368
+ end
369
+
370
+ def deserialize_record(hash_data, model_class, registration)
371
+ model_instance = @serializer.deserialize(hash_data, model_class, registration)
372
+
373
+ composite_references = hash_data["_composite_models"]
374
+ if composite_references
375
+ model_instance = @composite_handler.restore_composite_models(
376
+ model_instance, composite_references
377
+ )
378
+ end
379
+
380
+ model_instance
381
+ end
382
+
383
+ def deserialize_results(raw_results, model_class)
384
+ registration = @registry.registration_for(model_class)
385
+
386
+ raw_results.filter_map do |(storage_key, hash_data)|
387
+ deserialize_record(hash_data, model_class, registration)
388
+ rescue StandardError => e
389
+ @store.emit_event(:deserialization_error, key: storage_key, error: e)
390
+ nil
391
+ end
392
+ end
393
+
394
+ # ── Fallback query execution (no adapter query support) ──
395
+
396
+ def fallback_execute(query, _registration)
397
+ results = []
398
+
399
+ @store.each_key do |storage_key|
400
+ parsed = StorageKey.parse(storage_key.to_s)
401
+ next unless parsed.class_name == query.model_class.name
402
+
403
+ stored_data = @store.get(storage_key)
404
+ next unless stored_data
405
+ next unless query.predicates.all? { |p| p.match?(stored_data) }
406
+
407
+ results << [storage_key, stored_data]
408
+ rescue StandardError
409
+ next
410
+ end
411
+
412
+ results = apply_sort(results, query.orders)
413
+ results = apply_pagination(results, query.limit_value, query.offset_value)
414
+ deserialize_results(results, query.model_class)
415
+ end
416
+
417
+ def fallback_count(query, _registration)
418
+ count = 0
419
+
420
+ @store.each_key do |storage_key|
421
+ parsed = StorageKey.parse(storage_key.to_s)
422
+ next unless parsed.class_name == query.model_class.name
423
+
424
+ stored_data = @store.get(storage_key)
425
+ next unless stored_data
426
+ next unless query.predicates.all? { |p| p.match?(stored_data) }
427
+
428
+ count += 1
429
+ rescue StandardError
430
+ next
431
+ end
432
+
433
+ count
434
+ end
435
+
436
+ def fallback_batch(query, after: nil, limit: 1000)
437
+ results = []
438
+
439
+ @store.each_key do |storage_key|
440
+ parsed = StorageKey.parse(storage_key.to_s)
441
+ next unless parsed.class_name == query.model_class.name
442
+ next if after && storage_key.to_s <= after
443
+
444
+ stored_data = @store.get(storage_key)
445
+ next unless stored_data
446
+ next unless query.predicates.all? { |p| p.match?(stored_data) }
447
+
448
+ results << [storage_key, stored_data]
449
+ break if results.size >= limit
450
+ rescue StandardError
451
+ next
452
+ end
453
+
454
+ deserialize_results(results, query.model_class)
455
+ end
456
+
457
+ def apply_sort(results, orders)
458
+ return results if orders.empty?
459
+
460
+ results.sort do |a, b|
461
+ orders.reduce(0) do |cmp, o|
462
+ break cmp unless cmp.zero?
463
+
464
+ va = a.last.is_a?(Hash) ? a.last[o.field.to_s] : nil
465
+ vb = b.last.is_a?(Hash) ? b.last[o.field.to_s] : nil
466
+
467
+ cmp_val = if va.nil? && vb.nil?
468
+ 0
469
+ elsif va.nil?
470
+ 1
471
+ elsif vb.nil?
472
+ -1
473
+ else
474
+ va <=> vb || 0
475
+ end
476
+
477
+ o.direction == :desc ? -cmp_val : cmp_val
478
+ end
479
+ end
480
+ end
481
+
482
+ def apply_pagination(results, limit, offset)
483
+ start = offset || 0
484
+ results = results[start..] || []
485
+ results = results.first(limit) if limit
486
+ results
487
+ end
488
+
489
+ # ── CRUD internals ──
490
+
238
491
  def find_stored_data(registration, model, key_value)
239
492
  if registration.polymorphic?
240
493
  find_polymorphic_data(model, key_value)
@@ -331,8 +584,8 @@ module Lutaml
331
584
  Dir.glob(glob).sort.each do |file_path|
332
585
  next unless File.file?(file_path)
333
586
 
334
- raw = File.read(file_path, encoding: "utf-8")
335
- next if raw.strip.empty?
587
+ raw = read_file(file_path, fmt)
588
+ next if !fmt.binary? && raw.strip.empty?
336
589
 
337
590
  begin
338
591
  model = fmt.deserialize(raw, model_class)
@@ -351,8 +604,8 @@ module Lutaml
351
604
  Dir.glob(glob).sort.each do |file_path|
352
605
  next unless File.file?(file_path)
353
606
 
354
- raw = File.read(file_path, encoding: "utf-8")
355
- next if raw.strip.empty?
607
+ raw = read_file(file_path, fmt)
608
+ next if !fmt.binary? && raw.strip.empty?
356
609
 
357
610
  begin
358
611
  loaded = fmt.deserialize_many(raw, model_class)
@@ -375,8 +628,7 @@ module Lutaml
375
628
  key = extract_model_key(model)
376
629
  filename = key || model.class.name.to_s.gsub("::", "_")
377
630
  file_path = File.join(dir, "#{filename}#{fmt.extension}")
378
- content = fmt.serialize(model)
379
- File.write(file_path, content, encoding: "utf-8")
631
+ write_file(file_path, fmt.serialize(model), fmt)
380
632
  model
381
633
  end
382
634
  end
@@ -391,8 +643,7 @@ module Lutaml
391
643
 
392
644
  grouped.map do |key, group|
393
645
  file_path = File.join(dir, "#{key}#{fmt.extension}")
394
- content = fmt.serialize_many(group)
395
- File.write(file_path, content, encoding: "utf-8")
646
+ write_file(file_path, fmt.serialize_many(group), fmt)
396
647
  group
397
648
  end.flatten
398
649
  end
@@ -420,6 +671,18 @@ module Lutaml
420
671
  basename = File.basename(file_path, ".*")
421
672
  model.public_send(:"#{registration.key_field}=", basename)
422
673
  end
674
+
675
+ def read_file(file_path, fmt)
676
+ fmt.binary? ? File.binread(file_path) : File.read(file_path, encoding: "utf-8")
677
+ end
678
+
679
+ def write_file(file_path, content, fmt)
680
+ if fmt.binary?
681
+ File.binwrite(file_path, content)
682
+ else
683
+ File.write(file_path, content, encoding: "utf-8")
684
+ end
685
+ end
423
686
  end
424
687
  end
425
688
  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)