From feab9b0ea5c47f2e1e016ff148ad50a260f34561 Mon Sep 17 00:00:00 2001 From: BuckarooBanzay Date: Sun, 22 Oct 2023 17:26:29 +0200 Subject: [PATCH] player search --- player/columns.go | 34 ++++++++++ player/export.go | 67 ++++++++++++++++++ player/player.go | 111 ++++-------------------------- player/player_search.go | 67 ++++++++++++++++++ player/player_search_test.go | 124 ++++++++++++++++++++++++++++++++++ player/player_test.go | 4 +- player/playermetadata_test.go | 1 + player/types.go | 32 ++++++++- 8 files changed, 337 insertions(+), 103 deletions(-) create mode 100644 player/columns.go create mode 100644 player/export.go create mode 100644 player/player_search.go create mode 100644 player/player_search_test.go diff --git a/player/columns.go b/player/columns.go new file mode 100644 index 0000000..0f74186 --- /dev/null +++ b/player/columns.go @@ -0,0 +1,34 @@ +package player + +import ( + "github.com/minetest-go/mtdb/types" +) + +func getColumns(dbtype types.DatabaseType) []string { + switch dbtype { + case types.DATABASE_SQLITE: + return []string{ + "name", "pitch", "yaw", + "posx", "posy", "posz", + "hp", "breath", + "strftime('%s', creation_date)", + "strftime('%s', modification_date)", + } + case types.DATABASE_POSTGRES: + return []string{ + "name", "pitch", "yaw", + "posx", "posy", "posz", + "hp", "breath", + "extract(epoch from creation_date)::int", + "extract(epoch from modification_date)::int", + } + default: + return nil + } +} + +func scanPlayer(scan func(v ...any) error) (*Player, error) { + p := &Player{} + err := scan(&p.Name, &p.Pitch, &p.Yaw, &p.PosX, &p.PosY, &p.PosZ, &p.HP, &p.Breath, &p.CreationDate, &p.ModificationDate) + return p, err +} diff --git a/player/export.go b/player/export.go new file mode 100644 index 0000000..9dafb02 --- /dev/null +++ b/player/export.go @@ -0,0 +1,67 @@ +package player + +import ( + "archive/zip" + "bufio" + "bytes" + "encoding/json" +) + +func (r *PlayerRepository) Export(z *zip.Writer) error { + w, err := z.Create("player.json") + if err != nil { + return err + } + enc := json.NewEncoder(w) + + rows, err := r.db.Query("select name from player") + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + name := "" + err = rows.Scan(&name) + if err != nil { + return err + } + + player, err := r.GetPlayer(name) + if err != nil { + return err + } + + err = enc.Encode(player) + if err != nil { + return err + } + } + + return nil +} + +func (r *PlayerRepository) Import(z *zip.Reader) error { + f, err := z.Open("player.json") + if err != nil { + return err + } + defer f.Close() + + sc := bufio.NewScanner(f) + for sc.Scan() { + dc := json.NewDecoder(bytes.NewReader(sc.Bytes())) + e := &Player{} + err = dc.Decode(e) + if err != nil { + return err + } + + err = r.CreateOrUpdate(e) + if err != nil { + return err + } + } + + return nil +} diff --git a/player/player.go b/player/player.go index 883ae29..81602fd 100644 --- a/player/player.go +++ b/player/player.go @@ -1,12 +1,10 @@ package player import ( - "archive/zip" - "bufio" - "bytes" "database/sql" - "encoding/json" "errors" + "fmt" + "strings" "github.com/minetest-go/mtdb/types" ) @@ -21,33 +19,10 @@ type PlayerRepository struct { } func (r *PlayerRepository) GetPlayer(name string) (*Player, error) { - var q string - switch r.dbtype { - case types.DATABASE_SQLITE: - q = ` - select name,pitch,yaw, - posx,posy,posz, - hp,breath, - strftime('%s', creation_date),strftime('%s', modification_date) - from player - where name = $1 - ` - case types.DATABASE_POSTGRES: - q = ` - select name,pitch,yaw, - posx,posy,posz, - hp,breath, - extract(epoch from creation_date)::int,extract(epoch from modification_date)::int - from player - where name = $1 - ` - default: - return nil, errors.New("invalid dbtype") - } + q := fmt.Sprintf("select %s from player where name = $1", strings.Join(getColumns(r.dbtype), ",")) row := r.db.QueryRow(q, name) - p := &Player{} - err := row.Scan(&p.Name, &p.Pitch, &p.Yaw, &p.PosX, &p.PosY, &p.PosZ, &p.HP, &p.Breath, &p.CreationDate, &p.ModificationDate) + p, err := scanPlayer(row.Scan) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -64,13 +39,15 @@ func (r *PlayerRepository) CreateOrUpdate(p *Player) error { name,pitch,yaw, posx,posy,posz, hp,breath, - creation_date,modification_date + creation_date, + modification_date ) values( $1,$2,$3, $4,$5,$6, $7,$8, - datetime($9, 'unixepoch'),datetime($10, 'unixepoch') + datetime($9, 'unixepoch'), + datetime($10, 'unixepoch') ) ` case types.DATABASE_POSTGRES: @@ -79,13 +56,15 @@ func (r *PlayerRepository) CreateOrUpdate(p *Player) error { name,pitch,yaw, posx,posy,posz, hp,breath, - creation_date,modification_date + creation_date, + modification_date ) values( $1,$2,$3, $4,$5,$6, $7,$8, - to_timestamp($9),to_timestamp($10) + to_timestamp($9), + to_timestamp($10) ) on conflict (name) do update set @@ -111,69 +90,3 @@ func (r *PlayerRepository) RemovePlayer(name string) error { _, err := r.db.Exec("delete from player where name = $1", name) return err } - -func (r *PlayerRepository) Count() (int64, error) { - row := r.db.QueryRow("select count(*) from player") - count := int64(0) - err := row.Scan(&count) - return count, err -} - -func (r *PlayerRepository) Export(z *zip.Writer) error { - w, err := z.Create("player.json") - if err != nil { - return err - } - enc := json.NewEncoder(w) - - rows, err := r.db.Query("select name from player") - if err != nil { - return err - } - defer rows.Close() - - for rows.Next() { - name := "" - err = rows.Scan(&name) - if err != nil { - return err - } - - player, err := r.GetPlayer(name) - if err != nil { - return err - } - - err = enc.Encode(player) - if err != nil { - return err - } - } - - return nil -} - -func (r *PlayerRepository) Import(z *zip.Reader) error { - f, err := z.Open("player.json") - if err != nil { - return err - } - defer f.Close() - - sc := bufio.NewScanner(f) - for sc.Scan() { - dc := json.NewDecoder(bytes.NewReader(sc.Bytes())) - e := &Player{} - err = dc.Decode(e) - if err != nil { - return err - } - - err = r.CreateOrUpdate(e) - if err != nil { - return err - } - } - - return nil -} diff --git a/player/player_search.go b/player/player_search.go new file mode 100644 index 0000000..7bc9498 --- /dev/null +++ b/player/player_search.go @@ -0,0 +1,67 @@ +package player + +import ( + "fmt" + "strings" +) + +func (repo *PlayerRepository) buildWhereClause(fields string, s *PlayerSearch) (string, []any) { + q := `select ` + fields + ` from player where true ` + args := make([]any, 0) + i := 1 + + if s.Name != nil { + q += fmt.Sprintf(" and name = $%d", i) + args = append(args, *s.Name) + i++ + } + + if s.Namelike != nil { + q += fmt.Sprintf(" and name like $%d", i) + args = append(args, *s.Namelike) + i++ + } + + if s.OrderColumn != nil && orderColumns[*s.OrderColumn] { + order := Ascending + if s.OrderDirection != nil && orderDirections[*s.OrderDirection] { + order = *s.OrderDirection + } + + q += fmt.Sprintf(" order by %s %s", *s.OrderColumn, order) + } + + // limit result length to 1000 per default + limit := 1000 + if s.Limit != nil { + limit = *s.Limit + } + q += fmt.Sprintf(" limit %d", limit) + + return q, args +} + +func (repo *PlayerRepository) Search(s *PlayerSearch) ([]*Player, error) { + q, args := repo.buildWhereClause(strings.Join(getColumns(repo.dbtype), ","), s) + rows, err := repo.db.Query(q, args...) + if err != nil { + return nil, err + } + list := make([]*Player, 0) + for rows.Next() { + p, err := scanPlayer(rows.Scan) + if err != nil { + return nil, err + } + list = append(list, p) + } + return list, nil +} + +func (repo *PlayerRepository) Count(s *PlayerSearch) (int, error) { + q, args := repo.buildWhereClause("count(*)", s) + row := repo.db.QueryRow(q, args...) + count := 0 + err := row.Scan(&count) + return count, err +} diff --git a/player/player_search_test.go b/player/player_search_test.go new file mode 100644 index 0000000..3f664a2 --- /dev/null +++ b/player/player_search_test.go @@ -0,0 +1,124 @@ +package player_test + +import ( + "database/sql" + "testing" + + "github.com/minetest-go/mtdb/player" + "github.com/minetest-go/mtdb/types" + "github.com/stretchr/testify/assert" +) + +func ref[T any](s T) *T { + return &s +} + +func testPlayerSearch(t *testing.T, repo *player.PlayerRepository) { + assert.NotNil(t, repo) + + p1 := &player.Player{ + Name: "player1", + Yaw: 1.4, + Pitch: 0.9, + PosX: 1200.0, + PosY: 2300.0, + PosZ: 4500.0, + HP: 10, + Breath: 9, + CreationDate: 12000, + ModificationDate: 15000, + } + assert.NoError(t, repo.CreateOrUpdate(p1)) + + p2 := &player.Player{ + Name: "player2", + Yaw: 1.4, + Pitch: 0.9, + PosX: 1200.0, + PosY: 2300.0, + PosZ: 4500.0, + HP: 10, + Breath: 9, + CreationDate: 12000, + ModificationDate: 10000, + } + assert.NoError(t, repo.CreateOrUpdate(p2)) + + // search all + res, err := repo.Search(&player.PlayerSearch{}) + assert.NoError(t, err) + assert.NotNil(t, res) + assert.Equal(t, 2, len(res)) + + // search all with limit + res, err = repo.Search(&player.PlayerSearch{Limit: ref(1)}) + assert.NoError(t, err) + assert.NotNil(t, res) + assert.Equal(t, 1, len(res)) + + // search all with limit, order by mod-date + res, err = repo.Search(&player.PlayerSearch{ + Limit: ref(1), + OrderColumn: ref(player.ModificationDate), + OrderDirection: ref(player.Ascending), + }) + assert.NoError(t, err) + assert.NotNil(t, res) + assert.Equal(t, 1, len(res)) + assert.Equal(t, "player2", res[0].Name) + + // count all + c, err := repo.Count(&player.PlayerSearch{}) + assert.NoError(t, err) + assert.Equal(t, 2, c) + + // search by name + res, err = repo.Search(&player.PlayerSearch{ + Name: ref("player1"), + }) + assert.NoError(t, err) + assert.NotNil(t, res) + assert.Equal(t, 1, len(res)) + assert.Equal(t, "player1", res[0].Name) + + // search by namelike + res, err = repo.Search(&player.PlayerSearch{ + Namelike: ref("player%"), + }) + assert.NoError(t, err) + assert.NotNil(t, res) + assert.Equal(t, 2, len(res)) + + // search by namelike (no match) + res, err = repo.Search(&player.PlayerSearch{ + Namelike: ref("xy%"), + }) + assert.NoError(t, err) + assert.NotNil(t, res) + assert.Equal(t, 0, len(res)) + + // delete + assert.NoError(t, repo.RemovePlayer("player1")) + assert.NoError(t, repo.RemovePlayer("player2")) +} + +func TestPlayerSearchSQLite(t *testing.T) { + // open db + db, err := sql.Open("sqlite3", ":memory:") + assert.NoError(t, err) + + assert.NoError(t, player.MigratePlayerDB(db, types.DATABASE_SQLITE)) + repo := player.NewPlayerRepository(db, types.DATABASE_SQLITE) + testPlayerSearch(t, repo) +} + +func TestPlayerSearchPostgres(t *testing.T) { + // open db + db, err := getPostgresDB(t) + assert.NoError(t, err) + assert.NotNil(t, db) + + assert.NoError(t, player.MigratePlayerDB(db, types.DATABASE_POSTGRES)) + repo := player.NewPlayerRepository(db, types.DATABASE_POSTGRES) + testPlayerSearch(t, repo) +} diff --git a/player/player_test.go b/player/player_test.go index 8cfc329..5252a3e 100644 --- a/player/player_test.go +++ b/player/player_test.go @@ -69,9 +69,9 @@ func TestSqlitePlayerRepo(t *testing.T) { assert.NotNil(t, repo) // count - player_count, err := repo.Count() + player_count, err := repo.Count(&player.PlayerSearch{}) assert.NoError(t, err) - assert.Equal(t, int64(1), player_count) + assert.Equal(t, 1, player_count) // existing entry p, err := repo.GetPlayer("singleplayer") diff --git a/player/playermetadata_test.go b/player/playermetadata_test.go index c8cba5e..ad7c723 100644 --- a/player/playermetadata_test.go +++ b/player/playermetadata_test.go @@ -50,6 +50,7 @@ func testPlayerMetadata(t *testing.T, repo *player.PlayerMetadataRepository, pre func TestPlayerMetadataSQlite(t *testing.T) { dbfile, err := os.CreateTemp(os.TempDir(), "playermetadata.sqlite") + assert.NoError(t, err) db, err := sql.Open("sqlite3", dbfile.Name()) assert.NoError(t, err) diff --git a/player/types.go b/player/types.go index 29029ac..de1634f 100644 --- a/player/types.go +++ b/player/types.go @@ -9,8 +9,36 @@ type Player struct { PosZ float64 `json:"posz"` HP int `json:"hp"` Breath int `json:"breath"` - CreationDate int64 `json:"creation_date"` - ModificationDate int64 `json:"modification_date"` + CreationDate int64 `json:"creation_date"` // unix seconds + ModificationDate int64 `json:"modification_date"` // unix seconds +} + +type OrderColumnType string +type OrderDirectionType string + +const ( + ModificationDate OrderColumnType = "modification_date" + Name OrderColumnType = "name" + Ascending OrderDirectionType = "asc" + Descending OrderDirectionType = "desc" +) + +var orderColumns = map[OrderColumnType]bool{ + ModificationDate: true, + Name: true, +} + +var orderDirections = map[OrderDirectionType]bool{ + Ascending: true, + Descending: true, +} + +type PlayerSearch struct { + Namelike *string `json:"namelike"` + Name *string `json:"name"` + Limit *int `json:"limit"` + OrderColumn *OrderColumnType `json:"order_column"` + OrderDirection *OrderDirectionType `json:"order_direction"` } type PlayerMetadata struct {