Skip to content

Commit 0702af7

Browse files
authored
Merge pull request rails#54332 from Shopify/mt/support-mysql-index-visibility
Add support for index visibility for MySQL v8.0.0+ and MariaDB v10.6.0+
2 parents 0477527 + e614cf6 commit 0702af7

18 files changed

+314
-8
lines changed

activerecord/CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,41 @@
1+
* Support disabling indexes for MySQL v8.0.0+ and MariaDB v10.6.0+
2+
3+
MySQL 8.0.0 added an option to disable indexes from being used by the query
4+
optimizer by making them "invisible". This allows the index to still be maintained
5+
and updated but no queries will be permitted to use it. This can be useful for adding
6+
new invisible indexes or making existing indexes invisible before dropping them
7+
to ensure queries are not negatively affected.
8+
See https://dev.mysql.com/blog-archive/mysql-8-0-invisible-indexes/ for more details.
9+
10+
MariaDB 10.6.0 also added support for this feature by allowing indexes to be "ignored"
11+
in queries. See https://mariadb.com/kb/en/ignored-indexes/ for more details.
12+
13+
Active Record now supports this option for MySQL 8.0.0+ and MariaDB 10.6.0+ for
14+
index creation and alteration where the new index option `enabled: true/false` can be
15+
passed to column and index methods as below:
16+
17+
```ruby
18+
add_index :users, :email, enabled: false
19+
enable_index :users, :email
20+
add_column :users, :dob, :string, index: { enabled: false }
21+
22+
change_table :users do |t|
23+
t.index :name, enabled: false
24+
t.index :dob
25+
t.disable_index :dob
26+
t.column :username, :string, index: { enabled: false }
27+
t.references :account, index: { enabled: false }
28+
end
29+
30+
create_table :users do |t|
31+
t.string :name, index: { enabled: false }
32+
t.string :email
33+
t.index :email, enabled: false
34+
end
35+
```
36+
37+
*Merve Taner*
38+
139
* Respect `implicit_order_column` in `ActiveRecord::Relation#reverse_order`.
240

341
*Joshua Young*

activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,19 @@ def rename_column(table_name, column_name, new_column_name)
912912
# Concurrently adding an index is not supported in a transaction.
913913
#
914914
# For more information see the {"Transactional Migrations" section}[rdoc-ref:Migration].
915+
#
916+
# ====== Creating an index that is not used by queries
917+
#
918+
# add_index(:developers, :name, enabled: false)
919+
#
920+
# generates:
921+
#
922+
# CREATE INDEX index_developers_on_name ON developers (name) INVISIBLE -- MySQL
923+
#
924+
# CREATE INDEX index_developers_on_name ON developers (name) IGNORED -- MariaDB
925+
#
926+
# Note: only supported by MySQL version 8.0.0 and greater, and MariaDB version 10.6.0 and greater.
927+
#
915928
def add_index(table_name, column_name, **options)
916929
create_index = build_create_index_definition(table_name, column_name, **options)
917930
execute schema_creation.accept(create_index)
@@ -1475,7 +1488,7 @@ def update_table_definition(table_name, base) # :nodoc:
14751488
end
14761489

14771490
def add_index_options(table_name, column_name, name: nil, if_not_exists: false, internal: false, **options) # :nodoc:
1478-
options.assert_valid_keys(:unique, :length, :order, :opclass, :where, :type, :using, :comment, :algorithm, :include, :nulls_not_distinct)
1491+
options.assert_valid_keys(valid_index_options)
14791492

14801493
column_names = index_column_names(column_name)
14811494

@@ -1484,7 +1497,7 @@ def add_index_options(table_name, column_name, name: nil, if_not_exists: false,
14841497

14851498
validate_index_length!(table_name, index_name, internal)
14861499

1487-
index = IndexDefinition.new(
1500+
index = create_index_definition(
14881501
table_name, index_name,
14891502
options[:unique],
14901503
column_names,
@@ -1539,6 +1552,20 @@ def change_column_comment(table_name, column_name, comment_or_changes)
15391552
raise NotImplementedError, "#{self.class} does not support changing column comments"
15401553
end
15411554

1555+
# Enables an index to be used by queries.
1556+
#
1557+
# enable_index(:users, :email)
1558+
def enable_index(table_name, index_name)
1559+
raise NotImplementedError, "#{self.class} does not support enabling indexes"
1560+
end
1561+
1562+
# Prevents an index from being used by queries.
1563+
#
1564+
# disable_index(:users, :email)
1565+
def disable_index(table_name, index_name)
1566+
raise NotImplementedError, "#{self.class} does not support disabling indexes"
1567+
end
1568+
15421569
def create_schema_dumper(options) # :nodoc:
15431570
SchemaDumper.create(self, options)
15441571
end
@@ -1627,6 +1654,10 @@ def add_index_sort_order(quoted_columns, **options)
16271654
end
16281655
end
16291656

1657+
def valid_index_options
1658+
[:unique, :length, :order, :opclass, :where, :type, :using, :comment, :algorithm, :include, :nulls_not_distinct]
1659+
end
1660+
16301661
def options_for_index_columns(options)
16311662
if options.is_a?(Hash)
16321663
options.symbolize_keys
@@ -1703,6 +1734,10 @@ def create_table_definition(name, **options)
17031734
TableDefinition.new(self, name, **options)
17041735
end
17051736

1737+
def create_index_definition(table_name, name, unique, columns, **options)
1738+
IndexDefinition.new(table_name, name, unique, columns, **options)
1739+
end
1740+
17061741
def create_alter_table(name)
17071742
AlterTable.new create_table_definition(name)
17081743
end

activerecord/lib/active_record/connection_adapters/abstract_adapter.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,10 @@ def supports_nulls_not_distinct?
555555
false
556556
end
557557

558+
def supports_disabling_indexes?
559+
false
560+
end
561+
558562
def return_value_after_insert?(column) # :nodoc:
559563
column.auto_populated?
560564
end

activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,16 @@ def return_value_after_insert?(column) # :nodoc:
178178
supports_insert_returning? ? column.auto_populated? : column.auto_increment?
179179
end
180180

181+
# See https://dev.mysql.com/doc/refman/8.0/en/invisible-indexes.html for more details on MySQL feature.
182+
# See https://mariadb.com/kb/en/ignored-indexes/ for more details on the MariaDB feature.
183+
def supports_disabling_indexes?
184+
if mariadb?
185+
database_version >= "10.6.0"
186+
else
187+
database_version >= "8.0.0"
188+
end
189+
end
190+
181191
def get_advisory_lock(lock_name, timeout = 0) # :nodoc:
182192
query_value("SELECT GET_LOCK(#{quote(lock_name.to_s)}, #{timeout})") == 1
183193
end
@@ -457,6 +467,24 @@ def build_create_index_definition(table_name, column_name, **options) # :nodoc:
457467
CreateIndexDefinition.new(index, algorithm)
458468
end
459469

470+
def enable_index(table_name, index_name) # :nodoc:
471+
raise NotImplementedError unless supports_disabling_indexes?
472+
473+
query = <<~SQL
474+
ALTER TABLE #{quote_table_name(table_name)} ALTER INDEX #{index_name} #{mariadb? ? "NOT IGNORED" : "VISIBLE"}
475+
SQL
476+
execute(query)
477+
end
478+
479+
def disable_index(table_name, index_name) # :nodoc:
480+
raise NotImplementedError unless supports_disabling_indexes?
481+
482+
query = <<~SQL
483+
ALTER TABLE #{quote_table_name(table_name)} ALTER INDEX #{index_name} #{mariadb? ? "IGNORED" : "INVISIBLE"}
484+
SQL
485+
execute(query)
486+
end
487+
460488
def add_sql_comment!(sql, comment) # :nodoc:
461489
sql << " COMMENT #{quote(comment)}" if comment.present?
462490
sql

activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ def visit_IndexDefinition(o, create = false)
4949
sql << "USING #{o.using}" if o.using
5050
sql << "ON #{quote_table_name(o.table)}" if create
5151
sql << "(#{quoted_columns(o)})"
52+
sql << "INVISIBLE" if o.disabled? && !mariadb?
53+
sql << "IGNORED" if o.disabled? && mariadb?
5254

5355
add_sql_comment!(sql.join(" "), o.comment)
5456
end

activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,25 @@ module ColumnMethods
5050
deprecate :unsigned_float, :unsigned_decimal, deprecator: ActiveRecord.deprecator
5151
end
5252

53+
# = Active Record MySQL Adapter \Index Definition
54+
class IndexDefinition < ActiveRecord::ConnectionAdapters::IndexDefinition
55+
attr_accessor :enabled
56+
57+
def initialize(*args, **kwargs)
58+
@enabled = kwargs.key?(:enabled) ? kwargs.delete(:enabled) : true
59+
super
60+
end
61+
62+
def defined_for?(columns = nil, name: nil, unique: nil, valid: nil, include: nil, nulls_not_distinct: nil, enabled: nil, **options)
63+
super(columns, name:, unique:, valid:, include:, nulls_not_distinct:, **options) &&
64+
(enabled.nil? || self.enabled == enabled)
65+
end
66+
67+
def disabled?
68+
!@enabled
69+
end
70+
end
71+
5372
# = Active Record MySQL Adapter \Table Definition
5473
class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition
5574
include ColumnMethods
@@ -99,6 +118,28 @@ def integer_like_primary_key_type(type, options)
99118
# = Active Record MySQL Adapter \Table
100119
class Table < ActiveRecord::ConnectionAdapters::Table
101120
include ColumnMethods
121+
122+
# Enables an index to be used by query optimizers.
123+
#
124+
# t.enable_index(:email)
125+
#
126+
# Note: only supported by MySQL version 8.0.0 and greater, and MariaDB version 10.6.0 and greater.
127+
#
128+
# See {connection.enable_index}[rdoc-ref:SchemaStatements#enable_index]
129+
def enable_index(index_name)
130+
@base.enable_index(name, index_name)
131+
end
132+
133+
# Disables an index not to be used by query optimizers.
134+
#
135+
# t.disable_index(:email)
136+
#
137+
# Note: only supported by MySQL version 8.0.0 and greater, and MariaDB version 10.6.0 and greater.
138+
#
139+
# See {connection.disable_index}[rdoc-ref:SchemaStatements#disable_index]
140+
def disable_index(index_name)
141+
@base.disable_index(name, index_name)
142+
end
102143
end
103144
end
104145
end

activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def indexes(table_name)
2121
index_using = mysql_index_type
2222
end
2323

24-
indexes << [
24+
index = [
2525
row["Table"],
2626
row["Key_name"],
2727
row["Non_unique"].to_i == 0,
@@ -30,8 +30,14 @@ def indexes(table_name)
3030
orders: {},
3131
type: index_type,
3232
using: index_using,
33-
comment: row["Index_comment"].presence
33+
comment: row["Index_comment"].presence,
3434
]
35+
36+
if supports_disabling_indexes?
37+
index[-1][:enabled] = mariadb? ? row["Ignored"] == "NO" : row["Visible"] == "YES"
38+
end
39+
40+
indexes << index
3541
end
3642

3743
if expression = row["Expression"]
@@ -63,8 +69,7 @@ def indexes(table_name)
6369
columns, order: orders, length: lengths
6470
).values.join(", ")
6571
end
66-
67-
IndexDefinition.new(*index, **options)
72+
MySQL::IndexDefinition.new(*index, **options)
6873
end
6974
rescue StatementInvalid => e
7075
if e.message.match?(/Table '.+' doesn't exist/)
@@ -74,6 +79,16 @@ def indexes(table_name)
7479
end
7580
end
7681

82+
def create_index_definition(table_name, name, unique, columns, **options)
83+
MySQL::IndexDefinition.new(table_name, name, unique, columns, **options)
84+
end
85+
86+
def add_index_options(table_name, column_name, name: nil, if_not_exists: false, internal: false, **options) # :nodoc:
87+
index, algorithm, if_not_exists = super
88+
index.enabled = options[:enabled] unless options[:enabled].nil?
89+
[index, algorithm, if_not_exists]
90+
end
91+
7792
def remove_column(table_name, column_name, type = nil, **options)
7893
if foreign_key_exists?(table_name, column: column_name)
7994
remove_foreign_key(table_name, column: column_name)
@@ -234,6 +249,12 @@ def add_index_length(quoted_columns, **options)
234249
end
235250
end
236251

252+
def valid_index_options
253+
index_options = super
254+
index_options << :enabled if supports_disabling_indexes?
255+
index_options
256+
end
257+
237258
def add_options_for_index_columns(quoted_columns, **options)
238259
quoted_columns = add_index_length(quoted_columns, **options)
239260
super

activerecord/lib/active_record/migration/command_recorder.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ class Migration
4444
# * rename_enum_value (must supply a +:from+ and +:to+ option)
4545
# * rename_index
4646
# * rename_table
47+
# * enable_index
48+
# * disable_index
4749
class CommandRecorder
4850
ReversibleAndIrreversibleMethods = [
4951
:create_table, :create_join_table, :rename_table, :add_column, :remove_column,
@@ -58,7 +60,8 @@ class CommandRecorder
5860
:add_unique_constraint, :remove_unique_constraint,
5961
:create_enum, :drop_enum, :rename_enum, :add_enum_value, :rename_enum_value,
6062
:create_schema, :drop_schema,
61-
:create_virtual_table, :drop_virtual_table
63+
:create_virtual_table, :drop_virtual_table,
64+
:enable_index, :disable_index
6265
]
6366
include JoinTable
6467

@@ -183,6 +186,16 @@ def invert_#{method}(args, &block) # def invert_create_table(args, &block)
183186

184187
include StraightReversions
185188

189+
def invert_enable_index(args)
190+
table_name, index_name = args
191+
[:disable_index, [table_name, index_name]]
192+
end
193+
194+
def invert_disable_index(args)
195+
table_name, index_name = args
196+
[:enable_index, [table_name, index_name]]
197+
end
198+
186199
def invert_transaction(args, &block)
187200
sub_recorder = CommandRecorder.new(delegate)
188201
sub_recorder.revert(&block)

activerecord/lib/active_record/schema_dumper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ def index_parts(index)
277277
index_parts << "nulls_not_distinct: #{index.nulls_not_distinct.inspect}" if index.nulls_not_distinct
278278
index_parts << "type: #{index.type.inspect}" if index.type
279279
index_parts << "comment: #{index.comment.inspect}" if index.comment
280+
index_parts << "enabled: #{index.enabled.inspect}" if @connection.supports_disabling_indexes? && index.disabled?
280281
index_parts
281282
end
282283

activerecord/test/cases/active_record_schema_test.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class ActiveRecordSchemaTest < ActiveRecord::TestCase
1818
@connection.drop_table :fruits rescue nil
1919
@connection.drop_table :has_timestamps rescue nil
2020
@connection.drop_table :multiple_indexes rescue nil
21+
@connection.drop_table :disabled_index rescue nil
2122
@schema_migration.delete_all_versions
2223
ActiveRecord::Migration.verbose = @original_verbose
2324
end
@@ -120,6 +121,20 @@ def test_schema_load_with_multiple_indexes_for_column_of_different_names
120121
assert_equal ["multiple_indexes_foo_1", "multiple_indexes_foo_2"], indexes.collect(&:name).sort
121122
end
122123

124+
if ActiveRecord::Base.lease_connection.supports_disabling_indexes?
125+
def test_schema_load_for_index_visibility
126+
ActiveRecord::Schema.define do
127+
create_table :disabled_index do |t|
128+
t.string "foo"
129+
t.index ["foo"], name: "disabled_foo_index", enabled: false
130+
end
131+
end
132+
133+
indexes = @connection.indexes("disabled_index").find { |index| index.name == "disabled_foo_index" }
134+
assert_predicate indexes, :disabled?
135+
end
136+
end
137+
123138
if current_adapter?(:PostgreSQLAdapter)
124139
def test_timestamps_with_and_without_zones
125140
ActiveRecord::Schema.define do

0 commit comments

Comments
 (0)