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/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go
index afc86b9..7e5cc76 100644
--- a/internal/infrastructure/database/repository/recipe_repository.go
+++ b/internal/infrastructure/database/repository/recipe_repository.go
@@ -3,7 +3,6 @@ package repository
import (
"database/sql"
"encoding/json"
- "errors"
"fmt"
"strings"
"time"
@@ -34,26 +33,7 @@ func NewRecipeRepository(db *sqlx.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
@@ -74,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
@@ -107,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
@@ -157,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
@@ -197,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
@@ -646,28 +636,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
@@ -681,28 +669,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
@@ -753,15 +742,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
@@ -774,41 +772,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/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() {