diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml
index 4c77769..9093c4c 100644
--- a/.github/workflows/swift.yml
+++ b/.github/workflows/swift.yml
@@ -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
diff --git a/Scripts/coverage.sh b/Scripts/coverage.sh
new file mode 100755
index 0000000..41ce429
--- /dev/null
+++ b/Scripts/coverage.sh
@@ -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 = [
+ '',
+ '',
+ ''
+ % (overall, total_covered, total_lines, timestamp),
+ " ",
+ " %s" % escape(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 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
diff --git a/Sources/CoreModelSQLite/Database.swift b/Sources/CoreModelSQLite/Database.swift
index f6e9909..4236570 100644
--- a/Sources/CoreModelSQLite/Database.swift
+++ b/Sources/CoreModelSQLite/Database.swift
@@ -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)
diff --git a/Tests/CoreModelSQLiteTests/AttributeValueTests.swift b/Tests/CoreModelSQLiteTests/AttributeValueTests.swift
new file mode 100644
index 0000000..617c307
--- /dev/null
+++ b/Tests/CoreModelSQLiteTests/AttributeValueTests.swift
@@ -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)
+}
diff --git a/Tests/CoreModelSQLiteTests/DatabaseBranchTests.swift b/Tests/CoreModelSQLiteTests/DatabaseBranchTests.swift
new file mode 100644
index 0000000..a41b24e
--- /dev/null
+++ b/Tests/CoreModelSQLiteTests/DatabaseBranchTests.swift
@@ -0,0 +1,100 @@
+//
+// DatabaseBranchTests.swift
+// CoreModel-SQLite
+//
+// Covers database-level branches not hit by the main integration tests: the
+// to-many relationship write cases, an invalid sort property, an upsert with no
+// updatable columns, and custom-function arguments of every storage class.
+//
+
+import Foundation
+import Testing
+import CoreModel
+import SQLite
+@testable import CoreModelSQLite
+
+// MARK: - To-many relationship writes
+
+@Test func insertToManyRelationshipAsNullClearsLinks() async throws {
+ let database = try makeDatabase()
+ let people = ["p1", "p2"].map {
+ ModelData(entity: "Person", id: ObjectID(rawValue: $0), attributes: ["name": .string($0)])
+ }
+ try await database.insert(people)
+ try await database.insert(ModelData(
+ entity: "Event", id: "e1",
+ attributes: ["name": .string("E")],
+ relationships: ["people": .toMany(["p1", "p2"])]
+ ))
+ // Re-inserting with `.null` for the to-many relationship clears all links.
+ try await database.insert(ModelData(
+ entity: "Event", id: "e1",
+ attributes: ["name": .string("E")],
+ relationships: ["people": .null]
+ ))
+ let event = try #require(try await database.fetch("Event", for: "e1"))
+ #expect(event.relationships["people"] == .toMany([]))
+}
+
+@Test func insertToOneValueForToManyRelationshipThrows() async throws {
+ let database = try makeDatabase()
+ // "people" is a to-many relationship; a to-one value is invalid.
+ let event = ModelData(
+ entity: "Event", id: "e1",
+ attributes: ["name": .string("E")],
+ relationships: ["people": .toOne("p1")]
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.insert(event)
+ }
+}
+
+// MARK: - Upsert with no updatable columns
+
+@Test func upsertWithNoProvidedColumns() async throws {
+ let database = try makeDatabase()
+ // A ModelData with only a primary key provides no columns to overwrite, so a
+ // conflicting re-insert resolves to `DO NOTHING` rather than `DO UPDATE`.
+ let bare = ModelData(entity: "Person", id: "p1")
+ try await database.insert(bare)
+ try await database.insert(bare) // must not throw
+ #expect(try await database.count(FetchRequest(entity: "Person")) == 1)
+}
+
+// MARK: - Sorting
+
+@Test func sortByUnknownPropertyThrows() async throws {
+ let database = try makeDatabase()
+ try await database.insert(ModelData(entity: "Person", id: "p1", attributes: ["name": .string("A")]))
+ let request = FetchRequest(
+ entity: "Person",
+ sortDescriptors: [.init(property: "doesNotExist", ascending: true)]
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.fetch(request)
+ }
+}
+
+// MARK: - Custom-function argument storage classes
+
+@Test func customFunctionReceivesEveryStorageClass() async throws {
+ let database = try makeDatabase()
+ // A function that always returns NULL — we only care that its argument, of
+ // varying storage class, is decoded on the way in.
+ let probe = DatabaseFunction(name: "probe", argumentCount: 1) { _ in nil }
+ try await database.register(function: probe)
+ // name: TEXT, avatar: BLOB, token: NULL (omitted)
+ try await database.insert(ModelData(
+ entity: "Person", id: "p1",
+ attributes: ["name": .string("Alice"), "avatar": .data(Data([1, 2, 3]))]
+ ))
+
+ for column in ["name", "avatar", "token"] {
+ let request = FetchRequest(
+ entity: "Person",
+ sortDescriptors: [.init(term: .function(.init(name: "probe", arguments: [.keyPath(.init(rawValue: column))])))]
+ )
+ // Executes `probe()` once per row, decoding the argument binding.
+ #expect(try await database.fetchID(request) == ["p1"])
+ }
+}
diff --git a/Tests/CoreModelSQLiteTests/ModelDataTests.swift b/Tests/CoreModelSQLiteTests/ModelDataTests.swift
new file mode 100644
index 0000000..7c5e1d7
--- /dev/null
+++ b/Tests/CoreModelSQLiteTests/ModelDataTests.swift
@@ -0,0 +1,63 @@
+//
+// ModelDataTests.swift
+// CoreModel-SQLite
+//
+// Unit tests for the `ModelData` <-> table row conversions, covering the error
+// paths not reached through the normal insert/fetch round trip.
+//
+
+import Foundation
+import Testing
+import CoreModel
+import SQLite
+@testable import CoreModelSQLite
+
+private var personEntity: EntityDescription {
+ testModel.entities.first { $0.id == "Person" }!
+}
+
+// MARK: - columnValues
+
+@Test func columnValuesRejectsToManyOnToOneRelationship() throws {
+ // "team" is a to-one relationship; a to-many value can't be a foreign key column.
+ let person = ModelData(
+ entity: "Person",
+ id: "person1",
+ attributes: ["name": .string("Alice")],
+ relationships: ["team": .toMany(["a", "b"])]
+ )
+ #expect(throws: SQLiteDatabaseError.self) {
+ _ = try person.columnValues(for: personEntity)
+ }
+}
+
+// MARK: - init(row:)
+
+@Test func decodeRowRoundTrip() throws {
+ let row: [String: Binding?] = [
+ SQLiteDatabase.primaryKeyColumn: .text("person1"),
+ "name": .text("Alice"),
+ "age": .integer(30),
+ "team": .text("team1")
+ ]
+ let value = try ModelData(row: row, entity: personEntity)
+ #expect(value.id == "person1")
+ #expect(value.attributes["name"] == .string("Alice"))
+ #expect(value.attributes["age"] == .int32(30))
+ #expect(value.relationships["team"] == .toOne("team1"))
+}
+
+@Test func decodeRowMissingPrimaryKeyThrows() throws {
+ // No primary key column present at all.
+ #expect(throws: SQLiteDatabaseError.self) {
+ _ = try ModelData(row: ["name": .text("Alice")], entity: personEntity)
+ }
+}
+
+@Test func decodeRowNonTextPrimaryKeyThrows() throws {
+ // Primary key present but stored with a non-text storage class.
+ let row: [String: Binding?] = [SQLiteDatabase.primaryKeyColumn: .integer(5)]
+ #expect(throws: SQLiteDatabaseError.self) {
+ _ = try ModelData(row: row, entity: personEntity)
+ }
+}
diff --git a/Tests/CoreModelSQLiteTests/PredicateBranchTests.swift b/Tests/CoreModelSQLiteTests/PredicateBranchTests.swift
new file mode 100644
index 0000000..36c1bd7
--- /dev/null
+++ b/Tests/CoreModelSQLiteTests/PredicateBranchTests.swift
@@ -0,0 +1,134 @@
+//
+// PredicateBranchTests.swift
+// CoreModel-SQLite
+//
+// Covers the remaining predicate-translation branches: invalid expression
+// shapes, function-call comparison edge cases (NULL, nested arguments,
+// unsupported operators), and the scalar/collection binding error paths.
+//
+
+import Foundation
+import Testing
+import CoreModel
+import SQLite
+@testable import CoreModelSQLite
+
+/// Returns the absolute value of an integer argument, or `nil` for anything else.
+private let magnitude = DatabaseFunction(name: "magnitude", argumentCount: 1) { arguments in
+ guard case let .int64(value) = arguments[0] else { return nil }
+ return .int64(abs(value))
+}
+
+private func makeMagnitudeDatabase() async throws -> SQLiteDatabase {
+ let database = try makeDatabase()
+ try await database.register(function: magnitude)
+ try await database.insert([
+ ModelData(entity: "Person", id: "a", attributes: ["name": .string("A"), "age": .int32(30)]),
+ ModelData(entity: "Person", id: "b", attributes: ["name": .string("B"), "age": .int32(40)])
+ ])
+ return database
+}
+
+private func magnitudeOfAge() -> FetchRequest.Predicate.Expression {
+ .function(.init(name: "magnitude", arguments: [.keyPath("age")]))
+}
+
+// MARK: - Invalid expression shapes
+
+@Test func comparisonWithNonKeyPathLeftThrows() async throws {
+ let database = try await makeMagnitudeDatabase()
+ // Left side is a constant, which is neither a keyPath nor a function.
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .attribute(.int32(5)), right: .attribute(.int32(5)), type: .equalTo))
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.count(request)
+ }
+}
+
+@Test func comparisonWithKeyPathRightThrows() async throws {
+ let database = try await makeMagnitudeDatabase()
+ // A column-to-column comparison has no constant binding on the right.
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("age"), right: .keyPath("weight"), type: .equalTo))
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.count(request)
+ }
+}
+
+@Test func inWithSingleConstantValue() async throws {
+ let database = try await makeMagnitudeDatabase()
+ // `IN` with a single (non-collection) constant falls through to the scalar binding path.
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("age"), right: .attribute(.int32(30)), type: .in))
+ )
+ #expect(try await database.fetchID(request) == ["a"])
+}
+
+// MARK: - Function-call comparisons
+
+@Test func functionComparisonAgainstNull() async throws {
+ let database = try makeDatabase()
+ let probe = DatabaseFunction(name: "probe", argumentCount: 1) { _ in nil }
+ try await database.register(function: probe)
+ try await database.insert(ModelData(entity: "Person", id: "a", attributes: ["name": .string("A")]))
+
+ // probe(probe(name)) == NULL -> nested function argument + IS NULL translation
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(
+ left: .function(.init(name: "probe", arguments: [
+ .function(.init(name: "probe", arguments: [.keyPath("name")]))
+ ])),
+ right: .attribute(.null),
+ type: .equalTo
+ ))
+ )
+ #expect(try await database.count(request) == 1)
+}
+
+@Test func functionComparisonWithModifierThrows() async throws {
+ let database = try await makeMagnitudeDatabase()
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(
+ left: magnitudeOfAge(),
+ right: .attribute(.int32(30)),
+ type: .equalTo,
+ modifier: .any
+ ))
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.count(request)
+ }
+}
+
+@Test func functionComparisonWithUnsupportedOperatorThrows() async throws {
+ let database = try await makeMagnitudeDatabase()
+ // Only ordering/equality operators are valid against a function result.
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: magnitudeOfAge(), right: .attribute(.string("3")), type: .contains))
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.count(request)
+ }
+}
+
+// MARK: - To-many relationship membership
+
+@Test func toManyMembershipWithUnsupportedOperatorThrows() async throws {
+ let database = try makeDatabase()
+ // `<` has no membership-subquery translation for a to-many relationship.
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("events"), right: .relationship(.toOne("e1")), type: .lessThan))
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.count(request)
+ }
+}
diff --git a/Tests/CoreModelSQLiteTests/PredicateTests.swift b/Tests/CoreModelSQLiteTests/PredicateTests.swift
new file mode 100644
index 0000000..a5df2ce
--- /dev/null
+++ b/Tests/CoreModelSQLiteTests/PredicateTests.swift
@@ -0,0 +1,363 @@
+//
+// PredicateTests.swift
+// CoreModel-SQLite
+//
+// Exercises the predicate → SQL `WHERE` clause translation in Predicate.swift
+// through the public fetch API, covering the operators, modifiers, and error
+// paths not touched by the higher-level integration tests.
+//
+
+import Foundation
+import Testing
+import CoreModel
+import SQLite
+@testable import CoreModelSQLite
+
+/// A small fixture of people with predictable, easy-to-filter attributes.
+private func makePeopleDatabase() async throws -> SQLiteDatabase {
+ let database = try makeDatabase()
+ let people = [
+ ModelData(entity: "Person", id: "alice", attributes: [
+ "name": .string("Alice"), "age": .int32(30), "weight": .double(60)
+ ]),
+ ModelData(entity: "Person", id: "bob", attributes: [
+ "name": .string("Bob"), "age": .int32(40), "weight": .double(80)
+ ]),
+ ModelData(entity: "Person", id: "carol", attributes: [
+ "name": .string("Carol"), "age": .int32(50) // weight omitted -> NULL
+ ])
+ ]
+ try await database.insert(people)
+ return database
+}
+
+// MARK: - Constant & compound predicates
+
+@Test func constantPredicateTrueAndFalse() async throws {
+ let database = try await makePeopleDatabase()
+ let all = try await database.count(FetchRequest(entity: "Person", predicate: .value(true)))
+ #expect(all == 3)
+ let none = try await database.count(FetchRequest(entity: "Person", predicate: .value(false)))
+ #expect(none == 0)
+}
+
+@Test func notCompound() async throws {
+ let database = try await makePeopleDatabase()
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .compound(.not(
+ .comparison(.init(left: .keyPath("name"), right: .attribute(.string("Alice")), type: .equalTo))
+ ))
+ )
+ let ids = try await database.fetchID(request).sorted { $0.rawValue < $1.rawValue }
+ #expect(ids == ["bob", "carol"])
+}
+
+@Test func emptyCompoundMatchesEverything() async throws {
+ let database = try await makePeopleDatabase()
+ let and = try await database.count(FetchRequest(entity: "Person", predicate: .compound(.and([]))))
+ #expect(and == 3)
+ let or = try await database.count(FetchRequest(entity: "Person", predicate: .compound(.or([]))))
+ #expect(or == 3)
+}
+
+// MARK: - NULL handling
+
+@Test func nullEquality() async throws {
+ let database = try await makePeopleDatabase()
+ // weight IS NULL
+ let isNull = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("weight"), right: .attribute(.null), type: .equalTo))
+ )
+ #expect(try await database.fetchID(isNull) == ["carol"])
+ // weight IS NOT NULL
+ let notNull = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("weight"), right: .attribute(.null), type: .notEqualTo))
+ )
+ let ids = try await database.fetchID(notNull).sorted { $0.rawValue < $1.rawValue }
+ #expect(ids == ["alice", "bob"])
+}
+
+@Test func toOneRelationshipNullComparison() async throws {
+ let database = try await makePeopleDatabase()
+ // team IS NULL for everyone (no team assigned)
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("team"), right: .relationship(.null), type: .equalTo))
+ )
+ #expect(try await database.count(request) == 3)
+}
+
+// MARK: - String operators
+
+@Test func caseInsensitiveEquality() async throws {
+ let database = try await makePeopleDatabase()
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(
+ left: .keyPath("name"),
+ right: .attribute(.string("alice")),
+ type: .equalTo,
+ options: [.caseInsensitive]
+ ))
+ )
+ #expect(try await database.fetchID(request) == ["alice"])
+}
+
+@Test func endsWithAndContains() async throws {
+ let database = try await makePeopleDatabase()
+ let endsWith = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("name"), right: .attribute(.string("ob")), type: .endsWith))
+ )
+ #expect(try await database.fetchID(endsWith) == ["bob"])
+
+ let contains = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("name"), right: .attribute(.string("aro")), type: .contains))
+ )
+ #expect(try await database.fetchID(contains) == ["carol"])
+}
+
+@Test func likeWithWildcards() async throws {
+ let database = try await makePeopleDatabase()
+ // Cocoa-style wildcards: `*` -> any run, `?` -> single char
+ let star = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("name"), right: .attribute(.string("A*")), type: .like))
+ )
+ #expect(try await database.fetchID(star) == ["alice"])
+
+ let question = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("name"), right: .attribute(.string("Bo?")), type: .like))
+ )
+ #expect(try await database.fetchID(question) == ["bob"])
+}
+
+// MARK: - IN / BETWEEN
+
+@Test func inWithEmptySetMatchesNothing() async throws {
+ let database = try await makePeopleDatabase()
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("id"), right: .relationship(.toMany([])), type: .in))
+ )
+ #expect(try await database.count(request) == 0)
+}
+
+@Test func betweenBounds() async throws {
+ let database = try await makePeopleDatabase()
+ // id BETWEEN "alice" AND "bob" (lexical) -> alice, bob
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(
+ left: .keyPath("id"),
+ right: .relationship(.toMany(["alice", "bob"])),
+ type: .between
+ ))
+ )
+ let ids = try await database.fetchID(request).sorted { $0.rawValue < $1.rawValue }
+ #expect(ids == ["alice", "bob"])
+}
+
+@Test func betweenWithWrongCountThrows() async throws {
+ let database = try await makePeopleDatabase()
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(
+ left: .keyPath("id"),
+ right: .relationship(.toMany(["alice"])), // needs exactly two
+ type: .between
+ ))
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.count(request)
+ }
+}
+
+// MARK: - To-many relationship membership
+
+@Test func toManyMembershipViaJoinTable() async throws {
+ // Person <-toMany-> Event (many-to-many, join table)
+ let database = try makeDatabase()
+ let people = ["p1", "p2", "p3"].map {
+ ModelData(entity: "Person", id: ObjectID(rawValue: $0), attributes: ["name": .string($0)])
+ }
+ try await database.insert(people)
+ let event = ModelData(
+ entity: "Event",
+ id: "event1",
+ attributes: ["name": .string("WWDC")],
+ relationships: ["people": .toMany(["p1", "p2"])]
+ )
+ try await database.insert(event)
+
+ // ANY events == event1 (contains / equalTo on a to-many relationship)
+ let contains = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("events"), right: .relationship(.toOne("event1")), type: .contains))
+ )
+ let ids = try await database.fetchID(contains).sorted { $0.rawValue < $1.rawValue }
+ #expect(ids == ["p1", "p2"])
+
+ // events IN {event1}
+ let inSet = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("events"), right: .relationship(.toMany(["event1"])), type: .in))
+ )
+ #expect(try await database.count(inSet) == 2)
+
+ // empty membership set matches nothing
+ let empty = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("events"), right: .relationship(.toMany([])), type: .in))
+ )
+ #expect(try await database.count(empty) == 0)
+}
+
+@Test func toManyMembershipViaForeignKey() async throws {
+ // Team <-toMany- members, inverse Person.team is toOne (one-to-many branch)
+ let database = try makeDatabase()
+ let people = ["p1", "p2"].map {
+ ModelData(entity: "Person", id: ObjectID(rawValue: $0), attributes: ["name": .string($0)])
+ }
+ try await database.insert(people)
+ let team = ModelData(
+ entity: "Team",
+ id: "team1",
+ attributes: ["name": .string("Red")],
+ relationships: ["members": .toMany(["p1"])]
+ )
+ try await database.insert(team)
+
+ // Teams whose members contain p1
+ let request = FetchRequest(
+ entity: "Team",
+ predicate: .comparison(.init(left: .keyPath("members"), right: .relationship(.toOne("p1")), type: .contains))
+ )
+ #expect(try await database.fetchID(request) == ["team1"])
+}
+
+@Test func toManyMembershipInvalidModifierThrows() async throws {
+ let database = try makeDatabase()
+ // `.all` has no direct SQL translation for a to-many relationship
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(
+ left: .keyPath("events"),
+ right: .relationship(.toOne("event1")),
+ type: .contains,
+ modifier: .all
+ ))
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.count(request)
+ }
+}
+
+// MARK: - Function-call comparisons
+
+private let magnitudeFunction = DatabaseFunction(name: "magnitude", argumentCount: 1) { arguments in
+ guard case let .int64(value) = arguments[0] else { return nil }
+ return .int64(abs(value))
+}
+
+@Test func functionComparisonEqualTo() async throws {
+ let database = try makeDatabase()
+ try await database.register(function: magnitudeFunction)
+ let people = [
+ ModelData(entity: "Person", id: "a", attributes: ["name": .string("A"), "age": .int32(30)]),
+ ModelData(entity: "Person", id: "b", attributes: ["name": .string("B"), "age": .int32(40)])
+ ]
+ try await database.insert(people)
+
+ let magnitudeOfAge = FetchRequest.Predicate.Expression.function(
+ .init(name: "magnitude", arguments: [.keyPath("age")])
+ )
+ // magnitude(age) == 30
+ let equalTo = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: magnitudeOfAge, right: .attribute(.int32(30)), type: .equalTo))
+ )
+ #expect(try await database.fetchID(equalTo) == ["a"])
+
+ // magnitude(age) != 30
+ let notEqualTo = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: magnitudeOfAge, right: .attribute(.int32(30)), type: .notEqualTo))
+ )
+ #expect(try await database.fetchID(notEqualTo) == ["b"])
+}
+
+// MARK: - Error paths
+
+@Test func modifierOnColumnComparisonThrows() async throws {
+ let database = try await makePeopleDatabase()
+ // A modifier is only valid on a to-many relationship, not a scalar column.
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(
+ left: .keyPath("age"),
+ right: .attribute(.int32(30)),
+ type: .equalTo,
+ modifier: .any
+ ))
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.count(request)
+ }
+}
+
+@Test func matchesOperatorThrows() async throws {
+ let database = try await makePeopleDatabase()
+ // Regular-expression matching has no plain-SQL translation.
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("name"), right: .attribute(.string("A.*")), type: .matches))
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.count(request)
+ }
+}
+
+@Test func likePatternRequiresStringThrows() async throws {
+ let database = try await makePeopleDatabase()
+ // beginsWith needs a string operand; an int cannot form a LIKE pattern.
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("name"), right: .attribute(.int32(5)), type: .beginsWith))
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.count(request)
+ }
+}
+
+@Test func unknownColumnThrows() async throws {
+ let database = try await makePeopleDatabase()
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(left: .keyPath("doesNotExist"), right: .attribute(.int32(1)), type: .equalTo))
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.count(request)
+ }
+}
+
+@Test func toManyValueInScalarComparisonThrows() async throws {
+ let database = try await makePeopleDatabase()
+ // A to-many relationship value cannot be a single scalar binding.
+ let request = FetchRequest(
+ entity: "Person",
+ predicate: .comparison(.init(
+ left: .keyPath("team"),
+ right: .relationship(.toMany(["x", "y"])),
+ type: .equalTo
+ ))
+ )
+ await #expect(throws: SQLiteDatabaseError.self) {
+ try await database.count(request)
+ }
+}
diff --git a/Tests/CoreModelSQLiteTests/RelationshipBranchTests.swift b/Tests/CoreModelSQLiteTests/RelationshipBranchTests.swift
new file mode 100644
index 0000000..0f8b726
--- /dev/null
+++ b/Tests/CoreModelSQLiteTests/RelationshipBranchTests.swift
@@ -0,0 +1,105 @@
+//
+// RelationshipBranchTests.swift
+// CoreModel-SQLite
+//
+// Covers the symmetric (self-inverse) many-to-many relationship paths — the
+// `UNION` halves of the join-table fetch, delete, and membership subquery — plus
+// a missing inverse relationship and a fetch offset with no limit.
+//
+
+import Foundation
+import Testing
+import CoreModel
+import SQLite
+@testable import CoreModelSQLite
+
+/// A single entity with a symmetric self-relationship (`friends` is its own inverse),
+/// which resolves to a symmetric join table.
+private var symmetricModel: Model {
+ Model(entities: [
+ EntityDescription(
+ id: "Node",
+ attributes: [.init(id: "name", type: .string)],
+ relationships: [
+ .init(id: "friends", type: .toMany, destinationEntity: "Node", inverseRelationship: "friends")
+ ]
+ )
+ ])
+}
+
+@Test func symmetricManyToManyRelationship() async throws {
+ let database = try SQLiteDatabase(path: temporaryDatabasePath(named: "Symmetric"), model: symmetricModel)
+ let nodes = ["a", "b", "c"].map {
+ ModelData(entity: "Node", id: ObjectID(rawValue: $0), attributes: ["name": .string($0)])
+ }
+ try await database.insert(nodes)
+
+ // a befriends b and c; symmetry means b and c befriend a in return.
+ var a = nodes[0]
+ a.relationships["friends"] = .toMany(["b", "c"])
+ try await database.insert(a)
+
+ // Reading back exercises the symmetric UNION in JoinTable.fetch.
+ let fetchedA = try #require(try await database.fetch("Node", for: "a"))
+ #expect(fetchedA.relationships["friends"] == .toMany(["b", "c"]))
+ let fetchedB = try #require(try await database.fetch("Node", for: "b"))
+ #expect(fetchedB.relationships["friends"] == .toMany(["a"]))
+
+ // Membership predicate exercises the symmetric UNION in the query subquery:
+ // which nodes count `a` among their friends? -> b and c.
+ let request = FetchRequest(
+ entity: "Node",
+ predicate: .comparison(.init(left: .keyPath("friends"), right: .relationship(.toOne("a")), type: .contains))
+ )
+ let ids = try await database.fetchID(request).sorted { $0.rawValue < $1.rawValue }
+ #expect(ids == ["b", "c"])
+
+ // Re-writing the set exercises the symmetric branch of removeAll.
+ a.relationships["friends"] = .toMany(["b"])
+ try await database.insert(a)
+ let updatedC = try #require(try await database.fetch("Node", for: "c"))
+ #expect(updatedC.relationships["friends"] == .toMany([]))
+}
+
+/// A to-many relationship whose declared inverse doesn't exist on the destination.
+private var missingInverseModel: Model {
+ Model(entities: [
+ EntityDescription(
+ id: "A",
+ attributes: [.init(id: "name", type: .string)],
+ relationships: [
+ .init(id: "items", type: .toMany, destinationEntity: "B", inverseRelationship: "missing")
+ ]
+ ),
+ EntityDescription(id: "B", attributes: [.init(id: "name", type: .string)], relationships: [])
+ ])
+}
+
+@Test func missingInverseRelationshipThrows() async throws {
+ let path = temporaryDatabasePath(named: "MissingInverse")
+ // Building the schema resolves the to-many relationship's inverse type, which fails
+ // because the destination entity has no relationship named "missing".
+ await #expect(throws: CoreModelError.self) {
+ let database = try SQLiteDatabase(path: path, model: missingInverseModel)
+ try await database.insert(ModelData(entity: "B", id: "b1", attributes: ["name": .string("B")]))
+ }
+}
+
+@Test func fetchOffsetWithoutLimit() async throws {
+ let database = try makeDatabase()
+ let people = (0..<5).map { index in
+ ModelData(
+ entity: "Person",
+ id: ObjectID(rawValue: "p\(index)"),
+ attributes: ["name": .string("P\(index)"), "age": .int32(Int32(index))]
+ )
+ }
+ try await database.insert(people)
+ // Offset with no limit -> `LIMIT -1 OFFSET n`.
+ let request = FetchRequest(
+ entity: "Person",
+ sortDescriptors: [.init(property: "age", ascending: true)],
+ fetchOffset: 2
+ )
+ #expect(try await database.fetchID(request) == ["p2", "p3", "p4"])
+}