diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb index 4688c51a9f999c64b40d4a0c7bd49372fcead40e..fc94cf9e4242aadd7289e679189c4e0f0ac8b99b 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -74,19 +74,37 @@ def exec_delete(sql, name = "SQL", binds = []) end alias :exec_update :exec_delete + def begin_isolated_db_transaction(isolation) #:nodoc + raise ArgumentError, "SQLite3 only supports the `read_uncommitted` transaction isolation level" if isolation != :read_uncommitted + raise StandardError, "You need to enable the shared-cache mode in SQLite mode before attempting to change the transaction isolation level" unless shared_cache? + + Thread.current.thread_variable_set("read_uncommitted", @connection.get_first_value("PRAGMA read_uncommitted")) + @connection.read_uncommitted = true + begin_db_transaction + end + def begin_db_transaction #:nodoc: log("begin transaction", "TRANSACTION") { @connection.transaction } end def commit_db_transaction #:nodoc: log("commit transaction", "TRANSACTION") { @connection.commit } + reset_read_uncommitted end def exec_rollback_db_transaction #:nodoc: log("rollback transaction", "TRANSACTION") { @connection.rollback } + reset_read_uncommitted end private + def reset_read_uncommitted + read_uncommitted = Thread.current.thread_variable_get("read_uncommitted") + return unless read_uncommitted + + @connection.read_uncommitted = read_uncommitted + end + def execute_batch(statements, name = nil) sql = combine_multi_statements(statements) diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index a87312d95b625410a9add0fc297bbcb4dd3f66b7..3ef368e1d67d5e8a93444c51d2a7d0f9648831f7 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -26,7 +26,7 @@ def sqlite3_connection(config) # Allow database path relative to Rails.root, but only if the database # path is not the special path that tells sqlite to build a database only # in memory. - if ":memory:" != config[:database] + if ":memory:" != config[:database] && !config[:database].to_s.starts_with?("file:") config[:database] = File.expand_path(config[:database], Rails.root) if defined?(Rails.root) dirname = File.dirname(config[:database]) Dir.mkdir(dirname) unless File.directory?(dirname) @@ -116,6 +116,10 @@ def supports_savepoints? true end + def supports_transaction_isolation? + true + end + def supports_partial_index? true end @@ -325,6 +329,10 @@ def build_insert_sql(insert) # :nodoc: sql end + def shared_cache? # :nodoc: + @config.fetch(:flags, 0).anybits?(::SQLite3::Constants::Open::SHAREDCACHE) + end + def get_database_version # :nodoc: SQLite3Adapter::Version.new(query_value("SELECT sqlite_version(*)")) end diff --git a/activerecord/test/cases/adapters/sqlite3/transaction_test.rb b/activerecord/test/cases/adapters/sqlite3/transaction_test.rb new file mode 100644 index 0000000000000000000000000000000000000000..5d65312d36282c95f93de00c96677d3d39231bb1 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/transaction_test.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "cases/helper" + +class SQLite3TransactionTest < ActiveRecord::SQLite3TestCase + test "shared_cached? is true when cache-mode is enabled" do + with_connection(flags: shared_cache_flags) do |conn| + assert_predicate(conn, :shared_cache?) + end + end + + test "shared_cached? is false when cache-mode is disabled" do + flags =::SQLite3::Constants::Open::READWRITE | SQLite3::Constants::Open::CREATE + + with_connection(flags: flags) do |conn| + assert_not_predicate(conn, :shared_cache?) + end + end + + test "raises when trying to open a transaction in a isolation level other than `read_uncommitted`" do + with_connection do |conn| + assert_raises(ArgumentError) do + conn.transaction(requires_new: true, isolation: :something) do + conn.transaction_manager.materialize_transactions + end + end + end + end + + test "raises when trying to open a read_uncommitted transaction but shared-cache mode is turned off" do + with_connection do |conn| + error = assert_raises(StandardError) do + conn.transaction(requires_new: true, isolation: :read_uncommitted) do + conn.transaction_manager.materialize_transactions + end + end + + assert_match("You need to enable the shared-cache mode", error.message) + end + end + + test "opens a `read_uncommitted` transaction" do + with_connection(flags: shared_cache_flags) do |conn1| + conn1.create_table(:zines) { |t| t.column(:title, :string) } if in_memory_db? + conn1.transaction do + conn1.transaction_manager.materialize_transactions + conn1.execute("INSERT INTO zines (title) VALUES ('foo')") + + with_connection(flags: shared_cache_flags) do |conn2| + conn2.transaction(joinable: false, isolation: :read_uncommitted) do + assert_not_empty(conn2.execute("SELECT * FROM zines WHERE title = 'foo'")) + end + end + + raise ActiveRecord::Rollback + end + end + end + + test "reset the read_uncommitted PRAGMA when transactions is rolled back" do + with_connection(flags: shared_cache_flags) do |conn| + conn.transaction(joinable: false, isolation: :read_uncommitted) do + assert_not(read_uncommitted?(conn)) + conn.transaction_manager.materialize_transactions + assert(read_uncommitted?(conn)) + + raise ActiveRecord::Rollback + end + + assert_not(read_uncommitted?(conn)) + end + end + + test "reset the read_uncommitted PRAGMA when transactions is commited" do + with_connection(flags: shared_cache_flags) do |conn| + conn.transaction(joinable: false, isolation: :read_uncommitted) do + assert_not(read_uncommitted?(conn)) + conn.transaction_manager.materialize_transactions + assert(read_uncommitted?(conn)) + end + + assert_not(read_uncommitted?(conn)) + end + end + + test "set the read_uncommited PRAGMA to its previous value" do + with_connection(flags: shared_cache_flags) do |conn| + conn.transaction(joinable: false, isolation: :read_uncommitted) do + conn.instance_variable_get(:@connection).read_uncommitted = true + assert(read_uncommitted?(conn)) + conn.transaction_manager.materialize_transactions + assert(read_uncommitted?(conn)) + end + + assert(read_uncommitted?(conn)) + end + end + + private + def read_uncommitted?(conn) + conn.instance_variable_get(:@connection).get_first_value("PRAGMA read_uncommitted") != 0 + end + + def shared_cache_flags + ::SQLite3::Constants::Open::READWRITE | SQLite3::Constants::Open::CREATE | ::SQLite3::Constants::Open::SHAREDCACHE | ::SQLite3::Constants::Open::URI + end + + def with_connection(options = {}) + conn_options = options.reverse_merge( + database: in_memory_db? ? "file::memory:" : ActiveRecord::Base.configurations["arunit"][:database] + ) + conn = ActiveRecord::Base.sqlite3_connection(conn_options) + + yield(conn) + ensure + conn.disconnect! if conn + end +end diff --git a/activerecord/test/cases/transaction_isolation_test.rb b/activerecord/test/cases/transaction_isolation_test.rb index 29329694129b8ca35fe4699b08c57ca35502e61d..84fdb544ba31fa4ba51534434203dcea105b2a4c 100644 --- a/activerecord/test/cases/transaction_isolation_test.rb +++ b/activerecord/test/cases/transaction_isolation_test.rb @@ -2,7 +2,7 @@ require "cases/helper" -unless ActiveRecord::Base.connection.supports_transaction_isolation? +unless ActiveRecord::Base.connection.supports_transaction_isolation? && !current_adapter?(:SQLite3Adapter) class TransactionIsolationUnsupportedTest < ActiveRecord::TestCase self.use_transactional_tests = false @@ -10,6 +10,8 @@ class Tag < ActiveRecord::Base end test "setting the isolation level raises an error" do + skip if current_adapter?(:SQLite3Adapter) + assert_raises(ActiveRecord::TransactionIsolationError) do Tag.transaction(isolation: :serializable) { Tag.connection.materialize_transactions } end diff --git a/activerecord/test/cases/transactions_test.rb b/activerecord/test/cases/transactions_test.rb index b5c1cac3d9e1eda078393153c268705b972ec357..1d210e3d156b8e8a8d2f41e3dac97844cc09e723 100644 --- a/activerecord/test/cases/transactions_test.rb +++ b/activerecord/test/cases/transactions_test.rb @@ -1127,7 +1127,7 @@ def test_no_automatic_savepoint_for_inner_transaction end end if Topic.connection.supports_savepoints? -if ActiveRecord::Base.connection.supports_transaction_isolation? +if ActiveRecord::Base.connection.supports_transaction_isolation? && !in_memory_db? class ConcurrentTransactionTest < TransactionTest # This will cause transactions to overlap and fail unless they are performed on # separate database connections.