Hayden Hargreaves c0fc47c569 (UI): UI implementation of the search page. Somewhat wired up.
Missing some context storage and better passing of data to allow
between the home page to the search page. Need a way to store the search
results in state so they can be retrieved when the page is reloaded,
etc.
2025-07-07 21:36:13 -07:00

327 lines
8.3 KiB
Go

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
}
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
}