rdbr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +155 -0
  5. data/app/public/application.js +609 -0
  6. data/app/public/database-diagram.js +602 -0
  7. data/app/public/styles.css +2141 -0
  8. data/app/views/data.haml +166 -0
  9. data/app/views/group.haml +50 -0
  10. data/app/views/index.haml +108 -0
  11. data/app/views/layout.haml +59 -0
  12. data/app/views/record.haml +81 -0
  13. data/demo/README.md +47 -0
  14. data/demo/compose.yml +34 -0
  15. data/demo/seed.rb +270 -0
  16. data/demo/smoke.rb +13 -0
  17. data/docs/RELEASING.md +30 -0
  18. data/docs/VALUE_PRESENTATION.md +54 -0
  19. data/exe/rdbr +5 -0
  20. data/lib/rdbr/access/read_only.rb +61 -0
  21. data/lib/rdbr/access.rb +6 -0
  22. data/lib/rdbr/browser_launcher.rb +71 -0
  23. data/lib/rdbr/catalog/connection.rb +68 -0
  24. data/lib/rdbr/catalog/errors.rb +12 -0
  25. data/lib/rdbr/catalog/mysql/queries.rb +131 -0
  26. data/lib/rdbr/catalog/mysql.rb +146 -0
  27. data/lib/rdbr/catalog/postgresql/queries.rb +171 -0
  28. data/lib/rdbr/catalog/postgresql.rb +138 -0
  29. data/lib/rdbr/catalog/reader.rb +30 -0
  30. data/lib/rdbr/catalog/serializer.rb +24 -0
  31. data/lib/rdbr/catalog/sqlite.rb +182 -0
  32. data/lib/rdbr/catalog.rb +20 -0
  33. data/lib/rdbr/cli.rb +74 -0
  34. data/lib/rdbr/cli_options.rb +63 -0
  35. data/lib/rdbr/metadata/column.rb +44 -0
  36. data/lib/rdbr/metadata/constraints.rb +25 -0
  37. data/lib/rdbr/metadata/database.rb +37 -0
  38. data/lib/rdbr/metadata/index.rb +30 -0
  39. data/lib/rdbr/metadata/keys.rb +56 -0
  40. data/lib/rdbr/metadata/namespace.rb +26 -0
  41. data/lib/rdbr/metadata/relation.rb +104 -0
  42. data/lib/rdbr/metadata/relationship_inference.rb +75 -0
  43. data/lib/rdbr/metadata/relationships.rb +126 -0
  44. data/lib/rdbr/metadata/values.rb +73 -0
  45. data/lib/rdbr/metadata.rb +10 -0
  46. data/lib/rdbr/presentation/record_identity.rb +125 -0
  47. data/lib/rdbr/query/connection.rb +138 -0
  48. data/lib/rdbr/query/errors.rb +33 -0
  49. data/lib/rdbr/query/lookup.rb +40 -0
  50. data/lib/rdbr/query/mysql.rb +15 -0
  51. data/lib/rdbr/query/page.rb +66 -0
  52. data/lib/rdbr/query/postgresql.rb +7 -0
  53. data/lib/rdbr/query/postgresql_dialect.rb +11 -0
  54. data/lib/rdbr/query/relational.rb +239 -0
  55. data/lib/rdbr/query/sqlite.rb +15 -0
  56. data/lib/rdbr/query/type_support.rb +68 -0
  57. data/lib/rdbr/query/value_limiter.rb +38 -0
  58. data/lib/rdbr/query.rb +32 -0
  59. data/lib/rdbr/version.rb +3 -0
  60. data/lib/rdbr/web/application.rb +234 -0
  61. data/lib/rdbr/web/array_value_helpers.rb +92 -0
  62. data/lib/rdbr/web/binary_value_helpers.rb +51 -0
  63. data/lib/rdbr/web/column_preferences.rb +56 -0
  64. data/lib/rdbr/web/database_graph.rb +91 -0
  65. data/lib/rdbr/web/deferred_column_value_helpers.rb +20 -0
  66. data/lib/rdbr/web/deferred_value_helpers.rb +76 -0
  67. data/lib/rdbr/web/document_value_helpers.rb +68 -0
  68. data/lib/rdbr/web/filter_presentation_helpers.rb +44 -0
  69. data/lib/rdbr/web/helpers.rb +42 -0
  70. data/lib/rdbr/web/identifier_value_helpers.rb +20 -0
  71. data/lib/rdbr/web/json_page_stream.rb +30 -0
  72. data/lib/rdbr/web/navigation_helpers.rb +115 -0
  73. data/lib/rdbr/web/presentation_helpers.rb +114 -0
  74. data/lib/rdbr/web/range_value_helpers.rb +86 -0
  75. data/lib/rdbr/web/record_value_helpers.rb +90 -0
  76. data/lib/rdbr/web/related_value_helpers.rb +99 -0
  77. data/lib/rdbr/web/relationship_loader.rb +65 -0
  78. data/lib/rdbr/web/search_value_helpers.rb +58 -0
  79. data/lib/rdbr/web/spatial_value_helpers.rb +83 -0
  80. data/lib/rdbr/web/through_relationship_loader.rb +90 -0
  81. data/lib/rdbr/web/vector_value_helpers.rb +59 -0
  82. data/lib/rdbr.rb +25 -0
  83. metadata +298 -0
@@ -0,0 +1,166 @@
1
+ %section.page-heading.table-page-heading
2
+ .heading-copy
3
+ %h2= @relation.name
4
+ - if @relation.comment
5
+ %p= @relation.comment
6
+ %form.table-search{ action: '/data', method: 'get', role: 'search' }
7
+ %input{ name: 'namespace', type: 'hidden', value: @namespace.name }
8
+ %input{ name: 'relation', type: 'hidden', value: @relation.name }
9
+ %input{ name: 'per_page', type: 'hidden', value: @page.per_page }
10
+ - if params['sort'].to_s != ''
11
+ %input{ name: 'sort', type: 'hidden', value: params['sort'] }
12
+ - filter_entries.each do |entry|
13
+ %input{ name: "filter[#{entry.fetch(:column)}][#{entry.fetch(:operator)}]", type: 'hidden', value: entry.fetch(:value) }
14
+ %span{ 'aria-hidden': 'true' } 🔍
15
+ %input{ id: 'search', name: 'search', type: 'search', value: params['search'], placeholder: 'Search',
16
+ 'aria-label': 'Search text columns' }
17
+ .heading-actions.table-heading-actions
18
+ .table-pagination
19
+ .rows-control
20
+ %label{ for: 'per-page' } Rows
21
+ %select{ id: 'per-page', data: { page_size: true } }
22
+ - RDBr::Web::Application::PAGE_SIZES.each do |size|
23
+ %option{ value: size, selected: @page.per_page == size }= size
24
+ - if @page.total_pages > 1
25
+ %nav.pagination{ 'aria-label': 'Pagination' }
26
+ - if @page.page > 1
27
+ %a{ href: page_url(@page.page - 1), rel: 'prev', 'aria-label': 'Previous page' } ‹
28
+ - else
29
+ %span.disabled{ 'aria-label': 'Previous page' } ‹
30
+ %a{ href: page_url(1), 'aria-label': 'First page' } 1
31
+ %label.visually-hidden{ for: 'page-number' } Page number
32
+ %input{ id: 'page-number', type: 'text', inputmode: 'numeric', pattern: '[0-9]*', value: @page.page,
33
+ data: { page_jump: true, last_page: @page.total_pages }, 'aria-label': 'Page number' }
34
+ %a{ href: page_url(@page.total_pages), 'aria-label': 'Last page' }= @page.total_pages
35
+ - if @page.has_next
36
+ %a{ href: page_url(@page.page + 1), rel: 'next', 'aria-label': 'Next page' } ›
37
+ - else
38
+ %span.disabled{ 'aria-label': 'Next page' } ›
39
+ %button.column-picker-trigger{ type: 'button', data: { column_picker_trigger: true },
40
+ 'aria-haspopup': 'dialog', 'aria-controls': 'column-picker',
41
+ 'aria-expanded': 'false' } Columns
42
+ %a{ href: query_url('/data.json', request.params) } JSON
43
+
44
+ #date-filter.date-filter.popup-backdrop{ role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'date-filter-title', hidden: true }
45
+ .date-filter-panel.popup-surface
46
+ %button.dialog-close{ type: 'button', data: { close_date_filter: true }, 'aria-label': 'Close' } ×
47
+ %h3#date-filter-title Filter dates
48
+ %p.date-filter-column
49
+ .date-presets
50
+ %button{ type: 'button', data: { date_preset: 'today' } } Today
51
+ %button{ type: 'button', data: { date_preset: 'yesterday' } } Yesterday
52
+ %button{ type: 'button', data: { date_preset: 'last-7-days' } } Last 7 days
53
+ %button{ type: 'button', data: { date_preset: 'last-30-days' } } Last 30 days
54
+ %button{ type: 'button', data: { date_preset: 'this-month' } } This month
55
+ %form.date-range-form
56
+ %label
57
+ From
58
+ .iso-date-input
59
+ %input{ type: 'text', data: { date_from: true }, required: true, inputmode: 'numeric',
60
+ placeholder: 'YYYY-MM-DD', pattern: '\d{4}-\d{2}-\d{2}',
61
+ title: 'Enter a date in YYYY-MM-DD format' }
62
+ %button.date-picker-button{ type: 'button', data: { open_date_picker: 'from' },
63
+ 'aria-label': 'Choose From date from calendar' } 📅
64
+ %input.native-date-picker{ type: 'date', data: { native_date_from: true },
65
+ tabindex: '-1', 'aria-hidden': 'true' }
66
+ %label
67
+ To
68
+ .iso-date-input
69
+ %input{ type: 'text', data: { date_to: true }, required: true, inputmode: 'numeric',
70
+ placeholder: 'YYYY-MM-DD', pattern: '\d{4}-\d{2}-\d{2}',
71
+ title: 'Enter a date in YYYY-MM-DD format' }
72
+ %button.date-picker-button{ type: 'button', data: { open_date_picker: 'to' },
73
+ 'aria-label': 'Choose To date from calendar' } 📅
74
+ %input.native-date-picker{ type: 'date', data: { native_date_to: true },
75
+ tabindex: '-1', 'aria-hidden': 'true' }
76
+ %button{ type: 'submit' } Apply
77
+
78
+ #column-picker.column-picker.popup-surface{ role: 'dialog', 'aria-labelledby': 'column-picker-title', hidden: true }
79
+ %div{ data: { column_picker_form: true } }
80
+ .column-picker-heading
81
+ %h3#column-picker-title Columns
82
+ %button.dialog-close{ type: 'button', data: { close_column_picker: true }, 'aria-label': 'Close' } ×
83
+ %p Drag to reorder, or use the arrow buttons.
84
+ %ol.column-picker-list
85
+ - @available_columns.each do |column|
86
+ %li{ draggable: 'true', data: { column_name: column.name } }
87
+ %span.column-drag-handle{ 'aria-hidden': 'true' } ⋮⋮
88
+ %label
89
+ %input{ type: 'checkbox', checked: @visible_columns.include?(column) }
90
+ %span= display_column_name(column)
91
+ .column-move-actions
92
+ %button{ type: 'button', data: { move_column: 'up' },
93
+ title: "Move #{column.name} left", 'aria-label': "Move #{column.name} left" } ←
94
+ %button{ type: 'button', data: { move_column: 'down' },
95
+ title: "Move #{column.name} right", 'aria-label': "Move #{column.name} right" } →
96
+ .column-picker-actions
97
+ %button.secondary-button{ type: 'button', data: { show_all_columns: true } } Show all
98
+ %button.secondary-button{ type: 'button', data: { reset_columns: true } } Reset
99
+
100
+ - if filter_entries.any? || params['search'].to_s != ''
101
+ %section.active-filters{ 'aria-label': 'Active filters' }
102
+ .filter-chips
103
+ - filter_entries.each do |entry|
104
+ - filter_value, filter_column = filter_chip_parts(entry)
105
+ - filter_related = filter_related_value(entry)
106
+ %span.filter-chip
107
+ %small= filter_column
108
+ %strong= filter_value
109
+ - if filter_related
110
+ %a.relationship-link{ href: related_value_url(filter_related),
111
+ title: "Open related #{entry.fetch(:column)}",
112
+ 'aria-label': "Open related #{entry.fetch(:column)}" } ↗
113
+ %a.remove-filter{ href: remove_filter_url(entry.fetch(:column), entry.fetch(:operator)),
114
+ title: "Remove #{entry.fetch(:column)} filter", 'aria-label': "Remove #{entry.fetch(:column)} filter" } ×
115
+ - if params['search'].to_s != ''
116
+ %span.filter-chip.search-chip
117
+ %small Search
118
+ %strong= params['search']
119
+ %a.remove-filter{ href: remove_search_url, title: 'Remove search', 'aria-label': 'Remove search' } ×
120
+ %a.clear-all{ href: clear_filters_url } Clear all
121
+
122
+ %section.data-section{ data: { column_preference_cookie: @column_preferences.cookie_name,
123
+ column_preference_storage: @column_preferences.storage_key } }
124
+ - if @relation.keyless?
125
+ .table-meta
126
+ %p.notice No primary key: record detail links are unavailable.
127
+ .table-scroll
128
+ %table.data-table
129
+ %thead
130
+ %tr
131
+ - unless @relation.keyless?
132
+ %th.record-link-column Record
133
+ - if @visible_columns.empty? && @relation.keyless?
134
+ %th Matching rows
135
+ - @available_columns.each do |column|
136
+ %th{ data: { column: column.name }, hidden: !@visible_columns.include?(column) }
137
+ .column-heading
138
+ - if settings.query_service.sortable_column?(column)
139
+ %a.column-name{ href: sort_url(column.name), title: column.sql_type || column.logical_type }!= "#{Rack::Utils.escape_html(display_column_name(column))}#{sort_indicator(column.name)}"
140
+ - else
141
+ %span.column-name{ title: column.sql_type || column.logical_type }= display_column_name(column)
142
+ - if settings.query_service.groupable_column?(column) && !@relation.uniquely_constrained_column?(column.name)
143
+ %a.group-link{ href: group_url(@namespace.name, @relation.name, column.name), title: "Group by #{column.name}" } Group
144
+ - if temporal_column?(column)
145
+ %button.date-filter-trigger{ type: 'button', data: { date_filter_column: column.name },
146
+ title: "Filter #{column.name} by date range",
147
+ 'aria-label': "Filter #{column.name} by date range" } 📅
148
+ %tbody
149
+ - if @page.rows.empty?
150
+ %tr
151
+ %td.empty-state{ colspan: @visible_columns.length + (@relation.keyless? ? 0 : 1) } No matching records
152
+ - @page.rows.each do |row|
153
+ %tr
154
+ - unless @relation.keyless?
155
+ %td.record-link-column
156
+ %a{ href: record_url(@namespace.name, @relation.name, row,
157
+ @relation.primary_key) }= record_label(@relation, row, exclude: fixed_filter_columns)
158
+ - if @visible_columns.empty? && @relation.keyless?
159
+ %td Match
160
+ - @available_columns.each do |column|
161
+ %td{ data: { column_cell: column.name }, hidden: !@visible_columns.include?(column) }
162
+ != render_table_value(@relation, column, row[column.name])
163
+ - related_url = foreign_record_url(@namespace, @relation, column, row)
164
+ - if related_url
165
+ %a.relationship-link{ href: related_url, title: "Open related #{column.name}",
166
+ 'aria-label': "Open related #{column.name}" } ↗
@@ -0,0 +1,50 @@
1
+ %section.page-heading
2
+ .heading-copy
3
+ %p.eyebrow Grouped values
4
+ %h2= @column.name
5
+ %p #{@relation.name} · #{@column.sql_type || @column.logical_type}
6
+
7
+ - if filter_entries.any? || params['search'].to_s != ''
8
+ %section.active-filters{ 'aria-label': 'Active filters' }
9
+ .filter-heading
10
+ %h3 Applied before grouping
11
+ .filter-chips
12
+ - filter_entries.each do |entry|
13
+ - filter_related = filter_related_value(entry)
14
+ %span.filter-chip
15
+ = filter_description(entry)
16
+ - if filter_related
17
+ %a.relationship-link{ href: related_value_url(filter_related),
18
+ title: "Open related #{entry.fetch(:column)}",
19
+ 'aria-label': "Open related #{entry.fetch(:column)}" } ↗
20
+ - if params['search'].to_s != ''
21
+ %span.filter-chip Search: #{params['search']}
22
+
23
+ %section.data-section
24
+ .table-scroll
25
+ %table.data-table.group-table
26
+ %thead
27
+ %tr
28
+ %th Value
29
+ %th Count
30
+ %th Records
31
+ %tbody
32
+ - @page.rows.each do |row|
33
+ - condition = row['value'].nil? ? { is_null: true } : { eq: row['value'] }
34
+ - filter = @filters.merge(@column.name => condition)
35
+ %tr
36
+ - related = related_value_for(@column, row['value'])
37
+ %td!= related ? render_related_value(related, compact: false) : render_value(row['value'])
38
+ %td.number= row['count']
39
+ %td
40
+ %a{ href: relation_url(@namespace.name, @relation.name, filter: filter) } Browse
41
+ %nav.pagination{ 'aria-label': 'Pagination' }
42
+ - if @page.page > 1
43
+ %a{ href: page_url(@page.page - 1), rel: 'prev' } ← Previous
44
+ - else
45
+ %span.disabled ← Previous
46
+ %span Page #{@page.page}
47
+ - if @page.has_next
48
+ %a{ href: page_url(@page.page + 1), rel: 'next' } Next →
49
+ - else
50
+ %span.disabled Next →
@@ -0,0 +1,108 @@
1
+ %section
2
+ - if settings.catalog
3
+ .catalog-actions
4
+ %button{ type: 'button', data: { open_database_diagram: true } } Database diagram
5
+ - settings.catalog.namespaces.each do |namespace|
6
+ %section.namespace
7
+ %h3= namespace.name
8
+ - if namespace.relations.empty?
9
+ %p No relations discovered.
10
+ - else
11
+ %table
12
+ %thead
13
+ %tr
14
+ %th Relation
15
+ %th Kind
16
+ %th Columns
17
+ %th Primary key
18
+ %th JSON
19
+ %tbody
20
+ - namespace.relations.each do |relation|
21
+ %tr
22
+ %td.relation-cell
23
+ %a{ href: relation_url(namespace.name, relation.name) }= relation.name
24
+ %details.relation-schema{ 'data-row-count-url': query_url('/count.json', namespace: namespace.name, relation: relation.name) }
25
+ %summary{ title: "View definition for #{namespace.name}.#{relation.name}", 'aria-label': "View definition for #{namespace.name}.#{relation.name}" }
26
+ %span{ 'aria-hidden': 'true' } i
27
+ .schema-popover.popup-surface{ role: 'dialog', 'aria-label': "Definition for #{namespace.name}.#{relation.name}" }
28
+ %button.dialog-close{ type: 'button', 'data-close-schema': true, 'aria-label': 'Close table definition' } ×
29
+ .schema-popover-heading
30
+ %div
31
+ %p.eyebrow Table definition
32
+ %h4 #{namespace.name}.#{relation.name}
33
+ %small.schema-row-count{ 'aria-live': 'polite' } Rows: …
34
+ .schema-table-scroll
35
+ %table.schema-table
36
+ %thead
37
+ %tr
38
+ %th Column
39
+ %th Type
40
+ %th Nullable
41
+ %th Key
42
+ %th Default
43
+ %tbody
44
+ - relation.columns.each do |column|
45
+ - key_labels = []
46
+ - key_labels << 'PK' if relation.primary_key&.columns&.include?(column.name)
47
+ - relation.foreign_keys.each do |foreign_key|
48
+ - column_index = foreign_key.columns.index(column.name)
49
+ - if column_index
50
+ - target_namespace = foreign_key.referenced_namespace || namespace.name
51
+ - target_column = foreign_key.referenced_columns.fetch(column_index)
52
+ - key_labels << "FK → #{target_namespace}.#{foreign_key.referenced_relation}.#{target_column}"
53
+ %tr
54
+ %th{ scope: 'row' }= column.name
55
+ %td= column.sql_type || column.logical_type || 'Unknown'
56
+ %td= column.nullable ? 'Yes' : 'No'
57
+ %td
58
+ - if key_labels.empty?
59
+ %span.schema-empty —
60
+ - else
61
+ - key_labels.each do |label|
62
+ %span.schema-key= label
63
+ %td
64
+ - if column.generated?
65
+ %span{ title: column.generated_expression } Generated
66
+ - elsif column.default.nil?
67
+ %span.schema-empty —
68
+ - else
69
+ %code= column.default
70
+ %td= relation.kind.to_s.tr('_', ' ')
71
+ %td= relation.columns.length
72
+ %td= relation.primary_key&.columns&.join(', ') || 'None'
73
+ %td
74
+ %a{ href: query_url('/data.json', namespace: namespace.name, relation: relation.name) } JSON
75
+ - else
76
+ %p RDBr is ready. Supply a database URL to discover a live relational database.
77
+ %pre rdbr serve --database-url postgresql://user:password@host/database
78
+
79
+ - if settings.catalog
80
+ #database-diagram.database-diagram.popup-backdrop{ role: 'dialog', 'aria-modal': 'true',
81
+ 'aria-labelledby': 'database-diagram-title', hidden: true,
82
+ data: { graph_url: '/database-graph.json' } }
83
+ .database-diagram-shell.popup-surface
84
+ %header.database-diagram-heading
85
+ %div
86
+ %p.eyebrow Database structure
87
+ %h2#database-diagram-title= settings.catalog.name
88
+ .database-diagram-actions
89
+ %button.secondary-button{ type: 'button', data: { diagram_zoom_out: true },
90
+ title: 'Zoom out', 'aria-label': 'Zoom out' } −
91
+ %button.secondary-button{ type: 'button', data: { diagram_fit: true } } Fit
92
+ %button.secondary-button{ type: 'button', data: { diagram_zoom_in: true },
93
+ title: 'Zoom in', 'aria-label': 'Zoom in' } +
94
+ %button.dialog-close{ type: 'button', data: { close_database_diagram: true },
95
+ 'aria-label': 'Close database diagram' } ×
96
+ .database-diagram-toolbar
97
+ %label
98
+ %span.visually-hidden Search tables
99
+ %input{ type: 'search', placeholder: 'Search tables', data: { diagram_search: true },
100
+ 'aria-label': 'Search tables' }
101
+ %p.database-diagram-help Drag to pan · Scroll to zoom · Dashed links are inferred
102
+ .database-diagram-body
103
+ .database-diagram-canvas{ data: { diagram_canvas: true } }
104
+ %p.database-diagram-status{ data: { diagram_status: true }, 'aria-live': 'polite' } Loading diagram…
105
+ %svg{ data: { diagram_svg: true }, role: 'img', 'aria-label': 'Database relationship diagram' }
106
+ %aside.database-diagram-details{ data: { diagram_details: true }, 'aria-live': 'polite' }
107
+ %p Select a table to inspect its columns.
108
+ %script{ src: versioned_asset_url('/database-diagram.js') }
@@ -0,0 +1,59 @@
1
+ !!!
2
+ %html{ lang: 'en' }
3
+ %head
4
+ %meta{ charset: 'utf-8' }
5
+ %meta{ content: 'width=device-width, initial-scale=1', name: 'viewport' }
6
+ %title RDBr
7
+ :javascript
8
+ (() => {
9
+ const storedTheme = window.localStorage.getItem('rdbr-theme');
10
+ const theme = storedTheme === 'madhat2r' ? 'dark' : storedTheme;
11
+ if (theme === 'dark') {
12
+ document.documentElement.dataset.theme = theme;
13
+ if (storedTheme !== theme) window.localStorage.setItem('rdbr-theme', theme);
14
+ }
15
+ const scrollKey = `rdbr.tableScroll:${window.location.pathname}${window.location.search}`;
16
+ if (window.sessionStorage.getItem(scrollKey) !== null) {
17
+ document.documentElement.dataset.restoreTableScroll = 'true';
18
+ }
19
+ })();
20
+ %link{ href: versioned_asset_url('/styles.css'), rel: 'stylesheet' }
21
+ %body
22
+ %header
23
+ .brand-container
24
+ %a.brand{ href: '/', 'aria-describedby': settings.catalog ? 'database-summary' : nil }
25
+ %svg.brand-logo{ viewBox: '0 0 128 48', width: 128, height: 48, role: 'img', 'aria-labelledby': 'rdbr-logo-title rdbr-logo-description' }
26
+ %title#rdbr-logo-title RDBr
27
+ %desc#rdbr-logo-description RDBr wordmark with two closely overlapping letter Bs
28
+ %g{ fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': 5 }
29
+ %path{ d: 'M7 41V7h12c8 0 13 4 13 10s-5 10-13 10H7m14 0 13 14', transform: 'translate(.84) scale(.88 1)' }
30
+ %path{ d: 'M43 7v34h10c12 0 19-6 19-17S65 7 53 7H43Z', transform: 'translate(1.16) scale(.88 1)' }
31
+ %path{ d: 'M81 41V7h12c8 0 13 3 13 9 0 5-4 8-11 8H81m14 0c8 0 13 3 13 9s-5 8-13 8H81', transform: 'translate(1.72) scale(.88 1)' }
32
+ %path{ d: 'M87 41V7h12c8 0 13 3 13 9 0 5-4 8-11 8H87m14 0c8 0 13 3 13 9s-5 8-13 8H87', opacity: 0.75, transform: 'translate(2.44) scale(.88 1)' }
33
+ %path{ d: 'M124 41V19m0 8c2-6 7-9 14-8', transform: 'translate(-.12) scale(.88 1)' }
34
+ %span Relational Database Browser
35
+ - if settings.catalog
36
+ #database-summary.database-summary.popup-surface{ role: 'tooltip' }
37
+ %p
38
+ Connected to
39
+ %strong= settings.catalog.name
40
+ using the
41
+ %strong= settings.catalog.adapter
42
+ adapter.
43
+ %dl
44
+ %div
45
+ %dt Namespaces
46
+ %dd= settings.catalog.namespaces.length
47
+ %div
48
+ %dt Relations
49
+ %dd= settings.catalog.relations.length
50
+ %nav
51
+ %a{ href: '/catalog.json' } Catalog JSON
52
+ %label.theme-picker
53
+ %span Theme
54
+ %select{ data: { theme_picker: true }, 'aria-label': 'Theme' }
55
+ %option{ value: 'default' } Light
56
+ %option{ value: 'dark' } Dark
57
+ %main
58
+ != yield
59
+ %script{ src: versioned_asset_url('/application.js') }
@@ -0,0 +1,81 @@
1
+ %section.page-heading
2
+ .heading-copy
3
+ %h2= record_label(@relation, @record)
4
+ %p
5
+ %a{ href: relation_url(@namespace.name, @relation.name) } #{@namespace.name}.#{@relation.name}
6
+
7
+ %section.record-section
8
+ %dl.record-fields
9
+ - @relation.columns.each do |column|
10
+ %div
11
+ %dt
12
+ %span{ title: column.sql_type || column.logical_type }= display_column_name(column)
13
+ - related = related_value_for(column, @record[column.name])
14
+ - if related
15
+ %dd.related-record-value
16
+ %a{ href: related_value_url(related) }!= render_related_value(related, compact: false)
17
+ - else
18
+ %dd!= render_record_value(column, @record[column.name])
19
+
20
+ - unless @outgoing_relationships.empty?
21
+ %section.relationship-section
22
+ %h3 Referenced records
23
+ %ul.relationship-list
24
+ - @outgoing_relationships.each do |relationship|
25
+ %li
26
+ %a{ href: relationship.fetch(:url) }
27
+ = "#{relationship.fetch(:namespace).name}.#{relationship.fetch(:relation).name}"
28
+ %small
29
+ = relationship.fetch(:foreign_key).columns.join(', ')
30
+ - if relationship.fetch(:inferred)
31
+ · inferred
32
+
33
+ - unless @through_relationships.empty?
34
+ %section.relationship-section
35
+ %h3 Associated records
36
+ - @through_relationships.each do |entry|
37
+ - relationship = entry.fetch(:relationship)
38
+ %article.related-records
39
+ %header
40
+ %h4= "#{relationship.target_namespace.name}.#{relationship.target_relation.name}"
41
+ %span
42
+ through
43
+ %a{ href: entry.fetch(:junction_url) }
44
+ = "#{relationship.junction_namespace.name}.#{relationship.junction_relation.name}"
45
+ - if relationship.inferred
46
+ · inferred
47
+ - if entry.fetch(:targets).empty?
48
+ %p.empty-related-records No associated records
49
+ - else
50
+ %ul
51
+ - entry.fetch(:targets).each do |target|
52
+ %li
53
+ - if target.fetch(:row)
54
+ %a{ href: target.fetch(:url) }
55
+ = record_label(relationship.target_relation, target.fetch(:row))
56
+ ·
57
+ %a.secondary-link{ href: target.fetch(:junction_url) } View association record
58
+ %a.secondary-link{ href: entry.fetch(:junction_url) } Browse association records
59
+
60
+ - unless @incoming_relationships.empty?
61
+ %section.relationship-section
62
+ %h3 Related records
63
+ - @incoming_relationships.each do |relationship|
64
+ %article.related-records
65
+ %header
66
+ %h4
67
+ %a{ href: relationship.fetch(:url) }
68
+ = "#{relationship.fetch(:namespace).name}.#{relationship.fetch(:relation).name}"
69
+ %span= relationship.fetch(:page).has_next ? 'More available' : "#{relationship.fetch(:page).rows.length} found"
70
+ - if relationship.fetch(:inferred)
71
+ %small inferred relationship
72
+ %ul
73
+ - relationship.fetch(:page).rows.each do |row|
74
+ %li
75
+ - source_relation = relationship.fetch(:relation)
76
+ - if source_relation.primary_key
77
+ %a{ href: record_url(relationship.fetch(:namespace).name, source_relation.name, row,
78
+ source_relation.primary_key) }
79
+ = record_label(source_relation, row)
80
+ - else
81
+ != render_value(row.values.first, compact: true)
data/demo/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # RDBr demonstration
2
+
3
+ The demo is a small fictional online shop containing customers, addresses,
4
+ categories, products, tags, orders, order items, and shipments. Its generated
5
+ data is deterministic and uses reserved `example.test` email addresses.
6
+
7
+ The schema exercises declared one-to-many and many-to-many relationships,
8
+ meaningful junction records with quantities and prices, inferred record
9
+ identities, dates, timestamps, nullable fields, long text, JSON, unique values,
10
+ and realistic filters. It is deliberately familiar and generic: no source data
11
+ or schema comes from a real organisation.
12
+
13
+ The seed command is destructive only for a database named `rdbr_demo` on
14
+ `localhost`/`127.0.0.1`, or its fixed SQLite file under `tmp`. It refuses every
15
+ other target.
16
+
17
+ ## SQLite
18
+
19
+ ```sh
20
+ bundle install
21
+ bundle exec ruby demo/seed.rb
22
+ bundle exec ruby demo/smoke.rb
23
+ bundle exec rdbr serve --database-url sqlite3:$(pwd)/tmp/rdbr-demo.sqlite3
24
+ ```
25
+
26
+ ## PostgreSQL
27
+
28
+ ```sh
29
+ docker compose -f demo/compose.yml up -d postgres
30
+ DEMO_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:55432/rdbr_demo \
31
+ bundle exec ruby demo/seed.rb
32
+ bundle exec rdbr serve \
33
+ --database-url postgresql://postgres:postgres@127.0.0.1:55432/rdbr_demo
34
+ ```
35
+
36
+ ## MariaDB
37
+
38
+ ```sh
39
+ docker compose -f demo/compose.yml up -d mariadb
40
+ DEMO_DATABASE_URL=mysql2://root:mariadb@127.0.0.1:53306/rdbr_demo \
41
+ bundle exec ruby demo/seed.rb
42
+ bundle exec rdbr serve \
43
+ --database-url mysql2://root:mariadb@127.0.0.1:53306/rdbr_demo
44
+ ```
45
+
46
+ Stop the containers with `docker compose -f demo/compose.yml down`. Add `-v` if
47
+ you also want to remove the disposable demonstration volumes.
data/demo/compose.yml ADDED
@@ -0,0 +1,34 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:18
4
+ environment:
5
+ POSTGRES_DB: rdbr_demo
6
+ POSTGRES_PASSWORD: postgres
7
+ ports:
8
+ - "55432:5432"
9
+ healthcheck:
10
+ test: [ "CMD-SHELL", "pg_isready -U postgres -d rdbr_demo" ]
11
+ interval: 2s
12
+ timeout: 5s
13
+ retries: 20
14
+ volumes:
15
+ - rdbr-demo-postgres:/var/lib/postgresql
16
+
17
+ mariadb:
18
+ image: mariadb:11.8
19
+ environment:
20
+ MARIADB_DATABASE: rdbr_demo
21
+ MARIADB_ROOT_PASSWORD: mariadb
22
+ ports:
23
+ - "53306:3306"
24
+ healthcheck:
25
+ test: [ "CMD", "healthcheck.sh", "--connect", "--innodb_initialized" ]
26
+ interval: 2s
27
+ timeout: 5s
28
+ retries: 30
29
+ volumes:
30
+ - rdbr-demo-mariadb:/var/lib/mysql
31
+
32
+ volumes:
33
+ rdbr-demo-postgres:
34
+ rdbr-demo-mariadb: