Compare commits

..

No commits in common. '7062e86c5b85a006870d00aecca03dc1a8e064fd' and '7a38cb536ab7a3e83aed76e9ab4c00f3ac64d626' have entirely different histories.

@ -46,7 +46,7 @@ start-local:
.PHONY: start-local
db-local:
docker exec -it pulleydb psql postgres://pulley:pulley@localhost:5432/pulley
docker exec -it pulleydb psql postgres://pulley:passwd@localhost:5432/pulley
.PHONY: db-local
stop-local:

@ -15,7 +15,7 @@ curl -i http://0.0.0.0:5002/v1/movies/1
## Creating a movie
```bash
curl -i -X POST -d '{"title":"Passengers","year":2016,"runtime":"120 mins", "genres":["comedy","adventure"]}' http://0.0.0.0:5002/v1/movies
curl -i -X POST -d '{"title":"Moana","year":2016,"runtime":"107 mins", "genres":["animation","adventure"]}' http://0.0.0.0:5002/v1/movies
```
```bash
@ -33,25 +33,6 @@ 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.
@ -90,35 +71,3 @@ We can timeout our curl request like so
```bash
curl --max-time 2 localhost:5002/v1/movies/4
```
## Migrations
Don't forget that the migrate tool can be used to create the stub of your index files.
```bash
migrate create -seq -ext .sql -dir ./migrations add_movies_indexes
```
To run migrations from the cmdline you need to have the pgx5 installed
```bash
go install -tags 'pgx5' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
```
Then you need to specify a database uri
```bash
migrate -dir ./migrations -database "pgx://pulley:pulley@localhost:5434/pulley" goto 2
```
## Postgres
en.wikipedia.org/wiki/Stemming
We are using the postgres simple type for full text search but we could use some of the more advanced options for this like english. This will perform stemming for us and resulting in common words like "a" or "the" from appearing in the lexemes it generates.
Check out `\dF` for all the available configurations. See the docs for more about full text search. https://www.postgresql.org/docs/current/textsearch.html
### Other Resources
- https://niallburkley.com/blog/index-columns-for-like-in-postgres/

@ -92,7 +92,7 @@ func run(ctx context.Context, w io.Writer, args []string) error {
pool.Close()
}()
models := data.NewModels(pool, logger)
models := data.NewModels(pool)
app := application{config: cfg, logger: logger, models: models}

@ -11,10 +11,11 @@ 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}
}

@ -3,21 +3,22 @@ module git.runcible.io/learning/pulley
go 1.24.1
require (
github.com/golang-migrate/migrate v3.5.4+incompatible
github.com/golang-migrate/migrate/v4 v4.18.3
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.5
github.com/julienschmidt/httprouter v1.3.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/pashagolub/pgxmock/v4 v4.9.0
)
require (
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/pashagolub/pgxmock/v4 v4.9.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect

@ -1,28 +1,8 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM=
github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate v3.5.4+incompatible h1:R7OzwvCJTCgwapPCiX6DyBiu2czIUMDCB118gFTKTUA=
github.com/golang-migrate/migrate v3.5.4+incompatible/go.mod h1:IsVUlFN5puWOmXrqjgGUfIRIbU7mr8oNBE2tyERd9Wk=
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -48,20 +28,8 @@ github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dv
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj/gGl7I=
github.com/pashagolub/pgxmock/v4 v4.9.0/go.mod h1:9L57pC193h2aKRHVyiiE817avasIPZnPwPlw3JczWvM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -69,22 +37,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

@ -1,10 +1,6 @@
package data
import (
"strings"
"git.runcible.io/learning/pulley/internal/validator"
)
import "git.runcible.io/learning/pulley/internal/validator"
type Filters struct {
Page int
@ -20,24 +16,3 @@ 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"
}

@ -2,7 +2,6 @@ package data
import (
"errors"
"log/slog"
"git.runcible.io/learning/pulley/internal/database"
)
@ -17,8 +16,8 @@ type Models struct {
Movies MovieModel
}
func NewModels(pool database.PgxIface, logger *slog.Logger) *Models {
func NewModels(pool database.PgxIface) *Models {
return &Models{
Movies: MovieModel{db: pool, logger: logger},
Movies: MovieModel{db: pool},
}
}

@ -3,8 +3,6 @@ package data
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"git.runcible.io/learning/pulley/internal/database"
@ -32,8 +30,7 @@ type Movie struct {
}
type MovieModel struct {
db database.PgxIface
logger *slog.Logger
db database.PgxIface
}
func (m MovieModel) Insert(ctx context.Context, movie *Movie) error {
@ -173,38 +170,12 @@ func (m MovieModel) Delete(ctx context.Context, id int64) (err error) {
}
func (m MovieModel) GetAll(ctx context.Context, title string, genres []string, filters Filters) ([]*Movie, error) {
// OLD query
// query := `
// SELECT id, created_at, title, year, runtime, genres, version
// FROM movies
// ORDER BY id ASC
// `
// The plainto_tsquery('simple', $1) function takes a search value and turns it
// into a formatted query term that PostgreSQL full-text search can understand.
// It normalizes the search value (again using the simple configuration), strips
// any special characters, and inserts the and operator & between the words.
// As an example, the search value "The Club" would result in the query term
// 'the' & 'club'.
// The @@ operator is the matches operator. In our statement we are using it to
// check whether the generated query term matches the lexemes. To continue the
// example, the query term 'the' & 'club' will match rows which contain both lexemes
// 'the' and 'club'.
// 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 = '')
AND (genres @> $2 OR $2 = '{}')
ORDER BY %s %s`, filters.sortColumn(), filters.sortDirection())
query := `
SELECT id, created_at, title, year, runtime, genres, version
FROM movies
ORDER BY id ASC
`
// 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
@ -212,16 +183,13 @@ 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)
rows, err := m.db.Query(ctxTimeout, query)
if err != nil {
return nil, err
}
defer rows.Close()
movies := []*Movie{}
var movies []*Movie
for rows.Next() {
var movie Movie
err := rows.Scan(

@ -1,2 +0,0 @@
DROP INDEX IF EXISTS movies_title_idx;
DROP INDEX IF EXISTS movies_genres_idx;

@ -1,4 +0,0 @@
DROP INDEX IF EXISTS movies_title_idx;
DROP INDEX IF EXISTS movies_genres_idx;
CREATE INDEX IF NOT EXISTS movies_title_idx ON public.movies USING GIN (to_tsvector('simple', title));
CREATE INDEX IF NOT EXISTS movies_genres_idx ON public.movies USING GIN (genres);
Loading…
Cancel
Save