diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b68bd6..d8a1f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/hanami/db/gem_inflector.rb b/lib/hanami/db/gem_inflector.rb index 733f0d4..d949980 100644 --- a/lib/hanami/db/gem_inflector.rb +++ b/lib/hanami/db/gem_inflector.rb @@ -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 diff --git a/lib/hanami/db/sqlite.rb b/lib/hanami/db/sqlite.rb new file mode 100644 index 0000000..69d919c --- /dev/null +++ b/lib/hanami/db/sqlite.rb @@ -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 diff --git a/lib/hanami/db/sqlite/pragmas.rb b/lib/hanami/db/sqlite/pragmas.rb new file mode 100644 index 0000000..251543b --- /dev/null +++ b/lib/hanami/db/sqlite/pragmas.rb @@ -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 diff --git a/lib/hanami/db/sqlite/unknown_pragma_error.rb b/lib/hanami/db/sqlite/unknown_pragma_error.rb new file mode 100644 index 0000000..ca4f50d --- /dev/null +++ b/lib/hanami/db/sqlite/unknown_pragma_error.rb @@ -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 diff --git a/spec/integration/sqlite/pragmas_spec.rb b/spec/integration/sqlite/pragmas_spec.rb new file mode 100644 index 0000000..7badd9d --- /dev/null +++ b/spec/integration/sqlite/pragmas_spec.rb @@ -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 diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb index 779f53c..4b8017f 100644 --- a/spec/support/helpers.rb +++ b/spec/support/helpers.rb @@ -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 diff --git a/spec/unit/sqlite/pragmas_spec.rb b/spec/unit/sqlite/pragmas_spec.rb new file mode 100644 index 0000000..fe97551 --- /dev/null +++ b/spec/unit/sqlite/pragmas_spec.rb @@ -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 diff --git a/spec/unit/sqlite/unknown_pragma_error_spec.rb b/spec/unit/sqlite/unknown_pragma_error_spec.rb new file mode 100644 index 0000000..6bf0a01 --- /dev/null +++ b/spec/unit/sqlite/unknown_pragma_error_spec.rb @@ -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