strong_migrations 0.7.0 → 2.2.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.
@@ -12,6 +12,31 @@ module StrongMigrations
12
12
  def start_after
13
13
  Time.now.utc.strftime("%Y%m%d%H%M%S")
14
14
  end
15
+
16
+ def pgbouncer_message
17
+ if postgresql?
18
+ "\n# If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user"
19
+ end
20
+ end
21
+
22
+ def target_version
23
+ case adapter
24
+ when /mysql|trilogy/
25
+ # could try to connect to database and check for MariaDB
26
+ # but this should be fine
27
+ "8.0"
28
+ else
29
+ "10"
30
+ end
31
+ end
32
+
33
+ def adapter
34
+ ActiveRecord::Base.connection_db_config.adapter.to_s
35
+ end
36
+
37
+ def postgresql?
38
+ adapter =~ /postg/
39
+ end
15
40
  end
16
41
  end
17
42
  end
@@ -1,8 +1,7 @@
1
1
  # Mark existing migrations as safe
2
2
  StrongMigrations.start_after = <%= start_after %>
3
3
 
4
- # Set timeouts for migrations
5
- # If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user
4
+ # Set timeouts for migrations<%= pgbouncer_message %>
6
5
  StrongMigrations.lock_timeout = 10.seconds
7
6
  StrongMigrations.statement_timeout = 1.hour
8
7
 
@@ -10,9 +9,17 @@ StrongMigrations.statement_timeout = 1.hour
10
9
  # Outdated statistics can sometimes hurt performance
11
10
  StrongMigrations.auto_analyze = true
12
11
 
12
+ # Set the version of the production database
13
+ # so the right checks are run in development
14
+ # StrongMigrations.target_version = <%= target_version %>
15
+
13
16
  # Add custom checks
14
17
  # StrongMigrations.add_check do |method, args|
15
18
  # if method == :add_index && args[0].to_s == "users"
16
19
  # stop! "No more indexes on the users table"
17
20
  # end
18
- # end
21
+ # end<% if postgresql? %>
22
+
23
+ # Make some operations safe by default
24
+ # See https://github.com/ankane/strong_migrations#safe-by-default
25
+ # StrongMigrations.safe_by_default = true<% end %>
@@ -0,0 +1,76 @@
1
+ module StrongMigrations
2
+ module Adapters
3
+ class AbstractAdapter
4
+ def initialize(checker)
5
+ @checker = checker
6
+ end
7
+
8
+ def name
9
+ "Unknown"
10
+ end
11
+
12
+ def min_version
13
+ end
14
+
15
+ def set_statement_timeout(timeout)
16
+ # do nothing
17
+ end
18
+
19
+ def set_lock_timeout(timeout)
20
+ # do nothing
21
+ end
22
+
23
+ def check_lock_timeout(limit)
24
+ # do nothing
25
+ end
26
+
27
+ def add_column_default_safe?
28
+ false
29
+ end
30
+
31
+ def change_type_safe?(table, column, type, options, existing_column, existing_type)
32
+ false
33
+ end
34
+
35
+ def rewrite_blocks
36
+ "reads and writes"
37
+ end
38
+
39
+ def auto_incrementing_types
40
+ ["primary_key"]
41
+ end
42
+
43
+ def max_constraint_name_length
44
+ end
45
+
46
+ private
47
+
48
+ def connection
49
+ @checker.send(:connection)
50
+ end
51
+
52
+ def select_all(statement)
53
+ connection.select_all(statement)
54
+ end
55
+
56
+ def target_version(target_version)
57
+ target_version ||= StrongMigrations.target_version
58
+ version =
59
+ if target_version && StrongMigrations.developer_env?
60
+ if target_version.is_a?(Hash)
61
+ db_config_name = connection.pool.db_config.name
62
+ target_version.stringify_keys.fetch(db_config_name) do
63
+ # error class is not shown in db:migrate output so ensure message is descriptive
64
+ raise StrongMigrations::Error, "StrongMigrations.target_version is not configured for :#{db_config_name} database"
65
+ end.to_s
66
+ else
67
+ target_version.to_s
68
+ end
69
+ else
70
+ yield
71
+ end
72
+ Gem::Version.new(version)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,32 @@
1
+ module StrongMigrations
2
+ module Adapters
3
+ class MariaDBAdapter < MySQLAdapter
4
+ def name
5
+ "MariaDB"
6
+ end
7
+
8
+ def min_version
9
+ "10.5"
10
+ end
11
+
12
+ def server_version
13
+ @server_version ||= begin
14
+ target_version(StrongMigrations.target_mariadb_version) do
15
+ select_all("SELECT VERSION()").first["VERSION()"].split("-").first
16
+ end
17
+ end
18
+ end
19
+
20
+ def set_statement_timeout(timeout)
21
+ # fix deprecation warning with Active Record 7.1
22
+ timeout = timeout.value if timeout.is_a?(ActiveSupport::Duration)
23
+
24
+ select_all("SET max_statement_time = #{connection.quote(timeout)}")
25
+ end
26
+
27
+ def add_column_default_safe?
28
+ true
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,112 @@
1
+ # note: MariaDB inherits from this adapter
2
+ # when making changes, be sure to see how it affects it
3
+ module StrongMigrations
4
+ module Adapters
5
+ class MySQLAdapter < AbstractAdapter
6
+ def name
7
+ "MySQL"
8
+ end
9
+
10
+ def min_version
11
+ "8.0"
12
+ end
13
+
14
+ def server_version
15
+ @server_version ||= begin
16
+ target_version(StrongMigrations.target_mysql_version) do
17
+ select_all("SELECT VERSION()").first["VERSION()"].split("-").first
18
+ end
19
+ end
20
+ end
21
+
22
+ def set_statement_timeout(timeout)
23
+ # use ceil to prevent no timeout for values under 1 ms
24
+ select_all("SET max_execution_time = #{connection.quote((timeout.to_f * 1000).ceil)}")
25
+ end
26
+
27
+ def set_lock_timeout(timeout)
28
+ # fix deprecation warning with Active Record 7.1
29
+ timeout = timeout.value if timeout.is_a?(ActiveSupport::Duration)
30
+
31
+ select_all("SET lock_wait_timeout = #{connection.quote(timeout)}")
32
+ end
33
+
34
+ def check_lock_timeout(limit)
35
+ lock_timeout = connection.select_all("SHOW VARIABLES LIKE 'lock_wait_timeout'").first["Value"]
36
+ # lock timeout is an integer
37
+ if lock_timeout.to_i > limit
38
+ warn "[strong_migrations] DANGER: Lock timeout is longer than #{limit} seconds: #{lock_timeout}"
39
+ end
40
+ end
41
+
42
+ def analyze_table(table)
43
+ connection.execute "ANALYZE TABLE #{connection.quote_table_name(table.to_s)}"
44
+ end
45
+
46
+ def add_column_default_safe?
47
+ true
48
+ end
49
+
50
+ def change_type_safe?(table, column, type, options, existing_column, existing_type)
51
+ safe = false
52
+
53
+ case type.to_s
54
+ when "string"
55
+ limit = options[:limit] || 255
56
+ if ["varchar"].include?(existing_type) && limit >= existing_column.limit
57
+ # https://dev.mysql.com/doc/refman/5.7/en/innodb-online-ddl-operations.html
58
+ # https://mariadb.com/kb/en/innodb-online-ddl-operations-with-the-instant-alter-algorithm/#changing-the-data-type-of-a-column
59
+ # increased limit, but doesn't change number of length bytes
60
+ # 1-255 = 1 byte, 256-65532 = 2 bytes, 65533+ = too big for varchar
61
+
62
+ # account for charset
63
+ # https://dev.mysql.com/doc/refman/8.0/en/charset-mysql.html
64
+ # https://mariadb.com/kb/en/supported-character-sets-and-collations/
65
+ sql = <<~SQL
66
+ SELECT cs.MAXLEN
67
+ FROM INFORMATION_SCHEMA.CHARACTER_SETS cs
68
+ INNER JOIN INFORMATION_SCHEMA.COLUMNS c ON c.CHARACTER_SET_NAME = cs.CHARACTER_SET_NAME
69
+ WHERE c.TABLE_SCHEMA = database() AND
70
+ c.TABLE_NAME = #{connection.quote(table)} AND
71
+ c.COLUMN_NAME = #{connection.quote(column)}
72
+ SQL
73
+ row = connection.select_all(sql).first
74
+ if row
75
+ threshold = 255 / row["MAXLEN"]
76
+ safe = limit <= threshold || existing_column.limit > threshold
77
+ else
78
+ warn "[strong_migrations] Could not determine charset"
79
+ end
80
+ end
81
+ end
82
+
83
+ safe
84
+ end
85
+
86
+ def strict_mode?
87
+ sql_modes = sql_modes()
88
+ sql_modes.include?("STRICT_ALL_TABLES") || sql_modes.include?("STRICT_TRANS_TABLES")
89
+ end
90
+
91
+ def rewrite_blocks
92
+ "writes"
93
+ end
94
+
95
+ def max_constraint_name_length
96
+ 64
97
+ end
98
+
99
+ private
100
+
101
+ # do not memoize
102
+ # want latest value
103
+ def sql_modes
104
+ if StrongMigrations.target_sql_mode && StrongMigrations.developer_env?
105
+ StrongMigrations.target_sql_mode.split(",")
106
+ else
107
+ select_all("SELECT @@SESSION.sql_mode").first["@@SESSION.sql_mode"].split(",")
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,232 @@
1
+ module StrongMigrations
2
+ module Adapters
3
+ class PostgreSQLAdapter < AbstractAdapter
4
+ def name
5
+ "PostgreSQL"
6
+ end
7
+
8
+ def min_version
9
+ "12"
10
+ end
11
+
12
+ def server_version
13
+ @version ||= begin
14
+ target_version(StrongMigrations.target_postgresql_version) do
15
+ version = select_all("SHOW server_version_num").first["server_version_num"].to_i
16
+ # major and minor version
17
+ "#{version / 10000}.#{(version % 10000)}"
18
+ end
19
+ end
20
+ end
21
+
22
+ def set_statement_timeout(timeout)
23
+ set_timeout("statement_timeout", timeout)
24
+ end
25
+
26
+ def set_lock_timeout(timeout)
27
+ set_timeout("lock_timeout", timeout)
28
+ end
29
+
30
+ def check_lock_timeout(limit)
31
+ lock_timeout = connection.select_all("SHOW lock_timeout").first["lock_timeout"]
32
+ lock_timeout_sec = timeout_to_sec(lock_timeout)
33
+ if lock_timeout_sec == 0
34
+ warn "[strong_migrations] DANGER: No lock timeout set"
35
+ elsif lock_timeout_sec > limit
36
+ warn "[strong_migrations] DANGER: Lock timeout is longer than #{limit} seconds: #{lock_timeout}"
37
+ end
38
+ end
39
+
40
+ def analyze_table(table)
41
+ connection.execute "ANALYZE #{connection.quote_table_name(table.to_s)}"
42
+ end
43
+
44
+ def add_column_default_safe?
45
+ true
46
+ end
47
+
48
+ def change_type_safe?(table, column, type, options, existing_column, existing_type)
49
+ safe = false
50
+
51
+ case type.to_s
52
+ when "string"
53
+ # safe to increase limit or remove it
54
+ # not safe to decrease limit or add a limit
55
+ case existing_type
56
+ when "character varying"
57
+ safe = !options[:limit] || (existing_column.limit && options[:limit] >= existing_column.limit)
58
+ when "text"
59
+ safe = !options[:limit]
60
+ when "citext"
61
+ safe = !options[:limit] && !indexed?(table, column)
62
+ end
63
+ when "text"
64
+ # safe to change varchar to text (and text to text)
65
+ safe =
66
+ ["character varying", "text"].include?(existing_type) ||
67
+ (existing_type == "citext" && !indexed?(table, column))
68
+ when "citext"
69
+ safe = ["character varying", "text"].include?(existing_type) && !indexed?(table, column)
70
+ when "varbit"
71
+ # increasing length limit or removing the limit is safe
72
+ # but there doesn't seem to be a way to set/modify it
73
+ # https://wiki.postgresql.org/wiki/What%27s_new_in_PostgreSQL_9.2#Reduce_ALTER_TABLE_rewrites
74
+ when "numeric", "decimal"
75
+ # numeric and decimal are equivalent and can be used interchangeably
76
+ safe = ["numeric", "decimal"].include?(existing_type) &&
77
+ (
78
+ (
79
+ # unconstrained
80
+ !options[:precision] && !options[:scale]
81
+ ) || (
82
+ # increased precision, same scale
83
+ options[:precision] && existing_column.precision &&
84
+ options[:precision] >= existing_column.precision &&
85
+ options[:scale] == existing_column.scale
86
+ )
87
+ )
88
+ when "datetime", "timestamp", "timestamptz"
89
+ # precision for datetime
90
+ # limit for timestamp, timestamptz
91
+ precision = (type.to_s == "datetime" ? options[:precision] : options[:limit]) || 6
92
+ existing_precision = existing_column.limit || existing_column.precision || 6
93
+
94
+ type_map = {
95
+ "timestamp" => "timestamp without time zone",
96
+ "timestamptz" => "timestamp with time zone"
97
+ }
98
+ maybe_safe = type_map.value?(existing_type) && precision >= existing_precision
99
+
100
+ if maybe_safe
101
+ new_type = type.to_s == "datetime" ? datetime_type : type.to_s
102
+
103
+ # resolve with fallback
104
+ new_type = type_map[new_type] || new_type
105
+
106
+ safe = new_type == existing_type || time_zone == "UTC"
107
+ end
108
+ when "time"
109
+ precision = options[:precision] || options[:limit] || 6
110
+ existing_precision = existing_column.precision || existing_column.limit || 6
111
+
112
+ safe = existing_type == "time without time zone" && precision >= existing_precision
113
+ when "timetz"
114
+ # increasing precision is safe
115
+ # but there doesn't seem to be a way to set/modify it
116
+ when "interval"
117
+ # https://wiki.postgresql.org/wiki/What%27s_new_in_PostgreSQL_9.2#Reduce_ALTER_TABLE_rewrites
118
+ # Active Record uses precision before limit
119
+ precision = options[:precision] || options[:limit] || 6
120
+ existing_precision = existing_column.precision || existing_column.limit || 6
121
+
122
+ safe = existing_type == "interval" && precision >= existing_precision
123
+ when "inet"
124
+ safe = existing_type == "cidr"
125
+ end
126
+
127
+ safe
128
+ end
129
+
130
+ # TODO remove when Active Record < 7.1 is no longer supported
131
+ def index_invalid?(table_name, index_name)
132
+ query = <<~SQL
133
+ SELECT
134
+ indisvalid
135
+ FROM
136
+ pg_index
137
+ WHERE
138
+ indrelid = to_regclass(#{connection.quote(connection.quote_table_name(table_name))}) AND
139
+ indexrelid = to_regclass(#{connection.quote(connection.quote_table_name(index_name))}) AND
140
+ indisvalid = false
141
+ SQL
142
+ select_all(query.squish).any?
143
+ end
144
+
145
+ def writes_blocked?
146
+ query = <<~SQL
147
+ SELECT
148
+ relation::regclass::text
149
+ FROM
150
+ pg_locks
151
+ WHERE
152
+ mode IN ('ShareRowExclusiveLock', 'AccessExclusiveLock') AND
153
+ pid = pg_backend_pid()
154
+ SQL
155
+ select_all(query.squish).any?
156
+ end
157
+
158
+ # only check in non-developer environments (where actual server version is used)
159
+ def index_corruption?
160
+ server_version >= Gem::Version.new("14.0") &&
161
+ server_version < Gem::Version.new("14.4") &&
162
+ !StrongMigrations.developer_env?
163
+ end
164
+
165
+ # default to true if unsure
166
+ def default_volatile?(default)
167
+ name = default.to_s.delete_suffix("()")
168
+ rows = select_all("SELECT provolatile FROM pg_proc WHERE proname = #{connection.quote(name)}").to_a
169
+ rows.empty? || rows.any? { |r| r["provolatile"] == "v" }
170
+ end
171
+
172
+ def auto_incrementing_types
173
+ ["primary_key", "serial", "bigserial"]
174
+ end
175
+
176
+ def max_constraint_name_length
177
+ 63
178
+ end
179
+
180
+ private
181
+
182
+ def set_timeout(setting, timeout)
183
+ # use ceil to prevent no timeout for values under 1 ms
184
+ timeout = (timeout.to_f * 1000).ceil unless timeout.is_a?(String)
185
+
186
+ select_all("SET #{setting} TO #{connection.quote(timeout)}")
187
+ end
188
+
189
+ # units: https://www.postgresql.org/docs/current/config-setting.html
190
+ def timeout_to_sec(timeout)
191
+ units = {
192
+ "us" => 0.001,
193
+ "ms" => 1,
194
+ "s" => 1000,
195
+ "min" => 1000 * 60,
196
+ "h" => 1000 * 60 * 60,
197
+ "d" => 1000 * 60 * 60 * 24
198
+ }
199
+ timeout_ms = timeout.to_i
200
+ units.each do |k, v|
201
+ if timeout.end_with?(k)
202
+ timeout_ms *= v
203
+ break
204
+ end
205
+ end
206
+ timeout_ms / 1000.0
207
+ end
208
+
209
+ # columns is array for column index and string for expression index
210
+ # the current approach can yield false positives for expression indexes
211
+ # but prefer to keep it simple for now
212
+ def indexed?(table, column)
213
+ connection.indexes(table).any? { |i| i.columns.include?(column.to_s) }
214
+ end
215
+
216
+ def datetime_type
217
+ # https://github.com/rails/rails/pull/41084
218
+ # no need to support custom datetime_types
219
+ key = connection.class.datetime_type
220
+
221
+ # could be timestamp, timestamp without time zone, timestamp with time zone, etc
222
+ connection.class.const_get(:NATIVE_DATABASE_TYPES).fetch(key).fetch(:name)
223
+ end
224
+
225
+ # do not memoize
226
+ # want latest value
227
+ def time_zone
228
+ select_all("SHOW timezone").first["TimeZone"]
229
+ end
230
+ end
231
+ end
232
+ end