Skip to main content
Version: 1.2.2

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

RelationField shapeFK lives onTags
Has oneProfile *Profilerelated tablerel:"has_one" join:"user_id"
Has manyPosts []Postrelated tablerel:"has_many" join:"user_id"
Belongs toTeam *Teamcurrent tablerel:"belongs_to" join:"team_id"
Many to manyRoles []Rolejoin tablerel:"many_to_many" m2m:"user_roles:user_id:role_id"
PolymorphicComments []Commentrelated tablerel:"polymorphic" polymorphic:"poly_type:post" join:"polyable_id"

If you omit join, Quark infers it (belongs_to from OrderUser infers user_id; has_one/has_many from UserPost infers user_id). Being explicit is clearer for legacy or non-conventional 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 — a cap sized for Oracle's 1000-element IN ceiling and SQL Server's ~2100 bind-parameter limit. Tenant predicates and polymorphic discriminators are re-applied per 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 is a no-op — Quark recognizes the join table's unique-key violation and returns nil, so you don't have to dedupe input. 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 via errors.Unwrap.

Use the long form 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 RLS.

Common pitfalls

SymptomLikely causeFix
relation X not foundPreload uses the Go field name, not the table name.Preload("Posts"), not Preload("posts").
Empty relation sliceParent or foreign key is zero.Ensure parent rows have persisted primary keys.
Join table missingUsed rel:"m2m", or forgot to Migrate the parent.Use rel:"many_to_many" and run Migrate.
Belongs-to FK not setRelated value is zero or the field was omitted.Provide the related struct, or set the FK manually.
Cross-tenant preload leakRelated model lacks the tenant column.Add the tenant column to related models under RLS.

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.