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.
@@ -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
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'dba'
4
+
5
+ DBA::Shell.run
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,8 @@
1
+ class DBA::Find < DBA::RowCommand
2
+ def call(table, identifier)
3
+ super
4
+
5
+ printer.print(row)
6
+ printer.print_line
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ class DBA::Indexes < DBA::TableCommand
2
+ def visit(table_name)
3
+ printer.print_indexes(database.indexes(table_name))
4
+ end
5
+ end
@@ -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
@@ -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