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