lutaml-store 0.1.1
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 +7 -0
- data/.github/workflows/main.yml +27 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop.yml +10 -0
- data/.rubocop_todo.yml +450 -0
- data/CLAUDE.md +57 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CORRECTED_HTTP_CACHE_IMPLEMENTATION.md +209 -0
- data/CORRECTED_HTTP_CACHE_PLAN.md +164 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +220 -0
- data/README.adoc +1430 -0
- data/Rakefile +12 -0
- data/TODO.impl/0-lutaml-store-self-quality.md +112 -0
- data/TODO.impl/1-lutaml-hal-migration.md +60 -0
- data/TODO.impl/2-glossarist-migration.md +359 -0
- data/TODO.impl/3-lutaml-jsonschema-migration.md +273 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/demo/Gemfile +15 -0
- data/demo/Gemfile.lock +61 -0
- data/demo/README.adoc +301 -0
- data/demo/data/vcards/co/contact_10_thompson.data +1 -0
- data/demo/data/vcards/co/contact_10_thompson.meta +1 -0
- data/demo/data/vcards/co/contact_1_doe.data +1 -0
- data/demo/data/vcards/co/contact_1_doe.meta +1 -0
- data/demo/data/vcards/co/contact_2_smith.data +1 -0
- data/demo/data/vcards/co/contact_2_smith.meta +1 -0
- data/demo/data/vcards/co/contact_3_johnson.data +1 -0
- data/demo/data/vcards/co/contact_3_johnson.meta +1 -0
- data/demo/data/vcards/co/contact_4_garcia.data +1 -0
- data/demo/data/vcards/co/contact_4_garcia.meta +1 -0
- data/demo/data/vcards/co/contact_5_wilson.data +1 -0
- data/demo/data/vcards/co/contact_5_wilson.meta +1 -0
- data/demo/data/vcards/co/contact_6_brown.data +1 -0
- data/demo/data/vcards/co/contact_6_brown.meta +1 -0
- data/demo/data/vcards/co/contact_7_davis.data +1 -0
- data/demo/data/vcards/co/contact_7_davis.meta +1 -0
- data/demo/data/vcards/co/contact_8_anderson.data +1 -0
- data/demo/data/vcards/co/contact_8_anderson.meta +1 -0
- data/demo/data/vcards/co/contact_9_taylor.data +1 -0
- data/demo/data/vcards/co/contact_9_taylor.meta +1 -0
- data/demo/data/vcards.db +0 -0
- data/demo/pottery_class_demo.rb +164 -0
- data/demo/vcard_models.rb +140 -0
- data/demo/vcard_store_demo.rb +526 -0
- data/lib/lutaml/store/adapter/base.rb +65 -0
- data/lib/lutaml/store/adapter/filesystem.rb +288 -0
- data/lib/lutaml/store/adapter/memory.rb +225 -0
- data/lib/lutaml/store/adapter/sqlite.rb +193 -0
- data/lib/lutaml/store/adapter.rb +12 -0
- data/lib/lutaml/store/attribute_updater.rb +198 -0
- data/lib/lutaml/store/basic_store.rb +190 -0
- data/lib/lutaml/store/cache.rb +108 -0
- data/lib/lutaml/store/cache_store.rb +282 -0
- data/lib/lutaml/store/composite_model_handler.rb +169 -0
- data/lib/lutaml/store/compression.rb +137 -0
- data/lib/lutaml/store/config.rb +178 -0
- data/lib/lutaml/store/database_store.rb +425 -0
- data/lib/lutaml/store/events.rb +92 -0
- data/lib/lutaml/store/format/base.rb +33 -0
- data/lib/lutaml/store/format/json.rb +25 -0
- data/lib/lutaml/store/format/jsonl.rb +37 -0
- data/lib/lutaml/store/format/marshal_format.rb +37 -0
- data/lib/lutaml/store/format/yaml.rb +29 -0
- data/lib/lutaml/store/format/yamls.rb +35 -0
- data/lib/lutaml/store/format.rb +33 -0
- data/lib/lutaml/store/http_cache.rb +279 -0
- data/lib/lutaml/store/http_cache_config.rb +53 -0
- data/lib/lutaml/store/http_cache_entry.rb +69 -0
- data/lib/lutaml/store/http_header_processor.rb +175 -0
- data/lib/lutaml/store/integrity.rb +102 -0
- data/lib/lutaml/store/model_registration.rb +75 -0
- data/lib/lutaml/store/model_registry.rb +123 -0
- data/lib/lutaml/store/model_serializer.rb +69 -0
- data/lib/lutaml/store/monitor.rb +192 -0
- data/lib/lutaml/store/storage_key.rb +40 -0
- data/lib/lutaml/store/version.rb +7 -0
- data/lib/lutaml/store.rb +41 -0
- data/lutaml-store.gemspec +35 -0
- data/plan.adoc +606 -0
- data/sig/lutaml/store.rbs +6 -0
- data/spec/lutaml/store/adapter_interface_spec.rb +89 -0
- data/spec/lutaml/store/anti_pattern_guard_spec.rb +35 -0
- data/spec/lutaml/store/anti_pattern_spec.rb +78 -0
- data/spec/lutaml/store/autoload_spec.rb +34 -0
- data/spec/lutaml/store/cache_store_spec.rb +271 -0
- data/spec/lutaml/store/compression_spec.rb +78 -0
- data/spec/lutaml/store/config_enhanced_spec.rb +158 -0
- data/spec/lutaml/store/corrected_http_cache_integration_spec.rb +336 -0
- data/spec/lutaml/store/custom_serializer_spec.rb +108 -0
- data/spec/lutaml/store/database_store_spec.rb +279 -0
- data/spec/lutaml/store/file_io_spec.rb +219 -0
- data/spec/lutaml/store/format_round_trip_spec.rb +110 -0
- data/spec/lutaml/store/format_spec.rb +70 -0
- data/spec/lutaml/store/http_cache_entry_spec.rb +203 -0
- data/spec/lutaml/store/http_cache_hal_integration_spec.rb +404 -0
- data/spec/lutaml/store/http_cache_spec.rb +422 -0
- data/spec/lutaml/store/http_header_processor_spec.rb +290 -0
- data/spec/lutaml/store/import_spec.rb +90 -0
- data/spec/lutaml/store/integrity_spec.rb +157 -0
- data/spec/lutaml/store/key_collision_serializer_spec.rb +98 -0
- data/spec/lutaml/store/load_save_spec.rb +107 -0
- data/spec/lutaml/store/lutaml_model_integration_spec.rb +291 -0
- data/spec/lutaml/store/model_serializer_spec.rb +140 -0
- data/spec/lutaml/store/store_spec.rb +182 -0
- data/spec/lutaml/store_spec.rb +21 -0
- data/spec/spec_helper.rb +16 -0
- metadata +166 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module Store
|
|
7
|
+
# TTL-aware cache store with LRU eviction. Wraps a storage adapter directly.
|
|
8
|
+
class CacheStore
|
|
9
|
+
class CacheEntry
|
|
10
|
+
attr_reader :value, :created_at, :ttl, :metadata
|
|
11
|
+
|
|
12
|
+
def initialize(value, ttl: nil, metadata: {}, created_at: nil)
|
|
13
|
+
@value = value
|
|
14
|
+
@created_at = created_at || Time.now
|
|
15
|
+
@ttl = ttl
|
|
16
|
+
@metadata = metadata
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def expired?
|
|
20
|
+
return false unless @ttl
|
|
21
|
+
|
|
22
|
+
Time.now - @created_at > @ttl
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def expires_at
|
|
26
|
+
return nil unless @ttl
|
|
27
|
+
|
|
28
|
+
@created_at + @ttl
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
{
|
|
33
|
+
value: @value,
|
|
34
|
+
created_at: @created_at.iso8601,
|
|
35
|
+
ttl: @ttl,
|
|
36
|
+
expires_at: expires_at&.iso8601,
|
|
37
|
+
metadata: @metadata
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.from_h(hash)
|
|
42
|
+
new(
|
|
43
|
+
hash[:value],
|
|
44
|
+
ttl: hash[:ttl],
|
|
45
|
+
metadata: hash[:metadata] || {},
|
|
46
|
+
created_at: Time.parse(hash[:created_at])
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :adapter
|
|
52
|
+
|
|
53
|
+
def initialize(config = {})
|
|
54
|
+
@adapter = create_adapter(config)
|
|
55
|
+
@default_ttl = config[:default_ttl]
|
|
56
|
+
@max_size = config[:max_size]
|
|
57
|
+
@cleanup_interval = config[:cleanup_interval] || 300
|
|
58
|
+
@last_cleanup = Time.now
|
|
59
|
+
@access_times = {}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def get(key)
|
|
63
|
+
cleanup_expired if should_cleanup?
|
|
64
|
+
|
|
65
|
+
entry_data = @adapter.get(key)
|
|
66
|
+
return nil unless entry_data
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
entry = deserialize_entry(entry_data)
|
|
70
|
+
|
|
71
|
+
if entry.expired?
|
|
72
|
+
delete(key)
|
|
73
|
+
return nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@access_times[key] = Time.now
|
|
77
|
+
entry.value
|
|
78
|
+
rescue StandardError
|
|
79
|
+
delete(key)
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def set(key, value, ttl: :default, metadata: {})
|
|
85
|
+
cleanup_expired if should_cleanup?
|
|
86
|
+
evict_if_needed
|
|
87
|
+
|
|
88
|
+
effective_ttl = ttl == :default ? @default_ttl : ttl
|
|
89
|
+
entry = CacheEntry.new(value, ttl: effective_ttl, metadata: metadata)
|
|
90
|
+
|
|
91
|
+
serialized_entry = serialize_entry(entry)
|
|
92
|
+
@adapter.set(key, serialized_entry)
|
|
93
|
+
|
|
94
|
+
@access_times[key] = Time.now
|
|
95
|
+
value
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def delete(key)
|
|
99
|
+
value = nil
|
|
100
|
+
entry_data = @adapter.get(key)
|
|
101
|
+
if entry_data
|
|
102
|
+
begin
|
|
103
|
+
entry = deserialize_entry(entry_data)
|
|
104
|
+
value = entry.value unless entry.expired?
|
|
105
|
+
rescue StandardError
|
|
106
|
+
# If we can't deserialize, treat as nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
@access_times.delete(key)
|
|
111
|
+
|
|
112
|
+
deleted = @adapter.delete(key)
|
|
113
|
+
|
|
114
|
+
deleted ? value : nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def clear
|
|
118
|
+
@access_times.clear
|
|
119
|
+
@adapter.clear
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def exists?(key)
|
|
123
|
+
return false unless @adapter.exists?(key)
|
|
124
|
+
|
|
125
|
+
entry_data = @adapter.get(key)
|
|
126
|
+
return false unless entry_data
|
|
127
|
+
|
|
128
|
+
begin
|
|
129
|
+
entry = deserialize_entry(entry_data)
|
|
130
|
+
!entry.expired?
|
|
131
|
+
rescue StandardError
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def keys
|
|
137
|
+
cleanup_expired if should_cleanup?
|
|
138
|
+
@adapter.keys.select { |key| exists?(key) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def size
|
|
142
|
+
cleanup_expired if should_cleanup?
|
|
143
|
+
keys.size
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def ttl(key)
|
|
147
|
+
entry_data = @adapter.get(key)
|
|
148
|
+
return nil unless entry_data
|
|
149
|
+
|
|
150
|
+
begin
|
|
151
|
+
entry = deserialize_entry(entry_data)
|
|
152
|
+
return nil if entry.expired?
|
|
153
|
+
return nil unless entry.ttl
|
|
154
|
+
|
|
155
|
+
remaining = entry.ttl - (Time.now - entry.created_at)
|
|
156
|
+
remaining.positive? ? remaining : nil
|
|
157
|
+
rescue StandardError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def expire(key)
|
|
163
|
+
delete(key)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def expire_all
|
|
167
|
+
clear
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def cleanup_expired
|
|
171
|
+
expired_keys = []
|
|
172
|
+
|
|
173
|
+
@adapter.keys.each do |key|
|
|
174
|
+
entry_data = @adapter.get(key)
|
|
175
|
+
next unless entry_data
|
|
176
|
+
|
|
177
|
+
entry = deserialize_entry(entry_data)
|
|
178
|
+
expired_keys << key if entry.expired?
|
|
179
|
+
rescue StandardError
|
|
180
|
+
expired_keys << key
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
expired_keys.each { |key| delete(key) }
|
|
184
|
+
@last_cleanup = Time.now
|
|
185
|
+
|
|
186
|
+
expired_keys.size
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def cache_info
|
|
190
|
+
total_keys = @adapter.keys.size
|
|
191
|
+
valid_keys = keys.size
|
|
192
|
+
expired_keys = total_keys - valid_keys
|
|
193
|
+
|
|
194
|
+
{
|
|
195
|
+
total_entries: total_keys,
|
|
196
|
+
valid_entries: valid_keys,
|
|
197
|
+
expired_entries: expired_keys,
|
|
198
|
+
max_size: @max_size,
|
|
199
|
+
default_ttl: @default_ttl,
|
|
200
|
+
last_cleanup: @last_cleanup
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def touch(key, ttl: nil)
|
|
205
|
+
entry_data = @adapter.get(key)
|
|
206
|
+
return false unless entry_data
|
|
207
|
+
|
|
208
|
+
begin
|
|
209
|
+
entry = deserialize_entry(entry_data)
|
|
210
|
+
return false if entry.expired?
|
|
211
|
+
|
|
212
|
+
new_ttl = ttl || entry.ttl
|
|
213
|
+
new_entry = CacheEntry.new(entry.value, ttl: new_ttl, metadata: entry.metadata)
|
|
214
|
+
|
|
215
|
+
serialized_entry = serialize_entry(new_entry)
|
|
216
|
+
@adapter.set(key, serialized_entry)
|
|
217
|
+
|
|
218
|
+
@access_times[key] = Time.now
|
|
219
|
+
true
|
|
220
|
+
rescue StandardError
|
|
221
|
+
false
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def fetch(key, ttl: :default, metadata: {}, &block)
|
|
226
|
+
value = get(key)
|
|
227
|
+
return value unless value.nil?
|
|
228
|
+
|
|
229
|
+
return nil unless block_given?
|
|
230
|
+
|
|
231
|
+
value = block.call
|
|
232
|
+
set(key, value, ttl: ttl, metadata: metadata)
|
|
233
|
+
value
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def close
|
|
237
|
+
@adapter.close
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def create_adapter(config)
|
|
243
|
+
adapter_type = config[:adapter]&.dig(:type) || config[:adapter_type] || :memory
|
|
244
|
+
adapter_options = config[:adapter]&.dig(:options) || config[:adapter_options] || {}
|
|
245
|
+
|
|
246
|
+
case adapter_type.to_sym
|
|
247
|
+
when :memory
|
|
248
|
+
Adapter::Memory.new(adapter_options)
|
|
249
|
+
when :filesystem
|
|
250
|
+
Adapter::FileSystem.new(adapter_options)
|
|
251
|
+
when :sqlite
|
|
252
|
+
Adapter::Sqlite.new(adapter_options)
|
|
253
|
+
else
|
|
254
|
+
raise ConfigurationError, "Unknown adapter type: #{adapter_type}"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def serialize_entry(entry)
|
|
259
|
+
JSON.generate(entry.to_h)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def deserialize_entry(data)
|
|
263
|
+
hash = JSON.parse(data, symbolize_names: true)
|
|
264
|
+
CacheEntry.from_h(hash)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def should_cleanup?
|
|
268
|
+
Time.now - @last_cleanup > @cleanup_interval
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def evict_if_needed
|
|
272
|
+
return unless @max_size
|
|
273
|
+
return if size < @max_size
|
|
274
|
+
|
|
275
|
+
keys_by_access = @access_times.sort_by { |_, time| time }.map(&:first)
|
|
276
|
+
keys_to_evict = keys_by_access.first(size - @max_size + 1)
|
|
277
|
+
|
|
278
|
+
keys_to_evict.each { |key| delete(key) }
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Store
|
|
5
|
+
class CompositeModelHandler
|
|
6
|
+
Reference = Struct.new(:storage_key, :model_class, :key_value, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
def initialize(registry, store, model_store = nil, serializer:)
|
|
9
|
+
@registry = registry
|
|
10
|
+
@store = store
|
|
11
|
+
@model_store = model_store
|
|
12
|
+
@serializer = serializer
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def process_composite_models(model)
|
|
16
|
+
composite_models = @registry.find_composite_models(model)
|
|
17
|
+
stored_composites = {}
|
|
18
|
+
|
|
19
|
+
composite_models.each do |attr_path, composite_info|
|
|
20
|
+
nested_model = composite_info[:model]
|
|
21
|
+
registration = composite_info[:registration]
|
|
22
|
+
|
|
23
|
+
storage_key = registration.generate_storage_key(nested_model)
|
|
24
|
+
serialized_data = @serializer.serialize(nested_model)
|
|
25
|
+
@store.set(storage_key, serialized_data)
|
|
26
|
+
|
|
27
|
+
stored_composites[attr_path] = Reference.new(
|
|
28
|
+
storage_key: storage_key.to_s,
|
|
29
|
+
model_class: nested_model.class.name,
|
|
30
|
+
key_value: composite_info[:key_value]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
stored_composites
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def restore_composite_models(model, composite_references = nil)
|
|
38
|
+
return model unless composite_references
|
|
39
|
+
|
|
40
|
+
restored_model = model.dup
|
|
41
|
+
|
|
42
|
+
composite_references.each do |attr_path, reference_info|
|
|
43
|
+
ref = if reference_info.is_a?(Hash)
|
|
44
|
+
Reference.new(**reference_info.transform_keys(&:to_sym).slice(
|
|
45
|
+
:storage_key, :model_class, :key_value
|
|
46
|
+
))
|
|
47
|
+
else
|
|
48
|
+
reference_info
|
|
49
|
+
end
|
|
50
|
+
nested_model = restore_nested_model(ref)
|
|
51
|
+
next unless nested_model
|
|
52
|
+
|
|
53
|
+
set_nested_attribute(restored_model, attr_path, nested_model)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
restored_model
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def update_composite_models(model, updates)
|
|
60
|
+
composite_updates = extract_composite_updates(updates)
|
|
61
|
+
return if composite_updates.empty?
|
|
62
|
+
|
|
63
|
+
composite_updates.each do |attr_path, update_value|
|
|
64
|
+
if attr_path.include?(".")
|
|
65
|
+
update_nested_composite(model, attr_path, update_value)
|
|
66
|
+
else
|
|
67
|
+
update_direct_composite(model, attr_path, update_value)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def delete_composite_models(model)
|
|
73
|
+
composite_models = @registry.find_composite_models(model)
|
|
74
|
+
|
|
75
|
+
composite_models.each_value do |composite_info|
|
|
76
|
+
registration = composite_info[:registration]
|
|
77
|
+
storage_key = registration.generate_storage_key(composite_info[:model])
|
|
78
|
+
@store.delete(storage_key)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def restore_nested_model(reference)
|
|
85
|
+
storage_key = reference.storage_key
|
|
86
|
+
model_class_name = reference.model_class
|
|
87
|
+
key_value = reference.key_value
|
|
88
|
+
|
|
89
|
+
if @model_store && key_value
|
|
90
|
+
model_class = Object.const_get(model_class_name)
|
|
91
|
+
registration = @registry.find_registration(model_class)
|
|
92
|
+
if registration
|
|
93
|
+
key_field = registration.key_field
|
|
94
|
+
return @model_store.fetch(model: model_class, key_field => key_value)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
serialized_data = @store.get(storage_key)
|
|
99
|
+
return nil unless serialized_data
|
|
100
|
+
|
|
101
|
+
model_class = Object.const_get(model_class_name)
|
|
102
|
+
@serializer.deserialize(serialized_data, model_class)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def set_nested_attribute(model, attr_path, value)
|
|
106
|
+
attr_path_str = attr_path.to_s
|
|
107
|
+
if attr_path_str.include?(".")
|
|
108
|
+
parts = attr_path_str.split(".")
|
|
109
|
+
current = navigate_to_parent(model, parts[0..-2])
|
|
110
|
+
set_final_attribute(current, parts.last, value)
|
|
111
|
+
else
|
|
112
|
+
model.public_send("#{attr_path}=", value)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def navigate_to_parent(model, path_parts)
|
|
117
|
+
path_parts.reduce(model) do |current, part|
|
|
118
|
+
if part.match?(/\A\d+\z/)
|
|
119
|
+
current[part.to_i]
|
|
120
|
+
else
|
|
121
|
+
current.public_send(part)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def set_final_attribute(current, attr, value)
|
|
127
|
+
if attr.match?(/\A\d+\z/)
|
|
128
|
+
current[attr.to_i] = value
|
|
129
|
+
else
|
|
130
|
+
current.public_send("#{attr}=", value)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def extract_composite_updates(updates)
|
|
135
|
+
updates.each_with_object({}) do |update, result|
|
|
136
|
+
key = update[:key].to_s
|
|
137
|
+
value = update[:value]
|
|
138
|
+
result[key] = value if key.include?(".") || registered_model?(value)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def update_nested_composite(model, attr_path, update_value)
|
|
143
|
+
root_attr = attr_path.split(".").first
|
|
144
|
+
composite_model = model.public_send(root_attr)
|
|
145
|
+
return unless composite_model && @registry.registered?(composite_model.class)
|
|
146
|
+
|
|
147
|
+
set_nested_attribute(composite_model, attr_path.split(".", 2).last, update_value)
|
|
148
|
+
|
|
149
|
+
registration = @registry.registration_for(composite_model.class)
|
|
150
|
+
storage_key = registration.generate_storage_key(composite_model)
|
|
151
|
+
@store.set(storage_key, @serializer.serialize(composite_model))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def update_direct_composite(model, attr_path, new_composite_model)
|
|
155
|
+
return unless registered_model?(new_composite_model)
|
|
156
|
+
|
|
157
|
+
registration = @registry.registration_for(new_composite_model.class)
|
|
158
|
+
storage_key = registration.generate_storage_key(new_composite_model)
|
|
159
|
+
@store.set(storage_key, @serializer.serialize(new_composite_model))
|
|
160
|
+
|
|
161
|
+
model.public_send("#{attr_path}=", new_composite_model)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def registered_model?(value)
|
|
165
|
+
value.is_a?(Object) && @registry.registered?(value.class)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module Lutaml
|
|
7
|
+
module Store
|
|
8
|
+
class Compression
|
|
9
|
+
SUPPORTED_ALGORITHMS = %w[gzip deflate bzip2 lz4 zstd].freeze
|
|
10
|
+
|
|
11
|
+
def self.compress(data, algorithm = "gzip", level = 6)
|
|
12
|
+
case algorithm.to_s.downcase
|
|
13
|
+
when "gzip"
|
|
14
|
+
compress_gzip(data, level)
|
|
15
|
+
when "deflate"
|
|
16
|
+
compress_deflate(data, level)
|
|
17
|
+
when "bzip2"
|
|
18
|
+
compress_bzip2(data, level)
|
|
19
|
+
when "lz4"
|
|
20
|
+
compress_lz4(data)
|
|
21
|
+
when "zstd"
|
|
22
|
+
compress_zstd(data, level)
|
|
23
|
+
else
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"Unsupported compression algorithm: #{algorithm}. Supported: #{SUPPORTED_ALGORITHMS.join(", ")}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.decompress(data, algorithm = "gzip")
|
|
30
|
+
case algorithm.to_s.downcase
|
|
31
|
+
when "gzip"
|
|
32
|
+
decompress_gzip(data)
|
|
33
|
+
when "deflate"
|
|
34
|
+
decompress_deflate(data)
|
|
35
|
+
when "bzip2"
|
|
36
|
+
decompress_bzip2(data)
|
|
37
|
+
when "lz4"
|
|
38
|
+
decompress_lz4(data)
|
|
39
|
+
when "zstd"
|
|
40
|
+
decompress_zstd(data)
|
|
41
|
+
else
|
|
42
|
+
raise ArgumentError,
|
|
43
|
+
"Unsupported compression algorithm: #{algorithm}. Supported: #{SUPPORTED_ALGORITHMS.join(", ")}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.detect_algorithm(data)
|
|
48
|
+
return nil unless data.is_a?(String)
|
|
49
|
+
|
|
50
|
+
# Force binary encoding for magic number detection
|
|
51
|
+
binary_data = data.dup.force_encoding("ASCII-8BIT")
|
|
52
|
+
|
|
53
|
+
# Define magic numbers as binary strings
|
|
54
|
+
gzip_magic = "\x1f\x8b".b
|
|
55
|
+
deflate_magic = "\x78".b
|
|
56
|
+
bzip2_magic = "BZ".b
|
|
57
|
+
lz4_magic = "\x04\"M\x18".b
|
|
58
|
+
zstd_magic = "\x28\xb5\x2f\xfd".b
|
|
59
|
+
|
|
60
|
+
# Check magic numbers
|
|
61
|
+
return "gzip" if binary_data.start_with?(gzip_magic)
|
|
62
|
+
return "deflate" if binary_data.start_with?(deflate_magic)
|
|
63
|
+
return "bzip2" if binary_data.start_with?(bzip2_magic)
|
|
64
|
+
return "lz4" if binary_data.start_with?(lz4_magic)
|
|
65
|
+
return "zstd" if binary_data.start_with?(zstd_magic)
|
|
66
|
+
|
|
67
|
+
nil # No compression detected
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.compress_gzip(data, level)
|
|
71
|
+
io = StringIO.new
|
|
72
|
+
gz = Zlib::GzipWriter.new(io, level)
|
|
73
|
+
gz.write(data)
|
|
74
|
+
gz.close
|
|
75
|
+
io.string
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.decompress_gzip(data)
|
|
79
|
+
io = StringIO.new(data)
|
|
80
|
+
gz = Zlib::GzipReader.new(io)
|
|
81
|
+
result = gz.read
|
|
82
|
+
gz.close
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.compress_deflate(data, level)
|
|
87
|
+
Zlib::Deflate.deflate(data, level)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.decompress_deflate(data)
|
|
91
|
+
Zlib::Inflate.inflate(data)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.compress_bzip2(data, level)
|
|
95
|
+
require "bzip2-ffi"
|
|
96
|
+
Bzip2::FFI.compress(data, level)
|
|
97
|
+
rescue LoadError
|
|
98
|
+
raise ArgumentError, "bzip2-ffi gem is required for bzip2 compression"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.decompress_bzip2(data)
|
|
102
|
+
require "bzip2-ffi"
|
|
103
|
+
Bzip2::FFI.decompress(data)
|
|
104
|
+
rescue LoadError
|
|
105
|
+
raise ArgumentError, "bzip2-ffi gem is required for bzip2 compression"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.compress_lz4(data)
|
|
109
|
+
require "lz4-ruby"
|
|
110
|
+
LZ4.compress(data)
|
|
111
|
+
rescue LoadError
|
|
112
|
+
raise ArgumentError, "lz4-ruby gem is required for LZ4 compression"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.decompress_lz4(data)
|
|
116
|
+
require "lz4-ruby"
|
|
117
|
+
LZ4.decompress(data)
|
|
118
|
+
rescue LoadError
|
|
119
|
+
raise ArgumentError, "lz4-ruby gem is required for LZ4 compression"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.compress_zstd(data, level)
|
|
123
|
+
require "zstd-ruby"
|
|
124
|
+
Zstd.compress(data, level: level)
|
|
125
|
+
rescue LoadError
|
|
126
|
+
raise ArgumentError, "zstd-ruby gem is required for Zstd compression"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.decompress_zstd(data)
|
|
130
|
+
require "zstd-ruby"
|
|
131
|
+
Zstd.decompress(data)
|
|
132
|
+
rescue LoadError
|
|
133
|
+
raise ArgumentError, "zstd-ruby gem is required for Zstd compression"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|