From 56beb01f075a046840e6a6e54ffdf8c391214850 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:37:13 -0400 Subject: [PATCH 01/23] Add code coverage script --- Scripts/coverage.sh | 198 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100755 Scripts/coverage.sh 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 From 230846c76a3d4e013b631cc5a5675c65a0dcb6e8 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:37:13 -0400 Subject: [PATCH 02/23] Fix stale row count expectation in getCount() test --- Tests/SQLiteTests/SQLiteTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SQLiteTests/SQLiteTests.swift b/Tests/SQLiteTests/SQLiteTests.swift index 9f5853a..4ec0b84 100644 --- a/Tests/SQLiteTests/SQLiteTests.swift +++ b/Tests/SQLiteTests/SQLiteTests.swift @@ -67,7 +67,7 @@ import Testing } } } - #expect(count == 5932) + #expect(count == 6099) } @Test func iterateTextRows() throws { From 32621b0d1dd1907f6f9ad29233fb9a1ae32fb108 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:37:13 -0400 Subject: [PATCH 03/23] Add code coverage job to CI --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd73250..bd16392 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,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: 65 + - 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 From 6e682487ce8f8044573b957883b29f2e07cacde3 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:46:39 -0400 Subject: [PATCH 04/23] Add tests for UUID, Date, and Data bindings --- Tests/SQLiteTests/BindingFormatTests.swift | 133 +++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 Tests/SQLiteTests/BindingFormatTests.swift 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) + } +} From 278f06865c66955dd5491f69f1c0c628cba8520d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:46:40 -0400 Subject: [PATCH 05/23] Add tests for Connection.Location, URIQueryParameter, and TypeAffinity --- .../LocationAndAffinityTests.swift | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 Tests/SQLiteTests/LocationAndAffinityTests.swift diff --git a/Tests/SQLiteTests/LocationAndAffinityTests.swift b/Tests/SQLiteTests/LocationAndAffinityTests.swift new file mode 100644 index 0000000..dd8cde1 --- /dev/null +++ b/Tests/SQLiteTests/LocationAndAffinityTests.swift @@ -0,0 +1,130 @@ +// +// 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 + #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) + } + } +} From c95a435a8b8c923b6bae5252eebe2569d09d1b3e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:46:40 -0400 Subject: [PATCH 06/23] Raise coverage threshold to 80% --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd16392..52bd175 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Export coverage and enforce threshold run: Scripts/coverage.sh env: - COVERAGE_THRESHOLD: 65 + COVERAGE_THRESHOLD: 80 - name: Upload coverage to GitHub Code Quality uses: actions/upload-code-coverage@v1 with: From 1c5849e37a2dc3434612db88503f8c345dc5ed40 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:48:30 -0400 Subject: [PATCH 07/23] Add tests for Binding conversions, Column values, and Row access --- Tests/SQLiteTests/ValueAccessTests.swift | 164 +++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 Tests/SQLiteTests/ValueAccessTests.swift 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) + } + } +} From 45ee619ce8ead3456a343eb593f14a20e14e11fa Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:48:30 -0400 Subject: [PATCH 08/23] Raise coverage threshold to 85% --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52bd175..abf9d06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Export coverage and enforce threshold run: Scripts/coverage.sh env: - COVERAGE_THRESHOLD: 80 + COVERAGE_THRESHOLD: 85 - name: Upload coverage to GitHub Code Quality uses: actions/upload-code-coverage@v1 with: From ff82b0e429f81b157830e8b2c67397f121a5a1e5 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:50:26 -0400 Subject: [PATCH 09/23] Add tests for Connection properties and Statement binding/errors --- .../ConnectionStatementTests.swift | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 Tests/SQLiteTests/ConnectionStatementTests.swift 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)") + } + } +} From 4a1e8ce945b52643539f2a651a0cc9854992032c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:50:26 -0400 Subject: [PATCH 10/23] Raise coverage threshold to 90% --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abf9d06..b22ed9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Export coverage and enforce threshold run: Scripts/coverage.sh env: - COVERAGE_THRESHOLD: 85 + COVERAGE_THRESHOLD: 90 - name: Upload coverage to GitHub Code Quality uses: actions/upload-code-coverage@v1 with: From aff4d510870f675eb44456ecc3888ab4fdcd03df Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:53:34 -0400 Subject: [PATCH 11/23] Add tests for function, collation, window, and error edge cases --- Tests/SQLiteTests/FunctionEdgeCaseTests.swift | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 Tests/SQLiteTests/FunctionEdgeCaseTests.swift diff --git a/Tests/SQLiteTests/FunctionEdgeCaseTests.swift b/Tests/SQLiteTests/FunctionEdgeCaseTests.swift new file mode 100644 index 0000000..761b76b --- /dev/null +++ b/Tests/SQLiteTests/FunctionEdgeCaseTests.swift @@ -0,0 +1,236 @@ +// +// 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: - 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 + } + } +} From 7b0f7028d44e8cd2026e660ec6be65375a4b4512 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:53:35 -0400 Subject: [PATCH 12/23] Raise coverage threshold to 95% --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b22ed9e..f38049d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Export coverage and enforce threshold run: Scripts/coverage.sh env: - COVERAGE_THRESHOLD: 90 + COVERAGE_THRESHOLD: 95 - name: Upload coverage to GitHub Code Quality uses: actions/upload-code-coverage@v1 with: From da3e562e9d0d33b914b660262bb186accd157c00 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:58:36 -0400 Subject: [PATCH 13/23] Fix case-sensitive BLOB rule in affinity determination --- Sources/SQLite/TypeAffinity.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 105a2187c944f80cc545d95f31ba3eb47bb6a565 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:58:36 -0400 Subject: [PATCH 14/23] Add regression test for lowercase blob affinity --- Tests/SQLiteTests/LocationAndAffinityTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/SQLiteTests/LocationAndAffinityTests.swift b/Tests/SQLiteTests/LocationAndAffinityTests.swift index dd8cde1..2c5a1ef 100644 --- a/Tests/SQLiteTests/LocationAndAffinityTests.swift +++ b/Tests/SQLiteTests/LocationAndAffinityTests.swift @@ -102,8 +102,9 @@ import Testing #expect(TypeAffinity("VARCHAR(255)") == .text) #expect(TypeAffinity("CLOB") == .text) #expect(TypeAffinity("text") == .text) - // Rule 3: BLOB + // 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) From 8a32c9cb1b3e29616e7ff909da8305b8ee6a4648 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:58:36 -0400 Subject: [PATCH 15/23] Surface SQLite diagnostic messages from failed statement preparation --- Sources/SQLite/Statement.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 { From 9ab94746d2697782ac9d78115cba7f652227e5f9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:58:36 -0400 Subject: [PATCH 16/23] Update statement error test for SQLite diagnostic messages --- Tests/SQLiteTests/SQLiteTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SQLiteTests/SQLiteTests.swift b/Tests/SQLiteTests/SQLiteTests.swift index 4ec0b84..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 From 8f9a97e717e67e1f707c5aa896442ffb1f04e13f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 20:58:36 -0400 Subject: [PATCH 17/23] Port representative cases from the official SQLite test suite --- .../SQLiteTests/OfficialSuitePortTests.swift | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 Tests/SQLiteTests/OfficialSuitePortTests.swift 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") == []) + } +} From 56d675471871d5483b4696fad92bc2e25d8e9076 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:13:40 -0400 Subject: [PATCH 18/23] Fix double release when scalar function registration fails --- Sources/SQLite/Function.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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(()) From 39aba3b1df594cd30e82e980ef5b30a62a6c9a2f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:13:43 -0400 Subject: [PATCH 19/23] Fix double release when aggregate function registration fails --- Sources/SQLite/AggregateFunction.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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(()) From 1cf59297e5fe1993cf3bfd541a12be811ddc03d1 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:13:44 -0400 Subject: [PATCH 20/23] Fix double release when window function registration fails --- Sources/SQLite/WindowFunction.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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(()) From 214146e2645b6effb87bf87f93365dbf40e4d5d0 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:13:46 -0400 Subject: [PATCH 21/23] Add tests for registration failures and stale blob read errors --- Tests/SQLiteTests/FunctionEdgeCaseTests.swift | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/Tests/SQLiteTests/FunctionEdgeCaseTests.swift b/Tests/SQLiteTests/FunctionEdgeCaseTests.swift index 761b76b..3c8d301 100644 --- a/Tests/SQLiteTests/FunctionEdgeCaseTests.swift +++ b/Tests/SQLiteTests/FunctionEdgeCaseTests.swift @@ -190,6 +190,56 @@ import Testing #expect(Binding.text("x").bytes == nil) } + // MARK: - Registration failures + + // SQLite rejects functions with more than 127 arguments (SQLITE_MISUSE), + // exercising the cleanup path that releases the retained callback box. + + @Test func createFunctionWithTooManyArguments() throws { + let connection = try Connection(path: ":memory:") + #expect(throws: SQLiteError.self) { + try connection.createFunction("f", argumentCount: 200) { _ in .null } + } + } + + @Test func createAggregateFunctionWithTooManyArguments() throws { + let connection = try Connection(path: ":memory:") + #expect(throws: SQLiteError.self) { + try connection.createAggregateFunction( + "f", + argumentCount: 200, + 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: 200, + 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 { @@ -233,4 +283,20 @@ import Testing 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 + } + } } From c0414bf723c6a4e1eedd8291bbd33c4bd6285fe6 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:15:26 -0400 Subject: [PATCH 22/23] Run CI only on push to avoid duplicate runs --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f38049d..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: From c4460fa6fa96ca84deead6ef904fe98c5c1fccbe Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:24:08 -0400 Subject: [PATCH 23/23] Use argument count above SQLITE_MAX_FUNCTION_ARG ceiling for all builds --- Tests/SQLiteTests/FunctionEdgeCaseTests.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Tests/SQLiteTests/FunctionEdgeCaseTests.swift b/Tests/SQLiteTests/FunctionEdgeCaseTests.swift index 3c8d301..f2aaadc 100644 --- a/Tests/SQLiteTests/FunctionEdgeCaseTests.swift +++ b/Tests/SQLiteTests/FunctionEdgeCaseTests.swift @@ -192,13 +192,15 @@ import Testing // MARK: - Registration failures - // SQLite rejects functions with more than 127 arguments (SQLITE_MISUSE), - // exercising the cleanup path that releases the retained callback box. + // 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: 200) { _ in .null } + try connection.createFunction("f", argumentCount: 32768) { _ in .null } } } @@ -207,7 +209,7 @@ import Testing #expect(throws: SQLiteError.self) { try connection.createAggregateFunction( "f", - argumentCount: 200, + argumentCount: 32768, initialState: { Int64(0) }, step: { _, _ in }, final: { _ in .null } @@ -222,7 +224,7 @@ import Testing #expect(throws: SQLiteError.self) { try connection.createWindowFunction( "f", - argumentCount: 200, + argumentCount: 32768, initialState: { Int64(0) }, step: { _, _ in }, inverse: { _, _ in },