dbviewer 0.3.3 → 0.3.4
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/app/controllers/concerns/dbviewer/database_operations.rb +11 -1
- data/app/controllers/dbviewer/tables_controller.rb +9 -1
- data/app/views/dbviewer/tables/show.html.erb +127 -26
- data/app/views/layouts/dbviewer/application.html.erb +2 -1
- data/lib/dbviewer/database_manager.rb +65 -1
- data/lib/dbviewer/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c368abfe3b3131b1df3b3234e1e560c2cb5f715c035680d3848ff5ff07d99cc3
|
4
|
+
data.tar.gz: 7f9b27e43d86b0181f14d9deceebcb1d6624b22cb51885ab5c3b929814cb97f9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 05f35000d4c57ec2e17e0c0fe56d49520add9f0c061debc25f5f6407ec1e52ece7a1d4361c0b92d283135492bc78d86890083e9781a30705ebcddc0fce2e58df
|
7
|
+
data.tar.gz: a76035a207c87105f9331dc4246a133da9941a5a9e435133b80254fc0037578bc69b5dbb17cb40113049ba50625d3a81816df118015446af8e9c368127e481c9
|
@@ -139,15 +139,25 @@ module Dbviewer
|
|
139
139
|
|
140
140
|
# Fetch records for a table with pagination and sorting
|
141
141
|
def fetch_table_records(table_name)
|
142
|
+
column_filters = params[:column_filters] || {}
|
143
|
+
# Clean up blank filters
|
144
|
+
column_filters.reject! { |_, v| v.blank? }
|
145
|
+
|
142
146
|
database_manager.table_records(
|
143
147
|
table_name,
|
144
148
|
@current_page,
|
145
149
|
@order_by,
|
146
150
|
@order_direction,
|
147
|
-
@per_page
|
151
|
+
@per_page,
|
152
|
+
column_filters || {}
|
148
153
|
)
|
149
154
|
end
|
150
155
|
|
156
|
+
# Get filtered record count for a table
|
157
|
+
def fetch_filtered_record_count(table_name, column_filters)
|
158
|
+
database_manager.filtered_record_count(table_name, column_filters)
|
159
|
+
end
|
160
|
+
|
151
161
|
# Safely quote a table name, with fallback
|
152
162
|
def safe_quote_table_name(table_name)
|
153
163
|
database_manager.connection.quote_table_name(table_name)
|
@@ -15,7 +15,15 @@ module Dbviewer
|
|
15
15
|
set_pagination_params
|
16
16
|
set_sorting_params
|
17
17
|
|
18
|
-
|
18
|
+
# Extract column filters from params
|
19
|
+
@column_filters = params[:column_filters].presence ? params[:column_filters].to_enum.to_h : {}
|
20
|
+
|
21
|
+
if @column_filters.present? && @column_filters.values.any?(&:present?)
|
22
|
+
@total_count = fetch_filtered_record_count(@table_name, @column_filters)
|
23
|
+
else
|
24
|
+
@total_count = fetch_table_record_count(@table_name)
|
25
|
+
end
|
26
|
+
|
19
27
|
@total_pages = calculate_total_pages(@total_count, @per_page)
|
20
28
|
@records = fetch_table_records(@table_name)
|
21
29
|
|
@@ -66,51 +66,73 @@
|
|
66
66
|
<div class="d-flex align-items-center">
|
67
67
|
<div class="me-3">
|
68
68
|
<label for="per-page-select" class="me-2">Per page:</label>
|
69
|
-
<select id="per-page-select" class="form-select form-select-sm" onchange="window.location.href='<%= table_path(@table_name) %>?per_page=' + this.value + '&page=1&order_by=<%= @order_by %>&order_direction=<%= @order_direction %>'">
|
69
|
+
<select id="per-page-select" class="form-select form-select-sm" onchange="window.location.href='<%= table_path(@table_name) %>?per_page=' + this.value + '&page=1&order_by=<%= @order_by %>&order_direction=<%= @order_direction %><%= @column_filters.reject { |_, v| v.blank? }.any? ? "&" + @column_filters.reject { |_, v| v.blank? }.map { |k, v| "column_filters[#{k}]=#{CGI.escape(v.to_s)}" }.join("&") : "" %>'">
|
70
70
|
<% Dbviewer::TablesController.per_page_options.each do |option| %>
|
71
71
|
<option value="<%= option %>" <%= 'selected' if @per_page == option %>><%= option %></option>
|
72
72
|
<% end %>
|
73
73
|
</select>
|
74
74
|
</div>
|
75
75
|
<span class="badge bg-secondary">Total: <%= @total_count %> records</span>
|
76
|
+
<% active_filters = @column_filters.reject { |_, v| v.blank? }.size %>
|
77
|
+
<% if active_filters > 0 %>
|
78
|
+
<span class="badge bg-info ms-2" title="Active filters"><i class="bi bi-funnel-fill me-1"></i><%= active_filters %></span>
|
79
|
+
<% end %>
|
76
80
|
</div>
|
77
81
|
</div>
|
78
82
|
<div class="card-body p-0">
|
79
83
|
<div class="table-responsive dbviewer-scrollable">
|
80
|
-
|
81
|
-
<%
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
</tr>
|
90
|
-
</thead>
|
91
|
-
<tbody>
|
92
|
-
<% @records.rows.each do |row| %>
|
84
|
+
<%= form_with(url: table_path(@table_name), method: :get, local: true, id: "column-filters-form", class: "mb-0") do |form| %>
|
85
|
+
<% # Hidden fields to preserve current parameters %>
|
86
|
+
<%= form.hidden_field :per_page, value: @per_page %>
|
87
|
+
<%= form.hidden_field :order_by, value: @order_by %>
|
88
|
+
<%= form.hidden_field :order_direction, value: @order_direction %>
|
89
|
+
<%= form.hidden_field :page, value: 1 %> <!-- Reset to first page on filter -->
|
90
|
+
|
91
|
+
<table class="table table-bordered table-striped rounded-none">
|
92
|
+
<thead class="dbviewer-table-header">
|
93
93
|
<tr>
|
94
|
-
<%
|
95
|
-
|
96
|
-
|
94
|
+
<% @records.columns.each do |column_name| %>
|
95
|
+
<th class="pe-4">
|
96
|
+
<%= column_name %>
|
97
|
+
</th>
|
97
98
|
<% end %>
|
98
99
|
</tr>
|
99
|
-
|
100
|
+
<tr class="column-filters">
|
101
|
+
<% @records.columns.each do |column_name| %>
|
102
|
+
<th class="p-0">
|
103
|
+
<%= form.text_field "column_filters[#{column_name}]",
|
104
|
+
value: @column_filters[column_name],
|
105
|
+
placeholder: "",
|
106
|
+
class: "form-control form-control-sm column-filter rounded-0",
|
107
|
+
data: { column: column_name } %>
|
108
|
+
</th>
|
109
|
+
<% end %>
|
110
|
+
</tr>
|
111
|
+
</thead>
|
112
|
+
<tbody>
|
113
|
+
<% if @records.empty? %>
|
114
|
+
<tr>
|
115
|
+
<td colspan="100%" class="text-center">No records found or table is empty.</td>
|
116
|
+
</tr>
|
117
|
+
<% end %>
|
118
|
+
<% @records.rows.each do |row| %>
|
119
|
+
<tr>
|
120
|
+
<% row.each do |cell| %>
|
121
|
+
<% cell_value = format_cell_value(cell) %>
|
122
|
+
<td title="<%= cell_value %>"><%= cell_value %></td>
|
123
|
+
<% end %>
|
124
|
+
</tr>
|
125
|
+
<% end %>
|
100
126
|
</tbody>
|
101
|
-
<% else %>
|
102
|
-
<tr>
|
103
|
-
<td colspan="100%">No records found or table is empty.</td>
|
104
|
-
</tr>
|
105
|
-
<% end %>
|
106
127
|
</table>
|
128
|
+
<% end %> <!-- End of form_with -->
|
107
129
|
</div>
|
108
130
|
|
109
131
|
<% if @total_pages > 1 %>
|
110
132
|
<nav aria-label="Page navigation">
|
111
133
|
<ul class="pagination justify-content-center">
|
112
134
|
<li class="page-item <%= 'disabled' if @current_page == 1 %>">
|
113
|
-
<%= link_to '«', table_path(@table_name, page: [@current_page - 1, 1].max, order_by: @order_by, order_direction: @order_direction, per_page: @per_page), class: 'page-link' %>
|
135
|
+
<%= link_to '«', table_path(@table_name, page: [@current_page - 1, 1].max, order_by: @order_by, order_direction: @order_direction, per_page: @per_page, column_filters: @column_filters), class: 'page-link' %>
|
114
136
|
</li>
|
115
137
|
|
116
138
|
<% start_page = [1, @current_page - 2].max %>
|
@@ -119,12 +141,12 @@
|
|
119
141
|
|
120
142
|
<% (start_page..end_page).each do |page_num| %>
|
121
143
|
<li class="page-item <%= 'active' if page_num == @current_page %>">
|
122
|
-
<%= link_to page_num, table_path(@table_name, page: page_num, order_by: @order_by, order_direction: @order_direction, per_page: @per_page), class: 'page-link' %>
|
144
|
+
<%= link_to page_num, table_path(@table_name, page: page_num, order_by: @order_by, order_direction: @order_direction, per_page: @per_page, column_filters: @column_filters), class: 'page-link' %>
|
123
145
|
</li>
|
124
146
|
<% end %>
|
125
147
|
|
126
148
|
<li class="page-item <%= 'disabled' if @current_page == @total_pages %>">
|
127
|
-
<%= link_to '»', table_path(@table_name, page: [@current_page + 1, @total_pages].min, order_by: @order_by, order_direction: @order_direction, per_page: @per_page), class: 'page-link' %>
|
149
|
+
<%= link_to '»', table_path(@table_name, page: [@current_page + 1, @total_pages].min, order_by: @order_by, order_direction: @order_direction, per_page: @per_page, column_filters: @column_filters), class: 'page-link' %>
|
128
150
|
</li>
|
129
151
|
</ul>
|
130
152
|
</nav>
|
@@ -190,6 +212,85 @@
|
|
190
212
|
</div>
|
191
213
|
</div>
|
192
214
|
|
215
|
+
<script>
|
216
|
+
document.addEventListener('DOMContentLoaded', function() {
|
217
|
+
// Column filter functionality
|
218
|
+
const columnFilters = document.querySelectorAll('.column-filter');
|
219
|
+
const filterForm = document.getElementById('column-filters-form');
|
220
|
+
|
221
|
+
// Add debounce function to reduce form submissions
|
222
|
+
function debounce(func, wait) {
|
223
|
+
let timeout;
|
224
|
+
return function() {
|
225
|
+
const context = this;
|
226
|
+
const args = arguments;
|
227
|
+
clearTimeout(timeout);
|
228
|
+
timeout = setTimeout(function() {
|
229
|
+
func.apply(context, args);
|
230
|
+
}, wait);
|
231
|
+
};
|
232
|
+
}
|
233
|
+
|
234
|
+
// Function to submit the form
|
235
|
+
const submitForm = debounce(function() {
|
236
|
+
filterForm.submit();
|
237
|
+
}, 500);
|
238
|
+
|
239
|
+
// Add event listeners to all filter inputs
|
240
|
+
columnFilters.forEach(function(filter) {
|
241
|
+
filter.addEventListener('input', submitForm);
|
242
|
+
});
|
243
|
+
|
244
|
+
// Add clear button functionality if there are any filters applied
|
245
|
+
const hasActiveFilters = Array.from(columnFilters).some(input => input.value);
|
246
|
+
|
247
|
+
if (hasActiveFilters) {
|
248
|
+
// Add a clear filters button
|
249
|
+
const paginationContainer = document.querySelector('nav[aria-label="Page navigation"]') ||
|
250
|
+
document.querySelector('.table-responsive');
|
251
|
+
|
252
|
+
if (paginationContainer) {
|
253
|
+
const clearButton = document.createElement('div');
|
254
|
+
clearButton.className = 'text-center mt-3';
|
255
|
+
clearButton.innerHTML = '<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-filters">' +
|
256
|
+
'<i class="bi bi-x-circle me-1"></i>Clear All Filters</button>';
|
257
|
+
|
258
|
+
paginationContainer.insertAdjacentHTML('afterend', clearButton.outerHTML);
|
259
|
+
|
260
|
+
document.getElementById('clear-filters').addEventListener('click', function() {
|
261
|
+
columnFilters.forEach(filter => filter.value = '');
|
262
|
+
submitForm();
|
263
|
+
});
|
264
|
+
}
|
265
|
+
}
|
266
|
+
});
|
267
|
+
</script>
|
268
|
+
|
269
|
+
<style>
|
270
|
+
/* Column filter styling */
|
271
|
+
.column-filters td {
|
272
|
+
padding: 0.5rem;
|
273
|
+
background-color: var(--bs-tertiary-bg, #f8f9fa);
|
274
|
+
}
|
275
|
+
|
276
|
+
.column-filter {
|
277
|
+
width: 100%;
|
278
|
+
border: 1px solid rgba(0,0,0,0.1);
|
279
|
+
padding: 0.3rem 0.5rem;
|
280
|
+
font-size: 0.85rem;
|
281
|
+
}
|
282
|
+
|
283
|
+
[data-bs-theme="dark"] .column-filters td {
|
284
|
+
background-color: rgba(255,255,255,0.05);
|
285
|
+
}
|
286
|
+
|
287
|
+
[data-bs-theme="dark"] .column-filter {
|
288
|
+
background-color: rgba(255,255,255,0.1);
|
289
|
+
color: rgba(255,255,255,0.9);
|
290
|
+
border-color: rgba(255,255,255,0.15);
|
291
|
+
}
|
292
|
+
</style>
|
293
|
+
|
193
294
|
<% if @timestamp_data.present? %>
|
194
295
|
<script>
|
195
296
|
document.addEventListener('DOMContentLoaded', function() {
|
@@ -211,7 +211,8 @@
|
|
211
211
|
border: 1px solid #495057;
|
212
212
|
}
|
213
213
|
|
214
|
-
.dbviewer-scrollable { max-height:
|
214
|
+
.dbviewer-scrollable { max-height: 700px; overflow-y: auto; }
|
215
|
+
.dbviewer-scrollable thead { position: sticky; top: 0; z-index: 1; }
|
215
216
|
|
216
217
|
/* Badge styling for dark mode */
|
217
218
|
[data-bs-theme="dark"] .bg-secondary-subtle {
|
@@ -75,9 +75,10 @@ module Dbviewer
|
|
75
75
|
# @param direction [String] Sort direction ('ASC' or 'DESC')
|
76
76
|
# @param per_page [Integer] Number of records per page
|
77
77
|
# @return [ActiveRecord::Result] Result set with columns and rows
|
78
|
-
def table_records(table_name, page = 1, order_by = nil, direction = "ASC", per_page = nil)
|
78
|
+
def table_records(table_name, page = 1, order_by = nil, direction = "ASC", per_page = nil, column_filters = nil)
|
79
79
|
page = [ 1, page.to_i ].max
|
80
80
|
default_per_page = self.class.default_per_page
|
81
|
+
column_filters ||= {}
|
81
82
|
max_records = self.class.max_records
|
82
83
|
per_page = (per_page || default_per_page).to_i
|
83
84
|
|
@@ -87,6 +88,32 @@ module Dbviewer
|
|
87
88
|
model = get_model_for(table_name)
|
88
89
|
query = model.all
|
89
90
|
|
91
|
+
# Apply column filters if provided
|
92
|
+
if column_filters.present?
|
93
|
+
column_filters.each do |column, value|
|
94
|
+
next if value.blank?
|
95
|
+
next unless column_exists?(table_name, column)
|
96
|
+
|
97
|
+
# Use LIKE for string-based searches, = for exact matches on other types
|
98
|
+
column_info = table_columns(table_name).find { |c| c[:name] == column }
|
99
|
+
if column_info
|
100
|
+
column_type = column_info[:type].to_s
|
101
|
+
|
102
|
+
if column_type =~ /char|text|string|uuid|enum/i
|
103
|
+
query = query.where("#{connection.quote_column_name(column)} LIKE ?", "%#{value}%")
|
104
|
+
else
|
105
|
+
# For numeric types, try exact match if value looks like a number
|
106
|
+
if value =~ /\A[+-]?\d+(\.\d+)?\z/
|
107
|
+
query = query.where(column => value)
|
108
|
+
else
|
109
|
+
# Otherwise, try string comparison for non-string fields
|
110
|
+
query = query.where("CAST(#{connection.quote_column_name(column)} AS CHAR) LIKE ?", "%#{value}%")
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
90
117
|
# Apply sorting if provided
|
91
118
|
if order_by.present? && column_exists?(table_name, order_by)
|
92
119
|
direction = %w[ASC DESC].include?(direction.to_s.upcase) ? direction.to_s.upcase : "ASC"
|
@@ -110,6 +137,43 @@ module Dbviewer
|
|
110
137
|
table_count(table_name)
|
111
138
|
end
|
112
139
|
|
140
|
+
# Get the number of records in a table with filters applied
|
141
|
+
# @param table_name [String] Name of the table
|
142
|
+
# @param column_filters [Hash] Hash of column_name => filter_value for filtering
|
143
|
+
# @return [Integer] Number of filtered records
|
144
|
+
def filtered_record_count(table_name, column_filters = {})
|
145
|
+
model = get_model_for(table_name)
|
146
|
+
query = model.all
|
147
|
+
|
148
|
+
# Apply column filters if provided
|
149
|
+
if column_filters.present?
|
150
|
+
column_filters.each do |column, value|
|
151
|
+
next if value.blank?
|
152
|
+
next unless column_exists?(table_name, column)
|
153
|
+
|
154
|
+
# Use LIKE for string-based searches, = for exact matches on other types
|
155
|
+
column_info = table_columns(table_name).find { |c| c[:name] == column }
|
156
|
+
if column_info
|
157
|
+
column_type = column_info[:type].to_s
|
158
|
+
|
159
|
+
if column_type =~ /char|text|string|uuid|enum/i
|
160
|
+
query = query.where("#{connection.quote_column_name(column)} LIKE ?", "%#{value}%")
|
161
|
+
else
|
162
|
+
# For numeric types, try exact match if value looks like a number
|
163
|
+
if value =~ /\A[+-]?\d+(\.\d+)?\z/
|
164
|
+
query = query.where(column => value)
|
165
|
+
else
|
166
|
+
# Otherwise, try string comparison for non-string fields
|
167
|
+
query = query.where("CAST(#{connection.quote_column_name(column)} AS CHAR) LIKE ?", "%#{value}%")
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
query.count
|
175
|
+
end
|
176
|
+
|
113
177
|
# Get the number of columns in a table
|
114
178
|
# @param table_name [String] Name of the table
|
115
179
|
# @return [Integer] Number of columns
|
data/lib/dbviewer/version.rb
CHANGED