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"]) +}