A high-level, type-safe database abstraction layer for PostgreSQL in Swift, built on StructuredQueries and PostgresNIO, inspired by GRDB.
- 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
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+
.package(url: "https://github.com/coenttb/swift-records", exact: "0.0.1")- 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
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
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...
}
}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)
}
}
}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)
}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...
}
}Swift Records deliberately omits rollback functionality for migrations:
- Production Safety: Rollbacks risk data loss and are rarely safe in production
- Simplicity: Single migration path reduces complexity and potential for errors
- Modern Practice: Aligns with immutable infrastructure and forward-fix strategies
- Real-World Usage: Teams typically fix issues with new migrations, not rollbacks
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.
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")
}
}
}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=passwordOr create a .env file in your test directory:
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=test_db
DATABASE_USER=postgres
DATABASE_PASSWORD=passwordSwift Records provides a layered architecture:
- Database Layer: Top-level coordinator with
ReaderandWriteractors - Connection Management: Automatic pooling with configurable min/max connections
- Query Execution: Type-safe query building via StructuredQueries
- PostgreSQL Bridge: Low-level utilities from swift-structured-queries-postgres
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
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)
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))
}// 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)
}
}@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)
}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")
}
}
}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
tsvectorcolumns within regular tables, unlike SQLite's virtual table approach. This necessitates thesearchVectorColumnprotocol requirement to specify which column to search.Default behavior: Most tables can use the default
"search_vector"column name without any configuration—just conform toFullTextSearchableand you're done. See the Full-Text Search Guide for architectural details.
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:
Swift Records provides multiple search methods for different use cases:
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 adjacentSafe 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) }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") }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") }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)
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...")Search within specific columns:
// Ad-hoc search without pre-computed tsvector
Article.where { $0.title.matchText("Swift") }
// Only searches the title columnPostgreSQL 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") }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-
Always use GIN indexes for tsvector columns:
CREATE INDEX articles_search_idx ON articles USING GIN (search_vector);
-
Update search vectors automatically with triggers to keep them in sync
-
Use appropriate search method:
match()- Most powerful but requires valid tsquery syntaxplainMatch()- Safest for user inputwebMatch()- Best UX for end users
-
Consider normalization for ranking:
Article.order { $0.rank("query", normalization: 1) } // 1 = divide by (1 + log(length)) - favors longer documents less
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)
}
}
}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
- swift-environment-variables: A Swift package for type-safe environment variable management.
- coenttb-newsletter: A Swift package for newsletter subscription and email management.
- swift-identities: The Swift library for identity authentication and management.
- pointfreeco/swift-dependencies: A dependency management library for controlling dependencies in Swift.
- pointfreeco/swift-snapshot-testing: Delightful snapshot testing for Swift.
- pointfreeco/xctest-dynamic-overlay: Define XCTest assertion helpers directly in production code.
- vapor/postgres-nio: Non-blocking, event-driven Swift client for PostgreSQL.
This package builds on excellent work from:
- StructuredQueries - Type-safe SQL generation
- PostgresNIO - PostgreSQL driver
- swift-dependencies - Dependency injection
This project is licensed under the Apache 2.0 License - see the LICENSE file for details.
Contributions are welcome! Please feel free to submit a Pull Request.
- Point-Free for StructuredQueries and Dependencies
- The Vapor team for PostgresNIO
- GRDB for API design inspiration