rails_pulse 0.4.2 → 0.5.0.pre.2
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 +4 -4
- data/CHANGELOG.md +58 -1
- data/README.md +15 -4
- data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
- data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
- data/app/controllers/concerns/chart_table_concern.rb +28 -69
- data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
- data/app/controllers/concerns/metric_card_concern.rb +6 -7
- data/app/controllers/rails_pulse/api/v1/base_controller.rb +133 -0
- data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
- data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
- data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +40 -0
- data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +77 -0
- data/app/controllers/rails_pulse/api/v1/insights_controller.rb +62 -0
- data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
- data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
- data/app/controllers/rails_pulse/api/v1/queries_controller.rb +123 -0
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +74 -0
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +103 -0
- data/app/controllers/rails_pulse/application_controller.rb +1 -1
- data/app/controllers/rails_pulse/dashboard_controller.rb +25 -21
- data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
- data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
- data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
- data/app/controllers/rails_pulse/queries_controller.rb +6 -5
- data/app/controllers/rails_pulse/requests_controller.rb +15 -14
- data/app/controllers/rails_pulse/routes_controller.rb +4 -8
- data/app/helpers/rails_pulse/application_helper.rb +15 -0
- data/app/helpers/rails_pulse/chart_helper.rb +9 -2
- data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
- data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
- data/app/jobs/rails_pulse/cloud_health_job.rb +11 -0
- data/app/jobs/rails_pulse/cloud_sync_job.rb +11 -0
- data/app/jobs/rails_pulse/summary_job.rb +13 -0
- data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
- data/app/models/concerns/rails_pulse/taggable.rb +5 -12
- data/app/models/rails_pulse/cards/base.rb +20 -11
- data/app/models/rails_pulse/charts/base.rb +5 -18
- data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -5
- data/app/models/rails_pulse/cleanup_run.rb +32 -0
- data/app/models/rails_pulse/cloud/buffered_batch.rb +55 -0
- data/app/models/rails_pulse/cloud/installation.rb +55 -0
- data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
- data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
- data/app/models/rails_pulse/dashboard/concerns/attention_classification.rb +104 -0
- data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +25 -2
- data/app/models/rails_pulse/dashboard/health_summary.rb +38 -13
- data/app/models/rails_pulse/dashboard/needs_attention.rb +31 -98
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +39 -63
- data/app/models/rails_pulse/dashboard/storage_status.rb +130 -39
- data/app/models/rails_pulse/deployment.rb +2 -7
- data/app/models/rails_pulse/event.rb +41 -0
- data/app/models/rails_pulse/exception_occurrence.rb +1 -1
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -7
- data/app/models/rails_pulse/job_run.rb +1 -1
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
- data/app/models/rails_pulse/jobs/charts/duration.rb +1 -2
- data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -2
- data/app/models/rails_pulse/like_pattern.rb +35 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/charts/database_load.rb +5 -8
- data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
- data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
- data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
- data/app/models/rails_pulse/summary.rb +5 -0
- data/app/models/rails_pulse/tables/base.rb +5 -6
- data/app/models/rails_pulse/time_range.rb +309 -0
- data/app/models/rails_pulse/time_window.rb +15 -0
- data/app/models/rails_pulse/writer_heartbeat.rb +90 -0
- data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
- data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
- data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
- data/app/serializers/rails_pulse/api/v1/request_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
- data/app/services/rails_pulse/cloud/batch.rb +83 -0
- data/app/services/rails_pulse/cloud/client.rb +68 -0
- data/app/services/rails_pulse/cloud/combined_metrics.rb +49 -0
- data/app/services/rails_pulse/cloud/health_item.rb +77 -0
- data/app/services/rails_pulse/cloud/path_prefix.rb +50 -0
- data/app/services/rails_pulse/cloud/preview.rb +83 -0
- data/app/services/rails_pulse/cloud/query_shape.rb +195 -0
- data/app/services/rails_pulse/cloud/record_items.rb +60 -0
- data/app/services/rails_pulse/cloud/route_pattern.rb +111 -0
- data/app/services/rails_pulse/cloud/summary_items.rb +188 -0
- data/app/services/rails_pulse/cloud/sync.rb +275 -0
- data/app/services/rails_pulse/config_recommendations.rb +132 -0
- data/app/services/rails_pulse/deployment_comparison.rb +177 -0
- data/app/services/rails_pulse/exception_capture_service.rb +1 -1
- data/app/services/rails_pulse/operations/change_point.rb +1 -1
- data/app/services/rails_pulse/operations/comparison.rb +1 -1
- data/app/services/rails_pulse/period_insights.rb +131 -0
- data/app/services/rails_pulse/sql_query_normalizer.rb +222 -122
- data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
- data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
- data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
- data/app/services/rails_pulse/summary_service.rb +121 -133
- data/app/services/rails_pulse/tag_filter_service.rb +5 -5
- data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
- data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
- data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +13 -3
- data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
- data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/jobs/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/queries/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/show.html.erb +1 -1
- data/app/views/rails_pulse/requests/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/routes/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/show.html.erb +1 -1
- data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
- data/app/views/rails_pulse/storage/show.html.erb +39 -2
- data/config/routes.rb +23 -0
- data/db/rails_pulse_cloud_schema.rb +58 -0
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
- data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
- data/db/rails_pulse_schema.rb +20 -2
- data/exe/rails-pulse +7 -0
- data/lib/generators/rails_pulse/base_methods.rb +19 -0
- data/lib/generators/rails_pulse/install_cloud_generator.rb +84 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_cloud_schema.rb +58 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_cloud_tables.rb +30 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +71 -19
- data/lib/generators/rails_pulse/upgrade_generator.rb +9 -16
- data/lib/rails_pulse/cleanup_service.rb +37 -20
- data/lib/rails_pulse/cli/agent_files/agents.md +89 -0
- data/lib/rails_pulse/cli/agent_files/claude_skill.md +182 -0
- data/lib/rails_pulse/cli/base_command.rb +37 -0
- data/lib/rails_pulse/cli/client.rb +64 -0
- data/lib/rails_pulse/cli/config.rb +87 -0
- data/lib/rails_pulse/cli/configure.rb +75 -0
- data/lib/rails_pulse/cli/coverage.rb +91 -0
- data/lib/rails_pulse/cli/deployments.rb +54 -0
- data/lib/rails_pulse/cli/exceptions.rb +116 -0
- data/lib/rails_pulse/cli/formatter.rb +31 -0
- data/lib/rails_pulse/cli/insights.rb +70 -0
- data/lib/rails_pulse/cli/install.rb +145 -0
- data/lib/rails_pulse/cli/job_runs.rb +53 -0
- data/lib/rails_pulse/cli/jobs.rb +71 -0
- data/lib/rails_pulse/cli/main.rb +76 -0
- data/lib/rails_pulse/cli/mcp.rb +47 -0
- data/lib/rails_pulse/cli/queries.rb +67 -0
- data/lib/rails_pulse/cli/requests.rb +57 -0
- data/lib/rails_pulse/cli/routes.rb +73 -0
- data/lib/rails_pulse/configuration.rb +100 -2
- data/lib/rails_pulse/current.rb +11 -0
- data/lib/rails_pulse/engine.rb +12 -3
- data/lib/rails_pulse/installers/config_updater.rb +9 -2
- data/lib/rails_pulse/job_run_collector.rb +15 -15
- data/lib/rails_pulse/mcp/server.rb +78 -0
- data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
- data/lib/rails_pulse/mcp/tools/deployments.rb +117 -0
- data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
- data/lib/rails_pulse/mcp/tools/errors.rb +104 -0
- data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
- data/lib/rails_pulse/mcp/tools/exceptions.rb +128 -0
- data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
- data/lib/rails_pulse/mcp/tools/insights.rb +99 -0
- data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
- data/lib/rails_pulse/mcp/tools/queries.rb +142 -0
- data/lib/rails_pulse/mcp/tools/routes.rb +108 -0
- data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -9
- data/lib/rails_pulse/scoped_inflector.rb +35 -0
- data/lib/rails_pulse/statistics.rb +27 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
- data/lib/rails_pulse/tasks/status_reporter.rb +201 -0
- data/lib/rails_pulse/tracker.rb +76 -14
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +0 -4
- data/lib/tasks/rails_pulse.rake +6 -0
- data/lib/tasks/rails_pulse_tasks.rake +7 -0
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +13 -13
- metadata +123 -24
- data/app/controllers/concerns/page_timings.rb +0 -10
- data/app/controllers/concerns/response_range_concern.rb +0 -45
- data/app/controllers/concerns/time_range_concern.rb +0 -152
- data/app/controllers/concerns/zoom_range_concern.rb +0 -86
|
@@ -34,7 +34,7 @@ module RailsPulse
|
|
|
34
34
|
name: :rails_pulse_summaries,
|
|
35
35
|
model: "RailsPulse::Summary",
|
|
36
36
|
label: "Summaries",
|
|
37
|
-
description: "Hourly and daily aggregates used by charts. Hourly rows
|
|
37
|
+
description: "Hourly and daily aggregates used by charts. Hourly rows are cleaned up after 7 days by default (hourly_summary_retention)",
|
|
38
38
|
time_column: :period_start
|
|
39
39
|
},
|
|
40
40
|
{
|
|
@@ -71,42 +71,50 @@ module RailsPulse
|
|
|
71
71
|
label: "Deployments",
|
|
72
72
|
description: "Deploy markers shown on charts",
|
|
73
73
|
time_column: :started_at
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: :rails_pulse_events,
|
|
77
|
+
model: "RailsPulse::Event",
|
|
78
|
+
label: "Events",
|
|
79
|
+
description: "What Pulse noticed: writer heartbeats every minute, pruned after a day; any other kind is kept for event_retention_period",
|
|
80
|
+
time_column: :occurred_at
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: :rails_pulse_cloud_batches,
|
|
84
|
+
model: "RailsPulse::Cloud::BufferedBatch",
|
|
85
|
+
label: "Cloud buffer",
|
|
86
|
+
description: "Batches waiting to be sent to Rails Pulse Cloud. Held for at most 7 days or 50 MB",
|
|
87
|
+
time_column: :created_at
|
|
74
88
|
}
|
|
75
89
|
].freeze
|
|
76
90
|
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return entry[:bytes] if entry && entry[:measured_at] > SIZE_CACHE_TTL.ago
|
|
90
|
-
|
|
91
|
-
bytes = yield
|
|
92
|
-
@size_cache[table_name] = { bytes: bytes, measured_at: Time.current }
|
|
93
|
-
bytes
|
|
94
|
-
end
|
|
95
|
-
end
|
|
91
|
+
# The Storage page counts every row exactly: one unindexed COUNT/MIN/MAX
|
|
92
|
+
# scan per table, which takes tens of seconds on large installs. The
|
|
93
|
+
# dashboard only shows headline counts and fill bars, so with
|
|
94
|
+
# estimated: true it reads the catalog's row estimates instead
|
|
95
|
+
# (pg_class.reltuples, information_schema TABLE_ROWS). SQLite keeps no
|
|
96
|
+
# such estimate, and dbstat walks the whole file, so there the dashboard
|
|
97
|
+
# measures no tables at all.
|
|
98
|
+
def initialize(estimated: false, storage_pressure: nil)
|
|
99
|
+
@config = RailsPulse.configuration
|
|
100
|
+
@pressure = storage_pressure || StoragePressure.new
|
|
101
|
+
@estimated = estimated
|
|
102
|
+
end
|
|
96
103
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
end
|
|
104
|
+
def estimated?
|
|
105
|
+
@estimated
|
|
100
106
|
end
|
|
101
107
|
|
|
102
|
-
def
|
|
103
|
-
@
|
|
104
|
-
@pressure = StoragePressure.new
|
|
105
|
-
@cached_sizes = cached_sizes
|
|
108
|
+
def counts_available?
|
|
109
|
+
!(@estimated && sqlite?)
|
|
106
110
|
end
|
|
107
111
|
|
|
108
112
|
def tables
|
|
109
|
-
@tables ||=
|
|
113
|
+
@tables ||= if counts_available?
|
|
114
|
+
with_current_mysql_statistics { TABLE_CATALOG.filter_map { |definition| build_table(definition) } }
|
|
115
|
+
else
|
|
116
|
+
[]
|
|
117
|
+
end
|
|
110
118
|
end
|
|
111
119
|
|
|
112
120
|
def pressure_items
|
|
@@ -137,6 +145,35 @@ module RailsPulse
|
|
|
137
145
|
}
|
|
138
146
|
end
|
|
139
147
|
|
|
148
|
+
# Built from live_processes/dropped_by_process rather than
|
|
149
|
+
# WriterHeartbeat.summary, which would recompute both internally.
|
|
150
|
+
def tracking
|
|
151
|
+
@tracking ||= begin
|
|
152
|
+
live = RailsPulse::WriterHeartbeat.live_processes
|
|
153
|
+
dropped_by_process = RailsPulse::WriterHeartbeat.dropped_by_process(window: 1.hour)
|
|
154
|
+
processes = live.map do |process|
|
|
155
|
+
{
|
|
156
|
+
label: process.process_label,
|
|
157
|
+
queue_depth: process.queue_depth,
|
|
158
|
+
queue_size: process.queue_size,
|
|
159
|
+
dropped_last_hour: dropped_by_process.fetch(process.process_label, 0),
|
|
160
|
+
last_seen_at: process.sampled_at
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
{
|
|
165
|
+
processes: processes,
|
|
166
|
+
live_count: processes.size,
|
|
167
|
+
queue_depth: live.sum(&:queue_depth),
|
|
168
|
+
queue_size: live.map(&:queue_size).max || 0,
|
|
169
|
+
dropped: dropped_by_process.values.sum,
|
|
170
|
+
last_sampled_at: RailsPulse::WriterHeartbeat.events.maximum(:occurred_at)
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
rescue ActiveRecord::ActiveRecordError
|
|
174
|
+
{ processes: [], live_count: 0, queue_depth: 0, queue_size: 0, dropped: 0, last_sampled_at: nil }
|
|
175
|
+
end
|
|
176
|
+
|
|
140
177
|
def dashboard_tables
|
|
141
178
|
capped = tables.select { |table| table[:limit] }
|
|
142
179
|
return tables.first(4) if capped.empty?
|
|
@@ -162,12 +199,37 @@ module RailsPulse
|
|
|
162
199
|
RailsPulse::ApplicationRecord.connection
|
|
163
200
|
end
|
|
164
201
|
|
|
202
|
+
# MySQL 8 caches information_schema's TABLE_ROWS, DATA_LENGTH and
|
|
203
|
+
# INDEX_LENGTH for information_schema_stats_expiry seconds, a day by
|
|
204
|
+
# default, so a table that grew after its first read reports its old
|
|
205
|
+
# size. An expiry of 0 reads current statistics; a SET_VAR hint does
|
|
206
|
+
# not reach information_schema, so it is set on the session and put
|
|
207
|
+
# back afterwards because the connection returns to the pool. MariaDB
|
|
208
|
+
# has no such variable and does not cache, so the block runs as is.
|
|
209
|
+
def with_current_mysql_statistics
|
|
210
|
+
previous = session_statistics_expiry
|
|
211
|
+
return yield if previous.nil?
|
|
212
|
+
|
|
213
|
+
connection.execute("SET SESSION information_schema_stats_expiry = 0")
|
|
214
|
+
yield
|
|
215
|
+
ensure
|
|
216
|
+
connection.execute("SET SESSION information_schema_stats_expiry = #{previous.to_i}") unless previous.nil?
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def session_statistics_expiry
|
|
220
|
+
return unless mysql?
|
|
221
|
+
|
|
222
|
+
connection.select_value("SELECT @@SESSION.information_schema_stats_expiry")
|
|
223
|
+
rescue ActiveRecord::StatementInvalid
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
165
227
|
def build_table(definition)
|
|
166
228
|
model = definition[:model].safe_constantize
|
|
167
229
|
return unless model
|
|
168
230
|
return unless connection.table_exists?(definition[:name])
|
|
169
231
|
|
|
170
|
-
stats =
|
|
232
|
+
stats = measured_counts(model, definition)
|
|
171
233
|
limit = @config.max_table_records&.[](definition[:name])
|
|
172
234
|
count = stats[:count]
|
|
173
235
|
percent = limit.to_i.positive? ? ((count.to_f / limit) * 100).round(1) : nil
|
|
@@ -187,7 +249,7 @@ module RailsPulse
|
|
|
187
249
|
newest_at: stats[:newest_at],
|
|
188
250
|
recent_count: stats[:recent_count],
|
|
189
251
|
runway_label: runway_label(count, limit, stats[:recent_count]),
|
|
190
|
-
bytes:
|
|
252
|
+
bytes: table_bytes(definition[:name]),
|
|
191
253
|
history_label: history_label(stats[:oldest_at], stats[:newest_at])
|
|
192
254
|
}
|
|
193
255
|
rescue => error
|
|
@@ -211,6 +273,33 @@ module RailsPulse
|
|
|
211
273
|
}
|
|
212
274
|
end
|
|
213
275
|
|
|
276
|
+
def measured_counts(model, definition)
|
|
277
|
+
return fetch_counts(model, definition) unless @estimated
|
|
278
|
+
|
|
279
|
+
{ count: estimated_count(definition[:name]), oldest_at: nil, newest_at: nil, recent_count: 0 }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def estimated_count(table_name)
|
|
283
|
+
if connection.adapter_name.downcase.include?("postgres")
|
|
284
|
+
estimate = connection.select_value(
|
|
285
|
+
"SELECT reltuples::bigint FROM pg_class WHERE oid = to_regclass(#{connection.quote(table_name.to_s)})"
|
|
286
|
+
).to_i
|
|
287
|
+
# -1 until the table's first ANALYZE; autovacuum runs one after
|
|
288
|
+
# about 50 inserts, so a table without an estimate is small enough
|
|
289
|
+
# to count.
|
|
290
|
+
estimate.negative? ? connection.select_value("SELECT COUNT(*) FROM #{connection.quote_table_name(table_name)}").to_i : estimate
|
|
291
|
+
else
|
|
292
|
+
connection.select_value(
|
|
293
|
+
RailsPulse::ApplicationRecord.sanitize_sql_array([ <<~SQL, table_name.to_s ])
|
|
294
|
+
SELECT TABLE_ROWS
|
|
295
|
+
FROM information_schema.TABLES
|
|
296
|
+
WHERE TABLE_SCHEMA = DATABASE()
|
|
297
|
+
AND TABLE_NAME = ?
|
|
298
|
+
SQL
|
|
299
|
+
).to_i
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
214
303
|
def fetch_counts(model, definition)
|
|
215
304
|
quoted_table = connection.quote_table_name(definition[:name])
|
|
216
305
|
time_column = definition[:time_column]
|
|
@@ -315,12 +404,6 @@ module RailsPulse
|
|
|
315
404
|
row[key] || row[key.to_s] || row[key.to_s.upcase] || row[key.to_sym]
|
|
316
405
|
end
|
|
317
406
|
|
|
318
|
-
def measured_table_bytes(table_name)
|
|
319
|
-
return table_bytes(table_name) unless @cached_sizes
|
|
320
|
-
|
|
321
|
-
self.class.cached_table_bytes(table_name) { table_bytes(table_name) }
|
|
322
|
-
end
|
|
323
|
-
|
|
324
407
|
def table_bytes(table_name)
|
|
325
408
|
adapter = connection.adapter_name.downcase
|
|
326
409
|
|
|
@@ -328,7 +411,7 @@ module RailsPulse
|
|
|
328
411
|
connection.select_value(
|
|
329
412
|
"SELECT pg_total_relation_size(#{connection.quote(table_name.to_s)})"
|
|
330
413
|
).to_i
|
|
331
|
-
elsif
|
|
414
|
+
elsif mysql?
|
|
332
415
|
connection.select_value(
|
|
333
416
|
RailsPulse::ApplicationRecord.sanitize_sql_array([ <<~SQL, table_name.to_s ])
|
|
334
417
|
SELECT DATA_LENGTH + INDEX_LENGTH
|
|
@@ -374,11 +457,19 @@ module RailsPulse
|
|
|
374
457
|
nil
|
|
375
458
|
end
|
|
376
459
|
|
|
460
|
+
def mysql?
|
|
461
|
+
connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def sqlite?
|
|
465
|
+
connection.adapter_name.downcase.include?("sqlite")
|
|
466
|
+
end
|
|
467
|
+
|
|
377
468
|
def adapter_label
|
|
378
469
|
name = connection.adapter_name
|
|
379
470
|
case name.downcase
|
|
380
471
|
when /postgres/ then "PostgreSQL"
|
|
381
|
-
when /mysql/ then "MySQL"
|
|
472
|
+
when /mysql|trilogy/ then "MySQL"
|
|
382
473
|
when /sqlite/ then "SQLite"
|
|
383
474
|
else name
|
|
384
475
|
end
|
|
@@ -407,7 +498,7 @@ module RailsPulse
|
|
|
407
498
|
"File size of the dedicated Rails Pulse SQLite database."
|
|
408
499
|
elsif adapter.include?("postgres")
|
|
409
500
|
"PostgreSQL relation size including indexes (pg_total_relation_size)."
|
|
410
|
-
elsif
|
|
501
|
+
elsif mysql?
|
|
411
502
|
"MySQL data + index length from information_schema."
|
|
412
503
|
end
|
|
413
504
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
2
|
class Deployment < RailsPulse::ApplicationRecord
|
|
3
|
+
include HasMetadata
|
|
4
|
+
|
|
3
5
|
self.table_name = "rails_pulse_deployments"
|
|
4
6
|
|
|
5
7
|
# The create endpoint is reachable with a CI token, so every field a
|
|
@@ -52,13 +54,6 @@ module RailsPulse
|
|
|
52
54
|
finished_at - started_at
|
|
53
55
|
end
|
|
54
56
|
|
|
55
|
-
def metadata_hash
|
|
56
|
-
return {} if metadata.blank?
|
|
57
|
-
JSON.parse(metadata)
|
|
58
|
-
rescue JSON::ParserError
|
|
59
|
-
{}
|
|
60
|
-
end
|
|
61
|
-
|
|
62
57
|
def to_chart_marker
|
|
63
58
|
{ timestamp: started_at.to_i * 1000, revision: revision, started_at: started_at.iso8601 }
|
|
64
59
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# What Rails Pulse noticed, as opposed to what it measured. One row per
|
|
3
|
+
# outcome or sample, tagged by `kind`; `subject` names what it is about,
|
|
4
|
+
# `value` carries its number and `metadata` the kind-specific detail as JSON.
|
|
5
|
+
#
|
|
6
|
+
# Two kinds are written today: writer_heartbeat (see WriterHeartbeat) and
|
|
7
|
+
# cleanup_run (see CleanupRun). The table is generic so a new kind needs no
|
|
8
|
+
# migration. Decision 0019.
|
|
9
|
+
#
|
|
10
|
+
# CleanupService deletes rows older than config.event_retention_period,
|
|
11
|
+
# except kinds listed in config.event_retention_exempt_kinds (rows a writer
|
|
12
|
+
# updates in place). Writer heartbeats are pruned after a day by the writer
|
|
13
|
+
# itself.
|
|
14
|
+
class Event < RailsPulse::ApplicationRecord
|
|
15
|
+
include HasMetadata
|
|
16
|
+
|
|
17
|
+
self.table_name = "rails_pulse_events"
|
|
18
|
+
|
|
19
|
+
validates :kind, :outcome, :occurred_at, presence: true
|
|
20
|
+
|
|
21
|
+
scope :of_kind, ->(*kinds) { where(kind: kinds.flatten.map(&:to_s)) }
|
|
22
|
+
scope :for_subject, ->(subject) { where(subject: subject) }
|
|
23
|
+
scope :since, ->(time) { where(occurred_at: time..) }
|
|
24
|
+
scope :recent, -> { order(occurred_at: :desc) }
|
|
25
|
+
|
|
26
|
+
def self.ransackable_attributes(_auth_object = nil)
|
|
27
|
+
%w[kind subject outcome value occurred_at message created_at updated_at]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.ransackable_associations(_auth_object = nil)
|
|
31
|
+
[]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# table_exists?, rescued — for an upgrader who hasn't migrated yet
|
|
35
|
+
def self.table_available?
|
|
36
|
+
table_exists?
|
|
37
|
+
rescue ActiveRecord::ActiveRecordError
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -16,10 +16,7 @@ module RailsPulse
|
|
|
16
16
|
PER = 1_000
|
|
17
17
|
|
|
18
18
|
def initialize(disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
|
|
19
|
-
|
|
20
|
-
@show_non_tagged = show_non_tagged
|
|
21
|
-
@period = period
|
|
22
|
-
@period_type = period_type
|
|
19
|
+
super
|
|
23
20
|
end
|
|
24
21
|
|
|
25
22
|
def to_metric_card
|
|
@@ -63,8 +60,8 @@ module RailsPulse
|
|
|
63
60
|
.where(period_type: @period_type, period_start: range_start..now)
|
|
64
61
|
.select(
|
|
65
62
|
"SUM(rails_pulse_summaries.count) AS total_count",
|
|
66
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
67
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
63
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_count"),
|
|
64
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count")
|
|
68
65
|
)
|
|
69
66
|
.take
|
|
70
67
|
|
|
@@ -9,10 +9,7 @@ module RailsPulse
|
|
|
9
9
|
# you about the other.
|
|
10
10
|
class OpenGroups < RailsPulse::Cards::Base
|
|
11
11
|
def initialize(disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
|
|
12
|
-
|
|
13
|
-
@show_non_tagged = show_non_tagged
|
|
14
|
-
@period = period
|
|
15
|
-
@period_type = period_type
|
|
12
|
+
super
|
|
16
13
|
end
|
|
17
14
|
|
|
18
15
|
def to_metric_card
|
|
@@ -11,10 +11,7 @@ module RailsPulse
|
|
|
11
11
|
SUMMARIZABLE_TYPE = "RailsPulse::ExceptionGroup".freeze
|
|
12
12
|
|
|
13
13
|
def initialize(disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
|
|
14
|
-
|
|
15
|
-
@show_non_tagged = show_non_tagged
|
|
16
|
-
@period = period
|
|
17
|
-
@period_type = period_type
|
|
14
|
+
super
|
|
18
15
|
end
|
|
19
16
|
|
|
20
17
|
def to_metric_card
|
|
@@ -24,8 +21,8 @@ module RailsPulse
|
|
|
24
21
|
|
|
25
22
|
metrics = base_query.select(
|
|
26
23
|
"SUM(rails_pulse_summaries.count) AS total_count",
|
|
27
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
28
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
24
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_count"),
|
|
25
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count")
|
|
29
26
|
).take
|
|
30
27
|
|
|
31
28
|
total = metrics&.total_count.to_i
|
|
@@ -8,16 +8,15 @@ module RailsPulse
|
|
|
8
8
|
# silently flattens toward zero the further back you look. Summaries are
|
|
9
9
|
# retained at day granularity indefinitely.
|
|
10
10
|
class OccurrenceVolume
|
|
11
|
-
def initialize(
|
|
12
|
-
@
|
|
13
|
-
@end_time = end_time
|
|
11
|
+
def initialize(window:, period_type: "day")
|
|
12
|
+
@window = window
|
|
14
13
|
@period_type = period_type
|
|
15
14
|
end
|
|
16
15
|
|
|
17
16
|
def to_chart_data
|
|
18
17
|
totals = RailsPulse::Summary
|
|
19
18
|
.overall_exceptions
|
|
20
|
-
.where(period_type: @period_type, period_start: @start_time..@end_time)
|
|
19
|
+
.where(period_type: @period_type, period_start: @window.start_time..@window.end_time)
|
|
21
20
|
.group(:period_start)
|
|
22
21
|
.sum(:count)
|
|
23
22
|
|
|
@@ -42,10 +41,8 @@ module RailsPulse
|
|
|
42
41
|
# answer "zero" rather than "no data" — leaving the gap unfilled would
|
|
43
42
|
# draw a chart that skips quiet days and misrepresents the trend.
|
|
44
43
|
def pad_with_zeros(raw)
|
|
45
|
-
step = @period_type.to_s == "hour" ? 3600 : 86_400
|
|
46
|
-
|
|
47
44
|
{}.tap do |padded|
|
|
48
|
-
(@
|
|
45
|
+
@window.bucket_timestamps(@period_type).each do |timestamp|
|
|
49
46
|
padded[timestamp] = raw[timestamp] || 0
|
|
50
47
|
end
|
|
51
48
|
end
|
|
@@ -2,14 +2,8 @@ module RailsPulse
|
|
|
2
2
|
module Jobs
|
|
3
3
|
module Cards
|
|
4
4
|
class FailureRate < RailsPulse::Cards::Base
|
|
5
|
-
def initialize(job: nil,
|
|
6
|
-
|
|
7
|
-
@disabled_tags = disabled_tags
|
|
8
|
-
@show_non_tagged = show_non_tagged
|
|
9
|
-
@period = period
|
|
10
|
-
@period_type = period_type
|
|
11
|
-
@start_time = start_time
|
|
12
|
-
@end_time = end_time
|
|
5
|
+
def initialize(job: nil, period: 14, **kwargs)
|
|
6
|
+
super(subject: job, period: period, **kwargs)
|
|
13
7
|
end
|
|
14
8
|
|
|
15
9
|
def to_metric_card
|
|
@@ -20,15 +14,15 @@ module RailsPulse
|
|
|
20
14
|
period_type: @period_type,
|
|
21
15
|
period_start: range_start..now
|
|
22
16
|
)
|
|
23
|
-
base_query = base_query.where(summarizable_id: @
|
|
17
|
+
base_query = base_query.where(summarizable_id: @subject.id) if @subject
|
|
24
18
|
|
|
25
19
|
metrics = base_query.select(
|
|
26
20
|
"SUM(rails_pulse_summaries.count) AS total_count",
|
|
27
21
|
"SUM(rails_pulse_summaries.error_count) AS total_errors",
|
|
28
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
29
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
30
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
31
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
22
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_count"),
|
|
23
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.error_count ELSE 0 END) AS current_errors"),
|
|
24
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count"),
|
|
25
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.error_count ELSE 0 END) AS previous_errors")
|
|
32
26
|
).take
|
|
33
27
|
|
|
34
28
|
total_runs = metrics&.total_count.to_i
|
|
@@ -52,7 +46,7 @@ module RailsPulse
|
|
|
52
46
|
period_type: @period_type,
|
|
53
47
|
period_start: current_window_start..now
|
|
54
48
|
)
|
|
55
|
-
sparkline_query = sparkline_query.where(summarizable_id: @
|
|
49
|
+
sparkline_query = sparkline_query.where(summarizable_id: @subject.id) if @subject
|
|
56
50
|
|
|
57
51
|
grouped_errors = bucket_by_period(sparkline_query) { |relation| relation.sum("rails_pulse_summaries.error_count") }
|
|
58
52
|
grouped_counts = bucket_by_period(sparkline_query) { |relation| relation.sum("rails_pulse_summaries.count") }
|
|
@@ -2,14 +2,8 @@ module RailsPulse
|
|
|
2
2
|
module Jobs
|
|
3
3
|
module Cards
|
|
4
4
|
class P95Duration < RailsPulse::Cards::Base
|
|
5
|
-
def initialize(job: nil,
|
|
6
|
-
|
|
7
|
-
@disabled_tags = disabled_tags
|
|
8
|
-
@show_non_tagged = show_non_tagged
|
|
9
|
-
@period = period
|
|
10
|
-
@period_type = period_type
|
|
11
|
-
@start_time = start_time
|
|
12
|
-
@end_time = end_time
|
|
5
|
+
def initialize(job: nil, period: 14, **kwargs)
|
|
6
|
+
super(subject: job, period: period, **kwargs)
|
|
13
7
|
end
|
|
14
8
|
|
|
15
9
|
def to_metric_card
|
|
@@ -20,15 +14,15 @@ module RailsPulse
|
|
|
20
14
|
period_type: @period_type,
|
|
21
15
|
period_start: range_start..now
|
|
22
16
|
)
|
|
23
|
-
base_query = base_query.where(summarizable_id: @
|
|
17
|
+
base_query = base_query.where(summarizable_id: @subject.id) if @subject
|
|
24
18
|
|
|
25
19
|
metrics = base_query.select(
|
|
26
20
|
"SUM(rails_pulse_summaries.p95_duration * rails_pulse_summaries.count) AS total_weighted_p95",
|
|
27
21
|
"SUM(rails_pulse_summaries.count) AS total_runs",
|
|
28
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
29
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
30
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
31
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
22
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.p95_duration * rails_pulse_summaries.count ELSE 0 END) AS current_weighted_p95"),
|
|
23
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_runs"),
|
|
24
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.p95_duration * rails_pulse_summaries.count ELSE 0 END) AS previous_weighted_p95"),
|
|
25
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_runs")
|
|
32
26
|
).take
|
|
33
27
|
|
|
34
28
|
total_runs = metrics&.total_runs.to_i
|
|
@@ -42,7 +36,7 @@ module RailsPulse
|
|
|
42
36
|
current_p95 = weighted_average(current_weighted_p95, current_runs)
|
|
43
37
|
previous_p95 = weighted_average(previous_weighted_p95, previous_runs)
|
|
44
38
|
|
|
45
|
-
baseline = baseline_trend_for(@
|
|
39
|
+
baseline = baseline_trend_for(@subject, metric: :p95)
|
|
46
40
|
|
|
47
41
|
if baseline
|
|
48
42
|
trend_icon, trend_amount, trend_caption = baseline
|
|
@@ -2,14 +2,8 @@ module RailsPulse
|
|
|
2
2
|
module Jobs
|
|
3
3
|
module Cards
|
|
4
4
|
class TotalRuns < RailsPulse::Cards::Base
|
|
5
|
-
def initialize(job: nil,
|
|
6
|
-
|
|
7
|
-
@disabled_tags = disabled_tags
|
|
8
|
-
@show_non_tagged = show_non_tagged
|
|
9
|
-
@period = period
|
|
10
|
-
@period_type = period_type
|
|
11
|
-
@start_time = start_time
|
|
12
|
-
@end_time = end_time
|
|
5
|
+
def initialize(job: nil, period: 14, **kwargs)
|
|
6
|
+
super(subject: job, period: period, **kwargs)
|
|
13
7
|
end
|
|
14
8
|
|
|
15
9
|
def to_metric_card
|
|
@@ -20,12 +14,12 @@ module RailsPulse
|
|
|
20
14
|
period_type: @period_type,
|
|
21
15
|
period_start: range_start..now
|
|
22
16
|
)
|
|
23
|
-
base_query = base_query.where(summarizable_id: @
|
|
17
|
+
base_query = base_query.where(summarizable_id: @subject.id) if @subject
|
|
24
18
|
|
|
25
19
|
metrics = base_query.select(
|
|
26
20
|
"SUM(rails_pulse_summaries.count) AS total_count",
|
|
27
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
28
|
-
"SUM(CASE WHEN rails_pulse_summaries.period_start >=
|
|
21
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_count"),
|
|
22
|
+
window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count")
|
|
29
23
|
).take
|
|
30
24
|
|
|
31
25
|
total_runs = metrics&.total_count.to_i
|
|
@@ -31,9 +31,8 @@ module RailsPulse
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# Convert to final values (weighted averages) and pad missing data
|
|
34
|
-
step = time_step
|
|
35
34
|
daily_data = {}
|
|
36
|
-
(@
|
|
35
|
+
@window.bucket_timestamps(@period_type).each do |timestamp|
|
|
37
36
|
if raw_data[timestamp]
|
|
38
37
|
count = raw_data[timestamp][:total_count]
|
|
39
38
|
daily_data[timestamp] = {
|
|
@@ -21,7 +21,7 @@ module RailsPulse
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Pad missing data with zeros using base class helper
|
|
24
|
-
daily_data = pad_data_with_zeros(raw_data
|
|
24
|
+
daily_data = pad_data_with_zeros(raw_data)
|
|
25
25
|
|
|
26
26
|
# Build series data as [timestamp_ms, value] pairs for ECharts time axis
|
|
27
27
|
series = [ {
|
|
@@ -23,9 +23,8 @@ module RailsPulse
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
# Pad missing data with zeros
|
|
26
|
-
step = time_step
|
|
27
26
|
daily_data = {}
|
|
28
|
-
(@
|
|
27
|
+
@window.bucket_timestamps(@period_type).each do |timestamp|
|
|
29
28
|
daily_data[timestamp] = raw_data[timestamp] || nil
|
|
30
29
|
end
|
|
31
30
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# Builds LIKE patterns whose `_`, `%` and escape characters match literally.
|
|
3
|
+
#
|
|
4
|
+
# Every pattern built here must be used with CLAUSE appended to the LIKE
|
|
5
|
+
# expression. SQLite has no default escape character, so `foo!_bar` without an
|
|
6
|
+
# explicit ESCAPE matches a literal `!` while the `_` still wildcards, and the
|
|
7
|
+
# comparison silently matches nothing. `!` is the escape character rather than
|
|
8
|
+
# `\` because MySQL and PostgreSQL disagree on how a backslash is quoted.
|
|
9
|
+
module LikePattern
|
|
10
|
+
ESCAPE_CHARACTER = "!".freeze
|
|
11
|
+
|
|
12
|
+
# Interpolated directly into SQL strings, so it must stay a literal.
|
|
13
|
+
CLAUSE = "ESCAPE '#{ESCAPE_CHARACTER}'".freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param value [#to_s] user-supplied text to match anywhere in a column
|
|
18
|
+
# @return [String] escaped pattern wrapped in leading and trailing wildcards
|
|
19
|
+
def containing(value)
|
|
20
|
+
"%#{escape(value)}%"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param value [#to_s] user-supplied text to match at the start of a column
|
|
24
|
+
# @return [String] escaped pattern followed by a trailing wildcard
|
|
25
|
+
def starting_with(value)
|
|
26
|
+
"#{escape(value)}%"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param value [#to_s] user-supplied text
|
|
30
|
+
# @return [String] the text with LIKE metacharacters escaped
|
|
31
|
+
def escape(value)
|
|
32
|
+
ActiveRecord::Base.sanitize_sql_like(value.to_s, ESCAPE_CHARACTER)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|