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.
327 lines
8.3 KiB
Go
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
|
|
}
|