diff --git a/flake.nix b/flake.nix index 2465d44..02b459d 100644 --- a/flake.nix +++ b/flake.nix @@ -20,13 +20,8 @@ go gopls go-tools - htmx-lsp2 - templ tailwindcss_4 tailwindcss-language-server - watchman - docker-language-server - dockerfile-language-server-nodejs gcc_multi glibc_multi nodejs diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go index c31962a..2f6d1d8 100644 --- a/internal/infrastructure/database/repository/recipe_repository.go +++ b/internal/infrastructure/database/repository/recipe_repository.go @@ -210,19 +210,41 @@ func (r *RecipeRepository) DeleteRecipe(recipeId, userId 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) GetRecipe(id int, userId *int) (*domain.Recipe, error) { - query := `SELECT - id, title, description, instructions, serves, difficulty, duration, category, ingredients, - userid, modified, created, deleted - FROM recipes - WHERE id = $1 AND deleted = false; - ` + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + + query := psql. + Select( + "id", + "title", + "description", + "instructions", + "serves", + "difficulty", + "duration", + "category", + "ingredients", + "userid", + "modified", + "created", + "deleted", + ). + From("recipes"). + Where(sq.Eq{ + "id": id, + "deleted": false, + }) + + _sql, args, err := query.ToSql() + if err != nil { + return nil, fmt.Errorf("Failed to construct sql query: %w", err) + } var durationBytes []byte var instructions pq.StringArray var ingredientBytes []byte var recipe domain.Recipe - if err := r.db.QueryRow(query, id).Scan( + if err := r.db.QueryRowx(_sql, args...).Scan( &recipe.Id, &recipe.Title, &recipe.Description, @@ -504,51 +526,64 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i // this function successfully, therefore, it must be an existing recipe. Any errors will be bubbled // to the caller. func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string) error { - tx, err := r.db.Begin() + tx, err := r.db.Beginx() if err != nil { return err } + defer tx.Rollback() - // Normalize the tag names (lower case with trimmed space) - normalized := make(map[string]struct{}) // Use map to disallow duplicates + psql := sq.StatementBuilder. + PlaceholderFormat(sq.Dollar). + RunWith(tx) + + // Normalize tags (lowercase, trimmed, no duplicates) + normalized := make(map[string]struct{}) for _, tag := range tags { - normalized[strings.ToLower(strings.TrimSpace(tag))] = struct{}{} + t := strings.ToLower(strings.TrimSpace(tag)) + if t != "" { + normalized[t] = struct{}{} + } } - // Insert the tags into the DB and return their IDS into the tag ID list - var tagIds []int + // Insert tags and collect IDs + var tagIDs []int for tag := range normalized { - var tagId int + var tagID int - query := ` - INSERT INTO tags (name) VALUES ($1) - ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name - RETURNING id; - ` - - err := tx.QueryRow(query, tag).Scan(&tagId) + _sql, args, err := psql. + Insert("tags"). + Columns("name"). + Values(tag). + Suffix("ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id"). + ToSql() if err != nil { - return fmt.Errorf("Failed to retrieve or create tag. %s\n", err.Error()) + return fmt.Errorf("failed to build tag insert query: %w", err) } - tagIds = append(tagIds, tagId) + if err = tx.QueryRowx(_sql, args...).Scan(&tagID); err != nil { + return fmt.Errorf("failed to retrieve or create tag: %w", err) + } + + tagIDs = append(tagIDs, tagID) } - // Using a prepared statement, execute the mapping insertions one-by-one - for _, id := range tagIds { - stmt, err := tx.Prepare("INSERT INTO RecipeTags (RecipeId, TagId) VALUES ($1, $2);") + // Insert recipe <-> tag mappings + for _, tagID := range tagIDs { + _sql, args, err := psql. + Insert("RecipeTags"). + Columns("RecipeId", "TagId"). + Values(recipe.Id, tagID). + ToSql() if err != nil { - return fmt.Errorf("Failed to create statement for recipe tag mapping. %s\n", err.Error()) + return fmt.Errorf("failed to build recipe tag mapping query: %w", err) } - defer stmt.Close() - if _, err := stmt.Exec(recipe.Id, id); err != nil { - return fmt.Errorf("Failed to insert tag-recipe-mapping. %s\n", err.Error()) + if _, err = tx.Exec(_sql, args...); err != nil { + return fmt.Errorf("failed to insert recipe tag mapping: %w", err) } } - if err := tx.Commit(); err != nil { - tx.Rollback() + if err = tx.Commit(); err != nil { return err } @@ -560,75 +595,88 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string) // and creates new associations for the provided tags. The recipe object must contain // a valid ID. Any errors will be bubbled to the caller. func (r *RecipeRepository) UpdateRecipeTags(recipe domain.Recipe, tags []string) error { - tx, err := r.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() // Rollback if we don't commit - if recipe.Id <= 0 { return fmt.Errorf("[ERROR] Recipe must have a valid ID") } - // Step 1: Delete all existing tag associations for this recipe - deleteQuery := `DELETE FROM RecipeTags WHERE RecipeId = $1;` - if _, err := tx.Exec(deleteQuery, recipe.Id); err != nil { - return fmt.Errorf("[ERROR] Failed to delete existing recipe tags: %w", err) - } - - // Step 2: Normalize the tag names (lower case with trimmed space) - normalized := make(map[string]struct{}) // Use map to disallow duplicates - for _, tag := range tags { - trimmed := strings.ToLower(strings.TrimSpace(tag)) - if trimmed != "" { - normalized[trimmed] = struct{}{} - } - } - - // If no tags provided, we're done (all tags removed) - if len(normalized) == 0 { - if err := tx.Commit(); err != nil { - return err - } - return nil - } - - // Step 3: Insert the tags into the DB and return their IDs into the tag ID list - var tagIds []int - for tag := range normalized { - var tagId int - query := ` - INSERT INTO tags (name) VALUES ($1) - ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name - RETURNING id; - ` - err := tx.QueryRow(query, tag).Scan(&tagId) - if err != nil { - return fmt.Errorf("[ERROR] Failed to retrieve or create tag: %w", err) - } - tagIds = append(tagIds, tagId) - } - - // Step 4: Insert the new tag associations - // Use a single prepared statement for all inserts - stmt, err := tx.Prepare("INSERT INTO RecipeTags (RecipeId, TagId) VALUES ($1, $2);") + tx, err := r.db.Beginx() if err != nil { - return fmt.Errorf("[ERROR] Failed to create statement for recipe tag mapping: %w", err) - } - defer stmt.Close() - - for _, id := range tagIds { - if _, err := stmt.Exec(recipe.Id, id); err != nil { - return fmt.Errorf("[ERROR] Failed to insert tag-recipe mapping: %w", err) - } - } - - // Commit the transaction - if err := tx.Commit(); err != nil { return err } + defer tx.Rollback() - return nil + psql := sq.StatementBuilder. + PlaceholderFormat(sq.Dollar). + RunWith(tx) + + // Step 1: delete existing tag mappings + { + _sql, args, err := psql. + Delete("RecipeTags"). + Where(sq.Eq{"RecipeId": recipe.Id}). + ToSql() + if err != nil { + return fmt.Errorf("[ERROR] failed to build delete recipe tags query: %w", err) + } + + if _, err = tx.Exec(_sql, args...); err != nil { + return fmt.Errorf("[ERROR] failed to delete existing recipe tags: %w", err) + } + } + + // Step 2: normalize tags + normalized := make(map[string]struct{}) + for _, tag := range tags { + t := strings.ToLower(strings.TrimSpace(tag)) + if t != "" { + normalized[t] = struct{}{} + } + } + + // No tags means "remove all tags" — we’re done + if len(normalized) == 0 { + return tx.Commit() + } + + // Step 3: upsert tags and collect IDs + var tagIDs []int + for tag := range normalized { + var tagID int + + _sql, args, err := psql. + Insert("tags"). + Columns("name"). + Values(tag). + Suffix("ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id"). + ToSql() + if err != nil { + return fmt.Errorf("[ERROR] failed to build tag upsert query: %w", err) + } + + if err = tx.QueryRowx(_sql, args...).Scan(&tagID); err != nil { + return fmt.Errorf("[ERROR] failed to retrieve or create tag: %w", err) + } + + tagIDs = append(tagIDs, tagID) + } + + // Step 4: insert new recipe ↔ tag mappings + for _, tagID := range tagIDs { + _sql, args, err := psql. + Insert("RecipeTags"). + Columns("RecipeId", "TagId"). + Values(recipe.Id, tagID). + ToSql() + if err != nil { + return fmt.Errorf("[ERROR] failed to build recipe tag mapping query: %w", err) + } + + if _, err = tx.Exec(_sql, args...); err != nil { + return fmt.Errorf("[ERROR] failed to insert recipe tag mapping: %w", err) + } + } + + return tx.Commit() } // GetUserRecipes gets a list of a users owned recipes. This function does not ensure the user is @@ -709,28 +757,21 @@ func (r *RecipeRepository) GetRecipeTags(recipe *domain.Recipe) error { return nil } - recipe.Tags = []domain.Tag{} + psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + query := psql. + Select("t.*"). + From("tags t"). + Join("recipetags rt on rt.tagid = t.id"). + Where(sq.Eq{"rt.recipeid": recipe.Id}) - query := ` - SELECT t.* FROM tags t - JOIN recipetags rt ON rt.tagid = t.id - WHERE rt.recipeid = $1; - ` - rows, err := r.db.Query(query, recipe.Id) + _sql, args, err := query.ToSql() if err != nil { - return fmt.Errorf("Failed to get tags for recipe. %s\n", err.Error()) + return fmt.Errorf("Failed to construct sql query: %w", err) } - defer rows.Close() - for rows.Next() { - var tag domain.Tag - - err := rows.Scan(&tag.Id, &tag.Name, &tag.Created) - if err != nil { - return fmt.Errorf("Failed to scan tag onto domain model. %s\n", err.Error()) - } - - recipe.Tags = append(recipe.Tags, tag) + recipe.Tags = []domain.Tag{} + if err := r.db.Select(&recipe.Tags, _sql, args...); err != nil { + return fmt.Errorf("Failed to get recipe tags: %w", err) } return nil