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,44 @@
1
+ module RDBr
2
+ module Web
3
+ module FilterPresentationHelpers
4
+ TEMPORAL_TYPES = %i[date time timestamp timestamptz timetz].freeze
5
+
6
+ def filter_description(entry)
7
+ value, column = filter_chip_parts(entry)
8
+ "#{column}: #{value}"
9
+ end
10
+
11
+ def filter_chip_parts(entry)
12
+ operator = entry.fetch(:operator).to_s.tr('_', ' ')
13
+ value = entry.fetch(:value)
14
+ return [ truthy?(value) ? 'NULL' : 'NOT NULL', entry.fetch(:column) ] if operator == 'is null'
15
+
16
+ metadata = @relation&.column(entry.fetch(:column))
17
+ related = filter_related_value(entry)
18
+ column_name = metadata ? display_column_name(metadata) : entry.fetch(:column)
19
+ column = operator == 'eq' ? column_name : "#{column_name} #{operator}"
20
+ [ display_text(related ? related.label : value, limit: 80), column ]
21
+ end
22
+
23
+ def filter_related_value(entry)
24
+ return unless entry.fetch(:operator).to_s == 'eq'
25
+
26
+ metadata = @relation&.column(entry.fetch(:column))
27
+ related_value_for(metadata, entry.fetch(:value)) if metadata
28
+ end
29
+
30
+ def temporal_column?(column)
31
+ TEMPORAL_TYPES.include?(column.logical_type.to_s.to_sym)
32
+ end
33
+
34
+ def render_temporal_filter(column, value)
35
+ rendered = render_column_value(column, value, compact: true)
36
+ name = Rack::Utils.escape_html(column.name)
37
+ raw_value = Rack::Utils.escape_html(value.to_s)
38
+ '<button class="temporal-value-filter" type="button" ' \
39
+ "data-date-filter-column=\"#{name}\" data-date-filter-value=\"#{raw_value}\" " \
40
+ "title=\"Filter #{name} by date range\">#{rendered}</button>"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,42 @@
1
+ require_relative 'array_value_helpers'
2
+ require_relative 'binary_value_helpers'
3
+ require_relative 'deferred_column_value_helpers'
4
+ require_relative 'deferred_value_helpers'
5
+ require_relative 'document_value_helpers'
6
+ require_relative 'filter_presentation_helpers'
7
+ require_relative 'identifier_value_helpers'
8
+ require_relative 'navigation_helpers'
9
+ require_relative 'presentation_helpers'
10
+ require_relative 'record_value_helpers'
11
+ require_relative 'related_value_helpers'
12
+ require_relative 'range_value_helpers'
13
+ require_relative 'search_value_helpers'
14
+ require_relative 'spatial_value_helpers'
15
+ require_relative 'vector_value_helpers'
16
+
17
+ module RDBr
18
+ module Web
19
+ module Helpers
20
+ include ArrayValueHelpers
21
+ include BinaryValueHelpers
22
+ include DeferredColumnValueHelpers
23
+ include DeferredValueHelpers
24
+ include DocumentValueHelpers
25
+ include FilterPresentationHelpers
26
+ include IdentifierValueHelpers
27
+ include NavigationHelpers
28
+ include PresentationHelpers
29
+ include RecordValueHelpers
30
+ include RelatedValueHelpers
31
+ include RangeValueHelpers
32
+ include SearchValueHelpers
33
+ include SpatialValueHelpers
34
+ include VectorValueHelpers
35
+
36
+ def versioned_asset_url(path)
37
+ filename = File.join(settings.public_folder, path.delete_prefix('/'))
38
+ "#{path}?v=#{File.mtime(filename).to_i}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,20 @@
1
+ module RDBr
2
+ module Web
3
+ module IdentifierValueHelpers
4
+ IDENTIFIER_TYPES = %w[uuid uniqueidentifier].freeze
5
+ IDENTIFIER_NAMES = %w[uuid uid guid].freeze
6
+
7
+ def identifier_column?(column)
8
+ type = column.logical_type.to_s.downcase
9
+ sql_type = column.sql_type.to_s.downcase
10
+ name_parts = column.name.gsub(/([a-z\d])([A-Z])/, '\\1_\\2').downcase.split(/[^a-z0-9]+/)
11
+ IDENTIFIER_TYPES.include?(type) || sql_type.match?(/\b(?:uuid|uniqueidentifier)\b/) ||
12
+ name_parts.any?{|part| IDENTIFIER_NAMES.include?(part) }
13
+ end
14
+
15
+ def render_identifier_controls(value)
16
+ render_deferred_value(value, qualifier: 'identifier')
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ module RDBr
2
+ module Web
3
+ class JsonPageStream
4
+ def initialize(page)
5
+ @attributes = page.to_h
6
+ end
7
+
8
+ def each(&writer)
9
+ writer.call('{')
10
+ @attributes.each_with_index do |(key, value), index|
11
+ writer.call(',') unless index.zero?
12
+ writer.call("#{JSON.generate(key)}:")
13
+ key == :rows ? yield_rows(value, &writer) : writer.call(JSON.generate(value))
14
+ end
15
+ writer.call('}')
16
+ end
17
+
18
+ private
19
+
20
+ def yield_rows(rows)
21
+ yield '['
22
+ rows.each_with_index do |row, index|
23
+ yield ',' unless index.zero?
24
+ yield JSON.generate(row)
25
+ end
26
+ yield ']'
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,115 @@
1
+ module RDBr
2
+ module Web
3
+ module NavigationHelpers
4
+ def relation_url(namespace, relation, extra = {})
5
+ query_url('/data', { namespace: namespace, relation: relation }.merge(extra))
6
+ end
7
+
8
+ def record_url(namespace, relation, row, primary_key)
9
+ key = primary_key.columns.to_h{|column| [ column, row[column]] }
10
+ query_url('/record', namespace: namespace, relation: relation, key: key)
11
+ end
12
+
13
+ def group_url(namespace, relation, column)
14
+ query_url(
15
+ '/group', namespace: namespace, relation: relation, column: column,
16
+ filter: request.params['filter'], search: params['search'], per_page: params['per_page']
17
+ )
18
+ end
19
+
20
+ def page_url(number)
21
+ query_url(request.path, request.params.merge('page' => number))
22
+ end
23
+
24
+ def sort_url(column)
25
+ parameters = mutable_request_params
26
+ current = parameters['sort'].to_s
27
+ next_sort = current == column ? "-#{column}" : column
28
+ query_url(request.path, parameters.merge('sort' => next_sort, 'page' => 1))
29
+ end
30
+
31
+ def sort_indicator(column)
32
+ return ' ↑' if params['sort'] == column
33
+ return ' ↓' if params['sort'] == "-#{column}"
34
+
35
+ ''
36
+ end
37
+
38
+ def filter_operators(column)
39
+ settings.query_service.filter_operators(column).map(&:to_s)
40
+ end
41
+
42
+ def filter_entries
43
+ requested_filter_hash.flat_map do |column, conditions|
44
+ conditions.to_h.map{|operator, value| { column: column, operator: operator, value: value } }
45
+ end
46
+ end
47
+
48
+ def fixed_filter_columns
49
+ requested_filter_hash.filter_map do |column, conditions|
50
+ condition = conditions.to_h
51
+ operator, value = condition.first
52
+ column if condition.length == 1 && (operator == 'eq' || (operator == 'is_null' && truthy?(value)))
53
+ end
54
+ end
55
+
56
+ def add_filter_url(column, value)
57
+ parameters = mutable_request_params
58
+ filters = requested_filter_hash
59
+ filters[column.name] = value.nil? ? { 'is_null' => true } : { 'eq' => value }
60
+ parameters.merge!('filter' => filters, 'page' => 1)
61
+ query_url('/data', parameters)
62
+ end
63
+
64
+ def remove_filter_url(column, operator)
65
+ parameters = mutable_request_params
66
+ filters = requested_filter_hash
67
+ filters.fetch(column, {}).delete(operator)
68
+ filters.delete(column) if filters.fetch(column, {}).empty?
69
+ parameters.merge!('filter' => filters, 'page' => 1)
70
+ query_url('/data', parameters)
71
+ end
72
+
73
+ def remove_search_url
74
+ parameters = mutable_request_params
75
+ parameters.delete('search')
76
+ query_url('/data', parameters.merge('page' => 1))
77
+ end
78
+
79
+ def clear_filters_url
80
+ parameters = mutable_request_params
81
+ parameters.delete('filter')
82
+ parameters.delete('search')
83
+ query_url('/data', parameters.merge('page' => 1))
84
+ end
85
+
86
+ def query_url(path, parameters)
87
+ query = Rack::Utils.build_nested_query(parameters.reject{|_key, value| value.nil? || value == '' })
88
+ query.empty? ? path : "#{path}?#{query}"
89
+ end
90
+
91
+ private
92
+
93
+ def requested_filter_hash
94
+ deep_hash(request.params.fetch('filter', {}))
95
+ end
96
+
97
+ def mutable_request_params
98
+ deep_hash(request.params).tap do |parameters|
99
+ %w[filter_column filter_operator filter_value].each{|name| parameters.delete(name) }
100
+ end
101
+ end
102
+
103
+ def deep_hash(value)
104
+ return value.to_h.to_h{|key, item| [ key.to_s, deep_hash(item) ] } if value.respond_to?(:to_h)
105
+ return value.map{|item| deep_hash(item) } if value.is_a?(Array)
106
+
107
+ value
108
+ end
109
+
110
+ def truthy?(value)
111
+ [ true, 'true', '1', 1 ].include?(value)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,114 @@
1
+ module RDBr
2
+ module Web
3
+ module PresentationHelpers
4
+ def render_table_value(relation, column, value)
5
+ return render_temporal_filter(column, value) if temporal_column?(column) && !value.nil?
6
+
7
+ related = related_value_for(column, value)
8
+ deferred = deferred_table_value(column, value) unless related
9
+ return deferred if deferred
10
+
11
+ rendered = related ? render_related_value(related) : render_column_value(column, value, compact: true)
12
+ return rendered unless filterable_column?(relation, column) && directly_filterable?(column, value)
13
+
14
+ title = "Filter #{column.name} by this value"
15
+ href = Rack::Utils.escape_html(add_filter_url(column, value))
16
+ %(<a class="value-filter" href="#{href}" title="#{Rack::Utils.escape_html(title)}">#{rendered}</a>)
17
+ end
18
+
19
+ def filterable_column?(relation, column)
20
+ !relation.uniquely_constrained_column?(column.name)
21
+ end
22
+
23
+ def record_label(relation, row, exclude: [])
24
+ label = record_identity(relation).label(row, exclude: exclude)
25
+ display_text(label, limit: 100)
26
+ end
27
+
28
+ def foreign_record_url(namespace, relation, column, row)
29
+ related = related_value_for(column, row[column.name])
30
+ return related_value_url(related) if related
31
+
32
+ foreign_key = relation.foreign_keys.find{|key| key.columns.include?(column.name) }
33
+ return unless foreign_key
34
+
35
+ target_namespace, target = foreign_target(namespace, foreign_key)
36
+ return unless navigable_foreign_key?(target, foreign_key)
37
+
38
+ key = foreign_key.referenced_columns.zip(foreign_key.columns.map{|name| row[name] }).to_h
39
+ return if key.values.any?(&:nil?)
40
+
41
+ record_url(target_namespace.name, target.name, key, target.primary_key)
42
+ end
43
+
44
+ def render_value(value, compact: false)
45
+ return '<span class="null">NULL</span>' if value.nil?
46
+ return render_structured(value, compact: compact) if value.is_a?(Array) || value.is_a?(Hash)
47
+
48
+ text = display_value(value)
49
+ text = "#{text.slice(0, 160)}…" if compact && text.length > 160
50
+ css_class = text.empty? ? 'empty' : value_class(value)
51
+ %(<span class="#{css_class}">#{Rack::Utils.escape_html(text)}</span>)
52
+ end
53
+
54
+ private
55
+
56
+ def deferred_table_value(column, value)
57
+ return if value.nil?
58
+
59
+ deferred = render_deferred_column_value(column, value)
60
+ return deferred if deferred
61
+
62
+ render_identifier_controls(value) if identifier_column?(column)
63
+ end
64
+
65
+ def record_identity(relation)
66
+ @record_identities ||= {}.compare_by_identity
67
+ @record_identities[relation] ||= RDBr::Presentation::RecordIdentity.new(relation)
68
+ end
69
+
70
+ def display_value(value) = case value; when Time, DateTime then value.strftime('%F %T'); else value.to_s end
71
+
72
+ def foreign_target(namespace, foreign_key)
73
+ target_namespace = settings.catalog.namespace(foreign_key.referenced_namespace || namespace.name)
74
+ [ target_namespace, target_namespace&.relation(foreign_key.referenced_relation) ]
75
+ end
76
+
77
+ def navigable_foreign_key?(target, foreign_key)
78
+ target&.primary_key && target.primary_key.columns.sort == foreign_key.referenced_columns.sort
79
+ end
80
+
81
+ def directly_filterable?(column, value)
82
+ return true if value.nil?
83
+ return false if value.is_a?(Array) || value.is_a?(Hash)
84
+
85
+ !%i[bytea json xml].include?(column.logical_type.to_s.to_sym)
86
+ end
87
+
88
+ def display_text(value, limit:)
89
+ text = if value.is_a?(Array) || value.is_a?(Hash)
90
+ JSON.generate(value)
91
+ elsif value.nil?
92
+ 'NULL'
93
+ else
94
+ value.to_s
95
+ end
96
+ text.length > limit ? "#{text.slice(0, limit)}…" : text
97
+ end
98
+
99
+ def render_structured(value, compact:)
100
+ json = compact ? JSON.generate(value) : JSON.pretty_generate(value)
101
+ json = "#{json.slice(0, 160)}…" if compact && json.length > 160
102
+ %(<code class="structured">#{Rack::Utils.escape_html(json)}</code>)
103
+ end
104
+
105
+ def value_class(value)
106
+ case value
107
+ when true, false then 'boolean'
108
+ when Numeric then 'number'
109
+ else 'text'
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,86 @@
1
+ module RDBr
2
+ module Web
3
+ module RangeValueHelpers
4
+ FALLBACK_PREVIEW_LENGTH = 160
5
+ RANGE_LABELS = {
6
+ 'daterange' => 'Date range', 'int4range' => 'Integer range', 'int8range' => 'Integer range',
7
+ 'numrange' => 'Numeric range', 'tsrange' => 'Timestamp range',
8
+ 'tstzrange' => 'Timestamp with time zone range'
9
+ }.freeze
10
+
11
+ def range_column?(column)
12
+ range_type(column).match?(/(?:multi)?range\z/i)
13
+ end
14
+
15
+ def render_range_value(column, value)
16
+ text = range_text(value)
17
+ render_deferred_value(text, qualifier: 'range', summary: range_summary(column, value, text))
18
+ end
19
+
20
+ def deferred_unfamiliar_value?(column, value)
21
+ unfamiliar_column?(column) && fallback_text(value).length > FALLBACK_PREVIEW_LENGTH
22
+ end
23
+
24
+ def render_unfamiliar_value(column, value)
25
+ text = fallback_text(value)
26
+ type = column.sql_type.to_s.empty? ? column.logical_type : column.sql_type
27
+ render_deferred_value(
28
+ text, qualifier: 'unfamiliar', summary: "#{type} · #{text.length} characters"
29
+ )
30
+ end
31
+
32
+ private
33
+
34
+ def range_type(column)
35
+ logical_type = column.logical_type.to_s
36
+ logical_type.end_with?('range') ? logical_type : column.sql_type.to_s
37
+ end
38
+
39
+ def range_text(value)
40
+ value.is_a?(Array) ? JSON.pretty_generate(value) : value.to_s
41
+ end
42
+
43
+ def range_summary(column, value, text)
44
+ type = range_type(column).downcase
45
+ base_type = type.delete_suffix('multirange')
46
+ label = RANGE_LABELS["#{base_type}range"] || humanize_range_type(type)
47
+ return "#{label} · empty" if text.casecmp('empty').zero?
48
+ return label unless type.end_with?('multirange')
49
+
50
+ count = value.is_a?(Array) ? value.length : multirange_count(text)
51
+ "#{label.sub(/ range\z/, ' multirange')} · #{count} #{count == 1 ? 'range' : 'ranges'}"
52
+ end
53
+
54
+ def humanize_range_type(type)
55
+ type.sub(/multirange\z/i, '').sub(/range\z/i, '').tr('_', ' ').strip.capitalize.then do |name|
56
+ name.empty? ? 'Range' : "#{name} range"
57
+ end
58
+ end
59
+
60
+ def multirange_count(text)
61
+ quoted = false
62
+ escaped = false
63
+ text.each_char.count do |character|
64
+ if escaped
65
+ escaped = false
66
+ elsif character == '\\'
67
+ escaped = true
68
+ elsif character == '"'
69
+ quoted = !quoted
70
+ end
71
+ !quoted && [ '[', '(' ].include?(character)
72
+ end
73
+ end
74
+
75
+ def unfamiliar_column?(column)
76
+ type = column.logical_type.to_s.to_sym
77
+ known = Query::TypeSupport::TYPE_GROUPS.values.flatten
78
+ !known.include?(type) && !%i[json jsonb].include?(type)
79
+ end
80
+
81
+ def fallback_text(value)
82
+ value.is_a?(Array) || value.is_a?(Hash) ? JSON.pretty_generate(value) : value.to_s
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,90 @@
1
+ module RDBr
2
+ module Web
3
+ module RecordValueHelpers
4
+ PREVIEW_LENGTH = 320
5
+ JSON_DECODE_DEPTH = 3
6
+ TIMESTAMP_TYPES = %i[timestamp timestamptz].freeze
7
+ JSON_TYPES = %i[json jsonb].freeze
8
+
9
+ def render_record_value(column, value)
10
+ deferred_value = render_deferred_record_value(column, value) unless value.nil?
11
+ return deferred_value if deferred_value
12
+ return render_identifier_controls(value) if identifier_column?(column) && !value.nil?
13
+ return render_column_value(column, value) unless long_text_value?(column, value)
14
+
15
+ text = value.to_s
16
+ dialog_id = "full-value-#{column.ordinal || safe_dom_name(column.name)}"
17
+ render_deferred_dialog(
18
+ dialog_id: dialog_id, column_name: column.name, text: text,
19
+ trigger: preview_button(dialog_id, text)
20
+ )
21
+ end
22
+
23
+ def render_column_value(column, value, compact: false)
24
+ render_value(normalized_column_value(column, value), compact: compact)
25
+ end
26
+
27
+ private
28
+
29
+ def render_deferred_record_value(column, value)
30
+ return render_json_value(column, value) if JSON_TYPES.include?(column.logical_type.to_s.to_sym)
31
+
32
+ render_deferred_column_value(column, value)
33
+ end
34
+
35
+ def render_json_value(column, value)
36
+ dialog_id = "full-value-#{column.ordinal || safe_dom_name(column.name)}"
37
+ json = pretty_json(value)
38
+ render_deferred_dialog(
39
+ dialog_id: dialog_id, column_name: column.name, text: json,
40
+ trigger: json_button(dialog_id), content_class: 'structured'
41
+ )
42
+ end
43
+
44
+ def pretty_json(value)
45
+ JSON.pretty_generate(normalized_json(value))
46
+ end
47
+
48
+ def normalized_json(value)
49
+ decoded = value.is_a?(String) ? JSON.parse(value) : value
50
+ (JSON_DECODE_DEPTH - 1).times do
51
+ break unless decoded.is_a?(String)
52
+ candidate = decoded.strip
53
+ break unless candidate.start_with?('{', '[')
54
+
55
+ decoded = JSON.parse(candidate)
56
+ end
57
+ decoded
58
+ rescue JSON::ParserError
59
+ value
60
+ end
61
+
62
+ def json_button(dialog_id)
63
+ id = Rack::Utils.escape_html(dialog_id)
64
+ attributes = %(data-open-dialog="#{id}" aria-haspopup="dialog" aria-controls="#{id}")
65
+ %(<button class="json-preview" type="button" #{attributes}>Show JSON</button>)
66
+ end
67
+
68
+ def normalized_column_value(column, value)
69
+ return value unless TIMESTAMP_TYPES.include?(column.logical_type.to_s.to_sym) && value.is_a?(String)
70
+
71
+ Time.iso8601(value)
72
+ rescue ArgumentError
73
+ value
74
+ end
75
+
76
+ def long_text_value?(column, value)
77
+ Query::TypeSupport::TYPE_GROUPS.fetch(:text).include?(column.logical_type.to_s.to_sym) &&
78
+ value.to_s.length > PREVIEW_LENGTH
79
+ end
80
+
81
+ def preview_button(dialog_id, text)
82
+ id = Rack::Utils.escape_html(dialog_id)
83
+ preview = Rack::Utils.escape_html("#{text.slice(0, PREVIEW_LENGTH)}…")
84
+ attributes = %(data-open-dialog="#{id}" aria-haspopup="dialog" aria-controls="#{id}")
85
+ %(<button class="text-preview" type="button" #{attributes}>) \
86
+ "<span>#{preview}</span><small>Show full text</small></button>"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,99 @@
1
+ module RDBr
2
+ module Web
3
+ module RelatedValueHelpers
4
+ RelatedValue = Data.define(
5
+ :column, :raw_value, :label, :namespace, :relation, :row, :key, :inferred
6
+ )
7
+
8
+ def load_related_values(namespace, relation, rows)
9
+ settings.catalog.outgoing_relationships(namespace, relation).each_with_object({}) do |relationship, values|
10
+ load_relationship_values(relationship, rows, values)
11
+ end
12
+ end
13
+
14
+ def related_value_for(column, value)
15
+ @related_values&.fetch(related_value_key(column.name, value), nil)
16
+ end
17
+
18
+ def display_column_name(column)
19
+ return column.name unless relationship_column?(column)
20
+
21
+ humanize_identifier(column.name.sub(/_id\z/i, '').sub(/Id\z/, ''))
22
+ end
23
+
24
+ def related_value_url(value)
25
+ primary_key = value.relation.primary_key
26
+ if primary_key&.columns&.sort == value.key.keys.sort
27
+ return record_url(value.namespace.name, value.relation.name, value.key, primary_key)
28
+ end
29
+
30
+ relation_url(value.namespace.name, value.relation.name, filter: equality_filters(value.key))
31
+ end
32
+
33
+ def render_related_value(value, compact: true)
34
+ title = "#{value.column.name}: #{value.raw_value}"
35
+ text = display_text(value.label, limit: compact ? 160 : 500)
36
+ %(<span class="related-value" title="#{Rack::Utils.escape_html(title)}">) \
37
+ "#{Rack::Utils.escape_html(text)}</span>"
38
+ end
39
+
40
+ private
41
+
42
+ def load_relationship_values(relationship, rows, values)
43
+ foreign_key, targets = relationship_targets(relationship, rows)
44
+ return unless foreign_key
45
+
46
+ referenced_column = foreign_key.referenced_columns.first
47
+ targets.each do |target|
48
+ add_related_value(relationship, foreign_key, referenced_column, target, values)
49
+ end
50
+ end
51
+
52
+ def relationship_targets(relationship, rows)
53
+ foreign_key = relationship.foreign_key
54
+ return unless foreign_key.columns.length == 1 && foreign_key.referenced_columns.length == 1
55
+
56
+ raw_values = rows.filter_map{|row| row[foreign_key.columns.first] }.uniq
57
+ return if raw_values.empty?
58
+
59
+ keys = raw_values.map{|value| { foreign_key.referenced_columns.first => value } }
60
+ targets = settings.query_service.lookup(
61
+ namespace: relationship.target_namespace.name, relation: relationship.target_relation.name, keys: keys
62
+ )
63
+ [ foreign_key, targets ]
64
+ rescue Query::ExecutionError, Query::InvalidQueryError
65
+ nil
66
+ end
67
+
68
+ def add_related_value(relationship, foreign_key, referenced_column, target, values)
69
+ raw_value = target[referenced_column]
70
+ return if raw_value.nil?
71
+
72
+ local_column = foreign_key.columns.first
73
+ entry = RelatedValue.new(
74
+ column: @relation.column(local_column), raw_value: raw_value,
75
+ label: record_identity(relationship.target_relation).label(target),
76
+ namespace: relationship.target_namespace, relation: relationship.target_relation,
77
+ row: target, key: { referenced_column => raw_value }, inferred: relationship.inferred
78
+ )
79
+ values[related_value_key(local_column, raw_value)] = entry
80
+ end
81
+
82
+ def related_value_key(column, value)
83
+ [ column.to_s, value.to_s ]
84
+ end
85
+
86
+ def relationship_column?(column)
87
+ return false unless @namespace && @relation
88
+
89
+ settings.catalog.outgoing_relationships(@namespace, @relation).any? do |relationship|
90
+ relationship.foreign_key.columns == [ column.name ]
91
+ end
92
+ end
93
+
94
+ def humanize_identifier(value)
95
+ value.gsub(/([a-z\d])([A-Z])/, '\1 \2').tr('_-', ' ').split.map(&:capitalize).join(' ')
96
+ end
97
+ end
98
+ end
99
+ end