dbviewer 0.6.2 → 0.6.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +43 -14
  3. data/app/controllers/concerns/dbviewer/connection_management.rb +88 -0
  4. data/app/controllers/concerns/dbviewer/data_export.rb +32 -0
  5. data/app/controllers/concerns/dbviewer/database_information.rb +62 -0
  6. data/app/controllers/concerns/dbviewer/database_operations.rb +8 -514
  7. data/app/controllers/concerns/dbviewer/datatable_support.rb +47 -0
  8. data/app/controllers/concerns/dbviewer/query_operations.rb +28 -0
  9. data/app/controllers/concerns/dbviewer/relationship_management.rb +173 -0
  10. data/app/controllers/concerns/dbviewer/table_operations.rb +56 -0
  11. data/app/controllers/dbviewer/api/entity_relationship_diagrams_controller.rb +26 -24
  12. data/app/controllers/dbviewer/tables_controller.rb +16 -11
  13. data/app/helpers/dbviewer/application_helper.rb +9 -521
  14. data/app/helpers/dbviewer/database_helper.rb +59 -0
  15. data/app/helpers/dbviewer/filter_helper.rb +137 -0
  16. data/app/helpers/dbviewer/formatting_helper.rb +30 -0
  17. data/app/helpers/dbviewer/navigation_helper.rb +35 -0
  18. data/app/helpers/dbviewer/pagination_helper.rb +72 -0
  19. data/app/helpers/dbviewer/sorting_helper.rb +47 -0
  20. data/app/helpers/dbviewer/table_rendering_helper.rb +145 -0
  21. data/app/helpers/dbviewer/ui_helper.rb +41 -0
  22. data/app/views/dbviewer/tables/show.html.erb +225 -139
  23. data/app/views/layouts/dbviewer/application.html.erb +55 -0
  24. data/lib/dbviewer/database/dynamic_model_factory.rb +40 -5
  25. data/lib/dbviewer/database/manager.rb +2 -3
  26. data/lib/dbviewer/datatable/query_operations.rb +84 -214
  27. data/lib/dbviewer/engine.rb +1 -22
  28. data/lib/dbviewer/query/executor.rb +1 -36
  29. data/lib/dbviewer/query/notification_subscriber.rb +46 -0
  30. data/lib/dbviewer/validator/sql.rb +198 -0
  31. data/lib/dbviewer/validator.rb +9 -0
  32. data/lib/dbviewer/version.rb +1 -1
  33. data/lib/dbviewer.rb +69 -45
  34. data/lib/generators/dbviewer/templates/initializer.rb +15 -0
  35. metadata +20 -3
  36. data/lib/dbviewer/sql_validator.rb +0 -194
@@ -0,0 +1,137 @@
1
+ module Dbviewer
2
+ module FilterHelper
3
+ # Determine default operator based on column type
4
+ def default_operator_for_column_type(column_type)
5
+ if column_type && column_type =~ /char|text|string|uuid|enum/i
6
+ "contains"
7
+ else
8
+ "eq"
9
+ end
10
+ end
11
+
12
+ # Generate operator options based on column type
13
+ def operator_options_for_column_type(column_type)
14
+ # Common operators for all types
15
+ common_operators = [
16
+ [ "is null", "is_null" ],
17
+ [ "is not null", "is_not_null" ]
18
+ ]
19
+
20
+ type_specific_operators = if column_type && (column_type =~ /datetime/ || column_type =~ /^date$/ || column_type =~ /^time$/)
21
+ # Date/Time operators
22
+ [
23
+ [ "=", "eq" ],
24
+ [ "≠", "neq" ],
25
+ [ "<", "lt" ],
26
+ [ ">", "gt" ],
27
+ [ "≤", "lte" ],
28
+ [ "≥", "gte" ]
29
+ ]
30
+ elsif column_type && column_type =~ /int|float|decimal|double|number|numeric|real|money|bigint|smallint|tinyint|mediumint|bit/i
31
+ # Numeric operators
32
+ [
33
+ [ "=", "eq" ],
34
+ [ "≠", "neq" ],
35
+ [ "<", "lt" ],
36
+ [ ">", "gt" ],
37
+ [ "≤", "lte" ],
38
+ [ "≥", "gte" ]
39
+ ]
40
+ else
41
+ # Text operators
42
+ [
43
+ [ "contains", "contains" ],
44
+ [ "not contains", "not_contains" ],
45
+ [ "=", "eq" ],
46
+ [ "≠", "neq" ],
47
+ [ "starts with", "starts_with" ],
48
+ [ "ends with", "ends_with" ]
49
+ ]
50
+ end
51
+
52
+ # Return type-specific operators first, then common operators
53
+ type_specific_operators + common_operators
54
+ end
55
+
56
+ # Render column filter input based on column type
57
+ def render_column_filter_input(form, column_name, column_type, column_filters)
58
+ # Get selected operator to check if it's a null operator
59
+ operator = column_filters["#{column_name}_operator"]
60
+ is_null_operator = operator == "is_null" || operator == "is_not_null"
61
+
62
+ # Clean up the value for non-null operators if the value contains a null operator
63
+ # This ensures we don't carry over 'is_null' or 'is_not_null' values when switching operators
64
+ value = column_filters[column_name]
65
+ if !is_null_operator && value.present? && (value == "is_null" || value == "is_not_null")
66
+ value = nil
67
+ end
68
+
69
+ # For null operators, display a non-editable field without placeholder
70
+ if is_null_operator
71
+ # Keep a hidden field for the actual value
72
+ hidden_field = form.hidden_field("column_filters[#{column_name}]",
73
+ value: operator,
74
+ class: "null-filter-value",
75
+ data: { column: column_name })
76
+
77
+ # Add a visible but disabled text field with no placeholder or value
78
+ visible_field = form.text_field("column_filters[#{column_name}_display]",
79
+ disabled: true,
80
+ value: "",
81
+ class: "form-control form-control-sm column-filter rounded-0 disabled-filter",
82
+ data: { column: "#{column_name}_display" })
83
+
84
+ hidden_field + visible_field
85
+ elsif column_type && column_type =~ /datetime/
86
+ form.datetime_local_field("column_filters[#{column_name}]",
87
+ value: value,
88
+ class: "form-control form-control-sm column-filter rounded-0",
89
+ data: { column: column_name })
90
+ elsif column_type && column_type =~ /^date$/
91
+ form.date_field("column_filters[#{column_name}]",
92
+ value: value,
93
+ class: "form-control form-control-sm column-filter rounded-0",
94
+ data: { column: column_name })
95
+ elsif column_type && column_type =~ /^time$/
96
+ form.time_field("column_filters[#{column_name}]",
97
+ value: value,
98
+ class: "form-control form-control-sm column-filter rounded-0",
99
+ data: { column: column_name })
100
+ else
101
+ form.text_field("column_filters[#{column_name}]",
102
+ value: value,
103
+ placeholder: "",
104
+ class: "form-control form-control-sm column-filter rounded-0",
105
+ data: { column: column_name })
106
+ end
107
+ end
108
+
109
+ # Render operator select for column filter
110
+ def render_operator_select(form, column_name, column_type, column_filters)
111
+ # Get previously selected operator or default
112
+ default_operator = default_operator_for_column_type(column_type)
113
+ selected_operator = column_filters["#{column_name}_operator"]
114
+ selected_operator = default_operator if selected_operator.nil? || selected_operator == "default"
115
+
116
+ # Get appropriate options
117
+ operator_options = operator_options_for_column_type(column_type)
118
+
119
+ form.select("column_filters[#{column_name}_operator]",
120
+ options_for_select(operator_options, selected_operator),
121
+ { include_blank: false },
122
+ { class: "form-select form-select-sm operator-select" })
123
+ end
124
+
125
+ # Render complete filter input group for a column
126
+ def render_column_filter(form, column_name, columns, column_filters)
127
+ column_type = column_type_from_info(column_name, columns)
128
+
129
+ content_tag(:div, class: "filter-input-group") do
130
+ operator_select = render_operator_select(form, column_name, column_type, column_filters)
131
+ input_field = render_column_filter_input(form, column_name, column_type, column_filters)
132
+
133
+ operator_select + input_field
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,30 @@
1
+ module Dbviewer
2
+ module FormattingHelper
3
+ def format_cell_value(value)
4
+ return "NULL" if value.nil?
5
+ return value.to_s.truncate(100) unless value.is_a?(String)
6
+
7
+ case value
8
+ when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
9
+ # ISO 8601 datetime
10
+ begin
11
+ Time.parse(value).strftime("%Y-%m-%d %H:%M:%S")
12
+ rescue
13
+ value.to_s.truncate(100)
14
+ end
15
+ when /\A\d{4}-\d{2}-\d{2}\z/
16
+ # Date
17
+ value
18
+ when /\A{.+}\z/, /\A\[.+\]\z/
19
+ # JSON
20
+ begin
21
+ JSON.pretty_generate(JSON.parse(value)).truncate(100)
22
+ rescue
23
+ value.to_s.truncate(100)
24
+ end
25
+ else
26
+ value.to_s.truncate(100)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,35 @@
1
+ module Dbviewer
2
+ module NavigationHelper
3
+ # Helper method to determine if current controller and action match
4
+ def active_nav_class(controller_name, action_name = nil)
5
+ current_controller = params[:controller].split("/").last
6
+ active = current_controller == controller_name
7
+
8
+ if action_name.present?
9
+ active = active && params[:action] == action_name
10
+ end
11
+
12
+ active ? "active" : ""
13
+ end
14
+
15
+ # Helper for highlighting dashboard link
16
+ def dashboard_nav_class
17
+ active_nav_class("home")
18
+ end
19
+
20
+ # Helper for highlighting tables link
21
+ def tables_nav_class
22
+ active_nav_class("tables")
23
+ end
24
+
25
+ # Helper for highlighting ERD link
26
+ def erd_nav_class
27
+ active_nav_class("entity_relationship_diagrams")
28
+ end
29
+
30
+ # Helper for highlighting SQL Logs link
31
+ def logs_nav_class
32
+ active_nav_class("logs")
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,72 @@
1
+ module Dbviewer
2
+ module PaginationHelper
3
+ # Common parameters for pagination and filtering
4
+ def common_params(options = {})
5
+ params = {
6
+ order_by: @order_by,
7
+ order_direction: @order_direction,
8
+ per_page: @per_page,
9
+ column_filters: @column_filters
10
+ }.merge(options)
11
+
12
+ # Add creation filters if they exist
13
+ params[:creation_filter_start] = @creation_filter_start if @creation_filter_start.present?
14
+ params[:creation_filter_end] = @creation_filter_end if @creation_filter_end.present?
15
+
16
+ params
17
+ end
18
+
19
+ # Render pagination UI
20
+ def render_pagination(table_name, current_page, total_pages, params = {})
21
+ return unless total_pages && total_pages > 1
22
+
23
+ content_tag(:nav, 'aria-label': "Page navigation") do
24
+ content_tag(:ul, class: "pagination justify-content-center") do
25
+ prev_link = content_tag(:li, class: "page-item #{current_page == 1 ? 'disabled' : ''}") do
26
+ link_to "«", table_path(table_name, params.merge(page: [ current_page - 1, 1 ].max)), class: "page-link"
27
+ end
28
+
29
+ # Calculate page range to display
30
+ start_page = [ 1, current_page - 2 ].max
31
+ end_page = [ start_page + 4, total_pages ].min
32
+ start_page = [ 1, end_page - 4 ].max
33
+
34
+ # Generate page links
35
+ page_links = (start_page..end_page).map do |page_num|
36
+ content_tag(:li, class: "page-item #{page_num == current_page ? 'active' : ''}") do
37
+ link_to page_num, table_path(table_name, params.merge(page: page_num)), class: "page-link"
38
+ end
39
+ end.join.html_safe
40
+
41
+ next_link = content_tag(:li, class: "page-item #{current_page == total_pages ? 'disabled' : ''}") do
42
+ link_to "»", table_path(table_name, params.merge(page: [ current_page + 1, total_pages ].min)), class: "page-link"
43
+ end
44
+
45
+ prev_link + page_links + next_link
46
+ end
47
+ end
48
+ end
49
+
50
+ # Generate URL parameters for per-page dropdown
51
+ def per_page_url_params(table_name)
52
+ # Start with the dynamic part for the select element
53
+ url_params = "per_page=' + this.value + '&page=1"
54
+
55
+ # Add all other common parameters except per_page and page which we already set
56
+ params = common_params.except(:per_page, :page)
57
+
58
+ # Convert the params hash to URL parameters
59
+ params.each do |key, value|
60
+ if key == :column_filters && value.is_a?(Hash) && value.reject { |_, v| v.blank? }.any?
61
+ value.reject { |_, v| v.blank? }.each do |filter_key, filter_value|
62
+ url_params += "&column_filters[#{filter_key}]=#{CGI.escape(filter_value.to_s)}"
63
+ end
64
+ elsif value.present?
65
+ url_params += "&#{key}=#{CGI.escape(value.to_s)}"
66
+ end
67
+ end
68
+
69
+ url_params
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,47 @@
1
+ module Dbviewer
2
+ module SortingHelper
3
+ # Returns a sort icon based on the current sort direction
4
+ def sort_icon(column_name, current_order_by, current_direction)
5
+ if column_name == current_order_by
6
+ direction = current_direction == "ASC" ? "up" : "down"
7
+ "<i class='bi bi-sort-#{direction}'></i>".html_safe
8
+ else
9
+ "<i class='bi bi-filter invisible sort-icon'></i>".html_safe
10
+ end
11
+ end
12
+
13
+ # Determine the next sort direction based on the current one
14
+ def next_sort_direction(column_name, current_order_by, current_direction)
15
+ if column_name == current_order_by && current_direction == "ASC"
16
+ "DESC"
17
+ else
18
+ "ASC"
19
+ end
20
+ end
21
+
22
+ # Generate a sortable column header link
23
+ def sortable_column_header(column_name, current_order_by, current_direction, table_name, current_page, per_page, column_filters)
24
+ is_sorted = column_name == current_order_by
25
+ sort_direction = next_sort_direction(column_name, current_order_by, current_direction)
26
+
27
+ aria_sort = if is_sorted
28
+ current_direction.downcase == "asc" ? "ascending" : "descending"
29
+ else
30
+ "none"
31
+ end
32
+
33
+ # Use common_params helper to build parameters
34
+ sort_params = common_params(order_by: column_name, order_direction: sort_direction)
35
+
36
+ link_to table_path(table_name, sort_params),
37
+ class: "d-flex align-items-center text-decoration-none text-reset column-sort-link",
38
+ title: "Sort by #{column_name} (#{sort_direction.downcase})",
39
+ "aria-sort": aria_sort,
40
+ role: "button",
41
+ tabindex: "0" do
42
+ content_tag(:span, column_name, class: "column-name") +
43
+ content_tag(:span, sort_icon(column_name, current_order_by, current_direction), class: "sort-icon-container")
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,145 @@
1
+ module Dbviewer
2
+ module TableRenderingHelper
3
+ # Render a complete table header row with sortable columns
4
+ def render_sortable_header_row(records, order_by, order_direction, table_name, current_page, per_page, column_filters)
5
+ return content_tag(:tr) { content_tag(:th, "No columns available") } unless records&.columns
6
+
7
+ content_tag(:tr) do
8
+ # Start with action column header (sticky first column)
9
+ headers = [
10
+ content_tag(:th, class: "px-3 py-2 text-center action-column action-column-header", width: "60px", rowspan: 2) do
11
+ content_tag(:span, "Actions")
12
+ end
13
+ ]
14
+
15
+ # Add all data columns
16
+ headers += records.columns.map do |column_name|
17
+ is_sorted = order_by == column_name
18
+ content_tag(:th, class: "px-3 py-2 sortable-column #{is_sorted ? 'sorted' : ''}") do
19
+ sortable_column_header(column_name, order_by, order_direction, table_name, current_page, per_page, column_filters)
20
+ end
21
+ end
22
+
23
+ headers.join.html_safe
24
+ end
25
+ end
26
+
27
+ # Render the column filters row
28
+ def render_column_filters_row(form, records, columns, column_filters)
29
+ return content_tag(:tr) { content_tag(:th, "") } unless records&.columns
30
+
31
+ content_tag(:tr, class: "column-filters") do
32
+ filters = records.columns.map do |column_name|
33
+ content_tag(:th, class: "p-0") do
34
+ render_column_filter(form, column_name, columns, column_filters)
35
+ end
36
+ end
37
+
38
+ filters.join.html_safe
39
+ end
40
+ end
41
+
42
+ # Render a cell that may include a foreign key link
43
+ def render_table_cell(cell, column_name, metadata)
44
+ cell_value = format_cell_value(cell)
45
+ foreign_key = metadata && metadata[:foreign_keys] ?
46
+ metadata[:foreign_keys].find { |fk| fk[:column] == column_name } :
47
+ nil
48
+
49
+ if foreign_key && !cell.nil?
50
+ fk_params = { column_filters: { foreign_key[:primary_key] => cell } }
51
+ fk_params = fk_params.merge(common_params.except(:column_filters))
52
+
53
+ content_tag(:td, title: "#{cell_value} (Click to view referenced record)") do
54
+ link_to(cell_value, table_path(foreign_key[:to_table], fk_params),
55
+ class: "text-decoration-none foreign-key-link") +
56
+ content_tag(:i, "", class: "bi bi-link-45deg text-muted small")
57
+ end
58
+ else
59
+ content_tag(:td, cell_value, title: cell_value)
60
+ end
61
+ end
62
+
63
+ # Render a table row with cells
64
+ def render_table_row(row, records, metadata)
65
+ content_tag(:tr) do
66
+ # Start with action column (sticky first column)
67
+ cells = [ render_action_cell(row, records.columns, metadata) ]
68
+
69
+ # Add all data cells
70
+ cells += row.each_with_index.map do |cell, cell_index|
71
+ column_name = records.columns[cell_index]
72
+ render_table_cell(cell, column_name, metadata)
73
+ end
74
+
75
+ cells.join.html_safe
76
+ end
77
+ end
78
+
79
+ # Render the entire table body with rows
80
+ def render_table_body(records, metadata)
81
+ if records.nil? || records.rows.nil? || records.empty?
82
+ content_tag(:tbody) do
83
+ content_tag(:tr) do
84
+ # Adding +1 to account for the action column
85
+ total_columns = records&.columns&.size.to_i + 1
86
+ content_tag(:td, "No records found or table is empty.", colspan: total_columns, class: "text-center")
87
+ end
88
+ end
89
+ else
90
+ content_tag(:tbody) do
91
+ records.rows.map do |row|
92
+ render_table_row(row, records, metadata)
93
+ end.join.html_safe
94
+ end
95
+ end
96
+ end
97
+
98
+ # Render action buttons for a record
99
+ def render_action_cell(row_data, columns, metadata = nil)
100
+ data_attributes = {}
101
+
102
+ # Create a hash of column_name: value pairs for data attributes
103
+ columns.each_with_index do |column_name, index|
104
+ data_attributes[column_name] = row_data[index].to_s
105
+ end
106
+
107
+ content_tag(:td, class: "text-center action-column") do
108
+ content_tag(:div, class: "d-flex gap-1 justify-content-center") do
109
+ # View Record button (existing)
110
+ view_button = button_tag(
111
+ type: "button",
112
+ class: "btn btn-sm btn-primary view-record-btn",
113
+ title: "View Record Details",
114
+ data: {
115
+ bs_toggle: "modal",
116
+ bs_target: "#recordDetailModal",
117
+ record_data: data_attributes.to_json,
118
+ foreign_keys: metadata && metadata[:foreign_keys] ? metadata[:foreign_keys].to_json : "[]",
119
+ reverse_foreign_keys: metadata && metadata[:reverse_foreign_keys] ? metadata[:reverse_foreign_keys].to_json : "[]"
120
+ }
121
+ ) do
122
+ content_tag(:i, "", class: "bi bi-eye")
123
+ end
124
+
125
+ # Copy FactoryBot button (new)
126
+ copy_factory_button = button_tag(
127
+ type: "button",
128
+ class: "btn btn-sm btn-outline-secondary copy-factory-btn",
129
+ title: "Copy to JSON",
130
+ data: {
131
+ record_data: data_attributes.to_json,
132
+ table_name: @table_name
133
+ },
134
+ onclick: "copyToJson(this)"
135
+ ) do
136
+ content_tag(:i, "", class: "bi bi-clipboard")
137
+ end
138
+
139
+ # Concatenate both buttons
140
+ view_button + copy_factory_button
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,41 @@
1
+ module Dbviewer
2
+ module UiHelper
3
+ # Dark mode helper methods
4
+
5
+ # Returns the theme toggle icon based on the current theme
6
+ def theme_toggle_icon
7
+ '<i class="bi bi-moon"></i><i class="bi bi-sun"></i>'.html_safe
8
+ end
9
+
10
+ # Returns the aria label for the theme toggle button
11
+ def theme_toggle_label
12
+ "Toggle dark mode"
13
+ end
14
+
15
+ # Returns the appropriate background class for stat cards that adapts to dark mode
16
+ def stat_card_bg_class
17
+ "stat-card-bg"
18
+ end
19
+
20
+ # Helper method for code blocks background that adapts to dark mode
21
+ def code_block_bg_class
22
+ "sql-code-block"
23
+ end
24
+
25
+ # Render time grouping links
26
+ def time_grouping_links(table_name, current_grouping)
27
+ params = common_params
28
+
29
+ content_tag(:div, class: "btn-group btn-group-sm", role: "group", 'aria-label': "Time grouping") do
30
+ [
31
+ link_to("Hourly", table_path(table_name, params.merge(time_group: "hourly")),
32
+ class: "btn btn-outline-primary #{current_grouping == 'hourly' ? 'active' : ''}"),
33
+ link_to("Daily", table_path(table_name, params.merge(time_group: "daily")),
34
+ class: "btn btn-outline-primary #{current_grouping == 'daily' ? 'active' : ''}"),
35
+ link_to("Weekly", table_path(table_name, params.merge(time_group: "weekly")),
36
+ class: "btn btn-outline-primary #{current_grouping == 'weekly' ? 'active' : ''}")
37
+ ].join.html_safe
38
+ end
39
+ end
40
+ end
41
+ end