Skip to content

Commit

Permalink
Merge pull request #586 from sparklemotion/flavorjones-sqlite-extensi…
Browse files Browse the repository at this point in the history
…on-contract

feat: easier and more flexible loading of sqlite extensions
  • Loading branch information
flavorjones authored Dec 3, 2024
2 parents 0df3e88 + 41e20fa commit cdcadf8
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 42 deletions.
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

0 comments on commit cdcadf8

Please sign in to comment.