app/services/editions/db/CollectionsQueries.scala (409 lines of code) (raw):
package services.editions.db
import java.time._
import model.editions.internal.PrefillUpdate
import model.editions.{
CapiPrefillQuery,
Edition,
EditionsArticle,
EditionsCard,
EditionsChef,
EditionsCollection,
EditionsFeastCollection,
PathType
}
import model.forms.GetCollectionsFilter
import play.api.libs.json.Json
import scalikejdbc._
import services.editions.DbEditionsCard
import services.editions.prefills.CapiQueryTimeWindow
import play.api.libs.json.Writes
import com.gu.pandomainauth.model.User
import logging.Logging
import scala.util.Try
import model.editions.CuratedPlatform.Editions
trait CollectionsQueries extends Logging {
def getCollections(filters: List[GetCollectionsFilter]) = DB readOnly {
implicit session =>
case class TypedFilters(id: String, updatedAt: Option[OffsetDateTime])
val sqlFilters = filters.map { f =>
TypedFilters(
f.id,
f.lastUpdated.map(
Instant.ofEpochMilli(_).atOffset(ZoneOffset.UTC)
)
)
}
val rows = fetchCollectionsSql(where = sqls"""
EXISTS (
SELECT *
FROM (VALUES ${sqlFilters.map(f => sqls"(${f.id}, ${f.updatedAt})")}) AS F(id, updated_on)
WHERE collections.id = F.id AND (F.updated_on IS NULL OR collections.updated_on > F.updated_on::TIMESTAMPTZ)
)
""").apply()
convertRowsToCollections(rows)
}
def getCollectionPrefill(id: String) = DB readOnly { implicit session =>
val rows =
sql"""
SELECT collections.prefill,
collections.path_type,
collections.content_prefill_window_start,
collections.content_prefill_window_end,
cards.id,
edition_issues.name,
edition_issues.issue_date,
edition_issues.timezone_id
FROM collections
LEFT JOIN cards ON (collections.id = cards.collection_id)
JOIN fronts ON (collections.front_id = fronts.id)
JOIN edition_issues ON (fronts.issue_id = edition_issues.id)
WHERE collections.id = $id
"""
.map { rs =>
val date = rs.localDate("issue_date")
val editionStr = rs.string("name")
val edition = Edition.withName(editionStr)
val zone = ZoneId.of(rs.string("timezone_id"))
val pathTypeStr = rs.string("path_type")
val pathType = PathType.withName(pathTypeStr)
val timeWinStart =
rs.zonedDateTime("content_prefill_window_start").toInstant
val timeWinEnd =
rs.zonedDateTime("content_prefill_window_end").toInstant
val contentPrefillQueryTimeWindow =
CapiQueryTimeWindow(timeWinStart, timeWinEnd)
(
date,
edition,
zone,
CapiPrefillQuery(rs.string("prefill"), pathType),
contentPrefillQueryTimeWindow,
rs.string("id")
)
}
.list
.apply()
rows.headOption.map {
case (
issueDate,
edition,
zone,
prefillQueryUrlSegments,
contentPrefillQueryTimeWindow,
_
) =>
PrefillUpdate(
issueDate,
edition,
zone,
prefillQueryUrlSegments,
contentPrefillQueryTimeWindow,
rows.map(_._6)
)
}
}
def updateCollectionName(collection: EditionsCollection): EditionsCollection =
DB localTx { implicit session =>
val lastUpdated = EditionsDB.truncateDateTime(OffsetDateTime.now())
sql"""
UPDATE collections
SET "name" = ${collection.displayName.trim()},
updated_on = $lastUpdated,
updated_by = ${collection.updatedBy},
updated_email = ${collection.updatedEmail}
WHERE id = ${collection.id}
""".execute.apply()
val rows = fetchCollectionsSql(where =
sqls"collections.id = ${collection.id}"
).apply()
val updatedCollections = convertRowsToCollections(rows)
// we have filtered on a single id so this list should only contain one collection
assert(
updatedCollections.size == 1,
s"Retrieved ${updatedCollections.size} collections from DB but there should be exactly one. Failing fast."
)
updatedCollections.head
}
def updateCollectionRegionsInDB(
collection: EditionsCollection
): EditionsCollection =
DB localTx { implicit session =>
val lastUpdated = EditionsDB.truncateDateTime(OffsetDateTime.now())
sql"""
UPDATE collections
SET "targeted_regions" = ${collection.targetedRegionsPG()},
"excluded_regions" = ${collection.excludedRegionsPG()},
updated_on = $lastUpdated,
updated_by = ${collection.updatedBy},
updated_email = ${collection.updatedEmail}
WHERE id = ${collection.id}
""".execute.apply()
val rows = fetchCollectionsSql(where =
sqls"collections.id = ${collection.id}"
).apply()
val updatedCollections = convertRowsToCollections(rows)
// we have filtered on a single id so this list should only contain one collection
assert(
updatedCollections.size == 1,
s"Retrieved ${updatedCollections.size} collections from DB but there should be exactly one. Failing fast."
)
updatedCollections.head
}
/** Move the collection to the given index, updating the index values for the
* other collections in that front to ensure a contiguous range.
*/
def moveCollectionToIndex(collectionId: String, newIndex: Int)(implicit
session: DBSession
): Either[Error, Unit] =
for {
currentCollectionIds <- getCollectionIdsInFrontFromCollectionId(
collectionId
)
} yield {
currentCollectionIds.indexOf(collectionId) match {
case -1 =>
Left(
EditionsDB.NotFoundError(
s"Tried to move collection $collectionId to $newIndex, but could not find collection with that ID"
)
)
case currentIndex if currentIndex == newIndex =>
logger.info(s"Collection $collectionId is already at index $newIndex")
Right(()) // No move
case currentIndex =>
logger.info(
s"Moving $collectionId at $currentIndex to index $newIndex"
)
val newCollectionIds = currentCollectionIds
.filter(_ != collectionId)
.patch(newIndex, List(collectionId), 0)
updateCollectionIndices(newCollectionIds)
}
}
def updateCollection(collection: EditionsCollection): EditionsCollection =
DB localTx { implicit session =>
val lastUpdated =
collection.lastUpdated.map(EditionsDB.dateTimeFromMillis)
sql"""
UPDATE collections
SET is_hidden = ${collection.isHidden},
updated_on = $lastUpdated,
updated_by = ${collection.updatedBy},
updated_email = ${collection.updatedEmail}
WHERE id = ${collection.id}
""".execute.apply()
// At the moment we don't do partial updates so simply delete all of them and reinsert.
sql"""
DELETE FROM cards WHERE collection_id = ${collection.id}
""".execute.apply()
collection.items.zipWithIndex.foreach { case (card, index) =>
val metadataJson = EditionsCard.getMetadataJson(card)
val addedOn = EditionsDB.dateTimeFromMillis(card.addedOn)
sql"""
INSERT INTO cards (
collection_id,
id,
card_type,
index,
added_on,
metadata
) VALUES (${collection.id}, ${card.id}, ${card.cardType.entryName}, $index, $addedOn, $metadataJson::JSONB)
""".execute.apply()
}
val rows = fetchCollectionsSql(where =
sqls"collections.id = ${collection.id}"
).apply()
val updatedCollections = convertRowsToCollections(rows)
// we have filtered on a single id so this list should only contain one collection
assert(
updatedCollections.size == 1,
s"Retrieved ${updatedCollections.size} collections from DB but there should be exactly one. Failing fast."
)
updatedCollections.head
}
/** Update the indices for a list of collections, setting their index as their
* position in the given list.
*
* @param collectionIds
* The list of collectionIds, in order they are to be indexed
* @param offset
* If supplied, offset the indices by this value
* @return
*/
protected def updateCollectionIndices(
collectionIds: List[String],
offset: Option[Int] = None
)(implicit session: DBSession): Either[Error, Unit] = {
logger.info(
s"Updating collection indices with order ${collectionIds.mkString(",")} at offset $offset"
)
Try {
collectionIds match {
case Nil => Right(())
case _ =>
sql"""
UPDATE collections
SET index=CASE
${sqls.join(
collectionIds.zipWithIndex.map { case (id, index) =>
sqls"""WHEN id=$id THEN ${index + offset.getOrElse(0)}"""
},
sqls.empty
)}
END
WHERE id IN (${sqls.join(
collectionIds.map(id => sqls"$id"),
sqls","
)})
""".update.apply()
}
}.toEither match {
case Left(error) =>
Left(
EditionsDB.WriteError(
s"Could not update collection indices: ${error.getMessage}"
)
)
case Right(_) => Right(())
}
}
private def getCollectionIdsInFrontFromCollectionId(collectionId: String)(
implicit session: DBSession
): Either[Error, List[String]] =
getCollectionIds(sqls"""
WHERE front_id = (
SELECT front_id
FROM collections
WHERE id=$collectionId
)""")
private def getCollectionIdsInFront(frontId: String)(implicit
session: DBSession
): Either[Error, List[String]] =
getCollectionIds(sqls"""WHERE front_id = $frontId""")
private def getCollectionIds(
where: SQLSyntax
)(implicit session: DBSession): Either[Error, List[String]] =
sql"""
SELECT id
FROM collections
$where
ORDER BY index
"""
.map(_.string("id"))
.list
.apply() match {
case collectionIds => Right(collectionIds)
}
private def maybeJson[T](maybeModel: Option[T])(implicit writes: Writes[T]) =
maybeModel.map(m => Json.toJson(m).toString)
private def fetchCollectionsSql(
where: SQLSyntax
): SQLToList[GetCollectionsRow, HasExtractor] = {
val sql =
sql"""
SELECT
collections.id,
collections.front_id,
collections.index,
collections.name,
collections.is_hidden,
collections.metadata,
collections.updated_on,
collections.updated_by,
collections.updated_email,
collections.prefill,
collections.path_type,
collections.content_prefill_window_start,
collections.content_prefill_window_end,
collections.targeted_regions,
collections.excluded_regions,
fronts.is_special,
cards.collection_id AS cards_collection_id,
cards.id AS cards_id,
cards.card_type AS cards_card_type,
cards.index AS cards_index,
cards.added_on AS cards_added_on,
cards.metadata AS cards_metadata
FROM collections
LEFT JOIN cards ON (cards.collection_id = collections.id)
LEFT JOIN fronts ON (collections.front_id = fronts.id)
WHERE $where
"""
sql.map { rs =>
GetCollectionsRow(
EditionsCollection.fromRow(rs),
DbEditionsCard.fromRowOpt(rs, "cards_")
)
}.toList
}
private def convertRowsToCollections(
rows: List[GetCollectionsRow]
): List[EditionsCollection] = {
rows.groupBy(_.collection.id).values.toList.map { rowsWithId =>
val cards = rowsWithId.flatMap(_.card).sortBy(_.index).map(_.card)
rowsWithId.head.collection.copy(items = cards)
}
}
private case class GetCollectionsRow(
collection: EditionsCollection,
card: Option[DbEditionsCard]
)
/** Insert a collection owned by the specified front.
*
* @return
* the Collection id
*/
protected def insertCollection(
frontId: String,
collectionIndex: Int,
name: String,
now: OffsetDateTime,
user: User
)(implicit session: DBSession): Either[Error, String] = {
logger.info(
s"Inserting new collection into front $frontId at index $collectionIndex"
)
for {
currentCollectionIds <- getCollectionIdsInFront(frontId)
maxCollectionIndex = currentCollectionIds.size
_ <-
if (collectionIndex > maxCollectionIndex) {
Left(
EditionsDB.InvalidInput(
s"Cannot add a collection at index $collectionIndex (min: 0, max: $maxCollectionIndex"
)
)
} else Right(())
// Make a gap in the index for the new collection
_ <- updateCollectionIndices(
currentCollectionIds.slice(collectionIndex, currentCollectionIds.size),
Some(collectionIndex + 1)
)
id <- Try {
sql"""
INSERT INTO collections (
front_id,
index,
name,
is_hidden,
updated_on,
updated_by,
updated_email
) VALUES (
$frontId
, $collectionIndex
, $name
, FALSE
, $now
, ${EditionsDB.getUserName(user)}
, ${user.email}
)
RETURNING id;
""".map(_.string("id"))
.single
.apply()
.toRight(
EditionsDB.WriteError("Could not write new collection to database")
)
}.toEither.left.map { error =>
EditionsDB.WriteError(error.getMessage)
}.flatten
} yield id
}
/** Delete a collection.
*/
protected def deleteCollection(
collectionId: String,
now: OffsetDateTime,
user: User
)(implicit session: DBSession): Either[Error, Unit] =
Try {
sql"""DELETE FROM collections WHERE id=$collectionId"""
.map(_.string("id"))
.update
.apply()
}.toEither match {
case Right(1) => Right(())
case Right(_) =>
Left(
EditionsDB.NotFoundError(s"Collection ${collectionId} was not found")
)
case Left(error) => Left(EditionsDB.WriteError(error.getMessage()))
}
}