package store import ( "context" "database/sql" "errors" "sort" ) // A list's entries sit at places 1…max_items, and places can be empty: an // entry can be #28 with nothing at #29 and the next at #30. The functions // here decide where entries go; every change is written in one transaction. // // The rules, so that lists without gaps behave like an ordinary ranking: // - Moving onto an empty place just puts the entry there. Its old place // becomes empty. // - Moving onto a taken place shifts the entries in between one step // toward where it came from, stopping at the first empty place. // - Inserting (a new entry, or an empty place) pushes entries down only as // far as the next empty place, which absorbs the shift. Entries placed // further down stay put. // places maps place → entry id. type places map[int]int64 func (p places) last() int { n := 0 for r := range p { n = max(n, r) } return n } // shift moves the entries at places [from, to] by d (±1), walking in the // direction that never overwrites an entry before it's moved. func (p places) shift(from, to, d int) { if from > to { return } if d > 0 { for r := to; r >= from; r-- { if id, ok := p[r]; ok { delete(p, r) p[r+d] = id } } return } for r := from; r <= to; r++ { if id, ok := p[r]; ok { delete(p, r) p[r+d] = id } } } // makeRoom frees place at by pushing entries down to the next empty place. func (p places) makeRoom(at, limit int) error { if _, taken := p[at]; !taken { return nil } gap := at for { if _, taken := p[gap]; !taken { break } gap++ } if gap > limit { return invalid("There's no room left. This list stops at #%d; make it bigger in List settings or close up an open spot.", limit) } p.shift(at, gap-1, +1) return nil } // move takes the entry at from to place to. func (p places) move(from, to int) { id := p[from] delete(p, from) if _, taken := p[to]; taken { if to < from { gap := to + 1 // the nearest empty place below to; from is one for { if _, t := p[gap]; !t { break } gap++ } p.shift(to, gap-1, +1) } else { gap := to - 1 // the nearest empty place above to for { if _, t := p[gap]; !t { break } gap-- } p.shift(gap+1, to, -1) } } p[to] = id } // closeUp pulls the entries right below an emptied place up by one, as far // as the next empty place. func (p places) closeUp(at int) { end := at + 1 for { if _, t := p[end]; !t { break } end++ } p.shift(at+1, end-1, -1) } // compact numbers the entries 1, 2, 3… in order, removing every gap. func (p places) compact() { ranks := make([]int, 0, len(p)) for r := range p { ranks = append(ranks, r) } sort.Ints(ranks) ids := make([]int64, len(ranks)) for i, r := range ranks { ids[i] = p[r] delete(p, r) } for i, id := range ids { p[i+1] = id } } // Place is an entry's id and place, as returned after any change. type Place struct { ID int64 `json:"id"` Rank int `json:"rank"` } // placeTx loads a list's places inside a transaction. type placeTx struct { tx *sql.Tx listID int64 limit int // max_items before map[int64]int p places } func (s *Store) beginPlaces(ctx context.Context, listID int64) (*placeTx, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, err } pt := &placeTx{tx: tx, listID: listID, before: map[int64]int{}, p: places{}} err = tx.QueryRowContext(ctx, `SELECT max_items FROM lists WHERE id = ?`, listID).Scan(&pt.limit) if errors.Is(err, sql.ErrNoRows) { tx.Rollback() return nil, ErrNotFound } if err != nil { tx.Rollback() return nil, err } rows, err := tx.QueryContext(ctx, `SELECT id, rank FROM items WHERE list_id = ?`, listID) if err != nil { tx.Rollback() return nil, err } defer rows.Close() for rows.Next() { var id int64 var r int if err := rows.Scan(&id, &r); err != nil { tx.Rollback() return nil, err } pt.before[id] = r pt.p[r] = id } if err := rows.Err(); err != nil { tx.Rollback() return nil, err } return pt, nil } // write saves the places that changed, without committing. func (pt *placeTx) write(ctx context.Context) error { if last := pt.p.last(); last > pt.limit { return invalid("There's no room left. This list stops at #%d; make it bigger in List settings.", pt.limit) } stmt, err := pt.tx.PrepareContext(ctx, `UPDATE items SET rank = ? WHERE id = ?`) if err != nil { return err } defer stmt.Close() for r, id := range pt.p { if old, ok := pt.before[id]; ok && old != r { if _, err := stmt.ExecContext(ctx, r, id); err != nil { return err } } } return touchList(ctx, pt.tx, pt.listID) } // commit writes the places that changed and returns them all. func (pt *placeTx) commit(ctx context.Context) ([]Place, error) { defer pt.tx.Rollback() if err := pt.write(ctx); err != nil { return nil, err } if err := pt.tx.Commit(); err != nil { return nil, err } return pt.list(), nil } // appendPlace is where a new entry goes when no place is asked for: after // the last one, or the first empty place if the list ends there. func (p places) appendPlace(limit int) int { if last := p.last(); last < limit { return last + 1 } for r := 1; r <= limit; r++ { if _, t := p[r]; !t { return r } } return limit + 1 } func (pt *placeTx) list() []Place { out := make([]Place, 0, len(pt.p)) for r, id := range pt.p { out = append(out, Place{ID: id, Rank: r}) } sort.Slice(out, func(i, j int) bool { return out[i].Rank < out[j].Rank }) return out } func (pt *placeTx) checkPlace(at int) error { if at < 1 || at > pt.limit { return invalid("Pick a number from 1 to %d.", pt.limit) } return nil } // Places returns where each of a list's entries sits. func (s *Store) Places(ctx context.Context, listID int64) ([]Place, error) { pt, err := s.beginPlaces(ctx, listID) if err != nil { return nil, err } defer pt.tx.Rollback() return pt.list(), nil } // MoveItem puts an entry at place to (see the rules above) and returns the // list's places. func (s *Store) MoveItem(ctx context.Context, id int64, to int) ([]Place, error) { var listID int64 if err := s.db.QueryRowContext(ctx, `SELECT list_id FROM items WHERE id = ?`, id).Scan(&listID); errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } else if err != nil { return nil, err } pt, err := s.beginPlaces(ctx, listID) if err != nil { return nil, err } defer pt.tx.Rollback() if err := pt.checkPlace(to); err != nil { return nil, err } if from := pt.before[id]; from != to { pt.p.move(from, to) } return pt.commit(ctx) } // InsertPlace opens an empty place at at, pushing entries down to the next // empty place. func (s *Store) InsertPlace(ctx context.Context, listID int64, at int) ([]Place, error) { pt, err := s.beginPlaces(ctx, listID) if err != nil { return nil, err } defer pt.tx.Rollback() if err := pt.checkPlace(at); err != nil { return nil, err } if err := pt.p.makeRoom(at, pt.limit); err != nil { return nil, err } return pt.commit(ctx) } // RemovePlace deletes count empty places starting at at: everything below // moves up by count. func (s *Store) RemovePlace(ctx context.Context, listID int64, at, count int) ([]Place, error) { pt, err := s.beginPlaces(ctx, listID) if err != nil { return nil, err } defer pt.tx.Rollback() if err := pt.checkPlace(at); err != nil { return nil, err } count = max(count, 1) for r := at; r < at+count; r++ { if _, taken := pt.p[r]; taken { return nil, invalid("#%d isn't open.", r) } } for range count { pt.p.shift(at+1, pt.p.last(), -1) } return pt.commit(ctx) } // CloseGaps renumbers a list's entries 1, 2, 3… keeping their order. func (s *Store) CloseGaps(ctx context.Context, listID int64) ([]Place, error) { pt, err := s.beginPlaces(ctx, listID) if err != nil { return nil, err } defer pt.tx.Rollback() pt.p.compact() return pt.commit(ctx) } // MaxRank is the highest place used in a list, 0 when it's empty. func (s *Store) MaxRank(ctx context.Context, listID int64) (int, error) { var n int err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(rank), 0) FROM items WHERE list_id = ?`, listID).Scan(&n) return n, err }