diff --git a/NOTES.md b/NOTES.md index c8d70e2..75a205e 100644 --- a/NOTES.md +++ b/NOTES.md @@ -33,6 +33,25 @@ BODY='{"title":"Black Panther","year":2018,"runtime":"134 mins","genres":["sci-f curl -X PUT -d "$BODY" http://0.0.0.0:5002/v1/movies/1 ``` +## Sorting by items + +REMEMBER: you have to quote strings that contain & + +```bash +curl http://localhost:5002/v1/movies?genres=comedy&sort=-year +``` +```text +"time":"2026-02-08T11:35:42.782856326-05:00","level":"DEBUG","source":{"function":"main.(*application).listMoviesHandler","file":"/home/toor/workspace/pulley/cmd/api/handlers.go","line":247},"msg":"query params","qp":{"genres":["comedy"]}} +``` + + +```bash +curl "http://localhost:5002/v1/movies?genres=comedy&sort=-year" +``` +```text +{"time":"2026-02-08T11:38:52.253316749-05:00","level":"DEBUG","source":{"function":"main.(*application).listMoviesHandler","file":"/home/toor/workspace/pulley/cmd/api/handlers.go","line":247},"msg":"query params","qp":{"genres":["comedy"],"sort":["-year"]}} +``` + ## Generating an Update Conflict We can try to use the xargs tool to submit multiple requests to our webserver. Hopefully this results in the 409 that we are looking to create. diff --git a/cmd/api/main.go b/cmd/api/main.go index f1a1d42..9476dfd 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -92,7 +92,7 @@ func run(ctx context.Context, w io.Writer, args []string) error { pool.Close() }() - models := data.NewModels(pool) + models := data.NewModels(pool, logger) app := application{config: cfg, logger: logger, models: models} diff --git a/cmd/api/testutils.go b/cmd/api/testutils.go index 9372745..ae96a29 100644 --- a/cmd/api/testutils.go +++ b/cmd/api/testutils.go @@ -11,11 +11,10 @@ import ( func newTestApplication(pool database.PgxIface) application { cfg := config.ServiceConfig{Env: "test"} - mockModels := data.NewModels(pool) // Discards log output from tests // logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: true})) - + mockModels := data.NewModels(pool, logger) return application{config: cfg, logger: logger, models: mockModels} } diff --git a/internal/data/filters.go b/internal/data/filters.go index 0ce7690..58ceee0 100644 --- a/internal/data/filters.go +++ b/internal/data/filters.go @@ -1,6 +1,10 @@ package data -import "git.runcible.io/learning/pulley/internal/validator" +import ( + "strings" + + "git.runcible.io/learning/pulley/internal/validator" +) type Filters struct { Page int @@ -16,3 +20,24 @@ func ValidateFilters(v *validator.Validator, f Filters) { v.Check(f.PageSize <= 100, "page_size", "must be a maximum of 100") v.Check(validator.PermittedValue(f.Sort, f.SortSafelist...), "sort", "invalid sort value") } + +// sortColumn checks that the client-provided Sort field matches one of the entries in our safelist +// and if it does, extract the column name from the Sort field. +func (f Filters) sortColumn() string { + for _, safeValue := range f.SortSafelist { + if f.Sort == safeValue { + return strings.TrimPrefix(f.Sort, "-") + } + } + // ValidateFilters should have checked this, but we are extra careful of sql injection. + panic("unsafe sort parameter: " + f.Sort) +} + +// sortDirection returns the sort direction depending on the prefix +// character of the Sort field. +func (f Filters) sortDirection() string { + if strings.HasPrefix(f.Sort, "-") { + return "DESC" + } + return "ASC" +} diff --git a/internal/data/models.go b/internal/data/models.go index f647060..2e64600 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -2,6 +2,7 @@ package data import ( "errors" + "log/slog" "git.runcible.io/learning/pulley/internal/database" ) @@ -16,8 +17,8 @@ type Models struct { Movies MovieModel } -func NewModels(pool database.PgxIface) *Models { +func NewModels(pool database.PgxIface, logger *slog.Logger) *Models { return &Models{ - Movies: MovieModel{db: pool}, + Movies: MovieModel{db: pool, logger: logger}, } } diff --git a/internal/data/movies.go b/internal/data/movies.go index a44914f..90e130d 100644 --- a/internal/data/movies.go +++ b/internal/data/movies.go @@ -3,6 +3,8 @@ package data import ( "context" "errors" + "fmt" + "log/slog" "time" "git.runcible.io/learning/pulley/internal/database" @@ -30,7 +32,8 @@ type Movie struct { } type MovieModel struct { - db database.PgxIface + db database.PgxIface + logger *slog.Logger } func (m MovieModel) Insert(ctx context.Context, movie *Movie) error { @@ -189,12 +192,19 @@ func (m MovieModel) GetAll(ctx context.Context, title string, genres []string, f // example, the query term 'the' & 'club' will match rows which contain both lexemes // 'the' and 'club'. - query := ` + // query := ` + // SELECT id, created_at, title, year, runtime, genres, version + // FROM movies + // WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '') + // AND (genres @> $2 OR $2 = '{}') + // ORDER BY id` + + query := fmt.Sprintf(` SELECT id, created_at, title, year, runtime, genres, version FROM movies - WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '') + WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '') AND (genres @> $2 OR $2 = '{}') - ORDER BY id` + ORDER BY %s %s`, filters.sortColumn(), filters.sortDirection()) // ctx want some timeout for queries. When used in the handler the context passed should // be the r.Context. Since cancel functions are inherited it will cancel on client @@ -202,6 +212,8 @@ func (m MovieModel) GetAll(ctx context.Context, title string, genres []string, f ctxTimeout, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() + m.logger.Debug(query) + rows, err := m.db.Query(ctxTimeout, query, title, genres) if err != nil { return nil, err