Skip to content

Commit 4f55b44

Browse files
committed
Optional Extralite client mode
1 parent 36042b1 commit 4f55b44

File tree

8 files changed

+185
-24
lines changed

8 files changed

+185
-24
lines changed

Gemfile.lock

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ GEM
5353
drb (2.1.1)
5454
ruby2_keywords
5555
erubi (1.12.0)
56+
extralite (2.2)
5657
i18n (1.14.1)
5758
concurrent-ruby (~> 1.0)
5859
io-console (0.6.0)
@@ -65,11 +66,11 @@ GEM
6566
loofah (2.21.3)
6667
crass (~> 1.0.2)
6768
nokogiri (>= 1.12.0)
69+
mini_portile2 (2.8.5)
6870
minitest (5.20.0)
6971
mutex_m (0.1.2)
70-
nokogiri (1.15.4-arm64-darwin)
71-
racc (~> 1.4)
72-
nokogiri (1.15.4-x86_64-linux)
72+
nokogiri (1.15.4)
73+
mini_portile2 (~> 2.8.2)
7374
racc (~> 1.4)
7475
parallel (1.23.0)
7576
parser (3.2.2.3)
@@ -126,8 +127,8 @@ GEM
126127
rubocop-ast (>= 0.4.0)
127128
ruby-progressbar (1.13.0)
128129
ruby2_keywords (0.0.5)
129-
sqlite3 (1.6.6-arm64-darwin)
130-
sqlite3 (1.6.6-x86_64-linux)
130+
sqlite3 (1.6.6)
131+
mini_portile2 (~> 2.8.0)
131132
standard (1.30.1)
132133
language_server-protocol (~> 3.17.0.2)
133134
lint_roller (~> 1.0)
@@ -151,11 +152,14 @@ GEM
151152

152153
PLATFORMS
153154
arm64-darwin-21
155+
arm64-darwin-22
156+
arm64-darwin-23
154157
x86_64-linux
155158

156159
DEPENDENCIES
157160
activerecord-enhancedsqlite3-adapter!
158161
combustion (~> 1.3)
162+
extralite
159163
minitest (~> 5.0)
160164
railties
161165
rake (~> 13.0)

activerecord-enhancedsqlite3-adapter.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Gem::Specification.new do |spec|
3636
spec.add_dependency "sqlite3", "~> 1.6"
3737

3838
spec.add_development_dependency "combustion", "~> 1.3"
39+
spec.add_development_dependency "extralite"
3940
spec.add_development_dependency "railties"
4041
spec.add_development_dependency "minitest"
4142
spec.add_development_dependency "rake"

lib/enhanced_sqlite3/adapter.rb

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,68 @@
77
require "active_record/connection_adapters/sqlite3_adapter"
88
require "enhanced_sqlite3/supports_virtual_columns"
99
require "enhanced_sqlite3/supports_deferrable_constraints"
10+
require "enhanced_sqlite3/extralite/database_compatibility"
11+
require "enhanced_sqlite3/extralite/adapter_compatibility"
1012

1113
module EnhancedSQLite3
1214
module Adapter
15+
module ClassMethods
16+
def new_client(config)
17+
if config[:client] == "extralite"
18+
new_client_extralite(config)
19+
else
20+
super
21+
end
22+
end
23+
24+
def new_client_extralite(config)
25+
config.delete(:results_as_hash)
26+
27+
if config[:strict] == true
28+
raise ArgumentError, "The :strict option is not supported by the SQLite3 adapter using Extralite"
29+
end
30+
31+
unsupported_configuration_keys = config.keys - %i[database readonly client adapter strict]
32+
if unsupported_configuration_keys.any?
33+
raise ArgumentError, "Unsupported configuration options for SQLite3 adapter using Extralite: #{unsupported_configuration_keys}"
34+
end
35+
36+
::Extralite::Database.new(config[:database].to_s, read_only: config[:readonly]).tap do |database|
37+
database.singleton_class.prepend EnhancedSQLite3::Extralite::DatabaseCompatibility
38+
end
39+
rescue Errno::ENOENT => error
40+
if error.message.include?("No such file or directory")
41+
raise ActiveRecord::NoDatabaseError
42+
else
43+
raise
44+
end
45+
end
46+
end
47+
1348
# Setup the Rails SQLite3 adapter instance.
1449
#
1550
# extends https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L90
1651
def initialize(...)
1752
super
18-
# Ensure that all connections default to immediate transaction mode.
19-
# This is necessary to prevent SQLite from deadlocking when concurrent processes open write transactions.
20-
# By default, SQLite opens transactions in deferred mode, which means that a transactions acquire
21-
# a shared lock on the database, but will attempt to upgrade that lock to an exclusive lock if/when
22-
# a write is attempted. Because SQLite is in the middle of a transaction, it cannot retry the transaction
23-
# if a BUSY exception is raised, and so it will immediately raise a SQLITE_BUSY exception without calling
24-
# the `busy_handler`. Because Rails only wraps writes in transactions, this means that all transactions
25-
# will attempt to acquire an exclusive lock on the database. Thus, under any concurrent load, you are very
26-
# likely to encounter a SQLITE_BUSY exception.
27-
# By setting the default transaction mode to immediate, SQLite will instead attempt to acquire
28-
# an exclusive lock as soon as the transaction is opened. If the lock cannot be acquired, it will
29-
# immediately call the `busy_handler` to retry the transaction. This allows concurrent processes to
30-
# coordinate and linearize their transactions, avoiding deadlocks.
31-
@connection_parameters.merge!(default_transaction_mode: :immediate)
53+
54+
if @config[:client] == "extralite"
55+
singleton_class.prepend EnhancedSQLite3::Extralite::AdapterCompatibility
56+
else
57+
# Ensure that all connections default to immediate transaction mode.
58+
# This is necessary to prevent SQLite from deadlocking when concurrent processes open write transactions.
59+
# By default, SQLite opens transactions in deferred mode, which means that a transactions acquire
60+
# a shared lock on the database, but will attempt to upgrade that lock to an exclusive lock if/when
61+
# a write is attempted. Because SQLite is in the middle of a transaction, it cannot retry the transaction
62+
# if a BUSY exception is raised, and so it will immediately raise a SQLITE_BUSY exception without calling
63+
# the `busy_handler`. Because Rails only wraps writes in transactions, this means that all transactions
64+
# will attempt to acquire an exclusive lock on the database. Thus, under any concurrent load, you are very
65+
# likely to encounter a SQLITE_BUSY exception.
66+
# By setting the default transaction mode to immediate, SQLite will instead attempt to acquire
67+
# an exclusive lock as soon as the transaction is opened. If the lock cannot be acquired, it will
68+
# immediately call the `busy_handler` to retry the transaction. This allows concurrent processes to
69+
# coordinate and linearize their transactions, avoiding deadlocks.
70+
@connection_parameters.merge!(default_transaction_mode: :immediate)
71+
end
3272
end
3373

3474
# Perform any necessary initialization upon the newly-established
@@ -111,7 +151,9 @@ def configure_pragmas
111151
end
112152

113153
def configure_extensions
114-
@raw_connection.enable_load_extension(true)
154+
# NOTE: Extralite enables extension loading by default and doesn't provide an API to toggle it.
155+
@raw_connection.enable_load_extension(true) if @raw_connection.is_a?(::SQLite3::Database)
156+
115157
@config.fetch(:extensions, []).each do |extension_name|
116158
require extension_name
117159
extension_classname = extension_name.camelize
@@ -122,7 +164,7 @@ def configure_extensions
122164
rescue NameError
123165
Rails.logger.error("Failed to find the SQLite extension class: #{extension_classname}. Skipping...")
124166
end
125-
@raw_connection.enable_load_extension(false)
167+
@raw_connection.enable_load_extension(false) if @raw_connection.is_a?(::SQLite3::Database)
126168
end
127169
end
128170
end
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# frozen_string_literal: true
2+
3+
# Makes some methods of the ActiveRecord SQLite3 adapter compatible with Extralite::Database.
4+
5+
module EnhancedSQLite3
6+
module Extralite
7+
module AdapterCompatibility
8+
def internal_exec_query(sql, name = nil, binds = [], prepare: false, async: false)
9+
sql = transform_query(sql)
10+
check_if_write_query(sql)
11+
12+
mark_transaction_written_if_write(sql)
13+
14+
type_casted_binds = type_casted_binds(binds)
15+
16+
log(sql, name, binds, type_casted_binds, async: async) do
17+
with_raw_connection do |conn|
18+
unless prepare
19+
stmt = conn.prepare(sql)
20+
begin
21+
cols = stmt.columns
22+
unless without_prepared_statement?(binds)
23+
stmt.bind(*type_casted_binds) # Added splat since Extralite doesn't accept an array argument for #bind
24+
end
25+
records = stmt.to_a_ary # Extralite uses to_a_ary rather than to_a to get a Array[Array] result
26+
ensure
27+
stmt.close
28+
end
29+
else
30+
stmt = @statements[sql] ||= conn.prepare(sql)
31+
cols = stmt.columns
32+
stmt.reset # Extralite uses reset rather than reset!
33+
stmt.bind(*type_casted_binds) # Added splat since Extralite doesn't accept an array argument for #bind
34+
records = stmt.to_a_ary # Extralite uses to_a_ary rather than to_a to get a Array[Array] result
35+
end
36+
37+
# Extralite defaults to returning symbols for columns but #build_result expects strings
38+
build_result(columns: cols.map(&:to_s), rows: records)
39+
end
40+
end
41+
end
42+
43+
private
44+
45+
def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: false)
46+
log(sql, name, async: async) do
47+
with_raw_connection(allow_retry: allow_retry, materialize_transactions: materialize_transactions) do |conn|
48+
conn.query(sql) # Extralite::Database#execute doesn't return results so use #query instead
49+
end
50+
end
51+
end
52+
end
53+
end
54+
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
# Implements some methods to make Extralite::Database compatible enough with
4+
# SQLite3::Database to be used by ActiveRecord's SQLite adapter.
5+
6+
module EnhancedSQLite3
7+
module Extralite
8+
module DatabaseCompatibility
9+
def transaction(mode = :deferred)
10+
execute "BEGIN #{mode.to_s.upcase} TRANSACTION"
11+
12+
if block_given?
13+
abort = false
14+
begin
15+
yield self
16+
rescue StandardError
17+
abort = true
18+
raise
19+
ensure
20+
abort and rollback or commit
21+
end
22+
end
23+
24+
true
25+
end
26+
27+
def commit
28+
execute "COMMIT TRANSACTION"
29+
end
30+
31+
def rollback
32+
execute "ROLLBACK TRANSACTION"
33+
end
34+
35+
# NOTE: Extralite only supports UTF-8 encoding while the sqlite3 gem can use UTF-16
36+
# if utf16: true is passed to the Database initializer.
37+
def encoding
38+
"UTF-8"
39+
end
40+
41+
# NOTE: The sqlite3 gem appears to support both busy_timeout= and busy_timeout
42+
# The ActiveRecord adapter #configure_connection method uses the latter, which
43+
# could potentially be changed, allowing us to get rid of the monkey patch.
44+
def busy_timeout(timeout)
45+
self.busy_timeout = timeout
46+
end
47+
48+
def readonly?
49+
read_only?
50+
end
51+
end
52+
end
53+
end

lib/enhanced_sqlite3/railtie.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ class Railtie < ::Rails::Railtie
88
# Enhance the SQLite3 ActiveRecord adapter with optimized defaults
99
initializer "enhanced_sqlite3.enhance_active_record_sqlite3adapter" do |app|
1010
ActiveSupport.on_load(:active_record_sqlite3adapter) do
11-
# self refers to `SQLite3Adapter` here,
11+
# self refers to `ActiveRecord::ConnectionAdapters::SQLite3Adapter` here,
1212
# so we can call .prepend
1313
prepend EnhancedSQLite3::Adapter
14+
singleton_class.prepend EnhancedSQLite3::Adapter::ClassMethods
1415
end
1516
end
1617
end

test/combustion/config/database.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
test:
22
adapter: sqlite3
3-
database: database.sqlite
3+
database: database.sqlite
4+
client: <%= "extralite" if ENV["EXTRALITE"] == "true" %>

test/test_helper.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
require "minitest/autorun"
1313

1414
require "combustion"
15-
# require "sqlite3"
15+
16+
if ENV["EXTRALITE"] == "true"
17+
puts "Testing in Extralite client mode..."
18+
require "extralite"
19+
end
20+
1621
Combustion.path = "test/combustion"
1722
Combustion.initialize! :active_record
1823

0 commit comments

Comments
 (0)