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,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# Enforces code quality rules from CLAUDE.md global instructions.
|
|
6
|
+
# These specs MUST pass — any failure means an anti-pattern was introduced.
|
|
7
|
+
RSpec.describe "Anti-pattern guard" do
|
|
8
|
+
lib_dir = File.expand_path("../../../lib", __dir__)
|
|
9
|
+
rb_files = Dir.glob(File.join(lib_dir, "**", "*.rb"))
|
|
10
|
+
|
|
11
|
+
rb_files.each do |path|
|
|
12
|
+
rel = path.sub("#{lib_dir}/", "")
|
|
13
|
+
|
|
14
|
+
it "#{rel} does not use .send() to call private methods" do
|
|
15
|
+
code = File.read(path).lines.reject { |l| l.strip.start_with?("#") }.join
|
|
16
|
+
code = code.gsub(/#.*$/, "")
|
|
17
|
+
uses = code.scan(/\.send\s*\(/)
|
|
18
|
+
expect(uses).to be_empty, "#{rel} uses .send() — refactor to use a public API"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "#{rel} does not use instance_variable_get or instance_variable_set" do
|
|
22
|
+
code = File.read(path).lines.reject { |l| l.strip.start_with?("#") }.join
|
|
23
|
+
code = code.gsub(/#.*$/, "")
|
|
24
|
+
uses = code.scan(/instance_variable_(get|set)/)
|
|
25
|
+
expect(uses).to be_empty, "#{rel} uses instance_variable_get/set — add a public accessor"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "#{rel} does not use respond_to? for type checking" do
|
|
29
|
+
code = File.read(path).lines.reject { |l| l.strip.start_with?("#") }.join
|
|
30
|
+
code = code.gsub(/#.*$/, "")
|
|
31
|
+
uses = code.scan(/respond_to\?\s*/)
|
|
32
|
+
expect(uses).to be_empty, "#{rel} uses respond_to? — use is_a? or redesign the type hierarchy"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Anti-pattern regression prevention" do
|
|
6
|
+
let(:lib_dir) { File.expand_path("../../lib/lutaml/store", __dir__) }
|
|
7
|
+
|
|
8
|
+
def ruby_files
|
|
9
|
+
Dir.glob(File.join(lib_dir, "**", "*.rb"))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def file_contents
|
|
13
|
+
ruby_files.to_h { |f| [f, File.read(f)] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe "no respond_to? in lib code" do
|
|
17
|
+
it "contains zero respond_to? calls" do
|
|
18
|
+
offenders = file_contents.filter_map do |path, content|
|
|
19
|
+
matches = content.scan(/^.*respond_to\?.*$/)
|
|
20
|
+
next if matches.empty?
|
|
21
|
+
next if matches.all? { |line| line.strip.start_with?("#") }
|
|
22
|
+
|
|
23
|
+
rel = path.sub(%r{.*lib/}, "")
|
|
24
|
+
count = matches.count { |line| !line.strip.start_with?("#") }
|
|
25
|
+
"#{rel}: #{count} occurrence(s)"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
expect(offenders).to be_empty,
|
|
29
|
+
"Found respond_to? in lib code:\n#{offenders.join("\n")}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe "no instance_variable_get/set in lib code" do
|
|
34
|
+
it "contains zero instance_variable_get calls" do
|
|
35
|
+
offenders = file_contents.filter_map do |path, content|
|
|
36
|
+
matches = content.scan(/^.*instance_variable_get.*$/)
|
|
37
|
+
next if matches.empty?
|
|
38
|
+
next if matches.all? { |line| line.strip.start_with?("#") }
|
|
39
|
+
|
|
40
|
+
rel = path.sub(%r{.*lib/}, "")
|
|
41
|
+
"#{rel}: #{matches.size} occurrence(s)"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
expect(offenders).to be_empty,
|
|
45
|
+
"Found instance_variable_get in lib code:\n#{offenders.join("\n")}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "contains zero instance_variable_set calls" do
|
|
49
|
+
offenders = file_contents.filter_map do |path, content|
|
|
50
|
+
matches = content.scan(/^.*instance_variable_set.*$/)
|
|
51
|
+
next if matches.empty?
|
|
52
|
+
next if matches.all? { |line| line.strip.start_with?("#") }
|
|
53
|
+
|
|
54
|
+
rel = path.sub(%r{.*lib/}, "")
|
|
55
|
+
"#{rel}: #{matches.size} occurrence(s)"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
expect(offenders).to be_empty,
|
|
59
|
+
"Found instance_variable_set in lib code:\n#{offenders.join("\n")}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe "no send on private methods in lib code" do
|
|
64
|
+
it "contains zero .send( calls" do
|
|
65
|
+
offenders = file_contents.filter_map do |path, content|
|
|
66
|
+
matches = content.scan(/^.*\.send\(.*$/)
|
|
67
|
+
next if matches.empty?
|
|
68
|
+
next if matches.all? { |line| line.strip.start_with?("#") }
|
|
69
|
+
|
|
70
|
+
rel = path.sub(%r{.*lib/}, "")
|
|
71
|
+
"#{rel}: #{matches.size} occurrence(s)"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
expect(offenders).to be_empty,
|
|
75
|
+
"Found .send( in lib code:\n#{offenders.join("\n")}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Autoload" do
|
|
6
|
+
it "loads BasicStore without sqlite3" do
|
|
7
|
+
store = Lutaml::Store::BasicStore.new(adapter_type: :memory)
|
|
8
|
+
store.set("key", "value")
|
|
9
|
+
expect(store.get("key")).to eq("value")
|
|
10
|
+
|
|
11
|
+
sqlite_loaded = $LOADED_FEATURES.any? { |path| path.include?("sqlite3") }
|
|
12
|
+
expect(sqlite_loaded).to be false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "loads all constants lazily" do
|
|
16
|
+
# After requiring lutaml/store, only error classes and autoload entries exist
|
|
17
|
+
expect(Lutaml::Store::Error).to be_a(Class)
|
|
18
|
+
expect(Lutaml::Store::ConfigurationError).to be_a(Class)
|
|
19
|
+
expect(Lutaml::Store::BackendError).to be_a(Class)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "loads DatabaseStore on first use" do
|
|
23
|
+
expect(defined?(Lutaml::Store::DatabaseStore)).to eq("constant")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "loads Adapter::Base and subclasses on first reference" do
|
|
27
|
+
expect(defined?(Lutaml::Store::Adapter::Base)).to eq("constant")
|
|
28
|
+
expect(defined?(Lutaml::Store::Adapter::Memory)).to eq("constant")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "loads BasicStore lazily" do
|
|
32
|
+
expect(defined?(Lutaml::Store::BasicStore)).to eq("constant")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Lutaml::Store::CacheStore do
|
|
6
|
+
let(:config) do
|
|
7
|
+
{
|
|
8
|
+
adapter: {
|
|
9
|
+
type: :memory
|
|
10
|
+
},
|
|
11
|
+
default_ttl: 60,
|
|
12
|
+
max_size: 100,
|
|
13
|
+
cleanup_interval: 10
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
let(:cache) { described_class.new(config) }
|
|
17
|
+
|
|
18
|
+
describe "CacheEntry" do
|
|
19
|
+
let(:entry) { described_class::CacheEntry.new("value", ttl: 60, metadata: { source: "test" }) }
|
|
20
|
+
|
|
21
|
+
it "tracks creation time" do
|
|
22
|
+
expect(entry.created_at).to be_within(1).of(Time.now)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "calculates expiration" do
|
|
26
|
+
expect(entry.expired?).to be false
|
|
27
|
+
|
|
28
|
+
# Simulate time passing
|
|
29
|
+
allow(Time).to receive(:now).and_return(entry.created_at + 61)
|
|
30
|
+
expect(entry.expired?).to be true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "calculates expires_at" do
|
|
34
|
+
expect(entry.expires_at).to be_within(1).of(entry.created_at + 60)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "serializes to hash" do
|
|
38
|
+
hash = entry.to_h
|
|
39
|
+
expect(hash).to include(:value, :created_at, :ttl, :expires_at, :metadata)
|
|
40
|
+
expect(hash[:value]).to eq("value")
|
|
41
|
+
expect(hash[:ttl]).to eq(60)
|
|
42
|
+
expect(hash[:metadata]).to eq({ source: "test" })
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "deserializes from hash" do
|
|
46
|
+
hash = entry.to_h
|
|
47
|
+
restored = described_class::CacheEntry.from_h(hash)
|
|
48
|
+
|
|
49
|
+
expect(restored.value).to eq(entry.value)
|
|
50
|
+
expect(restored.ttl).to eq(entry.ttl)
|
|
51
|
+
expect(restored.metadata).to eq(entry.metadata)
|
|
52
|
+
expect(restored.created_at).to be_within(1).of(entry.created_at)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe "#set and #get" do
|
|
57
|
+
it "stores and retrieves values" do
|
|
58
|
+
cache.set("key1", "value1")
|
|
59
|
+
expect(cache.get("key1")).to eq("value1")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it "returns nil for non-existent keys" do
|
|
63
|
+
expect(cache.get("nonexistent")).to be_nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "respects TTL" do
|
|
67
|
+
cache.set("key1", "value1", ttl: 1)
|
|
68
|
+
expect(cache.get("key1")).to eq("value1")
|
|
69
|
+
|
|
70
|
+
# Simulate time passing
|
|
71
|
+
allow(Time).to receive(:now).and_return(Time.now + 2)
|
|
72
|
+
expect(cache.get("key1")).to be_nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "uses default TTL when not specified" do
|
|
76
|
+
cache.set("key1", "value1")
|
|
77
|
+
expect(cache.ttl("key1")).to be_within(5).of(60)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "stores metadata" do
|
|
81
|
+
cache.set("key1", "value1", metadata: { source: "api" })
|
|
82
|
+
|
|
83
|
+
entry_data = cache.adapter.get("key1")
|
|
84
|
+
parsed = JSON.parse(entry_data, symbolize_names: true)
|
|
85
|
+
expect(parsed[:metadata]).to eq({ source: "api" })
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
describe "#exists?" do
|
|
90
|
+
it "returns true for existing non-expired keys" do
|
|
91
|
+
cache.set("key1", "value1")
|
|
92
|
+
expect(cache.exists?("key1")).to be true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "returns false for non-existent keys" do
|
|
96
|
+
expect(cache.exists?("nonexistent")).to be false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "returns false for expired keys" do
|
|
100
|
+
cache.set("key1", "value1", ttl: 1)
|
|
101
|
+
|
|
102
|
+
# Simulate time passing
|
|
103
|
+
allow(Time).to receive(:now).and_return(Time.now + 2)
|
|
104
|
+
expect(cache.exists?("key1")).to be false
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
describe "#ttl" do
|
|
109
|
+
it "returns remaining TTL" do
|
|
110
|
+
cache.set("key1", "value1", ttl: 60)
|
|
111
|
+
expect(cache.ttl("key1")).to be_within(5).of(60)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "returns nil for keys without TTL" do
|
|
115
|
+
cache.set("key1", "value1", ttl: nil)
|
|
116
|
+
expect(cache.ttl("key1")).to be_nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "returns nil for expired keys" do
|
|
120
|
+
cache.set("key1", "value1", ttl: 1)
|
|
121
|
+
|
|
122
|
+
# Simulate time passing
|
|
123
|
+
allow(Time).to receive(:now).and_return(Time.now + 2)
|
|
124
|
+
expect(cache.ttl("key1")).to be_nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
describe "#touch" do
|
|
129
|
+
it "updates TTL for existing key" do
|
|
130
|
+
cache.set("key1", "value1", ttl: 60)
|
|
131
|
+
cache.ttl("key1")
|
|
132
|
+
|
|
133
|
+
# Simulate some time passing
|
|
134
|
+
allow(Time).to receive(:now).and_return(Time.now + 30)
|
|
135
|
+
|
|
136
|
+
expect(cache.touch("key1", ttl: 120)).to be true
|
|
137
|
+
expect(cache.ttl("key1")).to be_within(5).of(120)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "returns false for non-existent keys" do
|
|
141
|
+
expect(cache.touch("nonexistent")).to be false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "returns false for expired keys" do
|
|
145
|
+
cache.set("key1", "value1", ttl: 1)
|
|
146
|
+
|
|
147
|
+
# Simulate time passing
|
|
148
|
+
allow(Time).to receive(:now).and_return(Time.now + 2)
|
|
149
|
+
expect(cache.touch("key1")).to be false
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
describe "#fetch" do
|
|
154
|
+
it "returns existing value" do
|
|
155
|
+
cache.set("key1", "value1")
|
|
156
|
+
|
|
157
|
+
result = cache.fetch("key1") { "new_value" }
|
|
158
|
+
expect(result).to eq("value1")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "executes block for missing key" do
|
|
162
|
+
result = cache.fetch("key1") { "new_value" }
|
|
163
|
+
expect(result).to eq("new_value")
|
|
164
|
+
expect(cache.get("key1")).to eq("new_value")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it "returns nil for missing key without block" do
|
|
168
|
+
result = cache.fetch("key1")
|
|
169
|
+
expect(result).to be_nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "respects TTL in fetch" do
|
|
173
|
+
result = cache.fetch("key1", ttl: 30) { "new_value" }
|
|
174
|
+
expect(result).to eq("new_value")
|
|
175
|
+
expect(cache.ttl("key1")).to be_within(5).of(30)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
describe "#cleanup_expired" do
|
|
180
|
+
it "removes expired entries" do
|
|
181
|
+
cache.set("key1", "value1", ttl: 1)
|
|
182
|
+
cache.set("key2", "value2", ttl: 60)
|
|
183
|
+
|
|
184
|
+
# Simulate time passing
|
|
185
|
+
allow(Time).to receive(:now).and_return(Time.now + 2)
|
|
186
|
+
|
|
187
|
+
expired_count = cache.cleanup_expired
|
|
188
|
+
expect(expired_count).to eq(1)
|
|
189
|
+
expect(cache.exists?("key1")).to be false
|
|
190
|
+
expect(cache.exists?("key2")).to be true
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
describe "#cache_info" do
|
|
195
|
+
it "provides cache statistics" do
|
|
196
|
+
cache.set("key1", "value1", ttl: 1)
|
|
197
|
+
cache.set("key2", "value2", ttl: 60)
|
|
198
|
+
|
|
199
|
+
# Simulate time passing to expire one entry
|
|
200
|
+
allow(Time).to receive(:now).and_return(Time.now + 2)
|
|
201
|
+
|
|
202
|
+
info = cache.cache_info
|
|
203
|
+
expect(info).to include(:total_entries, :valid_entries, :expired_entries, :max_size, :default_ttl)
|
|
204
|
+
expect(info[:total_entries]).to eq(2)
|
|
205
|
+
expect(info[:valid_entries]).to eq(1)
|
|
206
|
+
expect(info[:expired_entries]).to eq(1)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
describe "LRU eviction" do
|
|
211
|
+
let(:small_cache) { described_class.new(config.merge(max_size: 2)) }
|
|
212
|
+
|
|
213
|
+
it "evicts least recently used entries when max size is reached" do
|
|
214
|
+
small_cache.set("key1", "value1")
|
|
215
|
+
small_cache.set("key2", "value2")
|
|
216
|
+
|
|
217
|
+
# Access key1 to make it more recently used
|
|
218
|
+
small_cache.get("key1")
|
|
219
|
+
|
|
220
|
+
# Adding key3 should evict key2 (least recently used)
|
|
221
|
+
small_cache.set("key3", "value3")
|
|
222
|
+
|
|
223
|
+
expect(small_cache.exists?("key1")).to be true
|
|
224
|
+
expect(small_cache.exists?("key2")).to be false
|
|
225
|
+
expect(small_cache.exists?("key3")).to be true
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
describe "#clear" do
|
|
230
|
+
it "removes all entries" do
|
|
231
|
+
cache.set("key1", "value1")
|
|
232
|
+
cache.set("key2", "value2")
|
|
233
|
+
|
|
234
|
+
cache.clear
|
|
235
|
+
|
|
236
|
+
expect(cache.size).to eq(0)
|
|
237
|
+
expect(cache.exists?("key1")).to be false
|
|
238
|
+
expect(cache.exists?("key2")).to be false
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
describe "#delete" do
|
|
243
|
+
it "removes specific entry" do
|
|
244
|
+
cache.set("key1", "value1")
|
|
245
|
+
cache.set("key2", "value2")
|
|
246
|
+
|
|
247
|
+
deleted_value = cache.delete("key1")
|
|
248
|
+
|
|
249
|
+
expect(deleted_value).to eq("value1")
|
|
250
|
+
expect(cache.exists?("key1")).to be false
|
|
251
|
+
expect(cache.exists?("key2")).to be true
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
describe "automatic cleanup" do
|
|
256
|
+
let(:auto_cache) { described_class.new(config.merge(cleanup_interval: 1)) }
|
|
257
|
+
|
|
258
|
+
it "automatically cleans up expired entries" do
|
|
259
|
+
auto_cache.set("key1", "value1", ttl: 1)
|
|
260
|
+
|
|
261
|
+
# Simulate time passing beyond cleanup interval and TTL
|
|
262
|
+
allow(Time).to receive(:now).and_return(Time.now + 2)
|
|
263
|
+
|
|
264
|
+
# Next operation should trigger cleanup
|
|
265
|
+
auto_cache.set("key2", "value2")
|
|
266
|
+
|
|
267
|
+
expect(auto_cache.exists?("key1")).to be false
|
|
268
|
+
expect(auto_cache.exists?("key2")).to be true
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Lutaml::Store::Compression do
|
|
6
|
+
describe ".compress and .decompress" do
|
|
7
|
+
let(:test_data) { "Hello, World! This is a test string for compression." }
|
|
8
|
+
|
|
9
|
+
context "with gzip compression" do
|
|
10
|
+
it "compresses and decompresses data correctly" do
|
|
11
|
+
compressed = described_class.compress(test_data, "gzip")
|
|
12
|
+
expect(compressed).not_to eq(test_data)
|
|
13
|
+
# NOTE: For small strings, compression might actually increase size due to headers
|
|
14
|
+
# The important thing is that decompression works correctly
|
|
15
|
+
|
|
16
|
+
decompressed = described_class.decompress(compressed, "gzip")
|
|
17
|
+
expect(decompressed).to eq(test_data)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "supports different compression levels" do
|
|
21
|
+
compressed_1 = described_class.compress(test_data, "gzip", 1)
|
|
22
|
+
compressed_9 = described_class.compress(test_data, "gzip", 9)
|
|
23
|
+
|
|
24
|
+
expect(compressed_1.length).to be >= compressed_9.length
|
|
25
|
+
expect(described_class.decompress(compressed_1, "gzip")).to eq(test_data)
|
|
26
|
+
expect(described_class.decompress(compressed_9, "gzip")).to eq(test_data)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
context "with deflate compression" do
|
|
31
|
+
it "compresses and decompresses data correctly" do
|
|
32
|
+
compressed = described_class.compress(test_data, "deflate")
|
|
33
|
+
expect(compressed).not_to eq(test_data)
|
|
34
|
+
|
|
35
|
+
decompressed = described_class.decompress(compressed, "deflate")
|
|
36
|
+
expect(decompressed).to eq(test_data)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
context "with unsupported algorithm" do
|
|
41
|
+
it "raises an error for unsupported compression algorithm" do
|
|
42
|
+
expect do
|
|
43
|
+
described_class.compress(test_data, "unsupported")
|
|
44
|
+
end.to raise_error(ArgumentError, /Unsupported compression algorithm/)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "raises an error for unsupported decompression algorithm" do
|
|
48
|
+
expect do
|
|
49
|
+
described_class.decompress(test_data, "unsupported")
|
|
50
|
+
end.to raise_error(ArgumentError, /Unsupported compression algorithm/)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe ".detect_algorithm" do
|
|
56
|
+
let(:test_data) { "Hello, World!" }
|
|
57
|
+
|
|
58
|
+
it "detects gzip compression" do
|
|
59
|
+
compressed = described_class.compress(test_data, "gzip")
|
|
60
|
+
expect(described_class.detect_algorithm(compressed)).to eq("gzip")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "detects deflate compression" do
|
|
64
|
+
compressed = described_class.compress(test_data, "deflate")
|
|
65
|
+
expect(described_class.detect_algorithm(compressed)).to eq("deflate")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "returns nil for uncompressed data" do
|
|
69
|
+
expect(described_class.detect_algorithm(test_data)).to be_nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe "SUPPORTED_ALGORITHMS" do
|
|
74
|
+
it "includes expected algorithms" do
|
|
75
|
+
expect(described_class::SUPPORTED_ALGORITHMS).to include("gzip", "deflate", "bzip2", "lz4", "zstd")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Lutaml::Store::Config do
|
|
6
|
+
describe "enhanced configuration features" do
|
|
7
|
+
let(:enhanced_config) do
|
|
8
|
+
{
|
|
9
|
+
adapter_type: :memory,
|
|
10
|
+
adapter_options: {},
|
|
11
|
+
cache: {
|
|
12
|
+
enabled: true,
|
|
13
|
+
max_size: 500,
|
|
14
|
+
ttl: 3600
|
|
15
|
+
},
|
|
16
|
+
monitoring: {
|
|
17
|
+
enabled: true
|
|
18
|
+
},
|
|
19
|
+
events: {
|
|
20
|
+
async: true
|
|
21
|
+
},
|
|
22
|
+
serialization: {
|
|
23
|
+
formats: %w[marshal json yaml],
|
|
24
|
+
validate_on_write: true
|
|
25
|
+
},
|
|
26
|
+
compression: {
|
|
27
|
+
enabled: true,
|
|
28
|
+
algorithm: "gzip",
|
|
29
|
+
level: 9
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
subject { described_class.new(**enhanced_config) }
|
|
35
|
+
|
|
36
|
+
describe "serialization configuration" do
|
|
37
|
+
it "provides access to serialization formats" do
|
|
38
|
+
expect(subject.serialization_formats).to eq(%w[marshal json yaml])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "provides access to validate_on_write setting" do
|
|
42
|
+
expect(subject.validate_on_write?).to be true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
context "with default configuration" do
|
|
46
|
+
subject { described_class.new }
|
|
47
|
+
|
|
48
|
+
it "has default serialization formats" do
|
|
49
|
+
expect(subject.serialization_formats).to include("marshal", "hash", "json", "yaml", "xml", "toml")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "has validate_on_write disabled by default" do
|
|
53
|
+
expect(subject.validate_on_write?).to be false
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe "compression configuration" do
|
|
59
|
+
it "provides access to compression enabled setting" do
|
|
60
|
+
expect(subject.compression_enabled?).to be true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "provides access to compression algorithm" do
|
|
64
|
+
expect(subject.compression_algorithm).to eq("gzip")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "provides access to compression level" do
|
|
68
|
+
expect(subject.compression_level).to eq(9)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
context "with default configuration" do
|
|
72
|
+
subject { described_class.new }
|
|
73
|
+
|
|
74
|
+
it "has compression disabled by default" do
|
|
75
|
+
expect(subject.compression_enabled?).to be false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "has default compression algorithm" do
|
|
79
|
+
expect(subject.compression_algorithm).to eq("gzip")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "has default compression level" do
|
|
83
|
+
expect(subject.compression_level).to eq(6)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
describe "configuration merging" do
|
|
89
|
+
it "merges partial configuration with defaults" do
|
|
90
|
+
config = described_class.new(compression: { enabled: true })
|
|
91
|
+
expect(config.compression_enabled?).to be true
|
|
92
|
+
expect(config.compression_algorithm).to eq("gzip") # default
|
|
93
|
+
expect(config.compression_level).to eq(6) # default
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
describe "YAML configuration loading" do
|
|
98
|
+
let(:yaml_config) do
|
|
99
|
+
<<~YAML
|
|
100
|
+
lutaml_store:
|
|
101
|
+
adapter:
|
|
102
|
+
type: filesystem
|
|
103
|
+
options:
|
|
104
|
+
path: /tmp/test_store
|
|
105
|
+
compression:
|
|
106
|
+
enabled: true
|
|
107
|
+
algorithm: deflate
|
|
108
|
+
level: 3
|
|
109
|
+
serialization:
|
|
110
|
+
validate_on_write: true
|
|
111
|
+
formats:
|
|
112
|
+
- json
|
|
113
|
+
- yaml
|
|
114
|
+
YAML
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "loads enhanced configuration from YAML" do
|
|
118
|
+
config = described_class.from_yaml(yaml_config)
|
|
119
|
+
|
|
120
|
+
expect(config.adapter_type).to eq(:filesystem)
|
|
121
|
+
expect(config.adapter_options[:path]).to eq("/tmp/test_store")
|
|
122
|
+
expect(config.compression_enabled?).to be true
|
|
123
|
+
expect(config.compression_algorithm).to eq("deflate")
|
|
124
|
+
expect(config.compression_level).to eq(3)
|
|
125
|
+
expect(config.validate_on_write?).to be true
|
|
126
|
+
expect(config.serialization_formats).to eq(%w[json yaml])
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
describe "#to_h" do
|
|
131
|
+
it "includes all configuration sections" do
|
|
132
|
+
hash = subject.to_h
|
|
133
|
+
|
|
134
|
+
expect(hash).to have_key(:adapter)
|
|
135
|
+
expect(hash).to have_key(:cache)
|
|
136
|
+
expect(hash).to have_key(:monitoring)
|
|
137
|
+
expect(hash).to have_key(:events)
|
|
138
|
+
# NOTE: to_h method needs to be updated to include new sections
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
describe "configuration validation" do
|
|
144
|
+
context "with invalid compression settings" do
|
|
145
|
+
it "validates compression algorithm" do
|
|
146
|
+
# This would require adding validation for compression settings
|
|
147
|
+
# in the Config class validate! method
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
context "with invalid serialization settings" do
|
|
152
|
+
it "validates serialization formats" do
|
|
153
|
+
# This would require adding validation for serialization settings
|
|
154
|
+
# in the Config class validate! method
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|