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,182 @@
1
+ module RDBr
2
+ module Catalog
3
+ class Sqlite < Reader
4
+ FOREIGN_KEY_ACTIONS = {
5
+ 'NO ACTION' => :no_action,
6
+ 'RESTRICT' => :restrict,
7
+ 'CASCADE' => :cascade,
8
+ 'SET NULL' => :set_null,
9
+ 'SET DEFAULT' => :set_default
10
+ }.freeze
11
+ TYPE_MAPPINGS = {
12
+ /BOOL/ => :boolean, /JSON/ => :json, /DATETIME|TIMESTAMP/ => :timestamp, /DATE/ => :date,
13
+ /TIME/ => :time, /INT/ => :int8, /CHAR|CLOB|TEXT/ => :text, /REAL|FLOA|DOUB/ => :float8,
14
+ /\A\z|BLOB/ => :blob
15
+ }.freeze
16
+
17
+ def read
18
+ database_rows = connection.select_all('PRAGMA database_list')
19
+ namespaces = database_rows.filter_map{|row| build_namespace(row.fetch('name')) }
20
+ Metadata::Database.new(name: database_name(database_rows), adapter: :sqlite, namespaces: namespaces)
21
+ end
22
+
23
+ private
24
+
25
+ def build_namespace(name)
26
+ relations = relation_rows(name).map{|row| build_relation(name, row) }
27
+ return if relations.empty? && name == 'temp' && !include_system?
28
+
29
+ Metadata::Namespace.new(name: name, relations: relations)
30
+ end
31
+
32
+ def relation_rows(namespace)
33
+ schema = connection.quote_identifier(namespace)
34
+ system_clause = include_system? ? '' : " AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'"
35
+ connection.select_all(<<~SQL)
36
+ SELECT name, type, sql
37
+ FROM #{schema}.sqlite_schema
38
+ WHERE type IN ('table', 'view')#{system_clause}
39
+ ORDER BY name
40
+ SQL
41
+ end
42
+
43
+ def build_relation(namespace, row)
44
+ table_name = row.fetch('name')
45
+ columns = column_rows(namespace, table_name)
46
+ indexes = index_rows(namespace, table_name)
47
+ Metadata::Relation.new(
48
+ name: table_name,
49
+ kind: row.fetch('type').to_sym,
50
+ columns: build_columns(columns),
51
+ primary_key: build_primary_key(columns),
52
+ foreign_keys: build_foreign_keys(namespace, table_name),
53
+ unique_constraints: build_unique_constraints(namespace, indexes),
54
+ indexes: build_indexes(namespace, indexes)
55
+ )
56
+ end
57
+
58
+ def column_rows(namespace, table_name)
59
+ pragma(namespace, 'table_xinfo', table_name)
60
+ end
61
+
62
+ def build_columns(rows)
63
+ rows.sort_by{|row| Integer(row.fetch('cid')) }.map do |row|
64
+ sql_type = row.fetch('type').to_s
65
+ Metadata::Column.new(
66
+ name: row.fetch('name'),
67
+ logical_type: logical_type(sql_type),
68
+ sql_type: sql_type.empty? ? 'BLOB' : sql_type,
69
+ nullable: !truthy?(row.fetch('notnull')) && !Integer(row.fetch('pk')).positive?,
70
+ default: row['dflt_value'],
71
+ ordinal: Integer(row.fetch('cid')) + 1
72
+ )
73
+ end
74
+ end
75
+
76
+ def build_primary_key(rows)
77
+ columns = rows.select{|row| Integer(row.fetch('pk')).positive? }
78
+ .sort_by{|row| Integer(row.fetch('pk')) }
79
+ .map{|row| row.fetch('name') }
80
+ Metadata::PrimaryKey.new(columns: columns) unless columns.empty?
81
+ end
82
+
83
+ def build_foreign_keys(namespace, table_name)
84
+ pragma(namespace, 'foreign_key_list', table_name).group_by{|row| row.fetch('id') }.map do |id, rows|
85
+ ordered = rows.sort_by{|row| Integer(row.fetch('seq')) }
86
+ referenced_table = ordered.first.fetch('table')
87
+ referenced_columns = ordered.map{|row| row['to'] }
88
+ if referenced_columns.any?{|column| column.to_s.empty? }
89
+ referenced_columns = primary_key_columns(namespace, referenced_table)
90
+ end
91
+ Metadata::ForeignKey.new(
92
+ name: "sqlite_fk_#{table_name}_#{id}",
93
+ columns: ordered.map{|row| row.fetch('from') },
94
+ referenced_namespace: namespace,
95
+ referenced_relation: referenced_table,
96
+ referenced_columns: referenced_columns,
97
+ on_update: foreign_key_action(ordered.first.fetch('on_update')),
98
+ on_delete: foreign_key_action(ordered.first.fetch('on_delete'))
99
+ )
100
+ end
101
+ end
102
+
103
+ def index_rows(namespace, table_name)
104
+ pragma(namespace, 'index_list', table_name)
105
+ end
106
+
107
+ def build_unique_constraints(namespace, rows)
108
+ rows.select{|row| row.fetch('origin') == 'u' }.filter_map do |row|
109
+ columns = index_parts(namespace, row.fetch('name')).first
110
+ Metadata::UniqueConstraint.new(name: row.fetch('name'), columns: columns) unless columns.empty?
111
+ end
112
+ end
113
+
114
+ def build_indexes(namespace, rows)
115
+ rows.filter_map do |row|
116
+ columns, expression = index_parts(namespace, row.fetch('name'))
117
+ next if columns.empty? && expression.nil?
118
+
119
+ Metadata::Index.new(
120
+ name: row.fetch('name'),
121
+ columns: columns,
122
+ expressions: expression ? [ expression ] : [],
123
+ unique: truthy?(row.fetch('unique')),
124
+ where: index_predicate(namespace, row),
125
+ using: 'btree'
126
+ )
127
+ end
128
+ end
129
+
130
+ def index_parts(namespace, index_name)
131
+ rows = pragma(namespace, 'index_xinfo', index_name).select{|row| truthy?(row.fetch('key', 1)) }
132
+ columns = rows.filter_map{|row| row['name'] }
133
+ expression = index_sql(namespace, index_name) if columns.length < rows.length
134
+ [ columns, expression ]
135
+ end
136
+
137
+ def index_predicate(namespace, row)
138
+ return unless truthy?(row.fetch('partial'))
139
+
140
+ index_sql(namespace, row.fetch('name'))&.split(/\bWHERE\b/i, 2)&.last&.strip
141
+ end
142
+
143
+ def index_sql(namespace, index_name)
144
+ schema = connection.quote_identifier(namespace)
145
+ name = index_name.to_s.gsub("'", "''")
146
+ connection.select_all("SELECT sql FROM #{schema}.sqlite_schema WHERE type = 'index' AND name = '#{name}'")
147
+ .first&.fetch('sql', nil)
148
+ end
149
+
150
+ def primary_key_columns(namespace, table_name)
151
+ column_rows(namespace, table_name).select{|row| Integer(row.fetch('pk')).positive? }
152
+ .sort_by{|row| Integer(row.fetch('pk')) }
153
+ .map{|row| row.fetch('name') }
154
+ end
155
+
156
+ def pragma(namespace, operation, object)
157
+ schema = connection.quote_identifier(namespace)
158
+ identifier = connection.quote_identifier(object)
159
+ connection.select_all("PRAGMA #{schema}.#{operation}(#{identifier})")
160
+ end
161
+
162
+ def database_name(rows)
163
+ main = rows.find{|row| row.fetch('name') == 'main' }
164
+ file = main&.fetch('file', '').to_s
165
+ file.empty? ? 'main' : File.basename(file)
166
+ end
167
+
168
+ def logical_type(sql_type)
169
+ normalized = sql_type.upcase
170
+ TYPE_MAPPINGS.find{|pattern, _type| normalized.match?(pattern) }&.last || :numeric
171
+ end
172
+
173
+ def foreign_key_action(value)
174
+ FOREIGN_KEY_ACTIONS.fetch(value.to_s.upcase, :no_action)
175
+ end
176
+
177
+ def truthy?(value)
178
+ value == true || value.to_i == 1
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,20 @@
1
+ require_relative 'catalog/errors'
2
+ require_relative 'catalog/connection'
3
+ require_relative 'catalog/serializer'
4
+ require_relative 'catalog/reader'
5
+ require_relative 'catalog/postgresql'
6
+ require_relative 'catalog/sqlite'
7
+ require_relative 'catalog/mysql'
8
+
9
+ module RDBr
10
+ module Catalog
11
+ module_function
12
+
13
+ def read(database_url:, include_system: false)
14
+ connection = Connection.new(database_url)
15
+ Reader.for(connection.adapter_name).new(connection, include_system: include_system).read
16
+ ensure
17
+ connection&.disconnect
18
+ end
19
+ end
20
+ end
data/lib/rdbr/cli.rb ADDED
@@ -0,0 +1,74 @@
1
+ require 'rackup'
2
+
3
+ module RDBr
4
+ module CLI
5
+ module_function
6
+
7
+ def run(
8
+ arguments,
9
+ output: $stdout,
10
+ error: $stderr,
11
+ **dependencies
12
+ )
13
+ catalog_reader = dependencies.fetch(:catalog_reader, Catalog.method(:read))
14
+ query_builder = dependencies.fetch(:query_builder, Query.method(:build))
15
+ server_starter = dependencies.fetch(:server_starter, Rackup::Server.method(:start))
16
+ browser_launcher = dependencies.fetch(:browser_launcher, BrowserLauncher)
17
+ services = { catalog_reader: catalog_reader, query_builder: query_builder,
18
+ server_starter: server_starter, browser_launcher: browser_launcher }
19
+ command, options = CLIOptions.parse(arguments)
20
+ case command
21
+ when 'inspect'
22
+ output.puts Catalog::Serializer.pretty_json(read_catalog(options, catalog_reader))
23
+ when 'serve'
24
+ serve(options, services, output)
25
+ when 'version'
26
+ output.puts VERSION
27
+ else
28
+ error.puts "Unknown command: #{command}"
29
+ return 1
30
+ end
31
+ 0
32
+ rescue Catalog::Error, Query::Error, OptionParser::ParseError, ArgumentError => e
33
+ error.puts e.message
34
+ 1
35
+ end
36
+
37
+ def serve(options, services, output)
38
+ if options[:database_url]
39
+ query_service = configure_web(options, services.fetch(:catalog_reader), services.fetch(:query_builder))
40
+ end
41
+ if options[:open_browser]
42
+ services.fetch(:browser_launcher).open_when_ready(host: options[:host], port: options[:port], output: output)
43
+ end
44
+ services.fetch(:server_starter).call(app: Web::Application, Host: options[:host], Port: options[:port])
45
+ ensure
46
+ query_service.disconnect if query_service.respond_to?(:disconnect)
47
+ end
48
+
49
+ def read_catalog(options, catalog_reader)
50
+ catalog_reader.call(
51
+ database_url: options.fetch(:database_url),
52
+ include_system: options.fetch(:include_system)
53
+ )
54
+ end
55
+
56
+ def configure_web(options, catalog_reader, query_builder)
57
+ catalog = read_catalog(options, catalog_reader)
58
+ access_policy = Access::ReadOnly.new(statement_timeout: options.fetch(:statement_timeout))
59
+ query_service = query_builder.call(
60
+ database_url: options.fetch(:database_url),
61
+ catalog: catalog,
62
+ connection_options: options.slice(
63
+ :statement_timeout, :pool_size, :checkout_timeout, :max_value_bytes
64
+ ),
65
+ access_policy: access_policy,
66
+ max_results: options.fetch(:max_results)
67
+ )
68
+ Web::Application.set :catalog, catalog
69
+ Web::Application.set :query_service, query_service
70
+ Web::Application.set :access_policy, access_policy
71
+ query_service
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,63 @@
1
+ require 'optparse'
2
+
3
+ module RDBr
4
+ module CLIOptions
5
+ module_function
6
+
7
+ def parse(arguments)
8
+ command = arguments.first&.start_with?('-') ? 'serve' : (arguments.shift || 'serve')
9
+ options = defaults
10
+ parser(options).parse!(arguments)
11
+ raise ArgumentError, 'A database URL is required for inspect' if command == 'inspect' && !options[:database_url]
12
+
13
+ [ command, options ]
14
+ end
15
+
16
+ def defaults
17
+ {
18
+ host: '127.0.0.1',
19
+ port: 9292,
20
+ database_url: ENV['DATABASE_URL'],
21
+ include_system: false,
22
+ open_browser: true,
23
+ statement_timeout: ENV.fetch('RDBR_STATEMENT_TIMEOUT', 5000),
24
+ pool_size: ENV.fetch('RDBR_POOL_SIZE', 5),
25
+ checkout_timeout: ENV.fetch('RDBR_CHECKOUT_TIMEOUT', 5000),
26
+ max_value_bytes: ENV.fetch('RDBR_MAX_VALUE_BYTES', 1_048_576),
27
+ max_results: ENV.fetch('RDBR_MAX_RESULTS', 100)
28
+ }
29
+ end
30
+
31
+ def parser(options)
32
+ OptionParser.new do |parser|
33
+ parser.banner = 'Usage: rdbr [serve|inspect|version] [options]'
34
+ parser.on('--database-url URL', 'Relational database URL'){|url| options[:database_url] = url }
35
+ parser.on('--include-system', 'Include system namespaces'){ options[:include_system] = true }
36
+ parser.on('--host HOST', 'Host to bind (default: 127.0.0.1)'){|host| options[:host] = host }
37
+ parser.on('--port PORT', Integer, 'Port to bind (default: 9292)'){|port| options[:port] = port }
38
+ parser.on('--[no-]open', 'Open the browser when the server is ready (default: true)') do |open|
39
+ options[:open_browser] = open
40
+ end
41
+ add_query_options(parser, options)
42
+ end
43
+ end
44
+
45
+ def add_query_options(parser, options)
46
+ parser.on('--statement-timeout MS', Integer, 'Query timeout in milliseconds (default: 5000)') do |timeout|
47
+ options[:statement_timeout] = timeout
48
+ end
49
+ parser.on('--pool-size COUNT', Integer, 'Database connection pool size (default: 5)') do |size|
50
+ options[:pool_size] = size
51
+ end
52
+ parser.on('--checkout-timeout MS', Integer, 'Pool checkout timeout in milliseconds') do |timeout|
53
+ options[:checkout_timeout] = timeout
54
+ end
55
+ parser.on('--max-value-bytes BYTES', Integer, 'Maximum bytes returned for one value') do |bytes|
56
+ options[:max_value_bytes] = bytes
57
+ end
58
+ parser.on('--max-results COUNT', Integer, 'Maximum rows per page (default: 100)') do |limit|
59
+ options[:max_results] = limit
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,44 @@
1
+ module RDBr
2
+ module Metadata
3
+ Column = Data.define(
4
+ :name,
5
+ :logical_type,
6
+ :sql_type,
7
+ :nullable,
8
+ :default,
9
+ :generated_expression,
10
+ :identity,
11
+ :ordinal
12
+ ) do
13
+ def initialize(
14
+ name:,
15
+ logical_type: nil,
16
+ sql_type: nil,
17
+ nullable: true,
18
+ default: nil,
19
+ generated_expression: nil,
20
+ identity: nil,
21
+ ordinal: nil
22
+ )
23
+ super(
24
+ name: Values.name(name, field: 'column name'),
25
+ logical_type: Values.deep_copy_freeze(logical_type),
26
+ sql_type: Values.optional_name(sql_type, field: 'SQL type'),
27
+ nullable: Values.boolean(nullable, field: 'nullable'),
28
+ default: Values.deep_copy_freeze(default),
29
+ generated_expression: Values.optional_name(generated_expression, field: 'generated expression'),
30
+ identity: Values.deep_copy_freeze(identity),
31
+ ordinal: ordinal
32
+ )
33
+ end
34
+
35
+ def generated?
36
+ !generated_expression.nil?
37
+ end
38
+
39
+ def identity?
40
+ !identity.nil? && identity != false
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,25 @@
1
+ module RDBr
2
+ module Metadata
3
+ UniqueConstraint = Data.define(:name, :columns) do
4
+ def initialize(columns:, name: nil)
5
+ super(
6
+ name: Values.optional_name(name, field: 'unique constraint name'),
7
+ columns: Values.names(columns, field: 'unique constraint columns')
8
+ )
9
+ end
10
+
11
+ def composite?
12
+ columns.length > 1
13
+ end
14
+ end
15
+
16
+ CheckConstraint = Data.define(:name, :expression) do
17
+ def initialize(expression:, name: nil)
18
+ super(
19
+ name: Values.optional_name(name, field: 'check constraint name'),
20
+ expression: Values.name(expression, field: 'check constraint expression')
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,37 @@
1
+ module RDBr
2
+ module Metadata
3
+ Database = Data.define(:name, :adapter, :namespaces) do
4
+ include Relationships
5
+
6
+ def initialize(name:, namespaces: [], adapter: nil)
7
+ database_namespaces = Values.collection(namespaces, member: Namespace, field: 'namespaces')
8
+ Values.unique_names!(database_namespaces, field: 'namespaces')
9
+ @relationship_candidates = build_relationship_candidates(database_namespaces).freeze
10
+ super(
11
+ name: Values.name(name, field: 'database name'),
12
+ adapter: Values.deep_copy_freeze(adapter),
13
+ namespaces: database_namespaces
14
+ )
15
+ end
16
+
17
+ def namespace(name)
18
+ namespaces.find{|namespace| namespace.name == name.to_s }
19
+ end
20
+
21
+ def relations
22
+ namespaces.flat_map(&:relations).freeze
23
+ end
24
+
25
+ def relation(name, namespace: nil)
26
+ return self.namespace(namespace)&.relation(name) if namespace
27
+
28
+ matches = relations.select{|relation| relation.name == name.to_s }
29
+ raise InvalidMetadataError, "relation #{name.inspect} is ambiguous; specify a namespace" if matches.length > 1
30
+
31
+ matches.first
32
+ end
33
+ end
34
+
35
+ Catalog = Database
36
+ end
37
+ end
@@ -0,0 +1,30 @@
1
+ module RDBr
2
+ module Metadata
3
+ Index = Data.define(:name, :columns, :expressions, :unique, :where, :using) do
4
+ def initialize(name:, columns: [], expressions: [], unique: false, where: nil, using: nil)
5
+ index_columns = Values.names(columns, field: 'index columns', allow_empty: true)
6
+ index_expressions = Values.names(expressions, field: 'index expressions', allow_empty: true)
7
+ if index_columns.empty? && index_expressions.empty?
8
+ raise InvalidMetadataError, 'index must contain columns or expressions'
9
+ end
10
+
11
+ super(
12
+ name: Values.name(name, field: 'index name'),
13
+ columns: index_columns,
14
+ expressions: index_expressions,
15
+ unique: Values.boolean(unique, field: 'index unique'),
16
+ where: Values.optional_name(where, field: 'index predicate'),
17
+ using: Values.optional_name(using, field: 'index method')
18
+ )
19
+ end
20
+
21
+ def partial?
22
+ !where.nil?
23
+ end
24
+
25
+ def expression?
26
+ !expressions.empty?
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,56 @@
1
+ module RDBr
2
+ module Metadata
3
+ PrimaryKey = Data.define(:name, :columns) do
4
+ def initialize(columns:, name: nil)
5
+ super(
6
+ name: Values.optional_name(name, field: 'primary key name'),
7
+ columns: Values.names(columns, field: 'primary key columns')
8
+ )
9
+ end
10
+
11
+ def composite?
12
+ columns.length > 1
13
+ end
14
+ end
15
+
16
+ ForeignKey = Data.define(
17
+ :name,
18
+ :columns,
19
+ :referenced_namespace,
20
+ :referenced_relation,
21
+ :referenced_columns,
22
+ :on_update,
23
+ :on_delete
24
+ ) do
25
+ def initialize(
26
+ columns:,
27
+ referenced_relation:,
28
+ referenced_columns:,
29
+ name: nil,
30
+ referenced_namespace: nil,
31
+ on_update: nil,
32
+ on_delete: nil
33
+ )
34
+ local_columns = Values.names(columns, field: 'foreign key columns')
35
+ remote_columns = Values.names(referenced_columns, field: 'referenced columns')
36
+ if local_columns.length != remote_columns.length
37
+ raise InvalidMetadataError, 'foreign key column counts must match'
38
+ end
39
+
40
+ super(
41
+ name: Values.optional_name(name, field: 'foreign key name'),
42
+ columns: local_columns,
43
+ referenced_namespace: Values.optional_name(referenced_namespace, field: 'referenced namespace'),
44
+ referenced_relation: Values.name(referenced_relation, field: 'referenced relation'),
45
+ referenced_columns: remote_columns,
46
+ on_update: Values.deep_copy_freeze(on_update),
47
+ on_delete: Values.deep_copy_freeze(on_delete)
48
+ )
49
+ end
50
+
51
+ def composite?
52
+ columns.length > 1
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,26 @@
1
+ module RDBr
2
+ module Metadata
3
+ Namespace = Data.define(:name, :relations) do
4
+ def initialize(name:, relations: [])
5
+ namespace_relations = Values.collection(relations, member: Relation, field: 'relations')
6
+ Values.unique_names!(namespace_relations, field: 'relations')
7
+ super(
8
+ name: Values.name(name, field: 'namespace name'),
9
+ relations: namespace_relations
10
+ )
11
+ end
12
+
13
+ def relation(name)
14
+ relations.find{|relation| relation.name == name.to_s }
15
+ end
16
+
17
+ def tables
18
+ relations.select(&:table?).freeze
19
+ end
20
+
21
+ def views
22
+ relations.select(&:view?).freeze
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,104 @@
1
+ module RDBr
2
+ module Metadata
3
+ Relation = Data.define(
4
+ :name,
5
+ :kind,
6
+ :comment,
7
+ :columns,
8
+ :primary_key,
9
+ :foreign_keys,
10
+ :unique_constraints,
11
+ :check_constraints,
12
+ :indexes
13
+ ) do
14
+ def initialize(
15
+ name:,
16
+ kind:,
17
+ columns:,
18
+ comment: nil,
19
+ primary_key: nil,
20
+ foreign_keys: [],
21
+ unique_constraints: [],
22
+ check_constraints: [],
23
+ indexes: []
24
+ )
25
+ relation_columns = Values.collection(columns, member: Column, field: 'columns')
26
+ Values.unique_names!(relation_columns, field: 'columns')
27
+ validate_primary_key!(primary_key)
28
+ relation_foreign_keys = metadata_collection(foreign_keys, ForeignKey, 'foreign keys')
29
+ relation_unique_constraints = metadata_collection(unique_constraints, UniqueConstraint, 'unique constraints')
30
+ relation_check_constraints = metadata_collection(check_constraints, CheckConstraint, 'check constraints')
31
+ relation_indexes = metadata_collection(indexes, Index, 'indexes')
32
+ validate_local_columns!(
33
+ relation_columns, primary_key, relation_foreign_keys, relation_unique_constraints, relation_indexes
34
+ )
35
+
36
+ super(
37
+ name: Values.name(name, field: 'relation name'),
38
+ kind: Values.deep_copy_freeze(kind.to_sym),
39
+ comment: Values.optional_string(comment, field: 'relation comment'),
40
+ columns: relation_columns,
41
+ primary_key: primary_key,
42
+ foreign_keys: relation_foreign_keys,
43
+ unique_constraints: relation_unique_constraints,
44
+ check_constraints: relation_check_constraints,
45
+ indexes: relation_indexes
46
+ )
47
+ end
48
+
49
+ def column(name)
50
+ columns.find{|column| column.name == name.to_s }
51
+ end
52
+
53
+ def table?
54
+ kind == :table
55
+ end
56
+
57
+ def view?
58
+ [ :view, :materialized_view ].include?(kind)
59
+ end
60
+
61
+ def keyless?
62
+ primary_key.nil?
63
+ end
64
+
65
+ def uniquely_constrained_column?(name)
66
+ column_name = name.to_s
67
+ return true if primary_key&.columns == [ column_name ]
68
+ return true if unique_constraints.any?{|constraint| constraint.columns == [ column_name ] }
69
+
70
+ indexes.any? do |index|
71
+ index.unique && !index.partial? && !index.expression? && index.columns == [ column_name ]
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def metadata_collection(value, member, field)
78
+ collection = Values.collection(value, member: member, field: field)
79
+ Values.unique_names!(collection, field: field)
80
+ collection
81
+ end
82
+
83
+ def validate_primary_key!(primary_key)
84
+ return if primary_key.nil? || primary_key.is_a?(PrimaryKey)
85
+
86
+ raise InvalidMetadataError, 'primary key must be a PrimaryKey or nil'
87
+ end
88
+
89
+ def validate_local_columns!(columns, primary_key, foreign_keys, unique_constraints, indexes)
90
+ known_names = columns.map(&:name)
91
+ referenced_names = [
92
+ primary_key&.columns,
93
+ *foreign_keys.map(&:columns),
94
+ *unique_constraints.map(&:columns),
95
+ *indexes.map(&:columns)
96
+ ].compact.flatten.uniq
97
+ unknown_names = referenced_names - known_names
98
+ return if unknown_names.empty?
99
+
100
+ raise InvalidMetadataError, "unknown local columns: #{unknown_names.join(', ')}"
101
+ end
102
+ end
103
+ end
104
+ end