dba 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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