Setting SQLite3 PRAGMA's automatically with crystal-sqlite3
January 14, 2023 -For a project in Crystal using SQLite3 (crystal-sqlite3
), I wanted to
automate setting PRAGMAs on the database connection without having to
leverage on DB#setup_connection
and hardcoding values in
my code.
So, instead of doing this:
require "sqlite3"
DB.open "sqlite3:file.db" do |db|
db.setup_connection do |cnn|
cnn.exec "PRAGMA busy_timeout=1000"
end
# ... your code ...
end
I could do something like this:
DB.open "sqlite3:file.db?_busy_timeout=1000" do |db|
# ... your code ...
end
Even better, I can move the entire DB connection string to an ENV variable!
DB.open ENV["DATABASE_URL"] do |db|
# success!
end
▶️ Similar to previous TIL, I made a recording of this initial prototype here.
👨💻 Also submitted a PR #85 to crystal-sqlite3 for discussion! 😊
1. Write some specs/examples of the desired functionality
I planned to introduce multiple PRAGMAs, so wrote a helper method:
private def it_sets_pragma_on_connection(pragma : String, option : String, value : String, expected, file = __FILE__, line = __LINE__)
it "sets pragma '#{pragma}' to #{expected} using '#{option}'", file, line do
with_db("#{DB_FILENAME}?#{option}=#{value}") do |db|
db.scalar("PRAGMA #{pragma}").should eq(expected)
end
end
end
This allow to write the specific PRAGMA specs as one-liner:
it_sets_pragma_on_connection "busy_timeout", "_busy_timeout", "1000", 1000
it_sets_pragma_on_connection "cache_size", "_cache_size", "-4000", -4000
it_sets_pragma_on_connection "foreign_keys", "_foreign_keys", "1", 1
2. Ensure each connection understand those PRAGMAs
When doing DB.connect
, a new SQLite3 connection will be instantiated and
following SQLite3 rules about PRAGMAs, some are per-connection, meaning
that once the connection is created, those PRAGMAs must be set.
We could process the options given in the URI in SQLite3::Connection
:
class SQLite3::Connection < DB::Connection
def initialize(database)
super
filename = self.class.filename(database.uri)
check LibSQLite3.open_v2(filename, out @db, (Flag::READWRITE | Flag::CREATE), nil)
# ...
end
# ...
end
From there we know that the URI is available to us via database.uri
, so we
can process these PRAGMA options! 😊
private def process_query_params(uri : URI)
pragmas = Hash(String, String).new
URI::Params.parse(query) do |key, value|
case key
when "_busy_timeout"
pragmas["busy_timeout"] = value
when "_cache_size"
pragmas["cache_size"] = value
when "_foreign_keys"
pragmas["foreign_keys"] = value
when "_journal_mode"
pragmas["journal_mode"] = value
when "_synchronous"
pragmas["synchronous"] = value
when "_wal_autocheckpoint"
pragmas["wal_autocheckpoint"] = value
end
end
end
Notice there is no sanitization to the input value, as SQLite3 will ignore any invalid value, example:
sqlite> PRAGMA busy_timeout;
0
sqlite> PRAGMA busy_timeout=something;
0
sqlite> PRAGMA busy_timeout;
0
The same for other PRAGMAs that expect boolean (yes, no) or specific options.
Also decided to use a Hash to collect all the set pragmas to avoid doing multiple queries but to honor last parameter set (as override).
Setting _busy_timeout=100&_busy_timeout=5000
will result in only one call
to set busy_timeout
to 5000
.
3. Use SQLite3 C API 😬
Now that we have all the collected pragmas, is time to pass those to SQLite3.
We will use SQLite3's exec
, which combines all the stages
needed to process SQL statements in a single call. Plus, it allow us to
combine multiple SQL in a single call (I'm lazy, OK?).
# src/sqlite3/lib_sqlite3.cr
# ...
fun prepare_v2 = sqlite3_prepare_v2(db : SQLite3, zSql : UInt8*, nByte : Int32, ppStmt : Statement*, pzTail : UInt8**) : Int32
+ fun exec = sqlite3_exec(db : SQLite3, zSql : UInt8*, pCallback : Callback, pCallbackArgs : Void*, pzErrMsg : UInt8**) : Int32
And we complete the changes in process_query_params
, after collecting all
PRAGMAs:
# concatenate all into a single SQL string
sql = String.build do |str|
pragmas.each do |key, value|
str << "PRAGMA #{key}=#{value};"
end
end
check LibSQLite3.exec(@db, sql, nil, nil, nil)
4. Test it, and ship it!
With the changes in place, make sure specs are passing (crystal spec
) and
send that over for review! 🎉