626 lines
18 KiB
Go
626 lines
18 KiB
Go
package repository
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"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
|
|
// convert ingredients to store type
|
|
// cast store type to JSON
|
|
// extract string instructions from type
|
|
// cast category to string
|
|
// use nil for the modified time
|
|
|
|
durationJSON, err := json.Marshal(recipe.Duration)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ingredientsStore := domain.RecipeIngredientStore{
|
|
Sections: recipe.Sections,
|
|
Ingredients: recipe.Ingredients,
|
|
}
|
|
|
|
ingredientsJSON, err := json.Marshal(ingredientsStore)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
instructions := make([]string, len(recipe.Instructions))
|
|
for i, instruction := range recipe.Instructions {
|
|
instructions[i] = instruction.Content
|
|
}
|
|
|
|
var id int
|
|
if err = tx.QueryRow(
|
|
query,
|
|
recipe.Title,
|
|
recipe.Description,
|
|
pq.Array(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) {
|
|
query := ` SELECT
|
|
id, title, description, instructions, serves, difficulty, duration, category, ingredients,
|
|
userid, modified, created
|
|
FROM recipes
|
|
WHERE id = $1
|
|
`
|
|
|
|
var durationBytes []byte
|
|
var instructions pq.StringArray
|
|
var ingredientBytes []byte
|
|
|
|
var recipe domain.Recipe
|
|
if err := r.db.QueryRow(query, id).Scan(
|
|
&recipe.Id,
|
|
&recipe.Title,
|
|
&recipe.Description,
|
|
// pq.Array(&instructions),
|
|
&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 store domain.RecipeIngredientStore
|
|
if err := json.Unmarshal(ingredientBytes, &store); err != nil {
|
|
// Check for unmarshal to support backwards compatability
|
|
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
|
|
}
|
|
|
|
recipe.Ingredients = store.Ingredients
|
|
recipe.Sections = store.Sections
|
|
} else {
|
|
recipe.Ingredients = []domain.RecipeIngredient{}
|
|
}
|
|
|
|
// Add instructions
|
|
for _, instruction := range instructions {
|
|
recipe.Instructions = append(recipe.Instructions, domain.RecipeInstruction{Content: instruction})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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) {
|
|
var recipes []domain.Recipe
|
|
|
|
for _, id := range ids {
|
|
recipe, err := r.GetRecipe(id, userId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Skip any un-found recipes...?
|
|
if recipe != nil {
|
|
recipes = append(recipes, *recipe)
|
|
}
|
|
}
|
|
|
|
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.
|
|
//
|
|
// TODO: This does not work in the current build, the DB does not return valid values.
|
|
//
|
|
// 12/28/25: This function has changed, now longer returns the recipes, but their IDs for fetching
|
|
// elsewhere.
|
|
func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]int, error) {
|
|
// 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",
|
|
}
|
|
|
|
// 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 != "" {
|
|
spl := strings.Split(filters.Search, " ")
|
|
var cleaned []string
|
|
|
|
// Use a string replacer, each word in the query will be passed through this
|
|
replacer := strings.NewReplacer(
|
|
"'", "",
|
|
"-", "",
|
|
"&", "",
|
|
"|", "",
|
|
"!", "",
|
|
)
|
|
|
|
for i := range len(spl) {
|
|
q := strings.TrimSpace(replacer.Replace(spl[i]))
|
|
|
|
if q != "" {
|
|
cleaned = append(cleaned, q)
|
|
}
|
|
}
|
|
|
|
vector_query := strings.Join(cleaned, " | ")
|
|
|
|
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 := r.db.Query(query)
|
|
if err != nil {
|
|
return []int{}, fmt.Errorf("failed to query recipes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var ids []int
|
|
for rows.Next() {
|
|
var id int
|
|
if err := rows.Scan(&id); err != nil {
|
|
return []int{}, fmt.Errorf("failed to extract ID: %s\n", err.Error())
|
|
}
|
|
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
return ids, 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.
|
|
//
|
|
// TODO: This should just return the IDs
|
|
func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) {
|
|
query := `
|
|
SELECT id
|
|
FROM recipes
|
|
WHERE userid = $1
|
|
ORDER BY created DESC;
|
|
`
|
|
|
|
rows, err := r.db.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 r_id int
|
|
if err := rows.Scan(&r_id); err != nil {
|
|
return []domain.Recipe{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
|
|
}
|
|
|
|
recipe, err := r.GetRecipe(r_id, &id)
|
|
if err != nil {
|
|
return []domain.Recipe{}, err
|
|
}
|
|
|
|
if recipe != nil {
|
|
recipes = append(recipes, *recipe)
|
|
}
|
|
}
|
|
|
|
return recipes, nil
|
|
}
|
|
|
|
// GetUserRecipes gets a list of a users favorited 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.
|
|
//
|
|
// TODO: This should just return the IDs
|
|
func (r *RecipeRepository) GetUserFavoriteRecipes(id int) ([]domain.Recipe, error) {
|
|
query := `
|
|
SELECT r.id
|
|
FROM favorites f
|
|
JOIN recipes r ON r.id = f.recipeid
|
|
WHERE f.userid = $1
|
|
ORDER BY f.created DESC;
|
|
`
|
|
rows, err := r.db.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 r_id int
|
|
if err := rows.Scan(&r_id); err != nil {
|
|
return []domain.Recipe{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
|
|
}
|
|
|
|
recipe, err := r.GetRecipe(r_id, &id)
|
|
if err != nil {
|
|
return []domain.Recipe{}, err
|
|
}
|
|
|
|
if recipe != nil {
|
|
recipes = append(recipes, *recipe)
|
|
}
|
|
}
|
|
|
|
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. If the recipe is nil, the function will
|
|
// return nothing (skipping). Any errors will be bubbled to the caller.
|
|
func (r *RecipeRepository) GetRecipeTags(recipe *domain.Recipe) error {
|
|
if recipe == nil {
|
|
return nil
|
|
}
|
|
|
|
recipe.Tags = []domain.Tag{}
|
|
|
|
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)
|
|
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)
|
|
}
|
|
|
|
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. If the recipe is nil, the function
|
|
// will return nothing (skipping). Any errors will be bubbled to the caller.
|
|
func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int) error {
|
|
if recipe == nil {
|
|
return nil
|
|
}
|
|
|
|
query := `
|
|
SELECT COUNT(*)
|
|
FROM favorites
|
|
WHERE recipeid = $1 AND userid = $2;
|
|
`
|
|
|
|
var count int
|
|
if err := r.db.QueryRow(query, recipe.Id, userId).Scan(&count); err != nil {
|
|
return fmt.Errorf("Failed to get recipe favorite. %s", err.Error())
|
|
}
|
|
|
|
recipe.Favorite = count > 0
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetRecipeOfTheWeekId searches for the most recent recipe of the week. If there is not a value,
|
|
// the recipe will be nil. This function simply collects the most recent entry in the recipeoftheweek
|
|
// table and return it. If there is no entry, nil will be returned. Any errors will be bubbled to
|
|
// the caller. All that is returned is the recipe ID, that way the caller can handle the fetching.
|
|
func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
|
|
query := `
|
|
SELECT
|
|
r.id
|
|
FROM recipes r
|
|
JOIN recipeoftheweek rw ON rw.recipeid = r.id
|
|
ORDER BY rw.created DESC
|
|
LIMIT 1;
|
|
`
|
|
|
|
var id int
|
|
if err := r.db.QueryRow(query).Scan(&id); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
|
|
}
|
|
|
|
return &id, nil
|
|
}
|