diff --git a/README.md b/README.md index d693c85..1a4ca45 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,11 @@ Warning: http.ServeFile() does not automatically sanitize the file path. If you The Let's-go book calls for MySQL. We use [go-sqlite3](ggithub.com/mattn/go-sqlite3) and [go-migrate]() tool to manage migrations instead. Use `make check-system-deps` to validate all tools for this repository are installed. +Packages to consider adopting include sqlx or https://github.com/blockloop/scan. Both have the potential to reduce the verbosity that comes will using the standard database/sql package. + +### Nulls Gotcha + +Beware of Nulls when using `DB.Scan()` To avoid issues consider using the sql.Null* types or putting a `NOT NULL` constraint in your DDL. Also a sensible Default ## Managing Dependencies diff --git a/internal/model/errors.go b/internal/model/errors.go new file mode 100644 index 0000000..6d3a5d2 --- /dev/null +++ b/internal/model/errors.go @@ -0,0 +1,6 @@ +package model + +import "errors" + +// TODO migrate this to an apperror +var ErrNoRecord = errors.New("models: no record found") diff --git a/internal/model/example.go b/internal/model/example.go new file mode 100644 index 0000000..c814eed --- /dev/null +++ b/internal/model/example.go @@ -0,0 +1,65 @@ +// This file simply illustrates the use of transactions when performing operations on your sql db. +package model + +import "database/sql" + +type ExampleModel struct { + DB *sql.DB + // Prepared statements + // + // Prepared statements exist on database connections. Statement objects will therefore attempt to reuse the connection + // object from the connection pool that the statement was created on. If the connection was Closed or in use, it will + // be re-prepared on a new connection. This can increase load, create more connections than expected. Etc. Really its + // and optimization that you may not need to start looking at. When you do you have to look at load test data to get an + // idea for how it actually behaves. + // + // Another pattern is avoid recreated prepared statements on each invocation and instead attach them + // to the service instead. This doesn't really work well with transactions which have thier own tx.Prepare + // method + // InsertStmt *sql.Stmt +} + +// func NewExampleModel(db *sql.DB) (*ExampleModel, error) { +// insertStmt, err := db.Prepare("INSERT INTO example (message, thought) VALUES (?, ?)") +// if err != nil { +// return nil, err +// } +// return &ExampleModel{DB: db, InsertStmt: insertStmt}, nil +// } + +func (m *ExampleModel) ExampleTransaction() error { + // Calling the Begin() method on the connection pool creates a new sql.Tx + // object, which represents the in-progress database transaction. + tx, err := m.DB.Begin() + if err != nil { + return err + } + + // Defer a call to tx.Rollback() to ensure it is always called before the + // function returns. If the transaction succeeds it will be already be + // committed by the time tx.Rollback() is called, making tx.Rollback() a + // no-op. Otherwise, in the event of an error, tx.Rollback() will rollback + // the changes before the function returns. + defer tx.Rollback() + + // Call Exec() on the transaction, passing in your statement and any + // parameters. It's important to notice that tx.Exec() is called on the + // transaction object just created, NOT the connection pool. Although we're + // using tx.Exec() here you can also use tx.Query() and tx.QueryRow() in + // exactly the same way. + _, err = tx.Exec("INSERT INTO ...") + if err != nil { + return err + } + + // Carry out another transaction in exactly the same way. + _, err = tx.Exec("UPDATE ...") + if err != nil { + return err + } + + // If there are no errors, the statements in the transaction can be committed + // to the database with the tx.Commit() method. + err = tx.Commit() + return err +} diff --git a/internal/model/snippets.go b/internal/model/snippets.go index 7c146d9..ea4ac1e 100644 --- a/internal/model/snippets.go +++ b/internal/model/snippets.go @@ -2,15 +2,18 @@ package model import ( "database/sql" + "errors" "fmt" "log/slog" "time" ) type Snippet struct { - ID int - Title string - Content string + ID int + // Title string + // Content string + Title sql.NullString + Content sql.NullString CreatedAt time.Time UpdatedAt time.Time ExpiresAt time.Time @@ -23,6 +26,8 @@ type SnippetService struct { // Insert inserts a new SnippetModel into the database func (s *SnippetService) Insert(title, content string, expiresAt int) (int, error) { slog.Debug(fmt.Sprintf("Inserting new snippet. Title: %s", title)) + // Really don't prepare statements. There are a lot of gotcha's where they exist on the connection objects they were created. They can potentially + // recreate connections. It's an optimization you probably don't need at the moment. stmt, err := s.DB.Prepare("INSERT INTO snippets (title, content, expires_at) VALUES ($1, $2, DATETIME(CURRENT_TIMESTAMP, '+' || $3 || ' DAY'))") if err != nil { slog.Debug("The prepared statement has an error") @@ -37,7 +42,7 @@ func (s *SnippetService) Insert(title, content string, expiresAt int) (int, erro // will hold on to a connection until .Close() is called. res, err := stmt.Exec(title, content, expiresAt) if err != nil { - slog.Debug("SQL DDL returned an error.") + slog.Debug("SQL DML statement returned an error.") return 0, err } @@ -55,12 +60,88 @@ func (s *SnippetService) Insert(title, content string, expiresAt int) (int, erro return int(lastId), nil } -// Get retrieves a specific Snippet by ID +// Get retrieves a specific Snippet by ID ignoring the record if expired. func (s *SnippetService) Get(id int) (Snippet, error) { - return Snippet{}, nil + + stmt := `SELECT id, title, content, created_at, updated_at, expires_at FROM snippets + WHERE expires_at > CURRENT_TIMESTAMP AND id = $1` + + // errors from DB.QueryRow() are deferred until Scan() is called. + // meaning you could also have used DB.QueryRow(...).Scan(...) + row := s.DB.QueryRow(stmt, id) + + var snip Snippet + + err := row.Scan(&snip.ID, + &snip.Title, + &snip.Content, + &snip.CreatedAt, + &snip.UpdatedAt, + &snip.ExpiresAt, + ) + + if err != nil { + slog.Debug("SQL DML statement returned an error.") + // Loop up the difference between errors.Is and errors.As + if errors.Is(err, sql.ErrNoRows) { + return Snippet{}, ErrNoRecord + } else { + return Snippet{}, err + } + } + + return snip, nil } // Latest retrieves up to latest 10 Snippets from the database. -func (s *SnippetService) Lastest() (Snippet, error) { - return Snippet{}, nil +func (s *SnippetService) Lastest() ([]Snippet, error) { + + stmt := `SELECT id, title, content, created_at, updated_at, expires_at FROM snippets + WHERE expires_at > CURRENT_TIMESTAMP ORDER BY id DESC LIMIT 10` + + rows, err := s.DB.Query(stmt) + if err != nil { + return nil, err + } + + // We defer rows.Close() to ensure the sql.Rows resultset is + // always properly closed before the Latest() method returns. This defer + // statement should come *after* you check for an error from the Query() + // method. Otherwise, if Query() returns an error, you'll get a panic + // trying to close a nil resultset. + defer rows.Close() + + var snippets []Snippet + + // Use rows.Next to iterate through the rows in the resultset. This + // prepares the first (and then each subsequent) row to be acted on by the + // rows.Scan() method. If iteration over all the rows completes then the + // resultset automatically closes itself and frees-up the underlying + // database connection. + for rows.Next() { + var snip Snippet + + err := rows.Scan(&snip.ID, + &snip.Title, + &snip.Content, + &snip.CreatedAt, + &snip.UpdatedAt, + &snip.ExpiresAt, + ) + if err != nil { + return nil, err + } + + snippets = append(snippets, snip) + } + + // When the rows.Next() loop has finished we call rows.Err() to retrieve any + // error that was encountered during the iteration. It's important to + // call this - don't assume that a successful iteration was completed + // over the whole resultset. + if err = rows.Err(); err != nil { + return nil, err + } + + return snippets, nil } diff --git a/internal/server/base_server.go b/internal/server/base_server.go index db19959..fcc836e 100644 --- a/internal/server/base_server.go +++ b/internal/server/base_server.go @@ -2,8 +2,8 @@ package server import ( "database/sql" + "errors" "fmt" - "html/template" "log/slog" "net/http" "strconv" @@ -55,29 +55,42 @@ func (rs *RatchetServer) home(w http.ResponseWriter, r *http.Request) { w.Header().Add("Server", "Go") - // Initialize a slice containing the paths to the two files. It's important - // to note that the file containing our base template must be the *first* - // file in the slice. - files := []string{ - "./ui/html/base.go.tmpl", - "./ui/html/partials/nav.go.tmpl", - "./ui/html/pages/home.go.tmpl", - } - - // read template file into template set. - ts, err := template.ParseFiles(files...) - if err != nil { + // Retrieve Snippets from DB + snippets, err := rs.snippetService.Lastest() + if err != err { rs.serverError(w, r, err) return } - // Write template content to response body - err = ts.ExecuteTemplate(w, "base", nil) - if err != nil { - // This is the older more verbose way of doing what RatchetServer.serverError does - // rs.logger.Error(err.Error()) - // http.Error(w, "Internal Server Error", http.StatusInternalServerError) - rs.serverError(w, r, err) + + rs.logger.Debug(fmt.Sprintf("%d snippets retrieved", len(snippets))) + + for _, snippet := range snippets { + fmt.Fprintf(w, "%+v\n", snippet) } + + // // Initialize a slice containing the paths to the two files. It's important + // // to note that the file containing our base template must be the *first* + // // file in the slice. + // files := []string{ + // "./ui/html/base.go.tmpl", + // "./ui/html/partials/nav.go.tmpl", + // "./ui/html/pages/home.go.tmpl", + // } + + // // read template file into template set. + // ts, err := template.ParseFiles(files...) + // if err != nil { + // rs.serverError(w, r, err) + // return + // } + // // Write template content to response body + // err = ts.ExecuteTemplate(w, "base", nil) + // if err != nil { + // // This is the older more verbose way of doing what RatchetServer.serverError does + // // rs.logger.Error(err.Error()) + // // http.Error(w, "Internal Server Error", http.StatusInternalServerError) + // rs.serverError(w, r, err) + // } } func (rs *RatchetServer) snippetView(w http.ResponseWriter, r *http.Request) { @@ -91,16 +104,21 @@ func (rs *RatchetServer) snippetView(w http.ResponseWriter, r *http.Request) { // Set a new cache-control header. If an existing "Cache-Control" header exists // it will be overwritten. - w.Header().Set("Cache-Control", "public, max-age=31536000") - - // msg := fmt.Sprintf("Snippet %d...", id) - - // w.Write([]byte(msg)) + // w.Header().Set("Cache-Control", "public, max-age=31536000") - // we can rely on the Write() interface to use a differnent - // function to write out our response + snippet, err := rs.snippetService.Get(id) + if err != nil { + rs.logger.Debug(fmt.Sprintf("Failed to retrieve an active record with id: %d", id)) + if errors.Is(err, model.ErrNoRecord) { + rs.clientError(w, http.StatusNotFound) + } else { + rs.serverError(w, r, err) + } + return + } - fmt.Fprintf(w, "Snippet %d...", id) + // Write the snippet data as a plain-text HTTP response body. + fmt.Fprintf(w, "%+v", snippet) } // snippetCreate handles display of the form used to create snippets