package repository import ( "database/sql" "encoding/json" "fmt" "strings" domain "github.com/haydenhargreaves/Potion/internal/domain/recipe" "github.com/lib/pq" ) type RecipeRepository struct { db *sql.DB } // Compile-time check to ensure the RecipeRepository implements domain.RecipeRepository 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 { return &RecipeRepository{db: db} } // NOTE: This function modified the provided recipe with the new values, such as id and time stamp // CreateRecipe creates a recipe in the database. The recipe provided should contain all data except // time stamps and the ID; the database will fill them when the operation succeeds. Any errors will // 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 { tx.Rollback() 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 // cast ingredients to JSON // cast category to string // use nil for the modified time durationJSON, err := json.Marshal(recipe.Duration) if err != nil { return err } ingredientsJSON, err := json.Marshal(recipe.Ingredients) if err != nil { return err } var id int if err = tx.QueryRow( query, recipe.Title, recipe.Description, pq.Array(recipe.Instructions), recipe.Serves, recipe.Difficulty, durationJSON, string(recipe.Category), ingredientsJSON, recipe.UserId, nil, recipe.Created, ).Scan(&id); err != nil { tx.Rollback() return err } if err := tx.Commit(); err != nil { tx.Rollback() return err } // Set the new ID recipe.Id = id return nil } // GetRecipe gets a recipe from the database via its ID. The operation is wrapped in a transaction // for added safety. The repository will not check for a nil result, instead the service will. Callers // are responsible for protecting against double nil results. Any errors will be bubbled to the caller. func (r *RecipeRepository) GetRecipe(id int) (*domain.Recipe, error) { tx, err := r.db.Begin() if err != nil { tx.Rollback() return nil, err } query := "SELECT * FROM recipes WHERE id = $1" var durationBytes []byte var ingredientBytes []byte var recipe domain.Recipe if err := tx.QueryRow(query, id).Scan( &recipe.Id, &recipe.Title, &recipe.Description, pq.Array(&recipe.Instructions), &recipe.Serves, &recipe.Difficulty, &durationBytes, &recipe.Category, &ingredientBytes, &recipe.UserId, &recipe.Modified, &recipe.Created, ); err != nil { return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error()) } if err := tx.Commit(); err != nil { tx.Rollback() return nil, err } // Parse duration if len(durationBytes) > 0 { var duration domain.RecipeDuration if err := json.Unmarshal(durationBytes, &duration); err != nil { return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error()) } recipe.Duration = duration } else { recipe.Duration = domain.RecipeDuration{} } // Parse ingredient if len(ingredientBytes) > 0 { var ingredients []domain.RecipeIngredient if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil { return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error()) } recipe.Ingredients = ingredients } else { recipe.Ingredients = []domain.RecipeIngredient{} } return &recipe, nil } // isBitActive returns true when the bit at pos (0 indexed) is true. func isBitActive(bits, pos int) bool { return (bits>>pos)&1 == 1 } // SearchRecipes will search the recipe table using the provided filters and return an unbound list // of recipes. The filters are fairly complex, they are stored as bit masks. A more details // description can be found in the recipe service implementation. Any errors will be bubbled to the // caller. // // TODO: Pagination is required, to provide infinite scroll. func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters) ([]domain.Recipe, error) { tx, err := r.db.Begin() if err != nil { tx.Rollback() return nil, err } // Generate the query query := "SELECT * FROM recipes" // Compute meals type filters (there are 7 bits) var mealConditions []string for i := range 7 { if isBitActive(filters.MealType, i) { mealConditions = append(mealConditions, fmt.Sprintf("category = '%s'", domain.ParseMeal(i))) } } // Compute time filters (there are 5 bits) var timeConditions []string for i := range 5 { var cond string if isBitActive(filters.Time, i) { switch i { case 0: cond = "(duration->>'total')::int < 15" case 1: cond = "(duration->>'total')::int BETWEEN 15 AND 30" case 2: cond = "(duration->>'total')::int BETWEEN 30 AND 60" case 3: cond = "(duration->>'total')::int BETWEEN 60 AND 120" case 4: cond = "(duration->>'total')::int > 120" } timeConditions = append(timeConditions, cond) } } // Compute difficulty filters (there are 5 bits) var difficultyConditions []string for i := range 5 { if isBitActive(filters.Difficulty, i) { cond := fmt.Sprintf("difficulty = '%d'", i+1) difficultyConditions = append(difficultyConditions, cond) } } // Compute serving size filters (there are 5 bits) var servingConditions []string for i := range 5 { var cond string if isBitActive(filters.ServingSize, i) { switch i { case 0: cond = "serves BETWEEN 1 AND 2" case 1: cond = "serves BETWEEN 2 AND 4" case 2: cond = "serves BETWEEN 4 AND 6" case 3: cond = "serves BETWEEN 6 AND 8" case 4: cond = "serves > 8" } servingConditions = append(servingConditions, cond) } } // TODO: Title search somehow... // 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) } // Convert and append conditions if len(conditions) > 0 { conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND ")) query = fmt.Sprintf("%s %s;", query, conditionsString) } else { query += ";" } fmt.Println(query) // Execute the query rows, err := tx.Query(query) if err != nil { return nil, fmt.Errorf("failed to query recipes: %w", err) } defer rows.Close() var recipes []domain.Recipe for rows.Next() { // Parsed values location var recipe domain.Recipe var durationBytes []byte var ingredientBytes []byte if err := rows.Scan( &recipe.Id, &recipe.Title, &recipe.Description, pq.Array(&recipe.Instructions), &recipe.Serves, &recipe.Difficulty, &durationBytes, &recipe.Category, &ingredientBytes, &recipe.UserId, &recipe.Modified, &recipe.Created, ); err != nil { return nil, fmt.Errorf("failed to scan recipe row: %w", err) } // Parse duration from bytes if len(durationBytes) > 0 { var duration domain.RecipeDuration if err := json.Unmarshal(durationBytes, &duration); err != nil { return nil, fmt.Errorf("failed to parse duration for recipe ID %d: %w", recipe.Id, err) } recipe.Duration = duration } else { recipe.Duration = domain.RecipeDuration{} } // Parse ingredients from bytes if len(ingredientBytes) > 0 { var ingredients []domain.RecipeIngredient if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil { return nil, fmt.Errorf("failed to parse ingredients for recipe ID %d: %w", recipe.Id, err) } recipe.Ingredients = ingredients } else { recipe.Ingredients = []domain.RecipeIngredient{} } recipes = append(recipes, recipe) } if err := tx.Commit(); err != nil { tx.Rollback() return nil, err } return recipes, nil }