From 62313ab03ad26531b9397c06c1a56660c74f05b0 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 16 Nov 2020 12:39:49 +0100 Subject: [PATCH] Add and change custom fields --- .../docspell/backend/ops/OCustomFields.scala | 63 +++++++++++- .../docspell/common/CustomFieldType.scala | 1 - .../src/main/resources/docspell-openapi.yml | 98 +++++++++++++++++++ .../restserver/routes/CustomFieldRoutes.scala | 82 ++++++++++++++-- .../postgresql/V1.13.0__custom_fields.sql | 1 + .../docspell/store/impl/DoobieSyntax.scala | 8 ++ .../docspell/store/queries/QCustomField.scala | 64 ++++++++++++ .../scala/docspell/store/queries/QItem.scala | 19 +--- .../store/queries/QueryWildcard.scala | 17 ++++ .../docspell/store/records/RCustomField.scala | 36 ++++++- .../store/records/RCustomFieldValue.scala | 8 ++ .../store/queries/QueryWildcardTest.scala | 21 ++++ 12 files changed, 388 insertions(+), 30 deletions(-) create mode 100644 modules/store/src/main/scala/docspell/store/queries/QCustomField.scala create mode 100644 modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala create mode 100644 modules/store/src/test/scala/docspell/store/queries/QueryWildcardTest.scala diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala index e8058b17..70bd0f49 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala @@ -1,25 +1,80 @@ 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.store.AddResult import docspell.store.Store +import docspell.store.UpdateResult +import docspell.store.queries.QCustomField import docspell.store.records.RCustomField +import docspell.store.records.RCustomFieldValue + +import doobie._ 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 { + type CustomFieldData = QCustomField.CustomFieldData + val CustomFieldData = QCustomField.CustomFieldData + + case class NewCustomField( + name: Ident, + label: Option[String], + ftype: CustomFieldType, + cid: Ident + ) + def apply[F[_]: Effect]( store: Store[F] ): Resource[F, OCustomFields[F]] = Resource.pure[F, OCustomFields[F]](new OCustomFields[F] { - def findAll(coll: Ident): F[Vector[RCustomField]] = - store.transact(RCustomField.findAll(coll)) + def findAll(coll: Ident, nameQuery: Option[String]): F[Vector[CustomFieldData]] = + 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))) + } }) } diff --git a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala index 021bbe28..9ff08bbf 100644 --- a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala +++ b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala @@ -42,7 +42,6 @@ object CustomFieldType { def unsafe(str: String): CustomFieldType = fromString(str).fold(sys.error, identity) - implicit val jsonDecoder: Decoder[CustomFieldType] = Decoder.decodeString.emap(fromString) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 8acefbec..ea6addb8 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -3210,6 +3210,8 @@ paths: Get all custom fields defined for the current collective. security: - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/q" responses: 200: description: Ok @@ -3217,6 +3219,79 @@ paths: application/json: schema: $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: @@ -3310,6 +3385,22 @@ components: items: $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: description: | A custom field definition. @@ -3317,6 +3408,7 @@ components: - id - name - ftype + - usages - created properties: id: @@ -3324,9 +3416,15 @@ components: format: ident name: type: string + format: ident + label: + type: string ftype: type: string format: customfieldtype + usages: + type: integer + format: int32 created: type: integer format: date-time diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala index 14313262..ceab2aea 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala @@ -1,18 +1,25 @@ package docspell.restserver.routes +import cats.data.OptionT import cats.effect._ import cats.implicits._ import docspell.backend.BackendApp 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.restserver.conv.Conversions import docspell.restserver.http4s._ +import docspell.store.AddResult +import docspell.store.UpdateResult +import docspell.store.records.RCustomField import org.http4s.HttpRoutes -//import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe.CirceEntityEncoder._ import org.http4s.dsl.Http4sDsl -import docspell.store.records.RCustomField object CustomFieldRoutes { @@ -21,15 +28,78 @@ object CustomFieldRoutes { import dsl._ HttpRoutes.of { - case GET -> Root => + case GET -> Root :? QueryParam.QueryOpt(param) => 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)) } 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 = - CustomField(f.id, f.name, f.ftype, f.created) + private def convertResult(r: UpdateResult): BasicResult = + 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 + ) } diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql index 2614e9e5..4a18bcaa 100644 --- a/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql @@ -1,6 +1,7 @@ CREATE TABLE "custom_field" ( "id" varchar(254) not null primary key, "name" varchar(254) not null, + "label" varchar(254), "cid" varchar(254) not null, "ftype" varchar(100) not null, "created" timestamp not null, diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieSyntax.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieSyntax.scala index 310e1eec..b4ac5b7f 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieSyntax.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieSyntax.scala @@ -1,5 +1,7 @@ package docspell.store.impl +import cats.data.NonEmptyList + import docspell.common.Timestamp import doobie._ @@ -7,6 +9,12 @@ import doobie.implicits._ 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 = sql" coalesce(" ++ commas(f0 :: fs.toList) ++ sql") " diff --git a/modules/store/src/main/scala/docspell/store/queries/QCustomField.scala b/modules/store/src/main/scala/docspell/store/queries/QCustomField.scala new file mode 100644 index 00000000..92cc0b1f --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QCustomField.scala @@ -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 + } +} diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index 7f768f93..8f771827 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -342,8 +342,8 @@ object QItem { TagItemName.itemsWithTagOrCategory(q.tagsExclude, q.tagCategoryExcl) val iFolder = IC.folder.prefix("i") - val name = q.name.map(_.toLowerCase).map(queryWildcard) - val allNames = q.allNames.map(_.toLowerCase).map(queryWildcard) + val name = q.name.map(_.toLowerCase).map(QueryWildcard.apply) + val allNames = q.allNames.map(_.toLowerCase).map(QueryWildcard.apply) val cond = and( IC.cid.prefix("i").is(q.account.collective), IC.state.prefix("i").isOneOf(q.states), @@ -516,8 +516,9 @@ object QItem { rn <- QAttachment.deleteItemAttachments(store)(itemId, collective) tn <- store.transact(RTagItem.deleteItemTags(itemId)) mn <- store.transact(RSentMail.deleteByItem(itemId)) + cf <- store.transact(RCustomFieldValue.deleteByItem(itemId)) n <- store.transact(RItem.deleteByIdAndCollective(itemId, collective)) - } yield tn + rn + n + mn + } yield tn + rn + n + mn + cf private def findByFileIdsQuery( fileMetaIds: NonEmptyList[Ident], @@ -618,18 +619,6 @@ object QItem { .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( id: Ident, collective: Ident, diff --git a/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala b/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala new file mode 100644 index 00000000..b11df4ec --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala @@ -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)) + } + +} diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomField.scala b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala index 5b4492e2..497506e7 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCustomField.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala @@ -9,7 +9,8 @@ import doobie.implicits._ case class RCustomField( id: Ident, - name: String, + name: Ident, + label: Option[String], cid: Ident, ftype: CustomFieldType, created: Timestamp @@ -23,22 +24,49 @@ object RCustomField { val id = Column("id") val name = Column("name") + val label = Column("label") val cid = Column("cid") val ftype = Column("ftype") 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] = { val sql = insertRow( table, 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 } + 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]] = - 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 } diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala index 1bc90d66..85c7f58c 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala @@ -39,4 +39,12 @@ object RCustomFieldValue { 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 } diff --git a/modules/store/src/test/scala/docspell/store/queries/QueryWildcardTest.scala b/modules/store/src/test/scala/docspell/store/queries/QueryWildcardTest.scala new file mode 100644 index 00000000..688a4bc6 --- /dev/null +++ b/modules/store/src/test/scala/docspell/store/queries/QueryWildcardTest.scala @@ -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*")) + } +}