migrate to anorm

I'm tired of ORM and would like to write clear sql requests. The
following wrappers were checked:
* doobie - cats api which is useless in this project
* scalike - can't work with sqlite at all
* anorm - awful api
* something also

Anorm fits more than any other my criteria so I migrated to it with
native hikaricp usage
This commit is contained in:
Evgenii Alekseev 2022-01-17 04:51:29 +03:00
parent 012cdd2d8b
commit ced781bba2
38 changed files with 495 additions and 345 deletions

View File

@ -1,9 +1,9 @@
val AkkaVersion = "2.6.17"
val AkkaVersion = "2.6.18"
val AkkaHttpVersion = "10.2.7"
val ScalaTestVersion = "3.2.10"
val SlickVersion = "3.3.3"
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.9"
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.10"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.4"
libraryDependencies += "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion
@ -15,16 +15,15 @@ libraryDependencies += "jakarta.platform" % "jakarta.jakartaee-web-api" % "9.1.0
libraryDependencies += "io.spray" %% "spray-json" % "1.3.6"
libraryDependencies += "com.typesafe.slick" %% "slick" % SlickVersion
libraryDependencies += "com.typesafe.slick" %% "slick-hikaricp" % SlickVersion
libraryDependencies += "org.flywaydb" % "flyway-core" % "8.2.2"
libraryDependencies += "org.playframework.anorm" %% "anorm" % "2.6.10"
libraryDependencies += "com.zaxxer" % "HikariCP" % "5.0.1" exclude("org.slf4j", "slf4j-api")
libraryDependencies += "org.flywaydb" % "flyway-core" % "8.4.1"
libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.36.0.3"
libraryDependencies += "org.postgresql" % "postgresql" % "42.3.1"
libraryDependencies += "org.mindrot" % "jbcrypt" % "0.4"
libraryDependencies += "com.google.guava" % "guava" % "31.0.1-jre"
// testing
libraryDependencies += "org.scalactic" %% "scalactic" % ScalaTestVersion % "test"
libraryDependencies += "org.scalatest" %% "scalatest" % ScalaTestVersion % "test"

View File

@ -0,0 +1,2 @@
drop index bis_piece_type_player_id_idx;
create unique index bis_piece_type_player_id_idx on bis(player_id, piece, piece_type);

View File

@ -0,0 +1,2 @@
drop index bis_piece_type_player_id_idx;
create unique index bis_piece_type_player_id_idx on bis(player_id, piece, piece_type);

View File

@ -68,6 +68,8 @@
data-show-search-clear-button="true"
data-single-select="true"
data-sortable="true"
data-sort-name="nick"
data-sort-order="desc"
data-sort-reset="true"
data-toolbar="#toolbar">
<thead class="table-primary">

View File

@ -68,6 +68,8 @@
data-show-search-clear-button="true"
data-single-select="true"
data-sortable="true"
data-sort-name="timestamp"
data-sort-order="desc"
data-sort-reset="true"
data-toolbar="#toolbar">
<thead class="table-primary">
@ -117,6 +119,15 @@
<select id="job" name="job" class="form-control" title="job"></select>
</div>
</div>
<div class="form-group row">
<div class="col-sm-4"></div>
<div class="col-sm-8">
<div class="form-check">
<input id="free-loot" name="freeLoot" type="checkbox" class="form-check-input">
<label class="form-check-label" for="free-loot">as free loot</label>
</div>
</div>
</div>
<table id="stats" class="table table-striped table-hover">
<thead class="table-primary">
@ -133,10 +144,6 @@
</form>
<div class="modal-footer">
<div class="form-check form-switch">
<input id="free-loot" name="freeLoot" type="checkbox" class="form-check-input">
<label class="form-check-label" for="free-loot">as free loot</label>
</div>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button type="button" class="btn btn-secondary" onclick="suggestLoot()">suggest</button>
<button id="submit-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addLoot()" disabled>add</button>

View File

@ -64,6 +64,8 @@
data-show-search-clear-button="true"
data-single-select="true"
data-sortable="true"
data-sort-name="nick"
data-sort-order="desc"
data-sort-reset="true"
data-toolbar="#toolbar">
<thead class="table-primary">

View File

@ -68,6 +68,8 @@
data-show-search-clear-button="true"
data-single-select="true"
data-sortable="true"
data-sort-name="username"
data-sort-order="desc"
data-sort-reset="true"
data-toolbar="#toolbar">
<thead class="table-primary">

View File

@ -3,15 +3,14 @@
<include resource="logback-application.xml" />
<include resource="logback-http.xml" />
<root level="debug">
<root level="DEBUG">
<appender-ref ref="application" />
</root>
<logger name="me.arcanis.ffxivbis" level="DEBUG" />
<logger name="http" level="DEBUG" additivity="false">
<appender-ref ref="http" />
</logger>
<logger name="slick" level="INFO" />
<logger name="org.flywaydb.core.internal" level="INFO" />
<logger name="com.zaxxer.hikari.pool.HikariPool" level="INFO" />
</configuration>

View File

@ -15,26 +15,17 @@ me.arcanis.ffxivbis {
mode = "sqlite"
sqlite {
profile = "slick.jdbc.SQLiteProfile$"
db {
url = "jdbc:sqlite:ffxivbis.db"
#user = "user"
#password = "password"
}
numThreads = 10
driverClassName = "org.sqlite.JDBC"
jdbcUrl = "jdbc:sqlite:ffxivbis.db"
#username = "user"
#password = "password"
}
postgresql {
profile = "slick.jdbc.PostgresProfile$"
db {
url = "jdbc:postgresql://localhost/ffxivbis"
#user = "ffxivbis"
#password = "ffxivbis"
connectionPool = disabled
keepAliveConnection = yes
}
numThreads = 10
driverClassName = "org.postgresql.Driver"
jdbcUrl = "jdbc:postgresql://localhost/ffxivbis"
#username = "ffxivbis"
#password = "ffxivbis"
}
}

View File

@ -17,8 +17,7 @@ import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.RootEndpoint
import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.Database
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.service.database.{Database, Migration}
import scala.concurrent.ExecutionContext
import scala.jdk.CollectionConverters._

View File

@ -1,3 +1,11 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable

View File

@ -1,3 +1,11 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable

View File

@ -1,3 +1,11 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable
@ -25,8 +33,8 @@ trait LootHelper {
): Future[Unit] =
(action, maybeFree) match {
case (ApiAction.add, Some(isFreeLoot)) => addPieceLoot(playerId, piece, isFreeLoot)
case (ApiAction.remove, _) => removePieceLoot(playerId, piece)
case _ => throw new IllegalArgumentException(s"Invalid combinantion of action $action and fee loot $maybeFree")
case (ApiAction.remove, Some(isFreeLoot)) => removePieceLoot(playerId, piece, isFreeLoot)
case _ => throw new IllegalArgumentException("Loot modification must always contain `isFreeLoot` field")
}
def loot(partyId: String, playerId: Option[PlayerId])(implicit
@ -35,8 +43,11 @@ trait LootHelper {
): Future[Seq[Player]] =
storage.ask(GetLoot(partyId, playerId, _))
def removePieceLoot(playerId: PlayerId, piece: Piece)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] =
storage.ask(RemovePieceFrom(playerId, piece, _))
def removePieceLoot(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean)(implicit
timeout: Timeout,
scheduler: Scheduler
): Future[Unit] =
storage.ask(RemovePieceFrom(playerId, piece, isFreeLoot, _))
def suggestPiece(partyId: String, piece: Piece)(implicit
executionContext: ExecutionContext,

View File

@ -1,3 +1,11 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable

View File

@ -1,3 +1,11 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable

View File

@ -51,7 +51,8 @@ case class AddPieceTo(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, rep
case class GetLoot(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]])
extends LootDatabaseMessage
case class RemovePieceFrom(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends LootDatabaseMessage {
case class RemovePieceFrom(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit])
extends LootDatabaseMessage {
override def partyId: String = playerId.partyId
}

View File

@ -13,4 +13,6 @@ import java.time.Instant
case class Loot(playerId: Long, piece: Piece, timestamp: Instant, isFreeLoot: Boolean) {
def isFreeLootToString: String = if (isFreeLoot) "yes" else "no"
def isFreeLootToInt: Int = if (isFreeLoot) 1 else 0
}

View File

@ -23,7 +23,9 @@ import scala.util.{Failure, Success}
trait Database extends StrictLogging {
implicit def executionContext: ExecutionContext
def config: Config
def profile: DatabaseProfile
def filterParty(party: Party, maybePlayerId: Option[PlayerId]): Seq[Player] =
@ -36,8 +38,8 @@ trait Database extends StrictLogging {
for {
partyDescription <- profile.getPartyDescription(partyId)
players <- profile.getParty(partyId)
bis <- if (withBiS) profile.getPiecesBiS(partyId) else Future(Seq.empty)
loot <- if (withLoot) profile.getPieces(partyId) else Future(Seq.empty)
bis <- if (withBiS) profile.getPiecesBiS(partyId) else Future.successful(Seq.empty)
loot <- if (withLoot) profile.getPieces(partyId) else Future.successful(Seq.empty)
} yield Party(partyDescription, config, players, bis, loot)
protected def run[T](fn: => Future[T])(onSuccess: T => Unit): Unit =

View File

@ -6,9 +6,10 @@
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.storage
package me.arcanis.ffxivbis.service.database
import com.typesafe.config.Config
import me.arcanis.ffxivbis.storage.DatabaseProfile
import org.flywaydb.core.Flyway
import org.flywaydb.core.api.configuration.ClassicConfiguration
import org.flywaydb.core.api.output.MigrateResult
@ -17,12 +18,14 @@ import scala.util.Try
class Migration(config: Config) {
import me.arcanis.ffxivbis.utils.Implicits._
def performMigration(): Try[MigrateResult] = {
val section = DatabaseProfile.getSection(config)
val url = section.getString("db.url")
val username = Try(section.getString("db.user")).toOption.filter(_.nonEmpty).orNull
val password = Try(section.getString("db.password")).toOption.filter(_.nonEmpty).orNull
val url = section.getString("jdbcUrl")
val username = section.getOptString("username").orNull
val password = section.getOptString("password").orNull
val provider = url match {
case s"jdbc:$p:$_" => p

View File

@ -30,8 +30,8 @@ trait DatabaseLootHandler { this: Database =>
}(client ! _)
Behaviors.same
case RemovePieceFrom(playerId, piece, client) =>
run(profile.deletePiece(playerId, piece))(_ => client ! ())
case RemovePieceFrom(playerId, piece, isFreeLoot, client) =>
run(profile.deletePiece(playerId, piece, isFreeLoot))(_ => client ! ())
Behaviors.same
case SuggestLoot(partyId, piece, client) =>

View File

@ -8,66 +8,72 @@
*/
package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.{Job, Loot, Piece, PieceType}
import slick.lifted.ForeignKeyQuery
import java.time.Instant
import scala.concurrent.Future
trait BiSProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
trait BiSProfile extends DatabaseConnection {
case class BiSRep(playerId: Long, created: Long, piece: String, pieceType: String, job: String) {
def toLoot: Loot = Loot(
playerId,
Piece(piece, PieceType.withName(pieceType), Job.withName(job)),
Instant.ofEpochMilli(created),
isFreeLoot = false
)
}
object BiSRep {
def fromPiece(playerId: Long, piece: Piece): BiSRep =
BiSRep(playerId, DatabaseProfile.now, piece.piece, piece.pieceType.toString, piece.job.toString)
}
class BiSPieces(tag: Tag) extends Table[BiSRep](tag, "bis") {
def playerId: Rep[Long] = column[Long]("player_id", O.PrimaryKey)
def created: Rep[Long] = column[Long]("created")
def piece: Rep[String] = column[String]("piece", O.PrimaryKey)
def pieceType: Rep[String] = column[String]("piece_type")
def job: Rep[String] = column[String]("job")
def * =
(playerId, created, piece, pieceType, job) <> ((BiSRep.apply _).tupled, BiSRep.unapply)
def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] =
foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade)
}
private val loot: RowParser[Loot] =
(long("player_id") ~ str("piece") ~ str("piece_type")
~ str("job") ~ long("created"))
.map { case playerId ~ piece ~ pieceType ~ job ~ created =>
Loot(
playerId = playerId,
piece = Piece(
piece = piece,
pieceType = PieceType.withName(pieceType),
job = Job.withName(job)
),
timestamp = Instant.ofEpochMilli(created),
isFreeLoot = false,
)
}
def deletePieceBiSById(piece: Piece)(playerId: Long): Future[Int] =
db.run(pieceBiS(BiSRep.fromPiece(playerId, piece)).delete)
withConnection { implicit conn =>
SQL("""delete from bis
| where player_id = {player_id}
| and piece = {piece}
| and piece_type = {piece_type}""".stripMargin)
.on("player_id" -> playerId, "piece" -> piece.piece, "piece_type" -> piece.pieceType.toString)
.executeUpdate()
}
def deletePiecesBiSById(playerId: Long): Future[Int] =
db.run(piecesBiS(Seq(playerId)).delete)
withConnection { implicit conn =>
SQL("""delete from bis where player_id = {player_id}""")
.on("player_id" -> playerId)
.executeUpdate()
}
def getPiecesBiSById(playerId: Long): Future[Seq[Loot]] = getPiecesBiSById(Seq(playerId))
def getPiecesBiSById(playerIds: Seq[Long]): Future[Seq[Loot]] =
db.run(piecesBiS(playerIds).result).map(_.map(_.toLoot))
withConnection { implicit conn =>
SQL("""select * from bis where player_id in ({player_ids})""")
.on("player_ids" -> playerIds)
.executeQuery()
.as(loot.*)
}
def insertPieceBiSById(piece: Piece)(playerId: Long): Future[Int] =
getPiecesBiSById(playerId).flatMap {
case pieces if pieces.exists(loot => loot.piece.strictEqual(piece)) => Future.successful(0)
case _ => db.run(bisTable.insertOrUpdate(BiSRep.fromPiece(playerId, piece)))
withConnection { implicit conn =>
SQL("""insert into bis
| (player_id, piece, piece_type, job, created)
| values
| ({player_id}, {piece}, {piece_type}, {job}, {created})
| on conflict (player_id, piece, piece_type) do nothing""".stripMargin)
.on(
"player_id" -> playerId,
"piece" -> piece.piece,
"piece_type" -> piece.pieceType.toString,
"job" -> piece.job.toString,
"created" -> DatabaseProfile.now
)
.executeUpdate()
}
private def pieceBiS(piece: BiSRep) =
piecesBiS(Seq(piece.playerId)).filter { stored =>
(stored.piece === piece.piece) && (stored.pieceType === piece.pieceType)
}
private def piecesBiS(playerIds: Seq[Long]) =
bisTable.filter(_.playerId.inSet(playerIds.toSet))
}

View File

@ -0,0 +1,58 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.storage
import com.typesafe.config.Config
import com.zaxxer.hikari.HikariConfig
import java.sql.Connection
import java.util.Properties
import javax.sql.DataSource
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.jdk.CollectionConverters._
import scala.util.control.NonFatal
import scala.util.{Failure, Success, Try}
trait DatabaseConnection {
def datasource: DataSource
def executionContext: ExecutionContext
def withConnection[T](fn: Connection => T): Future[T] = {
val promise = Promise[T]()
executionContext.execute { () =>
Try(datasource.getConnection) match {
case Success(conn) =>
try {
val result = fn(conn)
promise.trySuccess(result)
} catch {
case NonFatal(exception) => promise.tryFailure(exception)
} finally
conn.close()
case Failure(exception) => promise.tryFailure(exception)
}
}
promise.future
}
}
object DatabaseConnection {
def getDataSourceConfig(config: Config): HikariConfig = {
val properties = new Properties()
config.entrySet().asScala.map(_.getKey).foreach { key =>
properties.setProperty(key, config.getString(key))
}
new HikariConfig(properties)
}
}

View File

@ -9,32 +9,32 @@
package me.arcanis.ffxivbis.storage
import com.typesafe.config.Config
import com.typesafe.scalalogging.StrictLogging
import com.zaxxer.hikari.HikariDataSource
import me.arcanis.ffxivbis.models.{Loot, Piece, PlayerId}
import slick.basic.DatabaseConfig
import slick.jdbc.JdbcProfile
import java.time.Instant
import javax.sql.DataSource
import scala.concurrent.{ExecutionContext, Future}
class DatabaseProfile(context: ExecutionContext, config: Config)
extends BiSProfile
class DatabaseProfile(override val executionContext: ExecutionContext, config: Config)
extends StrictLogging
with BiSProfile
with LootProfile
with PartyProfile
with PlayersProfile
with UsersProfile {
implicit val executionContext: ExecutionContext = context
val dbConfig: DatabaseConfig[JdbcProfile] =
DatabaseConfig.forConfig[JdbcProfile]("", DatabaseProfile.getSection(config))
import dbConfig.profile.api._
val db = dbConfig.db
val bisTable: TableQuery[BiSPieces] = TableQuery[BiSPieces]
val lootTable: TableQuery[LootPieces] = TableQuery[LootPieces]
val partiesTable: TableQuery[Parties] = TableQuery[Parties]
val playersTable: TableQuery[Players] = TableQuery[Players]
val usersTable: TableQuery[Users] = TableQuery[Users]
override val datasource: DataSource =
try {
val profile = DatabaseProfile.getSection(config)
val dataSourceConfig = DatabaseConnection.getDataSourceConfig(profile)
new HikariDataSource(dataSourceConfig)
} catch {
case exception: Exception =>
logger.error("exception during storage initialization", exception)
throw exception
}
// generic bis api
def deletePieceBiS(playerId: PlayerId, piece: Piece): Future[Int] =
@ -53,9 +53,8 @@ class DatabaseProfile(context: ExecutionContext, config: Config)
byPlayerId(playerId, insertPieceBiSById(piece))
// generic loot api
def deletePiece(playerId: PlayerId, piece: Piece): Future[Int] = {
// we don't really care here about loot
val loot = Loot(-1, piece, Instant.now, isFreeLoot = false)
def deletePiece(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean): Future[Int] = {
val loot = Loot(-1, piece, Instant.now, isFreeLoot)
byPlayerId(playerId, deletePieceById(loot))
}
@ -69,21 +68,23 @@ class DatabaseProfile(context: ExecutionContext, config: Config)
byPlayerId(playerId, insertPieceById(loot))
private def byPartyId[T](partyId: String, callback: Seq[Long] => Future[T]): Future[T] =
getPlayers(partyId).flatMap(callback)
getPlayers(partyId).flatMap(callback)(executionContext)
private def byPlayerId[T](playerId: PlayerId, callback: Long => Future[T]): Future[T] =
getPlayer(playerId).flatMap {
case Some(id) => callback(id)
case None => Future.failed(new Error(s"Could not find player $playerId"))
}
case None => Future.failed(DatabaseProfile.PlayerNotFound(playerId))
}(executionContext)
}
object DatabaseProfile {
def now: Long = Instant.now.toEpochMilli
case class PlayerNotFound(playerId: PlayerId) extends Exception(s"Could not find player $playerId")
def getSection(config: Config): Config = {
val section = config.getString("me.arcanis.ffxivbis.database.mode")
config.getConfig("me.arcanis.ffxivbis.database").getConfig(section)
config.getConfig(s"me.arcanis.ffxivbis.database.$section")
}
def now: Long = Instant.now.toEpochMilli
}

View File

@ -8,81 +8,78 @@
*/
package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.{Job, Loot, Piece, PieceType}
import slick.lifted.{ForeignKeyQuery, Index}
import java.time.Instant
import scala.concurrent.Future
trait LootProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
trait LootProfile extends DatabaseConnection {
case class LootRep(
lootId: Option[Long],
playerId: Long,
created: Long,
piece: String,
pieceType: String,
job: String,
isFreeLoot: Int
) {
def toLoot: Loot = Loot(
playerId,
Piece(piece, PieceType.withName(pieceType), Job.withName(job)),
Instant.ofEpochMilli(created),
isFreeLoot == 1
)
}
object LootRep {
def fromLoot(playerId: Long, loot: Loot): LootRep =
LootRep(
None,
playerId,
loot.timestamp.toEpochMilli,
loot.piece.piece,
loot.piece.pieceType.toString,
loot.piece.job.toString,
if (loot.isFreeLoot) 1 else 0
)
}
class LootPieces(tag: Tag) extends Table[LootRep](tag, "loot") {
def lootId: Rep[Long] = column[Long]("loot_id", O.AutoInc, O.PrimaryKey)
def playerId: Rep[Long] = column[Long]("player_id")
def created: Rep[Long] = column[Long]("created")
def piece: Rep[String] = column[String]("piece")
def pieceType: Rep[String] = column[String]("piece_type")
def job: Rep[String] = column[String]("job")
def isFreeLoot: Rep[Int] = column[Int]("is_free_loot")
def * =
(lootId.?, playerId, created, piece, pieceType, job, isFreeLoot) <> ((LootRep.apply _).tupled, LootRep.unapply)
def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] =
foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade)
def lootOwnerIdx: Index =
index("loot_owner_idx", playerId, unique = false)
}
private val loot: RowParser[Loot] =
(long("player_id") ~ str("piece") ~ str("piece_type")
~ str("job") ~ long("created") ~ int("is_free_loot"))
.map { case playerId ~ piece ~ pieceType ~ job ~ created ~ isFreeLoot =>
Loot(
playerId = playerId,
piece = Piece(
piece = piece,
pieceType = PieceType.withName(pieceType),
job = Job.withName(job)
),
timestamp = Instant.ofEpochMilli(created),
isFreeLoot = isFreeLoot == 1,
)
}
def deletePieceById(loot: Loot)(playerId: Long): Future[Int] =
db.run(pieceLoot(LootRep.fromLoot(playerId, loot)).map(_.lootId).max.result).flatMap {
case Some(id) => db.run(lootTable.filter(_.lootId === id).delete)
case _ => throw new IllegalArgumentException(s"Could not find piece $loot belong to $playerId")
withConnection { implicit conn =>
SQL("""delete from loot
| where loot_id in
| (
| select loot_id from loot
| where player_id = {player_id}
| and piece = {piece}
| and piece_type = {piece_type}
| and job = {job}
| and is_free_loot = {is_free_loot}
| limit 1
| )""".stripMargin)
.on(
"player_id" -> playerId,
"piece" -> loot.piece.piece,
"piece_type" -> loot.piece.pieceType.toString,
"job" -> loot.piece.job.toString,
"is_free_loot" -> loot.isFreeLootToInt
)
.executeUpdate()
}
def getPiecesById(playerId: Long): Future[Seq[Loot]] = getPiecesById(Seq(playerId))
def getPiecesById(playerIds: Seq[Long]): Future[Seq[Loot]] =
db.run(piecesLoot(playerIds).result).map(_.map(_.toLoot))
withConnection { implicit conn =>
SQL("""select * from loot where player_id in ({player_ids})""")
.on("player_ids" -> playerIds)
.executeQuery()
.as(loot.*)
}
def insertPieceById(loot: Loot)(playerId: Long): Future[Int] =
db.run(lootTable.insertOrUpdate(LootRep.fromLoot(playerId, loot)))
private def pieceLoot(piece: LootRep) =
piecesLoot(Seq(piece.playerId)).filter(_.piece === piece.piece)
private def piecesLoot(playerIds: Seq[Long]) =
lootTable.filter(_.playerId.inSet(playerIds.toSet))
withConnection { implicit conn =>
SQL("""insert into loot
| (player_id, piece, piece_type, job, created, is_free_loot)
| values
| ({player_id}, {piece}, {piece_type}, {job}, {created}, {is_free_loot})""".stripMargin)
.on(
"player_id" -> playerId,
"piece" -> loot.piece.piece,
"piece_type" -> loot.piece.pieceType.toString,
"job" -> loot.piece.job.toString,
"created" -> DatabaseProfile.now,
"is_free_loot" -> loot.isFreeLootToInt
)
.executeUpdate()
}
}

View File

@ -8,47 +8,41 @@
*/
package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.PartyDescription
import scala.concurrent.Future
trait PartyProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
trait PartyProfile extends DatabaseConnection {
case class PartyRep(partyId: Option[Long], partyName: String, partyAlias: Option[String]) {
def toDescription: PartyDescription = PartyDescription(partyName, partyAlias)
}
object PartyRep {
def fromDescription(party: PartyDescription, id: Option[Long]): PartyRep =
PartyRep(id, party.partyId, party.partyAlias)
}
class Parties(tag: Tag) extends Table[PartyRep](tag, "parties") {
def partyId: Rep[Long] = column[Long]("party_id", O.AutoInc, O.PrimaryKey)
def partyName: Rep[String] = column[String]("party_name")
def partyAlias: Rep[Option[String]] = column[Option[String]]("party_alias")
def * =
(partyId.?, partyName, partyAlias) <> ((PartyRep.apply _).tupled, PartyRep.unapply)
}
private val description: RowParser[PartyDescription] =
(str("party_name") ~ str("party_alias").?)
.map { case partyName ~ partyAlias =>
PartyDescription(
partyId = partyName,
partyAlias = partyAlias,
)
}
def getPartyDescription(partyId: String): Future[PartyDescription] =
db.run(
partyDescription(partyId).result.headOption.map(_.map(_.toDescription).getOrElse(PartyDescription.empty(partyId)))
)
def getUniquePartyId(partyId: String): Future[Option[Long]] =
db.run(partyDescription(partyId).map(_.partyId).result.headOption)
def insertPartyDescription(partyDescription: PartyDescription): Future[Int] =
getUniquePartyId(partyDescription.partyId).flatMap {
case Some(id) => db.run(partiesTable.update(PartyRep.fromDescription(partyDescription, Some(id))))
case _ => db.run(partiesTable.insertOrUpdate(PartyRep.fromDescription(partyDescription, None)))
withConnection { implicit conn =>
SQL("""select * from parties where party_name = {party_name}""")
.on("party_name" -> partyId)
.executeQuery()
.as(description.singleOpt)
.getOrElse(PartyDescription.empty(partyId))
}
private def partyDescription(partyId: String) =
partiesTable.filter(_.partyName === partyId)
def insertPartyDescription(partyDescription: PartyDescription): Future[Int] =
withConnection { implicit conn =>
SQL("""insert into parties
| (party_name, party_alias)
| values
| ({party_name}, {party_alias})
| on conflict (party_name) do update set
| party_alias = {party_alias}""".stripMargin)
.on("party_name" -> partyDescription.partyId, "party_alias" -> partyDescription.partyAlias)
.executeUpdate()
}
}

View File

@ -8,76 +8,97 @@
*/
package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.{BiS, Job, Player, PlayerId}
import scala.concurrent.Future
trait PlayersProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
trait PlayersProfile extends DatabaseConnection {
case class PlayerRep(
partyId: String,
playerId: Option[Long],
created: Long,
nick: String,
job: String,
link: Option[String],
priority: Int
) {
private val player: RowParser[Player] =
(long("player_id") ~ str("party_id") ~ str("job")
~ str("nick") ~ str("bis_link").? ~ int("priority").?)
.map { case playerId ~ partyId ~ job ~ nick ~ link ~ priority =>
Player(
id = playerId,
partyId = partyId,
job = Job.withName(job),
nick = nick,
bis = BiS.empty,
loot = Seq.empty,
link = link,
priority = priority.getOrElse(0),
)
}
def toPlayer: Player =
Player(playerId.getOrElse(-1), partyId, Job.withName(job), nick, BiS.empty, Seq.empty, link, priority)
}
object PlayerRep {
def fromPlayer(player: Player, id: Option[Long]): PlayerRep =
PlayerRep(player.partyId, id, DatabaseProfile.now, player.nick, player.job.toString, player.link, player.priority)
}
class Players(tag: Tag) extends Table[PlayerRep](tag, "players") {
def partyId: Rep[String] = column[String]("party_id")
def playerId: Rep[Long] = column[Long]("player_id", O.AutoInc, O.PrimaryKey)
def created: Rep[Long] = column[Long]("created")
def nick: Rep[String] = column[String]("nick")
def job: Rep[String] = column[String]("job")
def bisLink: Rep[Option[String]] = column[Option[String]]("bis_link")
def priority: Rep[Int] = column[Int]("priority", O.Default(1))
def * =
(partyId, playerId.?, created, nick, job, bisLink, priority) <> ((PlayerRep.apply _).tupled, PlayerRep.unapply)
}
def deletePlayer(playerId: PlayerId): Future[Int] = db.run(player(playerId).delete)
def getParty(partyId: String): Future[Map[Long, Player]] =
db.run(players(partyId).result)
.map(_.foldLeft(Map.empty[Long, Player]) {
case (acc, p @ PlayerRep(_, Some(id), _, _, _, _, _)) => acc + (id -> p.toPlayer)
case (acc, _) => acc
})
def getPlayer(playerId: PlayerId): Future[Option[Long]] =
db.run(player(playerId).map(_.playerId).result.headOption)
def getPlayerFull(playerId: PlayerId): Future[Option[Player]] =
db.run(player(playerId).result.headOption.map(_.map(_.toPlayer)))
def getPlayers(partyId: String): Future[Seq[Long]] =
db.run(players(partyId).map(_.playerId).result)
def insertPlayer(playerObj: Player): Future[Int] =
getPlayer(playerObj.playerId).flatMap {
case Some(id) => db.run(playersTable.update(PlayerRep.fromPlayer(playerObj, Some(id))))
case _ => db.run(playersTable.insertOrUpdate(PlayerRep.fromPlayer(playerObj, None)))
def deletePlayer(playerId: PlayerId): Future[Int] =
withConnection { implicit conn =>
SQL("""delete from players
| where party_id = {party_id}
| and nick = {nick}
| and job = {job}""".stripMargin)
.on("party_id" -> playerId.partyId, "nick" -> playerId.nick, "job" -> playerId.job.toString)
.executeUpdate()
}
private def player(playerId: PlayerId) =
playersTable
.filter(_.partyId === playerId.partyId)
.filter(_.job === playerId.job.toString)
.filter(_.nick === playerId.nick)
def getParty(partyId: String): Future[Map[Long, Player]] =
withConnection { implicit conn =>
SQL("""select * from players where party_id = {party_id}""")
.on("party_id" -> partyId)
.executeQuery()
.as(player.*)
.map(p => p.id -> p)
.toMap
}
def getPlayer(playerId: PlayerId): Future[Option[Long]] =
withConnection { implicit conn =>
SQL("""select player_id from players
| where party_id = {party_id}
| and nick = {nick}
| and job = {job}""".stripMargin)
.on("party_id" -> playerId.partyId, "nick" -> playerId.nick, "job" -> playerId.job.toString)
.executeQuery()
.as(scalar[Long].singleOpt)
}
def getPlayerFull(playerId: PlayerId): Future[Option[Player]] =
withConnection { implicit conn =>
SQL("""select * from players
| where party_id = {party_id}
| and nick = {nick}
| and job = {job}""".stripMargin)
.on("party_id" -> playerId.partyId, "nick" -> playerId.nick, "job" -> playerId.job.toString)
.executeQuery()
.as(player.singleOpt)
}
def getPlayers(partyId: String): Future[Seq[Long]] =
withConnection { implicit conn =>
SQL("""select player_id from players where party_id = {party_id}""")
.on("party_id" -> partyId)
.executeQuery()
.as(scalar[Long].*)
}
def insertPlayer(player: Player): Future[Int] =
withConnection { implicit conn =>
SQL("""insert into players
| (party_id, created, job, nick, bis_link, priority)
| values
| ({party_id}, {created}, {job}, {nick}, {link}, {priority})
| on conflict (party_id, nick, job) do update set
| bis_link = {link}, priority = {priority}""".stripMargin)
.on(
"party_id" -> player.partyId,
"created" -> DatabaseProfile.now,
"job" -> player.job.toString,
"nick" -> player.nick,
"link" -> player.link,
"priority" -> player.priority
)
.executeUpdate()
}
private def players(partyId: String) =
playersTable.filter(_.partyId === partyId)
}

View File

@ -8,64 +8,67 @@
*/
package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.{Permission, User}
import slick.lifted.{Index, PrimaryKey}
import scala.concurrent.Future
trait UsersProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
trait UsersProfile extends DatabaseConnection {
case class UserRep(partyId: String, userId: Option[Long], username: String, password: String, permission: String) {
def toUser: User = User(partyId, username, password, Permission.withName(permission))
}
object UserRep {
def fromUser(user: User, id: Option[Long]): UserRep =
UserRep(user.partyId, id, user.username, user.password, user.permission.toString)
}
class Users(tag: Tag) extends Table[UserRep](tag, "users") {
def partyId: Rep[String] = column[String]("party_id")
def userId: Rep[Long] = column[Long]("user_id", O.AutoInc, O.PrimaryKey)
def username: Rep[String] = column[String]("username")
def password: Rep[String] = column[String]("password")
def permission: Rep[String] = column[String]("permission")
def * =
(partyId, userId.?, username, password, permission) <> ((UserRep.apply _).tupled, UserRep.unapply)
def pk: PrimaryKey = primaryKey("users_username_idx", (partyId, username))
def usersUsernameIdx: Index =
index("users_username_idx", (partyId, username), unique = true)
}
private val user: RowParser[User] =
(str("party_id") ~ str("username") ~ str("password") ~ str("permission"))
.map { case partyId ~ username ~ password ~ permission =>
User(
partyId = partyId,
username = username,
password = password,
permission = Permission.withName(permission),
)
}
def deleteUser(partyId: String, username: String): Future[Int] =
db.run(
user(partyId, Some(username))
.filter(_.permission =!= Permission.admin.toString) // we do not allow to remove admins
.delete
)
def exists(partyId: String): Future[Boolean] =
db.run(user(partyId, None).exists.result)
def getUser(partyId: String, username: String): Future[Option[User]] =
db.run(user(partyId, Some(username)).result.headOption).map(_.map(_.toUser))
def getUsers(partyId: String): Future[Seq[User]] =
db.run(user(partyId, None).result).map(_.map(_.toUser))
def insertUser(userObj: User): Future[Int] =
db.run(user(userObj.partyId, Some(userObj.username)).map(_.userId).result.headOption).flatMap {
case Some(id) => db.run(usersTable.insertOrUpdate(UserRep.fromUser(userObj, Some(id))))
case _ => db.run(usersTable.insertOrUpdate(UserRep.fromUser(userObj, None)))
withConnection { implicit conn =>
SQL("""delete from users
| where party_id = {party_id}
| and username = {username}
| and permission <> {admin}""".stripMargin)
.on("party_id" -> partyId, "username" -> username, "admin" -> Permission.admin.toString)
.executeUpdate()
}
private def user(partyId: String, username: Option[String]) =
usersTable
.filter(_.partyId === partyId)
.filterIf(username.isDefined)(_.username === username.orNull)
def exists(partyId: String): Future[Boolean] = getUsers(partyId).map(_.nonEmpty)(executionContext)
def getUser(partyId: String, username: String): Future[Option[User]] =
withConnection { implicit conn =>
SQL("""select * from users where party_id = {party_id} and username = {username}""")
.on("party_id" -> partyId, "username" -> username)
.executeQuery()
.as(user.singleOpt)
}
def getUsers(partyId: String): Future[Seq[User]] =
withConnection { implicit conn =>
SQL("""select * from users where party_id = {party_id}""")
.on("party_id" -> partyId)
.executeQuery()
.as(user.*)
}
def insertUser(user: User): Future[Int] =
withConnection { implicit conn =>
SQL("""insert into users
| (party_id, username, password, permission)
| values
| ({party_id}, {username}, {password}, {permission})
| on conflict (party_id, username) do update set
| password = {password}, permission = {permission}""".stripMargin)
.on(
"party_id" -> user.partyId,
"username" -> user.username,
"password" -> user.password,
"permission" -> user.permission.toString
)
.executeUpdate()
}
}

View File

@ -9,11 +9,13 @@
package me.arcanis.ffxivbis.utils
import akka.util.Timeout
import com.typesafe.config.Config
import java.time.Duration
import java.util.concurrent.TimeUnit
import scala.concurrent.duration.FiniteDuration
import scala.language.implicitConversions
import scala.util.Try
object Implicits {
@ -27,4 +29,10 @@ object Implicits {
implicit def getTimeout(duration: Duration): Timeout =
FiniteDuration(duration.toNanos, TimeUnit.NANOSECONDS)
implicit class ConfigExtension(config: Config) {
def getOptString(path: String): Option[String] =
Try(config.getString(path)).toOption.filter(_.nonEmpty)
}
}

View File

@ -17,7 +17,7 @@ object Settings {
}
def clearDatabase(config: Config): Unit =
config.getString("me.arcanis.ffxivbis.database.sqlite.db.url").split(":")
config.getString("me.arcanis.ffxivbis.database.sqlite.jdbcUrl").split(":")
.lastOption.foreach { databasePath =>
val databaseFile = new File(databasePath)
if (databaseFile.exists)
@ -25,5 +25,5 @@ object Settings {
}
def randomDatabasePath: String = File.createTempFile("ffxivdb-",".db").toPath.toString
def withRandomDatabase: Config =
config(Map("me.arcanis.ffxivbis.database.sqlite.db.url" -> s"jdbc:sqlite:$randomDatabasePath"))
config(Map("me.arcanis.ffxivbis.database.sqlite.jdbcUrl" -> s"jdbc:sqlite:$randomDatabasePath"))
}

View File

@ -12,8 +12,7 @@ import me.arcanis.ffxivbis.messages.{AddPlayer, AddUser}
import me.arcanis.ffxivbis.models.{BiS, Job}
import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.Database
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.service.database.{Database, Migration}
import me.arcanis.ffxivbis.utils.Compare
import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.matchers.should.Matchers

View File

@ -10,8 +10,7 @@ import com.typesafe.config.Config
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.messages.{AddPlayer, AddUser}
import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.database.Database
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.service.database.{Database, Migration}
import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike

View File

@ -12,8 +12,7 @@ import me.arcanis.ffxivbis.messages.AddUser
import me.arcanis.ffxivbis.models.PartyDescription
import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.Database
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.service.database.{Database, Migration}
import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike

View File

@ -11,8 +11,7 @@ import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.messages.{AddPlayer, AddUser}
import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.Database
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.service.database.{Database, Migration}
import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike

View File

@ -8,8 +8,7 @@ import akka.testkit.TestKit
import com.typesafe.config.Config
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.database.Database
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.service.database.{Database, Migration}
import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike

View File

@ -4,7 +4,6 @@ import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
import akka.actor.typed.scaladsl.AskPattern.Askable
import me.arcanis.ffxivbis.messages.{AddPieceToBis, AddPlayer, GetBiS, RemovePieceFromBiS}
import me.arcanis.ffxivbis.models._
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.utils.Compare
import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.wordspec.AnyWordSpecLike
@ -85,6 +84,6 @@ class DatabaseBiSHandlerTest extends ScalaTestWithActorTestKit(Settings.withRand
}
private def partyBiSCompare[T](party: Seq[T], bis: Seq[Piece]): Boolean =
Compare.seqEquals(party.foldLeft(Seq.empty[Piece]){ case (acc, player) => acc ++ player.asInstanceOf[Player].bis.pieces }, bis)
private def partyBiSCompare(party: Seq[Player], bis: Seq[Piece]): Boolean =
Compare.seqEquals(party.foldLeft(Seq.empty[Piece]){ case (acc, player) => acc ++ player.bis.pieces }, bis)
}

View File

@ -2,9 +2,9 @@ package me.arcanis.ffxivbis.service.database
import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
import akka.actor.typed.scaladsl.AskPattern.Askable
import ch.qos.logback.core.util.FixedDelay
import me.arcanis.ffxivbis.messages.{AddPieceTo, AddPlayer, GetLoot, RemovePieceFrom}
import me.arcanis.ffxivbis.models._
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.utils.Compare
import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.wordspec.AnyWordSpecLike
@ -58,7 +58,7 @@ class DatabaseLootHandlerTest extends ScalaTestWithActorTestKit(Settings.withRan
"remove loot" in {
val updateProbe = testKit.createTestProbe[Unit]()
database ! RemovePieceFrom(Fixtures.playerEmpty.playerId, Fixtures.lootBody, updateProbe.ref)
database ! RemovePieceFrom(Fixtures.playerEmpty.playerId, Fixtures.lootBody, isFreeLoot = false, updateProbe.ref)
updateProbe.expectMessage(askTimeout, ())
val newLoot = Fixtures.loot.filterNot(_ == Fixtures.lootBody)
@ -87,10 +87,24 @@ class DatabaseLootHandlerTest extends ScalaTestWithActorTestKit(Settings.withRan
partyLootCompare(party, Fixtures.loot ++ Fixtures.loot) shouldEqual true
}
"remove only one piece" in {
val updateProbe = testKit.createTestProbe[Unit]()
database ! RemovePieceFrom(Fixtures.playerEmpty.playerId, Fixtures.lootBody, isFreeLoot = false, updateProbe.ref)
updateProbe.expectMessage(askTimeout, ())
val probe = testKit.createTestProbe[Seq[Player]]()
database ! GetLoot(Fixtures.playerEmpty.partyId, None, probe.ref)
val party = probe.expectMessageType[Seq[Player]](askTimeout)
val player = party.filter(_.playerId == Fixtures.playerEmpty.playerId)
player should not be empty
player.flatMap(_.loot).map(_.piece) should contain (Fixtures.lootBody)
}
}
private def partyLootCompare[T](party: Seq[T], loot: Seq[Piece]): Boolean =
private def partyLootCompare(party: Seq[Player], loot: Seq[Piece]): Boolean =
Compare.seqEquals(party.foldLeft(Seq.empty[Piece]){ case (acc, player) =>
acc ++ player.asInstanceOf[Player].loot.map(_.piece)
acc ++ player.loot.map(_.piece)
}, loot)
}

View File

@ -3,7 +3,6 @@ package me.arcanis.ffxivbis.service.database
import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
import me.arcanis.ffxivbis.messages.{AddPlayer, GetParty, GetPlayer, RemovePlayer}
import me.arcanis.ffxivbis.models._
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.utils.Compare
import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.wordspec.AnyWordSpecLike

View File

@ -3,7 +3,6 @@ package me.arcanis.ffxivbis.service.database
import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
import me.arcanis.ffxivbis.messages.{AddUser, DeleteUser, GetUser, GetUsers}
import me.arcanis.ffxivbis.models.User
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.utils.Compare
import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.wordspec.AnyWordSpecLike