diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 9824b2d..e4f08cc 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -19,6 +19,43 @@ jobs: - name: Test run: ${{ matrix.options }} 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 90 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: .build/coverage/coverage.lcov + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + - 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 + # any target repo might not have the feature enabled, so a failed upload + # doesn't break CI. Remove once every consumer is on GA. + 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/Package.swift b/Package.swift index 3ed1588..7f27ddf 100644 --- a/Package.swift +++ b/Package.swift @@ -152,4 +152,28 @@ if enableMacros { package.targets[0].dependencies += [ "CoreModelMacros" ] + package.targets += [ + .testTarget( + name: "CoreModelMacrosTests", + dependencies: [ + "CoreModelMacros", + .product( + name: "SwiftSyntaxMacros", + package: "swift-syntax" + ), + .product( + name: "SwiftSyntaxMacroExpansion", + package: "swift-syntax" + ), + .product( + name: "SwiftParser", + package: "swift-syntax" + ), + .product( + name: "SwiftSyntaxMacrosTestSupport", + package: "swift-syntax" + ) + ] + ) + ] } diff --git a/Scripts/coverage.sh b/Scripts/coverage.sh new file mode 100755 index 0000000..80bc30a --- /dev/null +++ b/Scripts/coverage.sh @@ -0,0 +1,221 @@ +#!/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_BINARIES=() +for candidate in \ + "$BIN_PATH"/*Tests.xctest/Contents/MacOS/*Tests \ + "$BIN_PATH"/*Tests.xctest; do + if [ -f "$candidate" ]; then + TEST_BINARIES+=("$candidate") + fi +done + +# llvm-cov takes the first binary as a positional argument and the rest via +# -object. On macOS/Linux SwiftPM normally merges every test target into one +# `PackageTests` bundle, so there's usually a single binary and +# OBJECT_ARGS stays empty; the extra binaries only appear in multi-product +# setups. The `${arr[@]+…}` guards below keep empty-array expansions from +# tripping `set -u` on the macOS system bash (3.2), where "${empty[@]}" is +# otherwise treated as an unbound variable. +OBJECT_ARGS=() +if [ "${#TEST_BINARIES[@]}" -gt 1 ]; then + for bin in "${TEST_BINARIES[@]:1}"; do + OBJECT_ARGS+=(-object "$bin") + done +fi + +# 4. Export an LCOV report (for Codecov / Coveralls / editors). +mkdir -p "$(dirname "$OUTPUT")" +if [ "${#TEST_BINARIES[@]}" -gt 0 ] && [ -f "$PROFDATA" ]; then + echo "==> Exporting LCOV report to $OUTPUT" + $LLVM_COV export \ + -format=lcov \ + -instr-profile "$PROFDATA" \ + "${TEST_BINARIES[0]}" ${OBJECT_ARGS[@]+"${OBJECT_ARGS[@]}"} \ + -ignore-filename-regex='.build/(checkouts|.*\.build)/|Tests/|\.derived/|DerivedSources/' \ + > "$OUTPUT" + # SwiftPM's own codecov JSON only reflects a single test binary when the + # package has multiple test products, so export a merged JSON ourselves and + # use it for the Cobertura report and the threshold below. + echo "==> Exporting merged llvm-cov JSON" + $LLVM_COV export \ + -format=text \ + -instr-profile "$PROFDATA" \ + "${TEST_BINARIES[0]}" ${OBJECT_ARGS[@]+"${OBJECT_ARGS[@]}"} \ + -ignore-filename-regex='.build/(checkouts|.*\.build)/|Tests/|\.derived/|DerivedSources/' \ + > "$(dirname "$OUTPUT")/coverage.json" + CODECOV_JSON="$(dirname "$OUTPUT")/coverage.json" +else + echo "warning: could not locate test binaries or profdata; skipping LCOV export" >&2 +fi + +# 5. Generate a Cobertura XML report (for GitHub Code Quality / upload-code-coverage). +echo "==> Writing Cobertura report to $COBERTURA_OUTPUT" +mkdir -p "$(dirname "$COBERTURA_OUTPUT")" +python3 - "$CODECOV_JSON" "$SOURCE_PREFIX" "$PWD" "$COBERTURA_OUTPUT" <<'PY' +import json, os, sys, time +from xml.sax.saxutils import escape, quoteattr + +report_path, source_prefix, repo_root, output = sys.argv[1:5] + +with open(report_path) as f: + report = json.load(f) + +files = [] +total_covered = total_lines = 0 +for file in report["data"][0]["files"]: + name = file["filename"] + if not name.startswith(source_prefix): + continue + # Per-line hit count: the greatest region count starting on each line (a line + # is covered if any region on it executed, so branch sub-regions don't hide it). + line_hits = {} + for seg in file["segments"]: + line, count, has_count = seg[0], seg[2], seg[3] + if has_count: + line_hits[line] = max(line_hits.get(line, 0), count) + covered = sum(1 for h in line_hits.values() if h > 0) + total_covered += covered + total_lines += len(line_hits) + files.append((os.path.relpath(name, repo_root), line_hits, covered, len(line_hits))) + +def rate(c, t): + return c / t if t else 0.0 + +overall = rate(total_covered, total_lines) +timestamp = int(time.time()) + +out = [ + '', + '', + '' + % (overall, total_covered, total_lines, timestamp), + " ", + " %s" % escape(repo_root), + " ", + " ", + ' ' + % (escape(os.path.basename(repo_root)), overall), + " ", +] +for rel, line_hits, covered, total in sorted(files): + out.append( + ' ' + % (quoteattr(os.path.basename(rel)), quoteattr(rel), rate(covered, total)) + ) + out.append(" ") + out.append(" ") + for line in sorted(line_hits): + out.append(' ' % (line, line_hits[line])) + out.append(" ") + out.append(" ") +out += [" ", " ", " ", ""] + +with open(output, "w") as f: + f.write("\n".join(out) + "\n") + +print(" %d/%d lines (%.2f%%)" % (total_covered, total_lines, 100 * overall)) +PY + +# 6. Compute line coverage for the package sources and enforce the threshold. +echo "==> Computing coverage for ${SOURCE_PREFIX}" +python3 - "$CODECOV_JSON" "$SOURCE_PREFIX" "$THRESHOLD" "$PWD" <<'PY' +import json, os, sys + +report_path, source_prefix, threshold, repo_root = sys.argv[1], sys.argv[2], float(sys.argv[3]), sys.argv[4] + +with open(report_path) as f: + report = json.load(f) + +covered = total = 0 +rows = [] +for file in report["data"][0]["files"]: + name = file["filename"] + if not name.startswith(source_prefix): + continue + lines = file["summary"]["lines"] + covered += lines["covered"] + total += lines["count"] + rows.append((lines["percent"], os.path.relpath(name, repo_root))) + +if total == 0: + print("error: no source files matched prefix %r" % source_prefix, file=sys.stderr) + print(" (set COVERAGE_SOURCE_PREFIX to your package's Sources path)", file=sys.stderr) + sys.exit(1) + +percent = 100.0 * covered / total + +for pct, name in sorted(rows): + print(" %6.2f%% %s" % (pct, name)) + +print("-" * 48) +print("Total line coverage: %.2f%% (%d/%d lines)" % (percent, covered, total)) +print("Required threshold: %.2f%%" % threshold) + +if percent < threshold: + print("FAILED: coverage %.2f%% is below the %.2f%% threshold" % (percent, threshold), file=sys.stderr) + sys.exit(1) + +print("PASSED: coverage meets the threshold") +PY diff --git a/Sources/CoreDataModel/NSFetchRequest.swift b/Sources/CoreDataModel/NSFetchRequest.swift index d80b3c7..45cdf94 100644 --- a/Sources/CoreDataModel/NSFetchRequest.swift +++ b/Sources/CoreDataModel/NSFetchRequest.swift @@ -22,6 +22,7 @@ public extension FetchRequest { let fetchRequest = NSFetchRequest(entityName: entity.rawValue) fetchRequest.predicate = predicate?.toFoundation() fetchRequest.fetchLimit = fetchLimit + fetchRequest.fetchOffset = fetchOffset var sortDescriptors = sortDescriptors.compactMap { sort -> NSSortDescriptor? in guard let property = sort.property else { // Function-based sort terms are not supported by NSFetchRequest; diff --git a/Sources/CoreModelMacros/Entity.swift b/Sources/CoreModelMacros/Entity.swift index 4ecc96a..3d7b21a 100644 --- a/Sources/CoreModelMacros/Entity.swift +++ b/Sources/CoreModelMacros/Entity.swift @@ -236,7 +236,7 @@ extension EntityMacro { inverseKeyName = memberAccess.declName.baseName.text } else if let keyPathExpr = inverseArg.expression.as(KeyPathExprSyntax.self), let lastComponent = keyPathExpr.components.last { - inverseKeyName = lastComponent.description + inverseKeyName = lastComponent.component.trimmedDescription } else { throw MacroError.unknownInverseRelationship(for: identifier) } diff --git a/Tests/CoreModelMacrosTests/EntityMacroTests.swift b/Tests/CoreModelMacrosTests/EntityMacroTests.swift new file mode 100644 index 0000000..29ca4f9 --- /dev/null +++ b/Tests/CoreModelMacrosTests/EntityMacroTests.swift @@ -0,0 +1,347 @@ +// +// EntityMacroTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 7/17/25. +// + +// Macro expansion tests depend on the swift-syntax host tooling and only run +// where the test suite is executed (macOS and Linux); other CI platforms +// cross-compile the package and never run `swift test`. +#if os(macOS) || os(Linux) + +import Foundation +import XCTest +import SwiftSyntax +import SwiftParser +import SwiftSyntaxMacros +import SwiftSyntaxMacroExpansion +@testable import CoreModelMacros + +final class EntityMacroTests: XCTestCase { + + func testStructExpansion() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + @Attribute + var name: String + @Attribute + var age: Int + @Attribute + var created: Date + } + """) + let context = BasicMacroExpansionContext() + let members = try expandMembers(of: node, attachedTo: declaration, in: context) + XCTAssertEqual(members.count, 5) + let source = members.map { $0.description }.joined(separator: "\n") + XCTAssert(source.contains(#"public static var entityName: EntityName { "Person" }"#)) + XCTAssert(source.contains(".name: .string")) + XCTAssert(source.contains(".age: .int64")) + XCTAssert(source.contains(".created: .date")) + XCTAssert(source.contains("public static var relationships: [CodingKeys: Relationship] { [:] }")) + XCTAssert(source.contains("public init(from container: ModelData) throws")) + XCTAssert(source.contains("self.name = try container.decode(String.self, forKey: Person.CodingKeys.name)")) + XCTAssert(source.contains("public func encode() -> ModelData")) + XCTAssert(source.contains("container.encode(self.age, forKey: Person.CodingKeys.age)")) + } + + func testExtensionExpansion() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + } + """) + let context = BasicMacroExpansionContext() + let extensions = try EntityMacro.expansion( + of: node, + attachedTo: declaration, + providingExtensionsOf: TypeSyntax(stringLiteral: "Person"), + conformingTo: [], + in: context + ) + XCTAssertEqual(extensions.count, 1) + XCTAssert(extensions[0].description.contains("CoreModel.Entity")) + } + + func testExplicitEntityName() throws { + let (node, declaration) = try parse(""" + @Entity("PersonEntity") + struct Person { + var id: UUID + } + """) + let context = BasicMacroExpansionContext() + let decl = try EntityMacro.entityNameDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) + XCTAssert(decl.description.contains(#""PersonEntity""#)) + } + + func testOptionalAttributes() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + @Attribute + var nickname: String? + @Attribute + var count: Optional + } + """) + let context = BasicMacroExpansionContext() + let decl = try EntityMacro.attributesDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) + XCTAssert(decl.description.contains(".nickname: .string")) + XCTAssert(decl.description.contains(".count: .int64")) + } + + func testExplicitAttributeType() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + @Attribute(.data) + var avatar: CustomImage + } + """) + let context = BasicMacroExpansionContext() + let decl = try EntityMacro.attributesDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) + XCTAssert(decl.description.contains(".avatar: .data")) + } + + func testUnknownAttributeType() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + @Attribute + var point: CGPoint + } + """) + let context = BasicMacroExpansionContext() + XCTAssertThrowsError(try EntityMacro.attributesDeclarationSyntax(of: node, providingMembersOf: declaration, in: context)) { error in + guard case MacroError.unknownAttributeType(let name) = error else { + return XCTFail("Expected unknownAttributeType, got \(error)") + } + XCTAssertEqual(name, "point") + } + } + + func testRelationships() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + @Relationship(inverse: .owner) + var pets: [Pet.ID] + @Relationship(inverse: \\Company.employees) + var employer: Company.ID? + } + """) + let context = BasicMacroExpansionContext() + let decl = try EntityMacro.relationshipsDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) + let source = decl.description + XCTAssert(source.contains("destination: Pet.self")) + XCTAssert(source.contains("type: .toMany")) + XCTAssert(source.contains("inverseRelationship: .owner")) + XCTAssert(source.contains("destination: Company.self")) + XCTAssert(source.contains("type: .toOne")) + XCTAssert(source.contains("inverseRelationship: .employees")) + } + + func testMissingInverseRelationship() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + @Relationship + var pets: [Pet.ID] + } + """) + let context = BasicMacroExpansionContext() + XCTAssertThrowsError(try EntityMacro.relationshipsDeclarationSyntax(of: node, providingMembersOf: declaration, in: context)) { error in + guard case MacroError.unknownInverseRelationship(let name) = error else { + return XCTFail("Expected unknownInverseRelationship, got \(error)") + } + XCTAssertEqual(name, "pets") + } + } + + func testInvalidInverseExpression() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + @Relationship(inverse: "owner") + var pets: [Pet.ID] + } + """) + let context = BasicMacroExpansionContext() + XCTAssertThrowsError(try EntityMacro.relationshipsDeclarationSyntax(of: node, providingMembersOf: declaration, in: context)) { error in + guard case MacroError.unknownInverseRelationship = error else { + return XCTFail("Expected unknownInverseRelationship, got \(error)") + } + } + } + + func testRelationshipDecodeEncode() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + @Attribute + var name: String + @Relationship(inverse: .owner) + var pets: [Pet.ID] + } + """) + let context = BasicMacroExpansionContext() + let initDecl = try EntityMacro.initDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) + XCTAssert(initDecl.description.contains("self.pets = try container.decodeRelationship([Pet.ID].self, forKey: Person.CodingKeys.pets)")) + let encodeDecl = try EntityMacro.encodeDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) + XCTAssert(encodeDecl.description.contains("container.encodeRelationship(self.pets, forKey: Person.CodingKeys.pets)")) + } + + func testUnrelatedPropertyAttributeIgnored() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + @Attribute + var name: String + @Published + var ignored: Int + } + """) + let context = BasicMacroExpansionContext() + let properties = EntityMacro.codableProperties(of: declaration) + XCTAssertEqual(properties.map { $0.name }, ["name"]) + let initDecl = try EntityMacro.initDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) + XCTAssertFalse(initDecl.description.contains("ignored")) + } + + func testClassTypeName() throws { + let (node, declaration) = try parse(""" + @Entity + class Animal { + var id: UUID = UUID() + } + """) + let context = BasicMacroExpansionContext() + XCTAssertEqual(try EntityMacro.typeName(of: node, providingMembersOf: declaration, in: context), "Animal") + } + + func testEnumTypeName() throws { + let (node, declaration) = try parse(""" + @Entity + enum Kind { + case dog + } + """) + let context = BasicMacroExpansionContext() + XCTAssertEqual(try EntityMacro.typeName(of: node, providingMembersOf: declaration, in: context), "Kind") + } + + func testInvalidType() throws { + let (node, declaration) = try parse(""" + @Entity + actor Worker { + var id: UUID = UUID() + } + """) + let context = BasicMacroExpansionContext() + XCTAssertThrowsError(try EntityMacro.typeName(of: node, providingMembersOf: declaration, in: context)) { error in + guard case MacroError.invalidType = error else { + return XCTFail("Expected invalidType, got \(error)") + } + } + } + + func testInferAttributeType() { + XCTAssertEqual(inferAttributeType(from: "String"), ".string") + XCTAssertEqual(inferAttributeType(from: "Data"), ".data") + XCTAssertEqual(inferAttributeType(from: "Bool"), ".bool") + XCTAssertEqual(inferAttributeType(from: "Int16"), ".int16") + XCTAssertEqual(inferAttributeType(from: "Int32"), ".int32") + XCTAssertEqual(inferAttributeType(from: "Int64"), ".int64") + XCTAssertEqual(inferAttributeType(from: "Int"), ".int64") + XCTAssertEqual(inferAttributeType(from: "Float"), ".float") + XCTAssertEqual(inferAttributeType(from: "Double"), ".double") + XCTAssertEqual(inferAttributeType(from: "Date"), ".date") + XCTAssertEqual(inferAttributeType(from: "UUID"), ".uuid") + XCTAssertEqual(inferAttributeType(from: "URL"), ".url") + XCTAssertEqual(inferAttributeType(from: "Decimal"), ".decimal") + XCTAssertNil(inferAttributeType(from: "CGPoint")) + } + + func testPeerMacrosExpandToNothing() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + } + """) + let context = BasicMacroExpansionContext() + XCTAssertEqual(try AttributeMacro.expansion(of: node, providingPeersOf: declaration, in: context).count, 0) + XCTAssertEqual(try RelationshipMacro.expansion(of: node, providingPeersOf: declaration, in: context).count, 0) + } + + func testExpansionNames() { + XCTAssertEqual(EntityMacro.expansionNames.count, 5) + } + + #if canImport(Darwin) + func testMacroErrorDescriptions() { + XCTAssertNotNil(MacroError.invalidType.errorDescription) + XCTAssertNotNil(MacroError.unknownAttributeType(for: "point").errorDescription) + XCTAssertNotNil(MacroError.unknownInverseRelationship(for: "pets").errorDescription) + } + #endif +} + +// MARK: - Helpers + +private extension EntityMacroTests { + + /// Parse source containing a single attributed type declaration, returning the + /// macro attribute node and the declaration group it is attached to. + func parse(_ source: String) throws -> (AttributeSyntax, any DeclGroupSyntax) { + let file = Parser.parse(source: source) + guard let decl = file.statements.first?.item.as(DeclSyntax.self) else { + throw MacroError.invalidType + } + let group: (any DeclGroupSyntax)? + let attributes: AttributeListSyntax + if let structDecl = decl.as(StructDeclSyntax.self) { + group = structDecl + attributes = structDecl.attributes + } else if let classDecl = decl.as(ClassDeclSyntax.self) { + group = classDecl + attributes = classDecl.attributes + } else if let enumDecl = decl.as(EnumDeclSyntax.self) { + group = enumDecl + attributes = enumDecl.attributes + } else if let actorDecl = decl.as(ActorDeclSyntax.self) { + group = actorDecl + attributes = actorDecl.attributes + } else { + group = nil + attributes = [] + } + guard let group, let node = attributes.first?.as(AttributeSyntax.self) else { + throw MacroError.invalidType + } + return (node, group) + } + + func expandMembers( + of node: AttributeSyntax, + attachedTo declaration: any DeclGroupSyntax, + in context: BasicMacroExpansionContext + ) throws -> [DeclSyntax] { + try EntityMacro.expansion(of: node, providingMembersOf: declaration, in: context) + } +} + +#endif diff --git a/Tests/CoreModelTests/AttributeCodingTests.swift b/Tests/CoreModelTests/AttributeCodingTests.swift new file mode 100644 index 0000000..e4d7fb6 --- /dev/null +++ b/Tests/CoreModelTests/AttributeCodingTests.swift @@ -0,0 +1,228 @@ +// +// AttributeCodingTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 7/17/25. +// + +import Foundation +import XCTest +@testable import CoreModel + +final class AttributeCodingTests: XCTestCase { + + enum Key: CodingKey { + case value + case other + } + + enum Color: String, AttributeEncodable, AttributeDecodable { + case red + case blue + } + + // MARK: - AttributeEncodable + + func testEncodeAttributeValues() { + XCTAssertEqual(true.attributeValue, .bool(true)) + XCTAssertEqual("test".attributeValue, .string("test")) + XCTAssertEqual(Int(1).attributeValue, .int64(1)) + XCTAssertEqual(Int8(2).attributeValue, .int16(2)) + XCTAssertEqual(Int16(3).attributeValue, .int16(3)) + XCTAssertEqual(Int32(4).attributeValue, .int32(4)) + XCTAssertEqual(Int64(5).attributeValue, .int64(5)) + XCTAssertEqual(UInt(6).attributeValue, .int64(6)) + XCTAssertEqual(UInt8(7).attributeValue, .int16(7)) + XCTAssertEqual(UInt16(8).attributeValue, .int32(8)) + XCTAssertEqual(UInt32(9).attributeValue, .int64(9)) + XCTAssertEqual(UInt64(10).attributeValue, .int64(10)) + XCTAssertEqual(Float(1.5).attributeValue, .float(1.5)) + XCTAssertEqual(Double(2.5).attributeValue, .double(2.5)) + let date = Date(timeIntervalSince1970: 100) + XCTAssertEqual(date.attributeValue, .date(date)) + let data = Data([0x01, 0x02]) + XCTAssertEqual(data.attributeValue, .data(data)) + let uuid = UUID() + XCTAssertEqual(uuid.attributeValue, .uuid(uuid)) + let url = URL(string: "https://example.com")! + XCTAssertEqual(url.attributeValue, .url(url)) + let decimal = Decimal(string: "3.14")! + XCTAssertEqual(decimal.attributeValue, .decimal(decimal)) + // RawRepresentable + XCTAssertEqual(Color.red.attributeValue, .string("red")) + // Optional + XCTAssertEqual(Optional.none.attributeValue, .null) + XCTAssertEqual(Optional.some("test").attributeValue, .string("test")) + } + + // MARK: - AttributeDecodable + + func testDecodeAttributeValues() { + XCTAssertEqual(Bool(attributeValue: .bool(true)), true) + XCTAssertNil(Bool(attributeValue: .string("true"))) + XCTAssertEqual(String(attributeValue: .string("test")), "test") + XCTAssertNil(String(attributeValue: .bool(false))) + let uuid = UUID() + XCTAssertEqual(UUID(attributeValue: .uuid(uuid)), uuid) + XCTAssertNil(UUID(attributeValue: .null)) + let url = URL(string: "https://example.com")! + XCTAssertEqual(URL(attributeValue: .url(url)), url) + XCTAssertNil(URL(attributeValue: .null)) + let date = Date(timeIntervalSince1970: 100) + XCTAssertEqual(Date(attributeValue: .date(date)), date) + XCTAssertNil(Date(attributeValue: .null)) + let data = Data([0x01]) + XCTAssertEqual(Data(attributeValue: .data(data)), data) + XCTAssertNil(Data(attributeValue: .null)) + let decimal = Decimal(string: "3.14")! + XCTAssertEqual(Decimal(attributeValue: .decimal(decimal)), decimal) + XCTAssertNil(Decimal(attributeValue: .double(3.14))) + XCTAssertEqual(Float(attributeValue: .float(1.5)), 1.5) + XCTAssertNil(Float(attributeValue: .double(1.5))) + XCTAssertEqual(Double(attributeValue: .double(2.5)), 2.5) + XCTAssertNil(Double(attributeValue: .float(2.5))) + // RawRepresentable + XCTAssertEqual(Color(attributeValue: .string("red")), .red) + XCTAssertNil(Color(attributeValue: .string("green"))) + XCTAssertNil(Color(attributeValue: .bool(true))) + // Optional + XCTAssertEqual(Optional(attributeValue: .null), .some(.none)) + XCTAssertEqual(Optional(attributeValue: .string("x")), "x") + XCTAssertNil(Optional(attributeValue: .bool(true))) + } + + func testDecodeIntegerValues() { + // every integer type decodes from all three stored widths + func verify(_ type: T.Type) where T: AttributeDecodable & FixedWidthInteger { + XCTAssertEqual(T(attributeValue: .int16(16)), 16) + XCTAssertEqual(T(attributeValue: .int32(32)), 32) + XCTAssertEqual(T(attributeValue: .int64(64)), 64) + XCTAssertNil(T(attributeValue: .null)) + XCTAssertNil(T(attributeValue: .string("1"))) + XCTAssertNil(T(attributeValue: .bool(true))) + XCTAssertNil(T(attributeValue: .float(1))) + XCTAssertNil(T(attributeValue: .double(1))) + XCTAssertNil(T(attributeValue: .date(Date()))) + XCTAssertNil(T(attributeValue: .uuid(UUID()))) + XCTAssertNil(T(attributeValue: .url(URL(string: "https://example.com")!))) + XCTAssertNil(T(attributeValue: .data(Data()))) + XCTAssertNil(T(attributeValue: .decimal(1))) + } + verify(Int.self) + verify(Int8.self) + verify(Int16.self) + verify(Int32.self) + verify(Int64.self) + verify(UInt.self) + verify(UInt8.self) + verify(UInt16.self) + verify(UInt32.self) + verify(UInt64.self) + } + + // MARK: - ModelData attribute decoding + + func testModelDataDecode() throws { + var model = ModelData(entity: "Test", id: "1") + model.encode("value", forKey: Key.value) + XCTAssertEqual(try model.decode(String.self, forKey: Key.value), "value") + // key not found + XCTAssertThrowsError(try model.decode(String.self, forKey: Key.other)) { error in + guard case DecodingError.keyNotFound = error else { + return XCTFail("Expected keyNotFound, got \(error)") + } + } + // type mismatch + XCTAssertThrowsError(try model.decode(Bool.self, forKey: Key.value)) { error in + guard case DecodingError.typeMismatch = error else { + return XCTFail("Expected typeMismatch, got \(error)") + } + } + } + + // MARK: - ModelData relationship decoding + + func testDecodeToOneRelationship() throws { + let uuid = UUID() + var model = ModelData(entity: "Test", id: "1") + model.encodeRelationship(uuid, forKey: Key.value) + XCTAssertEqual(try model.decodeRelationship(UUID.self, forKey: Key.value), uuid) + // key not found + XCTAssertThrowsError(try model.decodeRelationship(UUID.self, forKey: Key.other)) + // null throws for non-optional + model.relationships[PropertyKey(Key.value)] = .null + XCTAssertThrowsError(try model.decodeRelationship(UUID.self, forKey: Key.value)) + // to-many mismatch + model.relationships[PropertyKey(Key.value)] = .toMany([ObjectID(uuid)]) + XCTAssertThrowsError(try model.decodeRelationship(UUID.self, forKey: Key.value)) + // invalid identifier + model.relationships[PropertyKey(Key.value)] = .toOne("not-a-uuid") + XCTAssertThrowsError(try model.decodeRelationship(UUID.self, forKey: Key.value)) + } + + func testDecodeOptionalRelationship() throws { + let uuid = UUID() + var model = ModelData(entity: "Test", id: "1") + // missing key decodes as nil + XCTAssertNil(try model.decodeRelationship(UUID?.self, forKey: Key.value)) + // null decodes as nil + model.encodeRelationship(UUID?.none, forKey: Key.value) + XCTAssertNil(try model.decodeRelationship(UUID?.self, forKey: Key.value)) + // value decodes + model.encodeRelationship(UUID?.some(uuid), forKey: Key.value) + XCTAssertEqual(try model.decodeRelationship(UUID?.self, forKey: Key.value), uuid) + // to-many mismatch + model.relationships[PropertyKey(Key.value)] = .toMany([ObjectID(uuid)]) + XCTAssertThrowsError(try model.decodeRelationship(UUID?.self, forKey: Key.value)) + // invalid identifier + model.relationships[PropertyKey(Key.value)] = .toOne("not-a-uuid") + XCTAssertThrowsError(try model.decodeRelationship(UUID?.self, forKey: Key.value)) + } + + func testDecodeToManyRelationship() throws { + let ids = [UUID(), UUID()] + var model = ModelData(entity: "Test", id: "1") + // missing key throws + XCTAssertThrowsError(try model.decodeRelationship([UUID].self, forKey: Key.value)) + // values decode + model.encodeRelationship(ids, forKey: Key.value) + XCTAssertEqual(try model.decodeRelationship([UUID].self, forKey: Key.value), ids) + // null decodes as empty + model.relationships[PropertyKey(Key.value)] = .null + XCTAssertEqual(try model.decodeRelationship([UUID].self, forKey: Key.value), []) + // to-one mismatch + model.relationships[PropertyKey(Key.value)] = .toOne(ObjectID(ids[0])) + XCTAssertThrowsError(try model.decodeRelationship([UUID].self, forKey: Key.value)) + // invalid identifier + model.relationships[PropertyKey(Key.value)] = .toMany(["not-a-uuid"]) + XCTAssertThrowsError(try model.decodeRelationship([UUID].self, forKey: Key.value)) + } + + // MARK: - ObjectID + + func testObjectID() { + let id: ObjectID = "test-id" + XCTAssertEqual(id.rawValue, "test-id") + XCTAssertEqual(id.description, "test-id") + XCTAssertEqual(id.debugDescription, "test-id") + let uuid = UUID() + XCTAssertEqual(ObjectID(uuid).rawValue, uuid.uuidString) + XCTAssertEqual(UUID(objectID: ObjectID(uuid)), uuid) + XCTAssertNil(UUID(objectID: "invalid")) + XCTAssertEqual(String(objectID: "value"), "value") + // RawRepresentable conversion + XCTAssertEqual(Color(objectID: "red"), Color.red) + XCTAssertNil(Color(objectID: "green")) + // Optional conversion + XCTAssertEqual(UUID?(objectID: ObjectID(uuid)), uuid) + XCTAssertNil(UUID?(objectID: "invalid")) + // Optional description + XCTAssertEqual(UUID?.none.description, "") + XCTAssertEqual(UUID?.some(uuid).description, uuid.uuidString) + } +} + +extension AttributeCodingTests.Color: ObjectIDConvertible { + + var description: String { rawValue } +} diff --git a/Tests/CoreModelTests/CoreDataModelTests.swift b/Tests/CoreModelTests/CoreDataModelTests.swift new file mode 100644 index 0000000..4eaebf8 --- /dev/null +++ b/Tests/CoreModelTests/CoreDataModelTests.swift @@ -0,0 +1,208 @@ +// +// CoreDataModelTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 7/17/25. +// + +#if canImport(CoreData) + +import Foundation +import CoreData +import XCTest +@testable import CoreModel +@testable import CoreDataModel + +@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) +final class CoreDataModelTests: XCTestCase { + + static func makeContext() throws -> NSManagedObjectContext { + let model = Model(entities: Person.self, Event.self) + let coordinator = NSPersistentStoreCoordinator(managedObjectModel: NSManagedObjectModel(model: model)) + try coordinator.addPersistentStore(ofType: NSInMemoryStoreType, configurationName: nil, at: nil, options: nil) + let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) + context.persistentStoreCoordinator = coordinator + return context + } + + func testAttributeTypeConversion() { + // CoreModel -> CoreData + XCTAssertEqual(NSAttributeType(attributeType: .bool), .booleanAttributeType) + XCTAssertEqual(NSAttributeType(attributeType: .int16), .integer16AttributeType) + XCTAssertEqual(NSAttributeType(attributeType: .int32), .integer32AttributeType) + XCTAssertEqual(NSAttributeType(attributeType: .int64), .integer64AttributeType) + XCTAssertEqual(NSAttributeType(attributeType: .float), .floatAttributeType) + XCTAssertEqual(NSAttributeType(attributeType: .double), .doubleAttributeType) + XCTAssertEqual(NSAttributeType(attributeType: .string), .stringAttributeType) + XCTAssertEqual(NSAttributeType(attributeType: .data), .binaryDataAttributeType) + XCTAssertEqual(NSAttributeType(attributeType: .date), .dateAttributeType) + XCTAssertEqual(NSAttributeType(attributeType: .uuid), .UUIDAttributeType) + XCTAssertEqual(NSAttributeType(attributeType: .url), .URIAttributeType) + XCTAssertEqual(NSAttributeType(attributeType: .decimal), .decimalAttributeType) + // CoreData -> CoreModel round trip + for type in [AttributeType.bool, .int16, .int32, .int64, .float, .double, .string, .data, .date, .uuid, .url, .decimal] { + XCTAssertEqual(AttributeType(attributeType: NSAttributeType(attributeType: type)), type) + } + // unsupported CoreData types + XCTAssertNil(AttributeType(attributeType: .undefinedAttributeType)) + XCTAssertNil(AttributeType(attributeType: .transformableAttributeType)) + XCTAssertNil(AttributeType(attributeType: .objectIDAttributeType)) + #if swift(>=5.9) + XCTAssertNil(AttributeType(attributeType: .compositeAttributeType)) + #endif + } + + func testComparisonModifierConversion() { + XCTAssertEqual(FetchRequest.Predicate.Comparison.Modifier.all.toFoundation(), .all) + XCTAssertEqual(FetchRequest.Predicate.Comparison.Modifier.any.toFoundation(), .any) + } + + func testFunctionSortDescriptorNotConvertible() { + // function-based sort terms can't be represented in NSFetchRequest and are dropped + let request = FetchRequest( + entity: "Person", + sortDescriptors: [ + .init(term: .function(.init(name: "f", arguments: [.keyPath("name")])), ascending: true), + .init(property: "name", ascending: true) + ] + ) + let sortDescriptors = request.toFoundation().sortDescriptors ?? [] + // only the property sort plus the built-in id tiebreaker survive + XCTAssertEqual(sortDescriptors.count, 2) + XCTAssertEqual(sortDescriptors.first?.key, "name") + } + + func testContextModelStorageInsert() throws { + let context = try Self.makeContext() + let person = Person(name: "Alice", age: 30) + // single insert through the ModelStorage conformance + try context.insert(person.encode()) + XCTAssertEqual(try context.count(FetchRequest(entity: Person.entityName)), 1) + // batch insert through the ModelStorage conformance + let more = [Person(name: "Bob", age: 25), Person(name: "Charlie", age: 35)] + try context.insert(more.map { try! $0.encode() }) + XCTAssertEqual(try context.count(FetchRequest(entity: Person.entityName)), 3) + // single delete + try context.delete(Person.entityName, for: ObjectID(person.id)) + XCTAssertEqual(try context.count(FetchRequest(entity: Person.entityName)), 2) + // deleting a missing object is a no-op + try context.delete(Person.entityName, for: ObjectID(UUID())) + XCTAssertEqual(try context.count(FetchRequest(entity: Person.entityName)), 2) + } + + func testContextInMemoryFetchID() throws { + let context = try Self.makeContext() + try context.register(function: DatabaseFunction(name: "lower", argumentCount: 1) { arguments in + guard case let .string(value) = arguments[0] else { return nil } + return .string(value.lowercased()) + }) + let person = Person(name: "Alice", age: 30) + try context.insert(person.encode()) + let lower = FetchRequest.Predicate.Expression.function(.init(name: "lower", arguments: [.keyPath("name")])) + let request = FetchRequest( + entity: Person.entityName, + predicate: lower.compare(.equalTo, .attribute(.string("alice"))) + ) + XCTAssertEqual(try context.fetchID(request), [ObjectID(person.id)]) + // in-memory path with limit and offset + try context.insert(Person(name: "alina", age: 20).encode()) + let paged = FetchRequest( + entity: Person.entityName, + sortDescriptors: [.init(term: .function(.init(name: "lower", arguments: [.keyPath("name")])), ascending: true)], + predicate: lower.compare(.beginsWith, .attribute(.string("al"))), + fetchLimit: 1, + fetchOffset: 1 + ) + let results = try context.fetch(paged) + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results[0].attributes["name"], .string("alina")) + } + + func testNullRelationshipInsert() throws { + let context = try Self.makeContext() + var data = Person(name: "Loner", age: 40).encode() + data.relationships[PropertyKey(Person.CodingKeys.events)] = .null + // batch insert path (exercises relationship prefetch with a null value) + try context.insert([data]) + let fetched = try context.fetch(Person.entityName, for: data.id) + // CoreData represents an empty to-many relationship as an empty set + XCTAssertEqual(fetched?.relationships[PropertyKey(Person.CodingKeys.events)], .toMany([])) + } + + @MainActor + func testManagedObjectViewContextObservation() throws { + let context = try Self.makeContext() + let viewContext = ManagedObjectViewContext(context: context) + var changes = 0 + let cancellable = viewContext.objectWillChange.sink { changes += 1 } + defer { cancellable.cancel() } + // mutate the observed context to trigger change and save notifications + try context.insert(Person(name: "Alice", age: 30).encode()) + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + XCTAssertGreaterThan(changes, 0) + // ViewContext conformance + XCTAssertEqual(try viewContext.count(FetchRequest(entity: Person.entityName)), 1) + XCTAssertEqual(try viewContext.fetchID(FetchRequest(entity: Person.entityName)).count, 1) + XCTAssertEqual(try viewContext.fetch(FetchRequest(entity: Person.entityName)).count, 1) + } + + func testPersistentContainerFetchAndDelete() async throws { + let model = Model(entities: Person.self, Event.self) + let container = NSPersistentContainer( + name: "Test\(UUID())", + managedObjectModel: NSManagedObjectModel(model: model) + ) + try container.syncLoadPersistentStores() + let person = Person(name: "Alice", age: 30) + try await container.insert(person.encode()) + // fetch with a fetch request + let results = try await container.fetch(FetchRequest(entity: Person.entityName)) + XCTAssertEqual(results.count, 1) + // delete a single object + try await container.delete(Person.entityName, for: ObjectID(person.id)) + let remaining = try await container.count(FetchRequest(entity: Person.entityName)) + XCTAssertEqual(remaining, 0) + } + + func testStorageLoadFailure() async throws { + // a store URL inside a nonexistent directory fails to load + let description = NSPersistentStoreDescription( + url: URL(fileURLWithPath: "/nonexistent-\(UUID())/store.sqlite") + ) + description.type = NSSQLiteStoreType + let storage = PersistentStorageTests.makeStorage(model: Model(entities: Person.self, Event.self)) + let failing = PersistentContainerStorage( + name: "Failing\(UUID())", + model: Model(entities: Person.self, Event.self), + storeDescriptions: [description] + ) + do { + _ = try await failing.fetch(FetchRequest(entity: Person.entityName)) + XCTFail("Expected store load to fail") + } catch { + // expected + } + // healthy storage still works after exercising the failure path + _ = try await storage.count(FetchRequest(entity: Person.entityName)) + } + + @MainActor + func testViewContextLoadFailure() throws { + let description = NSPersistentStoreDescription( + url: URL(fileURLWithPath: "/nonexistent-\(UUID())/store.sqlite") + ) + description.type = NSSQLiteStoreType + let failing = PersistentContainerStorage( + name: "Failing\(UUID())", + model: Model(entities: Person.self, Event.self), + storeDescriptions: [description] + ) + // the sync load swallows the error; the view context is still created and + // fetches simply return no results against the unloaded store + let viewContext = try failing.viewContext + let results = try? viewContext.fetch(FetchRequest(entity: Person.entityName)) + XCTAssertEqual(results ?? [], []) + } +} + +#endif diff --git a/Tests/CoreModelTests/FunctionEvaluationTests.swift b/Tests/CoreModelTests/FunctionEvaluationTests.swift new file mode 100644 index 0000000..4f8b425 --- /dev/null +++ b/Tests/CoreModelTests/FunctionEvaluationTests.swift @@ -0,0 +1,196 @@ +// +// FunctionEvaluationTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 7/17/25. +// + +#if canImport(CoreData) + +import Foundation +import XCTest +@testable import CoreModel +@testable import CoreDataModel + +final class FunctionEvaluationTests: XCTestCase { + + typealias Predicate = FetchRequest.Predicate + + static let lowercase = DatabaseFunction(name: "lowercase", argumentCount: 1) { arguments in + guard case let .string(value) = arguments[0] else { return nil } + return .string(value.lowercased()) + } + + static let functions = ["lowercase": lowercase] + + static let functionExpression = Predicate.Expression.function( + .init(name: "lowercase", arguments: [.keyPath("name")]) + ) + + static func makeData(name: String = "Alice", age: Int64 = 30, id: String = "1") -> ModelData { + ModelData( + entity: "Person", + id: ObjectID(rawValue: id), + attributes: ["name": .string(name), "age": .int64(age)] + ) + } + + func testRequiresInMemoryEvaluation() { + let native = FetchRequest(entity: "Person", predicate: "name".compare(.equalTo, .attribute(.string("x")))) + XCTAssertFalse(native.requiresInMemoryEvaluation) + let functionPredicate = FetchRequest( + entity: "Person", + predicate: Self.functionExpression.compare(.equalTo, .attribute(.string("x"))) + ) + XCTAssert(functionPredicate.requiresInMemoryEvaluation) + let functionSort = FetchRequest( + entity: "Person", + sortDescriptors: [.init(term: .function(.init(name: "lowercase", arguments: [.keyPath("name")])), ascending: true)] + ) + XCTAssert(functionSort.requiresInMemoryEvaluation) + } + + func testContainsFunction() { + XCTAssertFalse(Predicate.value(true).containsFunction) + XCTAssertFalse("name".compare(.equalTo, .attribute(.string("x"))).containsFunction) + XCTAssert(Self.functionExpression.compare(.equalTo, .attribute(.string("x"))).containsFunction) + // function on the right side + XCTAssert(Predicate.Expression.keyPath("name").compare(.equalTo, Self.functionExpression).containsFunction) + XCTAssert(Predicate.compound(.and([.value(true), Self.functionExpression.compare(.equalTo, .attribute(.null))])).containsFunction) + XCTAssertFalse(Predicate.compound(.or([.value(false)])).containsFunction) + } + + func testStrippingFunctionComparisons() { + let function = Self.functionExpression.compare(.equalTo, .attribute(.string("x"))) + let native = "name".compare(.equalTo, .attribute(.string("x"))) + XCTAssertEqual(Predicate.value(false).strippingFunctionComparisons(), .value(false)) + XCTAssertEqual(native.strippingFunctionComparisons(), native) + XCTAssertEqual(function.strippingFunctionComparisons(), .value(true)) + XCTAssertEqual( + Predicate.compound(.and([native, function])).strippingFunctionComparisons(), + .compound(.and([native, .value(true)])) + ) + XCTAssertEqual( + Predicate.compound(.or([function])).strippingFunctionComparisons(), + .compound(.or([.value(true)])) + ) + XCTAssertEqual( + Predicate.compound(.not(function)).strippingFunctionComparisons(), + .compound(.not(.value(true))) + ) + } + + func testPredicateEvaluation() { + let data = Self.makeData() + XCTAssert(Predicate.value(true).evaluate(with: data, functions: [:])) + XCTAssertFalse(Predicate.value(false).evaluate(with: data, functions: [:])) + let isAlice = Self.functionExpression.compare(.equalTo, .attribute(.string("alice"))) + XCTAssert(isAlice.evaluate(with: data, functions: Self.functions)) + // compound evaluation + XCTAssert(Predicate.compound(.and([.value(true), isAlice])).evaluate(with: data, functions: Self.functions)) + XCTAssertFalse(Predicate.compound(.and([.value(false), isAlice])).evaluate(with: data, functions: Self.functions)) + XCTAssert(Predicate.compound(.or([.value(false), isAlice])).evaluate(with: data, functions: Self.functions)) + XCTAssertFalse(Predicate.compound(.not(isAlice)).evaluate(with: data, functions: Self.functions)) + } + + func testExpressionEvaluation() { + let data = Self.makeData() + XCTAssertEqual(Predicate.Expression.attribute(.int64(1)).evaluate(with: data, functions: [:]), .int64(1)) + XCTAssertEqual(Predicate.Expression.keyPath("name").evaluate(with: data, functions: [:]), .string("Alice")) + XCTAssertNil(Predicate.Expression.keyPath("missing").evaluate(with: data, functions: [:])) + XCTAssertEqual(Self.functionExpression.evaluate(with: data, functions: Self.functions), .string("alice")) + // unregistered function + XCTAssertNil(Self.functionExpression.evaluate(with: data, functions: [:])) + // relationships aren't evaluated + XCTAssertNil(Predicate.Expression.relationship(.toOne("x")).evaluate(with: data, functions: [:])) + } + + func testOperatorEvaluation() { + let data = Self.makeData() + func evaluate( + _ type: Predicate.Comparison.Operator, + _ lhs: Predicate.Expression, + _ rhs: Predicate.Expression, + options: Set = [] + ) -> Bool { + Predicate.comparison(.init(left: lhs, right: rhs, type: type, options: options)) + .evaluate(with: data, functions: Self.functions) + } + let name = Predicate.Expression.keyPath("name") + let age = Predicate.Expression.keyPath("age") + // equality + XCTAssert(evaluate(.equalTo, name, .attribute(.string("Alice")))) + XCTAssert(evaluate(.equalTo, name, .attribute(.string("ALICE")), options: [.caseInsensitive])) + XCTAssertFalse(evaluate(.equalTo, name, .attribute(.string("ALICE")))) + XCTAssert(evaluate(.notEqualTo, name, .attribute(.string("Bob")))) + // null equality + XCTAssert(evaluate(.equalTo, .attribute(.null), .attribute(.null))) + XCTAssert(evaluate(.equalTo, .keyPath("missing"), .attribute(.null))) + XCTAssertFalse(evaluate(.equalTo, name, .attribute(.null))) + XCTAssertFalse(evaluate(.equalTo, name, .keyPath("missing"))) + // ordering (numeric) + XCTAssert(evaluate(.lessThan, age, .attribute(.int64(40)))) + XCTAssertFalse(evaluate(.lessThan, age, .attribute(.int64(30)))) + XCTAssert(evaluate(.lessThanOrEqualTo, age, .attribute(.int64(30)))) + XCTAssert(evaluate(.greaterThan, age, .attribute(.int64(20)))) + XCTAssert(evaluate(.greaterThanOrEqualTo, age, .attribute(.int64(30)))) + // ordering with mixed numeric types + XCTAssert(evaluate(.lessThan, age, .attribute(.double(30.5)))) + XCTAssert(evaluate(.greaterThan, age, .attribute(.float(29.5)))) + XCTAssert(evaluate(.greaterThan, age, .attribute(.int16(29)))) + XCTAssert(evaluate(.lessThan, age, .attribute(.int32(31)))) + XCTAssert(evaluate(.greaterThan, age, .attribute(.bool(true)))) + XCTAssert(evaluate(.lessThan, age, .attribute(.decimal(Decimal(50))))) + // date ordering + XCTAssert(evaluate(.lessThan, .attribute(.date(Date(timeIntervalSinceReferenceDate: 0))), .attribute(.date(Date(timeIntervalSinceReferenceDate: 100))))) + // string ordering + XCTAssert(evaluate(.lessThan, name, .attribute(.string("Bob")))) + XCTAssertFalse(evaluate(.greaterThan, name, .attribute(.string("Bob")))) + // non-comparable ordering + XCTAssertFalse(evaluate(.lessThan, name, .attribute(.int64(1)))) + XCTAssertFalse(evaluate(.lessThan, .attribute(.null), age)) + // string operators + XCTAssert(evaluate(.beginsWith, name, .attribute(.string("Al")))) + XCTAssert(evaluate(.beginsWith, name, .attribute(.string("AL")), options: [.caseInsensitive])) + XCTAssert(evaluate(.endsWith, name, .attribute(.string("ice")))) + XCTAssert(evaluate(.contains, name, .attribute(.string("lic")))) + XCTAssertFalse(evaluate(.contains, name, .attribute(.string("bob")))) + XCTAssertFalse(evaluate(.contains, age, .attribute(.string("3")))) + // like / matches + XCTAssert(evaluate(.like, name, .attribute(.string("A*e")))) + XCTAssert(evaluate(.like, name, .attribute(.string("Alic?")))) + XCTAssertFalse(evaluate(.like, name, .attribute(.string("B*")))) + XCTAssert(evaluate(.matches, name, .attribute(.string("^A[a-z]+e$")))) + XCTAssertFalse(evaluate(.matches, name, .attribute(.string("^[0-9]+$")))) + // unsupported collection operators + XCTAssertFalse(evaluate(.in, name, .attribute(.string("Alice")))) + XCTAssertFalse(evaluate(.between, age, .attribute(.int64(50)))) + } + + func testSortedInMemory() { + let people = [ + Self.makeData(name: "Charlie", age: 35, id: "3"), + Self.makeData(name: "alice", age: 30, id: "1"), + Self.makeData(name: "Bob", age: 30, id: "2") + ] + // no descriptors returns as-is + XCTAssertEqual(people.sortedInMemory(by: [], functions: [:]), people) + // property ascending + let byAge = people.sortedInMemory(by: [.init(property: "age", ascending: true)], functions: [:]) + XCTAssertEqual(byAge.map { $0.id.rawValue }, ["1", "2", "3"]) + // property descending + let byAgeDesc = people.sortedInMemory(by: [.init(property: "age", ascending: false)], functions: [:]) + XCTAssertEqual(byAgeDesc.first?.id.rawValue, "3") + // function term (case-insensitive name order) + let byName = people.sortedInMemory( + by: [.init(term: .function(.init(name: "lowercase", arguments: [.keyPath("name")])), ascending: true)], + functions: Self.functions + ) + XCTAssertEqual(byName.map { $0.id.rawValue }, ["1", "2", "3"]) + // ties fall back to id ordering + let tied = people.sortedInMemory(by: [.init(property: "missing", ascending: true)], functions: [:]) + XCTAssertEqual(tied.map { $0.id.rawValue }, ["1", "2", "3"]) + } +} + +#endif diff --git a/Tests/CoreModelTests/ModelTests.swift b/Tests/CoreModelTests/ModelTests.swift new file mode 100644 index 0000000..e3bc376 --- /dev/null +++ b/Tests/CoreModelTests/ModelTests.swift @@ -0,0 +1,97 @@ +// +// ModelTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 7/17/25. +// + +import Foundation +import XCTest +@testable import CoreModel +#if canImport(CoreData) +@testable import CoreDataModel +#endif + +final class ModelTests: XCTestCase { + + func testModel() throws { + let model = Model(entities: Person.self, Event.self) + XCTAssertEqual(model.entities.count, 2) + // subscript + XCTAssertNotNil(model[Person.entityName]) + XCTAssertNotNil(model["Event"]) + XCTAssertNil(model["Missing"]) + // codable round trip + let data = try JSONEncoder().encode(model) + let decoded = try JSONDecoder().decode(Model.self, from: data) + XCTAssertEqual(decoded, model) + } + + func testEntityName() throws { + let name: EntityName = "Person" + XCTAssertEqual(name.rawValue, "Person") + XCTAssertEqual(name.description, "Person") + XCTAssertEqual(name.debugDescription, "Person") + let data = try JSONEncoder().encode(name) + XCTAssertEqual(try JSONDecoder().decode(EntityName.self, from: data), name) + } + + func testPropertyKey() throws { + let key: PropertyKey = "name" + XCTAssertEqual(key.rawValue, "name") + XCTAssertEqual(key.description, "name") + XCTAssertEqual(key.debugDescription, "name") + XCTAssertEqual(PropertyKey(Person.CodingKeys.name), key) + let data = try JSONEncoder().encode(key) + XCTAssertEqual(try JSONDecoder().decode(PropertyKey.self, from: data), key) + } + + func testEntityDefaultImplementations() { + // entity with no attributes or relationships uses protocol defaults + struct Empty: Entity { + typealias ID = UUID + let id: UUID + enum CodingKeys: CodingKey { + case id + } + init(from model: ModelData) throws { + self.id = UUID(objectID: model.id)! + } + init(id: UUID) { + self.id = id + } + func encode() throws -> ModelData { + ModelData(entity: Self.entityName, id: ObjectID(id)) + } + } + XCTAssertEqual(Empty.entityName.rawValue, "Empty") + XCTAssertEqual(Empty.attributes, [:]) + XCTAssertEqual(Empty.relationships, [:]) + let description = EntityDescription(entity: Empty.self) + XCTAssertEqual(description.id, Empty.entityName) + XCTAssertEqual(description.attributes, []) + XCTAssertEqual(description.relationships, []) + } + + func testModelDataCodable() throws { + var data = ModelData(entity: "Person", id: "1") + data.encode("Alice", forKey: PredicateCodingTests.Key.name) + data.encodeRelationship([UUID()], forKey: PredicateCodingTests.Key.age) + let encoded = try JSONEncoder().encode(data) + let decoded = try JSONDecoder().decode(ModelData.self, from: encoded) + XCTAssertEqual(decoded, data) + } + + #if canImport(CoreData) + func testNSNumberConversion() { + XCTAssertEqual(NSNumber(value: .bool(true)), NSNumber(value: true)) + XCTAssertEqual(NSNumber(value: .int16(16)), NSNumber(value: Int16(16))) + XCTAssertEqual(NSNumber(value: .int32(32)), NSNumber(value: Int32(32))) + XCTAssertEqual(NSNumber(value: .int64(64)), NSNumber(value: Int64(64))) + XCTAssertEqual(NSNumber(value: .float(1.5)), NSNumber(value: Float(1.5))) + XCTAssertEqual(NSNumber(value: .double(2.5)), NSNumber(value: Double(2.5))) + XCTAssertNil(NSNumber(value: .string("x"))) + XCTAssertNil(NSNumber(value: .null)) + } + #endif +} diff --git a/Tests/CoreModelTests/PersistentStorageTests.swift b/Tests/CoreModelTests/PersistentStorageTests.swift new file mode 100644 index 0000000..2128aad --- /dev/null +++ b/Tests/CoreModelTests/PersistentStorageTests.swift @@ -0,0 +1,275 @@ +// +// PersistentStorageTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 7/17/25. +// + +#if canImport(CoreData) + +import Foundation +import CoreData +import XCTest +@testable import CoreModel +@testable import CoreDataModel + +/// Entity exercising every supported attribute type. +@Entity +struct AllTypes: Equatable, Hashable, Codable, Identifiable { + + let id: UUID + + @Attribute + var boolValue: Bool + + @Attribute + var int16Value: Int16 + + @Attribute + var int32Value: Int32 + + @Attribute + var int64Value: Int64 + + @Attribute + var floatValue: Float + + @Attribute + var doubleValue: Double + + @Attribute + var stringValue: String + + @Attribute + var dateValue: Date + + @Attribute + var dataValue: Data + + @Attribute + var uuidValue: UUID + + @Attribute + var urlValue: URL + + @Attribute + var decimalValue: Decimal + + @Attribute + var optionalString: String? + + init( + id: UUID, + boolValue: Bool, + int16Value: Int16, + int32Value: Int32, + int64Value: Int64, + floatValue: Float, + doubleValue: Double, + stringValue: String, + dateValue: Date, + dataValue: Data, + uuidValue: UUID, + urlValue: URL, + decimalValue: Decimal, + optionalString: String? + ) { + self.id = id + self.boolValue = boolValue + self.int16Value = int16Value + self.int32Value = int32Value + self.int64Value = int64Value + self.floatValue = floatValue + self.doubleValue = doubleValue + self.stringValue = stringValue + self.dateValue = dateValue + self.dataValue = dataValue + self.uuidValue = uuidValue + self.urlValue = urlValue + self.decimalValue = decimalValue + self.optionalString = optionalString + } + + enum CodingKeys: CodingKey { + case id + case boolValue + case int16Value + case int32Value + case int64Value + case floatValue + case doubleValue + case stringValue + case dateValue + case dataValue + case uuidValue + case urlValue + case decimalValue + case optionalString + } +} + +@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) +final class PersistentStorageTests: XCTestCase { + + static func makeStorage(model: Model = Model(entities: Person.self, Event.self, AllTypes.self)) -> PersistentContainerStorage { + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + return PersistentContainerStorage( + name: "Test\(UUID())", + model: model, + storeDescriptions: [description] + ) + } + + static func makeAllTypes() -> AllTypes { + AllTypes( + id: UUID(), + boolValue: true, + int16Value: 16, + int32Value: 32, + int64Value: 64, + floatValue: 1.5, + doubleValue: 2.5, + stringValue: "test", + dateValue: Date(timeIntervalSince1970: 100), + dataValue: Data([0x01, 0x02]), + uuidValue: UUID(), + urlValue: URL(string: "https://example.com")!, + decimalValue: Decimal(string: "3.14")!, + optionalString: nil + ) + } + + func testAllAttributeTypesRoundTrip() async throws { + let storage = Self.makeStorage() + var value = Self.makeAllTypes() + try await storage.insert(value) + var fetched = try await storage.fetch(AllTypes.self, for: value.id) + XCTAssertEqual(fetched, value) + // update with non-nil optional + value.optionalString = "present" + value.stringValue = "updated" + try await storage.insert(value) + fetched = try await storage.fetch(AllTypes.self, for: value.id) + XCTAssertEqual(fetched, value) + XCTAssertEqual(fetched?.optionalString, "present") + } + + func testStorageCRUD() async throws { + let storage = Self.makeStorage() + let people = [ + Person(name: "Alice", age: 30), + Person(name: "Bob", age: 25), + Person(name: "Charlie", age: 35) + ] + // batch insert (ModelData array) + try await storage.insert(people.map { try! $0.encode() }) + // count + let fetchRequest = FetchRequest(entity: Person.entityName) + let total = try await storage.count(fetchRequest) + XCTAssertEqual(total, 3) + // typed count + let typedCount = try await storage.count(Person.self) + XCTAssertEqual(typedCount, 3) + // fetchID + let ids = try await storage.fetchID(fetchRequest) + XCTAssertEqual(Set(ids), Set(people.map { ObjectID($0.id) })) + // typed fetch with sort and predicate + let sorted: [Person] = try await storage.fetch( + Person.self, + sortDescriptors: [.init(property: PropertyKey(Person.CodingKeys.name), ascending: false)], + predicate: Person.CodingKeys.age.compare(.greaterThan, .attribute(.int16(26))) + ) + XCTAssertEqual(sorted.map { $0.name }, ["Charlie", "Alice"]) + // fetch with limit and offset + let limited = try await storage.fetch( + FetchRequest( + entity: Person.entityName, + sortDescriptors: [.init(property: PropertyKey(Person.CodingKeys.name), ascending: true)], + fetchLimit: 1, + fetchOffset: 1 + ) + ) + XCTAssertEqual(limited.count, 1) + XCTAssertEqual(limited[0].attributes[PropertyKey(Person.CodingKeys.name)], .string("Bob")) + // fetch missing object + let missing = try await storage.fetch(Person.self, for: UUID()) + XCTAssertNil(missing) + // typed delete + try await storage.delete(Person.self, for: people[0].id) + // batch delete by id + try await storage.delete(Person.entityName, for: [ObjectID(people[1].id), ObjectID(people[2].id)]) + let remaining = try await storage.count(fetchRequest) + XCTAssertEqual(remaining, 0) + } + + func testStorageCustomFunction() async throws { + let storage = Self.makeStorage() + try await storage.register(function: DatabaseFunction(name: "upperName", argumentCount: 1) { arguments in + guard case let .string(name) = arguments[0] else { return nil } + return .string(name.uppercased()) + }) + try await storage.insert(Person(name: "alice", age: 30)) + let upperName = FetchRequest.Predicate.Expression.function( + .init(name: "upperName", arguments: [.keyPath(PredicateKeyPath(rawValue: "name"))]) + ) + let request = FetchRequest( + entity: Person.entityName, + predicate: .comparison(.init(left: upperName, right: .attribute(.string("ALICE")), type: .equalTo)) + ) + let matches = try await storage.fetch(request) + XCTAssertEqual(matches.count, 1) + } + + @MainActor + func testViewContext() async throws { + let storage = Self.makeStorage() + let person = Person(name: "Alice", age: 30) + try await storage.insert(person) + let viewContext = try storage.viewContext + // repeated access reuses the lazily created context + _ = try storage.viewContext + // typed fetch by id + let fetched = try viewContext.fetch(Person.self, for: person.id) + XCTAssertEqual(fetched, person) + // typed fetch with predicate + let all: [Person] = try viewContext.fetch( + Person.self, + sortDescriptors: [.init(property: PropertyKey(Person.CodingKeys.name), ascending: true)], + predicate: Person.CodingKeys.name.compare(.equalTo, .attribute(.string("Alice"))) + ) + XCTAssertEqual(all, [person]) + // typed count + XCTAssertEqual(try viewContext.count(Person.self), 1) + // count with fetch request + XCTAssertEqual(try viewContext.count(FetchRequest(entity: Person.entityName)), 1) + // fetch missing + XCTAssertNil(try viewContext.fetch(Person.self, for: UUID())) + } + + func testNSPersistentContainerStorage() async throws { + let model = Model(entities: Person.self, Event.self, AllTypes.self) + let container = NSPersistentContainer( + name: "Test\(UUID())", + managedObjectModel: NSManagedObjectModel(model: model) + ) + container.persistentStoreDescriptions.forEach { $0.shouldAddStoreAsynchronously = false } + try container.syncLoadPersistentStores() + let people = [ + Person(name: "Alice", age: 30), + Person(name: "Bob", age: 25) + ] + try await container.insert(people.map { try! $0.encode() }) + let fetchRequest = FetchRequest(entity: Person.entityName) + let count = try await container.count(fetchRequest) + XCTAssertEqual(count, 2) + let ids = try await container.fetchID(fetchRequest) + XCTAssertEqual(Set(ids), Set(people.map { ObjectID($0.id) })) + try await container.register(function: DatabaseFunction(name: "identity", argumentCount: 1) { arguments in arguments[0] }) + try await container.delete(Person.entityName, for: ids) + let remaining = try await container.count(fetchRequest) + XCTAssertEqual(remaining, 0) + } +} + +#endif diff --git a/Tests/CoreModelTests/PredicateCodingTests.swift b/Tests/CoreModelTests/PredicateCodingTests.swift new file mode 100644 index 0000000..cda66f9 --- /dev/null +++ b/Tests/CoreModelTests/PredicateCodingTests.swift @@ -0,0 +1,259 @@ +// +// PredicateCodingTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 7/17/25. +// + +import Foundation +import XCTest +@testable import CoreModel + +final class PredicateCodingTests: XCTestCase { + + typealias Predicate = FetchRequest.Predicate + + enum Key: String, CodingKey { + case name + case age + } + + func roundTrip(_ predicate: Predicate, file: StaticString = #filePath, line: UInt = #line) { + do { + let data = try JSONEncoder().encode(predicate) + let decoded = try JSONDecoder().decode(Predicate.self, from: data) + XCTAssertEqual(decoded, predicate, file: file, line: line) + } catch { + XCTFail("Failed to round trip: \(error)", file: file, line: line) + } + } + + func testPredicateType() { + XCTAssertEqual(Predicate.value(true).type, .value) + XCTAssertEqual(Predicate.comparison(.init(left: .attribute(.null), right: .attribute(.null))).type, .comparison) + XCTAssertEqual(Predicate.compound(.and([])).type, .compound) + } + + func testExpressionType() { + XCTAssertEqual(Predicate.Expression.attribute(.null).type, .attribute) + XCTAssertEqual(Predicate.Expression.relationship(.null).type, .relationship) + XCTAssertEqual(Predicate.Expression.keyPath("name").type, .keyPath) + XCTAssertEqual(Predicate.Expression.function(.init(name: "f", arguments: [])).type, .function) + } + + func testCompoundAccessors() { + let comparison = Predicate.comparison(.init(left: .keyPath("name"), right: .attribute(.string("x")))) + XCTAssertEqual(Predicate.Compound.and([comparison]).type, .and) + XCTAssertEqual(Predicate.Compound.or([comparison]).type, .or) + XCTAssertEqual(Predicate.Compound.not(comparison).type, .not) + XCTAssertEqual(Predicate.Compound.and([comparison, comparison]).subpredicates.count, 2) + XCTAssertEqual(Predicate.Compound.or([comparison]).subpredicates.count, 1) + XCTAssertEqual(Predicate.Compound.not(comparison).subpredicates, [comparison]) + } + + func testPredicateCodable() { + let comparison = Predicate.comparison( + .init( + left: .keyPath("name"), + right: .attribute(.string("John")), + type: .beginsWith, + modifier: .any, + options: [.caseInsensitive, .diacriticInsensitive] + ) + ) + roundTrip(comparison) + roundTrip(.value(true)) + roundTrip(.value(false)) + roundTrip(.compound(.and([comparison, .value(true)]))) + roundTrip(.compound(.or([comparison, .value(false)]))) + roundTrip(.compound(.not(comparison))) + // nested compounds + roundTrip(.compound(.not(.compound(.and([comparison, .compound(.or([comparison]))]))))) + } + + func testExpressionCodable() throws { + let expressions: [Predicate.Expression] = [ + .attribute(.string("test")), + .attribute(.null), + .relationship(.toOne("id1")), + .relationship(.toMany(["id1", "id2"])), + .keyPath("events.name"), + .function(.init(name: "lowercase", arguments: [.keyPath("name"), .attribute(.int64(1))])) + ] + for expression in expressions { + let data = try JSONEncoder().encode(expression) + let decoded = try JSONDecoder().decode(Predicate.Expression.self, from: data) + XCTAssertEqual(decoded, expression) + } + } + + func testDescriptions() { + XCTAssertEqual(Predicate.value(true).description, "true") + let comparison = Predicate.Comparison( + left: .keyPath("name"), + right: .attribute(.string("John")), + type: .equalTo + ) + XCTAssertEqual(comparison.description, #"name == "John""#) + let modified = Predicate.Comparison( + left: .keyPath("name"), + right: .attribute(.string("j")), + type: .beginsWith, + modifier: .all, + options: [.caseInsensitive, .diacriticInsensitive] + ) + XCTAssertEqual(modified.description, #"ALL name BEGINSWITH[cd] "j""#) + XCTAssertEqual(Predicate.comparison(comparison).description, comparison.description) + // compound descriptions + let and = Predicate.compound(.and([.comparison(comparison), .value(true)])) + XCTAssertEqual(and.description, #"name == "John" AND true"#) + let notNested = Predicate.compound(.not(and)) + XCTAssert(notNested.description.contains("NOT (")) + XCTAssertEqual(Predicate.Compound.and([]).description, "(Empty and predicate)") + // function expression description + let function = Predicate.FunctionExpression(name: "f", arguments: [.keyPath("name"), .attribute(.int64(1))]) + XCTAssertEqual(function.description, "f(name, 1)") + XCTAssertEqual(Predicate.Expression.function(function).description, "f(name, 1)") + } + + func testAttributeValuePredicateDescriptions() { + let date = Date(timeIntervalSince1970: 0) + let uuid = UUID() + let url = URL(string: "https://example.com")! + XCTAssertEqual(Predicate.Expression.attribute(.null).description, "nil") + XCTAssertEqual(Predicate.Expression.attribute(.string("x")).description, "\"x\"") + XCTAssertEqual(Predicate.Expression.attribute(.bool(true)).description, "true") + XCTAssertEqual(Predicate.Expression.attribute(.int16(1)).description, "1") + XCTAssertEqual(Predicate.Expression.attribute(.int32(2)).description, "2") + XCTAssertEqual(Predicate.Expression.attribute(.int64(3)).description, "3") + XCTAssertEqual(Predicate.Expression.attribute(.float(1.5)).description, "1.5") + XCTAssertEqual(Predicate.Expression.attribute(.double(2.5)).description, "2.5") + XCTAssertEqual(Predicate.Expression.attribute(.date(date)).description, date.description) + XCTAssertEqual(Predicate.Expression.attribute(.uuid(uuid)).description, uuid.uuidString) + XCTAssertEqual(Predicate.Expression.attribute(.url(url)).description, url.description) + XCTAssertEqual(Predicate.Expression.attribute(.data(Data([0x01]))).description, Data([0x01]).description) + XCTAssertEqual(Predicate.Expression.attribute(.decimal(3)).description, "3") + // relationship values + XCTAssertEqual(Predicate.Expression.relationship(.null).description, "nil") + XCTAssertEqual(Predicate.Expression.relationship(.toOne("a")).description, "a") + XCTAssertEqual(Predicate.Expression.relationship(.toMany(["a", "b"])).description, "{a, b}") + } + + func testComparisonOperators() { + let name = Predicate.Expression.keyPath("name") + let value = Predicate.Expression.attribute(.string("x")) + func comparisonType(_ predicate: Predicate) -> Predicate.Comparison.Operator? { + guard case let .comparison(comparison) = predicate else { return nil } + return comparison.type + } + // expression op expression + XCTAssertEqual(comparisonType(name < value), .lessThan) + XCTAssertEqual(comparisonType(name <= value), .lessThanOrEqualTo) + XCTAssertEqual(comparisonType(name > value), .greaterThan) + XCTAssertEqual(comparisonType(name >= value), .greaterThanOrEqualTo) + XCTAssertEqual(comparisonType(name == value), .equalTo) + XCTAssertEqual(comparisonType(name != value), .notEqualTo) + // string op value + XCTAssertEqual(comparisonType("age" < 1), .lessThan) + XCTAssertEqual(comparisonType("age" <= 1), .lessThanOrEqualTo) + XCTAssertEqual(comparisonType("age" > 1), .greaterThan) + XCTAssertEqual(comparisonType("age" >= 1), .greaterThanOrEqualTo) + XCTAssertEqual(comparisonType("age" == 1), .equalTo) + XCTAssertEqual(comparisonType("age" != 1), .notEqualTo) + // coding key op value + XCTAssertEqual(comparisonType(Key.age < 1), .lessThan) + XCTAssertEqual(comparisonType(Key.age <= 1), .lessThanOrEqualTo) + XCTAssertEqual(comparisonType(Key.age > 1), .greaterThan) + XCTAssertEqual(comparisonType(Key.age >= 1), .greaterThanOrEqualTo) + XCTAssertEqual(comparisonType(Key.age == 1), .equalTo) + XCTAssertEqual(comparisonType(Key.age != 1), .notEqualTo) + } + + func testCompareExtensions() { + let rhs = Predicate.Expression.attribute(.string("x")) + // string + XCTAssertEqual("name".compare(.equalTo, rhs).type, .comparison) + XCTAssertEqual("name".compare(.like, [.caseInsensitive], rhs).type, .comparison) + XCTAssertEqual("name".compare(.any, .contains, [.diacriticInsensitive], rhs).type, .comparison) + // coding key + XCTAssertEqual(Key.name.compare(.equalTo, rhs).type, .comparison) + XCTAssertEqual(Key.name.compare(.matches, [.normalized], rhs).type, .comparison) + XCTAssertEqual(Key.name.compare(.all, .endsWith, [.localeSensitive], rhs).type, .comparison) + // expression + let lhs = Predicate.Expression.keyPath("name") + XCTAssertEqual(lhs.compare(.in, rhs).type, .comparison) + XCTAssertEqual(lhs.compare(.between, [.caseInsensitive], rhs).type, .comparison) + XCTAssertEqual(lhs.compare(.any, .beginsWith, [.caseInsensitive], rhs).type, .comparison) + } + + func testCompoundOperators() { + let a = Predicate.value(true) + let b = Predicate.value(false) + XCTAssertEqual(a && b, .compound(.and([a, b]))) + XCTAssertEqual(a && [b, a], .compound(.and([a, b, a]))) + XCTAssertEqual(a || b, .compound(.or([a, b]))) + XCTAssertEqual(a || [b, a], .compound(.or([a, b, a]))) + XCTAssertEqual(!a, .compound(.not(a))) + } + + func testKeyPath() { + var keyPath: PredicateKeyPath = [.property("events"), .property("name")] + XCTAssertEqual(keyPath.keys, [.property("events"), .property("name")]) + XCTAssertEqual(keyPath.rawValue, "events.name") + XCTAssertEqual(keyPath.description, "events.name") + // append / removal + keyPath.append(.index(0)) + XCTAssertEqual(keyPath.rawValue, "events.name.0") + XCTAssertEqual(keyPath.appending(.operator(.count)).rawValue, "events.name.0.@count") + keyPath.append(contentsOf: [.property("id")]) + XCTAssertEqual(keyPath.appending(contentsOf: [PredicateKeyPath.Key.property("x")]).keys.count, 5) + XCTAssertEqual(keyPath.removeFirst(), .property("events")) + XCTAssertEqual(keyPath.removingFirst().keys.first, .index(0)) + XCTAssertEqual(keyPath.removeLast(), .property("id")) + XCTAssertEqual(keyPath.removingLast().keys.count, keyPath.keys.count - 1) + // begins(with:) + let path: PredicateKeyPath = "events.name" + XCTAssert(path.begins(with: "events")) + XCTAssertFalse(path.begins(with: "people")) + // raw value parsing + let parsed = PredicateKeyPath(rawValue: "events.0.@count") + XCTAssertEqual(parsed.keys, [.property("events"), .index(0), .operator(.count)]) + // operators + for op in [PredicateKeyPath.Operator.count, .sum, .min, .max, .average] { + XCTAssertEqual(PredicateKeyPath.Key(rawValue: op.rawValue), .operator(op)) + XCTAssertEqual(op.description, op.rawValue) + } + XCTAssertEqual(PredicateKeyPath.Key.index(1).description, "1") + XCTAssertEqual(PredicateKeyPath.Key.property("a").description, "a") + } + + func testStringComparisonHelpers() { + let locale = Locale(identifier: "en_US") + XCTAssert("apple".compare("APPLE", [.caseInsensitive], nil, .orderedSame)) + XCTAssertFalse("apple".compare("banana", [], nil, .orderedSame)) + XCTAssert("apple".compare("banana", [.localeSensitive], locale, .orderedAscending)) + XCTAssertNotNil("hello world".range(of: "WORLD", [.caseInsensitive], nil)) + XCTAssertNil("hello".range(of: "xyz", [], locale)) + XCTAssert("hello123".matches("[a-z]+[0-9]+", [], nil)) + XCTAssertFalse("hello".matches("^[0-9]+$", [.caseInsensitive], locale)) + XCTAssert("hello world".begins(with: "HELLO", [.caseInsensitive], nil)) + XCTAssertFalse("hello world".begins(with: "world", [], locale)) + XCTAssert("hello world".ends(with: "WORLD", [.caseInsensitive], nil)) + XCTAssertFalse("hello world".ends(with: "hello", [], locale)) + XCTAssert("héllo".compare("hello", [.diacriticInsensitive], nil, .orderedSame)) + XCTAssert("hello"[...].begins(with: "he")) + XCTAssertFalse("hello"[...].begins(with: "lo")) + // CompareOptions conversion + XCTAssertEqual(String.CompareOptions(.caseInsensitive), .caseInsensitive) + XCTAssertEqual(String.CompareOptions(.diacriticInsensitive), .diacriticInsensitive) + XCTAssertNil(String.CompareOptions(.normalized)) + XCTAssertNil(String.CompareOptions(.localeSensitive)) + } + + func testCollectionHelpers() { + XCTAssert([1, 2, 3].begins(with: [1, 2])) + XCTAssertFalse([1, 2, 3].begins(with: [2])) + XCTAssert([1, 2, 3].contains([3, 1])) + XCTAssertFalse([1, 2].contains([1, 4])) + } +} diff --git a/Tests/CoreModelTests/StoreDefaultsTests.swift b/Tests/CoreModelTests/StoreDefaultsTests.swift new file mode 100644 index 0000000..cd956f8 --- /dev/null +++ b/Tests/CoreModelTests/StoreDefaultsTests.swift @@ -0,0 +1,66 @@ +// +// StoreDefaultsTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 7/17/25. +// + +import Foundation +import XCTest +@testable import CoreModel + +/// Minimal in-memory `ModelStorage` conformer that relies on the protocol's +/// default implementations of `count(_:)` and `insert(_:)` for arrays. +private final class MinimalStore: ModelStorage, @unchecked Sendable { + + private var objects = [EntityName: [ObjectID: ModelData]]() + + func fetch(_ entity: EntityName, for id: ObjectID) async throws -> ModelData? { + objects[entity]?[id] + } + + func fetch(_ fetchRequest: FetchRequest) async throws -> [ModelData] { + (objects[fetchRequest.entity] ?? [:]) + .values + .sorted { $0.id.rawValue < $1.id.rawValue } + } + + func fetchID(_ fetchRequest: FetchRequest) async throws -> [ObjectID] { + try await fetch(fetchRequest).map { $0.id } + } + + func insert(_ value: ModelData) async throws { + objects[value.entity, default: [:]][value.id] = value + } + + func delete(_ entity: EntityName, for id: ObjectID) async throws { + objects[entity]?[id] = nil + } + + func delete(_ entity: EntityName, for ids: [ObjectID]) async throws { + for id in ids { + try await delete(entity, for: id) + } + } + + func register(function: DatabaseFunction) async throws { } +} + +final class StoreDefaultsTests: XCTestCase { + + func testDefaultImplementations() async throws { + let store = MinimalStore() + let people = [ + Person(name: "Alice", age: 30), + Person(name: "Bob", age: 25) + ] + // default insert(_:) for arrays inserts one at a time + try await store.insert(people.map { try! $0.encode() }) + // default count(_:) falls back to fetching and counting + let count = try await store.count(FetchRequest(entity: Person.entityName)) + XCTAssertEqual(count, 2) + // typed convenience still works through the defaults + let fetched = try await store.fetch(Person.self, for: people[0].id) + XCTAssertEqual(fetched, people[0]) + } +}