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.
@@ -3,136 +3,145 @@
3
3
  module Lutaml
4
4
  module Store
5
5
  module Adapter
6
+ # In-memory key-value adapter optimized for read-heavy workloads.
7
+ # Writes are synchronized; reads are lock-free using snapshot copies.
6
8
  class Memory < Base
7
9
  def initialize(config = {})
8
10
  super
9
11
  @data = {}
10
- @mutex = Mutex.new
12
+ @write_mutex = Mutex.new
13
+ @read_snapshot = {}.freeze
14
+ @snapshot_stale = true
11
15
  @ttl_enabled = @config.fetch(:ttl_enabled, false)
12
- @ttl_data = {} if @ttl_enabled
16
+ @ttl_data = @ttl_enabled ? {} : nil
13
17
  @default_ttl = @config[:default_ttl] || 3600
14
18
  end
15
19
 
20
+ # ── Read operations (lock-free via snapshot) ──
21
+
16
22
  def get(key)
17
- @mutex.synchronize do
18
- return nil unless @data.key?(key)
23
+ cleanup_expired_if_needed
24
+ snapshot[key]
25
+ end
19
26
 
20
- if @ttl_enabled && expired?(key)
21
- @data.delete(key)
22
- @ttl_data.delete(key)
23
- return nil
24
- end
27
+ def exists?(key)
28
+ cleanup_expired_if_needed
29
+ snapshot.key?(key)
30
+ end
31
+
32
+ def all
33
+ cleanup_expired_if_needed
34
+ snapshot.dup
35
+ end
36
+
37
+ def size
38
+ cleanup_expired_if_needed
39
+ snapshot.size
40
+ end
41
+
42
+ def keys
43
+ cleanup_expired_if_needed
44
+ snapshot.keys
45
+ end
25
46
 
26
- @data[key]
47
+ def each_key(&block)
48
+ cleanup_expired_if_needed
49
+ snapshot.each_key(&block)
50
+ end
51
+
52
+ def bulk_get(keys)
53
+ cleanup_expired_if_needed
54
+ snap = snapshot
55
+ keys.each_with_object({}) do |k, h|
56
+ h[k] = snap[k]
27
57
  end
28
58
  end
29
59
 
60
+ # ── Write operations (synchronized) ──
61
+
30
62
  def set(key, value, metadata = {})
31
- @mutex.synchronize do
63
+ @write_mutex.synchronize do
32
64
  @data[key] = value
65
+ invalidate_snapshot
33
66
 
34
67
  if @ttl_enabled
35
68
  ttl_value = metadata[:ttl] || @default_ttl
36
69
  @ttl_data[key] = Time.now + ttl_value if ttl_value
37
70
  end
38
-
39
- value
40
71
  end
72
+ value
41
73
  end
42
74
 
43
75
  def delete(key)
44
- @mutex.synchronize do
76
+ @write_mutex.synchronize do
45
77
  existed = @data.key?(key)
46
78
  @data.delete(key)
47
- @ttl_data.delete(key) if @ttl_enabled
79
+ @ttl_data&.delete(key)
80
+ invalidate_snapshot
48
81
  existed
49
82
  end
50
83
  end
51
84
 
52
- def exists?(key)
53
- @mutex.synchronize do
54
- return false unless @data.key?(key)
55
-
56
- if @ttl_enabled && expired?(key)
57
- @data.delete(key)
58
- @ttl_data.delete(key)
59
- return false
60
- end
61
-
62
- true
63
- end
64
- end
65
-
66
- def all
67
- @mutex.synchronize do
68
- cleanup_expired if @ttl_enabled
69
- @data.dup
70
- end
71
- end
72
-
73
85
  def clear
74
- @mutex.synchronize do
86
+ @write_mutex.synchronize do
75
87
  count = @data.size
76
88
  @data.clear
77
- @ttl_data.clear if @ttl_enabled
89
+ @ttl_data&.clear
90
+ invalidate_snapshot
78
91
  count
79
92
  end
80
93
  end
81
94
 
82
- def size
83
- @mutex.synchronize do
84
- cleanup_expired if @ttl_enabled
85
- @data.size
95
+ def close
96
+ @write_mutex.synchronize do
97
+ @data.clear
98
+ @ttl_data&.clear
99
+ invalidate_snapshot
86
100
  end
87
101
  end
88
102
 
89
- def keys
90
- @mutex.synchronize do
91
- cleanup_expired if @ttl_enabled
92
- @data.keys
93
- end
94
- end
103
+ def bulk_set(key_value_pairs, ttl: nil)
104
+ @write_mutex.synchronize do
105
+ key_value_pairs.each do |key, value|
106
+ @data[key] = value
95
107
 
96
- def each_key(&block)
97
- current_keys = @mutex.synchronize do
98
- cleanup_expired if @ttl_enabled
99
- @data.keys
108
+ if @ttl_enabled
109
+ ttl_value = ttl || @default_ttl
110
+ @ttl_data[key] = Time.now + ttl_value if ttl_value
111
+ end
112
+ end
113
+ invalidate_snapshot
100
114
  end
101
- current_keys.each(&block)
102
115
  end
103
116
 
104
- def close
105
- @mutex.synchronize do
106
- @data.clear
107
- @ttl_data.clear if @ttl_enabled
117
+ def bulk_delete(keys)
118
+ @write_mutex.synchronize do
119
+ result = {}
120
+ keys.each do |key|
121
+ result[key] = @data.delete(key)
122
+ @ttl_data&.delete(key)
123
+ end
124
+ invalidate_snapshot
125
+ result
108
126
  end
109
127
  end
110
128
 
111
- def stats
112
- @mutex.synchronize do
113
- cleanup_expired if @ttl_enabled
114
- super.merge(
115
- size: @data.size,
116
- ttl_enabled: @ttl_enabled,
117
- expired_keys: @ttl_enabled ? count_expired_keys : 0
118
- )
119
- end
120
- end
129
+ # ── TTL operations ──
121
130
 
122
131
  def cleanup_expired
123
132
  return unless @ttl_enabled
124
133
 
125
- @mutex.synchronize do
126
- expired_keys = []
127
- @ttl_data.each do |key, expiry_time|
128
- expired_keys << key if Time.now > expiry_time
134
+ @write_mutex.synchronize do
135
+ now = Time.now
136
+ expired_keys = @ttl_data.each_with_object([]) do |(key, expiry_time), arr|
137
+ arr << key if now > expiry_time
129
138
  end
130
139
 
131
140
  expired_keys.each do |key|
132
141
  @data.delete(key)
133
142
  @ttl_data.delete(key)
134
143
  end
135
-
144
+ invalidate_snapshot unless expired_keys.empty?
136
145
  expired_keys.size
137
146
  end
138
147
  end
@@ -140,7 +149,7 @@ module Lutaml
140
149
  def set_ttl(key, ttl)
141
150
  return false unless @ttl_enabled
142
151
 
143
- @mutex.synchronize do
152
+ @write_mutex.synchronize do
144
153
  return false unless @data.key?(key)
145
154
 
146
155
  if ttl
@@ -148,7 +157,7 @@ module Lutaml
148
157
  else
149
158
  @ttl_data.delete(key)
150
159
  end
151
-
160
+ invalidate_snapshot
152
161
  true
153
162
  end
154
163
  end
@@ -156,76 +165,159 @@ module Lutaml
156
165
  def get_ttl(key)
157
166
  return nil unless @ttl_enabled
158
167
 
159
- @mutex.synchronize do
160
- return nil unless @ttl_data.key?(key)
168
+ snap = snapshot
169
+ return nil unless snap.key?(key)
170
+ return nil unless @ttl_data.key?(key)
171
+
172
+ remaining = @ttl_data[key] - Time.now
173
+ remaining.positive? ? remaining : nil
174
+ end
175
+
176
+ # ── Query operations (lock-free via snapshot) ──
177
+
178
+ def execute_query(query)
179
+ cleanup_expired_if_needed
180
+ snap = snapshot
181
+ model_name = query.model_class.name
182
+ preds = query.predicates
183
+
184
+ results = []
185
+ snap.each do |key, value|
186
+ parsed = StorageKey.parse(key.to_s)
187
+ next unless parsed.class_name == model_name
188
+ next unless preds_all_match?(preds, value)
161
189
 
162
- expiry_time = @ttl_data[key]
163
- remaining = expiry_time - Time.now
164
- remaining.positive? ? remaining : nil
190
+ results << [key, value]
165
191
  end
192
+
193
+ results = apply_sort(results, query.orders)
194
+ results = apply_pagination(results, query.limit_value, query.offset_value)
166
195
  end
167
196
 
168
- def bulk_set(key_value_pairs, ttl: nil)
169
- @mutex.synchronize do
170
- key_value_pairs.each do |key, value|
171
- @data[key] = value
197
+ def count_query(query)
198
+ cleanup_expired_if_needed
199
+ snap = snapshot
200
+ model_name = query.model_class.name
201
+ preds = query.predicates
172
202
 
173
- if @ttl_enabled
174
- ttl_value = ttl || @default_ttl
175
- @ttl_data[key] = Time.now + ttl_value if ttl_value
176
- end
177
- end
203
+ count = 0
204
+ snap.each do |key, value|
205
+ parsed = StorageKey.parse(key.to_s)
206
+ next unless parsed.class_name == model_name
207
+ next unless preds_all_match?(preds, value)
208
+
209
+ count += 1
178
210
  end
211
+ count
179
212
  end
180
213
 
181
- def bulk_get(keys)
182
- @mutex.synchronize do
183
- result = {}
184
- keys.each do |key|
185
- if @data.key?(key)
186
- if @ttl_enabled && expired?(key)
187
- @data.delete(key)
188
- @ttl_data.delete(key)
189
- result[key] = nil
190
- else
191
- result[key] = @data[key]
192
- end
193
- else
194
- result[key] = nil
195
- end
196
- end
197
- result
214
+ def batch_query(query, after: nil, limit: 1000)
215
+ cleanup_expired_if_needed
216
+ snap = snapshot
217
+ model_name = query.model_class.name
218
+ preds = query.predicates
219
+
220
+ sorted_keys = snap.keys.sort_by(&:to_s)
221
+ sorted_keys = sorted_keys.select { |k| k.to_s > after } if after
222
+
223
+ results = []
224
+ sorted_keys.each do |key|
225
+ parsed = StorageKey.parse(key.to_s)
226
+ next unless parsed.class_name == model_name
227
+
228
+ value = snap[key]
229
+ next unless value
230
+ next unless preds_all_match?(preds, value)
231
+
232
+ results << [key.to_s, value]
233
+ break if results.size >= limit
198
234
  end
235
+ results
199
236
  end
200
237
 
201
- def bulk_delete(keys)
202
- @mutex.synchronize do
203
- result = {}
204
- keys.each do |key|
205
- result[key] = @data.delete(key)
206
- @ttl_data.delete(key) if @ttl_enabled
238
+ def stats
239
+ cleanup_expired_if_needed
240
+ snap = snapshot
241
+ super.merge(
242
+ size: snap.size,
243
+ ttl_enabled: @ttl_enabled,
244
+ expired_keys: @ttl_enabled ? count_expired_keys : 0
245
+ )
246
+ end
247
+
248
+ private
249
+
250
+ # Frozen snapshot — lock-free reads. Rebuilt lazily after writes.
251
+ def snapshot
252
+ if @snapshot_stale
253
+ @write_mutex.synchronize do
254
+ if @snapshot_stale
255
+ @read_snapshot = @data.dup.freeze
256
+ @snapshot_stale = false
257
+ end
207
258
  end
208
- result
209
259
  end
260
+ @read_snapshot
210
261
  end
211
262
 
212
- private
263
+ def invalidate_snapshot
264
+ @snapshot_stale = true
265
+ end
213
266
 
214
- def expired?(key)
215
- return false unless @ttl_enabled
216
- return false unless @ttl_data.key?(key)
267
+ def cleanup_expired_if_needed
268
+ return unless @ttl_enabled
269
+
270
+ now = Time.now
271
+ return unless @ttl_data.any? { |_k, expiry| now > expiry }
217
272
 
218
- Time.now > @ttl_data[key]
273
+ cleanup_expired
219
274
  end
220
275
 
221
276
  def count_expired_keys
222
277
  return 0 unless @ttl_enabled
223
278
 
224
- count = 0
225
- @ttl_data.each_value do |expiry_time|
226
- count += 1 if Time.now > expiry_time
279
+ now = Time.now
280
+ @ttl_data.count { |_k, expiry| now > expiry }
281
+ end
282
+
283
+ # Inline predicate evaluation — avoids method dispatch overhead in tight loops
284
+ def preds_all_match?(predicates, value)
285
+ predicates.all? { |p| p.match?(value) }
286
+ end
287
+
288
+ def apply_sort(results, orders)
289
+ return results if orders.empty?
290
+
291
+ results.sort do |a, b|
292
+ orders.reduce(0) do |cmp, o|
293
+ break cmp unless cmp.zero?
294
+
295
+ va = a.last.is_a?(Hash) ? a.last[o.field.to_s] : nil
296
+ vb = b.last.is_a?(Hash) ? b.last[o.field.to_s] : nil
297
+
298
+ cmp_val = compare_values(va, vb)
299
+ o.direction == :desc ? -cmp_val : cmp_val
300
+ end
227
301
  end
228
- count
302
+ end
303
+
304
+ def compare_values(val_a, val_b)
305
+ if val_a.nil? && val_b.nil?
306
+ 0
307
+ elsif val_a.nil?
308
+ 1
309
+ elsif val_b.nil?
310
+ -1
311
+ else
312
+ val_a <=> val_b || 0
313
+ end
314
+ end
315
+
316
+ def apply_pagination(results, limit, offset)
317
+ start = offset || 0
318
+ results = results[start..] || []
319
+ results = results.first(limit) if limit
320
+ results
229
321
  end
230
322
  end
231
323
  end
@@ -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