Skip to content
30 changes: 30 additions & 0 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,36 @@ jobs:
- name: Test
run: swift test -c ${{ matrix.config }}

coverage:
name: Code Coverage
runs-on: macos-26
permissions:
contents: read
code-quality: write # required by actions/upload-code-coverage
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Swift Version
run: swift --version
- name: Export coverage and enforce threshold
run: Scripts/coverage.sh
- 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 is in public preview; don't fail CI if the upload/feature
# is unavailable on the repo. Drop this once it's generally available.
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:
name: Linux (${{ matrix.container }})
runs-on: ubuntu-latest
Expand Down
189 changes: 189 additions & 0 deletions Scripts/coverage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
#
# coverage.sh — run the test suite with code coverage, export LCOV and Cobertura
# reports, and enforce a minimum line-coverage threshold for the library sources.
#
# Only the CoreModelSQLite target sources are counted toward the threshold and
# reports; dependencies, generated sources and the test target are excluded so
# the number reflects the coverage of this package's own code.
#
# The Cobertura XML (coverage.xml) is what CI hands to GitHub Code Quality via
# `actions/upload-code-coverage`, which surfaces the coverage percentage on PRs.
#
# Usage:
# Scripts/coverage.sh [threshold]
#
# Environment variables:
# COVERAGE_THRESHOLD Minimum line coverage percentage (default: 80).
# Overridden by the optional [threshold] argument.
# 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`.

set -euo pipefail

# Directory that holds the sources counted toward the coverage threshold.
SOURCE_FILTER="/Sources/CoreModelSQLite/"

THRESHOLD="${1:-${COVERAGE_THRESHOLD:-80}}"
OUTPUT="${COVERAGE_OUTPUT:-.build/coverage/coverage.lcov}"
COBERTURA_OUTPUT="${COBERTURA_OUTPUT:-.build/coverage/coverage.xml}"

# Resolve the correct llvm-cov / llvm-profdata (xcrun on Apple platforms).
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).
BIN_PATH="$(swift build --show-bin-path)"
TEST_BINARY=""
for candidate in \
"$BIN_PATH"/*PackageTests.xctest/Contents/MacOS/*PackageTests \
"$BIN_PATH"/*PackageTests.xctest; do
if [ -f "$candidate" ]; then
TEST_BINARY="$candidate"
break
fi
done

# 4. Export an LCOV report (for artifact upload / external tooling).
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_FILTER" "$PWD" "$COBERTURA_OUTPUT" <<'PY'
import json, os, sys, time
from xml.sax.saxutils import escape, quoteattr

report_path, source_filter, 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 source_filter not in name:
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 = [
'<?xml version="1.0" ?>',
'<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">',
'<coverage line-rate="%.4f" branch-rate="0" lines-covered="%d" lines-valid="%d" '
'branches-covered="0" branches-valid="0" complexity="0" version="llvm-cov" timestamp="%d">'
% (overall, total_covered, total_lines, timestamp),
" <sources>",
" <source>%s</source>" % escape(repo_root),
" </sources>",
" <packages>",
' <package name="CoreModelSQLite" line-rate="%.4f" branch-rate="0" complexity="0">' % overall,
" <classes>",
]
for rel, line_hits, covered, total in sorted(files):
out.append(
' <class name=%s filename=%s line-rate="%.4f" branch-rate="0" complexity="0">'
% (quoteattr(os.path.basename(rel)), quoteattr(rel), rate(covered, total))
)
out.append(" <methods/>")
out.append(" <lines>")
for line in sorted(line_hits):
out.append(' <line number="%d" hits="%d"/>' % (line, line_hits[line]))
out.append(" </lines>")
out.append(" </class>")
out += [" </classes>", " </package>", " </packages>", "</coverage>"]

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 library sources and enforce the threshold.
echo "==> Computing coverage for ${SOURCE_FILTER}"
python3 - "$CODECOV_JSON" "$SOURCE_FILTER" "$THRESHOLD" <<'PY'
import json, sys

report_path, source_filter, threshold = sys.argv[1], sys.argv[2], float(sys.argv[3])

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 source_filter not in name:
continue
lines = file["summary"]["lines"]
covered += lines["covered"]
total += lines["count"]
rows.append((lines["percent"], name.split(source_filter, 1)[1]))

if total == 0:
print("error: no source files matched %r" % source_filter, file=sys.stderr)
sys.exit(1)

percent = 100.0 * covered / total

for pct, name in sorted(rows):
print(" %6.2f%% %s" % (pct, name))

print("-" * 40)
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
1 change: 1 addition & 0 deletions Sources/CoreModelSQLite/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ internal extension Connection {
let entityDescription = try model.entity(fetchRequest.entity)
let query = try fetchRequest.sqlFragment(for: entityDescription, model: model, columns: "COUNT(*)")
guard let count = try scalar(query.sql, query.bindings)?.integer else {
assertionFailure("COUNT(*) always returns a single integer row")
return 0
}
return UInt(count)
Expand Down
95 changes: 95 additions & 0 deletions Tests/CoreModelSQLiteTests/AttributeValueTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//
// AttributeValueTests.swift
// CoreModel-SQLite
//
// Unit tests for `AttributeValue(binding:type:)` decoding, exercising the error
// paths (a stored value whose storage class doesn't match the declared attribute
// type) and the `Binding` accessor helpers directly.
//

import Foundation
import Testing
import CoreModel
import SQLite
@testable import CoreModelSQLite

/// A BLOB binding carrying the given bytes.
private func blobBinding(_ bytes: [UInt8] = [0x00]) -> Binding {
Blob(bytes: bytes).binding
}

// MARK: - Successful decoding

@Test func decodeNullBinding() throws {
// A nil binding decodes to `.null` regardless of the declared type.
#expect(try AttributeValue(binding: nil, type: .int32) == .null)
#expect(try AttributeValue(binding: nil, type: .string) == .null)
}

@Test func decodeDecimalFromNumericStorage() throws {
// A decimal may be stored as TEXT, REAL or INTEGER; all decode back to `.decimal`.
#expect(try AttributeValue(binding: .text("1.5"), type: .decimal) == .decimal(Decimal(string: "1.5")!))
#expect(try AttributeValue(binding: .double(2.5), type: .decimal) == .decimal(Decimal(2.5)))
#expect(try AttributeValue(binding: .integer(3), type: .decimal) == .decimal(Decimal(3)))
}

// MARK: - Decode error paths (storage class mismatch)

@Test func decodeTypeMismatchThrows() throws {
// For each declared type, a binding of an incompatible storage class must throw.
let blob = blobBinding()
let cases: [(Binding, AttributeType)] = [
(blob, .bool),
(blob, .int16),
(blob, .int32),
(blob, .int64),
(blob, .float),
(blob, .double),
(.integer(5), .string), // textValue only accepts TEXT storage
(.integer(5), .data), // blobValue only accepts BLOB storage
(blob, .date),
(.integer(5), .uuid), // not TEXT -> textValue nil
(.integer(5), .url), // not TEXT -> textValue nil
(blob, .decimal)
]
for (binding, type) in cases {
#expect(throws: SQLiteDatabaseError.self) {
try AttributeValue(binding: binding, type: type)
}
}
}

@Test func decodeIntegerOverflowThrows() throws {
// A stored integer outside the fixed-width range can't be represented exactly.
#expect(throws: SQLiteDatabaseError.self) {
try AttributeValue(binding: .integer(99_999), type: .int16)
}
#expect(throws: SQLiteDatabaseError.self) {
try AttributeValue(binding: .integer(Int64(Int32.max) + 1), type: .int32)
}
}

@Test func decodeMalformedUUIDThrows() throws {
#expect(throws: SQLiteDatabaseError.self) {
try AttributeValue(binding: .text("not-a-uuid"), type: .uuid)
}
}

// MARK: - Binding accessor helpers

@Test func bindingTextValueOnlyForText() {
#expect(Binding.text("hi").textValue == "hi")
#expect(Binding.integer(5).textValue == nil)
}

@Test func bindingBlobValueOnlyForBlob() {
#expect(blobBinding([1, 2, 3]).blobValue == [1, 2, 3])
#expect(Binding.integer(5).blobValue == nil)
}

@Test func bindingDecimalValueConversions() {
#expect(Binding.text("4.25").decimalValue == Decimal(string: "4.25"))
#expect(Binding.double(4.5).decimalValue == Decimal(4.5))
#expect(Binding.integer(7).decimalValue == Decimal(7))
#expect(blobBinding().decimalValue == nil)
}
Loading
Loading