Compare commits
4 Commits
8c6d8354df
...
912210cc90
| Author | SHA1 | Date | |
|---|---|---|---|
| 912210cc90 | |||
| dbde3c8724 | |||
| b7b581ff5a | |||
| 6a837d28c6 |
@ -58,9 +58,10 @@ linters:
|
||||
checks:
|
||||
- all
|
||||
- -ST1000 # Re-enable this once we have docstrings
|
||||
- -ST1001 # Dot imports are good sometimes (e.g., in test packages)
|
||||
- -ST1003 # I like snake_case
|
||||
- -ST1013 # HTTP status codes are shorter and more readable than names
|
||||
dot-import-whitelist:
|
||||
- "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
exclusions:
|
||||
generated: lax # Don't lint generated files
|
||||
paths:
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
TODO: auto-timestamps
|
||||
- SaveXyz should set created_at and updated_at; shouldn't touch is_deleted or deleted_at
|
||||
- if soft delete is enabled, DeleteXyz should do update (not delete) and set is_deleted and deleted_at
|
||||
- ...and DeleteXyz should have pointer receiver for soft-delete
|
||||
- SaveXyz shouldn't set created_at in the do-update branch
|
||||
- DeleteXyz should have pointer receiver
|
||||
- GetXyzByID should include `ErrItemIsDeleted` if item is soft-deleted
|
||||
|
||||
TODO: primary-key
|
||||
|
||||
@ -22,10 +22,6 @@ var (
|
||||
version_number uint
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTargetExists = errors.New("target already exists")
|
||||
)
|
||||
|
||||
// Colors for terminal output
|
||||
const (
|
||||
ColorReset = "\033[0m"
|
||||
@ -51,22 +47,39 @@ func Create(path string) (*sqlx.DB, error) {
|
||||
// First check if the path already exists
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return nil, ErrTargetExists
|
||||
return nil, ErrDatabaseAlreadyExists
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("path error: %w", err)
|
||||
}
|
||||
|
||||
// Create DB file
|
||||
fmt.Printf("Creating............. %s\n", path)
|
||||
db := sqlx.MustOpen("sqlite3", path+"?_foreign_keys=on&_journal_mode=WAL")
|
||||
db.MustExec(*sql_schema)
|
||||
db, err := sqlx.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening db: %w", err)
|
||||
}
|
||||
|
||||
// Initialize schema
|
||||
if _, err = db.Exec("pragma foreign_keys=on; pragma journal_mode=WAL;"); err != nil {
|
||||
return nil, fmt.Errorf("running pragma statements: %w", err)
|
||||
}
|
||||
if _, err = db.Exec(*sql_schema); err != nil {
|
||||
return nil, fmt.Errorf("creating schema: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func Connect(path string) (*sqlx.DB, error) {
|
||||
db := sqlx.MustOpen("sqlite3", fmt.Sprintf("%s?_foreign_keys=on&_journal_mode=WAL", path))
|
||||
err := CheckAndUpdateVersion(db)
|
||||
db, err := sqlx.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening db: %w", err)
|
||||
}
|
||||
if _, err = db.Exec("pragma foreign_keys=on; pragma journal_mode=WAL;"); err != nil {
|
||||
return nil, fmt.Errorf("running pragma statements: %w", err)
|
||||
}
|
||||
|
||||
err = CheckAndUpdateVersion(db)
|
||||
return db, err
|
||||
}
|
||||
|
||||
|
||||
75
pkg/db/errors.go
Normal file
75
pkg/db/errors.go
Normal file
@ -0,0 +1,75 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotInDB = errors.New("not in db")
|
||||
ErrItemIsDeleted = errors.New("item is deleted")
|
||||
ErrForeignKeyViolation = errors.New("foreign key constraint failed")
|
||||
ErrDatabaseAlreadyExists = errors.New("target already exists")
|
||||
)
|
||||
|
||||
type ForeignKey interface {
|
||||
~uint64 | ~string
|
||||
}
|
||||
|
||||
type ForeignKeyError[T ForeignKey] struct {
|
||||
Field string
|
||||
TargetTable string
|
||||
FkValue T
|
||||
}
|
||||
|
||||
func NewForeignKeyError[T ForeignKey](field, table string, fkValue T) ForeignKeyError[T] {
|
||||
return ForeignKeyError[T]{
|
||||
Field: field,
|
||||
TargetTable: table,
|
||||
FkValue: fkValue,
|
||||
}
|
||||
}
|
||||
|
||||
func (e ForeignKeyError[T]) Error() string {
|
||||
return fmt.Sprintf(`%s: fk field %q (to %q) with value "%v"`,
|
||||
ErrForeignKeyViolation.Error(), e.Field, e.TargetTable, e.FkValue,
|
||||
)
|
||||
}
|
||||
|
||||
// Unwrap returns the sentinel error-- permits the use of errors.Is for convenience
|
||||
func (e ForeignKeyError[T]) Unwrap() error {
|
||||
return ErrForeignKeyViolation
|
||||
}
|
||||
|
||||
// -------------
|
||||
// SQLite errors
|
||||
// -------------
|
||||
|
||||
// IsSqliteFkError checks whether an error is a SQLite foreign key constraint violation.
|
||||
func IsSqliteFkError(err error) bool {
|
||||
var sqliteErr sqlite3.Error
|
||||
if !errors.As(err, &sqliteErr) {
|
||||
return false
|
||||
}
|
||||
return errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintForeignKey)
|
||||
}
|
||||
|
||||
// IsSqliteUniqError checks whether an error is a SQLite unique constraint violation.
|
||||
func IsSqliteUniqError(err error) bool {
|
||||
var sqliteErr sqlite3.Error
|
||||
if !errors.As(err, &sqliteErr) {
|
||||
return false
|
||||
}
|
||||
return errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintUnique)
|
||||
}
|
||||
|
||||
// IsSqliteNotNullError checks whether an error is a SQLite not null constraint violation.
|
||||
func IsSqliteNotNullError(err error) bool {
|
||||
var sqliteErr sqlite3.Error
|
||||
if !errors.As(err, &sqliteErr) {
|
||||
return false
|
||||
}
|
||||
return errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintNotNull)
|
||||
}
|
||||
64
pkg/db/timestamp.go
Normal file
64
pkg/db/timestamp.go
Normal file
@ -0,0 +1,64 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Timestamp is a type that wraps `time.Time`. It implements `driver.Value` and `json.Marshal` to
|
||||
// store its value as an integer, a Unix timestamp in milliseconds.
|
||||
//
|
||||
// It uses type embedding (`struct { time.Time }`), instead of making it simply a typedef of
|
||||
// `time.Time`, because that keeps methods on `time.Time` (like Add, UnixMilli, etc.) intact and
|
||||
// exposed on instances of Timestamp.
|
||||
type Timestamp struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
func TimestampFromUnix(num int64) Timestamp {
|
||||
return Timestamp{time.Unix(num, 0)}
|
||||
}
|
||||
func TimestampFromUnixMilli(num int64) Timestamp {
|
||||
return Timestamp{time.UnixMilli(num)}
|
||||
}
|
||||
|
||||
// TimestampNow returns a new Timestamp corresponding to the current time, rounded to the nearest millisecond.
|
||||
func TimestampNow() Timestamp {
|
||||
return Timestamp{time.Now().Round(time.Millisecond)}
|
||||
}
|
||||
|
||||
// ------------
|
||||
// driver.Value
|
||||
// ------------
|
||||
|
||||
func (t Timestamp) Value() (driver.Value, error) {
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
|
||||
func (t *Timestamp) Scan(src any) error {
|
||||
val, isOk := src.(int64)
|
||||
if !isOk {
|
||||
return fmt.Errorf("incompatible type for Timestamp: %#v", src)
|
||||
}
|
||||
*t = TimestampFromUnixMilli(val)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------
|
||||
// json.Marshal / Unmarshal
|
||||
// ------------------------
|
||||
|
||||
func (t Timestamp) MarshalJSON() ([]byte, error) {
|
||||
return fmt.Appendf(nil, "%d", t.UnixMilli()), nil
|
||||
}
|
||||
|
||||
func (t *Timestamp) UnmarshalJSON(b []byte) error {
|
||||
ms, err := strconv.ParseInt(string(b), 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timestamp %q: %w", string(b), err)
|
||||
}
|
||||
*t = TimestampFromUnixMilli(ms)
|
||||
return nil
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user