Add and change custom fields

This commit is contained in:
Eike Kettner 2020-11-16 12:39:49 +01:00
parent 248ad04dd0
commit 62313ab03a
12 changed files with 388 additions and 30 deletions

View File

@ -1,25 +1,80 @@
package docspell.backend.ops package docspell.backend.ops
import cats.effect.{Effect, Resource} import cats.data.OptionT
import cats.effect._
import docspell.backend.ops.OCustomFields.CustomFieldData
import docspell.backend.ops.OCustomFields.NewCustomField
import docspell.common._ import docspell.common._
import docspell.store.AddResult
import docspell.store.Store import docspell.store.Store
import docspell.store.UpdateResult
import docspell.store.queries.QCustomField
import docspell.store.records.RCustomField import docspell.store.records.RCustomField
import docspell.store.records.RCustomFieldValue
import doobie._
trait OCustomFields[F[_]] { trait OCustomFields[F[_]] {
def findAll(coll: Ident): F[Vector[RCustomField]] def findAll(coll: Ident, nameQuery: Option[String]): F[Vector[CustomFieldData]]
def findById(coll: Ident, fieldId: Ident): F[Option[CustomFieldData]]
def create(field: NewCustomField): F[AddResult]
def change(field: RCustomField): F[UpdateResult]
def delete(coll: Ident, fieldIdOrName: Ident): F[UpdateResult]
} }
object OCustomFields { object OCustomFields {
type CustomFieldData = QCustomField.CustomFieldData
val CustomFieldData = QCustomField.CustomFieldData
case class NewCustomField(
name: Ident,
label: Option[String],
ftype: CustomFieldType,
cid: Ident
)
def apply[F[_]: Effect]( def apply[F[_]: Effect](
store: Store[F] store: Store[F]
): Resource[F, OCustomFields[F]] = ): Resource[F, OCustomFields[F]] =
Resource.pure[F, OCustomFields[F]](new OCustomFields[F] { Resource.pure[F, OCustomFields[F]](new OCustomFields[F] {
def findAll(coll: Ident): F[Vector[RCustomField]] = def findAll(coll: Ident, nameQuery: Option[String]): F[Vector[CustomFieldData]] =
store.transact(RCustomField.findAll(coll)) store.transact(QCustomField.findAllLike(coll, nameQuery))
def findById(coll: Ident, field: Ident): F[Option[CustomFieldData]] =
store.transact(QCustomField.findById(field, coll))
def create(field: NewCustomField): F[AddResult] = {
val exists = RCustomField.exists(field.name, field.cid)
val insert = for {
id <- Ident.randomId[ConnectionIO]
now <- Timestamp.current[ConnectionIO]
rec = RCustomField(id, field.name, field.label, field.cid, field.ftype, now)
n <- RCustomField.insert(rec)
} yield n
store.add(insert, exists)
}
def change(field: RCustomField): F[UpdateResult] =
UpdateResult.fromUpdate(store.transact(RCustomField.update(field)))
def delete(coll: Ident, fieldIdOrName: Ident): F[UpdateResult] = {
val update =
for {
field <- OptionT(RCustomField.findByIdOrName(fieldIdOrName, coll))
n <- OptionT.liftF(RCustomFieldValue.deleteByField(field.id))
k <- OptionT.liftF(RCustomField.deleteById(field.id, coll))
} yield n + k
UpdateResult.fromUpdate(store.transact(update.getOrElse(0)))
}
}) })
} }

View File

@ -42,7 +42,6 @@ object CustomFieldType {
def unsafe(str: String): CustomFieldType = def unsafe(str: String): CustomFieldType =
fromString(str).fold(sys.error, identity) fromString(str).fold(sys.error, identity)
implicit val jsonDecoder: Decoder[CustomFieldType] = implicit val jsonDecoder: Decoder[CustomFieldType] =
Decoder.decodeString.emap(fromString) Decoder.decodeString.emap(fromString)

View File

@ -3210,6 +3210,8 @@ paths:
Get all custom fields defined for the current collective. Get all custom fields defined for the current collective.
security: security:
- authTokenHeader: [] - authTokenHeader: []
parameters:
- $ref: "#/components/parameters/q"
responses: responses:
200: 200:
description: Ok description: Ok
@ -3217,6 +3219,79 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/CustomFieldList" $ref: "#/components/schemas/CustomFieldList"
post:
tags: [ Custom Fields ]
summary: Create a new custom field
description: |
Creates a new custom field.
security:
- authTokenHeader: []
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/NewCustomField"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/customfields/{id}:
get:
tags: [ Custom Fields ]
summary: Get details about a custom field.
description: |
Returns the details about a custom field.
security:
- authTokenHeader: []
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/CustomField"
put:
tags: [ Custom Fields ]
summary: Change a custom field
description: |
Change properties of a custom field.
Changing the label has no further impliciations, since it is
only used for displaying. The name and type on the other hand
have consequences: name must be unique and the type determines
how the value is stored internally.
security:
- authTokenHeader: []
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/NewCustomField"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
delete:
tags: [ Custom Fields ]
summary: Deletes a custom field.
description: |
Deletes the custom field and all its relations.
security:
- authTokenHeader: []
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
components: components:
@ -3310,6 +3385,22 @@ components:
items: items:
$ref: "#/components/schemas/CustomField" $ref: "#/components/schemas/CustomField"
NewCustomField:
description: |
Data for creating a custom field.
required:
- name
- ftype
properties:
name:
type: string
format: ident
label:
type: string
ftype:
type: string
format: customfieldtype
CustomField: CustomField:
description: | description: |
A custom field definition. A custom field definition.
@ -3317,6 +3408,7 @@ components:
- id - id
- name - name
- ftype - ftype
- usages
- created - created
properties: properties:
id: id:
@ -3324,9 +3416,15 @@ components:
format: ident format: ident
name: name:
type: string type: string
format: ident
label:
type: string
ftype: ftype:
type: string type: string
format: customfieldtype format: customfieldtype
usages:
type: integer
format: int32
created: created:
type: integer type: integer
format: date-time format: date-time

View File

@ -1,18 +1,25 @@
package docspell.restserver.routes package docspell.restserver.routes
import cats.data.OptionT
import cats.effect._ import cats.effect._
import cats.implicits._ import cats.implicits._
import docspell.backend.BackendApp import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken import docspell.backend.auth.AuthToken
import docspell.backend.ops.OCustomFields
import docspell.backend.ops.OCustomFields.CustomFieldData
import docspell.common._
import docspell.restapi.model._ import docspell.restapi.model._
import docspell.restserver.conv.Conversions
import docspell.restserver.http4s._ import docspell.restserver.http4s._
import docspell.store.AddResult
import docspell.store.UpdateResult
import docspell.store.records.RCustomField
import org.http4s.HttpRoutes import org.http4s.HttpRoutes
//import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.CirceEntityEncoder._ import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl import org.http4s.dsl.Http4sDsl
import docspell.store.records.RCustomField
object CustomFieldRoutes { object CustomFieldRoutes {
@ -21,15 +28,78 @@ object CustomFieldRoutes {
import dsl._ import dsl._
HttpRoutes.of { HttpRoutes.of {
case GET -> Root => case GET -> Root :? QueryParam.QueryOpt(param) =>
for { for {
fs <- backend.customFields.findAll(user.account.collective) fs <- backend.customFields.findAll(user.account.collective, param.map(_.q))
res <- Ok(CustomFieldList(fs.map(convertField).toList)) res <- Ok(CustomFieldList(fs.map(convertField).toList))
} yield res } yield res
case req @ POST -> Root =>
for {
data <- req.as[NewCustomField]
res <- backend.customFields.create(convertNewField(user, data))
resp <- Ok(convertResult(res))
} yield resp
case GET -> Root / Ident(id) =>
(for {
field <- OptionT(backend.customFields.findById(user.account.collective, id))
res <- OptionT.liftF(Ok(convertField(field)))
} yield res).getOrElseF(NotFound(BasicResult(false, "Not found")))
case req @ PUT -> Root / Ident(id) =>
for {
data <- req.as[NewCustomField]
res <- backend.customFields.change(convertChangeField(id, user, data))
resp <- Ok(convertResult(res))
} yield resp
case DELETE -> Root / Ident(id) =>
for {
res <- backend.customFields.delete(id, user.account.collective)
resp <- Ok(convertResult(res))
} yield resp
} }
} }
private def convertResult(r: AddResult): BasicResult =
Conversions.basicResult(r, "New field created.")
private def convertField(f: RCustomField): CustomField = private def convertResult(r: UpdateResult): BasicResult =
CustomField(f.id, f.name, f.ftype, f.created) Conversions.basicResult(r, "Field updated.")
private def convertChangeField(
id: Ident,
user: AuthToken,
in: NewCustomField
): RCustomField =
RCustomField(
id,
in.name,
in.label,
user.account.collective,
in.ftype,
Timestamp.Epoch
)
private def convertNewField(
user: AuthToken,
in: NewCustomField
): OCustomFields.NewCustomField =
OCustomFields.NewCustomField(
in.name,
in.label,
in.ftype,
user.account.collective
)
private def convertField(f: CustomFieldData): CustomField =
CustomField(
f.field.id,
f.field.name,
f.field.label,
f.field.ftype,
f.usageCount,
f.field.created
)
} }

View File

@ -1,6 +1,7 @@
CREATE TABLE "custom_field" ( CREATE TABLE "custom_field" (
"id" varchar(254) not null primary key, "id" varchar(254) not null primary key,
"name" varchar(254) not null, "name" varchar(254) not null,
"label" varchar(254),
"cid" varchar(254) not null, "cid" varchar(254) not null,
"ftype" varchar(100) not null, "ftype" varchar(100) not null,
"created" timestamp not null, "created" timestamp not null,

View File

@ -1,5 +1,7 @@
package docspell.store.impl package docspell.store.impl
import cats.data.NonEmptyList
import docspell.common.Timestamp import docspell.common.Timestamp
import doobie._ import doobie._
@ -7,6 +9,12 @@ import doobie.implicits._
trait DoobieSyntax { trait DoobieSyntax {
def groupBy(c0: Column, cs: Column*): Fragment =
groupBy(NonEmptyList.of(c0, cs: _*))
def groupBy(cs: NonEmptyList[Column]): Fragment =
fr" GROUP BY (" ++ commas(cs.toList.map(_.f)) ++ fr")"
def coalesce(f0: Fragment, fs: Fragment*): Fragment = def coalesce(f0: Fragment, fs: Fragment*): Fragment =
sql" coalesce(" ++ commas(f0 :: fs.toList) ++ sql") " sql" coalesce(" ++ commas(f0 :: fs.toList) ++ sql") "

View File

@ -0,0 +1,64 @@
package docspell.store.queries
import cats.data.NonEmptyList
import cats.implicits._
import docspell.common._
import docspell.store.impl.Column
import docspell.store.impl.Implicits._
import docspell.store.records._
import doobie._
import doobie.implicits._
object QCustomField {
case class CustomFieldData(field: RCustomField, usageCount: Int)
def findAllLike(
coll: Ident,
nameQuery: Option[String]
): ConnectionIO[Vector[CustomFieldData]] =
findFragment(coll, nameQuery, None).query[CustomFieldData].to[Vector]
def findById(field: Ident, collective: Ident): ConnectionIO[Option[CustomFieldData]] =
findFragment(collective, None, field.some).query[CustomFieldData].option
private def findFragment(
coll: Ident,
nameQuery: Option[String],
fieldId: Option[Ident]
): Fragment = {
val fId = RCustomField.Columns.id.prefix("f")
val fColl = RCustomField.Columns.cid.prefix("f")
val fName = RCustomField.Columns.name.prefix("f")
val fLabel = RCustomField.Columns.label.prefix("f")
val vField = RCustomFieldValue.Columns.field.prefix("v")
val join = RCustomField.table ++ fr"f LEFT OUTER JOIN" ++
RCustomFieldValue.table ++ fr"v ON" ++ fId.is(vField)
val cols = RCustomField.Columns.all.map(_.prefix("f")) :+ Column("COUNT(v.id)")
val nameCond = nameQuery.map(QueryWildcard.apply) match {
case Some(q) =>
or(fName.lowerLike(q), fLabel.lowerLike(q))
case None =>
Fragment.empty
}
val fieldCond = fieldId match {
case Some(id) =>
fId.is(id)
case None =>
Fragment.empty
}
val cond = and(fColl.is(coll), nameCond, fieldCond)
val group = NonEmptyList.fromList(RCustomField.Columns.all) match {
case Some(nel) => groupBy(nel.map(_.prefix("f")))
case None => Fragment.empty
}
selectSimple(cols, join, cond) ++ group
}
}

View File

@ -342,8 +342,8 @@ object QItem {
TagItemName.itemsWithTagOrCategory(q.tagsExclude, q.tagCategoryExcl) TagItemName.itemsWithTagOrCategory(q.tagsExclude, q.tagCategoryExcl)
val iFolder = IC.folder.prefix("i") val iFolder = IC.folder.prefix("i")
val name = q.name.map(_.toLowerCase).map(queryWildcard) val name = q.name.map(_.toLowerCase).map(QueryWildcard.apply)
val allNames = q.allNames.map(_.toLowerCase).map(queryWildcard) val allNames = q.allNames.map(_.toLowerCase).map(QueryWildcard.apply)
val cond = and( val cond = and(
IC.cid.prefix("i").is(q.account.collective), IC.cid.prefix("i").is(q.account.collective),
IC.state.prefix("i").isOneOf(q.states), IC.state.prefix("i").isOneOf(q.states),
@ -516,8 +516,9 @@ object QItem {
rn <- QAttachment.deleteItemAttachments(store)(itemId, collective) rn <- QAttachment.deleteItemAttachments(store)(itemId, collective)
tn <- store.transact(RTagItem.deleteItemTags(itemId)) tn <- store.transact(RTagItem.deleteItemTags(itemId))
mn <- store.transact(RSentMail.deleteByItem(itemId)) mn <- store.transact(RSentMail.deleteByItem(itemId))
cf <- store.transact(RCustomFieldValue.deleteByItem(itemId))
n <- store.transact(RItem.deleteByIdAndCollective(itemId, collective)) n <- store.transact(RItem.deleteByIdAndCollective(itemId, collective))
} yield tn + rn + n + mn } yield tn + rn + n + mn + cf
private def findByFileIdsQuery( private def findByFileIdsQuery(
fileMetaIds: NonEmptyList[Ident], fileMetaIds: NonEmptyList[Ident],
@ -618,18 +619,6 @@ object QItem {
.to[Vector] .to[Vector]
} }
private def queryWildcard(value: String): String = {
def prefix(n: String) =
if (n.startsWith("*")) s"%${n.substring(1)}"
else n
def suffix(n: String) =
if (n.endsWith("*")) s"${n.dropRight(1)}%"
else n
prefix(suffix(value))
}
final case class NameAndNotes( final case class NameAndNotes(
id: Ident, id: Ident,
collective: Ident, collective: Ident,

View File

@ -0,0 +1,17 @@
package docspell.store.queries
object QueryWildcard {
def apply(value: String): String = {
def prefix(n: String) =
if (n.startsWith("*")) s"%${n.substring(1)}"
else n
def suffix(n: String) =
if (n.endsWith("*")) s"${n.dropRight(1)}%"
else n
prefix(suffix(value))
}
}

View File

@ -9,7 +9,8 @@ import doobie.implicits._
case class RCustomField( case class RCustomField(
id: Ident, id: Ident,
name: String, name: Ident,
label: Option[String],
cid: Ident, cid: Ident,
ftype: CustomFieldType, ftype: CustomFieldType,
created: Timestamp created: Timestamp
@ -23,22 +24,49 @@ object RCustomField {
val id = Column("id") val id = Column("id")
val name = Column("name") val name = Column("name")
val label = Column("label")
val cid = Column("cid") val cid = Column("cid")
val ftype = Column("ftype") val ftype = Column("ftype")
val created = Column("created") val created = Column("created")
val all = List(id, name, cid, ftype, created) val all = List(id, name, label, cid, ftype, created)
} }
import Columns._
def insert(value: RCustomField): ConnectionIO[Int] = { def insert(value: RCustomField): ConnectionIO[Int] = {
val sql = insertRow( val sql = insertRow(
table, table,
Columns.all, Columns.all,
fr"${value.id},${value.name},${value.cid},${value.ftype},${value.created}" fr"${value.id},${value.name},${value.label},${value.cid},${value.ftype},${value.created}"
) )
sql.update.run sql.update.run
} }
def exists(fname: Ident, coll: Ident): ConnectionIO[Boolean] =
???
def findById(fid: Ident, coll: Ident): ConnectionIO[Option[RCustomField]] =
selectSimple(all, table, and(id.is(fid), cid.is(coll))).query[RCustomField].option
def findByIdOrName(idOrName: Ident, coll: Ident): ConnectionIO[Option[RCustomField]] =
selectSimple(all, table, and(cid.is(coll), or(id.is(idOrName), name.is(idOrName))))
.query[RCustomField]
.option
def deleteById(fid: Ident, coll: Ident): ConnectionIO[Int] =
deleteFrom(table, and(id.is(fid), cid.is(coll))).update.run
def findAll(coll: Ident): ConnectionIO[Vector[RCustomField]] = def findAll(coll: Ident): ConnectionIO[Vector[RCustomField]] =
selectSimple(Columns.all, table, Columns.cid.is(coll)).query[RCustomField].to[Vector] selectSimple(all, table, cid.is(coll)).query[RCustomField].to[Vector]
def update(value: RCustomField): ConnectionIO[Int] =
updateRow(
table,
and(id.is(value.id), cid.is(value.cid)),
commas(
name.setTo(value.name),
label.setTo(value.label),
ftype.setTo(value.ftype)
)
).update.run
} }

View File

@ -39,4 +39,12 @@ object RCustomFieldValue {
sql.update.run sql.update.run
} }
def countField(fieldId: Ident): ConnectionIO[Int] =
selectCount(Columns.id, table, Columns.field.is(fieldId)).query[Int].unique
def deleteByField(fieldId: Ident): ConnectionIO[Int] =
deleteFrom(table, Columns.field.is(fieldId)).update.run
def deleteByItem(item: Ident): ConnectionIO[Int] =
deleteFrom(table, Columns.itemId.is(item)).update.run
} }

View File

@ -0,0 +1,21 @@
package docspell.store.queries
import minitest._
object QueryWildcardTest extends SimpleTestSuite {
test("replace prefix") {
assertEquals("%name", QueryWildcard("*name"))
assertEquals("%some more", QueryWildcard("*some more"))
}
test("replace suffix") {
assertEquals("name%", QueryWildcard("name*"))
assertEquals("some other name%", QueryWildcard("some other name*"))
}
test("replace both sides") {
assertEquals("%name%", QueryWildcard("*name*"))
assertEquals("%some other name%", QueryWildcard("*some other name*"))
}
}