Skip to main content

Loading relations

Loading is always explicit and always batched. Reading a field never runs a query, so whatever a page costs is written right there in the code.

Include​

Include loads a []T relation, and IncludeOne loads a *T one. Each runs one extra query, however many parents there are.

users, err := db.Users.
OrderBy(&U.Name).
Include(&U.Posts).
All(ctx)

fmt.Println(users[0].Posts[0].Title)

posts, err := db.Posts.
IncludeOne(&P.User).
All(ctx)

fmt.Println(posts[0].User.Name)
SELECT ... FROM "users" ORDER BY "users"."name"
SELECT ... FROM "posts" WHERE "posts"."user_id" IN ($1, $2, $3)

It works with All, First, Single, Find, and Chunk. Rows with one parent share it.

Nil means not loaded​

This is how you tell "not loaded" from "there are none".

user, err := db.Users.Find(ctx, id)

fmt.Println(user.Posts == nil) // true: not loaded

loaded, err := db.Users.Include(&U.Posts).Find(ctx, id)

fmt.Println(loaded.Posts != nil) // true: loaded, and maybe empty

Shaping what loads​

The optional function shapes the related query, with the same methods as any other query.

users, err := db.Users.
Include(&U.Posts, func(q *ant.Query[Post]) *ant.Query[Post] {
return q.Where(ant.Eq(&P.Published, true)).OrderByDesc(&P.CreatedAt)
}).
All(ctx)

Limit and Offset apply per parent, so this gives you the three newest posts of each user, not three posts in total.

users, err := db.Users.
Include(&U.Posts, func(q *ant.Query[Post]) *ant.Query[Post] {
return q.OrderByDesc(&P.CreatedAt).Limit(3)
}).
All(ctx)

Column picks columns, and the foreign key is always kept. IncludeOne takes a shape too, and a filter that excludes the parent leaves nil.

Going deeper​

Include inside the function loads the next level, down or up.

users, err := db.Users.
Include(&U.Posts, func(q *ant.Query[Post]) *ant.Query[Post] {
return q.Include(&P.Comments)
}).
All(ctx)

One statement, with a join​

IncludeJoined reads a parent in the same statement, through a LEFT JOIN, instead of the extra query IncludeOne runs. A parent it doesn't find stays nil, and the row survives.

posts, err := db.Posts.IncludeJoined(&P.User).OrderBy(&P.Title).All(ctx)

Loading later​

Load fills relations on entities you already have, in one query.

users, err := db.Users.OrderBy(&U.Name).All(ctx)

err = db.Users.Load(ctx, &U.Posts, users...)
err = db.Posts.LoadOne(ctx, &P.User, post)

LoadMissing skips entities whose relation is already loaded, and ant.Pointers adapts a []T, as from ant.Raw.

rows, err := ant.Raw[User](db, `SELECT * FROM users`).All(ctx)

err = db.Users.LoadMissing(ctx, &U.Posts, ant.Pointers(rows)...)

ant.Related starts a query from one entity's relation, which you can refine, count, or delete. ant.RelatedOne does it for a *T relation.

drafts, err := ant.Related(db, alice, &U.Posts).
Where(ant.Eq(&P.Published, false)).
OrderBy(&P.Title).
All(ctx)

author, err := ant.RelatedOne(db, post, &P.User).Single(ctx)

Counts and sums​

Fill a computed field with an aggregate of a relation, in one query, with no rows loaded.

type Writer struct {
ID int64
Articles []Article
ArticleCount int64 `ant:"computed"`
TotalViews int `ant:"computed"`
AverageViews sql.Null[float64] `ant:"computed"`
}

type Article struct {
ID int64
WriterID int64
Views int
Live bool
}

var (
W = ant.Fields[Writer]()
A = ant.Fields[Article]()
)

writers, err := db.Writers.
With(&W.ArticleCount, ant.CountRelated(&W.Articles, ant.Eq(&A.Live, true))).
With(&W.TotalViews, ant.SumRelated(&W.Articles, &A.Views)).
With(&W.AverageViews, ant.AvgRelated(&W.Articles, &A.Views)).
All(ctx)

CountRelated and SumRelated are zero with no rows. AvgRelated, MinRelated, and MaxRelated are NULL, so they need a nullable field.