strong_migrations 0.7.6 → 1.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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