active_record_mysql_repl 0.1.0 → 0.1.2

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.
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