dbviewer 0.6.3 → 0.6.5

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.
@@ -1,545 +1,13 @@
1
1
  module Dbviewer
2
2
  module ApplicationHelper
3
- # Check if a table has a created_at column
4
- def has_timestamp_column?(table_name)
5
- return false unless table_name.present?
6
-
7
- # Get the columns for the table directly using DatabaseManager
8
- columns = get_database_manager.table_columns(table_name)
9
- columns.any? { |col| col[:name] == "created_at" && [ :datetime, :timestamp ].include?(col[:type]) }
10
- end
11
-
12
- # Helper to access the database manager
13
- def get_database_manager
14
- @database_manager ||= ::Dbviewer::Database::Manager.new
15
- end
16
-
17
- # Extract column type from columns info
18
- def column_type_from_info(column_name, columns)
19
- return nil unless columns.present?
20
-
21
- column_info = columns.find { |c| c[:name].to_s == column_name.to_s }
22
- column_info ? column_info[:type].to_s.downcase : nil
23
- end
24
-
25
- # Determine default operator based on column type
26
- def default_operator_for_column_type(column_type)
27
- if column_type && column_type =~ /char|text|string|uuid|enum/i
28
- "contains"
29
- else
30
- "eq"
31
- end
32
- end
33
-
34
- # Generate operator options based on column type
35
- def operator_options_for_column_type(column_type)
36
- # Common operators for all types
37
- common_operators = [
38
- [ "is null", "is_null" ],
39
- [ "is not null", "is_not_null" ]
40
- ]
41
-
42
- type_specific_operators = if column_type && (column_type =~ /datetime/ || column_type =~ /^date$/ || column_type =~ /^time$/)
43
- # Date/Time operators
44
- [
45
- [ "=", "eq" ],
46
- [ "≠", "neq" ],
47
- [ "<", "lt" ],
48
- [ ">", "gt" ],
49
- [ "≤", "lte" ],
50
- [ "≥", "gte" ]
51
- ]
52
- elsif column_type && column_type =~ /int|float|decimal|double|number|numeric|real|money|bigint|smallint|tinyint|mediumint|bit/i
53
- # Numeric operators
54
- [
55
- [ "=", "eq" ],
56
- [ "≠", "neq" ],
57
- [ "<", "lt" ],
58
- [ ">", "gt" ],
59
- [ "≤", "lte" ],
60
- [ "≥", "gte" ]
61
- ]
62
- else
63
- # Text operators
64
- [
65
- [ "contains", "contains" ],
66
- [ "not contains", "not_contains" ],
67
- [ "=", "eq" ],
68
- [ "≠", "neq" ],
69
- [ "starts with", "starts_with" ],
70
- [ "ends with", "ends_with" ]
71
- ]
72
- end
73
-
74
- # Return type-specific operators first, then common operators
75
- type_specific_operators + common_operators
76
- end
77
-
78
- # Render column filter input based on column type
79
- def render_column_filter_input(form, column_name, column_type, column_filters)
80
- # Get selected operator to check if it's a null operator
81
- operator = column_filters["#{column_name}_operator"]
82
- is_null_operator = operator == "is_null" || operator == "is_not_null"
83
-
84
- # Clean up the value for non-null operators if the value contains a null operator
85
- # This ensures we don't carry over 'is_null' or 'is_not_null' values when switching operators
86
- value = column_filters[column_name]
87
- if !is_null_operator && value.present? && (value == "is_null" || value == "is_not_null")
88
- value = nil
89
- end
90
-
91
- # For null operators, display a non-editable field without placeholder
92
- if is_null_operator
93
- # Keep a hidden field for the actual value
94
- hidden_field = form.hidden_field("column_filters[#{column_name}]",
95
- value: operator,
96
- class: "null-filter-value",
97
- data: { column: column_name })
98
-
99
- # Add a visible but disabled text field with no placeholder or value
100
- visible_field = form.text_field("column_filters[#{column_name}_display]",
101
- disabled: true,
102
- value: "",
103
- class: "form-control form-control-sm column-filter rounded-0 disabled-filter",
104
- data: { column: "#{column_name}_display" })
105
-
106
- hidden_field + visible_field
107
- elsif column_type && column_type =~ /datetime/
108
- form.datetime_local_field("column_filters[#{column_name}]",
109
- value: value,
110
- class: "form-control form-control-sm column-filter rounded-0",
111
- data: { column: column_name })
112
- elsif column_type && column_type =~ /^date$/
113
- form.date_field("column_filters[#{column_name}]",
114
- value: value,
115
- class: "form-control form-control-sm column-filter rounded-0",
116
- data: { column: column_name })
117
- elsif column_type && column_type =~ /^time$/
118
- form.time_field("column_filters[#{column_name}]",
119
- value: value,
120
- class: "form-control form-control-sm column-filter rounded-0",
121
- data: { column: column_name })
122
- else
123
- form.text_field("column_filters[#{column_name}]",
124
- value: value,
125
- placeholder: "",
126
- class: "form-control form-control-sm column-filter rounded-0",
127
- data: { column: column_name })
128
- end
129
- end
130
-
131
- # Render operator select for column filter
132
- def render_operator_select(form, column_name, column_type, column_filters)
133
- # Get previously selected operator or default
134
- default_operator = default_operator_for_column_type(column_type)
135
- selected_operator = column_filters["#{column_name}_operator"]
136
- selected_operator = default_operator if selected_operator.nil? || selected_operator == "default"
137
-
138
- # Get appropriate options
139
- operator_options = operator_options_for_column_type(column_type)
140
-
141
- form.select("column_filters[#{column_name}_operator]",
142
- options_for_select(operator_options, selected_operator),
143
- { include_blank: false },
144
- { class: "form-select form-select-sm operator-select" })
145
- end
146
-
147
- # Render complete filter input group for a column
148
- def render_column_filter(form, column_name, columns, column_filters)
149
- column_type = column_type_from_info(column_name, columns)
150
-
151
- content_tag(:div, class: "filter-input-group") do
152
- operator_select = render_operator_select(form, column_name, column_type, column_filters)
153
- input_field = render_column_filter_input(form, column_name, column_type, column_filters)
154
-
155
- operator_select + input_field
156
- end
157
- end
158
-
159
- def format_cell_value(value)
160
- return "NULL" if value.nil?
161
- return value.to_s.truncate(100) unless value.is_a?(String)
162
-
163
- case value
164
- when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
165
- # ISO 8601 datetime
166
- begin
167
- Time.parse(value).strftime("%Y-%m-%d %H:%M:%S")
168
- rescue
169
- value.to_s.truncate(100)
170
- end
171
- when /\A\d{4}-\d{2}-\d{2}\z/
172
- # Date
173
- value
174
- when /\A{.+}\z/, /\A\[.+\]\z/
175
- # JSON
176
- begin
177
- JSON.pretty_generate(JSON.parse(value)).truncate(100)
178
- rescue
179
- value.to_s.truncate(100)
180
- end
181
- else
182
- value.to_s.truncate(100)
183
- end
184
- end
185
-
186
- # Common parameters for pagination and filtering
187
- def common_params(options = {})
188
- params = {
189
- order_by: @order_by,
190
- order_direction: @order_direction,
191
- per_page: @per_page,
192
- column_filters: @column_filters
193
- }.merge(options)
194
-
195
- # Add creation filters if they exist
196
- params[:creation_filter_start] = @creation_filter_start if @creation_filter_start.present?
197
- params[:creation_filter_end] = @creation_filter_end if @creation_filter_end.present?
198
-
199
- params
200
- end
201
-
202
- # Render pagination UI
203
- def render_pagination(table_name, current_page, total_pages, params = {})
204
- return unless total_pages && total_pages > 1
205
-
206
- content_tag(:nav, 'aria-label': "Page navigation") do
207
- content_tag(:ul, class: "pagination justify-content-center") do
208
- prev_link = content_tag(:li, class: "page-item #{current_page == 1 ? 'disabled' : ''}") do
209
- link_to "«", table_path(table_name, params.merge(page: [ current_page - 1, 1 ].max)), class: "page-link"
210
- end
211
-
212
- # Calculate page range to display
213
- start_page = [ 1, current_page - 2 ].max
214
- end_page = [ start_page + 4, total_pages ].min
215
- start_page = [ 1, end_page - 4 ].max
216
-
217
- # Generate page links
218
- page_links = (start_page..end_page).map do |page_num|
219
- content_tag(:li, class: "page-item #{page_num == current_page ? 'active' : ''}") do
220
- link_to page_num, table_path(table_name, params.merge(page: page_num)), class: "page-link"
221
- end
222
- end.join.html_safe
223
-
224
- next_link = content_tag(:li, class: "page-item #{current_page == total_pages ? 'disabled' : ''}") do
225
- link_to "»", table_path(table_name, params.merge(page: [ current_page + 1, total_pages ].min)), class: "page-link"
226
- end
227
-
228
- prev_link + page_links + next_link
229
- end
230
- end
231
- end
232
-
233
- # Generate URL parameters for per-page dropdown
234
- def per_page_url_params(table_name)
235
- # Start with the dynamic part for the select element
236
- url_params = "per_page=' + this.value + '&page=1"
237
-
238
- # Add all other common parameters except per_page and page which we already set
239
- params = common_params.except(:per_page, :page)
240
-
241
- # Convert the params hash to URL parameters
242
- params.each do |key, value|
243
- if key == :column_filters && value.is_a?(Hash) && value.reject { |_, v| v.blank? }.any?
244
- value.reject { |_, v| v.blank? }.each do |filter_key, filter_value|
245
- url_params += "&column_filters[#{filter_key}]=#{CGI.escape(filter_value.to_s)}"
246
- end
247
- elsif value.present?
248
- url_params += "&#{key}=#{CGI.escape(value.to_s)}"
249
- end
250
- end
251
-
252
- url_params
253
- end
254
-
255
- # Render time grouping links
256
- def time_grouping_links(table_name, current_grouping)
257
- params = common_params
258
-
259
- content_tag(:div, class: "btn-group btn-group-sm", role: "group", 'aria-label': "Time grouping") do
260
- [
261
- link_to("Hourly", table_path(table_name, params.merge(time_group: "hourly")),
262
- class: "btn btn-outline-primary #{current_grouping == 'hourly' ? 'active' : ''}"),
263
- link_to("Daily", table_path(table_name, params.merge(time_group: "daily")),
264
- class: "btn btn-outline-primary #{current_grouping == 'daily' ? 'active' : ''}"),
265
- link_to("Weekly", table_path(table_name, params.merge(time_group: "weekly")),
266
- class: "btn btn-outline-primary #{current_grouping == 'weekly' ? 'active' : ''}")
267
- ].join.html_safe
268
- end
269
- end
270
-
271
- # Dark mode helper methods
272
-
273
- # Returns the theme toggle icon based on the current theme
274
- def theme_toggle_icon
275
- '<i class="bi bi-moon"></i><i class="bi bi-sun"></i>'.html_safe
276
- end
277
-
278
- # Returns the aria label for the theme toggle button
279
- def theme_toggle_label
280
- "Toggle dark mode"
281
- end
282
-
283
- # Returns the appropriate background class for stat cards that adapts to dark mode
284
- def stat_card_bg_class
285
- "stat-card-bg"
286
- end
287
-
288
- # Helper method for code blocks background that adapts to dark mode
289
- def code_block_bg_class
290
- "sql-code-block"
291
- end
292
-
293
- # Determine if the current table should be active in the sidebar
294
- def current_table?(table_name)
295
- @table_name.present? && @table_name == table_name
296
- end
297
-
298
- # Format table name for display - truncate if too long
299
- def format_table_name(table_name)
300
- if table_name.length > 20
301
- "#{table_name.first(17)}..."
302
- else
303
- table_name
304
- end
305
- end
306
-
307
- # Get appropriate icon for column data type
308
- def column_type_icon(column_type)
309
- case column_type.to_s.downcase
310
- when /int/, /serial/, /number/, /decimal/, /float/, /double/
311
- "bi-123"
312
- when /char/, /text/, /string/, /uuid/
313
- "bi-fonts"
314
- when /date/, /time/
315
- "bi-calendar"
316
- when /bool/
317
- "bi-toggle-on"
318
- when /json/, /jsonb/
319
- "bi-braces"
320
- when /array/
321
- "bi-list-ol"
322
- else
323
- "bi-file-earmark"
324
- end
325
- end
326
-
327
- # Helper method to determine if current controller and action match
328
- def active_nav_class(controller_name, action_name = nil)
329
- current_controller = params[:controller].split("/").last
330
- active = current_controller == controller_name
331
-
332
- if action_name.present?
333
- active = active && params[:action] == action_name
334
- end
335
-
336
- active ? "active" : ""
337
- end
338
-
339
- # Helper for highlighting dashboard link
340
- def dashboard_nav_class
341
- active_nav_class("home")
342
- end
343
-
344
- # Helper for highlighting tables link
345
- def tables_nav_class
346
- active_nav_class("tables")
347
- end
348
-
349
- # Helper for highlighting ERD link
350
- def erd_nav_class
351
- active_nav_class("entity_relationship_diagrams")
352
- end
353
-
354
- # Helper for highlighting SQL Logs link
355
- def logs_nav_class
356
- active_nav_class("logs")
357
- end
358
-
359
- # Returns a sort icon based on the current sort direction
360
- def sort_icon(column_name, current_order_by, current_direction)
361
- if column_name == current_order_by
362
- direction = current_direction == "ASC" ? "up" : "down"
363
- "<i class='bi bi-sort-#{direction}'></i>".html_safe
364
- else
365
- "<i class='bi bi-filter invisible sort-icon'></i>".html_safe
366
- end
367
- end
368
-
369
- # Determine the next sort direction based on the current one
370
- def next_sort_direction(column_name, current_order_by, current_direction)
371
- if column_name == current_order_by && current_direction == "ASC"
372
- "DESC"
373
- else
374
- "ASC"
375
- end
376
- end
377
-
378
- # Generate a sortable column header link
379
- def sortable_column_header(column_name, current_order_by, current_direction, table_name, current_page, per_page, column_filters)
380
- is_sorted = column_name == current_order_by
381
- sort_direction = next_sort_direction(column_name, current_order_by, current_direction)
382
-
383
- aria_sort = if is_sorted
384
- current_direction.downcase == "asc" ? "ascending" : "descending"
385
- else
386
- "none"
387
- end
388
-
389
- # Use common_params helper to build parameters
390
- sort_params = common_params(order_by: column_name, order_direction: sort_direction)
391
-
392
- link_to table_path(table_name, sort_params),
393
- class: "d-flex align-items-center text-decoration-none text-reset column-sort-link",
394
- title: "Sort by #{column_name} (#{sort_direction.downcase})",
395
- "aria-sort": aria_sort,
396
- role: "button",
397
- tabindex: "0" do
398
- content_tag(:span, column_name, class: "column-name") +
399
- content_tag(:span, sort_icon(column_name, current_order_by, current_direction), class: "sort-icon-container")
400
- end
401
- end
402
-
403
- # Render a complete table header row with sortable columns
404
- def render_sortable_header_row(records, order_by, order_direction, table_name, current_page, per_page, column_filters)
405
- return content_tag(:tr) { content_tag(:th, "No columns available") } unless records&.columns
406
-
407
- content_tag(:tr) do
408
- # Start with action column header (sticky first column)
409
- headers = [
410
- content_tag(:th, class: "px-3 py-2 text-center action-column action-column-header", width: "60px", rowspan: 2) do
411
- content_tag(:span, "Actions")
412
- end
413
- ]
414
-
415
- # Add all data columns
416
- headers += records.columns.map do |column_name|
417
- is_sorted = order_by == column_name
418
- content_tag(:th, class: "px-3 py-2 sortable-column #{is_sorted ? 'sorted' : ''}") do
419
- sortable_column_header(column_name, order_by, order_direction, table_name, current_page, per_page, column_filters)
420
- end
421
- end
422
-
423
- headers.join.html_safe
424
- end
425
- end
426
-
427
- # Render the column filters row
428
- def render_column_filters_row(form, records, columns, column_filters)
429
- return content_tag(:tr) { content_tag(:th, "") } unless records&.columns
430
-
431
- content_tag(:tr, class: "column-filters") do
432
- filters = records.columns.map do |column_name|
433
- content_tag(:th, class: "p-0") do
434
- render_column_filter(form, column_name, columns, column_filters)
435
- end
436
- end
437
-
438
- filters.join.html_safe
439
- end
440
- end
441
-
442
- # Render a cell that may include a foreign key link
443
- def render_table_cell(cell, column_name, metadata)
444
- cell_value = format_cell_value(cell)
445
- foreign_key = metadata && metadata[:foreign_keys] ?
446
- metadata[:foreign_keys].find { |fk| fk[:column] == column_name } :
447
- nil
448
-
449
- if foreign_key && !cell.nil?
450
- fk_params = { column_filters: { foreign_key[:primary_key] => cell } }
451
- fk_params = fk_params.merge(common_params.except(:column_filters))
452
-
453
- content_tag(:td, title: "#{cell_value} (Click to view referenced record)") do
454
- link_to(cell_value, table_path(foreign_key[:to_table], fk_params),
455
- class: "text-decoration-none foreign-key-link") +
456
- content_tag(:i, "", class: "bi bi-link-45deg text-muted small")
457
- end
458
- else
459
- content_tag(:td, cell_value, title: cell_value)
460
- end
461
- end
462
-
463
- # Render a table row with cells
464
- def render_table_row(row, records, metadata)
465
- content_tag(:tr) do
466
- # Start with action column (sticky first column)
467
- cells = [ render_action_cell(row, records.columns, metadata) ]
468
-
469
- # Add all data cells
470
- cells += row.each_with_index.map do |cell, cell_index|
471
- column_name = records.columns[cell_index]
472
- render_table_cell(cell, column_name, metadata)
473
- end
474
-
475
- cells.join.html_safe
476
- end
477
- end
478
-
479
- # Render the entire table body with rows
480
- def render_table_body(records, metadata)
481
- if records.nil? || records.rows.nil? || records.empty?
482
- content_tag(:tbody) do
483
- content_tag(:tr) do
484
- # Adding +1 to account for the action column
485
- total_columns = records&.columns&.size.to_i + 1
486
- content_tag(:td, "No records found or table is empty.", colspan: total_columns, class: "text-center")
487
- end
488
- end
489
- else
490
- content_tag(:tbody) do
491
- records.rows.map do |row|
492
- render_table_row(row, records, metadata)
493
- end.join.html_safe
494
- end
495
- end
496
- end
497
-
498
- # Render action buttons for a record
499
- def render_action_cell(row_data, columns, metadata = nil)
500
- data_attributes = {}
501
-
502
- # Create a hash of column_name: value pairs for data attributes
503
- columns.each_with_index do |column_name, index|
504
- data_attributes[column_name] = row_data[index].to_s
505
- end
506
-
507
- content_tag(:td, class: "text-center action-column") do
508
- content_tag(:div, class: "d-flex gap-1 justify-content-center") do
509
- # View Record button (existing)
510
- view_button = button_tag(
511
- type: "button",
512
- class: "btn btn-sm btn-primary view-record-btn",
513
- title: "View Record Details",
514
- data: {
515
- bs_toggle: "modal",
516
- bs_target: "#recordDetailModal",
517
- record_data: data_attributes.to_json,
518
- foreign_keys: metadata && metadata[:foreign_keys] ? metadata[:foreign_keys].to_json : "[]",
519
- reverse_foreign_keys: metadata && metadata[:reverse_foreign_keys] ? metadata[:reverse_foreign_keys].to_json : "[]"
520
- }
521
- ) do
522
- content_tag(:i, "", class: "bi bi-eye")
523
- end
524
-
525
- # Copy FactoryBot button (new)
526
- copy_factory_button = button_tag(
527
- type: "button",
528
- class: "btn btn-sm btn-outline-secondary copy-factory-btn",
529
- title: "Copy to JSON",
530
- data: {
531
- record_data: data_attributes.to_json,
532
- table_name: @table_name
533
- },
534
- onclick: "copyToJson(this)"
535
- ) do
536
- content_tag(:i, "", class: "bi bi-clipboard")
537
- end
538
-
539
- # Concatenate both buttons
540
- view_button + copy_factory_button
541
- end
542
- end
543
- end
3
+ # Include all the helper modules organized by logical concerns
4
+ include DatabaseHelper
5
+ include FilterHelper
6
+ include FormattingHelper
7
+ include PaginationHelper
8
+ include SortingHelper
9
+ include TableRenderingHelper
10
+ include NavigationHelper
11
+ include UiHelper
544
12
  end
545
13
  end
@@ -0,0 +1,59 @@
1
+ module Dbviewer
2
+ module DatabaseHelper
3
+ # Check if a table has a created_at column
4
+ def has_timestamp_column?(table_name)
5
+ return false unless table_name.present?
6
+
7
+ # Get the columns for the table directly using DatabaseManager
8
+ columns = get_database_manager.table_columns(table_name)
9
+ columns.any? { |col| col[:name] == "created_at" && [ :datetime, :timestamp ].include?(col[:type]) }
10
+ end
11
+
12
+ # Helper to access the database manager
13
+ def get_database_manager
14
+ @database_manager ||= ::Dbviewer::Database::Manager.new
15
+ end
16
+
17
+ # Extract column type from columns info
18
+ def column_type_from_info(column_name, columns)
19
+ return nil unless columns.present?
20
+
21
+ column_info = columns.find { |c| c[:name].to_s == column_name.to_s }
22
+ column_info ? column_info[:type].to_s.downcase : nil
23
+ end
24
+
25
+ # Get appropriate icon for column data type
26
+ def column_type_icon(column_type)
27
+ case column_type.to_s.downcase
28
+ when /int/, /serial/, /number/, /decimal/, /float/, /double/
29
+ "bi-123"
30
+ when /char/, /text/, /string/, /uuid/
31
+ "bi-fonts"
32
+ when /date/, /time/
33
+ "bi-calendar"
34
+ when /bool/
35
+ "bi-toggle-on"
36
+ when /json/, /jsonb/
37
+ "bi-braces"
38
+ when /array/
39
+ "bi-list-ol"
40
+ else
41
+ "bi-file-earmark"
42
+ end
43
+ end
44
+
45
+ # Determine if the current table should be active in the sidebar
46
+ def current_table?(table_name)
47
+ @table_name.present? && @table_name == table_name
48
+ end
49
+
50
+ # Format table name for display - truncate if too long
51
+ def format_table_name(table_name)
52
+ if table_name.length > 20
53
+ "#{table_name.first(17)}..."
54
+ else
55
+ table_name
56
+ end
57
+ end
58
+ end
59
+ end