Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: easier and more flexible loading of sqlite extensions #586

Merged
merged 1 commit into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .rdoc_options
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ exclude:
- "vendor"
- "ports"
- "tmp"
- "pkg"
hyperlink_all: false
line_numbers: false
locale:
Expand Down
10 changes: 2 additions & 8 deletions ext/sqlite3/database.c
Original file line number Diff line number Diff line change
Expand Up @@ -771,14 +771,8 @@ collation(VALUE self, VALUE name, VALUE comparator)
}

#ifdef HAVE_SQLITE3_LOAD_EXTENSION
/* call-seq: db.load_extension(file)
*
* Loads an SQLite extension library from the named file. Extension
* loading must be enabled using db.enable_load_extension(true) prior
* to calling this API.
*/
static VALUE
load_extension(VALUE self, VALUE file)
load_extension_internal(VALUE self, VALUE file)
{
sqlite3RubyPtr ctx;
int status;
Expand Down Expand Up @@ -997,7 +991,7 @@ init_sqlite3_database(void)
rb_define_private_method(cSqlite3Database, "db_filename", db_filename, 1);

#ifdef HAVE_SQLITE3_LOAD_EXTENSION
rb_define_method(cSqlite3Database, "load_extension", load_extension, 1);
rb_define_private_method(cSqlite3Database, "load_extension_internal", load_extension_internal, 1);
#endif

#ifdef HAVE_SQLITE3_ENABLE_LOAD_EXTENSION
Expand Down
137 changes: 110 additions & 27 deletions lib/sqlite3/database.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
require "sqlite3/fork_safety"

module SQLite3
# The Database class encapsulates a single connection to a SQLite3 database.
# Its usage is very straightforward:
# == Overview
#
# The Database class encapsulates a single connection to a SQLite3 database. Here's a
# straightforward example of usage:
#
# require 'sqlite3'
#
Expand All @@ -19,28 +21,59 @@ module SQLite3
# end
# end
#
# It wraps the lower-level methods provided by the selected driver, and
# includes the Pragmas module for access to various pragma convenience
# methods.
# It wraps the lower-level methods provided by the selected driver, and includes the Pragmas
# module for access to various pragma convenience methods.
#
# The Database class provides type translation services as well, by which
# the SQLite3 data types (which are all represented as strings) may be
# converted into their corresponding types (as defined in the schemas
# for their tables). This translation only occurs when querying data from
# The Database class provides type translation services as well, by which the SQLite3 data types
# (which are all represented as strings) may be converted into their corresponding types (as
# defined in the schemas for their tables). This translation only occurs when querying data from
# the database--insertions and updates are all still typeless.
#
# Furthermore, the Database class has been designed to work well with the
# ArrayFields module from Ara Howard. If you require the ArrayFields
# module before performing a query, and if you have not enabled results as
# hashes, then the results will all be indexible by field name.
# Furthermore, the Database class has been designed to work well with the ArrayFields module from
# Ara Howard. If you require the ArrayFields module before performing a query, and if you have not
# enabled results as hashes, then the results will all be indexible by field name.
#
# == Thread safety
#
# When SQLite3.threadsafe? returns true, it is safe to share instances of the database class
# among threads without adding specific locking. Other object instances may require applications
# to provide their own locks if they are to be shared among threads. Please see the README.md for
# more information.
#
# == SQLite Extensions
#
# SQLite3::Database supports the universe of {sqlite
# extensions}[https://www.sqlite.org/loadext.html]. It's possible to load an extension into an
# existing Database object using the #load_extension method and passing a filesystem path:
#
# db = SQLite3::Database.new(":memory:")
# db.enable_load_extension(true)
# db.load_extension("/path/to/extension")
#
# As of v2.4.0, it's also possible to pass an object that responds to +#to_path+. This
# documentation will refer to the supported interface as +_ExtensionSpecifier+, which can be
# expressed in RBS syntax as:
#
# interface _ExtensionSpecifier
# def to_path: () → String
# end
#
# Thread safety:
# So, for example, if you are using the {sqlean gem}[https://github.com/flavorjones/sqlean-ruby]
# which provides modules that implement this interface, you can pass the module directly:
#
# db = SQLite3::Database.new(":memory:")
# db.enable_load_extension(true)
# db.load_extension(SQLean::Crypto)
#
# It's also possible in v2.4.0+ to load extensions via the SQLite3::Database constructor by using
# the +extensions:+ keyword argument to pass an array of String paths or extension specifiers:
#
# db = SQLite3::Database.new(":memory:", extensions: ["/path/to/extension", SQLean::Crypto])
#
# Note that when loading extensions via the constructor, there is no need to call
# #enable_load_extension; however it is still necessary to call #enable_load_extensions before any
# subsequently invocations of #load_extension on the initialized Database object.
#
# When `SQLite3.threadsafe?` returns true, it is safe to share instances of
# the database class among threads without adding specific locking. Other
# object instances may require applications to provide their own locks if
# they are to be shared among threads. Please see the README.md for more
# information.
class Database
attr_reader :collations

Expand Down Expand Up @@ -76,23 +109,25 @@ def quote(string)
# as hashes or not. By default, rows are returned as arrays.
attr_accessor :results_as_hash

# call-seq: SQLite3::Database.new(file, options = {})
# call-seq:
# SQLite3::Database.new(file, options = {})
#
# Create a new Database object that opens the given file.
#
# Supported permissions +options+:
# - the default mode is <tt>READWRITE | CREATE</tt>
# - +:readonly+: boolean (default false), true to set the mode to +READONLY+
# - +:readwrite+: boolean (default false), true to set the mode to +READWRITE+
# - +:flags+: set the mode to a combination of SQLite3::Constants::Open flags.
# - +readonly:+ boolean (default false), true to set the mode to +READONLY+
# - +readwrite:+ boolean (default false), true to set the mode to +READWRITE+
# - +flags:+ set the mode to a combination of SQLite3::Constants::Open flags.
#
# Supported encoding +options+:
# - +:utf16+: boolean (default false), is the filename's encoding UTF-16 (only needed if the filename encoding is not UTF_16LE or BE)
# - +utf16:+ +boolish+ (default false), is the filename's encoding UTF-16 (only needed if the filename encoding is not UTF_16LE or BE)
#
# Other supported +options+:
# - +: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)
# - +:results_as_hash+: boolean (default false), return rows as hashes instead of arrays
# - +: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.
# - +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)
# - +results_as_hash:+ +boolish+ (default false), return rows as hashes instead of arrays
# - +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.
# - +extensions:+ <tt>Array[String | _ExtensionSpecifier]</tt> SQLite extensions to load into the database. See Database@SQLite+Extensions for more information.
#
def initialize file, options = {}, zvfs = nil
mode = Constants::Open::READWRITE | Constants::Open::CREATE
Expand Down Expand Up @@ -135,6 +170,8 @@ def initialize file, options = {}, zvfs = nil
@readonly = mode & Constants::Open::READONLY != 0
@default_transaction_mode = options[:default_transaction_mode] || :deferred

initialize_extensions(options[:extensions])

ForkSafety.track(self)

if block_given?
Expand Down Expand Up @@ -658,6 +695,52 @@ def busy_handler_timeout=(milliseconds)
end
end

# call-seq:
# load_extension(extension_specifier) -> self
#
# Loads an SQLite extension library from the named file. Extension loading must be enabled using
# #enable_load_extension prior to using this method.
#
# See also: Database@SQLite+Extensions
#
# [Parameters]
# - +extension_specifier+: (String | +_ExtensionSpecifier+) If a String, it is the filesystem path
# to the sqlite extension file. If an object that responds to #to_path, the
# return value of that method is used as the filesystem path to the sqlite extension file.
#
# [Example] Using a filesystem path:
#
# db.load_extension("/path/to/my_extension.so")
#
# [Example] Using the {sqlean gem}[https://github.com/flavorjones/sqlean-ruby]:
#
# db.load_extension(SQLean::VSV)
#
def load_extension(extension_specifier)
if extension_specifier.respond_to?(:to_path)
extension_specifier = extension_specifier.to_path
elsif !extension_specifier.is_a?(String)
raise TypeError, "extension_specifier #{extension_specifier.inspect} is not a String or a valid extension specifier object"
end
load_extension_internal(extension_specifier)
end

def initialize_extensions(extensions) # :nodoc:
return if extensions.nil?
raise TypeError, "extensions must be an Array" unless extensions.is_a?(Array)
return if extensions.empty?

begin
enable_load_extension(true)

extensions.each do |extension|
load_extension(extension)
end
ensure
enable_load_extension(false)
end
end

# A helper class for dealing with custom functions (see #create_function,
# #create_aggregate, and #create_aggregate_handler). It encapsulates the
# opaque function object that represents the current invocation. It also
Expand Down
2 changes: 1 addition & 1 deletion lib/sqlite3/version.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module SQLite3
# (String) the version of the sqlite3 gem, e.g. "2.1.1"
VERSION = "2.3.1"
VERSION = "2.4.0.dev"
end
130 changes: 124 additions & 6 deletions test/test_database.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
require "pathname"

module SQLite3
class FakeExtensionSpecifier
def self.to_path
"/path/to/extension"
end
end

class TestDatabase < SQLite3::TestCase
attr_reader :db

Expand All @@ -15,6 +21,17 @@ def teardown
@db.close unless @db.closed?
end

def mock_database_load_extension_internal(db)
class << db
attr_reader :load_extension_internal_path

def load_extension_internal(path)
@load_extension_internal_path ||= []
@load_extension_internal_path << path
end
end
end

def test_custom_function_encoding
@db.execute("CREATE TABLE
sourceTable(
Expand Down Expand Up @@ -650,16 +667,117 @@ def test_strict_mode
assert_match(/no such column: "?nope"?/, error.message)
end

def test_load_extension_with_nonstring_argument
db = SQLite3::Database.new(":memory:")
def test_load_extension_error_with_nonexistent_path
skip("extensions are not enabled") unless db.respond_to?(:load_extension)
db.enable_load_extension(true)

assert_raises(SQLite3::Exception) { db.load_extension("/nonexistent/path") }
assert_raises(SQLite3::Exception) { db.load_extension(Pathname.new("nonexistent")) }
end

def test_load_extension_error_with_invalid_argument
skip("extensions are not enabled") unless db.respond_to?(:load_extension)
db.enable_load_extension(true)

assert_raises(TypeError) { db.load_extension(1) }
assert_raises(TypeError) { db.load_extension(Pathname.new("foo.so")) }
assert_raises(TypeError) { db.load_extension({a: 1}) }
assert_raises(TypeError) { db.load_extension([]) }
assert_raises(TypeError) { db.load_extension(Object.new) }
end

def test_load_extension_error
db = SQLite3::Database.new(":memory:")
assert_raises(SQLite3::Exception) { db.load_extension("path/to/foo.so") }
def test_load_extension_with_an_extension_descriptor
mock_database_load_extension_internal(db)

db.load_extension(Pathname.new("/path/to/ext2"))
assert_equal(["/path/to/ext2"], db.load_extension_internal_path)

db.load_extension_internal_path.clear # reset

db.load_extension(FakeExtensionSpecifier)
assert_equal(["/path/to/extension"], db.load_extension_internal_path)
end

def test_initialize_extensions_with_extensions_calls_enable_load_extension
mock_database_load_extension_internal(db)
class << db
attr_accessor :enable_load_extension_called
attr_reader :enable_load_extension_arg

def reset_test
@enable_load_extension_called = 0
@enable_load_extension_arg = []
end

def enable_load_extension(val)
@enable_load_extension_called += 1
@enable_load_extension_arg << val
end
end

db.reset_test
db.initialize_extensions(nil)
assert_equal(0, db.enable_load_extension_called)

db.reset_test
db.initialize_extensions([])
assert_equal(0, db.enable_load_extension_called)

db.reset_test
db.initialize_extensions(["/path/to/extension"])
assert_equal(2, db.enable_load_extension_called)
assert_equal([true, false], db.enable_load_extension_arg)

db.reset_test
db.initialize_extensions([FakeExtensionSpecifier])
assert_equal(2, db.enable_load_extension_called)
assert_equal([true, false], db.enable_load_extension_arg)
end

def test_initialize_extensions_object_is_an_extension_specifier
mock_database_load_extension_internal(db)

db.initialize_extensions([Pathname.new("/path/to/extension")])
assert_equal(["/path/to/extension"], db.load_extension_internal_path)

db.load_extension_internal_path.clear # reset

db.initialize_extensions([FakeExtensionSpecifier])
assert_equal(["/path/to/extension"], db.load_extension_internal_path)
end

def test_initialize_extensions_object_not_an_extension_specifier
mock_database_load_extension_internal(db)

db.initialize_extensions(["/path/to/extension"])
assert_equal(["/path/to/extension"], db.load_extension_internal_path)

assert_raises(TypeError) { db.initialize_extensions([Class.new]) }

assert_raises(TypeError) { db.initialize_extensions(FakeExtensionSpecifier) }
end

def test_initialize_with_extensions_calls_initialize_extensions
# ephemeral class to capture arguments passed to initialize_extensions
klass = Class.new(SQLite3::Database) do
attr :initialize_extensions_called, :initialize_extensions_arg

def initialize_extensions(extensions)
@initialize_extensions_called = true
@initialize_extensions_arg = extensions
end
end

db = klass.new(":memory:")
assert(db.initialize_extensions_called)
assert_nil(db.initialize_extensions_arg)

db = klass.new(":memory:", extensions: [])
assert(db.initialize_extensions_called)
assert_empty(db.initialize_extensions_arg)

db = klass.new(":memory:", extensions: ["path/to/ext1", "path/to/ext2", FakeExtensionSpecifier])
assert(db.initialize_extensions_called)
assert_equal(["path/to/ext1", "path/to/ext2", FakeExtensionSpecifier], db.initialize_extensions_arg)
end

def test_raw_float_infinity
Expand Down
Loading