active_record_mysql_repl 0.1.0 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +3 -0
  3. data/.standard.yml +3 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +43 -0
  8. data/Rakefile +10 -0
  9. data/exe/army +5 -0
  10. data/json_schemas/army_schema.json +34 -0
  11. data/json_schemas/association_schema.json +80 -0
  12. data/json_schemas/databases_schema.json +61 -0
  13. data/lib/active_record_mysql_repl/cli/erd.rb +11 -0
  14. data/lib/active_record_mysql_repl/cli/main.rb +60 -0
  15. data/lib/active_record_mysql_repl/cli/options.rb +13 -0
  16. data/lib/active_record_mysql_repl/cli/zsh_completion.rb +37 -0
  17. data/lib/active_record_mysql_repl/cli.rb +10 -0
  18. data/lib/active_record_mysql_repl/config.rb +38 -0
  19. data/lib/active_record_mysql_repl/database/association/analysis.rb +17 -0
  20. data/lib/active_record_mysql_repl/database/association/definition.rb +0 -0
  21. data/lib/active_record_mysql_repl/database/association.rb +83 -0
  22. data/lib/active_record_mysql_repl/database/configs.rb +53 -0
  23. data/lib/active_record_mysql_repl/database/connection.rb +42 -0
  24. data/lib/active_record_mysql_repl/database/loader.rb +63 -0
  25. data/lib/active_record_mysql_repl/database.rb +10 -0
  26. data/lib/active_record_mysql_repl/extensions/active_record.rb +32 -0
  27. data/lib/active_record_mysql_repl/extensions/global.rb +29 -0
  28. data/lib/active_record_mysql_repl/extensions/hash.rb +20 -0
  29. data/lib/active_record_mysql_repl/extensions/object.rb +138 -0
  30. data/lib/active_record_mysql_repl/extensions/tabler.rb +32 -0
  31. data/lib/active_record_mysql_repl/extensions.rb +18 -0
  32. data/lib/active_record_mysql_repl/ssh_tunnel.rb +21 -0
  33. data/lib/active_record_mysql_repl/version.rb +5 -0
  34. data/lib/active_record_mysql_repl.rb +12 -0
  35. data/sample_config/.army.sample.yml +6 -0
  36. data/sample_config/.pryrc.sample +9 -0
  37. data/sample_config/associations.sample.yml +13 -0
  38. data/sample_config/databases.sample.yml +9 -0
  39. data/sample_config/sample_db.sql +153 -0
  40. metadata +75 -36
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordMysqlRepl
4
+ module Database
5
+ class Associations
6
+ class AnalyzedTable
7
+ attr_accessor :name, :has_many, :has_one, :belongs_to
8
+ def initialize(name)
9
+ @name = name
10
+ @has_many = []
11
+ @has_one = []
12
+ @belongs_to = []
13
+ end
14
+ end
15
+
16
+ def self.load(path)
17
+ new(YAML.load_file(path, aliases: true))
18
+ end
19
+
20
+ def initialize(associations)
21
+ @associations = associations.map { |key, association| [key, Association.new(association)] }.to_h
22
+ end
23
+
24
+ def [](key)
25
+ @associations[key]
26
+ end
27
+
28
+ # Analyze the relationship between tables based on the information of *_id columns
29
+ # For example, if users.company_id exists, users belongs_to companies and companies has_many users
30
+ def self.analyze(tables, association_settings)
31
+ analyzed_tables = tables.map { |table| [table, AnalyzedTable.new(table)] }.to_h
32
+
33
+ analyzed_tables.each do |table_name, table|
34
+ association_setting = association_settings[table_name]
35
+ columns = ActiveRecord::Base.connection.columns(table_name)
36
+ columns.each do |column|
37
+ next if association_setting.present? && association_settings.ignore_columns(table_name).include?(column.name)
38
+
39
+ associatable = column.name.gsub(/_id$/, '') if column.name.end_with?('_id')
40
+ next if associatable.blank? || associatable == 'class' # reserved word
41
+
42
+ if analyzed_tables.keys.include?(associatable.pluralize)
43
+ table.belongs_to << associatable.singularize if associatable
44
+ analyzed_tables[associatable.pluralize].has_many << table_name.pluralize
45
+ else
46
+ associatable_table_name = associatable.split('_').last
47
+ if analyzed_tables.keys.include?(associatable_table_name.pluralize)
48
+ table.belongs_to << { name: associatable, class_name: associatable_table_name.classify, foreign_key: :id }
49
+ else
50
+ table.belongs_to << { name: associatable, class_name: table_name.singularize.classify, foreign_key: :id }
51
+ end
52
+ end
53
+ end
54
+
55
+ next if association_setting.blank?
56
+
57
+ # merge yaml settings
58
+ [:has_many, :belongs_to].each do |type|
59
+ ass = association_setting.fetch(type.to_s, []).map(&:symbolize_keys)
60
+ table.send(type).concat(ass) unless ass.blank?
61
+ end
62
+ end
63
+
64
+ analyzed_tables.values
65
+ end
66
+ end
67
+
68
+ class Association
69
+ def initialize(association)
70
+ @association = association
71
+ end
72
+
73
+ def [](table)
74
+ table = (@association.keys - ['ignore_columns']) & [table]
75
+ @association[table.first] if table.present?
76
+ end
77
+
78
+ def ignore_columns(table)
79
+ @association.fetch('ignore_columns', {}).fetch(table, [])
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordMysqlRepl
4
+ module Database
5
+ class Configs
6
+ attr_reader :configs
7
+
8
+ def self.load(path)
9
+ new(YAML.load_file(path))
10
+ end
11
+
12
+ def [](key)
13
+ configs[key]
14
+ end
15
+
16
+ def keys
17
+ configs.keys
18
+ end
19
+
20
+ private
21
+
22
+ def initialize(configs)
23
+ @configs = configs.map { |key, config| [key, Config.new(config)] }.to_h
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ class Config
30
+ attr_reader *%i[
31
+ bastion
32
+ ssh_user
33
+ remote_host
34
+ database
35
+ port
36
+ user
37
+ password
38
+ prompt_color
39
+ ]
40
+
41
+ def initialize(config)
42
+ @bastion = config["bastion"]
43
+ @ssh_user = config["ssh_user"]
44
+ @remote_host = config["remote_host"]
45
+ @database = config["database"]
46
+ @port = config["port"]
47
+ @user = config["user"]
48
+ @password = config["password"]
49
+ @prompt_color = config["prompt_color"]
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordMysqlRepl
4
+ module Database
5
+ module Connection
6
+ MAX_RETRY = 3
7
+
8
+ def self.connect(db_config, port)
9
+ conn = {
10
+ adapter: 'mysql2',
11
+ host: '127.0.0.1',
12
+ port: port,
13
+ username: db_config.user,
14
+ password: db_config.password,
15
+ database: db_config.database,
16
+ }
17
+
18
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
19
+ conn = ActiveRecord::Base.establish_connection(conn)
20
+
21
+ puts "Ensureing connection to #{db_config.database} on port 127.0.0.1:#{port}".gray
22
+
23
+ tables = nil
24
+ (1..MAX_RETRY-1).to_a.each do |time|
25
+ begin
26
+ tables = ActiveRecord::Base.connection.tables
27
+ rescue => e
28
+ puts "#{e}, Retry #{time}/#{MAX_RETRY}".red
29
+ sleep time*time
30
+ next
31
+ end
32
+ end
33
+
34
+ if tables.blank?
35
+ raise "Retred #{MAX_RETRY} times, but failed to connect to database."
36
+ end
37
+
38
+ yield if block_given?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'active_record'
5
+ require 'active_support/all'
6
+
7
+ module ActiveRecordMysqlRepl
8
+ module Database
9
+ module Loader
10
+ def self.load_tables(db_config_key, army_config, port)
11
+ puts "Loading tables".gray
12
+
13
+ tables = ActiveRecord::Base.connection.tables
14
+ association_settings = Associations.load(army_config.associations)
15
+
16
+ analyzed_tables = Associations.analyze(tables, association_settings[db_config_key])
17
+ skipped = []
18
+ analyzed_tables.each do |analyzed_table|
19
+ # defer model definition for tables with `has_many: :xxx, through: xxx` associations
20
+ if analyzed_table.has_many.any? { |hm| hm.is_a?(Hash) && hm.key?(:through) }
21
+ skipped << analyzed_table
22
+ next
23
+ end
24
+ define_model(analyzed_table)
25
+ end
26
+
27
+ skipped.each do |analyzed_table|
28
+ define_model(analyzed_table)
29
+ end
30
+ end
31
+
32
+ def self.define_model(analyzed_table)
33
+ klass = Class.new(ActiveRecord::Base)
34
+
35
+ analyzed_table.has_many.each do |has_many|
36
+ if has_many.is_a?(String)
37
+ klass.has_many has_many.to_sym
38
+ elsif has_many.is_a?(Hash)
39
+ klass.has_many has_many[:name].to_sym, **has_many.except(:name)
40
+ end
41
+ end
42
+
43
+ analyzed_table.has_one.each do |has_one|
44
+ klass.has_one has_one[:name].to_sym, class_name: has_one[:class_name], foreign_key: has_one[:foreign_key]
45
+ end
46
+
47
+ analyzed_table.belongs_to.each do |belongs_to|
48
+ if belongs_to.is_a?(String)
49
+ klass.belongs_to belongs_to.to_sym
50
+ elsif belongs_to.is_a?(Hash)
51
+ klass.belongs_to belongs_to[:name].to_sym, class_name: belongs_to[:class_name].to_sym
52
+ end
53
+ end
54
+
55
+ # allow type column
56
+ klass.inheritance_column = :_type_disabled
57
+ klass.table_name = analyzed_table.name
58
+
59
+ Object.const_set(analyzed_table.name.classify, klass)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'database/configs'
4
+ require_relative 'database/association'
5
+ require_relative 'database/connection'
6
+ require_relative 'database/loader'
7
+
8
+ module ActiveRecordMysqlRepl
9
+ module Database; end
10
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordMysqlRepl
4
+ module Extensions
5
+ module ActiveRecord
6
+ module Base
7
+ def as(name = nil)
8
+ self.reflect_on_all_associations(name).map(&:name)
9
+ end
10
+
11
+ def describe
12
+ [
13
+ exec_sql("DESCRIBE #{self.table_name}").tab(:h),
14
+ exec_sql("SHOW INDEX FROM #{self.table_name}").tab(:h)
15
+ ].join("\n")
16
+ end
17
+
18
+ alias desc describe
19
+ alias d describe
20
+
21
+ def show_ddl
22
+ exec_sql("SHOW CREATE TABLE #{self.table_name}")[0]['Create Table']
23
+ end
24
+
25
+ alias ddl show_ddl
26
+ end
27
+
28
+ ::ActiveRecord::Base.extend(Base)
29
+ end
30
+ end
31
+ end
32
+
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordMysqlRepl
4
+ module Extensions
5
+ module Global
6
+ def tables
7
+ ::ActiveRecord::Base.connection.tables.map(&:singularize).map(&:classify)
8
+ end
9
+
10
+ def models
11
+ ::ActiveRecord::Base.subclasses
12
+ end
13
+
14
+ def transaction
15
+ ::ActiveRecord::Base.transaction { yield }
16
+ end
17
+
18
+ def exec_sql(sql)
19
+ res = ::ActiveRecord::Base.connection.select_all(sql)
20
+ res.rows.map do |row|
21
+ res.columns.zip(row).to_h
22
+ end
23
+ end
24
+ end
25
+
26
+ Kernel.include(Global)
27
+ end
28
+ end
29
+
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordMysqlRepl
4
+ module Extensions
5
+ module Hash
6
+ def method_missing(name, *args)
7
+ if (self.keys & [name, name.to_s]).present?
8
+ self[name] || self[name.to_s]
9
+ elsif name.to_s.end_with?('=') && self.keys.include?(name.to_s[0..-2])
10
+ name = name[0..-2]
11
+ self[name] = args.first if self.key?(name)
12
+ self[name.to_sym] = args.first if self.key?(name.to_sym)
13
+ end
14
+ end
15
+
16
+ ::Hash.include(Hash)
17
+ end
18
+ end
19
+ end
20
+
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'terminal-table'
4
+ require 'csv'
5
+ require 'clipboard'
6
+ require 'json'
7
+
8
+ module ActiverecordMysqlRepl
9
+ module Extensions
10
+ module Object
11
+ def tabulate(orientation = nil)
12
+ arr = if self.is_a?(Array)
13
+ self
14
+ elsif self.is_a?(::ActiveRecord::Relation)
15
+ self.to_a
16
+ else
17
+ [self]
18
+ end
19
+
20
+ arr = arr.map do |e|
21
+ if e.is_a?(::ActiveRecord::Base)
22
+ values = e.attributes.transform_values do |v|
23
+ next JSON.pretty_generate(v) if v.is_a?(Enumerable) && v.size > 0
24
+ next 'NULL' if v.nil?
25
+ next '""' if v == ''
26
+ v.to_s
27
+ end
28
+ next values
29
+ end
30
+ e
31
+ end
32
+
33
+ raise "#{arr} is not an array of hash".red unless arr.all? { |e| e.is_a?(Hash) }
34
+ raise "#{arr} contains Hashes with different keys".red unless arr.map(&:keys).uniq.size == 1
35
+
36
+ orientation = case orientation
37
+ when :vertical, :v
38
+ :vertical
39
+ when :horizontal, :h
40
+ :horizontal
41
+ else
42
+ if arr.first.keys.size > 5
43
+ :vertical
44
+ else
45
+ :horizontal
46
+ end
47
+ end
48
+
49
+ t = Terminal::Table.new
50
+
51
+ case orientation
52
+ when :horizontal
53
+ t.headings = arr.first.keys
54
+ t.rows = arr.map(&:values)
55
+
56
+ when :vertical
57
+ t.headings = ['Name', 'Value']
58
+ arr.each.with_index do |row, i|
59
+ row.each { |col, val| t.add_row [col, val] }
60
+ t.add_separator if i < arr.size - 1
61
+ end
62
+ end
63
+
64
+ t.to_s
65
+ end
66
+
67
+ alias tab tabulate
68
+ alias table tabulate
69
+ alias to_table tabulate
70
+
71
+ def csv(orientation = nil)
72
+ arr = if self.is_a?(Array)
73
+ self
74
+ elsif self.is_a?(ActiveRecord::Relation)
75
+ self.to_a
76
+ else
77
+ [self]
78
+ end
79
+
80
+ arr = arr.map do |e|
81
+ if e.is_a?(ActiveRecord::Base)
82
+ values = e.attributes.transform_values do |v|
83
+ next JSON.pretty_generate(v) if v.is_a?(Enumerable) && v.size > 0
84
+ next 'NULL' if v.nil?
85
+ next '""' if v == ''
86
+ v.to_s
87
+ end
88
+ next values
89
+ end
90
+ e
91
+ end
92
+
93
+ raise "#{arr} is not an array of hash".red unless arr.all? { |e| e.is_a?(Hash) }
94
+ raise "#{arr} contains Hashes with different keys".red unless arr.map(&:keys).uniq.size == 1
95
+
96
+ orientation = case orientation
97
+ when :vertical, :v
98
+ :vertical
99
+ when :horizontal, :h
100
+ :horizontal
101
+ else
102
+ if arr.first.keys.size > 5
103
+ :vertical
104
+ else
105
+ :horizontal
106
+ end
107
+ end
108
+
109
+ CSV.generate do |csv|
110
+ case orientation
111
+ when :horizontal
112
+ csv << arr.first.keys
113
+ arr.each { |row| csv << row.values }
114
+ when :vertical
115
+ arr.each do |row|
116
+ row.each { |col, val| csv << [col, val] }
117
+ csv << []
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ def copy
124
+ Clipboard.copy(self.to_s)
125
+ end
126
+ alias cp copy
127
+
128
+ alias j to_json
129
+
130
+ def jp
131
+ JSON.pretty_generate(JSON.parse(to_json))
132
+ end
133
+ end
134
+ end
135
+
136
+ ::Object.include(Extensions::Object)
137
+ end
138
+
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordMysqlRepl
4
+ module Extensions
5
+ module Tabler
6
+ ::ActiveRecord::Base.subclasses.each do |model|
7
+ define_method(model.table_name) do
8
+ model.find(self)
9
+ end
10
+
11
+ define_method(model.table_name.singularize) do
12
+ model.find(self)
13
+ end
14
+
15
+ model.column_names.each do |column|
16
+ define_method("#{model.table_name.singularize}_by_#{column}") do
17
+ model.where(column => self)
18
+ end
19
+
20
+ define_method("#{model.table_name.singularize}_#{column}_like") do
21
+ model.where("#{column} like ?", "%#{self}%")
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ String.include Tabler
28
+ Symbol.include Tabler
29
+ Enumerable.include Tabler
30
+ end
31
+ end
32
+
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'extensions/object'
4
+ require_relative 'extensions/global'
5
+ require_relative 'extensions/active_record'
6
+ require_relative 'extensions/hash'
7
+ require_relative 'extensions/tabler'
8
+
9
+ module ActiveRecordMysqlRepl
10
+ module Extensions
11
+ def self.load_external(dir)
12
+ Dir.glob("#{dir}/*.rb").each do |file|
13
+ require file
14
+ end
15
+ end
16
+ end
17
+ end
18
+
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/ssh'
4
+ require 'net/ssh/gateway'
5
+
6
+ module ActiveRecordMysqlRepl
7
+ module SSHTunnel
8
+ EPHEMERAL_PORT = 0
9
+
10
+ def self.tunnel(db_config)
11
+ return yield(db_config.port) if block_given? unless db_config.bastion
12
+
13
+ puts "Establishing ssh tunnel to #{db_config.remote_host}:#{db_config.port} via #{db_config.ssh_user}#{db_config.bastion}".gray
14
+
15
+ gateway = Net::SSH::Gateway.new(db_config.bastion, db_config.ssh_user)
16
+ gateway.open(db_config.remote_host, db_config.port) do |port|
17
+ yield(port) if block_given?
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordMysqlRepl
4
+ VERSION = "0.1.2"
5
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "active_record_mysql_repl/version"
4
+ require_relative 'active_record_mysql_repl/config'
5
+ require_relative 'active_record_mysql_repl/cli'
6
+ require_relative 'active_record_mysql_repl/database'
7
+ require_relative 'active_record_mysql_repl/ssh_tunnel'
8
+ require_relative 'active_record_mysql_repl/version'
9
+ # extensions should be loaded after database connection is established
10
+ # require_relative 'active_record_mysql_repl/extensions'
11
+
12
+ module ActiveRecordMysqlRepl; end
@@ -0,0 +1,6 @@
1
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/nogahighland/active_record_mysql_repl/refs/heads/main/json_schemas/army_schema.json
2
+
3
+ database_config: ./databases.sample.yml
4
+ associations: ./associations.sample.yml
5
+ extensions_dir:
6
+ pryrc: ./.pryrc.sample
@@ -0,0 +1,9 @@
1
+ begin
2
+ require "awesome_print"
3
+ Pry.config.print = proc do |_output, value, pry_instance|
4
+ value = value.is_a?(String) ? value : value.ai
5
+ pry_instance.pager.page(value)
6
+ end
7
+ rescue LoadError
8
+ puts "no awesome_print :("
9
+ end
@@ -0,0 +1,13 @@
1
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/nogahighland/active_record_mysql_repl/refs/heads/main/json_schemas/association_schema.json
2
+
3
+ test:
4
+ users:
5
+ belongs_to:
6
+ - name: profile
7
+ foreign_key: id
8
+ class_name: UserProfile
9
+
10
+ ignore_columns:
11
+ user:
12
+ - login_id
13
+
@@ -0,0 +1,9 @@
1
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/nogahighland/active_record_mysql_repl/refs/heads/main/json_schema/databases_schema.json
2
+
3
+ test:
4
+ remote_host: 127.0.0.1
5
+ database: test
6
+ port: 3306
7
+ user: root
8
+ password: root
9
+ prompt_color: cyan