A Swift ORM for PostgreSQL, inspired by Elixir's Ecto. Property-wrapper schemas, a composable query builder, actor-based concurrency, and a CLI for migrations.
- Features
- Requirements
- Installation
- Quick Start
- Schema Definition
- CRUD Operations
- Query Builder
- Relationships
- Transactions
- Aggregates and GROUP BY
- Field Selection
- CLI Reference
- Architecture
- Configuration
- Development
- Testing
- Troubleshooting
- License
- Property-wrapper schema definitions --
@ID,@Column,@Timestamp,@ForeignKey,@HasMany,@HasOne,@BelongsTo,@ManyToMany @Schemamacro -- generatesSchemaBuilderconformance at compile time (zero boilerplate)- Generic primary keys --
@ID<UUID>,@ID<Int>,@ID<String>via thePrimaryKeyTypeprotocol - Immutable query builder --
Query<T>is a value type; every.where(),.join(),.orderBy()returns a new query - Type-safe aggregates --
.sum(),.avg(),.min(),.max(),.count()withGROUP BYandHAVINGsupport - Upsert and bulk insert --
ON CONFLICTupserts and multi-row inserts with automatic batching - Relationship preloading -- batch-loads
HasMany,HasOne,BelongsTo, andManyToManyrelationships to prevent N+1 queries - Transaction support --
READ COMMITTEDisolation with automatic rollback; full CRUD and query builder available inside transactions viaQueryExecutor - Actor-based connection pooling -- built on SwiftNIO and PostgresKit
- Plain SQL migrations -- timestamped
.sqlfiles with-- migrate:up/-- migrate:downmarkers - CLI tool --
spectrobinary for database creation, migrations, and status - Swift 6 strict concurrency -- full
Sendablecompliance across all types
- Swift 6.0+ (managed via
mise.toml) - macOS 13+
- PostgreSQL
Add to your Package.swift:
.package(url: "https://github.com/Maartz/Spectro.git", from: "1.0.0")Then add "SpectroKit" to your target's dependencies:
.target(
name: "MyApp",
dependencies: [
.product(name: "SpectroKit", package: "Spectro"),
]
)The spectro CLI is distributed via Mint:
mint install Maartz/SpectroPin a version in your Mintfile:
Maartz/Spectro@1.0.0
import Spectro
@Schema("users")
struct User {
@ID var id: UUID
@Column var name: String
@Column var email: String
@Timestamp var createdAt: Date
}The @Schema macro generates Schema and SchemaBuilder conformance at compile time -- no manual init() or build(from:) required.
let spectro = try Spectro(
hostname: "localhost",
username: "postgres",
password: "postgres",
database: "myapp_dev"
)
// Insert
let user = try await spectro.insert(User())
// Query
let repo = spectro.repository()
let activeUsers = try await repo.query(User.self)
.where { $0.name == "John" }
.orderBy({ $0.createdAt }, .desc)
.limit(10)
.all()
// Get by ID
let found = try await spectro.get(User.self, id: someUUID)
// Update
let updated = try await spectro.update(User.self, id: someUUID, changes: ["name": "Jane"])
// Delete
try await spectro.delete(User.self, id: someUUID)
// Shutdown (always call before releasing)
await spectro.shutdown()The @Schema("table_name") macro generates everything needed to map a struct to a database table:
static let tableNamefrom the string argument- A default
init()with type-appropriate defaults - A convenience
init(column params...)for@Columnand@ForeignKeyproperties SchemaBuilder.build(from:)for row mapping
@Schema("users")
struct User {
@ID var id: UUID
@Column var name: String
@Column var email: String
@Column var bio: String?
@Timestamp var createdAt: Date
}| Wrapper | Purpose | Example |
|---|---|---|
@ID<T> |
Primary key (UUID, Int, or String) | @ID var id: UUID |
@Column<T> |
Regular column, optional name override | @Column("display_name") var name: String |
@Timestamp |
Date column | @Timestamp var createdAt: Date |
@ForeignKey<T> |
Foreign key reference, optional name override | @ForeignKey var userId: UUID |
@HasMany<T> |
One-to-many relationship, optional FK binding | @HasMany(foreignKey: "authorId") var posts: [Post] |
@HasOne<T> |
One-to-one relationship | @HasOne var profile: Profile? |
@BelongsTo<T> |
Inverse of HasMany/HasOne | @BelongsTo var user: User? |
@ManyToMany<T> |
Many-to-many via junction table | @ManyToMany(junctionTable: "user_tags") var tags: [Tag] |
Primary keys are not limited to UUID. Any type conforming to PrimaryKeyType can be used. Built-in conformances: UUID, Int, String.
@Schema("articles")
struct Article {
@ID var id: Int // SERIAL primary key
@Column var title: String
}
@Schema("slugs")
struct Slug {
@ID var id: String // TEXT primary key
@Column var target: String
}Foreign keys match the primary key type of the referenced table:
@Schema("comments")
struct Comment {
@ID var id: UUID
@Column var body: String
@ForeignKey var articleId: Int // references Article's Int PK
}By default, Swift property names are converted to snake_case for column names. Override with a string argument:
@Schema("users")
struct User {
@ID var id: UUID
@Column("display_name") var name: String // maps to "display_name" column
@ForeignKey("team_ref_id") var teamId: UUID // maps to "team_ref_id" column
}If you prefer not to use the macro, implement Schema and SchemaBuilder manually:
struct User: Schema, SchemaBuilder {
static let tableName = "users"
@ID var id: UUID
@Column var name: String
@Column var email: String
@Timestamp var createdAt: Date
init() {
self.id = UUID()
self.name = ""
self.email = ""
self.createdAt = Date()
}
static func build(from values: [String: Any]) -> User {
var user = User()
if let v = values["id"] as? UUID { user.id = v }
if let v = values["name"] as? String { user.name = v }
if let v = values["email"] as? String { user.email = v }
if let v = values["createdAt"] as? Date { user.createdAt = v }
return user
}
}All CRUD is available on both the Spectro facade and the GenericDatabaseRepo actor (via spectro.repository()).
let repo = spectro.repository()
// Insert a single record
let user = try await repo.insert(User(name: "Alice", email: "alice@example.com"))
// Insert with explicit primary key (e.g. for seeding)
let admin = try await repo.insert(
User(id: knownUUID, name: "Admin", email: "admin@example.com"),
includePrimaryKey: true
)
// Get by primary key
let found = try await repo.get(User.self, id: someUUID)
// Get or throw SpectroError.notFound
let mustExist = try await repo.getOrFail(User.self, id: someUUID)
// Fetch all
let everyone = try await repo.all(User.self)
// Update by ID
let updated = try await repo.update(User.self, id: someUUID, changes: [
"name": "Bob",
"email": "bob@example.com"
])
// Delete by ID
try await repo.delete(User.self, id: someUUID)Insert or update on conflict using ConflictTarget:
// Upsert on column conflict -- updates all non-PK columns
let user = try await repo.upsert(
User(name: "Alice", email: "alice@example.com"),
conflictTarget: .columns(["email"])
)
// Upsert with specific columns to update
let user = try await repo.upsert(
User(name: "Alice", email: "alice@example.com"),
conflictTarget: .columns(["email"]),
set: ["name"] // only update name on conflict
)
// Upsert on named constraint
let user = try await repo.upsert(
User(name: "Alice", email: "alice@example.com"),
conflictTarget: .constraint("users_email_unique")
)Insert multiple records in a single query. Automatically batches at 1000 rows to stay under PostgreSQL's parameter limit:
let users = [
User(name: "Alice", email: "alice@example.com"),
User(name: "Bob", email: "bob@example.com"),
User(name: "Carol", email: "carol@example.com"),
]
let inserted = try await repo.insertAll(users)Query<T> is an immutable value type. Every method returns a new query, so you can safely branch and reuse intermediate queries.
let repo = spectro.repository()
let base = repo.query(User.self)
.where { $0.isActive == true }
// Branch 1: recent users
let recent = try await base
.orderBy({ $0.createdAt }, .desc)
.limit(10)
.all()
// Branch 2: count
let total = try await base.count()// Equality
.where { $0.name == "John" }
.where { $0.status != "banned" }
// Comparison
.where { $0.age >= 18 }
.where { $0.score < 100 }
// String patterns (case-sensitive)
.where { $0.name.like("J%") }
.where { $0.name.contains("ohn") } // LIKE '%ohn%'
.where { $0.email.endsWith("@gmail.com") } // LIKE '%@gmail.com'
.where { $0.name.startsWith("J") } // LIKE 'J%'
// String patterns (case-insensitive)
.where { $0.name.ilike("%john%") }
.where { $0.name.iContains("john") } // ILIKE '%john%'
.where { $0.name.iStartsWith("j") } // ILIKE 'j%'
.where { $0.name.iEndsWith("son") } // ILIKE '%son'
// Collection
.where { $0.status.in(["active", "pending"]) }
.where { $0.role.notIn(["banned", "suspended"]) }
.where { $0.age.between(18, and: 65) }
// Null checks
.where { $0.deletedAt.isNull() }
.where { $0.email.isNotNull() }
// Date
.where { $0.createdAt.isToday() }
.where { $0.createdAt.isThisWeek() }
.where { $0.createdAt.isThisMonth() }
.where { $0.createdAt.isThisYear() }
.where { $0.createdAt.before(cutoffDate) }
.where { $0.createdAt.after(startDate) }
// Logical operators
.where { $0.role == "admin" || $0.role == "moderator" }
.where { ($0.age >= 18) && ($0.isActive == true) }
.where { !($0.status == "banned") }// Single field (ascending by default)
.orderBy { $0.createdAt }
// Explicit direction
.orderBy({ $0.createdAt }, .desc)
// Multiple fields
.orderBy({ $0.name }, .asc, then: { $0.createdAt }, .desc).limit(20)
.offset(40)// Inner join
let results = try await repo.query(User.self)
.join(Post.self, on: { $0.left.id == $0.right.userId })
.where { $0.name == "John" }
.all()
// Left join
let results = try await repo.query(User.self)
.leftJoin(Post.self, on: { $0.left.id == $0.right.userId })
.all()
// Right join
let results = try await repo.query(User.self)
.rightJoin(Post.self, on: { $0.left.id == $0.right.userId })
.all()
// Through join (many-to-many via junction table)
let results = try await repo.query(User.self)
.joinThrough(Tag.self, through: UserTag.self, on: { builder in
(builder.main.id == builder.junction.userId,
builder.junction.tagId == builder.target.id)
})
.all()| Method | Returns | Description |
|---|---|---|
.all() |
[T] |
Execute query, return all matching rows |
.first() |
T? |
Execute query, return first row or nil |
.firstOrFail() |
T |
Execute query, throw SpectroError.notFound if empty |
.count() |
Int |
Return count of matching rows |
@Schema("users")
struct User {
@ID var id: UUID
@Column var name: String
@HasMany var posts: [Post]
@HasOne var profile: Profile?
@ManyToMany(junctionTable: "user_tags", parentFK: "userId", relatedFK: "tagId")
var tags: [Tag]
}
@Schema("posts")
struct Post {
@ID var id: UUID
@Column var title: String
@ForeignKey var userId: UUID
@BelongsTo var user: User?
}
@Schema("profiles")
struct Profile {
@ID var id: UUID
@Column var bio: String
@ForeignKey var userId: UUID
}
@Schema("tags")
struct Tag {
@ID var id: UUID
@Column var name: String
}
@Schema("user_tags")
struct UserTag {
@ID var id: UUID
@ForeignKey var userId: UUID
@ForeignKey var tagId: UUID
}Preloading executes one additional query per relationship (not one per row):
// Load users with their posts (2 queries total)
let users = try await repo.query(User.self)
.preload(\.$posts)
.all()
// Chain multiple preloads
let users = try await repo.query(User.self)
.preload(\.$posts)
.preload(\.$profile)
.preload(\.$tags) // many-to-many preload
.all()
// Override foreign key when it doesn't follow convention
let posts = try await repo.query(Post.self)
.preload(\.$author, foreignKey: "authorId")
.all()Preload queries support chaining with .where(), .orderBy(), and .limit():
let users = try await repo.query(User.self)
.where { $0.isActive == true }
.preload(\.$posts)
.orderBy({ $0.name }, .asc)
.limit(50)
.all()Transactions use READ COMMITTED isolation with automatic rollback on error. The closure receives a Repo-conforming object with full CRUD and query builder support:
let (user, profile) = try await spectro.transaction { repo in
let user = try await repo.insert(User(name: "Alice", email: "alice@example.com"))
let profile = try await repo.insert(Profile(bio: "Hello!", userId: user.id))
// Query builder works inside transactions
let count = try await repo.query(User.self)
.where { $0.isActive == true }
.count()
return (user, profile)
}Both GenericDatabaseRepo and TransactionRepo conform to the Repo protocol, so code that accepts any Repo works transparently inside and outside transactions. The QueryExecutor protocol allows Query<T> to execute against both pooled connections and pinned transaction connections.
Nested transactions are not supported and will throw SpectroError.transactionAlreadyStarted.
let total = try await repo.query(Order.self)
.where { $0.status == "completed" }
.sum { $0.amount } // Double?
let average = try await repo.query(Order.self)
.avg { $0.amount } // Double?
let highest = try await repo.query(Order.self)
.max { $0.amount } // Double?
let lowest = try await repo.query(Order.self)
.min { $0.amount } // Double?
let count = try await repo.query(Order.self)
.where { $0.status == "completed" }
.count() // IntCombine .groupBy() with grouped aggregate methods to get per-group results:
// Sum of order amounts grouped by status
let results = try await repo.query(Order.self)
.groupBy { $0.status }
.groupedSum { $0.amount }
// returns [GroupedResult] where each has .group["status"] and .value
// Group by multiple fields
let results = try await repo.query(Order.self)
.groupBy({ $0.status }, { $0.region })
.groupedCount()
// HAVING clause
let results = try await repo.query(Order.self)
.groupBy { $0.status }
.having { $0.amount > 100 }
.groupedSum { $0.amount }Available grouped methods: groupedSum, groupedAvg, groupedMin, groupedMax, groupedCount.
Each returns [GroupedResult]:
public struct GroupedResult: Sendable {
public let group: [String: String] // GROUP BY column values
public let value: Double? // aggregate result
}Select specific columns instead of SELECT * using TupleQuery:
// Single field
let names: [String] = try await repo.query(User.self)
.select { $0.name }
.all()
// Two fields
let pairs: [Tuple2<String, String>] = try await repo.query(User.self)
.select { ($0.name, $0.email) }
.all()
// Access: pairs[0]._0 (name), pairs[0]._1 (email)
// Three fields
let triples: [Tuple3<String, String, Int>] = try await repo.query(User.self)
.select { ($0.name, $0.email, $0.age) }
.all()
// Four fields
let quads: [Tuple4<UUID, String, String, Bool>] = try await repo.query(User.self)
.select { ($0.id, $0.name, $0.email, $0.isActive) }
.all()TupleQuery supports .where(), .orderBy(), .limit(), .offset(), .first(), .firstOrFail(), and .count().
spectro database create Create a new PostgreSQL database
spectro database drop Drop an existing database
spectro migrate up Run all pending migrations
spectro migrate down Rollback applied migrations (--step N)
spectro migrate status Show migration status
spectro generate migration <name> Generate a new SQL migration file
All commands accept --username, --password, and --database flags. Values are resolved in order: CLI flags > .env file > environment variables > defaults.
Migrations are plain SQL in Sources/Migrations/, named YYYYMMDDHHMMSS_<name>.sql:
-- migrate:up
CREATE TABLE "users" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL DEFAULT '',
"email" TEXT NOT NULL DEFAULT '',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- migrate:down
DROP TABLE "users";The SQLStatementParser handles semicolons inside dollar-quoted strings, inline -- comments, and /* */ block comments.
spectro generate migration CreateUsers
# Creates: Sources/Migrations/20260324120000_CreateUsers.sql# Apply all pending
spectro migrate up
# Rollback last migration
spectro migrate down
# Rollback N migrations
spectro migrate down --step 3
# Check status
spectro migrate statusSpectro is organized into four targets:
| Target | Product | Role |
|---|---|---|
SpectroCommon |
SpectroCommon |
Shared types (zero external deps): Inflector, MigrationFile, MigrationRecord, error enums, String.snakeCase() |
SpectroMacros |
Compiler plugin | @Schema macro implementation via SwiftSyntax |
Spectro |
SpectroKit |
Core ORM library: schemas, query builder, connection pool, migrations |
SpectroCLI |
spectro |
CLI executable (ArgumentParser-based) |
DatabaseConnection-- wraps an NIOEventLoopGroupConnectionPool<PostgresConnectionSource>; bridges futures to async/await viawithCheckedThrowingContinuation; tracks in-flight operations for safe shutdownGenericDatabaseRepo-- implements theRepoprotocol; primary CRUD layer with query building, raw SQL, and transaction supportSchemaRegistry-- singleton actor; inspects schema types viaMirror; caches field metadata for row mapping
Schema-- requirestableName: Stringandinit()SchemaBuilder-- addsstatic func build(from: [String: Any]) -> Selffor reflection-free row mappingPrimaryKeyType--UUID,Int,Stringconformances; providestoPostgresData(),fromPostgresData(),defaultValue,fieldTypeRepo-- common interface forGenericDatabaseRepoandTransactionRepo; definesget,insert,update,delete,upsert,insertAll,transaction,queryQueryExecutor-- abstraction over query execution soQuery<T>works with both pooled connections and pinned transaction connections
Query<T> stores conditions with ? as positional sentinels. Placeholder numbering ($1, $2, ...) is applied in a single left-to-right pass at SQL assembly time via renumberPlaceholders(). This means individual operators never need to know their absolute parameter index.
Spectro (facade)
└─ GenericDatabaseRepo (actor, Repo protocol)
└─ DatabaseConnection (actor, QueryExecutor protocol)
└─ EventLoopGroupConnectionPool<PostgresConnectionSource>
└─ PostgresConnection (NIO EventLoop)
Query<T> ──execute──▶ QueryExecutor.executeQuery()
├─ DatabaseConnection (pooled)
└─ TransactionContext (pinned connection)
See docs/architecture.html for the full architecture reference with diagrams.
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=myapp_dev
Create a .env file in your project root. The CLI reads it automatically:
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=myapp_dev
// From explicit parameters
let spectro = try Spectro(
hostname: "localhost",
port: 5432,
username: "postgres",
password: "postgres",
database: "myapp_dev",
maxConnectionsPerEventLoop: 4
)
// From environment variables (DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME)
let spectro = try Spectro.fromEnvironment()
// From a DatabaseConfiguration struct (supports TLS)
let config = DatabaseConfiguration(
hostname: "db.example.com",
port: 5432,
username: "app",
password: "secret",
database: "production",
maxConnectionsPerEventLoop: 8,
numberOfThreads: System.coreCount,
tlsConfiguration: nil
)
let spectro = try Spectro(configuration: config)# Debug build
swift build
# Release build
swift build -c release
# CLI only
swift build --product spectro
# Run CLI from source
./.build/debug/spectro migrate status| Package | Version | Purpose |
|---|---|---|
| postgres-kit | 2.7+ | PostgreSQL driver and connection pooling |
| sql-kit | 3.30+ | SQL building utilities |
| async-kit | 1.15+ | Connection pool infrastructure |
| swift-nio | 2.34+ | Async I/O runtime |
| swift-argument-parser | 1.2+ | CLI argument parsing |
| swift-syntax | 600+ | @Schema macro implementation |
Tests use Swift Testing (@Suite, @Test), not XCTest.
Integration tests require a live PostgreSQL database:
# Set environment variables (or use defaults)
export DB_HOST=localhost
export DB_PORT=5432
export DB_USER=postgres
export DB_PASSWORD=postgres
export TEST_DB_NAME=spectro_test
# Create the test database (one-time)
./Tests/setup_test_db.sh# All tests
swift test
# Specific suite
swift test --filter CoreFunctionalTests
# Query tests only
swift test --filter QueryTests
# Aggregate tests
swift test --filter AggregateQueryTestsTests/SpectroTests/
├── Helpers/
│ ├── TestDatabase.swift # Test DB connection setup
│ └── TestSchemas.swift # Schema definitions for tests
├── SchemaTests/
│ ├── SchemaTests.swift # Schema registration and metadata
│ ├── RelationshipTests.swift # Relationship property wrappers
│ ├── LazyLoaderTests.swift # SpectroLazyRelation state machine
│ └── MacroLoaderInjectionTests.swift
├── QueryTests/
│ ├── QueryTests.swift # SQL generation for Query<T>
│ ├── QueryOperatorTests.swift # All operator combinations
│ ├── QueryExecutionTests.swift # Live query execution
│ ├── PreloadTests.swift # Relationship preloading
│ └── AggregateQueryTests.swift # SUM, AVG, MIN, MAX, GROUP BY
├── RepoTests/
│ ├── RepositoryTests.swift # CRUD operations
│ ├── NonUUIDPrimaryKeyTests.swift # Int and String PK support
│ ├── UpsertBulkInsertTests.swift # Upsert and insertAll
│ └── TransactionTests.swift # Transaction isolation and rollback
└── MigrationTests/
├── SQLStatementParserTests.swift
└── StringCase.swift # snake_case conversion
SpectroError.connectionFailed: Database connection failed
- Verify PostgreSQL is running:
pg_isreadyordocker ps - Check env vars:
DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_NAME - Verify the database exists:
psql -l | grep myapp_dev
SpectroError.databaseError: Insert did not return a row
The table likely does not exist. Run migrations first:
spectro migrate upSpectroError.invalidSchema: Schema User must implement SchemaBuilder
Either use the @Schema macro or manually conform to SchemaBuilder:
// Option A: use the macro
@Schema("users")
struct User { ... }
// Option B: manual conformance
struct User: Schema, SchemaBuilder {
static func build(from values: [String: Any]) -> User { ... }
}SpectroError.transactionAlreadyStarted
Nested transactions are not supported. Restructure your code so that all work happens within a single transaction closure.
Always call await spectro.shutdown() before releasing the Spectro instance. The connection pool tracks in-flight operations and waits for them to complete before tearing down.
MIT