Still having the stupid ass nil dereferences, I think I might need to migrate to using success returns instead of pointers. Because they're fucked. And even more so now.
920 lines
24 KiB
Go
920 lines
24 KiB
Go
package repository
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
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, userId *int) (*domain.Recipe, error) {
|
|
tx, err := r.db.Begin()
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
query := `
|
|
SELECT
|
|
id, title, description, instructions, serves, difficulty, duration, category, ingredients,
|
|
userid, modified, created
|
|
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())
|
|
}
|
|
|
|
// 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{}
|
|
}
|
|
|
|
// Add tags
|
|
if err := r.GetRecipeTags(&recipe); err != nil {
|
|
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
|
|
}
|
|
|
|
// Get favorite status, if user id is provided
|
|
if userId != nil {
|
|
if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
|
|
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
|
|
}
|
|
} else {
|
|
recipe.Favorite = false
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
return &recipe, nil
|
|
}
|
|
|
|
// GetRecipes gets a list of recipes from the database via their 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) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) {
|
|
tx, err := r.db.Begin()
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
query := `
|
|
SELECT
|
|
id, title, description, instructions, serves, difficulty, duration, category, ingredients, userid, modified, created
|
|
FROM recipes
|
|
WHERE id = ANY($1)
|
|
ORDER BY array_position($1, id);
|
|
`
|
|
|
|
var recipes []domain.Recipe
|
|
|
|
rows, err := tx.Query(query, pq.Array(ids))
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, fmt.Errorf("Failed to get recipes. %s", err.Error())
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
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 from database: %s", err.Error())
|
|
}
|
|
|
|
// 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{}
|
|
}
|
|
|
|
// Add tags
|
|
if err := r.GetRecipeTags(&recipe); err != nil {
|
|
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
|
|
}
|
|
|
|
// Get favorite status, if user id is provided
|
|
if userId != nil {
|
|
if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
|
|
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
|
|
}
|
|
} else {
|
|
recipe.Favorite = false
|
|
}
|
|
|
|
recipes = append(recipes, recipe)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
return recipes, 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.
|
|
//
|
|
// The favorites parameter is used to only return filters favorited by the userId provided.
|
|
//
|
|
// TODO: Pagination is required, to provide infinite scroll.
|
|
func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]domain.Recipe, error) {
|
|
tx, err := r.db.Begin()
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Define columns to select. More fields can be added if the full text search is required
|
|
columns := []string{
|
|
"r.id",
|
|
"r.title",
|
|
"r.description",
|
|
"r.instructions",
|
|
"r.serves",
|
|
"r.difficulty",
|
|
"r.duration",
|
|
"r.category",
|
|
"r.ingredients",
|
|
"r.userid",
|
|
"r.modified",
|
|
"r.created",
|
|
}
|
|
|
|
// TODO: Need to add these to the query
|
|
|
|
// FROM ... JOIN favorites f ON f.recipeId = r.id
|
|
// WHERE ... AND f.userId = 3
|
|
|
|
// Create search vector query
|
|
var orderBy string = ""
|
|
if filters.Search != "" {
|
|
vector_query := strings.ReplaceAll(filters.Search, " ", " | ")
|
|
|
|
conditions = append(
|
|
conditions,
|
|
fmt.Sprintf("r.search_vector @@ to_tsquery('english', '%s')", vector_query),
|
|
)
|
|
|
|
template := `
|
|
ORDER BY
|
|
ts_rank(r.search_vector, to_tsquery('english', '%s')) DESC,
|
|
ts_rank_cd(r.search_vector, to_tsquery('english', '%s')) DESC
|
|
`
|
|
orderBy = fmt.Sprintf(template, vector_query, vector_query)
|
|
}
|
|
|
|
// Generate the query
|
|
var query string
|
|
if favorites && userId != nil {
|
|
query = fmt.Sprintf(
|
|
"SELECT %s FROM recipes r JOIN favorites f ON f.recipeId = r.id",
|
|
strings.Join(columns, ","),
|
|
)
|
|
|
|
// Add new favorite condition to the conditions list
|
|
conditions = append(conditions, fmt.Sprintf("f.userid = %d", *userId))
|
|
} else {
|
|
query = fmt.Sprintf("SELECT %s FROM recipes r", strings.Join(columns, ","))
|
|
}
|
|
|
|
// Convert and append conditions if provided
|
|
if len(conditions) > 0 {
|
|
conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND "))
|
|
query = fmt.Sprintf("%s %s", query, conditionsString)
|
|
}
|
|
|
|
// Append sorting order if exists
|
|
if len(orderBy) > 0 {
|
|
query = fmt.Sprintf("%s %s", query, orderBy)
|
|
}
|
|
|
|
// Finish it off with a colon!
|
|
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{}
|
|
}
|
|
|
|
// Add tags
|
|
if err := r.GetRecipeTags(&recipe); err != nil {
|
|
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
|
|
}
|
|
|
|
// Add recipe if not a favorite search
|
|
if !favorites && userId != nil {
|
|
if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
|
|
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
|
|
}
|
|
}
|
|
|
|
if favorites {
|
|
recipe.Favorite = true
|
|
}
|
|
|
|
recipes = append(recipes, recipe)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
return recipes, nil
|
|
}
|
|
|
|
// CreateRecipeTags accepts a list of tags (names) and a recipe (already created by the DB) and
|
|
// creates the tags that do not exists, and adds those that do exist to the mapping table for the
|
|
// recipe. The result is records in the RecipeTags mapping table that represent all of the new
|
|
// and existing tags provided to this function. The recipe object must only contain an ID to call
|
|
// 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()
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
// Normalize the tag names (lower case with trimmed space)
|
|
normalized := make(map[string]struct{}) // Use map to disallow duplicates
|
|
for _, tag := range tags {
|
|
normalized[strings.ToLower(strings.TrimSpace(tag))] = struct{}{}
|
|
}
|
|
|
|
// 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("Failed to retrieve or create tag. %s\n", err.Error())
|
|
}
|
|
|
|
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);")
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to create statement for recipe tag mapping. %s\n", err.Error())
|
|
}
|
|
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.Commit(); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetUserRecipes gets a list of a users owned recipes. This function does not ensure the user is
|
|
// authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list
|
|
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
|
|
func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) {
|
|
tx, err := r.db.Begin()
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
query := `
|
|
SELECT id, title, description, instructions, serves, difficulty, duration, category, ingredients,
|
|
userid, modified, created
|
|
FROM recipes
|
|
WHERE userid = $1
|
|
ORDER BY created DESC;
|
|
`
|
|
|
|
rows, err := tx.Query(query, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error())
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Prepare statement for tag query
|
|
// tagQuery := `
|
|
// `
|
|
|
|
var recipes []domain.Recipe
|
|
for rows.Next() {
|
|
var recipe domain.Recipe
|
|
var durationBytes []byte
|
|
var ingredientBytes []byte
|
|
|
|
// Scan results from recipe query onto recipe object
|
|
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 row onto recipe object. %s\n", err.Error())
|
|
}
|
|
|
|
// 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{}
|
|
}
|
|
|
|
// Add tags
|
|
if err := r.GetRecipeTags(&recipe); err != nil {
|
|
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
|
|
}
|
|
|
|
// Get favorite status
|
|
if err := r.GetRecipeFavorite(&recipe, id); err != nil {
|
|
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
|
|
}
|
|
|
|
recipes = append(recipes, recipe)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
return recipes, nil
|
|
}
|
|
|
|
func (r *RecipeRepository) GetUserFavoriteRecipes(id int) ([]domain.Recipe, error) {
|
|
tx, err := r.db.Begin()
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
query := `
|
|
SELECT r.id, r.title, r.description, r.instructions, r.serves, r.difficulty, r.duration, r.category, r.ingredients, r.
|
|
userid, r.modified, r.created
|
|
FROM favorites f
|
|
JOIN recipes r ON r.id = f.recipeid
|
|
WHERE f.userid = $1
|
|
ORDER BY f.created DESC;
|
|
`
|
|
rows, err := tx.Query(query, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error())
|
|
}
|
|
defer rows.Close()
|
|
|
|
var recipes []domain.Recipe
|
|
for rows.Next() {
|
|
var recipe domain.Recipe
|
|
var durationBytes []byte
|
|
var ingredientBytes []byte
|
|
|
|
// Scan results from recipe query onto recipe object
|
|
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 row onto recipe object. %s\n", err.Error())
|
|
}
|
|
|
|
// 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{}
|
|
}
|
|
|
|
// Add tags
|
|
if err := r.GetRecipeTags(&recipe); err != nil {
|
|
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
|
|
}
|
|
|
|
// Set favorite status (they're always true!)
|
|
recipe.Favorite = true
|
|
|
|
recipes = append(recipes, recipe)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
return recipes, nil
|
|
}
|
|
|
|
// GetRecipeTags requires a recipe to be filled with at least an ID. This function will use the ID
|
|
// defined in the provided recipe to fill the Tags array with the recipe's tags from the database.
|
|
// The recipe is modified in place and is not returned. Any errors will be bubbled to the caller.
|
|
func (r *RecipeRepository) GetRecipeTags(recipe *domain.Recipe) error {
|
|
tx, err := r.db.Begin()
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
recipe.Tags = []domain.Tag{}
|
|
|
|
query := `
|
|
SELECT t.* FROM tags t
|
|
JOIN recipetags rt ON rt.tagid = t.id
|
|
WHERE rt.recipeid = $1;
|
|
`
|
|
rows, err := tx.Query(query, recipe.Id)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to get tags for recipe. %s\n", err.Error())
|
|
}
|
|
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)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetRecipeFavorite requires a recipe to be filled with at least an ID. This function will use the
|
|
// ID defined in the provided recipe to fill the favorite status of the recipe, based on the provided
|
|
// userId. The recipe is modified in place and is not returned. Any errors will be bubbled to the caller.
|
|
func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int) error {
|
|
tx, err := r.db.Begin()
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
query := `
|
|
SELECT COUNT(*)
|
|
FROM favorites
|
|
WHERE recipeid = $1 AND userid = $2;
|
|
`
|
|
|
|
var count int
|
|
if err := tx.QueryRow(query, recipe.Id, userId).Scan(&count); err != nil {
|
|
tx.Rollback()
|
|
return fmt.Errorf("Failed to get recipe favorite. %s", err.Error())
|
|
}
|
|
|
|
recipe.Favorite = count > 0
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *RecipeRepository) GetRecipeOfTheWeek(userId *int, date time.Time) (*domain.Recipe, error) {
|
|
tx, err := r.db.Begin()
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
query := `
|
|
SELECT
|
|
r.id, r.title, r.description, r.instructions, r.serves, r.difficulty, r.duration, r.category,
|
|
r.ingredients, r.userid, r.modified, r.created
|
|
FROM recipes r
|
|
JOIN recipeoftheweek rw ON rw.recipeid = r.id
|
|
ORDER BY created DESC
|
|
LIMIT 1;
|
|
`
|
|
|
|
var durationBytes []byte
|
|
var ingredientBytes []byte
|
|
|
|
var recipe domain.Recipe
|
|
if err := tx.QueryRow(query).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())
|
|
}
|
|
|
|
// 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{}
|
|
}
|
|
|
|
// Add tags
|
|
if err := r.GetRecipeTags(&recipe); err != nil {
|
|
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
|
|
}
|
|
|
|
// Get favorite status, if user id is provided
|
|
if userId != nil {
|
|
if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
|
|
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
|
|
}
|
|
} else {
|
|
recipe.Favorite = false
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
return &recipe, nil
|
|
}
|