strong_migrations 0.6.8 → 1.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,35 @@ 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/
25
+ # could try to connect to database and check for MariaDB
26
+ # but this should be fine
27
+ '"8.0.12"'
28
+ else
29
+ "10"
30
+ end
31
+ end
32
+
33
+ def adapter
34
+ if ActiveRecord::VERSION::STRING.to_f >= 6.1
35
+ ActiveRecord::Base.connection_db_config.adapter.to_s
36
+ else
37
+ ActiveRecord::Base.connection_config[:adapter].to_s
38
+ end
39
+ end
40
+
41
+ def postgresql?
42
+ adapter =~ /postg/
43
+ end
15
44
  end
16
45
  end
17
46
  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,61 @@
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
+ raise StrongMigrations::Error, "Statement timeout not supported for this database"
17
+ end
18
+
19
+ def set_lock_timeout(timeout)
20
+ raise StrongMigrations::Error, "Lock timeout not supported for this database"
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
+ private
40
+
41
+ def connection
42
+ @checker.send(:connection)
43
+ end
44
+
45
+ def select_all(statement)
46
+ connection.select_all(statement)
47
+ end
48
+
49
+ def target_version(target_version)
50
+ target_version ||= StrongMigrations.target_version
51
+ version =
52
+ if target_version && StrongMigrations.developer_env?
53
+ target_version.to_s
54
+ else
55
+ yield
56
+ end
57
+ Gem::Version.new(version)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,29 @@
1
+ module StrongMigrations
2
+ module Adapters
3
+ class MariaDBAdapter < MySQLAdapter
4
+ def name
5
+ "MariaDB"
6
+ end
7
+
8
+ def min_version
9
+ "10.2"
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
+ select_all("SET max_statement_time = #{connection.quote(timeout)}")
22
+ end
23
+
24
+ def add_column_default_safe?
25
+ server_version >= Gem::Version.new("10.3.2")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,87 @@
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
+ "5.7"
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
+ select_all("SET lock_wait_timeout = #{connection.quote(timeout)}")
29
+ end
30
+
31
+ def check_lock_timeout(limit)
32
+ lock_timeout = connection.select_all("SHOW VARIABLES LIKE 'lock_wait_timeout'").first["Value"]
33
+ # lock timeout is an integer
34
+ if lock_timeout.to_i > limit
35
+ warn "[strong_migrations] DANGER: Lock timeout is longer than #{limit} seconds: #{lock_timeout}"
36
+ end
37
+ end
38
+
39
+ def analyze_table(table)
40
+ connection.execute "ANALYZE TABLE #{connection.quote_table_name(table.to_s)}"
41
+ end
42
+
43
+ def add_column_default_safe?
44
+ server_version >= Gem::Version.new("8.0.12")
45
+ end
46
+
47
+ def change_type_safe?(table, column, type, options, existing_column, existing_type)
48
+ safe = false
49
+
50
+ case type.to_s
51
+ when "string"
52
+ # https://dev.mysql.com/doc/refman/5.7/en/innodb-online-ddl-operations.html
53
+ # https://mariadb.com/kb/en/innodb-online-ddl-operations-with-the-instant-alter-algorithm/#changing-the-data-type-of-a-column
54
+ # increased limit, but doesn't change number of length bytes
55
+ # 1-255 = 1 byte, 256-65532 = 2 bytes, 65533+ = too big for varchar
56
+ limit = options[:limit] || 255
57
+ safe = ["varchar"].include?(existing_type) &&
58
+ limit >= existing_column.limit &&
59
+ (limit <= 255 || existing_column.limit > 255)
60
+ end
61
+
62
+ safe
63
+ end
64
+
65
+ def strict_mode?
66
+ sql_modes = sql_modes()
67
+ sql_modes.include?("STRICT_ALL_TABLES") || sql_modes.include?("STRICT_TRANS_TABLES")
68
+ end
69
+
70
+ def rewrite_blocks
71
+ "writes"
72
+ end
73
+
74
+ private
75
+
76
+ # do not memoize
77
+ # want latest value
78
+ def sql_modes
79
+ if StrongMigrations.target_sql_mode && StrongMigrations.developer_env?
80
+ StrongMigrations.target_sql_mode.split(",")
81
+ else
82
+ select_all("SELECT @@SESSION.sql_mode").first["@@SESSION.sql_mode"].split(",")
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,227 @@
1
+ module StrongMigrations
2
+ module Adapters
3
+ class PostgreSQLAdapter < AbstractAdapter
4
+ def name
5
+ "PostgreSQL"
6
+ end
7
+
8
+ def min_version
9
+ "10"
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
+ if version >= 100000
18
+ "#{version / 10000}.#{(version % 10000)}"
19
+ else
20
+ "#{version / 10000}.#{(version % 10000) / 100}"
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ def set_statement_timeout(timeout)
27
+ set_timeout("statement_timeout", timeout)
28
+ end
29
+
30
+ def set_lock_timeout(timeout)
31
+ set_timeout("lock_timeout", timeout)
32
+ end
33
+
34
+ def check_lock_timeout(limit)
35
+ lock_timeout = connection.select_all("SHOW lock_timeout").first["lock_timeout"]
36
+ lock_timeout_sec = timeout_to_sec(lock_timeout)
37
+ if lock_timeout_sec == 0
38
+ warn "[strong_migrations] DANGER: No lock timeout set"
39
+ elsif lock_timeout_sec > limit
40
+ warn "[strong_migrations] DANGER: Lock timeout is longer than #{limit} seconds: #{lock_timeout}"
41
+ end
42
+ end
43
+
44
+ def analyze_table(table)
45
+ connection.execute "ANALYZE #{connection.quote_table_name(table.to_s)}"
46
+ end
47
+
48
+ def add_column_default_safe?
49
+ server_version >= Gem::Version.new("11")
50
+ end
51
+
52
+ def change_type_safe?(table, column, type, options, existing_column, existing_type)
53
+ safe = false
54
+
55
+ case type.to_s
56
+ when "string"
57
+ # safe to increase limit or remove it
58
+ # not safe to decrease limit or add a limit
59
+ case existing_type
60
+ when "character varying"
61
+ safe = !options[:limit] || (existing_column.limit && options[:limit] >= existing_column.limit)
62
+ when "text"
63
+ safe = !options[:limit]
64
+ when "citext"
65
+ safe = !options[:limit] && !indexed?(table, column)
66
+ end
67
+ when "text"
68
+ # safe to change varchar to text (and text to text)
69
+ safe =
70
+ ["character varying", "text"].include?(existing_type) ||
71
+ (existing_type == "citext" && !indexed?(table, column))
72
+ when "citext"
73
+ safe = ["character varying", "text"].include?(existing_type) && !indexed?(table, column)
74
+ when "varbit"
75
+ # increasing length limit or removing the limit is safe
76
+ # but there doesn't seem to be a way to set/modify it
77
+ # https://wiki.postgresql.org/wiki/What%27s_new_in_PostgreSQL_9.2#Reduce_ALTER_TABLE_rewrites
78
+ when "numeric", "decimal"
79
+ # numeric and decimal are equivalent and can be used interchangably
80
+ safe = ["numeric", "decimal"].include?(existing_type) &&
81
+ (
82
+ (
83
+ # unconstrained
84
+ !options[:precision] && !options[:scale]
85
+ ) || (
86
+ # increased precision, same scale
87
+ options[:precision] && existing_column.precision &&
88
+ options[:precision] >= existing_column.precision &&
89
+ options[:scale] == existing_column.scale
90
+ )
91
+ )
92
+ when "datetime", "timestamp", "timestamptz"
93
+ # precision for datetime
94
+ # limit for timestamp, timestamptz
95
+ precision = (type.to_s == "datetime" ? options[:precision] : options[:limit]) || 6
96
+ existing_precision = existing_column.limit || existing_column.precision || 6
97
+
98
+ type_map = {
99
+ "timestamp" => "timestamp without time zone",
100
+ "timestamptz" => "timestamp with time zone"
101
+ }
102
+ maybe_safe = type_map.values.include?(existing_type) && precision >= existing_precision
103
+
104
+ if maybe_safe
105
+ new_type = type.to_s == "datetime" ? datetime_type : type.to_s
106
+
107
+ # resolve with fallback
108
+ new_type = type_map[new_type] || new_type
109
+
110
+ safe = new_type == existing_type || (server_version >= Gem::Version.new("12") && time_zone == "UTC")
111
+ end
112
+ when "time"
113
+ precision = options[:precision] || options[:limit] || 6
114
+ existing_precision = existing_column.precision || existing_column.limit || 6
115
+
116
+ safe = existing_type == "time without time zone" && precision >= existing_precision
117
+ when "timetz"
118
+ # increasing precision is safe
119
+ # but there doesn't seem to be a way to set/modify it
120
+ when "interval"
121
+ # https://wiki.postgresql.org/wiki/What%27s_new_in_PostgreSQL_9.2#Reduce_ALTER_TABLE_rewrites
122
+ # Active Record uses precision before limit
123
+ precision = options[:precision] || options[:limit] || 6
124
+ existing_precision = existing_column.precision || existing_column.limit || 6
125
+
126
+ safe = existing_type == "interval" && precision >= existing_precision
127
+ when "inet"
128
+ safe = existing_type == "cidr"
129
+ end
130
+
131
+ safe
132
+ end
133
+
134
+ def constraints(table_name)
135
+ query = <<~SQL
136
+ SELECT
137
+ conname AS name,
138
+ pg_get_constraintdef(oid) AS def
139
+ FROM
140
+ pg_constraint
141
+ WHERE
142
+ contype = 'c' AND
143
+ convalidated AND
144
+ conrelid = #{connection.quote(connection.quote_table_name(table_name))}::regclass
145
+ SQL
146
+ select_all(query.squish).to_a
147
+ end
148
+
149
+ def writes_blocked?
150
+ query = <<~SQL
151
+ SELECT
152
+ relation::regclass::text
153
+ FROM
154
+ pg_locks
155
+ WHERE
156
+ mode IN ('ShareRowExclusiveLock', 'AccessExclusiveLock') AND
157
+ pid = pg_backend_pid()
158
+ SQL
159
+ select_all(query.squish).any?
160
+ end
161
+
162
+ # only check in non-developer environments (where actual server version is used)
163
+ def index_corruption?
164
+ server_version >= Gem::Version.new("14.0") &&
165
+ server_version < Gem::Version.new("14.4") &&
166
+ !StrongMigrations.developer_env?
167
+ end
168
+
169
+ private
170
+
171
+ def set_timeout(setting, timeout)
172
+ # use ceil to prevent no timeout for values under 1 ms
173
+ timeout = (timeout.to_f * 1000).ceil unless timeout.is_a?(String)
174
+
175
+ select_all("SET #{setting} TO #{connection.quote(timeout)}")
176
+ end
177
+
178
+ # units: https://www.postgresql.org/docs/current/config-setting.html
179
+ def timeout_to_sec(timeout)
180
+ units = {
181
+ "us" => 0.001,
182
+ "ms" => 1,
183
+ "s" => 1000,
184
+ "min" => 1000 * 60,
185
+ "h" => 1000 * 60 * 60,
186
+ "d" => 1000 * 60 * 60 * 24
187
+ }
188
+ timeout_ms = timeout.to_i
189
+ units.each do |k, v|
190
+ if timeout.end_with?(k)
191
+ timeout_ms *= v
192
+ break
193
+ end
194
+ end
195
+ timeout_ms / 1000.0
196
+ end
197
+
198
+ # columns is array for column index and string for expression index
199
+ # the current approach can yield false positives for expression indexes
200
+ # but prefer to keep it simple for now
201
+ def indexed?(table, column)
202
+ connection.indexes(table).any? { |i| i.columns.include?(column.to_s) }
203
+ end
204
+
205
+ def datetime_type
206
+ key =
207
+ if ActiveRecord::VERSION::MAJOR >= 7
208
+ # https://github.com/rails/rails/pull/41084
209
+ # no need to support custom datetime_types
210
+ connection.class.datetime_type
211
+ else
212
+ # https://github.com/rails/rails/issues/21126#issuecomment-327895275
213
+ :datetime
214
+ end
215
+
216
+ # could be timestamp, timestamp without time zone, timestamp with time zone, etc
217
+ connection.class.const_get(:NATIVE_DATABASE_TYPES).fetch(key).fetch(:name)
218
+ end
219
+
220
+ # do not memoize
221
+ # want latest value
222
+ def time_zone
223
+ select_all("SHOW timezone").first["TimeZone"]
224
+ end
225
+ end
226
+ end
227
+ end