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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +155 -0
- data/app/public/application.js +609 -0
- data/app/public/database-diagram.js +602 -0
- data/app/public/styles.css +2141 -0
- data/app/views/data.haml +166 -0
- data/app/views/group.haml +50 -0
- data/app/views/index.haml +108 -0
- data/app/views/layout.haml +59 -0
- data/app/views/record.haml +81 -0
- data/demo/README.md +47 -0
- data/demo/compose.yml +34 -0
- data/demo/seed.rb +270 -0
- data/demo/smoke.rb +13 -0
- data/docs/RELEASING.md +30 -0
- data/docs/VALUE_PRESENTATION.md +54 -0
- data/exe/rdbr +5 -0
- data/lib/rdbr/access/read_only.rb +61 -0
- data/lib/rdbr/access.rb +6 -0
- data/lib/rdbr/browser_launcher.rb +71 -0
- data/lib/rdbr/catalog/connection.rb +68 -0
- data/lib/rdbr/catalog/errors.rb +12 -0
- data/lib/rdbr/catalog/mysql/queries.rb +131 -0
- data/lib/rdbr/catalog/mysql.rb +146 -0
- data/lib/rdbr/catalog/postgresql/queries.rb +171 -0
- data/lib/rdbr/catalog/postgresql.rb +138 -0
- data/lib/rdbr/catalog/reader.rb +30 -0
- data/lib/rdbr/catalog/serializer.rb +24 -0
- data/lib/rdbr/catalog/sqlite.rb +182 -0
- data/lib/rdbr/catalog.rb +20 -0
- data/lib/rdbr/cli.rb +74 -0
- data/lib/rdbr/cli_options.rb +63 -0
- data/lib/rdbr/metadata/column.rb +44 -0
- data/lib/rdbr/metadata/constraints.rb +25 -0
- data/lib/rdbr/metadata/database.rb +37 -0
- data/lib/rdbr/metadata/index.rb +30 -0
- data/lib/rdbr/metadata/keys.rb +56 -0
- data/lib/rdbr/metadata/namespace.rb +26 -0
- data/lib/rdbr/metadata/relation.rb +104 -0
- data/lib/rdbr/metadata/relationship_inference.rb +75 -0
- data/lib/rdbr/metadata/relationships.rb +126 -0
- data/lib/rdbr/metadata/values.rb +73 -0
- data/lib/rdbr/metadata.rb +10 -0
- data/lib/rdbr/presentation/record_identity.rb +125 -0
- data/lib/rdbr/query/connection.rb +138 -0
- data/lib/rdbr/query/errors.rb +33 -0
- data/lib/rdbr/query/lookup.rb +40 -0
- data/lib/rdbr/query/mysql.rb +15 -0
- data/lib/rdbr/query/page.rb +66 -0
- data/lib/rdbr/query/postgresql.rb +7 -0
- data/lib/rdbr/query/postgresql_dialect.rb +11 -0
- data/lib/rdbr/query/relational.rb +239 -0
- data/lib/rdbr/query/sqlite.rb +15 -0
- data/lib/rdbr/query/type_support.rb +68 -0
- data/lib/rdbr/query/value_limiter.rb +38 -0
- data/lib/rdbr/query.rb +32 -0
- data/lib/rdbr/version.rb +3 -0
- data/lib/rdbr/web/application.rb +234 -0
- data/lib/rdbr/web/array_value_helpers.rb +92 -0
- data/lib/rdbr/web/binary_value_helpers.rb +51 -0
- data/lib/rdbr/web/column_preferences.rb +56 -0
- data/lib/rdbr/web/database_graph.rb +91 -0
- data/lib/rdbr/web/deferred_column_value_helpers.rb +20 -0
- data/lib/rdbr/web/deferred_value_helpers.rb +76 -0
- data/lib/rdbr/web/document_value_helpers.rb +68 -0
- data/lib/rdbr/web/filter_presentation_helpers.rb +44 -0
- data/lib/rdbr/web/helpers.rb +42 -0
- data/lib/rdbr/web/identifier_value_helpers.rb +20 -0
- data/lib/rdbr/web/json_page_stream.rb +30 -0
- data/lib/rdbr/web/navigation_helpers.rb +115 -0
- data/lib/rdbr/web/presentation_helpers.rb +114 -0
- data/lib/rdbr/web/range_value_helpers.rb +86 -0
- data/lib/rdbr/web/record_value_helpers.rb +90 -0
- data/lib/rdbr/web/related_value_helpers.rb +99 -0
- data/lib/rdbr/web/relationship_loader.rb +65 -0
- data/lib/rdbr/web/search_value_helpers.rb +58 -0
- data/lib/rdbr/web/spatial_value_helpers.rb +83 -0
- data/lib/rdbr/web/through_relationship_loader.rb +90 -0
- data/lib/rdbr/web/vector_value_helpers.rb +59 -0
- data/lib/rdbr.rb +25 -0
- metadata +298 -0
data/app/views/data.haml
ADDED
|
@@ -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:
|