@@ -6,7 +6,7 @@ package golink
66import (
77 "context"
88 "database/sql"
9- _ "embed"
9+ "embed"
1010 "errors"
1111 "fmt"
1212 "io/fs"
@@ -15,17 +15,19 @@ import (
1515 "sync"
1616 "time"
1717
18+ "github.com/pressly/goose/v3"
1819 _ "modernc.org/sqlite"
1920 "tailscale.com/tstime"
2021)
2122
2223// Link is the structure stored for each go short link.
2324type Link struct {
24- Short string // the "foo" part of http://go/foo
25- Long string // the target URL or text/template pattern to run
26- Created time.Time
27- LastEdit time.Time // when the link was last edited
28- Owner string // user@domain
25+ Short string // the "foo" part of http://go/foo
26+ Long string // the target URL or text/template pattern to run
27+ Description string // optional human-readable description of the link
28+ Created time.Time
29+ LastEdit time.Time // when the link was last edited
30+ Owner string // user@domain
2931}
3032
3133// ClickStats is the number of clicks a set of links have received in a given
@@ -47,8 +49,8 @@ type SQLiteDB struct {
4749 clock tstime.Clock // allow overriding time for tests
4850}
4951
50- //go:embed schema .sql
51- var sqlSchema string
52+ //go:embed migrations/* .sql
53+ var migrationsFS embed. FS
5254
5355// NewSQLiteDB returns a new SQLiteDB that stores links in a SQLite database stored at f.
5456func NewSQLiteDB (f string ) (* SQLiteDB , error ) {
@@ -60,18 +62,53 @@ func NewSQLiteDB(f string) (*SQLiteDB, error) {
6062 return nil , err
6163 }
6264
63- if _ , err = db . Exec ( sqlSchema ); err != nil {
65+ if err := migrateDB ( db ); err != nil {
6466 return nil , err
6567 }
6668
6769 return & SQLiteDB {db : db }, nil
6870}
6971
72+ // migrateDB applies any pending schema migrations to db. Migrations are embedded
73+ // from the migrations directory and applied in version order; goose records
74+ // applied versions in a goose_db_version table, so it is safe to run on every
75+ // startup.
76+ func migrateDB (db * sql.DB ) error {
77+ migrationFiles , err := fs .Sub (migrationsFS , "migrations" )
78+ if err != nil {
79+ return err
80+ }
81+ provider , err := goose .NewProvider (goose .DialectSQLite3 , db , migrationFiles )
82+ if err != nil {
83+ return err
84+ }
85+ _ , err = provider .Up (context .Background ())
86+ return err
87+ }
88+
7089// Now returns the current time.
7190func (s * SQLiteDB ) Now () time.Time {
7291 return tstime.DefaultClock {Clock : s .clock }.Now ()
7392}
7493
94+ // linkColumns is the column list, in scan order, shared by every query that
95+ // loads Links. Description is read through COALESCE so a NULL surfaces as the
96+ // empty string.
97+ const linkColumns = `Short, Long, COALESCE(Description, ''), Created, LastEdit, Owner`
98+
99+ // scanLink scans a single Link row (in linkColumns order) from s, which is
100+ // satisfied by both *sql.Row and *sql.Rows.
101+ func scanLink (s interface { Scan (... any ) error }) (* Link , error ) {
102+ link := new (Link )
103+ var created , lastEdit int64
104+ if err := s .Scan (& link .Short , & link .Long , & link .Description , & created , & lastEdit , & link .Owner ); err != nil {
105+ return nil , err
106+ }
107+ link .Created = time .Unix (created , 0 ).UTC ()
108+ link .LastEdit = time .Unix (lastEdit , 0 ).UTC ()
109+ return link , nil
110+ }
111+
75112// LoadAll returns all stored Links.
76113//
77114// The caller owns the returned values.
@@ -80,19 +117,15 @@ func (s *SQLiteDB) LoadAll() ([]*Link, error) {
80117 defer s .mu .RUnlock ()
81118
82119 var links []* Link
83- rows , err := s .db .Query ("SELECT Short, Long, Created, LastEdit, Owner FROM Links" )
120+ rows , err := s .db .Query ("SELECT " + linkColumns + " FROM Links" )
84121 if err != nil {
85122 return nil , err
86123 }
87124 for rows .Next () {
88- link := new (Link )
89- var created , lastEdit int64
90- err := rows .Scan (& link .Short , & link .Long , & created , & lastEdit , & link .Owner )
125+ link , err := scanLink (rows )
91126 if err != nil {
92127 return nil , err
93128 }
94- link .Created = time .Unix (created , 0 ).UTC ()
95- link .LastEdit = time .Unix (lastEdit , 0 ).UTC ()
96129 links = append (links , link )
97130 }
98131 return links , rows .Err ()
@@ -107,18 +140,14 @@ func (s *SQLiteDB) Load(short string) (*Link, error) {
107140 s .mu .RLock ()
108141 defer s .mu .RUnlock ()
109142
110- link := new (Link )
111- var created , lastEdit int64
112- row := s .db .QueryRow ("SELECT Short, Long, Created, LastEdit, Owner FROM Links WHERE ID = ?1 LIMIT 1" , linkID (short ))
113- err := row .Scan (& link .Short , & link .Long , & created , & lastEdit , & link .Owner )
143+ row := s .db .QueryRow ("SELECT " + linkColumns + " FROM Links WHERE ID = ?1 LIMIT 1" , linkID (short ))
144+ link , err := scanLink (row )
114145 if err != nil {
115146 if errors .Is (err , sql .ErrNoRows ) {
116147 err = fs .ErrNotExist
117148 }
118149 return nil , err
119150 }
120- link .Created = time .Unix (created , 0 ).UTC ()
121- link .LastEdit = time .Unix (lastEdit , 0 ).UTC ()
122151 return link , nil
123152}
124153
@@ -127,7 +156,9 @@ func (s *SQLiteDB) Save(link *Link) error {
127156 s .mu .Lock ()
128157 defer s .mu .Unlock ()
129158
130- result , err := s .db .Exec ("INSERT OR REPLACE INTO Links (ID, Short, Long, Created, LastEdit, Owner) VALUES (?, ?, ?, ?, ?, ?)" , linkID (link .Short ), link .Short , link .Long , link .Created .Unix (), link .LastEdit .Unix (), link .Owner )
159+ // Store an absent description as NULL rather than an empty string.
160+ description := sql.NullString {String : link .Description , Valid : link .Description != "" }
161+ result , err := s .db .Exec ("INSERT OR REPLACE INTO Links (ID, Short, Long, Description, Created, LastEdit, Owner) VALUES (?, ?, ?, ?, ?, ?, ?)" , linkID (link .Short ), link .Short , link .Long , description , link .Created .Unix (), link .LastEdit .Unix (), link .Owner )
131162 if err != nil {
132163 return err
133164 }
@@ -232,19 +263,15 @@ func (s *SQLiteDB) GetLinksByOwner(owner string) ([]*Link, error) {
232263 defer s .mu .RUnlock ()
233264
234265 var links []* Link
235- rows , err := s .db .Query ("SELECT Short, Long, Created, LastEdit, Owner FROM Links WHERE LOWER(Owner) = LOWER(?)" , owner )
266+ rows , err := s .db .Query ("SELECT " + linkColumns + " FROM Links WHERE LOWER(Owner) = LOWER(?)" , owner )
236267 if err != nil {
237268 return nil , err
238269 }
239270 for rows .Next () {
240- link := new (Link )
241- var created , lastEdit int64
242- err := rows .Scan (& link .Short , & link .Long , & created , & lastEdit , & link .Owner )
271+ link , err := scanLink (rows )
243272 if err != nil {
244273 return nil , err
245274 }
246- link .Created = time .Unix (created , 0 ).UTC ()
247- link .LastEdit = time .Unix (lastEdit , 0 ).UTC ()
248275 links = append (links , link )
249276 }
250277 return links , rows .Err ()
0 commit comments