Skip to content

coenttb/swift-records

Swift Records

CI Development Status

A high-level, type-safe database abstraction layer for PostgreSQL in Swift, built on StructuredQueries and PostgresNIO, inspired by GRDB.

Features

  • Connection Pooling: Automatic connection lifecycle management with configurable pool sizes
  • Transactions: Full transaction support with isolation levels and savepoints
  • Migrations: Version-tracked schema migrations with automatic execution
  • Full-Text Search: Type-safe PostgreSQL full-text search with highlighting and ranking
  • Testing Utilities: Schema isolation for parallel test execution
  • Type Safety: Leverages Swift's type system and StructuredQueries for compile-time guarantees
  • Actor-Based Concurrency: Safe multi-threaded database access with Swift 6.0 concurrency
  • Dependency Injection: Seamless integration with Point-Free's Dependencies library

Installation

Add to your Package.swift:

dependencies: [
    .package(url: "https://github.com/coenttb/swift-records", exact: "0.0.1")
]

targets: [
    .target(
        name: "YourTarget",
        dependencies: [
            .product(name: "Records", package: "swift-records")
        ]
    ),
    .testTarget(
        name: "YourTargetTests",
        dependencies: [
            .product(name: "RecordsTestSupport", package: "swift-records")
        ]
    )
]

Requirements:

  • Swift 6.0+
  • PostgreSQL 12+
  • macOS 10.15+ / iOS 13+ / tvOS 13+ / watchOS 6+

Versioning

Current Version: 0.0.1 (Experimental)

⚠️ Important: This is experimental software. Breaking changes may occur in any version update until we reach 1.0.0. We strongly recommend pinning to exact versions in your Package.swift:

.package(url: "https://github.com/coenttb/swift-records", exact: "0.0.1")

Version History

  • 0.0.1 (2024): Initial experimental release
    • Complete database abstraction layer for PostgreSQL
    • Actor-based architecture for safe concurrent access
    • Built on swift-structured-queries-postgres and PostgresNIO

Roadmap to 1.0.0

We will continue with 0.x.x versions while the API evolves:

  • 0.0.x - Bug fixes and critical issues
  • 0.x.0 - New features and potential breaking changes
  • 1.0.0 - API stability achieved, production-ready

Quick Start

Basic Setup

import Records

// Define your model using @Table macro
@Table("users")
struct User {
    let id: Int
    let name: String
    let email: String
    let createdAt: Date
}

// Configure database at app startup
import Dependencies

@main
struct MyApp {
    static func main() async throws {
        
        let database = try await Database.Pool(
            configuration: .init(
                host: "localhost",
                port: 5432,
                database: "myapp",
                username: "postgres",
                password: "password"
            ),
            minConnections: 5,
            maxConnections: 20
        )
        
        try await prepareDependencies {
            $0.defaultDatabase = database
        }
        
        // Or use environment variables
        let database = try await Database.Pool(
            configuration: .fromEnvironment(),
            minConnections: 5,
            maxConnections: 20
        )
        try await prepareDependencies {
            $0.defaultDatabase = database
        }
        
        // Your app code here...
    }
}

Query Operations

import Dependencies

// Access database via dependency injection
struct UserService {
    @Dependency(\.defaultDatabase) var db
    
    // Fetch all users
    func fetchUsers() async throws -> [User] {
        try await db.read { db in
            try await User.fetchAll(db)
        }
    }
    
    // Fetch with conditions
    func fetchActiveUsers() async throws -> [User] {
        try await db.read { db in
            try await User
                .filter { $0.isActive }
                .order(by: .descending(\.createdAt))
                .limit(10)
                .fetchAll(db)
        }
    }
    
    // Insert new user
    func createUser(name: String, email: String) async throws {
        try await db.write { db in
            try await User.insert {
                User.Draft(
                    name: name,
                    email: email,
                    createdAt: Date()
                )
            }.execute(db)
        }
    }
    
    // Update user
    func updateUserName(email: String, newName: String) async throws {
        try await db.write { db in
            try await User
                .filter { $0.email == email }
                .update { $0.name = newName }
                .execute(db)
        }
    }
    
    // Delete old users
    func deleteOldUsers(olderThan date: Date) async throws {
        try await db.write { db in
            try await User
                .filter { $0.createdAt < date }
                .delete()
                .execute(db)
        }
    }
}

Transactions

struct TransferService {
    @Dependency(\.defaultDatabase) var db
    
    // Basic transaction
    func createUserWithProfile(name: String, email: String) async throws {
        try await db.withTransaction { db in
            let userId = try await User.insert {
                User.Draft(
                    name: name,
                    email: email,
                    createdAt: Date()
                )
            }
            .returning(\.id)
            .fetchOne(db)
            
            try await Profile.insert {
                Profile.Draft(
                    userId: userId!,
                    bio: "New user"
                )
            }.execute(db)
            // Both succeed or both are rolled back
        }
    }
    
    // Transaction with isolation level
    func transferFunds(from: Int, to: Int, amount: Decimal) async throws {
        try await db.withTransaction(isolation: .serializable) { db in
            // Your transactional operations
        }
    }
}

// Savepoints for nested transactions
try await db.withTransaction { db in
    try await User.insert { ... }.execute(db)
    
    do {
        try await db.withSavepoint("risky_operation") { db in
            try await riskyOperation(db)
        }
    } catch {
        // Only the savepoint is rolled back
        print("Risky operation failed: \\(error)")
    }
    
    try await Post.insert { ... }.execute(db)
}

Migrations

Swift Records uses a forward-only migration system - migrations can only be applied, not rolled back. This design choice prioritizes simplicity and safety over reversibility.

import Records

// Define your migrations
var migrator = Database.Migrator()

// Register migrations in order
migrator.registerMigration("create_users") { db in
    try await db.execute("""
        CREATE TABLE users (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            email TEXT UNIQUE NOT NULL,
            name TEXT NOT NULL,
            created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
        )
    """)
}

migrator.registerMigration("add_user_status") { db in
    try await db.execute("""
        ALTER TABLE users 
        ADD COLUMN status TEXT NOT NULL DEFAULT 'active'
    """)
}

migrator.registerMigration("create_posts") { db in
    try await db.execute("""
        CREATE TABLE posts (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            title TEXT NOT NULL,
            content TEXT,
            published_at TIMESTAMPTZ
        )
    """)
    
    try await db.execute("""
        CREATE INDEX idx_posts_user_id ON posts(user_id)
    """)
}

// Apply migrations at startup
@main
struct MyApp {
    static func main() async throws {
        let db = try await Database.Pool(
            configuration: .fromEnvironment(),
            minConnections: 5,
            maxConnections: 20
        )
        
        // Run pending migrations
        try await db.write { db in
            try await migrator.migrate(db)
        }
        
        prepareDependencies {
            $0.defaultDatabase = db
        }
        
        // Your app code...
    }
}

Why Forward-Only?

Swift Records deliberately omits rollback functionality for migrations:

  1. Production Safety: Rollbacks risk data loss and are rarely safe in production
  2. Simplicity: Single migration path reduces complexity and potential for errors
  3. Modern Practice: Aligns with immutable infrastructure and forward-fix strategies
  4. Real-World Usage: Teams typically fix issues with new migrations, not rollbacks

Why Pure SQL?

Migrations use raw SQL strings rather than Swift model references because migrations must remain immutable historical records. Using type-safe references (like User.table or field names) would break when models evolve - if you rename User to Account or change field names, old migrations would fail. Pure SQL ensures migrations can always recreate the exact database schema progression, regardless of how your Swift code changes.

For development iteration, use eraseDatabaseOnSchemaChange:

// Development configuration
try await migrator.migrate(
    db,
    options: .init(eraseDatabaseOnSchemaChange: true)
)

This approach keeps production migrations safe and predictable while providing flexibility during development.

Testing

The RecordsTestSupport module provides utilities for testing with automatic schema isolation:

import Testing
import Records
import RecordsTestSupport
import Dependencies

@Suite("User Tests", .dependency(\.defaultDatabase, Database.TestDatabase.withSchema()))
struct UserTests {
    @Dependency(\.defaultDatabase) var db
    
    @Test func createUser() async throws {
        // Each test runs in its own schema, enabling parallel execution
        try await db.withRollback { db in
            let user = try await User.insert {
                User.Draft(
                    name: "Test User",
                    email: "test@example.com"
                )
            }
            .returning(\\.self)
            .execute(db)
            
            #expect(user.first?.name == "Test User")
        }
    }
}

Test Database Configuration

Tests use environment variables for database configuration:

export DATABASE_HOST=localhost
export DATABASE_PORT=5432
export DATABASE_NAME=test_db
export DATABASE_USER=postgres
export DATABASE_PASSWORD=password

Or create a .env file in your test directory:

DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=test_db
DATABASE_USER=postgres
DATABASE_PASSWORD=password

Architecture

Swift Records provides a layered architecture:

  1. Database Layer: Top-level coordinator with Reader and Writer actors
  2. Connection Management: Automatic pooling with configurable min/max connections
  3. Query Execution: Type-safe query building via StructuredQueries
  4. PostgreSQL Bridge: Low-level utilities from swift-structured-queries-postgres

Connection Pooling

The connection pool automatically manages connection lifecycle:

  • Maintains minimum connections for quick response
  • Scales up to maximum under load
  • Validates connections before reuse
  • Handles connection failures gracefully

Concurrency Safety

Using Swift 6.0's actor model ensures thread-safe database access:

  • Database.Reader: Read-only operations (can use multiple connections)
  • Database.Writer: Write operations (ensures serialization when needed)

Connection Lifecycle Management

Properly manage database connections in your application lifecycle:

// For Vapor applications
import Vapor
import Records

struct DatabaseLifecycleHandler: LifecycleHandler {
    let database: any Database.Reader
    
    func shutdown(_ app: Application) {
        app.eventLoopGroup.next().execute {
            Task {
                try? await database.close()
            }
        }
    }
}

// In your configure function
func configure(_ app: Application) async throws {
    let db = try await Database.Pool(
        configuration: .fromEnvironment(),
        minConnections: 5,
        maxConnections: 20
    )
    
    prepareDependencies {
        $0.defaultDatabase = db
    }
    
    app.lifecycle.use(DatabaseLifecycleHandler(database: db))
}

Error Recovery Strategies

// Retry logic for transient failures
func withRetry<T>(
    maxAttempts: Int = 3,
    operation: () async throws -> T
) async throws -> T {
    var lastError: Error?
    
    for attempt in 1...maxAttempts {
        do {
            return try await operation()
        } catch Database.Error.connectionTimeout {
            lastError = error
            if attempt < maxAttempts {
                // Exponential backoff
                try await Task.sleep(nanoseconds: UInt64(attempt * 1_000_000_000))
            }
        } catch {
            throw error
        }
    }
    
    throw lastError!
}

// Usage
let users = try await withRetry {
    try await db.read { db in
        try await User.fetchAll(db)
    }
}

Advanced Usage

Custom Query Types

@Selection
struct UserWithPosts {
    let userId: Int
    let userName: String
    let postCount: Int
}

let results = try await db.reader.read { db in
    try await User
        .join(Post.all) { $0.id.eq($1.userId) }
        .group(by: { user, _ in user.id })
        .select { user, post in
            UserWithPosts.Columns(
                userId: user.id,
                userName: user.name,
                postCount: post.id.count()
            )
        }
        .fetchAll(db)
}

Raw SQL

When needed, you can execute raw SQL:

struct MaintenanceService {
    @Dependency(\.defaultDatabase) var db
    
    func createEmailIndex() async throws {
        try await db.write { db in
            try await db.execute("""
                CREATE INDEX CONCURRENTLY idx_users_email 
                ON users(email)
            """)
        }
    }
    
    func vacuumDatabase() async throws {
        try await db.write { db in
            try await db.execute("VACUUM ANALYZE")
        }
    }
}

Full-Text Search

Swift Records provides first-class support for PostgreSQL's powerful full-text search capabilities through an elegant type-safe DSL. Built on top of PostgreSQL's tsvector and tsquery types, you can add sophisticated search functionality to your application with just a few lines of code.

📐 Architecture Note: PostgreSQL full-text search uses dedicated tsvector columns within regular tables, unlike SQLite's virtual table approach. This necessitates the searchVectorColumn protocol requirement to specify which column to search.

Default behavior: Most tables can use the default "search_vector" column name without any configuration—just conform to FullTextSearchable and you're done. See the Full-Text Search Guide for architectural details.

Quick Start

import Records
import StructuredQueriesPostgres

// 1. Make your model searchable
@Table
struct Article: FullTextSearchable {
    let id: Int
    var title: String
    var body: String
    var author: String

    // Specify the tsvector column name (defaults to "search_vector")
    static var searchVectorColumn: String { "search_vector" }
}

// 2. Set up full-text search in a migration
migrator.registerMigration("add_articles_fts") { db in
    // Add tsvector column
    try await db.execute("""
        ALTER TABLE articles
        ADD COLUMN search_vector tsvector
    """)

    // Create GIN index for fast searches
    try await db.execute("""
        CREATE INDEX articles_search_idx
        ON articles
        USING GIN (search_vector)
    """)

    // Create trigger to automatically update search vector
    try await db.execute("""
        CREATE OR REPLACE FUNCTION articles_search_trigger() RETURNS trigger AS $$
        BEGIN
          NEW.search_vector :=
            setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
            setweight(to_tsvector('english', coalesce(NEW.body, '')), 'B') ||
            setweight(to_tsvector('english', coalesce(NEW.author, '')), 'C');
          RETURN NEW;
        END
        $$ LANGUAGE plpgsql
    """)

    try await db.execute("""
        CREATE TRIGGER articles_search_update
        BEFORE INSERT OR UPDATE ON articles
        FOR EACH ROW EXECUTE FUNCTION articles_search_trigger()
    """)

    // Backfill existing data
    try await db.execute("""
        UPDATE articles SET search_vector =
          setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
          setweight(to_tsvector('english', coalesce(body, '')), 'B') ||
          setweight(to_tsvector('english', coalesce(author, '')), 'C')
    """)
}

// 3. Search your content
struct SearchService {
    @Dependency(\.defaultDatabase) var db

    func searchArticles(query: String) async throws -> [Article] {
        try await db.read { db in
            try await Article
                .where { $0.match(query) }
                .order { $0.rank(query) }
                .fetchAll(db)
        }
    }
}

📚 For comprehensive documentation, see the Full-Text Search Guide including:

Search Methods

Swift Records provides multiple search methods for different use cases:

Basic Search (match)

Uses PostgreSQL's to_tsquery() for powerful boolean searches:

// Single term
Article.where { $0.match("Swift") }

// Boolean AND - both terms must match
Article.where { $0.match("Swift & PostgreSQL") }

// Boolean OR - either term can match
Article.where { $0.match("Swift | Rust") }

// Negation - must NOT contain term
Article.where { $0.match("Swift & !Objective-C") }

// Phrase search with adjacency
Article.where { $0.match("quick <-> brown") }  // Words must be adjacent

Plain Text Search (plainMatch)

Safe for user input - treats all words as AND-connected terms:

// User enters: "swift postgresql database"
// Automatically becomes: swift & postgresql & database
Article.where { $0.plainMatch(userInput) }

Web Search Syntax (webMatch)

Google-like search syntax for end users:

// Quoted phrases
Article.where { $0.webMatch(#""swift postgresql" database"#) }

// Exclusions with minus
Article.where { $0.webMatch("swift -objective-c") }

// OR operator
Article.where { $0.webMatch("Swift OR Rust") }

Phrase Search (phraseMatch)

Exact phrase matching where words must appear in order:

// Finds "San Francisco" but not "Francisco's San Diego trip"
Article.where { $0.phraseMatch("San Francisco") }

Ranking Results

Order search results by relevance:

// Basic relevance ranking
Article
    .where { $0.match("Swift") }
    .order { $0.rank("Swift") }
    .fetchAll(db)

// Weighted ranking - prioritize title matches over body
Article
    .where { $0.match("Swift") }
    .order {
        $0.rank(
            "Swift",
            weights: [0.1, 0.2, 0.4, 1.0]  // [D, C, B, A]
        )
    }
    .fetchAll(db)

// Coverage-based ranking (better for phrase searches)
Article
    .where { $0.match("database indexing") }
    .order { $0.rankCoverage("database indexing") }
    .fetchAll(db)

Weight Labels:

  • A - Highest importance (typically titles)
  • B - High importance (typically subtitles, emphasized text)
  • C - Medium importance (typically metadata, tags)
  • D - Lowest importance (typically body text)

Highlighting Search Results

Show users exactly where matches appear:

// Highlight matches in search results
let results = try await db.read { db in
    try await Article
        .where { $0.match("Swift") }
        .select {
            (
                $0.title,
                $0.body.tsHeadline(
                    "Swift",
                    startSel: "<mark>",
                    stopSel: "</mark>",
                    maxWords: 50
                )
            )
        }
        .fetchAll(db)
}

// Returns: ("Swift Concurrency Guide", "Modern async/await patterns in <mark>Swift</mark> programming...")

Column-Specific Search

Search within specific columns:

// Ad-hoc search without pre-computed tsvector
Article.where { $0.title.matchText("Swift") }

// Only searches the title column

Search Configuration

PostgreSQL supports multiple languages for stemming and stop words:

// English (default)
Article.where { $0.match("running", language: "english") }
// Matches: run, runs, running, ran

// Simple (no stemming)
Article.where { $0.match("running", language: "simple") }
// Matches: only "running" exactly

// Other languages
Article.where { $0.match("courir", language: "french") }
Article.where { $0.match("laufen", language: "german") }

Multi-Column Weighting

Weight different columns differently in your search vector:

// Title has highest weight (A), body medium (B), tags low (C)
CREATE TRIGGER product_search_update
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION products_search_trigger()

CREATE OR REPLACE FUNCTION products_search_trigger() RETURNS trigger AS $$
BEGIN
  NEW.search_vector :=
    setweight(to_tsvector('english', coalesce(NEW.name, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(NEW.description, '')), 'B') ||
    setweight(to_tsvector('english', coalesce(NEW.tags, '')), 'C');
  RETURN NEW;
END
$$ LANGUAGE plpgsql

Performance Considerations

  1. Always use GIN indexes for tsvector columns:

    CREATE INDEX articles_search_idx ON articles USING GIN (search_vector);
  2. Update search vectors automatically with triggers to keep them in sync

  3. Use appropriate search method:

    • match() - Most powerful but requires valid tsquery syntax
    • plainMatch() - Safest for user input
    • webMatch() - Best UX for end users
  4. Consider normalization for ranking:

    Article.order { $0.rank("query", normalization: 1) }
    // 1 = divide by (1 + log(length)) - favors longer documents less

Complete Search Example

struct ArticleSearchService {
    @Dependency(\.defaultDatabase) var db

    struct SearchResult {
        let article: Article
        let headline: String
        let rank: Double
    }

    func search(query: String, limit: Int = 20) async throws -> [SearchResult] {
        // Sanitize user input with plainMatch for safety
        let results = try await db.read { db in
            try await Article
                .where { $0.plainMatch(query) }
                .select {
                    (
                        $0,  // Full article
                        $0.body.tsHeadline(
                            query,
                            startSel: "<mark>",
                            stopSel: "</mark>",
                            maxWords: 50
                        ),
                        $0.rank(query, weights: [0.1, 0.2, 0.4, 1.0])
                    )
                }
                .order { $0.rank(query, weights: [0.1, 0.2, 0.4, 1.0]) }
                .limit(limit)
                .fetchAll(db)
        }

        return results.map { article, headline, rank in
            SearchResult(article: article, headline: headline, rank: rank)
        }
    }
}

PostgreSQL Full-Text Search Resources

Development Documentation

For contributors and those interested in the package's development history:

  • Development History - Journey from initial implementation to 94 passing tests

    • Phase 1: Test cleanup and package boundary establishment
    • Phase 2: Reminder schema implementation (upstream alignment)
    • Phase 3: Package deduplication (removing ~750 lines of duplicate code)
    • Phase 4: PostgreSQL-specific test fixes (sequences, DATE types)
  • Testing Architecture - Comprehensive testing patterns and solutions

    • Upstream patterns analysis (sqlite-data, swift-structured-queries)
    • PostgreSQL vs SQLite differences
    • Evolution of testing approaches
    • Final solution: Direct database creation for parallel test execution
    • Best practices and troubleshooting

Related Packages

Dependencies

Used By

Third-Party Dependencies

Dependencies

This package builds on excellent work from:

License

This project is licensed under the Apache 2.0 License - see the LICENSE file for details.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Acknowledgments

  • Point-Free for StructuredQueries and Dependencies
  • The Vapor team for PostgresNIO
  • GRDB for API design inspiration