Skip to content
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve

### Added

- `Hanami::DB::SQLite::Pragmas` — a value class holding a set of SQLite
pragmas with sensible defaults, user overrides, and validation against
`PRAGMA pragma_list`. Exposes `#connect_sqls`, an array of `PRAGMA`
statements suitable for Sequel's per-connection `connect_sqls` option.
`UnknownPragmaError` is raised when an override names a pragma the
linked SQLite doesn't recognise.

### Changed

### Deprecated
Expand Down
1 change: 1 addition & 0 deletions lib/hanami/db/gem_inflector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module DB
class GemInflector < Zeitwerk::GemInflector
def camelize(basename, _abspath)
return "DB" if basename == "db"
return "SQLite" if basename == "sqlite"

super
end
Expand Down
9 changes: 9 additions & 0 deletions lib/hanami/db/sqlite.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module Hanami
module DB
module SQLite
MEMORY_URL = RUBY_ENGINE == "jruby" ? "jdbc:sqlite::memory:" : "sqlite::memory:"
end
end
end
61 changes: 61 additions & 0 deletions lib/hanami/db/sqlite/pragmas.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

module Hanami
module DB
module SQLite
class Pragmas
DEFAULTS = {
foreign_keys: true,
journal_mode: :wal,
synchronous: :normal,
mmap_size: 128 * 1024 * 1024,
journal_size_limit: 64 * 1024 * 1024,
cache_size: 2_000
}.freeze

NAMES_MUTEX = Mutex.new
private_constant :NAMES_MUTEX

# The authoritative set of pragma names SQLite recognises, queried
# via `PRAGMA pragma_list` (here through its table-valued form,
# `pragma_pragma_list`) against a transient in-memory connection
# on first access. Reflects whatever SQLite the runtime has linked,
# so newer pragmas appear automatically without a gem upgrade.
# Memoized at the class level; the in-memory handle is opened at
# most once per class load. The mutex guards concurrent first
# access (e.g. parallel connection warmup at boot).
def self.names
@names || NAMES_MUTEX.synchronize do
@names ||= begin
db = Sequel.connect(MEMORY_URL)
db.fetch("SELECT name FROM pragma_pragma_list").map { |row| row[:name].to_sym }.to_set.freeze
ensure
db&.disconnect
end
end
end

def initialize(overrides: {}, clear_defaults: false)
base = clear_defaults ? {} : DEFAULTS
@resolved = base.merge(overrides.transform_keys(&:to_sym)).freeze
validate_names!
end

def to_h
@resolved
end

def connect_sqls
@resolved.map { |name, value| "PRAGMA #{name} = #{value}" }
end

private

def validate_names!
unknown = @resolved.keys.reject { self.class.names.include?(_1) }
raise UnknownPragmaError.new(unknown) unless unknown.empty?
end
end
end
end
end
22 changes: 22 additions & 0 deletions lib/hanami/db/sqlite/unknown_pragma_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Hanami
module DB
module SQLite
class UnknownPragmaError < StandardError
attr_reader :unknown

def initialize(unknown)
@unknown = unknown.dup.freeze
super(build_message(@unknown))
end

private

def build_message(unknown)
"Unknown SQLite pragma(s): #{unknown.join(", ")}."
end
end
end
end
end
48 changes: 48 additions & 0 deletions spec/integration/sqlite/pragmas_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

require "sequel"
require "tempfile"

RSpec.describe "SQLite Pragmas integration" do
subject(:pragmas) { Hanami::DB::SQLite::Pragmas.new(overrides: overrides) }

let(:overrides) { {synchronous: :full} }
let(:tempfile) { Tempfile.new(["hanami-db-pragmas", ".sqlite"]) }
let(:db) do
Sequel.connect(sqlite_file_database_url(tempfile.path), connect_sqls: pragmas.connect_sqls)
end

after do
db.disconnect
tempfile.close
tempfile.unlink
end

def pragma(name)
db.fetch("PRAGMA #{name}").first&.fetch(name)
end

it "sets foreign_keys" do
expect(pragma(:foreign_keys).to_i).to eq(1)
end

it "sets journal_mode to wal" do
expect(pragma(:journal_mode)).to eq("wal")
end

it "applies user overrides over defaults" do
expect(pragma(:synchronous).to_i).to eq(2) # full = 2
end

it "leaves unrelated defaults applied" do
expect(pragma(:cache_size).to_i).to eq(2_000)
end

it "applies the pragmas to every new pool connection, not just the first" do
# Force a second physical connection to be opened from the pool.
db.pool.hold { |_conn| }
db.pool.hold { |_conn| }

expect(pragma(:journal_mode)).to eq("wal")
end
end
10 changes: 9 additions & 1 deletion spec/support/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@
module Test
module Helpers
def sqlite_memory_database_url
if RUBY_PLATFORM == "java"
if RUBY_ENGINE == "jruby"
"jdbc:sqlite:file::memory:?cache=private"
else
"sqlite:file::memory:?cache=private"
end
end

def sqlite_file_database_url(path)
if RUBY_ENGINE == "jruby"
"jdbc:sqlite:#{path}"
else
"sqlite://#{path}"
end
end
end
end

Expand Down
148 changes: 148 additions & 0 deletions spec/unit/sqlite/pragmas_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# frozen_string_literal: true

RSpec.describe Hanami::DB::SQLite::Pragmas do
subject { described_class.new(**args) }

let(:args) { {} }

describe "DEFAULTS" do
it "is frozen" do
expect(described_class::DEFAULTS).to be_frozen
end

it "is the chosen set of pragmas" do
expect(described_class::DEFAULTS).to eq(
foreign_keys: true,
journal_mode: :wal,
synchronous: :normal,
mmap_size: 128 * 1024 * 1024,
journal_size_limit: 64 * 1024 * 1024,
cache_size: 2_000
)
end
end

describe "#to_h" do
context "with no arguments" do
it "returns the default set" do
expect(subject.to_h).to eq(described_class::DEFAULTS)
end
end

context "with overrides" do
let(:args) { {overrides: {synchronous: "full", cache_size: 4_000}} }

it "overrides matching defaults" do
expect(subject.to_h.fetch(:synchronous)).to eq("full")
end

it "replaces overridden default values" do
expect(subject.to_h.fetch(:cache_size)).to eq(4_000)
end

it "keeps unaffected defaults" do
expect(subject.to_h.fetch(:journal_mode)).to eq(:wal)
end
end

context "with clear_defaults: true" do
let(:args) { {clear_defaults: true, overrides: {foreign_keys: 1}} }

it "drops the entire default set" do
expect(subject.to_h.keys).to eq([:foreign_keys])
end
end

context "with clear_defaults: true and no overrides" do
let(:args) { {clear_defaults: true} }

it "produces an empty resolved set" do
expect(subject.to_h).to be_empty
end
end

context "with string-keyed overrides" do
let(:args) { {overrides: {"synchronous" => "full"}} }

it "normalizes the key to a symbol" do
expect(subject.to_h.fetch(:synchronous)).to eq("full")
end

it "does not leak the string key into the resolved set" do
expect(subject.to_h.keys).to all(be_a(Symbol))
end
end
end

describe "validation" do
context "with an unknown override pragma name" do
let(:args) { {overrides: {frobnicate: 1}} }

it "raises UnknownPragmaError" do
expect { subject }.to raise_error(
Hanami::DB::SQLite::UnknownPragmaError,
/frobnicate/
)
end

it "lists every unknown name when multiple are given" do
multi_args = {overrides: {frobnicate: 1, wibble: 2}}
expect { described_class.new(**multi_args) }.to raise_error(
Hanami::DB::SQLite::UnknownPragmaError
) { |e| expect(e.unknown).to contain_exactly(:frobnicate, :wibble) }
end
end

context "with a known override pragma name" do
let(:args) { {overrides: {temp_store: "memory"}} }

it "does not raise" do
expect { subject }.not_to raise_error
end
end

context "with clear_defaults: true and an unknown override" do
let(:args) { {clear_defaults: true, overrides: {frobnicate: 1}} }

it "still raises UnknownPragmaError" do
expect { subject }.to raise_error(
Hanami::DB::SQLite::UnknownPragmaError,
/frobnicate/
)
end
end
end

describe ".names" do
let(:fake_db) { instance_double(Sequel::Database, fetch: [{name: "foreign_keys"}], disconnect: nil) }

before { described_class.instance_variable_set(:@names, nil) }
after { described_class.instance_variable_set(:@names, nil) }

it "opens the :memory: connection only once across many validations" do
allow(Sequel).to receive(:connect).and_return(fake_db)

3.times { described_class.new(clear_defaults: true, overrides: {foreign_keys: 1}) }

expect(Sequel).to have_received(:connect).once
end
end

describe "#connect_sqls" do
let(:args) { {overrides: {synchronous: :full}} }

it "returns one `PRAGMA name = value` statement per resolved pragma" do
expect(subject.connect_sqls).to contain_exactly(
*subject.to_h.map { |name, value| "PRAGMA #{name} = #{value}" }
)
end

it "interpolates overridden values" do
expect(subject.connect_sqls).to include("PRAGMA synchronous = full")
end

it "preserves the resolved insertion order" do
expect(subject.connect_sqls.first).to eq("PRAGMA foreign_keys = true")
end
end
end
36 changes: 36 additions & 0 deletions spec/unit/sqlite/unknown_pragma_error_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

RSpec.describe Hanami::DB::SQLite::UnknownPragmaError do
subject { described_class.new(unknown_pragmas) }

let(:unknown_pragmas) { [:frobnicate, :wibble] }

it "is a StandardError" do
expect(subject).to be_a(StandardError)
end

it "lists the unknown pragma names in the message" do
expect(subject.message).to include("frobnicate")
expect(subject.message).to include("wibble")
end

it "exposes the unknown pragma names" do
expect(subject.unknown).to eq([:frobnicate, :wibble])
end

it "exposes a frozen copy of the unknown pragma names" do
expect(subject.unknown).to be_frozen
end

context "with a single unknown pragma" do
subject { described_class.new([:frobnicate]) }

it "formats the name plainly in the message" do
expect(subject.message).to include("frobnicate")
end

it "does not render the names as an inspected array" do
expect(subject.message).not_to include("[:frobnicate]")
end
end
end