Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Dec 3, 2024

Conversation

flavorjones
Copy link
Member

@flavorjones flavorjones commented Nov 24, 2024

Using sqlite extensions in a Rails app is currently a bit tricky.

Assuming you can figure out how to compile it, where do you put the extension on disk? If it's provided by a gem, how do you get a reliable path that works robustly across environments and upgrades?

Do you monkeypatch the sqlite3 adapter to call #load_extension, or do you explicitly inject the extension yourself before any query that needs it?

Using sqlite extensions shouldn't be this hard.

The goal of this PR is to enable injection of sqlite extensions by specifying the name of a Ruby constant in a Rails database config:

development:
  adapter: sqlite3
  database: storage/development.sqlite3
  extensions:
    - SQLean::Crypto

0. Establishing a packaging convention

Over this past weekend, I packaged up the https://github.com/nalgeon/sqlean/ extensions as a Ruby gem: https://github.com/flavorjones/sqlean-ruby

The gem offers a set of modules that simply return the filesystem path of the extension files. Each module implements this interface, which I'll call _ExtensionSpecifier:

interface _ExtensionSpecifier
  def to_path: () → String
end

So, for example, on my machine:

SQLean::Crypto.to_path
=> "/home/flavorjones/.../ruby/3.3.6/lib/ruby/gems/3.3.0/gems/sqlean-0.2.0-x86_64-linux-gnu/lib/sqlean/dist/x86_64-linux-gnu/crypto"

which means I can load the extension like so:

db = SQLite3::Database.new(":memory:")
db.enable_load_extension(true)
db.load_extension(SQLean::Crypto.to_path)

I think this is a reasonable self-describing pattern for an extensions gem to offer. Assuming a future world where other people package up their extensions like this ...

1. Database#load_extension contract

Database#load_extension now supports _ExtensionSpecifier in addition to filesystem paths.

When passed an object that responds to #to_path, that method's return value is used as the filesystem path for the extension, allowing a terser calling convention than above:

db.load_extension(SQLean::Crypto)

2. Database#new supports extensions:

Database#new supports an extensions: keyword argument which accepts:

  • String filesystem path
  • _ExtensionSpecifier object

3. Future work

I'll open a PR with Rails to constantize class names that appear in config/database.yml so they're then passed through to the gem. That should unlock usage like:

development:
  adapter: sqlite3
  database: storage/development.sqlite3
  extensions:
    - SQLean::Crypto

@flavorjones flavorjones force-pushed the flavorjones-sqlite-extension-contract branch from 3fd1cc5 to ceb76cc Compare November 24, 2024 19:39
@flavorjones flavorjones changed the title feat: Database#load_extension supports an extension specifier feat: easier and more flexible loading of sqlite extensions Nov 25, 2024
@flavorjones flavorjones force-pushed the flavorjones-sqlite-extension-contract branch 3 times, most recently from c7d625a to 175c05e Compare November 25, 2024 21:49
@tenderlove
Copy link
Member

When passed an object that responds to #sqlite_extension_path, that method's return value is used as the filesystem path for the extension, allowing a terser calling convention than above:

What about using the method #to_path instead? Then it could work with these modules or a Path object?

  • the name of a constant that is an _ExtensionSpecifier

Unsure if I like this. I don't know if I like that SQLite gets in to the business of knowing how to constantize things. I feel this should be a responsibility of the Rails SQLite adapter layer rather than the SQLite gem itself.

I could see something like:

Database.new(":memory:", extensions: [SQLean::Crypto])

Is there a particular use case where we would want to support strings in the extensions array outside of Rails?

@flavorjones
Copy link
Member Author

@tenderlove Thanks for the feedback!

What about using the method #to_path instead? Then it could work with these modules or a Path object?

Oh, that's a better idea. I'll switch it up.

I feel this should be a responsibility of the Rails SQLite adapter layer rather than the SQLite gem itself.

I don't disagree? But the Rails sqlite adapter is currently just shoveling the ActiveRecord::DatabaseConfigurations::HashConfig hash to SQLite3::Database.new with very little modification or sanity-checking. I actually started adding the const_get code to the sqlite adapter and it felt weird treating this one value so differently from the rest of the config.

Doing it in Rails also limits the use of SQLite extensions to Rails 8.1+, which might be the best reason to do it in this gem.

Is there a particular use case where we would want to support strings in the extensions array outside of Rails?

Not that I know of, beyond perhaps other web frameworks with the same problem space.

@flavorjones
Copy link
Member Author

Updated to use #to_path instead of #sqlite_extension_path.

First change: Database#load_extension supports an extension
specifier, which is anything that responds to `#to_path` like Pathname
or the SQLean gem modules.

When passed an object that responds to `#to_path`, that method's
return value is used as the filesystem path for the extension,
allowing a calling convention like:

    db.load_extension(SQLean::VSV)

where prior to this change that would need to be the more verbose:

    db.load_extension(SQLean::VSV.to_path)

Second change: Database.new supports `extensions:` option parameter,
which is an array of paths or extension specifiers to be loaded.

This commit updates the Database documentation with lots of examples
and explanation, but to sum up:

   Database.new(":memory:", extensions: [SQLean::Crypto, "/path/to/extension"])

My hope is that we can now make a very small change to Rails to allow
injection of database extensions via the config/database.yml file
using this constructor param.
@flavorjones flavorjones force-pushed the flavorjones-sqlite-extension-contract branch from f73cafc to 41e20fa Compare December 3, 2024 19:59
@flavorjones
Copy link
Member Author

flavorjones commented Dec 3, 2024

@tenderlove Thanks again for the feedback. I've removed constantization from this PR since I can point people at https://fractaledmind.github.io/2023/12/24/enhancing-rails-installing-extensions/ for an explanation of how to load extensions in an existing Rails app.

I'll submit a PR to Rails for constantizing the database config extension strings.

@flavorjones flavorjones merged commit cdcadf8 into main Dec 3, 2024
105 checks passed
@flavorjones flavorjones deleted the flavorjones-sqlite-extension-contract branch December 3, 2024 21:56
flavorjones added a commit to flavorjones/rails that referenced this pull request Dec 4, 2024
The `sqlite3` gem v2.4.0 introduces support for loading extensions
that are passed to the Database constructor. This feature leverages
that feature to allow configuration of extensions using either
filesystem paths or the names of modules that respond to `.to_path`.

This commit documents the feature in both SQLite3Adapter rdoc and the
"Configuring" guide. It also extends and improves the documentation
around general SQLite3Adapter configuration.

See sparklemotion/sqlite3-ruby#586 for more information.
flavorjones added a commit to flavorjones/rails that referenced this pull request Dec 4, 2024
The `sqlite3` gem v2.4.0 introduces support for loading extensions
that are passed to the Database constructor. This feature leverages
that feature to allow configuration of extensions using either
filesystem paths or the names of modules that respond to `.to_path`.

This commit documents the feature in both SQLite3Adapter rdoc and the
"Configuring" guide. It also extends and improves the documentation
around general SQLite3Adapter configuration.

See sparklemotion/sqlite3-ruby#586 for more information.
flavorjones added a commit to flavorjones/rails that referenced this pull request Dec 4, 2024
The `sqlite3` gem v2.4.0 introduces support for loading extensions
that are passed to the Database constructor. This feature leverages
that feature to allow configuration of extensions using either
filesystem paths or the names of modules that respond to `.to_path`.

This commit documents the feature in both SQLite3Adapter rdoc and the
"Configuring" guide. It also extends and improves the documentation
around general SQLite3Adapter configuration.

See sparklemotion/sqlite3-ruby#586 for more information.
flavorjones added a commit to flavorjones/rails that referenced this pull request Dec 4, 2024
The `sqlite3` gem v2.4.0 introduces support for loading extensions
that are passed to the Database constructor. This feature leverages
that feature to allow configuration of extensions using either
filesystem paths or the names of modules that respond to `.to_path`.

This commit documents the feature in both SQLite3Adapter rdoc and the
"Configuring" guide. It also extends and improves the documentation
around general SQLite3Adapter configuration.

See sparklemotion/sqlite3-ruby#586 for more information.
rossta added a commit to rossta/asg017-sqlite-dist that referenced this pull request Dec 12, 2024
The sqlite3-ruby recently published an improvement to make it easier and
more flexible to load extensions:

sparklemotion/sqlite3-ruby#586

Ruby modules for SQLite extensions should implement the interface:

    interface _ExtensionSpecifier
      def to_path: () → String
    end

A complementary change in Rails takes advantage of this interface to
integrate the primary configuration file with the new sqlite3-ruby
interface for extension loading:

rails/rails#53827

The gem template provided here already has a similar method:

    def loadable_path

The change proposed here is to modify the gem template to provide an
alias to loadable_path as to_path.o

    # example
    SqliteVec.to_path # => returns same result as loadable_path

As a result of this change, Ruby gems published with this tool will
conform to the new interface supported in sqlite3-ruby and Rails.
rossta added a commit to rossta/asg017-sqlite-dist that referenced this pull request Dec 12, 2024
The sqlite3-ruby recently published an improvement to make it easier and
more flexible to load extensions:

sparklemotion/sqlite3-ruby#586

Ruby modules for SQLite extensions should implement the interface:

    interface _ExtensionSpecifier
      def to_path: () → String
    end

A complementary change in Rails takes advantage of this interface to
integrate the primary configuration file with the new sqlite3-ruby
interface for extension loading:

rails/rails#53827

The gem template provided here already has a similar method:

    def loadable_path

The change proposed here is to modify the gem template to provide an
alias to loadable_path as to_path.o

    # example
    SqliteVec.to_path # => returns same result as loadable_path

As a result of this change, Ruby gems published with this tool will
conform to the new interface supported in sqlite3-ruby and Rails.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants