lutaml-store 0.2.2 → 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.
@@ -23,9 +23,11 @@ module Lutaml
23
23
  setup_database
24
24
  end
25
25
 
26
+ # ── Key-value operations ──
27
+
26
28
  def get(key)
27
29
  result = nil
28
- execute_query("SELECT value FROM #{@table_name} WHERE key = ?", [key]) do |row|
30
+ execute_query_raw("SELECT value FROM #{@table_name} WHERE key = ?", [key]) do |row|
29
31
  value = row[0]
30
32
  begin
31
33
  result = JSON.parse(value)
@@ -55,7 +57,7 @@ module Lutaml
55
57
  end
56
58
 
57
59
  def exists?(key)
58
- execute_query("SELECT 1 FROM #{@table_name} WHERE key = ? LIMIT 1", [key]) do |_row|
60
+ execute_query_raw("SELECT 1 FROM #{@table_name} WHERE key = ? LIMIT 1", [key]) do |_row|
59
61
  return true
60
62
  end
61
63
  false
@@ -63,7 +65,7 @@ module Lutaml
63
65
 
64
66
  def all
65
67
  result = {}
66
- execute_query("SELECT key, value FROM #{@table_name}") do |row|
68
+ execute_query_raw("SELECT key, value FROM #{@table_name}") do |row|
67
69
  result[row[0]] = row[1]
68
70
  end
69
71
  result
@@ -76,7 +78,7 @@ module Lutaml
76
78
  end
77
79
 
78
80
  def size
79
- execute_query("SELECT COUNT(*) FROM #{@table_name}") do |row|
81
+ execute_query_raw("SELECT COUNT(*) FROM #{@table_name}") do |row|
80
82
  return row[0]
81
83
  end
82
84
  0
@@ -84,7 +86,7 @@ module Lutaml
84
86
 
85
87
  def keys
86
88
  result = []
87
- execute_query("SELECT key FROM #{@table_name}") do |row|
89
+ execute_query_raw("SELECT key FROM #{@table_name}") do |row|
88
90
  result << row[0]
89
91
  end
90
92
  result
@@ -132,6 +134,56 @@ module Lutaml
132
134
  result
133
135
  end
134
136
 
137
+ # ── Query operations ──
138
+
139
+ def execute_query(query)
140
+ sql, params = build_select_sql(query)
141
+ results = []
142
+ execute_query_raw(sql, params) do |row|
143
+ key = row[0]
144
+ value = parse_json_value(row[1])
145
+ results << [key, value]
146
+ end
147
+ results
148
+ end
149
+
150
+ def count_query(query)
151
+ sql, params = build_count_sql(query)
152
+ execute_query_raw(sql, params) do |row|
153
+ return row[0]
154
+ end
155
+ 0
156
+ end
157
+
158
+ def batch_query(query, after: nil, limit: 1000)
159
+ conditions, params = build_conditions(query)
160
+
161
+ if after
162
+ conditions << "key > ?"
163
+ params << after
164
+ end
165
+
166
+ sql = "SELECT key, value FROM #{@table_name}"
167
+ sql += " WHERE #{conditions.join(" AND ")}" unless conditions.empty?
168
+ sql += " ORDER BY key ASC"
169
+ sql += " LIMIT ?"
170
+ params << limit
171
+
172
+ results = []
173
+ execute_query_raw(sql, params) do |row|
174
+ key = row[0]
175
+ value = parse_json_value(row[1])
176
+ results << [key, value]
177
+ end
178
+ results
179
+ end
180
+
181
+ def transaction(&block)
182
+ @db.transaction(&block)
183
+ rescue SQLite3::Exception => e
184
+ raise BackendError, "Transaction failed: #{e.message}"
185
+ end
186
+
135
187
  private
136
188
 
137
189
  def setup_database
@@ -161,7 +213,129 @@ module Lutaml
161
213
  @db.execute("CREATE INDEX IF NOT EXISTS idx_#{@table_name}_updated_at ON #{@table_name} (updated_at)")
162
214
  end
163
215
 
164
- def execute_query(sql, params = [])
216
+ # ── SQL generation ──
217
+
218
+ def build_select_sql(query)
219
+ conditions, params = build_conditions(query)
220
+
221
+ sql = "SELECT key, value FROM #{@table_name}"
222
+ sql += " WHERE #{conditions.join(" AND ")}" unless conditions.empty?
223
+ sql += build_order_clause(query.orders)
224
+ sql += build_limit_offset(query.limit_value, query.offset_value, params)
225
+
226
+ [sql, params]
227
+ end
228
+
229
+ def build_count_sql(query)
230
+ conditions, params = build_conditions(query)
231
+
232
+ sql = "SELECT COUNT(*) FROM #{@table_name}"
233
+ sql += " WHERE #{conditions.join(" AND ")}" unless conditions.empty?
234
+
235
+ [sql, params]
236
+ end
237
+
238
+ def build_conditions(query)
239
+ conditions = ["key LIKE ?"]
240
+ params = ["#{query.model_class.name}:%"]
241
+
242
+ query.predicates.each do |pred|
243
+ clause, bind = translate_predicate(pred)
244
+ next unless clause
245
+
246
+ conditions << clause
247
+ params.concat(bind)
248
+ end
249
+
250
+ [conditions, params]
251
+ end
252
+
253
+ SIMPLE_PREDICATES = {
254
+ Predicate::Equal => "=",
255
+ Predicate::NotEqual => "!=",
256
+ Predicate::GreaterThan => ">",
257
+ Predicate::LessThan => "<",
258
+ Predicate::GreaterThanOrEqual => ">=",
259
+ Predicate::LessThanOrEqual => "<="
260
+ }.freeze
261
+
262
+ def translate_predicate(pred)
263
+ field_json = "json_extract(value, '$.#{pred.field}')"
264
+
265
+ op = SIMPLE_PREDICATES[pred.class]
266
+ return ["#{field_json} #{op} ?", [pred.value]] if op
267
+
268
+ case pred
269
+ when Predicate::Between
270
+ ["#{field_json} BETWEEN ? AND ?", [pred.value.first, pred.value.last]]
271
+ when Predicate::NotBetween
272
+ ["#{field_json} NOT BETWEEN ? AND ?", [pred.value.first, pred.value.last]]
273
+ when Predicate::In
274
+ ["#{field_json} IN (#{in_placeholders(pred)})", pred.value]
275
+ when Predicate::NotIn
276
+ ["#{field_json} NOT IN (#{in_placeholders(pred)})", pred.value]
277
+ when Predicate::Matches
278
+ translate_matches(field_json, pred, "LIKE")
279
+ when Predicate::NotMatches
280
+ translate_matches(field_json, pred, "NOT LIKE")
281
+ when Predicate::Nil
282
+ ["#{field_json} IS NULL", []]
283
+ when Predicate::NotNil
284
+ ["#{field_json} IS NOT NULL", []]
285
+ end
286
+ end
287
+
288
+ def in_placeholders(pred)
289
+ pred.value.map { "?" }.join(", ")
290
+ end
291
+
292
+ def translate_matches(field_json, pred, op)
293
+ pattern = pred.value.is_a?(Regexp) ? regex_to_like(pred.value) : "%#{pred.value}%"
294
+ ["#{field_json} #{op} ?", [pattern]]
295
+ end
296
+
297
+ def pred_value(pred) # :nodoc:
298
+ pred.value
299
+ end
300
+
301
+ def build_order_clause(orders)
302
+ return "" if orders.empty?
303
+
304
+ clauses = orders.map do |o|
305
+ dir = o.direction == :desc ? "DESC" : "ASC"
306
+ "json_extract(value, '$.#{o.field}') #{dir} NULLS LAST"
307
+ end
308
+ " ORDER BY #{clauses.join(", ")}"
309
+ end
310
+
311
+ def build_limit_offset(limit, offset, params)
312
+ sql = ""
313
+ if limit
314
+ sql += " LIMIT ?"
315
+ params << limit.to_i
316
+ end
317
+ if offset
318
+ sql += " OFFSET ?"
319
+ params << offset.to_i
320
+ end
321
+ sql
322
+ end
323
+
324
+ def regex_to_like(regex)
325
+ source = regex.source
326
+ pattern = source.gsub(".+", "%").gsub(".*", "%").gsub(".", "_").gsub("^", "").gsub("$", "")
327
+ "%#{pattern}%"
328
+ end
329
+
330
+ def parse_json_value(raw)
331
+ JSON.parse(raw)
332
+ rescue JSON::ParserError
333
+ raw
334
+ end
335
+
336
+ # ── Raw query helpers ──
337
+
338
+ def execute_query_raw(sql, params = [])
165
339
  @db.execute(sql, params) do |row|
166
340
  yield row if block_given?
167
341
  end
@@ -182,7 +356,7 @@ module Lutaml
182
356
  end
183
357
 
184
358
  def get_schema_version
185
- execute_query("PRAGMA user_version") do |row|
359
+ execute_query_raw("PRAGMA user_version") do |row|
186
360
  return row[0]
187
361
  end
188
362
  0
@@ -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)
@@ -212,6 +243,8 @@ module Lutaml
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)