rails_event_viewer 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +451 -0
- data/Rakefile +16 -0
- data/app/assets/stylesheets/rails_event_viewer/application.css +32 -0
- data/app/assets/stylesheets/rails_event_viewer/tailwind.css +54 -0
- data/app/controllers/rails_event_viewer/analytics_controller.rb +78 -0
- data/app/controllers/rails_event_viewer/application_controller.rb +84 -0
- data/app/controllers/rails_event_viewer/dashboard_controller.rb +18 -0
- data/app/controllers/rails_event_viewer/event_types_controller.rb +16 -0
- data/app/controllers/rails_event_viewer/events_controller.rb +72 -0
- data/app/controllers/rails_event_viewer/groups_controller.rb +52 -0
- data/app/helpers/rails_event_viewer/application_helper.rb +163 -0
- data/app/models/rails_event_viewer/application_record.rb +5 -0
- data/app/models/rails_event_viewer/entry.rb +34 -0
- data/app/views/layouts/rails_event_viewer/application.html.erb +45 -0
- data/app/views/rails_event_viewer/analytics/overview.html.erb +134 -0
- data/app/views/rails_event_viewer/dashboard/index.html.erb +113 -0
- data/app/views/rails_event_viewer/event_types/index.html.erb +59 -0
- data/app/views/rails_event_viewer/event_types/show.html.erb +102 -0
- data/app/views/rails_event_viewer/events/_event_row.html.erb +42 -0
- data/app/views/rails_event_viewer/events/_filters_sidebar.html.erb +66 -0
- data/app/views/rails_event_viewer/events/index.html.erb +65 -0
- data/app/views/rails_event_viewer/events/show.html.erb +155 -0
- data/app/views/rails_event_viewer/groups/index.html.erb +115 -0
- data/app/views/rails_event_viewer/groups/show.html.erb +134 -0
- data/app/views/rails_event_viewer/shared/_navigation.html.erb +47 -0
- data/app/views/rails_event_viewer/shared/_pagination.html.erb +60 -0
- data/app/views/rails_event_viewer/shared/_stat_card.html.erb +36 -0
- data/config/routes.rb +18 -0
- data/db/migrate/20241208000001_create_rails_event_viewer_entries.rb +36 -0
- data/lib/generators/rails_event_viewer/install/USAGE +33 -0
- data/lib/generators/rails_event_viewer/install/install_generator.rb +86 -0
- data/lib/generators/rails_event_viewer/install/templates/create_rails_event_viewer_entries.rb.erb +35 -0
- data/lib/generators/rails_event_viewer/install/templates/initializer.rb.erb +90 -0
- data/lib/rails_event_viewer/adapter.rb +93 -0
- data/lib/rails_event_viewer/adapters/active_record.rb +227 -0
- data/lib/rails_event_viewer/adapters/concerns/in_memory_filtering.rb +136 -0
- data/lib/rails_event_viewer/adapters/memory.rb +128 -0
- data/lib/rails_event_viewer/adapters/null.rb +63 -0
- data/lib/rails_event_viewer/adapters/redis.rb +213 -0
- data/lib/rails_event_viewer/engine.rb +24 -0
- data/lib/rails_event_viewer/events_relation.rb +130 -0
- data/lib/rails_event_viewer/json_query.rb +111 -0
- data/lib/rails_event_viewer/pagy_countable.rb +31 -0
- data/lib/rails_event_viewer/subscriber.rb +255 -0
- data/lib/rails_event_viewer/time_utils.rb +24 -0
- data/lib/rails_event_viewer/version.rb +3 -0
- data/lib/rails_event_viewer.rb +122 -0
- data/lib/tasks/rails_event_viewer_tasks.rake +81 -0
- metadata +151 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module RailsEventViewer
|
|
2
|
+
module Adapters
|
|
3
|
+
class Null
|
|
4
|
+
include Adapter
|
|
5
|
+
|
|
6
|
+
def supports_persistence?
|
|
7
|
+
false
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def supports_analytics?
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def supports_retention?
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def write_events(events)
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch_events(relation)
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def count_events(relation)
|
|
27
|
+
0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def distinct_event_names
|
|
31
|
+
[]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def find_event(id)
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def delete_before(timestamp)
|
|
39
|
+
0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def events_over_time(since:, interval:)
|
|
43
|
+
{}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def counts_by_name(limit:)
|
|
47
|
+
{}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def count_since(since)
|
|
51
|
+
0
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def distinct_group_values(key, source: :context)
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def group_instances(key, source: :context, limit: 100)
|
|
59
|
+
[]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "concerns/in_memory_filtering"
|
|
4
|
+
|
|
5
|
+
module RailsEventViewer
|
|
6
|
+
module Adapters
|
|
7
|
+
class Redis
|
|
8
|
+
include Adapter
|
|
9
|
+
include Concerns::InMemoryFiltering
|
|
10
|
+
|
|
11
|
+
DEFAULT_KEY_PREFIX = "rails_event_viewer"
|
|
12
|
+
DEFAULT_MAX_EVENTS = 10_000
|
|
13
|
+
DEFAULT_POOL_SIZE = 5
|
|
14
|
+
DEFAULT_POOL_TIMEOUT = 5
|
|
15
|
+
NAME_BATCH_TTL_SECONDS = 60
|
|
16
|
+
|
|
17
|
+
# Matches event IDs in format: microsecond_timestamp-hex
|
|
18
|
+
# Example: "1702089600123456-a1b2c3d4e5f6g7h8"
|
|
19
|
+
EVENT_ID_PATTERN = /^(\d+)-([a-f0-9]+)$/i
|
|
20
|
+
|
|
21
|
+
def initialize(redis_options: {}, pool_size: DEFAULT_POOL_SIZE, pool_timeout: DEFAULT_POOL_TIMEOUT,
|
|
22
|
+
pool: build_pool(pool_size, pool_timeout, redis_options),
|
|
23
|
+
key_prefix: DEFAULT_KEY_PREFIX, max_events: DEFAULT_MAX_EVENTS, **)
|
|
24
|
+
@redis = pool
|
|
25
|
+
@key_prefix = "{#{key_prefix}}"
|
|
26
|
+
@max_events = max_events
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def supports_analytics?
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def write_events(events)
|
|
34
|
+
return if events.empty?
|
|
35
|
+
|
|
36
|
+
newest_by_name = Hash.new(0)
|
|
37
|
+
batch_key = "#{@key_prefix}:name_batch:#{SecureRandom.hex(8)}"
|
|
38
|
+
|
|
39
|
+
@redis.with do |redis|
|
|
40
|
+
results = redis.pipelined do |pipe|
|
|
41
|
+
events.each do |event|
|
|
42
|
+
timestamp_usec = (event[:occurred_at].to_f * 1_000_000).to_i
|
|
43
|
+
pipe.zadd(events_key, timestamp_usec, serialize_event(event, timestamp_usec))
|
|
44
|
+
name = event[:name].to_s
|
|
45
|
+
newest_by_name[name] = timestamp_usec if timestamp_usec > newest_by_name[name]
|
|
46
|
+
end
|
|
47
|
+
pipe.zadd(batch_key, newest_by_name.map { |name, score| [score, name] })
|
|
48
|
+
pipe.expire(batch_key, NAME_BATCH_TTL_SECONDS)
|
|
49
|
+
pipe.zunionstore(names_key, [names_key, batch_key], aggregate: "max")
|
|
50
|
+
pipe.del(batch_key)
|
|
51
|
+
pipe.zremrangebyrank(events_key, 0, -@max_events - 1)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
prune_names(redis) if results.last.to_i.positive?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fetch_events(relation)
|
|
59
|
+
if relation.filtered?
|
|
60
|
+
return fetch_all_filtered(relation).drop(relation.offset_value).take(relation.limit_value)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
start_idx = relation.offset_value
|
|
64
|
+
end_idx = start_idx + relation.limit_value - 1
|
|
65
|
+
|
|
66
|
+
@redis.with { |conn| conn.zrevrange(events_key, start_idx, end_idx) }.map { |json| deserialize_event(json) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def count_events(relation)
|
|
70
|
+
if relation.filtered?
|
|
71
|
+
fetch_all_filtered(relation).size
|
|
72
|
+
else
|
|
73
|
+
@redis.with { |conn| conn.zcard(events_key) }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def distinct_event_names
|
|
78
|
+
@redis.with { |conn| conn.zrange(names_key, 0, -1).sort }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def find_event(id)
|
|
82
|
+
id_str = id.to_s
|
|
83
|
+
match = id_str.match(EVENT_ID_PATTERN)
|
|
84
|
+
return nil unless match
|
|
85
|
+
|
|
86
|
+
timestamp_usec = match[1].to_i
|
|
87
|
+
|
|
88
|
+
serialized_events = @redis.with { |conn| conn.zrangebyscore(events_key, timestamp_usec, timestamp_usec) }
|
|
89
|
+
return nil if serialized_events.empty?
|
|
90
|
+
|
|
91
|
+
serialized_events.each do |json|
|
|
92
|
+
event = deserialize_event(json)
|
|
93
|
+
return event if event[:id] == id_str
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def delete_before(timestamp)
|
|
100
|
+
score = (timestamp.to_f * 1_000_000).to_i
|
|
101
|
+
@redis.with do |conn|
|
|
102
|
+
deleted = conn.zremrangebyscore(events_key, "-inf", score)
|
|
103
|
+
prune_names(conn)
|
|
104
|
+
deleted
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def events_over_time(since:, interval:)
|
|
109
|
+
events = fetch_events_since(since)
|
|
110
|
+
|
|
111
|
+
events.group_by do |e|
|
|
112
|
+
TimeUtils.truncate_to_interval(e[:occurred_at], interval)
|
|
113
|
+
end.transform_values(&:size)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def counts_by_name(limit:)
|
|
117
|
+
serialized_events = @redis.with { |conn| conn.zrevrange(events_key, 0, -1) }
|
|
118
|
+
serialized_events
|
|
119
|
+
.map { |json| deserialize_event(json)[:name] }
|
|
120
|
+
.tally
|
|
121
|
+
.sort_by { |_, count| -count }
|
|
122
|
+
.take(limit)
|
|
123
|
+
.to_h
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def count_since(since)
|
|
127
|
+
score = (since.to_f * 1_000_000).to_i
|
|
128
|
+
@redis.with { |conn| conn.zcount(events_key, score, "+inf") }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def clear!
|
|
132
|
+
@redis.with { |conn| conn.del(events_key, names_key) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def build_pool(size, timeout, redis_options)
|
|
138
|
+
require "connection_pool"
|
|
139
|
+
ConnectionPool.new(size: size, timeout: timeout) { build_redis(redis_options) }
|
|
140
|
+
rescue LoadError
|
|
141
|
+
raise ArgumentError, "connection_pool gem not loaded. Add `gem 'connection_pool'` to your Gemfile."
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def build_redis(options)
|
|
145
|
+
if defined?(::Redis)
|
|
146
|
+
::Redis.new(**options)
|
|
147
|
+
else
|
|
148
|
+
raise ArgumentError, "Redis gem not loaded. Add `gem 'redis'` to your Gemfile."
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def events_key
|
|
153
|
+
"#{@key_prefix}:events"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def names_key
|
|
157
|
+
"#{@key_prefix}:name_index"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def prune_names(conn)
|
|
161
|
+
conn.watch(events_key) do
|
|
162
|
+
oldest = conn.zrange(events_key, 0, 0, with_scores: true).first
|
|
163
|
+
|
|
164
|
+
conn.multi do |tx|
|
|
165
|
+
if oldest
|
|
166
|
+
tx.zremrangebyscore(names_key, "-inf", "(#{oldest.last.to_i}")
|
|
167
|
+
else
|
|
168
|
+
tx.del(names_key)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def serialize_event(event, timestamp_usec = nil)
|
|
175
|
+
timestamp_usec ||= (event[:occurred_at].to_f * 1_000_000).to_i
|
|
176
|
+
event_with_id = event.merge(id: "#{timestamp_usec}-#{SecureRandom.hex(8)}")
|
|
177
|
+
JSON.generate(event_with_id)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def deserialize_event(json)
|
|
181
|
+
data = JSON.parse(json, symbolize_names: true)
|
|
182
|
+
|
|
183
|
+
if data[:occurred_at].is_a?(String)
|
|
184
|
+
data[:occurred_at] = Time.parse(data[:occurred_at])
|
|
185
|
+
elsif data[:occurred_at].is_a?(Numeric)
|
|
186
|
+
data[:occurred_at] = Time.at(data[:occurred_at])
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
data
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def fetch_events_since(since)
|
|
193
|
+
score = (since.to_f * 1_000_000).to_i
|
|
194
|
+
serialized_events = @redis.with { |conn| conn.zrangebyscore(events_key, score, "+inf") }
|
|
195
|
+
serialized_events.map { |json| deserialize_event(json) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def fetch_all_events
|
|
199
|
+
serialized_events = @redis.with { |conn| conn.zrevrange(events_key, 0, -1) }
|
|
200
|
+
serialized_events.map { |json| deserialize_event(json) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def fetch_all_filtered(relation)
|
|
204
|
+
events = fetch_all_events
|
|
205
|
+
filter_events(events, relation)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def in_memory_events
|
|
209
|
+
fetch_all_events
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module RailsEventViewer
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace RailsEventViewer
|
|
4
|
+
|
|
5
|
+
initializer "rails_event_viewer.middleware" do |app|
|
|
6
|
+
if app.config.api_only
|
|
7
|
+
config.middleware.use ActionDispatch::Flash
|
|
8
|
+
config.middleware.use Rack::MethodOverride
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
initializer "rails_event_viewer.assets" do |app|
|
|
13
|
+
if app.config.respond_to?(:assets)
|
|
14
|
+
app.config.assets.paths << root.join("app/assets/stylesheets")
|
|
15
|
+
app.config.assets.precompile += %w[rails_event_viewer/application.css]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
config.after_initialize do
|
|
20
|
+
RailsEventViewer.subscribe!
|
|
21
|
+
at_exit { RailsEventViewer.shutdown! }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
module RailsEventViewer
|
|
2
|
+
class EventsRelation
|
|
3
|
+
include Enumerable
|
|
4
|
+
|
|
5
|
+
attr_reader :names, :tags, :contexts, :query, :since_time, :until_time
|
|
6
|
+
attr_reader :limit_value, :offset_value
|
|
7
|
+
|
|
8
|
+
def initialize(adapter: RailsEventViewer.adapter)
|
|
9
|
+
@adapter = adapter
|
|
10
|
+
@names = []
|
|
11
|
+
@tags = {}
|
|
12
|
+
@contexts = {}
|
|
13
|
+
@query = nil
|
|
14
|
+
@since_time = nil
|
|
15
|
+
@until_time = nil
|
|
16
|
+
@limit_value = RailsEventViewer.per_page
|
|
17
|
+
@offset_value = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def with_name(*event_names)
|
|
21
|
+
event_names = event_names.flatten.compact
|
|
22
|
+
return self if event_names.empty?
|
|
23
|
+
|
|
24
|
+
clone_with { |rel| rel.names.concat(event_names) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def with_tag(key, value = nil)
|
|
28
|
+
return self if key.blank?
|
|
29
|
+
|
|
30
|
+
clone_with { |rel| rel.tags[key.to_s] = value }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with_context(key, value = nil)
|
|
34
|
+
return self if key.blank?
|
|
35
|
+
|
|
36
|
+
clone_with { |rel| rel.contexts[key.to_s] = value }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def search(term)
|
|
40
|
+
return self if term.blank?
|
|
41
|
+
|
|
42
|
+
clone_with { |rel| rel.query = term.to_s }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def since(time)
|
|
46
|
+
return self if time.nil?
|
|
47
|
+
|
|
48
|
+
clone_with { |rel| rel.since_time = time }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def until(time)
|
|
52
|
+
return self if time.nil?
|
|
53
|
+
|
|
54
|
+
clone_with { |rel| rel.until_time = time }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def limit(value)
|
|
58
|
+
clone_with { |rel| rel.limit_value = value.to_i }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def offset(value)
|
|
62
|
+
clone_with { |rel| rel.offset_value = value.to_i }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def each(&block)
|
|
66
|
+
return to_enum(:each) unless block_given?
|
|
67
|
+
|
|
68
|
+
@adapter.fetch_events(self).each(&block)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def count
|
|
72
|
+
@adapter.count_events(self)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
alias_method :size, :count
|
|
76
|
+
alias_method :length, :count
|
|
77
|
+
|
|
78
|
+
def to_a
|
|
79
|
+
@adapter.fetch_events(self).to_a
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def any?
|
|
83
|
+
count > 0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def empty?
|
|
87
|
+
count == 0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
alias_method :none?, :empty?
|
|
91
|
+
|
|
92
|
+
def first
|
|
93
|
+
limit(1).to_a.first
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def last
|
|
97
|
+
return @adapter.fetch_last(self) if @adapter.respond_to?(:fetch_last)
|
|
98
|
+
|
|
99
|
+
n = count
|
|
100
|
+
offset(n - 1).first if n.positive?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def filtered?
|
|
104
|
+
names.any? || tags.any? || contexts.any? || query.present? || since_time.present? || until_time.present?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def inspect
|
|
108
|
+
"#<#{self.class.name} names=#{names.inspect} tags=#{tags.inspect} " \
|
|
109
|
+
"contexts=#{contexts.inspect} query=#{query.inspect} " \
|
|
110
|
+
"since=#{since_time.inspect} until=#{until_time.inspect} " \
|
|
111
|
+
"limit=#{limit_value} offset=#{offset_value}>"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def clone_with
|
|
117
|
+
dup.tap do |rel|
|
|
118
|
+
rel.instance_variable_set(:@names, @names.dup)
|
|
119
|
+
rel.instance_variable_set(:@tags, @tags.dup)
|
|
120
|
+
rel.instance_variable_set(:@contexts, @contexts.dup)
|
|
121
|
+
yield rel if block_given?
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
protected
|
|
126
|
+
|
|
127
|
+
attr_writer :names, :tags, :contexts, :query, :since_time, :until_time
|
|
128
|
+
attr_writer :limit_value, :offset_value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module RailsEventViewer
|
|
2
|
+
module JsonQuery
|
|
3
|
+
INTEGER_PATTERN = /\A-?\d+\z/ # Matches: "123", "-456", "0"
|
|
4
|
+
FLOAT_PATTERN = /\A-?\d+\.?\d*\z/ # Matches: "123.45", "-0.5", "123"
|
|
5
|
+
SAFE_KEY_PATTERN = /\A[\w.-]+\z/
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def contains(column, key, value = nil)
|
|
10
|
+
adapter = detect_adapter
|
|
11
|
+
|
|
12
|
+
case adapter
|
|
13
|
+
when :postgresql
|
|
14
|
+
postgresql_contains(column, key, value)
|
|
15
|
+
when :mysql
|
|
16
|
+
mysql_contains(column, key, value)
|
|
17
|
+
when :sqlite
|
|
18
|
+
sqlite_contains(column, key, value)
|
|
19
|
+
else
|
|
20
|
+
fallback_contains(column, key, value)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def detect_adapter
|
|
25
|
+
adapter_name = ActiveRecord::Base.connection.adapter_name.downcase
|
|
26
|
+
|
|
27
|
+
case adapter_name
|
|
28
|
+
when /postgresql/, /postgis/
|
|
29
|
+
:postgresql
|
|
30
|
+
when /mysql/, /trilogy/
|
|
31
|
+
:mysql
|
|
32
|
+
when /sqlite/
|
|
33
|
+
:sqlite
|
|
34
|
+
else
|
|
35
|
+
:fallback
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# PostgreSQL: Uses @> operator for "contains"
|
|
40
|
+
# Example: tags @> '{"env": "production"}'
|
|
41
|
+
def postgresql_contains(column, key, value = nil)
|
|
42
|
+
if value.present?
|
|
43
|
+
["#{column} @> ?", { key.to_s => value }.to_json]
|
|
44
|
+
else
|
|
45
|
+
["#{column} ? ?", key.to_s]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def mysql_contains(column, key, value = nil)
|
|
50
|
+
if value.present?
|
|
51
|
+
["JSON_CONTAINS(#{column}, ?, ?)", value.to_json, "$.#{key}"]
|
|
52
|
+
else
|
|
53
|
+
["JSON_CONTAINS_PATH(#{column}, 'one', ?)", "$.#{key}"]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def sqlite_contains(column, key, value = nil)
|
|
58
|
+
if value.present?
|
|
59
|
+
# SQLite stores JSON values with their original types (integers stay integers)
|
|
60
|
+
# We need to cast the value appropriately for comparison
|
|
61
|
+
normalized_value = normalize_sqlite_value(value)
|
|
62
|
+
["json_extract(#{column}, ?) = ?", "$.#{key}", normalized_value]
|
|
63
|
+
else
|
|
64
|
+
["json_extract(#{column}, ?) IS NOT NULL", "$.#{key}"]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def normalize_sqlite_value(value)
|
|
69
|
+
return value unless value.is_a?(String)
|
|
70
|
+
|
|
71
|
+
if value.match?(INTEGER_PATTERN)
|
|
72
|
+
value.to_i
|
|
73
|
+
elsif value.match?(FLOAT_PATTERN)
|
|
74
|
+
value.to_f
|
|
75
|
+
else
|
|
76
|
+
value
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def fallback_contains(column, key, value = nil)
|
|
81
|
+
if value.present?
|
|
82
|
+
escaped_key = key.to_s.gsub(/[%_\\]/) { |m| "\\#{m}" }
|
|
83
|
+
["CAST(#{column} AS TEXT) LIKE ?", "%\"#{escaped_key}\":#{value.to_json}%"]
|
|
84
|
+
else
|
|
85
|
+
escaped_key = key.to_s.gsub(/[%_\\]/) { |m| "\\#{m}" }
|
|
86
|
+
["CAST(#{column} AS TEXT) LIKE ?", "%\"#{escaped_key}\"%"]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def extract_path(column, key)
|
|
91
|
+
raise ArgumentError, "Invalid JSON key: #{key.inspect}" unless key.to_s.match?(SAFE_KEY_PATTERN)
|
|
92
|
+
|
|
93
|
+
adapter = detect_adapter
|
|
94
|
+
|
|
95
|
+
case adapter
|
|
96
|
+
when :postgresql
|
|
97
|
+
"#{column} ->> '#{key}'"
|
|
98
|
+
when :mysql
|
|
99
|
+
"JSON_UNQUOTE(JSON_EXTRACT(#{column}, '$.#{key}'))"
|
|
100
|
+
when :sqlite
|
|
101
|
+
"json_extract(#{column}, '$.#{key}')"
|
|
102
|
+
else
|
|
103
|
+
"json_extract(#{column}, '$.#{key}')"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def extract_path_not_null(column, key)
|
|
108
|
+
"#{extract_path(column, key)} IS NOT NULL"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module RailsEventViewer
|
|
2
|
+
class PagyCountable
|
|
3
|
+
def initialize(relation)
|
|
4
|
+
@relation = relation
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def count(*)
|
|
8
|
+
@relation.count
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def offset(value)
|
|
12
|
+
OffsetWrapper.new(@relation, value)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Intermediate wrapper that captures offset and applies limit
|
|
16
|
+
class OffsetWrapper
|
|
17
|
+
def initialize(relation, offset_value)
|
|
18
|
+
@relation = relation
|
|
19
|
+
@offset_value = offset_value
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def limit(value)
|
|
23
|
+
@relation.offset(@offset_value).limit(value).to_a
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_a
|
|
27
|
+
@relation.offset(@offset_value).to_a
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|