diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fd73250..89830b7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,8 +2,6 @@ name: CI
on:
push:
- pull_request:
- branches: [ main, master ]
workflow_dispatch:
jobs:
@@ -18,6 +16,36 @@ jobs:
- name: Test
run: swift test -v
+ coverage:
+ name: Code Coverage
+ runs-on: macos-26
+ permissions:
+ contents: read
+ code-quality: write # required by actions/upload-code-coverage
+ steps:
+ - uses: actions/checkout@v4
+ - name: Export coverage and enforce threshold
+ run: Scripts/coverage.sh
+ env:
+ COVERAGE_THRESHOLD: 95
+ - name: Upload coverage to GitHub Code Quality
+ uses: actions/upload-code-coverage@v1
+ with:
+ file: .build/coverage/coverage.xml
+ language: Swift
+ label: code-coverage/llvm-cov
+ # Code Quality was in public preview until 2026-07-20; keep this while
+ # the repo might not have the feature enabled, so a failed upload
+ # doesn't break CI.
+ fail-on-error: false
+ - name: Upload coverage artifacts
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: code-coverage
+ path: .build/coverage/
+ if-no-files-found: error
+
linux:
runs-on: ubuntu-latest
container: swift:6.3.3
diff --git a/Scripts/coverage.sh b/Scripts/coverage.sh
new file mode 100755
index 0000000..0dbddd3
--- /dev/null
+++ b/Scripts/coverage.sh
@@ -0,0 +1,198 @@
+#!/usr/bin/env bash
+#
+# coverage.sh — run a SwiftPM test suite with code coverage, export LCOV and
+# Cobertura reports, and enforce a minimum line-coverage threshold.
+#
+# Coverage is measured against the package's OWN sources only (everything under
+# the repo's `Sources/` directory). Dependencies (checked out under `.build`),
+# generated sources, and the test target are excluded, so the number reflects
+# the coverage of this package's code rather than being diluted by third-party
+# code that the tests happen to exercise.
+#
+# The Cobertura XML (coverage.xml) is the format GitHub Code Quality consumes via
+# `actions/upload-code-coverage`; the LCOV report (coverage.lcov) is kept for
+# other tools (Codecov, Coveralls, editors) that prefer it.
+#
+# Usage:
+# Scripts/coverage.sh [threshold]
+#
+# Environment variables:
+# COVERAGE_THRESHOLD Minimum line coverage percentage (default: 80).
+# Overridden by the optional [threshold] argument.
+# COVERAGE_SOURCE_PREFIX Absolute path prefix selecting the sources that count
+# toward coverage (default: "$PWD/Sources/"). Narrow it
+# to a single target, e.g. "$PWD/Sources/MyLibrary/", to
+# gate on one target instead of every source file.
+# COVERAGE_OUTPUT LCOV output file (default: .build/coverage/coverage.lcov).
+# COBERTURA_OUTPUT Cobertura XML output file (default: .build/coverage/coverage.xml).
+# SKIP_TEST If set to 1, reuse existing coverage data instead of
+# re-running `swift test` (useful while iterating).
+
+set -euo pipefail
+
+THRESHOLD="${1:-${COVERAGE_THRESHOLD:-80}}"
+SOURCE_PREFIX="${COVERAGE_SOURCE_PREFIX:-$PWD/Sources/}"
+OUTPUT="${COVERAGE_OUTPUT:-.build/coverage/coverage.lcov}"
+COBERTURA_OUTPUT="${COBERTURA_OUTPUT:-.build/coverage/coverage.xml}"
+
+# Resolve the correct llvm-cov (xcrun on Apple platforms, plain llvm-cov elsewhere).
+if command -v xcrun >/dev/null 2>&1; then
+ LLVM_COV="xcrun llvm-cov"
+else
+ LLVM_COV="llvm-cov"
+fi
+
+# 1. Run the tests with coverage instrumentation.
+if [ "${SKIP_TEST:-0}" != "1" ]; then
+ echo "==> Running tests with code coverage"
+ swift test --enable-code-coverage
+fi
+
+# 2. Locate the coverage artifacts SwiftPM produced.
+CODECOV_JSON="$(swift test --enable-code-coverage --show-codecov-path)"
+COV_DIR="$(dirname "$CODECOV_JSON")"
+PROFDATA="$COV_DIR/default.profdata"
+
+if [ ! -f "$CODECOV_JSON" ]; then
+ echo "error: coverage report not found at $CODECOV_JSON" >&2
+ exit 1
+fi
+
+# 3. Locate the instrumented test binary (differs by platform: on macOS it lives
+# inside the .xctest bundle, on Linux the .xctest file is the binary itself).
+BIN_PATH="$(swift build --show-bin-path)"
+TEST_BINARY=""
+for candidate in \
+ "$BIN_PATH"/*PackageTests.xctest/Contents/MacOS/*PackageTests \
+ "$BIN_PATH"/*.xctest/Contents/MacOS/* \
+ "$BIN_PATH"/*.xctest; do
+ if [ -f "$candidate" ]; then
+ TEST_BINARY="$candidate"
+ break
+ fi
+done
+
+# 4. Export an LCOV report (for Codecov / Coveralls / editors).
+mkdir -p "$(dirname "$OUTPUT")"
+if [ -n "$TEST_BINARY" ] && [ -f "$PROFDATA" ]; then
+ echo "==> Exporting LCOV report to $OUTPUT"
+ $LLVM_COV export \
+ -format=lcov \
+ -instr-profile "$PROFDATA" \
+ "$TEST_BINARY" \
+ -ignore-filename-regex='.build/(checkouts|.*\.build)/|Tests/|\.derived/|DerivedSources/' \
+ > "$OUTPUT"
+else
+ echo "warning: could not locate test binary or profdata; skipping LCOV export" >&2
+fi
+
+# 5. Generate a Cobertura XML report (for GitHub Code Quality / upload-code-coverage).
+echo "==> Writing Cobertura report to $COBERTURA_OUTPUT"
+mkdir -p "$(dirname "$COBERTURA_OUTPUT")"
+python3 - "$CODECOV_JSON" "$SOURCE_PREFIX" "$PWD" "$COBERTURA_OUTPUT" <<'PY'
+import json, os, sys, time
+from xml.sax.saxutils import escape, quoteattr
+
+report_path, source_prefix, repo_root, output = sys.argv[1:5]
+
+with open(report_path) as f:
+ report = json.load(f)
+
+files = []
+total_covered = total_lines = 0
+for file in report["data"][0]["files"]:
+ name = file["filename"]
+ if not name.startswith(source_prefix):
+ continue
+ # Per-line hit count: the greatest region count starting on each line (a line
+ # is covered if any region on it executed, so branch sub-regions don't hide it).
+ line_hits = {}
+ for seg in file["segments"]:
+ line, count, has_count = seg[0], seg[2], seg[3]
+ if has_count:
+ line_hits[line] = max(line_hits.get(line, 0), count)
+ covered = sum(1 for h in line_hits.values() if h > 0)
+ total_covered += covered
+ total_lines += len(line_hits)
+ files.append((os.path.relpath(name, repo_root), line_hits, covered, len(line_hits)))
+
+def rate(c, t):
+ return c / t if t else 0.0
+
+overall = rate(total_covered, total_lines)
+timestamp = int(time.time())
+
+out = [
+ '',
+ '',
+ ''
+ % (overall, total_covered, total_lines, timestamp),
+ " ",
+ " %s" % escape(repo_root),
+ " ",
+ " ",
+ ' '
+ % (escape(os.path.basename(repo_root)), overall),
+ " ",
+]
+for rel, line_hits, covered, total in sorted(files):
+ out.append(
+ ' '
+ % (quoteattr(os.path.basename(rel)), quoteattr(rel), rate(covered, total))
+ )
+ out.append(" ")
+ out.append(" ")
+ for line in sorted(line_hits):
+ out.append(' ' % (line, line_hits[line]))
+ out.append(" ")
+ out.append(" ")
+out += [" ", " ", " ", ""]
+
+with open(output, "w") as f:
+ f.write("\n".join(out) + "\n")
+
+print(" %d/%d lines (%.2f%%)" % (total_covered, total_lines, 100 * overall))
+PY
+
+# 6. Compute line coverage for the package sources and enforce the threshold.
+echo "==> Computing coverage for ${SOURCE_PREFIX}"
+python3 - "$CODECOV_JSON" "$SOURCE_PREFIX" "$THRESHOLD" "$PWD" <<'PY'
+import json, os, sys
+
+report_path, source_prefix, threshold, repo_root = sys.argv[1], sys.argv[2], float(sys.argv[3]), sys.argv[4]
+
+with open(report_path) as f:
+ report = json.load(f)
+
+covered = total = 0
+rows = []
+for file in report["data"][0]["files"]:
+ name = file["filename"]
+ if not name.startswith(source_prefix):
+ continue
+ lines = file["summary"]["lines"]
+ covered += lines["covered"]
+ total += lines["count"]
+ rows.append((lines["percent"], os.path.relpath(name, repo_root)))
+
+if total == 0:
+ print("error: no source files matched prefix %r" % source_prefix, file=sys.stderr)
+ print(" (set COVERAGE_SOURCE_PREFIX to your package's Sources path)", file=sys.stderr)
+ sys.exit(1)
+
+percent = 100.0 * covered / total
+
+for pct, name in sorted(rows):
+ print(" %6.2f%% %s" % (pct, name))
+
+print("-" * 48)
+print("Total line coverage: %.2f%% (%d/%d lines)" % (percent, covered, total))
+print("Required threshold: %.2f%%" % threshold)
+
+if percent < threshold:
+ print("FAILED: coverage %.2f%% is below the %.2f%% threshold" % (percent, threshold), file=sys.stderr)
+ sys.exit(1)
+
+print("PASSED: coverage meets the threshold")
+PY
diff --git a/Sources/SQLite/AggregateFunction.swift b/Sources/SQLite/AggregateFunction.swift
index 4b36004..9df0406 100644
--- a/Sources/SQLite/AggregateFunction.swift
+++ b/Sources/SQLite/AggregateFunction.swift
@@ -133,7 +133,8 @@ internal extension Connection.Handle {
}
)
guard resultCode == SQLITE_OK else {
- Unmanaged.fromOpaque(context).release()
+ // sqlite3_create_function_v2 invokes the xDestroy callback when
+ // registration fails, so the box has already been released
return check(resultCode)
}
return .success(())
diff --git a/Sources/SQLite/Function.swift b/Sources/SQLite/Function.swift
index ff312d3..55c5c57 100644
--- a/Sources/SQLite/Function.swift
+++ b/Sources/SQLite/Function.swift
@@ -95,8 +95,8 @@ internal extension Connection.Handle {
}
)
guard resultCode == SQLITE_OK else {
- // release the retained box since registration failed and the destructor will not be called
- Unmanaged.fromOpaque(context).release()
+ // sqlite3_create_function_v2 invokes the xDestroy callback when
+ // registration fails, so the box has already been released
return check(resultCode)
}
return .success(())
diff --git a/Sources/SQLite/Statement.swift b/Sources/SQLite/Statement.swift
index c3c04be..a8f7217 100644
--- a/Sources/SQLite/Statement.swift
+++ b/Sources/SQLite/Statement.swift
@@ -92,7 +92,8 @@ internal extension Statement.Handle {
var pointer: OpaquePointer?
let errorCode = sqlite3_prepare_v2(connection.pointer, sql, -1, &pointer, nil)
guard let pointer else {
- return .failure(SQLiteError(errorCode: SQLiteError.ErrorCode(errorCode), message: "Unable to initialize statement.", connection: connection.filename, statement: sql))
+ // surface SQLite's own diagnostic (e.g. "no such table: t") rather than a generic message
+ return .failure(connection.forceError(SQLiteError.ErrorCode(errorCode), statement: sql))
}
let handle = Statement.Handle(pointer: pointer)
guard errorCode == SQLITE_OK else {
diff --git a/Sources/SQLite/TypeAffinity.swift b/Sources/SQLite/TypeAffinity.swift
index 5d980ac..a664502 100644
--- a/Sources/SQLite/TypeAffinity.swift
+++ b/Sources/SQLite/TypeAffinity.swift
@@ -86,7 +86,7 @@ public extension TypeAffinity {
self = .integer
} else if ["CHAR", "CLOB", "TEXT"].first(where: {test.contains($0)}) != nil { // Rule 2
self = .text
- } else if string.contains("BLOB") { // Rule 3
+ } else if test.contains("BLOB") { // Rule 3
self = .blob
} else if ["REAL", "FLOA", "DOUB"].first(where: {test.contains($0)}) != nil { // Rule 4
self = .real
diff --git a/Sources/SQLite/WindowFunction.swift b/Sources/SQLite/WindowFunction.swift
index 1a4b314..6619316 100644
--- a/Sources/SQLite/WindowFunction.swift
+++ b/Sources/SQLite/WindowFunction.swift
@@ -171,7 +171,8 @@ internal extension Connection.Handle {
}
)
guard resultCode == SQLITE_OK else {
- Unmanaged.fromOpaque(context).release()
+ // sqlite3_create_window_function invokes the xDestroy callback when
+ // registration fails, so the box has already been released
return check(resultCode)
}
return .success(())
diff --git a/Tests/SQLiteTests/BindingFormatTests.swift b/Tests/SQLiteTests/BindingFormatTests.swift
new file mode 100644
index 0000000..495769e
--- /dev/null
+++ b/Tests/SQLiteTests/BindingFormatTests.swift
@@ -0,0 +1,133 @@
+//
+// BindingFormatTests.swift
+// SQLite
+//
+// Created by Alsey Coleman Miller on 7/17/26.
+//
+
+import Foundation
+import Testing
+@testable import SQLite
+
+@Suite struct BindingFormatTests {
+
+ // MARK: - UUID
+
+ @Test func uuidFormatAffinity() {
+ #expect(Binding.UUIDFormat.text.affinity == .text)
+ #expect(Binding.UUIDFormat.blob.affinity == .blob)
+ }
+
+ @Test func uuidTextFormat() throws {
+ let uuid = try #require(UUID(uuidString: "0F6EF7B2-4D3A-4C0F-9C7B-2C5D0E1A2B3C"))
+ #expect(Binding.UUIDFormat.format(text: uuid) == uuid.uuidString)
+ guard case let .text(string) = Binding.uuid(uuid, type: .text) else {
+ Issue.record("Expected .text binding")
+ return
+ }
+ #expect(string == uuid.uuidString)
+ }
+
+ @Test func uuidBlobFormat() throws {
+ let uuid = try #require(UUID(uuidString: "0F6EF7B2-4D3A-4C0F-9C7B-2C5D0E1A2B3C"))
+ let expectedBytes = withUnsafeBytes(of: uuid.uuid) { [UInt8]($0) }
+ #expect(Binding.uuid(uuid).bytes == expectedBytes) // .blob is the default format
+ #expect(Binding.uuid(uuid, type: .blob).bytes == expectedBytes)
+ #expect(Binding.blob(Binding.UUIDFormat.format(blob: uuid)).bytes == expectedBytes)
+ }
+
+ @Test func uuidRoundTrip() throws {
+ let uuid = UUID()
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (id BLOB, name TEXT)")
+ try connection.run("INSERT INTO t (id, name) VALUES (?, ?)", [.uuid(uuid, type: .blob), .uuid(uuid, type: .text)])
+ let statement = try connection.prepare("SELECT id, name FROM t")
+ let row = try #require(try statement.failableNext())
+ #expect(row[0]?.bytes == withUnsafeBytes(of: uuid.uuid) { [UInt8]($0) })
+ #expect(row[1]?.string == uuid.uuidString)
+ }
+
+ // MARK: - Date extension
+
+ @Test func julianDay() {
+ #expect(Date(timeIntervalSince1970: 0).julian == 2440587.5)
+ // one day later is exactly one Julian day later
+ #expect(Date(timeIntervalSince1970: 86400).julian == 2440588.5)
+ }
+
+ @Test func iso8601String() {
+ #expect(Date(timeIntervalSince1970: 0).iso8601 == "1970-01-01T00:00:00Z")
+ }
+
+ #if canImport(Foundation) && !canImport(FoundationEssentials)
+ @Test func iso8601Formatters() {
+ let epoch = Date(timeIntervalSince1970: 0)
+ #expect(Date.iso8601DateFormatter.string(from: epoch) == "1970-01-01T00:00:00Z")
+ #expect(Date.dateTimeFormatter.string(from: epoch) == "1970-01-01 00:00:00")
+ #expect(Date.dateFormatter.string(from: epoch) == "1970-01-01")
+ // matches the output of SQLite's datetime() and date() functions
+ #expect(Date.dateTimeFormatter.date(from: "1970-01-01 00:00:00") == epoch)
+ #expect(Date.dateFormatter.date(from: "1970-01-01") == epoch)
+ }
+ #endif
+
+ // MARK: - Date binding
+
+ @Test func dateFormatAffinity() {
+ #expect(Binding.DateFormat.text.affinity == .text)
+ #expect(Binding.DateFormat.real.affinity == .real)
+ #expect(Binding.DateFormat.integer.affinity == .integer)
+ }
+
+ @Test func dateFormats() {
+ let epoch = Date(timeIntervalSince1970: 0)
+ #expect(Binding.DateFormat.format(text: epoch) == "1970-01-01T00:00:00Z")
+ #expect(Binding.DateFormat.format(integer: epoch) == 0)
+ #expect(Binding.DateFormat.format(real: epoch) == 2440587.5)
+ #expect(Binding.date(epoch).integer == 0) // .integer is the default format
+ #expect(Binding.date(epoch, type: .integer).integer == 0)
+ #expect(Binding.date(epoch, type: .real).double == 2440587.5)
+ #expect(Binding.date(epoch, type: .text).string == "1970-01-01T00:00:00Z")
+ }
+
+ @Test func dateRoundTrip() throws {
+ let date = Date(timeIntervalSince1970: 1_000_000)
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (unix INTEGER, julian REAL, iso TEXT)")
+ try connection.run(
+ "INSERT INTO t (unix, julian, iso) VALUES (?, ?, ?)",
+ [.date(date, type: .integer), .date(date, type: .real), .date(date, type: .text)]
+ )
+ #expect(try connection.scalar("SELECT unix FROM t")?.integer == 1_000_000)
+ #expect(try connection.scalar("SELECT julian FROM t")?.double == date.julian)
+ #expect(try connection.scalar("SELECT iso FROM t")?.string == date.iso8601)
+ // SQLite's own date functions agree with the stored representations
+ #expect(try connection.scalar("SELECT unixepoch(iso) FROM t")?.integer == 1_000_000)
+ }
+
+ // MARK: - Data binding
+
+ @Test func emptyDataBindsZeroBlob() {
+ let binding = Data().binding
+ guard case .blob(.zero(0)) = binding else {
+ Issue.record("Expected .blob(.zero(0)), got \(binding)")
+ return
+ }
+ #expect(binding.bytes == [])
+ }
+
+ @Test func dataBindsBlobBytes() {
+ #expect(Data([1, 2, 3]).binding.bytes == [1, 2, 3])
+ }
+
+ @Test func dataRoundTrip() throws {
+ let data = Data([0xDE, 0xAD, 0xBE, 0xEF])
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (payload BLOB, empty BLOB)")
+ try connection.run("INSERT INTO t (payload, empty) VALUES (?, ?)", [data.binding, Data().binding])
+ let statement = try connection.prepare("SELECT payload, empty FROM t")
+ let row = try #require(try statement.failableNext())
+ #expect(row[0]?.bytes == [0xDE, 0xAD, 0xBE, 0xEF])
+ #expect(try connection.scalar("SELECT length(empty) FROM t")?.integer == 0)
+ }
+}
diff --git a/Tests/SQLiteTests/ConnectionStatementTests.swift b/Tests/SQLiteTests/ConnectionStatementTests.swift
new file mode 100644
index 0000000..8a56c02
--- /dev/null
+++ b/Tests/SQLiteTests/ConnectionStatementTests.swift
@@ -0,0 +1,154 @@
+//
+// ConnectionStatementTests.swift
+// SQLite
+//
+// Created by Alsey Coleman Miller on 7/17/26.
+//
+
+import Foundation
+import Testing
+@testable import SQLite
+
+@Suite struct ConnectionStatementTests {
+
+ // MARK: - Connection properties
+
+ @Test func threadSafety() {
+ // the connection is opened with SQLITE_OPEN_FULLMUTEX
+ #expect(Connection.isThreadSafe)
+ }
+
+ @Test func readOnlyState() throws {
+ let path = NSTemporaryDirectory() + "readonly-\(UUID().uuidString).sqlite"
+ defer { try? FileManager.default.removeItem(atPath: path) }
+ do {
+ let connection = try Connection(path: path)
+ #expect(connection.isReadonly == false)
+ try connection.run("CREATE TABLE t (value INTEGER)")
+ }
+ let connection = try Connection(path: path, isReadOnly: true)
+ let isReadonly = connection.isReadonly
+ #expect(isReadonly)
+ }
+
+ @Test func changeCounters() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)")
+ try connection.run("INSERT INTO t (value) VALUES ('a')")
+ #expect(connection.lastInsertRowID == 1)
+ #expect(connection.changes == 1)
+ try connection.run("INSERT INTO t (value) VALUES ('b')")
+ try connection.run("INSERT INTO t (value) VALUES ('c')")
+ #expect(connection.lastInsertRowID == 3)
+ try connection.run("UPDATE t SET value = 'z'")
+ #expect(connection.changes == 3)
+ #expect(connection.totalChanges == 6)
+ }
+
+ @Test func extendedErrorCodes() throws {
+ var connection = try Connection(path: ":memory:")
+ connection.usesExtendedErrorCodes = true
+ let isEnabled = connection.usesExtendedErrorCodes
+ #expect(isEnabled)
+ try connection.run("CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT UNIQUE)")
+ try connection.run("INSERT INTO t (value) VALUES ('a')")
+ do {
+ try connection.run("INSERT INTO t (value) VALUES ('a')")
+ Issue.record("Expected unique constraint violation")
+ } catch {
+ // SQLITE_CONSTRAINT_UNIQUE (2067) rather than the primary SQLITE_CONSTRAINT (19)
+ #expect(error.errorCode.rawValue == 2067)
+ }
+ connection.usesExtendedErrorCodes = false
+ let isDisabled = connection.usesExtendedErrorCodes == false
+ #expect(isDisabled)
+ }
+
+ // MARK: - execute
+
+ @Test func executeStopsAtLimit() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (value INTEGER)")
+ for value in 1...5 {
+ try connection.run("INSERT INTO t (value) VALUES (?)", [value.binding])
+ }
+ var unlimited = 0
+ try connection.execute(try Statement("SELECT value FROM t", connection: connection)) { row in
+ unlimited += 1
+ }
+ #expect(unlimited == 5)
+ // the limit check runs after the block, so limit N yields N + 1 rows
+ var limited = 0
+ try connection.execute(try Statement("SELECT value FROM t", connection: connection), limit: 2) { row in
+ limited += 1
+ }
+ #expect(limited == 3)
+ }
+
+ // MARK: - Statement
+
+ @Test func statementProperties() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (a INTEGER, b TEXT)")
+ let sql = "SELECT a, b FROM t"
+ let statement = try Statement(sql, connection: connection)
+ #expect(statement.sql == sql)
+ #expect(statement.columnCount == 2)
+ #expect(statement.columnName(at: 0) == "a")
+ #expect(statement.columnName(at: 1) == "b")
+ let update = try Statement("DELETE FROM t", connection: connection)
+ #expect(update.columnCount == 0)
+ }
+
+ @Test func prepareWithBindings() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (i INTEGER, d REAL, s TEXT, b BLOB, z BLOB, n TEXT)")
+ // every Binding case: integer, double, text, pointer blob, zero blob, null
+ let statement = try Statement.prepare(
+ "INSERT INTO t (i, d, s, b, z, n) VALUES (?, ?, ?, ?, ?, ?)",
+ bindings: [
+ .integer(42),
+ .double(1.5),
+ .text("value"),
+ Blob(bytes: [0xCA, 0xFE]).binding,
+ .blob(.zero(4)),
+ .null
+ ],
+ connection: connection
+ )
+ try connection.execute(statement) { _ in }
+ #expect(try connection.scalar("SELECT i FROM t")?.integer == 42)
+ #expect(try connection.scalar("SELECT d FROM t")?.double == 1.5)
+ #expect(try connection.scalar("SELECT s FROM t")?.string == "value")
+ #expect(try connection.scalar("SELECT hex(b) FROM t")?.string == "CAFE")
+ #expect(try connection.scalar("SELECT hex(z) FROM t")?.string == "00000000")
+ #expect(try connection.scalar("SELECT n IS NULL FROM t")?.integer == 1)
+ }
+
+ @Test func stepError() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (value TEXT UNIQUE)")
+ try connection.run("INSERT INTO t (value) VALUES ('a')")
+ do {
+ // fails at step, not prepare
+ try connection.run("INSERT INTO t (value) VALUES ('a')")
+ Issue.record("Expected unique constraint violation")
+ } catch {
+ #expect(error.errorCode.rawValue == 19) // SQLITE_CONSTRAINT
+ #expect(error.statement == "INSERT INTO t (value) VALUES ('a')")
+ }
+ }
+
+ @Test func writeToReadOnlyConnection() throws {
+ let path = NSTemporaryDirectory() + "readonly-write-\(UUID().uuidString).sqlite"
+ defer { try? FileManager.default.removeItem(atPath: path) }
+ do {
+ let connection = try Connection(path: path)
+ try connection.run("CREATE TABLE t (value INTEGER)")
+ }
+ let connection = try Connection(path: path, isReadOnly: true)
+ #expect(throws: SQLiteError.self) {
+ try connection.run("INSERT INTO t (value) VALUES (1)")
+ }
+ }
+}
diff --git a/Tests/SQLiteTests/FunctionEdgeCaseTests.swift b/Tests/SQLiteTests/FunctionEdgeCaseTests.swift
new file mode 100644
index 0000000..f2aaadc
--- /dev/null
+++ b/Tests/SQLiteTests/FunctionEdgeCaseTests.swift
@@ -0,0 +1,304 @@
+//
+// FunctionEdgeCaseTests.swift
+// SQLite
+//
+// Created by Alsey Coleman Miller on 7/17/26.
+//
+
+import Foundation
+import Testing
+@testable import SQLite
+
+@Suite struct FunctionEdgeCaseTests {
+
+ // MARK: - Scalar functions
+
+ @Test func removeFunction() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.createFunction("twice", argumentCount: 1) { arguments in
+ .integer((arguments[0].integer ?? 0) * 2)
+ }
+ #expect(try connection.scalar("SELECT twice(21)")?.integer == 42)
+ try connection.removeFunction("twice", argumentCount: 1)
+ #expect(throws: SQLiteError.self) {
+ _ = try connection.scalar("SELECT twice(21)")
+ }
+ }
+
+ @Test func functionReceivesEveryArgumentType() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.createFunction("kind", argumentCount: 1) { arguments in
+ switch arguments[0] {
+ case .null: return .text("null")
+ case .integer: return .text("integer")
+ case .double: return .text("double")
+ case .text: return .text("text")
+ case .blob(let blob):
+ guard case let .blob(binding) = Binding.blob(blob), let bytes = Binding.blob(binding).bytes else {
+ return .text("blob")
+ }
+ return .text("blob(\(bytes.count))")
+ }
+ }
+ #expect(try connection.scalar("SELECT kind(NULL)")?.string == "null")
+ #expect(try connection.scalar("SELECT kind(1)")?.string == "integer")
+ #expect(try connection.scalar("SELECT kind(1.5)")?.string == "double")
+ #expect(try connection.scalar("SELECT kind('x')")?.string == "text")
+ #expect(try connection.scalar("SELECT kind(X'CAFE')")?.string == "blob(2)")
+ // a zero-length blob argument arrives as an empty blob
+ #expect(try connection.scalar("SELECT kind(X'')")?.string == "blob(0)")
+ }
+
+ @Test func functionReturnsEveryResultType() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.createFunction("make_null", argumentCount: 0) { _ in .null }
+ try connection.createFunction("make_blob", argumentCount: 0) { _ in
+ Blob(bytes: [0xAB, 0xCD]).binding
+ }
+ try connection.createFunction("make_zeroblob", argumentCount: 0) { _ in .blob(.zero(2)) }
+ #expect(try connection.scalar("SELECT make_null() IS NULL")?.integer == 1)
+ #expect(try connection.scalar("SELECT hex(make_blob())")?.string == "ABCD")
+ #expect(try connection.scalar("SELECT hex(make_zeroblob())")?.string == "0000")
+ }
+
+ // MARK: - Collations
+
+ @Test func removeCollation() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (value TEXT)")
+ try connection.createCollation("REVERSE") { lhs, rhs in
+ rhs == lhs ? 0 : (rhs < lhs ? -1 : 1)
+ }
+ try connection.run("SELECT value FROM t ORDER BY value COLLATE REVERSE")
+ try connection.removeCollation("REVERSE")
+ #expect(throws: SQLiteError.self) {
+ try connection.run("SELECT value FROM t ORDER BY value COLLATE REVERSE")
+ }
+ }
+
+ @Test func collationComparesEmptyStrings() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (value TEXT)")
+ for value in ["b", "", "a"] {
+ try connection.run("INSERT INTO t (value) VALUES (?)", [value.binding])
+ }
+ try connection.createCollation("SIMPLE") { lhs, rhs in
+ lhs == rhs ? 0 : (lhs < rhs ? -1 : 1)
+ }
+ let statement = try connection.prepare("SELECT value FROM t ORDER BY value COLLATE SIMPLE")
+ var results = [String]()
+ while let row = try statement.failableNext() {
+ results.append(row[0]?.string ?? "?")
+ }
+ #expect(results == ["", "a", "b"])
+ }
+
+ // MARK: - Window functions
+
+ @Test
+ @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, visionOS 1.0, *)
+ func windowFunctionMovingFrameUsesInverse() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (position INTEGER, value INTEGER)")
+ for (position, value) in [(1, 10), (2, 20), (3, 30)] {
+ try connection.run("INSERT INTO t (position, value) VALUES (?, ?)", [position.binding, value.binding])
+ }
+ try connection.createWindowFunction(
+ "moving_sum",
+ argumentCount: 1,
+ initialState: { Int64(0) },
+ step: { state, arguments in state += arguments[0].integer ?? 0 },
+ inverse: { state, arguments in state -= arguments[0].integer ?? 0 },
+ value: { state in .integer(state) },
+ final: { state in .integer(state) }
+ )
+ // a sliding two-row frame forces xInverse to evict rows leaving the window
+ let statement = try connection.prepare(
+ "SELECT moving_sum(value) OVER (ORDER BY position ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) FROM t ORDER BY position"
+ )
+ var results = [Int64]()
+ while let row = try statement.failableNext() {
+ results.append(row[0]?.integer ?? -1)
+ }
+ #expect(results == [10, 30, 50])
+ }
+
+ @Test
+ @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, visionOS 1.0, *)
+ func windowFunctionEmptyFrameUsesInitialState() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (position INTEGER, value INTEGER)")
+ for (position, value) in [(1, 10), (2, 20)] {
+ try connection.run("INSERT INTO t (position, value) VALUES (?, ?)", [position.binding, value.binding])
+ }
+ try connection.createWindowFunction(
+ "frame_sum",
+ argumentCount: 1,
+ initialState: { Int64(0) },
+ step: { state, arguments in state += arguments[0].integer ?? 0 },
+ inverse: { state, arguments in state -= arguments[0].integer ?? 0 },
+ value: { state in .integer(state) },
+ final: { state in .integer(state) }
+ )
+ // the first row's frame (the preceding row only) is empty, so xValue
+ // runs without any accumulated state
+ let statement = try connection.prepare(
+ "SELECT frame_sum(value) OVER (ORDER BY position ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) FROM t ORDER BY position"
+ )
+ var results = [Int64]()
+ while let row = try statement.failableNext() {
+ results.append(row[0]?.integer ?? -1)
+ }
+ #expect(results == [0, 10])
+ }
+
+ // MARK: - Schema default values
+
+ @Test func createTableWithDefaultValues() throws {
+ let connection = try Connection(path: ":memory:")
+ let schemaChanger = SchemaChanger(connection: connection)
+ try schemaChanger.create(table: "settings") { table in
+ table.add(column: ColumnDefinition(
+ name: "id", primaryKey: .init(autoIncrement: true), type: .INTEGER,
+ nullable: true, unique: false, defaultValue: .NULL, references: nil
+ ))
+ table.add(column: ColumnDefinition(
+ name: "count", primaryKey: nil, type: .INTEGER,
+ nullable: false, unique: false, defaultValue: .integer(7), references: nil
+ ))
+ table.add(column: ColumnDefinition(
+ name: "ratio", primaryKey: nil, type: .REAL,
+ nullable: false, unique: false, defaultValue: .double(2.5), references: nil
+ ))
+ table.add(column: ColumnDefinition(
+ name: "owner", primaryKey: nil, type: .TEXT,
+ nullable: false, unique: false, defaultValue: .text("O'Brien"), references: nil
+ ))
+ }
+ try connection.run("INSERT INTO settings DEFAULT VALUES")
+ #expect(try connection.scalar("SELECT count FROM settings")?.integer == 7)
+ #expect(try connection.scalar("SELECT ratio FROM settings")?.double == 2.5)
+ // the single quote survives SQL escaping in the DEFAULT clause
+ #expect(try connection.scalar("SELECT owner FROM settings")?.string == "O'Brien")
+ }
+
+ // MARK: - Binding accessors
+
+ @Test func bytesAccessorOnNonBlob() {
+ #expect(Binding.null.bytes == nil)
+ #expect(Binding.integer(1).bytes == nil)
+ #expect(Binding.text("x").bytes == nil)
+ }
+
+ // MARK: - Registration failures
+
+ // SQLite rejects functions with more arguments than SQLITE_MAX_FUNCTION_ARG,
+ // whose compile-time ceiling is 32767 (the default varies by version: 127
+ // for the system library, 1000 for newer embedded builds), exercising the
+ // failure path of registration.
+
+ @Test func createFunctionWithTooManyArguments() throws {
+ let connection = try Connection(path: ":memory:")
+ #expect(throws: SQLiteError.self) {
+ try connection.createFunction("f", argumentCount: 32768) { _ in .null }
+ }
+ }
+
+ @Test func createAggregateFunctionWithTooManyArguments() throws {
+ let connection = try Connection(path: ":memory:")
+ #expect(throws: SQLiteError.self) {
+ try connection.createAggregateFunction(
+ "f",
+ argumentCount: 32768,
+ initialState: { Int64(0) },
+ step: { _, _ in },
+ final: { _ in .null }
+ )
+ }
+ }
+
+ @Test
+ @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, visionOS 1.0, *)
+ func createWindowFunctionWithTooManyArguments() throws {
+ let connection = try Connection(path: ":memory:")
+ #expect(throws: SQLiteError.self) {
+ try connection.createWindowFunction(
+ "f",
+ argumentCount: 32768,
+ initialState: { Int64(0) },
+ step: { _, _ in },
+ inverse: { _, _ in },
+ value: { _ in .null },
+ final: { _ in .null }
+ )
+ }
+ }
+
+ @Test func nilSQLiteValueIsNull() {
+ // SQLite never passes nil argument values, but the conversion tolerates it
+ guard case .null = Binding(sqliteValue: nil) else {
+ Issue.record("Expected .null for a nil sqlite3_value")
+ return
+ }
+ }
+
+ // MARK: - Error paths
+
+ @Test func bindBeyondParameterCount() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (value INTEGER)")
+ do {
+ // one parameter in the SQL, two bindings: the second bind is out of range
+ _ = try Statement.prepare(
+ "INSERT INTO t (value) VALUES (?)",
+ bindings: [.integer(1), .integer(2)],
+ connection: connection
+ )
+ Issue.record("Expected out-of-range bind to fail")
+ } catch {
+ #expect(error.errorCode.rawValue == 25) // SQLITE_RANGE
+ }
+ }
+
+ @Test func columnReadsReportConnectionErrors() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (i INTEGER, n TEXT)")
+ try connection.run("INSERT INTO t (i, n) VALUES (42, NULL)")
+ let statement = try Statement("SELECT i, n FROM t", connection: connection)
+ #expect(try statement.handle.step(connection: connection.handle).get())
+ // reading a NULL column as a blob yields no pointer and reports an error
+ guard case .failure = statement.handle.readBlob(at: 1, connection: connection.handle) else {
+ Issue.record("Expected readBlob of NULL column to fail")
+ return
+ }
+ // a failed statement leaves an error code on the connection, which
+ // subsequent column reads surface
+ #expect(throws: SQLiteError.self) {
+ _ = try Statement("SELECT * FROM missing_table", connection: connection)
+ }
+ let handle = statement.handle
+ guard case .failure = handle.readText(at: 0, connection: connection.handle),
+ case .failure = handle.readInteger(at: 0, connection: connection.handle),
+ case .failure = handle.readDouble(at: 0, connection: connection.handle),
+ case .failure = handle.readBlobSize(at: 0, connection: connection.handle) else {
+ Issue.record("Expected column reads to surface the connection's error code")
+ return
+ }
+ }
+
+ @Test func blobReadReportsStaleConnectionError() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (b BLOB)")
+ try connection.run("INSERT INTO t (b) VALUES (X'CAFE')")
+ let statement = try Statement("SELECT b FROM t", connection: connection)
+ #expect(try statement.handle.step(connection: connection.handle).get())
+ // the column pointer is valid, but a stale connection error still surfaces
+ #expect(throws: SQLiteError.self) {
+ _ = try Statement("SELECT * FROM missing_table", connection: connection)
+ }
+ guard case .failure = statement.handle.readBlob(at: 0, connection: connection.handle) else {
+ Issue.record("Expected blob read to surface the connection's error code")
+ return
+ }
+ }
+}
diff --git a/Tests/SQLiteTests/LocationAndAffinityTests.swift b/Tests/SQLiteTests/LocationAndAffinityTests.swift
new file mode 100644
index 0000000..2c5a1ef
--- /dev/null
+++ b/Tests/SQLiteTests/LocationAndAffinityTests.swift
@@ -0,0 +1,131 @@
+//
+// LocationAndAffinityTests.swift
+// SQLite
+//
+// Created by Alsey Coleman Miller on 7/17/26.
+//
+
+import Foundation
+import Testing
+@testable import SQLite
+
+@Suite struct LocationAndAffinityTests {
+
+ // MARK: - Connection.Location
+
+ @Test func locationDescriptions() {
+ #expect(Connection.Location.inMemory.description == ":memory:")
+ #expect(Connection.Location.temporary.description == "")
+ #expect(Connection.Location.uri("/tmp/db.sqlite").description == "/tmp/db.sqlite")
+ #expect(Connection.Location.uri("/tmp/db.sqlite", parameters: []).description == "/tmp/db.sqlite")
+ }
+
+ @Test func locationURIWithParameters() {
+ let description = Connection.Location.uri(
+ "/tmp/db.sqlite",
+ parameters: [.mode(.readOnly), .cache(.shared)]
+ ).description
+ // a scheme-less URI gains the file: scheme when parameters are appended
+ #expect(description.hasPrefix("file:"))
+ #expect(description.contains("mode=ro"))
+ #expect(description.contains("cache=shared"))
+ // existing query items are preserved
+ let merged = Connection.Location.uri(
+ "file:/tmp/db.sqlite?foo=1",
+ parameters: [.immutable(true)]
+ ).description
+ #expect(merged.contains("foo=1"))
+ #expect(merged.contains("immutable=1"))
+ }
+
+ @Test func locationEquality() {
+ #expect(Connection.Location.inMemory == .inMemory)
+ #expect(Connection.Location.inMemory != .temporary)
+ #expect(Connection.Location.uri("/a") == .uri("/a", parameters: []))
+ let locations: Set = [.inMemory, .temporary, .uri("/a"), .uri("/a")]
+ #expect(locations.count == 3)
+ }
+
+ @Test func openInMemoryLocation() throws {
+ let connection = try Connection(path: .inMemory)
+ try connection.run("CREATE TABLE t (value INTEGER)")
+ try connection.run("INSERT INTO t (value) VALUES (?)", [42.binding])
+ #expect(try connection.scalar("SELECT value FROM t")?.integer == 42)
+ }
+
+ @Test func openTemporaryLocation() throws {
+ let connection = try Connection(path: .temporary)
+ try connection.run("CREATE TABLE t (value INTEGER)")
+ #expect(try connection.scalar("SELECT COUNT(*) FROM t")?.integer == 0)
+ }
+
+ // MARK: - URIQueryParameter
+
+ @Test func uriQueryParameterItems() {
+ #expect(URIQueryParameter.cache(.shared).queryItem == URLQueryItem(name: "cache", value: "shared"))
+ #expect(URIQueryParameter.cache(.private).queryItem == URLQueryItem(name: "cache", value: "private"))
+ #expect(URIQueryParameter.immutable(true).queryItem == URLQueryItem(name: "immutable", value: "1"))
+ #expect(URIQueryParameter.immutable(false).queryItem == URLQueryItem(name: "immutable", value: "0"))
+ #expect(URIQueryParameter.modeOf("/tmp/base.sqlite").queryItem == URLQueryItem(name: "modeOf", value: "/tmp/base.sqlite"))
+ #expect(URIQueryParameter.mode(.memory).queryItem == URLQueryItem(name: "mode", value: "memory"))
+ #expect(URIQueryParameter.nolock(true).queryItem == URLQueryItem(name: "nolock", value: "1"))
+ #expect(URIQueryParameter.nolock(false).queryItem == URLQueryItem(name: "nolock", value: "0"))
+ #expect(URIQueryParameter.powersafeOverwrite(true).queryItem == URLQueryItem(name: "psow", value: "1"))
+ #expect(URIQueryParameter.powersafeOverwrite(false).queryItem == URLQueryItem(name: "psow", value: "0"))
+ #expect(URIQueryParameter.vfs("unix-none").queryItem == URLQueryItem(name: "vfs", value: "unix-none"))
+ }
+
+ @Test func uriQueryParameterDescription() {
+ #expect(URIQueryParameter.mode(.readWriteCreate).description == URLQueryItem(name: "mode", value: "rwc").description)
+ }
+
+ @Test func fileModeRawValues() {
+ #expect(URIQueryParameter.FileMode.readOnly.rawValue == "ro")
+ #expect(URIQueryParameter.FileMode.readWrite.rawValue == "rw")
+ #expect(URIQueryParameter.FileMode.readWriteCreate.rawValue == "rwc")
+ #expect(URIQueryParameter.FileMode.memory.rawValue == "memory")
+ #expect(URIQueryParameter.FileMode.allCases.count == 4)
+ #expect(URIQueryParameter.CacheMode.allCases.map(\.rawValue) == ["shared", "private"])
+ }
+
+ // MARK: - TypeAffinity
+
+ @Test func typeAffinityFromDeclaredType() throws {
+ guard #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) else { return }
+ // Rule 1: INT anywhere in the declared type
+ #expect(TypeAffinity("INTEGER") == .integer)
+ #expect(TypeAffinity("TINYINT") == .integer)
+ #expect(TypeAffinity("int") == .integer)
+ // rule order: CHARINT matches rules 1 and 2, rule 1 wins
+ #expect(TypeAffinity("CHARINT") == .integer)
+ // Rule 2: CHAR, CLOB, or TEXT
+ #expect(TypeAffinity("VARCHAR(255)") == .text)
+ #expect(TypeAffinity("CLOB") == .text)
+ #expect(TypeAffinity("text") == .text)
+ // Rule 3: BLOB (case-insensitive, like every other rule)
+ #expect(TypeAffinity("BLOB") == .blob)
+ #expect(TypeAffinity("blob") == .blob)
+ // Rule 4: REAL, FLOA, or DOUB
+ #expect(TypeAffinity("REAL") == .real)
+ #expect(TypeAffinity("FLOAT") == .real)
+ #expect(TypeAffinity("DOUBLE PRECISION") == .real)
+ // Rule 5: everything else
+ #expect(TypeAffinity("DECIMAL(10,5)") == .numeric)
+ #expect(TypeAffinity("DATETIME") == .numeric)
+ #expect(TypeAffinity("") == .numeric)
+ }
+
+ @Test func typeAffinityRules() {
+ #expect(TypeAffinity.integer.rule == 1)
+ #expect(TypeAffinity.text.rule == 2)
+ #expect(TypeAffinity.blob.rule == 3)
+ #expect(TypeAffinity.real.rule == 4)
+ #expect(TypeAffinity.numeric.rule == 5)
+ }
+
+ @Test func typeAffinityDescription() {
+ for affinity in TypeAffinity.allCases {
+ #expect(affinity.description == affinity.rawValue)
+ }
+ }
+}
diff --git a/Tests/SQLiteTests/OfficialSuitePortTests.swift b/Tests/SQLiteTests/OfficialSuitePortTests.swift
new file mode 100644
index 0000000..5161c53
--- /dev/null
+++ b/Tests/SQLiteTests/OfficialSuitePortTests.swift
@@ -0,0 +1,252 @@
+//
+// OfficialSuitePortTests.swift
+// SQLite
+//
+// Created by Alsey Coleman Miller on 7/17/26.
+//
+// Ports of representative cases from SQLite's official TCL test suite
+// (https://sqlite.org/testing.html). Each test cites the original test file
+// and case numbers (e.g. select1-1.4) from sqlite/test/*.test.
+//
+
+import Foundation
+import Testing
+@testable import SQLite
+
+/// A Swift stand-in for the TCL test harness's `execsql` / `catchsql` commands.
+///
+/// Like `execsql`, `run(_:)` executes a script and returns all result rows
+/// flattened into a single list of strings, with NULL rendered as the empty
+/// string and blobs rendered as uppercase hex.
+struct TCLHarness: ~Copyable {
+
+ let connection: Connection
+
+ init() throws(SQLiteError) {
+ self.connection = try Connection(path: ":memory:")
+ }
+
+ /// Executes each statement of the script and returns the flattened results.
+ ///
+ /// Statements are split on `;`, so scripts must not embed semicolons in
+ /// string literals — the ported tests don't.
+ @discardableResult
+ func run(_ script: String) throws(SQLiteError) -> [String] {
+ var results = [String]()
+ for sql in script.split(separator: ";") {
+ let sql = sql.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard sql.isEmpty == false else { continue }
+ let statement = try connection.prepare(sql)
+ while let row = try statement.failableNext() {
+ for value in row {
+ if let string = value?.string {
+ results.append(string)
+ } else if let bytes = value?.bytes {
+ results.append(bytes.map { String(format: "%02X", $0) }.joined())
+ } else {
+ results.append("") // TCL renders NULL as the empty string
+ }
+ }
+ }
+ }
+ return results
+ }
+
+ /// Like TCL's `catchsql`: expects the script to fail and returns the error message.
+ func errorMessage(_ script: String) -> String? {
+ do {
+ _ = try run(script)
+ return nil
+ } catch {
+ return error.message
+ }
+ }
+}
+
+@Suite struct OfficialSuitePortTests {
+
+ // MARK: - select1.test
+
+ @Test func select1() throws {
+ let db = try TCLHarness()
+ // select1-1.1: querying a table before it exists is an error
+ let missingTable = db.errorMessage("SELECT f1 FROM test1")
+ #expect(missingTable?.contains("no such table") == true)
+ try db.run("CREATE TABLE test1(f1 int, f2 int); INSERT INTO test1(f1,f2) VALUES(11,22)")
+ // select1-1.4
+ #expect(try db.run("SELECT f1 FROM test1") == ["11"])
+ // select1-1.7
+ #expect(try db.run("SELECT f2 FROM test1") == ["22"])
+ // select1-1.8
+ #expect(try db.run("SELECT * FROM test1") == ["11", "22"])
+ try db.run("INSERT INTO test1(f1,f2) VALUES(33,44)")
+ // select1-1.11.1
+ #expect(try db.run("SELECT * FROM test1 ORDER BY f1") == ["11", "22", "33", "44"])
+ // select1-1.12
+ #expect(try db.run("SELECT f1 FROM test1 WHERE f2==44") == ["33"])
+ // select1-2.x aggregates
+ #expect(try db.run("SELECT count(*) FROM test1") == ["2"])
+ #expect(try db.run("SELECT min(f1) FROM test1") == ["11"])
+ #expect(try db.run("SELECT max(f1)+1 FROM test1") == ["34"])
+ #expect(try db.run("SELECT sum(f1) FROM test1") == ["44"])
+ #expect(try db.run("SELECT avg(f1) FROM test1") == ["22.0"])
+ // select1-4.5: ordering by a nonexistent column is an error
+ let missingColumn = db.errorMessage("SELECT f1 FROM test1 ORDER BY f3")
+ #expect(missingColumn?.contains("no such column") == true)
+ // select1-6.9.x: DISTINCT
+ try db.run("INSERT INTO test1(f1,f2) VALUES(11,22)")
+ #expect(try db.run("SELECT DISTINCT f1 FROM test1 ORDER BY f1") == ["11", "33"])
+ // select1-12.x: LIMIT and OFFSET
+ #expect(try db.run("SELECT f1 FROM test1 ORDER BY f1 LIMIT 2 OFFSET 1") == ["11", "33"])
+ }
+
+ // MARK: - null.test
+
+ @Test func nullHandling() throws {
+ let db = try TCLHarness()
+ // the table from null.test's setup, rows 1-7
+ try db.run("""
+ CREATE TABLE t1(a int, b int, c int);
+ INSERT INTO t1 VALUES(1,0,0);
+ INSERT INTO t1 VALUES(2,0,1);
+ INSERT INTO t1 VALUES(3,1,0);
+ INSERT INTO t1 VALUES(4,1,1);
+ INSERT INTO t1 VALUES(5,null,0);
+ INSERT INTO t1 VALUES(6,null,1);
+ INSERT INTO t1 VALUES(7,null,null)
+ """)
+ // null-1.4: count(*) counts NULL rows, count(column) does not
+ #expect(try db.run("SELECT count(*), count(b) FROM t1") == ["7", "4"])
+ // null-2.1: NULLs are excluded by any comparison
+ #expect(try db.run("SELECT a FROM t1 WHERE b < 10 ORDER BY a") == ["1", "2", "3", "4"])
+ #expect(try db.run("SELECT a FROM t1 WHERE b IS NULL ORDER BY a") == ["5", "6", "7"])
+ // null-3.x: three-valued logic
+ #expect(try db.run("SELECT null AND 0") == ["0"])
+ #expect(try db.run("SELECT null AND 1") == [""])
+ #expect(try db.run("SELECT null OR 1") == ["1"])
+ #expect(try db.run("SELECT null OR 0") == [""])
+ // null-4.1: NULL never equals NULL
+ #expect(try db.run("SELECT 1 WHERE NULL = NULL") == [])
+ #expect(try db.run("SELECT NULL IS NULL") == ["1"])
+ // null-5.x: DISTINCT treats NULLs as equal
+ #expect(try db.run("SELECT count(DISTINCT b) FROM t1") == ["2"])
+ // null-6.x: NULLs sort first by default
+ #expect(try db.run("SELECT b FROM t1 ORDER BY b LIMIT 3") == ["", "", ""])
+ }
+
+ // MARK: - types.test
+
+ @Test func storageClassesAndAffinity() throws {
+ let db = try TCLHarness()
+ // types-1.x: typeof() literals
+ #expect(try db.run("SELECT typeof(1)") == ["integer"])
+ #expect(try db.run("SELECT typeof(1.0)") == ["real"])
+ #expect(try db.run("SELECT typeof('x')") == ["text"])
+ #expect(try db.run("SELECT typeof(NULL)") == ["null"])
+ #expect(try db.run("SELECT typeof(X'AB')") == ["blob"])
+ // types-1.1.x: column affinity converts well-formed literals on insert
+ try db.run("""
+ CREATE TABLE t1(i INTEGER, r REAL, t TEXT, n NUMERIC);
+ INSERT INTO t1 VALUES('500', 500, 500, '500.0')
+ """)
+ #expect(try db.run("SELECT typeof(i), typeof(r), typeof(t), typeof(n) FROM t1")
+ == ["integer", "real", "text", "integer"])
+ // text that is not a well-formed number is stored as text despite affinity
+ try db.run("DELETE FROM t1; INSERT INTO t1 VALUES('abc', 'abc', 'abc', 'abc')")
+ #expect(try db.run("SELECT typeof(i), typeof(r), typeof(t), typeof(n) FROM t1")
+ == ["text", "text", "text", "text"])
+ // types-2.1.x: integer range round-trip including 64-bit boundaries
+ try db.run("CREATE TABLE t2(x INTEGER)")
+ for value in ["0", "1", "-1", "9223372036854775807", "-9223372036854775808"] {
+ try db.run("DELETE FROM t2; INSERT INTO t2 VALUES(\(value))")
+ #expect(try db.run("SELECT x FROM t2") == [value])
+ }
+ }
+
+ // MARK: - expr.test
+
+ @Test func expressions() throws {
+ let db = try TCLHarness()
+ // expr-1.x: arithmetic operators and precedence
+ #expect(try db.run("SELECT 1+2.3") == ["3.3"])
+ #expect(try db.run("SELECT 6/4") == ["1"]) // integer division truncates
+ #expect(try db.run("SELECT 6.0/4") == ["1.5"])
+ #expect(try db.run("SELECT 5%3") == ["2"])
+ #expect(try db.run("SELECT 2+3*4") == ["14"])
+ // expr-1.x: bitwise operators
+ #expect(try db.run("SELECT 4<<1, 4>>1, 6&3, 6|3, ~0") == ["8", "2", "2", "7", "-1"])
+ // expr-4.x: string concatenation
+ #expect(try db.run("SELECT 'a' || 'b'") == ["ab"])
+ // expr-5.x: LIKE is case-insensitive for ASCII, GLOB is case-sensitive
+ #expect(try db.run("SELECT 'abc' LIKE 'ABC'") == ["1"])
+ #expect(try db.run("SELECT 'abc' LIKE 'ab%'") == ["1"])
+ #expect(try db.run("SELECT 'abc' GLOB 'ABC'") == ["0"])
+ #expect(try db.run("SELECT 'abc' GLOB 'ab*'") == ["1"])
+ // expr-8.x: CASE expressions
+ #expect(try db.run("SELECT CASE WHEN 1>0 THEN 'yes' ELSE 'no' END") == ["yes"])
+ #expect(try db.run("SELECT CASE 2 WHEN 1 THEN 'one' WHEN 2 THEN 'two' END") == ["two"])
+ // expr-10.x: CAST
+ #expect(try db.run("SELECT CAST('123abc' AS INTEGER)") == ["123"])
+ #expect(try db.run("SELECT CAST(4.6 AS INTEGER)") == ["4"]) // truncates toward zero
+ #expect(try db.run("SELECT typeof(CAST(1 AS TEXT))") == ["text"])
+ // expr-11.x: BETWEEN and IN
+ #expect(try db.run("SELECT 5 BETWEEN 1 AND 10") == ["1"])
+ #expect(try db.run("SELECT 3 IN (1,2,3)") == ["1"])
+ }
+
+ // MARK: - insert.test / delete.test / update.test
+
+ @Test func insertDeleteUpdate() throws {
+ let db = try TCLHarness()
+ // insert-1.1: inserting into a nonexistent table is an error
+ let missingTable = db.errorMessage("INSERT INTO test1 VALUES(1)")
+ #expect(missingTable?.contains("no such table") == true)
+ try db.run("CREATE TABLE test1(one int, two text)")
+ // insert-1.3-ish: column order can be permuted
+ try db.run("INSERT INTO test1(two, one) VALUES('hello', 1)")
+ #expect(try db.run("SELECT one, two FROM test1") == ["1", "hello"])
+ // insert-2.x: INSERT INTO ... SELECT
+ try db.run("""
+ CREATE TABLE test2(one int, two text);
+ INSERT INTO test2 SELECT one+1, two FROM test1
+ """)
+ #expect(try db.run("SELECT one, two FROM test2") == ["2", "hello"])
+ // too many values is an error
+ let tooManyValues = db.errorMessage("INSERT INTO test1 VALUES(1, 'x', 3)")
+ #expect(tooManyValues?.contains("values") == true)
+ // delete-3.x: DELETE with a WHERE clause
+ try db.run("""
+ CREATE TABLE t3(a int);
+ INSERT INTO t3 VALUES(1);
+ INSERT INTO t3 VALUES(2);
+ INSERT INTO t3 VALUES(3);
+ INSERT INTO t3 VALUES(4)
+ """)
+ try db.run("DELETE FROM t3 WHERE a % 2 == 0")
+ #expect(try db.run("SELECT a FROM t3 ORDER BY a") == ["1", "3"])
+ // update-3.x: UPDATE with expressions and WHERE
+ try db.run("UPDATE t3 SET a = a * 10 WHERE a > 1")
+ #expect(try db.run("SELECT a FROM t3 ORDER BY a") == ["1", "30"])
+ // delete-1.x: DELETE without WHERE empties the table
+ try db.run("DELETE FROM t3")
+ #expect(try db.run("SELECT count(*) FROM t3") == ["0"])
+ }
+
+ // MARK: - collate1.test
+
+ @Test func builtInCollations() throws {
+ let db = try TCLHarness()
+ try db.run("""
+ CREATE TABLE t1(a TEXT COLLATE NOCASE);
+ INSERT INTO t1 VALUES('aaa');
+ INSERT INTO t1 VALUES('BBB');
+ INSERT INTO t1 VALUES('ccc')
+ """)
+ // collate1-1.x: NOCASE ignores ASCII case in ordering and comparison
+ #expect(try db.run("SELECT a FROM t1 ORDER BY a") == ["aaa", "BBB", "ccc"])
+ #expect(try db.run("SELECT a FROM t1 WHERE a = 'AAA'") == ["aaa"])
+ // BINARY is case-sensitive: uppercase sorts before lowercase
+ #expect(try db.run("SELECT a FROM t1 ORDER BY a COLLATE BINARY") == ["BBB", "aaa", "ccc"])
+ #expect(try db.run("SELECT a FROM t1 WHERE a = 'AAA' COLLATE BINARY") == [])
+ }
+}
diff --git a/Tests/SQLiteTests/SQLiteTests.swift b/Tests/SQLiteTests/SQLiteTests.swift
index 9f5853a..a449dd5 100644
--- a/Tests/SQLiteTests/SQLiteTests.swift
+++ b/Tests/SQLiteTests/SQLiteTests.swift
@@ -24,7 +24,7 @@ import Testing
_ = try Statement(sql, connection: connection)
}
catch {
- #expect(error.message == "Unable to initialize statement.")
+ #expect(error.message == "no such table: abcdz")
#expect(error.statement == sql)
print(error)
return
@@ -67,7 +67,7 @@ import Testing
}
}
}
- #expect(count == 5932)
+ #expect(count == 6099)
}
@Test func iterateTextRows() throws {
diff --git a/Tests/SQLiteTests/ValueAccessTests.swift b/Tests/SQLiteTests/ValueAccessTests.swift
new file mode 100644
index 0000000..d506662
--- /dev/null
+++ b/Tests/SQLiteTests/ValueAccessTests.swift
@@ -0,0 +1,164 @@
+//
+// ValueAccessTests.swift
+// SQLite
+//
+// Created by Alsey Coleman Miller on 7/17/26.
+//
+
+import Foundation
+import Testing
+@testable import SQLite
+
+@Suite struct ValueAccessTests {
+
+ // MARK: - Binding construction
+
+ @Test func boolBinding() {
+ #expect(Binding.bool(true).integer == 1)
+ #expect(Binding.bool(false).integer == 0)
+ #expect(true.binding.integer == 1)
+ #expect(false.binding.integer == 0)
+ }
+
+ @Test func floatBinding() {
+ #expect(Binding.float(1.5).double == 1.5)
+ #expect(Float(2.5).binding.double == 2.5)
+ }
+
+ @Test func optionalBinding() {
+ let none: String? = nil
+ guard case .null = none.binding else {
+ Issue.record("Expected .null binding")
+ return
+ }
+ let some: String? = "value"
+ #expect(some.binding.string == "value")
+ }
+
+ @Test func rawRepresentableBinding() {
+ enum Color: String {
+ case red
+ }
+ #expect(Color.red.binding.string == "red")
+ enum Level: Int {
+ case high = 3
+ }
+ #expect(Level.high.binding.integer == 3)
+ }
+
+ @Test func sequenceBinding() {
+ #expect([1, 2, 3].binding.compactMap(\.integer) == [1, 2, 3])
+ }
+
+ @Test func fixedWidthIntegerBinding() {
+ #expect(UInt8(7).binding.integer == 7)
+ #expect(Int16(-300).binding.integer == -300)
+ #expect(UInt32(70_000).binding.integer == 70_000)
+ }
+
+ // MARK: - Binding conversion accessors
+
+ @Test func integerAccessor() {
+ #expect(Binding.integer(42).integer == 42)
+ #expect(Binding.double(3.9).integer == 3)
+ #expect(Binding.text("42").integer == 42)
+ #expect(Binding.text("not a number").integer == nil)
+ #expect(Binding.null.integer == nil)
+ #expect(Blob(bytes: [1]).binding.integer == nil)
+ }
+
+ @Test func doubleAccessor() {
+ #expect(Binding.integer(3).double == 3.0)
+ #expect(Binding.double(2.5).double == 2.5)
+ #expect(Binding.text("2.5").double == 2.5)
+ #expect(Binding.text("not a number").double == nil)
+ #expect(Binding.null.double == nil)
+ #expect(Blob(bytes: [1]).binding.double == nil)
+ }
+
+ @Test func stringAccessor() {
+ #expect(Binding.integer(5).string == "5")
+ #expect(Binding.double(2.5).string == "2.5")
+ #expect(Binding.text("value").string == "value")
+ #expect(Binding.null.string == nil)
+ #expect(Blob(bytes: [1]).binding.string == nil)
+ }
+
+ // MARK: - Column
+
+ @Test func columnID() {
+ let column = Column(row: 0, index: 3, name: "value")
+ #expect(column.id == 3)
+ }
+
+ @Test func columnValueTypes() {
+ #expect(Column.Value.null.type == .null)
+ #expect(Column.Value.double(1.5).type == .double)
+ #expect(Column.Value.integer(42).type == .integer)
+ #expect(Column.Value.text("value").type == .text)
+ let bytes: [UInt8] = [1, 2, 3]
+ bytes.withUnsafeBytes { buffer in
+ #expect(Column.Value.blob(buffer).type == .blob)
+ }
+ }
+
+ // MARK: - Row reading
+
+ @Test func readEveryValueType() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (n, d, i, s, b, empty)")
+ try connection.run(
+ "INSERT INTO t (n, d, i, s, b, empty) VALUES (NULL, 1.5, 42, 'text', X'DEAD', X'')"
+ )
+ let statement = try Statement("SELECT n, d, i, s, b, empty FROM t", connection: connection)
+ try connection.execute(statement) { (row: consuming Row) throws(SQLiteError) -> () in
+ #expect(try row.readType(at: 0) == .null)
+ #expect(try row.read(at: 0) { $0.type } == .null)
+ let double: Double? = try row.read(at: 1) { value in
+ guard case let .double(double) = value else { return nil }
+ return double
+ }
+ #expect(double == 1.5)
+ let integer: Int64? = try row.read(at: 2) { value in
+ guard case let .integer(integer) = value else { return nil }
+ return integer
+ }
+ #expect(integer == 42)
+ let text: String? = try row.read(at: 3) { value in
+ guard case let .text(text) = value else { return nil }
+ return text
+ }
+ #expect(text == "text")
+ let blobBytes: [UInt8]? = try row.read(at: 4) { value in
+ guard case let .blob(buffer) = value else { return nil }
+ return [UInt8](buffer)
+ }
+ #expect(blobBytes == [0xDE, 0xAD])
+ // a zero-length blob reads as an empty buffer
+ let emptyCount: Int? = try row.read(at: 5) { value in
+ guard case let .blob(buffer) = value else { return nil }
+ return buffer.count
+ }
+ #expect(emptyCount == 0)
+ }
+ }
+
+ @Test func rowAccessors() throws {
+ let connection = try Connection(path: ":memory:")
+ try connection.run("CREATE TABLE t (a INTEGER, b TEXT)")
+ try connection.run("INSERT INTO t (a, b) VALUES (1, 'x')")
+ let statement = try Statement("SELECT a, b FROM t", connection: connection)
+ try connection.execute(statement) { (row: consuming Row) throws(SQLiteError) -> () in
+ #expect(row.id == row.index)
+ #expect(row.isEmpty == false)
+ #expect(row.count == 2)
+ #expect(row.startIndex == 0)
+ #expect(row.endIndex == 2)
+ let columns = row.columns
+ #expect(columns.isEmpty == false)
+ #expect(columns.count == 2)
+ #expect(columns.map(\.name) == ["a", "b"])
+ #expect(columns[0].id == 0)
+ }
+ }
+}