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:")) - } -}