diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml
index d5b8dfa..8f89b59 100644
--- a/.github/workflows/swift.yml
+++ b/.github/workflows/swift.yml
@@ -12,17 +12,18 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
# Analog to Cocoanetics/ShellKit's swift.yml (SQLiteKit's standalone-library
-# peer), extended to the full Apple platform matrix the package declares
-# (macOS / iOS / tvOS / watchOS / visionOS) plus Linux / Windows / Android.
-# No external-C provisioning on any platform (no brew / apt / vcpkg): the SQLite
-# engine is vendored, so there are no system libraries to install.
+# peer). Apple platforms in CI: macOS / iOS / watchOS — tvOS and visionOS are
+# not built here (iOS coverage is sufficient for the Apple-embedded story).
+# Plus Linux / Windows / Android. No external-C provisioning on any platform
+# (no brew / apt / vcpkg): the SQLite engine is vendored, so there are no
+# system libraries to install.
#
# The four full-build platforms (macOS / Linux / Windows / Android) run the
# suite twice — a default run that pins the trait-OFF contract (the `#else`
# branches: FTS5 / vec0 modules absent), then a `--traits FTS5,SQLiteVec` run for
# the on-state proofs (fullTextSearchFTS5, semanticSearchSQLiteVec, vec0BlobBind).
# The Apple-mobile jobs build the library against each device SDK (a portability
-# check); iOS additionally runs the suite on a simulator.
+# check); the full test suites run on macOS / Linux / Windows / Android.
jobs:
@@ -56,30 +57,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.0"
- # Build both the SDK and the shell driver for the iOS device SDK. We
- # build (not test) on iOS: the shell driver pulls in ShellKit ->
- # swift-subprocess, which declares `iOS("99.0")` — i.e. iOS unsupported —
- # so once `Sqlite3ShellTests` is folded into the autogenerated `SQLiteKit`
- # scheme's test action the scheme's iOS platform set is empty
- # ("not configured for the test action"). The actual SDK + shell test
- # suites run on macOS / Linux / Windows / Android via `swift test`; here
- # we just prove both products compile for iOS.
+ # Build the SDK against the iOS device SDK — a portability check. The
+ # test suites run on macOS / Linux / Windows / Android via `swift test`.
- name: Build (iOS device SDK)
- run: |
- xcodebuild -scheme SQLiteKit -destination 'generic/platform=iOS' build
- xcodebuild -scheme Sqlite3Shell -destination 'generic/platform=iOS' build
-
- build-tvos:
- runs-on: macos-15
- timeout-minutes: 20
- steps:
- - uses: actions/checkout@v6
- - name: Select Xcode 26.0
- uses: maxim-lobanov/setup-xcode@v1
- with:
- xcode-version: "26.0"
- - name: Build (tvOS device SDK)
- run: xcodebuild -scheme SQLiteKit -destination 'generic/platform=tvOS' build
+ run: xcodebuild -scheme SQLiteKit -destination 'generic/platform=iOS' build
build-watchos:
runs-on: macos-15
@@ -93,18 +74,6 @@ jobs:
- name: Build (watchOS device SDK)
run: xcodebuild -scheme SQLiteKit -destination 'generic/platform=watchOS' build
- build-visionos:
- runs-on: macos-15
- timeout-minutes: 25
- steps:
- - uses: actions/checkout@v6
- - name: Select Xcode 26.0
- uses: maxim-lobanov/setup-xcode@v1
- with:
- xcode-version: "26.0"
- - name: Build (visionOS device SDK)
- run: xcodebuild -scheme SQLiteKit -destination 'generic/platform=visionOS' build
-
build-linux:
# No `apt-get` — the SQLite engine is vendored. Pinned to 6.2
# (SQLiteKit's tools-version).
diff --git a/Package.swift b/Package.swift
index 4b8faa9..c6ead7a 100644
--- a/Package.swift
+++ b/Package.swift
@@ -14,15 +14,6 @@ let package = Package(
// The SDK: a thin, pure-Swift wrapper over the vendored SQLite
// amalgamation. FTS5 and sqlite-vec ride along behind opt-in traits.
.library(name: "SQLiteKit", targets: ["SQLiteKit"]),
- // The `sqlite3` shell driver — the argv parser plus the dot-command /
- // REPL engine that reproduces the sqlite3 CLI. ArgumentParser-free and
- // IO-agnostic (it reads / writes / authorizes paths through
- // `ShellKit.Shell`), so a host can drive it in-process on any platform,
- // Android included. The SwiftPorts `sqlite3` executable wraps it in an
- // ArgumentParser command; SwiftBash registers it as a native builtin.
- // Pulls in ShellKit — SDK-only consumers that depend on the `SQLiteKit`
- // product never build this target (or ShellKit) into their link.
- .library(name: "Sqlite3Shell", targets: ["Sqlite3Shell"]),
],
// Opt-in, build-time engine toggles. Both off by default.
// • depending on this package: .package(url: …, traits: ["FTS5", "SQLiteVec"])
@@ -44,14 +35,6 @@ let package = Package(
.package(url: "https://github.com/stephencelis/CSQLite",
exact: "3.50.4",
traits: [.trait(name: "FTS5", condition: .when(traits: ["FTS5"]))]),
- // Host runtime context for the `Sqlite3Shell` driver: the IO sinks it
- // reads / writes through and the sandbox gate (`Shell.resolve` /
- // `Shell.authorize`) every file-touching dot-command passes. Only the
- // ArgumentParser-free `ShellKit` core product is used, so no
- // ArgumentParser enters this package's graph. Pinned to `main` until
- // ShellKit ships a tagged release.
- .package(url: "https://github.com/Cocoanetics/ShellKit",
- branch: "main"),
],
targets: [
// Typed C wrappers for SQLite's variadic printf (`sqlite3_mprintf`),
@@ -114,29 +97,5 @@ let package = Package(
name: "SQLiteKitTests",
dependencies: ["SQLiteKit"]
),
- // The `sqlite3` shell driver: `Parser` (SQLite's single-dash long
- // options, which ArgumentParser can't express) plus `Sqlite3Executable`
- // / `Session` (the dot-command and REPL engine). Depends on the SDK and
- // on ShellKit's core for IO + the sandbox gate; carries no
- // ArgumentParser, so it builds on every platform (Android included).
- .target(
- name: "Sqlite3Shell",
- dependencies: [
- "SQLiteKit",
- .product(name: "ShellKit", package: "ShellKit"),
- ]
- ),
- // Drives `Sqlite3Executable` directly (no ArgumentParser), so it builds
- // and runs on every platform `swift test` covers — Android included —
- // exercising the shell port on the emulator in CI. Depends only on
- // `Sqlite3Shell` (+ ShellKit for the IO harness); it references no
- // `SQLiteKit` symbols directly.
- .testTarget(
- name: "Sqlite3ShellTests",
- dependencies: [
- "Sqlite3Shell",
- .product(name: "ShellKit", package: "ShellKit"),
- ]
- ),
]
)
diff --git a/Sources/Sqlite3Shell/Parser.swift b/Sources/Sqlite3Shell/Parser.swift
deleted file mode 100644
index e9c392a..0000000
--- a/Sources/Sqlite3Shell/Parser.swift
+++ /dev/null
@@ -1,157 +0,0 @@
-import Foundation
-import SQLiteKit
-
-/// Hand-rolled argv parser for the `sqlite3` CLI. SQLite uses single-dash
-/// long options (`-csv`, `-header`, `-separator X`), which ArgumentParser
-/// can't express, so — like the `rg` / `fd` ports — we parse argv directly.
-enum Parser {
- struct Options {
- var databasePath: String?
- var sql: [String] = []
- var mode: OutputMode = .list
- var showHeader = false
- var headerExplicit = false
- var separator = "|"
- var nullValue = ""
- var readonly = false
- var interactive = false
- var echo = false
- var bail = false
- var safe = false
- var initFile: String?
- var commands: [String] = []
- var special: Special = .none
- }
-
- enum Special { case none, help, version }
-
- struct ArgError: Error { let message: String }
-
- // SQLite's single-dash long options are parsed by one flat switch over argv,
- // inherently a long, high-branch function.
- // swiftlint:disable:next cyclomatic_complexity function_body_length
- static func parse(_ argv: [String]) throws -> Options {
- var options = Options()
- var positionals: [String] = []
- var i = 0
-
- func value(for flag: String) throws -> String {
- guard i + 1 < argv.count else {
- throw ArgError(message: "option requires an argument: \(flag)")
- }
- i += 1
- return argv[i]
- }
-
- while i < argv.count {
- let arg = argv[i]
- switch arg {
- case "-help", "--help", "-?": options.special = .help
- case "-version", "--version": options.special = .version
- case "-csv": options.mode = .csv
- case "-json": options.mode = .json
- case "-line": options.mode = .line
- case "-column": options.mode = .column
- case "-list": options.mode = .list
- case "-tabs": options.mode = .tabs
- case "-ascii": options.mode = .ascii
- case "-html": options.mode = .html
- case "-markdown": options.mode = .markdown
- case "-table": options.mode = .table
- case "-box": options.mode = .box
- case "-quote": options.mode = .quote
- case "-header", "-headers": options.showHeader = true; options.headerExplicit = true
- case "-noheader", "-noheaders": options.showHeader = false; options.headerExplicit = true
- case "-readonly": options.readonly = true
- case "-batch": options.interactive = false
- case "-interactive": options.interactive = true
- case "-echo": options.echo = true
- case "-bail": options.bail = true
- case "-safe": options.safe = true
- case "-separator": options.separator = try value(for: arg)
- case "-nullvalue": options.nullValue = try value(for: arg)
- case "-init": options.initFile = try value(for: arg)
- case "-cmd": options.commands.append(try value(for: arg))
- default:
- if arg.hasPrefix("-") && arg.count > 1 {
- throw ArgError(message: "unknown option: \(arg)")
- }
- positionals.append(arg)
- }
- i += 1
- }
-
- if let first = positionals.first {
- options.databasePath = first
- options.sql = Array(positionals.dropFirst())
- }
- return options
- }
-
- static let helpText = """
- Usage: sqlite3 [OPTIONS] FILENAME [SQL]
-
- FILENAME is the SQLite database to open. Omit it (or use ":memory:")
- for a transient in-memory database. A trailing SQL argument runs and
- then exits; otherwise SQL is read from standard input.
-
- OPTIONS:
- -version show the SQLite library version and exit
- -help show this message and exit
- -readonly open the database read-only
- -init FILE run FILE before reading the main input
- -cmd COMMAND run COMMAND before reading the main input
- -echo print each statement before running it
- -bail stop after the first error
- -batch non-interactive mode
- -interactive interactive mode (prompts; SQL run line-by-line)
- -safe refuse dot-commands that touch the filesystem/shell
-
- -list values separated by .separator (default)
- -csv comma-separated values
- -tabs tab-separated values
- -ascii 0x1F/0x1E separated values
- -column left-aligned columns
- -markdown Markdown table
- -table ASCII-art table
- -box Unicode box-drawing table
- -line one value per line
- -json JSON array of objects
- -html HTML
/
rows
- -quote SQL-literal values
- -header / -noheader show or hide column headers
- -separator SEP field separator for -list mode (default "|")
- -nullvalue STR text to print for NULL values (default "")
-
- Dot-commands (at a statement boundary):
- .tables [PATTERN] list tables and views
- .schema [TABLE] show CREATE statements
- .databases list attached databases
- .indexes [TABLE] list indexes
- .mode MODE [TABLE] set output mode (list/csv/tabs/ascii/column/
- markdown/table/box/line/json/html/quote/insert)
- .headers on|off show or hide headers
- .separator SEP set the -list separator
- .nullvalue STR set the NULL placeholder
- .width N1 N2 ... set column widths (negative right-justifies, 0 auto)
- .limit [NAME [VAL]] show or set run-time limits
- .dump [TABLE] dump the database (or one table) as SQL
- .fullschema show the schema plus the ANALYZE (stat) tables
- .echo on|off echo each statement before running it
- .bail on|off stop after an error
- .changes on|off report changed-row counts after each statement
- .eqp on|off print the query plan before each statement
- .print TEXT... print TEXT
- .import FILE TABLE import delimited FILE into TABLE
- .output [FILE] send output to FILE (stdout if omitted)
- .once FILE send the next command's output to FILE
- .read FILE run SQL from FILE
- .open FILE close the current database and open FILE
- .backup [DB] FILE back up the database to FILE
- .restore [DB] FILE restore the database from FILE
- .show show current settings
- .help show this message
- .quit / .exit exit
-
- """
-}
diff --git a/Sources/Sqlite3Shell/Sqlite3Executable.swift b/Sources/Sqlite3Shell/Sqlite3Executable.swift
deleted file mode 100644
index b6e770e..0000000
--- a/Sources/Sqlite3Shell/Sqlite3Executable.swift
+++ /dev/null
@@ -1,1046 +0,0 @@
-import Foundation
-import ShellKit
-import SQLiteKit
-
-// A faithful in-process port of the sqlite3 CLI shell. `handleDot` is a single
-// dispatch switch over the ~30 dot-commands and `Session` is one cohesive REPL
-// type; splitting either to satisfy per-function / per-type budgets would only
-// blur the 1:1 mapping to sqlite3's behavior. The oversized declarations carry
-// scoped `disable:next` directives below; `file_length` is the one size rule
-// that can't be scoped that way (it's reported at EOF), so it's disabled
-// file-wide here — the SDK proper stays strict.
-// swiftlint:disable file_length
-
-/// Argv-level entry point for the `sqlite3` CLI. Returns the process exit
-/// code. Kept in its own enum — mirroring the other ports — so embedders
-/// can drive the CLI behavior in-process.
-public enum Sqlite3Executable {
-
- @discardableResult
- // swiftlint:disable:next cyclomatic_complexity function_body_length
- public static func run(argv: [String],
- stdin: InputSource,
- stdout: OutputSink,
- stderr: OutputSink) async throws -> Int32 {
- let options: Parser.Options
- do {
- options = try Parser.parse(argv)
- } catch let error as Parser.ArgError {
- stderr.write("sqlite3: Error: \(error.message)\n")
- return 1
- }
-
- switch options.special {
- case .help:
- stdout.write(Parser.helpText)
- return 0
- case .version:
- stdout.write("\(SQLiteDatabase.libVersion) \(SQLiteDatabase.sourceID) (64-bit)\n")
- return 0
- case .none:
- break
- }
-
- // Resolve + authorize the database file through ShellKit so the
- // tool honors the host's sandbox / path mapping. A missing name or
- // ":memory:" means a transient in-memory database.
- let location: SQLiteDatabase.Location
- if let path = options.databasePath, path != ":memory:", !path.isEmpty {
- let url = Shell.resolve(path)
- do {
- try await Shell.authorize(url)
- } catch {
- stderr.write("sqlite3: Error: \(error)\n")
- return 1
- }
- location = .file(url.path)
- } else {
- location = .memory
- }
-
- let database: SQLiteDatabase
- do {
- database = try SQLiteDatabase(location, readonly: options.readonly)
- } catch let error as SQLiteError {
- stderr.write("sqlite3: Error: \(error.message)\n")
- return 1
- }
- // -safe also gates SQL-level filesystem access (ATTACH / load_extension)
- // via an authorizer, not just the file-touching dot-commands.
- if options.safe { database.enableSafeMode() }
-
- // None of the columnar command-line flags flip the headers setting:
- // -box/-table/-markdown render a header regardless of it, and -column
- // leaves it off (matching sqlite3 — only the `.mode column`
- // dot-command turns the setting on).
- let showHeader = options.showHeader
-
- // `.show`/`.open` display the database name as the user typed it
- // (sqlite3's zDbFilename), not the sandbox-resolved host path — which
- // also keeps host paths out of a sandboxed shell's output.
- let filename: String = {
- if let p = options.databasePath, p != ":memory:", !p.isEmpty { return p }
- return ":memory:"
- }()
- let session = Session(
- database: database,
- formatter: ResultFormatter(mode: options.mode,
- showHeader: showHeader,
- separator: options.separator,
- nullValue: options.nullValue),
- stdout: stdout,
- stderr: stderr,
- interactive: options.interactive,
- headerExplicit: options.headerExplicit,
- echo: options.echo,
- bail: options.bail,
- safeMode: options.safe,
- filename: filename)
-
- // -init FILE, then any -cmd commands, before the main input.
- if let initFile = options.initFile {
- await session.runScript(path: initFile)
- }
- for command in options.commands where !session.shouldQuit {
- _ = await session.process(command, context: .inline)
- }
-
- // A trailing SQL argument runs and exits; otherwise read stdin.
- if !options.sql.isEmpty {
- for statement in options.sql where !session.shouldQuit {
- if await session.process(statement, context: .inline) == false { break }
- }
- } else if !session.shouldQuit {
- if options.interactive {
- await session.runInteractive(stdin: stdin)
- } else {
- let input = await stdin.readAllString()
- _ = await session.process(input, context: .script)
- }
- }
-
- session.finishOutput()
- return session.exitCode
- }
-}
-
-// swiftlint:disable type_body_length
-/// Holds the mutable shell state (current database, output formatter) and
-/// drives statement / dot-command execution. One instance per invocation.
-final class Session {
- /// Where the current input came from. SQLite formats errors (and sets
- /// exit codes) differently for a command-line argument, a script, and
- /// the interactive REPL.
- enum SourceContext { case inline, script, interactive }
-
- private var database: SQLiteDatabase
- private var formatter: ResultFormatter
- /// The database filename as opened (":memory:" or a path), shown by `.show`.
- private var filename: String
- private let stdout: OutputSink
- private let stderr: OutputSink
- private let interactive: Bool
- /// Whether the user pinned headers via `-header`/`-noheader`/`.headers`.
- /// Until they do, `.mode column` turns headers on (matching sqlite3).
- private var headerExplicit: Bool
- private var echo: Bool
- private var bail: Bool
- /// `-safe` mode: refuse dot-commands that touch the filesystem or shell.
- private let safeMode: Bool
- /// Input line number of the dot-command currently dispatching, for the
- /// `-safe` refusal message (0 for a command-line argument).
- private var safeLine = 0
- private var changesMode = false
- /// When on, print the EXPLAIN QUERY PLAN tree before each statement.
- private var eqp = false
- /// When set, result output is buffered to a file instead of stdout
- /// (`.output` / `.once`).
- private var redirect: Redirect?
-
- private struct Redirect { let url: URL; var buffer: String; let once: Bool }
-
- private(set) var shouldQuit = false
- private(set) var exitCode: Int32 = 0
- private var buffer = ""
-
- init(database: SQLiteDatabase,
- formatter: ResultFormatter,
- stdout: OutputSink,
- stderr: OutputSink,
- interactive: Bool,
- headerExplicit: Bool,
- echo: Bool,
- bail: Bool,
- safeMode: Bool = false,
- filename: String) {
- self.database = database
- self.formatter = formatter
- self.filename = filename
- self.stdout = stdout
- self.stderr = stderr
- self.interactive = interactive
- self.headerExplicit = headerExplicit
- self.echo = echo
- self.bail = bail
- self.safeMode = safeMode
- }
-
- private func out(_ s: String) {
- if redirect != nil { redirect!.buffer += s } else { stdout.write(s) }
- }
- private func err(_ s: String) { stderr.write(s) }
-
- /// Flushes the current `.output`/`.once` redirect to its file and
- /// reverts to stdout. Called at end of input too.
- func finishOutput() {
- guard let active = redirect else { return }
- redirect = nil
- do {
- try active.buffer.write(to: active.url, atomically: true, encoding: .utf8)
- } catch {
- err("Error: unable to write \"\(active.url.path)\"\n")
- exitCode = 1
- }
- }
-
- /// Processes a chunk of input (stdin, a SQL argument, a -cmd string, or
- /// a script file), tracking line numbers for error reporting. Returns
- /// `false` when a SQL error should stop a non-interactive run.
- @discardableResult
- func process(_ text: String, context: SourceContext) async -> Bool {
- let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
- var lineNo = 0
- var statementStart = 1
- // Command-line SQL bails on the first error; a script keeps going
- // unless `.bail on` is set (matching sqlite3).
- func stopsOnError() -> Bool { context == .inline || bail }
- for line in lines {
- if shouldQuit { return true }
- lineNo += 1
- let trimmed = line.trimmingCharacters(in: .whitespaces)
- // Dot-commands are only recognized at a statement boundary.
- if buffer.isEmpty && trimmed.hasPrefix(".") {
- if echo { out(trimmed + "\n") }
- // sqlite3 reports a command-line argument as "line 0".
- safeLine = context == .inline ? 0 : lineNo
- await handleDot(trimmed)
- continue
- }
- if buffer.isEmpty { statementStart = lineNo }
- buffer += line + "\n"
- if SQLiteDatabase.isCompleteStatement(buffer) {
- let sql = buffer
- buffer = ""
- if !(await runStatement(sql, startLine: statementStart, context: context)) && stopsOnError() {
- return false
- }
- }
- }
- // SQLite runs a final statement even without a trailing semicolon.
- let leftover = buffer.trimmingCharacters(in: .whitespacesAndNewlines)
- buffer = ""
- if !leftover.isEmpty {
- if !(await runStatement(leftover, startLine: statementStart, context: context)) && stopsOnError() {
- return false
- }
- }
- return true
- }
-
- /// The startup banner sqlite3 prints when entering interactive mode:
- /// the library version plus the date/time prefix of the source id.
- static var banner: String {
- "SQLite version \(SQLiteDatabase.libVersion) \(String(SQLiteDatabase.sourceID.prefix(19)))\n"
- + "Enter \".help\" for usage hints.\n"
- }
-
- /// The line-buffered interactive REPL (`-interactive`): a startup
- /// banner, then `sqlite> ` / ` ...> ` prompts until `.quit` or EOF.
- ///
- /// Triggered by the explicit flag rather than auto-detecting a TTY —
- /// an embedded builtin doesn't own the terminal, so interactivity is
- /// the host's call. A SIGINT→`sqlite3_interrupt` handler is likewise
- /// left to the host: installing a process-global signal handler from a
- /// library would be wrong.
- func runInteractive(stdin: InputSource) async {
- out(Self.banner)
- var lineNo = 0
- var statementStart = 1
- while !shouldQuit {
- // The continuation prompt shows while a statement is still open.
- out(buffer.isEmpty ? "sqlite> " : " ...> ")
- guard let line = await stdin.readLine() else { out("\n"); break }
- lineNo += 1
- let trimmed = line.trimmingCharacters(in: .whitespaces)
- // Dot-commands are recognized only at a statement boundary.
- if buffer.isEmpty && trimmed.hasPrefix(".") {
- if echo { out(trimmed + "\n") }
- safeLine = lineNo
- await handleDot(trimmed)
- continue
- }
- if buffer.isEmpty { statementStart = lineNo }
- buffer += line + "\n"
- if SQLiteDatabase.isCompleteStatement(buffer) {
- let sql = buffer
- buffer = ""
- _ = await runStatement(sql, startLine: statementStart, context: .interactive)
- }
- }
- // Run a trailing statement left unterminated at EOF, like sqlite3.
- let leftover = buffer.trimmingCharacters(in: .whitespacesAndNewlines)
- buffer = ""
- if !leftover.isEmpty {
- _ = await runStatement(leftover, startLine: statementStart, context: .interactive)
- }
- }
-
- /// Runs one chunk of SQL and renders any result sets. Returns `false`
- /// on error (after reporting it).
- @discardableResult
- // swiftlint:disable:next cyclomatic_complexity
- private func runStatement(_ sql: String, startLine: Int, context: SourceContext) async -> Bool {
- if echo { out(sql.trimmingCharacters(in: .whitespacesAndNewlines) + "\n") }
- if eqp { renderQueryPlan(sql) }
- // Gate any ATTACH'd file through the host sandbox before SQLite opens
- // it — the same resolve/authorize path the db file and `.read` /
- // `.open` take. (`-safe` blocks ATTACH outright via its authorizer,
- // so this is the non-safe confinement path; `:memory:` / temp ATTACHes
- // touch no file and are skipped.)
- if !safeMode, sql.range(of: "attach", options: .caseInsensitive) != nil {
- for target in database.attachTargets(in: sql)
- where !target.isEmpty && target != ":memory:" {
- do {
- try await Shell.authorize(Shell.resolve(target))
- } catch {
- err("Error: \(error)\n")
- exitCode = 1
- return false
- }
- }
- }
- do {
- for set in try database.evaluate(sql) {
- out(formatter.render(set))
- }
- if changesMode {
- out("changes: \(database.changes) total_changes: \(database.totalChanges)\n")
- }
- if redirect?.once == true { finishOutput() }
- return true
- } catch let error as SQLiteError {
- // A `-safe` authorizer denial surfaces as a generic SQLITE_AUTH
- // error; replace it with sqlite3's safe-mode message and halt
- // (line 0 for a command-line argument).
- if let violation = database.safeModeViolation {
- database.clearSafeModeViolation()
- err("line \(context == .inline ? 0 : startLine): \(violation)\n")
- exitCode = 1
- shouldQuit = true
- return false
- }
- report(error, sql: sql, startLine: startLine, context: context)
- return false
- } catch {
- err("Error: \(error)\n")
- exitCode = 1
- return false
- }
- }
-
- /// Reproduces sqlite3's error reporting: script input gets
- /// `Parse/Runtime error near line N:` (exit 1); a command-line argument
- /// gets `Error: in prepare,/stepping,` (exit = SQLite result code).
- /// Both append a caret pointer when SQLite reports an error offset, and
- /// runtime errors append the result code.
- private func report(_ error: SQLiteError, sql: String, startLine: Int, context: SourceContext) {
- let header: String
- switch context {
- case .script:
- let line = errorLine(start: startLine, sql: sql, offset: error.offset)
- header = (error.phase == .prepare ? "Parse error" : "Runtime error") + " near line \(line): "
- exitCode = 1
- case .inline:
- header = error.phase == .prepare ? "Error: in prepare, " : "Error: stepping, "
- exitCode = error.code
- case .interactive:
- // The REPL keeps going on error (no exit code) and omits the
- // line number that script context carries.
- header = (error.phase == .prepare ? "Parse error: " : "Runtime error: ")
- }
- var message = error.message
- if error.phase == .step { message += " (\(error.code))" }
- err(header + message + "\n")
- if error.offset >= 0 {
- err(caretBlock(sql: sql, offset: Int(error.offset)))
- }
- }
-
- private func errorLine(start: Int, sql: String, offset: Int32) -> Int {
- guard offset >= 0 else { return start }
- let newlines = sql.utf8.prefix(Int(offset)).reduce(0) { $0 + ($1 == 0x0a ? 1 : 0) }
- return start + newlines
- }
-
- /// Builds the ` \n ^--- error here\n` block that
- /// sqlite3 prints under a failing statement.
- private func caretBlock(sql: String, offset: Int) -> String {
- let bytes = Array(sql.utf8)
- let position = min(max(offset, 0), bytes.count)
- var lineStart = position
- while lineStart > 0 && bytes[lineStart - 1] != 0x0a { lineStart -= 1 }
- var lineEnd = position
- while lineEnd < bytes.count && bytes[lineEnd] != 0x0a { lineEnd += 1 }
- // swiftlint:disable:next optional_data_string_conversion
- let line = String(decoding: bytes[lineStart.. 3 ? (row[3].cliText ?? "") : "")
- }
- var output = "QUERY PLAN\n"
- func level(_ parent: Int, _ prefix: String) {
- let children = nodes.filter { $0.parent == parent }
- for (i, node) in children.enumerated() {
- let last = i == children.count - 1
- output += prefix + (last ? "`--" : "|--") + node.detail + "\n"
- level(node.id, prefix + (last ? " " : "| "))
- }
- }
- level(0, "")
- out(output)
- }
-
- // MARK: Dot-commands
-
- // swiftlint:disable:next cyclomatic_complexity function_body_length
- private func handleDot(_ line: String) async {
- let tokens = Self.tokenize(line)
- guard let command = tokens.first else { return }
- let args = Array(tokens.dropFirst())
-
- // `-safe` refuses any dot-command that could touch the filesystem or
- // shell, and aborts — matching sqlite3. (SQL-level restrictions like
- // ATTACH / load_extension would need an authorizer and are tracked
- // separately.)
- if safeMode, let message = Self.safeModeBlock(command, args) {
- err("line \(safeLine): \(message)\n")
- exitCode = 1
- shouldQuit = true
- return
- }
-
- switch command {
- case ".quit", ".exit":
- shouldQuit = true
-
- case ".help":
- out(Parser.helpText)
-
- case ".tables":
- introspect {
- var names = try database.tableNames()
- if let pattern = args.first {
- names = names.filter { Self.glob(pattern, matches: $0) }
- }
- if !names.isEmpty { out(Self.columnize(names)) }
- }
-
- case ".schema":
- introspect {
- // Filter on tbl_name so `.schema foo` also returns foo's
- // indexes/triggers (matching sqlite3). Views get a trailing
- // `/* name(cols) */` comment listing their result columns.
- let filter = args.first.map { " AND tbl_name = '\(SQLiteDatabase.quote($0))'" } ?? ""
- let rows = try database.evaluate("""
- SELECT type, name, sql FROM sqlite_schema
- WHERE sql NOT NULL\(filter) ORDER BY rowid;
- """).first?.rows ?? []
- var lines: [String] = []
- for r in rows {
- guard case .text(let type) = r[0], case .text(let name) = r[1],
- case .text(let sql) = r[2] else { continue }
- // sqlite3 appends the /* view(cols) */ comment only when the
- // view's columns resolve; an unpreparable view (e.g. one
- // referencing a missing table) prints just its stored CREATE.
- if type == "view",
- let cols = (try? database.evaluate(
- "SELECT * FROM \(SQLiteDatabase.quoteIdentifier(name)) LIMIT 0;"))?.first?.columns,
- !cols.isEmpty {
- let list = cols.map { SQLiteDatabase.quoteIdentifier($0) }.joined(separator: ",")
- lines.append("\(sql)\n/* \(SQLiteDatabase.quoteIdentifier(name))(\(list)) */;")
- } else {
- lines.append(sql + ";")
- }
- }
- if !lines.isEmpty { out(lines.joined(separator: "\n") + "\n") }
- }
-
- case ".fullschema":
- introspect {
- // The schema as plain statements (no view comments), then —
- // if ANALYZE has run — the sqlite_stat[134] contents as
- // INSERTs bracketed by `ANALYZE sqlite_schema;` markers,
- // exactly like sqlite3's `.fullschema`.
- let schema = try database.evaluate("""
- SELECT sql FROM (
- SELECT sql, type, name, rowid AS x FROM sqlite_schema UNION ALL
- SELECT sql, type, name, rowid FROM sqlite_temp_schema)
- WHERE type != 'meta' AND sql NOT NULL AND name NOT LIKE 'sqlite_%'
- ORDER BY x;
- """).first?.rows.compactMap { $0.first?.cliText } ?? []
- for sql in schema { out(sql + ";\n") }
- let hasStats = try !(database.evaluate(
- "SELECT 1 FROM sqlite_schema WHERE name GLOB 'sqlite_stat[134]' LIMIT 1;")
- .first?.rows.isEmpty ?? true)
- guard hasStats else { out("/* No STAT tables available */\n"); return }
- out("ANALYZE sqlite_schema;\n")
- for statTable in ["sqlite_stat1", "sqlite_stat4"] where try tableExists(statTable) {
- let rows = try database.evaluate("SELECT * FROM \(statTable);").first?.rows ?? []
- for row in rows {
- out("INSERT INTO \(statTable) VALUES(\(row.map(\.sqlLiteral).joined(separator: ",")));\n")
- }
- }
- out("ANALYZE sqlite_schema;\n")
- }
-
- case ".databases":
- introspect {
- // sqlite3 prints: : <"" if no file else path>
- let lines = try database.databaseList().map { db -> String in
- let file = db.file.isEmpty ? "\"\"" : db.file
- return "\(db.name): \(file) \(database.isReadOnly(db.name) ? "r/o" : "r/w")"
- }
- if !lines.isEmpty { out(lines.joined(separator: "\n") + "\n") }
- }
-
- case ".indexes", ".indices":
- introspect {
- let filter = args.first.map { " AND tbl_name = '\(SQLiteDatabase.quote($0))'" } ?? ""
- let sql = "SELECT name FROM sqlite_schema WHERE type='index'\(filter) ORDER BY name;"
- let names = try database.evaluate(sql).first?.rows.compactMap { $0.first?.cliText } ?? []
- if !names.isEmpty { out(Self.columnize(names)) }
- }
-
- case ".dump":
- introspect {
- out("PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n")
- let only = args.first
- let tableFilter = only.map { " AND name = '\(SQLiteDatabase.quote($0))'" } ?? ""
- // Each table: its CREATE statement, then its rows as INSERTs.
- let tables = try database.evaluate("""
- SELECT name, sql FROM sqlite_schema
- WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql NOT NULL\(tableFilter)
- ORDER BY rowid;
- """).first?.rows ?? []
- for t in tables {
- guard case .text(let name) = t[0], case .text(let createSQL) = t[1] else { continue }
- out(createSQL + ";\n")
- // Quote the table name the way sqlite3 does — bare for a
- // simple identifier, double-quoted otherwise — so both the
- // read-back and the emitted INSERTs stay valid for any name.
- let ident = SQLiteDatabase.quoteIdentifier(name)
- // SELECT only the non-generated columns so the emitted
- // INSERT replays (sqlite3 omits VIRTUAL/STORED generated
- // columns — their values can't be inserted). The INSERT
- // itself stays column-list-free, exactly like sqlite3.
- let cols = try database.nonGeneratedColumns(of: name)
- let selectList = cols.isEmpty
- ? "*"
- : cols.map { SQLiteDatabase.quoteIdentifier($0) }.joined(separator: ",")
- let rows = try database.evaluate("SELECT \(selectList) FROM \(ident);").first?.rows ?? []
- for row in rows {
- out("INSERT INTO \(ident) VALUES(\(row.map(\.sqlLiteral).joined(separator: ",")));\n")
- }
- }
- // AUTOINCREMENT high-water marks live in the internal
- // sqlite_sequence table. sqlite3 re-emits its rows (no CREATE —
- // it is created implicitly by the first AUTOINCREMENT table)
- // after all table data and before views/triggers/indexes, and
- // only for a full dump.
- if only == nil, try tableExists("sqlite_sequence") {
- let seq = try database.evaluate(
- "SELECT name, seq FROM sqlite_sequence ORDER BY rowid;").first?.rows ?? []
- for row in seq {
- out("INSERT INTO sqlite_sequence VALUES(\(row.map(\.sqlLiteral).joined(separator: ",")));\n")
- }
- }
- // Then views + triggers, then indexes last (sqlite3's order).
- let objFilter = only.map { " AND tbl_name = '\(SQLiteDatabase.quote($0))'" } ?? ""
- for types in ["'view','trigger'", "'index'"] {
- let sqls = try database.evaluate("""
- SELECT sql FROM sqlite_schema
- WHERE type IN (\(types)) AND sql NOT NULL\(objFilter) ORDER BY rowid;
- """).first?.rows.compactMap { $0.first?.cliText } ?? []
- for sql in sqls { out(sql + ";\n") }
- }
- out("COMMIT;\n")
- }
-
- case ".mode":
- guard let raw = args.first, let mode = OutputMode(rawValue: raw) else {
- err("Error: .mode expects one of: \(OutputMode.allCases.map(\.rawValue).joined(separator: ", "))\n")
- return
- }
- formatter.mode = mode
- // The `.mode csv` dot-command uses CRLF row terminators; every
- // other mode (and the `-csv` command-line flag) uses LF. This is
- // sqlite3's one genuine flag-vs-dot-command divergence.
- formatter.rowSeparator = mode == .csv ? "\r\n" : "\n"
- // `.mode insert [TABLE]` carries an optional destination table.
- if mode == .insert { formatter.insertTable = args.count > 1 ? args[1] : nil }
- // Only `.mode column` flips the headers *setting* on; box / table /
- // markdown always *display* a header (their renderers don't gate on
- // it) but leave the setting untouched, which `.show` reflects.
- // Matches sqlite3's `.mode` dot-command.
- if !headerExplicit, mode == .column {
- formatter.showHeader = true
- }
-
- case ".headers", ".header":
- guard let value = args.first else {
- err("Error: .headers expects on or off\n")
- return
- }
- formatter.showHeader = ["on", "1", "yes", "true"].contains(value.lowercased())
- headerExplicit = true
-
- case ".separator":
- guard let value = args.first else {
- err("Error: .separator expects a value\n")
- return
- }
- formatter.separator = value
-
- case ".nullvalue":
- formatter.nullValue = args.first ?? ""
-
- case ".width", ".widths":
- // `.width N1 N2 …` sets per-column display widths for the
- // column-family modes (negative = right-justify, 0 = auto);
- // `.width` with no args clears them. Non-numeric args read as 0,
- // matching sqlite3's atoi-style parse.
- formatter.widths = args.map { Int($0) ?? 0 }
-
- case ".limit":
- handleLimit(args)
-
- case ".echo":
- if let value = Self.onOff(args.first) { echo = value }
-
- case ".bail":
- if let value = Self.onOff(args.first) { bail = value }
-
- case ".changes":
- if let value = Self.onOff(args.first) { changesMode = value }
-
- case ".eqp":
- // "full" also dumps bytecode in sqlite3; we render the plan tree.
- if args.first?.lowercased() == "full" { eqp = true } else if let value = Self.onOff(args.first) { eqp = value }
-
- case ".print":
- out(args.joined(separator: " ") + "\n")
-
- case ".output":
- finishOutput()
- if let path = args.first {
- guard let url = await resolveAuthorized(path) else { return }
- redirect = Redirect(url: url, buffer: "", once: false)
- }
-
- case ".once":
- guard let path = args.first else {
- err("Error: .once expects a filename\n")
- return
- }
- finishOutput()
- guard let url = await resolveAuthorized(path) else { return }
- redirect = Redirect(url: url, buffer: "", once: true)
-
- case ".import":
- guard args.count >= 2 else {
- err("Error: .import expects FILE and TABLE\n")
- return
- }
- guard let url = await resolveAuthorized(args[0]) else { return }
- let text: String
- do {
- text = try String(contentsOf: url, encoding: .utf8)
- } catch {
- err("Error: cannot open \"\(args[0])\"\n")
- exitCode = 1
- return
- }
- importDelimited(text, into: args[1])
-
- case ".backup":
- guard let (dbName, path) = Self.dbAndFile(args) else {
- err("Error: .backup expects ?DB? FILE\n")
- return
- }
- guard let url = await resolveAuthorized(path) else { return }
- do {
- let destination = try SQLiteDatabase(.file(url.path))
- defer { destination.close() }
- try database.backup(to: destination, sourceName: dbName)
- } catch let error as SQLiteError {
- err("Error: \(error.message)\n")
- exitCode = 1
- } catch {
- err("Error: \(error)\n")
- exitCode = 1
- }
-
- case ".restore":
- guard let (dbName, path) = Self.dbAndFile(args) else {
- err("Error: .restore expects ?DB? FILE\n")
- return
- }
- guard let url = await resolveAuthorized(path) else { return }
- do {
- let source = try SQLiteDatabase(.file(url.path))
- defer { source.close() }
- try source.backup(to: database, destinationName: dbName)
- } catch let error as SQLiteError {
- err("Error: \(error.message)\n")
- exitCode = 1
- } catch {
- err("Error: \(error)\n")
- exitCode = 1
- }
-
- case ".show":
- // Mirrors sqlite3's .show: 12 labels right-justified to width 12.
- // explain / stats / rowseparator / width aren't modeled yet, so
- // they show sqlite3's defaults (matches the common no-.width case).
- func showEscape(_ s: String) -> String {
- s.replacingOccurrences(of: "\t", with: "\\t")
- .replacingOccurrences(of: "\n", with: "\\n")
- .replacingOccurrences(of: "\r", with: "\\r")
- }
- // sqlite3 derives the separators from the active mode: csv → , / \r\n,
- // tabs → \t, ascii → \037 / \036, quote → , ; other modes use the
- // configured list separator. (A `.separator` override issued *after*
- // a mode change isn't tracked separately here — a rare edge.)
- let colsep: String, rowsep: String
- switch formatter.mode {
- // csv reports its actual row terminator (CRLF via `.mode csv`,
- // LF via the `-csv` flag) — see formatter.rowSeparator.
- case .csv: colsep = ","; rowsep = showEscape(formatter.rowSeparator)
- case .tabs: colsep = "\\t"; rowsep = "\\n"
- case .ascii: colsep = "\\037"; rowsep = "\\036"
- case .quote: colsep = ","; rowsep = "\\n"
- default: colsep = showEscape(formatter.separator); rowsep = "\\n"
- }
- // sqlite3 reports `tabs` as its underlying `list` mode, and the
- // column-family modes append their wrap/wordwrap/quote options.
- // We don't expose those knobs yet, so they print sqlite3's
- // defaults (--wrap 60 --wordwrap off --noquote).
- let modeBase = formatter.mode == .tabs ? "list" : formatter.mode.rawValue
- let columnFamily: Set = [.column, .box, .table, .markdown]
- let modeField = columnFamily.contains(formatter.mode)
- ? "\(modeBase) --wrap 60 --wordwrap off --noquote"
- : modeBase
- let fields: [(String, String)] = [
- ("echo", echo ? "on" : "off"),
- ("eqp", eqp ? "on" : "off"),
- ("explain", "auto"),
- ("headers", formatter.showHeader ? "on" : "off"),
- ("mode", modeField),
- ("nullvalue", "\"\(showEscape(formatter.nullValue))\""),
- ("output", redirect?.url.path ?? "stdout"),
- ("colseparator", "\"\(colsep)\""),
- ("rowseparator", "\"\(rowsep)\""),
- ("stats", "off"),
- // sqlite3 prints each configured width followed by a space.
- ("width", formatter.widths.map { "\($0) " }.joined()),
- ("filename", filename)
- ]
- let body = fields.map { label, value in
- String(repeating: " ", count: max(0, 12 - label.count)) + label + ": " + value
- }.joined(separator: "\n")
- out(body + "\n")
-
- case ".open":
- guard let path = args.first else {
- err("Error: .open expects a filename\n")
- return
- }
- // ":memory:" / an empty name opens a fresh in-memory database
- // (matching the command-line argument and real sqlite3) — it is
- // never resolved to a path, so `.open :memory:` can't create a
- // stray `:memory:` file on disk.
- let location: SQLiteDatabase.Location
- if path != ":memory:", !path.isEmpty {
- guard let url = await resolveAuthorized(path) else { return }
- location = .file(url.path)
- } else {
- location = .memory
- }
- do {
- let replacement = try SQLiteDatabase(location)
- database.close()
- database = replacement
- if safeMode { database.enableSafeMode() } // re-arm on the new connection
- filename = path // as-typed, matching sqlite3's `.show`
- } catch let error as SQLiteError {
- err("Error: \(error.message)\n")
- } catch {
- err("Error: \(error)\n")
- }
-
- case ".read":
- guard let path = args.first else {
- err("Error: .read expects a filename\n")
- return
- }
- await runScript(path: path)
-
- default:
- err("Error: unknown command or invalid arguments: \"\(command.dropFirst())\". Enter \".help\" for help\n")
- }
- }
-
- /// Reads a SQL/dot-command script through ShellKit's sandbox gate and
- /// runs it.
- func runScript(path: String) async {
- guard let url = await resolveAuthorized(path) else { exitCode = 1; return }
- do {
- let text = try String(contentsOf: url, encoding: .utf8)
- _ = await process(text, context: .script)
- } catch {
- err("Error: cannot open \"\(path)\"\n")
- exitCode = 1
- }
- }
-
- /// Converts a user-supplied path to a sandbox URL and asks the host to
- /// authorize it. Returns `nil` (after reporting) if denied.
- private func resolveAuthorized(_ path: String) async -> URL? {
- let url = Shell.resolve(path)
- do {
- try await Shell.authorize(url)
- return url
- } catch {
- err("Error: \(error)\n")
- return nil
- }
- }
-
- private func introspect(_ body: () throws -> Void) {
- do {
- try body()
- } catch let error as SQLiteError {
- err("Error: \(error.message)\n")
- } catch {
- err("Error: \(error)\n")
- }
- }
-
- /// SQLite run-time limits in the order `.limit` lists them, paired with
- /// their stable `SQLITE_LIMIT_*` codes (0…11, part of the public API).
- static let limitTable: [(name: String, code: Int32)] = [
- ("length", 0), ("sql_length", 1), ("column", 2), ("expr_depth", 3),
- ("compound_select", 4), ("vdbe_op", 5), ("function_arg", 6),
- ("attached", 7), ("like_pattern_length", 8), ("variable_number", 9),
- ("trigger_depth", 10), ("worker_threads", 11)
- ]
-
- /// `.limit` — list every limit, show one, or set one and show the new
- /// value. sqlite3 right-justifies the name in a 20-wide field.
- private func handleLimit(_ args: [String]) {
- func show(_ name: String, _ code: Int32) {
- let value = database.limit(code)
- out(String(repeating: " ", count: max(0, 20 - name.count)) + name + " \(value)\n")
- }
- guard let name = args.first else {
- for (name, code) in Self.limitTable { show(name, code) }
- return
- }
- guard let entry = Self.limitTable.first(where: { $0.name == name }) else {
- err("unknown limit: \"\(name)\"\n")
- return
- }
- if args.count >= 2, let newValue = Int32(args[1]) {
- database.limit(entry.code, newValue: newValue)
- }
- show(entry.name, entry.code)
- }
-
- /// The `-safe` refusal message for a filesystem/shell dot-command, or
- /// `nil` if it's allowed. `.open` of a real file gets its own message;
- /// `:memory:` (or no argument) stays allowed.
- static func safeModeBlock(_ command: String, _ args: [String]) -> String? {
- switch command {
- case ".open":
- if let target = args.first, target != ":memory:", !target.isEmpty {
- return "cannot open disk-based database files in safe mode"
- }
- return nil
- case ".read", ".import", ".output", ".once", ".backup", ".restore":
- return "cannot run \(command) in safe mode"
- default:
- return nil
- }
- }
-
- /// Column separator implied by the current mode (used by `.import`).
- private func currentColumnSeparator() -> Character {
- switch formatter.mode {
- case .csv: return ","
- case .tabs: return "\t"
- case .ascii: return "\u{1F}"
- default: return formatter.separator.first ?? "|"
- }
- }
-
- private func tableExists(_ name: String) throws -> Bool {
- let sql = "SELECT 1 FROM sqlite_schema WHERE type='table' AND name='\(SQLiteDatabase.quote(name))' LIMIT 1;"
- return !((try database.evaluate(sql).first?.rows.isEmpty) ?? true)
- }
-
- /// Imports delimited rows from `text` into `table`, creating the table
- /// from the header row (all TEXT columns) when it doesn't yet exist —
- /// matching sqlite3's `.import`.
- private func importDelimited(_ text: String, into table: String) {
- let rows = Self.parseDelimited(text, separator: currentColumnSeparator())
- guard !rows.isEmpty else { return }
- let ident = table.replacingOccurrences(of: "\"", with: "\"\"")
- introspect {
- var data = rows
- if try !tableExists(table) {
- data = Array(rows.dropFirst())
- let cols = rows[0]
- .map { "\"\($0.replacingOccurrences(of: "\"", with: "\"\""))\" TEXT" }
- .joined(separator: ", ")
- // sqlite normalizes "IF NOT EXISTS" out of the stored schema,
- // so .schema/.dump show `CREATE TABLE "t"(…)`. Real sqlite3's
- // import retains the prefix in stored text; matching that
- // would mean bypassing the normalizer — a cosmetic-only diff.
- try database.evaluate("CREATE TABLE IF NOT EXISTS \"\(ident)\"(\n\(cols));")
- }
- for row in data {
- let values = row
- .map { "'\($0.replacingOccurrences(of: "'", with: "''"))'" }
- .joined(separator: ",")
- try database.evaluate("INSERT INTO \"\(ident)\" VALUES(\(values));")
- }
- }
- }
-
- // MARK: Small helpers
-
- /// Parses delimited text — CSV-style: double-quoted fields, `""`
- /// escapes, embedded separators and newlines — into rows of fields.
- private static func parseDelimited(_ text: String, separator: Character) -> [[String]] {
- var rows: [[String]] = []
- var row: [String] = []
- var field = ""
- var inQuotes = false
- let chars = Array(text)
- var i = 0
- while i < chars.count {
- let c = chars[i]
- if inQuotes {
- if c == "\"" {
- if i + 1 < chars.count && chars[i + 1] == "\"" { field.append("\""); i += 2; continue }
- inQuotes = false
- } else {
- field.append(c)
- }
- } else {
- switch c {
- case "\"": inQuotes = true
- case separator: row.append(field); field = ""
- case "\n": row.append(field); rows.append(row); row = []; field = ""
- case "\r": break
- default: field.append(c)
- }
- }
- i += 1
- }
- if !field.isEmpty || !row.isEmpty { row.append(field); rows.append(row) }
- return rows
- }
-
- /// Parses an on/off argument; returns nil for an unrecognized value.
- private static func onOff(_ value: String?) -> Bool? {
- switch value?.lowercased() {
- case "on", "1", "yes", "true": return true
- case "off", "0", "no", "false": return false
- default: return nil
- }
- }
-
- /// Parses a `?DB? FILE` argument list (db name defaults to "main").
- private static func dbAndFile(_ args: [String]) -> (db: String, file: String)? {
- if args.count >= 2 { return (args[0], args[1]) }
- if args.count == 1 { return ("main", args[0]) }
- return nil
- }
-
- /// Splits a dot-command line into tokens, honoring double quotes.
- private static func tokenize(_ line: String) -> [String] {
- var tokens: [String] = []
- var current = ""
- var inQuote = false
- var sawToken = false
- for ch in line {
- if ch == "\"" {
- inQuote.toggle()
- sawToken = true
- } else if ch == " " && !inQuote {
- if sawToken { tokens.append(current); current = ""; sawToken = false }
- } else {
- current.append(ch)
- sawToken = true
- }
- }
- if sawToken { tokens.append(current) }
- return tokens
- }
-
- /// Tiny GLOB matcher (`*` and `?`) for `.tables PATTERN`.
- private static func glob(_ pattern: String, matches name: String) -> Bool {
- let escaped = NSRegularExpression.escapedPattern(for: pattern)
- .replacingOccurrences(of: "\\*", with: ".*")
- .replacingOccurrences(of: "\\?", with: ".")
- return name.range(of: "^\(escaped)$", options: .regularExpression) != nil
- }
-
- /// Packs names into space-padded columns the way sqlite3's `.tables`
- /// does: column-major order, every entry left-padded to the longest
- /// name, columns separated by two spaces, 80-column budget.
- private static func columnize(_ names: [String], width totalWidth: Int = 80) -> String {
- guard !names.isEmpty else { return "" }
- let maxLen = names.map(\.count).max() ?? 0
- let printCols = max(1, totalWidth / (maxLen + 2))
- let printRows = (names.count + printCols - 1) / printCols
- var output = ""
- for i in 0..2-member fixture tuples carry scoped directives below.
-// swiftlint:disable file_length
-
-// swiftlint:disable:next type_body_length
-@Suite struct Sqlite3ExecutableTests {
-
- /// Drives the executable with a fake stdin and captures stdout/stderr
- /// via ShellKit's `OutputSink` / `InputSource` — same harness as the
- /// other CLI ports. Uses an in-memory database so no filesystem (and
- /// no sandbox authorization) is involved.
- private func run(_ argv: [String], input: String = "") async throws
- // swiftlint:disable:next large_tuple
- -> (stdout: String, stderr: String, exit: Int32) {
- let stdinSource: InputSource = .string(input)
- let stdoutSink = OutputSink()
- let stderrSink = OutputSink()
-
- let exit = try await Sqlite3Executable.run(
- argv: argv,
- stdin: stdinSource,
- stdout: stdoutSink,
- stderr: stderrSink)
-
- stdoutSink.finish()
- stderrSink.finish()
- return (await stdoutSink.readAllString(), await stderrSink.readAllString(), exit)
- }
-
- @Test func inlineSelect() async throws {
- let r = try await run([":memory:", "SELECT 1 + 1;"])
- #expect(r.exit == 0)
- #expect(r.stdout == "2\n")
- }
-
- @Test func crudViaStdin() async throws {
- let script = """
- CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT, qty INTEGER);
- INSERT INTO t(name, qty) VALUES ('apple', 3), ('banana', 5);
- UPDATE t SET qty = qty + 1 WHERE name = 'apple';
- DELETE FROM t WHERE name = 'banana';
- SELECT * FROM t;
- """
- let r = try await run([":memory:"], input: script)
- #expect(r.exit == 0)
- #expect(r.stdout == "1|apple|4\n")
- }
-
- @Test func csvFlagWithHeader() async throws {
- // The `-csv` flag emits LF row terminators — sqlite3 reserves CRLF
- // for the `.mode csv` dot-command (see csvModeUsesCRLF).
- let r = try await run(["-csv", "-header", ":memory:", "SELECT 1 AS a, 'x' AS b;"])
- #expect(r.exit == 0)
- #expect(r.stdout == "a,b\n1,x\n")
- }
-
- @Test func jsonFlag() async throws {
- let r = try await run(["-json", ":memory:", "SELECT 1 AS a;"])
- #expect(r.stdout == "[{\"a\":1}]\n")
- }
-
- @Test func jsonBlob() async throws {
- let r = try await run(["-json", ":memory:", "SELECT x'00ff' AS b;"])
- #expect(r.stdout == "[{\"b\":\"\\u0000\\u00ff\"}]\n")
- }
-
- @Test func dotPrint() async throws {
- let r = try await run([":memory:"], input: ".print hello world\n.print \"quoted arg\"\n")
- #expect(r.stdout == "hello world\nquoted arg\n")
- }
-
- @Test func dotEcho() async throws {
- let r = try await run([":memory:"], input: ".echo on\n.headers on\nSELECT 1 AS a;\n")
- #expect(r.stdout == ".headers on\nSELECT 1 AS a;\na\n1\n")
- }
-
- @Test func dotChanges() async throws {
- let r = try await run([":memory:"],
- input: ".changes on\nCREATE TABLE t(x);\nINSERT INTO t VALUES(1),(2),(3);\n")
- #expect(r.stdout.contains("changes: 0 total_changes: 0"))
- #expect(r.stdout.contains("changes: 3 total_changes: 3"))
- }
-
- @Test func eqpShowsScanPlan() async throws {
- let r = try await run([":memory:"], input: "CREATE TABLE t(x);\n.eqp on\nSELECT * FROM t;\n")
- #expect(r.stdout == "QUERY PLAN\n`--SCAN t\n")
- }
-
- @Test func eqpShowsIndexSearch() async throws {
- let r = try await run([":memory:"], input: """
- CREATE TABLE t(id INTEGER, name TEXT);
- CREATE INDEX ix ON t(name);
- .eqp on
- SELECT * FROM t WHERE name = 'bob';
- """)
- #expect(r.stdout.contains("QUERY PLAN\n`--SEARCH t USING INDEX ix (name=?)"))
- }
-
- @Test func eqpOffStopsPlans() async throws {
- let r = try await run([":memory:"], input: ".eqp on\n.eqp off\nSELECT 1;\n")
- #expect(r.stdout == "1\n")
- }
-
- @Test func scriptContinuesAfterError() async throws {
- // A script keeps going after an error (exit 1), matching sqlite3.
- let r = try await run([":memory:"], input: "SELECT * FROM nope;\nSELECT 99;\n")
- #expect(r.exit == 1)
- #expect(r.stdout == "99\n")
- #expect(r.stderr.contains("no such table: nope"))
- }
-
- @Test func bailStopsAfterError() async throws {
- let r = try await run([":memory:"], input: ".bail on\nSELECT * FROM nope;\nSELECT 99;\n")
- #expect(r.exit == 1)
- #expect(r.stdout == "")
- #expect(r.stderr.contains("no such table: nope"))
- }
-
- /// Creates a unique temp directory removed at the end of `body`.
- private func withTempDir(_ body: (URL) async throws -> Void) async throws {
- let dir = FileManager.default.temporaryDirectory
- .appendingPathComponent("sqlite3-tests-" + UUID().uuidString)
- try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: dir) }
- try await body(dir)
- }
-
- @Test func dotOutputRedirectsThenReverts() async throws {
- try await withTempDir { dir in
- let file = dir.appendingPathComponent("out.txt")
- let r = try await run([":memory:"],
- input: ".output \(file.path)\nSELECT 1;\n.output\nSELECT 2;\n")
- #expect(r.stdout == "2\n")
- #expect(try String(contentsOf: file, encoding: .utf8) == "1\n")
- }
- }
-
- @Test func dotOnceRedirectsNextOnly() async throws {
- try await withTempDir { dir in
- let file = dir.appendingPathComponent("once.txt")
- let r = try await run([":memory:"],
- input: ".once \(file.path)\nSELECT 10;\nSELECT 20;\n")
- #expect(r.stdout == "20\n")
- #expect(try String(contentsOf: file, encoding: .utf8) == "10\n")
- }
- }
-
- @Test func dotImportIntoExistingTable() async throws {
- try await withTempDir { dir in
- let csv = dir.appendingPathComponent("data.csv")
- try "1,alice\n\"x,y\",bob\n".write(to: csv, atomically: true, encoding: .utf8)
- let r = try await run([":memory:"], input: """
- .mode csv
- CREATE TABLE t(id,name);
- .import \(csv.path) t
- .mode list
- SELECT id || '/' || name FROM t ORDER BY name;
- """)
- #expect(r.stdout == "1/alice\nx,y/bob\n")
- }
- }
-
- @Test func dotImportCreatesTableFromHeader() async throws {
- try await withTempDir { dir in
- let csv = dir.appendingPathComponent("data.csv")
- try "id,name\n1,alice\n2,bob\n".write(to: csv, atomically: true, encoding: .utf8)
- let r = try await run([":memory:"], input: """
- .mode csv
- .import \(csv.path) newt
- .mode list
- SELECT id, name FROM newt ORDER BY id;
- """)
- #expect(r.stdout == "1|alice\n2|bob\n")
- }
- }
-
- @Test func dotBackupAndRestore() async throws {
- try await withTempDir { dir in
- let backup = dir.appendingPathComponent("bk.db")
- let r1 = try await run([":memory:"], input: """
- CREATE TABLE t(id, name);
- INSERT INTO t VALUES(1,'alice'),(2,'bob');
- .backup \(backup.path)
- """)
- #expect(r1.exit == 0)
- // Restore the backup into a fresh in-memory database.
- let r2 = try await run([":memory:"], input: """
- .restore \(backup.path)
- SELECT id || '/' || name FROM t ORDER BY id;
- """)
- #expect(r2.stdout == "1/alice\n2/bob\n")
- }
- }
-
- @Test func boxFlag() async throws {
- let r = try await run(["-box", ":memory:", "SELECT 1 AS a;"])
- #expect(r.stdout == "┌───┐\n│ a │\n├───┤\n│ 1 │\n└───┘\n")
- }
-
- @Test func insertModeNamedTable() async throws {
- let r = try await run([":memory:"],
- input: "CREATE TABLE t(a);INSERT INTO t VALUES(1);\n.mode insert t\nSELECT * FROM t;\n")
- #expect(r.stdout == "INSERT INTO t VALUES(1);\n")
- }
-
- @Test func dumpRoundTrip() async throws {
- let r = try await run([":memory:"], input: """
- CREATE TABLE t(id INTEGER, name TEXT);
- INSERT INTO t VALUES (1,'alice'),(2,NULL);
- .dump
- """)
- #expect(r.exit == 0)
- #expect(r.stdout == """
- PRAGMA foreign_keys=OFF;
- BEGIN TRANSACTION;
- CREATE TABLE t(id INTEGER, name TEXT);
- INSERT INTO t VALUES(1,'alice');
- INSERT INTO t VALUES(2,NULL);
- COMMIT;
- """ + "\n")
- }
-
- @Test func dotModeAndHeadersFromStdin() async throws {
- let script = """
- .mode csv
- .headers on
- SELECT 1 AS a, 2 AS b;
- """
- let r = try await run([":memory:"], input: script)
- #expect(r.stdout == "a,b\r\n1,2\r\n")
- }
-
- @Test func dotModeColumnEnablesHeaders() async throws {
- // `.mode column` turns headers on (sqlite3 behavior).
- let r = try await run([":memory:"], input: ".mode column\nSELECT 1 AS a, 2 AS b;\n")
- #expect(r.stdout == "a b\n- -\n1 2\n")
- }
-
- @Test func columnFlagDoesNotEnableHeaders() async throws {
- // ...but the -column flag does not.
- let r = try await run(["-column", ":memory:", "SELECT 1 AS a, 2 AS b;"])
- #expect(r.stdout == "1 2\n")
- }
-
- @Test func explicitHeadersOffBeatsColumnMode() async throws {
- let r = try await run([":memory:"], input: ".headers off\n.mode column\nSELECT 1 AS a;\n")
- #expect(r.stdout == "1\n")
- }
-
- @Test func dotTablesAndSchema() async throws {
- let script = """
- CREATE TABLE foo(id INTEGER);
- CREATE TABLE bar(id INTEGER);
- .tables
- .schema foo
- """
- let r = try await run([":memory:"], input: script)
- #expect(r.stdout.contains("bar"))
- #expect(r.stdout.contains("foo"))
- #expect(r.stdout.contains("CREATE TABLE foo(id INTEGER);"))
- }
-
- @Test func inlinePrepareError() async throws {
- // Command-line SQL: "Error: in prepare, ..." and exit = SQLite code.
- let r = try await run([":memory:", "SELECT * FROM missing;"])
- #expect(r.exit == 1)
- #expect(r.stderr == "Error: in prepare, no such table: missing\n")
- }
-
- @Test func inlineRuntimeError() async throws {
- // Stepping failure: "Error: stepping, ... (code)" and exit = code.
- let r = try await run([":memory:",
- "CREATE TABLE x(a INTEGER NOT NULL); INSERT INTO x VALUES(NULL);"])
- #expect(r.exit == 19)
- #expect(r.stderr == "Error: stepping, NOT NULL constraint failed: x.a (19)\n")
- }
-
- @Test func scriptParseErrorWithCaret() async throws {
- let r = try await run([":memory:"], input: "SELEC 1;\n")
- #expect(r.exit == 1)
- #expect(r.stderr == "Parse error near line 1: near \"SELEC\": syntax error\n SELEC 1;\n ^--- error here\n")
- }
-
- @Test func dotReadMissingFileFailsExitCode() async throws {
- let r = try await run([":memory:"], input: ".read /no/such/file\n")
- #expect(r.exit == 1)
- #expect(!r.stderr.isEmpty)
- }
-
- @Test func scriptRuntimeErrorLineNumber() async throws {
- let r = try await run([":memory:"],
- input: "CREATE TABLE x(a INTEGER NOT NULL);\nINSERT INTO x VALUES(NULL);\n")
- #expect(r.exit == 1)
- #expect(r.stderr == "Runtime error near line 2: NOT NULL constraint failed: x.a (19)\n")
- }
-
- @Test func unknownDotCommandContinues() async throws {
- let r = try await run([":memory:"], input: ".bogus\nSELECT 1;\n")
- #expect(r.exit == 0)
- #expect(r.stderr.contains("unknown command"))
- #expect(r.stdout == "1\n")
- }
-
- @Test func quitStopsProcessing() async throws {
- let script = """
- SELECT 1;
- .quit
- SELECT 2;
- """
- let r = try await run([":memory:"], input: script)
- #expect(r.stdout == "1\n")
- }
-
- @Test func versionFlag() async throws {
- let r = try await run(["-version"])
- #expect(r.exit == 0)
- #expect(r.stdout.hasPrefix("3.50.4 "))
- #expect(r.stdout.hasSuffix(" (64-bit)\n"))
- }
-
- @Test func unknownOptionFails() async throws {
- let r = try await run(["-bogus", ":memory:"])
- #expect(r.exit == 1)
- #expect(r.stderr.contains("unknown option"))
- }
-
- // MARK: .dump fidelity + output parity (issue #43)
-
- @Test func dumpQuotesSpaceTableName() async throws {
- // A table name with a space must be double-quoted in the emitted
- // INSERT (the old code used the raw name → invalid, un-replayable SQL).
- let r = try await run([":memory:"], input: """
- CREATE TABLE "my table"(x);
- INSERT INTO "my table" VALUES(1);
- .dump
- """)
- #expect(r.exit == 0)
- #expect(r.stdout.contains("INSERT INTO \"my table\" VALUES(1);"))
- }
-
- @Test func dumpQuotesKeywordTableName() async throws {
- // `order` is a SQL keyword, so the INSERT must quote it — verified
- // against the engine's own sqlite3_keyword_check, case-insensitively.
- let r = try await run([":memory:"], input: """
- CREATE TABLE "order"(x);
- INSERT INTO "order" VALUES(1);
- .dump
- """)
- #expect(r.exit == 0)
- #expect(r.stdout.contains("INSERT INTO \"order\" VALUES(1);"))
- }
-
- @Test func dumpPreservesAutoincrementSequence() async throws {
- // sqlite3's .dump re-emits the AUTOINCREMENT high-water mark as a
- // sqlite_sequence INSERT — with no CREATE (the table is implicit) and,
- // for `.dump` specifically, no `DELETE FROM sqlite_sequence` (that is
- // `.recover`'s behavior; cf. shell.c dump_callback). We match it
- // byte-for-byte: sqlite3's own dump doesn't dedupe the row the table's
- // own inserts re-create on replay, so this is parity, not a "perfect"
- // counter reload — see PR #46 review.
- let r = try await run([":memory:"], input: """
- CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, v);
- INSERT INTO t(v) VALUES('a'),('b'),('c');
- DELETE FROM t WHERE id = 3;
- .dump
- """)
- #expect(r.exit == 0)
- #expect(r.stdout.contains("INSERT INTO sqlite_sequence VALUES('t',3);"))
- #expect(!r.stdout.contains("CREATE TABLE sqlite_sequence"))
- #expect(!r.stdout.contains("DELETE FROM sqlite_sequence")) // .dump ≠ .recover
- }
-
- @Test func dumpSingleTableOmitsSequence() async throws {
- // A single-table dump omits sqlite_sequence (matching sqlite3).
- let r = try await run([":memory:"], input: """
- CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, v);
- INSERT INTO t(v) VALUES('a');
- .dump t
- """)
- #expect(r.exit == 0)
- #expect(!r.stdout.contains("sqlite_sequence"))
- }
-
- @Test func jsonPreservesDuplicateAndOrderedColumns() async throws {
- // Hand-rolled JSON keeps column order and duplicate names (a dict
- // would reorder/collapse them) — matching sqlite3's -json.
- let r = try await run(["-json", ":memory:", "SELECT 2 AS b, 1 AS a, 3 AS a;"])
- #expect(r.stdout == "[{\"b\":2,\"a\":1,\"a\":3}]\n")
- }
-
- @Test func dotCommandInsideStringLiteralIsData() async throws {
- // A line beginning with "." inside an open string literal is data,
- // not a dot-command: it must not run `.tables`.
- let r = try await run([":memory:"], input: """
- CREATE TABLE marker(x);
- SELECT '
- .tables
- ' AS v;
- """)
- #expect(r.exit == 0)
- #expect(r.stdout.contains(".tables")) // preserved as data
- #expect(!r.stdout.contains("marker")) // .tables never executed
- }
-
- // MARK: dot-command coverage parity (issue #43)
-
- @Test func databasesShowsFileAndReadWrite() async throws {
- // sqlite3: ": <"" if no file> ".
- let r = try await run([":memory:"], input: ".databases\n")
- #expect(r.stdout == "main: \"\" r/w\n")
- }
-
- @Test func schemaViewGetsColumnComment() async throws {
- // sqlite3 appends /* view(cols) */ (with identifier quoting) to a view.
- let r = try await run([":memory:"], input: """
- CREATE TABLE t(a, b);
- CREATE VIEW v AS SELECT a, b + 1 AS bb FROM t;
- .schema v
- """)
- #expect(r.stdout == "CREATE VIEW v AS SELECT a, b + 1 AS bb FROM t\n/* v(a,bb) */;\n")
- }
-
- @Test func showListsAllTwelveSettings() async throws {
- // sqlite3's .show: 12 labels right-justified to width 12.
- let r = try await run([":memory:"], input: ".show\n")
- let expected = [
- " echo: off",
- " eqp: off",
- " explain: auto",
- " headers: off",
- " mode: list",
- " nullvalue: \"\"",
- " output: stdout",
- "colseparator: \"|\"",
- "rowseparator: \"\\n\"",
- " stats: off",
- " width: ",
- " filename: :memory:"
- ].joined(separator: "\n") + "\n"
- #expect(r.stdout == expected)
- }
-
- @Test func schemaUnresolvableViewHasNoComment() async throws {
- // A view that can't be prepared (missing table) prints just its stored
- // CREATE — no bogus /* v() */ comment (matches sqlite3). [PR #48 review]
- let r = try await run([":memory:"], input: """
- CREATE VIEW v AS SELECT * FROM missing;
- .schema v
- """)
- #expect(r.stdout == "CREATE VIEW v AS SELECT * FROM missing;\n")
- }
-
- @Test func showReportsModeDerivedSeparators() async throws {
- // .mode csv changes the reported separators to , / \r\n. [PR #48 review]
- let r = try await run([":memory:"], input: ".mode csv\n.show\n")
- #expect(r.stdout.contains("colseparator: \",\""))
- #expect(r.stdout.contains("rowseparator: \"\\r\\n\""))
- }
-
- // MARK: - Parity batch (issue #43): generated cols, reals, csv, .width,
- // REPL, -safe, .limit, .fullschema. Each expectation is pinned against a
- // real sqlite3 used as the oracle.
-
- @Test func dumpExcludesGeneratedColumns() async throws {
- let script = """
- CREATE TABLE t(a INT, b INT, c INT GENERATED ALWAYS AS (a+b) VIRTUAL, d INT GENERATED ALWAYS AS (a*b) STORED);
- INSERT INTO t(a,b) VALUES(2,3);
- .dump
- """
- let r = try await run([":memory:"], input: script)
- #expect(r.stdout.contains("INSERT INTO t VALUES(2,3);"))
- #expect(!r.stdout.contains("VALUES(2,3,")) // generated values excluded
- }
-
- @Test func fullPrecisionRealsInRoundTripModes() async throws {
- let q = try await run([":memory:"], input: ".mode quote\nSELECT 0.1+0.2, 3.14;\n")
- #expect(q.stdout == "0.3000000000000000445,3.140000000000000124\n")
- let j = try await run([":memory:"], input: ".mode json\nSELECT 0.1+0.2 AS a;\n")
- #expect(j.stdout == "[{\"a\":0.3000000000000000445}]\n")
- }
-
- @Test func csvModeUsesCRLF() async throws {
- let r = try await run([":memory:"], input: ".mode csv\n.headers on\nSELECT 1 AS a, 'x' AS b;\n")
- #expect(r.stdout == "a,b\r\n1,x\r\n")
- }
-
- @Test func widthWrapsAndRightJustifies() async throws {
- let wrap = try await run([":memory:"], input: ".mode column\n.headers off\n.width 3\nSELECT 'abcdef';\n")
- #expect(wrap.stdout == "abc\ndef\n")
- let rj = try await run([":memory:"], input: ".mode column\n.headers off\n.width -5\nSELECT 'ab';\n")
- #expect(rj.stdout == " ab\n")
- }
-
- @Test func showReportsWidthAndColumnModeSuffix() async throws {
- let r = try await run([":memory:"], input: ".mode box\n.width 3 5\n.show\n")
- #expect(r.stdout.contains("mode: box --wrap 60 --wordwrap off --noquote"))
- #expect(r.stdout.contains("width: 3 5 "))
- #expect(r.stdout.contains("filename: :memory:"))
- }
-
- @Test func interactiveBannerAndPrompts() async throws {
- let r = try await run(["-interactive", ":memory:"], input: "SELECT 1;\n.quit\n")
- #expect(r.stdout == Session.banner + "sqlite> 1\nsqlite> ")
- }
-
- @Test func interactiveContinuationPrompt() async throws {
- let r = try await run(["-interactive", ":memory:"], input: "SELECT\n1;\n.quit\n")
- #expect(r.stdout == Session.banner + "sqlite> ...> 1\nsqlite> ")
- }
-
- @Test func interactiveErrorFormat() async throws {
- let r = try await run(["-interactive", ":memory:"], input: "SELECT bad here;\n.quit\n")
- #expect(r.stderr.contains("Parse error: "))
- #expect(r.stderr.contains("^--- error here"))
- }
-
- @Test func safeModeBlocksFileCommandAndExits() async throws {
- let r = try await run(["-safe", ":memory:", ".backup x.db"])
- #expect(r.exit == 1)
- #expect(r.stderr == "line 0: cannot run .backup in safe mode\n")
- }
-
- @Test func safeModeRejectsRealFileOpenButAllowsMemory() async throws {
- let real = try await run(["-safe", ":memory:", ".open foo.db"])
- #expect(real.exit == 1)
- #expect(real.stderr == "line 0: cannot open disk-based database files in safe mode\n")
- let mem = try await run(["-safe", ":memory:"], input: ".open :memory:\nSELECT 7;\n")
- #expect(mem.exit == 0)
- #expect(mem.stdout == "7\n")
- // `.open :memory:` must open a true in-memory database, never resolve
- // to a real on-disk file named ":memory:" (regression guard).
- #expect(!FileManager.default.fileExists(atPath: ":memory:"))
- }
-
- @Test func safeModeBlocksAttachAndLoadExtension() async throws {
- // -safe gates SQL-level filesystem reach, not just dot-commands.
- let attach = try await run(["-safe", ":memory:", "ATTACH 'foo.db' AS x;"])
- #expect(attach.exit == 1)
- #expect(attach.stderr == "line 0: cannot run ATTACH in safe mode\n")
- let ext = try await run(["-safe", ":memory:", "SELECT load_extension('x');"])
- #expect(ext.exit == 1)
- #expect(ext.stderr == "line 0: cannot use the load_extension() function in safe mode\n")
- // A normal statement is unaffected; ATTACH is denied without -safe-ing SQL.
- let ok = try await run([":memory:"], input: "ATTACH ':memory:' AS y;\nSELECT 'ok';\n")
- #expect(ok.stdout == "ok\n")
- }
-
- @Test func attachIsGatedByTheSandbox() async throws {
- // A rooted sandbox confines ATTACH the same way it confines the db
- // file / .read / .open: a path outside the root is denied, one inside
- // is allowed. (No -safe here — this is the always-on sandbox gate.)
- let root = URL(fileURLWithPath: NSTemporaryDirectory())
- .appendingPathComponent("sqlite-attach-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: root) }
-
- // swiftlint:disable:next large_tuple
- func run(_ sql: String) async throws -> (out: String, err: String, exit: Int32) {
- let shell = Shell(environment: Environment(
- variables: ProcessInfo.processInfo.environment,
- workingDirectory: root.path))
- shell.sandbox = .rooted(at: root)
- let out = OutputSink(), err = OutputSink()
- shell.stdout = out; shell.stderr = err
- let exit = try await Shell.$current.withValue(shell) {
- try await Sqlite3Executable.run(
- argv: [":memory:", sql], stdin: .string(""), stdout: out, stderr: err)
- }
- out.finish(); err.finish()
- return (await out.readAllString(), await err.readAllString(), exit)
- }
-
- let outside = try await run("ATTACH '/etc/hosts' AS x;")
- #expect(outside.exit == 1)
- #expect(outside.err.contains("Error:")) // sandbox denial reported
-
- let inside = root.appendingPathComponent("inside.db").path
- let allowed = try await run("ATTACH '\(inside)' AS x; SELECT 'ok';")
- #expect(allowed.exit == 0)
- #expect(allowed.out == "ok\n")
- }
-
- @Test func limitListsAndSets() async throws {
- let list = try await run([":memory:", ".limit"])
- #expect(list.stdout.contains(" length 1000000000"))
- #expect(list.stdout.contains(" worker_threads 0"))
- let set = try await run([":memory:"], input: ".limit column 5\n.limit column\n")
- #expect(set.stdout == " column 5\n column 5\n")
- }
-
- @Test func fullschemaNoStats() async throws {
- let r = try await run([":memory:"], input: "CREATE TABLE x(a);\n.fullschema\n")
- #expect(r.stdout == "CREATE TABLE x(a);\n/* No STAT tables available */\n")
- }
-
- @Test func filenameShownAsTyped() async throws {
- let r = try await run([":memory:", ".show"])
- #expect(r.stdout.contains("filename: :memory:"))
- }
-}