Fluent Relations
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools
version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with fluent 4.13.0,
fluent-kit 1.57.0, fluent-sqlite-driver 4.9.0 and sql-kit 3.36.0, checked on 2026-10-17. Every Swift listing on this
page was compiled in the Swift 6 language mode with zero warnings in a scratch package, and the behaviour described was
run: the query counts, SQL statements, error messages and JSON bodies shown are captured from a VaporTesting suite (ten
tests, all passing) and from the running service answered with curl, always against in-memory SQLite. PostgreSQL,
MySQL and MongoDB expose the same relation API because it is defined in FluentKit above the drivers, but nothing on this
page was run against a live server of those; where their behaviour differs (foreign keys in MongoDB, placeholder syntax
in the logged SQL) it is said explicitly. Vapor 5 is at 5.0.0-beta.4 and is not released; Fluent has no 5.x release at
all (the latest tags are fluent 4.13.0 and fluent-kit 1.57.0), so everything here is the Fluent 4 API. The page extends
the Bookshelf models of the earlier pages of this section (the Fluent Models
page declares them, the Migrations & Schema page creates the schema, and
the Fluent Queries and Pagination page covers filters, joins and
pagination in depth); two page-local additions are marked as such. Swift itself is covered by the
Swift Reference, key paths by
Key Paths and Dynamic Member Lookup, and
async functions, Sendable and actors by Async/Await and
Tasks and Actors, Isolation and Sendable. The same
topic in Rust is in Databases with SQLx, and the relational foundations
(keys, one-to-many, many-to-many with a junction table) are in the site’s SQL
relations page.
What a relation is in Fluent
A relational database expresses a relationship with a foreign key column. Fluent does not hide that: a relation is a typed way of reading and writing a foreign key and of fetching the rows on the other side of it. There are five property wrappers, and only two of them own a column:
| Wrapper | Cardinality | Stored column | Declared on |
|---|---|---|---|
|
many-to-one (the "many" side) |
|
the model that holds the foreign key |
|
many-to-one, optional |
|
the model that holds the foreign key |
|
one-to-many (the "one" side) |
none |
the model that is referenced; points at the child’s |
|
one-to-one (the side without the column) |
none |
the model that is referenced; needs a |
|
many-to-many |
none; the two foreign keys live in the pivot model |
both models, each pointing at the pivot |
Because @Children, @OptionalChild and @Siblings store nothing, adding them to a model needs no migration; only
@Parent, @OptionalParent and the pivot’s two @Parent fields do. FluentKit 1.57.0 also contains composite-key
variants (@CompositeParent, @CompositeChildren, @CompositeOptionalChild, @CompositeOptionalParent) for models
whose primary key is a group of fields; they are not used by Bookshelf and are not covered further.
Each property wrapper hands you two things, and mixing them up is the commonest beginner error:
-
the wrapped value (
book.author,author.books,book.tags) is the related model or array, and it exists only when the relation has been loaded; reading it before then stops the process with afatalError; -
the projected value (
book.$author,author.$books,book.$tags) is the property object itself, with the foreign key ($author.id), the cached value ($author.value, an optional) and the operations (get,load,query,create,attach,detach, …).
Where it sits in the request lifecycle
Relations are a data-access feature, so they are used in the middle of the pipeline: after the middleware chain and the router have chosen a handler, and before the response is encoded.
request -> middleware -> router -> handler --(await)--> Fluent query/relations --> database
|
+-> build DTO -> encode (Content) -> response -> middleware -> client
Three places in that line are affected by relations, and the rest of the page keeps returning to them:
-
In the handler, every
get,load,query,create,attachand every eager load is a statement sent to the database and awaited. The number of statements, not the number of lines of Swift, is the cost of a relation access; the N+1 section measures it. -
At encoding time, a
Modelthat isCodableencodes its relations too: a loaded@Parentas a nested object, an unloaded one as{"id": ...}, a loaded@Childrenor@Siblingsas an array and an unloaded one not at all. That is rarely the JSON shape you want, so handlers usually map to a DTO (see Encoding relations). -
In the database, the foreign keys declared in the migration decide what happens when a row is deleted (see Cascade rules), whatever the Swift code does.
The Bookshelf model
The figure is the entity-relationship diagram of the canonical Bookshelf schema used throughout this section.
Crow’s-foot notation: || is exactly one, o{ is zero or many. The BOOK_TAG table is the pivot behind @Siblings.
Reading the diagram as Fluent declarations: an Author has @Children books; a Book has an @Parent author,
@Children reviews and @Siblings tags; a Review has two @Parent`s (book and user); `Tag has the @Siblings back
to books; BookTag is the pivot with two @Parent`s; `Loan has two @Parent`s and an optional timestamp. These are the
models of `Models.swift, which are the canonical set shared by every page of this section:
import Fluent
import Vapor
enum BookStatus: String, Codable, Sendable { case draft, published, archived }
enum UserRole: String, Codable, Sendable { case reader, librarian, admin }
final class Author: Model, @unchecked Sendable {
static let schema = "authors"
@ID(key: .id) var id: UUID?
@Field(key: "name") var name: String
@OptionalField(key: "bio") var bio: String?
@Children(for: \.$author) var books: [Book]
init() {}
init(id: UUID? = nil, name: String, bio: String? = nil) { self.id = id; self.name = name; self.bio = bio }
}
final class Book: Model, @unchecked Sendable {
static let schema = "books"
@ID(key: .id) var id: UUID?
@Field(key: "title") var title: String
@Field(key: "isbn") var isbn: String
@Field(key: "published_year") var publishedYear: Int
@Field(key: "price_cents") var priceCents: Int
@Enum(key: "status") var status: BookStatus
@Parent(key: "author_id") var author: Author
@Children(for: \.$book) var reviews: [Review]
@Siblings(through: BookTag.self, from: \.$book, to: \.$tag) var tags: [Tag]
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
@Timestamp(key: "updated_at", on: .update) var updatedAt: Date?
init() {}
init(id: UUID? = nil, title: String, isbn: String, publishedYear: Int, priceCents: Int,
status: BookStatus = .draft, authorID: Author.IDValue) {
self.id = id; self.title = title; self.isbn = isbn; self.publishedYear = publishedYear
self.priceCents = priceCents; self.status = status; self.$author.id = authorID
}
}
final class User: Model, @unchecked Sendable {
static let schema = "users"
@ID(key: .id) var id: UUID?
@Field(key: "email") var email: String
@Field(key: "password_hash") var passwordHash: String
@Enum(key: "role") var role: UserRole
init() {}
init(id: UUID? = nil, email: String, passwordHash: String, role: UserRole = .reader) {
self.id = id; self.email = email; self.passwordHash = passwordHash; self.role = role
}
}
final class Review: Model, @unchecked Sendable {
static let schema = "reviews"
@ID(key: .id) var id: UUID?
@Field(key: "rating") var rating: Int
@Field(key: "body") var body: String
@Parent(key: "book_id") var book: Book
@Parent(key: "user_id") var user: User
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
init(id: UUID? = nil, rating: Int, body: String, bookID: Book.IDValue, userID: User.IDValue) {
self.id = id; self.rating = rating; self.body = body; self.$book.id = bookID; self.$user.id = userID
}
}
final class Tag: Model, @unchecked Sendable {
static let schema = "tags"
@ID(key: .id) var id: UUID?
@Field(key: "name") var name: String
@Siblings(through: BookTag.self, from: \.$tag, to: \.$book) var books: [Book]
init() {}
init(id: UUID? = nil, name: String) { self.id = id; self.name = name }
}
final class BookTag: Model, @unchecked Sendable {
static let schema = "book_tag"
@ID(key: .id) var id: UUID?
@Parent(key: "book_id") var book: Book
@Parent(key: "tag_id") var tag: Tag
init() {}
init(bookID: Book.IDValue, tagID: Tag.IDValue) { self.$book.id = bookID; self.$tag.id = tagID }
}
final class Loan: Model, @unchecked Sendable {
static let schema = "loans"
@ID(key: .id) var id: UUID?
@Parent(key: "book_id") var book: Book
@Parent(key: "user_id") var user: User
@Field(key: "loaned_at") var loanedAt: Date
@Field(key: "due_at") var dueAt: Date
@OptionalField(key: "returned_at") var returnedAt: Date?
init() {}
init(id: UUID? = nil, bookID: Book.IDValue, userID: User.IDValue, loanedAt: Date, dueAt: Date) {
self.id = id; self.$book.id = bookID; self.$user.id = userID; self.loanedAt = loanedAt; self.dueAt = dueAt
}
}
Source: Vapor docs: Fluent relations
The foreign keys come from the migration. This is the part of the canonical CreateBookshelf migration that matters for
relations (every .references is a foreign key, and every onDelete: .cascade is a rule enforced by the database):
try await db.schema("books").id()
.field("title", .string, .required)
// ... isbn, published_year, price_cents, status, created_at, updated_at ...
.field("author_id", .uuid, .required, .references("authors", "id", onDelete: .cascade))
.create()
try await db.schema("reviews").id()
// ... rating, body, created_at ...
.field("book_id", .uuid, .required, .references("books", "id", onDelete: .cascade))
.field("user_id", .uuid, .required, .references("users", "id", onDelete: .cascade))
.create()
try await db.schema("book_tag").id()
.field("book_id", .uuid, .required, .references("books", "id", onDelete: .cascade))
.field("tag_id", .uuid, .required, .references("tags", "id", onDelete: .cascade))
.unique(on: "book_id", "tag_id")
.create()
Source: Vapor docs: Fluent schema
Two details deserve a comment. The tables must be created in dependency order (authors and users before books,
books before reviews), because a foreign key can only refer to a table that exists; the migration reverts in the
opposite order. And the pivot’s .unique(on: "book_id", "tag_id") is what makes "a book has a tag at most once" a
database guarantee rather than a hope; without it attach happily inserts duplicates.
Parent and OptionalParent
@Parent(key: "author_id") var author: Author declares a column whose type is the parent’s ID type (here UUID).
Internally the wrapper contains a @Field named id (the foreign key, $author.id) and an optional cache
($author.value). You set a parent by writing the ID, never the model:
let book = Book(title: "Notes", isbn: "978-1", publishedYear: 2026, priceCents: 1000, status: .draft,
authorID: try author.requireID()) // the initializer assigns self.$author.id = authorID
book.$author.id = try otherAuthor.requireID() // re-parenting later: assign the ID and save()
The initializer is where the assignment belongs: Fluent requires a parameterless init() and gives @Parent no initial
value, so the convenience initializer takes the parent’s IDValue and assigns self.$author.id. requireID() throws
FluentError.idRequired when the parent has not been saved, which is the right failure: a foreign key cannot point at a
row that does not exist yet.
@OptionalParent(key:) is the same with a nullable column and an optional ID ($publisher.id is a UUID?). Use it
when the relation may legitimately be absent; the foreign key then usually carries onDelete: .setNull (see
Cascade rules). The Bookshelf canon has no optional parent, so this page adds one, Edition.publisher, together
with an optional child, in the page-local models below.
Children and OptionalChild
@Children(for: \.$book) var reviews: [Review] is the other end of a @Parent. The argument is a key path to the
parent property on the child, \.$book (the $ selects the property object, not its value), which is how Fluent
knows which column to filter on. It stores nothing and the declaration needs no migration. What it offers is creation
and querying:
-
author.$books.create(book, on: db)andcreate([books], on: db)set the child’s foreign key to the owner’s ID and save the child (the owner must have been saved); -
author.$books.query(on: db)returns a normalQueryBuilder<Book>already filtered withbooks.author_idequal to the owner’s ID, so you addfilter,sort,range,countand the rest; -
get(on:)andload(on:)fetch the array (see Reading related rows).
@OptionalChild(for:) is the one-to-one variant: the owner has zero or one child, and owner.$contact.create(child,
on: db) creates it. Fluent cannot enforce "at most one" by itself; the child table needs a .unique constraint on its
foreign key, otherwise two children are possible and which one get returns is whichever the database answers first
(the Vapor documentation says the same). The wrapper dates from March 2021 (fluent-kit pull request 419, "Add
@OptionalChild property wrapper").
The page-local additions below put @OptionalChild, @OptionalParent and the three delete rules into one small model
set. A Publisher has at most one PublisherContact (unique foreign key) and many Edition`s, and an `Edition belongs
to a Book and optionally to a Publisher. They are not part of the canonical Bookshelf and no other page relies on
them.
import Fluent
import Vapor
// Page-local additions: they teach @OptionalParent, @OptionalChild and the other cascade rules.
// The canonical Bookshelf models are not changed.
final class Publisher: Model, @unchecked Sendable {
static let schema = "publishers"
@ID(key: .id) var id: UUID?
@Field(key: "name") var name: String
@OptionalChild(for: \.$publisher) var contact: PublisherContact?
@Children(for: \.$publisher) var editions: [Edition]
init() {}
init(id: UUID? = nil, name: String) { self.id = id; self.name = name }
}
final class PublisherContact: Model, @unchecked Sendable {
static let schema = "publisher_contacts"
@ID(key: .id) var id: UUID?
@Field(key: "email") var email: String
@Parent(key: "publisher_id") var publisher: Publisher
init() {}
init(id: UUID? = nil, email: String, publisherID: Publisher.IDValue) {
self.id = id; self.email = email; self.$publisher.id = publisherID
}
}
final class Edition: Model, @unchecked Sendable {
static let schema = "editions"
@ID(key: .id) var id: UUID?
@Field(key: "label") var label: String
@Parent(key: "book_id") var book: Book
@OptionalParent(key: "publisher_id") var publisher: Publisher?
init() {}
init(id: UUID? = nil, label: String, bookID: Book.IDValue, publisherID: Publisher.IDValue? = nil) {
self.id = id; self.label = label; self.$book.id = bookID; self.$publisher.id = publisherID
}
}
struct CreatePublishers: AsyncMigration {
func prepare(on db: any Database) async throws {
try await db.schema("publishers").id().field("name", .string, .required).create()
try await db.schema("publisher_contacts").id()
.field("email", .string, .required)
.field("publisher_id", .uuid, .required, .references("publishers", "id", onDelete: .restrict))
.unique(on: "publisher_id")
.create()
try await db.schema("editions").id()
.field("label", .string, .required)
.field("book_id", .uuid, .required, .references("books", "id", onDelete: .cascade))
.field("publisher_id", .uuid, .references("publishers", "id", onDelete: .setNull))
.create()
}
func revert(on db: any Database) async throws {
for s in ["editions", "publisher_contacts", "publishers"] { try await db.schema(s).delete() }
}
}
Observed results from the test suite that exercises these models:
second PublisherContact for the same publisher:
constraintUniqueFailed: UNIQUE constraint failed: publisher_contacts.publisher_id
Edition.query(on: db).with(\.$publisher).sort(\.$label).all(), mapped to "label=publisher name or none":
["First=Acme", "Self-published=none"]
publisher.$contact.get(on: db) before any contact exists:
nil
Siblings and the pivot
A many-to-many relationship needs a third table. In Fluent it is a pivot model with two @Parent fields, here
BookTag (book_tag, columns book_id and tag_id). Each side declares a @Siblings that names the pivot and the
two key paths:
// in Book
@Siblings(through: BookTag.self, from: \.$book, to: \.$tag) var tags: [Tag]
// in Tag
@Siblings(through: BookTag.self, from: \.$tag, to: \.$book) var books: [Book]
from is the pivot’s parent that points back at the model declaring the wrapper, to is the other one. The pivot model
may carry extra columns (a note, an added-at timestamp, a position); attach takes a closure to fill them. Operations
on the projected value, in the async flavour used throughout this section:
| Call | What it does |
|---|---|
|
Inserts one pivot row. A second identical call fails on the unique constraint if the pivot has one (observed: |
|
Inserts several pivot rows with one batch |
|
Checks first and inserts only if missing: the check is |
|
The trailing closure edits the pivot before it is saved. In the 1.57.0 sources the closure may also be |
|
One |
|
Deletes the matching pivot rows, not the tags. |
|
Deletes every pivot row of this book. |
|
A |
Two properties of @Siblings are easy to miss. attach requires the owner to carry its ID: the call fails with
siblings relation Siblings<Book, Tag, BookTag>(from: [book_id], to: [tag_id]) is missing owning model's ID (owner
likely unsaved) (a SiblingsPropertyError) for a book that was never saved. And a @Siblings on a model that was
created with the batch [Model].create(on:) has no owner ID cached: tag.$books.query(on: db) on such a tag stops
the process with Cannot query siblings relation Siblings<Tag, Book, BookTag>(from: [tag_id], to: [book_id]) from
unsaved model. (observed while writing this page; the same guard is in the sources of Children and OptionalChild).
Single create(on:), save(on:) and rows read from the database do carry the ID. When you batch-create models and then
use their relations, re-read them or create them one by one.
The next listing is the service layer of Bookshelf for the relations: publishing a book under an author with its tags, replacing the tag set, attaching idempotently, reading a parent defensively, a relation query, eager loading with the pivots, and a nested eager load for loans. Each function was exercised by the test suite, and the observed results are listed after it.
import Fluent
import Vapor
enum RelationOps {
/// Children.create sets books.author_id; Siblings.attach inserts the book_tag rows.
static func publish(title: String, isbn: String, under author: Author, tagged tags: [Tag],
on db: any Database) async throws -> Book {
let book = Book(title: title, isbn: isbn, publishedYear: 2026, priceCents: 2499,
status: .published, authorID: try author.requireID())
try await db.transaction { tx in
try await author.$books.create(book, on: tx)
try await book.$tags.attach(tags, on: tx)
}
return book
}
/// Replaces the whole tag set: detachAll deletes the pivots, attach inserts the new ones.
static func retag(_ book: Book, with tags: [Tag], on db: any Database) async throws {
try await db.transaction { tx in
try await book.$tags.detachAll(on: tx)
try await book.$tags.attach(tags, on: tx)
}
}
/// Attaches only when the pivot row is missing; returns whether a row was inserted.
static func ensureTag(_ tag: Tag, on book: Book, db: any Database) async throws -> Bool {
let before = try await book.$tags.isAttached(to: tag, on: db)
try await book.$tags.attach(tag, method: .ifNotExists, on: db)
return !before
}
/// Three ways to read a Parent.
static func authorName(of book: Book, on db: any Database) async throws -> String {
if let loaded = book.$author.value { return loaded.name } // already eager loaded or fetched
let author = try await book.$author.get(on: db) // query, cache in $author.value, return
return author.name
}
/// Filtering the related rows without loading them: a query on the relation.
static func expensiveBookCount(of author: Author, above cents: Int, on db: any Database) async throws -> Int {
try await author.$books.query(on: db).filter(\.$priceCents > cents).count()
}
/// An eager load cannot be filtered, so narrow the loaded array in memory; also load the pivots of a Siblings relation.
static func topReviewed(on db: any Database) async throws -> [(title: String, best: [Int], tagLinks: Int)] {
let books = try await Book.query(on: db)
.sort(\.$title)
.with(\.$reviews)
.with(\.$tags)
.with(\.$tags.$pivots)
.all()
return books.map { ($0.title, $0.reviews.map(\.rating).filter { $0 >= 4 }.sorted(by: >), $0.$tags.pivots.count) }
}
/// Overdue loans with everything the page needs, in four queries.
static func overdueLoans(asOf now: Date, on db: any Database) async throws -> [Loan] {
try await Loan.query(on: db)
.filter(\.$returnedAt == nil)
.filter(\.$dueAt < now)
.with(\.$book) { $0.with(\.$author) }
.with(\.$user)
.all()
}
}
Observed results:
publish(... tagged: [databases, fiction]) -> book.$tags.get(on: db) names: ["databases", "fiction"]
retag(book, with: [swift]) -> names after reload: ["swift"]
ensureTag(swift) again, then a missing tag -> false (was attached), then true (inserted)
authorName(of: freshlyFetchedBook) -> "Author 2" (one query), and $author.value is set afterwards
expensiveBookCount(of: author, above: 2000) -> 1 (one COUNT query on the relation)
topReviewed -> 4 statements: books, reviews, tags joined with book_tag, book_tag pivots
overdueLoans -> 4 statements: loans, books, authors, users
db.transaction { tx in ... } in publish and retag makes the writes atomic: if attach fails (a duplicate tag),
the book created a line earlier is rolled back. Use the transaction handle tx, not the outer db, inside the closure.
The closure is @Sendable, which is why the captured models must be Sendable (see Swift 6 and concurrency).
Transactions are covered by the Transactions,
Model Middleware & Soft Delete page.
Reading related rows
Given a book fetched from the database, its relations are not loaded. These are the ways to read them, with different
costs:
| Access | Statements | Notes |
|---|---|---|
|
0 |
The foreign key, always present. Enough when you only need to compare, or to build a link. |
|
1 on the first call, 0 afterwards |
Fetches the parent, stores it in |
|
1 |
Fetches and stores in |
|
1 per call |
A fresh |
|
1 per relation for the whole result set |
Eager loading; the next section. |
book.$author.value is nil until something loaded it, and book.author (no $) is then a fatalError with the
message "Parent relation not eager loaded, use $ prefix to access: …"; @Children and @Siblings have the same trap
with their own wording ("Children relation not eager loaded", "Siblings relation not eager loaded", taken from the
1.57.0 sources). The defensive pattern is authorName(of:on:) above: look at $author.value, and get when it is
nil.
Setting book.$author.value = someAuthor yourself caches an already-fetched model (useful when you hold the parent
from an earlier query), but it does not change the foreign key; assign $author.id for that.
Eager loading
Eager loading asks the query builder to load the relations of all rows it returns, in a fixed number of extra statements:
let books = try await Book.query(on: db)
.with(\.$author) // 1 extra statement for any number of books
.with(\.$tags) // 1 extra statement (tags INNER JOIN book_tag ... IN (...))
.with(\.$reviews) { $0.with(\.$user) } // reviews, then the users of those reviews: 2 more
.all()
// books[0].author.name, books[0].tags, books[0].reviews[0].user.email are now plain reads
How it works, from the FluentKit 1.57.0 sources and the captured SQL: the builder runs the main query, collects the
distinct key values of the result and issues one WHERE ... IN (...) query per relation; for a @Siblings the query
selects from the target table joined with the pivot, so the pivot costs no extra statement. The results are matched to
their owners in memory and written into $relation.value. The captured statements further down are the proof: SELECT
... FROM authors WHERE authors.id IN (?1,...,?5) carries the five distinct author IDs of the twenty books.
The rules that follow from that implementation:
-
withtakes the key path to the projected property,\.$author, and works on every relation kind, including\.$tagsfor siblings. The nested form.with(\.$reviews) { $0.with(\.$user) }goes through the relation to the related model’s relations, arbitrarily deep. The closure receives aNestedEagerLoadBuilder, which offerswithand nothing else. -
Eager loads cannot be filtered or sorted. The nested builder has no
filter: writing afilterinside the closure does not compile (the compiler reports thatNestedEagerLoadBuilder<...>has "no member 'filter'"; observed). Load everything and narrow the array in Swift (topReviewedabove keeps the ratings of four and five), run a separate$reviews.query(on:)per owner when the sets are large, or usejoinwhen you want the database to filter the owners by a related column. -
withhas overloads takingwithDeleted: true, which include soft-deleted related rows; by default they are skipped. -
Eager loaders run when the result is read as models:
all(),first()andpaginate. Aggregates such ascount()clear them explicitly, andupdate()anddelete()return no models to fill, sowithis meaningless there. -
A model that was not produced by such a query (a freshly built
Book(...)) has no loaded relations: eager loading is a property of the query result, not of the type. -
with(\.$tags.$pivots)additionally loads the pivot rows (as intopReviewed), exposed asbook.$tags.pivots; it costs one more statement (SELECT ... FROM book_tag WHERE book_id IN (...), observed). -
An
INlist has as many placeholders as there are distinct keys. Databases limit bind parameters per statement (SQLite’s default build allows 32766, PostgreSQL 65535), so page very large result sets withpaginateorrangeand eager-load per page. Pagination is covered by the Fluent Queries and Pagination page.
The N+1 problem, measured
The "N+1 problem" is the pattern in which code fetches N rows with one query and then runs one more query per row to
read a relation: 1 + N statements, each a round trip to the database, when 2 would do. In Fluent it is created by
calling get(on:) or load(on:) inside a loop, and it is invisible in the Swift because every get looks harmless.
The Patel book (chapter 5, Vapor 2) is where this section’s review of the books flags it: its REST resources serialise
related rows one query at a time, and it deletes parents without cascading to their children (the cascade section below
shows the Fluent 4 way).
Bookshelf has five handlers that return the same data in different ways. BookListing computes BookRow (title and
author name) and BookDetail (title, author, tags and the reviews with their readers):
import Fluent
import Vapor
struct BookRow: Content, Equatable {
let title: String
let author: String
}
struct ReviewRow: Content, Equatable {
let rating: Int
let reader: String
}
struct BookDetail: Content, Equatable {
let title: String
let author: String
let tags: [String]
let reviews: [ReviewRow]
}
enum BookListing {
// N+1: one query for the books, then one more per book.
static func rowsLazily(on db: any Database) async throws -> [BookRow] {
let books = try await Book.query(on: db).all()
var rows: [BookRow] = []
for book in books {
let author = try await book.$author.get(on: db)
rows.append(BookRow(title: book.title, author: author.name))
}
return rows
}
// Eager loading: two queries however many books there are.
static func rowsEagerly(on db: any Database) async throws -> [BookRow] {
let books = try await Book.query(on: db).with(\.$author).all()
return books.map { BookRow(title: $0.title, author: $0.author.name) }
}
// A join: one query, a single result set.
static func rowsJoined(on db: any Database) async throws -> [BookRow] {
let books = try await Book.query(on: db).join(Author.self, on: \Book.$author.$id == \Author.$id).all()
return try books.map { BookRow(title: $0.title, author: try $0.joined(Author.self).name) }
}
// Nested N+1: books, then authors, tags and reviews per book, then the reader of every review.
static func detailsLazily(on db: any Database) async throws -> [BookDetail] {
let books = try await Book.query(on: db).all()
var details: [BookDetail] = []
for book in books {
let author = try await book.$author.get(on: db)
let tags = try await book.$tags.get(on: db)
var reviews: [ReviewRow] = []
for review in try await book.$reviews.get(on: db) {
let reader = try await review.$user.get(on: db)
reviews.append(ReviewRow(rating: review.rating, reader: reader.email))
}
details.append(BookDetail(title: book.title, author: author.name, tags: tags.map(\.name), reviews: reviews))
}
return details
}
// Nested eager loading: five queries in total (books, authors, tags with their pivot rows, reviews, users).
static func detailsEagerly(on db: any Database) async throws -> [BookDetail] {
let books = try await Book.query(on: db)
.with(\.$author)
.with(\.$tags)
.with(\.$reviews) { $0.with(\.$user) }
.all()
return books.map { book in
BookDetail(title: book.title, author: book.author.name, tags: book.tags.map(\.name),
reviews: book.reviews.map { ReviewRow(rating: $0.rating, reader: $0.user.email) })
}
}
}
struct BookListingController: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
let books = routes.grouped("books")
books.get("simple", "lazy") { req async throws in try await BookListing.rowsLazily(on: req.db) }
books.get("simple", "eager") { req async throws in try await BookListing.rowsEagerly(on: req.db) }
books.get("simple", "join") { req async throws in try await BookListing.rowsJoined(on: req.db) }
books.get("detail", "lazy") { req async throws in try await BookListing.detailsLazily(on: req.db) }
books.get("detail", "eager") { req async throws in try await BookListing.detailsEagerly(on: req.db) }
}
}
rowsLazily and detailsLazily are the N+1 versions; rowsEagerly and detailsEagerly load the same relations with
with; rowsJoined uses one join, which returns the columns of both tables in a single result set and is read back
with joined(Author.self). All three return the same Content DTOs, so the JSON is identical. The detail view reads
three relation kinds per book (parent, siblings, children) and a nested parent per review.
Counting statements from the log
Fluent logs every statement through the logger of the database that runs it, at the level given as sqlLogLevel when
the database is registered (.debug by default, nil disables it). In configure.swift of the scratch package:
import Fluent
import FluentSQLiteDriver
import Vapor
func configure(_ app: Application) async throws {
// sqlLogLevel decides the level at which Fluent logs every statement (default .debug).
app.databases.use(.sqlite(.memory, sqlLogLevel: .info), as: .sqlite)
app.migrations.add(CreateBookshelf())
app.migrations.add(CreatePublishers())
try await app.autoMigrate()
if app.environment != .testing { try await seedBookshelf(on: app.db) }
try app.register(collection: BookListingController())
}
Source: Vapor docs: Fluent overview
With LOG_LEVEL=info the running server prints one Executing query line per statement, carrying the request id, which
makes a count per request a one-line awk. Consecutive lines of the lazy handler show the pattern: the author is
fetched again for every book, with the same bind, because Fluent does not share loaded models between rows:
[ INFO ] GET /books/simple/lazy [request-id: 4B53E492-...]
[ INFO ] Executing query [binds: [], database-id: sqlite, request-id: 4B53E492-..., sql: SELECT "books"."id" AS "books_id", ... FROM "books"]
[ INFO ] Executing query [binds: [84DDFA22-...], ..., sql: SELECT "authors"."id" AS "authors_id", ... FROM "authors" WHERE "authors"."id" = ?1 LIMIT 1]
[ INFO ] Executing query [binds: [84DDFA22-...], ..., sql: SELECT "authors"."id" AS "authors_id", ... FROM "authors" WHERE "authors"."id" = ?1 LIMIT 1]
The eager handler runs two statements, the second with all five author IDs in one IN list. In an automated test the
same information comes from a LogHandler that records the SQL; it is installed on the application’s logger, and every
request logger and req.db derives from it:
import Logging
import NIOConcurrencyHelpers
/// Collects the SQL statements Fluent logs at `sqlLogLevel`, so a test can count them.
final class QueryRecorder: Sendable {
private let lines = NIOLockedValueBox<[String]>([])
func record(_ line: String) { lines.withLockedValue { $0.append(line) } }
func reset() { lines.withLockedValue { $0.removeAll() } }
var statements: [String] { lines.withLockedValue { $0 } }
var selectCount: Int { statements.filter { $0.hasPrefix("SELECT") }.count }
}
struct RecordingLogHandler: LogHandler {
let recorder: QueryRecorder
var metadata: Logger.Metadata = [:]
var logLevel: Logger.Level = .debug
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
get { metadata[key] }
set { metadata[key] = newValue }
}
func log(event: LogEvent) {
// SQLiteKit logs "Executing query" with the SQL text in the `sql` metadata key.
guard "\(event.message)" == "Executing query", case .string(let sql)? = event.metadata?["sql"] else { return }
recorder.record(sql)
}
}
SQLiteKit logs the statement as the message "Executing query" with the SQL text in the sql metadata key; the handler
keeps exactly those events and ignores FluentKit’s own "Running query" line, which would double the count. Other drivers
word their messages and metadata differently, so on PostgreSQL or MySQL adapt the filter after looking at one captured
event; that was not run on this page.
The tests seed 5 authors, 20 books, 60 reviews, 6 readers and 4 tags and assert the counts. The helper creates a testing
Application, swaps its logger and runs the body:
import Fluent
import Foundation
import Testing
import VaporTesting
@testable import App
/// A testing application whose logger feeds a QueryRecorder, with the Bookshelf seed loaded.
func withBookshelf(_ body: (Application, QueryRecorder) async throws -> Void) async throws {
let recorder = QueryRecorder()
let app = try await Application.make(.testing)
app.logger = Logger(label: "bookshelf") { _ in RecordingLogHandler(recorder: recorder) }
do {
try await configure(app)
try await seedBookshelf(on: app.db)
recorder.reset()
try await body(app, recorder)
} catch { try await app.asyncShutdown(); throw error }
try await app.asyncShutdown()
}
@Suite(.serialized) struct QueryCountTests {
// The seed holds 20 books, 60 reviews, 6 readers and 4 tags.
@Test func lazyLoadingIsNPlusOne() async throws {
try await withBookshelf { app, recorder in
_ = try await BookListing.rowsLazily(on: app.db)
let expected = 1 + 20
#expect(recorder.statements.count == expected)
recorder.reset()
_ = try await BookListing.detailsLazily(on: app.db)
// 1 books + 20 x (author + tags + reviews) + 60 readers
let expectedNested = 1 + 20 * 3 + 60
#expect(recorder.statements.count == expectedNested)
}
}
@Test func eagerLoadingIsOneQueryPerRelation() async throws {
try await withBookshelf { app, recorder in
_ = try await BookListing.rowsEagerly(on: app.db)
#expect(recorder.statements.count == 2)
recorder.reset()
_ = try await BookListing.detailsEagerly(on: app.db)
// books, authors, tags (joined with the pivot), reviews, users
#expect(recorder.statements.count == 5)
recorder.reset()
_ = try await BookListing.rowsJoined(on: app.db)
#expect(recorder.statements.count == 1)
}
}
@Test func bothStrategiesReturnTheSameData() async throws {
try await withBookshelf { app, _ in
let lazy = try await BookListing.rowsLazily(on: app.db)
let eager = try await BookListing.rowsEagerly(on: app.db)
let joined = try await BookListing.rowsJoined(on: app.db)
#expect(lazy == eager)
#expect(eager == joined)
let lazyDetails = try await BookListing.detailsLazily(on: app.db)
let eagerDetails = try await BookListing.detailsEagerly(on: app.db)
#expect(lazyDetails == eagerDetails)
}
}
@Test func endpointsAnswer() async throws {
try await withBookshelf { app, recorder in
try await app.testing().test(.GET, "books/detail/eager") { res async in
#expect(res.status == .ok)
#expect(recorder.statements.count == 5)
}
}
}
}
Source: Vapor docs: Testing
The result of the assertions, and of five curl requests against the running server (statements per request counted
from the log by request id, so each number is a real count, not an estimate):
| Handler | Lazy (N+1) | Eager with |
Formula for the lazy version |
|---|---|---|---|
|
21 statements |
2 statements (join: 1) |
1 + N, N = 20 books |
|
121 statements |
5 statements |
1 + 3N + R, R = 60 reviews |
For the detail view the eager version needs one statement each for the books, authors, tags (joined with the pivot),
reviews and users: five in total however many books there are. Extrapolating with the same formulas, which holds because
the lazy code is a plain loop (this is arithmetic, not a measurement): 200 books with three reviews each would cost 1
600 + 600 = 1201 statements lazily and still 5 eagerly. The test bothStrategiesReturnTheSameData asserts that the
lazy, eager and joined results are equal, so only the cost differs.
Wall-clock times from the same curl run (a debug build on a laptop, in-memory SQLite, one request each) were 15 ms
against 10 ms for the simple list and 115 ms against 20 ms for the detail list. Those figures are nearly meaningless as
absolute numbers: with in-memory SQLite a statement costs microseconds. Against a database across a network every
statement adds a round trip plus a connection from the pool, so the statement counts above are what predict production
behaviour.
Which fix to choose:
-
Eager loading when you want the related models as models and the relations are parent, children or siblings. It costs one statement per relation level and keeps the code readable.
-
A join when you only need a few columns of a to-one relation, or when the filter belongs to the related table (for example, books whose author name matches): one statement, but a to-many join repeats the owner’s columns per related row and cannot fill
@Childrenarrays. -
An aggregate query (a count of reviews per book) when the page needs numbers, not rows; loading 60 review models only to count them is the same waste in a different shape. Aggregates and raw SQL are covered by the Fluent Queries and Pagination page and the SQLKit and Raw SQL page.
The same waste appears as the same row fetched repeatedly: the lazy listing fetched author 84DDFA22-… four times,
once per book of that author. Eager loading puts each distinct key in the IN list once, which is why five authors cost
one statement for twenty books.
Encoding relations
A Fluent Model is Codable, and the relation wrappers encode themselves. What comes out depends on what has been
loaded, and the rules are asymmetric, which surprises people the first time a model is returned straight from a handler:
-
a
@Parentencodes as the full parent object when loaded, and as{"id": "..."}when only the foreign key is known; -
an
@OptionalParentencodes as the parent when loaded and as{"id": ...}otherwise, with anullid when the relation is absent (from the 1.57.0 sources; not run); -
@Children,@OptionalChildand@Siblingsencode as an array (or object) when loaded and are omitted when not loaded.
The test below pins that behaviour, and the JSON shown after it is the captured output of the same two cases with the pretty printer turned on:
import Fluent
import Foundation
import Testing
import VaporTesting
@testable import App
@Suite(.serialized) struct EncodingTests {
private func json(_ book: Book) throws -> [String: Any] {
let data = try JSONEncoder().encode(book)
return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
}
@Test func unloadedRelationsEncodeAsTheirKeyOnly() async throws {
try await withBookshelf { app, _ in
let book = try #require(try await Book.query(on: app.db).first())
let object = try json(book)
let author = try #require(object["author"] as? [String: Any])
#expect(Set(author.keys) == ["id"]) // only the foreign key
#expect(object["reviews"] == nil) // @Children is omitted until loaded
#expect(object["tags"] == nil) // @Siblings too
}
}
@Test func loadedRelationsEncodeNested() async throws {
try await withBookshelf { app, _ in
let book = try #require(try await Book.query(on: app.db).with(\.$author).with(\.$tags).first())
let object = try json(book)
let author = try #require(object["author"] as? [String: Any])
#expect(author["name"] as? String == "Author 1")
let tags = try #require(object["tags"] as? [[String: Any]])
#expect(tags.count == 2)
}
}
}
Source: Vapor docs: Fluent models
{
"author" : { "id" : "9B2A8DB0-188B-415C-995B-8039F5F0F884" },
"createdAt" : "2026-10-17T15:28:38Z",
"id" : "70D03509-A1E7-4208-85F2-66271FD39094",
"isbn" : "978-1",
"priceCents" : 1000,
"publishedYear" : 2026,
"status" : "draft",
"title" : "Notes",
"updatedAt" : "2026-10-17T15:28:38Z"
}
That is the book fetched without eager loading. With .with(\.$author).with(\.$tags) the author member becomes the
full author ("bio": null, "id": ..., "name": "Ada") and tags appears as an array (empty for this book). The
property names are the Swift ones (priceCents), not the column names (price_cents), because the encoder uses the
property labels, not the @Field keys.
Returning a model directly therefore leaks the shape of your schema and of your loading strategy to API clients, and a
handler that forgets a with changes the response. The robust pattern is the one used by BookRow and BookDetail
above: eager load what the DTO needs, map to a Content struct, return that. A model such as User also carries
passwordHash, which a direct return would publish; the Fluent Models page and
the API Design, Versioning and DTOs page cover DTOs.
Cascade rules
A relation is only as safe as its foreign key (which the Migrations &
Schema page shows how to declare and change). .references("authors", "id", onDelete: .cascade) in the migration, not
any Swift property, decides what the database does when the referenced row is deleted or its key is updated. onDelete
and onUpdate take a DatabaseSchema.ForeignKeyAction:
| Rule | Effect when the parent row is deleted | Observed in the test suite (SQLite) |
|---|---|---|
|
The delete fails if children exist; the check is made at the end of the statement. |
not run separately |
|
The delete fails at once if children exist. |
|
|
The children are deleted too, recursively through their own cascade rules. |
deleting one author: books 20 to 16, reviews 60 to 48, pivot rows 40 to 32; tags unchanged at 4 |
|
The children’s foreign key becomes |
the |
|
The foreign key takes its column default. |
not run; not every database supports it (MySQL’s InnoDB rejects it) |
The tests:
@Suite(.serialized) struct DeletionRuleTests {
@Test func deletingAnAuthorCascades() async throws {
try await withBookshelf { app, _ in
let db = app.db
let author = try #require(try await Author.query(on: db).filter(\.$name == "Author 1").first())
let before = (books: try await Book.query(on: db).count(), reviews: try await Review.query(on: db).count(),
pivots: try await BookTag.query(on: db).count())
let expectedBefore: [Int] = [20, 60, 40]
#expect([before.books, before.reviews, before.pivots] == expectedBefore)
try await author.delete(on: db)
// authors.id -> books.author_id -> reviews.book_id and book_tag.book_id, all ON DELETE CASCADE
let after = (books: try await Book.query(on: db).count(), reviews: try await Review.query(on: db).count(),
pivots: try await BookTag.query(on: db).count(), tags: try await Tag.query(on: db).count())
let expectedAfter: [Int] = [16, 48, 32, 4]
#expect([after.books, after.reviews, after.pivots, after.tags] == expectedAfter) // the other side of the pivot (tags) survives
}
}
@Test func restrictBlocksAndSetNullClears() async throws {
try await withBookshelf { app, _ in
let db = app.db
let book = try #require(try await Book.query(on: db).first())
let publisher = Publisher(name: "Acme Press")
try await publisher.create(on: db)
try await publisher.$contact.create(PublisherContact(email: "press@example.com", publisherID: publisher.requireID()), on: db)
try await Edition(label: "First", bookID: book.requireID(), publisherID: publisher.requireID()).create(on: db)
// publisher_contacts.publisher_id is ON DELETE RESTRICT
await #expect(throws: (any Error).self) { try await publisher.delete(on: db) }
let contact = try #require(try await publisher.$contact.get(on: db))
try await contact.delete(on: db)
// editions.publisher_id is ON DELETE SET NULL
try await publisher.delete(on: db)
let edition = try #require(try await Edition.query(on: db).first())
#expect(edition.$publisher.id == nil)
let reloaded = try #require(try await Edition.query(on: db).with(\.$publisher).first())
#expect(reloaded.publisher == nil)
}
}
@Test func oneToOneIsUniqueOnTheForeignKey() async throws {
try await withBookshelf { app, _ in
let publisher = Publisher(name: "Acme Press")
try await publisher.create(on: app.db)
try await publisher.$contact.create(PublisherContact(email: "a@example.com", publisherID: publisher.requireID()), on: app.db)
await #expect(throws: (any Error).self) {
try await publisher.$contact.create(PublisherContact(email: "b@example.com", publisherID: publisher.requireID()), on: app.db)
}
}
}
}
Points that follow, and mistakes that come from missing them:
-
Fluent does not cascade.
author.delete(on: db)sends oneDELETE FROM authors WHERE id = ?. Everything else is the database’s work, so the rule must exist in the schema. SQLite only enforces foreign keys whenPRAGMA foreign_keysis on; the Fluent SQLite driver turns it on for its connections (the.cascadeand.restrictresults above are the evidence), but a database opened by another tool may not have it. -
The default is not what you want.
.references("authors", "id")withoutonDeleteis.noAction: deleting an author who has books fails..cascadeis right for owned data (a book’s reviews and tag links);.restrictfor data that must not disappear silently (an author with books, when the business rule says so);.setNullfor optional links. -
Cascade multiplies. Deleting one
Usercascades throughreviewsandloans; deleting oneAuthorcascades throughbooks, thenreviews,book_tagandloans. Soft delete (@Timestamp(on: .delete)) is a Fluent feature that sets a column and fires no database rule, so related rows are not touched; the Transactions, Model Middleware & Soft Delete page covers it. -
A rule cannot be changed in place. Altering
onDeletemeans dropping the foreign key constraint and creating it again in a schema-changing migration (the Migrations & Schema page). -
MongoDB has no foreign keys (see the MongoDB page). The relation API works (it filters on the stored ID), but
onDeleteand uniqueness of the pivot do not exist; deletion of children, if you need it, is application code (preferably in a transaction on a replica set). That was not run here. -
The pivot’s rule matters as much as the parents'.
book_tagis declared with.cascadeon both foreign keys, so deleting a tag or a book removes only the link rows, never the other side. Declare.restrictthere and a tag could not be deleted while any book uses it.
Swift 6 and concurrency
-
Models are
final class ...: Model, @unchecked Sendable. The class holds mutable state (every relation’s cachedvalue, every field), and Fluent mutates it while loading, so the compiler cannot verify that it is safe;@unchecked Sendableis a promise that you do not use one instance from two tasks at once. Thedb.transactionclosure is@Sendable, and capturing aBookin it compiles only because of that promise. -
get(on:),load(on:)and eager loading write into the model’s property objects. Callingbook.$author.getandbook.$tags.getin twoasync lettasks touches two property objects of the same model; it works today, but the compiler cannot see it, and it buys nothing: the two statements share one connection pool. Prefer one eager load. -
Convert models to
Sendablevalue types (the DTO structs) before they leave the handler or cross to an actor, a background job or a response stream. AContentstruct is checked by the compiler; a model is not. -
Do not fan out
getover many models withwithThrowingTaskGroupto "speed up" an N+1: it keeps N+1 statements and can exhaust the connection pool. The fix is fewer statements (with,join), not more concurrency. -
The async API (
get,load,attach,detach,create,isAttached) is the one to use. TheEventLoopFuture-returning methods in FluentKit are the legacy flavour of the same calls (they bridge to the async ones internally, as the 1.57.0 sources show).
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
The process stops with "Parent relation not eager loaded, use $ prefix to access" |
|
The process stops with "Cannot query siblings relation … from unsaved model." |
A relation operation was called on a model that was batch-created with |
|
The owner has not been saved. |
|
The same pair was attached twice and the pivot has a unique constraint (as it should). Use |
|
A |
|
The parent was not saved before its ID was used. |
A response with |
The relation was not eager loaded and the model was encoded directly. Eager load and map to a DTO. |
One endpoint issues hundreds of statements |
A |
Compiler: "value of type 'NestedEagerLoadBuilder<…>' has no member 'filter'" |
Eager loads cannot be constrained. Filter in Swift, query the relation per owner, or |
Two children for a supposed one-to-one |
|
|
The foreign key column is |
Sibling pairs duplicated |
The pivot lacks |
Running the examples
The scratch package used for this page is the template with Fluent added. The dependency part of its Package.swift
(the scratch copy pins exact: for reproducibility; an application normally uses from:):
// swift-tools-version:6.3
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.13.0"),
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.9.0"),
],
targets: [
.executableTarget(name: "App", dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
]),
.testTarget(name: "AppTests", dependencies: [
.target(name: "App"),
.product(name: "VaporTesting", package: "vapor"),
]),
]
Run the suite, then the server with SQL logging on, and count statements per request:
swift test # all tests, in-memory SQLite
LOG_LEVEL=info swift run App serve --hostname 127.0.0.1 --port 8089 > serve.log 2>&1 &
for p in simple/lazy simple/eager simple/join detail/lazy detail/eager; do
curl -s -o /dev/null -w "$p %{http_code}\n" http://127.0.0.1:8089/books/$p
done
grep -E '^\[ INFO \] (GET|Executing)' serve.log \
| awk '/\] GET /{if(n!="")print n, c; n=$5; c=0; next} /Executing query/{c++} END{print n, c}'
simple/lazy 200
simple/eager 200
simple/join 200
detail/lazy 200
detail/eager 200
/books/simple/lazy 21
/books/simple/eager 2
/books/simple/join 1
/books/detail/lazy 121
/books/detail/eager 5
The server’s start-up statements (migrations and seeding) are logged before the first request and are not counted.
swift test ran ten tests in four suites (query counts, deletion rules, encoding and the relation operations), all
passing.
Version notes
-
Fluent 3 to 4. Vapor 3 used generic relation types and accessors such as
parent,childrenandsiblingson the model and futures everywhere. Fluent 4 (Vapor 4.0, 2020) introduced the property wrappers, the projected$value, key-path basedwithandjoin, andquery(on: req.db). -
@OptionalChildis from March 2021;attachIfNeeded/isAttachedare from February 2020 (the present spelling isattach(_:method: .ifNotExists, on:)); the pivot-editing closure became able to read the attached model and to beasync throwsin 2023. The composite-key relation wrappers exist in 1.57.0. -
Async API. The relation methods have
async throwsoverloads (get,load,create,attach,detach,detachAll,isAttached); the future-returning versions remain. -
Vapor 5. The latest Vapor 5 is 5.0.0-beta.4 and Fluent has no 5.x release (fluent 4.13.0 and fluent-kit 1.57.0 are the newest tags; a
fluent-5branch exists in the fluent-kit repository). Expect the relation API to be source-compatible in spirit, but treat any claim about Fluent on Vapor 5 as unverified until a release exists.
Where the books are outdated
-
Condon et al. (Server-Side Swift with Vapor, Kodeco) teaches
@Parent,@Children(chapter 9) and@Siblingswith a pivot,attach,detachand cascade deletes (chapter 10) on the Vapor 4.0 API, withEventLoopFuturechains (flatMap,map,transform) where this page usesasync/await. The relation model is unchanged; the signatures areasync throwsnow, the pivot closure can beasync, and.ifNotExistsis the idempotent attach. Chapter 30 (nested and eager loading, joins, soft delete) belongs to the part of the book that the early-access edition still has on Vapor 3 and Swift 4.2, so its code must be rewritten withwith,joinand the@Timestamp(on: .delete)soft delete rather than copied. -
Patel (Hands-On Full-Stack Development with Swift, Packt) is Vapor 2 with Fluent 2: relations are
Modelmethods, there are no property wrappers, and chapter 5 loads related rows one at a time while serialising (the N+1 pattern measured above) and deletes parents without cascading to children. In Fluent 4 usewithandonDelete: .cascade. -
All three books predate the habit of counting statements. None of them shows
sqlLogLevelor tests that assert a query budget; the recorder and the assertions above are the replacement.
References
-
Vapor docs: Fluent relations and its eager loading section
-
vapor/fluent-kit 1.57.0: Sources/FluentKit/Properties (the relation property wrappers) and QueryBuilder+EagerLoad.swift
-
vapor/fluent-sqlite-driver 4.9.0 and vapor/sqlite-kit (statement logging)
-
fluent-kit pull request 419: Add @OptionalChild and 151: isAttached and attachIfNeeded
-
Kodeco: Server-Side Swift with Vapor (Condon et al.)