diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4155f77d..021eca92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,14 @@ on: - '*' jobs: + ruby_versions: + outputs: + setup_ruby: "['3.1', '3.2', '3.3', 'head']" + image_tag: "['3.1', '3.2', '3.3', '3.4-rc']" + runs-on: ubuntu-latest + steps: + - run: echo "generating rubies ..." + # # basic tests # @@ -45,12 +53,12 @@ jobs: - run: bundle exec rake test test: - needs: basic + needs: [basic, ruby_versions] strategy: fail-fast: false matrix: os: [ubuntu, macos, windows] - ruby: ["3.3", "3.2", "3.1"] + ruby: ${{ fromJSON(needs.ruby_versions.outputs.setup_ruby) }} syslib: [enable, disable] include: # additional compilation flags for homebrew @@ -117,12 +125,12 @@ jobs: bundle exec rake test sqlcipher: - needs: basic + needs: [basic, ruby_versions] strategy: fail-fast: false matrix: os: [ubuntu, macos, windows] - ruby: ["3.3", "3.1"] # oldest and newest + ruby: ${{ fromJSON(needs.ruby_versions.outputs.setup_ruby) }} include: - { os: windows, ruby: mingw } - { os: windows, ruby: mswin } @@ -207,13 +215,13 @@ jobs: retention-days: 1 install_source_linux: - needs: build_source_gem + needs: [build_source_gem, ruby_versions] name: "test source" strategy: fail-fast: false matrix: os: [ubuntu, macos, windows] - ruby: ["3.3", "3.2", "3.1"] + ruby: ${{ fromJSON(needs.ruby_versions.outputs.setup_ruby) }} syslib: [enable, disable] include: # additional compilation flags for homebrew @@ -270,7 +278,7 @@ jobs: test_architecture_matrix: name: "${{ matrix.platform }} ${{ matrix.ruby }}" - needs: build_native_gem + needs: [build_native_gem, ruby_versions] strategy: fail-fast: false matrix: @@ -283,7 +291,7 @@ jobs: - x86-linux-musl - x86_64-linux-gnu - x86_64-linux-musl - ruby: ["3.3", "3.2", "3.1"] + ruby: ${{ fromJSON(needs.ruby_versions.outputs.image_tag) }} include: # declare docker image for each platform - { platform: aarch64-linux-musl, docker_tag: "-alpine", bootstrap: "apk add build-base &&" } @@ -310,31 +318,23 @@ jobs: ${{ matrix.docker_platform}} ruby:${{ matrix.ruby }}${{ matrix.docker_tag }} \ sh -c " ${{ matrix.bootstrap }} - gem update --system && ./bin/test-gem-install ./gems " test_the_rest: name: "${{ matrix.platform }} ${{ matrix.ruby }}" - needs: build_native_gem + needs: [build_native_gem, ruby_versions] strategy: fail-fast: false matrix: os: [windows-latest, macos-13, macos-14] - ruby: ["3.3", "3.2", "3.1"] + ruby: ${{ fromJSON(needs.ruby_versions.outputs.setup_ruby) }} include: - os: macos-13 platform: x86_64-darwin - os: macos-14 platform: arm64-darwin - os: windows-latest - ruby: "3.1" - platform: x64-mingw-ucrt - - os: windows-latest - ruby: "3.2" - platform: x64-mingw-ucrt - - os: windows-latest - ruby: "3.3" platform: x64-mingw-ucrt runs-on: ${{ matrix.os }} steps: @@ -361,6 +361,7 @@ jobs: - { ruby: "3.2", flavor: "alpine3.19" } - { ruby: "3.3", flavor: "alpine3.18" } - { ruby: "3.3", flavor: "alpine3.19" } + - { ruby: "3.4-rc", flavor: "alpine" } runs-on: ubuntu-latest container: image: ruby:${{matrix.ruby}}-${{matrix.flavor}} diff --git a/.github/workflows/rdoc.yml b/.github/workflows/rdoc.yml new file mode 100644 index 00000000..ee6a8acc --- /dev/null +++ b/.github/workflows/rdoc.yml @@ -0,0 +1,37 @@ +# Simple workflow for deploying static content to GitHub Pages +name: rdocs + +on: + workflow_dispatch: + push: + tags: + - v*.*.* + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + - run: bundle exec rdoc + - uses: actions/upload-pages-artifact@v3 + with: + path: 'doc' + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.rdoc_options b/.rdoc_options index 768e351e..0d2ded97 100644 --- a/.rdoc_options +++ b/.rdoc_options @@ -14,6 +14,10 @@ exclude: - "bin" - "rakelib" - "ext/sqlite3/extconf.rb" +- "vendor" +- "ports" +- "tmp" +- "pkg" hyperlink_all: false line_numbers: false locale: diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ca0ad6..50971f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,86 @@ # sqlite3-ruby Changelog +## 2.5.0 / 2024-12-25 + +### Ruby + +This release introduces native gem packages that include Ruby 3.4. + + +## 2.4.1 / 2024-12-08 + +### Dependencies + +- Vendored sqlite is updated to [v3.47.2](https://sqlite.org/releaselog/3_47.2.html) #593 @flavorjones + + The description from the upstream maintainers is: + + > SQLite version 3.47.2, now available, fixes an important bug that first appeared in the 3.47.0 + > release. In SQLite versions 3.47.0 and 3.47.1, if you try to convert a string into a + > floating-point value and the first 16 significant digits of the value are exactly + > "1844674407370955", then the floating-point number generated might be incorrect. The problem + > only affects x64 and i386 CPUs, so it does not affect you if you are running on ARM. And it only + > affects releases 3.47.0 and 3.47.1. **If you are running SQLite versions 3.47.0 or 3.47.1, then + > upgrading is recommended.** + + Saving you a click, you should upgrade if you're running sqlite3-ruby v2.1.1 or later. + + +### Fixed + +- Prevent unnecessary "Invalid Reference" warnings from the `ForkSafety` module when GC runs during the "after fork" hook. #592 @flavorjones + + +## 2.4.0 / 2024-12-03 + +### Added + +- `Database#load_extension` now accepts any object that responds to `#to_path`, in addition to String filesystem paths. [#586] @flavorjones +- `Database.new` now accepts an `extensions:` parameter, which is an array of SQLite extensions that will be loaded during initialization. The array may contain String filesystem paths and objects that respond to `#to_path`. [#586] @flavorjones + + +## 2.3.1 / 2024-11-25 + +### Dependencies + +- Vendored sqlite is updated to [v3.47.1](https://sqlite.org/releaselog/3_47_1.html) [#589] @flavorjones + + +## 2.3.0 / 2024-11-20 + +### Added + +- The SQLITE_DBPAGE extension is now enabled by default, which implements an eponymous-only virtual table that provides direct access to the underlying database file by interacting with the pager. See https://www.sqlite.org/dbpage.html for more information. [#578] @flavorjones +- The DBSTAT extension is now enabled by default, which implements a read-only eponymous virtual table that returns information about the amount of disk space used to store the content of an SQLite database. See https://sqlite.org/dbstat.html for more information. [#580] @pawurb @flavorjones +- `Database#optimize` which wraps the `pragma optimize;` statement. Also added `Constants::Optimize` to allow advanced users to pass a bitmask of options. See https://www.sqlite.org/pragma.html#pragma_optimize. [#572] @alexcwatt @flavorjones +- `SQLite3::VERSION_INFO` is contains a bag of metadata about the gem and the sqlite library used. `SQLite3::SQLITE_PACKAGED_LIBRARIES` and `SQLite3::SQLITE_PRECOMPILED_LIBRARIES` are indicate how the gem was built. [#581] @flavorjones + + +### Fixed + +- `Database#encoding=` support for switching the database encoding to `UTF-16BE`, which has been broken since `Database#encoding=` was introduced in v1.3.12 in 2016. [#575] @miyucy +- Omit mention of the `pkg-config` gem when failing to build from source, since it is not used. [#358] @flavorjones + + +## 2.2.0 / 2024-10-30 + +### Added + +- URI filenames are now allowed. This allows the injection of some behavior via recognized query parameters. See https://www.sqlite.org/uri.html for more information. [#571] @flavorjones + + +### Improved + +- SQL Syntax errors during `Database#prepare` will raise a verbose exception with a multiline message indicating with a "^" exactly where in the statement the error occurred. [#554] @fractaledmind @flavorjones + + +## 2.1.1 / 2024-10-22 + +### Dependencies + +- Vendored sqlite is updated to [v3.47.0](https://sqlite.org/releaselog/3_47_0.html) [#570] @flavorjones + + ## 2.1.0 / 2024-09-24 ### Ruby diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cccea932..bb2172a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,12 +45,16 @@ Update `/dependencies.yml` to reflect: ## Making a release -A quick checklist: +A quick checklist to cutting a release of the sqlite3 gem: - [ ] make sure CI is green! -- [ ] update `CHANGELOG.md` and `lib/sqlite3/version.rb` -- [ ] run `bin/build-gems` and make sure it completes and all the tests pass -- [ ] create a git tag using a format that matches the pattern `v\d+\.\d+\.\d+`, e.g. `v1.3.13` -- [ ] `git push && git push --tags` -- [ ] `for g in gems/*.gem ; do gem push $g ; done` -- [ ] create a release at https://github.com/sparklemotion/sqlite3-ruby/releases and include sha2 checksums +- bump the version + - [ ] update `CHANGELOG.md` and `lib/sqlite3/version.rb` + - [ ] create a git tag using a format that matches the pattern `v\d+\.\d+\.\d+`, e.g. `v1.3.13` +- build the native gems + - [ ] run `bin/build-gems` and make sure it completes and all the tests pass +- push + - [ ] `git push && git push --tags` + - [ ] `for g in gems/*.gem ; do gem push $g ; done` +- announce + - [ ] create a release at https://github.com/sparklemotion/sqlite3-ruby/releases and include sha2 checksums diff --git a/Gemfile b/Gemfile index a56fee0b..b4643a9a 100644 --- a/Gemfile +++ b/Gemfile @@ -3,14 +3,14 @@ source "https://rubygems.org" gemspec group :development do - gem "minitest", "5.25.1" + gem "minitest", "5.25.4" - gem "rake-compiler", "1.2.7" - gem "rake-compiler-dock", "1.5.2" + gem "rake-compiler", "1.2.8" + gem "rake-compiler-dock", "1.7.0" gem "ruby_memcheck", "3.0.0" if Gem::Platform.local.os == "linux" - gem "rdoc", "6.7.0" + gem "rdoc", "6.10.0" gem "rubocop", "1.59.0", require: false gem "rubocop-minitest", "0.34.5", require: false diff --git a/README.md b/README.md index b83dcf7e..7b345f8b 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,20 @@ Note that this module is only compatible with SQLite 3.6.16 or newer. * Source code: https://github.com/sparklemotion/sqlite3-ruby * Mailing list: http://groups.google.com/group/sqlite3-ruby * Download: http://rubygems.org/gems/sqlite3 -* Documentation: http://www.rubydoc.info/gems/sqlite3 +* Documentation: https://sparklemotion.github.io/sqlite3-ruby/ [![Test suite](https://github.com/sparklemotion/sqlite3-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/sparklemotion/sqlite3-ruby/actions/workflows/ci.yml) ## Quick start -For help understanding the SQLite3 Ruby API, please read the [FAQ](./FAQ.md) and the [full API documentation](https://rubydoc.info/gems/sqlite3). +For help understanding the SQLite3 Ruby API, please read the [FAQ](./FAQ.md) and the [full API documentation](https://sparklemotion.github.io/sqlite3-ruby/). A few key classes whose APIs are often-used are: -- SQLite3::Database ([rdoc](https://rubydoc.info/gems/sqlite3/SQLite3/Database)) -- SQLite3::Statement ([rdoc](https://rubydoc.info/gems/sqlite3/SQLite3/Statement)) -- SQLite3::ResultSet ([rdoc](https://rubydoc.info/gems/sqlite3/SQLite3/ResultSet)) +- SQLite3::Database ([rdoc](https://sparklemotion.github.io/sqlite3-ruby/SQLite3/Database.html)) +- SQLite3::Statement ([rdoc](https://sparklemotion.github.io/sqlite3-ruby/SQLite3/Statement.html)) +- SQLite3::ResultSet ([rdoc](https://sparklemotion.github.io/sqlite3-ruby/SQLite3/ResultSet.html)) If you have any questions that you feel should be addressed in the FAQ, please send them to [the mailing list](http://groups.google.com/group/sqlite3-ruby) or open a [discussion thread](https://github.com/sparklemotion/sqlite3-ruby/discussions/categories/q-a). diff --git a/bin/build-gems b/bin/build-gems index ed162955..4dfef354 100755 --- a/bin/build-gems +++ b/bin/build-gems @@ -19,7 +19,7 @@ bundle exec rake compile test # package the gems, including precompiled native bundle exec rake clean clobber -bundle exec rake -m gem:all +bundle exec rake gem:all cp -v pkg/sqlite3*.gem gems # test those gem files! diff --git a/bin/test-gem-file-contents b/bin/test-gem-file-contents index 8ae6bea7..52a201fd 100755 --- a/bin/test-gem-file-contents +++ b/bin/test-gem-file-contents @@ -65,7 +65,7 @@ Minitest::Reporters.use!([Minitest::Reporters::SpecReporter.new]) puts "Testing '#{gemfile}' (#{gemspec.platform})" describe File.basename(gemfile) do - let(:supported_ruby_versions) { ["3.1", "3.2", "3.3"] } + let(:supported_ruby_versions) { ["3.1", "3.2", "3.3", "3.4"] } describe "setup" do it "gemfile contains some files" do diff --git a/dependencies.yml b/dependencies.yml index 3b1037ef..78c80e68 100644 --- a/dependencies.yml +++ b/dependencies.yml @@ -1,13 +1,13 @@ sqlite3: # checksum verified by first checking the published sha3(256) checksum against https://sqlite.org/download.html: - # 923f68143dcd9fc0c38778dee253fd6540a91f578173a04ca5adff885d8a8fbb + # 52cd4a2304b627abbabe1a438ba853d0f6edb8e2774fcb5773c7af11077afe94 # - # $ sha3sum -a 256 ports/archives/sqlite-autoconf-3460100.tar.gz - # 923f68143dcd9fc0c38778dee253fd6540a91f578173a04ca5adff885d8a8fbb ports/archives/sqlite-autoconf-3460100.tar.gz + # $ sha3sum -a 256 ports/archives/sqlite-autoconf-3470200.tar.gz + # 52cd4a2304b627abbabe1a438ba853d0f6edb8e2774fcb5773c7af11077afe94 ports/archives/sqlite-autoconf-3470200.tar.gz # - # $ sha256sum ports/archives/sqlite-autoconf-3460100.tar.gz - # 67d3fe6d268e6eaddcae3727fce58fcc8e9c53869bdd07a0c61e38ddf2965071 ports/archives/sqlite-autoconf-3460100.tar.gz - version: "3.46.1" + # $ sha256sum ports/archives/sqlite-autoconf-3470200.tar.gz + # f1b2ee412c28d7472bc95ba996368d6f0cdcf00362affdadb27ed286c179540b ports/archives/sqlite-autoconf-3470200.tar.gz + version: "3.47.2" files: - - url: "https://sqlite.org/2024/sqlite-autoconf-3460100.tar.gz" - sha256: "67d3fe6d268e6eaddcae3727fce58fcc8e9c53869bdd07a0c61e38ddf2965071" + - url: "https://sqlite.org/2024/sqlite-autoconf-3470200.tar.gz" + sha256: "f1b2ee412c28d7472bc95ba996368d6f0cdcf00362affdadb27ed286c179540b" 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/ext/sqlite3/exception.c b/ext/sqlite3/exception.c index 4889fd28..c7686189 100644 --- a/ext/sqlite3/exception.c +++ b/ext/sqlite3/exception.c @@ -1,101 +1,82 @@ #include -void -rb_sqlite3_raise(sqlite3 *db, int status) +static VALUE +status2klass(int status) { - VALUE klass = Qnil; - /* Consider only lower 8 bits, to work correctly when extended result codes are enabled. */ switch (status & 0xff) { case SQLITE_OK: - return; - break; + return Qnil; case SQLITE_ERROR: - klass = rb_path2class("SQLite3::SQLException"); - break; + return rb_path2class("SQLite3::SQLException"); case SQLITE_INTERNAL: - klass = rb_path2class("SQLite3::InternalException"); - break; + return rb_path2class("SQLite3::InternalException"); case SQLITE_PERM: - klass = rb_path2class("SQLite3::PermissionException"); - break; + return rb_path2class("SQLite3::PermissionException"); case SQLITE_ABORT: - klass = rb_path2class("SQLite3::AbortException"); - break; + return rb_path2class("SQLite3::AbortException"); case SQLITE_BUSY: - klass = rb_path2class("SQLite3::BusyException"); - break; + return rb_path2class("SQLite3::BusyException"); case SQLITE_LOCKED: - klass = rb_path2class("SQLite3::LockedException"); - break; + return rb_path2class("SQLite3::LockedException"); case SQLITE_NOMEM: - klass = rb_path2class("SQLite3::MemoryException"); - break; + return rb_path2class("SQLite3::MemoryException"); case SQLITE_READONLY: - klass = rb_path2class("SQLite3::ReadOnlyException"); - break; + return rb_path2class("SQLite3::ReadOnlyException"); case SQLITE_INTERRUPT: - klass = rb_path2class("SQLite3::InterruptException"); - break; + return rb_path2class("SQLite3::InterruptException"); case SQLITE_IOERR: - klass = rb_path2class("SQLite3::IOException"); - break; + return rb_path2class("SQLite3::IOException"); case SQLITE_CORRUPT: - klass = rb_path2class("SQLite3::CorruptException"); - break; + return rb_path2class("SQLite3::CorruptException"); case SQLITE_NOTFOUND: - klass = rb_path2class("SQLite3::NotFoundException"); - break; + return rb_path2class("SQLite3::NotFoundException"); case SQLITE_FULL: - klass = rb_path2class("SQLite3::FullException"); - break; + return rb_path2class("SQLite3::FullException"); case SQLITE_CANTOPEN: - klass = rb_path2class("SQLite3::CantOpenException"); - break; + return rb_path2class("SQLite3::CantOpenException"); case SQLITE_PROTOCOL: - klass = rb_path2class("SQLite3::ProtocolException"); - break; + return rb_path2class("SQLite3::ProtocolException"); case SQLITE_EMPTY: - klass = rb_path2class("SQLite3::EmptyException"); - break; + return rb_path2class("SQLite3::EmptyException"); case SQLITE_SCHEMA: - klass = rb_path2class("SQLite3::SchemaChangedException"); - break; + return rb_path2class("SQLite3::SchemaChangedException"); case SQLITE_TOOBIG: - klass = rb_path2class("SQLite3::TooBigException"); - break; + return rb_path2class("SQLite3::TooBigException"); case SQLITE_CONSTRAINT: - klass = rb_path2class("SQLite3::ConstraintException"); - break; + return rb_path2class("SQLite3::ConstraintException"); case SQLITE_MISMATCH: - klass = rb_path2class("SQLite3::MismatchException"); - break; + return rb_path2class("SQLite3::MismatchException"); case SQLITE_MISUSE: - klass = rb_path2class("SQLite3::MisuseException"); - break; + return rb_path2class("SQLite3::MisuseException"); case SQLITE_NOLFS: - klass = rb_path2class("SQLite3::UnsupportedException"); - break; + return rb_path2class("SQLite3::UnsupportedException"); case SQLITE_AUTH: - klass = rb_path2class("SQLite3::AuthorizationException"); - break; + return rb_path2class("SQLite3::AuthorizationException"); case SQLITE_FORMAT: - klass = rb_path2class("SQLite3::FormatException"); - break; + return rb_path2class("SQLite3::FormatException"); case SQLITE_RANGE: - klass = rb_path2class("SQLite3::RangeException"); - break; + return rb_path2class("SQLite3::RangeException"); case SQLITE_NOTADB: - klass = rb_path2class("SQLite3::NotADatabaseException"); - break; + return rb_path2class("SQLite3::NotADatabaseException"); default: - klass = rb_path2class("SQLite3::Exception"); + return rb_path2class("SQLite3::Exception"); + } +} + +void +rb_sqlite3_raise(sqlite3 *db, int status) +{ + VALUE klass = status2klass(status); + if (NIL_P(klass)) { + return; } - klass = rb_exc_new2(klass, sqlite3_errmsg(db)); - rb_iv_set(klass, "@code", INT2FIX(status)); - rb_exc_raise(klass); + VALUE exception = rb_exc_new2(klass, sqlite3_errmsg(db)); + rb_iv_set(exception, "@code", INT2FIX(status)); + + rb_exc_raise(exception); } /* @@ -104,14 +85,38 @@ rb_sqlite3_raise(sqlite3 *db, int status) void rb_sqlite3_raise_msg(sqlite3 *db, int status, const char *msg) { - VALUE exception; - - if (status == SQLITE_OK) { + VALUE klass = status2klass(status); + if (NIL_P(klass)) { return; } - exception = rb_exc_new2(rb_path2class("SQLite3::Exception"), msg); + VALUE exception = rb_exc_new2(klass, msg); + rb_iv_set(exception, "@code", INT2FIX(status)); sqlite3_free((void *)msg); + + rb_exc_raise(exception); +} + +void +rb_sqlite3_raise_with_sql(sqlite3 *db, int status, const char *sql) +{ + VALUE klass = status2klass(status); + if (NIL_P(klass)) { + return; + } + + const char *error_msg = sqlite3_errmsg(db); + int error_offset = -1; +#ifdef HAVE_SQLITE3_ERROR_OFFSET + error_offset = sqlite3_error_offset(db); +#endif + + VALUE exception = rb_exc_new2(klass, error_msg); rb_iv_set(exception, "@code", INT2FIX(status)); + if (sql) { + rb_iv_set(exception, "@sql", rb_str_new2(sql)); + rb_iv_set(exception, "@sql_offset", INT2FIX(error_offset)); + } + rb_exc_raise(exception); } diff --git a/ext/sqlite3/exception.h b/ext/sqlite3/exception.h index 6cb200cf..f51c80c0 100644 --- a/ext/sqlite3/exception.h +++ b/ext/sqlite3/exception.h @@ -3,8 +3,10 @@ #define CHECK(_db, _status) rb_sqlite3_raise(_db, _status); #define CHECK_MSG(_db, _status, _msg) rb_sqlite3_raise_msg(_db, _status, _msg); +#define CHECK_PREPARE(_db, _status, _sql) rb_sqlite3_raise_with_sql(_db, _status, _sql) void rb_sqlite3_raise(sqlite3 *db, int status); void rb_sqlite3_raise_msg(sqlite3 *db, int status, const char *msg); +void rb_sqlite3_raise_with_sql(sqlite3 *db, int status, const char *sql); #endif diff --git a/ext/sqlite3/extconf.rb b/ext/sqlite3/extconf.rb index 021b3304..90abf910 100644 --- a/ext/sqlite3/extconf.rb +++ b/ext/sqlite3/extconf.rb @@ -50,8 +50,9 @@ def configure_system_libraries def configure_packaged_libraries minimal_recipe.tap do |recipe| recipe.configure_options += [ - "--enable-shared=no", - "--enable-static=yes", + "--disable-shared", + "--enable-static", + "--disable-tcl", "--enable-fts5" ] ENV.to_h.tap do |env| @@ -60,7 +61,10 @@ def configure_packaged_libraries "-fPIC", # needed for linking the static library into a shared library "-O2", # see https://github.com/sparklemotion/sqlite3-ruby/issues/335 for some benchmarks "-fvisibility=hidden", # see https://github.com/rake-compiler/rake-compiler-dock/issues/87 - "-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1" + "-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1", + "-DSQLITE_USE_URI=1", + "-DSQLITE_ENABLE_DBPAGE_VTAB=1", + "-DSQLITE_ENABLE_DBSTAT_VTAB=1" ] env["CFLAGS"] = [user_cflags, env["CFLAGS"], more_cflags].flatten.join(" ") recipe.configure_options += env.select { |k, v| ENV_ALLOWLIST.include?(k) } @@ -93,6 +97,9 @@ def configure_packaged_libraries end ldflags.each { |ldflag| append_ldflags(ldflag) } + + append_cppflags("-DUSING_PACKAGED_LIBRARIES") + append_cppflags("-DUSING_PRECOMPILED_LIBRARIES") if cross_build? end end @@ -132,6 +139,7 @@ def configure_extension have_func("sqlite3_prepare_v2") have_func("sqlite3_db_name", "sqlite3.h") # v3.39.0 + have_func("sqlite3_error_offset", "sqlite3.h") # v3.38.0 have_type("sqlite3_int64", "sqlite3.h") have_type("sqlite3_uint64", "sqlite3.h") @@ -168,7 +176,7 @@ def abort_could_not_find(missing) end def abort_pkg_config(id) - abort("\nCould not configure the build properly (#{id}). Please install either the `pkg-config` utility or the `pkg-config` rubygem.\n\n") + abort("\nCould not configure the build properly (#{id}). Please install the `pkg-config` utility.\n\n") end def cross_build? diff --git a/ext/sqlite3/sqlite3.c b/ext/sqlite3/sqlite3.c index 652c8db5..d30e85d5 100644 --- a/ext/sqlite3/sqlite3.c +++ b/ext/sqlite3/sqlite3.c @@ -201,8 +201,25 @@ Init_sqlite3_native(void) rb_define_singleton_method(mSqlite3, "libversion", libversion, 0); rb_define_singleton_method(mSqlite3, "threadsafe", threadsafe_p, 0); rb_define_singleton_method(mSqlite3, "status", rb_sqlite3_status, -1); + + /* (String) The version of the sqlite3 library compiled with (e.g., "3.46.1") */ rb_define_const(mSqlite3, "SQLITE_VERSION", rb_str_new2(SQLITE_VERSION)); + + /* (Integer) The version of the sqlite3 library compiled with (e.g., 346001) */ rb_define_const(mSqlite3, "SQLITE_VERSION_NUMBER", INT2FIX(SQLITE_VERSION_NUMBER)); + + /* (String) The version of the sqlite3 library loaded at runtime (e.g., "3.46.1") */ rb_define_const(mSqlite3, "SQLITE_LOADED_VERSION", rb_str_new2(sqlite3_libversion())); +#ifdef USING_PACKAGED_LIBRARIES + rb_define_const(mSqlite3, "SQLITE_PACKAGED_LIBRARIES", Qtrue); +#else + rb_define_const(mSqlite3, "SQLITE_PACKAGED_LIBRARIES", Qfalse); +#endif + +#ifdef USING_PRECOMPILED_LIBRARIES + rb_define_const(mSqlite3, "SQLITE_PRECOMPILED_LIBRARIES", Qtrue); +#else + rb_define_const(mSqlite3, "SQLITE_PRECOMPILED_LIBRARIES", Qfalse); +#endif } diff --git a/ext/sqlite3/statement.c b/ext/sqlite3/statement.c index 705b7679..9dedcd2d 100644 --- a/ext/sqlite3/statement.c +++ b/ext/sqlite3/statement.c @@ -78,7 +78,7 @@ prepare(VALUE self, VALUE db, VALUE sql) &tail ); - CHECK(db_ctx->db, status); + CHECK_PREPARE(db_ctx->db, status, StringValuePtr(sql)); timespecclear(&db_ctx->stmt_deadline); return rb_utf8_str_new_cstr(tail); diff --git a/lib/sqlite3.rb b/lib/sqlite3.rb index 790fd754..93caef14 100644 --- a/lib/sqlite3.rb +++ b/lib/sqlite3.rb @@ -15,3 +15,5 @@ def self.threadsafe? threadsafe > 0 end end + +require "sqlite3/version_info" diff --git a/lib/sqlite3/constants.rb b/lib/sqlite3/constants.rb index 77e82e19..eae77b7c 100644 --- a/lib/sqlite3/constants.rb +++ b/lib/sqlite3/constants.rb @@ -170,5 +170,29 @@ module Status # This parameter records the number of separate memory allocations currently checked out. MALLOC_COUNT = 9 end + + module Optimize + # Debugging mode. Do not actually perform any optimizations but instead return one line of + # text for each optimization that would have been done. Off by default. + DEBUG = 0x00001 + + # Run ANALYZE on tables that might benefit. On by default. + ANALYZE_TABLES = 0x00002 + + # When running ANALYZE, set a temporary PRAGMA analysis_limit to prevent excess run-time. On + # by default. + LIMIT_ANALYZE = 0x00010 + + # Check the size of all tables, not just tables that have not been recently used, to see if + # any have grown and shrunk significantly and hence might benefit from being re-analyzed. Off + # by default. + CHECK_ALL_TABLES = 0x10000 + + # Useful for adding a bit to the default behavior, for example + # + # db.optimize(Optimize::DEFAULT | Optimize::CHECK_ALL_TABLES) + # + DEFAULT = ANALYZE_TABLES | LIMIT_ANALYZE + end end end diff --git a/lib/sqlite3/database.rb b/lib/sqlite3/database.rb index 1cf9e62e..efa91a95 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,68 @@ 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 + # + # 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. # - # Thread safety: + # You can load extensions in a Rails application by using the +extensions:+ configuration option: + # + # # config/database.yml + # development: + # adapter: sqlite3 + # extensions: + # - .sqlpkg/nalgeon/crypto/crypto.so # a filesystem path + # - <%= SQLean::UUID.to_path %> # or ruby code returning a path # - # 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 +118,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 +179,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 +704,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/errors.rb b/lib/sqlite3/errors.rb index d44936f7..4318e6fd 100644 --- a/lib/sqlite3/errors.rb +++ b/lib/sqlite3/errors.rb @@ -4,6 +4,34 @@ module SQLite3 class Exception < ::StandardError # A convenience for accessing the error code for this exception. attr_reader :code + + # If the error is associated with a SQL query, this is the query + attr_reader :sql + + # If the error is associated with a particular offset in a SQL query, this is the non-negative + # offset. If the offset is not available, this will be -1. + attr_reader :sql_offset + + def message + [super, sql_error].compact.join(":\n") + end + + private def sql_error + return nil unless @sql + return @sql.chomp unless @sql_offset >= 0 + + offset = @sql_offset + sql.lines.flat_map do |line| + if offset >= 0 && line.length > offset + blanks = " " * offset + offset = -1 + [line.chomp, blanks + "^"] + else + offset -= line.length if offset + line.chomp + end + end.join("\n") + end end class SQLException < Exception; end diff --git a/lib/sqlite3/fork_safety.rb b/lib/sqlite3/fork_safety.rb index 69cf6ac3..a1bc175a 100644 --- a/lib/sqlite3/fork_safety.rb +++ b/lib/sqlite3/fork_safety.rb @@ -2,10 +2,10 @@ require "weakref" -# based on Rails's active_support/fork_tracker.rb module SQLite3 + # based on Rails's active_support/fork_tracker.rb module ForkSafety - module CoreExt + module CoreExt # :nodoc: def _fork pid = super if pid == 0 @@ -20,32 +20,36 @@ def _fork @suppress = false class << self - def hook! + def hook! # :nodoc: ::Process.singleton_class.prepend(CoreExt) end - def track(database) + def track(database) # :nodoc: @mutex.synchronize do @databases << WeakRef.new(database) end end - def discard + def discard # :nodoc: warned = @suppress @databases.each do |db| next unless db.weakref_alive? - unless db.closed? || db.readonly? - unless warned - # If you are here, you may want to read - # https://github.com/sparklemotion/sqlite3-ruby/pull/558 - warn("Writable sqlite database connection(s) were inherited from a forked process. " \ - "This is unsafe and the connections are being closed to prevent possible data " \ - "corruption. Please close writable sqlite database connections before forking.", - uplevel: 0) - warned = true + begin + unless db.closed? || db.readonly? + unless warned + # If you are here, you may want to read + # https://github.com/sparklemotion/sqlite3-ruby/pull/558 + warn("Writable sqlite database connection(s) were inherited from a forked process. " \ + "This is unsafe and the connections are being closed to prevent possible data " \ + "corruption. Please close writable sqlite database connections before forking.", + uplevel: 0) + warned = true + end + db.close end - db.close + rescue WeakRef::RefError + # GC may run while this method is executing, and that's OK end end @databases.clear diff --git a/lib/sqlite3/pragmas.rb b/lib/sqlite3/pragmas.rb index 40ff4312..1197f6aa 100644 --- a/lib/sqlite3/pragmas.rb +++ b/lib/sqlite3/pragmas.rb @@ -93,7 +93,7 @@ def set_int_pragma(name, value) LOCKING_MODES = [["normal"], ["exclusive"]] # The list of valid encodings. - ENCODINGS = [["utf-8"], ["utf-16"], ["utf-16le"], ["utf-16be "]] + ENCODINGS = [["utf-8"], ["utf-16"], ["utf-16le"], ["utf-16be"]] # The list of valid WAL checkpoints. WAL_CHECKPOINTS = [["passive"], ["full"], ["restart"], ["truncate"]] @@ -338,6 +338,20 @@ def mmap_size=(size) set_int_pragma "mmap_size", size end + # Attempt to optimize the database. + # + # To customize the optimization options, pass +bitmask+ with a combination + # of the Constants::Optimize masks. + # + # See https://www.sqlite.org/pragma.html#pragma_optimize for more information. + def optimize(bitmask = nil) + if bitmask + set_int_pragma "optimize", bitmask + else + execute("PRAGMA optimize") + end + end + def page_count get_int_pragma "page_count" end diff --git a/lib/sqlite3/version.rb b/lib/sqlite3/version.rb index 21b0c51c..d4676418 100644 --- a/lib/sqlite3/version.rb +++ b/lib/sqlite3/version.rb @@ -1,3 +1,4 @@ module SQLite3 - VERSION = "2.1.0" + # (String) the version of the sqlite3 gem, e.g. "2.1.1" + VERSION = "2.5.0" end diff --git a/lib/sqlite3/version_info.rb b/lib/sqlite3/version_info.rb new file mode 100644 index 00000000..cf869528 --- /dev/null +++ b/lib/sqlite3/version_info.rb @@ -0,0 +1,17 @@ +module SQLite3 + # a hash of descriptive metadata about the current version of the sqlite3 gem + VERSION_INFO = { + ruby: RUBY_DESCRIPTION, + gem: { + version: SQLite3::VERSION + }, + sqlite: { + compiled: SQLite3::SQLITE_VERSION, + loaded: SQLite3::SQLITE_LOADED_VERSION, + packaged: SQLite3::SQLITE_PACKAGED_LIBRARIES, + precompiled: SQLite3::SQLITE_PRECOMPILED_LIBRARIES, + sqlcipher: SQLite3.sqlcipher?, + threadsafe: SQLite3.threadsafe? + } + } +end diff --git a/rakelib/check-manifest.rake b/rakelib/check-manifest.rake index bb87aa16..65f9bfbe 100644 --- a/rakelib/check-manifest.rake +++ b/rakelib/check-manifest.rake @@ -10,6 +10,7 @@ task :check_manifest do .git .github .ruby-lsp + adr bin doc gems diff --git a/rakelib/native.rake b/rakelib/native.rake index 4d29758e..1895b8d0 100644 --- a/rakelib/native.rake +++ b/rakelib/native.rake @@ -6,19 +6,19 @@ require "rake/extensiontask" require "rake_compiler_dock" require "yaml" -cross_rubies = ["3.3.0", "3.2.0", "3.1.0"] +cross_rubies = ["3.4.0", "3.3.5", "3.2.0", "3.1.0"] cross_platforms = [ "aarch64-linux-gnu", "aarch64-linux-musl", "arm-linux-gnu", "arm-linux-musl", - "arm64-darwin", - "x64-mingw-ucrt", "x86-linux-gnu", "x86-linux-musl", - "x86_64-darwin", "x86_64-linux-gnu", - "x86_64-linux-musl" + "x86_64-linux-musl", + "arm64-darwin", + "x86_64-darwin", + "x64-mingw-ucrt" ] ENV["RUBY_CC_VERSION"] = cross_rubies.join(":") diff --git a/sqlite3.gemspec b/sqlite3.gemspec index 8283fbed..4754f624 100644 --- a/sqlite3.gemspec +++ b/sqlite3.gemspec @@ -24,7 +24,7 @@ Gem::Specification.new do |s| s.metadata = { "homepage_uri" => "https://github.com/sparklemotion/sqlite3-ruby", "bug_tracker_uri" => "https://github.com/sparklemotion/sqlite3-ruby/issues", - "documentation_uri" => "https://www.rubydoc.info/gems/sqlite3", + "documentation_uri" => "https://sparklemotion.github.io/sqlite3-ruby/", "changelog_uri" => "https://github.com/sparklemotion/sqlite3-ruby/blob/master/CHANGELOG.md", "source_code_uri" => "https://github.com/sparklemotion/sqlite3-ruby", @@ -67,7 +67,8 @@ Gem::Specification.new do |s| "lib/sqlite3/resultset.rb", "lib/sqlite3/statement.rb", "lib/sqlite3/value.rb", - "lib/sqlite3/version.rb" + "lib/sqlite3/version.rb", + "lib/sqlite3/version_info.rb" ] s.extra_rdoc_files = [ diff --git a/test/helper.rb b/test/helper.rb index 9f159247..39a1b2b9 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -1,11 +1,8 @@ require "sqlite3" require "minitest/autorun" +require "yaml" -puts "info: ruby version: #{RUBY_DESCRIPTION}" -puts "info: gem version: #{SQLite3::VERSION}" -puts "info: sqlite version: #{SQLite3::SQLITE_VERSION}/#{SQLite3::SQLITE_LOADED_VERSION}" -puts "info: sqlcipher?: #{SQLite3.sqlcipher?}" -puts "info: threadsafe?: #{SQLite3.threadsafe?}" +puts SQLite3::VERSION_INFO.to_yaml module SQLite3 class TestCase < Minitest::Test @@ -21,5 +18,9 @@ def i_am_running_in_valgrind # https://stackoverflow.com/questions/365458/how-can-i-detect-if-a-program-is-running-from-within-valgrind/62364698#62364698 ENV["LD_PRELOAD"] =~ /valgrind|vgpreload/ end + + def windows? + ::RUBY_PLATFORM =~ /mingw|mswin/ + end end end diff --git a/test/test_database.rb b/test/test_database.rb index 19723478..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 @@ -721,5 +839,17 @@ def test_transaction_returns_block_result result = @db.transaction { :foo } assert_equal :foo, result end + + def test_sqlite_dbpage_vtab + skip("sqlite_dbpage not supported") unless SQLite3::SQLITE_PACKAGED_LIBRARIES + + assert_nothing_raised { @db.execute("select count(*) from sqlite_dbpage") } + end + + def test_dbstat_table_exists + skip("dbstat not supported") unless SQLite3::SQLITE_PACKAGED_LIBRARIES + + assert_nothing_raised { @db.execute("select * from dbstat") } + end end end diff --git a/test/test_database_uri.rb b/test/test_database_uri.rb new file mode 100644 index 00000000..72beda99 --- /dev/null +++ b/test/test_database_uri.rb @@ -0,0 +1,47 @@ +require "helper" +require "tempfile" +require "pathname" + +module SQLite3 + class TestDatabaseURI < SQLite3::TestCase + def test_open_absolute_file_uri + skip("windows uri paths are hard") if windows? + skip("sqlcipher may not allow URIs") if SQLite3.sqlcipher? + + Tempfile.open "test.db" do |file| + db = SQLite3::Database.new("file:#{file.path}") + assert db + db.close + end + end + + def test_open_relative_file_uri + skip("windows uri paths are hard") if windows? + skip("sqlcipher may not allow URIs") if SQLite3.sqlcipher? + + Dir.mktmpdir do |dir| + Dir.chdir dir do + db = SQLite3::Database.new("file:test.db") + assert db + assert_path_exists "test.db" + db.close + end + end + end + + def test_open_file_uri_readonly + skip("windows uri paths are hard") if windows? + skip("sqlcipher may not allow URIs") if SQLite3.sqlcipher? + + Tempfile.open "test.db" do |file| + db = SQLite3::Database.new("file:#{file.path}?mode=ro") + + assert_raise(SQLite3::ReadOnlyException) do + db.execute("CREATE TABLE foos (id integer)") + end + + db.close + end + end + end +end diff --git a/test/test_integration.rb b/test/test_integration.rb index f0c005ab..4e0706da 100644 --- a/test/test_integration.rb +++ b/test/test_integration.rb @@ -107,6 +107,70 @@ def test_prepare_invalid_syntax end end + def test_prepare_exception_shows_error_position + exception = assert_raise(SQLite3::SQLException) do + @db.prepare "select from foo" + end + if exception.sql_offset >= 0 # HAVE_SQLITE_ERROR_OFFSET + assert_equal(<<~MSG.chomp, exception.message) + near "from": syntax error: + select from foo + ^ + MSG + else + assert_equal(<<~MSG.chomp, exception.message) + near "from": syntax error: + select from foo + MSG + end + end + + def test_prepare_exception_shows_error_position_newline1 + exception = assert_raise(SQLite3::SQLException) do + @db.prepare(<<~SQL) + select + from foo + SQL + end + if exception.sql_offset >= 0 # HAVE_SQLITE_ERROR_OFFSET + assert_equal(<<~MSG.chomp, exception.message) + near "from": syntax error: + select + from foo + ^ + MSG + else + assert_equal(<<~MSG.chomp, exception.message) + near "from": syntax error: + select + from foo + MSG + end + end + + def test_prepare_exception_shows_error_position_newline2 + exception = assert_raise(SQLite3::SQLException) do + @db.prepare(<<~SQL) + select asdf + from foo + SQL + end + if exception.sql_offset >= 0 # HAVE_SQLITE_ERROR_OFFSET + assert_equal(<<~MSG.chomp, exception.message) + no such column: asdf: + select asdf + ^ + from foo + MSG + else + assert_equal(<<~MSG.chomp, exception.message) + no such column: asdf: + select asdf + from foo + MSG + end + end + def test_prepare_invalid_column assert_raise(SQLite3::SQLException) do @db.prepare "select k from foo" diff --git a/test/test_pragmas.rb b/test/test_pragmas.rb index d09a78c5..2b42debb 100644 --- a/test/test_pragmas.rb +++ b/test/test_pragmas.rb @@ -2,9 +2,27 @@ module SQLite3 class TestPragmas < SQLite3::TestCase + class DatabaseTracker < SQLite3::Database + attr_reader :test_statements + + def initialize(...) + @test_statements = [] + super + end + + def execute(sql, bind_vars = [], &block) + @test_statements << sql + super + end + end + def setup super - @db = SQLite3::Database.new(":memory:") + @db = DatabaseTracker.new(":memory:") + end + + def teardown + @db.close end def test_pragma_errors @@ -32,5 +50,76 @@ def test_set_boolean_pragma ensure @db.set_boolean_pragma("read_uncommitted", 0) end + + def test_optimize_with_no_args + @db.optimize + + assert_equal(["PRAGMA optimize"], @db.test_statements) + end + + def test_optimize_with_args + @db.optimize(Constants::Optimize::DEFAULT) + @db.optimize(Constants::Optimize::ANALYZE_TABLES | Constants::Optimize::LIMIT_ANALYZE) + @db.optimize(Constants::Optimize::ANALYZE_TABLES | Constants::Optimize::DEBUG) + @db.optimize(Constants::Optimize::DEFAULT | Constants::Optimize::CHECK_ALL_TABLES) + + assert_equal( + [ + "PRAGMA optimize=18", + "PRAGMA optimize=18", + "PRAGMA optimize=3", + "PRAGMA optimize=65554" + ], + @db.test_statements + ) + end + + def test_encoding_uppercase + assert_equal(Encoding::UTF_8, @db.encoding) + + @db.encoding = "UTF-16" + assert_equal(Encoding::UTF_16LE, @db.encoding) + + @db.encoding = "UTF-16LE" + assert_equal(Encoding::UTF_16LE, @db.encoding) + + @db.encoding = "UTF-16BE" + assert_equal(Encoding::UTF_16BE, @db.encoding) + + @db.encoding = "UTF-8" + assert_equal(Encoding::UTF_8, @db.encoding) + end + + def test_encoding_lowercase + assert_equal(Encoding::UTF_8, @db.encoding) + + @db.encoding = "utf-16" + assert_equal(Encoding::UTF_16LE, @db.encoding) + + @db.encoding = "utf-16le" + assert_equal(Encoding::UTF_16LE, @db.encoding) + + @db.encoding = "utf-16be" + assert_equal(Encoding::UTF_16BE, @db.encoding) + + @db.encoding = "utf-8" + assert_equal(Encoding::UTF_8, @db.encoding) + end + + def test_encoding_objects + assert_equal(Encoding::UTF_8, @db.encoding) + + @db.encoding = Encoding::UTF_16 + assert_equal(Encoding::UTF_16LE, @db.encoding) + + @db.encoding = Encoding::UTF_16LE + assert_equal(Encoding::UTF_16LE, @db.encoding) + + @db.encoding = Encoding::UTF_16BE + assert_equal(Encoding::UTF_16BE, @db.encoding) + + @db.encoding = Encoding::UTF_8 + assert_equal(Encoding::UTF_8, @db.encoding) + end end end