mudis-ql 0.1.0

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.
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MudisQL
4
+ # MetricsScope provides queryable access to mudis metrics data
5
+ class MetricsScope
6
+ attr_reader :metrics_data
7
+
8
+ def initialize
9
+ @metrics_data = Mudis.metrics
10
+ @conditions = []
11
+ @order_by = nil
12
+ @order_direction = :asc
13
+ @limit_value = nil
14
+ @offset_value = 0
15
+ end
16
+
17
+ # Get top-level metrics (hits, misses, evictions, etc.)
18
+ #
19
+ # @return [Hash] top-level metrics
20
+ def summary
21
+ @metrics_data.reject { |k, _| [:least_touched, :buckets].include?(k) }
22
+ end
23
+
24
+ # Query least touched keys
25
+ #
26
+ # @return [Scope] a new scope for least_touched array
27
+ def least_touched
28
+ data = @metrics_data[:least_touched] || []
29
+ create_scope_for_array(data.map { |key, count| { key: key, access_count: count } })
30
+ end
31
+
32
+ # Query bucket metrics
33
+ #
34
+ # @return [Scope] a new scope for buckets array
35
+ def buckets
36
+ data = @metrics_data[:buckets] || []
37
+ # Normalize to symbol keys
38
+ normalized = data.map do |bucket|
39
+ bucket.transform_keys(&:to_sym)
40
+ end
41
+ create_scope_for_array(normalized)
42
+ end
43
+
44
+ # Get total number of keys across all buckets
45
+ #
46
+ # @return [Integer] total key count
47
+ def total_keys
48
+ buckets_data = @metrics_data[:buckets] || []
49
+ buckets_data.sum { |b| (b[:keys] || b["keys"] || 0) }
50
+ end
51
+
52
+ # Get total memory across all buckets
53
+ #
54
+ # @return [Integer] total memory in bytes
55
+ def total_memory
56
+ @metrics_data[:total_memory] || 0
57
+ end
58
+
59
+ # Get hit rate as a percentage
60
+ #
61
+ # @return [Float] hit rate percentage
62
+ def hit_rate
63
+ hits = @metrics_data[:hits] || 0
64
+ misses = @metrics_data[:misses] || 0
65
+ total = hits + misses
66
+ return 0.0 if total.zero?
67
+
68
+ (hits.to_f / total * 100).round(2)
69
+ end
70
+
71
+ # Get cache efficiency metrics
72
+ #
73
+ # @return [Hash] efficiency statistics
74
+ def efficiency
75
+ {
76
+ hit_rate: hit_rate,
77
+ miss_rate: (100 - hit_rate).round(2),
78
+ eviction_rate: eviction_rate,
79
+ rejection_rate: rejection_rate
80
+ }
81
+ end
82
+
83
+ # Find buckets with high memory usage
84
+ #
85
+ # @param threshold [Integer] memory threshold in bytes
86
+ # @return [Array<Hash>] buckets exceeding threshold
87
+ def high_memory_buckets(threshold)
88
+ buckets_data = @metrics_data[:buckets] || []
89
+ buckets_data.select { |b| ((b[:memory_bytes] || b["memory_bytes"]) || 0) > threshold }
90
+ end
91
+
92
+ # Find buckets with many keys
93
+ #
94
+ # @param threshold [Integer] key count threshold
95
+ # @return [Array<Hash>] buckets exceeding threshold
96
+ def high_key_buckets(threshold)
97
+ buckets_data = @metrics_data[:buckets] || []
98
+ buckets_data.select { |b| ((b[:keys] || b["keys"]) || 0) > threshold }
99
+ end
100
+
101
+ # Get bucket distribution statistics
102
+ #
103
+ # @return [Hash] distribution stats
104
+ def bucket_distribution
105
+ buckets_data = @metrics_data[:buckets] || []
106
+ return default_distribution if buckets_data.empty?
107
+
108
+ key_counts = buckets_data.map { |b| (b[:keys] || b["keys"]) || 0 }
109
+ memory_values = buckets_data.map { |b| (b[:memory_bytes] || b["memory_bytes"]) || 0 }
110
+
111
+ {
112
+ total_buckets: buckets_data.size,
113
+ avg_keys_per_bucket: (key_counts.sum.to_f / buckets_data.size).round(2),
114
+ max_keys_per_bucket: key_counts.max || 0,
115
+ min_keys_per_bucket: key_counts.min || 0,
116
+ avg_memory_per_bucket: (memory_values.sum.to_f / buckets_data.size).round(2),
117
+ max_memory_per_bucket: memory_values.max || 0,
118
+ min_memory_per_bucket: memory_values.min || 0
119
+ }
120
+ end
121
+
122
+ # Get keys that have never been accessed
123
+ #
124
+ # @return [Array<String>] keys with 0 access count
125
+ def never_accessed_keys
126
+ data = @metrics_data[:least_touched] || []
127
+ data.select { |_key, count| count.zero? }.map(&:first)
128
+ end
129
+
130
+ # Refresh metrics data
131
+ #
132
+ # @return [MetricsScope] self
133
+ def refresh
134
+ @metrics_data = Mudis.metrics
135
+ self
136
+ end
137
+
138
+ private
139
+
140
+ def eviction_rate
141
+ evictions = @metrics_data[:evictions] || 0
142
+ total_ops = (@metrics_data[:hits] || 0) + (@metrics_data[:misses] || 0)
143
+ return 0.0 if total_ops.zero?
144
+
145
+ (evictions.to_f / total_ops * 100).round(2)
146
+ end
147
+
148
+ def rejection_rate
149
+ rejected = @metrics_data[:rejected] || 0
150
+ total_ops = (@metrics_data[:hits] || 0) + (@metrics_data[:misses] || 0)
151
+ return 0.0 if total_ops.zero?
152
+
153
+ (rejected.to_f / total_ops * 100).round(2)
154
+ end
155
+
156
+ def create_scope_for_array(data)
157
+ # Create a temporary store-like object for the array data
158
+ temp_store = Object.new
159
+ temp_store.define_singleton_method(:all) { data }
160
+ Scope.new(temp_store)
161
+ end
162
+
163
+ def default_distribution
164
+ {
165
+ total_buckets: 0,
166
+ avg_keys_per_bucket: 0.0,
167
+ max_keys_per_bucket: 0,
168
+ min_keys_per_bucket: 0,
169
+ avg_memory_per_bucket: 0.0,
170
+ max_memory_per_bucket: 0,
171
+ min_memory_per_bucket: 0
172
+ }
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MudisQL
4
+ # Scope provides a chainable DSL for querying mudis cache data
5
+ class Scope
6
+ attr_reader :store
7
+
8
+ def initialize(store)
9
+ @store = store
10
+ @conditions = []
11
+ @order_by = nil
12
+ @order_direction = :asc
13
+ @limit_value = nil
14
+ @offset_value = 0
15
+ end
16
+
17
+ # Create a new independent scope instance
18
+ def clone
19
+ cloned = self.class.new(@store)
20
+ cloned.instance_variable_set(:@conditions, @conditions.dup)
21
+ cloned.instance_variable_set(:@order_by, @order_by)
22
+ cloned.instance_variable_set(:@order_direction, @order_direction)
23
+ cloned.instance_variable_set(:@limit_value, @limit_value)
24
+ cloned.instance_variable_set(:@offset_value, @offset_value)
25
+ cloned
26
+ end
27
+
28
+ # Filter records by conditions
29
+ #
30
+ # @param conditions [Hash] hash of field => value or field => proc
31
+ # @return [Scope] self for chaining
32
+ #
33
+ # @example
34
+ # query.where(status: "active")
35
+ # query.where(age: ->(v) { v > 18 })
36
+ # query.where(name: /^A/)
37
+ def where(conditions)
38
+ @conditions = @conditions.dup unless @conditions.frozen?
39
+ @conditions << conditions
40
+ self
41
+ end
42
+
43
+ # Order results by a field
44
+ #
45
+ # @param field [Symbol, String] the field to order by
46
+ # @param direction [Symbol] :asc or :desc
47
+ # @return [Scope] self for chaining
48
+ def order(field, direction = :asc)
49
+ @order_by = field.to_s
50
+ @order_direction = direction
51
+ self
52
+ end
53
+
54
+ # Limit the number of results
55
+ #
56
+ # @param value [Integer] maximum number of results
57
+ # @return [Scope] self for chaining
58
+ def limit(value)
59
+ @limit_value = value
60
+ self
61
+ end
62
+
63
+ # Skip the first N results
64
+ #
65
+ # @param value [Integer] number of results to skip
66
+ # @return [Scope] self for chaining
67
+ def offset(value)
68
+ @offset_value = value
69
+ self
70
+ end
71
+
72
+ # Execute the query and return all matching records
73
+ #
74
+ # @return [Array<Hash>] array of matching records
75
+ def all
76
+ results = store.all
77
+ results = apply_conditions(results)
78
+ results = apply_order(results)
79
+ results = apply_pagination(results)
80
+ results
81
+ end
82
+
83
+ # Execute query and return first result
84
+ #
85
+ # @return [Hash, nil] first matching record or nil
86
+ def first
87
+ limit(1).all.first
88
+ end
89
+
90
+ # Execute query and return last result
91
+ #
92
+ # @return [Hash, nil] last matching record or nil
93
+ def last
94
+ all.last
95
+ end
96
+
97
+ # Count matching records
98
+ #
99
+ # @return [Integer] number of matching records
100
+ def count
101
+ apply_conditions(store.all).size
102
+ end
103
+
104
+ # Check if any records match
105
+ #
106
+ # @return [Boolean] true if at least one record matches
107
+ def exists?
108
+ count > 0
109
+ end
110
+
111
+ # Pluck specific fields from results
112
+ #
113
+ # @param fields [Array<Symbol, String>] fields to extract
114
+ # @return [Array] array of field values or arrays of values
115
+ def pluck(*fields)
116
+ fields = fields.map(&:to_s)
117
+ results = all
118
+
119
+ if fields.size == 1
120
+ results.map { |record| record[fields.first] }
121
+ else
122
+ results.map { |record| fields.map { |f| record[f] } }
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def apply_conditions(results)
129
+ @conditions.reduce(results) do |filtered, condition|
130
+ filtered.select do |record|
131
+ condition.all? do |key, matcher|
132
+ field = key.to_s
133
+ value = record[field]
134
+
135
+ match_condition?(value, matcher)
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ def match_condition?(value, matcher)
142
+ case matcher
143
+ when Proc
144
+ matcher.call(value)
145
+ when Regexp
146
+ value.to_s =~ matcher
147
+ when Range
148
+ matcher.include?(value)
149
+ else
150
+ value == matcher
151
+ end
152
+ end
153
+
154
+ def apply_order(results)
155
+ return results unless @order_by
156
+
157
+ begin
158
+ sorted = results.sort_by do |record|
159
+ val = record[@order_by]
160
+ # Handle nil values and type-safe comparison
161
+ if val.nil?
162
+ [@order_direction == :asc ? 1 : -1, ""]
163
+ elsif val.is_a?(Numeric)
164
+ [0, val]
165
+ else
166
+ [0, val.to_s]
167
+ end
168
+ end
169
+
170
+ @order_direction == :desc ? sorted.reverse : sorted
171
+ rescue ArgumentError => e
172
+ # If sorting fails, return unsorted results
173
+ results
174
+ end
175
+ end
176
+
177
+ def apply_pagination(results)
178
+ offset = [@offset_value, 0].max
179
+ results = results.drop(offset) if offset > 0
180
+ results = results.take(@limit_value) if @limit_value && @limit_value >= 0
181
+ results
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mudis"
4
+
5
+ module MudisQL
6
+ # Store wraps mudis operations for a specific namespace
7
+ class Store
8
+ attr_reader :namespace
9
+
10
+ def initialize(namespace = nil)
11
+ @namespace = namespace
12
+ end
13
+
14
+ # Retrieve all keys from the namespace
15
+ #
16
+ # @return [Array<String>] array of keys
17
+ def keys
18
+ if namespace
19
+ Mudis.keys(namespace: namespace) || []
20
+ else
21
+ # When no namespace, mudis uses default internal namespace
22
+ # We need to call without the namespace parameter
23
+ [] # mudis doesn't support listing keys without namespace
24
+ end
25
+ end
26
+
27
+ # Read a value from the cache
28
+ #
29
+ # @param key [String] the key to read
30
+ # @return [Object, nil] the cached value or nil
31
+ def read(key)
32
+ if namespace
33
+ Mudis.read(key, namespace: namespace)
34
+ else
35
+ Mudis.read(key)
36
+ end
37
+ end
38
+
39
+ # Write a value to the cache
40
+ #
41
+ # @param key [String] the key to write
42
+ # @param value [Object] the value to store
43
+ # @param expires_in [Integer, nil] optional TTL in seconds
44
+ def write(key, value, expires_in: nil)
45
+ if namespace
46
+ Mudis.write(key, value, expires_in: expires_in, namespace: namespace)
47
+ else
48
+ Mudis.write(key, value, expires_in: expires_in)
49
+ end
50
+ end
51
+
52
+ # Delete a key from the cache
53
+ #
54
+ # @param key [String] the key to delete
55
+ def delete(key)
56
+ if namespace
57
+ Mudis.delete(key, namespace: namespace)
58
+ else
59
+ Mudis.delete(key)
60
+ end
61
+ end
62
+
63
+ # Retrieve all records from the namespace
64
+ #
65
+ # @return [Array<Hash>] array of records with their keys
66
+ def all
67
+ keys.map do |key|
68
+ value = read(key)
69
+ next unless value
70
+
71
+ if value.is_a?(Hash)
72
+ value.merge("_key" => key)
73
+ else
74
+ { "_key" => key, "value" => value }
75
+ end
76
+ end.compact
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MudisQL
4
+ VERSION = "0.1.0"
5
+ end
data/lib/mudis-ql.rb ADDED
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mudis-ql/version"
4
+ require_relative "mudis-ql/store"
5
+ require_relative "mudis-ql/scope"
6
+ require_relative "mudis-ql/metrics_scope"
7
+
8
+ # MudisQL provides a simple query DSL for mudis cache
9
+ module MudisQL
10
+ class Error < StandardError; end
11
+
12
+ # Create a new scope for the given namespace
13
+ #
14
+ # @param namespace [String, nil] the mudis namespace to query (nil for default/no namespace)
15
+ # @return [MudisQL::Scope] a new scope object
16
+ def self.from(namespace = nil)
17
+ Scope.new(Store.new(namespace))
18
+ end
19
+
20
+ # Access mudis metrics with queryable interface
21
+ #
22
+ # @return [MudisQL::MetricsScope] a metrics scope object
23
+ def self.metrics
24
+ MetricsScope.new
25
+ end
26
+
27
+ # Configure MudisQL defaults
28
+ #
29
+ # @yield [Configuration] configuration object
30
+ def self.configure
31
+ yield configuration
32
+ end
33
+
34
+ # Get the current configuration
35
+ #
36
+ # @return [Configuration] current configuration
37
+ def self.configuration
38
+ @configuration ||= Configuration.new
39
+ end
40
+
41
+ # Configuration class for MudisQL
42
+ class Configuration
43
+ attr_accessor :default_limit
44
+
45
+ def initialize
46
+ @default_limit = 100
47
+ end
48
+ end
49
+ end