Merge branch 'commons/recipe-model-v2' into 'main'

refactor: New recipe & ingredients model to work with (in)formal quantities

Closes #43

See merge request cse1105/2025-2026/teams/csep-team-76!38
This commit is contained in:
Zhongheng Liu 2026-01-05 17:38:23 +01:00
commit 9f67800372
25 changed files with 606 additions and 281 deletions

View file

@ -11,7 +11,7 @@ import client.scenes.recipe.RecipeDetailCtrl;
import client.utils.Config;
import client.utils.ConfigService;
import client.utils.DefaultRecipeFactory;
import client.utils.DefaultValueFactory;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import client.utils.ServerUtils;
@ -217,7 +217,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
*/
@FXML
private void addRecipe() {
Recipe newRecipe = DefaultRecipeFactory.getDefaultRecipe(); // Create default recipe
Recipe newRecipe = DefaultValueFactory.getDefaultRecipe(); // Create default recipe
try {
newRecipe = server.addRecipe(newRecipe); // get the new recipe id
refresh(); // refresh view with server recipes

View file

@ -2,6 +2,7 @@ package client.scenes.nutrition;
import client.scenes.FoodpalApplicationCtrl;
import com.google.inject.Inject;
import commons.Ingredient;
import commons.Recipe;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
@ -14,12 +15,12 @@ import java.util.Set;
public class NutritionViewCtrl {
private ObservableList<Recipe> recipes;
private HashMap<String, Integer> ingredientStats;
public ListView<String> nutritionIngredientsView;
private HashMap<Ingredient, Integer> ingredientStats;
public ListView<Ingredient> nutritionIngredientsView;
private final NutritionDetailsCtrl nutritionDetailsCtrl;
// TODO into Ingredient class definition
// FIXME MOST LIKELY CURRENTLY BROKEN. TO BE FIXED.
/**
* Comedically verbose function to count unique appearances of an ingredient by name in each recipe.
* For each recipe:
@ -29,13 +30,15 @@ public class NutritionViewCtrl {
* 2. For each recipe in list:
* 1. If the name of the ingredient exists in the recipe list, increment the statistic by 1.
* 2. Else maintain the same value for that statistic.
* @param recipeList
* @param recipeList The recipe list
*/
private void updateIngredientStats(
List<Recipe> recipeList
) {
recipeList.forEach(recipe -> {
Set<String> uniqueIngredients = new HashSet<>(recipe.getIngredients());
Set<Ingredient> uniqueIngredients = new HashSet<>(
recipe.getIngredients().stream().map(
ingredient -> ingredient.ingredient).toList());
nutritionIngredientsView.getItems().setAll(uniqueIngredients);
uniqueIngredients.forEach(ingredient -> {
ingredientStats.put(ingredient, 0);

View file

@ -1,32 +1,71 @@
package client.scenes.recipe;
import commons.FormalIngredient;
import commons.Ingredient;
import commons.RecipeIngredient;
import commons.Unit;
import commons.VagueIngredient;
import javafx.collections.FXCollections;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.HBox;
import java.util.Optional;
/**
* A custom {@link OrderedEditableListCell<String>} for displaying and editing ingredients in an
* IngredientList. Allows inline editing of ingredient names.
* A custom {@link OrderedEditableListCell<String>} for
* displaying and editing ingredients in an IngredientList.
* Allows inline editing of ingredient names.
*
* @see IngredientListCtrl
*/
public class IngredientListCell extends OrderedEditableListCell<String> {
public class IngredientListCell extends OrderedEditableListCell<RecipeIngredient> {
@Override
public void commitEdit(String newValue) {
public void commitEdit(RecipeIngredient newValue) {
super.commitEdit(newValue);
}
/**
* Starts an edit box on the ingredient.
* It needs an {@link HBox HBox} container to fit 3 input boxes.
* @see OrderedEditableListCell
*/
@Override
public void startEdit() {
super.startEdit();
TextField amountInput = new TextField("0");
RecipeIngredient ingredient = getItem();
Optional<Double> templateAmount = Optional.empty();
Optional<Unit> unit = Optional.empty();
// Initialize the input fields with some initial data if we get a formal ingredient
if (ingredient.getClass().equals(FormalIngredient.class)) {
FormalIngredient formal = (FormalIngredient) ingredient;
templateAmount = Optional.of(formal.getAmount());
unit = Optional.of(Unit.fromString(formal.getUnitSuffix()).orElseThrow(
() -> new RuntimeException("FormalIngredient whereas invalid unit")));
}
// TODO initialize some other data in the case of vague ingredient
// Initialize the input boxes
TextField amountInput = new TextField(templateAmount.map(Object::toString).orElse(null));
ChoiceBox<Unit> unitChoice = new ChoiceBox<>();
unitChoice.setItems(FXCollections.observableArrayList(Unit.GRAM, Unit.KILOGRAM));
TextField nameInput = new TextField("ingredient");
// initialize the current unit if it is present
unit.ifPresent(unitChoice::setValue);
unitChoice.setItems(FXCollections.observableArrayList(Unit.values()));
// calls makeInputLine to create the inline edit container
HBox container = makeInputLine(ingredient, amountInput, unitChoice);
// set the graphic to the edit box
this.setText(null);
this.setGraphic(container);
}
private HBox makeInputLine(RecipeIngredient ingredient, TextField amountInput, ChoiceBox<Unit> unitChoice) {
TextField nameInput = new TextField(ingredient.ingredient.name);
HBox container = new HBox(amountInput, unitChoice, nameInput);
container.setOnKeyReleased(event -> {
if (event.getCode() != KeyCode.ENTER) {
@ -34,15 +73,16 @@ public class IngredientListCell extends OrderedEditableListCell<String> {
}
Unit unit = unitChoice.getValue();
String name = nameInput.getText();
if (unit == null) {
if (unit == null || !unit.isFormal()) {
String desc = amountInput.getText();
commitEdit(desc + " " + name);
Ingredient newIngredient = new Ingredient(name, 0., 0., 0.);
commitEdit(new VagueIngredient(newIngredient, desc));
return;
}
int amount = Integer.parseInt(amountInput.getText());
commitEdit(amount + " " + unit + " of " + name);
double amount = Double.parseDouble(amountInput.getText());
Ingredient newIngredient = new Ingredient(name, 0., 0., 0.);
commitEdit(new FormalIngredient(newIngredient, amount, unit.suffix));
});
this.setText(null);
this.setGraphic(container);
return container;
}
}

View file

@ -1,12 +1,16 @@
package client.scenes.recipe;
import client.utils.DefaultValueFactory;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import com.google.inject.Inject;
import commons.FormalIngredient;
import commons.Recipe;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import commons.RecipeIngredient;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
@ -40,15 +44,15 @@ public class IngredientListCtrl implements LocaleAware {
* changes and update the recipe accordingly.
* </p>
*/
private ObservableList<String> ingredients;
private ObservableList<RecipeIngredient> ingredients;
/**
* A callback function that is called when the ingredient list is updated.
*/
private Consumer<List<String>> updateCallback;
private Consumer<List<RecipeIngredient>> updateCallback;
@FXML
public ListView<String> ingredientListView;
public ListView<RecipeIngredient> ingredientListView;
@FXML
public Label ingredientsLabel;
@ -69,7 +73,7 @@ public class IngredientListCtrl implements LocaleAware {
if (recipe == null) {
this.ingredients = FXCollections.observableArrayList(new ArrayList<>());
} else {
List<String> ingredientList = recipe.getIngredients();
List<RecipeIngredient> ingredientList = recipe.getIngredients();
this.ingredients = FXCollections.observableArrayList(ingredientList);
}
@ -82,7 +86,7 @@ public class IngredientListCtrl implements LocaleAware {
*
* @param callback The function to call upon each update.
*/
public void setUpdateCallback(Consumer<List<String>> callback) {
public void setUpdateCallback(Consumer<List<RecipeIngredient>> callback) {
this.updateCallback = callback;
}
@ -99,12 +103,13 @@ public class IngredientListCtrl implements LocaleAware {
* @param event The action event.
*/
private void handleIngredientAdd(ActionEvent event) {
this.ingredients.add("Ingredient " + (this.ingredients.size() + 1));
FormalIngredient newIngredient = DefaultValueFactory.getDefaultFormalIngredient();
this.ingredients.add(newIngredient);
this.refresh();
this.updateCallback.accept(this.ingredients);
var select = this.ingredientListView.getSelectionModel();
select.select(this.ingredients.size() - 1);
select.select(newIngredient);
}
/**
@ -112,9 +117,9 @@ public class IngredientListCtrl implements LocaleAware {
*
* @param event The edit event.
*/
private void handleIngredientEdit(EditEvent<String> event) {
private void handleIngredientEdit(EditEvent<RecipeIngredient> event) {
int index = event.getIndex();
String newValue = event.getNewValue();
RecipeIngredient newValue = event.getNewValue();
this.ingredients.set(index, newValue);
this.refresh();
@ -154,9 +159,6 @@ public class IngredientListCtrl implements LocaleAware {
@Override
public void initializeComponents() {
// TODO: set up communication with the server
// this would probably be best done with the callback (so this class doesn't
// interact with the server at all)
this.ingredientListView.setEditable(true);
this.ingredientListView.setCellFactory(
list -> {

View file

@ -2,6 +2,12 @@ package client.scenes.recipe;
import javafx.scene.control.ListCell;
/**
* The abstract class that pre-defines common features between
* {@link IngredientListCell IngredientListCell} and
* {@link RecipeStepListCell RecipeStepListCell}.
* @param <DataType> The type of data the list cell contains.
*/
public abstract class OrderedEditableListCell<DataType> extends ListCell<DataType> {
/**
* Get the display text for the given item, prefixed with its index.
@ -23,6 +29,10 @@ public abstract class OrderedEditableListCell<DataType> extends ListCell<DataTyp
this.setText(this.getDisplayText(item.toString()));
}
}
/**
* A method stub that should be supplemented by child classes.
*/
public void startEdit() {
super.startEdit();
}

View file

@ -6,12 +6,14 @@ import javafx.scene.input.KeyCode;
/**
* A custom ListCell for displaying and editing ingredients in an
* RecipeStepList. Allows inline editing of ingredient names.
*
* The RecipeStepList only needs one {@link TextField TextField} element.
* @see IngredientListCtrl
* @see RecipeStepListCtrl
*/
public class RecipeStepListCell extends OrderedEditableListCell<String> {
@Override
public void startEdit() {
super.startEdit();
TextField textField = new TextField(this.getItem());
// Commit edit on Enter key press

View file

@ -1,15 +0,0 @@
package client.utils;
import commons.Recipe;
import java.util.List;
public class DefaultRecipeFactory {
public static Recipe getDefaultRecipe() {
return new Recipe(
null,
"Untitled recipe",
List.of(),
List.of());
}
}

View file

@ -0,0 +1,63 @@
package client.utils;
import commons.FormalIngredient;
import commons.Ingredient;
import commons.Recipe;
import commons.Unit;
import commons.VagueIngredient;
import java.util.List;
/**
* A factory for default values used in the client UX flow.
* @see DefaultValueFactory#getDefaultRecipe()
* @see DefaultValueFactory#getDefaultFormalIngredient()
* @see DefaultValueFactory#getDefaultVagueIngredient()
*/
public class DefaultValueFactory {
private static final Ingredient defaultIngredient = new Ingredient(0L, "default ingredient", 0., 0., 0.);
/**
* Instantiates a recipe with a default name and null-ID.
* The ID is <code>null</code> such that it can be updated
* as appropriate on the backend.
* @return The default recipe.
*/
public static Recipe getDefaultRecipe() {
return new Recipe(
null,
"Untitled recipe",
List.of(),
List.of());
}
/**
* Instantiates a default formal ingredient with a default name and default ID.
* Note in particular the ID being <code>0L</code>, see IngredientController
* server-side implementation for more details on safeguards.
* @return The default formal ingredient.
*/
public static FormalIngredient getDefaultFormalIngredient() {
return new FormalIngredient(
defaultIngredient,
0.0,
Unit.GRAMME.suffix
);
}
/**
* Instantiates a vague ingredient. Not currently in use.
* @see DefaultValueFactory#getDefaultFormalIngredient()
* @return The vague ingredient.
*/
public static VagueIngredient getDefaultVagueIngredient() {
return new VagueIngredient(
defaultIngredient,
"Some");
}
public static VagueIngredient getDefaultVagueIngredient(String name) {
return new VagueIngredient(
new Ingredient(name, 0., 0., 0.),
"Some");
}
}

View file

@ -3,6 +3,7 @@ package client.utils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import commons.Recipe;
import commons.RecipeIngredient;
import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.client.ClientBuilder;
import org.glassfish.jersey.client.ClientConfig;
@ -82,7 +83,10 @@ public class ServerUtils {
public Recipe addRecipe(Recipe newRecipe) throws IOException, InterruptedException {
//Make sure the name of the newRecipe is unique
List<Recipe> allRecipes = getRecipes();
newRecipe.setId(null); // otherwise the id is the same as the original, and that's wrong
// now that each recipeIngredient has its own ID in the database,
// we set that to null too to force a new persist value on the server
newRecipe.getIngredients().forEach(ingredient -> ingredient.setId(null));
int version = 1;
String originalName = newRecipe.getName();
@ -143,9 +147,6 @@ public class ServerUtils {
}
// recipe exists so you can make a "new" recipe aka the clone
Recipe recipe = objectMapper.readValue(response.body(), Recipe.class);
recipe.setId(null); // otherwise the id is the same as the original, and that's wrong
return addRecipe(recipe);
}
@ -170,8 +171,8 @@ public class ServerUtils {
}
public void addRecipeIngredient(Recipe recipe, String ingredient) throws IOException, InterruptedException {
List<String> ingredients = new ArrayList<>(recipe.getIngredients());
public void addRecipeIngredient(Recipe recipe, RecipeIngredient ingredient) throws IOException, InterruptedException {
List<RecipeIngredient> ingredients = new ArrayList<>(recipe.getIngredients());
ingredients.add(ingredient);
recipe.setIngredients(ingredients);

View file

@ -1,7 +1,11 @@
package client;
import client.utils.ServerUtils;
import commons.Ingredient;
import commons.Recipe;
import commons.RecipeIngredient;
import commons.VagueIngredient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -14,6 +18,16 @@ import static org.junit.jupiter.api.Assertions.*;
class ServerUtilsTest {
static ServerUtils dv;
static Recipe testRecipe;
static final List<Ingredient> ingredients = List.of(
new Ingredient("Bread", 1, 2, 3),
new Ingredient("Cheese", 2, 2, 2),
new Ingredient("Ham", 3, 3, 3)
);
static final List<RecipeIngredient> testIngredients = List.of(
new VagueIngredient(ingredients.get(0), "2 pieces of"),
new VagueIngredient(ingredients.get(1), "1 slice of"),
new VagueIngredient(ingredients.get(2), "1 slice of")
);
final int fakeId = -1; // If suppose ID's are only positive
@BeforeEach
@ -22,25 +36,33 @@ class ServerUtilsTest {
Assumptions.assumeTrue(dv.isServerAvailable(), "Server not available");
//Making sure there is no recipe in the backend yet
for (Recipe recipe : dv.getRecipes()) {
dv.deleteRecipe(recipe.getId());
}
Recipe r = new Recipe();
r.setName("Tosti");
r.setIngredients(List.of("Bread", "Cheese", "Ham"));
r.setIngredients(testIngredients);
r.setPreparationSteps(List.of("Step 1:", "Step 2"));
testRecipe = dv.addRecipe(r);
}
@AfterEach
void tearDown() throws IOException, InterruptedException {
// Not applicable in pipeline testing
Assumptions.assumeTrue(dv.isServerAvailable(), "Server not available");
dv.getRecipes().stream().map(Recipe::getId).forEach(id -> {
try {
dv.deleteRecipe(id);
} catch (Exception ex) {
System.err.println("Teardown failed: " + ex.getMessage());
}
});
}
@Test
void addRecipeTest() throws IOException, InterruptedException {
Recipe r = new Recipe();
r.setName("Eggs on toast");
r.setIngredients(List.of("Bread", "egg", "salt"));
r.setIngredients(testIngredients);
r.setPreparationSteps(List.of("Step 1:", "Step 2"));
testRecipe = dv.addRecipe(r);
@ -68,7 +90,7 @@ class ServerUtilsTest {
assertEquals(testRecipe.getId(), r.getId());
assertEquals("Tosti", r.getName());
assertIterableEquals(List.of("Bread", "Cheese", "Ham"), r.getIngredients());
assertIterableEquals(testIngredients, r.getIngredients());
assertIterableEquals(List.of("Step 1:", "Step 2"), r.getPreparationSteps());
}

View file

@ -1,8 +1,10 @@
package client.scenes;
import client.utils.DefaultValueFactory;
import client.utils.PrintExportService;
import client.utils.ServerUtils;
import commons.Recipe;
import commons.RecipeIngredient;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -25,9 +27,9 @@ public class PrintExportTest {
@Test
public void buildRecipeTextTest(){
List<String> ingredients = new ArrayList<>();
ingredients.add("Banana");
ingredients.add("Bread");
List<RecipeIngredient> ingredients = new ArrayList<>();
ingredients.add(DefaultValueFactory.getDefaultVagueIngredient("Banana"));
ingredients.add(DefaultValueFactory.getDefaultVagueIngredient("Bread"));
final long testRecipeId = 1234L;
List<String> preparationSteps = new ArrayList<>();
preparationSteps.add("Mix Ingredients");
@ -37,7 +39,7 @@ public class PrintExportTest {
assertEquals("""
Title: Banana Bread
Recipe ID: 1234
Ingredients: Banana, Bread,\s
Ingredients: some Banana, some Bread,\s
Steps:
1: Mix Ingredients
2: Heat in Oven

View file

@ -0,0 +1,72 @@
package commons;
import jakarta.persistence.Entity;
import java.util.Objects;
import java.util.Optional;
/**
* A formal ingredient inheriting from base {@link RecipeIngredient RecipeIngredient},
* holds an amount and a unit suffix in {@link String String} form.
* @see RecipeIngredient
*/
@Entity
public class FormalIngredient extends RecipeIngredient implements Scalable<FormalIngredient> {
private double amount;
private String unitSuffix;
public double getAmount() {
return amount;
}
public void setAmount(double amount) {
this.amount = amount;
}
public String getUnitSuffix() {
return unitSuffix;
}
public void setUnitSuffix(String unitSuffix) {
this.unitSuffix = unitSuffix;
}
public FormalIngredient(Ingredient ingredient, double amount, String unitSuffix) {
super(ingredient);
this.amount = amount;
this.unitSuffix = unitSuffix;
}
public FormalIngredient() {
// ORM
}
public double amountInBaseUnit() {
Optional<Unit> unit = Unit.fromString(unitSuffix);
if (unit.isEmpty() || !unit.get().isFormal() || unit.get().conversionFactor <= 0) {
return 0.0;
}
return amount * unit.get().conversionFactor;
}
public String toString() {
return amount + unitSuffix + " of " + ingredient.name;
}
@Override
public FormalIngredient scaleBy(double factor) {
return new FormalIngredient(getIngredient(), amount * factor, unitSuffix);
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
FormalIngredient that = (FormalIngredient) o;
return Double.compare(amount, that.amount) == 0 && Objects.equals(unitSuffix, that.unitSuffix);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), amount, unitSuffix);
}
}

View file

@ -6,6 +6,8 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.util.Objects;
@Entity
public class Ingredient {
@ -20,7 +22,8 @@ public class Ingredient {
@GeneratedValue(strategy = GenerationType.AUTO)
public long id;
@Column(name = "name", nullable = false, unique = true)
// FIXME Dec 22 2025::temporarily made this not a unique constraint because of weird JPA behaviour
@Column(name = "name", nullable = false, unique = false)
public String name;
@ -35,10 +38,24 @@ public class Ingredient {
public Ingredient() {}
public Ingredient(String name,
double proteinPer100g,
double fatPer100g,
double carbsPer100g) {
public Ingredient(
Long id,
String name,
double proteinPer100g,
double fatPer100g,
double carbsPer100g) {
this.id = id;
this.name = name;
this.proteinPer100g = proteinPer100g;
this.fatPer100g = fatPer100g;
this.carbsPer100g = carbsPer100g;
}
public Ingredient(
String name,
double proteinPer100g,
double fatPer100g,
double carbsPer100g) {
this.name = name;
this.proteinPer100g = proteinPer100g;
this.fatPer100g = fatPer100g;
@ -54,6 +71,18 @@ public class Ingredient {
public void setId(long id) {
this.id = id;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Ingredient that = (Ingredient) o;
return id == that.id && Double.compare(proteinPer100g, that.proteinPer100g) == 0 && Double.compare(fatPer100g, that.fatPer100g) == 0 && Double.compare(carbsPer100g, that.carbsPer100g) == 0 && Objects.equals(name, that.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name, proteinPer100g, fatPer100g, carbsPer100g);
}
}

View file

@ -15,16 +15,17 @@
*/
package commons;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Entity;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.Table;
import java.util.ArrayList;
@ -59,11 +60,11 @@ public class Recipe {
// | 1 (Steak) | 40g pepper |
// | 1 (Steak) | Meat |
// |----------------------------------|
@ElementCollection
@OneToMany
@CollectionTable(name = "recipe_ingredients", joinColumns = @JoinColumn(name = "recipe_id"))
@Column(name = "ingredient")
// TODO: Replace String with Embeddable Ingredient Class
private List<String> ingredients = new ArrayList<>();
private List<RecipeIngredient> ingredients = new ArrayList<>();
// Creates another table named recipe_preparation which stores:
// recipe_preparation(recipe_id -> recipes(id), preparation_step, step_order).
@ -94,7 +95,7 @@ public class Recipe {
}
// TODO: Replace String with Embeddable Ingredient Class for ingredients
public Recipe(Long id, String name, List<String> ingredients, List<String> preparationSteps) {
public Recipe(Long id, String name, List<RecipeIngredient> ingredients, List<String> preparationSteps) {
// Not used by JPA/Spring
this.id = id;
this.name = name;
@ -119,14 +120,14 @@ public class Recipe {
}
// TODO: Replace String with Embeddable Ingredient Class
public List<String> getIngredients() {
public List<RecipeIngredient> getIngredients() {
// Disallow modifying the returned list.
// You can still copy it with List.copyOf(...)
return Collections.unmodifiableList(ingredients);
}
// TODO: Replace String with Embeddable Ingredient Class
public void setIngredients(List<String> ingredients) {
public void setIngredients(List<RecipeIngredient> ingredients) {
this.ingredients = ingredients;
}

View file

@ -1,77 +1,90 @@
package commons;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
@Entity
public class RecipeIngredient {
import java.util.Objects;
/**
* The base RecipeIngredient class, holding a reference to the
* {@link Ingredient Ingredient} it has an (in)formal amount
* linked to.
* It uses {@link JsonSubTypes JsonSubTypes} such that the
* Jackson framework can conveniently decode JSON data sent
* by client, which could be either a formal or vague
* ingredient.
* @see Ingredient
*/
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME, // identifies subtype by logical name
include = JsonTypeInfo.As.PROPERTY,
property = "type" // JSON property carrying the subtype id
)
@JsonSubTypes({
@JsonSubTypes.Type(value = FormalIngredient.class, name = "formal"),
@JsonSubTypes.Type(value = VagueIngredient.class, name = "vague")
})
public abstract class RecipeIngredient {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@GeneratedValue(strategy = GenerationType.AUTO)
public Long id;
// which recipe is used
@ManyToOne(optional = false)
@JoinColumn(name = "recipe_id")
public Recipe recipe;
//which ingredient is used
/**
* Many-to-one: Many {@link RecipeIngredient RecipeIngredient}
* can use the same {@link Ingredient Ingredient} with varying
* amounts. This allows better ingredient collecting for
* nutrition view.
*/
@ManyToOne(optional = false)
@JoinColumn(name = "ingredient_id")
public Ingredient ingredient;
public double amount;
// store the unit name in the database
public String unitName;
@SuppressWarnings("unused")
protected RecipeIngredient() {
// for sebastian
// for ORM
}
public RecipeIngredient(Recipe recipe, //which recipe
Ingredient ingredient, // which ingredient
double amount, // the amount
String unit) { //gram liter etc
//store it im tha field
this.recipe = recipe;
public RecipeIngredient(
Ingredient ingredient) {
//store it in the field
this.ingredient = ingredient;
this.amount = amount;
this.unitName = unit;
}
// Convert unitName to Unit object so java can read it
public Unit getUnit() {
return switch (unitName) {
case "GRAM" -> Unit.GRAM;
case "KILOGRAM" -> Unit.KILOGRAM;
case "MILLILITER" -> Unit.MILLILITER;
case "LITER" -> Unit.LITER;
case "TABLESPOON" -> Unit.TABLESPOON;
case "TEASPOON" -> Unit.TEASPOON;
case "CUP" -> Unit.CUP;
case "PIECE" -> Unit.PIECE;
case "PINCH" -> Unit.PINCH;
case "HANDFUL" -> Unit.HANDFUL;
case "TO_TASTE" -> Unit.TO_TASTE;
default -> null;
};
public void setId(Long id) {
this.id = id;
}
public void setIngredient(Ingredient ingredient) {
this.ingredient = ingredient;
}
public Long getId() {
return id;
}
public double amountInBaseUnit() {
Unit unit = getUnit();
if (unit == null || !unit.isFormal() || unit.conversionFactor <= 0) {
return 0.0;
}
return amount * unit.conversionFactor;
public Ingredient getIngredient() {
return ingredient;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
RecipeIngredient that = (RecipeIngredient) o;
return Objects.equals(id, that.id) && Objects.equals(ingredient, that.ingredient);
}
@Override
public int hashCode() {
return Objects.hash(id, ingredient);
}
}

View file

@ -0,0 +1,8 @@
package commons;
public interface Scalable<IngredientType extends RecipeIngredient> {
default IngredientType scaleBy(int numPortions) {
return scaleBy((double) numPortions);
};
IngredientType scaleBy(double factor);
}

View file

@ -1,33 +1,45 @@
package commons;
//what is a record class and why is it recommended
import java.util.Optional;
/**
* A unit enum that holds some values.
* A {@link Unit Unit} enum holds primarily formal mass-units
* Except for an informal unit that always has a factor of zero
* is made for user input purposes.
* @see VagueIngredient
* @see FormalIngredient
*/
public enum Unit {
//formal units
//weight units
GRAM("GRAM", true, 1.0),
KILOGRAM("KILOGRAM", true, 1000.0 ),
// Mass units with their absolute scale
GRAMME("g", true, 1.0),
KILOGRAMME("kg", true, 1_000.0 ),
TONNE("t", true, 1_000_000.0),
//volume units
MILLILITER("MILLILITER",true, 1.0),
LITER("LITER", true, 1000.0),
TABLESPOON("TABLESPOON",true, 15.0),
TEASPOON("TEASPOON", true, 5),
CUP ("CUP", true, 240.0),
// TODO Consider more fine-tuned volume unit definitions
// TODO Use density-based calculations?
/*
Volume units are used with the assumption that 1L of ingredient = 1kg.
*/
LITRE("l", true, 1_000.0),
MILLILITRE("ml", true, 1.0),
//piece should be a formal unit to converse for portions like 3eggs can become 1,5 eggs this way
PIECE("PIECE", true, 1.0),
// We love our American friends!!
// Source:
// https://whatscookingamerica.net/equiv.htm
TABLESPOON("tbsp", true, 14.0),
OUNCE("oz", true, 28.35),
POUND("lb", true, 453.6),
CUP("cup(s)", true, 225),
// A special informal unit
INFORMAL("<NONE>", false, 0.0);
//informal units
PINCH("PINCH", false, 0.0),
HANDFUL("HANDFUL", false, 0.0),
TO_TASTE("TO_TASTE", false, 0.0);
public final String name;
public final String suffix;
public final boolean formal;
public final double conversionFactor;
Unit(String name, boolean formal, double conversionFactor) {
this.name = name;
Unit(String suffix, boolean formal, double conversionFactor) {
this.suffix = suffix;
this.formal = formal;
this.conversionFactor = conversionFactor;
}
@ -36,12 +48,16 @@ public enum Unit {
return formal;
}
public boolean isInformal() {
return !formal;
} //for oskar
@Override
public String toString() {
return name;
return suffix;
}
public static Optional<Unit> fromString(String suffix) {
for (Unit unit : Unit.values()) {
if (unit.suffix != null && unit.suffix.equals(suffix)) {
return Optional.of(unit);
}
}
return Optional.empty();
}
}

View file

@ -0,0 +1,48 @@
package commons;
import jakarta.persistence.Entity;
import java.util.Objects;
/**
* A vague ingredient inheriting from {@link RecipeIngredient RecipeIngredient}.
* It has a {@link String String} description that describes an informal quantity.
* The vague ingredient renders as its descriptor and its ingredient name
* conjugated by a space via {@link VagueIngredient#toString() toString()} method.
* @see RecipeIngredient
*/
@Entity
public class VagueIngredient extends RecipeIngredient {
private String description;
public VagueIngredient() {}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public VagueIngredient(Ingredient ingredient, String description) {
super(ingredient);
this.description = description;
}
@Override
public String toString() {
return description + " " + ingredient.name;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
VagueIngredient that = (VagueIngredient) o;
return Objects.equals(description, that.description);
}
@Override
public int hashCode() {
return Objects.hashCode(description);
}
}

View file

@ -2,94 +2,56 @@ package commons;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
public class RecipeIngredientTest {
private RecipeIngredient gram;
private RecipeIngredient kilogram;
private RecipeIngredient milliliter;
private RecipeIngredient liter;
private RecipeIngredient teaspoon;
private RecipeIngredient tablespoon;
private RecipeIngredient cup;
private RecipeIngredient piece;
private RecipeIngredient pinch;
private RecipeIngredient handful;
private RecipeIngredient toTaste;
private RecipeIngredient invalid;
private Map<String, FormalIngredient> ingredientUnitMap;
private Map<String, VagueIngredient> ingredientDescriptorMap;
private FormalIngredient getFormal(String unit) {
Ingredient ingredient = new Ingredient("Bread", 1, 2, 3);
return new FormalIngredient(ingredient, 1.0, unit);
}
private VagueIngredient getVague(String descriptor) {
Ingredient ingredient = new Ingredient("Bread", 1, 2, 3);
return new VagueIngredient(ingredient, descriptor);
}
@BeforeEach
void setup(){
Recipe recipe = new Recipe();
Ingredient ingredient = new Ingredient();
gram = new RecipeIngredient(recipe,ingredient,1,"GRAM");
kilogram = new RecipeIngredient(recipe,ingredient,1,"KILOGRAM");
milliliter = new RecipeIngredient(recipe,ingredient,1,"MILLILITER");
liter = new RecipeIngredient(recipe,ingredient,1,"LITER");
teaspoon = new RecipeIngredient(recipe,ingredient,1,"TEASPOON");
tablespoon = new RecipeIngredient(recipe,ingredient,1,"TABLESPOON");
cup = new RecipeIngredient(recipe,ingredient,1,"CUP");
piece = new RecipeIngredient(recipe,ingredient,1,"PIECE");
pinch = new RecipeIngredient(recipe,ingredient,1,"PINCH");
handful = new RecipeIngredient(recipe,ingredient,1,"HANDFUL");
toTaste = new RecipeIngredient(recipe,ingredient,1,"TO_TASTE");
invalid = new RecipeIngredient(recipe,ingredient,1,"INVALID");
}
@Test
void getFormalUnitTest(){
assertEquals(Unit.GRAM, gram.getUnit());
assertEquals(Unit.KILOGRAM, kilogram.getUnit());
assertEquals(Unit.MILLILITER, milliliter.getUnit());
assertEquals(Unit.LITER, liter.getUnit());
assertEquals(Unit.TEASPOON, teaspoon.getUnit());
assertEquals(Unit.TABLESPOON, tablespoon.getUnit());
assertEquals(Unit.CUP, cup.getUnit());
assertEquals(Unit.PIECE, piece.getUnit());
ingredientUnitMap = new HashMap<>();
ingredientDescriptorMap = new HashMap<>();
List.of("g", "kg", "ml", "l", "tbsp", "cup")
.forEach(u -> ingredientUnitMap.put(u, getFormal(u)));
List.of("a sprinkle of", "some", "bits of", "a few")
.forEach(d -> ingredientDescriptorMap.put(d, getVague(d)));
}
@Test
void getInformalUnitTest(){
assertEquals(Unit.PINCH, pinch.getUnit());
assertEquals(Unit.HANDFUL, handful.getUnit());
assertEquals(Unit.TO_TASTE, toTaste.getUnit());
void testInstantiateFormalIngredient() {
Ingredient ingredient = new Ingredient("Bread", 1, 2, 3);
FormalIngredient fi = new FormalIngredient(ingredient, 1.0, "g");
assertEquals(ingredient, fi.getIngredient());
}
@Test
void testInstantiateVagueIngredient() {
Ingredient ingredient = new Ingredient("Bread", 1, 2, 3);
VagueIngredient fi = new VagueIngredient(ingredient, "some");
assertEquals("some", fi.getDescription());
}
@Test
void getUnknownUnitTest(){
assertNull(invalid.getUnit());
}
@Test
void convertFormalToBaseUnit(){
assertEquals(1000, kilogram.amountInBaseUnit());
assertEquals(1,milliliter.amountInBaseUnit());
assertEquals(1000.0,liter.amountInBaseUnit());
assertEquals(15.0,tablespoon.amountInBaseUnit());
assertEquals(5,teaspoon.amountInBaseUnit());
assertEquals(240.0,cup.amountInBaseUnit());
assertEquals(1.0,piece.amountInBaseUnit());
}
@Test
void convertInformalToBaseUnit(){
assertEquals(0,pinch.amountInBaseUnit());
assertEquals(0,handful.amountInBaseUnit());
assertEquals(0, toTaste.amountInBaseUnit());
}
@Test
void convertUnknownToBaseUnit(){
assertEquals(0,invalid.amountInBaseUnit());
void testFormalIngredientScaleByFactor() {
ingredientUnitMap.replaceAll(
(_, b) -> b.scaleBy(2)
);
// Each amount is doubled after the mapping
assertEquals(ingredientUnitMap.size(), ingredientUnitMap.values().stream()
.filter(i -> i.getAmount() == 2.0).count());
}
}

View file

@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
@ -12,6 +13,17 @@ class RecipeTest {
Recipe recipe;
static final Long RECIPE_ID = 1L;
static final RecipeIngredient testIngredient = new FormalIngredient(
new Ingredient("Bread", 1, 2, 3),
1.0,
"g"
);
static RecipeIngredient getTemplate(String name) {
return new VagueIngredient(
new Ingredient(name, 1.0, 1.0, 1.0),
"some");
}
@BeforeEach
void setupRecipe() {
@ -42,8 +54,7 @@ class RecipeTest {
@Test
void getIngredientsAddThrow() {
// TODO: Change to actual Ingredient class later
assertThrows(UnsupportedOperationException.class, () -> recipe.getIngredients().add("Lasagna"));
assertThrows(UnsupportedOperationException.class, () -> recipe.getIngredients().add(testIngredient));
}
@Test
@ -53,8 +64,10 @@ class RecipeTest {
@Test
void setIngredients() {
// TODO: Change to actual Ingredient class later
List<String> ingredients = new ArrayList<>(List.of("Chocolate", "Flour", "Egg"));
List<RecipeIngredient> ingredients =
Stream.of("Chocolate", "Flour", "Egg")
.map(RecipeTest::getTemplate)
.toList();
recipe.setIngredients(ingredients);
assertEquals(recipe.getIngredients(), ingredients);

View file

@ -1,6 +1,7 @@
package commons;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@ -11,64 +12,55 @@ class UnitTest {
private static final double KILOGRAMS = 1000.0;
private static final double MILLILITERS = 1.0;
private static final double LITERS = 1000.0;
private static final double TABLESPOONS = 15.0;
private static final double TEASPOONS = 5.0;
private static final double CUPS = 240.0;
private static final double PIECES = 1.0;
private static final double NoMeaningfulValue = 0.0;
private static final double TABLESPOONS = 14.0;
private static final double CUPS = 225.0;
@Test
void formalUnitMarkedFormal(){
assertTrue(Unit.GRAM.isFormal());
assertTrue(Unit.KILOGRAM.isFormal());
assertTrue(Unit.LITER.isFormal());
assertTrue(Unit.GRAMME.isFormal());
assertTrue(Unit.KILOGRAMME.isFormal());
assertTrue(Unit.LITRE.isFormal());
assertTrue(Unit.CUP.isFormal());
assertTrue(Unit.MILLILITER.isFormal());
assertTrue(Unit.PIECE.isFormal());
assertTrue(Unit.MILLILITRE.isFormal());
assertTrue(Unit.TABLESPOON.isFormal());
assertTrue(Unit.TEASPOON.isFormal());
}
@Test
void informalUnitAreNotFormal() {
assertFalse(Unit.PINCH.isFormal());
assertFalse(Unit.HANDFUL.isFormal());
assertFalse(Unit.TO_TASTE.isFormal());
assertFalse(Unit.INFORMAL.isFormal());
}
@Test
void conversionIsCorrect() {
assertEquals(GRAMS, Unit.GRAM.conversionFactor);
assertEquals(KILOGRAMS, Unit.KILOGRAM.conversionFactor);
assertEquals(LITERS, Unit.LITER.conversionFactor);
assertEquals(GRAMS, Unit.GRAMME.conversionFactor);
assertEquals(KILOGRAMS, Unit.KILOGRAMME.conversionFactor);
assertEquals(LITERS, Unit.LITRE.conversionFactor);
assertEquals(CUPS, Unit.CUP.conversionFactor);
assertEquals(MILLILITERS, Unit.MILLILITER.conversionFactor);
assertEquals(PIECES, Unit.PIECE.conversionFactor);
assertEquals(MILLILITERS, Unit.MILLILITRE.conversionFactor);
assertEquals(TABLESPOONS, Unit.TABLESPOON.conversionFactor);
assertEquals(TEASPOONS, Unit.TEASPOON.conversionFactor);
assertEquals(NoMeaningfulValue, Unit.PINCH.conversionFactor);
assertEquals(NoMeaningfulValue, Unit.HANDFUL.conversionFactor);
assertEquals(NoMeaningfulValue, Unit.TO_TASTE.conversionFactor);
}
@Test
void toStringReturnsName(){
assertEquals("GRAM", Unit.GRAM.toString());
assertEquals("KILOGRAM", Unit.KILOGRAM.toString());
assertEquals("LITER", Unit.LITER.toString());
assertEquals("CUP", Unit.CUP.toString());
assertEquals("MILLILITER", Unit.MILLILITER.toString());
assertEquals("PIECE", Unit.PIECE.toString());
assertEquals("TABLESPOON", Unit.TABLESPOON.toString());
assertEquals("TEASPOON", Unit.TEASPOON.toString());
assertEquals("PINCH", Unit.PINCH.toString());
assertEquals("HANDFUL", Unit.HANDFUL.toString());
assertEquals("TO_TASTE", Unit.TO_TASTE.toString());
assertEquals("g", Unit.GRAMME.toString());
assertEquals("kg", Unit.KILOGRAMME.toString());
assertEquals("l", Unit.LITRE.toString());
assertEquals("cup(s)", Unit.CUP.toString());
assertEquals("ml", Unit.MILLILITRE.toString());
assertEquals("tbsp", Unit.TABLESPOON.toString());
}
@Test
void testFromSuffixCreatesSomeUnit() {
assertTrue(Unit.fromString("g").isPresent());
}
@Test
void testFromSuffixCreatesCorrectUnit() {
assertEquals(Optional.of(Unit.GRAMME), Unit.fromString("g"));
}
@Test
void testFromNonExistentSuffixCreatesNoUnit() {
assertFalse(Unit.fromString("joe").isPresent());
}
}

View file

@ -167,7 +167,7 @@ public class IngredientController {
Ingredient example = new Ingredient();
example.name = ingredient.name;
if (ingredientRepository.exists(Example.of(example))) {
if (ingredientRepository.existsById(ingredient.id) || ingredientRepository.exists(Example.of(example))) {
return ResponseEntity.badRequest().build();
}

View file

@ -1,5 +1,6 @@
package server.api;
import commons.Ingredient;
import commons.Recipe;
import commons.ws.Topics;
@ -21,6 +22,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import server.database.IngredientRepository;
import server.database.RecipeIngredientRepository;
import server.database.RecipeRepository;
import java.util.List;
@ -31,11 +34,17 @@ import java.util.Optional;
public class RecipeController {
private final RecipeRepository recipeRepository; // JPA repository used in this controller
private final SimpMessagingTemplate messagingTemplate;
private final RecipeIngredientRepository recipeIngredientRepository;
private final IngredientRepository ingredientRepository;
public RecipeController(RecipeRepository recipeRepository,
SimpMessagingTemplate messagingTemplate) {
SimpMessagingTemplate messagingTemplate,
IngredientRepository ingredientRepository,
RecipeIngredientRepository recipeIngredientRepository) {
this.recipeRepository = recipeRepository;
this.messagingTemplate = messagingTemplate;
this.recipeIngredientRepository = recipeIngredientRepository;
this.ingredientRepository = ingredientRepository;
}
/**
@ -72,20 +81,33 @@ public class RecipeController {
return ResponseEntity.ok(recipeRepository.findAll());
}
private Recipe saveRecipeAndDependencies(Recipe recipe) {
recipe.getIngredients()
.forEach(recipeIngredient ->
recipeIngredient.setIngredient(
ingredientRepository
.findByName(recipeIngredient.getIngredient().name)
.orElseGet(() -> ingredientRepository.save(recipeIngredient.getIngredient())
))
);
recipeIngredientRepository.saveAll(recipe.getIngredients());
Recipe saved = recipeRepository.save(recipe);
return saved;
}
/**
* Mapping for <code>POST /recipe/{id}</code>.
* Also creates the ingredient elements if they do not exist.
* @param id The recipe id to replace
* @param recipe The new recipe to be replaced from the original
* @return The changed recipe; returns 400 Bad Request if the recipe does not exist
* @see IngredientController#createIngredient(Ingredient)
*/
@PostMapping("/recipe/{id}")
public ResponseEntity<Recipe> updateRecipe(@PathVariable Long id, @RequestBody Recipe recipe) {
if (!recipeRepository.existsById(id)) {
return ResponseEntity.badRequest().build();
}
Recipe saved = recipeRepository.save(recipe);
Recipe saved = saveRecipeAndDependencies(recipe);
messagingTemplate.convertAndSend(Topics.RECIPES, new UpdateRecipeMessage(saved));
return ResponseEntity.ok(saved);
@ -93,11 +115,13 @@ public class RecipeController {
/**
* Mapping for <code>PUT /recipe/new</code>.
* Includes same transient object handling as the POST handler.
* <p>
* Inserts a new recipe into the repository
* </p>
* @param recipe The new recipe as a request body
* @return 200 OK with the recipe you added; or 400 Bad Request if the recipe already exists by name
* @see RecipeController#updateRecipe(Long, Recipe)
*/
@PutMapping("/recipe/new")
public ResponseEntity<Recipe> createRecipe(@RequestBody Recipe recipe) {
@ -113,8 +137,7 @@ public class RecipeController {
if (recipeRepository.exists(Example.of(example))) {
return ResponseEntity.badRequest().build();
}
Recipe saved = recipeRepository.save(recipe);
Recipe saved = saveRecipeAndDependencies(recipe);
messagingTemplate.convertAndSend(Topics.RECIPES, new CreateRecipeMessage(saved));
return ResponseEntity.ok(saved);

View file

@ -6,9 +6,11 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface IngredientRepository extends JpaRepository<Ingredient, Long> {
List<Ingredient> findAllByOrderByNameAsc();
Page<Ingredient> findAllByOrderByNameAsc(Pageable pageable);
Optional<Ingredient> findByName(String name);
}

View file

@ -13,6 +13,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.test.context.ActiveProfiles;
import server.WebSocketConfig;
import server.database.IngredientRepository;
import server.database.RecipeIngredientRepository;
import server.database.RecipeRepository;
import java.util.ArrayList;
@ -45,12 +47,21 @@ public class RecipeControllerTest {
private final RecipeRepository recipeRepository;
private List<Long> recipeIds;
public static final int NUM_RECIPES = 10;
private final IngredientRepository ingredientRepository;
private final RecipeIngredientRepository recipeIngredientRepository;
// Injects a test repository into the test class
@Autowired
public RecipeControllerTest(RecipeRepository recipeRepository, SimpMessagingTemplate template) {
public RecipeControllerTest(
RecipeRepository recipeRepository,
SimpMessagingTemplate template,
IngredientRepository ingredientRepository,
RecipeIngredientRepository recipeIngredientRepository
) {
this.recipeRepository = recipeRepository;
this.template = template;
this.ingredientRepository = ingredientRepository;
this.recipeIngredientRepository = recipeIngredientRepository;
}
@BeforeEach
@ -59,7 +70,12 @@ public class RecipeControllerTest {
.range(0, NUM_RECIPES)
.mapToObj(x -> new Recipe(null, "Recipe " + x, List.of(), List.of()))
.toList();
controller = new RecipeController(recipeRepository, template);
controller = new RecipeController(
recipeRepository,
template,
ingredientRepository,
recipeIngredientRepository
);
Set<String> tags = info.getTags();
List<Long> ids = new ArrayList<>();