Skip to content

Commit c7d625a

Browse files
committed
feat: Database.new supports extensions: option parameter
This commit updates the Database documentation with lots of examples and explanation, but to sum up: Database.new(":memory:", extensions: ["SQLean::Crypto"]) which allows injection of extensions in a Rails database config: development: adapter: sqlite3 database: storage/development.sqlite3 extensions: - SQLean::Crypto
1 parent ceb76cc commit c7d625a

File tree

2 files changed

+204
-53
lines changed

2 files changed

+204
-53
lines changed

lib/sqlite3/database.rb

Lines changed: 110 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
require "sqlite3/fork_safety"
99

1010
module SQLite3
11-
# The Database class encapsulates a single connection to a SQLite3 database.
12-
# Its usage is very straightforward:
11+
# == Overview
12+
#
13+
# The Database class encapsulates a single connection to a SQLite3 database. Here's a
14+
# straightforward example of usage:
1315
#
1416
# require 'sqlite3'
1517
#
@@ -19,28 +21,72 @@ module SQLite3
1921
# end
2022
# end
2123
#
22-
# It wraps the lower-level methods provided by the selected driver, and
23-
# includes the Pragmas module for access to various pragma convenience
24-
# methods.
24+
# It wraps the lower-level methods provided by the selected driver, and includes the Pragmas
25+
# module for access to various pragma convenience methods.
2526
#
26-
# The Database class provides type translation services as well, by which
27-
# the SQLite3 data types (which are all represented as strings) may be
28-
# converted into their corresponding types (as defined in the schemas
29-
# for their tables). This translation only occurs when querying data from
27+
# The Database class provides type translation services as well, by which the SQLite3 data types
28+
# (which are all represented as strings) may be converted into their corresponding types (as
29+
# defined in the schemas for their tables). This translation only occurs when querying data from
3030
# the database--insertions and updates are all still typeless.
3131
#
32-
# Furthermore, the Database class has been designed to work well with the
33-
# ArrayFields module from Ara Howard. If you require the ArrayFields
34-
# module before performing a query, and if you have not enabled results as
35-
# hashes, then the results will all be indexible by field name.
32+
# Furthermore, the Database class has been designed to work well with the ArrayFields module from
33+
# Ara Howard. If you require the ArrayFields module before performing a query, and if you have not
34+
# enabled results as hashes, then the results will all be indexible by field name.
35+
#
36+
# == Thread safety
37+
#
38+
# When SQLite3.threadsafe? returns true, it is safe to share instances of the database class
39+
# among threads without adding specific locking. Other object instances may require applications
40+
# to provide their own locks if they are to be shared among threads. Please see the README.md for
41+
# more information.
42+
#
43+
# == SQLite Extensions
44+
#
45+
# SQLite3::Database supports the universe of {sqlite
46+
# extensions}[https://www.sqlite.org/loadext.html]. It's possible to load an extension into an
47+
# existing Database object using the #load_extension method and passing a filesystem path:
48+
#
49+
# db = SQLite3::Database.new(":memory:")
50+
# db.enable_load_extension(true)
51+
# db.load_extension("/path/to/extension")
52+
#
53+
# As of v2.4.0, it's also possible to pass an object that responds to
54+
# +#sqlite_extension_path+. This documentation will refer to the supported interface as
55+
# +_ExtensionSpecifier+, which can be expressed in RBS syntax as:
56+
#
57+
# interface _ExtensionSpecifier
58+
# def sqlite_extension_path: () → String
59+
# end
60+
#
61+
# So, for example, if you are using the {sqlean gem}[https://github.com/flavorjones/sqlean-ruby]
62+
# which provides modules that implement this interface, you can pass the module directly:
63+
#
64+
# db = SQLite3::Database.new(":memory:")
65+
# db.enable_load_extension(true)
66+
# db.load_extension(SQLean::Crypto)
67+
#
68+
# It's also possible in v2.4.0+ to load extensions via the SQLite3::Database constructor by using
69+
# the +extensions:+ keyword argument to pass an array of strings or extension specifiers:
70+
#
71+
# db = SQLite3::Database.new(":memory:", extensions: ["/path/to/extension", SQLean::Crypto])
72+
#
73+
# Note that the constructor will implicitly call #enable_load_extension if the +extensions:+
74+
# keyword argument is present.
75+
#
76+
# Finally, it's also possible to load an extension by passing the _name_ of a constant that
77+
# implements the +_ExtensionSpecifier+ interface:
3678
#
37-
# Thread safety:
79+
# db = SQLite3::Database.new(":memory:", extensions: ["SQLean::Crypto"])
80+
#
81+
# Handling the name of a constant like this is what enables injection of an extension in a Rails
82+
# application's `config/database.yml` file, like this:
83+
#
84+
# development:
85+
# adapter: sqlite3
86+
# database: storage/development.sqlite3
87+
# extensions:
88+
# - SQLean::Crypto
3889
#
39-
# When `SQLite3.threadsafe?` returns true, it is safe to share instances of
40-
# the database class among threads without adding specific locking. Other
41-
# object instances may require applications to provide their own locks if
42-
# they are to be shared among threads. Please see the README.md for more
43-
# information.
4490
class Database
4591
attr_reader :collations
4692

@@ -76,23 +122,25 @@ def quote(string)
76122
# as hashes or not. By default, rows are returned as arrays.
77123
attr_accessor :results_as_hash
78124

79-
# call-seq: SQLite3::Database.new(file, options = {})
125+
# call-seq:
126+
# SQLite3::Database.new(file, options = {})
80127
#
81128
# Create a new Database object that opens the given file.
82129
#
83130
# Supported permissions +options+:
84131
# - the default mode is <tt>READWRITE | CREATE</tt>
85-
# - +:readonly+: boolean (default false), true to set the mode to +READONLY+
86-
# - +:readwrite+: boolean (default false), true to set the mode to +READWRITE+
87-
# - +:flags+: set the mode to a combination of SQLite3::Constants::Open flags.
132+
# - +readonly:+ boolean (default false), true to set the mode to +READONLY+
133+
# - +readwrite:+ boolean (default false), true to set the mode to +READWRITE+
134+
# - +flags:+ set the mode to a combination of SQLite3::Constants::Open flags.
88135
#
89136
# Supported encoding +options+:
90-
# - +:utf16+: boolean (default false), is the filename's encoding UTF-16 (only needed if the filename encoding is not UTF_16LE or BE)
137+
# - +utf16:+ +boolish+ (default false), is the filename's encoding UTF-16 (only needed if the filename encoding is not UTF_16LE or BE)
91138
#
92139
# Other supported +options+:
93-
# - +:strict+: boolean (default false), disallow the use of double-quoted string literals (see https://www.sqlite.org/quirks.html#double_quoted_string_literals_are_accepted)
94-
# - +:results_as_hash+: boolean (default false), return rows as hashes instead of arrays
95-
# - +:default_transaction_mode+: one of +:deferred+ (default), +:immediate+, or +:exclusive+. If a mode is not specified in a call to #transaction, this will be the default transaction mode.
140+
# - +strict:+ +boolish+ (default false), disallow the use of double-quoted string literals (see https://www.sqlite.org/quirks.html#double_quoted_string_literals_are_accepted)
141+
# - +results_as_hash:+ +boolish+ (default false), return rows as hashes instead of arrays
142+
# - +default_transaction_mode:+ one of +:deferred+ (default), +:immediate+, or +:exclusive+. If a mode is not specified in a call to #transaction, this will be the default transaction mode.
143+
# - +extensions:+ <tt>Array[String | _ExtensionSpecifier]</tt> SQLite extensions to load into the database. See Database@SQLite+Extensions for more information.
96144
#
97145
def initialize file, options = {}, zvfs = nil
98146
mode = Constants::Open::READWRITE | Constants::Open::CREATE
@@ -135,6 +183,8 @@ def initialize file, options = {}, zvfs = nil
135183
@readonly = mode & Constants::Open::READONLY != 0
136184
@default_transaction_mode = options[:default_transaction_mode] || :deferred
137185

186+
marshal_extensions(options[:extensions])
187+
138188
ForkSafety.track(self)
139189

140190
if block_given?
@@ -659,36 +709,59 @@ def busy_handler_timeout=(milliseconds)
659709
end
660710

661711
# call-seq:
662-
# load_extension(extension_specifier) -> Database
712+
# load_extension(extension_specifier) -> self
663713
#
664714
# Loads an SQLite extension library from the named file. Extension loading must be enabled using
665-
# Database#enable_load_extension prior to using this method.
715+
# #enable_load_extension prior to using this method.
666716
#
667-
# See also: https://www.sqlite.org/loadext.html
717+
# See also: Database@SQLite+Extensions
668718
#
669719
# [Parameters]
670-
# - extension_specifier (String | +_ExtensionSpecifier+) If a String, it is the filesystem path
720+
# - +extension_specifier+: (String | +_ExtensionSpecifier+) If a String, it is the filesystem path
671721
# to the sqlite extension file. If an object that responds to #sqlite_extension_path, the
672722
# return value of that method is used as the filesystem path to the sqlite extension file.
673723
#
674-
# +_ExtensionSpecifier+ describes the following interface:
724+
# [Example] Using a filesystem path:
675725
#
676-
# interface _ExtensionSpecifier
677-
# def sqlite_extension_path: () → String
678-
# end
726+
# db.load_extension("/path/to/my_extension.so")
679727
#
680-
# For example, the +sqlean+ ruby gem offers a set of classes that implement this interface,
681-
# allowing a calling convention like:
728+
# [Example] Using the {sqlean gem}[https://github.com/flavorjones/sqlean-ruby]:
682729
#
683730
# db.load_extension(SQLean::VSV)
684731
#
685732
def load_extension(extension_specifier)
686733
if extension_specifier.respond_to?(:sqlite_extension_path)
687734
extension_specifier = extension_specifier.sqlite_extension_path
735+
elsif !extension_specifier.is_a?(String)
736+
raise TypeError, "extension_specifier #{extension_specifier.inspect} is not a String or a valid extension specifier object"
688737
end
689738
load_extension_internal(extension_specifier)
690739
end
691740

741+
def marshal_extensions(extensions) # :nodoc:
742+
return if extensions.nil?
743+
raise TypeError, "extensions must be an Array" unless extensions.is_a?(Array)
744+
return if extensions.empty?
745+
746+
enable_load_extension(true)
747+
748+
extensions.each do |extension|
749+
# marshall the extension into an object if it's the name of a constant that responds to
750+
# `#sqlite_extension_path`
751+
if extension.is_a?(String)
752+
begin
753+
extension_spec = Object.const_get(extension)
754+
if extension_spec.respond_to?(:sqlite_extension_path)
755+
extension = extension_spec
756+
end
757+
rescue NameError
758+
end
759+
end
760+
761+
load_extension(extension)
762+
end
763+
end
764+
692765
# A helper class for dealing with custom functions (see #create_function,
693766
# #create_aggregate, and #create_aggregate_handler). It encapsulates the
694767
# opaque function object that represents the current invocation. It also

test/test_database.rb

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
require "pathname"
44

55
module SQLite3
6+
class FakeExtensionSpecifier
7+
def self.sqlite_extension_path
8+
"/path/to/extension"
9+
end
10+
end
11+
612
class TestDatabase < SQLite3::TestCase
713
attr_reader :db
814

@@ -15,6 +21,17 @@ def teardown
1521
@db.close unless @db.closed?
1622
end
1723

24+
def mock_database_load_extension_internal(db)
25+
class << db
26+
attr_reader :load_extension_internal_path
27+
28+
def load_extension_internal(path)
29+
@load_extension_internal_path ||= []
30+
@load_extension_internal_path << path
31+
end
32+
end
33+
end
34+
1835
def test_custom_function_encoding
1936
@db.execute("CREATE TABLE
2037
sourceTable(
@@ -650,37 +667,98 @@ def test_strict_mode
650667
assert_match(/no such column: "?nope"?/, error.message)
651668
end
652669

653-
def test_load_extension_with_nonstring_argument
654-
db = SQLite3::Database.new(":memory:")
670+
def test_load_extension_error_with_nonexistent_path
671+
skip("extensions are not enabled") unless db.respond_to?(:load_extension)
672+
db.enable_load_extension(true)
673+
674+
assert_raises(SQLite3::Exception) { db.load_extension("/path/to/extension") }
675+
end
676+
677+
def test_load_extension_error_with_invalid_argument
655678
skip("extensions are not enabled") unless db.respond_to?(:load_extension)
656679

657680
assert_raises(TypeError) { db.load_extension(1) }
658-
assert_raises(TypeError) { db.load_extension(Pathname.new("foo.so")) }
681+
assert_raises(TypeError) { db.load_extension(Pathname.new("foo")) }
659682
end
660683

661684
def test_load_extension_with_an_extension_descriptor
662-
extension_descriptor = Class.new do
663-
def sqlite_extension_path
664-
"path/to/extension"
665-
end
666-
end.new
685+
mock_database_load_extension_internal(db)
686+
687+
db.load_extension(FakeExtensionSpecifier)
667688

689+
assert_equal(["/path/to/extension"], db.load_extension_internal_path)
690+
end
691+
692+
def test_marshal_extensions_with_extensions_calls_enable_load_extension
693+
mock_database_load_extension_internal(db)
668694
class << db
669-
attr_reader :load_extension_internal_path
695+
attr_reader :enable_load_extension_called
670696

671-
def load_extension_internal(path)
672-
@load_extension_internal_path = path
697+
def enable_load_extension(...)
698+
@enable_load_extension_called = true
673699
end
674700
end
675701

676-
db.load_extension(extension_descriptor)
702+
db.marshal_extensions(nil)
703+
refute(db.enable_load_extension_called)
704+
705+
db.marshal_extensions([])
706+
refute(db.enable_load_extension_called)
677707

678-
assert_equal("path/to/extension", db.load_extension_internal_path)
708+
db.marshal_extensions([FakeExtensionSpecifier])
709+
assert(db.enable_load_extension_called)
679710
end
680711

681-
def test_load_extension_error
682-
db = SQLite3::Database.new(":memory:")
683-
assert_raises(SQLite3::Exception) { db.load_extension("path/to/foo.so") }
712+
def test_marshal_extensions_object_is_an_extension_specifier
713+
mock_database_load_extension_internal(db)
714+
715+
db.marshal_extensions([FakeExtensionSpecifier])
716+
assert_equal(["/path/to/extension"], db.load_extension_internal_path)
717+
718+
db.load_extension_internal_path.clear # reset
719+
720+
db.marshal_extensions(["SQLite3::FakeExtensionSpecifier"])
721+
assert_equal(["/path/to/extension"], db.load_extension_internal_path)
722+
723+
db.load_extension_internal_path.clear # reset
724+
725+
db.marshal_extensions(["CannotBeResolved"])
726+
assert_equal(["CannotBeResolved"], db.load_extension_internal_path)
727+
end
728+
729+
def test_marshal_extensions_object_not_an_extension_specifier
730+
mock_database_load_extension_internal(db)
731+
732+
db.marshal_extensions(["CannotBeResolved"])
733+
assert_equal(["CannotBeResolved"], db.load_extension_internal_path)
734+
735+
assert_raises(TypeError) { db.marshal_extensions([Class.new]) }
736+
737+
assert_raises(TypeError) { db.marshal_extensions(FakeExtensionSpecifier) }
738+
end
739+
740+
def test_initialize_with_extensions_calls_marshal_extensions
741+
# ephemeral class to capture arguments passed to marshal_extensions
742+
klass = Class.new(SQLite3::Database) do
743+
attr :marshal_extensions_called, :marshal_extensions_arg
744+
745+
def marshal_extensions(extensions)
746+
@marshal_extensions_called = true
747+
@marshal_extensions_arg = extensions
748+
end
749+
end
750+
751+
db = klass.new(":memory:")
752+
assert(db.marshal_extensions_called)
753+
assert_nil(db.marshal_extensions_arg)
754+
755+
db = klass.new(":memory:", extensions: [])
756+
assert(db.marshal_extensions_called)
757+
assert_empty(db.marshal_extensions_arg)
758+
759+
db = klass.new(":memory:", extensions: ["path/to/ext1", "path/to/ext2", "ClassName"])
760+
assert(db.marshal_extensions_called)
761+
assert_equal(["path/to/ext1", "path/to/ext2", "ClassName"], db.marshal_extensions_arg)
684762
end
685763

686764
def test_raw_float_infinity

0 commit comments

Comments
 (0)