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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4917f68f56456a19670424157dd5001d243b9bd6d97618f445d306a7998e2a9f
4
- data.tar.gz: 7076d765cf18cc91bfa9cf252bb0117ea8870c915bf1e7421648d9490de7e6a5
3
+ metadata.gz: b6093f17ea0bca9240b608835a29b8f060a0c8c41be950e93fc0713e8c1b9444
4
+ data.tar.gz: f0f9ee8ace22fa22ec62408425ba7fa479648c57158c92967bc8e235fd037285
5
5
  SHA512:
6
- metadata.gz: 897f40bf4972373587528a80afcc44ab2b0c159f2bba63a97f39cb997eff97d3633d7e34cabeb0dac37e1b337781f0aef60b9ffaae2b3a3426d5624b35fb7950
7
- data.tar.gz: 1b79b56672ace97f0b6e57715b1b56b7f4638b1d426db57b8f57c5ed21eb461a7fd2ff4181f57afaa094d85c9b7da37f9e3d4771c04afecfcef0c268e86ddcd7
6
+ metadata.gz: 97a8aa5dc0d52617b1f4d97bd8378664eead01e299410d8dd506b775be23d69616008cde2e053b6a649819726048afdd2b227a455979630b2f07d04e76deed6d
7
+ data.tar.gz: b80834cd232f87d378c0a3a5f7ea17ef2c7ea2d68103d890756eabb2f2c85e20e1b1f193ec64cd0198fdb4235c0ff68ca6de87e788b7aabaa26b49a081e2023e
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 09:38:49 UTC using RuboCop version 1.87.0.
3
+ # on 2026-06-10 09:02:53 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
@@ -11,54 +11,63 @@ Gemspec/RequiredRubyVersion:
11
11
  Exclude:
12
12
  - 'lutaml-store.gemspec'
13
13
 
14
- # Offense count: 29
14
+ # Offense count: 38
15
15
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
16
16
  Metrics/AbcSize:
17
17
  Max: 112
18
18
 
19
- # Offense count: 57
19
+ # Offense count: 63
20
20
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
21
21
  # AllowedMethods: refine
22
22
  Metrics/BlockLength:
23
23
  Max: 387
24
24
 
25
- # Offense count: 16
25
+ # Offense count: 17
26
26
  # Configuration parameters: CountComments, CountAsOne.
27
27
  Metrics/ClassLength:
28
- Max: 379
28
+ Max: 516
29
29
 
30
- # Offense count: 9
30
+ # Offense count: 15
31
31
  # Configuration parameters: AllowedMethods, AllowedPatterns.
32
32
  Metrics/CyclomaticComplexity:
33
- Max: 12
33
+ Max: 13
34
34
 
35
- # Offense count: 79
35
+ # Offense count: 89
36
36
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
37
37
  Metrics/MethodLength:
38
38
  Max: 84
39
39
 
40
- # Offense count: 5
40
+ # Offense count: 6
41
41
  # Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
42
42
  Metrics/ParameterLists:
43
43
  Max: 8
44
44
 
45
- # Offense count: 4
45
+ # Offense count: 8
46
46
  # Configuration parameters: AllowedMethods, AllowedPatterns.
47
47
  Metrics/PerceivedComplexity:
48
- Max: 12
48
+ Max: 14
49
49
 
50
50
  # Offense count: 1
51
51
  Naming/AccessorMethodName:
52
52
  Exclude:
53
53
  - 'lib/lutaml/store/adapter/sqlite.rb'
54
54
 
55
- # Offense count: 3
55
+ # Offense count: 1
56
+ # This cop supports unsafe autocorrection (--autocorrect-all).
57
+ # Configuration parameters: EnforcedStyleForLeadingUnderscores.
58
+ # SupportedStylesForLeadingUnderscores: disallowed, required, optional
59
+ Naming/MemoizedInstanceVariableName:
60
+ Exclude:
61
+ - 'lib/lutaml/store/query.rb'
62
+
63
+ # Offense count: 4
56
64
  # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames.
57
65
  # AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to
58
66
  Naming/MethodParameterName:
59
67
  Exclude:
60
68
  - 'demo/vcard_models.rb'
61
69
  - 'demo/vcard_store_demo.rb'
70
+ - 'lib/lutaml/store/adapter/sqlite.rb'
62
71
 
63
72
  # Offense count: 1
64
73
  # Configuration parameters: NamePrefix, ForbiddenPrefixes, AllowedMethods, MethodDefinitionMacros, UseSorbetSigs.
@@ -84,7 +93,7 @@ Security/MarshalLoad:
84
93
  Exclude:
85
94
  - 'lib/lutaml/store/format/marshal_format.rb'
86
95
 
87
- # Offense count: 36
96
+ # Offense count: 51
88
97
  # Configuration parameters: AllowedConstants.
89
98
  Style/Documentation:
90
99
  Enabled: false
@@ -8,6 +8,8 @@ module Lutaml
8
8
  @config = config
9
9
  end
10
10
 
11
+ # ── Key-value operations ──
12
+
11
13
  def get(key)
12
14
  raise NotImplementedError
13
15
  end
@@ -60,6 +62,32 @@ module Lutaml
60
62
  keys.each_with_object({}) { |k, h| h[k] = delete(k) }
61
63
  end
62
64
 
65
+ # ── Query operations ──
66
+
67
+ # Execute a query specification.
68
+ # Returns array of [storage_key, hash_data] pairs, sorted and paginated.
69
+ # Returns nil to indicate "not supported — fall back to in-memory scan".
70
+ def execute_query(_query)
71
+ nil
72
+ end
73
+
74
+ # Count matching records without returning data.
75
+ # Returns nil to indicate "not supported — count execute_query results".
76
+ def count_query(_query)
77
+ nil
78
+ end
79
+
80
+ # Fetch a batch of [storage_key, hash_data] pairs for keyset pagination.
81
+ # Returns nil to indicate "not supported — fall back to in-memory".
82
+ def batch_query(_query, _after: nil, _limit: 1000)
83
+ nil
84
+ end
85
+
86
+ # Execute a block atomically. No-op for adapters without transaction support.
87
+ def transaction
88
+ yield
89
+ end
90
+
63
91
  def stats
64
92
  { adapter: self.class.name }
65
93
  end
@@ -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