lhm-teak 3.6.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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +43 -0
  3. data/.gitignore +12 -0
  4. data/.rubocop.yml +183 -0
  5. data/.travis.yml +21 -0
  6. data/Appraisals +24 -0
  7. data/CHANGELOG.md +254 -0
  8. data/Gemfile +5 -0
  9. data/Gemfile.lock +67 -0
  10. data/LICENSE +27 -0
  11. data/README.md +335 -0
  12. data/Rakefile +33 -0
  13. data/dev.yml +45 -0
  14. data/docker-compose.yml +60 -0
  15. data/gemfiles/activerecord_5.2.gemfile +9 -0
  16. data/gemfiles/activerecord_5.2.gemfile.lock +66 -0
  17. data/gemfiles/activerecord_6.0.gemfile +7 -0
  18. data/gemfiles/activerecord_6.0.gemfile.lock +68 -0
  19. data/gemfiles/activerecord_6.1.gemfile +7 -0
  20. data/gemfiles/activerecord_6.1.gemfile.lock +67 -0
  21. data/gemfiles/activerecord_7.0.0.alpha2.gemfile +7 -0
  22. data/gemfiles/activerecord_7.0.0.alpha2.gemfile.lock +65 -0
  23. data/lhm.gemspec +38 -0
  24. data/lib/lhm/atomic_switcher.rb +46 -0
  25. data/lib/lhm/chunk_finder.rb +62 -0
  26. data/lib/lhm/chunk_insert.rb +61 -0
  27. data/lib/lhm/chunker.rb +95 -0
  28. data/lib/lhm/cleanup/current.rb +71 -0
  29. data/lib/lhm/command.rb +48 -0
  30. data/lib/lhm/connection.rb +108 -0
  31. data/lib/lhm/entangler.rb +112 -0
  32. data/lib/lhm/intersection.rb +51 -0
  33. data/lib/lhm/invoker.rb +100 -0
  34. data/lib/lhm/locked_switcher.rb +76 -0
  35. data/lib/lhm/migration.rb +51 -0
  36. data/lib/lhm/migrator.rb +244 -0
  37. data/lib/lhm/printer.rb +63 -0
  38. data/lib/lhm/proxysql_helper.rb +10 -0
  39. data/lib/lhm/railtie.rb +9 -0
  40. data/lib/lhm/sql_helper.rb +77 -0
  41. data/lib/lhm/sql_retry.rb +180 -0
  42. data/lib/lhm/table.rb +121 -0
  43. data/lib/lhm/table_name.rb +23 -0
  44. data/lib/lhm/test_support.rb +35 -0
  45. data/lib/lhm/throttler/slave_lag.rb +162 -0
  46. data/lib/lhm/throttler/threads_running.rb +53 -0
  47. data/lib/lhm/throttler/time.rb +29 -0
  48. data/lib/lhm/throttler.rb +36 -0
  49. data/lib/lhm/timestamp.rb +11 -0
  50. data/lib/lhm/version.rb +6 -0
  51. data/lib/lhm-shopify.rb +1 -0
  52. data/lib/lhm.rb +156 -0
  53. data/scripts/helpers/wait-for-dbs.sh +21 -0
  54. data/scripts/mysql/reader/create_replication.sql +10 -0
  55. data/scripts/mysql/writer/create_test_db.sql +1 -0
  56. data/scripts/mysql/writer/create_users.sql +6 -0
  57. data/scripts/proxysql/proxysql.cnf +117 -0
  58. data/shipit.rubygems.yml +0 -0
  59. data/spec/.lhm.example +4 -0
  60. data/spec/README.md +58 -0
  61. data/spec/fixtures/bigint_table.ddl +4 -0
  62. data/spec/fixtures/composite_primary_key.ddl +6 -0
  63. data/spec/fixtures/composite_primary_key_dest.ddl +6 -0
  64. data/spec/fixtures/custom_primary_key.ddl +6 -0
  65. data/spec/fixtures/custom_primary_key_dest.ddl +6 -0
  66. data/spec/fixtures/destination.ddl +6 -0
  67. data/spec/fixtures/lines.ddl +7 -0
  68. data/spec/fixtures/origin.ddl +6 -0
  69. data/spec/fixtures/permissions.ddl +5 -0
  70. data/spec/fixtures/small_table.ddl +4 -0
  71. data/spec/fixtures/tracks.ddl +5 -0
  72. data/spec/fixtures/users.ddl +14 -0
  73. data/spec/fixtures/wo_id_int_column.ddl +6 -0
  74. data/spec/integration/atomic_switcher_spec.rb +129 -0
  75. data/spec/integration/chunk_insert_spec.rb +30 -0
  76. data/spec/integration/chunker_spec.rb +269 -0
  77. data/spec/integration/cleanup_spec.rb +147 -0
  78. data/spec/integration/database.yml +25 -0
  79. data/spec/integration/entangler_spec.rb +68 -0
  80. data/spec/integration/integration_helper.rb +252 -0
  81. data/spec/integration/invoker_spec.rb +33 -0
  82. data/spec/integration/lhm_spec.rb +659 -0
  83. data/spec/integration/lock_wait_timeout_spec.rb +30 -0
  84. data/spec/integration/locked_switcher_spec.rb +50 -0
  85. data/spec/integration/proxysql_spec.rb +34 -0
  86. data/spec/integration/sql_retry/db_connection_helper.rb +52 -0
  87. data/spec/integration/sql_retry/lock_wait_spec.rb +127 -0
  88. data/spec/integration/sql_retry/lock_wait_timeout_test_helper.rb +114 -0
  89. data/spec/integration/sql_retry/proxysql_helper.rb +22 -0
  90. data/spec/integration/sql_retry/retry_with_proxysql_spec.rb +109 -0
  91. data/spec/integration/table_spec.rb +83 -0
  92. data/spec/integration/toxiproxy_helper.rb +40 -0
  93. data/spec/test_helper.rb +69 -0
  94. data/spec/unit/atomic_switcher_spec.rb +29 -0
  95. data/spec/unit/chunk_finder_spec.rb +73 -0
  96. data/spec/unit/chunk_insert_spec.rb +67 -0
  97. data/spec/unit/chunker_spec.rb +176 -0
  98. data/spec/unit/connection_spec.rb +111 -0
  99. data/spec/unit/entangler_spec.rb +187 -0
  100. data/spec/unit/intersection_spec.rb +51 -0
  101. data/spec/unit/lhm_spec.rb +46 -0
  102. data/spec/unit/locked_switcher_spec.rb +46 -0
  103. data/spec/unit/migrator_spec.rb +144 -0
  104. data/spec/unit/printer_spec.rb +85 -0
  105. data/spec/unit/sql_helper_spec.rb +28 -0
  106. data/spec/unit/table_name_spec.rb +39 -0
  107. data/spec/unit/table_spec.rb +47 -0
  108. data/spec/unit/throttler/slave_lag_spec.rb +322 -0
  109. data/spec/unit/throttler/threads_running_spec.rb +64 -0
  110. data/spec/unit/throttler_spec.rb +124 -0
  111. data/spec/unit/unit_helper.rb +26 -0
  112. metadata +366 -0
@@ -0,0 +1,112 @@
1
+ # Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'lhm/command'
5
+ require 'lhm/sql_helper'
6
+ require 'lhm/sql_retry'
7
+ require 'lhm/connection'
8
+
9
+ module Lhm
10
+ class Entangler
11
+ include Command
12
+ include SqlHelper
13
+
14
+ attr_reader :connection
15
+
16
+ LOG_PREFIX = "Entangler"
17
+
18
+ # Creates entanglement between two tables. All creates, updates and deletes
19
+ # to origin will be repeated on the destination table.
20
+ def initialize(migration, connection = nil)
21
+ @intersection = migration.intersection
22
+ @origin = migration.origin
23
+ @destination = migration.destination
24
+ @connection = connection
25
+ end
26
+
27
+ def entangle
28
+ [
29
+ create_delete_trigger,
30
+ create_insert_trigger,
31
+ create_update_trigger
32
+ ]
33
+ end
34
+
35
+ def untangle
36
+ [
37
+ "drop trigger if exists `#{ trigger(:del) }`",
38
+ "drop trigger if exists `#{ trigger(:ins) }`",
39
+ "drop trigger if exists `#{ trigger(:upd) }`"
40
+ ]
41
+ end
42
+
43
+ def create_insert_trigger
44
+ strip %Q{
45
+ create trigger `#{ trigger(:ins) }`
46
+ after insert on `#{ @origin.name }` for each row
47
+ replace into `#{ @destination.name }` (#{ @intersection.destination.joined }) #{ SqlHelper.annotation }
48
+ values (#{ @intersection.origin.typed('NEW') })
49
+ }
50
+ end
51
+
52
+ def create_update_trigger
53
+ strip %Q{
54
+ create trigger `#{ trigger(:upd) }`
55
+ after update on `#{ @origin.name }` for each row
56
+ replace into `#{ @destination.name }` (#{ @intersection.destination.joined }) #{ SqlHelper.annotation }
57
+ values (#{ @intersection.origin.typed('NEW') })
58
+ }
59
+ end
60
+
61
+ def create_delete_trigger
62
+ strip %Q{
63
+ create trigger `#{ trigger(:del) }`
64
+ after delete on `#{ @origin.name }` for each row
65
+ delete ignore from `#{ @destination.name }` #{ SqlHelper.annotation }
66
+ where `#{ @destination.name }`.`id` = OLD.`id`
67
+ }
68
+ end
69
+
70
+ def trigger(type)
71
+ "lhmt_#{ type }_#{ @origin.name }"[0...64]
72
+ end
73
+
74
+ def expected_triggers
75
+ [trigger(:ins), trigger(:upd), trigger(:del)]
76
+ end
77
+
78
+ def validate
79
+ unless @connection.data_source_exists?(@origin.name)
80
+ error("#{ @origin.name } does not exist")
81
+ end
82
+
83
+ unless @connection.data_source_exists?(@destination.name)
84
+ error("#{ @destination.name } does not exist")
85
+ end
86
+ end
87
+
88
+ def before
89
+ entangle.each do |stmt|
90
+ @connection.execute(stmt, should_retry: true, log_prefix: LOG_PREFIX)
91
+ end
92
+ Lhm.logger.info("Created triggers on #{@origin.name}")
93
+ end
94
+
95
+ def after
96
+ untangle.each do |stmt|
97
+ @connection.execute(stmt, should_retry: true, log_prefix: LOG_PREFIX)
98
+ end
99
+ Lhm.logger.info("Dropped triggers on #{@origin.name}")
100
+ end
101
+
102
+ def revert
103
+ after
104
+ end
105
+
106
+ private
107
+
108
+ def strip(sql)
109
+ sql.strip.gsub(/\n */, "\n")
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,51 @@
1
+ # Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ module Lhm
5
+ # Determine and format columns common to origin and destination.
6
+ class Intersection
7
+ def initialize(origin, destination, renames = {})
8
+ @origin = origin
9
+ @destination = destination
10
+ @renames = renames
11
+ end
12
+
13
+ def origin
14
+ (common + @renames.keys).extend(Joiners)
15
+ end
16
+
17
+ def destination
18
+ (common + @renames.values).extend(Joiners)
19
+ end
20
+
21
+ private
22
+
23
+ def common
24
+ (@origin.columns.keys & @destination.columns.keys).sort
25
+ end
26
+
27
+ module Joiners
28
+ def escaped
29
+ map { |name| tick(name) }
30
+ end
31
+
32
+ def joined
33
+ escaped.join(', ')
34
+ end
35
+
36
+ def typed(type)
37
+ map { |name| qualified(name, type) }.join(', ')
38
+ end
39
+
40
+ private
41
+
42
+ def qualified(name, type)
43
+ "`#{ type }`.`#{ name }`"
44
+ end
45
+
46
+ def tick(name)
47
+ "`#{ name }`"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,100 @@
1
+ # Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'lhm/chunker'
5
+ require 'lhm/entangler'
6
+ require 'lhm/atomic_switcher'
7
+ require 'lhm/locked_switcher'
8
+ require 'lhm/migrator'
9
+
10
+ module Lhm
11
+ # Copies an origin table to an altered destination table. Live activity is
12
+ # synchronized into the destination table using triggers.
13
+ #
14
+ # Once the origin and destination tables have converged, origin is archived
15
+ # and replaced by destination.
16
+ class Invoker
17
+ include SqlHelper
18
+ LOCK_WAIT_TIMEOUT_DELTA = 10
19
+ INNODB_LOCK_WAIT_TIMEOUT_MAX = 1073741824.freeze # https://dev.mysql.com/doc/refman/5.7/en/innodb-parameters.html#sysvar_innodb_lock_wait_timeout
20
+ LOCK_WAIT_TIMEOUT_MAX = 31536000.freeze # https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html
21
+
22
+ attr_reader :migrator, :connection
23
+
24
+ def initialize(origin, connection)
25
+ @connection = connection
26
+ @migrator = Migrator.new(origin, connection)
27
+ end
28
+
29
+ def set_session_lock_wait_timeouts
30
+ global_innodb_lock_wait_timeout = @connection.select_one("SHOW GLOBAL VARIABLES LIKE 'innodb_lock_wait_timeout'")
31
+ global_lock_wait_timeout = @connection.select_one("SHOW GLOBAL VARIABLES LIKE 'lock_wait_timeout'")
32
+
33
+ if global_innodb_lock_wait_timeout
34
+ desired_innodb_lock_wait_timeout = global_innodb_lock_wait_timeout['Value'].to_i + LOCK_WAIT_TIMEOUT_DELTA
35
+ if desired_innodb_lock_wait_timeout <= INNODB_LOCK_WAIT_TIMEOUT_MAX
36
+ @connection.execute("SET SESSION innodb_lock_wait_timeout=#{desired_innodb_lock_wait_timeout}")
37
+ end
38
+ end
39
+
40
+ if global_lock_wait_timeout
41
+ desired_lock_wait_timeout = global_lock_wait_timeout['Value'].to_i + LOCK_WAIT_TIMEOUT_DELTA
42
+ if desired_lock_wait_timeout <= LOCK_WAIT_TIMEOUT_MAX
43
+ @connection.execute("SET SESSION lock_wait_timeout=#{desired_lock_wait_timeout}")
44
+ end
45
+ end
46
+ end
47
+
48
+ def run(options = {})
49
+ normalize_options(options)
50
+ set_session_lock_wait_timeouts
51
+ migration = @migrator.run
52
+ entangler = Entangler.new(migration, @connection)
53
+
54
+ entangler.run do
55
+ options[:verifier] ||= Proc.new { |conn| triggers_still_exist?(conn, entangler) }
56
+ options.fetch(:chunker_class, Chunker).new(migration, @connection, options).run
57
+ raise "Required triggers do not exist" unless triggers_still_exist?(@connection, entangler)
58
+ if options[:atomic_switch]
59
+ AtomicSwitcher.new(migration, @connection).run
60
+ else
61
+ LockedSwitcher.new(migration, @connection).run
62
+ end
63
+ end
64
+ end
65
+
66
+ def triggers_still_exist?(conn, entangler)
67
+ triggers = conn.select_values("SHOW TRIGGERS LIKE '%#{migrator.origin.name}'").select { |name| name =~ /^lhmt/ }
68
+ triggers.sort == entangler.expected_triggers.sort
69
+ end
70
+
71
+ private
72
+
73
+ def normalize_options(options)
74
+ Lhm.logger.info "Starting LHM run on table=#{@migrator.name}"
75
+
76
+ unless options.include?(:atomic_switch)
77
+ if supports_atomic_switch?
78
+ options[:atomic_switch] = true
79
+ else
80
+ raise Error.new(
81
+ "Using mysql #{version_string}. You must explicitly set " \
82
+ 'options[:atomic_switch] (re SqlHelper#supports_atomic_switch?)')
83
+ end
84
+ end
85
+
86
+ if options[:throttler]
87
+ throttler_options = options[:throttler_options] || {}
88
+ options[:throttler] = Throttler::Factory.create_throttler(options[:throttler], throttler_options)
89
+ else
90
+ options[:throttler] = Lhm.throttler
91
+ end
92
+
93
+ Lhm.connection.retry_config = options[:retriable] || {}
94
+
95
+ rescue => e
96
+ Lhm.logger.error "LHM run failed with exception=#{e.class} message=#{e.message}"
97
+ raise
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,76 @@
1
+ # Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'lhm/command'
5
+ require 'lhm/migration'
6
+ require 'lhm/sql_helper'
7
+
8
+ module Lhm
9
+ # Switches origin with destination table nonatomically using a locked write.
10
+ # LockedSwitcher adopts the Facebook strategy, with the following caveat:
11
+ #
12
+ # "Since alter table causes an implicit commit in innodb, innodb locks get
13
+ # released after the first alter table. So any transaction that sneaks in
14
+ # after the first alter table and before the second alter table gets
15
+ # a 'table not found' error. The second alter table is expected to be very
16
+ # fast though because copytable is not visible to other transactions and so
17
+ # there is no need to wait."
18
+ #
19
+ class LockedSwitcher
20
+ include Command
21
+ include SqlHelper
22
+
23
+ attr_reader :connection
24
+
25
+ LOG_PREFIX = "LockedSwitcher"
26
+
27
+ def initialize(migration, connection = nil)
28
+ @migration = migration
29
+ @connection = connection
30
+ @origin = migration.origin
31
+ @destination = migration.destination
32
+ end
33
+
34
+ def statements
35
+ uncommitted { switch }
36
+ end
37
+
38
+ def switch
39
+ [
40
+ "lock table `#{ @origin.name }` write, `#{ @destination.name }` write",
41
+ "alter table `#{ @origin.name }` rename `#{ @migration.archive_name }`",
42
+ "alter table `#{ @destination.name }` rename `#{ @origin.name }`",
43
+ 'commit',
44
+ 'unlock tables'
45
+ ]
46
+ end
47
+
48
+ def uncommitted
49
+ [
50
+ 'set @lhm_auto_commit = @@session.autocommit',
51
+ 'set session autocommit = 0',
52
+ yield,
53
+ 'set session autocommit = @lhm_auto_commit'
54
+ ].flatten
55
+ end
56
+
57
+ def validate
58
+ unless @connection.data_source_exists?(@origin.name) &&
59
+ @connection.data_source_exists?(@destination.name)
60
+ error "`#{ @origin.name }` and `#{ @destination.name }` must exist"
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def revert
67
+ @connection.execute(tagged('unlock tables'))
68
+ end
69
+
70
+ def execute
71
+ statements.each do |stmt|
72
+ @connection.execute(tagged(stmt))
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,51 @@
1
+ # Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'lhm/intersection'
5
+ require 'lhm/timestamp'
6
+
7
+ module Lhm
8
+ class Migration
9
+ attr_reader :origin, :destination, :renames
10
+
11
+ def initialize(origin, destination, conditions = nil, renames = {}, time = Time.now)
12
+ @origin = origin
13
+ @destination = destination
14
+ @conditions = conditions
15
+ @renames = renames
16
+ @table_name = TableName.new(@origin.name, time)
17
+ end
18
+
19
+ def conditions
20
+ if @conditions.kind_of?(Proc)
21
+ @conditions.call
22
+ else
23
+ @conditions
24
+ end
25
+ end
26
+
27
+ def archive_name
28
+ @archive_name ||= @table_name.archived
29
+ end
30
+
31
+ def intersection
32
+ Intersection.new(@origin, @destination, @renames)
33
+ end
34
+
35
+ def origin_name
36
+ @table_name.original
37
+ end
38
+
39
+ def origin_columns
40
+ @origin_columns ||= intersection.origin.typed(origin_name)
41
+ end
42
+
43
+ def destination_name
44
+ @destination_name ||= destination.name
45
+ end
46
+
47
+ def destination_columns
48
+ @destination_columns ||= intersection.destination.joined
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,244 @@
1
+ # Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'lhm/command'
5
+ require 'lhm/migration'
6
+ require 'lhm/sql_helper'
7
+ require 'lhm/table'
8
+
9
+ module Lhm
10
+ # Copies existing schema and applies changes using alter on the empty table.
11
+ # `run` returns a Migration which can be used for the remaining process.
12
+ class Migrator
13
+ include Command
14
+ include SqlHelper
15
+
16
+ attr_reader :name, :statements, :connection, :conditions, :renames, :origin
17
+
18
+ def initialize(table, connection = nil)
19
+ @connection = connection
20
+ @origin = table
21
+ @name = table.destination_name
22
+ @statements = []
23
+ @renames = {}
24
+ end
25
+
26
+ # Alter a table with a custom statement
27
+ #
28
+ # @example
29
+ #
30
+ # Lhm.change_table(:users) do |m|
31
+ # m.ddl("ALTER TABLE #{m.name} ADD COLUMN age INT(11)")
32
+ # end
33
+ #
34
+ # @param [String] statement SQL alter statement
35
+ # @note
36
+ #
37
+ # Don't write the table name directly into the statement. Use the #name
38
+ # getter instead, because the alter statement will be executed against a
39
+ # temporary table.
40
+ #
41
+ def ddl(statement)
42
+ statements << statement
43
+ end
44
+
45
+ # Add a column to a table
46
+ #
47
+ # @example
48
+ #
49
+ # Lhm.change_table(:users) do |m|
50
+ # m.add_column(:comment, "VARCHAR(12) DEFAULT '0'")
51
+ # end
52
+ #
53
+ # @param [String] name Name of the column to add
54
+ # @param [String] definition Valid SQL column definition
55
+ def add_column(name, definition)
56
+ ddl('alter table `%s` add column `%s` %s' % [@name, name, definition])
57
+ end
58
+
59
+ # Change an existing column to a new definition
60
+ #
61
+ # @example
62
+ #
63
+ # Lhm.change_table(:users) do |m|
64
+ # m.change_column(:comment, "VARCHAR(12) DEFAULT '0' NOT NULL")
65
+ # end
66
+ #
67
+ # @param [String] name Name of the column to change
68
+ # @param [String] definition Valid SQL column definition
69
+ def change_column(name, definition)
70
+ ddl('alter table `%s` modify column `%s` %s' % [@name, name, definition])
71
+ end
72
+
73
+ # Rename an existing column.
74
+ #
75
+ # @example
76
+ #
77
+ # Lhm.change_table(:users) do |m|
78
+ # m.rename_column(:login, :username)
79
+ # end
80
+ #
81
+ # @param [String] old Name of the column to change
82
+ # @param [String] nu New name to use for the column
83
+ def rename_column(old, nu)
84
+ col = @origin.columns[old.to_s]
85
+
86
+ definition = col[:type]
87
+
88
+ definition += ' NOT NULL' unless col[:is_nullable] == "YES"
89
+ definition += " DEFAULT #{@connection.quote(col[:column_default])}" if col[:column_default]
90
+ definition += " COMMENT #{@connection.quote(col[:comment])}" if col[:comment]
91
+ definition += " COLLATE #{@connection.quote(col[:collate])}" if col[:collate]
92
+
93
+ ddl('alter table `%s` change column `%s` `%s` %s' % [@name, old, nu, definition])
94
+ @renames[old.to_s] = nu.to_s
95
+ end
96
+
97
+ # Remove a column from a table
98
+ #
99
+ # @example
100
+ #
101
+ # Lhm.change_table(:users) do |m|
102
+ # m.remove_column(:comment)
103
+ # end
104
+ #
105
+ # @param [String] name Name of the column to delete
106
+ def remove_column(name)
107
+ ddl('alter table `%s` drop `%s`' % [@name, name])
108
+ end
109
+
110
+ # Add an index to a table
111
+ #
112
+ # @example
113
+ #
114
+ # Lhm.change_table(:users) do |m|
115
+ # m.add_index(:comment)
116
+ # m.add_index([:username, :created_at])
117
+ # m.add_index("comment(10)")
118
+ # end
119
+ #
120
+ # @param [String, Symbol, Array<String, Symbol>] columns
121
+ # A column name given as String or Symbol. An Array of Strings or Symbols
122
+ # for compound indexes. It's possible to pass a length limit.
123
+ # @param [String, Symbol] index_name
124
+ # Optional name of the index to be created
125
+ def add_index(columns, index_name = nil)
126
+ ddl(index_ddl(columns, false, index_name))
127
+ end
128
+
129
+ # Add a unique index to a table
130
+ #
131
+ # @example
132
+ #
133
+ # Lhm.change_table(:users) do |m|
134
+ # m.add_unique_index(:comment)
135
+ # m.add_unique_index([:username, :created_at])
136
+ # m.add_unique_index("comment(10)")
137
+ # end
138
+ #
139
+ # @param [String, Symbol, Array<String, Symbol>] columns
140
+ # A column name given as String or Symbol. An Array of Strings or Symbols
141
+ # for compound indexes. It's possible to pass a length limit.
142
+ # @param [String, Symbol] index_name
143
+ # Optional name of the index to be created
144
+ def add_unique_index(columns, index_name = nil)
145
+ ddl(index_ddl(columns, true, index_name))
146
+ end
147
+
148
+ # Remove an index from a table
149
+ #
150
+ # @example
151
+ #
152
+ # Lhm.change_table(:users) do |m|
153
+ # m.remove_index(:comment)
154
+ # m.remove_index([:username, :created_at])
155
+ # end
156
+ #
157
+ # @param [String, Symbol, Array<String, Symbol>] columns
158
+ # A column name given as String or Symbol. An Array of Strings or Symbols
159
+ # for compound indexes.
160
+ # @param [String, Symbol] index_name
161
+ # Optional name of the index to be removed
162
+ def remove_index(columns, index_name = nil)
163
+ columns = [columns].flatten.map(&:to_sym)
164
+ from_origin = @origin.indices.find { |_, cols| cols.map(&:to_sym) == columns }
165
+ index_name ||= from_origin[0] unless from_origin.nil?
166
+ index_name ||= idx_name(@origin.name, columns)
167
+ ddl('drop index `%s` on `%s`' % [index_name, @name])
168
+ end
169
+
170
+ # Filter the data that is copied into the new table by the provided SQL.
171
+ # This SQL will be inserted into the copy directly after the "from"
172
+ # statement - so be sure to use inner/outer join syntax and not cross joins.
173
+ #
174
+ # @example Add a conditions filter to the migration.
175
+ # Lhm.change_table(:sounds) do |m|
176
+ # m.filter("inner join users on users.`id` = sounds.`user_id` and sounds.`public` = 1")
177
+ # end
178
+ #
179
+ # @example Add a dynamic conditions filter to the migration.
180
+ # Lhm.change_table(:sounds) do |m|
181
+ # m.filter(-> { "where sounds.created_at <= '#{Time.now.to_fs(:db)}'" })
182
+ # end
183
+ #
184
+ # @param [ String, Proc ] sql The sql filter or a proc that returns a sql filter.
185
+ #
186
+ # @return [ String, Proc ] The sql filter.
187
+ def filter(sql)
188
+ @conditions = sql
189
+ end
190
+
191
+ private
192
+
193
+ def validate
194
+ unless @connection.data_source_exists?(@origin.name)
195
+ error("could not find origin table #{ @origin.name }")
196
+ end
197
+
198
+ unless @origin.satisfies_id_column_requirement?
199
+ error('origin does not satisfy `id` key requirements')
200
+ end
201
+
202
+ dest = @origin.destination_name
203
+
204
+ if @connection.data_source_exists?(dest)
205
+ error("#{ dest } should not exist; not cleaned up from previous run?")
206
+ end
207
+ end
208
+
209
+ def execute
210
+ destination_create
211
+ @statements.each do |stmt|
212
+ @connection.execute(tagged(stmt))
213
+ end
214
+ Migration.new(@origin, destination_read, conditions, renames)
215
+ end
216
+
217
+ def destination_create
218
+ original = %{CREATE TABLE `#{ @origin.name }`}
219
+ replacement = %{CREATE TABLE `#{ @origin.destination_name }`}
220
+ stmt = @origin.ddl.gsub(original, replacement)
221
+ @connection.execute(tagged(stmt))
222
+
223
+ Lhm.logger.info("Created destination table #{@origin.destination_name}")
224
+ end
225
+
226
+ def destination_read
227
+ Table.parse(@origin.destination_name, connection)
228
+ end
229
+
230
+ def index_ddl(cols, unique = nil, index_name = nil)
231
+ assert_valid_idx_name(index_name)
232
+ type = unique ? 'unique index' : 'index'
233
+ index_name ||= idx_name(@origin.name, cols)
234
+ parts = [type, index_name, @name, idx_spec(cols)]
235
+ 'create %s `%s` on `%s` (%s)' % parts
236
+ end
237
+
238
+ def assert_valid_idx_name(index_name)
239
+ if index_name && !(index_name.is_a?(String) || index_name.is_a?(Symbol))
240
+ raise ArgumentError, 'index_name must be a string or symbol'
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,63 @@
1
+ module Lhm
2
+ module Printer
3
+ class Output
4
+ def write(message)
5
+ print message
6
+ end
7
+ end
8
+
9
+ class Base
10
+ def initialize
11
+ @output = Output.new
12
+ end
13
+ end
14
+
15
+ class Percentage
16
+ def initialize
17
+ @max_length = 0
18
+ end
19
+
20
+ def notify(lowest, highest)
21
+ return if !highest || highest == 0
22
+
23
+ # The argument lowest represents the next_to_insert row id, and highest represents the
24
+ # maximum id upto which chunker has to copy the data.
25
+ # If all the rows are inserted upto highest, then lowest passed here from chunker was
26
+ # highest + 1, which leads to the printer printing the progress > 100%.
27
+ return if lowest >= highest
28
+
29
+ message = "%.2f%% (#{lowest}/#{highest}) complete" % (lowest.to_f / highest * 100.0)
30
+ write(message)
31
+ end
32
+
33
+ def end
34
+ write('100% complete')
35
+ end
36
+
37
+ def exception(e)
38
+ Lhm.logger.error("failed: #{e}")
39
+ end
40
+
41
+ private
42
+
43
+ def write(message)
44
+ if (extra = @max_length - message.length) < 0
45
+ @max_length = message.length
46
+ extra = 0
47
+ end
48
+
49
+ Lhm.logger.info(message)
50
+ end
51
+ end
52
+
53
+ class Dot < Base
54
+ def notify(*)
55
+ @output.write '.'
56
+ end
57
+
58
+ def end
59
+ @output.write "\n"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,10 @@
1
+ module Lhm
2
+ module ProxySQLHelper
3
+ extend self
4
+ ANNOTATION = "/*maintenance:lhm*/"
5
+
6
+ def tagged(sql)
7
+ "#{sql} #{ANNOTATION}"
8
+ end
9
+ end
10
+ end