From 5af427c23baa30e97657fae2852784d582cf2ef7 Mon Sep 17 00:00:00 2001 From: Erik Winter Date: Thu, 3 Oct 2024 07:32:48 +0200 Subject: [PATCH] local id --- plan/command/add.go | 16 +- plan/command/add_test.go | 21 +- plan/command/list.go | 31 ++- plan/main.go | 8 +- plan/storage/{memory.go => memory/event.go} | 18 +- .../{memory_test.go => memory/event_test.go} | 6 +- plan/storage/memory/localid.go | 61 ++++++ plan/storage/memory/localid_test.go | 68 ++++++ plan/storage/sqlite.go | 206 ------------------ plan/storage/sqlite/event.go | 100 +++++++++ plan/storage/sqlite/localid.go | 77 +++++++ plan/storage/sqlite/sqlite.go | 117 ++++++++++ plan/storage/storage.go | 56 ++++- plan/storage/storage_test.go | 76 +++++++ 14 files changed, 618 insertions(+), 243 deletions(-) rename plan/storage/{memory.go => memory/event.go} (75%) rename plan/storage/{memory_test.go => memory/event_test.go} (93%) create mode 100644 plan/storage/memory/localid.go create mode 100644 plan/storage/memory/localid_test.go delete mode 100644 plan/storage/sqlite.go create mode 100644 plan/storage/sqlite/event.go create mode 100644 plan/storage/sqlite/localid.go create mode 100644 plan/storage/sqlite/sqlite.go create mode 100644 plan/storage/storage_test.go diff --git a/plan/command/add.go b/plan/command/add.go index 46491b7..a91460e 100644 --- a/plan/command/add.go +++ b/plan/command/add.go @@ -44,14 +44,14 @@ var AddCmd = &cli.Command{ }, } -func NewAddCmd(repo storage.EventRepo) *cli.Command { +func NewAddCmd(localRepo storage.LocalID, eventRepo storage.Event) *cli.Command { AddCmd.Action = func(cCtx *cli.Context) error { - return Add(cCtx.String("name"), cCtx.String("on"), cCtx.String("at"), cCtx.String("for"), repo) + return Add(localRepo, eventRepo, cCtx.String("name"), cCtx.String("on"), cCtx.String("at"), cCtx.String("for")) } return AddCmd } -func Add(nameStr, onStr, atStr, frStr string, repo storage.EventRepo) error { +func Add(localIDRepo storage.LocalID, eventRepo storage.Event, nameStr, onStr, atStr, frStr string) error { if nameStr == "" { return fmt.Errorf("%w: name is required", ErrInvalidArg) } @@ -91,9 +91,17 @@ func Add(nameStr, onStr, atStr, frStr string, repo storage.EventRepo) error { } e.Duration = fr } - if err := repo.Store(e); err != nil { + if err := eventRepo.Store(e); err != nil { return fmt.Errorf("could not store event: %v", err) } + localID, err := localIDRepo.Next() + if err != nil { + return fmt.Errorf("could not create next local id: %v", err) + } + if err := localIDRepo.Store(e.ID, localID); err != nil { + return fmt.Errorf("could not store local id: %v", err) + } + return nil } diff --git a/plan/command/add_test.go b/plan/command/add_test.go index f1b16a5..8c9a426 100644 --- a/plan/command/add_test.go +++ b/plan/command/add_test.go @@ -7,7 +7,7 @@ import ( "github.com/google/go-cmp/cmp" "go-mod.ewintr.nl/planner/item" "go-mod.ewintr.nl/planner/plan/command" - "go-mod.ewintr.nl/planner/plan/storage" + "go-mod.ewintr.nl/planner/plan/storage/memory" ) func TestAdd(t *testing.T) { @@ -104,21 +104,34 @@ func TestAdd(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - mem := storage.NewMemory() - actErr := command.Add(tc.args["name"], tc.args["on"], tc.args["at"], tc.args["for"], mem) != nil + eventRepo := memory.NewEvent() + localRepo := memory.NewLocalID() + actErr := command.Add(localRepo, eventRepo, tc.args["name"], tc.args["on"], tc.args["at"], tc.args["for"]) != nil if tc.expErr != actErr { t.Errorf("exp %v, got %v", tc.expErr, actErr) } if tc.expErr { return } - actEvents, err := mem.FindAll() + actEvents, err := eventRepo.FindAll() if err != nil { t.Errorf("exp nil, got %v", err) } if len(actEvents) != 1 { t.Errorf("exp 1, got %d", len(actEvents)) } + + actLocalIDs, err := localRepo.FindAll() + if err != nil { + t.Errorf("exp nil, got %v", err) + } + if len(actLocalIDs) != 1 { + t.Errorf("exp 1, got %v", len(actLocalIDs)) + } + if _, ok := actLocalIDs[actEvents[0].ID]; !ok { + t.Errorf("exp true, got %v", ok) + } + if actEvents[0].ID == "" { t.Errorf("exp string not te be empty") } diff --git a/plan/command/list.go b/plan/command/list.go index 02b150f..417c433 100644 --- a/plan/command/list.go +++ b/plan/command/list.go @@ -13,22 +13,29 @@ var ListCmd = &cli.Command{ Usage: "List everything", } -func NewListCmd(repo storage.EventRepo) *cli.Command { - ListCmd.Action = NewListAction(repo) +func NewListCmd(localRepo storage.LocalID, eventRepo storage.Event) *cli.Command { + ListCmd.Action = func(cCtx *cli.Context) error { + return List(localRepo, eventRepo) + } return ListCmd } -func NewListAction(repo storage.EventRepo) func(*cli.Context) error { - return func(cCtx *cli.Context) error { - all, err := repo.FindAll() - if err != nil { - return err +func List(localRepo storage.LocalID, eventRepo storage.Event) error { + localIDs, err := localRepo.FindAll() + if err != nil { + return fmt.Errorf("could not get local ids: %v", err) + } + all, err := eventRepo.FindAll() + if err != nil { + return err + } + for _, e := range all { + lid, ok := localIDs[e.ID] + if !ok { + return fmt.Errorf("could not find local id for %s", e.ID) } - for _, e := range all { - fmt.Printf("%s\t%s\t%s\t%s\n", e.ID, e.Title, e.Start.Format(time.DateTime), e.Duration.String()) - } - - return nil + fmt.Printf("%s\t%d\t%s\t%s\t%s\n", e.ID, lid, e.Title, e.Start.Format(time.DateTime), e.Duration.String()) } + return nil } diff --git a/plan/main.go b/plan/main.go index 6dadbc5..4df6f3b 100644 --- a/plan/main.go +++ b/plan/main.go @@ -7,7 +7,7 @@ import ( "github.com/urfave/cli/v2" "go-mod.ewintr.nl/planner/plan/command" - "go-mod.ewintr.nl/planner/plan/storage" + "go-mod.ewintr.nl/planner/plan/storage/sqlite" "gopkg.in/yaml.v3" ) @@ -23,7 +23,7 @@ func main() { os.Exit(1) } - repo, err := storage.NewSqlite(conf.DBPath) + localIDRepo, eventRepo, err := sqlite.NewSqlites(conf.DBPath) if err != nil { fmt.Printf("could not open db file: %s\n", err) os.Exit(1) @@ -33,8 +33,8 @@ func main() { Name: "plan", Usage: "Plan your day with events", Commands: []*cli.Command{ - command.NewAddCmd(repo), - command.NewListCmd(repo), + command.NewAddCmd(localIDRepo, eventRepo), + command.NewListCmd(localIDRepo, eventRepo), }, } diff --git a/plan/storage/memory.go b/plan/storage/memory/event.go similarity index 75% rename from plan/storage/memory.go rename to plan/storage/memory/event.go index 2100eb7..32214ed 100644 --- a/plan/storage/memory.go +++ b/plan/storage/memory/event.go @@ -1,4 +1,4 @@ -package storage +package memory import ( "errors" @@ -8,18 +8,18 @@ import ( "go-mod.ewintr.nl/planner/item" ) -type Memory struct { +type Event struct { events map[string]item.Event mutex sync.RWMutex } -func NewMemory() *Memory { - return &Memory{ +func NewEvent() *Event { + return &Event{ events: make(map[string]item.Event), } } -func (r *Memory) Find(id string) (item.Event, error) { +func (r *Event) Find(id string) (item.Event, error) { r.mutex.RLock() defer r.mutex.RUnlock() @@ -30,7 +30,7 @@ func (r *Memory) Find(id string) (item.Event, error) { return event, nil } -func (r *Memory) FindAll() ([]item.Event, error) { +func (r *Event) FindAll() ([]item.Event, error) { r.mutex.RLock() defer r.mutex.RUnlock() @@ -45,15 +45,16 @@ func (r *Memory) FindAll() ([]item.Event, error) { return events, nil } -func (r *Memory) Store(e item.Event) error { +func (r *Event) Store(e item.Event) error { r.mutex.Lock() defer r.mutex.Unlock() r.events[e.ID] = e + return nil } -func (r *Memory) Delete(id string) error { +func (r *Event) Delete(id string) error { r.mutex.Lock() defer r.mutex.Unlock() @@ -61,5 +62,6 @@ func (r *Memory) Delete(id string) error { return errors.New("event not found") } delete(r.events, id) + return nil } diff --git a/plan/storage/memory_test.go b/plan/storage/memory/event_test.go similarity index 93% rename from plan/storage/memory_test.go rename to plan/storage/memory/event_test.go index f9d7e22..54645c3 100644 --- a/plan/storage/memory_test.go +++ b/plan/storage/memory/event_test.go @@ -1,4 +1,4 @@ -package storage +package memory import ( "testing" @@ -7,10 +7,10 @@ import ( "go-mod.ewintr.nl/planner/item" ) -func TestMemory(t *testing.T) { +func TestEvent(t *testing.T) { t.Parallel() - mem := NewMemory() + mem := NewEvent() t.Log("empty") actEvents, actErr := mem.FindAll() diff --git a/plan/storage/memory/localid.go b/plan/storage/memory/localid.go new file mode 100644 index 0000000..40024b1 --- /dev/null +++ b/plan/storage/memory/localid.go @@ -0,0 +1,61 @@ +package memory + +import ( + "sync" + + "go-mod.ewintr.nl/planner/plan/storage" +) + +type LocalID struct { + ids map[string]int + mutex sync.RWMutex +} + +func NewLocalID() *LocalID { + return &LocalID{ + ids: make(map[string]int), + } +} + +func (ml *LocalID) FindAll() (map[string]int, error) { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + return ml.ids, nil +} + +func (ml *LocalID) Next() (int, error) { + ml.mutex.RLock() + defer ml.mutex.RUnlock() + + cur := make([]int, 0, len(ml.ids)) + for _, i := range ml.ids { + cur = append(cur, i) + } + + localID := storage.NextLocalID(cur) + + return localID, nil +} + +func (ml *LocalID) Store(id string, localID int) error { + ml.mutex.Lock() + defer ml.mutex.Unlock() + + ml.ids[id] = localID + + return nil +} + +func (ml *LocalID) Delete(id string) error { + ml.mutex.Lock() + defer ml.mutex.Unlock() + + if _, ok := ml.ids[id]; !ok { + return storage.ErrNotFound + } + + delete(ml.ids, id) + + return nil +} diff --git a/plan/storage/memory/localid_test.go b/plan/storage/memory/localid_test.go new file mode 100644 index 0000000..02d060e --- /dev/null +++ b/plan/storage/memory/localid_test.go @@ -0,0 +1,68 @@ +package memory_test + +import ( + "errors" + "testing" + + "github.com/google/go-cmp/cmp" + "go-mod.ewintr.nl/planner/plan/storage" + "go-mod.ewintr.nl/planner/plan/storage/memory" +) + +func TestLocalID(t *testing.T) { + t.Parallel() + + repo := memory.NewLocalID() + + t.Log("start empty") + actIDs, actErr := repo.FindAll() + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + if len(actIDs) != 0 { + t.Errorf("exp nil, got %v", actErr) + } + + t.Log("next id") + actNext, actErr := repo.Next() + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + if actNext != 1 { + t.Errorf("exp 1, got %v", actNext) + } + + t.Log("store") + if actErr = repo.Store("test", 1); actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + + actIDs, actErr = repo.FindAll() + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + expIDs := map[string]int{ + "test": 1, + } + if diff := cmp.Diff(expIDs, actIDs); diff != "" { + t.Errorf("(exp +, got -)\n%s", diff) + } + + t.Log("delete") + if actErr = repo.Delete("test"); actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + actIDs, actErr = repo.FindAll() + if actErr != nil { + t.Errorf("exp nil, got %v", actErr) + } + if len(actIDs) != 0 { + t.Errorf("exp 0, got %v", actErr) + } + + t.Log("delete non-existing") + actErr = repo.Delete("non-existing") + if !errors.Is(actErr, storage.ErrNotFound) { + t.Errorf("exp %v, got %v", storage.ErrNotFound, actErr) + } +} diff --git a/plan/storage/sqlite.go b/plan/storage/sqlite.go deleted file mode 100644 index 86c13e5..0000000 --- a/plan/storage/sqlite.go +++ /dev/null @@ -1,206 +0,0 @@ -package storage - -import ( - "database/sql" - "errors" - "fmt" - "time" - - "go-mod.ewintr.nl/planner/item" - _ "modernc.org/sqlite" -) - -const ( - timestampFormat = "2006-01-02 15:04:05" -) - -var migrations = []string{ - `CREATE TABLE events ("id" TEXT UNIQUE, "title" TEXT, "start" TIMESTAMP, "duration" TEXT)`, - `PRAGMA journal_mode=WAL`, - `PRAGMA synchronous=NORMAL`, - `PRAGMA cache_size=2000`, -} - -var ( - ErrInvalidConfiguration = errors.New("invalid configuration") - ErrIncompatibleSQLMigration = errors.New("incompatible migration") - ErrNotEnoughSQLMigrations = errors.New("already more migrations than wanted") - ErrSqliteFailure = errors.New("sqlite returned an error") -) - -type Sqlite struct { - db *sql.DB -} - -func NewSqlite(dbPath string) (*Sqlite, error) { - db, err := sql.Open("sqlite", dbPath) - if err != nil { - return &Sqlite{}, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err) - } - - s := &Sqlite{ - db: db, - } - - if err := s.migrate(migrations); err != nil { - return &Sqlite{}, err - } - - return s, nil -} - -func (s *Sqlite) Store(event item.Event) error { - if _, err := s.db.Exec(` -INSERT INTO events -(id, title, start, duration) -VALUES -(?, ?, ?, ?) -ON CONFLICT(id) DO UPDATE -SET -title=?, -start=?, -duration=?`, - event.ID, event.Title, event.Start.Format(timestampFormat), event.Duration.String(), - event.Title, event.Start.Format(timestampFormat), event.Duration.String()); err != nil { - return fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - return nil -} - -func (s *Sqlite) Find(id string) (item.Event, error) { - var event item.Event - var durStr string - err := s.db.QueryRow(` -SELECT id, title, start, duration -FROM events -WHERE id = ?`, id).Scan(&event.ID, &event.Title, &event.Start, &durStr) - switch { - case err == sql.ErrNoRows: - return item.Event{}, fmt.Errorf("event not found: %w", err) - case err != nil: - return item.Event{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - dur, err := time.ParseDuration(durStr) - if err != nil { - return item.Event{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - event.Duration = dur - - return event, nil -} - -func (s *Sqlite) FindAll() ([]item.Event, error) { - rows, err := s.db.Query(` -SELECT id, title, start, duration -FROM events`) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - - result := make([]item.Event, 0) - defer rows.Close() - for rows.Next() { - var event item.Event - var durStr string - if err := rows.Scan(&event.ID, &event.Title, &event.Start, &durStr); err != nil { - return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - dur, err := time.ParseDuration(durStr) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - event.Duration = dur - result = append(result, event) - } - - return result, nil -} - -func (s *Sqlite) Delete(id string) error { - result, err := s.db.Exec(` -DELETE FROM events -WHERE id = ?`, id) - if err != nil { - return fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - - if rowsAffected == 0 { - return fmt.Errorf("event not found: %s", id) - } - - return nil -} - -func (s *Sqlite) migrate(wanted []string) error { - // admin table - if _, err := s.db.Exec(` -CREATE TABLE IF NOT EXISTS migration -("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "query" TEXT) -`); err != nil { - return err - } - - // find existing - rows, err := s.db.Query(`SELECT query FROM migration ORDER BY id`) - if err != nil { - return fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - - existing := []string{} - for rows.Next() { - var query string - if err := rows.Scan(&query); err != nil { - return fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - existing = append(existing, string(query)) - } - rows.Close() - - // compare - missing, err := compareMigrations(wanted, existing) - if err != nil { - return fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - - // execute missing - for _, query := range missing { - if _, err := s.db.Exec(string(query)); err != nil { - return fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - - // register - if _, err := s.db.Exec(` -INSERT INTO migration -(query) VALUES (?) -`, query); err != nil { - return fmt.Errorf("%w: %v", ErrSqliteFailure, err) - } - } - - return nil -} - -func compareMigrations(wanted, existing []string) ([]string, error) { - needed := []string{} - if len(wanted) < len(existing) { - return []string{}, ErrNotEnoughSQLMigrations - } - - for i, want := range wanted { - switch { - case i >= len(existing): - needed = append(needed, want) - case want == existing[i]: - // do nothing - case want != existing[i]: - return []string{}, fmt.Errorf("%w: %v", ErrIncompatibleSQLMigration, want) - } - } - - return needed, nil -} diff --git a/plan/storage/sqlite/event.go b/plan/storage/sqlite/event.go new file mode 100644 index 0000000..6f553b2 --- /dev/null +++ b/plan/storage/sqlite/event.go @@ -0,0 +1,100 @@ +package sqlite + +import ( + "database/sql" + "fmt" + "time" + + "go-mod.ewintr.nl/planner/item" + "go-mod.ewintr.nl/planner/plan/storage" +) + +type SqliteEvent struct { + db *sql.DB +} + +func (s *SqliteEvent) Store(event item.Event) error { + if _, err := s.db.Exec(` +INSERT INTO events +(id, title, start, duration) +VALUES +(?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE +SET +title=?, +start=?, +duration=?`, + event.ID, event.Title, event.Start.Format(timestampFormat), event.Duration.String(), + event.Title, event.Start.Format(timestampFormat), event.Duration.String()); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + return nil +} + +func (s *SqliteEvent) Find(id string) (item.Event, error) { + var event item.Event + var durStr string + err := s.db.QueryRow(` +SELECT id, title, start, duration +FROM events +WHERE id = ?`, id).Scan(&event.ID, &event.Title, &event.Start, &durStr) + switch { + case err == sql.ErrNoRows: + return item.Event{}, fmt.Errorf("event not found: %w", err) + case err != nil: + return item.Event{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + dur, err := time.ParseDuration(durStr) + if err != nil { + return item.Event{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + event.Duration = dur + + return event, nil +} + +func (s *SqliteEvent) FindAll() ([]item.Event, error) { + rows, err := s.db.Query(` +SELECT id, title, start, duration +FROM events`) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + result := make([]item.Event, 0) + defer rows.Close() + for rows.Next() { + var event item.Event + var durStr string + if err := rows.Scan(&event.ID, &event.Title, &event.Start, &durStr); err != nil { + return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + dur, err := time.ParseDuration(durStr) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + event.Duration = dur + result = append(result, event) + } + + return result, nil +} + +func (s *SqliteEvent) Delete(id string) error { + result, err := s.db.Exec(` +DELETE FROM events +WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + if rowsAffected == 0 { + return storage.ErrNotFound + } + + return nil +} diff --git a/plan/storage/sqlite/localid.go b/plan/storage/sqlite/localid.go new file mode 100644 index 0000000..1e213c3 --- /dev/null +++ b/plan/storage/sqlite/localid.go @@ -0,0 +1,77 @@ +package sqlite + +import ( + "database/sql" + "fmt" + + "go-mod.ewintr.nl/planner/plan/storage" +) + +type LocalID struct { + db *sql.DB +} + +func (l *LocalID) FindAll() (map[string]int, error) { + rows, err := l.db.Query(` +SELECT id, local_id +FROM localids +`) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + result := make(map[string]int) + defer rows.Close() + for rows.Next() { + var id string + var localID int + if err := rows.Scan(&id, &localID); err != nil { + return nil, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + result[id] = localID + } + + return result, nil +} + +func (l *LocalID) Next() (int, error) { + idMap, err := l.FindAll() + if err != nil { + return 0, err + } + cur := make([]int, 0, len(idMap)) + for _, localID := range idMap { + cur = append(cur, localID) + } + + return storage.NextLocalID(cur), nil +} + +func (l *LocalID) Store(id string, localID int) error { + if _, err := l.db.Exec(` +INSERT INTO localids +(id, local_id) +VALUES +(? ,?)`, id, localID); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + return nil +} + +func (l *LocalID) Delete(id string) error { + result, err := l.db.Exec(` +DELETE FROM localids +WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + if rowsAffected == 0 { + return storage.ErrNotFound + } + return nil +} diff --git a/plan/storage/sqlite/sqlite.go b/plan/storage/sqlite/sqlite.go new file mode 100644 index 0000000..90f84f3 --- /dev/null +++ b/plan/storage/sqlite/sqlite.go @@ -0,0 +1,117 @@ +package sqlite + +import ( + "database/sql" + "errors" + "fmt" + + _ "modernc.org/sqlite" +) + +const ( + timestampFormat = "2006-01-02 15:04:05" +) + +var migrations = []string{ + `CREATE TABLE events ("id" TEXT UNIQUE, "title" TEXT, "start" TIMESTAMP, "duration" TEXT)`, + `PRAGMA journal_mode=WAL`, + `PRAGMA synchronous=NORMAL`, + `PRAGMA cache_size=2000`, + `CREATE TABLE localids ("id" TEXT UNIQUE, "local_id" INTEGER)`, +} + +var ( + ErrInvalidConfiguration = errors.New("invalid configuration") + ErrIncompatibleSQLMigration = errors.New("incompatible migration") + ErrNotEnoughSQLMigrations = errors.New("already more migrations than wanted") + ErrSqliteFailure = errors.New("sqlite returned an error") +) + +func NewSqlites(dbPath string) (*LocalID, *SqliteEvent, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, nil, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err) + } + + sl := &LocalID{ + db: db, + } + se := &SqliteEvent{ + db: db, + } + + if err := migrate(db, migrations); err != nil { + return nil, nil, err + } + + return sl, se, nil +} + +func migrate(db *sql.DB, wanted []string) error { + // admin table + if _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS migration +("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "query" TEXT) +`); err != nil { + return err + } + + // find existing + rows, err := db.Query(`SELECT query FROM migration ORDER BY id`) + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + existing := []string{} + for rows.Next() { + var query string + if err := rows.Scan(&query); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + existing = append(existing, string(query)) + } + rows.Close() + + // compare + missing, err := compareMigrations(wanted, existing) + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + // execute missing + for _, query := range missing { + if _, err := db.Exec(string(query)); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + // register + if _, err := db.Exec(` +INSERT INTO migration +(query) VALUES (?) +`, query); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + } + + return nil +} + +func compareMigrations(wanted, existing []string) ([]string, error) { + needed := []string{} + if len(wanted) < len(existing) { + return []string{}, ErrNotEnoughSQLMigrations + } + + for i, want := range wanted { + switch { + case i >= len(existing): + needed = append(needed, want) + case want == existing[i]: + // do nothing + case want != existing[i]: + return []string{}, fmt.Errorf("%w: %v", ErrIncompatibleSQLMigration, want) + } + } + + return needed, nil +} diff --git a/plan/storage/storage.go b/plan/storage/storage.go index 3e8369d..e20c24b 100644 --- a/plan/storage/storage.go +++ b/plan/storage/storage.go @@ -1,10 +1,62 @@ package storage -import "go-mod.ewintr.nl/planner/item" +import ( + "errors" + "sort" -type EventRepo interface { + "go-mod.ewintr.nl/planner/item" +) + +var ( + ErrNotFound = errors.New("not found") +) + +type LocalID interface { + FindAll() (map[string]int, error) + Next() (int, error) + Store(id string, localID int) error + Delete(id string) error +} + +type Event interface { Store(event item.Event) error Find(id string) (item.Event, error) FindAll() ([]item.Event, error) Delete(id string) error } + +func NextLocalID(used []int) int { + if len(used) == 0 { + return 1 + } + + sort.Ints(used) + usedMax := 1 + for _, u := range used { + if u > usedMax { + usedMax = u + } + } + + var limit int + for limit = 1; limit <= len(used) || limit < usedMax; limit *= 10 { + } + + newId := used[len(used)-1] + 1 + if newId < limit { + return newId + } + + usedMap := map[int]bool{} + for _, u := range used { + usedMap[u] = true + } + + for i := 1; i < limit; i++ { + if _, ok := usedMap[i]; !ok { + return i + } + } + + return limit +} diff --git a/plan/storage/storage_test.go b/plan/storage/storage_test.go new file mode 100644 index 0000000..528ec1a --- /dev/null +++ b/plan/storage/storage_test.go @@ -0,0 +1,76 @@ +package storage_test + +import ( + "testing" + + "go-mod.ewintr.nl/planner/plan/storage" +) + +func TestNextLocalId(t *testing.T) { + for _, tc := range []struct { + name string + used []int + exp int + }{ + { + name: "empty", + used: []int{}, + exp: 1, + }, + { + name: "not empty", + used: []int{5}, + exp: 6, + }, + { + name: "multiple", + used: []int{2, 3, 4}, + exp: 5, + }, + { + name: "holes", + used: []int{1, 5, 8}, + exp: 9, + }, + { + name: "expand limit", + used: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + exp: 11, + }, + { + name: "wrap if possible", + used: []int{8, 9}, + exp: 1, + }, + { + name: "find hole", + used: []int{1, 2, 3, 4, 5, 7, 8, 9}, + exp: 6, + }, + { + name: "dont wrap if expanded before", + used: []int{15, 16}, + exp: 17, + }, + { + name: "do wrap if expanded limit is reached", + used: []int{99}, + exp: 1, + }, + { + name: "sync bug", + used: []int{151, 956, 955, 150, 154, 155, 145, 144, + 136, 152, 148, 146, 934, 149, 937, 135, 140, 139, + 143, 137, 153, 939, 138, 953, 147, 141, 938, 142, + }, + exp: 957, + }, + } { + t.Run(tc.name, func(t *testing.T) { + act := storage.NextLocalID(tc.used) + if tc.exp != act { + t.Errorf("exp %v, got %v", tc.exp, act) + } + }) + } +}