Skip to main content

SaveChanges

Change what you loaded, then call SaveChanges. ant compares each entity with a snapshot it took when loading it, and writes one minimal UPDATE per changed entity, all in one transaction.

The basics​

user, err := db.Users.Find(ctx, id)
if err != nil {
return err
}

user.Age++

err = db.SaveChanges(ctx)
UPDATE "users" SET "age" = $1, "updated_at" = $2 WHERE "users"."id" = $3

Only the changed column goes out, so a value someone else changed in the meantime isn't overwritten with your stale copy. If nothing changed, no statement is sent at all.

Zero values are real​

The comparison is against what was loaded, so 0, "", false, and nil are changes like any other.

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

user.Age = 0
user.Bio = nil

err = db.SaveChanges(ctx)

A write through a pointer is seen too, and so is a change inside an ant.JSON value.

What is tracked​

All, First, Single, and Find track their results, and so do Insert and the rows an Include loads. All returns pointers, so changes in a range count.

users, err := db.Users.All(ctx)

for _, user := range users {
user.Age = 50
}

err = db.SaveChanges(ctx)

These aren't tracked, so changing them saves nothing:

Results ofWhy
EachIt yields values, one at a time
Column, OmitTheir zero fields aren't real values
NoTrackingYou asked
ant.Raw, FromRaw, Project, SelectThey aren't whole entities

Several entities​

SaveChanges covers everything the context tracks, in one transaction, parents before children on insert and children first on delete.

user.Age = 30
post.Title = "Edited"

err := db.SaveChanges(ctx) // two UPDATEs, one transaction

Is anything pending?​

user.Age = 60

db.HasChanges() // true

One instance per row​

A context holds one instance per row, so a later query returns the same pointer, with your unsaved changes.

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

first.Age = 70

again, err := db.Users.Where(ant.Eq(&U.ID, id)).Single(ctx)

fmt.Println(first == again, again.Age) // true 70

A bulk Update doesn't touch tracked entities. Reload picks up database changes, and they become the new baseline.

Turning it off​

NoTracking opts one query out, in any position in the chain. WithTracking(false) turns tracking off for a whole context.

db, err := ant.Open[AppDB](ctx, postgres.Dialect, dsn, ant.WithTracking(false))

session := ant.Session(db, ant.WithTracking(true))

That second line is a nice pattern: a shared context that never grows, and tracking only in the sessions that actually save.

Custom comparers​

Deep equality decides what changed. b.Comparer overrides it, for values where that is wrong, such as a list whose order means nothing.

type Tagged struct {
ID int64
Items ant.JSON[[]string]
}

func (Tagged) Configure(b *ant.ModelBuilder[Tagged]) {
b.Comparer(&TG.Items, sameItems)
}

func sameItems(a, b ant.JSON[[]string]) bool {
return slices.Equal(slices.Sorted(slices.Values(a.V)), slices.Sorted(slices.Values(b.V)))
}

var TG = ant.Fields[Tagged]()
A context remembers everything it loads

A tracker keeps growing until it's cleared, and a shared one mixes up concurrent requests. Use a session per unit of work.