dba 1.0.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/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
|