diff --git a/.rdoc_options b/.rdoc_options index 1f489341..0d2ded97 100644 --- a/.rdoc_options +++ b/.rdoc_options @@ -17,6 +17,7 @@ exclude: - "vendor" - "ports" - "tmp" +- "pkg" hyperlink_all: false line_numbers: false locale: diff --git a/ext/sqlite3/database.c b/ext/sqlite3/database.c index 621dc7aa..724c4c2f 100644 --- a/ext/sqlite3/database.c +++ b/ext/sqlite3/database.c @@ -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; @@ -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 diff --git a/lib/sqlite3/database.rb b/lib/sqlite3/database.rb index 1cf9e62e..98759792 100644 --- a/lib/sqlite3/database.rb +++ b/lib/sqlite3/database.rb @@ -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' # @@ -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 @@ -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 READWRITE | CREATE - # - +: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:+ Array[String | _ExtensionSpecifier] 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 @@ -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? @@ -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 diff --git a/lib/sqlite3/version.rb b/lib/sqlite3/version.rb index 20bc7adb..ccecebfa 100644 --- a/lib/sqlite3/version.rb +++ b/lib/sqlite3/version.rb @@ -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 diff --git a/test/test_database.rb b/test/test_database.rb index 27f0479a..15765621 100644 --- a/test/test_database.rb +++ b/test/test_database.rb @@ -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 @@ -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( @@ -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