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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Web
|
|
3
|
+
class Application < Sinatra::Base
|
|
4
|
+
include RelationshipLoader
|
|
5
|
+
|
|
6
|
+
ROOT = File.expand_path('../../..', __dir__).freeze
|
|
7
|
+
PAGE_SIZES = [ 10, 25, 50, 100 ].freeze
|
|
8
|
+
|
|
9
|
+
set :public_folder, File.join(ROOT, 'app/public')
|
|
10
|
+
set :views, File.join(ROOT, 'app/views')
|
|
11
|
+
set :static, true
|
|
12
|
+
set :catalog, nil
|
|
13
|
+
set :query_service, nil
|
|
14
|
+
set :access_policy, Access::ReadOnly.new
|
|
15
|
+
helpers Helpers
|
|
16
|
+
|
|
17
|
+
before do
|
|
18
|
+
halt 405, 'Read-only interface' unless settings.access_policy.allow_http_method?(request.request_method)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
get '/' do
|
|
22
|
+
haml :index
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
get '/data' do
|
|
26
|
+
require_query_service!
|
|
27
|
+
@namespace, @relation = find_relation!
|
|
28
|
+
redirect canonical_filter_url if params['filter_column'].to_s != ''
|
|
29
|
+
|
|
30
|
+
@filters = requested_filters
|
|
31
|
+
@column_preferences = column_preferences_for(@relation)
|
|
32
|
+
@available_columns = @column_preferences.ordered_columns
|
|
33
|
+
@visible_columns = @column_preferences.visible_columns
|
|
34
|
+
@page = settings.query_service.page(
|
|
35
|
+
namespace: @namespace.name,
|
|
36
|
+
relation: @relation.name,
|
|
37
|
+
page: params.fetch('page', 1),
|
|
38
|
+
per_page: preferred_page_size,
|
|
39
|
+
sort: params['sort'],
|
|
40
|
+
search: params['search'],
|
|
41
|
+
filters: @filters,
|
|
42
|
+
include_total: true
|
|
43
|
+
)
|
|
44
|
+
redirect page_url(@page.total_pages) if @page.page > @page.total_pages
|
|
45
|
+
@related_values = load_related_values(@namespace, @relation, @page.rows + filter_value_rows)
|
|
46
|
+
haml :data
|
|
47
|
+
rescue Query::InvalidQueryError => e
|
|
48
|
+
halt 400, e.message
|
|
49
|
+
rescue Query::ExecutionError => e
|
|
50
|
+
halt query_error_status(e), e.message
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
get '/record' do
|
|
54
|
+
require_query_service!
|
|
55
|
+
@namespace, @relation = find_relation!
|
|
56
|
+
@record = settings.query_service.record(
|
|
57
|
+
namespace: @namespace.name, relation: @relation.name, key: params.fetch('key', {})
|
|
58
|
+
)
|
|
59
|
+
halt 404, 'Record not found' unless @record
|
|
60
|
+
|
|
61
|
+
@related_values = load_related_values(@namespace, @relation, [ @record ])
|
|
62
|
+
@outgoing_relationships = outgoing_relationships
|
|
63
|
+
@through_relationships = through_relationships
|
|
64
|
+
@incoming_relationships = incoming_relationships
|
|
65
|
+
haml :record
|
|
66
|
+
rescue Query::InvalidQueryError => e
|
|
67
|
+
halt 400, e.message
|
|
68
|
+
rescue Query::ExecutionError => e
|
|
69
|
+
halt query_error_status(e), e.message
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
get '/group' do
|
|
73
|
+
require_query_service!
|
|
74
|
+
@namespace, @relation = find_relation!
|
|
75
|
+
@column = @relation.column(required_parameter('column'))
|
|
76
|
+
halt 404, 'Unknown column' unless @column
|
|
77
|
+
|
|
78
|
+
@filters = requested_filters
|
|
79
|
+
@page = settings.query_service.group(
|
|
80
|
+
namespace: @namespace.name, relation: @relation.name, column: @column.name,
|
|
81
|
+
page: params.fetch('page', 1), per_page: preferred_page_size,
|
|
82
|
+
search: params['search'], filters: @filters
|
|
83
|
+
)
|
|
84
|
+
group_rows = @page.rows.map{|row| { @column.name => row['value'] } }
|
|
85
|
+
@related_values = load_related_values(@namespace, @relation, group_rows + filter_value_rows)
|
|
86
|
+
haml :group
|
|
87
|
+
rescue Query::InvalidQueryError => e
|
|
88
|
+
halt 400, e.message
|
|
89
|
+
rescue Query::ExecutionError => e
|
|
90
|
+
halt query_error_status(e), e.message
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
get '/catalog.json' do
|
|
94
|
+
halt 404 unless settings.catalog
|
|
95
|
+
|
|
96
|
+
content_type :json
|
|
97
|
+
JSON.generate(Catalog::Serializer.call(settings.catalog))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
get '/database-graph.json' do
|
|
101
|
+
halt 404 unless settings.catalog
|
|
102
|
+
|
|
103
|
+
content_type :json
|
|
104
|
+
DatabaseGraph.call(settings.catalog)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
get '/data.json' do
|
|
108
|
+
halt 404 unless settings.query_service
|
|
109
|
+
|
|
110
|
+
page = settings.query_service.page(
|
|
111
|
+
namespace: required_parameter('namespace'),
|
|
112
|
+
relation: required_parameter('relation'),
|
|
113
|
+
page: params.fetch('page', 1),
|
|
114
|
+
per_page: params.fetch('per_page', Query::Postgresql::DEFAULT_PAGE_SIZE),
|
|
115
|
+
sort: params['sort'],
|
|
116
|
+
search: params['search'],
|
|
117
|
+
filters: params.fetch('filter', {}),
|
|
118
|
+
include_total: true
|
|
119
|
+
)
|
|
120
|
+
content_type :json
|
|
121
|
+
body JsonPageStream.new(page)
|
|
122
|
+
rescue Query::InvalidQueryError => e
|
|
123
|
+
halt 400, e.message
|
|
124
|
+
rescue Query::RelationNotFoundError
|
|
125
|
+
halt 404, 'Unknown namespace or relation'
|
|
126
|
+
rescue Query::ExecutionError => e
|
|
127
|
+
halt query_error_status(e), e.message
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
get '/count.json' do
|
|
131
|
+
require_query_service!
|
|
132
|
+
namespace, relation = find_relation!
|
|
133
|
+
page = settings.query_service.page(
|
|
134
|
+
namespace: namespace.name, relation: relation.name,
|
|
135
|
+
page: 1, per_page: 1, filters: {}, include_total: true
|
|
136
|
+
)
|
|
137
|
+
content_type :json
|
|
138
|
+
JSON.generate(total_count: page.total_count)
|
|
139
|
+
rescue Query::InvalidQueryError => e
|
|
140
|
+
halt 400, e.message
|
|
141
|
+
rescue Query::ExecutionError => e
|
|
142
|
+
halt query_error_status(e), e.message
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
get '/health' do
|
|
146
|
+
content_type :json
|
|
147
|
+
JSON.generate(
|
|
148
|
+
status: 'ok',
|
|
149
|
+
version: RDBr::VERSION,
|
|
150
|
+
access_mode: settings.access_policy.mode,
|
|
151
|
+
connected: !settings.catalog.nil?,
|
|
152
|
+
queryable: !settings.query_service.nil?
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def query_error_status(error)
|
|
159
|
+
return 403 if error.is_a?(Query::AccessDeniedError)
|
|
160
|
+
return 504 if error.is_a?(Query::TimeoutError)
|
|
161
|
+
|
|
162
|
+
503
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def column_preferences_for(relation)
|
|
166
|
+
preference = ColumnPreferences.new(
|
|
167
|
+
database: settings.catalog, namespace: @namespace, relation: relation,
|
|
168
|
+
fixed_columns: fixed_filter_columns
|
|
169
|
+
)
|
|
170
|
+
ColumnPreferences.new(
|
|
171
|
+
database: settings.catalog, namespace: @namespace, relation: relation,
|
|
172
|
+
fixed_columns: fixed_filter_columns, value: request.cookies[preference.cookie_name]
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def require_query_service!
|
|
177
|
+
halt 404 unless settings.query_service
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def find_relation!
|
|
181
|
+
namespace = settings.catalog&.namespace(required_parameter('namespace'))
|
|
182
|
+
relation = namespace&.relation(required_parameter('relation'))
|
|
183
|
+
halt 404, 'Unknown namespace or relation' unless relation
|
|
184
|
+
|
|
185
|
+
[ namespace, relation ]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def requested_filters
|
|
189
|
+
filters = params.fetch('filter', {}).to_h
|
|
190
|
+
column = params['filter_column'].to_s
|
|
191
|
+
return filters if column.empty?
|
|
192
|
+
|
|
193
|
+
operator = params.fetch('filter_operator', 'eq')
|
|
194
|
+
value = operator == 'is_null' ? true : params['filter_value']
|
|
195
|
+
filters.merge(column => { operator => value })
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def canonical_filter_url
|
|
199
|
+
parameters = params.to_h.dup
|
|
200
|
+
parameters.delete('filter_column')
|
|
201
|
+
parameters.delete('filter_operator')
|
|
202
|
+
parameters.delete('filter_value')
|
|
203
|
+
parameters['filter'] = requested_filters
|
|
204
|
+
parameters['page'] = 1
|
|
205
|
+
query_url('/data', parameters)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def filter_value_rows
|
|
209
|
+
@filters.flat_map do |column, condition|
|
|
210
|
+
next [ { column.to_s => condition } ] unless condition.is_a?(Hash)
|
|
211
|
+
|
|
212
|
+
condition.to_h.filter_map do |operator, value|
|
|
213
|
+
{ column.to_s => value } if operator.to_s == 'eq'
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def required_parameter(name)
|
|
219
|
+
value = params[name]
|
|
220
|
+
halt 400, "Missing parameter: #{name}" if value.to_s.empty?
|
|
221
|
+
|
|
222
|
+
value
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def preferred_page_size
|
|
226
|
+
explicit = params['per_page'].to_s
|
|
227
|
+
return explicit unless explicit.empty?
|
|
228
|
+
|
|
229
|
+
remembered = request.cookies['rdbr_page_size'].to_i
|
|
230
|
+
PAGE_SIZES.include?(remembered) ? remembered : Query::Postgresql::DEFAULT_PAGE_SIZE
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Web
|
|
3
|
+
module ArrayValueHelpers
|
|
4
|
+
INLINE_ARRAY_ITEMS = 5
|
|
5
|
+
INLINE_ARRAY_CHARACTERS = 100
|
|
6
|
+
|
|
7
|
+
def array_column?(column)
|
|
8
|
+
logical_type = column.logical_type.to_s
|
|
9
|
+
logical_type.start_with?('_') || column.sql_type.to_s.match?(/\[\](?:\[\])*\z/)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def deferred_array_value?(column, value)
|
|
13
|
+
return false unless array_column?(column)
|
|
14
|
+
|
|
15
|
+
array_dimensions(value) > 1 || array_item_count(value) > INLINE_ARRAY_ITEMS ||
|
|
16
|
+
array_text(value).length > INLINE_ARRAY_CHARACTERS
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render_array_value(value)
|
|
20
|
+
render_deferred_value(array_text(value), qualifier: 'array', summary: array_summary(value))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def array_text(value)
|
|
26
|
+
value.is_a?(Array) ? JSON.pretty_generate(value) : value.to_s
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def array_summary(value)
|
|
30
|
+
shape = rectangular_array_shape(value)
|
|
31
|
+
return "#{shape.join(' × ')} array" if shape&.length.to_i > 1
|
|
32
|
+
|
|
33
|
+
dimensions = array_dimensions(value)
|
|
34
|
+
return "#{dimensions}-dimensional array" if dimensions > 1
|
|
35
|
+
|
|
36
|
+
count = array_item_count(value)
|
|
37
|
+
"#{count} #{count == 1 ? 'item' : 'items'}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def array_dimensions(value)
|
|
41
|
+
return array_depth(value) if value.is_a?(Array)
|
|
42
|
+
|
|
43
|
+
value.to_s.scan(/\{+/).map(&:length).max.to_i.clamp(1, Float::INFINITY)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def array_depth(value)
|
|
47
|
+
return 0 unless value.is_a?(Array)
|
|
48
|
+
|
|
49
|
+
1 + value.map{|entry| array_depth(entry) }.max.to_i
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def array_item_count(value)
|
|
53
|
+
return value.sum{|entry| entry.is_a?(Array) ? array_item_count(entry) : 1 } if value.is_a?(Array)
|
|
54
|
+
|
|
55
|
+
array_literal_item_count(value.to_s)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def array_literal_item_count(text)
|
|
59
|
+
body = text.strip
|
|
60
|
+
return 0 if body.match?(/\A\{\s*\}\z/)
|
|
61
|
+
|
|
62
|
+
commas = 0
|
|
63
|
+
quoted = false
|
|
64
|
+
escaped = false
|
|
65
|
+
body.each_char do |character|
|
|
66
|
+
if escaped
|
|
67
|
+
escaped = false
|
|
68
|
+
elsif character == '\\'
|
|
69
|
+
escaped = true
|
|
70
|
+
elsif character == '"'
|
|
71
|
+
quoted = !quoted
|
|
72
|
+
elsif character == ',' && !quoted
|
|
73
|
+
commas += 1
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
commas + 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def rectangular_array_shape(value)
|
|
80
|
+
return unless value.is_a?(Array)
|
|
81
|
+
return [ 0 ] if value.empty?
|
|
82
|
+
return [ value.length ] unless value.any?(Array)
|
|
83
|
+
return unless value.all?(Array)
|
|
84
|
+
|
|
85
|
+
child_shapes = value.map{|entry| rectangular_array_shape(entry) }
|
|
86
|
+
return unless child_shapes.uniq.length == 1
|
|
87
|
+
|
|
88
|
+
[ value.length, *child_shapes.first ]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require 'base64'
|
|
2
|
+
|
|
3
|
+
module RDBr
|
|
4
|
+
module Web
|
|
5
|
+
module BinaryValueHelpers
|
|
6
|
+
BINARY_TYPES = %i[binary blob bytea image longblob mediumblob tinyblob varbinary].freeze
|
|
7
|
+
|
|
8
|
+
def binary_column?(column)
|
|
9
|
+
type = column.logical_type.to_s.to_sym
|
|
10
|
+
BINARY_TYPES.include?(type) || column.sql_type.to_s.match?(/(?:binary|blob|bytea)/i)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def deferred_binary_value?(column, value)
|
|
14
|
+
binary_column?(column) || encoded_binary_value?(value)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def render_binary_value(value)
|
|
18
|
+
payload, bytes = binary_payload(value)
|
|
19
|
+
summary = binary_summary(bytes)
|
|
20
|
+
render_deferred_value(payload, qualifier: 'binary', summary: summary)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def encoded_binary_value?(value)
|
|
26
|
+
value.is_a?(Hash) && value['encoding'] == 'base64' && value.key?('data')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def binary_summary(bytes)
|
|
30
|
+
return 'Base64' unless bytes
|
|
31
|
+
|
|
32
|
+
unit = bytes == 1 ? 'byte' : 'bytes'
|
|
33
|
+
"#{bytes} #{unit} · Base64"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def binary_payload(value)
|
|
37
|
+
return encoded_binary_payload(value) if encoded_binary_value?(value)
|
|
38
|
+
|
|
39
|
+
text = value.to_s.b
|
|
40
|
+
[ Base64.strict_encode64(text), text.bytesize ]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def encoded_binary_payload(value)
|
|
44
|
+
payload = value.fetch('data').to_s
|
|
45
|
+
[ payload, Base64.strict_decode64(payload).bytesize ]
|
|
46
|
+
rescue ArgumentError
|
|
47
|
+
[ payload, nil ]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require 'digest'
|
|
2
|
+
|
|
3
|
+
module RDBr
|
|
4
|
+
module Web
|
|
5
|
+
class ColumnPreferences
|
|
6
|
+
TRAILING_COLUMNS = %w[created_at updated_at discarded_at].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :cookie_name, :storage_key
|
|
9
|
+
|
|
10
|
+
def initialize(database:, namespace:, relation:, fixed_columns:, value: nil)
|
|
11
|
+
identity = [ database.adapter, database.name, namespace.name, relation.name ].join('/')
|
|
12
|
+
@columns = relation.columns.reject{|column| fixed_columns.include?(column.name) }
|
|
13
|
+
@preference = parse(value)
|
|
14
|
+
@cookie_name = "rdbr_columns_#{Digest::SHA256.hexdigest(identity).slice(0, 16)}"
|
|
15
|
+
@storage_key = "rdbr.columns.#{identity}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ordered_columns
|
|
19
|
+
preferred_names.filter_map{|name| columns_by_name[name] } +
|
|
20
|
+
default_columns.reject{|column| preferred_names.include?(column.name) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def visible_columns
|
|
24
|
+
visible = ordered_columns.reject{|column| hidden_names.include?(column.name) }
|
|
25
|
+
visible.empty? ? ordered_columns.first(1) : visible
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def parse(value)
|
|
31
|
+
parsed = JSON.parse(value.to_s)
|
|
32
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
33
|
+
rescue JSON::ParserError
|
|
34
|
+
{}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def preferred_names
|
|
38
|
+
@preferred_names ||= Array(@preference['order']).map(&:to_s).select{|name| columns_by_name.key?(name) }.uniq
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def hidden_names
|
|
42
|
+
@hidden_names ||= Array(@preference['hidden']).map(&:to_s).select{|name| columns_by_name.key?(name) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def columns_by_name
|
|
46
|
+
@columns_by_name ||= @columns.index_by(&:name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def default_columns
|
|
50
|
+
ordinary = @columns.reject{|column| TRAILING_COLUMNS.include?(column.name) }
|
|
51
|
+
trailing = TRAILING_COLUMNS.filter_map{|name| columns_by_name[name] }
|
|
52
|
+
ordinary + trailing
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Web
|
|
3
|
+
class DatabaseGraph
|
|
4
|
+
class << self
|
|
5
|
+
def call(catalog)
|
|
6
|
+
@cache ||= {}.compare_by_identity
|
|
7
|
+
@cache[catalog] ||= JSON.generate(build(catalog))
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def build(catalog)
|
|
13
|
+
relationships = graph_relationships(catalog)
|
|
14
|
+
{
|
|
15
|
+
database: catalog.name,
|
|
16
|
+
adapter: catalog.adapter,
|
|
17
|
+
nodes: graph_nodes(catalog, relationships),
|
|
18
|
+
edges: graph_edges(relationships)
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def graph_relationships(catalog)
|
|
23
|
+
catalog.namespaces.flat_map do |namespace|
|
|
24
|
+
namespace.relations.flat_map do |relation|
|
|
25
|
+
catalog.outgoing_relationships(namespace, relation)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def graph_nodes(catalog, relationships)
|
|
31
|
+
catalog.namespaces.flat_map do |namespace|
|
|
32
|
+
namespace.relations.map do |relation|
|
|
33
|
+
outgoing = relationships.select do |relationship|
|
|
34
|
+
relationship.source_namespace == namespace && relationship.source_relation == relation
|
|
35
|
+
end
|
|
36
|
+
{
|
|
37
|
+
id: node_id(namespace.name, relation.name),
|
|
38
|
+
namespace: namespace.name,
|
|
39
|
+
name: relation.name,
|
|
40
|
+
kind: relation.kind,
|
|
41
|
+
comment: relation.comment,
|
|
42
|
+
columns: relation.columns.map{|column| graph_column(relation, column, outgoing) }
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def graph_column(relation, column, relationships)
|
|
49
|
+
foreign_keys = relationships.filter_map do |relationship|
|
|
50
|
+
foreign_key = relationship.foreign_key
|
|
51
|
+
index = foreign_key.columns.index(column.name)
|
|
52
|
+
next unless index
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
namespace: relationship.target_namespace.name,
|
|
56
|
+
relation: relationship.target_relation.name,
|
|
57
|
+
column: foreign_key.referenced_columns.fetch(index),
|
|
58
|
+
inferred: relationship.inferred
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
{
|
|
62
|
+
name: column.name,
|
|
63
|
+
type: column.sql_type || column.logical_type,
|
|
64
|
+
nullable: column.nullable,
|
|
65
|
+
primary_key: relation.primary_key&.columns&.include?(column.name) || false,
|
|
66
|
+
foreign_keys: foreign_keys
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def graph_edges(relationships)
|
|
71
|
+
relationships.map.with_index do |relationship, index|
|
|
72
|
+
foreign_key = relationship.foreign_key
|
|
73
|
+
{
|
|
74
|
+
id: "relationship:#{index}",
|
|
75
|
+
source: node_id(relationship.source_namespace.name, relationship.source_relation.name),
|
|
76
|
+
target: node_id(relationship.target_namespace.name, relationship.target_relation.name),
|
|
77
|
+
inferred: relationship.inferred,
|
|
78
|
+
columns: foreign_key.columns.zip(foreign_key.referenced_columns).map do |source, target|
|
|
79
|
+
{ source: source, target: target }
|
|
80
|
+
end
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def node_id(namespace, relation)
|
|
86
|
+
JSON.generate([ namespace, relation ])
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Web
|
|
3
|
+
module DeferredColumnValueHelpers
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def render_deferred_column_value(column, value)
|
|
7
|
+
return render_vector_value(column, value) if vector_column?(column)
|
|
8
|
+
return render_array_value(value) if deferred_array_value?(column, value)
|
|
9
|
+
return render_spatial_value(column, value) if spatial_column?(column)
|
|
10
|
+
return render_binary_value(value) if deferred_binary_value?(column, value)
|
|
11
|
+
return render_document_value(column, value) if deferred_document_value?(column)
|
|
12
|
+
return render_bit_value(value) if deferred_bit_value?(column)
|
|
13
|
+
return render_search_value(column, value) if deferred_search_value?(column)
|
|
14
|
+
return render_range_value(column, value) if range_column?(column)
|
|
15
|
+
|
|
16
|
+
render_unfamiliar_value(column, value) if deferred_unfamiliar_value?(column, value)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Web
|
|
3
|
+
module DeferredValueHelpers
|
|
4
|
+
COPY_ICON_PATH = [
|
|
5
|
+
'M8 7V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-2v2H5a2 2 0 0 1-2-2V9',
|
|
6
|
+
'a2 2 0 0 1 2-2h3ZM10 7h5a2 2 0 0 1 2 2v8h2V5h-9v2Zm5 2H5v10h10V9Z'
|
|
7
|
+
].join.freeze
|
|
8
|
+
|
|
9
|
+
def render_deferred_value(value, label: 'View', qualifier: nil, summary: nil)
|
|
10
|
+
escaped = Rack::Utils.escape_html(value.to_s)
|
|
11
|
+
classes = [ 'deferred-value-controls', qualifier && "#{qualifier}-controls" ].compact.join(' ')
|
|
12
|
+
[
|
|
13
|
+
%(<span class="#{classes}">),
|
|
14
|
+
deferred_view_button(escaped, label, qualifier),
|
|
15
|
+
deferred_value_summary(summary),
|
|
16
|
+
deferred_copy_button(escaped, qualifier),
|
|
17
|
+
'<span class="deferred-copy-status" aria-live="polite"></span></span>'
|
|
18
|
+
].join
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def render_deferred_dialog(dialog_id:, column_name:, text:, trigger:, content_class: nil)
|
|
22
|
+
id = Rack::Utils.escape_html(dialog_id)
|
|
23
|
+
label = Rack::Utils.escape_html("Show full #{column_name}")
|
|
24
|
+
content = Rack::Utils.escape_html(text)
|
|
25
|
+
class_attribute = content_class ? %( class="#{content_class}") : ''
|
|
26
|
+
dialog_attributes = %(id="#{id}" role="dialog" aria-modal="true" aria-label="#{label}" hidden)
|
|
27
|
+
[
|
|
28
|
+
trigger,
|
|
29
|
+
%(<div class="text-dialog popup-backdrop" #{dialog_attributes}><div class="text-dialog-panel popup-surface">),
|
|
30
|
+
'<button class="dialog-close" type="button" data-close-dialog aria-label="Close">×</button>',
|
|
31
|
+
deferred_dialog_copy_button(id),
|
|
32
|
+
"<pre#{class_attribute}>#{content}</pre></div></div>"
|
|
33
|
+
].join
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def safe_dom_name(name)
|
|
37
|
+
name.gsub(/[^a-zA-Z0-9_-]/, '-')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def deferred_view_button(value, label, qualifier)
|
|
43
|
+
classes = [ 'deferred-value-view', qualifier && "#{qualifier}-view" ].compact.join(' ')
|
|
44
|
+
attributes = %(data-view-value="#{value}" aria-haspopup="dialog" ) \
|
|
45
|
+
'aria-controls="deferred-value-popover" aria-expanded="false"'
|
|
46
|
+
%(<button class="#{classes}" type="button" #{attributes}>#{Rack::Utils.escape_html(label)}</button>)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def deferred_value_summary(summary)
|
|
50
|
+
return '' if summary.to_s.empty?
|
|
51
|
+
|
|
52
|
+
%(<span class="deferred-value-summary">#{Rack::Utils.escape_html(summary)}</span>)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def deferred_copy_button(value, qualifier)
|
|
56
|
+
classes = [ 'deferred-value-copy', qualifier && "#{qualifier}-copy" ].compact.join(' ')
|
|
57
|
+
attributes = %(data-copy-value="#{value}" title="Copy to clipboard" aria-label="Copy value to clipboard")
|
|
58
|
+
%(<button class="#{classes}" type="button" #{attributes}>#{deferred_copy_icon}</button>)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def deferred_dialog_copy_button(dialog_id)
|
|
62
|
+
attributes = %(data-copy-target="#{dialog_id}" title="Copy to clipboard" ) \
|
|
63
|
+
'aria-label="Copy displayed value to clipboard"'
|
|
64
|
+
[
|
|
65
|
+
%(<span class="deferred-dialog-copy"><button class="deferred-value-copy" type="button" ),
|
|
66
|
+
%(#{attributes}>#{deferred_copy_icon}</button>),
|
|
67
|
+
'<span class="deferred-copy-status" aria-live="polite"></span></span>'
|
|
68
|
+
].join
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def deferred_copy_icon
|
|
72
|
+
%(<svg aria-hidden="true" viewBox="0 0 24 24"><path d="#{COPY_ICON_PATH}"/></svg>)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Web
|
|
3
|
+
module DocumentValueHelpers
|
|
4
|
+
KEY_VALUE_TYPES = %i[hstore].freeze
|
|
5
|
+
DOCUMENT_TYPES = %i[xml].freeze
|
|
6
|
+
|
|
7
|
+
def deferred_document_value?(column)
|
|
8
|
+
type = column.logical_type.to_s.to_sym
|
|
9
|
+
KEY_VALUE_TYPES.include?(type) || DOCUMENT_TYPES.include?(type)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render_document_value(column, value)
|
|
13
|
+
type = column.logical_type.to_s.to_sym
|
|
14
|
+
return render_hstore_value(value) if KEY_VALUE_TYPES.include?(type)
|
|
15
|
+
|
|
16
|
+
render_xml_value(value)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def render_hstore_value(value)
|
|
22
|
+
text, count = hstore_presentation(value)
|
|
23
|
+
return '<span class="empty">Empty</span>' if count.zero?
|
|
24
|
+
|
|
25
|
+
unit = count == 1 ? 'entry' : 'entries'
|
|
26
|
+
render_deferred_value(text, qualifier: 'structured', summary: "#{count} #{unit}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def hstore_presentation(value)
|
|
30
|
+
return [ JSON.pretty_generate(value), value.length ] if value.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
entries = split_hstore(value.to_s)
|
|
33
|
+
[ entries.join(",\n"), entries.length ]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def split_hstore(text)
|
|
37
|
+
parts = []
|
|
38
|
+
start = 0
|
|
39
|
+
quoted = false
|
|
40
|
+
escaped = false
|
|
41
|
+
text.each_char.with_index do |character, index|
|
|
42
|
+
escaped, quoted = hstore_quote_state(character, escaped, quoted)
|
|
43
|
+
next unless character == ',' && !quoted
|
|
44
|
+
|
|
45
|
+
parts << text[start...index].strip
|
|
46
|
+
start = index + 1
|
|
47
|
+
end
|
|
48
|
+
parts << text[start..].to_s.strip
|
|
49
|
+
parts.reject(&:empty?)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def hstore_quote_state(character, escaped, quoted)
|
|
53
|
+
return [ false, quoted ] if escaped
|
|
54
|
+
return [ true, quoted ] if character == '\\'
|
|
55
|
+
return [ false, !quoted ] if character == '"'
|
|
56
|
+
|
|
57
|
+
[ false, quoted ]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def render_xml_value(value)
|
|
61
|
+
text = value.to_s
|
|
62
|
+
root = text.match(/<(?![!?])([[:alnum:]_.:-]+)/)&.[](1)
|
|
63
|
+
summary = root ? "XML document · <#{root}>" : 'XML document'
|
|
64
|
+
render_deferred_value(text, qualifier: 'structured', summary: summary)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|