changeset.go

Summary

Maintainability
A
55 mins
Test Coverage
A
100%
package rel

import (
    "bytes"
    "reflect"
    "time"
)

type pair = [2]any

// Changeset mutator for structs.
// This allows REL to efficiently to perform update operation only on updated fields and association.
// The catch is, enabling changeset will duplicates the original struct values which consumes more memory.
type Changeset struct {
    doc       *Document
    snapshot  []any
    assoc     map[string]Changeset
    assocMany map[string]map[any]Changeset
}

func (c Changeset) valueChanged(typ reflect.Type, old any, new any) bool {
    if oeq, ok := old.(interface{ Equal(any) bool }); ok {
        return !oeq.Equal(new)
    }

    if ot, ok := old.(time.Time); ok {
        return !ot.Equal(new.(time.Time))
    }

    if typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.Uint8 {
        return !bytes.Equal(reflect.ValueOf(old).Bytes(), reflect.ValueOf(new).Bytes())
    }

    return !(typ.Comparable() && old == new)
}

// FieldChanged returns true if field exists and it's already changed.
// returns false otherwise.
func (c Changeset) FieldChanged(field string) bool {
    for i, f := range c.doc.Fields() {
        if f == field {
            var (
                typ, _ = c.doc.Type(field)
                old    = c.snapshot[i]
                new, _ = c.doc.Value(field)
            )

            return c.valueChanged(typ, old, new)
        }
    }

    return false
}

// Changes returns map of changes.
func (c Changeset) Changes() map[string]any {
    return buildChanges(c.doc, c)
}

// Apply mutation.
func (c Changeset) Apply(doc *Document, mut *Mutation) {
    var (
        t = Now()
    )

    for i, field := range c.doc.Fields() {
        var (
            typ, _ = c.doc.Type(field)
            old    = c.snapshot[i]
            new, _ = c.doc.Value(field)
        )

        if c.valueChanged(typ, old, new) {
            mut.Add(Set(field, new))
        }
    }

    if !mut.IsMutatesEmpty() && c.doc.Flag(HasUpdatedAt) && c.doc.SetValue("updated_at", t) {
        mut.Add(Set("updated_at", t))
    }

    if mut.Cascade {
        for _, field := range doc.BelongsTo() {
            c.applyAssoc(field, mut)
        }

        for _, field := range doc.HasOne() {
            c.applyAssoc(field, mut)
        }

        for _, field := range doc.HasMany() {
            c.applyAssocMany(field, mut)
        }
    }
}

func (c Changeset) applyAssoc(field string, mut *Mutation) {
    assoc := c.doc.Association(field)
    if assoc.IsZero() {
        return
    }

    doc, _ := assoc.Document()

    if ch, ok := c.assoc[field]; ok {
        if amod := Apply(doc, ch); !amod.IsEmpty() {
            mut.SetAssoc(field, amod)
        }
    } else {
        amod := Apply(doc, newStructset(doc, false))
        mut.SetAssoc(field, amod)
    }
}

func (c Changeset) applyAssocMany(field string, mut *Mutation) {
    if chs, ok := c.assocMany[field]; ok {
        var (
            assoc      = c.doc.Association(field)
            col, _     = assoc.Collection()
            muts       = make([]Mutation, 0, col.Len())
            updatedIDs = make(map[any]struct{})
            deletedIDs []any
        )

        for i := 0; i < col.Len(); i++ {
            var (
                doc    = col.Get(i)
                pValue = doc.PrimaryValue()
            )

            if ch, ok := chs[pValue]; ok {
                updatedIDs[pValue] = struct{}{}

                if amod := Apply(doc, ch); !amod.IsEmpty() {
                    muts = append(muts, amod)
                }
            } else {
                muts = append(muts, Apply(doc, newStructset(doc, false)))
            }
        }

        // leftover snapshot.
        if len(updatedIDs) != len(chs) {
            for id := range chs {
                if _, ok := updatedIDs[id]; !ok {
                    deletedIDs = append(deletedIDs, id)
                }
            }
        }

        if len(muts) > 0 || len(deletedIDs) > 0 {
            mut.SetAssoc(field, muts...)
            mut.SetDeletedIDs(field, deletedIDs)
        }
    } else {
        newStructset(c.doc, false).buildAssocMany(field, mut)
    }
}

// NewChangeset returns new changeset mutator for given entity.
func NewChangeset(entity any) Changeset {
    return newChangeset(NewDocument(entity))
}

func newChangeset(doc *Document) Changeset {
    c := Changeset{
        doc:       doc,
        snapshot:  make([]any, len(doc.Fields())),
        assoc:     make(map[string]Changeset),
        assocMany: make(map[string]map[any]Changeset),
    }

    for i, field := range doc.Fields() {
        c.snapshot[i], _ = doc.Value(field)
    }

    for _, field := range doc.BelongsTo() {
        initChangesetAssoc(doc, c.assoc, field)
    }

    for _, field := range doc.HasOne() {
        initChangesetAssoc(doc, c.assoc, field)
    }

    for _, field := range doc.HasMany() {
        initChangesetAssocMany(doc, c.assocMany, field)
    }

    return c
}

func initChangesetAssoc(doc *Document, assoc map[string]Changeset, field string) {
    doc, loaded := doc.Association(field).Document()
    if !loaded {
        return
    }

    assoc[field] = newChangeset(doc)
}

func initChangesetAssocMany(doc *Document, assoc map[string]map[any]Changeset, field string) {
    col, loaded := doc.Association(field).Collection()
    if !loaded {
        return
    }

    assoc[field] = make(map[any]Changeset)

    for i := 0; i < col.Len(); i++ {
        var (
            doc    = col.Get(i)
            pValue = doc.PrimaryValue()
        )

        if !isZero(pValue) {
            assoc[field][pValue] = newChangeset(doc)
        }
    }
}

func buildChanges(doc *Document, c Changeset) map[string]any {
    var (
        changes = make(map[string]any)
        fields  []string
    )

    if doc != nil {
        fields = doc.Fields()
    } else {
        fields = c.doc.Fields()
    }

    for i, field := range fields {
        switch {
        case doc == nil:
            if old := c.snapshot[i]; old != nil {
                changes[field] = pair{old, nil}
            }
        case i >= len(c.snapshot):
            if new, _ := doc.Value(field); new != nil {
                changes[field] = pair{nil, new}
            }
        default:
            old := c.snapshot[i]
            new, _ := doc.Value(field)
            if typ, _ := doc.Type(field); c.valueChanged(typ, old, new) {
                changes[field] = pair{old, new}
            }
        }
    }

    if doc == nil || len(c.snapshot) == 0 {
        return changes
    }

    for _, field := range doc.BelongsTo() {
        buildChangesAssoc(changes, c, field)
    }

    for _, field := range doc.HasOne() {
        buildChangesAssoc(changes, c, field)
    }

    for _, field := range doc.HasMany() {
        buildChangesAssocMany(changes, c, field)
    }

    return changes
}

func buildChangesAssoc(out map[string]any, c Changeset, field string) {
    assoc := c.doc.Association(field)
    if assoc.IsZero() {
        return
    }

    doc, _ := assoc.Document()
    if changes := buildChanges(doc, c.assoc[field]); len(changes) != 0 {
        out[field] = changes
    }
}

func buildChangesAssocMany(out map[string]any, c Changeset, field string) {
    var (
        changes    []map[string]any
        chs        = c.assocMany[field]
        assoc      = c.doc.Association(field)
        col, _     = assoc.Collection()
        updatedIDs = make(map[any]struct{})
    )

    for i := 0; i < col.Len(); i++ {
        var (
            doc          = col.Get(i)
            pValue       = doc.PrimaryValue()
            ch, isUpdate = chs[pValue]
        )

        if isUpdate {
            updatedIDs[pValue] = struct{}{}
        }

        if dChanges := buildChanges(doc, ch); len(dChanges) != 0 {
            changes = append(changes, dChanges)
        }
    }

    // leftover snapshot.
    if len(updatedIDs) != len(chs) {
        for id, ch := range chs {
            if _, ok := updatedIDs[id]; !ok {
                changes = append(changes, buildChanges(nil, ch))
            }
        }
    }

    if len(changes) != 0 {
        out[field] = changes
    }
}