Skip to content

Commit cdcadf8

Browse files
authored
Merge pull request #586 from sparklemotion/flavorjones-sqlite-extension-contract
feat: easier and more flexible loading of sqlite extensions
2 parents 0df3e88 + 41e20fa commit cdcadf8

File tree

5 files changed

+238
-42
lines changed

5 files changed

+238
-42
lines changed

.rdoc_options

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ exclude:
1717
- "vendor"
1818
- "ports"
1919
- "tmp"
20+
- "pkg"
2021
hyperlink_all: false
2122
line_numbers: false
2223
locale:

ext/sqlite3/database.c

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -771,14 +771,8 @@ collation(VALUE self, VALUE name, VALUE comparator)
771771
}
772772

773773
#ifdef HAVE_SQLITE3_LOAD_EXTENSION
774-
/* call-seq: db.load_extension(file)
775-
*
776-
* Loads an SQLite extension library from the named file. Extension
777-
* loading must be enabled using db.enable_load_extension(true) prior
778-
* to calling this API.
779-
*/
780774
static VALUE
781-
load_extension(VALUE self, VALUE file)
775+
load_extension_internal(VALUE self, VALUE file)
782776
{
783777
sqlite3RubyPtr ctx;
784778
int status;
@@ -997,7 +991,7 @@ init_sqlite3_database(void)
997991
rb_define_private_method(cSqlite3Database, "db_filename", db_filename, 1);
998992

999993
#ifdef HAVE_SQLITE3_LOAD_EXTENSION
1000-
rb_define_method(cSqlite3Database, "load_extension", load_extension, 1);
994+
rb_define_private_method(cSqlite3Database, "load_extension_internal", load_extension_internal, 1);
1001995
#endif
1002996

1003997
#ifdef HAVE_SQLITE3_ENABLE_LOAD_EXTENSION

lib/sqlite3/database.rb

Lines changed: 110 additions & 27 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,59 @@ 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 +#to_path+. This
54+
# documentation will refer to the supported interface as +_ExtensionSpecifier+, which can be
55+
# expressed in RBS syntax as:
56+
#
57+
# interface _ExtensionSpecifier
58+
# def to_path: () → String
59+
# end
3660
#
37-
# Thread safety:
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 String paths or extension specifiers:
70+
#
71+
# db = SQLite3::Database.new(":memory:", extensions: ["/path/to/extension", SQLean::Crypto])
72+
#
73+
# Note that when loading extensions via the constructor, there is no need to call
74+
# #enable_load_extension; however it is still necessary to call #enable_load_extensions before any
75+
# subsequently invocations of #load_extension on the initialized Database object.
3876
#
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.
4477
class Database
4578
attr_reader :collations
4679

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

79-
# call-seq: SQLite3::Database.new(file, options = {})
112+
# call-seq:
113+
# SQLite3::Database.new(file, options = {})
80114
#
81115
# Create a new Database object that opens the given file.
82116
#
83117
# Supported permissions +options+:
84118
# - 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.
119+
# - +readonly:+ boolean (default false), true to set the mode to +READONLY+
120+
# - +readwrite:+ boolean (default false), true to set the mode to +READWRITE+
121+
# - +flags:+ set the mode to a combination of SQLite3::Constants::Open flags.
88122
#
89123
# 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)
124+
# - +utf16:+ +boolish+ (default false), is the filename's encoding UTF-16 (only needed if the filename encoding is not UTF_16LE or BE)
91125
#
92126
# 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.
127+
# - +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)
128+
# - +results_as_hash:+ +boolish+ (default false), return rows as hashes instead of arrays
129+
# - +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.
130+
# - +extensions:+ <tt>Array[String | _ExtensionSpecifier]</tt> SQLite extensions to load into the database. See Database@SQLite+Extensions for more information.
96131
#
97132
def initialize file, options = {}, zvfs = nil
98133
mode = Constants::Open::READWRITE | Constants::Open::CREATE
@@ -135,6 +170,8 @@ def initialize file, options = {}, zvfs = nil
135170
@readonly = mode & Constants::Open::READONLY != 0
136171
@default_transaction_mode = options[:default_transaction_mode] || :deferred
137172

173+
initialize_extensions(options[:extensions])
174+
138175
ForkSafety.track(self)
139176

140177
if block_given?
@@ -658,6 +695,52 @@ def busy_handler_timeout=(milliseconds)
658695
end
659696
end
660697

698+
# call-seq:
699+
# load_extension(extension_specifier) -> self
700+
#
701+
# Loads an SQLite extension library from the named file. Extension loading must be enabled using
702+
# #enable_load_extension prior to using this method.
703+
#
704+
# See also: Database@SQLite+Extensions
705+
#
706+
# [Parameters]
707+
# - +extension_specifier+: (String | +_ExtensionSpecifier+) If a String, it is the filesystem path
708+
# to the sqlite extension file. If an object that responds to #to_path, the
709+
# return value of that method is used as the filesystem path to the sqlite extension file.
710+
#
711+
# [Example] Using a filesystem path:
712+
#
713+
# db.load_extension("/path/to/my_extension.so")
714+
#
715+
# [Example] Using the {sqlean gem}[https://github.com/flavorjones/sqlean-ruby]:
716+
#
717+
# db.load_extension(SQLean::VSV)
718+
#
719+
def load_extension(extension_specifier)
720+
if extension_specifier.respond_to?(:to_path)
721+
extension_specifier = extension_specifier.to_path
722+
elsif !extension_specifier.is_a?(String)
723+
raise TypeError, "extension_specifier #{extension_specifier.inspect} is not a String or a valid extension specifier object"
724+
end
725+
load_extension_internal(extension_specifier)
726+
end
727+
728+
def initialize_extensions(extensions) # :nodoc:
729+
return if extensions.nil?
730+
raise TypeError, "extensions must be an Array" unless extensions.is_a?(Array)
731+
return if extensions.empty?
732+
733+
begin
734+
enable_load_extension(true)
735+
736+
extensions.each do |extension|
737+
load_extension(extension)
738+
end
739+
ensure
740+
enable_load_extension(false)
741+
end
742+
end
743+
661744
# A helper class for dealing with custom functions (see #create_function,
662745
# #create_aggregate, and #create_aggregate_handler). It encapsulates the
663746
# opaque function object that represents the current invocation. It also

lib/sqlite3/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
module SQLite3
22
# (String) the version of the sqlite3 gem, e.g. "2.1.1"
3-
VERSION = "2.3.1"
3+
VERSION = "2.4.0.dev"
44
end

test/test_database.rb

Lines changed: 124 additions & 6 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.to_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,16 +667,117 @@ 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("/nonexistent/path") }
675+
assert_raises(SQLite3::Exception) { db.load_extension(Pathname.new("nonexistent")) }
676+
end
677+
678+
def test_load_extension_error_with_invalid_argument
655679
skip("extensions are not enabled") unless db.respond_to?(:load_extension)
680+
db.enable_load_extension(true)
681+
656682
assert_raises(TypeError) { db.load_extension(1) }
657-
assert_raises(TypeError) { db.load_extension(Pathname.new("foo.so")) }
683+
assert_raises(TypeError) { db.load_extension({a: 1}) }
684+
assert_raises(TypeError) { db.load_extension([]) }
685+
assert_raises(TypeError) { db.load_extension(Object.new) }
658686
end
659687

660-
def test_load_extension_error
661-
db = SQLite3::Database.new(":memory:")
662-
assert_raises(SQLite3::Exception) { db.load_extension("path/to/foo.so") }
688+
def test_load_extension_with_an_extension_descriptor
689+
mock_database_load_extension_internal(db)
690+
691+
db.load_extension(Pathname.new("/path/to/ext2"))
692+
assert_equal(["/path/to/ext2"], db.load_extension_internal_path)
693+
694+
db.load_extension_internal_path.clear # reset
695+
696+
db.load_extension(FakeExtensionSpecifier)
697+
assert_equal(["/path/to/extension"], db.load_extension_internal_path)
698+
end
699+
700+
def test_initialize_extensions_with_extensions_calls_enable_load_extension
701+
mock_database_load_extension_internal(db)
702+
class << db
703+
attr_accessor :enable_load_extension_called
704+
attr_reader :enable_load_extension_arg
705+
706+
def reset_test
707+
@enable_load_extension_called = 0
708+
@enable_load_extension_arg = []
709+
end
710+
711+
def enable_load_extension(val)
712+
@enable_load_extension_called += 1
713+
@enable_load_extension_arg << val
714+
end
715+
end
716+
717+
db.reset_test
718+
db.initialize_extensions(nil)
719+
assert_equal(0, db.enable_load_extension_called)
720+
721+
db.reset_test
722+
db.initialize_extensions([])
723+
assert_equal(0, db.enable_load_extension_called)
724+
725+
db.reset_test
726+
db.initialize_extensions(["/path/to/extension"])
727+
assert_equal(2, db.enable_load_extension_called)
728+
assert_equal([true, false], db.enable_load_extension_arg)
729+
730+
db.reset_test
731+
db.initialize_extensions([FakeExtensionSpecifier])
732+
assert_equal(2, db.enable_load_extension_called)
733+
assert_equal([true, false], db.enable_load_extension_arg)
734+
end
735+
736+
def test_initialize_extensions_object_is_an_extension_specifier
737+
mock_database_load_extension_internal(db)
738+
739+
db.initialize_extensions([Pathname.new("/path/to/extension")])
740+
assert_equal(["/path/to/extension"], db.load_extension_internal_path)
741+
742+
db.load_extension_internal_path.clear # reset
743+
744+
db.initialize_extensions([FakeExtensionSpecifier])
745+
assert_equal(["/path/to/extension"], db.load_extension_internal_path)
746+
end
747+
748+
def test_initialize_extensions_object_not_an_extension_specifier
749+
mock_database_load_extension_internal(db)
750+
751+
db.initialize_extensions(["/path/to/extension"])
752+
assert_equal(["/path/to/extension"], db.load_extension_internal_path)
753+
754+
assert_raises(TypeError) { db.initialize_extensions([Class.new]) }
755+
756+
assert_raises(TypeError) { db.initialize_extensions(FakeExtensionSpecifier) }
757+
end
758+
759+
def test_initialize_with_extensions_calls_initialize_extensions
760+
# ephemeral class to capture arguments passed to initialize_extensions
761+
klass = Class.new(SQLite3::Database) do
762+
attr :initialize_extensions_called, :initialize_extensions_arg
763+
764+
def initialize_extensions(extensions)
765+
@initialize_extensions_called = true
766+
@initialize_extensions_arg = extensions
767+
end
768+
end
769+
770+
db = klass.new(":memory:")
771+
assert(db.initialize_extensions_called)
772+
assert_nil(db.initialize_extensions_arg)
773+
774+
db = klass.new(":memory:", extensions: [])
775+
assert(db.initialize_extensions_called)
776+
assert_empty(db.initialize_extensions_arg)
777+
778+
db = klass.new(":memory:", extensions: ["path/to/ext1", "path/to/ext2", FakeExtensionSpecifier])
779+
assert(db.initialize_extensions_called)
780+
assert_equal(["path/to/ext1", "path/to/ext2", FakeExtensionSpecifier], db.initialize_extensions_arg)
663781
end
664782

665783
def test_raw_float_infinity

0 commit comments

Comments
 (0)