Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,18 @@ 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.
- name: Build (iOS device SDK)
run: xcodebuild -scheme SQLiteKit -destination 'generic/platform=iOS' build
- name: Test (iOS Simulator)
run: |
set -eo pipefail
# Pick the first available iPhone simulator rather than pinning a
# device name a future Xcode might drop.
UDID=$(xcrun simctl list devices available --json \
| jq -r '[.devices[][] | select(.name|test("iPhone"))][0].udid // empty')
[ -n "$UDID" ] || { echo "::error::no iPhone simulator available"; exit 1; }
echo "Using simulator $UDID"
xcodebuild test -scheme SQLiteKit -destination "platform=iOS Simulator,id=$UDID"
xcodebuild -scheme SQLiteKit -destination 'generic/platform=iOS' build
xcodebuild -scheme Sqlite3Shell -destination 'generic/platform=iOS' build

build-tvos:
runs-on: macos-15
Expand Down
18 changes: 14 additions & 4 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
# local pre-commit hook, mirroring the Cocoanetics convention (ShellKit).
#
# A handful of default rules are tuned for what this package is: a thin SQLite
# C-interop SDK. Short names (`rc`, `db`, `op`, `i`) are the idiomatic spelling
# at the C boundary, and the cohesive `SQLiteDatabase` / `ResultFormatter` types
# read better whole than carved up to satisfy a line budget. Everything else is
# stock SwiftLint.
# C-interop SDK plus the `Sqlite3Shell` CLI port. Short names (`rc`, `db`, `op`,
# `i`) are the idiomatic spelling at the C boundary, and the cohesive
# `SQLiteDatabase` / `ResultFormatter` types read better whole than carved up to
# satisfy a line budget. The shell port (`Sources/Sqlite3Shell`) is a faithful
# 1:1 reproduction of the sqlite3 CLI — a long dispatch switch and one REPL type
# — so it carries scoped `swiftlint:disable:next` directives at the few oversized
# declarations. Everything else is stock SwiftLint.
included:
- Sources
- Tests
Expand All @@ -14,6 +17,13 @@ excluded:
- .build
- .swiftpm

blanket_disable_command:
# `file_length` is reported at end-of-file, so it cannot be scoped with
# `disable:next` like the other size rules. The shell port and its test suite
# disable it file-wide on purpose; allow that one rule to be blanket-disabled.
allowed_rules:
- file_length

identifier_name:
# Allow the short, idiomatic names used at the C-interop boundary.
min_length:
Expand Down
41 changes: 41 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ 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"])
Expand All @@ -35,6 +44,14 @@ 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`),
Expand Down Expand Up @@ -97,5 +114,29 @@ 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"),
]
),
]
)
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,36 @@ recompiles the engine, so they are opt-in.
- **`SQLiteRow` / `ResultSet`** — typed rows, subscriptable by index and column name.
- **`ResultFormatter`** — sqlite3-compatible rendering.

## The `sqlite3` shell driver

The package also vends a **`Sqlite3Shell`** product — the argv parser plus the
dot-command / REPL engine that reproduces the `sqlite3` command-line shell
(`.tables`, `.schema`, `.dump`, `.import`, `.mode`, `-safe`, …). It is
ArgumentParser-free and IO-agnostic: it reads, writes, and authorizes file paths
through [`ShellKit.Shell`](https://github.com/Cocoanetics/ShellKit), so a host
can drive it in-process on any platform — Android included — and confine it to a
sandbox. SDK-only consumers that depend on the `SQLiteKit` product never build
this target (or ShellKit) into their link.

```swift
.product(name: "Sqlite3Shell", package: "SQLiteKit"),
```

```swift
import Sqlite3Shell
import ShellKit

let code = try await Sqlite3Executable.run(
argv: ["mydb.sqlite", "SELECT * FROM users;"],
stdin: Shell.current.stdin,
stdout: Shell.current.stdout,
stderr: Shell.current.stderr)
```

[Cocoanetics/SwiftPorts](https://github.com/Cocoanetics/SwiftPorts) wraps this
driver in an ArgumentParser command to ship the `sqlite3` executable, and
SwiftBash registers it as a native builtin.

## Platforms

macOS 13+, iOS 16+, tvOS 16+, watchOS 9+, visionOS 1+, Linux, Android, Windows.
Expand Down
157 changes: 157 additions & 0 deletions Sources/Sqlite3Shell/Parser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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 <TR>/<TD> 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

"""
}
Loading