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,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