Skip to content

Commit 534b4ab

Browse files
authored
Merge pull request rails#52354 from zachasme/sqlite-virtual-tables
Add support for SQLite3 full-text-search and other virtual tables
2 parents 1c0b912 + 1ecb91b commit 534b4ab

File tree

13 files changed

+155
-20
lines changed

13 files changed

+155
-20
lines changed

activerecord/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
* Add support for SQLite3 full-text-search and other virtual tables.
2+
3+
Previously, adding sqlite3 virtual tables messed up `schema.rb`.
4+
5+
Now, virtual tables can safely be added using `create_virtual_table`.
6+
7+
*Zacharias Knudsen*
8+
19
* Support use of alternative database interfaces via the `database_cli` ActiveRecord configuration option.
210

311
```ruby

activerecord/lib/active_record/connection_adapters/abstract_adapter.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,14 @@ def add_enum_value(*) # :nodoc:
595595
def rename_enum_value(*) # :nodoc:
596596
end
597597

598+
# This is meant to be implemented by the adapters that support virtual tables
599+
def create_virtual_table(*) # :nodoc:
600+
end
601+
602+
# This is meant to be implemented by the adapters that support virtual tables
603+
def drop_virtual_table(*) # :nodoc:
604+
end
605+
598606
def advisory_locks_enabled? # :nodoc:
599607
supports_advisory_locks? && @advisory_locks_enabled
600608
end

activerecord/lib/active_record/connection_adapters/sqlite3/schema_dumper.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ module ConnectionAdapters
55
module SQLite3
66
class SchemaDumper < ConnectionAdapters::SchemaDumper # :nodoc:
77
private
8+
def virtual_tables(stream)
9+
virtual_tables = @connection.virtual_tables
10+
if virtual_tables.any?
11+
stream.puts
12+
stream.puts " # Virtual tables defined in this database."
13+
stream.puts " # Note that virtual tables may not work with other database engines. Be careful if changing database."
14+
virtual_tables.sort.each do |table_name, options|
15+
module_name, arguments = options
16+
stream.puts " create_virtual_table #{table_name.inspect}, #{module_name.inspect}, #{arguments.split(", ").inspect}"
17+
end
18+
end
19+
end
20+
821
def default_primary_key?(column)
922
schema_type(column) == :integer
1023
end

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ def remove_foreign_key(from_table, to_table = nil, **options)
8282
alter_table(from_table, foreign_keys)
8383
end
8484

85+
def virtual_table_exists?(table_name)
86+
query_values(data_source_sql(table_name, type: "VIRTUAL TABLE"), "SCHEMA").any?
87+
end
88+
8589
def check_constraints(table_name)
8690
table_sql = query_value(<<-SQL, "SCHEMA")
8791
SELECT sql
@@ -176,7 +180,8 @@ def data_source_sql(name = nil, type: nil)
176180
scope = quoted_scope(name, type: type)
177181
scope[:type] ||= "'table','view'"
178182

179-
sql = +"SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence'"
183+
sql = +"SELECT name FROM pragma_table_list WHERE schema <> 'temp'"
184+
sql << " AND name NOT IN ('sqlite_sequence', 'sqlite_schema')"
180185
sql << " AND name = #{scope[:name]}" if scope[:name]
181186
sql << " AND type IN (#{scope[:type]})"
182187
sql
@@ -189,6 +194,8 @@ def quoted_scope(name = nil, type: nil)
189194
"'table'"
190195
when "VIEW"
191196
"'view'"
197+
when "VIRTUAL TABLE"
198+
"'virtual'"
192199
end
193200
scope = {}
194201
scope[:name] = quote(name) if name

activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,38 @@ def remove_index(table_name, column_name = nil, **options) # :nodoc:
283283
exec_query "DROP INDEX #{quote_column_name(index_name)}"
284284
end
285285

286+
VIRTUAL_TABLE_REGEX = /USING\s+(\w+)\s*\((.+)\)/i
287+
288+
# Returns a list of defined virtual tables
289+
def virtual_tables
290+
query = <<~SQL
291+
SELECT name, sql FROM sqlite_master WHERE sql LIKE 'CREATE VIRTUAL %';
292+
SQL
293+
294+
exec_query(query, "SCHEMA").cast_values.each_with_object({}) do |row, memo|
295+
table_name, sql = row[0], row[1]
296+
_, module_name, arguments = sql.match(VIRTUAL_TABLE_REGEX).to_a
297+
memo[table_name] = [module_name, arguments]
298+
end.to_a
299+
end
300+
301+
# Creates a virtual table
302+
#
303+
# Example:
304+
# create_virtual_table :emails, :fts5, ['sender', 'title',' body']
305+
def create_virtual_table(table_name, module_name, values)
306+
exec_query "CREATE VIRTUAL TABLE IF NOT EXISTS #{table_name} USING #{module_name} (#{values.join(", ")})"
307+
end
308+
309+
# Drops a virtual table
310+
#
311+
# Although this command ignores +module_name+ and +values+,
312+
# it can be helpful to provide these in a migration's +change+ method so it can be reverted.
313+
# In that case, +module_name+, +values+ and +options+ will be used by #create_virtual_table.
314+
def drop_virtual_table(table_name, module_name, values, **options)
315+
drop_table(table_name)
316+
end
317+
286318
# Renames a table.
287319
#
288320
# Example:

activerecord/lib/active_record/migration/command_recorder.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ class Migration
2222
# * change_table_comment (must supply a +:from+ and +:to+ option)
2323
# * create_enum
2424
# * create_join_table
25+
# * create_virtual_table
2526
# * create_table
2627
# * disable_extension
2728
# * drop_enum (must supply a list of values)
2829
# * drop_join_table
30+
# * drop_virtual_table (must supply options)
2931
# * drop_table (must supply a block)
3032
# * enable_extension
3133
# * remove_column (must supply a type)
@@ -56,6 +58,7 @@ class CommandRecorder
5658
:add_unique_constraint, :remove_unique_constraint,
5759
:create_enum, :drop_enum, :rename_enum, :add_enum_value, :rename_enum_value,
5860
:create_schema, :drop_schema,
61+
:create_virtual_table, :drop_virtual_table
5962
]
6063
include JoinTable
6164

@@ -166,6 +169,7 @@ module StraightReversions # :nodoc:
166169
enable_extension: :disable_extension,
167170
create_enum: :drop_enum,
168171
create_schema: :drop_schema,
172+
create_virtual_table: :drop_virtual_table
169173
}.each do |cmd, inv|
170174
[[inv, cmd], [cmd, inv]].uniq.each do |method, inverse|
171175
class_eval <<-EOV, __FILE__, __LINE__ + 1
@@ -374,6 +378,12 @@ def invert_rename_enum_value(args)
374378
[:rename_enum_value, [type_name, from: options[:to], to: options[:from]]]
375379
end
376380

381+
def invert_drop_virtual_table(args)
382+
_enum, values = args.dup.tap(&:extract_options!)
383+
raise ActiveRecord::IrreversibleMigration, "drop_virtual_table is only reversible if given options." unless values
384+
super
385+
end
386+
377387
def respond_to_missing?(method, _)
378388
super || delegate.respond_to?(method)
379389
end

activerecord/lib/active_record/schema_dumper.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def dump(stream)
6363
extensions(stream)
6464
types(stream)
6565
tables(stream)
66+
virtual_tables(stream)
6667
trailer(stream)
6768
stream
6869
end
@@ -126,6 +127,10 @@ def types(stream)
126127
def schemas(stream)
127128
end
128129

130+
# virtual tables are only supported by SQLite
131+
def virtual_tables(stream)
132+
end
133+
129134
def tables(stream)
130135
sorted_tables = @connection.tables.sort
131136

activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,7 @@ def test_tables
596596

597597
def test_tables_logs_name
598598
sql = <<~SQL
599-
SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence' AND type IN ('table')
599+
SELECT name FROM pragma_table_list WHERE schema <> 'temp' AND name NOT IN ('sqlite_sequence', 'sqlite_schema') AND type IN ('table')
600600
SQL
601601
@conn.connect!
602602
assert_logged [[sql.squish, "SCHEMA", []]] do
@@ -607,7 +607,7 @@ def test_tables_logs_name
607607
def test_table_exists_logs_name
608608
with_example_table do
609609
sql = <<~SQL
610-
SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence' AND name = 'ex' AND type IN ('table')
610+
SELECT name FROM pragma_table_list WHERE schema <> 'temp' AND name NOT IN ('sqlite_sequence', 'sqlite_schema') AND name = 'ex' AND type IN ('table')
611611
SQL
612612
assert_logged [[sql.squish, "SCHEMA", []]] do
613613
assert @conn.table_exists?("ex")
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper"
4+
require "support/schema_dumping_helper"
5+
6+
class SQLite3VirtualTableTest < ActiveRecord::SQLite3TestCase
7+
include SchemaDumpingHelper
8+
9+
def setup
10+
@connection = ActiveRecord::Base.lease_connection
11+
@connection.create_virtual_table :searchables, :fts5, ["content", "meta UNINDEXED", "tokenize='porter ascii'"]
12+
end
13+
14+
def teardown
15+
@connection.drop_table :searchables, if_exists: true
16+
end
17+
18+
def test_schema_dump
19+
output = dump_all_table_schema
20+
21+
assert_not_includes output, "searchables_docsize"
22+
assert_includes output, 'create_virtual_table "searchables", "fts5", ["content", "meta UNINDEXED", "tokenize=\'porter ascii\'"]'
23+
end
24+
25+
def test_schema_load
26+
original, $stdout = $stdout, StringIO.new
27+
28+
ActiveRecord::Schema.define do
29+
create_virtual_table :emails, :fts5, ["content", "meta UNINDEXED"]
30+
end
31+
32+
assert @connection.virtual_table_exists?(:emails)
33+
ensure
34+
$stdout = original
35+
end
36+
end

activerecord/test/cases/migration/command_recorder_test.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,22 @@ def test_invert_rename_enum_value_without_to
564564
@recorder.inverse_of :rename_enum_value, [:dog_breed, from: :beagle]
565565
end
566566
end
567+
568+
def test_invert_create_virtual_table
569+
drop = @recorder.inverse_of :create_virtual_table, [:searchables, :fts5, ["content", "meta UNINDEXED", "tokenize='porter ascii'"]]
570+
assert_equal [:drop_virtual_table, [:searchables, :fts5, ["content", "meta UNINDEXED", "tokenize='porter ascii'"]], nil], drop
571+
end
572+
573+
def test_invert_drop_virtual_table
574+
create = @recorder.inverse_of :drop_virtual_table, [:searchables, :fts5, ["title", "content"]]
575+
assert_equal [:create_virtual_table, [:searchables, :fts5, ["title", "content"]], nil], create
576+
end
577+
578+
def test_invert_drop_virtual_table_without_options
579+
assert_raises(ActiveRecord::IrreversibleMigration) do
580+
@recorder.inverse_of :drop_virtual_table, [:searchables]
581+
end
582+
end
567583
end
568584
end
569585
end

0 commit comments

Comments
 (0)