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