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,75 @@
|
|
|
1
|
+
require 'active_support/inflector'
|
|
2
|
+
|
|
3
|
+
module RDBr
|
|
4
|
+
module Metadata
|
|
5
|
+
module RelationshipInference
|
|
6
|
+
INTEGER_TYPES = %i[bigint int2 int4 int8 integer oid smallint].freeze
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def inferred_relationship_candidates(source_namespaces = namespaces)
|
|
11
|
+
source_namespaces.flat_map do |source_namespace|
|
|
12
|
+
source_namespace.relations.flat_map do |source_relation|
|
|
13
|
+
inferred_foreign_keys(source_namespace, source_relation).map do |foreign_key|
|
|
14
|
+
[ source_namespace, source_relation, foreign_key, true ]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def inferred_foreign_keys(source_namespace, source_relation)
|
|
21
|
+
matches = source_relation.columns.to_h do |column|
|
|
22
|
+
[ column, inferred_targets(source_namespace, source_relation, column) ]
|
|
23
|
+
end
|
|
24
|
+
matches.filter_map do |column, targets|
|
|
25
|
+
next unless targets.one?
|
|
26
|
+
|
|
27
|
+
inferred_foreign_key(source_namespace, column, targets.first)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def inferred_targets(namespace, source_relation, column)
|
|
32
|
+
return [] if source_relation.foreign_keys.any?{|foreign_key| foreign_key.columns.include?(column.name) }
|
|
33
|
+
|
|
34
|
+
namespace.relations.select do |target|
|
|
35
|
+
target_key = target.primary_key
|
|
36
|
+
next false unless target_key&.columns&.one?
|
|
37
|
+
next false unless inferred_column_names(target.name).include?(column.name)
|
|
38
|
+
|
|
39
|
+
target_column = target.column(target_key.columns.first)
|
|
40
|
+
target_column && compatible_relationship_types?(column, target_column)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inferred_foreign_key(namespace, column, target)
|
|
45
|
+
ForeignKey.new(
|
|
46
|
+
columns: [ column.name ], referenced_namespace: namespace.name, referenced_relation: target.name,
|
|
47
|
+
referenced_columns: target.primary_key.columns
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def inferred_column_names(relation_name)
|
|
52
|
+
singular = ActiveSupport::Inflector.singularize(relation_name)
|
|
53
|
+
snake = ActiveSupport::Inflector.underscore(singular)
|
|
54
|
+
camel = ActiveSupport::Inflector.camelize(snake, false)
|
|
55
|
+
pascal = ActiveSupport::Inflector.camelize(snake)
|
|
56
|
+
[ snake, "#{snake}_id", camel, "#{camel}Id", pascal, "#{pascal}Id" ]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def compatible_relationship_types?(source, target)
|
|
60
|
+
if source.logical_type && target.logical_type
|
|
61
|
+
return true if integer_relationship_types?(source.logical_type, target.logical_type)
|
|
62
|
+
|
|
63
|
+
return source.logical_type == target.logical_type
|
|
64
|
+
end
|
|
65
|
+
return source.sql_type.casecmp?(target.sql_type) if source.sql_type && target.sql_type
|
|
66
|
+
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def integer_relationship_types?(source_type, target_type)
|
|
71
|
+
INTEGER_TYPES.include?(source_type.to_sym) && INTEGER_TYPES.include?(target_type.to_sym)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Metadata
|
|
3
|
+
DirectRelationship = Data.define(
|
|
4
|
+
:source_namespace, :source_relation, :target_namespace, :target_relation, :foreign_key, :inferred
|
|
5
|
+
)
|
|
6
|
+
ThroughRelationship = Data.define(
|
|
7
|
+
:source_namespace, :source_relation, :target_namespace, :target_relation,
|
|
8
|
+
:junction_namespace, :junction_relation, :source_foreign_key, :target_foreign_key, :inferred
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
module Relationships
|
|
12
|
+
include RelationshipInference
|
|
13
|
+
|
|
14
|
+
def outgoing_relationships(namespace, relation)
|
|
15
|
+
relationship_candidates.filter_map do |source_namespace, source_relation, foreign_key, inferred|
|
|
16
|
+
next unless source_namespace == namespace && source_relation == relation
|
|
17
|
+
|
|
18
|
+
direct_relationship(source_namespace, source_relation, foreign_key, inferred)
|
|
19
|
+
end.freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def incoming_relationships(namespace, relation)
|
|
23
|
+
relationship_candidates.filter_map do |source_namespace, source_relation, foreign_key, inferred|
|
|
24
|
+
next unless reference_matches?(source_namespace, foreign_key, namespace, relation)
|
|
25
|
+
|
|
26
|
+
DirectRelationship.new(
|
|
27
|
+
source_namespace: source_namespace, source_relation: source_relation,
|
|
28
|
+
target_namespace: namespace, target_relation: relation, foreign_key: foreign_key, inferred: inferred
|
|
29
|
+
)
|
|
30
|
+
end.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def through_relationships(namespace, relation)
|
|
34
|
+
relationship_candidates.filter_map do |junction_namespace, junction_relation, source_foreign_key, inferred|
|
|
35
|
+
next unless reference_matches?(junction_namespace, source_foreign_key, namespace, relation)
|
|
36
|
+
|
|
37
|
+
through_relationships_from(
|
|
38
|
+
junction_namespace, junction_relation, source_foreign_key, namespace, relation, inferred
|
|
39
|
+
)
|
|
40
|
+
end.flatten.freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def relationship_candidates
|
|
46
|
+
@relationship_candidates || build_relationship_candidates(namespaces)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_relationship_candidates(source_namespaces)
|
|
50
|
+
declared = source_namespaces.flat_map do |namespace|
|
|
51
|
+
namespace.relations.flat_map do |relation|
|
|
52
|
+
relation.foreign_keys.map{|foreign_key| [ namespace, relation, foreign_key, false ] }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
declared + inferred_relationship_candidates(source_namespaces)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def through_relationships_from(junction_namespace, junction_relation, source_key, source_namespace, source,
|
|
59
|
+
source_inferred)
|
|
60
|
+
candidates = relationship_candidates.select do |namespace, relation, _key, _inferred|
|
|
61
|
+
namespace == junction_namespace && relation == junction_relation
|
|
62
|
+
end
|
|
63
|
+
candidates.filter_map do |_namespace, _relation, target_key, target_inferred|
|
|
64
|
+
next if target_key == source_key
|
|
65
|
+
|
|
66
|
+
target_namespace = namespace(target_key.referenced_namespace || junction_namespace.name)
|
|
67
|
+
target = target_namespace&.relation(target_key.referenced_relation)
|
|
68
|
+
next unless target
|
|
69
|
+
next unless junction_table?(junction_relation, source_key, target_key, source, target)
|
|
70
|
+
|
|
71
|
+
ThroughRelationship.new(
|
|
72
|
+
source_namespace: source_namespace, source_relation: source,
|
|
73
|
+
target_namespace: target_namespace, target_relation: target,
|
|
74
|
+
junction_namespace: junction_namespace, junction_relation: junction_relation,
|
|
75
|
+
source_foreign_key: source_key, target_foreign_key: target_key,
|
|
76
|
+
inferred: source_inferred || target_inferred
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def direct_relationship(source_namespace, source_relation, foreign_key, inferred)
|
|
82
|
+
target_namespace = namespace(foreign_key.referenced_namespace || source_namespace.name)
|
|
83
|
+
target = target_namespace&.relation(foreign_key.referenced_relation)
|
|
84
|
+
return unless target
|
|
85
|
+
|
|
86
|
+
DirectRelationship.new(
|
|
87
|
+
source_namespace: source_namespace, source_relation: source_relation,
|
|
88
|
+
target_namespace: target_namespace, target_relation: target, foreign_key: foreign_key, inferred: inferred
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def junction_key?(relation, columns)
|
|
93
|
+
names = columns.sort
|
|
94
|
+
return true if relation.primary_key&.columns&.sort == names
|
|
95
|
+
return true if relation.unique_constraints.any?{|constraint| constraint.columns.sort == names }
|
|
96
|
+
|
|
97
|
+
relation.indexes.any? do |index|
|
|
98
|
+
index.unique && !index.partial? && !index.expression? && index.columns.sort == names
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def junction_table?(relation, source_key, target_key, source, target)
|
|
103
|
+
columns = source_key.columns | target_key.columns
|
|
104
|
+
junction_key?(relation, columns) || conventional_junction_name?(relation.name, source.name, target.name)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def conventional_junction_name?(junction_name, source_name, target_name)
|
|
108
|
+
source_forms = relationship_name_forms(source_name)
|
|
109
|
+
target_forms = relationship_name_forms(target_name)
|
|
110
|
+
source_forms.product(target_forms).any? do |source, target|
|
|
111
|
+
[ "#{source}_#{target}", "#{target}_#{source}" ].include?(junction_name)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def relationship_name_forms(name)
|
|
116
|
+
snake = ActiveSupport::Inflector.underscore(name)
|
|
117
|
+
[ snake, ActiveSupport::Inflector.singularize(snake) ].uniq
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def reference_matches?(source_namespace, foreign_key, target_namespace, target_relation)
|
|
121
|
+
namespace_name = foreign_key.referenced_namespace || source_namespace.name
|
|
122
|
+
namespace_name == target_namespace.name && foreign_key.referenced_relation == target_relation.name
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Metadata
|
|
3
|
+
class InvalidMetadataError < ArgumentError
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
module Values
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def name(value, field:)
|
|
10
|
+
raise InvalidMetadataError, "#{field} must be a non-empty String" unless value.is_a?(String) && !value.empty?
|
|
11
|
+
|
|
12
|
+
value.dup.freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def optional_name(value, field:)
|
|
16
|
+
return if value.nil?
|
|
17
|
+
|
|
18
|
+
name(value, field: field)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def optional_string(value, field:)
|
|
22
|
+
return if value.nil?
|
|
23
|
+
raise InvalidMetadataError, "#{field} must be a String" unless value.is_a?(String)
|
|
24
|
+
|
|
25
|
+
value.dup.freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def collection(value, member:, field:, allow_empty: true)
|
|
29
|
+
unless value.is_a?(Array) && value.all?{|item| item.is_a?(member) }
|
|
30
|
+
raise InvalidMetadataError, "#{field} must be an Array of #{member}"
|
|
31
|
+
end
|
|
32
|
+
raise InvalidMetadataError, "#{field} must not be empty" if !allow_empty && value.empty?
|
|
33
|
+
|
|
34
|
+
value.dup.freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def names(value, field:, allow_empty: false)
|
|
38
|
+
unless value.is_a?(Array) && value.all?{|item| item.is_a?(String) && !item.empty? }
|
|
39
|
+
raise InvalidMetadataError, "#{field} must be an Array of non-empty Strings"
|
|
40
|
+
end
|
|
41
|
+
raise InvalidMetadataError, "#{field} must not be empty" if !allow_empty && value.empty?
|
|
42
|
+
|
|
43
|
+
value.map{|item| item.dup.freeze }.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def boolean(value, field:)
|
|
47
|
+
return value if [ true, false ].include?(value)
|
|
48
|
+
|
|
49
|
+
raise InvalidMetadataError, "#{field} must be true or false"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def deep_copy_freeze(value)
|
|
53
|
+
case value
|
|
54
|
+
when Array
|
|
55
|
+
value.map{|item| deep_copy_freeze(item) }.freeze
|
|
56
|
+
when Hash
|
|
57
|
+
value.to_h{|key, item| [ deep_copy_freeze(key), deep_copy_freeze(item) ] }.freeze
|
|
58
|
+
when String
|
|
59
|
+
value.dup.freeze
|
|
60
|
+
else
|
|
61
|
+
value.freeze
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def unique_names!(items, field:)
|
|
66
|
+
duplicates = items.group_by(&:name).reject{|name, _| name.nil? }.select{|_, matches| matches.length > 1 }.keys
|
|
67
|
+
return if duplicates.empty?
|
|
68
|
+
|
|
69
|
+
raise InvalidMetadataError, "#{field} contains duplicate names: #{duplicates.join(', ')}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require_relative 'metadata/values'
|
|
2
|
+
require_relative 'metadata/column'
|
|
3
|
+
require_relative 'metadata/keys'
|
|
4
|
+
require_relative 'metadata/constraints'
|
|
5
|
+
require_relative 'metadata/index'
|
|
6
|
+
require_relative 'metadata/relation'
|
|
7
|
+
require_relative 'metadata/namespace'
|
|
8
|
+
require_relative 'metadata/relationship_inference'
|
|
9
|
+
require_relative 'metadata/relationships'
|
|
10
|
+
require_relative 'metadata/database'
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Presentation
|
|
3
|
+
class RecordIdentity
|
|
4
|
+
Candidate = Data.define(:columns, :score)
|
|
5
|
+
TEXT_TYPES = %i[bpchar char citext name text varchar].freeze
|
|
6
|
+
EXACT_SCORES = {
|
|
7
|
+
'display_name' => 1000, 'full_name' => 990, 'name' => 980, 'title' => 970,
|
|
8
|
+
'legal_name' => 960, 'trading_name' => 955, 'label' => 940, 'caption' => 930,
|
|
9
|
+
'heading' => 925, 'subject' => 920, 'username' => 880, 'login' => 875,
|
|
10
|
+
'reference' => 850, 'reference_number' => 845, 'code' => 830, 'slug' => 810,
|
|
11
|
+
'email' => 780, 'summary' => 700, 'description' => 400
|
|
12
|
+
}.freeze
|
|
13
|
+
SENSITIVE_PATTERN = /password|passwd|secret|token|digest|hash|salt|api_?key|private_?key|credential/i
|
|
14
|
+
UNHELPFUL_NAMES = %w[type status state kind category body content notes].freeze
|
|
15
|
+
PERSON_NAME_PAIRS = [
|
|
16
|
+
%w[first_name last_name], %w[given_name family_name], %w[forename surname]
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(relation)
|
|
20
|
+
@relation = relation
|
|
21
|
+
@candidates = (person_name_candidates + column_candidates).sort_by{|candidate| -candidate.score }.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def label(row, exclude: [])
|
|
25
|
+
excluded = exclude.map(&:to_s)
|
|
26
|
+
candidate = @candidates.find do |item|
|
|
27
|
+
item.columns.none?{|column| excluded.include?(column.name) } &&
|
|
28
|
+
item.columns.all?{|column| present?(row[column.name]) }
|
|
29
|
+
end
|
|
30
|
+
return join_values(candidate.columns, row) if candidate
|
|
31
|
+
|
|
32
|
+
fallback_label(row)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :relation
|
|
38
|
+
|
|
39
|
+
def person_name_candidates
|
|
40
|
+
PERSON_NAME_PAIRS.filter_map do |names|
|
|
41
|
+
columns = names.filter_map{|name| relation.column(name) }
|
|
42
|
+
next unless columns.length == names.length
|
|
43
|
+
next if columns.any?{|column| !text_column?(column) }
|
|
44
|
+
|
|
45
|
+
Candidate.new(columns: columns, score: 965 + constraint_bonus(columns))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def column_candidates
|
|
50
|
+
relation.columns.filter_map do |column|
|
|
51
|
+
next unless text_column?(column)
|
|
52
|
+
next if sensitive_column?(column) || foreign_key_column?(column)
|
|
53
|
+
|
|
54
|
+
Candidate.new(columns: [ column ], score: column_score(column))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def column_score(column)
|
|
59
|
+
name = normalized_name(column.name)
|
|
60
|
+
score = EXACT_SCORES.fetch(name, inferred_name_score(name))
|
|
61
|
+
score -= 250 if UNHELPFUL_NAMES.include?(name)
|
|
62
|
+
score + constraint_bonus([ column ]) + (column.nullable ? 0 : 10)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def inferred_name_score(name)
|
|
66
|
+
return 950 if relation_name_forms.any?{|form| name == "#{form}_name" }
|
|
67
|
+
return 900 if name.end_with?('_name')
|
|
68
|
+
|
|
69
|
+
200
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def constraint_bonus(columns)
|
|
73
|
+
names = columns.map(&:name)
|
|
74
|
+
return 25 if relation.primary_key&.columns == names
|
|
75
|
+
return 20 if uniquely_constrained?(names)
|
|
76
|
+
|
|
77
|
+
0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def uniquely_constrained?(names)
|
|
81
|
+
return true if relation.unique_constraints.any?{|constraint| constraint.columns == names }
|
|
82
|
+
|
|
83
|
+
relation.indexes.any? do |index|
|
|
84
|
+
index.unique && !index.partial? && !index.expression? && index.columns == names
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def relation_name_forms
|
|
89
|
+
normalized = normalized_name(relation.name)
|
|
90
|
+
singular = normalized.end_with?('ies') ? "#{normalized.delete_suffix('ies')}y" : normalized.delete_suffix('s')
|
|
91
|
+
[ normalized, singular ].uniq
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def normalized_name(name)
|
|
95
|
+
name.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').tr(' -', '__').downcase.gsub(/_+/, '_')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def text_column?(column)
|
|
99
|
+
TEXT_TYPES.include?(column.logical_type.to_s.to_sym)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def sensitive_column?(column)
|
|
103
|
+
column.name.match?(SENSITIVE_PATTERN)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def foreign_key_column?(column)
|
|
107
|
+
relation.foreign_keys.any?{|key| key.columns.include?(column.name) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def present?(value)
|
|
111
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def join_values(columns, row)
|
|
115
|
+
columns.map{|column| row[column.name].to_s.strip }.join(' ')
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def fallback_label(row)
|
|
119
|
+
columns = relation.primary_key&.columns || relation.columns.map(&:name)
|
|
120
|
+
values = columns.filter_map{|column| row[column] unless row[column].nil? }
|
|
121
|
+
values.empty? ? 'Record' : values.map(&:to_s).join(' · ')
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Query
|
|
3
|
+
class Connection
|
|
4
|
+
module Records
|
|
5
|
+
MUTEX = Mutex.new
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def build(parent)
|
|
10
|
+
MUTEX.synchronize do
|
|
11
|
+
name = "Record#{next_identifier}"
|
|
12
|
+
const_set(name, Class.new(parent))
|
|
13
|
+
[ const_get(name), name ]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def remove(name)
|
|
18
|
+
MUTEX.synchronize{ remove_const(name) if const_defined?(name, false) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def next_identifier
|
|
22
|
+
@next_identifier = @next_identifier.to_i + 1
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class Record < ActiveRecord::Base
|
|
27
|
+
self.abstract_class = true
|
|
28
|
+
|
|
29
|
+
def readonly?
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
DEFAULT_OPTIONS = {
|
|
35
|
+
statement_timeout: 5000, pool_size: 5, checkout_timeout: 5000, max_value_bytes: 1_048_576
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
def initialize(database_url, options: {}, access_policy: nil)
|
|
39
|
+
options = DEFAULT_OPTIONS.merge(options)
|
|
40
|
+
@record_class, @record_class_name = Records.build(Record)
|
|
41
|
+
@access_policy = access_policy || Access::ReadOnly.new(statement_timeout: options.fetch(:statement_timeout))
|
|
42
|
+
@value_limiter = ValueLimiter.new(max_bytes: options.fetch(:max_value_bytes))
|
|
43
|
+
@record_class.establish_connection(
|
|
44
|
+
url: database_url,
|
|
45
|
+
pool: positive_integer(options.fetch(:pool_size), 'pool size'),
|
|
46
|
+
checkout_timeout: positive_integer(options.fetch(:checkout_timeout), 'checkout timeout').fdiv(1000)
|
|
47
|
+
)
|
|
48
|
+
with_connection do |database_connection|
|
|
49
|
+
@adapter_name = database_connection.adapter_name.downcase
|
|
50
|
+
@mariadb = database_connection.select_value('SELECT VERSION()').to_s.include?('MariaDB') if mysql?
|
|
51
|
+
@access_policy.configure(database_connection, adapter_name: @adapter_name, mariadb: @mariadb)
|
|
52
|
+
end
|
|
53
|
+
rescue ActiveRecord::ActiveRecordError, ArgumentError => e
|
|
54
|
+
raise ExecutionError, "Unable to configure read-only database connection (#{e.class})"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def quote_identifier(identifier)
|
|
58
|
+
with_connection{|database_connection| database_connection.quote_column_name(identifier.to_s) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def quote(value)
|
|
62
|
+
with_connection{|database_connection| database_connection.quote(value) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def select_all(sql)
|
|
66
|
+
@access_policy.validate_statement!(sql)
|
|
67
|
+
with_connection do |database_connection|
|
|
68
|
+
configure_session(database_connection)
|
|
69
|
+
@value_limiter.call(database_connection.select_all(sql).to_a)
|
|
70
|
+
end
|
|
71
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
72
|
+
raise classified_error(e), classified_message(e)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def select_value(sql)
|
|
76
|
+
@access_policy.validate_statement!(sql)
|
|
77
|
+
with_connection do |database_connection|
|
|
78
|
+
configure_session(database_connection)
|
|
79
|
+
@value_limiter.call(database_connection.select_value(sql))
|
|
80
|
+
end
|
|
81
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
82
|
+
raise classified_error(e), classified_message(e)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def disconnect
|
|
86
|
+
@record_class.connection_pool.disconnect!
|
|
87
|
+
Records.remove(@record_class_name)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def configure_session(database_connection)
|
|
93
|
+
database_connection.verify!
|
|
94
|
+
@access_policy.configure(database_connection, adapter_name: @adapter_name, mariadb: @mariadb)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def mysql?
|
|
98
|
+
[ 'mysql', 'mysql2', 'trilogy' ].include?(@adapter_name)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def positive_integer(value, label)
|
|
102
|
+
number = Integer(value)
|
|
103
|
+
raise ArgumentError, "#{label} must be positive" unless number.positive?
|
|
104
|
+
|
|
105
|
+
number
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def with_connection(&)
|
|
109
|
+
@record_class.connection_pool.with_connection(&)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def classified_error(error)
|
|
113
|
+
return TimeoutError if error.is_a?(ActiveRecord::ConnectionTimeoutError) || timeout_error?(error)
|
|
114
|
+
return ConnectionUnavailableError if error.is_a?(ActiveRecord::ConnectionNotEstablished)
|
|
115
|
+
return AccessDeniedError if permission_error?(error)
|
|
116
|
+
|
|
117
|
+
ExecutionError
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def classified_message(error)
|
|
121
|
+
case classified_error(error).name
|
|
122
|
+
when ConnectionUnavailableError.name then 'Database connection is unavailable'
|
|
123
|
+
when TimeoutError.name then 'Database operation timed out'
|
|
124
|
+
when AccessDeniedError.name then 'Database denied access to this relation'
|
|
125
|
+
else "Read-only database query failed (#{error.class})"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def timeout_error?(error)
|
|
130
|
+
error.message.match?(/(?:statement timeout|query execution was interrupted|maximum statement execution time)/i)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def permission_error?(error)
|
|
134
|
+
error.message.match?(/(?:permission denied|access denied|command denied|not authorized)/i)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Query
|
|
3
|
+
class Error < StandardError
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
class ConfigurationError < Error
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class InvalidQueryError < Error
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class RelationNotFoundError < Error
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class UnsupportedAdapterError < Error
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class ExecutionError < Error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class AccessViolationError < ExecutionError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class AccessDeniedError < ExecutionError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class TimeoutError < ExecutionError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class ConnectionUnavailableError < ExecutionError
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Query
|
|
3
|
+
module Lookup
|
|
4
|
+
def lookup(namespace:, relation:, keys:)
|
|
5
|
+
_namespace, relation_metadata = find_relation(namespace, relation)
|
|
6
|
+
normalized_keys = normalize_lookup_keys(relation_metadata, keys)
|
|
7
|
+
return [] if normalized_keys.empty?
|
|
8
|
+
|
|
9
|
+
table = [ namespace, relation ].map{|name| connection.quote_identifier(name) }.join('.')
|
|
10
|
+
predicates = normalized_keys.map{|key| lookup_predicate(key) }
|
|
11
|
+
connection.select_all("SELECT * FROM #{table} WHERE #{predicates.join(' OR ')}")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def normalize_lookup_keys(relation, keys)
|
|
17
|
+
normalized = keys.map{|key| key.to_h.transform_keys(&:to_s) }.uniq
|
|
18
|
+
normalized.each{|key| validate_lookup_key!(relation, key) }
|
|
19
|
+
column_sets = normalized.map{|key| key.keys.sort }.uniq
|
|
20
|
+
raise InvalidQueryError, 'Every lookup key must contain the same columns' unless column_sets.length <= 1
|
|
21
|
+
|
|
22
|
+
normalized.reject{|key| key.values.any?(&:nil?) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_lookup_key!(relation, key)
|
|
26
|
+
raise InvalidQueryError, 'Lookup keys must contain at least one column' if key.empty?
|
|
27
|
+
|
|
28
|
+
unknown = key.keys.reject{|name| relation.column(name) }
|
|
29
|
+
raise InvalidQueryError, "Unknown lookup column: #{unknown.first}" unless unknown.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def lookup_predicate(key)
|
|
33
|
+
conditions = key.map do |column, value|
|
|
34
|
+
"#{connection.quote_identifier(column)} = #{connection.quote(value)}"
|
|
35
|
+
end
|
|
36
|
+
"(#{conditions.join(' AND ')})"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module RDBr
|
|
2
|
+
module Query
|
|
3
|
+
class Mysql < Relational
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def pattern_expression(identifier, value)
|
|
7
|
+
"#{identifier} LIKE #{value} ESCAPE '\\\\'"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def text_expression(identifier)
|
|
11
|
+
"CAST(#{identifier} AS CHAR)"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|