Relations
Quark relations are ordinary struct fields with a rel tag — nothing is hidden
behind generated methods, so the foreign keys, the relation fields, and the
columns all sit in one place:
type User struct {
ID int64 `db:"id" pk:"true"`
Email string `db:"email"`
Profile *Profile `rel:"has_one" join:"user_id"`
Posts []Post `rel:"has_many" join:"user_id"`
}
type Profile struct {
ID int64 `db:"id" pk:"true"`
UserID int64 `db:"user_id"`
Bio string `db:"bio"`
}
type Post struct {
ID int64 `db:"id" pk:"true"`
UserID int64 `db:"user_id"`
Title string `db:"title"`
}
A rel field isn't a column. You fill it with Preload, and Create/Update
can persist it recursively.
The tag matrix
| Relation | Field shape | FK lives on | Tags |
|---|---|---|---|
| Has one | Profile *Profile | related table | rel:"has_one" join:"user_id" |
| Has many | Posts []Post | related table | rel:"has_many" join:"user_id" |
| Belongs to | Team *Team | current table | rel:"belongs_to" join:"team_id" |
| Many to many | Roles []Role | join table | rel:"many_to_many" m2m:"user_roles:user_id:role_id" |
| Polymorphic | Comments []Comment | related table | rel:"polymorphic" polymorphic:"poly_type:post" join:"polyable_id" |
Omit join and Quark infers it: belongs_to from Order to User infers
user_id, and has_one/has_many from User to Post infers user_id as
well. Being explicit is clearer on legacy or unconventional schemas.
Eager loading with Preload
Preload loads related rows in extra batched queries instead of one query
per parent row, so the query count grows with the number of relations you
preload, not with the number of parents:
users, err := quark.For[User](ctx, client).
Where("active", "=", true).
Preload("Profile", "Posts").
List()
Nested preload
Dotted paths walk multiple levels in one chain:
authors, err := quark.For[Author](ctx, client).
Preload("Posts.Comments").
List()
fmt.Println(len(authors[0].Posts), "posts;", len(authors[0].Posts[0].Comments), "comments on the first")
// => 2 posts; 2 comments on the first
// loads authors, then posts, then comments — three IN-batched SELECTs total.
Paths sharing a prefix are merged, so Preload("Posts", "Posts.Comments")
fetches Posts only once. Each segment is a Go field name, not a db tag;
an unknown segment surfaces as relation X not found at runtime.
Eager loading splits parent keys into chunks of 1000 before each SELECT. That
cap is sized for Oracle's 1000-element IN ceiling and SQL Server's roughly
2100 bind parameters. Tenant predicates and polymorphic discriminators are
re-applied on every chunk.
Belongs to
Use belongs_to when the current model holds the foreign key. On a recursive
save, Quark inserts the dependency first, then copies its key into the
parent:
order := Order{
User: &User{Email: "alice@example.com", Name: "Alice"},
Total: 4200,
}
quark.For[Order](ctx, client).Create(&order)
fmt.Println(order.UserID)
// => 1 (User was inserted first, and its id copied into order.UserID)
Has one and has many
Use these when the related table holds the foreign key. Quark saves the parent first, then writes its key into each child:
author := Author{
Name: "Ada",
Profile: &Profile{Bio: "Compiler notes"},
Posts: []Post{{Title: "Parsing"}, {Title: "Optimization"}},
}
quark.For[Author](ctx, client).Create(&author)
loaded, _ := quark.For[Author](ctx, client).Preload("Profile", "Posts").List()
fmt.Println(len(loaded[0].Posts), "posts; profile loaded:", loaded[0].Profile != nil)
// => 2 posts; profile loaded: true
This is handy for aggregate-style writes; for tightly controlled domain logic you can still save each table explicitly inside a transaction.
Many to many
type User struct {
ID int64 `db:"id" pk:"true"`
Email string `db:"email"`
Roles []Role `rel:"many_to_many" m2m:"user_roles:user_id:role_id"`
}
type Role struct {
ID int64 `db:"id" pk:"true"`
Name string `db:"name"`
}
The m2m tag is join_table:this_model_fk:related_model_fk. Running
client.Migrate(ctx, &User{}, &Role{}) creates users, roles, and the
user_roles join table (with a composite primary key over the two link
columns). Creating a user with roles saves the new roles and inserts the join
rows:
user := User{
Email: "alice@example.com",
Roles: []Role{{Name: "admin"}, {Name: "editor"}},
}
quark.For[User](ctx, client).Create(&user)
withRoles, _ := quark.For[User](ctx, client).Preload("Roles").List()
fmt.Println(len(withRoles[0].Roles), "roles")
// => 2 roles
Linking is idempotent: re-saving the same (user, role) pair does nothing.
Quark recognizes the join table's unique-key violation and returns nil, so you
don't have to deduplicate input yourself.
Any other driver error — a foreign-key violation, a missing table, a broken
connection — is wrapped with a linkM2M: prefix and propagated, with the
underlying error still reachable through errors.Unwrap.
rel:"many_to_many"rel:"m2m" is accepted by the eager-loading path, but Migrate and recursive
saves expect the long form. With rel:"m2m" the join table won't be created and
links won't be written — so write rel:"many_to_many" in new models.
Polymorphic relations
A polymorphic relation uses a discriminator column plus a parent-ID column:
type Comment struct {
ID int64 `db:"id" pk:"true"`
Body string `db:"body"`
PolyableID int64 `db:"polyable_id"`
PolyType string `db:"poly_type"`
}
type Post struct {
ID int64 `db:"id" pk:"true"`
Title string `db:"title"`
Comments []Comment `rel:"polymorphic" polymorphic:"poly_type:post" join:"polyable_id"`
}
polymorphic:"poly_type:post" reads as: poly_type is the discriminator column
on comments, post is this parent's discriminator value, and join:"polyable_id"
is the parent-ID column. Preload("Comments") then loads comments where
poly_type = 'post' AND polyable_id IN (...):
posts, err := quark.For[Post](ctx, client).Preload("Comments").List()
Multi-tenant relations
When TenantRouter uses row-level isolation, Quark propagates the tenant filter
into relation loading if the related model has the tenant column — so a
parent can't preload another tenant's children:
type Post struct {
ID int64 `db:"id" pk:"true"`
TenantID string `db:"tenant_id"`
UserID int64 `db:"user_id"`
Title string `db:"title"`
}
Add the configured tenant column to every related model you preload under row-level isolation.
Common pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
relation X not found | Preload uses the Go field name, not the table name. | Preload("Posts"), not Preload("posts"). |
| Empty relation slice | Parent or foreign key is zero. | Ensure parent rows have persisted primary keys. |
| Join table missing | Used rel:"m2m", or forgot to Migrate the parent. | Use rel:"many_to_many" and run Migrate. |
| Belongs-to FK not set | Related value is zero or the field was omitted. | Provide the related struct, or set the FK manually. |
| Cross-tenant preload leak | Related model lacks the tenant column. | Add the tenant column to related models under row-level isolation. |
For write-heavy workflows with complex invariants, wrap association saves in
client.Tx so the parent, children, and join rows commit or roll back together.