dba 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +674 -0
- data/README.md +40 -0
- data/bin/dba +5 -0
- data/dba.gemspec +18 -0
- data/lib/dba.rb +23 -0
- data/lib/dba/command.rb +23 -0
- data/lib/dba/database.rb +98 -0
- data/lib/dba/diff.rb +43 -0
- data/lib/dba/edit.rb +34 -0
- data/lib/dba/find.rb +8 -0
- data/lib/dba/indexes.rb +5 -0
- data/lib/dba/printer.rb +112 -0
- data/lib/dba/pull.rb +13 -0
- data/lib/dba/row_command.rb +35 -0
- data/lib/dba/row_editor.rb +24 -0
- data/lib/dba/row_parser.rb +39 -0
- data/lib/dba/sample.rb +30 -0
- data/lib/dba/schema.rb +5 -0
- data/lib/dba/shell.rb +71 -0
- data/lib/dba/table_command.rb +11 -0
- data/lib/dba/table_schema.rb +21 -0
- data/lib/dba/tables.rb +9 -0
- metadata +114 -0
data/README.md
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
# dba
|
2
|
+
|
3
|
+
A developer tool for working with development databases.
|
4
|
+
|
5
|
+
|
6
|
+
## Installation
|
7
|
+
|
8
|
+
$ gem install dba
|
9
|
+
|
10
|
+
Note: you may also need to install the pg, mysql2, or sqlite3 gems unless you
|
11
|
+
already have them in your environment.
|
12
|
+
|
13
|
+
You can connect to any database supported by [sequel](https://rubygems.org/gems/sequel).
|
14
|
+
|
15
|
+
|
16
|
+
## Usage
|
17
|
+
|
18
|
+
$ dba
|
19
|
+
Usage: dba COMMAND
|
20
|
+
|
21
|
+
dba diff URL
|
22
|
+
dba edit TABLE IDENTIFIER
|
23
|
+
dba find TABLE IDENTIFIER
|
24
|
+
dba indexes [TABLE]
|
25
|
+
dba pull TABLE URL
|
26
|
+
dba sample TABLE [COLUMN]
|
27
|
+
dba schema [TABLE]
|
28
|
+
dba tables
|
29
|
+
|
30
|
+
|
31
|
+
## ERROR: could not find database
|
32
|
+
|
33
|
+
In order to determine which database to connect to dba checks `.env` for a
|
34
|
+
`DATABASE_URL` variable, `config/database.yml` for your Rails development
|
35
|
+
database config, postgres on localhost (if it's running), and the current
|
36
|
+
working directory for any `.sqlite3` files.
|
37
|
+
|
38
|
+
If your development database does not match any of these criteria please
|
39
|
+
open a pull request or issue describing your development database setup,
|
40
|
+
but first make sure you're running the command from the correct directory.
|
data/bin/dba
ADDED
data/dba.gemspec
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = 'dba'
|
3
|
+
s.version = '1.0.0'
|
4
|
+
s.license = 'GPL-3.0'
|
5
|
+
s.platform = Gem::Platform::RUBY
|
6
|
+
s.authors = ['Tim Craft']
|
7
|
+
s.email = ['mail@timcraft.com']
|
8
|
+
s.homepage = 'https://github.com/readysteady/dba'
|
9
|
+
s.description = 'A developer tool for working with development databases'
|
10
|
+
s.summary = 'See description'
|
11
|
+
s.files = Dir.glob('lib/**/*.rb') + %w[LICENSE.txt README.md dba.gemspec]
|
12
|
+
s.required_ruby_version = '>= 2.3.0'
|
13
|
+
s.add_dependency('zeitwerk', '~> 2', '>= 2.2')
|
14
|
+
s.add_dependency('sequel', '~> 5')
|
15
|
+
s.add_dependency('pastel', '~> 0')
|
16
|
+
s.require_path = 'lib'
|
17
|
+
s.executables = ['dba']
|
18
|
+
end
|
data/lib/dba.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# Copyright (c) 2019 TIMCRAFT
|
2
|
+
#
|
3
|
+
# This program is free software: you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
6
|
+
# (at your option) any later version.
|
7
|
+
#
|
8
|
+
# This program is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
|
13
|
+
require 'zeitwerk'
|
14
|
+
|
15
|
+
module DBA
|
16
|
+
Error = Class.new(StandardError)
|
17
|
+
|
18
|
+
loader = Zeitwerk::Loader.new
|
19
|
+
loader.tag = File.basename(__FILE__, '.rb')
|
20
|
+
loader.inflector.inflect('dba' => 'DBA')
|
21
|
+
loader.push_dir(__dir__)
|
22
|
+
loader.setup
|
23
|
+
end
|
data/lib/dba/command.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
class DBA::Command
|
2
|
+
def initialize(database)
|
3
|
+
self.database = database
|
4
|
+
end
|
5
|
+
|
6
|
+
attr_accessor :database
|
7
|
+
|
8
|
+
attr_reader :table_name
|
9
|
+
|
10
|
+
def table_name=(table_name)
|
11
|
+
@table_name = table_name.to_sym
|
12
|
+
|
13
|
+
unless database.tables.include?(@table_name)
|
14
|
+
raise DBA::Error, "could not find table #{table_name}"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def printer
|
21
|
+
@printer ||= DBA::Printer.new
|
22
|
+
end
|
23
|
+
end
|
data/lib/dba/database.rb
ADDED
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sequel'
|
4
|
+
require 'yaml'
|
5
|
+
require 'erb'
|
6
|
+
|
7
|
+
module DBA::Database
|
8
|
+
extend self
|
9
|
+
|
10
|
+
def connect
|
11
|
+
args = find_connection_args
|
12
|
+
|
13
|
+
Sequel.connect(args)
|
14
|
+
rescue Sequel::DatabaseConnectionError => exception
|
15
|
+
raise DBA::Error, connection_error_message(args)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def find_connection_args
|
21
|
+
if dotenv?
|
22
|
+
File.readlines('.env').each do |line|
|
23
|
+
if line.strip =~ /\ADATABASE_URL=/
|
24
|
+
return $'
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
if database_config?
|
30
|
+
return development_database_args
|
31
|
+
end
|
32
|
+
|
33
|
+
if postgres_running?
|
34
|
+
return "postgres://localhost:5432/#{postgres_database_name}"
|
35
|
+
end
|
36
|
+
|
37
|
+
if path = Dir['*.sqlite3'].first
|
38
|
+
return 'sqlite://' + path
|
39
|
+
end
|
40
|
+
|
41
|
+
raise DBA::Error, 'could not find database'
|
42
|
+
end
|
43
|
+
|
44
|
+
def connection_error_message(args)
|
45
|
+
if args.is_a?(Hash)
|
46
|
+
adapter, host, port = args.values_at('adapter', 'host', 'port')
|
47
|
+
|
48
|
+
host ||= 'localhost'
|
49
|
+
|
50
|
+
if port
|
51
|
+
"could not connect to #{adapter} database at #{host}:#{port}"
|
52
|
+
else
|
53
|
+
"could not connect to #{adapter} database at #{host}"
|
54
|
+
end
|
55
|
+
else
|
56
|
+
uri = URI(args)
|
57
|
+
|
58
|
+
"could not connect to #{uri.scheme} database at #{uri.host}"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def dotenv?
|
63
|
+
File.exist?('.env')
|
64
|
+
end
|
65
|
+
|
66
|
+
def database_config?
|
67
|
+
File.exist?('config/database.yml')
|
68
|
+
end
|
69
|
+
|
70
|
+
def database_config
|
71
|
+
YAML.load(ERB.new(File.read('config/database.yml')).result)
|
72
|
+
end
|
73
|
+
|
74
|
+
def development_database_args
|
75
|
+
args = database_config['development']
|
76
|
+
args['adapter'] = 'postgres' if args['adapter'] == 'postgresql'
|
77
|
+
args['adapter'] = 'sqlite' if args['adapter'] == 'sqlite3'
|
78
|
+
args
|
79
|
+
end
|
80
|
+
|
81
|
+
def postgres_database_name
|
82
|
+
heroku_app_name || working_directory_name
|
83
|
+
end
|
84
|
+
|
85
|
+
def heroku_app_name
|
86
|
+
if `git config --get remote.heroku.url`.chomp =~ %r{(?<=[:/])([^:/]+)\.git\z}
|
87
|
+
$1
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def working_directory_name
|
92
|
+
File.basename(Dir.pwd)
|
93
|
+
end
|
94
|
+
|
95
|
+
def postgres_running?
|
96
|
+
!`lsof -i:5432`.strip.empty?
|
97
|
+
end
|
98
|
+
end
|
data/lib/dba/diff.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'sequel'
|
2
|
+
|
3
|
+
class DBA::Diff < DBA::Command
|
4
|
+
def call(url)
|
5
|
+
other_database = Sequel.connect(url)
|
6
|
+
|
7
|
+
tables = database.tables
|
8
|
+
|
9
|
+
other_tables = other_database.tables
|
10
|
+
|
11
|
+
diff tables, other_tables
|
12
|
+
|
13
|
+
tables &= other_tables # only diff columns/indexes for tables that exist in both databases
|
14
|
+
|
15
|
+
printer.print_diff list_columns(database, tables), list_columns(other_database, tables)
|
16
|
+
|
17
|
+
printer.print_diff list_indexes(database, tables), list_indexes(other_database, tables)
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def list_columns(database, tables)
|
23
|
+
tables.inject([]) do |columns, table_name|
|
24
|
+
columns + database.schema(table_name).map { |name, info| format_column(name, info) }
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def list_indexes(database, tables)
|
29
|
+
tables.inject([]) do |indexes, table_name|
|
30
|
+
indexes + database.indexes(table_name).map { |name, info| format_index(name, info) }
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def format_column(name, info_hash)
|
35
|
+
"#{table_name}.#{name} (#{info_hash.fetch(:type)})"
|
36
|
+
end
|
37
|
+
|
38
|
+
def format_index(name, info_hash)
|
39
|
+
columns = info_hash.fetch(:columns).map(&:to_s).join(', ')
|
40
|
+
|
41
|
+
"#{name} (#{columns})"
|
42
|
+
end
|
43
|
+
end
|
data/lib/dba/edit.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'time'
|
3
|
+
|
4
|
+
class DBA::Edit < DBA::RowCommand
|
5
|
+
def call(table, identifier)
|
6
|
+
super
|
7
|
+
|
8
|
+
row_parser = DBA::RowParser.new(table_schema)
|
9
|
+
|
10
|
+
row_editor = DBA::RowEditor.new(row_parser)
|
11
|
+
|
12
|
+
attributes = row_editor.edit(row)
|
13
|
+
|
14
|
+
attributes.each do |key, value|
|
15
|
+
attributes.delete(key) if equal?(value, row[key])
|
16
|
+
end
|
17
|
+
|
18
|
+
return if attributes.empty?
|
19
|
+
|
20
|
+
database.loggers << Logger.new(STDOUT)
|
21
|
+
|
22
|
+
dataset.update(attributes)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def equal?(a, b)
|
28
|
+
# A parsed Time and a database stored Time might be different if
|
29
|
+
# the database stored Time has a non-zero millisecond component.
|
30
|
+
return a.to_s == b.to_s if a.is_a?(Time) && b.is_a?(Time)
|
31
|
+
|
32
|
+
a == b
|
33
|
+
end
|
34
|
+
end
|
data/lib/dba/find.rb
ADDED
data/lib/dba/indexes.rb
ADDED
data/lib/dba/printer.rb
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'pastel'
|
4
|
+
require 'bigdecimal'
|
5
|
+
|
6
|
+
class DBA::Printer
|
7
|
+
def initialize(io = STDOUT)
|
8
|
+
@io = io
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :io
|
12
|
+
|
13
|
+
def print_line
|
14
|
+
io.puts
|
15
|
+
end
|
16
|
+
|
17
|
+
def print_error(message)
|
18
|
+
io.puts(pastel.red('ERROR: ' + message))
|
19
|
+
end
|
20
|
+
|
21
|
+
def print_row(hash)
|
22
|
+
hash.each do |name, value|
|
23
|
+
io.puts muted("#{name}: ") + format(value)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
alias_method :print, :print_row
|
28
|
+
|
29
|
+
def print_usage(program_name, command_parameters)
|
30
|
+
io.puts "Usage: #{program_name} COMMAND"
|
31
|
+
io.puts
|
32
|
+
|
33
|
+
command_parameters.each do |command_name, parameters|
|
34
|
+
parameters = parameters.map { |type, name| format_parameter(type, name) }.compact.join(' ').upcase
|
35
|
+
|
36
|
+
io.puts " #{program_name} #{command_name} #{parameters}"
|
37
|
+
end
|
38
|
+
|
39
|
+
io.puts
|
40
|
+
end
|
41
|
+
|
42
|
+
def print_diff(before_lines, after_lines)
|
43
|
+
removed = before_lines - after_lines
|
44
|
+
removed.each { |line| io.puts pastel.red("- #{line}") }
|
45
|
+
|
46
|
+
added = after_lines - before_lines
|
47
|
+
added.each { |line| io.puts pastel.bright_black("+ #{line}") }
|
48
|
+
end
|
49
|
+
|
50
|
+
def print_table(name, row_count)
|
51
|
+
rows = muted("#{row_count} rows")
|
52
|
+
|
53
|
+
io.puts "#{name} #{rows}"
|
54
|
+
end
|
55
|
+
|
56
|
+
def print_schema(table_name, schema_hash)
|
57
|
+
schema_hash.each do |column_name, info_hash|
|
58
|
+
fields = []
|
59
|
+
fields << "#{table_name}.#{column_name}"
|
60
|
+
fields << muted(info_hash[:type] || info_hash[:db_type])
|
61
|
+
fields << muted('(primary key)') if info_hash[:primary_key]
|
62
|
+
|
63
|
+
io.puts fields.join(' ')
|
64
|
+
end
|
65
|
+
|
66
|
+
io.puts
|
67
|
+
end
|
68
|
+
|
69
|
+
def print_indexes(indexes)
|
70
|
+
indexes.each do |index_name, info_hash|
|
71
|
+
columns = info_hash.fetch(:columns).map(&:to_s).join(', ')
|
72
|
+
|
73
|
+
io.puts "#{index_name} (#{columns})"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
def format(value)
|
80
|
+
case value
|
81
|
+
when NilClass
|
82
|
+
null
|
83
|
+
when BigDecimal
|
84
|
+
value.to_s('F')
|
85
|
+
when Time
|
86
|
+
value.strftime('%F %T')
|
87
|
+
else
|
88
|
+
value.to_s
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def format_parameter(type, name)
|
93
|
+
case type
|
94
|
+
when :req then name
|
95
|
+
when :opt then "[#{name}]"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def null
|
100
|
+
@null ||= muted('NULL')
|
101
|
+
end
|
102
|
+
|
103
|
+
def muted(text)
|
104
|
+
return text unless io.isatty
|
105
|
+
|
106
|
+
pastel.bright_black(text.to_s)
|
107
|
+
end
|
108
|
+
|
109
|
+
def pastel
|
110
|
+
@pastel ||= Pastel.new
|
111
|
+
end
|
112
|
+
end
|
data/lib/dba/pull.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
class DBA::Pull < DBA::Command
|
2
|
+
def call(table, url)
|
3
|
+
self.table_name = table
|
4
|
+
|
5
|
+
dataset = database[table_name]
|
6
|
+
|
7
|
+
other_database = Sequel.connect(url)
|
8
|
+
|
9
|
+
database.transaction do
|
10
|
+
other_database[table_name].each { |row| dataset.insert(row) }
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
class DBA::RowCommand < DBA::Command
|
2
|
+
def call(table, identifier)
|
3
|
+
self.table_name = table
|
4
|
+
|
5
|
+
self.table_schema = DBA::TableSchema.new(database, table_name)
|
6
|
+
|
7
|
+
self.primary_key = table_schema.primary_key
|
8
|
+
|
9
|
+
self.dataset = database[table_name].where(primary_key => identifier)
|
10
|
+
|
11
|
+
self.row = dataset.first
|
12
|
+
|
13
|
+
unless row
|
14
|
+
raise DBA::Error, "could not find row #{primary_key}=#{identifier}"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
attr_accessor :table_schema
|
21
|
+
|
22
|
+
attr_reader :primary_key
|
23
|
+
|
24
|
+
def primary_key=(primary_key)
|
25
|
+
unless primary_key
|
26
|
+
raise DBA::Error, "could not find primary key for #{table_name} table"
|
27
|
+
end
|
28
|
+
|
29
|
+
@primary_key = primary_key
|
30
|
+
end
|
31
|
+
|
32
|
+
attr_accessor :dataset
|
33
|
+
|
34
|
+
attr_accessor :row
|
35
|
+
end
|