diff --git a/Dockerfile b/Dockerfile index cbf0426..9b8013d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,9 @@ FROM golang:1.25-alpine WORKDIR /app +# Solution t IP block? +ENV GOPROXY=https://goproxy.io,https://athens.azurefd.net,direct + COPY go.mod go.sum ./ RUN go mod download diff --git a/go.mod b/go.mod index 2b8eca0..43828b6 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect @@ -25,10 +26,13 @@ require ( github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/go.sum b/go.sum index a3a4fbc..e8280b6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,8 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/a-h/templ v0.3.898 h1:g9oxL/dmM6tvwRe2egJS8hBDQTncokbMoOFk1oJMX7s= github.com/a-h/templ v0.3.898/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ= github.com/a-h/templ v0.3.920 h1:IQjjTu4KGrYreHo/ewzSeS8uefecisPayIIc9VflLSE= @@ -48,6 +51,7 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= @@ -65,6 +69,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -78,12 +84,17 @@ github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2 github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -99,6 +110,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/app/server/server.go b/internal/app/server/server.go index 889d925..0670df7 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -1,7 +1,6 @@ package server import ( - "database/sql" "fmt" "net/http" "os" @@ -18,6 +17,7 @@ import ( "github.com/haydenhargreaves/Potion/internal/infrastructure/database/repository" "github.com/haydenhargreaves/Potion/internal/infrastructure/logging" "github.com/haydenhargreaves/Potion/internal/infrastructure/logging/loggers" + "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) @@ -26,7 +26,7 @@ type Server struct { port int Router *gin.Engine config cors.Config - DB *sql.DB + DB *sqlx.DB deps domain.InjectedDependencies logs []logging.Logger cleanupFuncs []func() error @@ -132,7 +132,7 @@ func (s *Server) Setup() *Server { auth.NewGoogleConfig(redirectUrl, clientId, clientSecret, scope) // SETUP DATABASE - db, err := sql.Open("postgres", cfg.DatabaseUrl) + db, err := sqlx.Open("postgres", cfg.DatabaseUrl) if err != nil { panic("Could not connect to database: " + err.Error()) } diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go index e455bf4..d02c1b2 100644 --- a/internal/app/service/recipe_service.go +++ b/internal/app/service/recipe_service.go @@ -131,7 +131,7 @@ func (s *RecipeService) DeleteRecipe(userId, recipeId int) error { return fmt.Errorf("User id does not match. Do you own the target recipe?") } - return s.recipeRepository.DeleteRecipe(recipeId) + return s.recipeRepository.DeleteRecipe(recipeId, userId) } // GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore, diff --git a/internal/domain/recipe/repository.go b/internal/domain/recipe/repository.go index 9d4d305..55a2dc9 100644 --- a/internal/domain/recipe/repository.go +++ b/internal/domain/recipe/repository.go @@ -3,7 +3,7 @@ package domain type RecipeRepository interface { CreateRecipe(recipe *Recipe) error EditRecipe(recipe *Recipe, userId int) error - DeleteRecipe(recipeId int) error + DeleteRecipe(recipeId, userId int) error GetRecipe(id int, userId *int) (*Recipe, error) GetRecipes(ids []int, userId *int) ([]Recipe, error) SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error) diff --git a/internal/infrastructure/database/repository/engagement_repository.go b/internal/infrastructure/database/repository/engagement_repository.go index 879105f..7b9beec 100644 --- a/internal/infrastructure/database/repository/engagement_repository.go +++ b/internal/infrastructure/database/repository/engagement_repository.go @@ -7,11 +7,12 @@ import ( "time" domain "github.com/haydenhargreaves/Potion/internal/domain/engagement" + "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) type EngagementRepository struct { - db *sql.DB + db *sqlx.DB } // Compile-time check to ensure the EngagementRepository implements domain.EngagementRepository @@ -19,7 +20,7 @@ var _ domain.EngagementRepository = (*EngagementRepository)(nil) // NewUserRepository creates a user repository object which is used by the user service to access // the database. Any user related database operations will take place in this repository. -func NewEngagementRepository(db *sql.DB) domain.EngagementRepository { +func NewEngagementRepository(db *sqlx.DB) domain.EngagementRepository { return &EngagementRepository{db: db} } diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go index 856acaf..c31962a 100644 --- a/internal/infrastructure/database/repository/recipe_repository.go +++ b/internal/infrastructure/database/repository/recipe_repository.go @@ -3,17 +3,18 @@ package repository import ( "database/sql" "encoding/json" - "errors" "fmt" "strings" "time" + sq "github.com/Masterminds/squirrel" domain "github.com/haydenhargreaves/Potion/internal/domain/recipe" + "github.com/jmoiron/sqlx" "github.com/lib/pq" ) type RecipeRepository struct { - db *sql.DB + db *sqlx.DB } // Compile-time check to ensure the RecipeRepository implements domain.RecipeRepository @@ -21,7 +22,7 @@ var _ domain.RecipeRepository = (*RecipeRepository)(nil) // NewRecipeRepository creates a user repository object which is used by the user service to access // the database. Any recipe related database operations will take place in this repository. -func NewRecipeRepository(db *sql.DB) domain.RecipeRepository { +func NewRecipeRepository(db *sqlx.DB) domain.RecipeRepository { return &RecipeRepository{db: db} } @@ -32,26 +33,7 @@ func NewRecipeRepository(db *sql.DB) domain.RecipeRepository { // be bubbled to the caller. The recipe parameter is passed by reference and will therefore be updated // directly and the new fields (ID, created) can be accessed upon success. func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error { - tx, err := r.db.Begin() - if err != nil { - return err - } - - query := `INSERT INTO recipes ( - title, description, instructions, serves, difficulty, - duration, category, ingredients, userid, modified, created - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 - ) RETURNING id;` - - // NOTE: Data steps - // cast duration to JSON - // convert ingredients to store type - // cast store type to JSON - // extract string instructions from type - // cast category to string - // use nil for the modified time - + // Convert data into a readable format durationJSON, err := json.Marshal(recipe.Duration) if err != nil { return err @@ -72,27 +54,46 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error { instructions[i] = instruction.Content } - var id int - if err = tx.QueryRow( - query, - recipe.Title, - recipe.Description, - pq.Array(instructions), - recipe.Serves, - recipe.Difficulty, - durationJSON, - string(recipe.Category), - ingredientsJSON, - recipe.UserId, - nil, - recipe.Created, - ).Scan(&id); err != nil { - tx.Rollback() - return err + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + + query := psql. + Insert("recipes"). + Columns( + "title", + "description", + "instructions", + "serves", + "difficulty", + "duration", + "category", + "ingredients", + "userid", + "modified", + "created", + ). + Values( + recipe.Title, + recipe.Description, + pq.Array(instructions), + recipe.Serves, + recipe.Difficulty, + durationJSON, + string(recipe.Category), + ingredientsJSON, + recipe.UserId, + nil, + recipe.Created, + ). + Suffix("RETURNING id") + + _sql, args, err := query.ToSql() + if err != nil { + return fmt.Errorf("Failed to construct query: %w", err) } - if err := tx.Commit(); err != nil { - return err + var id int + if err := r.db.Get(&id, _sql, args...); err != nil { + return fmt.Errorf("Failed to create recipe: %w", err) } // Set the new ID @@ -105,36 +106,9 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error { // function will fail - it will not know what recipe to edit. func (r *RecipeRepository) EditRecipe(recipe *domain.Recipe, userId int) error { if recipe.Id <= 0 { - return fmt.Errorf("[ERROR] Recipe must contain an ID. Cannot edit unknown recipe.") + return fmt.Errorf("Recipe must contain an ID. Cannot edit unknown recipe.") } - tx, err := r.db.Begin() - if err != nil { - return err - } - - // This query will ensure the userId matches the owner. - query := `UPDATE recipes SET - title = $1, - description = $2, - instructions = $3, - serves = $4, - difficulty = $5, - duration = $6, - category = $7, - ingredients = $8, - modified = $9 - WHERE id = $10 - AND userid = $11;` - - // NOTE: Data steps - // cast duration to JSON - // convert ingredients to store type - // cast store type to JSON - // extract string instructions from type - // cast category to string - // use nil for the modified time - durationJSON, err := json.Marshal(recipe.Duration) if err != nil { return err @@ -155,38 +129,38 @@ func (r *RecipeRepository) EditRecipe(recipe *domain.Recipe, userId int) error { instructions[i] = instruction.Content } - result, err := tx.Exec( - query, - recipe.Title, - recipe.Description, - pq.Array(instructions), - recipe.Serves, - recipe.Difficulty, - durationJSON, - string(recipe.Category), - ingredientsJSON, - time.Now().UTC(), - recipe.Id, - userId, - ) + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + + query := psql. + Update("recipes"). + Set("title", recipe.Title). + Set("description", recipe.Description). + Set("instructions", pq.Array(instructions)). + Set("serves", recipe.Serves). + Set("difficulty", recipe.Difficulty). + Set("duration", durationJSON). + Set("category", string(recipe.Category)). + Set("ingredients", ingredientsJSON). + Set("modified", time.Now().UTC()). + Where(sq.Eq{ + "id": recipe.Id, + "userid": userId, + }) + + _sql, args, err := query.ToSql() if err != nil { - tx.Rollback() - return err + return fmt.Errorf("Failed to construct query: %w", err) } - rows, err := result.RowsAffected() + result, err := r.db.Exec(_sql, args...) if err != nil { - tx.Rollback() - return err + return fmt.Errorf("Failed to update recipe: %w", err) } - if rows != 1 { - tx.Rollback() - return fmt.Errorf("[ERROR] Modified an unexpected number of rows. Expected 1, modified %d.", rows) - } - - if err := tx.Commit(); err != nil { + if rows, err := result.RowsAffected(); err != nil { return err + } else if rows != 1 { + return fmt.Errorf("Modified an unexpected number of rows. Expected 1, modified %d.", rows) } return nil @@ -195,17 +169,35 @@ func (r *RecipeRepository) EditRecipe(recipe *domain.Recipe, userId int) error { // DeleteRecipe deletes a recipe in the database. This is done by setting the deleted field to true. // This will create a "soft delete" effect. This function does not validate that the user is the owner, // so the caller should validate the owner. If any errors occur, they will be returned to the caller. -func (r *RecipeRepository) DeleteRecipe(recipeId int) error { - query := "UPDATE recipes SET deleted = TRUE WHERE id = $1" +func (r *RecipeRepository) DeleteRecipe(recipeId, userId int) error { + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) - result, err := r.db.Exec(query, recipeId) + query := psql. + Update("recipes"). + Set("deleted", true). + Set("modified", time.Now().UTC()). + Where(sq.Eq{ + "id": recipeId, + "userid": userId, + "deleted": false, + }) + + sql, args, err := query.ToSql() if err != nil { - return err + return fmt.Errorf("Failed to build delete query: %w", err) } - rows, _ := result.RowsAffected() + result, err := r.db.Exec(sql, args...) + if err != nil { + return fmt.Errorf("Failed to delete recipe: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("Failed to get rows affects: %w", err) + } if rows != 1 { - return fmt.Errorf("[ERROR] Incorrect number of rows modified. Expected 1, received %d.", rows) + return fmt.Errorf("Incorrect number of rows modified. Expected 1, received %d.", rows) } return nil @@ -245,7 +237,10 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error &recipe.Created, &recipe.Deleted, ); err != nil { - return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error()) + if err == sql.ErrNoRows { + return nil, err + } + return nil, fmt.Errorf("Failed to location recipe (id: %d) in database: %s", id, err.Error()) } // Parse duration @@ -308,7 +303,7 @@ func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, for _, id := range ids { recipe, err := r.GetRecipe(id, userId) - if err != nil { + if err != nil && err != sql.ErrNoRows { return nil, err } @@ -338,198 +333,165 @@ func isBitActive(bits, pos int) bool { // 12/28/25: This function has changed, now longer returns the recipes, but their IDs for fetching // elsewhere. // +// 2/3/26: Refactored this large function to use Squirrel for simpler generation. Reduced line count by 50, +// but this is still insane. We need to clean this up. +// // This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored // and the standard "not-found" error will be returned. func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]int, error) { - // Compute meals type filters (there are 7 bits) - var mealConditions []string + // Begin creating the query + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + query := psql.Select("r.id").From("recipes r") + + // Only select fields where the recipe ID can be found in the favorites table (mapped to user ID) + if favorites && userId != nil { + query = query. + Join("favorites f ON f.recipeId = r.id"). + Where(sq.Eq{"f.userid": *userId}) + } + + // Compute and add meal type filters (7 bit options) + var mealCategories []string for i := range 7 { if isBitActive(filters.MealType, i) { - mealConditions = append(mealConditions, fmt.Sprintf("category = '%s'", domain.ParseMeal(i))) + mealCategories = append(mealCategories, string(domain.ParseMeal(i))) } } - // Compute time filters (there are 5 bits) - var timeConditions []string + if len(mealCategories) > 0 { + query = query.Where(sq.Eq{"category": mealCategories}) + } + + // Compute and add time filters (5 bit options) + var timeOr sq.Or for i := range 5 { - var cond string if isBitActive(filters.Time, i) { switch i { case 0: - cond = "(duration->>'total')::int < 15" + timeOr = append(timeOr, sq.Lt{"(duration->>'total')::int": 15}) case 1: - cond = "(duration->>'total')::int BETWEEN 15 AND 30" + timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 15 AND 30")) case 2: - cond = "(duration->>'total')::int BETWEEN 30 AND 60" + timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 30 AND 60")) case 3: - cond = "(duration->>'total')::int BETWEEN 60 AND 120" + timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 60 AND 120")) case 4: - cond = "(duration->>'total')::int > 120" + timeOr = append(timeOr, sq.Gt{"(duration->>'total')::int": 120}) } - timeConditions = append(timeConditions, cond) } } - // Compute difficulty filters (there are 5 bits) - var difficultyConditions []string + if len(timeOr) > 0 { + query = query.Where(timeOr) + } + + // Compute and add difficulty filters (5 bit options) + var difficulties []int for i := range 5 { if isBitActive(filters.Difficulty, i) { - cond := fmt.Sprintf("difficulty = '%d'", i+1) - difficultyConditions = append(difficultyConditions, cond) + difficulties = append(difficulties, i+1) } } - // Compute serving size filters (there are 5 bits) - var servingConditions []string + if len(difficulties) > 0 { + query = query.Where(sq.Eq{"difficulty": difficulties}) + } + + // Compute and add serving size filters (5 bit options) + var servingOr sq.Or for i := range 5 { - var cond string if isBitActive(filters.ServingSize, i) { switch i { case 0: - cond = "serves BETWEEN 1 AND 2" + servingOr = append(servingOr, sq.Expr("serves BETWEEN 1 AND 2")) case 1: - cond = "serves BETWEEN 2 AND 4" + servingOr = append(servingOr, sq.Expr("serves BETWEEN 2 AND 4")) case 2: - cond = "serves BETWEEN 4 AND 6" + servingOr = append(servingOr, sq.Expr("serves BETWEEN 4 AND 6")) case 3: - cond = "serves BETWEEN 6 AND 8" + servingOr = append(servingOr, sq.Expr("serves BETWEEN 6 AND 8")) case 4: - cond = "serves > 8" + servingOr = append(servingOr, sq.Gt{"serves": 8}) } - servingConditions = append(servingConditions, cond) } } - // Merge condition strings - mealString := fmt.Sprintf("(%s)", strings.Join(mealConditions, " OR ")) - timeString := fmt.Sprintf("(%s)", strings.Join(timeConditions, " OR ")) - difficultyString := fmt.Sprintf("(%s)", strings.Join(difficultyConditions, " OR ")) - servingString := fmt.Sprintf("(%s)", strings.Join(servingConditions, " OR ")) - - // Combine condition strings - var conditions []string - if len(mealConditions) > 0 { - conditions = append(conditions, mealString) - } - if len(timeConditions) > 0 { - conditions = append(conditions, timeString) - } - if len(difficultyConditions) > 0 { - conditions = append(conditions, difficultyString) - } - if len(servingConditions) > 0 { - conditions = append(conditions, servingString) + if len(servingOr) > 0 { + query = query.Where(servingOr) } - // Define columns to select - columns := []string{ - "r.id", - } - - // Create search vector query with SAFE parameterization - var orderBy string = "" - var searchQuery string = "" - + // Handle search with full-text search and ILIKE fallback if filters.Search != "" { spl := strings.Split(filters.Search, " ") var cleaned []string - // Use a string replacer for safety + // Sanitize search terms replacer := strings.NewReplacer( "'", "", "-", "", "&", "", "|", "", "!", "", - ":", "", // Remove colons to prevent tsquery syntax injection + ":", "", "(", "", ")", "", ) - for i := range len(spl) { - q := strings.TrimSpace(replacer.Replace(spl[i])) - if q != "" { - // Add :* suffix for prefix matching - cleaned = append(cleaned, q+":*") - } - } - - // Join with OR operator for full-text search - vector_query := strings.Join(cleaned, " | ") - searchQuery = vector_query - - // Full-text search with prefix matching - searchCondition := fmt.Sprintf("r.search_vector @@ to_tsquery('english', '%s')", vector_query) - - // Add fallback ILIKE for true substring matching - // This catches cases where "pan" is inside "pancake" but not at word boundaries - var ilikeConditions []string for _, term := range spl { - cleanTerm := strings.TrimSpace(replacer.Replace(term)) - if cleanTerm != "" { - ilikeConditions = append(ilikeConditions, fmt.Sprintf("(r.title ILIKE '%%%s%%' OR r.description ILIKE '%%%s%%')", cleanTerm, cleanTerm)) + q := strings.TrimSpace(replacer.Replace(term)) + if q != "" { + cleaned = append(cleaned, q+":*") // Add prefix matching } } - if len(ilikeConditions) > 0 { - searchCondition = fmt.Sprintf("(%s OR %s)", searchCondition, strings.Join(ilikeConditions, " OR ")) + if len(cleaned) > 0 { + vectorQuery := strings.Join(cleaned, " | ") + + // Build search condition as raw SQL expression + // We'll use sq.Expr for the entire OR clause + var searchConditions []string + var searchArgs []interface{} + + // Full-text search + searchConditions = append(searchConditions, "r.search_vector @@ to_tsquery('english', ?)") + searchArgs = append(searchArgs, vectorQuery) + + // ILIKE fallback for substring matching + for _, term := range spl { + cleanTerm := strings.TrimSpace(replacer.Replace(term)) + if cleanTerm != "" { + searchConditions = append(searchConditions, "r.title ILIKE ?") + searchArgs = append(searchArgs, "%"+cleanTerm+"%") + + searchConditions = append(searchConditions, "r.description ILIKE ?") + searchArgs = append(searchArgs, "%"+cleanTerm+"%") + } + } + + // Combine all conditions with OR + searchExpr := fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")) + query = query.Where(sq.Expr(searchExpr, searchArgs...)) + + // Add ordering for search results + query = query. + OrderBy(fmt.Sprintf("CASE WHEN r.search_vector @@ to_tsquery('english', '%s') THEN 1 ELSE 2 END", vectorQuery)). + OrderBy(fmt.Sprintf("ts_rank(r.search_vector, to_tsquery('english', '%s')) DESC", vectorQuery)). + OrderBy(fmt.Sprintf("ts_rank_cd(r.search_vector, to_tsquery('english', '%s')) DESC", vectorQuery)) } - - conditions = append(conditions, searchCondition) - - // Ranking with preference for full-text matches - orderBy = fmt.Sprintf(` - ORDER BY - CASE - WHEN r.search_vector @@ to_tsquery('english', '%s') THEN 1 - ELSE 2 - END, - ts_rank(r.search_vector, to_tsquery('english', '%s')) DESC, - ts_rank_cd(r.search_vector, to_tsquery('english', '%s')) DESC - `, searchQuery, searchQuery, searchQuery) } - // Generate the query - var query string - if favorites && userId != nil { - query = fmt.Sprintf( - "SELECT %s FROM recipes r JOIN favorites f ON f.recipeId = r.id", - strings.Join(columns, ","), - ) - conditions = append(conditions, fmt.Sprintf("f.userid = %d", *userId)) - } else { - query = fmt.Sprintf("SELECT %s FROM recipes r", strings.Join(columns, ",")) - } + // Exclude deleted recipes + query = query.Where(sq.Eq{"deleted": false}) - // Convert and append conditions if provided - conditions = append(conditions, "deleted = false") - if len(conditions) > 0 { - conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND ")) - query = fmt.Sprintf("%s %s", query, conditionsString) - } - - // Append sorting order if exists - if len(orderBy) > 0 { - query = fmt.Sprintf("%s %s", query, orderBy) - } - - // Finish it off with a semicolon! - query += ";" - - // Execute the query - rows, err := r.db.Query(query) + sql, args, err := query.ToSql() if err != nil { - return []int{}, fmt.Errorf("failed to query recipes: %w", err) + return nil, fmt.Errorf("Failed to build query: %w", err) } - defer rows.Close() + // Execute query using SQLX var ids []int - for rows.Next() { - var id int - if err := rows.Scan(&id); err != nil { - return []int{}, fmt.Errorf("failed to extract ID: %s\n", err.Error()) - } - ids = append(ids, id) + if err = r.db.Select(&ids, sql, args...); err != nil { + return nil, fmt.Errorf("Failed to query recipes: %w", err) } return ids, nil @@ -677,28 +639,26 @@ func (r *RecipeRepository) UpdateRecipeTags(recipe domain.Recipe, tags []string) // // This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored // and the standard "not-found" error will be returned. -func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) { - query := ` - SELECT id - FROM recipes - WHERE userid = $1 AND deleted = false - ORDER BY created DESC; - ` +func (r *RecipeRepository) GetUserRecipesIds(userId int) ([]int, error) { + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) - rows, err := r.db.Query(query, user_id) + query := psql. + Select("id"). + From("recipes"). + Where(sq.Eq{ + "userid": userId, + "deleted": false, + }). + OrderBy("created DESC") + + _sql, args, err := query.ToSql() if err != nil { - return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error()) + return []int{}, fmt.Errorf("Failed to construct SQL query: %w", err) } - defer rows.Close() var ids []int - for rows.Next() { - var r_id int - if err := rows.Scan(&r_id); err != nil { - return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error()) - } - - ids = append(ids, r_id) + if err := r.db.Select(&ids, _sql, args...); err != nil { + return []int{}, fmt.Errorf("Failed to get user recipes: %w", err) } return ids, nil @@ -712,28 +672,29 @@ func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) { // // This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored // and the standard "not-found" error will be returned. -func (r *RecipeRepository) GetUserFavoriteRecipesIds(id int) ([]int, error) { - query := ` - SELECT r.id - FROM favorites f - JOIN recipes r ON r.id = f.recipeid - WHERE f.userid = $1 AND deleted = false - ORDER BY f.created DESC; - ` - rows, err := r.db.Query(query, id) +func (r *RecipeRepository) GetUserFavoriteRecipesIds(userId int) ([]int, error) { + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + + query := psql. + Select("r.id"). + From("favorites f"). + Join("recipes r on r.id = f.recipeid"). + Where(sq.Eq{ + "f.userid": userId, + "deleted": false, + }). + OrderBy("f.created DESC") + + _sql, args, err := query.ToSql() if err != nil { - return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error()) + return []int{}, fmt.Errorf("Failed to construct SQL query: %w", err) } - defer rows.Close() + + fmt.Println(_sql) var ids []int - for rows.Next() { - var r_id int - if err := rows.Scan(&r_id); err != nil { - return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error()) - } - - ids = append(ids, r_id) + if err := r.db.Select(&ids, _sql, args...); err != nil { + return []int{}, fmt.Errorf("Failed to get users' favorite recipes: %w", err) } return ids, nil @@ -784,15 +745,24 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int) return nil } - query := ` - SELECT COUNT(*) - FROM favorites - WHERE recipeid = $1 AND userid = $2; - ` + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + + query := psql. + Select("COUNT(*)"). + From("favorites"). + Where(sq.Eq{ + "recipeid": recipe.Id, + "userid": userId, + }) + + _sql, args, err := query.ToSql() + if err != nil { + return fmt.Errorf("Failed to construct SQL query: %w", err) + } var count int - if err := r.db.QueryRow(query, recipe.Id, userId).Scan(&count); err != nil { - return fmt.Errorf("Failed to get recipe favorite. %s", err.Error()) + if err := r.db.Get(&count, _sql, args...); err != nil { + return fmt.Errorf("Failed to get recipe favorite status: %w", err) } recipe.Favorite = count > 0 @@ -805,41 +775,56 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int) // table and return it. If there is no entry, nil will be returned. Any errors will be bubbled to // the caller. All that is returned is the recipe ID, that way the caller can handle the fetching. func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) { - query := ` - SELECT - r.id - FROM recipes r - JOIN recipeoftheweek rw ON rw.recipeid = r.id - WHERE r.deleted = false - ORDER BY rw.created DESC - LIMIT 1; - ` + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) - var id int - if err := r.db.QueryRow(query).Scan(&id); err != nil { - if errors.Is(err, sql.ErrNoRows) { + query := psql. + Select("r.id"). + From("recipes r"). + Join("recipeoftheweek rw ON rw.recipeid = r.id"). + Where(sq.Eq{"r.deleted": false}). + OrderBy("rw.created DESC"). + Limit(1) + + _sql, args, err := query.ToSql() + if err != nil { + return nil, fmt.Errorf("Failed to build SQL query: %w", err) + } + + var recipeId int + if err := r.db.Get(&recipeId, _sql, args...); err != nil { + if err == sql.ErrNoRows { return nil, nil } return nil, fmt.Errorf("Failed to locate recipe in database: %s", err.Error()) } - return &id, nil + return &recipeId, nil } // IsRecipeOwner takes two required arguments: a user id and a recipe id. This function queries the DB // to check if the user is the owner of the provided recipe. Any error will be bubbled to the caller. func (r *RecipeRepository) IsRecipeOwner(userId, recipeId int) (bool, error) { - query := ` - SELECT - userid - FROM recipes - WHERE deleted = false - AND id = $1; - ` + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + + query := psql. + Select("userid"). + From("recipes"). + Where(sq.Eq{ + "id": recipeId, + "deleted": false, + }) + + _sql, args, err := query.ToSql() + if err != nil { + return false, err + } var recipeOwnerId int - if err := r.db.QueryRow(query, recipeId).Scan(&recipeOwnerId); err != nil { - return false, fmt.Errorf("Failed to get recipe owner id: %s", err.Error()) + if err := r.db.Get(&recipeOwnerId, _sql, args...); err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, fmt.Errorf("Failed to get recipe owner id: %w", err) } return recipeOwnerId == userId, nil diff --git a/internal/infrastructure/database/repository/user_repository.go b/internal/infrastructure/database/repository/user_repository.go index 12098b9..ba5236f 100644 --- a/internal/infrastructure/database/repository/user_repository.go +++ b/internal/infrastructure/database/repository/user_repository.go @@ -4,12 +4,14 @@ import ( "database/sql" "fmt" + sq "github.com/Masterminds/squirrel" domain "github.com/haydenhargreaves/Potion/internal/domain/user" + "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) type UserRepository struct { - db *sql.DB + db *sqlx.DB } // Compile-time check to ensure the UserRepository implements domain.UserRepository @@ -17,7 +19,7 @@ var _ domain.UserRepository = (*UserRepository)(nil) // NewUserRepository creates a user repository object which is used by the user service to access // the database. Any user related database operations will take place in this repository. -func NewUserRepository(db *sql.DB) domain.UserRepository { +func NewUserRepository(db *sqlx.DB) domain.UserRepository { return &UserRepository{db: db} } @@ -30,42 +32,37 @@ func NewUserRepository(db *sql.DB) domain.UserRepository { // best results, pair this function with the GetGoogleUser which will return the user if it can find // it. func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo, googleRefreshToken string) (domain.User, error) { - tx, err := r.db.Begin() - if err != nil { - return domain.User{}, err - } - if googleUserInfo == nil { return domain.User{}, fmt.Errorf("Google user info provided was nil") } - var user domain.User - query := `INSERT INTO users - (GoogleId, Name, Email, ImageUrl, GoogleRefreshToken) - VALUES ($1, $2, $3, $4, $5) RETURNING *;` + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) - if err := tx.QueryRow( - query, - googleUserInfo.Id, - googleUserInfo.Name, - googleUserInfo.Email, - googleUserInfo.Picture, - googleRefreshToken, - ).Scan( - &user.Id, - &user.GoogleId, - &user.Name, - &user.Email, - &user.ImageUrl, - &user.GoogleRefreshToken, - &user.Created, - ); err != nil { - tx.Rollback() - return domain.User{}, err + query := psql. + Insert("users"). + Columns( + "googleid", + "name", + "email", + "imageurl", + "googlerefreshtoken"). + Values( + googleUserInfo.Id, + googleUserInfo.Name, + googleUserInfo.Email, + googleUserInfo.Picture, + googleRefreshToken, + ). + Suffix("RETURNING *") + + _sql, args, err := query.ToSql() + if err != nil { + return domain.User{}, fmt.Errorf("Failed to construct sql query: %w", err) } - if err := tx.Commit(); err != nil { - return domain.User{}, err + var user domain.User + if err := r.db.Get(&user, _sql, args...); err != nil { + return domain.User{}, fmt.Errorf("Failed to create user: %w", err) } return user, nil @@ -75,23 +72,26 @@ func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo, // function is used when a user logs in with Google to prevent duplicate entries from being made. If // no user is found, this function will return a null pointer but not an error. func (r *UserRepository) GetGoogleUser(googleId string) (*domain.User, error) { - var user domain.User - query := `SELECT * FROM users WHERE GoogleId = $1` + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) - if err := r.db.QueryRow(query, googleId).Scan( - &user.Id, - &user.GoogleId, - &user.Name, - &user.Email, - &user.ImageUrl, - &user.GoogleRefreshToken, - &user.Created, - ); err != nil { - // If no user was found, don't error, just return + query := psql. + Select("*"). + From("users"). + Where(sq.Eq{ + "GoogleId": googleId, + }) + + _sql, args, err := query.ToSql() + if err != nil { + return nil, fmt.Errorf("Failed to construct sql query: %w", err) + } + + var user domain.User + if err := r.db.Get(&user, _sql, args...); err != nil { if err == sql.ErrNoRows { return nil, nil } - return nil, err + return nil, fmt.Errorf("Failed to get Google user: %w", err) } return &user, nil @@ -102,18 +102,19 @@ func (r *UserRepository) GetGoogleUser(googleId string) (*domain.User, error) { // Callers are responsible for protecting against double nil results. Any errors will be bubbled // to the caller. func (r *UserRepository) GetUser(id int) (*domain.User, error) { - query := "SELECT * FROM users WHERE id = $1" + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + query := psql. + Select("*"). + From("users"). + Where(sq.Eq{"id": id}) + + _sql, args, err := query.ToSql() + if err != nil { + return nil, fmt.Errorf("Failed to construct sql query: %w", err) + } var user domain.User - if err := r.db.QueryRow(query, id).Scan( - &user.Id, - &user.GoogleId, - &user.Name, - &user.Email, - &user.ImageUrl, - &user.GoogleRefreshToken, - &user.Created, - ); err != nil { + if err := r.db.Get(&user, _sql, args...); err != nil { // If no user was found, don't error, just return if err == sql.ErrNoRows { return nil, nil diff --git a/internal/infrastructure/logging/loggers/database_logger.go b/internal/infrastructure/logging/loggers/database_logger.go index df4160a..c57cd6d 100644 --- a/internal/infrastructure/logging/loggers/database_logger.go +++ b/internal/infrastructure/logging/loggers/database_logger.go @@ -1,21 +1,21 @@ package loggers import ( - "database/sql" "fmt" "github.com/haydenhargreaves/Potion/internal/infrastructure/logging" + "github.com/jmoiron/sqlx" ) type DatabaseLogger struct { - db *sql.DB + db *sqlx.DB table string filter logging.LogLevel } var _ logging.Logger = (*DatabaseLogger)(nil) -func NewDatabaseLogger(conn *sql.DB, table string, filter logging.LogLevel) (logging.Logger, error) { +func NewDatabaseLogger(conn *sqlx.DB, table string, filter logging.LogLevel) (logging.Logger, error) { if conn == nil { return &DatabaseLogger{}, fmt.Errorf("Connection is nil, something is very wrong.") } @@ -45,7 +45,7 @@ func NewDatabaseLogger(conn *sql.DB, table string, filter logging.LogLevel) (log // tableExists queries a database connection and returns whether the table name provided // exists on the table. -func tableExists(conn *sql.DB, tableName string) (bool, error) { +func tableExists(conn *sqlx.DB, tableName string) (bool, error) { var exists bool err := conn.QueryRow(` SELECT EXISTS ( diff --git a/web/src/components/forms/InstructionList.tsx b/web/src/components/forms/InstructionList.tsx index ed977ab..c14d43d 100644 --- a/web/src/components/forms/InstructionList.tsx +++ b/web/src/components/forms/InstructionList.tsx @@ -1,6 +1,6 @@ import { Reorder } from "motion/react"; import InstructionElement from "./InstructionElement"; -import { useEffect, type Dispatch, type SetStateAction } from "react"; +import { type Dispatch, type SetStateAction } from "react"; import type { RecipeInstruction } from "../../types/recipe"; import type { RecipeValidationEntry } from "../../pages/Create"; diff --git a/web/src/pages/Recipe.tsx b/web/src/pages/Recipe.tsx index 469ce18..00a5fa1 100644 --- a/web/src/pages/Recipe.tsx +++ b/web/src/pages/Recipe.tsx @@ -127,8 +127,12 @@ export default function RecipePage() { - {isAuthor && } - {isAuthor && } + {isAuthor && ( + <> + + + + )}

About this recipe