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,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
|
data/lib/rdbr/catalog.rb
ADDED
|
@@ -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
|