GORM Media & Query Enhancement: Cover Upload, Pagination/Search & Comment Count
1. The Image Field: Cover Upload & Static Serving
Goal: give books a cover image: a working upload endpoint — the image lands on disk, the path lands in the database, and the static directory is reachable from outside.
1.1 Decide the Naming First: Why cover_path, Not cover_url
A field name should reflect what is stored:
| What is stored | The better-fitting name |
|---|---|
A complete URL (http://host/uploads/x.jpg) |
cover_url |
A relative path (/uploads/x.jpg) — this tutorial’s choice |
cover_path |
| An object-storage key (S3, etc.) | cover_key |
This tutorial writes images into the uploads/ directory on disk and stores relative paths in the database: switching domains, putting a CDN in front, or relocating the directory means not a single row changes — only the base you join with when reading does.
Hence the name cover_path (it stores a path). Externally, though, the front end wants “something it can drop straight into <img src>”, so the JSON key is coverUrl: same-origin today it is /uploads/x.jpg, and once a CDN or object storage is in front it becomes a full https://... — the key describes the external contract (a displayable address), not the literal value in the column right now.
In Go that is a single line: the field CoverPath, plus json:"coverUrl" to separate the two names (no column tag needed — the default naming strategy turns CoverPath into cover_path, as Part 3 of the crash course explains). The exact place to add it is 1.2.
Two things to note:
json:"coverUrl"is the one necessary tag: without it the JSON carries the Go field nameCoverPath. And remember that a JSON key is an external contract — renaming it after release costs far more than renaming a column (front ends, caches, docs, already-shipped clients all have to follow), so decide deliberately;- The cost of a relative path: the front end does not get an absolute address. A same-origin page can use
<img src="/uploads/x.jpg">directly; a cross-origin deployment (say the Nuxt site on its own domain) must join it with the API base:apiBase + coverUrl. If you want “what you get is ready to use”, join it into an absolute address in the response (the configured public base + path) — which is also the legitimate reason to keep the key namedcoverUrl.
1.2 Adding the Field to the Model
models/book.go — append one line after Price (this is 1.1’s naming decision landing in code; existing fields like Comments stay unchanged):
CoverPath string `json:"coverUrl"` // relative path, may be empty; the cover_path column comes from the default naming strategy
AutoMigrate adds a nullable cover_path column to books; existing rows are unaffected (empty value).
1.3 The Upload Endpoint
handlers/cover.go:
package handlers
import (
"fmt"
"gin-demo/db"
"gin-demo/models"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// UploadCover uploads a book cover: type and size restricted, saved to uploads/,
// the path written back to cover_path
func UploadCover(c *gin.Context) {
// 1. The parent check already happened in 2.4's requireBook middleware: a missing book
// never reaches this handler. Take the entity the middleware stashed in the context,
// which also saves a second query for the book data
book := c.MustGet("book").(*models.Book)
// 2. Grab the file and validate it
file, err := c.FormFile("cover")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field cover"})
return
}
ext := strings.ToLower(filepath.Ext(file.Filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp":
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "only jpg/png/webp allowed"})
return
}
if file.Size > 2<<20 { // 2<<20 bytes = 2MB. Note this is a "post-hoc" check: FormFile has already read the whole file into memory; 2MB is not a hard request-level cap (fine for teaching — really stopping big requests needs a limit further out)
c.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 2MB)"})
return
}
// 3. Save to disk: a random filename — prevents overwrites and path injection
filename := fmt.Sprintf("%d_%d%s", book.ID, time.Now().UnixNano(), ext)
if err := c.SaveUploadedFile(file, filepath.Join("uploads", filename)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
return
}
// 4. Write the path back to the database (only this one field)
// coverPath is a URL path, joined with forward slashes (why not filepath.Join — see the note below)
coverPath := "/uploads/" + filename
if err := db.DB.WithContext(c.Request.Context()).
Model(book).Update("cover_path", coverPath).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update book"})
return
}
c.JSON(http.StatusOK, gin.H{"coverUrl": coverPath})
}
Key points:
- The route must hang off the
/books/:idgroup (2.4’sbookGroup):c.MustGet("book")relies onrequireBookhaving calledc.Set, and a missing middleware panics outright. If you skipped 2.4, put theFirst(&book, id)existence check back as step 1; - The filename never trusts user input:
filepath.Exttakes only the extension; the base name is built frombook.ID + timestamp— this blocks both overwrites and../../-style path injection; - Don’t mix the two kinds of paths:
coverPathis a URL path, always forward slashes/(what is stored and handed to the front end) — usepath.Joinor plain concatenation; only saving to disk usesfilepath.Join(OS separator). Windows backslashes are exactly the counterexample — joining a URL withfilepath.Joinwould store\uploads\on Windows, and the front end would never match it; - Type validation is an extension whitelist (the simple teaching version; the strict approach sniffs file headers with
http.DetectContentType— landed in Section 2 of GORM Engineering in Practice (Part 2): Reliability & Production Readiness); Model(book).Update("cover_path", ...)updates a single field without touching any other — echoing theUpdates(struct)semantics from the crash course.
1.4 Static Serving & Directory Setup
main.go, three small changes:
import (
"os"
// ...everything else unchanged
)
func main() {
db.InitDB()
if err := db.DB.AutoMigrate(&models.Book{}, &models.Comment{}); err != nil {
log.Fatal("migration failed:", err)
}
// Disk directory: locally this defaults to uploads/ relative to the working directory;
// in production point UPLOAD_DIR at an absolute path
uploadDir := "uploads"
_ = os.MkdirAll(uploadDir, 0o755) // make sure the directory exists
r := gin.Default()
r.Use(requestTimeout(5 * time.Second))
r.Static("/uploads", uploadDir) // URL prefix hardcoded with forward slashes: /uploads; the directory follows uploadDir
// /uploads/xxx.jpg → ./uploads/xxx.jpg
// ...new in the routes block: the cover route joins 2.4's bookGroup (its path is already /books/:id)
bookGroup.POST("/cover", handlers.UploadCover)
}
uploads is a relative path, resolved against the process’s working directory — running go run from the project root is fine locally, but under systemd / Docker / an IDE that starts the service elsewhere, images get written to (and read from) a different directory. Point an UPLOAD_DIR environment variable at an absolute path in production.
r.Static("/uploads", uploadDir) maps /uploads/<filename> straight onto the disk directory: a same-origin page can use <img src="/uploads/x.jpg"> right away; a cross-origin front end joins it with the API base (see the two notes in 1.1).
Testing:
curl -X POST http://localhost:8080/books/1/cover \
-F "cover=@/path/to/cover.jpg"
# {"coverUrl":"/uploads/1_1734xxxx.jpg"} <- a relative path; cross-origin front ends need apiBase + this value
curl -I http://localhost:8080/uploads/1_1734xxxx.jpg # 200, Content-Type image/jpeg
# Counter-example: a non-image / over-2MB upload should return 400
curl -X POST http://localhost:8080/books/1/cover -F "cover=@/etc/hosts"
# {"error":"only jpg/png/webp allowed"}
2. Query Enhancement: Pagination, Search & Sort
Goal: upgrade GetBooks from “dump everything” into a general “pagination + search + sort” endpoint, and put a comment count on every book in the list (JOIN + GROUP BY). The pagination skeleton learned in the relations part (GORM Relations in Practice: Comment Model, CRUD & Preload) is reused here on a bigger table.
2.1 First, Consolidate the Helper
In the relations part, ListComments does its pagination parsing inline (seeing the full shape first is the right teaching order). Now move those lines out verbatim into handlers/pagination.go — same behavior, just one reusable home instead of an inline copy:
// handlers/pagination.go — pagination-param parsing + validation (defensive: bad values fall back to defaults)
package handlers
import (
"strconv"
"github.com/gin-gonic/gin"
)
func parsePagination(c *gin.Context) (page, pageSize int) {
page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) // Atoi returns 0 on error; the < 1 check below catches it
if page < 1 {
page = 1
}
pageSize, _ = strconv.Atoi(c.DefaultQuery("pageSize", "10"))
if pageSize < 1 {
pageSize = 10
}
if pageSize > 100 {
pageSize = 100 // cap at 100: stop a client pulling too much data in one go
}
return page, pageSize
}
2.2 Reworking GetBooks
⚠️ Breaking contract change:
GET /books’s response changes from the crash course’s bare array into a{items,total,page,pageSize}object — existing front ends must adapt in step (Part 4’s route inventory logs this entry as a “1 → 3 rework”).
handlers/book.go:
// GetBooks lists books: q (fuzzy search over title/author), page/pageSize pagination;
// sort is fixed to newest-created-first (external sort fields need whitelisting first, see §2.1 of
// [GORM Engineering in Practice (Part 2)](./gorm-gin-engineering-reliability)).
// Returns {items, total, page, pageSize}.
func GetBooks(c *gin.Context) {
// 1. Parse the query params
// 1.1 q: fuzzy-search keyword over title/author
q := c.Query("q")
// 1.2 pagination parsing + validation (parsePagination in handlers/pagination.go)
page, pageSize := parsePagination(c)
// 2. Assemble the query: optional fuzzy search over title/author
query := db.DB.WithContext(c.Request.Context()).Model(&models.Book{})
if q != "" {
like := "%" + q + "%"
query = query.Where("title ILIKE ? OR author ILIKE ?", like, like)
}
// 3. Count the filtered total first
var total int64
if err := query.Count(&total).Error; err != nil {
_ = c.Error(err) // specific error goes to Gin's log; the client message stays uniform
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
// 4. Then fetch the current page (newest first)
var books []models.Book
if err := query.
Order("created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&books).Error; err != nil {
_ = c.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
// 5. Return the page result
c.JSON(http.StatusOK, gin.H{
"items": books,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
Testing:
curl "http://localhost:8080/books?q=Go&page=1&pageSize=10"
# {"items":[...],"total":1,"page":1,"pageSize":10}
curl "http://localhost:8080/books?page=2&pageSize=5"
# paging: total stays the same, items are the second page
Key points (all skeleton-level GORM query knowledge):
ILIKEis PG-specific:LIKEis case-sensitive,ILIKEis not. The tutorial runs PostgreSQL, so it writesILIKE; on MySQL useLIKE, on SQLite useLIKE(SQLite’sLIKEis case-insensitive for ASCII) — one concrete instance of driver differences. Also note aqcontaining%/_is treated as wildcards: parameterization only stops SQL injection, not wildcard semantics — a literal search needs escaping or anESCAPEclause first, which the tutorial doesn’t expand on;- Search on top of Part 2’s skeleton: the
Count→Order/Offset/Limitorder is unchanged; fetching just has one extraWherein front of it. BecauseCountruns before any paging condition, it naturally yields the filtered total — the canonical shape of a search-plus-pagination endpoint; - ORDER injection: if a
sortparameter ever comes from users, never concatenate it intoOrder()("/books?sort=created_at;DROP...") — the tutorial simply fixes the sort; accepting an external sort field requires whitelisting it first (landed in §2.1 of GORM Engineering in Practice (Part 2): Reliability & Production Readiness).
2.3 Adding a Comment Count to the List: JOIN + GROUP BY
Preload can only fetch “an array of comment objects”, never “a comment count”. To get “a comment count per book” you aggregate — which needs a struct to carry the result:
// List-response struct: the Book itself + the comment count (not a DB table — a query carrier only)
type BookListItem struct {
models.Book
CommentCount int64 `json:"commentCount"`
}
Then, on top of GetBooks, change only step 4 (steps 1/2/3/5 stay exactly as they are: pagination still runs through parsePagination, and the total is still a plain Count on the books table) — swap step 4’s var books []models.Book + Find for this var items []BookListItem + Scan (BookListItem is declared above):
var items []BookListItem
if err := query.
Select("books.*, COUNT(comments.id) AS comment_count").
Joins("LEFT JOIN comments ON comments.book_id = books.id AND comments.deleted_at IS NULL").
Group("books.id").
Order("books.created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Scan(&items).Error; err != nil {
_ = c.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
Three aggregate-query traps (all textbook-grade):
LEFT JOIN+Group("books.id"): books without comments must still show up (comment_count = 0) — hence LEFT, not INNER;- The
comments.deleted_at IS NULLcondition belongs in the JOIN, not the WHERE: put in the WHERE, it drops the whole rows of “books without comments”; put in the JOIN, it keeps the left table and counts only undeleted comments — the most common trap in aggregate queries; Scaninto a custom struct:BookListItemembedsmodels.Bookand adds one count field — hand-written aggregate SQL lands in a “query-only carrier”, which is exactly how GORM wires “raw SQL” back into the type world (the carrier never becomes a DB table; it exists only to fetch);- Ambiguous columns: after the JOIN,
created_atandidexist in both tables — writebooks.created_atandbooks.idin the SQL (Group("books.id")the same way); without the table prefixes you get “ambiguous column”.
Driver-difference note:
Select("books.*") + Group("books.id")holds together thanks to PostgreSQL’s “functional dependency” feature — grouping by the primary key lets you select the other columns directly. MySQL withonly_full_group_byon (the default) errors out and needsbooks.*expanded into the full column list, all of it grouped; that is the typical cross-database difference in “hand-written aggregate SQL”, and this PG-based tutorial adds no compatibility handling.
Testing:
curl "http://localhost:8080/books?q=Go&pageSize=10"
# items[0].commentCount should be that book's count of undeleted comments (0 for books without comments)
Contract layering:
GET /booksreturns{items,total,page,pageSize};GET /books/:idreturns one book + its comments — lists stay light, details run heavy.
Wrap-Up
- New/changed routes in this part:
| Method | Path | Handler / role |
|---|---|---|
| POST | /books/:id/cover |
UploadCover |
| GET | /uploads/* |
r.Static static serving |
| GET | /books (reworked) |
GetBooks: pagination + search + comment count |
- Your project now: the
books+commentstwo tables, cover upload with static serving, a paginated & searchable list, and a comment count on every book; - Next part, GORM Data Engineering: Batch Import, Request DTOs & Validation-Error Translation: batch-import real data, request DTOs with parameterized validation, friendly translation of validation errors — and it closes with a route quick reference plus a list of the engineering items (the centralized reconciliation promised by the Parts 6⁄7 previews).
Appendix: PostgreSQL Features Used in This Series (MySQL / SQLite Reference)
The only places in the whole series that truly depend on PostgreSQL are the few below — everything else is standard SQL, common to all three databases:
| PG feature | Where it appears | This series’ syntax | Moving to MySQL | Moving to SQLite |
|---|---|---|---|---|
| Case-insensitive fuzzy search | this part §2.2 | ILIKE ? |
LIKE ? (case is decided by the collation; use LOWER(col) LIKE LOWER(?) when needed) |
LIKE ? (case-insensitive within ASCII; non-ASCII needs COLLATE NOCASE) |
Leading-wildcard search (ILIKE '%q%') hitting an index |
this part §2.2 | full table scan by default; in production add pg_trgm + CREATE INDEX ... USING gin (title gin_trgm_ops) |
LIKE '%q%' scans too — use a full-text index or an external engine |
no trigram; a prefix LIKE 'q%' can use an index |
| Selecting other columns directly after grouping by the primary key (functional dependency) | this part §2.3 | SELECT books.*, COUNT(comments.id) ... GROUP BY books.id |
errors under the default only_full_group_by: expand books.* into the full column list and GROUP BY all of it |
same as MySQL (no functional dependency; must group every column) |
| Partial unique index on a soft-delete + unique field | crash course, Part 8 / tags part §1.1 | CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL |
no direct equivalent: use a generated column (a boolean column of (deleted_at IS NULL)) to do the partial-unique, or guarantee it at the application layer |
same as MySQL |
Also % / _ are wildcards in both ILIKE and LIKE (parameterization only stops SQL injection, not wildcard semantics) — identical in all three databases. The tutorial’s mainline runs through PostgreSQL; to switch to MySQL / SQLite, replace the few rows above and nothing else in the code needs to change.

