Przeglądaj źródła

Restructured the database to allow recipe versioning.

The foods collection can now contain either a FoodNodeId or a Meta node
that contains a `headVid`.  This head contains a reference to another
collection of FoodNodeIds which represent versions chained as a linked
list.

For convience, there is now a food_view which will resolve the meta
lookup.

Incidentally, also fixed an error in the migration manager.
Thomas Flucke 2 lat temu
rodzic
commit
cc4593fc53

+ 68 - 40
server/app/com/weEat/controllers/FoodController.scala

@@ -1,6 +1,8 @@
 package com.weEat.controllers
 
 import com.weEat.models.{FoodNode => FoodNodeCollection}
+import com.weEat.models.{RecipeVersion => RecipeVersionCollection}
+import com.weEat.models.FoodNodeView
 import com.weEat.services.MongoDBService
 import com.weEat.shared.models._
 import javax.inject.{Inject,Singleton}
@@ -27,7 +29,7 @@ class FoodController @Inject()(
 
   def get(id: String) = Action.async
   { implicit request: Request[AnyContent] =>
-    withCollection(FoodNodeCollection) { (collection) =>
+    withCollection(FoodNodeView) { (collection) =>
       collection.find(equal("_id", new ObjectId(id)))
         .first()
         .toFuture()
@@ -41,7 +43,7 @@ class FoodController @Inject()(
 
   def all() = Action.async
   { implicit request: Request[AnyContent] =>
-    withCollection(FoodNodeCollection) { (collection) =>
+    withCollection(FoodNodeView) { (collection) =>
       collection.find()
         .toFuture()
         .transform({
@@ -53,7 +55,7 @@ class FoodController @Inject()(
 
   def query(q: String) = Action.async
   { implicit request: Request[AnyContent] =>
-    withCollection(FoodNodeCollection) { (collection) =>
+    withCollection(FoodNodeView) { (collection) =>
       collection.find(regex("name", q, "i"))
         .toFuture()
         .transform({
@@ -63,6 +65,14 @@ class FoodController @Inject()(
     }.flatten
   }
 
+  def getByFdcId(fdcId: Long): Future[Option[USDANodeId]] =
+    withCollection(FoodNodeView) { (collection) =>
+      collection.find(equal("fdcId", fdcId))
+        .first()
+        .toFuture()
+        .map((foodNode) => Option(foodNode.asInstanceOf[USDANodeId]))
+    }.flatten
+
   // def getImage(foodId: String, idx: Int) = Action.async
   // { implicit request: Request[AnyContent] =>
   //   withCollection(FoodImages) {collection =>
@@ -131,12 +141,11 @@ class FoodController @Inject()(
           else None
         }).getOrElse(request.authInfo.user.userId)
       )
-      withCollection(FoodNodeCollection) { (collection) =>
-        collection.insertOne(food).map({ (res) =>
-          val id = res.getInsertedId().asObjectId().getValue()
-          Ok(Json.toJson(food.withId(id)))
-        }).head()
-      }.flatten
+
+      (food match {
+        case recipeNode: RecipeNodeId => _addRecipe(recipeNode)
+        case node => _addFood(node)
+      }).map((food) => Ok(Json.toJson(food.asInstanceOf[FoodNodeId])))
     }
     catch {
       case _: JsResultException => Future.successful(
@@ -145,40 +154,43 @@ class FoodController @Inject()(
     }
   }
 
+  private def _addRecipe(node: RecipeNodeId) = {
+    val (metaNode, versNode) = RecipeMetaNodeId(node)
+    _addFood(metaNode, (_) => _saveVersionIfRecipe(versNode))
+      .map((_) => node)
+  }
+
+  private def _addFood(
+    node: FoodId,
+    callback: (FoodId) => Future[FoodId] = Future.successful _
+  ) = withCollection(FoodNodeCollection) { (collection) =>
+    collection.insertOne(node).map({ (res) => node }).head()
+  }.flatten
+    .flatMap(callback)
+
+  private def _saveVersionIfRecipe(node: RecipeNodeId): Future[FoodNodeId] =
+    withCollection(RecipeVersionCollection) { (collection) =>
+      collection.insertOne(node).map({ (res) => node }).head()
+    }.flatten
+
   def update(id: String, uid: Option[String]) =
     AuthorizedAction[Authorization](oauth)
       .async(parse.json)
   { implicit request: AuthInfoRequest[JsValue, Authorization] =>
     try {
-      val refFood = if (request.authInfo.user.hasAdminPermissions)
-        request.body.as[FoodNode]
-      else
-        request.body.as[FoodNode]
-          .withId(new ObjectId(id), request.authInfo.user.userId)
-
-      val food = refFood.withId(
+      val food = request.body.as[FoodNode].withId(
         new ObjectId(id),
-        uid.flatMap({ (id) =>
-          if (request.authInfo.user.hasAdminPermissions) Some(new ObjectId(id))
+        uid.flatMap({ (uid) =>
+          if (request.authInfo.user.hasAdminPermissions) Some(new ObjectId(uid))
           else None
           // tflucke@[2023-10-07] Should this query for the current uid instead?
         }).getOrElse(request.authInfo.user.userId)
       )
-      withCollection(FoodNodeCollection) { (collection) =>
-        collection.replaceOne(refFood match {
-          case fni: FoodNodeId => and(
-            equal("_id", new ObjectId(id)),
-            equal("uid", fni.uid)
-          )
-          case _ => equal("_id", new ObjectId(id))
-        }, food)
-          .map({ (res) => if (res.getModifiedCount > 0)
-              Ok(Json.toJson(food))
-            else
-              NotFound(s"User ${request.authInfo.user.userId} does not have a food node $id")
-          })
-          .head()
-      }.flatten
+
+      (food match {
+        case recipeNode: RecipeNodeId => _updateRecipe(recipeNode)
+        case node => _updateFood(node)
+      }).map((food) => Ok(Json.toJson(food.asInstanceOf[FoodNodeId])))
     }
     catch {
       case _: JsResultException => Future.successful(
@@ -187,11 +199,27 @@ class FoodController @Inject()(
     }
   }
 
-  def getByFdcId(fdcId: Long): Future[Option[USDANodeId]] =
-    withCollection(FoodNodeCollection) { (collection) =>
-      collection.find(equal("fdcId", fdcId))
-        .first()
-        .toFuture()
-        .map((foodNode) => Option(foodNode.asInstanceOf[USDANodeId]))
-    }.flatten
+  private def _updateRecipe(node: RecipeNodeId) = {
+    val (metaNode, versNode) = RecipeMetaNodeId(node)
+    _updateFood(metaNode, (_) => _saveVersionIfRecipe(versNode))
+      .map((_) => node)
+  }
+
+  private def _updateFood(
+    node: FoodId,
+    callback: (FoodId) => Future[FoodId] = Future.successful _
+  ) = withCollection(FoodNodeCollection) { (collection) =>
+    collection.replaceOne(and(
+      equal("_id", node._id),
+      equal("uid", node.uid)
+    ), node)
+      .head()
+      .transform({
+        case Success(res) if (res.getModifiedCount > 0) => Success(node)
+        case Success(res) => Failure(new NoSuchElementException("User " +
+            s"${node.uid} does not have a food node ${node._id}"))
+        case Failure(e) => Failure(e)
+      })
+  }.flatten
+    .flatMap(callback)
 }

+ 2 - 1
server/app/com/weEat/controllers/ParserController.scala

@@ -53,7 +53,8 @@ class ParserController @Inject()(
           Nil, //instructions.toSeq,
           None,
           None,
-          Some(url)
+          Some(url),
+          None
         ))))
     }
   })

+ 7 - 3
server/app/com/weEat/migrations/Migration.scala

@@ -16,17 +16,21 @@ object Migration {
   val migrations = Seq(
     InitDb,
     SeedNutrition,
-    RestoreFromFile(Source.fromResource("db-seeds/nutrients.json"), Nutrient)
+    RestoreFromFile(Source.fromResource("db-seeds/nutrients.json"), Nutrient),
+    RecipeVersionCollection,
     //RestoreFromFile(Source.fromResource("db-seeds/usda-foods.json"), FoodNode),
   )
 
-  def executeAll(db: MongoDatabase) = executeFutures(db, migrations)
+  def executeAll(db: MongoDatabase) = executeFutures(db, migrations).transform {
+    case Failure(e) => throw e
+    case Success(_) => Success(())
+  }
 
   def updateToLatest(db: MongoDatabase) = {
     val collection = db.getCollection[Metadata](Metadata.collectionName)
     collection.find().first().headOption().map({
       case Some(Metadata(last)) =>
-        executeFutures(db, migrations.slice(last + 1, migrations.size))
+        executeFutures(db, migrations.slice(last, migrations.size))
       case None => executeAll(db)
     }).flatten
   }

+ 64 - 0
server/app/com/weEat/migrations/RecipeVersionCollection.scala

@@ -0,0 +1,64 @@
+package com.weEat.migrations
+
+import org.mongodb.scala.MongoDatabase
+import com.mongodb.client.model._
+import org.mongodb.scala.model.Filters._
+import org.mongodb.scala.model.Field
+import org.mongodb.scala.bson.{BsonArray,BsonDocument}
+import com.weEat.shared.models.{FoodNodeId,RecipeNodeId}
+import com.weEat.models.{FoodNodeView,FoodNode,RecipeVersion}
+import scala.concurrent.ExecutionContext
+
+object RecipeVersionCollection extends Migration {
+  
+  implicit val ec: ExecutionContext = ExecutionContext.global
+
+  private def typ = `type` _
+
+  def execute(db: MongoDatabase) = createRecipeVersionCollection(db)
+    .flatMap((_) => createFoodView(db))
+
+  def createRecipeVersionCollection(db: MongoDatabase) =
+    db.createCollection(RecipeVersion.collectionName).head().flatMap({ (_) =>
+      val foods = db.getCollection[RecipeNodeId](FoodNode.collectionName)
+      foods.aggregate(Seq(
+        Aggregates.`match`(equal("_t", "RecipeNodeId")),
+        Aggregates.addFields(Field("vid", "$_id")),
+        Aggregates.out(RecipeVersion.collectionName)
+      )).head().flatMap({ (_) =>
+        val foods = db.getCollection[FoodNodeId](FoodNode.collectionName)
+        foods.aggregate(Seq(
+          Aggregates.`match`(equal("_t", "RecipeNodeId")),
+          Aggregates.project(Projections.fields(
+            Projections.include("_id", "uid", "name"),
+            Projections.computed("headVid", "$_id"),
+            Projections.computed("_t", "RecipeMetaNodeId")
+          )),
+          Aggregates.merge(FoodNode.collectionName)
+        ))
+        .head()
+      })
+    })
+
+  def createFoodView(db: MongoDatabase) =
+    db.createView(
+      FoodNodeView.collectionName,
+      FoodNode.collectionName,
+      Seq(
+        Aggregates.lookup(
+          RecipeVersion.collectionName,
+          "headVid",
+          "_id",
+          "recipe"
+        ),
+        Aggregates.addFields(Field("recipe._id", "$_id")),
+        Aggregates.replaceRoot(
+          BsonDocument(("$ifNull", BsonArray(
+            BsonDocument(("$first", "$recipe")),
+            "$$ROOT"
+          )))
+        ),
+        Aggregates.project(Projections.exclude("recipe")),
+      )
+    ).head()
+}

+ 12 - 1
server/app/com/weEat/models/FoodNode.scala

@@ -1,5 +1,16 @@
 package com.weEat.models
 
-object FoodNode extends Collectable[com.weEat.shared.models.FoodNodeId] {
+import com.weEat.shared.models.{FoodId,RecipeNodeId}
+import com.weEat.shared.models.IdentifierHelper._
+
+object FoodNodeView extends Collectable[com.weEat.shared.models.FoodNodeId] {
+  val collectionName = "food_view"
+}
+
+object FoodNode extends Collectable[com.weEat.shared.models.FoodId] {
   val collectionName = "foods"
 }
+
+object RecipeVersion extends Collectable[com.weEat.shared.models.RecipeNodeId] {
+  val collectionName = "recipe_versions"
+}

+ 3 - 1
server/app/com/weEat/services/MongoDBService.scala

@@ -8,7 +8,7 @@ import org.bson.codecs.{Codec,DecoderContext,EncoderContext}
 import org.bson.codecs.configuration.{CodecProvider,CodecRegistry}
 import org.bson.codecs.configuration.CodecRegistries._
 import com.weEat.models._
-import com.weEat.shared.models.{FoodNodeId,USDANodeId,Ingredient,UnitType,MeasureUnit,RecipeNodeId}
+import com.weEat.shared.models.{FoodNodeId,USDANodeId,Ingredient,UnitType,MeasureUnit,RecipeNodeId,RecipeMetaNodeId,FoodId}
 import Ingredient._
 import javax.inject.{Inject,Singleton}
 import scala.reflect.ClassTag
@@ -47,6 +47,8 @@ class MongoDBService @Inject()(config: Configuration) {
         { n: RecipeNodeId => n }
       ),
       classOf[Metadata],
+      classOf[RecipeMetaNodeId],
+      classOf[FoodId],
       classOf[FoodNodeId],
       classOf[IngredientId],
       classOf[Ingredient],

+ 2 - 0
shared/js/src/main/scala/com/weEat/shared/models/Identifier.scala

@@ -5,4 +5,6 @@ object IdentifierHelper {
 
   import play.api.libs.json.Format
   implicit val fmt = Format.of[String]
+
+  def default(): Identifier = ""
 }

+ 2 - 0
shared/jvm/src/main/scala/com/weEat/shared/models/Identifier.scala

@@ -10,4 +10,6 @@ object IdentifierHelper {
 
   implicit val fmt = Format.of[String]
     .inmap[ObjectId](new ObjectId(_), _.toHexString())
+
+  def default(): Identifier = new ObjectId
 }

+ 34 - 7
shared/shared/src/main/scala/com/weEat/shared/models/FoodNode.scala

@@ -25,18 +25,36 @@ sealed trait FoodNode {
   //def image(idx: Int): APIRoute[Image] = FoodController.getImages(imageIds(idx))
 }
 
-sealed trait FoodNodeId extends FoodNode {
+sealed trait FoodId {
   implicit val ctx = com.weEat.shared.ctx
 
   val _id: Identifier
   val uid: Identifier
 
-  def withId(id: Identifier): FoodNodeId = withId(id, uid)
-  def versionId = _id
-
   lazy val user: Future[User] = UserController.get(uid.toString)()
 }
 
+case class RecipeMetaNodeId(
+  val _id: Identifier,
+  val uid: Identifier,
+  val headVid: Identifier,
+  val name: String
+) extends FoodId
+
+object RecipeMetaNodeId {
+  def apply(recipe: RecipeNodeId): (RecipeMetaNodeId, RecipeNodeId) = (
+    RecipeMetaNodeId(recipe._id, recipe.uid, recipe.vid, recipe.name),
+    recipe.copy(_id = recipe.vid)
+  )
+
+  def merge(meta: RecipeMetaNodeId, vers: RecipeNodeId): RecipeNodeId =
+    vers.copy(_id = meta._id, uid = meta.uid, vid = vers._id)
+}
+
+sealed trait FoodNodeId extends FoodNode with FoodId {
+  def withId(id: Identifier): FoodNodeId = withId(id, uid)
+}
+
 case class Ingredient(
   val id: Ingredient.IngredientId,
   val amount: Float,
@@ -121,6 +139,7 @@ sealed trait RecipeNode extends FoodNode {
   def ingredients: Seq[Ingredient]
   def steps: Seq[String]
   def source: Option[String]
+  def prevVersionVid: Option[Identifier]
 
   def numServings: Float = stdQties / stdQtiesPServing
   def servingSizeInGrams: Float = stdQtiesPServing * 100
@@ -128,6 +147,10 @@ sealed trait RecipeNode extends FoodNode {
   def withId(id: Identifier, uid: Identifier) = RecipeNodeId(
     id,
     uid,
+    this match {
+      case self: RecipeNodeId => self.vid
+      case _ => IdentifierHelper.default()
+    },
     name,
     stdQties,
     stdQtiesPServing,
@@ -136,7 +159,8 @@ sealed trait RecipeNode extends FoodNode {
     steps,
     density,
     massPerUnit,
-    source
+    source,
+    prevVersionVid
   )
 
   def nutrient(num: String) = ingredients.map({ ingr =>
@@ -170,12 +194,14 @@ case class RecipeNodeNoId(
   val steps: Seq[String],
   override val density: Option[Float],
   override val massPerUnit: Option[Float],
-  val source: Option[String]
+  val source: Option[String],
+  val prevVersionVid: Option[Identifier]
 ) extends RecipeNode
 
 case class RecipeNodeId(
   val _id: Identifier,
   val uid: Identifier,
+  val vid: Identifier,
   val name: String,
   val stdQties: Float,
   val stdQtiesPServing: Float,
@@ -184,7 +210,8 @@ case class RecipeNodeId(
   val steps: Seq[String],
   override val density: Option[Float],
   override val massPerUnit: Option[Float],
-  val source: Option[String]
+  val source: Option[String],
+  val prevVersionVid: Option[Identifier]
 ) extends RecipeNode with FoodNodeId {
   implicit override val ctx = com.weEat.shared.ctx
 }

+ 5 - 1
webClient/src/main/scala/com/weEat/models/RecipeVar.scala

@@ -30,6 +30,7 @@ case class RecipeVar(recipe: Option[RecipeNode])
     Seq(),
     None,
     None,
+    None,
     None
   ))
 
@@ -326,7 +327,10 @@ case class RecipeVar(recipe: Option[RecipeNode])
         density, massPerUnit, source
       ) => Success(RecipeNodeNoId(
         name, stdQties, stdQtiesPServing, defaultUnitType, ingredients, steps,
-        density, massPerUnit, source
+        density, massPerUnit, source, _defaultRecipe match {
+          case node: RecipeNodeId => Some(node.vid)
+          case _ => None
+        }
       ))
     }
 

+ 1 - 2
webClient/src/main/scala/com/weEat/views/RecipeEdit.scala

@@ -2,7 +2,6 @@ package com.weEat.view
 
 import com.raquo.laminar.api.L._
 import io.laminext.syntax.core._
-import com.raquo.airstream.ownership.ManualOwner
 import com.raquo.waypoint._
 
 import play.api.libs.json.{JsValue,Json}
@@ -62,7 +61,7 @@ object RecipeEdit extends View[Option[String]] {
           )
         ),
         child <-- refRecipe.map({
-          case Some(RecipeNodeId(id, uid, _, _, _, _, _, _, _, _, _)) =>
+          case Some(RecipeNodeId(id, uid, _, _, _, _, _, _, _, _, _, _, _)) =>
             button(cls := "btn",
               onMountBind { (ctx) =>
                 onClick --> { (e: Event) =>

+ 1 - 1
webClient/src/main/scala/com/weEat/views/RecipeImporter.scala

@@ -58,7 +58,7 @@ object RecipeImporter extends View[Unit] {
           )
         )
       ),
-      child <-- recipeVar.map(_.render),
+      child <-- recipeVar.map(_.render()),
       button(cls := "btn",
         onMountBind { (ctx) =>
           onClick --> { (e: Event) =>