diff --git a/CHANGELOG.md b/CHANGELOG.md index d234e683b..5d640ff9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Accept `sqlite:path` and `sqlite::memory:` in the CLI alongside the existing `sqlite://` forms, including offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). +- Reject empty SQLite paths in commands that connect to a database; bare prefixes remain valid for offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). - Fixed `JobListParams.Where` writing into the first `NamedArgs` it's given when merging multiple sets of named arguments, which could leak arguments into a map the caller reused for another query. It also no longer panics when the first set is `nil`. [PR #1575](https://github.com/riverqueue/river/pull/1575). ## [0.51.1] - 2026-11-05 diff --git a/cmd/river/rivercli/command.go b/cmd/river/rivercli/command.go index 317e96075..fcdcdf7a1 100644 --- a/cmd/river/rivercli/command.go +++ b/cmd/river/rivercli/command.go @@ -70,9 +70,13 @@ func RunCommand[TOpts CommandOpts](ctx context.Context, bundle *RunCommandBundle protocol = "postgres" } else if bundle.DatabaseURL != nil { databaseURL = bundle.DatabaseURL - var ok bool - protocol, urlWithoutProtocol, ok = strings.Cut(*databaseURL, "://") - if !ok { + var hasProtocol bool + if sqlitePath, sqlite := strings.CutPrefix(*databaseURL, "sqlite:"); sqlite { + protocol, urlWithoutProtocol, hasProtocol = "sqlite", strings.TrimPrefix(sqlitePath, "//"), true + } else { + protocol, urlWithoutProtocol, hasProtocol = strings.Cut(*databaseURL, "://") + } + if !hasProtocol { return false, fmt.Errorf("expected database URL (`%s`) to be formatted like `postgres://...`", *bundle.DatabaseURL) } } @@ -95,6 +99,10 @@ func RunCommand[TOpts CommandOpts](ctx context.Context, bundle *RunCommandBundle } case "sqlite": + if _, isMigrateGet := any(command).(*migrateGet); urlWithoutProtocol == "" && !isMigrateGet { + return false, fmt.Errorf("SQLite database URL (`%s`) needs a path or `:memory:`", *databaseURL) + } + dbPool, err := openSQLitePool(protocol, urlWithoutProtocol) if err != nil { return false, err diff --git a/cmd/river/rivercli/river_cli.go b/cmd/river/rivercli/river_cli.go index 224822269..6e18dd6f4 100644 --- a/cmd/river/rivercli/river_cli.go +++ b/cmd/river/rivercli/river_cli.go @@ -151,7 +151,7 @@ default. } addDatabaseURLFlag := func(cmd *cobra.Command, databaseURL *string) { - cmd.Flags().StringVar(databaseURL, "database-url", "", "URL of the database (should look like `postgres://...`)") + cmd.Flags().StringVar(databaseURL, "database-url", "", "URL of the database (postgres://..., sqlite://path, or sqlite:path)") } addLineFlag := func(cmd *cobra.Command, line *string) { cmd.Flags().StringVar(line, "line", "", "migration line to operate on (default: main)") @@ -412,7 +412,7 @@ func (c *bench) Run(ctx context.Context, opts *benchOpts) (bool, error) { // In the special case of a purely ephemeral memory database, migrate // the schema up to make sure it's fully prepared for use. (Otherwise // there's no way to raise it.) - if parsedDatabaseURL.Host == ":memory:" { + if parsedDatabaseURL.Host == ":memory:" || parsedDatabaseURL.Opaque == ":memory:" { migrator, err := c.DriverProcurer.GetMigrator(&rivermigrate.Config{ Schema: opts.Schema, }) diff --git a/cmd/river/rivercli/river_cli_test.go b/cmd/river/rivercli/river_cli_test.go index f8ce43714..b1fe3929b 100644 --- a/cmd/river/rivercli/river_cli_test.go +++ b/cmd/river/rivercli/river_cli_test.go @@ -8,6 +8,8 @@ import ( "fmt" "maps" "net/url" + "os" + "path/filepath" "runtime/debug" "strconv" "strings" @@ -544,6 +546,100 @@ SELECT 'up 1' FROM river_table `), strings.TrimSpace(out.String())) } +func TestBaseCommandSetSQLiteURLs(t *testing.T) { + t.Parallel() + + runFunc := func(t *testing.T, databaseURL string, args ...string) string { + t.Helper() + + var out bytes.Buffer + cli := NewCLI(&Config{Name: "River"}) + cli.SetOut(&out) + cmd := cli.BaseCommandSet() + cmd.SetArgs(append(args, "--database-url", databaseURL)) + require.NoError(t, cmd.Execute()) + return out.String() + } + + t.Run("BenchMemory", func(t *testing.T) { + t.Parallel() + + for _, databaseURL := range []string{"sqlite::memory:", "sqlite://:memory:"} { + t.Run(databaseURL, func(t *testing.T) { + t.Parallel() + + runFunc(t, databaseURL, "bench", "--num-total-jobs", "10") + }) + } + }) + + t.Run("DialectHint", func(t *testing.T) { + t.Parallel() + + for _, databaseURL := range []string{"sqlite:", "sqlite://"} { + sql := runFunc(t, databaseURL, "migrate-get", "--version", "1", "--up") + require.Contains(t, sql, "id integer PRIMARY KEY") + } + }) + + t.Run("EmptyPathRejected", func(t *testing.T) { + t.Parallel() + + for _, databaseURL := range []string{"sqlite:", "sqlite://"} { + for _, command := range []string{"bench", "migrate-down", "migrate-list", "migrate-up", "validate"} { + t.Run(databaseURL+"/"+command, func(t *testing.T) { + t.Parallel() + + cli := NewCLI(&Config{Name: "River"}) + cli.SetOut(&bytes.Buffer{}) + cmd := cli.BaseCommandSet() + cmd.SetArgs([]string{command, "--database-url", databaseURL}) + require.ErrorContains(t, cmd.Execute(), "needs a path or `:memory:`") + }) + } + } + }) + + t.Run("File", func(t *testing.T) { + t.Parallel() + + for _, prefix := range []string{"sqlite:", "sqlite://"} { + t.Run(prefix, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "river.db") + workingDir, err := os.Getwd() + require.NoError(t, err) + relativePath, err := filepath.Rel(workingDir, path) + require.NoError(t, err) + databaseURL := prefix + relativePath + + sql := runFunc(t, databaseURL, "migrate-get", "--version", "1", "--up") + require.Contains(t, sql, "CREATE TABLE") + require.NoFileExists(t, path) + runFunc(t, databaseURL, "migrate-up") + require.FileExists(t, path) + runFunc(t, "sqlite://"+path, "validate") + }) + } + }) + + t.Run("Memory", func(t *testing.T) { + t.Parallel() + + for _, databaseURL := range []string{"sqlite::memory:", "sqlite://:memory:"} { + t.Run(databaseURL, func(t *testing.T) { + t.Parallel() + + sql := runFunc(t, databaseURL, "migrate-get", "--version", "1", "--up") + require.Contains(t, sql, "CREATE TABLE") + out := runFunc(t, databaseURL, "migrate-up") + require.Contains(t, out, "applied migration 008") + }) + } + }) +} + func TestMigrateGet(t *testing.T) { t.Parallel() diff --git a/docs/sqlite-urls.md b/docs/sqlite-urls.md new file mode 100644 index 000000000..faf9b6404 --- /dev/null +++ b/docs/sqlite-urls.md @@ -0,0 +1,29 @@ +# SQLite CLI URLs + +The Go, Java, JavaScript, Ruby, and Rust migration CLIs accept the same SQLite +prefixes. The `//` after `sqlite:` is optional: + +| Database | Long form | Short form | +| --- | --- | --- | +| Relative file | `sqlite://river.db` | `sqlite:river.db` | +| Absolute file | `sqlite:///var/lib/app/river.db` | `sqlite:/var/lib/app/river.db` | +| In memory | `sqlite://:memory:` | `sqlite::memory:` | + +Relative paths are resolved from the command's working directory. An in-memory +database lasts only for that invocation; use a file to share a database between +migration commands and application processes. + +Commands that connect to a database reject an empty SQLite path. Use an explicit +`:memory:` path if you want a temporary database. + +For `migrate-get`, `--database-url` only selects the SQL dialect. Both `sqlite:` +and `sqlite://` work without a path, and export never opens or creates a database. + +Ruby also accepts `sqlite3:` and `sqlite3://` with either Active Record or Sequel. +Java also accepts native `jdbc:sqlite:` URLs, passed unchanged to the JDBC driver. +These aliases are specific to those languages. Query parameters and +percent-encoding are handled differently by each language and can select a +different file, or turn an in-memory database into an on-disk file. For portable +URLs, avoid `?` and `%` in paths, and don't add parameters to `:memory:`. +Driver-specific URI options are not portable; for example, Rust's `?mode=rwc` +creates a missing database file. diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md index 9e2227fc5..f70e58122 100644 --- a/java/CHANGELOG.md +++ b/java/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Normalize `sqlite://` URLs before opening JDBC connections so relative paths remain relative and `sqlite://:memory:` opens an in-memory database. Preserve `sqlite:` and native `jdbc:sqlite:` URLs. [PR #1588](https://github.com/riverqueue/river/pull/1588). + - **Upgrade note:** `sqlite://var/lib/x.db` now opens `./var/lib/x.db` instead of `/var/lib/x.db`. Use `sqlite:///var/lib/x.db` to keep targeting the absolute path. +- Reject empty SQLite paths in commands that connect to a database; bare prefixes remain valid for offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). + ## [0.4.0] - 2026-11-06 ### Added diff --git a/java/DIFFERENCES.md b/java/DIFFERENCES.md index 123daf41c..49c295994 100644 --- a/java/DIFFERENCES.md +++ b/java/DIFFERENCES.md @@ -10,6 +10,9 @@ - A `DataSource` and its pool belong to the application. `Database.connect` uses DriverManager without pooling; callers wanting pooled connections supply a data source. River-owned JDBC connections are closed after each transaction. +- SQLite connections accept the shared `sqlite:` and `sqlite://` forms, plus + native `jdbc:sqlite:` URLs passed unchanged to JDBC. See the + [SQLite URL reference](../docs/sqlite-urls.md) for the portable forms. - Caller-owned transactions use JDBC savepoints. Hooks run within those savepoints, so a failed River operation leaves the outer transaction usable. - Cancellation is cooperative and followed by interruption after the configured diff --git a/java/README.md b/java/README.md index 947bbe6e6..0b47adc8c 100644 --- a/java/README.md +++ b/java/README.md @@ -88,6 +88,10 @@ for each operation. River never closes an application-owned data source. An ## Migration CLI +SQLite connections accept `sqlite://path` and `sqlite:path`, as well as native +`jdbc:sqlite:` URLs. See the shared [SQLite URL reference](../docs/sqlite-urls.md) +for relative paths and in-memory databases. + The CLI runs with Java 21 or newer. [JBang](https://www.jbang.dev/documentation/jbang/latest/installation.html) resolves the thin CLI JAR and its dependencies and installs a reusable command: diff --git a/java/cli/src/main/java/com/riverqueue/cli/MigrationCli.java b/java/cli/src/main/java/com/riverqueue/cli/MigrationCli.java index bdee00485..8fdfa07f5 100644 --- a/java/cli/src/main/java/com/riverqueue/cli/MigrationCli.java +++ b/java/cli/src/main/java/com/riverqueue/cli/MigrationCli.java @@ -46,6 +46,8 @@ private static Database database( && !url.startsWith("sqlite:") && !url.startsWith("jdbc:sqlite:")) throw new Usage("Expected a Postgres URL or a SQLite JDBC URL"); + if (!offline && Set.of("sqlite:", "sqlite://", "jdbc:sqlite:").contains(url)) + throw new Usage("SQLite database URL needs a path or `:memory:`"); Database database; try { database = diff --git a/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java b/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java index f4fa310b7..af4cc2757 100644 --- a/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java +++ b/java/cli/src/test/java/com/riverqueue/cli/MigrationCliTest.java @@ -117,6 +117,42 @@ void operationFailureHasNonzeroExit() { assertTrue(result.out().isEmpty()); } + @Test + void sqliteEmptyPathsAreRejected() { + for (String url : List.of("sqlite:", "sqlite://", "jdbc:sqlite:")) { + for (String command : List.of("migrate-down", "migrate-list", "migrate-up")) { + var result = run(Map.of(), command, "--database-url", url); + assertEquals(2, result.exit(), result.err()); + assertTrue(result.err().contains("needs a path or `:memory:`"), result.err()); + assertTrue(result.out().isEmpty()); + } + } + } + + @Test + void sqliteUrlForms() { + for (String prefix : List.of("sqlite:", "sqlite://", "jdbc:sqlite:")) { + var hint = run(Map.of(), "migrate-get", "--database-url", prefix, "--version", "1", "--up"); + assertEquals(0, hint.exit(), hint.err()); + assertTrue(hint.out().contains("id integer PRIMARY KEY")); + Path file = directory.resolve("forms-" + prefix.length() + ".db"); + String relative = Path.of("").toAbsolutePath().relativize(file).toString(); + String url = prefix + relative; + var exported = run(Map.of(), "migrate-get", "--database-url", url, "--version", "1", "--up"); + assertEquals(0, exported.exit(), exported.err()); + assertTrue(exported.out().contains("id integer PRIMARY KEY")); + assertFalse(java.nio.file.Files.exists(file)); + var up = run(Map.of(), "migrate-up", "--database-url", url); + assertEquals(0, up.exit(), up.err()); + assertTrue(java.nio.file.Files.exists(file)); + var listed = run(Map.of(), "migrate-list", "--database-url", "sqlite://" + file); + assertEquals(0, listed.exit(), listed.err()); + assertTrue(listed.out().contains("008 applied"), listed.out()); + var memory = run(Map.of(), "migrate-list", "--database-url", prefix + ":memory:"); + assertEquals(0, memory.exit(), memory.err()); + } + } + @Test void targetsLimitsAndDryRunsPreserveExpectedHistory() { var environment = Map.of("DATABASE_URL", "jdbc:sqlite:" + directory.resolve("river.db")); diff --git a/java/river/src/main/java/com/riverqueue/Database.java b/java/river/src/main/java/com/riverqueue/Database.java index a7fc047bd..380193e16 100644 --- a/java/river/src/main/java/com/riverqueue/Database.java +++ b/java/river/src/main/java/com/riverqueue/Database.java @@ -41,7 +41,12 @@ public static Database connect(String url) { public static Database connect(String url, String applicationName) { if (url.startsWith("sqlite:") || url.startsWith("jdbc:sqlite:")) { - String jdbc = url.startsWith("jdbc:") ? url : "jdbc:" + url; + String jdbc; + if (url.startsWith("jdbc:")) jdbc = url; + else { + String path = url.substring("sqlite:".length()); + jdbc = "jdbc:sqlite:" + (path.startsWith("//") ? path.substring(2) : path); + } return new Database(() -> DriverManager.getConnection(jdbc), Dialect.SQLITE, ""); } String jdbc; diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 095fca8c7..f810f4807 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Accept `sqlite:path` and `sqlite::memory:` in the CLI alongside the existing `sqlite://` forms, including offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). + ## [0.3.1] - 2026-11-05 ### Fixed diff --git a/js/cli/README.md b/js/cli/README.md index 1598de306..8d8e18e84 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -28,8 +28,10 @@ Run `npx riverqueue --help` for every command, and - `postgres://…` or `postgresql://…` for Postgres. Without `--database-url`, Postgres commands use the standard `PG*` environment variables when `PGDATABASE` is set. -- `sqlite://PATH` for SQLite, for example `sqlite:///var/lib/app/river.db` - for an absolute path or `sqlite://river.db` for a relative one. +- `sqlite://PATH` or `sqlite:PATH` for SQLite, for example + `sqlite:///var/lib/app/river.db` for an absolute path or `sqlite://river.db` + for a relative one. `sqlite::memory:` and `sqlite://:memory:` use an in-memory + database. See the shared [SQLite URL reference](../../docs/sqlite-urls.md). Other common flags: diff --git a/js/cli/src/database.ts b/js/cli/src/database.ts index 942ce05df..e4ce064d2 100644 --- a/js/cli/src/database.ts +++ b/js/cli/src/database.ts @@ -43,7 +43,8 @@ const SQLITE_BUSY_TIMEOUT_MS = 5_000; * * `postgres://` and `postgresql://` URLs select Postgres. `sqlite://PATH` * selects SQLite, where `PATH` is a file path (`sqlite:///abs/river.db` or - * `sqlite://relative/river.db`), `:memory:`, or a `file:` URL. Without a URL, + * `sqlite://relative/river.db`), `:memory:`, or a `file:` URL. The `//` is + * optional: `sqlite:relative/river.db` and `sqlite::memory:` also work. Without a URL, * Postgres is configured from `PG*` environment variables when * `PGDATABASE` is set, as node-postgres and River's Go CLI do. */ @@ -63,13 +64,15 @@ export function parseDatabaseUrl( ); } - const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url)?.[1]?.toLowerCase(); + const scheme = /^sqlite:/i.test(url) + ? "sqlite" + : /^([a-z][a-z0-9+.-]*):\/\//i.exec(url)?.[1]?.toLowerCase(); switch (scheme) { case "postgres": case "postgresql": return { backend: "postgres", connectionString: url }; case "sqlite": { - const path = url.slice("sqlite://".length); + const path = url.slice("sqlite:".length).replace(/^\/\//, ""); if (path.length === 0) { throw new UsageError( "a SQLite --database-url needs a path, such as sqlite:///var/lib/river.db", diff --git a/js/cli/src/migrate-commands.ts b/js/cli/src/migrate-commands.ts index 9d2513a42..9bd9358ad 100644 --- a/js/cli/src/migrate-commands.ts +++ b/js/cli/src/migrate-commands.ts @@ -457,9 +457,9 @@ function sqlDialect( url: string | undefined ): MigrationBackend { if (url === undefined) return "postgres"; + if (/^sqlite:/i.test(url)) return "sqlite"; const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url)?.[1]?.toLowerCase(); if (scheme === "postgres" || scheme === "postgresql") return "postgres"; - if (scheme === "sqlite") return "sqlite"; throw new UsageError( "--database-url must start with postgres://, postgresql://, or sqlite://", command diff --git a/js/cli/src/run.test.ts b/js/cli/src/run.test.ts index ffff46908..f2f1883fb 100644 --- a/js/cli/src/run.test.ts +++ b/js/cli/src/run.test.ts @@ -1,6 +1,6 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, relative } from "node:path"; import { PassThrough } from "node:stream"; import type { Migration, MigrationBackend } from "@riverqueue/migrate"; @@ -170,10 +170,6 @@ describe("argument errors", () => { ["migrate-up", "--database-url", "sqlite://"], "a SQLite --database-url needs a path", ], - [ - ["migrate-up", "--database-url", "sqlite::memory:"], - "--database-url must start with", - ], [ [ "migrate-up", @@ -359,7 +355,13 @@ describe("migrate-get", () => { expect(headers).toEqual(["007", "006", "005", "004", "003", "002"]); }); - it("prints SQLite SQL when the URL selects SQLite", async () => { + it.each([ + "sqlite:", + "sqlite://", + "sqlite::memory:", + "sqlite://:memory:", + "sqlite:unused.db", + ])("prints SQLite SQL for %s", async (url) => { const postgres = await invoke(["migrate-get", "--version", "2", "--up"]); const sqlite = await invoke([ "migrate-get", @@ -367,7 +369,7 @@ describe("migrate-get", () => { "2", "--up", "--database-url", - "sqlite://", + url, ]); expect(sqlite.exitCode).toBe(0); @@ -428,6 +430,57 @@ describe("SQLite migrations", () => { rmSync(directory, { force: true, recursive: true }); }); + it.each(["sqlite:", "sqlite://"])("rejects empty %s paths", async (url) => { + for (const command of [ + "migrate-down", + "migrate-list", + "migrate-up", + "validate", + ]) { + const result = await invoke([command, "--database-url", url]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("a SQLite --database-url needs a path"); + expect(result.stdout).toBe(""); + } + }); + + it.each(["sqlite:", "sqlite://"])( + "opens the same file with %s and its alternate form", + async (prefix) => { + const path = join(directory, "forms.db"); + const relativeUrl = `${prefix}${relative(process.cwd(), path)}`; + const exported = await invoke([ + "migrate-get", + "--version", + "1", + "--up", + "--database-url", + relativeUrl, + ]); + expect(exported.exitCode).toBe(0); + expect(existsSync(path)).toBe(false); + const up = await invoke(["migrate-up", "--database-url", relativeUrl]); + expect(up.exitCode, up.stderr).toBe(0); + expect(existsSync(path)).toBe(true); + const validated = await invoke([ + "validate", + "--database-url", + `sqlite://${path}`, + ]); + expect(validated.exitCode, validated.stderr).toBe(0); + } + ); + + it.each(["sqlite::memory:", "sqlite://:memory:"])( + "migrates an in-memory database with %s", + async (url) => { + const result = await invoke(["migrate-up", "--database-url", url]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("applied migration 008"); + } + ); + it("migrates up, lists, validates, and migrates down", async () => { const missing = await invoke(["validate", "--database-url", url]); expect(missing).toEqual({ diff --git a/ruby/CHANGELOG.md b/ruby/CHANGELOG.md index 02511619f..90b62e6c2 100644 --- a/ruby/CHANGELOG.md +++ b/ruby/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Accept `sqlite:` and `sqlite://` URLs with both Active Record and Sequel, including `:memory:` databases, while preserving the `sqlite3:` aliases. [PR #1588](https://github.com/riverqueue/river/pull/1588). +- Reject empty SQLite paths in commands that connect to a database; bare prefixes remain valid for offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). + ## [0.16.0] - 2026-11-04 ### Added diff --git a/ruby/docs/migrations.md b/ruby/docs/migrations.md index fd9451e4b..cb3dc91e4 100644 --- a/ruby/docs/migrations.md +++ b/ruby/docs/migrations.md @@ -18,12 +18,14 @@ Create the database first, then run: bundle exec river migrate-status --database-url postgres://localhost/my_app bundle exec river migrate-up --database-url postgres://localhost/my_app -# SQLite, using Sequel: +# SQLite, using either driver: bundle exec river migrate-up --database-url sqlite://storage/river.sqlite3 ``` -`DATABASE_URL` supplies the URL when `--database-url` is omitted. ActiveRecord -uses `sqlite3:` URLs instead of Sequel's `sqlite:` URLs. To target an existing +`DATABASE_URL` supplies the URL when `--database-url` is omitted. Both drivers +accept `sqlite://path`, `sqlite:path`, and the `sqlite3:` aliases. For an in-memory +database, use `sqlite::memory:` or `sqlite://:memory:`. See the shared +[SQLite URL reference](../../docs/sqlite-urls.md) for path handling. To target an existing Postgres schema, pass `--schema jobs`; otherwise the connection's current schema is used. Schema names must be simple SQL identifiers. SQLite always uses its main schema. @@ -91,7 +93,7 @@ migration-tracking table and can usually be omitted with another framework. Preserve version boundaries and apply each migration in its own transaction. Export defaults to Postgres and ignores `DATABASE_URL`. Use -`--database-url sqlite://` (or `sqlite3://`) to select SQLite SQL; the URL is only +`--database-url sqlite:` or `sqlite://` (including the `sqlite3:` aliases) to select SQLite SQL; the URL is only a dialect hint. `--schema jobs` qualifies Postgres objects with that schema. Without it, objects are unqualified. Schema placeholders are always substituted. diff --git a/ruby/lib/migration_cli.rb b/ruby/lib/migration_cli.rb index 5cfd2f962..86ed8353c 100644 --- a/ruby/lib/migration_cli.rb +++ b/ruby/lib/migration_cli.rb @@ -56,16 +56,32 @@ def self.run(argv, err: $stderr, out: $stdout) raise ArgumentError, "down migrations may delete data; pass --yes to confirm" end - adapter = detect_driver + sqlite_path = url.sub(/\Asqlite3?:/i, "").delete_prefix("//") if url.match?(/\Asqlite3?:/i) + raise ArgumentError, "SQLite database URL needs a path or `:memory:`" if sqlite_path == "" + adapter = detect_driver if adapter == "activerecord" require "riverqueue-activerecord" active_record_base = Object.const_get("ActiveRecord::Base") - active_record_base.establish_connection(url) + connection = if sqlite_path == ":memory:" + {adapter: "sqlite3", database: sqlite_path} + elsif sqlite_path + "sqlite3:#{sqlite_path}" + else + url + end + active_record_base.establish_connection(connection) driver = River::Driver.const_get(:ActiveRecord).new else require "riverqueue-sequel" - database = Object.const_get(:Sequel).connect(url) + connection = if sqlite_path == ":memory:" + {adapter: "sqlite", database: sqlite_path} + elsif sqlite_path + "sqlite://#{sqlite_path}" + else + url + end + database = Object.const_get(:Sequel).connect(connection) driver = River::Driver.const_get(:Sequel).new(database) end diff --git a/ruby/spec/migration_cli_shared_examples.rb b/ruby/spec/migration_cli_shared_examples.rb index 427a4f641..eae5da314 100644 --- a/ruby/spec/migration_cli_shared_examples.rb +++ b/ruby/spec/migration_cli_shared_examples.rb @@ -5,6 +5,40 @@ require_relative "../lib/migration_cli" RSpec.shared_examples "migration command" do |adapter| + %w[sqlite: sqlite:// sqlite3: sqlite3://].each do |prefix| + it "accepts #{prefix} file and memory URLs with #{adapter}", database: :sqlite do + # Keep the relative path below the working directory so a lost first + # segment cannot be hidden by a run of ../ components. + Dir.mktmpdir("river-cli-urls-", Dir.pwd) do |directory| + path = File.join(directory, "river.sqlite3") + out = StringIO.new + err = StringIO.new + relative = Pathname.new(path).relative_path_from(Pathname.pwd) + url = "#{prefix}#{relative}" + + expect(River::MigrationCLI.run(["migrate-get", "--database-url", url, "--version", "1", "--up"], err: err, out: out)).to eq(0), err.string + expect(File.exist?(path)).to be(false) + expect(River::MigrationCLI.run(["migrate-up", "--database-url", url], err: err, out: out)).to eq(0), err.string + expect(File.exist?(path)).to be(true) + expect(River::MigrationCLI.run(["validate", "--database-url", "sqlite://#{path}"], err: err, out: out)).to eq(0), err.string + expect(River::MigrationCLI.run(["migrate-up", "--database-url", "#{prefix}:memory:"], err: err, out: out)).to eq(0), err.string + end + end + + %w[migrate-down migrate-status migrate-up validate].each do |command| + it "rejects an empty #{prefix} path for #{command} with #{adapter}", database: :sqlite do + out = StringIO.new + err = StringIO.new + args = [command, "--database-url", prefix] + args << "--yes" if command == "migrate-down" + + expect(River::MigrationCLI.run(args, err: err, out: out)).to eq(1) + expect(err.string).to include("needs a path or `:memory:`") + expect(out.string).to be_empty + end + end + end + it "supports help and reports usage errors" do out = StringIO.new err = StringIO.new diff --git a/ruby/spec/migration_export_spec.rb b/ruby/spec/migration_export_spec.rb index a8fbbdfd5..b5bf7d489 100644 --- a/ruby/spec/migration_export_spec.rb +++ b/ruby/spec/migration_export_spec.rb @@ -39,6 +39,13 @@ def versions expect(out.string).to eq("") end + %w[sqlite: sqlite:// sqlite::memory: sqlite://:memory: sqlite3: sqlite3:// sqlite3::memory: sqlite3://:memory:].each do |url| + it "exports SQLite SQL for #{url}" do + expect(run("--version", "1", "--up", "--database-url", url)).to eq(0), err.string + expect(out.string).to include("id integer PRIMARY KEY") + end + end + it "exports selected versions in supplied order with schema substitution" do expect(run("--version", "3,1", "--version", "2", "--up", "--schema", "jobs")).to eq(0), err.string expect(versions).to eq([3, 1, 2]) diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index fa43bf6a7..67433a78a 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -10,6 +10,11 @@ Changes to River for Go are recorded in the [repository changelog](../CHANGELOG. ## [Unreleased] +### Fixed + +- Accept `sqlite:` URL hints in offline migration SQL export, matching migration commands and preserving the existing `sqlite://` forms. [PR #1588](https://github.com/riverqueue/river/pull/1588). +- Reject empty SQLite paths in commands that connect to a database; bare prefixes remain valid for offline migration SQL export. [PR #1588](https://github.com/riverqueue/river/pull/1588). + ## [0.6.0] - 2026-11-05 ### Added diff --git a/rust/riverqueue-cli/README.md b/rust/riverqueue-cli/README.md index 80b0e3dea..bfe4f4df6 100644 --- a/rust/riverqueue-cli/README.md +++ b/rust/riverqueue-cli/README.md @@ -18,6 +18,10 @@ riverqueue migrate-up --database-url postgres://localhost/app --schema river riverqueue migrate-up --database-url sqlite://app.sqlite3 ``` +SQLite URLs accept either `sqlite://` or `sqlite:` before the path, including +`sqlite://:memory:` and `sqlite::memory:`. Add `?mode=rwc` to create a missing +file. See the shared [SQLite URL reference](../../docs/sqlite-urls.md). + `migrate-down`, `migrate-list`, and `validate` take the same connection options. `--target-version N`, `--max-steps N`, and `--dry-run` limit or preview a migration run. Applications can instead migrate from Rust with the diff --git a/rust/riverqueue-cli/src/migrate.rs b/rust/riverqueue-cli/src/migrate.rs index eb3d1b6ba..c194a382b 100644 --- a/rust/riverqueue-cli/src/migrate.rs +++ b/rust/riverqueue-cli/src/migrate.rs @@ -239,6 +239,9 @@ fn parse_args( if args.database_url.is_empty() { args.database_url = database_url_env.ok_or("--database-url or DATABASE_URL is required")?; } + if matches!(args.database_url.as_str(), "sqlite:" | "sqlite://") { + return Err("SQLite database URL needs a path or `:memory:`".into()); + } Ok(Some(args)) } diff --git a/rust/riverqueue-cli/src/migrate_get.rs b/rust/riverqueue-cli/src/migrate_get.rs index a78fbc5a1..48a5310c3 100644 --- a/rust/riverqueue-cli/src/migrate_get.rs +++ b/rust/riverqueue-cli/src/migrate_get.rs @@ -30,7 +30,7 @@ Options: Pass exactly one of --all or --version, and exactly one of --up or --down. Explicit versions retain their supplied order, including for down migrations. No database connection is made. DATABASE_URL is ignored; use --database-url -to select Postgres (postgres:// or postgresql://) or SQLite (sqlite://). +to select Postgres (postgres:// or postgresql://) or SQLite (sqlite: or sqlite://). Only the main migration line is bundled. Examples: @@ -120,6 +120,12 @@ pub(crate) fn run( } fn all_versions(database_url: &str) -> Result<&'static [Migration], Box> { + if database_url.starts_with("sqlite:") { + #[cfg(feature = "sqlite")] + return Ok(SqliteMigrator::all_versions()); + #[cfg(not(feature = "sqlite"))] + return Err("SQLite support requires the `sqlite` feature".into()); + } match database_url.split_once("://").map(|(scheme, _)| scheme) { Some("postgres" | "postgresql") => { #[cfg(feature = "postgres")] @@ -127,12 +133,6 @@ fn all_versions(database_url: &str) -> Result<&'static [Migration], Box { - #[cfg(feature = "sqlite")] - return Ok(SqliteMigrator::all_versions()); - #[cfg(not(feature = "sqlite"))] - return Err("SQLite support requires the `sqlite` feature".into()); - } _ => { Err("--database-url must use a postgres://, postgresql://, or sqlite:// prefix".into()) } diff --git a/rust/riverqueue-cli/tests/migrate.rs b/rust/riverqueue-cli/tests/migrate.rs index 32d00c33a..e2278e730 100644 --- a/rust/riverqueue-cli/tests/migrate.rs +++ b/rust/riverqueue-cli/tests/migrate.rs @@ -34,6 +34,20 @@ async fn postgres_migration_output() { pool.close().await; } +#[cfg(feature = "sqlite")] +#[test] +fn sqlite_empty_paths_are_rejected() { + for url in ["sqlite:", "sqlite://"] { + for command in ["migrate-down", "migrate-list", "migrate-up", "validate"] { + let output = run(url, None, &[command]); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(String::from_utf8(output.stdout).unwrap(), ""); + let error = String::from_utf8(output.stderr).unwrap(); + assert!(error.contains("needs a path or `:memory:`"), "{error}"); + } + } +} + #[cfg(feature = "sqlite")] #[tokio::test] async fn sqlite_migration_output() { @@ -56,6 +70,27 @@ async fn sqlite_migration_output() { std::fs::remove_file(path).unwrap(); } +#[cfg(feature = "sqlite")] +#[test] +fn sqlite_url_forms() { + for url in ["sqlite::memory:", "sqlite://:memory:"] { + let output = success(run(url, None, &["migrate-up"])); + assert!(output.contains("applied migration 008")); + } + let path = std::path::PathBuf::from(format!( + "river-cli-forms-{}-{}.db", + std::process::id(), + nonce() + )); + let short = format!("sqlite:{}?mode=rwc", path.display()); + let absolute = std::env::current_dir().unwrap().join(&path); + let long = format!("sqlite://{}?mode=rwc", absolute.display()); + success(run(&short, None, &["migrate-up"])); + assert!(path.exists()); + success(run(&long, None, &["validate"])); + std::fs::remove_file(path).unwrap(); +} + fn assert_listing(command: &impl Fn(&[&str]) -> Output, current: Option) { let names = [ "create_river_migration", diff --git a/rust/riverqueue-cli/tests/migrate_get.rs b/rust/riverqueue-cli/tests/migrate_get.rs index ae87052ee..ae5b91f7d 100644 --- a/rust/riverqueue-cli/tests/migrate_get.rs +++ b/rust/riverqueue-cli/tests/migrate_get.rs @@ -79,28 +79,41 @@ fn sqlite_does_not_create_database() { .unwrap() .as_nanos() )); - let database_url = format!("sqlite://{}?mode=rwc", path.display()); - let output = success(run(&database_url, &["--version", "1", "--up"])); - assert!(output.contains("id integer PRIMARY KEY")); - assert!(!path.exists(), "export must not create a database file"); + for prefix in ["sqlite:", "sqlite://"] { + let database_url = format!("{prefix}{}?mode=rwc", path.display()); + let output = success(run(&database_url, &["--version", "1", "--up"])); + assert!(output.contains("id integer PRIMARY KEY")); + assert!(!path.exists(), "export must not create a database file"); + } } #[cfg(feature = "sqlite")] #[test] fn sqlite_export() { - exercise_export( + for url in [ + "sqlite:", "sqlite://", - riverqueue_migrate::SqliteMigrator::all_versions(), - ); + "sqlite::memory:", + "sqlite://:memory:", + ] { + exercise_export(url, riverqueue_migrate::SqliteMigrator::all_versions()); + } } #[cfg(not(feature = "sqlite"))] #[test] fn sqlite_requires_feature() { - failure( - run("sqlite://", &["--all", "--up"]), - "SQLite support requires the `sqlite` feature", - ); + for url in [ + "sqlite:", + "sqlite://", + "sqlite::memory:", + "sqlite://:memory:", + ] { + failure( + run(url, &["--all", "--up"]), + "SQLite support requires the `sqlite` feature", + ); + } } fn command(arguments: &[&str]) -> Command {