package themis import ( "context" "database/sql" "embed" _ "embed" "errors" "fmt" "strings" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/golang-migrate/migrate/v4/source/iofs" _ "github.com/mattn/go-sqlite3" "github.com/rs/zerolog/log" ) //go:embed migrations/*.sql var migrations embed.FS type Store struct { db *sql.DB } func NewStore(conn string) (*Store, error) { log.Debug().Str("connection_string", conn).Msg("opening sqlite3 database") db, err := sql.Open("sqlite3", conn) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } d, err := iofs.New(migrations, "migrations") if err != nil { return nil, fmt.Errorf("failed to open iofs migration source: %w", err) } dr, err := sqlite3.WithInstance(db, &sqlite3.Config{}) if err != nil { return nil, fmt.Errorf("failed to initialize sqlite3 migrate driver: %w", err) } m, err := migrate.NewWithInstance("iofs", d, "main", dr) if err != nil { return nil, fmt.Errorf("failed to initialize db migrate: %w", err) } err = m.Up() if err != nil && !errors.Is(err, migrate.ErrNoChange) { return nil, fmt.Errorf("failed to roll up migrations: %w", err) } ver, dirty, err := m.Version() if err != nil && err != migrate.ErrNilVersion { return nil, fmt.Errorf("failed to get database migration version: %w", err) } log.Debug().Uint("current_version", ver).Bool("dirty", dirty).Msg("running database migrations") return &Store{ db: db, }, nil } func (s *Store) Close() error { log.Debug().Msg("closing database") return s.db.Close() } func (s *Store) Claim(ctx context.Context, userId, player, province string, claimType ClaimType) (int, error) { log.Debug(). Ctx(ctx). Str("userid", userId). Str("player", player). Str("province", province). Stringer("claim_type", claimType). Msg("inserting claim") audit := &AuditableEvent{ userId: userId, eventType: EventClaim, } defer s.Audit(ctx, audit) tx, err := s.db.Begin() if err != nil { audit.err = err return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Commit() //nolint:errcheck conflicts, err := s.FindConflicts(ctx, userId, province, claimType) if err != nil { audit.err = err return 0, fmt.Errorf("failed to run conflicts check: %w", err) } if len(conflicts) > 0 { log.Debug().Ctx(ctx).Int("len", len(conflicts)).Msg("found conflicts") audit.err = err return 0, ErrConflict{Conflicts: conflicts} } // check that provided name matches the claim type stmt, err := s.db.PrepareContext(ctx, `SELECT COUNT(1) FROM claimables WHERE lower(name) = ? and typ = ?`) if err != nil { audit.err = err return 0, fmt.Errorf("failed to prepare count query: %w", err) } defer stmt.Close() row := stmt.QueryRowContext(ctx, strings.ToLower(province), claimType) var count int err = row.Scan(&count) if err != nil { audit.err = err return 0, fmt.Errorf("failed to scan: %w", err) } if count == 0 { audit.err = err return 0, fmt.Errorf("found no provinces for %s named %s", claimType, province) } stmt, err = s.db.PrepareContext(ctx, "INSERT INTO claims (player, claim_type, val, userid) VALUES (?, ?, ?, ?)") if err != nil { audit.err = err return 0, fmt.Errorf("failed to prepare claim query: %w", err) } defer stmt.Close() res, err := stmt.ExecContext(ctx, player, claimType, province, userId) if err != nil { audit.err = err return 0, fmt.Errorf("failed to insert claim: %w", err) } id, err := res.LastInsertId() if err != nil { audit.err = err return 0, fmt.Errorf("failed to get last ID: %w", err) } return int(id), nil } func (s *Store) ListAvailability(ctx context.Context, claimType ClaimType, search ...string) ([]string, error) { log.Debug().Ctx(ctx).Stringer("claim_type", claimType).Strs("search_terms", search).Msg("listing available entries") queryParams := []any{string(claimType)} queryPattern := `SELECT distinct name FROM claimables LEFT JOIN claims ON claimables.name = claims.val AND claimables.typ = claims.claim_type WHERE claims.val IS NULL AND claimables.typ = ?` if len(search) > 0 && search[0] != "" { // only take one search param, ignore the rest queryPattern += `AND claimables.name LIKE ?` queryParams = append(queryParams, fmt.Sprintf("%%%s%%", search[0])) } stmt, err := s.db.PrepareContext(ctx, queryPattern) if err != nil { return nil, fmt.Errorf("failed to prepare query: %w", err) } defer stmt.Close() rows, err := stmt.QueryContext(ctx, queryParams...) if err != nil { return nil, fmt.Errorf("failed to execute query: %w", err) } avail := make([]string, 0) for rows.Next() { var s string if err := rows.Scan(&s); err != nil { return nil, fmt.Errorf("failed to scan rows: %w", err) } avail = append(avail, s) } return avail, nil } func (s *Store) ListClaims(ctx context.Context) ([]Claim, error) { log.Debug().Ctx(ctx).Msg("listing all claims currently in database") stmt, err := s.db.PrepareContext(ctx, `SELECT id, player, claim_type, val FROM claims`) if err != nil { return nil, fmt.Errorf("failed to prepare query: %w", err) } defer stmt.Close() rows, err := stmt.QueryContext(ctx) if err != nil { return nil, fmt.Errorf("failed to execute query: %w", err) } claims := make([]Claim, 0) for rows.Next() { c := Claim{} var rawType string err = rows.Scan(&c.ID, &c.Player, &rawType, &c.Name) if err != nil { return nil, fmt.Errorf("failed to scan row: %w", err) } cl, err := ClaimTypeFromString(rawType) if err != nil { return nil, fmt.Errorf("unexpected error converting raw claim type: %w", err) } c.Type = cl claims = append(claims, c) } return claims, nil } type ClaimDetail struct { Claim Provinces []string } func (cd ClaimDetail) String() string { sb := strings.Builder{} sb.WriteString(fmt.Sprintf("%s\n", cd.Claim)) for _, p := range cd.Provinces { sb.WriteString(fmt.Sprintf(" - %s\n", p)) } return sb.String() } func (s *Store) DescribeClaim(ctx context.Context, ID int) (ClaimDetail, error) { log.Debug().Ctx(ctx).Int("id", ID).Msg("describing claim") stmt, err := s.db.PrepareContext(ctx, `SELECT id, player, claim_type, val FROM claims WHERE id = ?`) if err != nil { return ClaimDetail{}, fmt.Errorf("failed to get claim: %w", err) } defer stmt.Close() row := stmt.QueryRowContext(ctx, ID) c := Claim{} var rawType string err = row.Scan(&c.ID, &c.Player, &rawType, &c.Name) if err == sql.ErrNoRows { return ClaimDetail{}, ErrNoSuchClaim } if err != nil { return ClaimDetail{}, fmt.Errorf("failed to scan row: %w", err) } cl, err := ClaimTypeFromString(rawType) if err != nil { return ClaimDetail{}, fmt.Errorf("unexpected error converting raw claim type: %w", err) } c.Type = cl stmt, err = s.db.PrepareContext(ctx, `SELECT province FROM claimables WHERE name = ? AND typ = ?`) if err != nil { return ClaimDetail{}, fmt.Errorf("failed to prepare query: %w", err) } defer stmt.Close() rows, err := stmt.QueryContext(ctx, c.Name, cl) if err != nil { return ClaimDetail{}, fmt.Errorf("failed to execute query: %w", err) } provinces := make([]string, 0) for rows.Next() { var p string err = rows.Scan(&p) if err != nil { return ClaimDetail{}, fmt.Errorf("failed to scan result set: %w", err) } provinces = append(provinces, p) } return ClaimDetail{ Claim: c, Provinces: provinces, }, nil } func (s *Store) DeleteClaim(ctx context.Context, ID int, userId string) error { log.Debug().Ctx(ctx).Str("userid", userId).Int("id", ID).Msg("deleting claim") audit := &AuditableEvent{ userId: userId, eventType: EventUnclaim, } defer s.Audit(ctx, audit) stmt, err := s.db.PrepareContext(ctx, "DELETE FROM claims WHERE id = ? AND userid = ?") if err != nil { audit.err = err return fmt.Errorf("failed to prepare query: %w", err) } defer stmt.Close() res, err := stmt.ExecContext(ctx, ID, userId) if err != nil { audit.err = err return fmt.Errorf("failed to delete claim ID %d: %w", ID, err) } rows, err := res.RowsAffected() if err != nil { audit.err = err return fmt.Errorf("failed to get affected rows: %w", err) } if rows == 0 { audit.err = ErrNoSuchClaim return ErrNoSuchClaim } return nil } func (s *Store) CountClaims(ctx context.Context) (total, uniquePlayers int, err error) { log.Debug().Ctx(ctx).Msg("counting all claims and unique users") stmt, err := s.db.PrepareContext(ctx, "SELECT COUNT(1), COUNT(DISTINCT(userid)) FROM claims") if err != nil { return 0, 0, fmt.Errorf("failed to prepare query: %w", err) } defer stmt.Close() res := stmt.QueryRowContext(ctx) if err := res.Scan(&total, &uniquePlayers); err != nil { return 0, 0, fmt.Errorf("failed to scan result: %w", err) } return total, uniquePlayers, nil } func (s *Store) Flush(ctx context.Context, userId string) error { log.Debug().Ctx(ctx).Str("initiated_by", userId).Msg("flushing all currently help claims") audit := &AuditableEvent{ userId: userId, eventType: EventFlush, } defer s.Audit(ctx, audit) _, err := s.db.ExecContext(ctx, "DELETE FROM claims;") if err != nil { audit.err = err return fmt.Errorf("failed to execute delete query: %w", err) } return nil }