strong_migrations 0.7.6 → 1.7.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.
@@ -0,0 +1,77 @@
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
+ if target_version.is_a?(Hash)
54
+ # Active Record 6.0 supports multiple databases
55
+ # but connection.pool.spec.name always returns "primary"
56
+ # in migrations with rails db:migrate
57
+ if ActiveRecord::VERSION::STRING.to_f < 6.1
58
+ # error class is not shown in db:migrate output so ensure message is descriptive
59
+ raise StrongMigrations::Error, "StrongMigrations.target_version does not support multiple databases for Active Record < 6.1"
60
+ end
61
+
62
+ db_config_name = connection.pool.db_config.name
63
+ target_version.stringify_keys.fetch(db_config_name) do
64
+ # error class is not shown in db:migrate output so ensure message is descriptive
65
+ raise StrongMigrations::Error, "StrongMigrations.target_version is not configured for :#{db_config_name} database"
66
+ end.to_s
67
+ else
68
+ target_version.to_s
69
+ end
70
+ else
71
+ yield
72
+ end
73
+ Gem::Version.new(version)
74
+ end
75
+ end
76
+ end
77
+ 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.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
+ # 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
+ server_version >= Gem::Version.new("10.3.2")
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,107 @@
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
+ # 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
+ server_version >= Gem::Version.new("8.0.12")
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.COLLATIONS c ON c.CHARACTER_SET_NAME = cs.CHARACTER_SET_NAME
69
+ INNER JOIN INFORMATION_SCHEMA.TABLES t ON t.TABLE_COLLATION = c.COLLATION_NAME
70
+ WHERE t.TABLE_SCHEMA = database() AND t.TABLE_NAME = #{connection.quote(table)}
71
+ SQL
72
+ row = connection.select_all(sql).first
73
+ if row
74
+ threshold = 255 / row["MAXLEN"]
75
+ safe = limit <= threshold || existing_column.limit > threshold
76
+ else
77
+ warn "[strong_migrations] Could not determine charset"
78
+ end
79
+ end
80
+ end
81
+
82
+ safe
83
+ end
84
+
85
+ def strict_mode?
86
+ sql_modes = sql_modes()
87
+ sql_modes.include?("STRICT_ALL_TABLES") || sql_modes.include?("STRICT_TRANS_TABLES")
88
+ end
89
+
90
+ def rewrite_blocks
91
+ "writes"
92
+ end
93
+
94
+ private
95
+
96
+ # do not memoize
97
+ # want latest value
98
+ def sql_modes
99
+ if StrongMigrations.target_sql_mode && StrongMigrations.developer_env?
100
+ StrongMigrations.target_sql_mode.split(",")
101
+ else
102
+ select_all("SELECT @@SESSION.sql_mode").first["@@SESSION.sql_mode"].split(",")
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,230 @@
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
+ "#{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
+ server_version >= Gem::Version.new("11")
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.values.include?(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 || (server_version >= Gem::Version.new("12") && 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
+ def constraints(table_name)
131
+ query = <<~SQL
132
+ SELECT
133
+ conname AS name,
134
+ pg_get_constraintdef(oid) AS def
135
+ FROM
136
+ pg_constraint
137
+ WHERE
138
+ contype = 'c' AND
139
+ convalidated AND
140
+ conrelid = #{connection.quote(connection.quote_table_name(table_name))}::regclass
141
+ SQL
142
+ select_all(query.squish).to_a
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
+ private
173
+
174
+ def set_timeout(setting, timeout)
175
+ # use ceil to prevent no timeout for values under 1 ms
176
+ timeout = (timeout.to_f * 1000).ceil unless timeout.is_a?(String)
177
+
178
+ select_all("SET #{setting} TO #{connection.quote(timeout)}")
179
+ end
180
+
181
+ # units: https://www.postgresql.org/docs/current/config-setting.html
182
+ def timeout_to_sec(timeout)
183
+ units = {
184
+ "us" => 0.001,
185
+ "ms" => 1,
186
+ "s" => 1000,
187
+ "min" => 1000 * 60,
188
+ "h" => 1000 * 60 * 60,
189
+ "d" => 1000 * 60 * 60 * 24
190
+ }
191
+ timeout_ms = timeout.to_i
192
+ units.each do |k, v|
193
+ if timeout.end_with?(k)
194
+ timeout_ms *= v
195
+ break
196
+ end
197
+ end
198
+ timeout_ms / 1000.0
199
+ end
200
+
201
+ # columns is array for column index and string for expression index
202
+ # the current approach can yield false positives for expression indexes
203
+ # but prefer to keep it simple for now
204
+ def indexed?(table, column)
205
+ connection.indexes(table).any? { |i| i.columns.include?(column.to_s) }
206
+ end
207
+
208
+ def datetime_type
209
+ key =
210
+ if ActiveRecord::VERSION::MAJOR >= 7
211
+ # https://github.com/rails/rails/pull/41084
212
+ # no need to support custom datetime_types
213
+ connection.class.datetime_type
214
+ else
215
+ # https://github.com/rails/rails/issues/21126#issuecomment-327895275
216
+ :datetime
217
+ end
218
+
219
+ # could be timestamp, timestamp without time zone, timestamp with time zone, etc
220
+ connection.class.const_get(:NATIVE_DATABASE_TYPES).fetch(key).fetch(:name)
221
+ end
222
+
223
+ # do not memoize
224
+ # want latest value
225
+ def time_zone
226
+ select_all("SHOW timezone").first["TimeZone"]
227
+ end
228
+ end
229
+ end
230
+ end