From f995f70d391ec503b80d211a0d2b6cd65a835d74 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Fri, 7 Aug 2026 12:37:11 +0200 Subject: [PATCH 01/24] feat: implement link-attributes --- src/main/resources/schema/schema_v43.sql | 19 ++ .../controller/StructureController.scala | 49 +++- .../controller/TableauxController.scala | 18 +- .../tableaux/database/domain/column.scala | 69 ++++-- .../database/domain/createcolumn.scala | 12 +- .../tableaux/database/domain/link.scala | 145 +++++++++++ .../database/model/HistoryModel.scala | 58 ++++- .../tableaux/database/model/SystemModel.scala | 3 +- .../database/model/TableauxModel.scala | 40 ++- .../model/structure/ColumnModel.scala | 214 ++++++++++++++-- .../database/model/tableaux/RowModel.scala | 63 +++-- .../campudus/tableaux/helper/JsonUtils.scala | 53 +++- .../tableaux/router/StructureRouter.scala | 6 +- .../tableaux/router/TableauxRouter.scala | 24 ++ .../api/content/CreateHistoryTest.scala | 77 ++++++ .../api/content/LinkAttributesTest.scala | 229 ++++++++++++++++++ .../api/structure/ChangeStructureTest.scala | 227 +++++++++++++++++ .../controller/SystemControllerTest.scala | 4 +- .../tableaux/testtools/RequestCreation.scala | 29 +++ .../verticles/MessagingVerticleTest.scala | 1 + 20 files changed, 1260 insertions(+), 80 deletions(-) create mode 100644 src/main/resources/schema/schema_v43.sql create mode 100644 src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala diff --git a/src/main/resources/schema/schema_v43.sql b/src/main/resources/schema/schema_v43.sql new file mode 100644 index 000000000..36c8c927c --- /dev/null +++ b/src/main/resources/schema/schema_v43.sql @@ -0,0 +1,19 @@ +ALTER TABLE system_link_table ADD COLUMN attributes JSONB; + +-- Link-attribute VALUES live per link-row, on each dynamically-created link_table_. +-- New link tables get the column via the updated CREATE TABLE DDL in ColumnModel.createLinkColumn. +-- Existing link tables are backfilled here. +CREATE OR REPLACE FUNCTION add_attributes_column_to_link_table(link_table REGCLASS) + RETURNS TEXT AS $$ +BEGIN + EXECUTE 'ALTER TABLE ' || link_table || ' ADD COLUMN IF NOT EXISTS attributes JSONB'; + RETURN link_table :: TEXT; +END +$$ LANGUAGE plpgsql; + +SELECT add_attributes_column_to_link_table(table_name :: REGCLASS) +FROM information_schema.tables +WHERE table_schema = 'public' AND table_name LIKE 'link_table_%' +ORDER BY table_name; + +DROP FUNCTION add_attributes_column_to_link_table(REGCLASS); diff --git a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala index 4feb4d93d..0850c9c75 100644 --- a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala +++ b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala @@ -14,6 +14,7 @@ import com.campudus.tableaux.database.model.StructureModel import com.campudus.tableaux.database.model.TableauxModel._ import com.campudus.tableaux.database.model.structure.{CachedColumnModel, TableGroupModel, TableModel} import com.campudus.tableaux.database.model.structure.ColumnModel.isColumnGroupMatchingToFormatPattern +import com.campudus.tableaux.database.model.structure.ColumnModel.isLinkColumnMatchingToFormatPattern import com.campudus.tableaux.helper.Json import com.campudus.tableaux.helper.JsonUtils.toCreateColumnSeq import com.campudus.tableaux.helper.JsonUtils.toJsonObjectSeq @@ -696,7 +697,8 @@ class StructureController( minLength: Option[Int] = None, showMemberColumns: Option[Boolean] = None, decimalDigits: Option[Int] = None, - formatPattern: Option[String] = None + formatPattern: Option[String] = None, + linkAttributes: Option[Seq[LinkAttributeDefinition]] = None )(implicit user: TableauxUser): Future[ColumnType[?]] = { checkArguments( greaterZero(tableId), @@ -717,14 +719,15 @@ class StructureController( minLength, showMemberColumns, decimalDigits, - formatPattern + formatPattern, + linkAttributes ), "name, ordering, kind, identifier, displayInfos, countryCodes, separator, attributes, " + - "rules, hidden, maxLength, minLength, showMemberColumns, decimalDigits, formatPattern" + "rules, hidden, maxLength, minLength, showMemberColumns, decimalDigits, formatPattern, linkAttributes" ) ) - val structureProperties: Seq[Option[Any]] = Seq(columnName, ordering, kind, identifier, countryCodes) + val structureProperties: Seq[Option[Any]] = Seq(columnName, ordering, kind, identifier, countryCodes, linkAttributes) val isAtLeastOneStructureProperty: Boolean = structureProperties.exists(_.isDefined) logger.info( @@ -751,7 +754,8 @@ class StructureController( minLength, showMemberColumns, decimalDigits, - formatPattern + formatPattern, + linkAttributes ) for { @@ -782,6 +786,28 @@ class StructureController( Future(()) } + _ <- + if (linkAttributes.nonEmpty) { + column match { + case _: LinkColumn => + if (linkAttributes.get.size > LinkAttributeDefinition.maxCount) { + Future.failed(UnprocessableEntityException( + s"Only ${LinkAttributeDefinition.maxCount} linkAttributes entry is currently supported, " + + s"but got ${linkAttributes.get.size}." + )) + } else { + Future.successful(()) + } + case _ => + Future.failed(ForbiddenException( + s"Update of linkAttributes is not allowed for column ${column.kind}.", + "column" + )) + } + } else { + Future.successful(()) + } + _ <- if (formatPattern.isDefined) { column match { @@ -796,6 +822,19 @@ class StructureController( Future.successful(()) } } + case linkColumn: LinkColumn => { + // if linkAttributes is also being changed in this same request, validate against the new + // definitions rather than the column's current (pre-change) ones + val effectiveLinkAttributes = linkAttributes.getOrElse(linkColumn.linkAttributes) + + if (!isLinkColumnMatchingToFormatPattern(formatPattern, effectiveLinkAttributes)) { + Future.failed(UnprocessableEntityException( + s"Invalid formatPattern: '$formatPattern' doesn't match link value/attributes" + )) + } else { + Future.successful(()) + } + } case _ => Future.failed(ForbiddenException( s"Update of formatPattern '$formatPattern' is not allowed for column ${column.kind}.", diff --git a/src/main/scala/com/campudus/tableaux/controller/TableauxController.scala b/src/main/scala/com/campudus/tableaux/controller/TableauxController.scala index 6d5d50b2d..ac81b85da 100644 --- a/src/main/scala/com/campudus/tableaux/controller/TableauxController.scala +++ b/src/main/scala/com/campudus/tableaux/controller/TableauxController.scala @@ -16,7 +16,7 @@ import com.campudus.tableaux.helper.UnionTableHelper import com.campudus.tableaux.router.auth.permission._ import com.campudus.tableaux.verticles.EventClient -import io.vertx.lang.scala.json.JsonObject +import io.vertx.lang.scala.json.{JsonArray, JsonObject} import scala.concurrent.Future import scala.util.Try @@ -578,6 +578,22 @@ class TableauxController( } yield filled } + def updateCellLinkAttributes( + tableId: TableId, + columnId: ColumnId, + rowId: RowId, + toId: RowId, + attributes: JsonArray + )(implicit user: TableauxUser): Future[Cell[?]] = { + checkArguments(greaterZero(tableId), greaterZero(columnId), greaterZero(rowId), greaterZero(toId)) + logger.info(s"updateCellLinkAttributes $tableId $columnId $rowId $toId $attributes") + for { + table <- repository.retrieveTable(tableId) + _ = UnionTableHelper.notImplemented(table) + filled <- repository.updateCellLinkAttributes(table, columnId, rowId, toId, attributes) + } yield filled + } + def updateAttachmentOrder( tableId: TableId, columnId: ColumnId, diff --git a/src/main/scala/com/campudus/tableaux/database/domain/column.scala b/src/main/scala/com/campudus/tableaux/database/domain/column.scala index 31240c024..f35f087e7 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/column.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/column.scala @@ -161,7 +161,7 @@ object ColumnType { private type MultiLanguageAndValue = (SimpleValueColumn[?], Map[String, Option[?]]) private type LanguageNeutralAndValue = (SimpleValueColumn[?], Option[?]) - private type LinkAndRowIds = (LinkColumn, Seq[RowId]) + private type LinkAndRowIds = (LinkColumn, Seq[LinkValue]) private type AttachmentAndUUIDs = (AttachmentColumn, Seq[(UUID, Option[Ordering])]) /** @@ -566,8 +566,10 @@ case class LinkColumn( override val columnInformation: ColumnInformation, to: ColumnType[?], linkId: LinkId, - linkDirection: LinkDirection -)(implicit override val roleModel: RoleModel, val user: TableauxUser) extends ColumnType[Seq[RowId]] + linkDirection: LinkDirection, + linkAttributes: Seq[LinkAttributeDefinition] = Seq.empty, + formatPattern: Option[String] = None +)(implicit override val roleModel: RoleModel, val user: TableauxUser) extends ColumnType[Seq[LinkValue]] with LazyLogging { override val kind: LinkType.type = LinkType override val languageType: LanguageType = to.languageType @@ -583,31 +585,52 @@ case class LinkColumn( case json => Json.obj("constraint" -> json) } + val linkAttributesJson = linkAttributes match { + case Seq() => Json.obj() + case attrs => Json.obj("linkAttributes" -> Json.arr(attrs.map(LinkAttributeDefinition.getJson)*)) + } + + val formatPatternJson = formatPattern match { + case Some(pattern) => Json.obj("formatPattern" -> pattern) + case None => Json.obj() + } + super.getJson .mergeIn(baseJson) .mergeIn(constraintJson) + .mergeIn(linkAttributesJson) + .mergeIn(formatPatternJson) + } + + // Handles both a bare id/RowId and a `{"id": ..., "attributes": [...]}` object; attributes are optional on + // every element so all pre-existing request shapes (bare ids, or objects with only "id") keep working unchanged. + private def extractLinkValue(v: Any): LinkValue = v match { + case id: RowId => LinkValue(id) + case id: Integer => LinkValue(id.toLong) + case obj: JsonObject => + val id = obj.getLong("id").longValue() + val attributesOpt = Option(obj.getJsonArray("attributes")) + attributesOpt.foreach(attrs => LinkAttributeValueValidator.checkValidValue(linkAttributes, attrs).get) + LinkValue(id, attributesOpt) } - override def checkValidValue[B](value: B): Try[Option[Seq[RowId]]] = { + override def checkValidValue[B](value: B): Try[Option[Seq[LinkValue]]] = { Try { val castedValue = value match { case x if Option(x).isEmpty => - Seq.empty[Long] + Seq.empty[LinkValue] case x: Int => - Seq(x.toLong) + Seq(LinkValue(x.toLong)) case x: Seq[_] => - x.map { - case id: RowId => id - case obj: JsonObject => obj.getLong("id").longValue() - } + x.map(extractLinkValue) case x: JsonObject if x.containsKey("to") => import ArgumentChecker._ hasLong("to", x) match { case arg: OkArg[Long] => - Seq(arg.get) + Seq(LinkValue(arg.get)) case _ => throw InvalidJsonException( s"A link column expects a JSON object with to values, but got $x", @@ -616,13 +639,9 @@ case class LinkColumn( } case x: JsonObject if x.containsKey("values") => - Try( - checked(hasArray("values", x)).asScala - .map(_.asInstanceOf[java.lang.Integer].longValue()) - .toSeq - ) match { - case Success(ids) => - ids + val rawElements = Try(checked(hasArray("values", x)).asScala.toSeq) match { + case Success(elements) => + elements case Failure(_) => throw InvalidJsonException( s"A link column expects a JSON object with to values, but got $x", @@ -630,6 +649,16 @@ case class LinkColumn( ) } + rawElements.map({ + case id: Integer => extractLinkValue(id) + case obj: JsonObject => extractLinkValue(obj) + case _ => + throw InvalidJsonException( + s"A link column expects a JSON object with to values, but got $x", + "link-value" + ) + }) + case x: JsonObject => throw InvalidJsonException(s"A link column expects a JSON object with to values, but got $x", "link-value") @@ -637,8 +666,8 @@ case class LinkColumn( x.asScala .map({ // need to check for java.lang.Integer because we are mapping over AnyRefs - case id: Integer => id.toLong - case obj: JsonObject => obj.getLong("id").toLong + case id: Integer => extractLinkValue(id) + case obj: JsonObject => extractLinkValue(obj) }) .toSeq diff --git a/src/main/scala/com/campudus/tableaux/database/domain/createcolumn.scala b/src/main/scala/com/campudus/tableaux/database/domain/createcolumn.scala index a07162e70..3ce0a67de 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/createcolumn.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/createcolumn.scala @@ -56,7 +56,9 @@ object CreateLinkColumn { displayInfos: Seq[DisplayInfo], constraint: Constraint, attributes: Option[JsonObject], - hidden: Boolean = false + hidden: Boolean = false, + linkAttributes: Seq[LinkAttributeDefinition] = Seq.empty, + formatPattern: Option[String] = None ): CreateLinkColumn = { val createBackLinkColumn = CreateBackLinkColumn( name = toName, @@ -74,7 +76,9 @@ object CreateLinkColumn { constraint, createBackLinkColumn, attributes, - hidden + hidden, + linkAttributes, + formatPattern ) } } @@ -89,7 +93,9 @@ case class CreateLinkColumn( constraint: Constraint, foreignLinkColumn: CreateBackLinkColumn, override val attributes: Option[JsonObject], - override val hidden: Boolean + override val hidden: Boolean, + linkAttributes: Seq[LinkAttributeDefinition], + formatPattern: Option[String] ) extends CreateColumn { override val kind: LinkType.type = LinkType override val languageType: LanguageNeutral.type = LanguageNeutral diff --git a/src/main/scala/com/campudus/tableaux/database/domain/link.scala b/src/main/scala/com/campudus/tableaux/database/domain/link.scala index dd0a3cc92..8e48ab1e6 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/link.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/link.scala @@ -1,10 +1,17 @@ package com.campudus.tableaux.database.domain +import com.campudus.tableaux.InvalidJsonException +import com.campudus.tableaux.database._ import com.campudus.tableaux.database.model.TableauxModel._ import com.campudus.tableaux.helper.Json import io.vertx.lang.scala.json._ +import scala.jdk.CollectionConverters._ +import scala.util.{Failure, Success, Try} + +import org.joda.time.{DateTime, LocalDate} + case class Cardinality(from: Int, to: Int) object DefaultCardinality extends Cardinality(0, 0) @@ -157,3 +164,141 @@ case class RightToLeft(from: TableId, to: TableId, constraint: Constraint) exten } } } + +/** + * Definition of a single attribute that can be carried by a link (in addition to which rows it connects). Its + * structure orients on ColumnDefinition, but it isn't a real column: it has no stable id (referenced by `name` + * only), no ordering, and doesn't live in `system_columns` - it's stored as part of `system_link_table.attributes`. + */ +case class LinkAttributeDefinition( + name: String, + displayInfos: Seq[DisplayInfo], + kind: TableauxDbType, + multilanguage: Boolean +) + +object LinkAttributeDefinition { + + val allowedKinds: Set[TableauxDbType] = Set(TextType, NumericType, IntegerType, BooleanType, DateType, DateTimeType) + + val maxCount = 1 + + def fromJson(json: JsonObject): LinkAttributeDefinition = { + LinkAttributeDefinition( + name = json.getString("name"), + displayInfos = DisplayInfos.fromJson(json), + kind = TableauxDbType(json.getString("kind")), + multilanguage = json.getBoolean("multilanguage", false) + ) + } + + def seqFromJson(json: JsonArray): Seq[LinkAttributeDefinition] = { + Option(json) + .map(_.asScala.toSeq.collect({ case obj: JsonObject => fromJson(obj) })) + .getOrElse(Seq.empty) + } + + def getJson(attr: LinkAttributeDefinition): JsonObject = { + val displayNameJson = attr.displayInfos.foldLeft(Json.obj()) { + case (acc, displayInfo) => + displayInfo.optionalName + .map(name => acc.mergeIn(Json.obj(displayInfo.langtag -> name))) + .getOrElse(acc) + } + + Json.obj( + "name" -> attr.name, + "displayName" -> displayNameJson, + "kind" -> attr.kind.toString, + "multilanguage" -> attr.multilanguage + ) + } +} + +/** + * A row id to be linked, optionally carrying positional values for the target LinkColumn's `linkAttributes` + * (parallel to the definitions array, exactly like the `attributes` array in a link cell's JSON value). + */ +case class LinkValue(id: RowId, attributes: Option[JsonArray] = None) + +/** + * Validates a link's attribute value array against its column's `linkAttributes` definitions. Implemented as small + * standalone per-kind checks rather than constructing throwaway SimpleValueColumn instances, since those require a + * full ColumnInformation/Table/RoleModel/TableauxUser context just to validate one scalar. + */ +object LinkAttributeValueValidator { + + def checkValidValue(definitions: Seq[LinkAttributeDefinition], attributes: JsonArray): Try[Unit] = Try { + val values = Option(attributes).map(_.asScala.toSeq).getOrElse(Seq.empty) + + if (values.size != definitions.size) { + throw InvalidJsonException( + s"Expected ${definitions.size} link attribute value(s) but got ${values.size}.", + "link-attributes" + ) + } + + definitions.zip(values).foreach { + case (definition, rawValue) => + if (definition.multilanguage) { + rawValue match { + case null => // no value set for this attribute, ok + case obj: JsonObject => + obj.getMap.asScala.foreach({ case (_, langValue) => checkKindValue(definition, langValue) }) + case other => + throw InvalidJsonException( + s"Attribute '${definition.name}' is multilanguage and expects an object of langtag to value, but got $other.", + "link-attributes" + ) + } + } else { + rawValue match { + case null => // no value set for this attribute, ok + case _: JsonObject => + throw InvalidJsonException( + s"Attribute '${definition.name}' is not multilanguage and expects a single value, but got an object.", + "link-attributes" + ) + case value => checkKindValue(definition, value) + } + } + } + } + + private def checkKindValue(definition: LinkAttributeDefinition, value: Any): Unit = { + val result: Try[Any] = definition.kind match { + case TextType => + Try(value.asInstanceOf[String]) + case NumericType => + Try(value match { + case n: Number => n + case _ => throw new IllegalArgumentException(s"expected a number") + }) + case IntegerType => + Try(value match { + case i: Integer => i + case _ => throw new IllegalArgumentException(s"expected an integer") + }) + case BooleanType => + Try(value match { + case b: Boolean => b + case _ => throw new IllegalArgumentException(s"expected a boolean") + }) + case DateType => + Try(LocalDate.parse(value.asInstanceOf[String])) + case DateTimeType => + Try(DateTime.parse(value.asInstanceOf[String])) + case other => + Failure(new IllegalArgumentException(s"unsupported link attribute kind: $other")) + } + + result match { + case Success(_) => () + case Failure(ex) => + throw InvalidJsonException( + s"Invalid value for attribute '${definition.name}' (${definition.kind}): ${ex.getMessage}", + "link-attributes" + ) + } + } +} diff --git a/src/main/scala/com/campudus/tableaux/database/model/HistoryModel.scala b/src/main/scala/com/campudus/tableaux/database/model/HistoryModel.scala index 7d3b6f677..437d0c298 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/HistoryModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/HistoryModel.scala @@ -191,15 +191,39 @@ case class CreateHistoryModel(tableauxModel: TableauxModel, connection: Database (languageType, cellValues) } - private def wrapLinkValue(linksData: Seq[(RowId, Object)] = Seq.empty[(RowId, Object)]): JsonObject = { + private def wrapLinkValue( + linksData: Seq[(RowId, Object)] = Seq.empty[(RowId, Object)], + attributesByRowId: Map[RowId, JsonArray] = Map.empty + ): JsonObject = { Json.obj( "value" -> linksData.map({ - case (rowId, value) => Json.obj("id" -> rowId, "value" -> value) + case (rowId, value) => + val baseJson = Json.obj("id" -> rowId, "value" -> value) + attributesByRowId.get(rowId) match { + case Some(attributes) => baseJson.mergeIn(Json.obj("attributes" -> attributes)) + case None => baseJson + } }) ) } + // retrieveForeignIdentifierCells/getLinksData only fetch the *foreign row's* identifier value - this fetches the + // join table's own per-link attributes column, keyed by the linked row id, for the given source row. + private def retrieveLinkAttributesByRowId(column: LinkColumn, rowId: RowId): Future[Map[RowId, JsonArray]] = { + val linkTable = s"link_table_${column.linkId}" + val fromIdColumn = column.linkDirection.fromSql + val toIdColumn = column.linkDirection.toSql + + connection + .query( + s"SELECT $toIdColumn, attributes FROM $linkTable WHERE $fromIdColumn = ? AND attributes IS NOT NULL", + Json.arr(rowId) + ) + .map(resultObjectToJsonArray) + .map(_.map(row => (row.getLong(0).longValue(), new JsonArray(row.getString(1)))).toMap) + } + private def createLinks( table: Table, rowId: RowId, @@ -236,9 +260,17 @@ case class CreateHistoryModel(tableauxModel: TableauxModel, connection: Database linkIds <- tableauxModel.retrieveCurrentLinkIds(table, column, rowId) identifierCellSeq <- retrieveForeignIdentifierCells(column, linkIds) langTags <- getLangTags(table) + attributesByRowId <- retrieveLinkAttributesByRowId(column, rowId) (languageType, linksData) = getLinksData(identifierCellSeq, langTags) - _ <- insertCellHistory(table, rowId, column.id, column.kind, languageType, wrapLinkValue(linksData)) + _ <- insertCellHistory( + table, + rowId, + column.id, + column.kind, + languageType, + wrapLinkValue(linksData, attributesByRowId) + ) _ <- if (allowRecursion) { @@ -292,8 +324,16 @@ case class CreateHistoryModel(tableauxModel: TableauxModel, connection: Database linkIds <- tableauxModel.retrieveCurrentLinkIds(table, linkColumn, rowId) identifierCellSeq <- retrieveForeignIdentifierCells(linkColumn, linkIds) langTags <- getLangTags(table) + attributesByRowId <- retrieveLinkAttributesByRowId(linkColumn, rowId) (languageType, linksData) = getLinksData(identifierCellSeq, langTags) - _ <- insertCellHistory(table, rowId, linkColumn.id, linkColumn.kind, languageType, wrapLinkValue(linksData)) + _ <- insertCellHistory( + table, + rowId, + linkColumn.id, + linkColumn.kind, + languageType, + wrapLinkValue(linksData, attributesByRowId) + ) } yield () } @@ -711,7 +751,7 @@ case class CreateHistoryModel(tableauxModel: TableauxModel, connection: Database case (linkColumn, newForeignIds) => for { foreignIdsBeforeClearing <- tableauxModel.updateRowModel.retrieveLinkedRows(table, rowId, linkColumn) - _ <- createClearBackLinks(table, foreignIdsBeforeClearing.diff(newForeignIds)) + _ <- createClearBackLinks(table, foreignIdsBeforeClearing.diff(newForeignIds.map(_.id))) } yield () }) Future.sequence(futureSeq) @@ -890,7 +930,13 @@ case class CreateHistoryModel(tableauxModel: TableauxModel, connection: Database for { _ <- if (simples.isEmpty) Future.successful(()) else createSimple(table, rowId, simples) _ <- if (multis.isEmpty) Future.successful(()) else createTranslation(table, rowId, multis, oldCell) - _ <- if (links.isEmpty) Future.successful(()) else createLinks(table, rowId, links, allowRecursion = true) + _ <- + if (links.isEmpty) Future.successful(()) + else { + // createLinks always re-reads current DB state for the history value it writes (see getLinksData) - + // it only needs ids here, not attribute values + createLinks(table, rowId, links.map({ case (c, vs) => (c, vs.map(_.id)) }), allowRecursion = true) + } _ <- if (attachments.isEmpty) Future.successful(()) else createAttachments(table, rowId, attachments.map({ case (column, _) => column })) diff --git a/src/main/scala/com/campudus/tableaux/database/model/SystemModel.scala b/src/main/scala/com/campudus/tableaux/database/model/SystemModel.scala index 05cbaaf0d..0a7fc4f5d 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/SystemModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/SystemModel.scala @@ -218,7 +218,8 @@ class SystemModel(override protected val connection: DatabaseConnection) extends setupVersion(readSchemaFile("schema_v39"), 39), setupVersion(readSchemaFile("schema_v40"), 40), setupVersion(readSchemaFile("schema_v41"), 41), - setupVersion(readSchemaFile("schema_v42"), 42) + setupVersion(readSchemaFile("schema_v42"), 42), + setupVersion(readSchemaFile("schema_v43"), 43) ) private val setupShortCutFunction: Seq[DbTransaction => Future[DbTransaction]] = Seq( diff --git a/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala b/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala index 06ae1d13c..34ff548bb 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala @@ -714,6 +714,36 @@ class TableauxModel( } yield updatedCell } + def updateCellLinkAttributes( + table: Table, + columnId: ColumnId, + rowId: RowId, + toId: RowId, + attributes: JsonArray + )(implicit user: TableauxUser): Future[Cell[?]] = { + for { + column <- retrieveColumn(table, columnId) + _ <- roleModel.checkAuthorization(EditCellValue, ComparisonObjects(table, column)) + + _ <- column match { + case linkColumn: LinkColumn if linkColumn.linkAttributes.isEmpty => + Future.failed(UnprocessableEntityException(s"Column ${linkColumn.id} has no linkAttributes defined.")) + case linkColumn: LinkColumn => { + for { + _ <- Future.fromTry(LinkAttributeValueValidator.checkValidValue(linkColumn.linkAttributes, attributes)) + _ <- createHistoryModel.createCellsInit(table, rowId, Seq((linkColumn, Seq(toId)))) + _ <- updateRowModel.updateLinkAttributes(table, linkColumn, rowId, toId, attributes) + _ <- invalidateCellAndDependentColumns(column, rowId) + _ <- createHistoryModel.updateLinks(table, linkColumn, Seq(rowId)) + } yield Future.successful(()) + } + case _ => Future.failed(WrongColumnKindException(column, classOf[LinkColumn])) + } + + updatedCell <- retrieveCell(column, rowId, true) + } yield updatedCell + } + def updateAttachmentOrder( table: Table, columnId: ColumnId, @@ -1260,15 +1290,21 @@ class TableauxModel( val buildReturnJson: (Option[Any], Boolean) => JsonObject = (valueOpt, userCanView) => { if (shouldHideValuesByRowPermissions && !userCanView) { + // attributes belong to the foreign row's protected payload too, so they're hidden here as well Json.obj( "id" -> linkRowId, "hiddenByRowPermissions" -> true ) } else { - Json.obj( + val baseJson = Json.obj( "id" -> linkRowId, "value" -> valueOpt.getOrElse(null) ) + + Option(link.getValue("attributes")) match { + case Some(attributes) => baseJson.mergeIn(Json.obj("attributes" -> attributes)) + case None => baseJson + } } } @@ -1428,7 +1464,7 @@ class TableauxModel( } }) - (_, linkDirection, _) <- structureModel.columnStruc.retrieveLinkInformation(table, linkColumn.id) + (_, linkDirection, _, _) <- structureModel.columnStruc.retrieveLinkInformation(table, linkColumn.id) totalSize <- retrieveRowModel.sizeForeign(linkColumn, rowId, linkDirection, finalFlagOpt, archivedFlagOpt) rawRows <- retrieveRowModel.retrieveForeign( linkColumn, diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index 4b8652e73..47fa647f4 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -220,7 +220,8 @@ class CachedColumnModel( minLength: Option[Int], showMemberColumns: Option[Boolean], decimalDigits: Option[Int], - formatPattern: Option[String] + formatPattern: Option[String], + linkAttributes: Option[Seq[LinkAttributeDefinition]] )(implicit user: TableauxUser): Future[ColumnType[?]] = { for { _ <- removeCache(table.id, Some(columnId)) @@ -242,7 +243,8 @@ class CachedColumnModel( minLength, showMemberColumns, decimalDigits, - formatPattern + formatPattern, + linkAttributes ) } yield r } @@ -284,6 +286,37 @@ object ColumnModel extends LazyLogging { case None => true } } + + // Kept as its own regex/val (rather than reusing isColumnGroupMatchingToFormatPattern's) so GroupColumn's existing + // numeric-column-id-only wildcard behaviour is unaffected by allowing dotted paths here (e.g. attributes.percentage). + def isLinkColumnMatchingToFormatPattern( + formatPattern: Option[String], + linkAttributes: Seq[LinkAttributeDefinition] + ): Boolean = { + val formatVariable = "\\{\\{([\\w.]+)\\}\\}".r + + formatPattern match { + case Some(patternString) => { + val distinctWildcards = + formatVariable + .findAllMatchIn(patternString) + .toSeq + .flatMap(_.subgroups) + .distinct + .to(SortedSet) + + val allowedTokens = (Set("value") ++ linkAttributes.map(a => s"attributes.${a.name}")).to(SortedSet) + + logger.info( + s"Compare distinct wildcards (${distinctWildcards.mkString(", ")}) " + + s"with allowed link tokens (${allowedTokens.mkString(", ")})" + ) + + distinctWildcards.subsetOf(allowedTokens) + } + case None => true + } + } } class ColumnModel(val connection: DatabaseConnection)( @@ -346,7 +379,14 @@ class ColumnModel(val connection: DatabaseConnection)( .map({ case (linkId, toCol, CreatedColumnInformation(_, id, ordering, displayInfos)) => val linkDirection = LeftToRight(table.id, linkColumnInfo.toTable, linkColumnInfo.constraint) - LinkColumn(applyColumnInformation(id, ordering, displayInfos), toCol, linkId, linkDirection) + LinkColumn( + applyColumnInformation(id, ordering, displayInfos), + toCol, + linkId, + linkDirection, + linkColumnInfo.linkAttributes, + linkColumnInfo.formatPattern + ) }) case attachmentColumnInfo: CreateAttachmentColumn => @@ -781,8 +821,9 @@ class ColumnModel(val connection: DatabaseConnection)( | cardinality_2, | delete_cascade, | archive_cascade, - | final_cascade - |) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING link_id""".stripMargin, + | final_cascade, + | attributes + |) VALUES (?, ?, ?, ?, ?, ?, ?, ?::jsonb) RETURNING link_id""".stripMargin, Json.arr( tableId, linkColumnInfo.toTable, @@ -790,13 +831,14 @@ class ColumnModel(val connection: DatabaseConnection)( linkColumnInfo.constraint.cardinality.to, linkColumnInfo.constraint.deleteCascade, linkColumnInfo.constraint.archiveCascade, - linkColumnInfo.constraint.finalCascade + linkColumnInfo.constraint.finalCascade, + Json.arr(linkColumnInfo.linkAttributes.map(LinkAttributeDefinition.getJson)*).encode() ) ) linkId = insertNotNull(result).head.get[Long](0) // insert link column on source table - (t, columnInfo) <- insertSystemColumn(t, tableId, linkColumnInfo, Some(linkId), None, false) + (t, columnInfo) <- insertSystemColumn(t, tableId, linkColumnInfo, Some(linkId), linkColumnInfo.formatPattern, false) // only add the second link column if tableId != toTableId or singleDirection is false t <- { @@ -829,7 +871,8 @@ class ColumnModel(val connection: DatabaseConnection)( | id_2 bigint, | ordering_1 serial, | ordering_2 serial, - | + | attributes jsonb, + | | PRIMARY KEY(id_1, id_2), | | CONSTRAINT link_table_${linkId}_foreign_1 @@ -1373,7 +1416,7 @@ class ColumnModel(val connection: DatabaseConnection)( kind match { case AttachmentType => Future(AttachmentColumn(columnInformation)) case StatusType => mapStatusColumn(columnInformation, rules) - case LinkType => mapLinkColumn(depth, columnInformation) + case LinkType => mapLinkColumn(depth, columnInformation, formatPattern) // placeholder for now, grouped columns will be filled in later case GroupType => Future(GroupColumn(columnInformation, Seq.empty, formatPattern, showMemberColumns)) case _ => Future(SimpleValueColumn(kind, languageType, columnInformation)) @@ -1472,11 +1515,12 @@ class ColumnModel(val connection: DatabaseConnection)( } yield columns } - private def mapLinkColumn(depth: Int, columnInformation: ColumnInformation)( + private def mapLinkColumn(depth: Int, columnInformation: ColumnInformation, formatPattern: Option[String])( implicit user: TableauxUser ): Future[LinkColumn] = { for { - (linkId, linkDirection, toTable) <- retrieveLinkInformation(columnInformation.table, columnInformation.id) + (linkId, linkDirection, toTable, linkAttributes) <- + retrieveLinkInformation(columnInformation.table, columnInformation.id) foreignColumns <- { if (depth > 0) { @@ -1496,7 +1540,7 @@ class ColumnModel(val connection: DatabaseConnection)( } val toColumn = toColumnOpt.get - LinkColumn(columnInformation, toColumn, linkId, linkDirection) + LinkColumn(columnInformation, toColumn, linkId, linkDirection, linkAttributes, formatPattern) } } @@ -1587,7 +1631,7 @@ class ColumnModel(val connection: DatabaseConnection)( def retrieveLinkInformation(fromTable: Table, columnId: ColumnId)( implicit user: TableauxUser - ): Future[(LinkId, LinkDirection, Table)] = { + ): Future[(LinkId, LinkDirection, Table, Seq[LinkAttributeDefinition])] = { for { result <- connection.query( """ @@ -1599,7 +1643,8 @@ class ColumnModel(val connection: DatabaseConnection)( | cardinality_2, | delete_cascade, | archive_cascade, - | final_cascade + | final_cascade, + | attributes |FROM system_link_table |WHERE link_id = ( | SELECT link_id @@ -1609,7 +1654,7 @@ class ColumnModel(val connection: DatabaseConnection)( Json.arr(fromTable.id, columnId) ) - (linkId, linkDirection) = { + (linkId, linkDirection, linkAttributes) = { val res = selectNotNull(result).head val table1 = res.getLong(0).longValue() @@ -1620,6 +1665,9 @@ class ColumnModel(val connection: DatabaseConnection)( val deleteCascade = res.getBoolean(5) val archiveCascade = res.getBoolean(6) val finalCascade = res.getBoolean(7) + val linkAttributes = Option(res.getString(8)) + .map(str => LinkAttributeDefinition.seqFromJson(new JsonArray(str))) + .getOrElse(Seq.empty) ( linkId, @@ -1632,13 +1680,14 @@ class ColumnModel(val connection: DatabaseConnection)( deleteCascade, archiveCascade, finalCascade - ) + ), + linkAttributes ) } toTable <- tableStruc.retrieve(linkDirection.to, isInternalCall = true) - } yield (linkId, linkDirection, toTable) + } yield (linkId, linkDirection, toTable, linkAttributes) } def deleteLinkBothDirections(table: Table, columnId: ColumnId)( @@ -1853,6 +1902,125 @@ class ColumnModel(val connection: DatabaseConnection)( cast: String = "" ): String = s"UPDATE system_columns SET $columnName = ?$cast WHERE table_id = ? AND column_id = ?" + // Reshapes existing attribute values (position 0, the only slot while linkAttributes is capped at 1) to match a + // multilanguage flip, before any kind cast runs on top. There's no cast for this - it's a structural change - so + // false -> true duplicates the scalar under every table langtag, and true -> false collapses to the first langtag + // (in configured priority order) that actually has a non-null value, discarding the rest. + private def reshapeLinkAttributeValues( + t: DbTransaction, + table: Table, + linkTable: String, + oldDefinition: LinkAttributeDefinition, + newDefinition: LinkAttributeDefinition + ): Future[(DbTransaction, JsonObject)] = { + if (oldDefinition.multilanguage == newDefinition.multilanguage) { + Future.successful((t, Json.obj())) + } else { + for { + langtags <- table.langtags.map(Future.successful).getOrElse(tableStruc.retrieveGlobalLangtags()) + + result <- + if (newDefinition.multilanguage) { + // Postgres can't infer a bare `?` placeholder's type from a variadic "any" function like + // jsonb_build_object - it needs an explicit cast, or every prepared execution fails with + // "could not determine data type of parameter $1". + val pairs = langtags.map(_ => "?::text, attributes->0").mkString(", ") + t.query( + s"""|UPDATE $linkTable + |SET attributes = jsonb_set(attributes, '{0}', jsonb_build_object($pairs)) + |WHERE attributes IS NOT NULL AND attributes->0 IS NOT NULL""".stripMargin, + Json.arr(langtags*) + ) + } else { + val coalesceParts = (langtags.map(_ => "attributes->0->?::text") :+ "'null'::jsonb").mkString(", ") + t.query( + s"""|UPDATE $linkTable + |SET attributes = jsonb_set(attributes, '{0}', COALESCE($coalesceParts)) + |WHERE attributes IS NOT NULL AND attributes->0 IS NOT NULL""".stripMargin, + Json.arr(langtags*) + ) + } + } yield result + } + } + + // Casts existing attribute values (position 0) to a new kind, all-or-nothing - a single value anywhere that can't + // cast fails the whole UPDATE, which (combined with the caller's rollbackAndFail) rolls back the entire change, + // exactly mirroring how a plain column's kind change behaves today (ALTER COLUMN ... USING ...::type). + private def castLinkAttributeValues( + t: DbTransaction, + linkTable: String, + oldDefinition: LinkAttributeDefinition, + newDefinition: LinkAttributeDefinition + ): Future[(DbTransaction, JsonObject)] = { + if (oldDefinition.kind == newDefinition.kind) { + Future.successful((t, Json.obj())) + } else if (!newDefinition.multilanguage) { + t.query( + s"""|UPDATE $linkTable + |SET attributes = jsonb_set(attributes, '{0}', to_jsonb((attributes->>0)::${newDefinition.kind.toDbType})) + |WHERE attributes IS NOT NULL AND attributes->0 IS NOT NULL""".stripMargin + ) + } else { + t.query( + s"""|UPDATE $linkTable + |SET attributes = jsonb_set( + | attributes, '{0}', + | (SELECT jsonb_object_agg(key, to_jsonb(value::${newDefinition.kind.toDbType})) + | FROM jsonb_each_text(attributes->0)) + |) + |WHERE attributes IS NOT NULL AND attributes->0 IS NOT NULL""".stripMargin + ) + } + } + + // Applies a linkAttributes definition change to system_link_table plus, when needed, migrates existing values + // already stored on link_table_. Diffing is by name (max-1 keeps this simple): no old + new = pure add + // (nothing to migrate); old + no new, or a rename (different name) = wipe stored values, since there's no + // continuity contract once the name that referenced them is gone; same name = reshape (multilanguage) then + // cast (kind) in place. + private def updateLinkAttributesDefinition( + t: DbTransaction, + table: Table, + columnId: ColumnId, + newDefinitions: Seq[LinkAttributeDefinition] + ): Future[(DbTransaction, JsonObject)] = { + for { + (t, linkIdResult) <- t.query( + "SELECT link_id FROM system_columns WHERE table_id = ? AND column_id = ?", + Json.arr(table.id, columnId) + ) + linkId = selectNotNull(linkIdResult).head.getLong(0).longValue() + linkTable = s"link_table_$linkId" + + (t, currentResult) <- t.query("SELECT attributes FROM system_link_table WHERE link_id = ?", Json.arr(linkId)) + currentDefinitions = Option(selectNotNull(currentResult).head.getString(0)) + .map(str => LinkAttributeDefinition.seqFromJson(new JsonArray(str))) + .getOrElse(Seq.empty) + + (t, _) <- (currentDefinitions.headOption, newDefinitions.headOption) match { + case (Some(oldDef), Some(newDef)) if oldDef.name == newDef.name => + for { + (t, _) <- reshapeLinkAttributeValues(t, table, linkTable, oldDef, newDef) + (t, result) <- castLinkAttributeValues(t, linkTable, oldDef, newDef) + } yield (t, result) + + case (Some(_), _) => + // pure remove, or renamed to a different name - either way the old values no longer have a definition + t.query(s"UPDATE $linkTable SET attributes = NULL") + + case (None, _) => + // pure add - no existing link rows can have a value yet + Future.successful((t, Json.obj())) + } + + (t, result) <- t.query( + "UPDATE system_link_table SET attributes = ?::jsonb WHERE link_id = ?", + Json.arr(Json.arr(newDefinitions.map(LinkAttributeDefinition.getJson)*).encode(), linkId) + ) + } yield (t, result) + } + def change( table: Table, columnId: ColumnId, @@ -1870,7 +2038,8 @@ class ColumnModel(val connection: DatabaseConnection)( minLength: Option[Int], showMemberColumns: Option[Boolean], decimalDigits: Option[Int], - formatPattern: Option[String] + formatPattern: Option[String], + linkAttributes: Option[Seq[LinkAttributeDefinition]] )(implicit user: TableauxUser): Future[ColumnType[?]] = { val tableId = table.id @@ -1932,6 +2101,15 @@ class ColumnModel(val connection: DatabaseConnection)( } ).recoverWith(t.rollbackAndFail()) + // change linkAttributes definition, migrating already-stored values (see updateLinkAttributesDefinition) + (t, _) <- optionToValidFuture( + linkAttributes, + t, + { (newDefinitions: Seq[LinkAttributeDefinition]) => + updateLinkAttributesDefinition(t, table, columnId, newDefinitions) + } + ).recoverWith(t.rollbackAndFail()) + _ <- Future( checkUpdateResults( resultColumnName, diff --git a/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala b/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala index 3a170855f..32a5e8055 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala @@ -1,6 +1,6 @@ package com.campudus.tableaux.database.model.tableaux -import com.campudus.tableaux.{RowNotFoundException, UnknownServerException, UnprocessableEntityException} +import com.campudus.tableaux.{NotFoundInDatabaseException, RowNotFoundException, UnknownServerException, UnprocessableEntityException} import com.campudus.tableaux.database._ import com.campudus.tableaux.database.domain.{MultiLanguageColumn, _} import com.campudus.tableaux.database.domain.DisplayInfos.Langtag @@ -56,10 +56,11 @@ sealed trait UpdateCreateRowModelHelper extends LazyLogging { private def generateInsertSelectAndBinds( rowId: RowId, column: LinkColumn, - toId: RowId - ): (String, Seq[Long]) = { + linkValue: LinkValue + ): (String, Seq[Any]) = { val linkId = column.linkId val direction = column.linkDirection + val toId = linkValue.id // Both cardinality checks use a plain "+ 1": each insert now runs on its own, sequentially, so by the time a // later toId's insert runs, the COUNT(*) already reflects the earlier ones in this same batch - unlike the @@ -67,14 +68,15 @@ sealed trait UpdateCreateRowModelHelper extends LazyLogging { // needed a running "+ index" offset to account for its own batch-mates. val select = s""" - |SELECT ?, ?, nextval('link_table_${linkId}_${direction.orderingSql}_seq') + |SELECT ?, ?, nextval('link_table_${linkId}_${direction.orderingSql}_seq'), ?::jsonb |WHERE |NOT EXISTS (SELECT ${direction.fromSql}, ${direction.toSql} FROM link_table_$linkId WHERE ${direction.fromSql} = ? AND ${direction.toSql} = ?) AND |(SELECT COUNT(*) FROM link_table_$linkId WHERE ${direction.fromSql} = ?) + 1 <= (SELECT ${direction.toCardinality} FROM system_link_table WHERE link_id = ?) AND |(SELECT COUNT(*) FROM link_table_$linkId WHERE ${direction.toSql} = ?) + 1 <= (SELECT ${direction.fromCardinality} FROM system_link_table WHERE link_id = ?) |""".stripMargin - val binds = List(rowId, toId, rowId, toId, rowId, linkId, toId, linkId) + val binds = + List(rowId, toId, linkValue.attributes.map(_.encode()).orNull, rowId, toId, rowId, linkId, toId, linkId) (select, binds) } @@ -126,11 +128,11 @@ sealed trait UpdateCreateRowModelHelper extends LazyLogging { def updateLinks( table: Table, rowId: RowId, - values: Seq[(LinkColumn, Seq[RowId])], + values: Seq[(LinkColumn, Seq[LinkValue])], maybeTransaction: Option[DbTransaction] = None )(implicit ec: ExecutionContext): Future[Unit] = { - val func = (value: (LinkColumn, Seq[RowId])) => { - val (column, toIds) = value + val func = (value: (LinkColumn, Seq[LinkValue])) => { + val (column, linkValues) = value val linkId = column.linkId val direction = column.linkDirection @@ -141,20 +143,20 @@ sealed trait UpdateCreateRowModelHelper extends LazyLogging { t <- rowExists(t, column.table.id, rowId) // check if "to-be-linked" rows really exist - t <- toIds - .foldLeft(Future(t))((futureT, toId) => { - futureT.flatMap(t => rowExists(t, column.to.table.id, toId)) + t <- linkValues + .foldLeft(Future(t))((futureT, linkValue) => { + futureT.flatMap(t => rowExists(t, column.to.table.id, linkValue.id)) }) .recoverWith({ case ex: Throwable => Future.failed(UnprocessableEntityException(ex.getMessage)) }) - t <- toIds.foldLeft(Future.successful(t))((futureT, toId) => { - val (select, binds) = generateInsertSelectAndBinds(rowId, column, toId) + t <- linkValues.foldLeft(Future.successful(t))((futureT, linkValue) => { + val (select, binds) = generateInsertSelectAndBinds(rowId, column, linkValue) futureT.flatMap(t => t.query( - s"INSERT INTO link_table_$linkId(${direction.fromSql}, ${direction.toSql}, ${direction.orderingSql}) $select RETURNING *", + s"INSERT INTO link_table_$linkId(${direction.fromSql}, ${direction.toSql}, ${direction.orderingSql}, attributes) $select RETURNING *", Json.arr(binds*) ).map({ // if no row comes back we hit the cardinality limit or the link already exists @@ -266,7 +268,9 @@ class UpdateRowModel(val connection: DatabaseConnection) extends DatabaseQuery w if (simple.isEmpty) Future.successful(()) else updateSimple(table, rowId, simple.map({ case (c, _) => (c, None) })) _ <- if (multis.isEmpty) Future.successful(()) else clearTranslation(table, rowId, multis.map(_._1)) - _ <- if (links.isEmpty) Future.successful(()) else clearLinksWithValues(table, rowId, links, deleteRowFn) + _ <- + if (links.isEmpty) Future.successful(()) + else clearLinksWithValues(table, rowId, links.map({ case (c, vs) => (c, vs.map(_.id)) }), deleteRowFn) _ <- if (attachments.isEmpty) Future.successful(()) else clearAttachments(table, rowId, attachments.map(_._1)) } yield () } @@ -447,6 +451,30 @@ class UpdateRowModel(val connection: DatabaseConnection) extends DatabaseQuery w } yield () } + def updateLinkAttributes( + table: Table, + column: LinkColumn, + rowId: RowId, + toId: RowId, + attributes: JsonArray + ): Future[Unit] = { + val rowIdColumn = column.linkDirection.fromSql + val toIdColumn = column.linkDirection.toSql + val linkTable = s"link_table_${column.linkId}" + + val sql = s"UPDATE $linkTable SET attributes = ?::jsonb WHERE $rowIdColumn = ? AND $toIdColumn = ? RETURNING *" + + for { + result <- connection.query(sql, Json.arr(Option(attributes).map(_.encode()).orNull, rowId, toId)) + // an UPDATE ... RETURNING is routed through the SELECT-shaped result path (see doMagicQuery), so its + // "rows" count - not its "message" string - is what actually reflects whether a row was updated + _ = + if (result.getInteger("rows") != 1) { + throw NotFoundInDatabaseException(s"Link from row $rowId to row $toId not found", "link") + } + } yield () + } + def updateLinkOrder( table: Table, column: LinkColumn, @@ -1668,7 +1696,8 @@ class RetrieveRowModel(val connection: DatabaseConnection)( | jsonb_strip_nulls( | jsonb_build_object( | 'final', CASE WHEN ut$toTableId.final IS TRUE THEN ut$toTableId.final ELSE NULL END, - | 'archived', CASE WHEN ut$toTableId.archived IS TRUE THEN ut$toTableId.archived ELSE NULL END + | 'archived', CASE WHEN ut$toTableId.archived IS TRUE THEN ut$toTableId.archived ELSE NULL END, + | 'attributes', lt$linkId.attributes | ) | ) | ) AS value @@ -1676,7 +1705,7 @@ class RetrieveRowModel(val connection: DatabaseConnection)( | link_table_$linkId lt$linkId | JOIN user_table_$toTableId ut$toTableId ON (lt$linkId.${direction.toSql} = ut$toTableId.id) | LEFT JOIN user_table_lang_$toTableId utl$toTableId ON (ut$toTableId.id = utl$toTableId.id) - | GROUP BY ut$toTableId.id, lt$linkId.${direction.fromSql}, lt$linkId.${direction.orderingSql} + | GROUP BY ut$toTableId.id, lt$linkId.${direction.fromSql}, lt$linkId.${direction.orderingSql}, lt$linkId.attributes | ORDER BY lt$linkId.${direction.fromSql}, lt$linkId.${direction.orderingSql} |) sub |WHERE sub.${direction.fromSql} = ut.id diff --git a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala index 208790019..9050dd3bd 100644 --- a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala +++ b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala @@ -194,6 +194,9 @@ object JsonUtils extends LazyLogging { finalCascade ) + val linkAttributes = parseLinkAttributes(json) + val linkFormatPattern = hasString("formatPattern", json).toOption + CreateLinkColumn( name, ordering, @@ -204,7 +207,9 @@ object JsonUtils extends LazyLogging { constraint.getOrElse(DefaultConstraint), createBackLinkColumn, attributes, - hidden + hidden, + linkAttributes, + linkFormatPattern ) case (GroupType) => @@ -317,6 +322,41 @@ object JsonUtils extends LazyLogging { }) } + private def parseLinkAttributes(json: JsonObject): Seq[LinkAttributeDefinition] = { + val entries = Option(json.getJsonArray("linkAttributes")) + .map(_.asScala.toSeq) + .getOrElse(Seq.empty) + + if (entries.size > LinkAttributeDefinition.maxCount) { + throw InvalidJsonException( + s"Only ${LinkAttributeDefinition.maxCount} linkAttributes entry is currently supported, but got ${entries.size}.", + "linkAttributes" + ) + } + + entries.map({ + case entryJson: JsonObject => + val name = checked(hasString("name", entryJson)) + val kind = checked(toTableauxType(checked(hasString("kind", entryJson)))) + + if (!LinkAttributeDefinition.allowedKinds.contains(kind)) { + throw InvalidJsonException( + s"linkAttributes kind '$kind' is not allowed. Allowed kinds: ${LinkAttributeDefinition.allowedKinds + .mkString(", ")}.", + "linkAttributes" + ) + } + + val multilanguage = entryJson.getBoolean("multilanguage", false) + val displayInfos = DisplayInfos.fromJson(entryJson) + + LinkAttributeDefinition(name, displayInfos, kind, multilanguage) + + case other => + throw InvalidJsonException(s"linkAttributes entries must be JSON objects, but got $other.", "linkAttributes") + }) + } + private def parseGroupReferences(json: JsonObject): (Seq[ColumnId], Seq[String]) = { val groupsJson = checked(hasArray("groups", json)) @@ -428,7 +468,8 @@ object JsonUtils extends LazyLogging { Option[Int], Option[Boolean], Option[Int], - Option[String] + Option[String], + Option[Seq[LinkAttributeDefinition]] ) = { val name = hasString("name", json).toOption @@ -466,6 +507,11 @@ object JsonUtils extends LazyLogging { val decimalDigits = parseDecimalDigits(json) val formatPattern = hasString("formatPattern", json).toOption + // None means "linkAttributes wasn't submitted at all, leave existing definition untouched" - as opposed to + // Some(Seq.empty) which means "submitted as an explicit empty array", the wire-level way to delete an + // existing linkAttributes definition (see ColumnModel.change). + val linkAttributes = booleanToValueOption(json.containsKey("linkAttributes"), parseLinkAttributes(json)) + ( name, ord, @@ -481,7 +527,8 @@ object JsonUtils extends LazyLogging { minLength, showMemberColumns, decimalDigits, - formatPattern + formatPattern, + linkAttributes ) } diff --git a/src/main/scala/com/campudus/tableaux/router/StructureRouter.scala b/src/main/scala/com/campudus/tableaux/router/StructureRouter.scala index c84e6349c..af544f265 100644 --- a/src/main/scala/com/campudus/tableaux/router/StructureRouter.scala +++ b/src/main/scala/com/campudus/tableaux/router/StructureRouter.scala @@ -337,7 +337,8 @@ class StructureRouter(override val config: TableauxConfig, val controller: Struc minLength, showMemberColumns, decimalDigits, - formatPattern + formatPattern, + linkAttributes ) = toColumnChanges(json) @@ -358,7 +359,8 @@ class StructureRouter(override val config: TableauxConfig, val controller: Struc minLength, showMemberColumns, decimalDigits, - formatPattern + formatPattern, + linkAttributes ) } ) diff --git a/src/main/scala/com/campudus/tableaux/router/TableauxRouter.scala b/src/main/scala/com/campudus/tableaux/router/TableauxRouter.scala index a51c8645f..d79b0c7fd 100644 --- a/src/main/scala/com/campudus/tableaux/router/TableauxRouter.scala +++ b/src/main/scala/com/campudus/tableaux/router/TableauxRouter.scala @@ -35,6 +35,7 @@ class TableauxRouter(override val config: TableauxConfig, val controller: Tablea s"/tables/$tableId/columns/$columnId/rows/$rowId/attachment/$uuidRegex/order" private val linkOfCell: String = s"/tables/$tableId/columns/$columnId/rows/$rowId/link/$linkId" private val linkOrderOfCell: String = s"/tables/$tableId/columns/$columnId/rows/$rowId/link/$linkId/order" + private val linkAttributesOfCell: String = s"/tables/$tableId/columns/$columnId/rows/$rowId/link/$linkId/attributes" private val columnsValues: String = s"/tables/$tableId/columns/$columnId/values" private val columnsValuesWithLangtag: String = s"/tables/$tableId/columns/$columnId/values/$langtagRegex" @@ -133,6 +134,7 @@ class TableauxRouter(override val config: TableauxConfig, val controller: Tablea router.postWithRegex(cell).handler(updateCell) router.putWithRegex(cell).handler(replaceCell) router.putWithRegex(linkOrderOfCell).handler(changeLinkOrder) + router.putWithRegex(linkAttributesOfCell).handler(changeLinkAttributes) router.putWithRegex(attachmentOrderOfCell).handler(changeAttachmentOrder) router.putWithRegex(row).handler(setRow) @@ -856,6 +858,28 @@ class TableauxRouter(override val config: TableauxConfig, val controller: Tablea } } + /** + * Change attribute values of a link + */ + private def changeLinkAttributes(context: RoutingContext): Unit = { + implicit val user = TableauxUser(context) + for { + tableId <- getTableId(context) + columnId <- getColumnId(context) + rowId <- getRowId(context) + toId <- getLinkId(context) + } yield { + sendReply( + context, + asyncGetReply { + val json = getJson(context) + val attributes = json.getJsonArray("attributes") + controller.updateCellLinkAttributes(tableId, columnId, rowId, toId, attributes) + } + ) + } + } + /** * Change order of attachment */ diff --git a/src/test/scala/com/campudus/tableaux/api/content/CreateHistoryTest.scala b/src/test/scala/com/campudus/tableaux/api/content/CreateHistoryTest.scala index ffdf761ba..0fd7b5b40 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/CreateHistoryTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/CreateHistoryTest.scala @@ -1332,6 +1332,83 @@ class CreateSimpleLinkOrderHistoryTest extends LinkTestBase with TestHelper { } } +@RunWith(classOf[VertxUnitRunner]) +class CreateLinkAttributesHistoryTest extends LinkTestBase with TestHelper { + + private def percentageAttribute: JsonObject = Json.obj( + "name" -> "percentage", + "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), + "kind" -> "integer", + "multilanguage" -> false + ) + + private def createLinkColumnWithAttributes(tableId: Long, toTableId: Long): Future[Long] = { + val json = Json.obj( + "columns" -> Json.arr( + Json.obj( + "name" -> "Test Link 1", + "kind" -> "link", + "toTable" -> toTableId, + "linkAttributes" -> Json.arr(percentageAttribute) + ) + ) + ) + + sendRequest("POST", s"/tables/$tableId/columns", json) + .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) + } + + @Test + def createLinkWithAttributesWritesHistoryWithAttributes(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50)))) + ) + + val expected = + """ + |[ + | {"id": 1, "value": "table2row1", "attributes": [50]} + |] + |""".stripMargin + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + rows <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1/history?historyType=cell").map(toRowsArray) + historyAfterCreation = getLinksValue(rows, 0) + } yield { + assertJSONEquals(expected, historyAfterCreation, JSONCompareMode.LENIENT) + } + } + + @Test + def changeLinkAttributesViaEndpointWritesNewHistoryEntry(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50)))) + ) + val putAttributes = Json.obj("attributes" -> Json.arr(75)) + + val expected = + """ + |[ + | {"id": 1, "value": "table2row1", "attributes": [75]} + |] + |""".stripMargin + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + _ <- sendRequest("PUT", s"/tables/1/columns/$linkColumnId/rows/1/link/1/attributes", putAttributes) + rows <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1/history?historyType=cell").map(toRowsArray) + historyAfterUpdate = getLinksValue(rows, 1) + } yield { + assertJSONEquals(expected, historyAfterUpdate, JSONCompareMode.LENIENT) + } + } +} + @RunWith(classOf[VertxUnitRunner]) class CreateMultiLanguageLinkHistoryTest extends LinkTestBase with TestHelper { diff --git a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala new file mode 100644 index 000000000..8aa56be7c --- /dev/null +++ b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala @@ -0,0 +1,229 @@ +package com.campudus.tableaux.api.content + +import com.campudus.tableaux.database.model.TableauxModel.{ColumnId, TableId} +import com.campudus.tableaux.helper.Json + +import io.vertx.ext.unit.TestContext +import io.vertx.ext.unit.junit.VertxUnitRunner +import io.vertx.lang.scala.json.JsonObject + +import scala.concurrent.Future + +import org.junit.Assert._ +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(classOf[VertxUnitRunner]) +class LinkAttributesTest extends LinkTestBase { + + private def percentageAttribute(multilanguage: Boolean = false): JsonObject = { + Json.obj( + "name" -> "percentage", + "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), + "kind" -> "integer", + "multilanguage" -> multilanguage + ) + } + + private def postLinkColWithAttributes( + toTableId: TableId, + name: String = "Test Link 1", + multilanguage: Boolean = false + ) = { + Json.obj( + "columns" -> Json.arr( + Json.obj( + "name" -> name, + "kind" -> "link", + "toTable" -> toTableId, + "linkAttributes" -> Json.arr(percentageAttribute(multilanguage)) + ) + ) + ) + } + + private def createLinkColumnWithAttributes( + tableId: TableId, + toTableId: TableId, + multilanguage: Boolean = false + ): Future[ColumnId] = { + sendRequest("POST", s"/tables/$tableId/columns", postLinkColWithAttributes(toTableId, multilanguage = multilanguage)) + .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) + } + + @Test + def createLinkWithInlineAttributes(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50)))) + ) + + val expected = Json.obj( + "status" -> "ok", + "value" -> Json.arr(Json.obj("id" -> 1, "value" -> "table2row1", "attributes" -> Json.arr(50))) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + assertEquals(expected, cell) + } + } + + @Test + def createLinkWithMixedBareIdAndObjectValues(implicit c: TestContext): Unit = okTest { + val putLinks = Json.obj( + "value" -> Json.obj("values" -> Json.arr(1, Json.obj("id" -> 2, "attributes" -> Json.arr(75)))) + ) + + val expected = Json.obj( + "status" -> "ok", + "value" -> Json.arr( + Json.obj("id" -> 1, "value" -> "table2row1"), + Json.obj("id" -> 2, "value" -> "table2row2", "attributes" -> Json.arr(75)) + ) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLinks) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + assertEquals(expected, cell) + } + } + + @Test + def existingRequestShapesStillWorkUnchanged(implicit c: TestContext): Unit = okTest { + val putLinks = Json.obj("value" -> Json.obj("values" -> Json.arr(1, 2))) + + val expected = Json.obj( + "status" -> "ok", + "value" -> Json.arr( + Json.obj("id" -> 1, "value" -> "table2row1"), + Json.obj("id" -> 2, "value" -> "table2row2") + ) + ) + + for { + _ <- setupTwoTables() + // column has a linkAttributes definition, but no attributes are supplied - shape/response must be unaffected + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLinks) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + assertEquals(expected, cell) + } + } + + @Test + def rejectAttributesLengthMismatch(implicit c: TestContext): Unit = exceptionTest("error.json.link-attributes") { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50, 60)))) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + } yield () + } + + @Test + def rejectAttributesWrongKind(implicit c: TestContext): Unit = exceptionTest("error.json.link-attributes") { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr("not-an-integer")))) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + } yield () + } + + @Test + def rejectAttributesOnColumnWithoutDefinition(implicit c: TestContext): Unit = + exceptionTest("error.json.link-attributes") { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50)))) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumn(1, 2, singleDirection = false) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + } yield () + } + + @Test + def multilanguageAttributeValueRoundtrips(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> 50, "en-GB" -> 60)))) + ) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2, multilanguage = true) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + val attributeValue = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getJsonObject(0) + assertEquals(50, attributeValue.getInteger("de-DE")) + assertEquals(60, attributeValue.getInteger("en-GB")) + } + } + + @Test + def putLinkAttributesEndpointSuccess(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50)))) + ) + val putAttributes = Json.obj("attributes" -> Json.arr(75)) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + _ <- sendRequest("PUT", s"/tables/1/columns/$linkColumnId/rows/1/link/1/attributes", putAttributes) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + assertEquals(75, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + } + } + + @Test + def putLinkAttributesEndpointLinkNotFoundFails(implicit c: TestContext): Unit = + // any 404-status exception surfaces to tests as the literal string "NOT FOUND" (see BaseRouter's error + // handling, which hardcodes the HTTP status message for status 404 regardless of the exception's real id) + exceptionTest("NOT FOUND") { + val putAttributes = Json.obj("attributes" -> Json.arr(75)) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + // row 1 is not linked to row 2 at all + _ <- sendRequest("PUT", s"/tables/1/columns/$linkColumnId/rows/1/link/2/attributes", putAttributes) + } yield () + } + + @Test + def putLinkAttributesEndpointOnColumnWithoutDefinitionFails(implicit c: TestContext): Unit = + exceptionTest("unprocessable.entity") { + val putLink = Json.obj("value" -> Json.obj("to" -> 1)) + val putAttributes = Json.obj("attributes" -> Json.arr(75)) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumn(1, 2, singleDirection = false) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + _ <- sendRequest("PUT", s"/tables/1/columns/$linkColumnId/rows/1/link/1/attributes", putAttributes) + } yield () + } + +} diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala index 3182aa7d9..6896ea68a 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala @@ -1,10 +1,12 @@ package com.campudus.tableaux.api.structure +import com.campudus.tableaux.database.model.TableauxModel.ColumnId import com.campudus.tableaux.helper.Json import com.campudus.tableaux.testtools.TableauxTestBase import io.vertx.ext.unit.TestContext import io.vertx.ext.unit.junit.VertxUnitRunner +import io.vertx.lang.scala.json.{JsonArray, JsonObject} import scala.concurrent.Future @@ -209,4 +211,229 @@ class ChangeStructureTest extends TableauxTestBase { } } + private def percentageAttribute(kind: String = "integer", multilanguage: Boolean = false): JsonObject = { + Json.obj( + "name" -> "percentage", + "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), + "kind" -> kind, + "multilanguage" -> multilanguage + ) + } + + private def createLinkColumn(linkAttributes: JsonArray = Json.arr()): Future[ColumnId] = { + val postJson = Json.obj( + "columns" -> Json.arr( + Json.obj("name" -> "Test Link 1", "kind" -> "link", "toTable" -> 2, "linkAttributes" -> linkAttributes) + ) + ) + + for { + _ <- createDefaultTable() + _ <- createDefaultTable("Test Table 2", 2) + result <- sendRequest("POST", "/tables/1/columns", postJson) + } yield result.getJsonArray("columns").getJsonObject(0).getLong("id").toLong + } + + @Test + def createLinkColumnWithLinkAttributes(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + result <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertJSONEquals(Json.arr(percentageAttribute()), result.getJsonArray("linkAttributes")) + } + } + + @Test + def createLinkColumnWithTooManyLinkAttributesFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + createLinkColumn(Json.arr(percentageAttribute(), percentageAttribute())) + } + + @Test + def createLinkColumnWithDisallowedLinkAttributeKindFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + createLinkColumn(Json.arr(percentageAttribute(kind = "link"))) + } + + @Test + def changeLinkColumnAddLinkAttributes(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn() + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute())) + ) + result <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertJSONEquals(Json.arr(percentageAttribute()), result.getJsonArray("linkAttributes")) + } + } + + @Test + def changeLinkColumnToTooManyLinkAttributesFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(), percentageAttribute())) + ) + } yield () + } + + @Test + def changeLinkColumnLinkAttributesForbiddenForNonLinkColumns(implicit c: TestContext): Unit = + exceptionTest("error.request.forbidden.column") { + for { + _ <- createDefaultTable() + _ <- sendRequest( + "POST", + "/tables/1/columns/1", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute())) + ) + } yield () + } + + @Test + def changeLinkColumnWithExplicitEmptyLinkAttributesClearsValues(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("linkAttributes" -> Json.arr())) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertFalse(column.containsKey("linkAttributes")) + assertFalse(cell.getJsonArray("value").getJsonObject(0).containsKey("attributes")) + } + } + + @Test + def changeLinkColumnOmittingLinkAttributesLeavesItUntouched(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("name" -> "renamed")) + result <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertJSONEquals(Json.arr(percentageAttribute()), result.getJsonArray("linkAttributes")) + } + } + + @Test + def changeLinkColumnLinkAttributesKindMigrationSucceeds(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric"))) + ) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals("numeric", column.getJsonArray("linkAttributes").getJsonObject(0).getString("kind")) + val migratedValue = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getValue(0) + assertEquals(50.0, migratedValue.asInstanceOf[Number].doubleValue(), 0.001) + } + } + + @Test + def changeLinkColumnLinkAttributesKindMigrationFailsAndRollsBack(implicit c: TestContext): Unit = okTest { + val putLink = + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr("abc"))))) + val failed = Json.obj("failed" -> "failed") + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "text"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + changeResult <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "integer"))) + ).recoverWith({ case _ => Future.successful(failed) }) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals(failed, changeResult) + assertEquals("text", column.getJsonArray("linkAttributes").getJsonObject(0).getString("kind")) + assertEquals("abc", cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getString(0)) + } + } + + @Test + def changeLinkColumnMultilanguageFalseToTrueDuplicatesValue(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = false))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + val attributeValue = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getJsonObject(0) + assertEquals(50, attributeValue.getInteger("de-DE")) + assertEquals(50, attributeValue.getInteger("en-GB")) + } + } + + @Test + def changeLinkColumnMultilanguageTrueToFalseCollapsesValue(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> 50, "en-GB" -> 75)))) + ) + ) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = true))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals(50, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + } + } + + @Test + def changeLinkColumnFormatPatternAccepted(implicit c: TestContext): Unit = okTest { + val pattern = "{{value}} ({{attributes.percentage}}%)" + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("formatPattern" -> pattern)) + result <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertEquals(pattern, result.getString("formatPattern")) + } + } + + @Test + def changeLinkColumnFormatPatternRejectedForUnknownToken(implicit c: TestContext): Unit = + exceptionTest("unprocessable.entity") { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("formatPattern" -> "{{attributes.doesNotExist}}") + ) + } yield () + } + } diff --git a/src/test/scala/com/campudus/tableaux/controller/SystemControllerTest.scala b/src/test/scala/com/campudus/tableaux/controller/SystemControllerTest.scala index 89626a3d0..ea4588fbf 100644 --- a/src/test/scala/com/campudus/tableaux/controller/SystemControllerTest.scala +++ b/src/test/scala/com/campudus/tableaux/controller/SystemControllerTest.scala @@ -91,8 +91,8 @@ class SystemControllerTest extends TableauxTestBase { okTest { val expectedJson = Json.obj( "database" -> Json.obj( - "current" -> 42, - "specification" -> 42 + "current" -> 43, + "specification" -> 43 ) ) diff --git a/src/test/scala/com/campudus/tableaux/testtools/RequestCreation.scala b/src/test/scala/com/campudus/tableaux/testtools/RequestCreation.scala index efb4c1d40..31e552a05 100644 --- a/src/test/scala/com/campudus/tableaux/testtools/RequestCreation.scala +++ b/src/test/scala/com/campudus/tableaux/testtools/RequestCreation.scala @@ -172,4 +172,33 @@ object RequestCreation { } } + case class LinkAttributeDef( + name: String, + displayName: Map[String, String] = Map.empty, + kind: String = "integer", + multilanguage: Boolean = false + ) extends DomainObject { + + override def getJson: JsonObject = Json.obj( + "name" -> name, + "displayName" -> Json.obj(displayName.toSeq*), + "kind" -> kind, + "multilanguage" -> multilanguage + ) + } + + case class WithLinkAttributes(column: ColumnType, linkAttributes: Seq[LinkAttributeDef]) + extends ColumnType(column.kind) { + val name: String = column.name + + override def getJson: JsonObject = + column.getJson.mergeIn(Json.obj("linkAttributes" -> Json.arr(linkAttributes.map(_.getJson)*))) + } + + case class WithFormatPattern(column: ColumnType, formatPattern: String) extends ColumnType(column.kind) { + val name: String = column.name + + override def getJson: JsonObject = column.getJson.mergeIn(Json.obj("formatPattern" -> formatPattern)) + } + } diff --git a/src/test/scala/com/campudus/tableaux/verticles/MessagingVerticleTest.scala b/src/test/scala/com/campudus/tableaux/verticles/MessagingVerticleTest.scala index 54884afee..07c8cae59 100644 --- a/src/test/scala/com/campudus/tableaux/verticles/MessagingVerticleTest.scala +++ b/src/test/scala/com/campudus/tableaux/verticles/MessagingVerticleTest.scala @@ -519,6 +519,7 @@ class MessagingVerticleTest extends TableauxTestBase { None, None, None, + None, None ) _ <- eventClient.columnChanged(table.id, updatedColumn.id) From ea5ff3e95a317d686bfe2c533e60ab0010f7eea7 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Fri, 7 Aug 2026 13:34:05 +0200 Subject: [PATCH 02/24] feat: add swagger docs --- src/main/resources/swagger.json | 143 +++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/src/main/resources/swagger.json b/src/main/resources/swagger.json index b1a425d75..fd6b1311b 100644 --- a/src/main/resources/swagger.json +++ b/src/main/resources/swagger.json @@ -1235,6 +1235,18 @@ "name": { "type": "string", "example": "New Column Name" + }, + "linkAttributes": { + "type": "array", + "description": "Definitions of attributes attached to a link column (only applicable if the column is of kind \"link\"). Send an empty array to clear the definition and any stored attribute values; omit the field entirely to leave it untouched.", + "items": { + "$ref": "#/definitions/LinkAttributeDefinition" + } + }, + "formatPattern": { + "type": "string", + "description": "Pattern for rendering a group or link column's content in the frontend. Only allowed for columns of kind \"group\" or \"link\".", + "example": "{{value}} ({{attributes.percentage}}%)" } } } @@ -2612,6 +2624,60 @@ ] } }, + "/tables/{tableId}/columns/{columnId}/rows/{rowId}/link/{linkId}/attributes": { + "parameters": [ + { + "$ref": "#/parameters/tableId" + }, + { + "$ref": "#/parameters/columnId" + }, + { + "$ref": "#/parameters/rowId" + }, + { + "$ref": "#/parameters/linkId" + } + ], + "put": { + "summary": "Changes the attribute values of a link.", + "description": "Sets the attribute values for the given link. The attributes array is positional and validated against the link column's linkAttributes definitions (same length, matching kind per entry). Requires the column to have at least one linkAttributes entry defined.", + "tags": [ + "content" + ], + "operationId": "update-cell-link-attributes", + "parameters": [ + { + "name": "attributes", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "attributes": { + "type": "array", + "description": "Positional attribute values, one per linkAttributes definition on the column.", + "items": { + "type": "any" + }, + "example": [ + 90 + ] + } + } + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/cell-value" + }, + "404": { + "$ref": "#/responses/not-found-in-database" + } + } + } + }, "/tables/{tableId}/columns/{columnId}/rows/{rowId}/foreignRows": { "parameters": [ { @@ -3741,6 +3807,7 @@ "properties": { "value": { "type": "any", + "description": "For link columns, each entry is an object with \"id\", \"value\" and, if the column has linkAttributes defined and a value is set for that link, an \"attributes\" array (one value per linkAttributes definition, positional). \"attributes\" is omitted for links without a stored value, and omitted entirely (along with \"value\") when the linked row is hidden by row permissions.", "example": { "de": "Bayern", "en": "Bavaria" @@ -4988,6 +5055,18 @@ "constraint": { "$ref": "#/definitions/constraint" }, + "linkAttributes": { + "type": "array", + "description": "Definitions of attributes attached to this link column, if any. Only present if kind is \"link\" and at least one attribute is defined.", + "items": { + "$ref": "#/definitions/LinkAttributeDefinition" + } + }, + "formatPattern": { + "type": "string", + "description": "Pattern for the frontend how to render the content of a group or link column. Only present if set. See the column creation request for placeholder syntax.", + "example": "{{value}} ({{attributes.percentage}}%)" + }, "originColumns": { "$ref": "#/definitions/Response: OriginColumns array" } @@ -5336,6 +5415,50 @@ "group" ] }, + "LinkAttributeDefinition": { + "type": "object", + "description": "Definition of an attribute that can be attached to a link, in addition to which rows it connects. Its structure orients on ColumnDefinition, but it isn't a real column - it has no own id and is referenced by name only. Currently limited to a maximum of 1 entry per link column.", + "required": [ + "name", + "kind" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the attribute. Used to reference its value, e.g. in formatPattern as {{attributes.}}.", + "example": "percentage" + }, + "displayName": { + "$ref": "#/definitions/Multi-language object" + }, + "kind": { + "type": "string", + "description": "The type of the attribute's value.", + "enum": [ + "text", + "numeric", + "integer", + "boolean", + "date", + "datetime" + ] + }, + "multilanguage": { + "type": "boolean", + "description": "If true, the attribute's value is a multi-language object keyed by langtag instead of a single value. Defaults to false.", + "example": false + } + }, + "example": { + "name": "percentage", + "displayName": { + "de-DE": "Prozentanteil", + "en-GB": "Percentage" + }, + "kind": "integer", + "multilanguage": false + } + }, "Enum: Service type": { "type": "string", "description": "Different types of services", @@ -5466,9 +5589,27 @@ }, "formatPattern": { "type": "string", - "description": "Pattern for the frontend how to render the content of a group column. Placeholders can be either numeric positions like {{1}} (based on column IDs) or column names like {{firstName}}. The type must match the groups array (if groups uses IDs, use numeric placeholders; if groups uses names, use name placeholders).", + "description": "Pattern for the frontend how to render the content of a group or link column. For group columns, placeholders can be either numeric positions like {{1}} (based on column IDs) or column names like {{firstName}}; the type must match the groups array. For link columns, use {{value}} to reference the link's own value and {{attributes.}} to reference a linkAttributes value by name.", "example": "{{1}} × {{2}} × {{3}} mm (B × H × T)" }, + "linkAttributes": { + "type": "array", + "description": "Definitions of attributes attached to this link (only applicable if kind is \"link\"). Currently limited to a maximum of 1 entry. Can also be set/changed on an existing link column via the change-column endpoint; sending an empty array clears the definition and any stored attribute values, omitting the field leaves it untouched.", + "items": { + "$ref": "#/definitions/LinkAttributeDefinition" + }, + "example": [ + { + "name": "percentage", + "displayName": { + "de-DE": "Prozentanteil", + "en-GB": "Percentage" + }, + "kind": "integer", + "multilanguage": false + } + ] + }, "showMemberColumns": { "type": "boolean", "description": "Flag if the member columns should be displayed in the frontend table view (They are always displayed in entity view).", From c2be3ad185f92a118a645649f68cd1b031d9f6a4 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Fri, 7 Aug 2026 14:16:55 +0200 Subject: [PATCH 03/24] refactor: run spotlessApply --- .../tableaux/controller/StructureController.scala | 3 ++- .../com/campudus/tableaux/database/domain/link.scala | 8 ++++---- .../tableaux/database/model/structure/ColumnModel.scala | 3 ++- .../tableaux/database/model/tableaux/RowModel.scala | 7 ++++++- .../tableaux/api/content/LinkAttributesTest.scala | 6 +++++- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala index 0850c9c75..c4fa1f0dd 100644 --- a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala +++ b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala @@ -727,7 +727,8 @@ class StructureController( ) ) - val structureProperties: Seq[Option[Any]] = Seq(columnName, ordering, kind, identifier, countryCodes, linkAttributes) + val structureProperties: Seq[Option[Any]] = + Seq(columnName, ordering, kind, identifier, countryCodes, linkAttributes) val isAtLeastOneStructureProperty: Boolean = structureProperties.exists(_.isDefined) logger.info( diff --git a/src/main/scala/com/campudus/tableaux/database/domain/link.scala b/src/main/scala/com/campudus/tableaux/database/domain/link.scala index 8e48ab1e6..a1bdfd050 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/link.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/link.scala @@ -167,8 +167,8 @@ case class RightToLeft(from: TableId, to: TableId, constraint: Constraint) exten /** * Definition of a single attribute that can be carried by a link (in addition to which rows it connects). Its - * structure orients on ColumnDefinition, but it isn't a real column: it has no stable id (referenced by `name` - * only), no ordering, and doesn't live in `system_columns` - it's stored as part of `system_link_table.attributes`. + * structure orients on ColumnDefinition, but it isn't a real column: it has no stable id (referenced by `name` only), + * no ordering, and doesn't live in `system_columns` - it's stored as part of `system_link_table.attributes`. */ case class LinkAttributeDefinition( name: String, @@ -216,8 +216,8 @@ object LinkAttributeDefinition { } /** - * A row id to be linked, optionally carrying positional values for the target LinkColumn's `linkAttributes` - * (parallel to the definitions array, exactly like the `attributes` array in a link cell's JSON value). + * A row id to be linked, optionally carrying positional values for the target LinkColumn's `linkAttributes` (parallel + * to the definitions array, exactly like the `attributes` array in a link cell's JSON value). */ case class LinkValue(id: RowId, attributes: Option[JsonArray] = None) diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index 47fa647f4..bb663f03e 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -838,7 +838,8 @@ class ColumnModel(val connection: DatabaseConnection)( linkId = insertNotNull(result).head.get[Long](0) // insert link column on source table - (t, columnInfo) <- insertSystemColumn(t, tableId, linkColumnInfo, Some(linkId), linkColumnInfo.formatPattern, false) + (t, columnInfo) <- + insertSystemColumn(t, tableId, linkColumnInfo, Some(linkId), linkColumnInfo.formatPattern, false) // only add the second link column if tableId != toTableId or singleDirection is false t <- { diff --git a/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala b/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala index 32a5e8055..514e3918f 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala @@ -1,6 +1,11 @@ package com.campudus.tableaux.database.model.tableaux -import com.campudus.tableaux.{NotFoundInDatabaseException, RowNotFoundException, UnknownServerException, UnprocessableEntityException} +import com.campudus.tableaux.{ + NotFoundInDatabaseException, + RowNotFoundException, + UnknownServerException, + UnprocessableEntityException +} import com.campudus.tableaux.database._ import com.campudus.tableaux.database.domain.{MultiLanguageColumn, _} import com.campudus.tableaux.database.domain.DisplayInfos.Langtag diff --git a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala index 8aa56be7c..16607ca16 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala @@ -47,7 +47,11 @@ class LinkAttributesTest extends LinkTestBase { toTableId: TableId, multilanguage: Boolean = false ): Future[ColumnId] = { - sendRequest("POST", s"/tables/$tableId/columns", postLinkColWithAttributes(toTableId, multilanguage = multilanguage)) + sendRequest( + "POST", + s"/tables/$tableId/columns", + postLinkColWithAttributes(toTableId, multilanguage = multilanguage) + ) .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) } From a1a698ade95f23761aaa4919a11cc061a2dc2569 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Fri, 7 Aug 2026 14:49:55 +0200 Subject: [PATCH 04/24] refactor: checkValidValue in column.scala --- .../tableaux/database/domain/column.scala | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/src/main/scala/com/campudus/tableaux/database/domain/column.scala b/src/main/scala/com/campudus/tableaux/database/domain/column.scala index f35f087e7..76f9deee5 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/column.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/column.scala @@ -610,10 +610,28 @@ case class LinkColumn( case obj: JsonObject => val id = obj.getLong("id").longValue() val attributesOpt = Option(obj.getJsonArray("attributes")) - attributesOpt.foreach(attrs => LinkAttributeValueValidator.checkValidValue(linkAttributes, attrs).get) + attributesOpt.foreach(attrs => + LinkAttributeValueValidator.checkValidValue(linkAttributes, attrs).fold(throw _, identity) + ) LinkValue(id, attributesOpt) + case other => + throw InvalidJsonException( + s"Link value must be an id (Int/Long) or a JSON object with an 'id' field, but got ${other.getClass.getSimpleName}", + "link-value" + ) } + private def parseArrayElements(elements: Seq[Any]): Seq[LinkValue] = + elements.map({ + case id: Integer => extractLinkValue(id) + case obj: JsonObject => extractLinkValue(obj) + case invalidElement => + throw InvalidJsonException( + s"Expected Integer or JSON object in link values array, but got ${invalidElement.getClass.getSimpleName}: $invalidElement", + "link-value" + ) + }) + override def checkValidValue[B](value: B): Try[Option[Seq[LinkValue]]] = { Try { val castedValue = value match { @@ -627,10 +645,9 @@ case class LinkColumn( x.map(extractLinkValue) case x: JsonObject if x.containsKey("to") => - import ArgumentChecker._ hasLong("to", x) match { - case arg: OkArg[Long] => - Seq(LinkValue(arg.get)) + case OkArg(to) => + Seq(LinkValue(to)) case _ => throw InvalidJsonException( s"A link column expects a JSON object with to values, but got $x", @@ -639,40 +656,30 @@ case class LinkColumn( } case x: JsonObject if x.containsKey("values") => - val rawElements = Try(checked(hasArray("values", x)).asScala.toSeq) match { - case Success(elements) => - elements - case Failure(_) => - throw InvalidJsonException( - s"A link column expects a JSON object with to values, but got $x", - "link-value" - ) - } - - rawElements.map({ - case id: Integer => extractLinkValue(id) - case obj: JsonObject => extractLinkValue(obj) + hasArray("values", x) match { + case OkArg(arr) => + parseArrayElements(arr.asScala.toSeq) case _ => throw InvalidJsonException( s"A link column expects a JSON object with to values, but got $x", "link-value" ) - }) + } case x: JsonObject => - throw InvalidJsonException(s"A link column expects a JSON object with to values, but got $x", "link-value") + throw InvalidJsonException( + s"Link column expects a JSON object with either 'to' or 'values' field, but got $x", + "link-value" + ) case x: JsonArray => - x.asScala - .map({ - // need to check for java.lang.Integer because we are mapping over AnyRefs - case id: Integer => extractLinkValue(id) - case obj: JsonObject => extractLinkValue(obj) - }) - .toSeq + parseArrayElements(x.asScala.toSeq) case x => - throw InvalidJsonException(s"A link column expects a JSON object with values, but got $x", "link-value") + throw InvalidJsonException( + s"Link value must be null, an int, a sequence, or a JSON object; got ${x.getClass.getSimpleName}", + "link-value" + ) } Some(castedValue) From 8aa08974eb8b919b23c74f00efd027a79b8bb0d9 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Fri, 7 Aug 2026 15:06:06 +0200 Subject: [PATCH 05/24] fix: usage of get on optional type in StructureController --- .../controller/StructureController.scala | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala index c4fa1f0dd..ba07ad400 100644 --- a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala +++ b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala @@ -788,25 +788,25 @@ class StructureController( } _ <- - if (linkAttributes.nonEmpty) { - column match { - case _: LinkColumn => - if (linkAttributes.get.size > LinkAttributeDefinition.maxCount) { - Future.failed(UnprocessableEntityException( - s"Only ${LinkAttributeDefinition.maxCount} linkAttributes entry is currently supported, " + - s"but got ${linkAttributes.get.size}." + linkAttributes match { + case Some(attrs) => + column match { + case _: LinkColumn => + if (attrs.size > LinkAttributeDefinition.maxCount) { + Future.failed(UnprocessableEntityException( + s"Only ${LinkAttributeDefinition.maxCount} linkAttributes entry is currently supported, " + + s"but got ${attrs.size}." + )) + } else { + Future.successful(()) + } + case _ => + Future.failed(ForbiddenException( + s"Update of linkAttributes is not allowed for column ${column.kind}.", + "column" )) - } else { - Future.successful(()) - } - case _ => - Future.failed(ForbiddenException( - s"Update of linkAttributes is not allowed for column ${column.kind}.", - "column" - )) - } - } else { - Future.successful(()) + } + case None => Future.successful(()) } _ <- From d42bda88b081437ee1116633447428cb1e18163a Mon Sep 17 00:00:00 2001 From: smnhgn Date: Mon, 17 Aug 2026 14:25:28 +0200 Subject: [PATCH 06/24] fix: keep attributes in link to concat --- .../database/model/TableauxModel.scala | 5 ++- .../api/content/LinkAttributesTest.scala | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala b/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala index 34ff548bb..8b56f4757 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala @@ -1911,7 +1911,10 @@ class TableauxModel( cell <- retrieveCell(concatenateColumn, rowId, true) } yield { val cellJson = cell.getJson - list ++ List(Json.obj("id" -> rowId).mergeIn(cellJson)) + // Start from the raw linked row (carries fields the SQL projection already + // filled in, e.g. attributes/final/archived) and only overwrite its value, + // which the projection couldn't compute for a concat target. + list ++ List(linkedRow.copy().mergeIn(cellJson)) } } } diff --git a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala index 16607ca16..845c0ea09 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala @@ -2,6 +2,7 @@ package com.campudus.tableaux.api.content import com.campudus.tableaux.database.model.TableauxModel.{ColumnId, TableId} import com.campudus.tableaux.helper.Json +import com.campudus.tableaux.testtools.RequestCreation.{Identifier, NumericCol, TextCol} import io.vertx.ext.unit.TestContext import io.vertx.ext.unit.junit.VertxUnitRunner @@ -100,6 +101,36 @@ class LinkAttributesTest extends LinkTestBase { } } + @Test + def attributesSurviveLinkToTableWithConcatIdentifier(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50)))) + ) + + val expected = Json.obj( + "status" -> "ok", + "value" -> Json.arr(Json.obj("id" -> 1, "value" -> Json.arr("target row 1", 1), "attributes" -> Json.arr(50))) + ) + + for { + sourceTableId <- createDefaultTable() + // target table has two identifier columns from the start, so its representing + // column is a ConcatenateColumn - the read path then has to re-fetch each linked + // row's value (see TableauxModel.fetchConcatValuesForLinkedRows) instead of using + // the value the SQL projection already produced for a plain single-identifier target + (targetTableId, _, _) <- createSimpleTableWithValues( + "Target Table", + List(Identifier(TextCol("name")), Identifier(NumericCol("num"))), + List(List("target row 1", 1), List("target row 2", 2)) + ) + linkColumnId <- createLinkColumnWithAttributes(sourceTableId, targetTableId) + _ <- sendRequest("POST", s"/tables/$sourceTableId/columns/$linkColumnId/rows/1", putLink) + cell <- sendRequest("GET", s"/tables/$sourceTableId/columns/$linkColumnId/rows/1") + } yield { + assertEquals(expected, cell) + } + } + @Test def existingRequestShapesStillWorkUnchanged(implicit c: TestContext): Unit = okTest { val putLinks = Json.obj("value" -> Json.obj("values" -> Json.arr(1, 2))) From 19199358a07e63e4647ea85c63a6a82d5364d749 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Mon, 17 Aug 2026 15:08:04 +0200 Subject: [PATCH 07/24] fix: keep attributes in to shape link --- .../tableaux/database/domain/column.scala | 21 ++++++----- .../api/content/LinkAttributesTest.scala | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/main/scala/com/campudus/tableaux/database/domain/column.scala b/src/main/scala/com/campudus/tableaux/database/domain/column.scala index 76f9deee5..5c9637b53 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/column.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/column.scala @@ -602,18 +602,23 @@ case class LinkColumn( .mergeIn(formatPatternJson) } + // Shared by both the `{"id": ..., "attributes": [...]}` (values array) and `{"to": ..., + // "attributes": [...]}` (single-value) shapes, so attributes are honored the same way + // regardless of which key carries the target row id. + private def buildLinkValue(id: RowId, obj: JsonObject): LinkValue = { + val attributesOpt = Option(obj.getJsonArray("attributes")) + attributesOpt.foreach(attrs => + LinkAttributeValueValidator.checkValidValue(linkAttributes, attrs).fold(throw _, identity) + ) + LinkValue(id, attributesOpt) + } + // Handles both a bare id/RowId and a `{"id": ..., "attributes": [...]}` object; attributes are optional on // every element so all pre-existing request shapes (bare ids, or objects with only "id") keep working unchanged. private def extractLinkValue(v: Any): LinkValue = v match { case id: RowId => LinkValue(id) case id: Integer => LinkValue(id.toLong) - case obj: JsonObject => - val id = obj.getLong("id").longValue() - val attributesOpt = Option(obj.getJsonArray("attributes")) - attributesOpt.foreach(attrs => - LinkAttributeValueValidator.checkValidValue(linkAttributes, attrs).fold(throw _, identity) - ) - LinkValue(id, attributesOpt) + case obj: JsonObject => buildLinkValue(obj.getLong("id").longValue(), obj) case other => throw InvalidJsonException( s"Link value must be an id (Int/Long) or a JSON object with an 'id' field, but got ${other.getClass.getSimpleName}", @@ -647,7 +652,7 @@ case class LinkColumn( case x: JsonObject if x.containsKey("to") => hasLong("to", x) match { case OkArg(to) => - Seq(LinkValue(to)) + Seq(buildLinkValue(to, x)) case _ => throw InvalidJsonException( s"A link column expects a JSON object with to values, but got $x", diff --git a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala index 845c0ea09..b4c3d819f 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala @@ -101,6 +101,41 @@ class LinkAttributesTest extends LinkTestBase { } } + @Test + def createLinkWithAttributesUsingToShape(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("to" -> 1, "attributes" -> Json.arr(50)) + ) + + val expected = Json.obj( + "status" -> "ok", + "value" -> Json.arr(Json.obj("id" -> 1, "value" -> "table2row1", "attributes" -> Json.arr(50))) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + assertEquals(expected, cell) + } + } + + @Test + def rejectAttributesOnToShapeOnColumnWithoutDefinition(implicit c: TestContext): Unit = + exceptionTest("error.json.link-attributes") { + val putLink = Json.obj( + "value" -> Json.obj("to" -> 1, "attributes" -> Json.arr(50)) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumn(1, 2, singleDirection = false) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + } yield () + } + @Test def attributesSurviveLinkToTableWithConcatIdentifier(implicit c: TestContext): Unit = okTest { val putLink = Json.obj( From d562be02d8279af9a6ef9146722286fb8742134c Mon Sep 17 00:00:00 2001 From: smnhgn Date: Mon, 17 Aug 2026 15:20:45 +0200 Subject: [PATCH 08/24] fix: validate link formatPattern if set on create --- .../model/structure/ColumnModel.scala | 9 +++++ .../api/structure/ChangeStructureTest.scala | 37 ++++++++++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index bb663f03e..0a4e516d9 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -12,6 +12,7 @@ import com.campudus.tableaux.database.domain._ import com.campudus.tableaux.database.model.TableauxModel._ import com.campudus.tableaux.database.model.structure.CachedColumnModel._ import com.campudus.tableaux.database.model.structure.ColumnModel.isColumnGroupMatchingToFormatPattern +import com.campudus.tableaux.database.model.structure.ColumnModel.isLinkColumnMatchingToFormatPattern import com.campudus.tableaux.helper.Json import com.campudus.tableaux.helper.JsonUtils.asSeqOf import com.campudus.tableaux.helper.ResultChecker._ @@ -813,6 +814,14 @@ class ColumnModel(val connection: DatabaseConnection)( toCol = toTableColumns.head + _ = { + if (!isLinkColumnMatchingToFormatPattern(linkColumnInfo.formatPattern, linkColumnInfo.linkAttributes)) { + throw UnprocessableEntityException( + s"Invalid formatPattern: '${linkColumnInfo.formatPattern.orNull}' doesn't match link value/attributes" + ) + } + } + (t, result) <- t.query( """|INSERT INTO system_link_table ( | table_id_1, diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala index 6896ea68a..882222344 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala @@ -220,12 +220,17 @@ class ChangeStructureTest extends TableauxTestBase { ) } - private def createLinkColumn(linkAttributes: JsonArray = Json.arr()): Future[ColumnId] = { - val postJson = Json.obj( - "columns" -> Json.arr( - Json.obj("name" -> "Test Link 1", "kind" -> "link", "toTable" -> 2, "linkAttributes" -> linkAttributes) - ) - ) + private def createLinkColumn( + linkAttributes: JsonArray = Json.arr(), + formatPattern: Option[String] = None + ): Future[ColumnId] = { + val baseJson = + Json.obj("name" -> "Test Link 1", "kind" -> "link", "toTable" -> 2, "linkAttributes" -> linkAttributes) + val columnJson = formatPattern match { + case Some(pattern) => baseJson.mergeIn(Json.obj("formatPattern" -> pattern)) + case None => baseJson + } + val postJson = Json.obj("columns" -> Json.arr(columnJson)) for { _ <- createDefaultTable() @@ -436,4 +441,24 @@ class ChangeStructureTest extends TableauxTestBase { } yield () } + @Test + def createLinkColumnFormatPatternAccepted(implicit c: TestContext): Unit = okTest { + val pattern = "{{value}} ({{attributes.percentage}}%)" + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some(pattern)) + result <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertEquals(pattern, result.getString("formatPattern")) + } + } + + @Test + def createLinkColumnFormatPatternRejectedForUnknownToken(implicit c: TestContext): Unit = + // same pattern that changeLinkColumnFormatPatternRejectedForUnknownToken rejects on the change path - + // creating a link column must be rejected the same way instead of silently storing a broken pattern + exceptionTest("unprocessable.entity") { + createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some("{{attributes.doesNotExist}}")) + } + } From 5f6b0907f77746406e17145ac3bcd19d07577889 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Mon, 17 Aug 2026 15:33:24 +0200 Subject: [PATCH 09/24] fix: do not delete attribute values on definition update --- .../model/structure/ColumnModel.scala | 19 +++++++----- .../api/structure/ChangeStructureTest.scala | 29 +++++++++++++++++-- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index 0a4e516d9..e855706f0 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -1985,10 +1985,11 @@ class ColumnModel(val connection: DatabaseConnection)( } // Applies a linkAttributes definition change to system_link_table plus, when needed, migrates existing values - // already stored on link_table_. Diffing is by name (max-1 keeps this simple): no old + new = pure add - // (nothing to migrate); old + no new, or a rename (different name) = wipe stored values, since there's no - // continuity contract once the name that referenced them is gone; same name = reshape (multilanguage) then - // cast (kind) in place. + // already stored on link_table_. Diffing is by position (max-1 keeps this simple, there's only ever + // position 0): no old + new = pure add (nothing to migrate); old + no new = pure remove, so stored values are + // wiped since no definition is left to interpret them; old + new = same slot regardless of name/displayName + // (those are cosmetic and don't affect how a stored value is interpreted) - reshape (multilanguage) then cast + // (kind) in place. private def updateLinkAttributesDefinition( t: DbTransaction, table: Table, @@ -2009,14 +2010,18 @@ class ColumnModel(val connection: DatabaseConnection)( .getOrElse(Seq.empty) (t, _) <- (currentDefinitions.headOption, newDefinitions.headOption) match { - case (Some(oldDef), Some(newDef)) if oldDef.name == newDef.name => + case (Some(oldDef), Some(newDef)) => + // Same slot whether or not the name changed - a rename or displayName edit is cosmetic + // and must not invalidate stored values. Multilanguage/kind changes still reshape/cast + // in place; an incompatible kind change fails and rolls back the whole update, same as + // when the name stays the same. for { (t, _) <- reshapeLinkAttributeValues(t, table, linkTable, oldDef, newDef) (t, result) <- castLinkAttributeValues(t, linkTable, oldDef, newDef) } yield (t, result) - case (Some(_), _) => - // pure remove, or renamed to a different name - either way the old values no longer have a definition + case (Some(_), None) => + // pure remove - no definition is left to interpret the old values t.query(s"UPDATE $linkTable SET attributes = NULL") case (None, _) => diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala index 882222344..e504367e4 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala @@ -211,9 +211,13 @@ class ChangeStructureTest extends TableauxTestBase { } } - private def percentageAttribute(kind: String = "integer", multilanguage: Boolean = false): JsonObject = { + private def percentageAttribute( + kind: String = "integer", + multilanguage: Boolean = false, + name: String = "percentage" + ): JsonObject = { Json.obj( - "name" -> "percentage", + "name" -> name, "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), "kind" -> kind, "multilanguage" -> multilanguage @@ -329,6 +333,27 @@ class ChangeStructureTest extends TableauxTestBase { } } + @Test + def changeLinkColumnRenamingLinkAttributePreservesValues(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(name = "percentage"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + // rename only - same kind, same multilanguage flag, just a different name/displayName label + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "percent"))) + ) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals("percent", column.getJsonArray("linkAttributes").getJsonObject(0).getString("name")) + assertEquals(50, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + } + } + @Test def changeLinkColumnLinkAttributesKindMigrationSucceeds(implicit c: TestContext): Unit = okTest { val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) From 5c5de18c352f7ef8726b3ac3a3ff2eda8718571e Mon Sep 17 00:00:00 2001 From: smnhgn Date: Tue, 18 Aug 2026 13:20:05 +0200 Subject: [PATCH 10/24] fix: invalidate caches for dependent columns after kind change --- .../controller/StructureController.scala | 34 ++++ .../api/structure/ChangeStructureTest.scala | 155 +++++++++++++++++- 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala index ba07ad400..45b738166 100644 --- a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala +++ b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala @@ -862,9 +862,43 @@ class StructureController( } _ <- eventClient.invalidateColumn(tableId, columnId) + _ <- invalidateDependentColumnCaches(tableId, columnId, changedColumn) } yield changedColumn } + // Mirrors TableauxModel.invalidateCellAndDependentColumns' dependent-column walk, but for the + // whole column (every row) rather than a single cell - a structure change (e.g. linkAttributes + // migrating/wiping values) can affect every row, not just one. Without this, a column shared + // across tables (the backlink side of a bidirectional link, or a group column referencing this + // one) keeps serving cell values cached before the change. + private def invalidateDependentColumnCaches(tableId: TableId, columnId: ColumnId, column: ColumnType[?])( + implicit user: TableauxUser + ): Future[Unit] = { + def invalidateColumnCache: (TableId, ColumnId) => Future[?] = eventClient.invalidateColumn + + for { + _ <- + if (column.columnInformation.groupColumnIds.nonEmpty) { + Future.sequence(column.columnInformation.groupColumnIds.map(invalidateColumnCache(tableId, _))) + } else { + Future.successful(()) + } + + dependentGroupColumns <- columnStruc.retrieveDependentGroupColumn(tableId, columnId) + dependentLinkColumns <- columnStruc.retrieveDependencies(tableId) + dependentColumns = dependentGroupColumns ++ dependentLinkColumns + + _ <- Future.sequence(dependentColumns.map({ + case DependentColumnInformation(depTableId, depColumnId, _, _, groupColumnIds) => + val invalidateLinkColumn = invalidateColumnCache(depTableId, depColumnId) + val invalidateConcatColumn = invalidateColumnCache(depTableId, 0) + val invalidateGroupColumns = Future.sequence(groupColumnIds.map(invalidateColumnCache(depTableId, _))) + + invalidateLinkColumn.zip(invalidateConcatColumn).zip(invalidateGroupColumns) + })) + } yield () + } + def createTableGroup(displayInfos: Seq[DisplayInfo])(implicit user: TableauxUser): Future[TableGroup] = { checkArguments(nonEmpty(displayInfos, "displayName or description")) logger.info(s"createTableGroup $displayInfos") diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala index e504367e4..e64c5195e 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala @@ -1,6 +1,6 @@ package com.campudus.tableaux.api.structure -import com.campudus.tableaux.database.model.TableauxModel.ColumnId +import com.campudus.tableaux.database.model.TableauxModel.{ColumnId, TableId} import com.campudus.tableaux.helper.Json import com.campudus.tableaux.testtools.TableauxTestBase @@ -9,6 +9,7 @@ import io.vertx.ext.unit.junit.VertxUnitRunner import io.vertx.lang.scala.json.{JsonArray, JsonObject} import scala.concurrent.Future +import scala.jdk.CollectionConverters._ import org.junit.Assert._ import org.junit.Test @@ -229,7 +230,15 @@ class ChangeStructureTest extends TableauxTestBase { formatPattern: Option[String] = None ): Future[ColumnId] = { val baseJson = - Json.obj("name" -> "Test Link 1", "kind" -> "link", "toTable" -> 2, "linkAttributes" -> linkAttributes) + Json.obj( + "name" -> "Test Link 1", + "kind" -> "link", + "toTable" -> 2, + // explicit rather than relying on the default - a backlink column actually being + // created is load-bearing for the backlink-focused tests further down + "singleDirection" -> false, + "linkAttributes" -> linkAttributes + ) val columnJson = formatPattern match { case Some(pattern) => baseJson.mergeIn(Json.obj("formatPattern" -> pattern)) case None => baseJson @@ -243,6 +252,20 @@ class ChangeStructureTest extends TableauxTestBase { } yield result.getJsonArray("columns").getJsonObject(0).getLong("id").toLong } + // createLinkColumn() is bidirectional by default, so table 2 gets an auto-created backlink + // column pointing back at table 1 - this finds its id so tests can read from that side. + private def findBacklinkColumnId(toTable: TableId): Future[ColumnId] = { + sendRequest("GET", "/tables/2/columns").map( + _.getJsonArray("columns") + .asScala + .map(_.asInstanceOf[JsonObject]) + .collectFirst({ + case col if col.getString("kind") == "link" && col.getLong("toTable") == toTable => col.getLong("id").toLong + }) + .get + ) + } + @Test def createLinkColumnWithLinkAttributes(implicit c: TestContext): Unit = okTest { for { @@ -486,4 +509,132 @@ class ChangeStructureTest extends TableauxTestBase { createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some("{{attributes.doesNotExist}}")) } + // linkAttributes definitions live once in system_link_table, keyed by link_id, shared by both + // sides of a bidirectional link (see ColumnModel.retrieveLinkInformation). Attribute values + // likewise live once per edge in link_table_.attributes. So on a fresh read (nothing + // cached yet), both the definition and the value are symmetric - identical whether read from + // the forward link column or from its auto-created backlink. + @Test + def backlinkColumnSharesLinkAttributesDefinitionAndValue(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(30))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + backlinkColumnId <- findBacklinkColumnId(toTable = 1) + + forwardColumn <- sendRequest("GET", s"/tables/1/columns/$columnId") + backlinkColumn <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId") + + forwardCell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + backlinkCell <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId/rows/1") + } yield { + // same definition on both sides + assertJSONEquals(forwardColumn.getJsonArray("linkAttributes"), backlinkColumn.getJsonArray("linkAttributes")) + + // same attribute value on both sides - it's one edge, read from either end + assertEquals(30, forwardCell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + assertEquals(30, backlinkCell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + } + } + + // Regression test for a fixed cache-invalidation gap: StructureController.changeColumn used to + // invalidate only the cell-value cache of the column that was actually changed + // (eventClient.invalidateColumn at the end of changeColumn), unlike the row-write path + // (TableauxModel.invalidateCellAndDependentColumns), which also walks retrieveDependencies. + // Without that walk, a backlink cell already cached before a linkAttributes kind change kept + // serving the pre-migration value even though the column's own definition (and the forward + // side's cell) were already up to date. invalidateDependentColumnCaches closes that gap. + @Test + def backlinkCellCacheIsInvalidatedAfterLinkAttributesKindChange(implicit c: TestContext): Unit = okTest { + val putLink = + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr("50"))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "text"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + backlinkColumnId <- findBacklinkColumnId(toTable = 1) + + // populate the backlink cell's cache before the definition changes + backlinkCellBefore <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId/rows/1") + + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "integer"))) + ) + + forwardCellAfter <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + backlinkCellAfter <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId/rows/1") + } yield { + val beforeValue = backlinkCellBefore.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getValue(0) + assertEquals("50", beforeValue) + + // forward side is freshly read (never cached before the change) - correctly migrated to a number + val forwardValue = forwardCellAfter.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getValue(0) + assertEquals(50, forwardValue) + + // backlink side was cached before the change too, but the cache is now invalidated as part + // of the change - re-read fresh and reflects the same migrated number + val backlinkValue = + backlinkCellAfter.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getValue(0) + assertEquals(50, backlinkValue) + } + } + + // Same invalidateDependentColumnCaches fix, but exercised via a GroupColumn instead of a link - + // no linkAttributes involved. A GroupColumn's cell value is a plain array of its grouped + // columns' own values, so casting one grouped column's kind must invalidate the GroupColumn's + // already-cached cell too (the retrieveDependentGroupColumn half of the fix, as opposed to the + // retrieveDependencies/backlink half exercised above). + @Test + def changeColumnKindInvalidatesDependentGroupColumnCellCache(implicit c: TestContext): Unit = okTest { + val createTextColumnJson = Json.obj("columns" -> Json.arr(Json.obj("kind" -> "text", "name" -> "Text"))) + val createNumericColumnJson = Json.obj("columns" -> Json.arr(Json.obj("kind" -> "numeric", "name" -> "Number"))) + + for { + tableId <- sendRequest("POST", "/tables", Json.obj("name" -> "Group Cache Test")).map(_.getLong("id")) + + textColumnId <- sendRequest("POST", s"/tables/$tableId/columns", createTextColumnJson) + .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) + numericColumnId <- sendRequest("POST", s"/tables/$tableId/columns", createNumericColumnJson) + .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) + + createGroupColumnJson = Json.obj( + "columns" -> Json.arr( + Json.obj("kind" -> "group", "name" -> "Group", "groups" -> Json.arr(textColumnId, numericColumnId)) + ) + ) + groupColumnId <- sendRequest("POST", s"/tables/$tableId/columns", createGroupColumnJson) + .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) + + _ <- sendRequest( + "POST", + s"/tables/$tableId/rows", + Json.obj( + "columns" -> Json.arr(Json.obj("id" -> textColumnId), Json.obj("id" -> numericColumnId)), + "rows" -> Json.arr(Json.obj("values" -> Json.arr("5", 10))) + ) + ) + + // populate the group column's cell cache before the structure change + groupCellBefore <- sendRequest("GET", s"/tables/$tableId/columns/$groupColumnId/rows/1") + + // cast the text column's stored "5" to the number 5 + _ <- sendRequest("POST", s"/tables/$tableId/columns/$textColumnId", Json.obj("kind" -> "numeric")) + + textCellAfter <- sendRequest("GET", s"/tables/$tableId/columns/$textColumnId/rows/1") + groupCellAfter <- sendRequest("GET", s"/tables/$tableId/columns/$groupColumnId/rows/1") + } yield { + assertEquals(Json.arr("5", 10), groupCellBefore.getJsonArray("value")) + + // the changed column itself is freshly read (never cached before the change) - correctly cast + assertEquals(5, textCellAfter.getInteger("value")) + + // the group column depends on it and was cached before the change - now invalidated, reflects + // the same cast number instead of the stale pre-migration string + assertEquals(Json.arr(5, 10), groupCellAfter.getJsonArray("value")) + } + } + } From d828e24105801419966a3454119dfddc408c8d51 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Tue, 18 Aug 2026 14:56:40 +0200 Subject: [PATCH 11/24] fix: proper null handling for all link attribute kinds --- src/main/resources/swagger.json | 6 +- .../tableaux/database/domain/link.scala | 55 ++++--- .../model/structure/ColumnModel.scala | 35 +++- .../database/model/tableaux/RowModel.scala | 14 +- .../api/content/LinkAttributesTest.scala | 155 +++++++++++++++++- .../api/structure/ChangeStructureTest.scala | 126 ++++++++++++++ 6 files changed, 348 insertions(+), 43 deletions(-) diff --git a/src/main/resources/swagger.json b/src/main/resources/swagger.json index fd6b1311b..a29761403 100644 --- a/src/main/resources/swagger.json +++ b/src/main/resources/swagger.json @@ -2641,7 +2641,7 @@ ], "put": { "summary": "Changes the attribute values of a link.", - "description": "Sets the attribute values for the given link. The attributes array is positional and validated against the link column's linkAttributes definitions (same length, matching kind per entry). Requires the column to have at least one linkAttributes entry defined.", + "description": "Sets the attribute values for the given link. The attributes array is positional and validated against the link column's linkAttributes definitions (same length, matching kind per entry). null is accepted for every kind and clears the value; for a multilanguage attribute a single langtag can be set to null to clear just that language. Requires the column to have at least one linkAttributes entry defined.", "tags": [ "content" ], @@ -3807,7 +3807,7 @@ "properties": { "value": { "type": "any", - "description": "For link columns, each entry is an object with \"id\", \"value\" and, if the column has linkAttributes defined and a value is set for that link, an \"attributes\" array (one value per linkAttributes definition, positional). \"attributes\" is omitted for links without a stored value, and omitted entirely (along with \"value\") when the linked row is hidden by row permissions.", + "description": "For link columns, each entry is an object with \"id\", \"value\" and, if the column has linkAttributes defined and a value is set for that link, an \"attributes\" array (one value per linkAttributes definition, positional). Values are returned as stored, including cleared ones: an attribute without a value is null, and a multilanguage attribute keeps its cleared langtags as null. \"attributes\" is omitted for links without a stored value, and omitted entirely (along with \"value\") when the linked row is hidden by row permissions.", "example": { "de": "Bayern", "en": "Bavaria" @@ -5445,7 +5445,7 @@ }, "multilanguage": { "type": "boolean", - "description": "If true, the attribute's value is a multi-language object keyed by langtag instead of a single value. Defaults to false.", + "description": "If true, the attribute's value is a multi-language object keyed by langtag instead of a single value; langtags may be omitted or set to null to mean \"no value in this language\". Defaults to false. Changing this on an existing column reshapes stored values: false to true copies the value to every table langtag, true to false keeps the value of the first langtag that has one; a value that is null is left as null in both directions.", "example": false } }, diff --git a/src/main/scala/com/campudus/tableaux/database/domain/link.scala b/src/main/scala/com/campudus/tableaux/database/domain/link.scala index a1bdfd050..0b1e274c1 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/link.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/link.scala @@ -266,30 +266,37 @@ object LinkAttributeValueValidator { } private def checkKindValue(definition: LinkAttributeDefinition, value: Any): Unit = { - val result: Try[Any] = definition.kind match { - case TextType => - Try(value.asInstanceOf[String]) - case NumericType => - Try(value match { - case n: Number => n - case _ => throw new IllegalArgumentException(s"expected a number") - }) - case IntegerType => - Try(value match { - case i: Integer => i - case _ => throw new IllegalArgumentException(s"expected an integer") - }) - case BooleanType => - Try(value match { - case b: Boolean => b - case _ => throw new IllegalArgumentException(s"expected a boolean") - }) - case DateType => - Try(LocalDate.parse(value.asInstanceOf[String])) - case DateTimeType => - Try(DateTime.parse(value.asInstanceOf[String])) - case other => - Failure(new IllegalArgumentException(s"unsupported link attribute kind: $other")) + // Clearing a value is legal for every kind, not a type violation: null means "no value (in this language)", + // which is what a multilanguage attribute with only some langtags filled in looks like - and what a + // multilanguage flip leaves behind - so a value read back from the API has to be acceptable as a write again. + val result: Try[Any] = if (value == null) { + Success(null) + } else { + definition.kind match { + case TextType => + Try(value.asInstanceOf[String]) + case NumericType => + Try(value match { + case n: Number => n + case _ => throw new IllegalArgumentException(s"expected a number") + }) + case IntegerType => + Try(value match { + case i: Integer => i + case _ => throw new IllegalArgumentException(s"expected an integer") + }) + case BooleanType => + Try(value match { + case b: Boolean => b + case _ => throw new IllegalArgumentException(s"expected a boolean") + }) + case DateType => + Try(LocalDate.parse(value.asInstanceOf[String])) + case DateTimeType => + Try(DateTime.parse(value.asInstanceOf[String])) + case other => + Failure(new IllegalArgumentException(s"unsupported link attribute kind: $other")) + } } result match { diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index e855706f0..015e486ac 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -1912,6 +1912,14 @@ class ColumnModel(val connection: DatabaseConnection)( cast: String = "" ): String = s"UPDATE system_columns SET $columnName = ?$cast WHERE table_id = ? AND column_id = ?" + // Guards the value migrations below: only a slot that actually holds a value gets migrated. A slot holding JSON + // null is a value that is explicitly empty, and no migration can improve on that - reshaping it would just respell + // "empty" (as per-langtag nulls, or as null again), and casting it would take `attributes->>0` as a SQL NULL, which + // strict jsonb_set turns into a wiped attributes column. Note that `attributes->0 IS NOT NULL` does not cover the + // JSON null case on its own: `->` hands JSON null back as a jsonb value, so that check is true for it. + private val slotHoldsAValue = + "attributes IS NOT NULL AND attributes->0 IS NOT NULL AND jsonb_typeof(attributes->0) <> 'null'" + // Reshapes existing attribute values (position 0, the only slot while linkAttributes is capped at 1) to match a // multilanguage flip, before any kind cast runs on top. There's no cast for this - it's a structural change - so // false -> true duplicates the scalar under every table langtag, and true -> false collapses to the first langtag @@ -1938,15 +1946,19 @@ class ColumnModel(val connection: DatabaseConnection)( t.query( s"""|UPDATE $linkTable |SET attributes = jsonb_set(attributes, '{0}', jsonb_build_object($pairs)) - |WHERE attributes IS NOT NULL AND attributes->0 IS NOT NULL""".stripMargin, + |WHERE $slotHoldsAValue""".stripMargin, Json.arr(langtags*) ) } else { - val coalesceParts = (langtags.map(_ => "attributes->0->?::text") :+ "'null'::jsonb").mkString(", ") + // NULLIF is what makes "first langtag that actually has a value" true: `->` yields a JSON null (not a + // SQL NULL) for a langtag that is present but cleared, so a bare COALESCE would stop at that langtag + // and throw away a real value stored under a later one. + val coalesceParts = + (langtags.map(_ => "NULLIF(attributes->0->?::text, 'null'::jsonb)") :+ "'null'::jsonb").mkString(", ") t.query( s"""|UPDATE $linkTable |SET attributes = jsonb_set(attributes, '{0}', COALESCE($coalesceParts)) - |WHERE attributes IS NOT NULL AND attributes->0 IS NOT NULL""".stripMargin, + |WHERE $slotHoldsAValue""".stripMargin, Json.arr(langtags*) ) } @@ -1969,17 +1981,26 @@ class ColumnModel(val connection: DatabaseConnection)( t.query( s"""|UPDATE $linkTable |SET attributes = jsonb_set(attributes, '{0}', to_jsonb((attributes->>0)::${newDefinition.kind.toDbType})) - |WHERE attributes IS NOT NULL AND attributes->0 IS NOT NULL""".stripMargin + |WHERE $slotHoldsAValue""".stripMargin ) } else { + // Cleared langtags cast to null and keep their key: jsonb_each_text hands them over as SQL NULL, the cast + // passes that through, and jsonb_object_agg puts them back as JSON null. The COALESCE only catches the + // degenerate all-langtags-removed slot ({}), where the aggregate over zero rows is a SQL NULL - which strict + // jsonb_set would turn into a wiped attributes column instead of an untouched empty object. The guard is + // narrower than slotHoldsAValue because jsonb_each_text errors on anything but an object; a multilanguage + // slot can only hold an object or JSON null anyway, since the reshape above runs first. t.query( s"""|UPDATE $linkTable |SET attributes = jsonb_set( | attributes, '{0}', - | (SELECT jsonb_object_agg(key, to_jsonb(value::${newDefinition.kind.toDbType})) - | FROM jsonb_each_text(attributes->0)) + | COALESCE( + | (SELECT jsonb_object_agg(key, to_jsonb(value::${newDefinition.kind.toDbType})) + | FROM jsonb_each_text(attributes->0)), + | '{}'::jsonb + | ) |) - |WHERE attributes IS NOT NULL AND attributes->0 IS NOT NULL""".stripMargin + |WHERE attributes IS NOT NULL AND jsonb_typeof(attributes->0) = 'object'""".stripMargin ) } } diff --git a/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala b/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala index 514e3918f..0bbe011e1 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala @@ -1690,6 +1690,14 @@ class RetrieveRowModel(val connection: DatabaseConnection)( ) } + // attributes must not go through jsonb_strip_nulls: that works recursively, so an explicitly null langtag of a + // multilanguage attribute value would silently vanish from the response ([{"de-DE": null, "en-GB": 50}] becomes + // [{"en-GB": 50}], and a value that is null in every langtag even collapses to [{}]). Stored values are handed + // out as-is instead; only the "no attributes at all" case still omits the key, which the CASE does explicitly. + val attributes = + s"CASE WHEN lt$linkId.attributes IS NULL THEN '{}'::jsonb " + + s"ELSE jsonb_build_object('attributes', lt$linkId.attributes) END" + s"""( |SELECT | json_agg(sub.value) @@ -1701,10 +1709,10 @@ class RetrieveRowModel(val connection: DatabaseConnection)( | jsonb_strip_nulls( | jsonb_build_object( | 'final', CASE WHEN ut$toTableId.final IS TRUE THEN ut$toTableId.final ELSE NULL END, - | 'archived', CASE WHEN ut$toTableId.archived IS TRUE THEN ut$toTableId.archived ELSE NULL END, - | 'attributes', lt$linkId.attributes + | 'archived', CASE WHEN ut$toTableId.archived IS TRUE THEN ut$toTableId.archived ELSE NULL END | ) - | ) + | ) || + | $attributes | ) AS value | FROM | link_table_$linkId lt$linkId diff --git a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala index b4c3d819f..5125261db 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala @@ -17,11 +17,11 @@ import org.junit.runner.RunWith @RunWith(classOf[VertxUnitRunner]) class LinkAttributesTest extends LinkTestBase { - private def percentageAttribute(multilanguage: Boolean = false): JsonObject = { + private def percentageAttribute(multilanguage: Boolean = false, kind: String = "integer"): JsonObject = { Json.obj( "name" -> "percentage", "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), - "kind" -> "integer", + "kind" -> kind, "multilanguage" -> multilanguage ) } @@ -29,7 +29,9 @@ class LinkAttributesTest extends LinkTestBase { private def postLinkColWithAttributes( toTableId: TableId, name: String = "Test Link 1", - multilanguage: Boolean = false + multilanguage: Boolean = false, + kind: String = "integer", + singleDirection: Boolean = false ) = { Json.obj( "columns" -> Json.arr( @@ -37,7 +39,8 @@ class LinkAttributesTest extends LinkTestBase { "name" -> name, "kind" -> "link", "toTable" -> toTableId, - "linkAttributes" -> Json.arr(percentageAttribute(multilanguage)) + "singleDirection" -> singleDirection, + "linkAttributes" -> Json.arr(percentageAttribute(multilanguage, kind)) ) ) ) @@ -46,12 +49,21 @@ class LinkAttributesTest extends LinkTestBase { private def createLinkColumnWithAttributes( tableId: TableId, toTableId: TableId, - multilanguage: Boolean = false + multilanguage: Boolean = false, + kind: String = "integer", + name: String = "Test Link 1", + singleDirection: Boolean = false ): Future[ColumnId] = { sendRequest( "POST", s"/tables/$tableId/columns", - postLinkColWithAttributes(toTableId, multilanguage = multilanguage) + postLinkColWithAttributes( + toTableId, + name = name, + multilanguage = multilanguage, + kind = kind, + singleDirection = singleDirection + ) ) .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) } @@ -249,6 +261,137 @@ class LinkAttributesTest extends LinkTestBase { } } + // a langtag explicitly set to null is a value ("cleared in this language"), not an absence - it has to survive + // the read path, which is why RowModel.generateLinkProjection merges attributes outside of jsonb_strip_nulls + // (that one works recursively and would drop the langtag key from the response) + @Test + def multilanguageAttributeKeepsExplicitlyNullLangtag(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr( + Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> null, "en-GB" -> "sixty"))) + ) + ) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2, multilanguage = true, kind = "text") + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + row <- sendRequest("GET", s"/tables/1/rows/1") + } yield { + val cellAttributeValue = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getJsonObject(0) + assertTrue(cellAttributeValue.containsKey("de-DE")) + assertNull(cellAttributeValue.getValue("de-DE")) + assertEquals("sixty", cellAttributeValue.getString("en-GB")) + + // same projection feeds the row endpoint - link column is the last one of the default table plus the link + val rowLinkValue = row.getJsonArray("values").getJsonArray(2).getJsonObject(0) + val rowAttributeValue = rowLinkValue.getJsonArray("attributes").getJsonObject(0) + assertTrue(rowAttributeValue.containsKey("de-DE")) + assertNull(rowAttributeValue.getValue("de-DE")) + assertEquals("sixty", rowAttributeValue.getString("en-GB")) + } + } + + // clearing a single langtag is legal for every kind - a multilanguage value is rarely filled in for all languages + // at once, and a value read back from the API (where a cleared langtag shows up as null) has to be writable again + @Test + def multilanguageAttributeAcceptsClearedLangtagForEveryKind(implicit c: TestContext): Unit = okTest { + val valuePerKind: Seq[(String, Any)] = Seq( + "text" -> "sixty", + "integer" -> 50, + "numeric" -> 12.5, + "boolean" -> true, + "date" -> "2026-08-18", + "datetime" -> "2026-08-18T12:00:00.000Z" + ) + + for { + _ <- setupTwoTables() + _ <- valuePerKind.foldLeft(Future.successful(()))({ + case (previousKind, (kind, value)) => + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr( + Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> null, "en-GB" -> value))) + ) + ) + ) + + for { + _ <- previousKind + // single direction only because every bidirectional link would add a backlink column to table 2, all + // named after table 1 - one column per kind on the same pair of tables is the point of this test + columnId <- createLinkColumnWithAttributes( + 1, + 2, + multilanguage = true, + kind = kind, + name = s"Link $kind", + singleDirection = true + ) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + val attributeValue = + cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getJsonObject(0) + assertNull(s"cleared langtag of kind $kind", attributeValue.getValue("de-DE")) + assertEquals(s"value of kind $kind", value, attributeValue.getValue("en-GB")) + } + }) + } yield () + } + + @Test + def multilanguageAttributeWithClearedLangtagRoundtrips(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> null, "en-GB" -> 50)))) + ) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2, multilanguage = true) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + // writing back verbatim what was just read must be accepted, not rejected as a type violation + _ <- sendRequest( + "PUT", + s"/tables/1/columns/$linkColumnId/rows/1/link/1/attributes", + Json.obj("attributes" -> cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes")) + ) + afterRoundtrip <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + assertEquals(cell, afterRoundtrip) + } + } + + // an attribute value stored as JSON null must not be confused with "no attributes stored at all" (SQL NULL): + // the first one keeps its slot in the positional array, only the latter drops the key from the response + @Test + def nullAttributeValueKeepsItsSlot(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr().addNull()))) + ) + + val expected = Json.obj( + "status" -> "ok", + "value" -> Json.arr(Json.obj("id" -> 1, "value" -> "table2row1", "attributes" -> Json.arr().addNull())) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + assertEquals(expected, cell) + } + } + @Test def putLinkAttributesEndpointSuccess(implicit c: TestContext): Unit = okTest { val putLink = Json.obj( diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala index e64c5195e..befae0b60 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala @@ -463,6 +463,132 @@ class ChangeStructureTest extends TableauxTestBase { } } + // a stored null is a value that is already empty - duplicating it under every langtag would only respell that, + // so the reshape leaves the slot alone instead of producing an object full of nulls + @Test + def changeLinkColumnMultilanguageFalseToTrueLeavesNullValueUntouched(implicit c: TestContext): Unit = okTest { + val putLink = + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr().addNull())))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = false))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals(Json.arr().addNull(), cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes")) + } + } + + // collapsing has to pick the first langtag that carries an actual value: a cleared langtag comes back from `->` as + // a JSON null rather than a SQL NULL, so a plain COALESCE would stop at it and discard the value behind it + @Test + def changeLinkColumnMultilanguageTrueToFalseSkipsClearedLangtags(implicit c: TestContext): Unit = okTest { + // de-DE comes first in the table's langtags and is explicitly cleared, so en-GB's value has to win + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> null, "en-GB" -> 75)))) + ) + ) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = true))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals(75, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + } + } + + @Test + def changeLinkColumnLinkAttributesKindMigrationKeepsClearedLangtags(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> null, "en-GB" -> 50)))) + ) + ) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer", multilanguage = true))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric", multilanguage = true))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + val attributeValue = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getJsonObject(0) + // the cleared langtag keeps its key instead of being dropped by the cast + assertEquals(Set("de-DE", "en-GB"), attributeValue.fieldNames().asScala.toSet) + assertNull(attributeValue.getValue("de-DE")) + assertEquals(50.0, attributeValue.getValue("en-GB").asInstanceOf[Number].doubleValue(), 0.001) + } + } + + // degenerate but reachable: a multilanguage value with every langtag removed. Aggregating over its zero entries + // yields a SQL NULL, which strict jsonb_set would turn into a wiped attributes column instead of an empty object. + @Test + def changeLinkColumnMultilanguageLinkAttributesKindMigrationKeepsEmptyValue(implicit c: TestContext): Unit = okTest { + val putLink = + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj()))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer", multilanguage = true))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric", multilanguage = true))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals(Json.arr(Json.obj()), cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes")) + } + } + + // a slot holding null has nothing to cast, so the kind change goes through and the value stays put - casting it + // would hand strict jsonb_set a SQL NULL, which wipes the whole attributes column + @Test + def changeLinkColumnLinkAttributesKindMigrationLeavesNullValueUntouched(implicit c: TestContext): Unit = + kindMigrationOnNullValueKeepsSlot(multilanguage = false) + + // same for a multilanguage slot, where deconstructing the null would additionally error out ("cannot call + // jsonb_each_text on a non-object") and roll back the whole column change + @Test + def changeLinkColumnMultilanguageLinkAttributesKindMigrationLeavesNullValueUntouched( + implicit c: TestContext + ): Unit = kindMigrationOnNullValueKeepsSlot(multilanguage = true) + + private def kindMigrationOnNullValueKeepsSlot(multilanguage: Boolean)(implicit c: TestContext): Unit = okTest { + val putLink = + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr().addNull())))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer", multilanguage = multilanguage))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric", multilanguage = multilanguage))) + ) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals("numeric", column.getJsonArray("linkAttributes").getJsonObject(0).getString("kind")) + assertEquals(Json.arr().addNull(), cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes")) + } + } + @Test def changeLinkColumnFormatPatternAccepted(implicit c: TestContext): Unit = okTest { val pattern = "{{value}} ({{attributes.percentage}}%)" From a7ef7d5dc7573a6b8c681f948bd438182eafdbf7 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Tue, 18 Aug 2026 15:10:15 +0200 Subject: [PATCH 12/24] fix: remove duplicate check for maxCount --- .../tableaux/controller/StructureController.scala | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala index 45b738166..24538bf6f 100644 --- a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala +++ b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala @@ -787,19 +787,14 @@ class StructureController( Future(()) } + // No maxCount check here: JsonUtils.parseLinkAttributes already rejects an oversized array while parsing the + // request, so a second check would be unreachable over HTTP and could only ever drift away from the one that + // actually fires (error.json.linkAttributes). _ <- linkAttributes match { - case Some(attrs) => + case Some(_) => column match { - case _: LinkColumn => - if (attrs.size > LinkAttributeDefinition.maxCount) { - Future.failed(UnprocessableEntityException( - s"Only ${LinkAttributeDefinition.maxCount} linkAttributes entry is currently supported, " + - s"but got ${attrs.size}." - )) - } else { - Future.successful(()) - } + case _: LinkColumn => Future.successful(()) case _ => Future.failed(ForbiddenException( s"Update of linkAttributes is not allowed for column ${column.kind}.", From 0691d3234bd1b3e98428b24e73391c1fa336c669 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Tue, 18 Aug 2026 15:16:22 +0200 Subject: [PATCH 13/24] fix: add validation for link attribute names --- src/main/resources/swagger.json | 2 +- .../campudus/tableaux/helper/JsonUtils.scala | 30 +++++++++++- .../api/structure/ChangeStructureTest.scala | 48 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/main/resources/swagger.json b/src/main/resources/swagger.json index a29761403..32f067bc3 100644 --- a/src/main/resources/swagger.json +++ b/src/main/resources/swagger.json @@ -5425,7 +5425,7 @@ "properties": { "name": { "type": "string", - "description": "Name of the attribute. Used to reference its value, e.g. in formatPattern as {{attributes.}}.", + "description": "Name of the attribute. Used to reference its value, e.g. in formatPattern as {{attributes.}}. Only letters, digits and underscores are allowed, and the name must be unique within the column - it is an identifier, the human-readable label belongs in displayName.", "example": "percentage" }, "displayName": { diff --git a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala index 9050dd3bd..abfa1214e 100644 --- a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala +++ b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala @@ -334,9 +334,23 @@ object JsonUtils extends LazyLogging { ) } - entries.map({ + // A name is an identifier, not a label (that's displayName): it is how a value gets referenced in a + // formatPattern as {{attributes.}}, and those tokens are matched by ColumnModel's + // isLinkColumnMatchingToFormatPattern via \{\{([\w.]+)\}\}. So anything outside \w could never be + // referenced at all, and a name containing a dot would make a token like {{attributes.a.b}} ambiguous. + val allowedName = "\\w+".r + + val definitions = entries.map({ case entryJson: JsonObject => val name = checked(hasString("name", entryJson)) + + if (!allowedName.matches(name)) { + throw InvalidJsonException( + s"linkAttributes name '$name' is not allowed. Only letters, digits and underscores are allowed.", + "linkAttributes" + ) + } + val kind = checked(toTableauxType(checked(hasString("kind", entryJson)))) if (!LinkAttributeDefinition.allowedKinds.contains(kind)) { @@ -355,6 +369,20 @@ object JsonUtils extends LazyLogging { case other => throw InvalidJsonException(s"linkAttributes entries must be JSON objects, but got $other.", "linkAttributes") }) + + // Unreachable while maxCount is 1 (two entries fail the size check above first), but a value is addressed by + // name only - both in a formatPattern and in {{attributes.}} - so duplicates would be unresolvable as + // soon as the cap is raised. + val duplicateNames = definitions.groupBy(_.name).collect({ case (name, group) if group.size > 1 => name }) + + if (duplicateNames.nonEmpty) { + throw InvalidJsonException( + s"linkAttributes names must be unique, but got duplicates: ${duplicateNames.mkString(", ")}.", + "linkAttributes" + ) + } + + definitions } private def parseGroupReferences(json: JsonObject): (Seq[ColumnId], Seq[String]) = { diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala index befae0b60..e00208bb1 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala @@ -288,6 +288,54 @@ class ChangeStructureTest extends TableauxTestBase { createLinkColumn(Json.arr(percentageAttribute(kind = "link"))) } + // the name is the only handle a value has - {{attributes.}} in a formatPattern, matched by a \w-based + // regex - so a name that regex can never produce is rejected instead of being stored unreferenceable + private def createLinkColumnWithNameFails(name: String)(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + createLinkColumn(Json.arr(percentageAttribute(name = name))) + } + + @Test + def createLinkColumnWithEmptyLinkAttributeNameFails(implicit c: TestContext): Unit = + createLinkColumnWithNameFails("") + + @Test + def createLinkColumnWithBlankLinkAttributeNameFails(implicit c: TestContext): Unit = + createLinkColumnWithNameFails(" ") + + // a dot would make a token like {{attributes.a.b}} ambiguous + @Test + def createLinkColumnWithDottedLinkAttributeNameFails(implicit c: TestContext): Unit = + createLinkColumnWithNameFails("percentage.value") + + @Test + def createLinkColumnWithSpaceInLinkAttributeNameFails(implicit c: TestContext): Unit = + createLinkColumnWithNameFails("percent age") + + @Test + def createLinkColumnWithLinkAttributeNameOfLettersDigitsUnderscoreSucceeds(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(name = "percentage_2"))) + result <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertEquals("percentage_2", result.getJsonArray("linkAttributes").getJsonObject(0).getString("name")) + } + } + + // the change path parses linkAttributes with the same parser, so it rejects the same names + @Test + def changeLinkColumnToInvalidLinkAttributeNameFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "percent age"))) + ) + } yield () + } + @Test def changeLinkColumnAddLinkAttributes(implicit c: TestContext): Unit = okTest { for { From 43964b1d56344c7a5e1d76481f4d8bb36d366874 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Tue, 18 Aug 2026 16:24:11 +0200 Subject: [PATCH 14/24] fix: various PR issues: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Critical - **Validate `formatPattern` when only `linkAttributes` changes.** The check ran only on `formatPattern.isDefined`, so renaming or clearing an attribute left a stored pattern pointing at a `{{attributes.}}` token that no longer existed. Both directions are now validated against the effective pair after the change. Not covered by decision: `linkAttributes` live on the link (shared with the backlink column) while `formatPattern` lives on the column, so a change from one side is still not validated against a pattern on the other — documented in code and swagger. - **Reject multilanguage link attributes on tables without langtags (422).** `langtags: []` is a supported configuration, but with an empty langtag list the reshape replaced every stored value with `{}` or `null` and committed successfully. Rejected on create and change; the reshape asserts the invariant too, because the failure mode was silent data loss. - **Validate langtag keys on write, and collapse langtag-agnostically.** Unknown keys are rejected against the table's langtags. The multilanguage → language-neutral collapse now walks the stored object via `jsonb_each` (ordered by `array_position`) instead of the table's langtag list, so a value under a since-removed langtag survives instead of being dropped silently. ## Important - Re-assert `maxCount` and the multilanguage invariant in `updateLinkAttributesDefinition` — the migrations only ever touch position 0, so a second entry would be persisted and never migrated. - Persist and return a link attribute's `description`; it was parsed but dropped on serialization, and that JSON is what gets stored, so it was lost for good. - Skip the link-attribute history query for columns without definitions (a guaranteed-empty round trip per link column per change for the entire existing stock). - Add `WHERE attributes IS NOT NULL` to the attribute-wipe UPDATE. - Invalidate dependent column caches only on structure changes, gated on the existing `isAtLeastOneStructureProperty` flag. - Normalize `date`/`datetime`: writes via Joda, kind migrations via the same `TO_CHAR` formats the row projections use. Two spellings of one instant could previously coexist, and a migration silently changed a value's format. ## Minor - Reduce `parseArrayElements` to `map(extractLinkValue)` (it re-matched, and rejected `Long` where the `Seq[_]` path accepted it). - Replace `asInstanceOf` in the value validator with explicit type expectations, so clients no longer get a raw `ClassCastException` message. - Treat an empty `attributes` array as no value instead of storing `[]`. - Guard `updateLinkAttributesDefinition` against non-link columns (was an NPE on a NULL `link_id`). - Move link-attribute structure tests into `ChangeLinkAttributesStructureTest`; `ChangeStructureTest` is byte-identical to master again. - Harden the `schema_v43` backfill: filter `table_type = 'BASE TABLE'` and resolve via `format('%I.%I', ...)` instead of a `search_path`-dependent unqualified cast. - Carry only `attributes` over when rebuilding a concat-target link row, instead of every projection field — the previous version newly surfaced `final`/`archived` there, and inconsistently. ## Tests 20 new tests, one per fix. `dateTimeValueIsNormalizedIdenticallyByWriteAndMigration` writes the same instant directly and via a kind migration and compares them — it is the only thing keeping the Joda format in `LinkAttributeValueValidator` and the `TO_CHAR` format in `ModelHelper` in sync. --- src/main/resources/schema/schema_v43.sql | 8 +- src/main/resources/swagger.json | 15 +- .../controller/StructureController.scala | 42 +- .../tableaux/database/domain/column.scala | 39 +- .../tableaux/database/domain/link.scala | 186 +++- .../database/model/HistoryModel.scala | 30 +- .../database/model/TableauxModel.scala | 29 +- .../model/structure/ColumnModel.scala | 98 +- .../database/model/tableaux/RowModel.scala | 5 +- .../campudus/tableaux/helper/JsonUtils.scala | 7 +- .../api/content/LinkAttributesTest.scala | 192 ++++ .../ChangeLinkAttributesStructureTest.scala | 926 ++++++++++++++++++ .../api/structure/ChangeStructureTest.scala | 602 ------------ 13 files changed, 1466 insertions(+), 713 deletions(-) create mode 100644 src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala diff --git a/src/main/resources/schema/schema_v43.sql b/src/main/resources/schema/schema_v43.sql index 36c8c927c..0f35816d9 100644 --- a/src/main/resources/schema/schema_v43.sql +++ b/src/main/resources/schema/schema_v43.sql @@ -11,9 +11,13 @@ BEGIN END $$ LANGUAGE plpgsql; -SELECT add_attributes_column_to_link_table(table_name :: REGCLASS) +-- format('%I.%I', ...) instead of a bare table_name cast: resolving an unqualified name to a REGCLASS goes through +-- the connection's search_path, which is not necessarily the schema table_schema just filtered on. BASE TABLE keeps +-- views and foreign tables out - information_schema.tables lists them alongside real tables, and neither can take +-- an ALTER TABLE ... ADD COLUMN. +SELECT add_attributes_column_to_link_table(format('%I.%I', table_schema, table_name) :: REGCLASS) FROM information_schema.tables -WHERE table_schema = 'public' AND table_name LIKE 'link_table_%' +WHERE table_schema = 'public' AND table_type = 'BASE TABLE' AND table_name LIKE 'link_table_%' ORDER BY table_name; DROP FUNCTION add_attributes_column_to_link_table(REGCLASS); diff --git a/src/main/resources/swagger.json b/src/main/resources/swagger.json index 32f067bc3..10c4fa0f0 100644 --- a/src/main/resources/swagger.json +++ b/src/main/resources/swagger.json @@ -1238,7 +1238,7 @@ }, "linkAttributes": { "type": "array", - "description": "Definitions of attributes attached to a link column (only applicable if the column is of kind \"link\"). Send an empty array to clear the definition and any stored attribute values; omit the field entirely to leave it untouched.", + "description": "Definitions of attributes attached to a link column (only applicable if the column is of kind \"link\"). Send an empty array to clear the definition and any stored attribute values; omit the field entirely to leave it untouched. Validated against the column's formatPattern in both directions: changing only the definitions is rejected with 422 if the stored pattern references an attribute that would no longer exist, and changing only the pattern is rejected if it references an attribute the column does not have.", "items": { "$ref": "#/definitions/LinkAttributeDefinition" } @@ -2641,7 +2641,7 @@ ], "put": { "summary": "Changes the attribute values of a link.", - "description": "Sets the attribute values for the given link. The attributes array is positional and validated against the link column's linkAttributes definitions (same length, matching kind per entry). null is accepted for every kind and clears the value; for a multilanguage attribute a single langtag can be set to null to clear just that language. Requires the column to have at least one linkAttributes entry defined.", + "description": "Sets the attribute values for the given link. The attributes array is positional and validated against the link column's linkAttributes definitions (same length, matching kind per entry, and for a multilanguage attribute every langtag key must be one of the table's langtags). null is accepted for every kind and clears the value; for a multilanguage attribute a single langtag can be set to null to clear just that language. date and datetime values are normalized before being stored. Requires the column to have at least one linkAttributes entry defined.", "tags": [ "content" ], @@ -3807,7 +3807,7 @@ "properties": { "value": { "type": "any", - "description": "For link columns, each entry is an object with \"id\", \"value\" and, if the column has linkAttributes defined and a value is set for that link, an \"attributes\" array (one value per linkAttributes definition, positional). Values are returned as stored, including cleared ones: an attribute without a value is null, and a multilanguage attribute keeps its cleared langtags as null. \"attributes\" is omitted for links without a stored value, and omitted entirely (along with \"value\") when the linked row is hidden by row permissions.", + "description": "For link columns, each entry is an object with \"id\", \"value\" and, if the column has linkAttributes defined and a value is set for that link, an \"attributes\" array (one value per linkAttributes definition, positional). On write, an empty \"attributes\" array means the same as omitting the key. Values are returned as stored, including cleared ones: an attribute without a value is null, and a multilanguage attribute keeps its cleared langtags as null. \"attributes\" is omitted for links without a stored value, and omitted entirely (along with \"value\") when the linked row is hidden by row permissions.", "example": { "de": "Bayern", "en": "Bavaria" @@ -5431,9 +5431,12 @@ "displayName": { "$ref": "#/definitions/Multi-language object" }, + "description": { + "$ref": "#/definitions/Multi-language object" + }, "kind": { "type": "string", - "description": "The type of the attribute's value.", + "description": "The type of the attribute's value. date and datetime values are normalized on write: a datetime is stored and returned in UTC as YYYY-MM-DDTHH:mm:ss.sssZ regardless of the offset it was sent with, a date as YYYY-MM-DD.", "enum": [ "text", "numeric", @@ -5445,7 +5448,7 @@ }, "multilanguage": { "type": "boolean", - "description": "If true, the attribute's value is a multi-language object keyed by langtag instead of a single value; langtags may be omitted or set to null to mean \"no value in this language\". Defaults to false. Changing this on an existing column reshapes stored values: false to true copies the value to every table langtag, true to false keeps the value of the first langtag that has one; a value that is null is left as null in both directions.", + "description": "If true, the attribute's value is a multi-language object keyed by langtag instead of a single value; langtags may be omitted or set to null to mean \"no value in this language\", and every key must be one of the table's langtags. Defaults to false. Requires the table to have langtags - a multi-language value is addressed by langtag, so it is rejected with 422 on a table created with \"langtags\": []. Changing this on an existing column reshapes stored values: false to true copies the value to every table langtag, true to false keeps the value of the langtag that has one (table langtags first in their configured order, then any other langtag still present in the stored value); a value that is null is left as null in both directions.", "example": false } }, @@ -5594,7 +5597,7 @@ }, "linkAttributes": { "type": "array", - "description": "Definitions of attributes attached to this link (only applicable if kind is \"link\"). Currently limited to a maximum of 1 entry. Can also be set/changed on an existing link column via the change-column endpoint; sending an empty array clears the definition and any stored attribute values, omitting the field leaves it untouched.", + "description": "Definitions of attributes attached to this link (only applicable if kind is \"link\"). Currently limited to a maximum of 1 entry. Can also be set/changed on an existing link column via the change-column endpoint; sending an empty array clears the definition and any stored attribute values, omitting the field leaves it untouched. A change is validated against the column's formatPattern - renaming or clearing an attribute that the stored pattern references as {{attributes.}} is rejected with 422; send a matching formatPattern in the same request. Note that linkAttributes are shared with the auto-created backlink column while formatPattern is per column, so changing the definitions from one side is not validated against a formatPattern set on the other side.", "items": { "$ref": "#/definitions/LinkAttributeDefinition" }, diff --git a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala index 24538bf6f..8a105ee0c 100644 --- a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala +++ b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala @@ -789,7 +789,7 @@ class StructureController( // No maxCount check here: JsonUtils.parseLinkAttributes already rejects an oversized array while parsing the // request, so a second check would be unreachable over HTTP and could only ever drift away from the one that - // actually fires (error.json.linkAttributes). + // actually fires (error.json.linkAttributes). ColumnModel re-asserts it where the cap is load-bearing. _ <- linkAttributes match { case Some(_) => @@ -804,10 +804,20 @@ class StructureController( case None => Future.successful(()) } + // A link column's formatPattern and its linkAttributes constrain each other - the pattern references the + // definitions by name as {{attributes.}} - so both directions have to be validated. Changing only the + // definitions (renaming an attribute, or clearing them with an empty array) invalidates a pattern stored + // earlier just as thoroughly as changing only the pattern does, and since formatPattern can't be set back to + // null through this endpoint, such a pattern can't easily be repaired afterwards either. + // + // Scope: this covers the column being changed. linkAttributes live on the link (system_link_table, shared with + // the backlink column) while formatPattern lives on the column (system_columns), so changing the definitions + // from one side can still leave a pattern on the *other* side dangling. Deliberately not chased here - it + // would mean loading the opposite column on every change - see the swagger note on linkAttributes. _ <- - if (formatPattern.isDefined) { + if (formatPattern.isDefined || linkAttributes.isDefined) { column match { - case groupColumn: GroupColumn => { + case groupColumn: GroupColumn if formatPattern.isDefined => { if (!isColumnGroupMatchingToFormatPattern(formatPattern, groupColumn.columns)) { val columnsIds = groupColumn.columns.map(_.id).mkString(", "); @@ -819,23 +829,26 @@ class StructureController( } } case linkColumn: LinkColumn => { - // if linkAttributes is also being changed in this same request, validate against the new - // definitions rather than the column's current (pre-change) ones + // Whichever of the two the request omits is taken from the column as it stands, so the check always + // sees the pair as it will be after the change. + val effectiveFormatPattern = formatPattern.orElse(linkColumn.formatPattern) val effectiveLinkAttributes = linkAttributes.getOrElse(linkColumn.linkAttributes) - if (!isLinkColumnMatchingToFormatPattern(formatPattern, effectiveLinkAttributes)) { + if (!isLinkColumnMatchingToFormatPattern(effectiveFormatPattern, effectiveLinkAttributes)) { Future.failed(UnprocessableEntityException( - s"Invalid formatPattern: '$formatPattern' doesn't match link value/attributes" + s"Invalid formatPattern: '${effectiveFormatPattern.orNull}' doesn't match link value/attributes" )) } else { Future.successful(()) } } - case _ => + case _ if formatPattern.isDefined => Future.failed(ForbiddenException( s"Update of formatPattern '$formatPattern' is not allowed for column ${column.kind}.", "column" )) + // linkAttributes on a non-link column was already rejected above + case _ => Future.successful(()) } } else { Future.successful(()) @@ -857,7 +870,18 @@ class StructureController( } _ <- eventClient.invalidateColumn(tableId, columnId) - _ <- invalidateDependentColumnCaches(tableId, columnId, changedColumn) + + // Only a structure change can alter what other columns' cells resolve to; a display property (displayName, + // hidden, formatPattern, decimalDigits, ...) is rendered by the frontend and leaves every cached cell value + // valid. Gating on the same flag the authorization check above uses keeps the two notions of "structure + // change" from drifting apart, and spares the dependency walk - which fans out over every table linking here - + // on the common case of renaming a column. + _ <- + if (isAtLeastOneStructureProperty) { + invalidateDependentColumnCaches(tableId, columnId, changedColumn) + } else { + Future.successful(()) + } } yield changedColumn } diff --git a/src/main/scala/com/campudus/tableaux/database/domain/column.scala b/src/main/scala/com/campudus/tableaux/database/domain/column.scala index 5c9637b53..0b46bbb33 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/column.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/column.scala @@ -602,19 +602,33 @@ case class LinkColumn( .mergeIn(formatPatternJson) } + // Langtags of the table this link column belongs to, used to reject unknown langtag keys in a multilanguage + // attribute value. TableModel.convertRowToTable falls back to the global langtags when a table has none of its + // own, so this is populated for every table that has any langtags at all; an empty Seq (a table explicitly + // created with `langtags: []`) turns the check off rather than failing every write. + private def tableLangtags: Seq[String] = columnInformation.table.langtags.getOrElse(Seq.empty) + // Shared by both the `{"id": ..., "attributes": [...]}` (values array) and `{"to": ..., // "attributes": [...]}` (single-value) shapes, so attributes are honored the same way // regardless of which key carries the target row id. private def buildLinkValue(id: RowId, obj: JsonObject): LinkValue = { - val attributesOpt = Option(obj.getJsonArray("attributes")) - attributesOpt.foreach(attrs => - LinkAttributeValueValidator.checkValidValue(linkAttributes, attrs).fold(throw _, identity) - ) - LinkValue(id, attributesOpt) + val attributes = Option(obj.getJsonArray("attributes")) + .map(attrs => + LinkAttributeValueValidator + .normalize(linkAttributes, attrs, tableLangtags) + .fold(throw _, identity) + ) + // An empty array carries no value for any definition (it only validates when there are none), so it is the + // same thing as sending no attributes at all - stored as SQL NULL rather than as `[]`, which would otherwise + // put an `attributes` key on a column that has no definitions to interpret it. + .filter(_.size() > 0) + + LinkValue(id, attributes) } - // Handles both a bare id/RowId and a `{"id": ..., "attributes": [...]}` object; attributes are optional on - // every element so all pre-existing request shapes (bare ids, or objects with only "id") keep working unchanged. + // Handles a bare id (Integer or Long) as well as a `{"id": ..., "attributes": [...]}` object; attributes are + // optional on every element so all pre-existing request shapes (bare ids, or objects with only "id") keep + // working unchanged. private def extractLinkValue(v: Any): LinkValue = v match { case id: RowId => LinkValue(id) case id: Integer => LinkValue(id.toLong) @@ -626,16 +640,7 @@ case class LinkColumn( ) } - private def parseArrayElements(elements: Seq[Any]): Seq[LinkValue] = - elements.map({ - case id: Integer => extractLinkValue(id) - case obj: JsonObject => extractLinkValue(obj) - case invalidElement => - throw InvalidJsonException( - s"Expected Integer or JSON object in link values array, but got ${invalidElement.getClass.getSimpleName}: $invalidElement", - "link-value" - ) - }) + private def parseArrayElements(elements: Seq[Any]): Seq[LinkValue] = elements.map(extractLinkValue) override def checkValidValue[B](value: B): Try[Option[Seq[LinkValue]]] = { Try { diff --git a/src/main/scala/com/campudus/tableaux/database/domain/link.scala b/src/main/scala/com/campudus/tableaux/database/domain/link.scala index 0b1e274c1..75135a2e9 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/link.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/link.scala @@ -1,6 +1,6 @@ package com.campudus.tableaux.database.domain -import com.campudus.tableaux.InvalidJsonException +import com.campudus.tableaux.{InvalidJsonException, UnprocessableEntityException} import com.campudus.tableaux.database._ import com.campudus.tableaux.database.model.TableauxModel._ import com.campudus.tableaux.helper.Json @@ -10,7 +10,7 @@ import io.vertx.lang.scala.json._ import scala.jdk.CollectionConverters._ import scala.util.{Failure, Success, Try} -import org.joda.time.{DateTime, LocalDate} +import org.joda.time.{DateTime, DateTimeZone, LocalDate} case class Cardinality(from: Int, to: Int) @@ -199,6 +199,9 @@ object LinkAttributeDefinition { } def getJson(attr: LinkAttributeDefinition): JsonObject = { + // Both halves of a DisplayInfo have to be written out, exactly like ColumnType.getJson does it: this JSON is + // not just the API response, it is also what gets persisted to system_link_table.attributes - so anything + // dropped here is dropped for good, not merely hidden from the response. val displayNameJson = attr.displayInfos.foldLeft(Json.obj()) { case (acc, displayInfo) => displayInfo.optionalName @@ -206,13 +209,47 @@ object LinkAttributeDefinition { .getOrElse(acc) } + val descriptionJson = attr.displayInfos.foldLeft(Json.obj()) { + case (acc, displayInfo) => + displayInfo.optionalDescription + .map(description => acc.mergeIn(Json.obj(displayInfo.langtag -> description))) + .getOrElse(acc) + } + Json.obj( "name" -> attr.name, "displayName" -> displayNameJson, + "description" -> descriptionJson, "kind" -> attr.kind.toString, "multilanguage" -> attr.multilanguage ) } + + /** + * A multilanguage attribute value is an object keyed by langtag, so without langtags there is no way to address one - + * and the value migrations in ColumnModel would have nothing to reshape into or collapse from, which used to + * silently destroy stored values. Rejecting the definition up front keeps that state unreachable. + */ + def checkMultilanguageAllowed(langtags: Seq[String], definitions: Seq[LinkAttributeDefinition]): Unit = { + if (langtags.isEmpty) { + definitions.filter(_.multilanguage).foreach(definition => + throw UnprocessableEntityException( + s"Link attribute '${definition.name}' can't be multilanguage because its table has no langtags." + ) + ) + } + } + + // Takes a count rather than the definitions so JsonUtils can reject an oversized array before it starts + // validating individual entries, and still produce the exact same error as the model-level assertion. + def checkMaxCount(count: Int): Unit = { + if (count > maxCount) { + throw InvalidJsonException( + s"Only $maxCount linkAttributes entry is currently supported, but got $count.", + "linkAttributes" + ) + } + } } /** @@ -228,7 +265,34 @@ case class LinkValue(id: RowId, attributes: Option[JsonArray] = None) */ object LinkAttributeValueValidator { - def checkValidValue(definitions: Seq[LinkAttributeDefinition], attributes: JsonArray): Try[Unit] = Try { + /** + * Canonical wire format for a `datetime` attribute value. Deliberately the Joda equivalent of ModelHelper's + * `dateTimeFormat`, which is what a real datetime column is rendered with and what ColumnModel's kind migration + * produces - a value has to look the same no matter whether it was written through the API or cast by a migration. + * LinkAttributesTest.dateTimeValueIsNormalizedIdenticallyByWriteAndMigration pins the two together. + */ + private val dateTimeFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + + def checkValidValue( + definitions: Seq[LinkAttributeDefinition], + attributes: JsonArray, + allowedLangtags: Seq[String] = Seq.empty + ): Try[Unit] = normalize(definitions, attributes, allowedLangtags).map(_ => ()) + + /** + * Validates a value array against its definitions and returns it in canonical form. Validating and normalizing are + * the same pass on purpose: every kind that has more than one spelling for the same value (date, datetime) has to be + * parsed to be checked anyway, and letting the parsed result fall on the floor is what allowed two spellings of one + * instant to be stored side by side. + * + * `allowedLangtags` empty means "don't check langtag keys" - the caller either has no table langtags to check + * against, or is a path where they aren't resolvable synchronously. + */ + def normalize( + definitions: Seq[LinkAttributeDefinition], + attributes: JsonArray, + allowedLangtags: Seq[String] = Seq.empty + ): Try[JsonArray] = Try { val values = Option(attributes).map(_.asScala.toSeq).getOrElse(Seq.empty) if (values.size != definitions.size) { @@ -238,13 +302,27 @@ object LinkAttributeValueValidator { ) } + val normalized = new JsonArray() + definitions.zip(values).foreach { case (definition, rawValue) => if (definition.multilanguage) { rawValue match { - case null => // no value set for this attribute, ok + case null => normalized.addNull() case obj: JsonObject => - obj.getMap.asScala.foreach({ case (_, langValue) => checkKindValue(definition, langValue) }) + val normalizedObj = new JsonObject() + + obj.getMap.asScala.foreach({ + case (langtag, langValue) => + checkLangtag(definition, langtag, allowedLangtags) + + normalizeKindValue(definition, langValue) match { + case null => normalizedObj.putNull(langtag) + case value => normalizedObj.put(langtag, value) + } + }) + + normalized.add(normalizedObj) case other => throw InvalidJsonException( s"Attribute '${definition.name}' is multilanguage and expects an object of langtag to value, but got $other.", @@ -253,54 +331,88 @@ object LinkAttributeValueValidator { } } else { rawValue match { - case null => // no value set for this attribute, ok + case null => normalized.addNull() case _: JsonObject => throw InvalidJsonException( s"Attribute '${definition.name}' is not multilanguage and expects a single value, but got an object.", "link-attributes" ) - case value => checkKindValue(definition, value) + case value => + normalizeKindValue(definition, value) match { + case null => normalized.addNull() + case normalizedValue => normalized.add(normalizedValue) + } } } } + + normalized } - private def checkKindValue(definition: LinkAttributeDefinition, value: Any): Unit = { - // Clearing a value is legal for every kind, not a type violation: null means "no value (in this language)", - // which is what a multilanguage attribute with only some langtags filled in looks like - and what a - // multilanguage flip leaves behind - so a value read back from the API has to be acceptable as a write again. - val result: Try[Any] = if (value == null) { + private def checkLangtag( + definition: LinkAttributeDefinition, + langtag: String, + allowedLangtags: Seq[String] + ): Unit = { + if (allowedLangtags.nonEmpty && !allowedLangtags.contains(langtag)) { + throw InvalidJsonException( + s"Langtag '$langtag' of attribute '${definition.name}' is not one of its table's langtags " + + s"(${allowedLangtags.mkString(", ")}).", + "link-attributes" + ) + } + } + + /** + * Returns the value in canonical form for its kind, or null if it is cleared. Clearing is legal for every kind and + * not a type violation: null means "no value (in this language)", which is what a multilanguage attribute with only + * some langtags filled in looks like - and what a multilanguage flip leaves behind - so a value read back from the + * API has to be acceptable as a write again. + */ + private def normalizeKindValue(definition: LinkAttributeDefinition, value: Any): AnyRef = { + // Every branch reports what it expected rather than letting a ClassCastException's message through - "class + // java.lang.Integer cannot be cast to class java.lang.String" is not something to hand an API client. + def expected(what: String): Nothing = throw new IllegalArgumentException(s"expected $what") + + def asString(what: String): String = value match { + case s: String => s + case _ => expected(what) + } + + val result: Try[AnyRef] = if (value == null) { Success(null) } else { - definition.kind match { - case TextType => - Try(value.asInstanceOf[String]) - case NumericType => - Try(value match { - case n: Number => n - case _ => throw new IllegalArgumentException(s"expected a number") - }) - case IntegerType => - Try(value match { - case i: Integer => i - case _ => throw new IllegalArgumentException(s"expected an integer") - }) - case BooleanType => - Try(value match { - case b: Boolean => b - case _ => throw new IllegalArgumentException(s"expected a boolean") - }) - case DateType => - Try(LocalDate.parse(value.asInstanceOf[String])) - case DateTimeType => - Try(DateTime.parse(value.asInstanceOf[String])) - case other => - Failure(new IllegalArgumentException(s"unsupported link attribute kind: $other")) + Try { + definition.kind match { + case TextType => + asString("a string") + case NumericType => + value match { + case n: Number => n + case _ => expected("a number") + } + case IntegerType => + value match { + case i: Integer => i + case _ => expected("an integer") + } + case BooleanType => + value match { + case b: java.lang.Boolean => b + case _ => expected("a boolean") + } + case DateType => + LocalDate.parse(asString("a date string")).toString + case DateTimeType => + DateTime.parse(asString("a datetime string")).withZone(DateTimeZone.UTC).toString(dateTimeFormat) + case other => + throw new IllegalArgumentException(s"unsupported link attribute kind: $other") + } } } result match { - case Success(_) => () + case Success(normalizedValue) => normalizedValue case Failure(ex) => throw InvalidJsonException( s"Invalid value for attribute '${definition.name}' (${definition.kind}): ${ex.getMessage}", diff --git a/src/main/scala/com/campudus/tableaux/database/model/HistoryModel.scala b/src/main/scala/com/campudus/tableaux/database/model/HistoryModel.scala index 437d0c298..95b41d4a1 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/HistoryModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/HistoryModel.scala @@ -210,18 +210,26 @@ case class CreateHistoryModel(tableauxModel: TableauxModel, connection: Database // retrieveForeignIdentifierCells/getLinksData only fetch the *foreign row's* identifier value - this fetches the // join table's own per-link attributes column, keyed by the linked row id, for the given source row. + // + // A column without definitions can't have stored values, so it skips the query entirely: this runs for every link + // column on every link change, which for the whole pre-existing stock of link columns would otherwise be a + // guaranteed-empty round trip per column per changed row. private def retrieveLinkAttributesByRowId(column: LinkColumn, rowId: RowId): Future[Map[RowId, JsonArray]] = { - val linkTable = s"link_table_${column.linkId}" - val fromIdColumn = column.linkDirection.fromSql - val toIdColumn = column.linkDirection.toSql - - connection - .query( - s"SELECT $toIdColumn, attributes FROM $linkTable WHERE $fromIdColumn = ? AND attributes IS NOT NULL", - Json.arr(rowId) - ) - .map(resultObjectToJsonArray) - .map(_.map(row => (row.getLong(0).longValue(), new JsonArray(row.getString(1)))).toMap) + if (column.linkAttributes.isEmpty) { + Future.successful(Map.empty) + } else { + val linkTable = s"link_table_${column.linkId}" + val fromIdColumn = column.linkDirection.fromSql + val toIdColumn = column.linkDirection.toSql + + connection + .query( + s"SELECT $toIdColumn, attributes FROM $linkTable WHERE $fromIdColumn = ? AND attributes IS NOT NULL", + Json.arr(rowId) + ) + .map(resultObjectToJsonArray) + .map(_.map(row => (row.getLong(0).longValue(), new JsonArray(row.getString(1)))).toMap) + } } private def createLinks( diff --git a/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala b/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala index 8b56f4757..f0e99d202 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala @@ -730,12 +730,20 @@ class TableauxModel( Future.failed(UnprocessableEntityException(s"Column ${linkColumn.id} has no linkAttributes defined.")) case linkColumn: LinkColumn => { for { - _ <- Future.fromTry(LinkAttributeValueValidator.checkValidValue(linkColumn.linkAttributes, attributes)) + // Normalizing rather than only validating means what lands in the database is the canonical spelling of + // each value, so this endpoint and a cell write store a given date/datetime identically. + normalizedAttributes <- Future.fromTry( + LinkAttributeValueValidator.normalize( + linkColumn.linkAttributes, + attributes, + table.langtags.getOrElse(Seq.empty) + ) + ) _ <- createHistoryModel.createCellsInit(table, rowId, Seq((linkColumn, Seq(toId)))) - _ <- updateRowModel.updateLinkAttributes(table, linkColumn, rowId, toId, attributes) + _ <- updateRowModel.updateLinkAttributes(table, linkColumn, rowId, toId, normalizedAttributes) _ <- invalidateCellAndDependentColumns(column, rowId) _ <- createHistoryModel.updateLinks(table, linkColumn, Seq(rowId)) - } yield Future.successful(()) + } yield () } case _ => Future.failed(WrongColumnKindException(column, classOf[LinkColumn])) } @@ -1911,10 +1919,17 @@ class TableauxModel( cell <- retrieveCell(concatenateColumn, rowId, true) } yield { val cellJson = cell.getJson - // Start from the raw linked row (carries fields the SQL projection already - // filled in, e.g. attributes/final/archived) and only overwrite its value, - // which the projection couldn't compute for a concat target. - list ++ List(linkedRow.copy().mergeIn(cellJson)) + // The projection can't compute a value for a concat target, so the row is rebuilt around the + // separately fetched one. Only `attributes` is carried over from the raw row - deliberately not + // everything it happens to hold: passing its flags through as well would newly surface + // final/archived on link values whose target is a concat column, and inconsistently at that, + // since removeUnauthorizedLinkAndConcatValues rebuilds the object without them further down. + val attributesJson = Option(linkedRow.getValue("attributes")) match { + case Some(attributes) => Json.obj("attributes" -> attributes) + case None => Json.obj() + } + + list ++ List(Json.obj("id" -> rowId).mergeIn(cellJson).mergeIn(attributesJson)) } } } diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index 015e486ac..d8b9cc1a2 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -13,6 +13,7 @@ import com.campudus.tableaux.database.model.TableauxModel._ import com.campudus.tableaux.database.model.structure.CachedColumnModel._ import com.campudus.tableaux.database.model.structure.ColumnModel.isColumnGroupMatchingToFormatPattern import com.campudus.tableaux.database.model.structure.ColumnModel.isLinkColumnMatchingToFormatPattern +import com.campudus.tableaux.database.model.tableaux.ModelHelper.{parseDateSql, parseDateTimeSql} import com.campudus.tableaux.helper.Json import com.campudus.tableaux.helper.JsonUtils.asSeqOf import com.campudus.tableaux.helper.ResultChecker._ @@ -814,7 +815,11 @@ class ColumnModel(val connection: DatabaseConnection)( toCol = toTableColumns.head + langtags <- retrieveEffectiveLangtags(table) + _ = { + LinkAttributeDefinition.checkMultilanguageAllowed(langtags, linkColumnInfo.linkAttributes) + if (!isLinkColumnMatchingToFormatPattern(linkColumnInfo.formatPattern, linkColumnInfo.linkAttributes)) { throw UnprocessableEntityException( s"Invalid formatPattern: '${linkColumnInfo.formatPattern.orNull}' doesn't match link value/attributes" @@ -1912,6 +1917,12 @@ class ColumnModel(val connection: DatabaseConnection)( cast: String = "" ): String = s"UPDATE system_columns SET $columnName = ?$cast WHERE table_id = ? AND column_id = ?" + // The langtags a multilanguage link attribute value can be keyed by. TableModel.convertRowToTable already + // substitutes the global langtags for a table that has none of its own, so the fallback here only covers callers + // holding a Table built some other way; an empty result means the table was explicitly created with `langtags: []`. + private def retrieveEffectiveLangtags(table: Table): Future[Seq[String]] = + table.langtags.map(Future.successful).getOrElse(tableStruc.retrieveGlobalLangtags()) + // Guards the value migrations below: only a slot that actually holds a value gets migrated. A slot holding JSON // null is a value that is explicitly empty, and no migration can improve on that - reshaping it would just respell // "empty" (as per-langtag nulls, or as null again), and casting it would take `attributes->>0` as a SQL NULL, which @@ -1922,8 +1933,8 @@ class ColumnModel(val connection: DatabaseConnection)( // Reshapes existing attribute values (position 0, the only slot while linkAttributes is capped at 1) to match a // multilanguage flip, before any kind cast runs on top. There's no cast for this - it's a structural change - so - // false -> true duplicates the scalar under every table langtag, and true -> false collapses to the first langtag - // (in configured priority order) that actually has a non-null value, discarding the rest. + // false -> true duplicates the scalar under every table langtag, and true -> false collapses to the langtag that + // actually has a non-null value, discarding the rest. private def reshapeLinkAttributeValues( t: DbTransaction, table: Table, @@ -1935,10 +1946,20 @@ class ColumnModel(val connection: DatabaseConnection)( Future.successful((t, Json.obj())) } else { for { - langtags <- table.langtags.map(Future.successful).getOrElse(tableStruc.retrieveGlobalLangtags()) + langtags <- retrieveEffectiveLangtags(table) result <- if (newDefinition.multilanguage) { + // Unreachable: checkMultilanguageAllowed rejects a multilanguage definition on a langtag-less table + // before we get here. Asserted anyway because the failure mode is silent data loss - an empty langtag + // list makes jsonb_build_object() return {}, which would replace every stored value with an empty + // object and still commit. + if (langtags.isEmpty) { + throw UnprocessableEntityException( + s"Cannot make link attribute '${newDefinition.name}' multilanguage: table ${table.id} has no langtags." + ) + } + // Postgres can't infer a bare `?` placeholder's type from a variadic "any" function like // jsonb_build_object - it needs an explicit cast, or every prepared execution fails with // "could not determine data type of parameter $1". @@ -1950,15 +1971,30 @@ class ColumnModel(val connection: DatabaseConnection)( Json.arr(langtags*) ) } else { - // NULLIF is what makes "first langtag that actually has a value" true: `->` yields a JSON null (not a - // SQL NULL) for a langtag that is present but cleared, so a bare COALESCE would stop at that langtag - // and throw away a real value stored under a later one. - val coalesceParts = - (langtags.map(_ => "NULLIF(attributes->0->?::text, 'null'::jsonb)") :+ "'null'::jsonb").mkString(", ") + // Collapsing walks the stored object itself instead of the table's langtag list, because the two can + // disagree: a value may sit under a langtag that was since removed from the table, or was written + // before langtag keys were validated. Iterating table langtags would drop those values without a + // trace, so jsonb_each decides what exists and array_position only decides the order - configured + // langtags first (in their configured priority), everything else after, key order as a tie-break so + // the outcome is deterministic. + // + // `jsonb_typeof(value) <> 'null'` is what makes "the langtag that actually has a value" true: a + // present-but-cleared langtag holds a JSON null, which is not a SQL NULL and would otherwise win. + val langtagArray = + if (langtags.isEmpty) "ARRAY[]::text[]" + else langtags.map(_ => "?::text").mkString("ARRAY[", ", ", "]") + t.query( s"""|UPDATE $linkTable - |SET attributes = jsonb_set(attributes, '{0}', COALESCE($coalesceParts)) - |WHERE $slotHoldsAValue""".stripMargin, + |SET attributes = jsonb_set(attributes, '{0}', COALESCE( + | (SELECT entry.value + | FROM jsonb_each(attributes->0) entry + | WHERE jsonb_typeof(entry.value) <> 'null' + | ORDER BY COALESCE(array_position($langtagArray, entry.key), 2147483647), entry.key + | LIMIT 1), + | 'null'::jsonb + |)) + |WHERE $slotHoldsAValue AND jsonb_typeof(attributes->0) = 'object'""".stripMargin, Json.arr(langtags*) ) } @@ -1966,6 +2002,20 @@ class ColumnModel(val connection: DatabaseConnection)( } } + // Renders one attribute value, cast to `kind`, as jsonb. date/datetime go through the same TO_CHAR formats the row + // projections use, which are also the formats LinkAttributeValueValidator normalizes writes to - without that, the + // very same instant would be spelled one way when written through the API and another way after a kind migration + // had touched it. + private def castLinkAttributeValueSql(kind: TableauxDbType, textExpression: String): String = { + val castedValue = kind match { + case DateType => parseDateSql(s"($textExpression)::${DateType.toDbType}") + case DateTimeType => parseDateTimeSql(s"($textExpression)::${DateTimeType.toDbType}") + case other => s"($textExpression)::${other.toDbType}" + } + + s"to_jsonb($castedValue)" + } + // Casts existing attribute values (position 0) to a new kind, all-or-nothing - a single value anywhere that can't // cast fails the whole UPDATE, which (combined with the caller's rollbackAndFail) rolls back the entire change, // exactly mirroring how a plain column's kind change behaves today (ALTER COLUMN ... USING ...::type). @@ -1980,7 +2030,9 @@ class ColumnModel(val connection: DatabaseConnection)( } else if (!newDefinition.multilanguage) { t.query( s"""|UPDATE $linkTable - |SET attributes = jsonb_set(attributes, '{0}', to_jsonb((attributes->>0)::${newDefinition.kind.toDbType})) + |SET attributes = jsonb_set( + | attributes, '{0}', ${castLinkAttributeValueSql(newDefinition.kind, "attributes->>0")} + |) |WHERE $slotHoldsAValue""".stripMargin ) } else { @@ -1995,7 +2047,7 @@ class ColumnModel(val connection: DatabaseConnection)( |SET attributes = jsonb_set( | attributes, '{0}', | COALESCE( - | (SELECT jsonb_object_agg(key, to_jsonb(value::${newDefinition.kind.toDbType})) + | (SELECT jsonb_object_agg(key, ${castLinkAttributeValueSql(newDefinition.kind, "value")}) | FROM jsonb_each_text(attributes->0)), | '{}'::jsonb | ) @@ -2018,11 +2070,25 @@ class ColumnModel(val connection: DatabaseConnection)( newDefinitions: Seq[LinkAttributeDefinition] ): Future[(DbTransaction, JsonObject)] = { for { + langtags <- retrieveEffectiveLangtags(table) + + // Both invariants are already enforced while parsing the request (JsonUtils.parseLinkAttributes) and in the + // controller, so over HTTP neither can fire. They are re-asserted here because this is where the max-1 + // assumption is actually load-bearing: the migrations below only ever look at position 0, so a second entry + // would be persisted but never reshaped or cast - a silent data bug rather than an error - and a multilanguage + // definition without langtags is what used to make the reshape wipe values. + _ = LinkAttributeDefinition.checkMaxCount(newDefinitions.size) + _ = LinkAttributeDefinition.checkMultilanguageAllowed(langtags, newDefinitions) + (t, linkIdResult) <- t.query( "SELECT link_id FROM system_columns WHERE table_id = ? AND column_id = ?", Json.arr(table.id, columnId) ) - linkId = selectNotNull(linkIdResult).head.getLong(0).longValue() + linkId = Option(selectNotNull(linkIdResult).head.getLong(0)) + .map(_.longValue()) + .getOrElse(throw UnprocessableEntityException( + s"Column $columnId of table ${table.id} is not a link column, it has no linkAttributes." + )) linkTable = s"link_table_$linkId" (t, currentResult) <- t.query("SELECT attributes FROM system_link_table WHERE link_id = ?", Json.arr(linkId)) @@ -2042,8 +2108,10 @@ class ColumnModel(val connection: DatabaseConnection)( } yield (t, result) case (Some(_), None) => - // pure remove - no definition is left to interpret the old values - t.query(s"UPDATE $linkTable SET attributes = NULL") + // pure remove - no definition is left to interpret the old values. The WHERE clause matters: without it + // this rewrites every row of a link table that may hold millions of them, for the common case (definition + // added, never used, removed again) where there is nothing to wipe at all. + t.query(s"UPDATE $linkTable SET attributes = NULL WHERE attributes IS NOT NULL") case (None, _) => // pure add - no existing link rows can have a value yet diff --git a/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala b/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala index 0bbe011e1..351f275d0 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/tableaux/RowModel.scala @@ -26,7 +26,10 @@ import com.typesafe.scalalogging.LazyLogging import java.util.UUID import org.joda.time.DateTime -private object ModelHelper { +// Visible inside com.campudus.tableaux.database so ColumnModel's link-attribute kind migration can render +// date/datetime values with exactly the same format the row projections use - two spellings of one instant is +// precisely the bug that motivated normalizing them in the first place. +private[database] object ModelHelper { val dateTimeFormat = "YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"" val dateFormat = "YYYY-MM-DD" diff --git a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala index abfa1214e..55ce9460a 100644 --- a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala +++ b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala @@ -327,12 +327,7 @@ object JsonUtils extends LazyLogging { .map(_.asScala.toSeq) .getOrElse(Seq.empty) - if (entries.size > LinkAttributeDefinition.maxCount) { - throw InvalidJsonException( - s"Only ${LinkAttributeDefinition.maxCount} linkAttributes entry is currently supported, but got ${entries.size}.", - "linkAttributes" - ) - } + LinkAttributeDefinition.checkMaxCount(entries.size) // A name is an identifier, not a label (that's displayName): it is how a value gets referenced in a // formatPattern as {{attributes.}}, and those tokens are matched by ColumnModel's diff --git a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala index 5125261db..f4d2cff30 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala @@ -439,4 +439,196 @@ class LinkAttributesTest extends LinkTestBase { } yield () } + // --------------------------------------------------------------------------------------------------------------- + // Langtag keys of a multilanguage attribute value are validated against the table's langtags. Writing was tolerant + // before, which let a value be stored under a langtag no migration would ever look at again. + // --------------------------------------------------------------------------------------------------------------- + + @Test + def rejectUnknownLangtagInAttributeValue(implicit c: TestContext): Unit = + exceptionTest("error.json.link-attributes") { + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("kling-ON" -> 50)))) + ) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2, multilanguage = true) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + } yield () + } + + @Test + def rejectUnknownLangtagOnPutAttributesEndpoint(implicit c: TestContext): Unit = + exceptionTest("error.json.link-attributes") { + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> 50)))) + ) + ) + val putAttributes = Json.obj("attributes" -> Json.arr(Json.obj("kling-ON" -> 75))) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2, multilanguage = true) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + _ <- sendRequest("PUT", s"/tables/1/columns/$linkColumnId/rows/1/link/1/attributes", putAttributes) + } yield () + } + + // --------------------------------------------------------------------------------------------------------------- + // date/datetime values are normalized on write, so one instant has exactly one stored spelling. + // --------------------------------------------------------------------------------------------------------------- + + @Test + def dateTimeValueIsNormalizedOnWrite(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr("2020-01-01T13:00:00.000+01:00"))) + ) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2, kind = "datetime") + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + assertEquals( + "2020-01-01T12:00:00.000Z", + cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getString(0) + ) + } + } + + @Test + def dateTimeValueIsNormalizedOnPutAttributesEndpoint(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj( + "id" -> 1, + "attributes" -> Json.arr("2019-01-01T00:00:00.000Z") + ))) + ) + val putAttributes = Json.obj("attributes" -> Json.arr("2020-01-01T13:00:00.000+01:00")) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2, kind = "datetime") + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + _ <- sendRequest("PUT", s"/tables/1/columns/$linkColumnId/rows/1/link/1/attributes", putAttributes) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + assertEquals( + "2020-01-01T12:00:00.000Z", + cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getString(0) + ) + } + } + + /** + * Pins LinkAttributeValueValidator's Joda format to ModelHelper's Postgres TO_CHAR format. The two normalize the + * same instant through completely different engines - Joda on write, TO_CHAR in ColumnModel's kind migration - so + * nothing but a test comparing their output keeps them from drifting apart. + */ + @Test + def dateTimeValueIsNormalizedIdenticallyByWriteAndMigration(implicit c: TestContext): Unit = okTest { + val sameInstant = "2020-06-15T14:30:45.123+02:00" + val putLink = + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(sameInstant))))) + + def attributeValue(cell: JsonObject): String = + cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getString(0) + + for { + _ <- setupTwoTables() + + // two link columns between the same pair of tables, hence singleDirection - a bidirectional pair would + // auto-create two backlink columns in table 2 that both derive their name from table 1 + // + // written straight into a datetime attribute - normalized by Joda + writtenColumnId <- + createLinkColumnWithAttributes(1, 2, kind = "datetime", name = "Written Link", singleDirection = true) + _ <- sendRequest("POST", s"/tables/1/columns/$writtenColumnId/rows/1", putLink) + writtenCell <- sendRequest("GET", s"/tables/1/columns/$writtenColumnId/rows/1") + + // written as text, then migrated to datetime - normalized by Postgres + migratedColumnId <- + createLinkColumnWithAttributes(1, 2, kind = "text", name = "Migrated Link", singleDirection = true) + _ <- sendRequest("POST", s"/tables/1/columns/$migratedColumnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$migratedColumnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "datetime"))) + ) + migratedCell <- sendRequest("GET", s"/tables/1/columns/$migratedColumnId/rows/1") + } yield { + assertEquals("2020-06-15T12:30:45.123Z", attributeValue(writtenCell)) + assertEquals(attributeValue(writtenCell), attributeValue(migratedCell)) + } + } + + @Test + def dateValueRoundtripsForDateKind(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr("2020-01-01")))) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2, kind = "date") + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + assertEquals("2020-01-01", cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getString(0)) + } + } + + // --------------------------------------------------------------------------------------------------------------- + // An empty attributes array carries no value for any definition, so it means the same as sending none at all - + // it must not put an `attributes` key on a column that has no definitions to interpret it. + // --------------------------------------------------------------------------------------------------------------- + + @Test + def emptyAttributesArrayOnColumnWithoutDefinitionsIsIgnored(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr())))) + + val expected = Json.obj( + "status" -> "ok", + "value" -> Json.arr(Json.obj("id" -> 1, "value" -> "table2row1")) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumn(1, 2, singleDirection = false) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + assertEquals(expected, cell) + } + } + + // --------------------------------------------------------------------------------------------------------------- + // Duplicating a row round-trips its link values back through the write path, so attributes have to survive it. + // --------------------------------------------------------------------------------------------------------------- + + @Test + def duplicateRowKeepsLinkAttributes(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + duplicated <- sendRequest("POST", "/tables/1/rows/1/duplicate") + duplicatedCell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/${duplicated.getNumber("id")}") + } yield { + assertEquals( + 50, + duplicatedCell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0) + ) + } + } + } diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala new file mode 100644 index 000000000..4f2dce121 --- /dev/null +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala @@ -0,0 +1,926 @@ +package com.campudus.tableaux.api.structure + +import com.campudus.tableaux.database.model.TableauxModel.{ColumnId, TableId} +import com.campudus.tableaux.helper.Json +import com.campudus.tableaux.testtools.TableauxTestBase + +import io.vertx.ext.unit.TestContext +import io.vertx.ext.unit.junit.VertxUnitRunner +import io.vertx.lang.scala.json.{JsonArray, JsonObject} + +import scala.concurrent.Future +import scala.jdk.CollectionConverters._ + +import org.junit.Assert._ +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Structure-level tests for a link column's `linkAttributes` definition: creating it, changing it, and migrating + * values that were already stored under the previous definition. + */ +@RunWith(classOf[VertxUnitRunner]) +class ChangeLinkAttributesStructureTest extends TableauxTestBase { + + private def percentageAttribute( + kind: String = "integer", + multilanguage: Boolean = false, + name: String = "percentage" + ): JsonObject = { + Json.obj( + "name" -> name, + "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), + "kind" -> kind, + "multilanguage" -> multilanguage + ) + } + + private def createLinkColumn( + linkAttributes: JsonArray = Json.arr(), + formatPattern: Option[String] = None + ): Future[ColumnId] = { + val baseJson = + Json.obj( + "name" -> "Test Link 1", + "kind" -> "link", + "toTable" -> 2, + // explicit rather than relying on the default - a backlink column actually being + // created is load-bearing for the backlink-focused tests further down + "singleDirection" -> false, + "linkAttributes" -> linkAttributes + ) + val columnJson = formatPattern match { + case Some(pattern) => baseJson.mergeIn(Json.obj("formatPattern" -> pattern)) + case None => baseJson + } + val postJson = Json.obj("columns" -> Json.arr(columnJson)) + + for { + _ <- createDefaultTable() + _ <- createDefaultTable("Test Table 2", 2) + result <- sendRequest("POST", "/tables/1/columns", postJson) + } yield result.getJsonArray("columns").getJsonObject(0).getLong("id").toLong + } + + // createLinkColumn() is bidirectional by default, so table 2 gets an auto-created backlink + // column pointing back at table 1 - this finds its id so tests can read from that side. + private def findBacklinkColumnId(toTable: TableId): Future[ColumnId] = { + sendRequest("GET", "/tables/2/columns").map( + _.getJsonArray("columns") + .asScala + .map(_.asInstanceOf[JsonObject]) + .collectFirst({ + case col if col.getString("kind") == "link" && col.getLong("toTable") == toTable => col.getLong("id").toLong + }) + .get + ) + } + + @Test + def createLinkColumnWithLinkAttributes(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + result <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertJSONEquals(Json.arr(percentageAttribute()), result.getJsonArray("linkAttributes")) + } + } + + @Test + def createLinkColumnWithTooManyLinkAttributesFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + createLinkColumn(Json.arr(percentageAttribute(), percentageAttribute())) + } + + @Test + def createLinkColumnWithDisallowedLinkAttributeKindFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + createLinkColumn(Json.arr(percentageAttribute(kind = "link"))) + } + + // the name is the only handle a value has - {{attributes.}} in a formatPattern, matched by a \w-based + // regex - so a name that regex can never produce is rejected instead of being stored unreferenceable + private def createLinkColumnWithNameFails(name: String)(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + createLinkColumn(Json.arr(percentageAttribute(name = name))) + } + + @Test + def createLinkColumnWithEmptyLinkAttributeNameFails(implicit c: TestContext): Unit = + createLinkColumnWithNameFails("") + + @Test + def createLinkColumnWithBlankLinkAttributeNameFails(implicit c: TestContext): Unit = + createLinkColumnWithNameFails(" ") + + // a dot would make a token like {{attributes.a.b}} ambiguous + @Test + def createLinkColumnWithDottedLinkAttributeNameFails(implicit c: TestContext): Unit = + createLinkColumnWithNameFails("percentage.value") + + @Test + def createLinkColumnWithSpaceInLinkAttributeNameFails(implicit c: TestContext): Unit = + createLinkColumnWithNameFails("percent age") + + @Test + def createLinkColumnWithLinkAttributeNameOfLettersDigitsUnderscoreSucceeds(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(name = "percentage_2"))) + result <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertEquals("percentage_2", result.getJsonArray("linkAttributes").getJsonObject(0).getString("name")) + } + } + + // the change path parses linkAttributes with the same parser, so it rejects the same names + @Test + def changeLinkColumnToInvalidLinkAttributeNameFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "percent age"))) + ) + } yield () + } + + @Test + def changeLinkColumnAddLinkAttributes(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn() + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute())) + ) + result <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertJSONEquals(Json.arr(percentageAttribute()), result.getJsonArray("linkAttributes")) + } + } + + @Test + def changeLinkColumnToTooManyLinkAttributesFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(), percentageAttribute())) + ) + } yield () + } + + @Test + def changeLinkColumnLinkAttributesForbiddenForNonLinkColumns(implicit c: TestContext): Unit = + exceptionTest("error.request.forbidden.column") { + for { + _ <- createDefaultTable() + _ <- sendRequest( + "POST", + "/tables/1/columns/1", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute())) + ) + } yield () + } + + @Test + def changeLinkColumnWithExplicitEmptyLinkAttributesClearsValues(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("linkAttributes" -> Json.arr())) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertFalse(column.containsKey("linkAttributes")) + assertFalse(cell.getJsonArray("value").getJsonObject(0).containsKey("attributes")) + } + } + + @Test + def changeLinkColumnOmittingLinkAttributesLeavesItUntouched(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("name" -> "renamed")) + result <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertJSONEquals(Json.arr(percentageAttribute()), result.getJsonArray("linkAttributes")) + } + } + + @Test + def changeLinkColumnRenamingLinkAttributePreservesValues(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(name = "percentage"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + // rename only - same kind, same multilanguage flag, just a different name/displayName label + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "percent"))) + ) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals("percent", column.getJsonArray("linkAttributes").getJsonObject(0).getString("name")) + assertEquals(50, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + } + } + + @Test + def changeLinkColumnLinkAttributesKindMigrationSucceeds(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric"))) + ) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals("numeric", column.getJsonArray("linkAttributes").getJsonObject(0).getString("kind")) + val migratedValue = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getValue(0) + assertEquals(50.0, migratedValue.asInstanceOf[Number].doubleValue(), 0.001) + } + } + + @Test + def changeLinkColumnLinkAttributesKindMigrationFailsAndRollsBack(implicit c: TestContext): Unit = okTest { + val putLink = + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr("abc"))))) + val failed = Json.obj("failed" -> "failed") + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "text"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + changeResult <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "integer"))) + ).recoverWith({ case _ => Future.successful(failed) }) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals(failed, changeResult) + assertEquals("text", column.getJsonArray("linkAttributes").getJsonObject(0).getString("kind")) + assertEquals("abc", cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getString(0)) + } + } + + @Test + def changeLinkColumnMultilanguageFalseToTrueDuplicatesValue(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = false))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + val attributeValue = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getJsonObject(0) + assertEquals(50, attributeValue.getInteger("de-DE")) + assertEquals(50, attributeValue.getInteger("en-GB")) + } + } + + @Test + def changeLinkColumnMultilanguageTrueToFalseCollapsesValue(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> 50, "en-GB" -> 75)))) + ) + ) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = true))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals(50, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + } + } + + // a stored null is a value that is already empty - duplicating it under every langtag would only respell that, + // so the reshape leaves the slot alone instead of producing an object full of nulls + @Test + def changeLinkColumnMultilanguageFalseToTrueLeavesNullValueUntouched(implicit c: TestContext): Unit = okTest { + val putLink = + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr().addNull())))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = false))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals(Json.arr().addNull(), cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes")) + } + } + + // collapsing has to pick the first langtag that carries an actual value: a cleared langtag comes back from `->` as + // a JSON null rather than a SQL NULL, so a plain COALESCE would stop at it and discard the value behind it + @Test + def changeLinkColumnMultilanguageTrueToFalseSkipsClearedLangtags(implicit c: TestContext): Unit = okTest { + // de-DE comes first in the table's langtags and is explicitly cleared, so en-GB's value has to win + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> null, "en-GB" -> 75)))) + ) + ) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = true))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals(75, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + } + } + + @Test + def changeLinkColumnLinkAttributesKindMigrationKeepsClearedLangtags(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> null, "en-GB" -> 50)))) + ) + ) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer", multilanguage = true))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric", multilanguage = true))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + val attributeValue = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getJsonObject(0) + // the cleared langtag keeps its key instead of being dropped by the cast + assertEquals(Set("de-DE", "en-GB"), attributeValue.fieldNames().asScala.toSet) + assertNull(attributeValue.getValue("de-DE")) + assertEquals(50.0, attributeValue.getValue("en-GB").asInstanceOf[Number].doubleValue(), 0.001) + } + } + + // degenerate but reachable: a multilanguage value with every langtag removed. Aggregating over its zero entries + // yields a SQL NULL, which strict jsonb_set would turn into a wiped attributes column instead of an empty object. + @Test + def changeLinkColumnMultilanguageLinkAttributesKindMigrationKeepsEmptyValue(implicit c: TestContext): Unit = okTest { + val putLink = + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj()))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer", multilanguage = true))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric", multilanguage = true))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals(Json.arr(Json.obj()), cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes")) + } + } + + // a slot holding null has nothing to cast, so the kind change goes through and the value stays put - casting it + // would hand strict jsonb_set a SQL NULL, which wipes the whole attributes column + @Test + def changeLinkColumnLinkAttributesKindMigrationLeavesNullValueUntouched(implicit c: TestContext): Unit = + kindMigrationOnNullValueKeepsSlot(multilanguage = false) + + // same for a multilanguage slot, where deconstructing the null would additionally error out ("cannot call + // jsonb_each_text on a non-object") and roll back the whole column change + @Test + def changeLinkColumnMultilanguageLinkAttributesKindMigrationLeavesNullValueUntouched( + implicit c: TestContext + ): Unit = kindMigrationOnNullValueKeepsSlot(multilanguage = true) + + private def kindMigrationOnNullValueKeepsSlot(multilanguage: Boolean)(implicit c: TestContext): Unit = okTest { + val putLink = + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr().addNull())))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer", multilanguage = multilanguage))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric", multilanguage = multilanguage))) + ) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals("numeric", column.getJsonArray("linkAttributes").getJsonObject(0).getString("kind")) + assertEquals(Json.arr().addNull(), cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes")) + } + } + + @Test + def changeLinkColumnFormatPatternAccepted(implicit c: TestContext): Unit = okTest { + val pattern = "{{value}} ({{attributes.percentage}}%)" + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("formatPattern" -> pattern)) + result <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertEquals(pattern, result.getString("formatPattern")) + } + } + + @Test + def changeLinkColumnFormatPatternRejectedForUnknownToken(implicit c: TestContext): Unit = + exceptionTest("unprocessable.entity") { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("formatPattern" -> "{{attributes.doesNotExist}}") + ) + } yield () + } + + @Test + def createLinkColumnFormatPatternAccepted(implicit c: TestContext): Unit = okTest { + val pattern = "{{value}} ({{attributes.percentage}}%)" + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some(pattern)) + result <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertEquals(pattern, result.getString("formatPattern")) + } + } + + @Test + def createLinkColumnFormatPatternRejectedForUnknownToken(implicit c: TestContext): Unit = + // same pattern that changeLinkColumnFormatPatternRejectedForUnknownToken rejects on the change path - + // creating a link column must be rejected the same way instead of silently storing a broken pattern + exceptionTest("unprocessable.entity") { + createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some("{{attributes.doesNotExist}}")) + } + + // linkAttributes definitions live once in system_link_table, keyed by link_id, shared by both + // sides of a bidirectional link (see ColumnModel.retrieveLinkInformation). Attribute values + // likewise live once per edge in link_table_.attributes. So on a fresh read (nothing + // cached yet), both the definition and the value are symmetric - identical whether read from + // the forward link column or from its auto-created backlink. + @Test + def backlinkColumnSharesLinkAttributesDefinitionAndValue(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(30))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + backlinkColumnId <- findBacklinkColumnId(toTable = 1) + + forwardColumn <- sendRequest("GET", s"/tables/1/columns/$columnId") + backlinkColumn <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId") + + forwardCell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + backlinkCell <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId/rows/1") + } yield { + // same definition on both sides + assertJSONEquals(forwardColumn.getJsonArray("linkAttributes"), backlinkColumn.getJsonArray("linkAttributes")) + + // same attribute value on both sides - it's one edge, read from either end + assertEquals(30, forwardCell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + assertEquals(30, backlinkCell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + } + } + + // Regression test for a fixed cache-invalidation gap: StructureController.changeColumn used to + // invalidate only the cell-value cache of the column that was actually changed + // (eventClient.invalidateColumn at the end of changeColumn), unlike the row-write path + // (TableauxModel.invalidateCellAndDependentColumns), which also walks retrieveDependencies. + // Without that walk, a backlink cell already cached before a linkAttributes kind change kept + // serving the pre-migration value even though the column's own definition (and the forward + // side's cell) were already up to date. invalidateDependentColumnCaches closes that gap. + @Test + def backlinkCellCacheIsInvalidatedAfterLinkAttributesKindChange(implicit c: TestContext): Unit = okTest { + val putLink = + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr("50"))))) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "text"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + backlinkColumnId <- findBacklinkColumnId(toTable = 1) + + // populate the backlink cell's cache before the definition changes + backlinkCellBefore <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId/rows/1") + + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "integer"))) + ) + + forwardCellAfter <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + backlinkCellAfter <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId/rows/1") + } yield { + val beforeValue = backlinkCellBefore.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getValue(0) + assertEquals("50", beforeValue) + + // forward side is freshly read (never cached before the change) - correctly migrated to a number + val forwardValue = forwardCellAfter.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getValue(0) + assertEquals(50, forwardValue) + + // backlink side was cached before the change too, but the cache is now invalidated as part + // of the change - re-read fresh and reflects the same migrated number + val backlinkValue = + backlinkCellAfter.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getValue(0) + assertEquals(50, backlinkValue) + } + } + + // Same invalidateDependentColumnCaches fix, but exercised via a GroupColumn instead of a link - + // no linkAttributes involved. A GroupColumn's cell value is a plain array of its grouped + // columns' own values, so casting one grouped column's kind must invalidate the GroupColumn's + // already-cached cell too (the retrieveDependentGroupColumn half of the fix, as opposed to the + // retrieveDependencies/backlink half exercised above). + @Test + def changeColumnKindInvalidatesDependentGroupColumnCellCache(implicit c: TestContext): Unit = okTest { + val createTextColumnJson = Json.obj("columns" -> Json.arr(Json.obj("kind" -> "text", "name" -> "Text"))) + val createNumericColumnJson = Json.obj("columns" -> Json.arr(Json.obj("kind" -> "numeric", "name" -> "Number"))) + + for { + tableId <- sendRequest("POST", "/tables", Json.obj("name" -> "Group Cache Test")).map(_.getLong("id")) + + textColumnId <- sendRequest("POST", s"/tables/$tableId/columns", createTextColumnJson) + .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) + numericColumnId <- sendRequest("POST", s"/tables/$tableId/columns", createNumericColumnJson) + .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) + + createGroupColumnJson = Json.obj( + "columns" -> Json.arr( + Json.obj("kind" -> "group", "name" -> "Group", "groups" -> Json.arr(textColumnId, numericColumnId)) + ) + ) + groupColumnId <- sendRequest("POST", s"/tables/$tableId/columns", createGroupColumnJson) + .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) + + _ <- sendRequest( + "POST", + s"/tables/$tableId/rows", + Json.obj( + "columns" -> Json.arr(Json.obj("id" -> textColumnId), Json.obj("id" -> numericColumnId)), + "rows" -> Json.arr(Json.obj("values" -> Json.arr("5", 10))) + ) + ) + + // populate the group column's cell cache before the structure change + groupCellBefore <- sendRequest("GET", s"/tables/$tableId/columns/$groupColumnId/rows/1") + + // cast the text column's stored "5" to the number 5 + _ <- sendRequest("POST", s"/tables/$tableId/columns/$textColumnId", Json.obj("kind" -> "numeric")) + + textCellAfter <- sendRequest("GET", s"/tables/$tableId/columns/$textColumnId/rows/1") + groupCellAfter <- sendRequest("GET", s"/tables/$tableId/columns/$groupColumnId/rows/1") + } yield { + assertEquals(Json.arr("5", 10), groupCellBefore.getJsonArray("value")) + + // the changed column itself is freshly read (never cached before the change) - correctly cast + assertEquals(5, textCellAfter.getInteger("value")) + + // the group column depends on it and was cached before the change - now invalidated, reflects + // the same cast number instead of the stale pre-migration string + assertEquals(Json.arr(5, 10), groupCellAfter.getJsonArray("value")) + } + } + + // --------------------------------------------------------------------------------------------------------------- + // formatPattern and linkAttributes constrain each other, so changing either one alone has to be validated against + // the other as it currently stands - otherwise a rename leaves a pattern pointing at a token that no longer exists. + // --------------------------------------------------------------------------------------------------------------- + + @Test + def changeLinkAttributesRenameLeavingFormatPatternDanglingFails(implicit c: TestContext): Unit = + exceptionTest("unprocessable.entity") { + for { + columnId <- createLinkColumn( + Json.arr(percentageAttribute()), + formatPattern = Some("{{value}} ({{attributes.percentage}}%)") + ) + // no formatPattern in this request - the stored one still references {{attributes.percentage}} + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "share"))) + ) + } yield () + } + + @Test + def clearingLinkAttributesLeavingFormatPatternDanglingFails(implicit c: TestContext): Unit = + exceptionTest("unprocessable.entity") { + for { + columnId <- createLinkColumn( + Json.arr(percentageAttribute()), + formatPattern = Some("{{attributes.percentage}}") + ) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("linkAttributes" -> Json.arr())) + } yield () + } + + // Renaming and re-pointing the pattern in one request is the supported way through: the check sees the pair as it + // will be after the change, not the mix of old pattern and new definitions. + @Test + def changeLinkAttributesRenameTogetherWithMatchingFormatPatternSucceeds(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + + for { + columnId <- createLinkColumn( + Json.arr(percentageAttribute()), + formatPattern = Some("{{value}} ({{attributes.percentage}}%)") + ) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj( + "linkAttributes" -> Json.arr(percentageAttribute(name = "share")), + "formatPattern" -> "{{value}} ({{attributes.share}}%)" + ) + ) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals("share", column.getJsonArray("linkAttributes").getJsonObject(0).getString("name")) + assertEquals("{{value}} ({{attributes.share}}%)", column.getString("formatPattern")) + // the rename is cosmetic, the stored value stays put + assertEquals(50, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + } + } + + // A pattern that never referenced an attribute can't be invalidated by renaming one. + @Test + def changeLinkAttributesRenameWithValueOnlyFormatPatternSucceeds(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some("{{value}}")) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "share"))) + ) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertEquals("share", column.getJsonArray("linkAttributes").getJsonObject(0).getString("name")) + assertEquals("{{value}}", column.getString("formatPattern")) + } + } + + // Clearing the definitions is fine as long as no pattern depends on them. + @Test + def clearingLinkAttributesWithoutFormatPatternSucceeds(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("linkAttributes" -> Json.arr())) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertNull(column.getJsonArray("linkAttributes")) + } + } + + // --------------------------------------------------------------------------------------------------------------- + // A multilanguage attribute value is keyed by langtag, so a table without langtags can't carry one. Allowing it + // used to let the multilanguage reshape run with an empty langtag list, which replaced every stored value with an + // empty object (false -> true) or a null (true -> false) and committed. + // --------------------------------------------------------------------------------------------------------------- + + // `langtags: []` is an explicitly supported table configuration (see StructureRouter), not a degenerate one. + // A column is needed because a link's target table must have at least one; rows are not. + private def createTableWithoutLangtags(name: String): Future[TableId] = { + for { + tableId <- sendRequest("POST", "/tables", Json.obj("name" -> name, "langtags" -> Json.arr())) + .map(_.getLong("id").toLong) + _ <- sendRequest( + "POST", + s"/tables/$tableId/columns", + Json.obj("columns" -> Json.arr(Json.obj("name" -> "text", "kind" -> "text", "identifier" -> true))) + ) + } yield tableId + } + + @Test + def createLinkColumnWithMultilanguageAttributeOnTableWithoutLangtagsFails(implicit c: TestContext): Unit = + exceptionTest("unprocessable.entity") { + for { + tableId <- createTableWithoutLangtags("No Langtags") + toTableId <- createTableWithoutLangtags("No Langtags 2") + _ <- sendRequest( + "POST", + s"/tables/$tableId/columns", + Json.obj("columns" -> Json.arr(Json.obj( + "name" -> "Test Link 1", + "kind" -> "link", + "toTable" -> toTableId, + "linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true)) + ))) + ) + } yield () + } + + @Test + def changeLinkColumnToMultilanguageAttributeOnTableWithoutLangtagsFails(implicit c: TestContext): Unit = + exceptionTest("unprocessable.entity") { + for { + tableId <- createTableWithoutLangtags("No Langtags") + toTableId <- createTableWithoutLangtags("No Langtags 2") + columnId <- sendRequest( + "POST", + s"/tables/$tableId/columns", + Json.obj("columns" -> Json.arr(Json.obj( + "name" -> "Test Link 1", + "kind" -> "link", + "toTable" -> toTableId, + "linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false)) + ))) + ).map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) + _ <- sendRequest( + "POST", + s"/tables/$tableId/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) + ) + } yield () + } + + // A language-neutral attribute needs no langtags at all and stays allowed. + @Test + def createLinkColumnWithLanguageNeutralAttributeOnTableWithoutLangtagsSucceeds(implicit c: TestContext) + : Unit = okTest { + for { + tableId <- createTableWithoutLangtags("No Langtags") + toTableId <- createTableWithoutLangtags("No Langtags 2") + result <- sendRequest( + "POST", + s"/tables/$tableId/columns", + Json.obj("columns" -> Json.arr(Json.obj( + "name" -> "Test Link 1", + "kind" -> "link", + "toTable" -> toTableId, + "linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false)) + ))) + ) + } yield { + assertEquals( + "percentage", + result.getJsonArray( + "columns" + ).getJsonObject(0).getJsonArray("linkAttributes").getJsonObject(0).getString("name") + ) + } + } + + // --------------------------------------------------------------------------------------------------------------- + // Collapsing multilanguage -> language-neutral walks the stored object, not the table's langtag list, because the + // two can disagree: a langtag can be removed from the table after a value was stored under it. + // --------------------------------------------------------------------------------------------------------------- + + @Test + def changeLinkColumnMultilanguageTrueToFalseKeepsValueOfRemovedLangtag(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> 50))))) + ) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = true))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + + // de-DE is dropped from the table, so the only stored value now sits under a langtag the table + // no longer knows - iterating table langtags to collapse would silently discard it + _ <- sendRequest("POST", "/tables/1", Json.obj("langtags" -> Json.arr("en-GB"))) + + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals(50, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + } + } + + // --------------------------------------------------------------------------------------------------------------- + // description is part of a DisplayInfo just like displayName, and the definition JSON is what gets persisted - + // dropping it on serialization loses it for good rather than just hiding it from the response. + // --------------------------------------------------------------------------------------------------------------- + + @Test + def linkAttributeDescriptionRoundtrips(implicit c: TestContext): Unit = okTest { + val attributeWithDescription = Json.obj( + "name" -> "percentage", + "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), + "description" -> Json.obj("de-DE" -> "Anteil in Prozent", "en-GB" -> "Share in percent"), + "kind" -> "integer", + "multilanguage" -> false + ) + + for { + columnId <- createLinkColumn(Json.arr(attributeWithDescription)) + afterCreate <- sendRequest("GET", s"/tables/1/columns/$columnId") + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(attributeWithDescription)) + ) + afterChange <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + val expected = Json.obj("de-DE" -> "Anteil in Prozent", "en-GB" -> "Share in percent") + + assertEquals(expected, afterCreate.getJsonArray("linkAttributes").getJsonObject(0).getJsonObject("description")) + assertEquals(expected, afterChange.getJsonArray("linkAttributes").getJsonObject(0).getJsonObject("description")) + assertEquals( + Json.obj("de-DE" -> "Prozentanteil"), + afterChange.getJsonArray("linkAttributes").getJsonObject(0).getJsonObject("displayName") + ) + } + } + + // --------------------------------------------------------------------------------------------------------------- + // A kind migration to datetime has to produce the same spelling the API hands out for a datetime value written + // directly - see LinkAttributesTest.dateTimeValueIsNormalizedIdenticallyByWriteAndMigration. + // --------------------------------------------------------------------------------------------------------------- + + @Test + def changeLinkColumnKindMigrationToDateTimeNormalizesFormat(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr("2020-01-01T13:00:00.000+01:00"))) + ) + ) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "text"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "datetime"))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals( + "2020-01-01T12:00:00.000Z", + cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getString(0) + ) + } + } + + @Test + def changeLinkColumnKindMigrationToDateNormalizesFormat(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr("2020-01-01")))) + ) + + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "text"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "date"))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertEquals( + "2020-01-01", + cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getString(0) + ) + } + } + +} diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala index e00208bb1..3182aa7d9 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeStructureTest.scala @@ -1,15 +1,12 @@ package com.campudus.tableaux.api.structure -import com.campudus.tableaux.database.model.TableauxModel.{ColumnId, TableId} import com.campudus.tableaux.helper.Json import com.campudus.tableaux.testtools.TableauxTestBase import io.vertx.ext.unit.TestContext import io.vertx.ext.unit.junit.VertxUnitRunner -import io.vertx.lang.scala.json.{JsonArray, JsonObject} import scala.concurrent.Future -import scala.jdk.CollectionConverters._ import org.junit.Assert._ import org.junit.Test @@ -212,603 +209,4 @@ class ChangeStructureTest extends TableauxTestBase { } } - private def percentageAttribute( - kind: String = "integer", - multilanguage: Boolean = false, - name: String = "percentage" - ): JsonObject = { - Json.obj( - "name" -> name, - "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), - "kind" -> kind, - "multilanguage" -> multilanguage - ) - } - - private def createLinkColumn( - linkAttributes: JsonArray = Json.arr(), - formatPattern: Option[String] = None - ): Future[ColumnId] = { - val baseJson = - Json.obj( - "name" -> "Test Link 1", - "kind" -> "link", - "toTable" -> 2, - // explicit rather than relying on the default - a backlink column actually being - // created is load-bearing for the backlink-focused tests further down - "singleDirection" -> false, - "linkAttributes" -> linkAttributes - ) - val columnJson = formatPattern match { - case Some(pattern) => baseJson.mergeIn(Json.obj("formatPattern" -> pattern)) - case None => baseJson - } - val postJson = Json.obj("columns" -> Json.arr(columnJson)) - - for { - _ <- createDefaultTable() - _ <- createDefaultTable("Test Table 2", 2) - result <- sendRequest("POST", "/tables/1/columns", postJson) - } yield result.getJsonArray("columns").getJsonObject(0).getLong("id").toLong - } - - // createLinkColumn() is bidirectional by default, so table 2 gets an auto-created backlink - // column pointing back at table 1 - this finds its id so tests can read from that side. - private def findBacklinkColumnId(toTable: TableId): Future[ColumnId] = { - sendRequest("GET", "/tables/2/columns").map( - _.getJsonArray("columns") - .asScala - .map(_.asInstanceOf[JsonObject]) - .collectFirst({ - case col if col.getString("kind") == "link" && col.getLong("toTable") == toTable => col.getLong("id").toLong - }) - .get - ) - } - - @Test - def createLinkColumnWithLinkAttributes(implicit c: TestContext): Unit = okTest { - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) - result <- sendRequest("GET", s"/tables/1/columns/$columnId") - } yield { - assertJSONEquals(Json.arr(percentageAttribute()), result.getJsonArray("linkAttributes")) - } - } - - @Test - def createLinkColumnWithTooManyLinkAttributesFails(implicit c: TestContext): Unit = - exceptionTest("error.json.linkAttributes") { - createLinkColumn(Json.arr(percentageAttribute(), percentageAttribute())) - } - - @Test - def createLinkColumnWithDisallowedLinkAttributeKindFails(implicit c: TestContext): Unit = - exceptionTest("error.json.linkAttributes") { - createLinkColumn(Json.arr(percentageAttribute(kind = "link"))) - } - - // the name is the only handle a value has - {{attributes.}} in a formatPattern, matched by a \w-based - // regex - so a name that regex can never produce is rejected instead of being stored unreferenceable - private def createLinkColumnWithNameFails(name: String)(implicit c: TestContext): Unit = - exceptionTest("error.json.linkAttributes") { - createLinkColumn(Json.arr(percentageAttribute(name = name))) - } - - @Test - def createLinkColumnWithEmptyLinkAttributeNameFails(implicit c: TestContext): Unit = - createLinkColumnWithNameFails("") - - @Test - def createLinkColumnWithBlankLinkAttributeNameFails(implicit c: TestContext): Unit = - createLinkColumnWithNameFails(" ") - - // a dot would make a token like {{attributes.a.b}} ambiguous - @Test - def createLinkColumnWithDottedLinkAttributeNameFails(implicit c: TestContext): Unit = - createLinkColumnWithNameFails("percentage.value") - - @Test - def createLinkColumnWithSpaceInLinkAttributeNameFails(implicit c: TestContext): Unit = - createLinkColumnWithNameFails("percent age") - - @Test - def createLinkColumnWithLinkAttributeNameOfLettersDigitsUnderscoreSucceeds(implicit c: TestContext): Unit = okTest { - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(name = "percentage_2"))) - result <- sendRequest("GET", s"/tables/1/columns/$columnId") - } yield { - assertEquals("percentage_2", result.getJsonArray("linkAttributes").getJsonObject(0).getString("name")) - } - } - - // the change path parses linkAttributes with the same parser, so it rejects the same names - @Test - def changeLinkColumnToInvalidLinkAttributeNameFails(implicit c: TestContext): Unit = - exceptionTest("error.json.linkAttributes") { - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "percent age"))) - ) - } yield () - } - - @Test - def changeLinkColumnAddLinkAttributes(implicit c: TestContext): Unit = okTest { - for { - columnId <- createLinkColumn() - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute())) - ) - result <- sendRequest("GET", s"/tables/1/columns/$columnId") - } yield { - assertJSONEquals(Json.arr(percentageAttribute()), result.getJsonArray("linkAttributes")) - } - } - - @Test - def changeLinkColumnToTooManyLinkAttributesFails(implicit c: TestContext): Unit = - exceptionTest("error.json.linkAttributes") { - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(), percentageAttribute())) - ) - } yield () - } - - @Test - def changeLinkColumnLinkAttributesForbiddenForNonLinkColumns(implicit c: TestContext): Unit = - exceptionTest("error.request.forbidden.column") { - for { - _ <- createDefaultTable() - _ <- sendRequest( - "POST", - "/tables/1/columns/1", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute())) - ) - } yield () - } - - @Test - def changeLinkColumnWithExplicitEmptyLinkAttributesClearsValues(implicit c: TestContext): Unit = okTest { - val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("linkAttributes" -> Json.arr())) - column <- sendRequest("GET", s"/tables/1/columns/$columnId") - cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - } yield { - assertFalse(column.containsKey("linkAttributes")) - assertFalse(cell.getJsonArray("value").getJsonObject(0).containsKey("attributes")) - } - } - - @Test - def changeLinkColumnOmittingLinkAttributesLeavesItUntouched(implicit c: TestContext): Unit = okTest { - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("name" -> "renamed")) - result <- sendRequest("GET", s"/tables/1/columns/$columnId") - } yield { - assertJSONEquals(Json.arr(percentageAttribute()), result.getJsonArray("linkAttributes")) - } - } - - @Test - def changeLinkColumnRenamingLinkAttributePreservesValues(implicit c: TestContext): Unit = okTest { - val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(name = "percentage"))) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - // rename only - same kind, same multilanguage flag, just a different name/displayName label - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "percent"))) - ) - column <- sendRequest("GET", s"/tables/1/columns/$columnId") - cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - } yield { - assertEquals("percent", column.getJsonArray("linkAttributes").getJsonObject(0).getString("name")) - assertEquals(50, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) - } - } - - @Test - def changeLinkColumnLinkAttributesKindMigrationSucceeds(implicit c: TestContext): Unit = okTest { - val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer"))) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric"))) - ) - column <- sendRequest("GET", s"/tables/1/columns/$columnId") - cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - } yield { - assertEquals("numeric", column.getJsonArray("linkAttributes").getJsonObject(0).getString("kind")) - val migratedValue = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getValue(0) - assertEquals(50.0, migratedValue.asInstanceOf[Number].doubleValue(), 0.001) - } - } - - @Test - def changeLinkColumnLinkAttributesKindMigrationFailsAndRollsBack(implicit c: TestContext): Unit = okTest { - val putLink = - Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr("abc"))))) - val failed = Json.obj("failed" -> "failed") - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "text"))) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - changeResult <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "integer"))) - ).recoverWith({ case _ => Future.successful(failed) }) - column <- sendRequest("GET", s"/tables/1/columns/$columnId") - cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - } yield { - assertEquals(failed, changeResult) - assertEquals("text", column.getJsonArray("linkAttributes").getJsonObject(0).getString("kind")) - assertEquals("abc", cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getString(0)) - } - } - - @Test - def changeLinkColumnMultilanguageFalseToTrueDuplicatesValue(implicit c: TestContext): Unit = okTest { - val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = false))) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) - ) - cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - } yield { - val attributeValue = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getJsonObject(0) - assertEquals(50, attributeValue.getInteger("de-DE")) - assertEquals(50, attributeValue.getInteger("en-GB")) - } - } - - @Test - def changeLinkColumnMultilanguageTrueToFalseCollapsesValue(implicit c: TestContext): Unit = okTest { - val putLink = Json.obj( - "value" -> Json.obj( - "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> 50, "en-GB" -> 75)))) - ) - ) - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = true))) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false))) - ) - cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - } yield { - assertEquals(50, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) - } - } - - // a stored null is a value that is already empty - duplicating it under every langtag would only respell that, - // so the reshape leaves the slot alone instead of producing an object full of nulls - @Test - def changeLinkColumnMultilanguageFalseToTrueLeavesNullValueUntouched(implicit c: TestContext): Unit = okTest { - val putLink = - Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr().addNull())))) - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = false))) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) - ) - cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - } yield { - assertEquals(Json.arr().addNull(), cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes")) - } - } - - // collapsing has to pick the first langtag that carries an actual value: a cleared langtag comes back from `->` as - // a JSON null rather than a SQL NULL, so a plain COALESCE would stop at it and discard the value behind it - @Test - def changeLinkColumnMultilanguageTrueToFalseSkipsClearedLangtags(implicit c: TestContext): Unit = okTest { - // de-DE comes first in the table's langtags and is explicitly cleared, so en-GB's value has to win - val putLink = Json.obj( - "value" -> Json.obj( - "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> null, "en-GB" -> 75)))) - ) - ) - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = true))) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false))) - ) - cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - } yield { - assertEquals(75, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) - } - } - - @Test - def changeLinkColumnLinkAttributesKindMigrationKeepsClearedLangtags(implicit c: TestContext): Unit = okTest { - val putLink = Json.obj( - "value" -> Json.obj( - "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> null, "en-GB" -> 50)))) - ) - ) - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer", multilanguage = true))) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric", multilanguage = true))) - ) - cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - } yield { - val attributeValue = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getJsonObject(0) - // the cleared langtag keeps its key instead of being dropped by the cast - assertEquals(Set("de-DE", "en-GB"), attributeValue.fieldNames().asScala.toSet) - assertNull(attributeValue.getValue("de-DE")) - assertEquals(50.0, attributeValue.getValue("en-GB").asInstanceOf[Number].doubleValue(), 0.001) - } - } - - // degenerate but reachable: a multilanguage value with every langtag removed. Aggregating over its zero entries - // yields a SQL NULL, which strict jsonb_set would turn into a wiped attributes column instead of an empty object. - @Test - def changeLinkColumnMultilanguageLinkAttributesKindMigrationKeepsEmptyValue(implicit c: TestContext): Unit = okTest { - val putLink = - Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj()))))) - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer", multilanguage = true))) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric", multilanguage = true))) - ) - cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - } yield { - assertEquals(Json.arr(Json.obj()), cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes")) - } - } - - // a slot holding null has nothing to cast, so the kind change goes through and the value stays put - casting it - // would hand strict jsonb_set a SQL NULL, which wipes the whole attributes column - @Test - def changeLinkColumnLinkAttributesKindMigrationLeavesNullValueUntouched(implicit c: TestContext): Unit = - kindMigrationOnNullValueKeepsSlot(multilanguage = false) - - // same for a multilanguage slot, where deconstructing the null would additionally error out ("cannot call - // jsonb_each_text on a non-object") and roll back the whole column change - @Test - def changeLinkColumnMultilanguageLinkAttributesKindMigrationLeavesNullValueUntouched( - implicit c: TestContext - ): Unit = kindMigrationOnNullValueKeepsSlot(multilanguage = true) - - private def kindMigrationOnNullValueKeepsSlot(multilanguage: Boolean)(implicit c: TestContext): Unit = okTest { - val putLink = - Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr().addNull())))) - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer", multilanguage = multilanguage))) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric", multilanguage = multilanguage))) - ) - column <- sendRequest("GET", s"/tables/1/columns/$columnId") - cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - } yield { - assertEquals("numeric", column.getJsonArray("linkAttributes").getJsonObject(0).getString("kind")) - assertEquals(Json.arr().addNull(), cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes")) - } - } - - @Test - def changeLinkColumnFormatPatternAccepted(implicit c: TestContext): Unit = okTest { - val pattern = "{{value}} ({{attributes.percentage}}%)" - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("formatPattern" -> pattern)) - result <- sendRequest("GET", s"/tables/1/columns/$columnId") - } yield { - assertEquals(pattern, result.getString("formatPattern")) - } - } - - @Test - def changeLinkColumnFormatPatternRejectedForUnknownToken(implicit c: TestContext): Unit = - exceptionTest("unprocessable.entity") { - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("formatPattern" -> "{{attributes.doesNotExist}}") - ) - } yield () - } - - @Test - def createLinkColumnFormatPatternAccepted(implicit c: TestContext): Unit = okTest { - val pattern = "{{value}} ({{attributes.percentage}}%)" - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some(pattern)) - result <- sendRequest("GET", s"/tables/1/columns/$columnId") - } yield { - assertEquals(pattern, result.getString("formatPattern")) - } - } - - @Test - def createLinkColumnFormatPatternRejectedForUnknownToken(implicit c: TestContext): Unit = - // same pattern that changeLinkColumnFormatPatternRejectedForUnknownToken rejects on the change path - - // creating a link column must be rejected the same way instead of silently storing a broken pattern - exceptionTest("unprocessable.entity") { - createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some("{{attributes.doesNotExist}}")) - } - - // linkAttributes definitions live once in system_link_table, keyed by link_id, shared by both - // sides of a bidirectional link (see ColumnModel.retrieveLinkInformation). Attribute values - // likewise live once per edge in link_table_.attributes. So on a fresh read (nothing - // cached yet), both the definition and the value are symmetric - identical whether read from - // the forward link column or from its auto-created backlink. - @Test - def backlinkColumnSharesLinkAttributesDefinitionAndValue(implicit c: TestContext): Unit = okTest { - val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(30))))) - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer"))) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - backlinkColumnId <- findBacklinkColumnId(toTable = 1) - - forwardColumn <- sendRequest("GET", s"/tables/1/columns/$columnId") - backlinkColumn <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId") - - forwardCell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - backlinkCell <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId/rows/1") - } yield { - // same definition on both sides - assertJSONEquals(forwardColumn.getJsonArray("linkAttributes"), backlinkColumn.getJsonArray("linkAttributes")) - - // same attribute value on both sides - it's one edge, read from either end - assertEquals(30, forwardCell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) - assertEquals(30, backlinkCell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) - } - } - - // Regression test for a fixed cache-invalidation gap: StructureController.changeColumn used to - // invalidate only the cell-value cache of the column that was actually changed - // (eventClient.invalidateColumn at the end of changeColumn), unlike the row-write path - // (TableauxModel.invalidateCellAndDependentColumns), which also walks retrieveDependencies. - // Without that walk, a backlink cell already cached before a linkAttributes kind change kept - // serving the pre-migration value even though the column's own definition (and the forward - // side's cell) were already up to date. invalidateDependentColumnCaches closes that gap. - @Test - def backlinkCellCacheIsInvalidatedAfterLinkAttributesKindChange(implicit c: TestContext): Unit = okTest { - val putLink = - Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr("50"))))) - - for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "text"))) - _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) - backlinkColumnId <- findBacklinkColumnId(toTable = 1) - - // populate the backlink cell's cache before the definition changes - backlinkCellBefore <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId/rows/1") - - _ <- sendRequest( - "POST", - s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "integer"))) - ) - - forwardCellAfter <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") - backlinkCellAfter <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId/rows/1") - } yield { - val beforeValue = backlinkCellBefore.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getValue(0) - assertEquals("50", beforeValue) - - // forward side is freshly read (never cached before the change) - correctly migrated to a number - val forwardValue = forwardCellAfter.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getValue(0) - assertEquals(50, forwardValue) - - // backlink side was cached before the change too, but the cache is now invalidated as part - // of the change - re-read fresh and reflects the same migrated number - val backlinkValue = - backlinkCellAfter.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getValue(0) - assertEquals(50, backlinkValue) - } - } - - // Same invalidateDependentColumnCaches fix, but exercised via a GroupColumn instead of a link - - // no linkAttributes involved. A GroupColumn's cell value is a plain array of its grouped - // columns' own values, so casting one grouped column's kind must invalidate the GroupColumn's - // already-cached cell too (the retrieveDependentGroupColumn half of the fix, as opposed to the - // retrieveDependencies/backlink half exercised above). - @Test - def changeColumnKindInvalidatesDependentGroupColumnCellCache(implicit c: TestContext): Unit = okTest { - val createTextColumnJson = Json.obj("columns" -> Json.arr(Json.obj("kind" -> "text", "name" -> "Text"))) - val createNumericColumnJson = Json.obj("columns" -> Json.arr(Json.obj("kind" -> "numeric", "name" -> "Number"))) - - for { - tableId <- sendRequest("POST", "/tables", Json.obj("name" -> "Group Cache Test")).map(_.getLong("id")) - - textColumnId <- sendRequest("POST", s"/tables/$tableId/columns", createTextColumnJson) - .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) - numericColumnId <- sendRequest("POST", s"/tables/$tableId/columns", createNumericColumnJson) - .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) - - createGroupColumnJson = Json.obj( - "columns" -> Json.arr( - Json.obj("kind" -> "group", "name" -> "Group", "groups" -> Json.arr(textColumnId, numericColumnId)) - ) - ) - groupColumnId <- sendRequest("POST", s"/tables/$tableId/columns", createGroupColumnJson) - .map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) - - _ <- sendRequest( - "POST", - s"/tables/$tableId/rows", - Json.obj( - "columns" -> Json.arr(Json.obj("id" -> textColumnId), Json.obj("id" -> numericColumnId)), - "rows" -> Json.arr(Json.obj("values" -> Json.arr("5", 10))) - ) - ) - - // populate the group column's cell cache before the structure change - groupCellBefore <- sendRequest("GET", s"/tables/$tableId/columns/$groupColumnId/rows/1") - - // cast the text column's stored "5" to the number 5 - _ <- sendRequest("POST", s"/tables/$tableId/columns/$textColumnId", Json.obj("kind" -> "numeric")) - - textCellAfter <- sendRequest("GET", s"/tables/$tableId/columns/$textColumnId/rows/1") - groupCellAfter <- sendRequest("GET", s"/tables/$tableId/columns/$groupColumnId/rows/1") - } yield { - assertEquals(Json.arr("5", 10), groupCellBefore.getJsonArray("value")) - - // the changed column itself is freshly read (never cached before the change) - correctly cast - assertEquals(5, textCellAfter.getInteger("value")) - - // the group column depends on it and was cached before the change - now invalidated, reflects - // the same cast number instead of the stale pre-migration string - assertEquals(Json.arr(5, 10), groupCellAfter.getJsonArray("value")) - } - } - } From 42f8609bfa61685d48c7cef771a364d7c8146101 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Tue, 18 Aug 2026 16:53:15 +0200 Subject: [PATCH 15/24] fix: more PR fixes: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Critical - **Restore the read-back-writable invariant.** The write-side per-key langtag validation is gone. A value belongs to the link, which two tables with differing langtag sets share, and a langtag can be removed from a table after a value was stored under it — so a stored key is not necessarily one the addressed side would accept today. Rejecting it made a value the API hands out unwritable and broke `duplicateRow` (it re-writes the values it just read) with a 400, reachable without anyone reconfiguring anything. Keys nobody can address anymore stay handled where it is safe: the multilanguage collapse walks the stored object instead of the langtag list. - **Evaluate the langtag guards against the link, not the addressed column.** `linkAttributes` live in `system_link_table` and are shared by both sides, so `checkMultilanguageAllowed` and the multilanguage flip now use the union of both linked tables' langtags (`retrieveLinkLangtags`). Before, the answer depended on which side the request came through, which locked the backlink side out of editing a valid definition — a pure rename was rejected with 422 whenever the other table had no langtags of its own. The flip had to move to the same set: a narrower one would push a value into langtags one side cannot read, or trip the assertion on a definition just accepted. ## Important - Correct the cache-invalidation comment: `structureProperties` contains `columnName` and `ordering`, so a rename does still invalidate. The gating only spares display-only changes. - Correct swagger: 422 fires only when *neither* linked table has langtags, and langtag keys of a stored value are deliberately unrestricted. ## Incidental - Drop the now-dead `checkValidValue` wrapper, `LinkColumn.tableLangtags`, and `normalize`'s `allowedLangtags` parameter whose default silently disabled validation. - Move `reshapeLinkAttributeValues` from `table: Table` to `langtags: Seq[String]`. - Resolve the link's langtags in `change` *before* `connection.begin()` — `retrieveLinkInformation` reads on its own connection, which must not happen while holding a transaction. - Run both definition guards inside a `Future` instead of `_ =`. The caller applies `recoverWith(t.rollbackAndFail())` to the returned Future, so a synchronously escaping throw would have skipped the rollback and left the transaction open. ## Tests Removed the two tests that pinned the wrong semantics (`rejectUnknownLangtagInAttributeValue`, `rejectUnknownLangtagOnPutAttributesEndpoint`). Added six that fail without these fixes: - `attributeValueUnderForeignLangtagIsAccepted` - `attributeValueUnderRemovedLangtagStaysWritable` — read, write back verbatim, then `duplicateRow` - `attributeValueSurvivesDuplicateOnSideWithNarrowerLangtags` — two tables with different langtag sets, nothing reconfigured - `createMultilanguageLinkAttributeSucceedsWhenOnlyOneSideHasLangtags` - `changeLinkAttributesFromBacklinkSideWithoutOwnLangtagsSucceeds` - `multilanguageFlipFromBacklinkSideUsesTheLinksLangtags` Not covered: that a display-only change does *not* trigger the dependency walk — "was not invalidated" is not observable over HTTP without inspecting cache state directly. --- src/main/resources/swagger.json | 4 +- .../controller/StructureController.scala | 8 +- .../tableaux/database/domain/column.scala | 8 +- .../tableaux/database/domain/link.scala | 33 +---- .../database/model/TableauxModel.scala | 6 +- .../model/structure/ColumnModel.scala | 68 +++++++--- .../api/content/LinkAttributesTest.scala | 122 ++++++++++++++---- .../ChangeLinkAttributesStructureTest.scala | 96 ++++++++++++++ 8 files changed, 257 insertions(+), 88 deletions(-) diff --git a/src/main/resources/swagger.json b/src/main/resources/swagger.json index 10c4fa0f0..669d8388b 100644 --- a/src/main/resources/swagger.json +++ b/src/main/resources/swagger.json @@ -2641,7 +2641,7 @@ ], "put": { "summary": "Changes the attribute values of a link.", - "description": "Sets the attribute values for the given link. The attributes array is positional and validated against the link column's linkAttributes definitions (same length, matching kind per entry, and for a multilanguage attribute every langtag key must be one of the table's langtags). null is accepted for every kind and clears the value; for a multilanguage attribute a single langtag can be set to null to clear just that language. date and datetime values are normalized before being stored. Requires the column to have at least one linkAttributes entry defined.", + "description": "Sets the attribute values for the given link. The attributes array is positional and validated against the link column's linkAttributes definitions (same length, matching kind per entry). null is accepted for every kind and clears the value; for a multilanguage attribute a single langtag can be set to null to clear just that language. date and datetime values are normalized before being stored. Requires the column to have at least one linkAttributes entry defined.", "tags": [ "content" ], @@ -5448,7 +5448,7 @@ }, "multilanguage": { "type": "boolean", - "description": "If true, the attribute's value is a multi-language object keyed by langtag instead of a single value; langtags may be omitted or set to null to mean \"no value in this language\", and every key must be one of the table's langtags. Defaults to false. Requires the table to have langtags - a multi-language value is addressed by langtag, so it is rejected with 422 on a table created with \"langtags\": []. Changing this on an existing column reshapes stored values: false to true copies the value to every table langtag, true to false keeps the value of the langtag that has one (table langtags first in their configured order, then any other langtag still present in the stored value); a value that is null is left as null in both directions.", + "description": "If true, the attribute's value is a multi-language object keyed by langtag instead of a single value; langtags may be omitted or set to null to mean \"no value in this language\". Defaults to false. Requires the link to have langtags - a multi-language value is addressed by langtag, so it is rejected with 422 when neither of the two linked tables has any (both created with \"langtags\": []). Langtag keys of a stored value are not restricted to those langtags: the definition is shared by both linked tables, whose langtag sets may differ, and a langtag can be removed from a table after a value was stored under it. Changing this on an existing column reshapes stored values: false to true copies the value to every langtag of the link, true to false keeps the value of the langtag that has one (the link's langtags first in their configured order, then any other langtag still present in the stored value); a value that is null is left as null in both directions.", "example": false } }, diff --git a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala index 8a105ee0c..8fc22e632 100644 --- a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala +++ b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala @@ -872,10 +872,10 @@ class StructureController( _ <- eventClient.invalidateColumn(tableId, columnId) // Only a structure change can alter what other columns' cells resolve to; a display property (displayName, - // hidden, formatPattern, decimalDigits, ...) is rendered by the frontend and leaves every cached cell value - // valid. Gating on the same flag the authorization check above uses keeps the two notions of "structure - // change" from drifting apart, and spares the dependency walk - which fans out over every table linking here - - // on the common case of renaming a column. + // hidden, formatPattern, decimalDigits, maxLength, ...) is rendered by the frontend and leaves every cached + // cell value valid, so it does not need the dependency walk that fans out over every table linking here. + // Gating on the same flag the authorization check above uses keeps the two notions of "structure change" from + // drifting apart - note that this counts `name` and `ordering` as structural, so a rename still invalidates. _ <- if (isAtLeastOneStructureProperty) { invalidateDependentColumnCaches(tableId, columnId, changedColumn) diff --git a/src/main/scala/com/campudus/tableaux/database/domain/column.scala b/src/main/scala/com/campudus/tableaux/database/domain/column.scala index 0b46bbb33..118d3f9c8 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/column.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/column.scala @@ -602,12 +602,6 @@ case class LinkColumn( .mergeIn(formatPatternJson) } - // Langtags of the table this link column belongs to, used to reject unknown langtag keys in a multilanguage - // attribute value. TableModel.convertRowToTable falls back to the global langtags when a table has none of its - // own, so this is populated for every table that has any langtags at all; an empty Seq (a table explicitly - // created with `langtags: []`) turns the check off rather than failing every write. - private def tableLangtags: Seq[String] = columnInformation.table.langtags.getOrElse(Seq.empty) - // Shared by both the `{"id": ..., "attributes": [...]}` (values array) and `{"to": ..., // "attributes": [...]}` (single-value) shapes, so attributes are honored the same way // regardless of which key carries the target row id. @@ -615,7 +609,7 @@ case class LinkColumn( val attributes = Option(obj.getJsonArray("attributes")) .map(attrs => LinkAttributeValueValidator - .normalize(linkAttributes, attrs, tableLangtags) + .normalize(linkAttributes, attrs) .fold(throw _, identity) ) // An empty array carries no value for any definition (it only validates when there are none), so it is the diff --git a/src/main/scala/com/campudus/tableaux/database/domain/link.scala b/src/main/scala/com/campudus/tableaux/database/domain/link.scala index 75135a2e9..a604f6f68 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/link.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/link.scala @@ -273,25 +273,22 @@ object LinkAttributeValueValidator { */ private val dateTimeFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" - def checkValidValue( - definitions: Seq[LinkAttributeDefinition], - attributes: JsonArray, - allowedLangtags: Seq[String] = Seq.empty - ): Try[Unit] = normalize(definitions, attributes, allowedLangtags).map(_ => ()) - /** * Validates a value array against its definitions and returns it in canonical form. Validating and normalizing are * the same pass on purpose: every kind that has more than one spelling for the same value (date, datetime) has to be * parsed to be checked anyway, and letting the parsed result fall on the floor is what allowed two spellings of one * instant to be stored side by side. * - * `allowedLangtags` empty means "don't check langtag keys" - the caller either has no table langtags to check - * against, or is a path where they aren't resolvable synchronously. + * Langtag keys are deliberately NOT validated against the table's langtags. A value belongs to the link, which two + * tables with different langtag sets share, and a langtag can be removed from a table long after a value was stored + * under it - so a stored key is not necessarily a key the addressed side would accept today. Rejecting it would make + * a value that the API hands out unwritable, which breaks read-modify-write and duplicateRow (it re-writes the + * values it just read). Keys nobody can address anymore are handled where it is safe to handle them: the + * multilanguage collapse in ColumnModel walks the stored object instead of the langtag list. */ def normalize( definitions: Seq[LinkAttributeDefinition], - attributes: JsonArray, - allowedLangtags: Seq[String] = Seq.empty + attributes: JsonArray ): Try[JsonArray] = Try { val values = Option(attributes).map(_.asScala.toSeq).getOrElse(Seq.empty) @@ -314,8 +311,6 @@ object LinkAttributeValueValidator { obj.getMap.asScala.foreach({ case (langtag, langValue) => - checkLangtag(definition, langtag, allowedLangtags) - normalizeKindValue(definition, langValue) match { case null => normalizedObj.putNull(langtag) case value => normalizedObj.put(langtag, value) @@ -349,20 +344,6 @@ object LinkAttributeValueValidator { normalized } - private def checkLangtag( - definition: LinkAttributeDefinition, - langtag: String, - allowedLangtags: Seq[String] - ): Unit = { - if (allowedLangtags.nonEmpty && !allowedLangtags.contains(langtag)) { - throw InvalidJsonException( - s"Langtag '$langtag' of attribute '${definition.name}' is not one of its table's langtags " + - s"(${allowedLangtags.mkString(", ")}).", - "link-attributes" - ) - } - } - /** * Returns the value in canonical form for its kind, or null if it is cleared. Clearing is legal for every kind and * not a type violation: null means "no value (in this language)", which is what a multilanguage attribute with only diff --git a/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala b/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala index f0e99d202..eac869e66 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/TableauxModel.scala @@ -733,11 +733,7 @@ class TableauxModel( // Normalizing rather than only validating means what lands in the database is the canonical spelling of // each value, so this endpoint and a cell write store a given date/datetime identically. normalizedAttributes <- Future.fromTry( - LinkAttributeValueValidator.normalize( - linkColumn.linkAttributes, - attributes, - table.langtags.getOrElse(Seq.empty) - ) + LinkAttributeValueValidator.normalize(linkColumn.linkAttributes, attributes) ) _ <- createHistoryModel.createCellsInit(table, rowId, Seq((linkColumn, Seq(toId)))) _ <- updateRowModel.updateLinkAttributes(table, linkColumn, rowId, toId, normalizedAttributes) diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index d8b9cc1a2..8dbbf010c 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -815,7 +815,7 @@ class ColumnModel(val connection: DatabaseConnection)( toCol = toTableColumns.head - langtags <- retrieveEffectiveLangtags(table) + langtags <- retrieveLinkLangtags(table, toTable) _ = { LinkAttributeDefinition.checkMultilanguageAllowed(langtags, linkColumnInfo.linkAttributes) @@ -1917,12 +1917,32 @@ class ColumnModel(val connection: DatabaseConnection)( cast: String = "" ): String = s"UPDATE system_columns SET $columnName = ?$cast WHERE table_id = ? AND column_id = ?" - // The langtags a multilanguage link attribute value can be keyed by. TableModel.convertRowToTable already - // substitutes the global langtags for a table that has none of its own, so the fallback here only covers callers - // holding a Table built some other way; an empty result means the table was explicitly created with `langtags: []`. + // The langtags of a single table. TableModel.convertRowToTable already substitutes the global langtags for a table + // that has none of its own, so the fallback here only covers callers holding a Table built some other way; an empty + // result means the table was explicitly created with `langtags: []`. private def retrieveEffectiveLangtags(table: Table): Future[Seq[String]] = table.langtags.map(Future.successful).getOrElse(tableStruc.retrieveGlobalLangtags()) + // The langtags a multilanguage link attribute can be keyed by: the union of BOTH linked tables' langtags. The + // definition and its values belong to the link, not to one of its two columns, so "can this be multilanguage?" is + // a question about the link. Asking only the addressed column's table meant the answer depended on which side the + // request came through - which locked the backlink side out of editing a perfectly valid definition whenever the + // two tables' langtag sets differed. + private def retrieveLinkLangtags(fromTable: Table, toTable: Table): Future[Seq[String]] = + for { + fromLangtags <- retrieveEffectiveLangtags(fromTable) + toLangtags <- retrieveEffectiveLangtags(toTable) + } yield (fromLangtags ++ toLangtags).distinct + + // Same union, resolved for an existing link column. Deliberately called before the caller opens its transaction: + // retrieveLinkInformation reads on its own connection, which must not happen while we hold one. + private def retrieveLinkLangtags(table: Table, columnId: ColumnId)( + implicit user: TableauxUser + ): Future[Seq[String]] = + retrieveLinkInformation(table, columnId).flatMap({ + case (_, _, toTable, _) => retrieveLinkLangtags(table, toTable) + }) + // Guards the value migrations below: only a slot that actually holds a value gets migrated. A slot holding JSON // null is a value that is explicitly empty, and no migration can improve on that - reshaping it would just respell // "empty" (as per-langtag nulls, or as null again), and casting it would take `attributes->>0` as a SQL NULL, which @@ -1933,11 +1953,15 @@ class ColumnModel(val connection: DatabaseConnection)( // Reshapes existing attribute values (position 0, the only slot while linkAttributes is capped at 1) to match a // multilanguage flip, before any kind cast runs on top. There's no cast for this - it's a structural change - so - // false -> true duplicates the scalar under every table langtag, and true -> false collapses to the langtag that - // actually has a non-null value, discarding the rest. + // false -> true duplicates the scalar under every langtag of the link, and true -> false collapses to the langtag + // that actually has a non-null value, discarding the rest. + // + // `langtags` is the union over both linked tables (see retrieveLinkLangtags), the same set the multilanguage guard + // was evaluated against - a narrower set here would flip a value into langtags one side cannot read, or make the + // assertion below fire on a definition that was just accepted. private def reshapeLinkAttributeValues( t: DbTransaction, - table: Table, + langtags: Seq[String], linkTable: String, oldDefinition: LinkAttributeDefinition, newDefinition: LinkAttributeDefinition @@ -1946,17 +1970,15 @@ class ColumnModel(val connection: DatabaseConnection)( Future.successful((t, Json.obj())) } else { for { - langtags <- retrieveEffectiveLangtags(table) - result <- if (newDefinition.multilanguage) { - // Unreachable: checkMultilanguageAllowed rejects a multilanguage definition on a langtag-less table + // Unreachable: checkMultilanguageAllowed rejects a multilanguage definition on a link without langtags // before we get here. Asserted anyway because the failure mode is silent data loss - an empty langtag // list makes jsonb_build_object() return {}, which would replace every stored value with an empty // object and still commit. if (langtags.isEmpty) { throw UnprocessableEntityException( - s"Cannot make link attribute '${newDefinition.name}' multilanguage: table ${table.id} has no langtags." + s"Cannot make link attribute '${newDefinition.name}' multilanguage: its link has no langtags." ) } @@ -2067,18 +2089,22 @@ class ColumnModel(val connection: DatabaseConnection)( t: DbTransaction, table: Table, columnId: ColumnId, - newDefinitions: Seq[LinkAttributeDefinition] + newDefinitions: Seq[LinkAttributeDefinition], + langtags: Seq[String] ): Future[(DbTransaction, JsonObject)] = { for { - langtags <- retrieveEffectiveLangtags(table) - // Both invariants are already enforced while parsing the request (JsonUtils.parseLinkAttributes) and in the // controller, so over HTTP neither can fire. They are re-asserted here because this is where the max-1 // assumption is actually load-bearing: the migrations below only ever look at position 0, so a second entry // would be persisted but never reshaped or cast - a silent data bug rather than an error - and a multilanguage // definition without langtags is what used to make the reshape wipe values. - _ = LinkAttributeDefinition.checkMaxCount(newDefinitions.size) - _ = LinkAttributeDefinition.checkMultilanguageAllowed(langtags, newDefinitions) + // + // Inside a Future rather than a plain `_ =`: the caller applies rollbackAndFail() to the Future this method + // returns, so a throw that escaped synchronously would skip the rollback and leave the transaction open. + _ <- Future { + LinkAttributeDefinition.checkMaxCount(newDefinitions.size) + LinkAttributeDefinition.checkMultilanguageAllowed(langtags, newDefinitions) + } (t, linkIdResult) <- t.query( "SELECT link_id FROM system_columns WHERE table_id = ? AND column_id = ?", @@ -2103,7 +2129,7 @@ class ColumnModel(val connection: DatabaseConnection)( // in place; an incompatible kind change fails and rolls back the whole update, same as // when the name stays the same. for { - (t, _) <- reshapeLinkAttributeValues(t, table, linkTable, oldDef, newDef) + (t, _) <- reshapeLinkAttributeValues(t, langtags, linkTable, oldDef, newDef) (t, result) <- castLinkAttributeValues(t, linkTable, oldDef, newDef) } yield (t, result) @@ -2162,6 +2188,12 @@ class ColumnModel(val connection: DatabaseConnection)( } for { + // Resolved before the transaction opens on purpose: this reads on its own connection (it has to retrieve the + // link's other table), which must not happen while we are holding one. + linkLangtags <- + if (linkAttributes.isDefined) retrieveLinkLangtags(table, columnId) + else Future.successful(Seq.empty[String]) + t <- connection.begin() // change column settings @@ -2210,7 +2242,7 @@ class ColumnModel(val connection: DatabaseConnection)( linkAttributes, t, { (newDefinitions: Seq[LinkAttributeDefinition]) => - updateLinkAttributesDefinition(t, table, columnId, newDefinitions) + updateLinkAttributesDefinition(t, table, columnId, newDefinitions, linkLangtags) } ).recoverWith(t.rollbackAndFail()) diff --git a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala index f4d2cff30..ed13e6a73 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala @@ -440,43 +440,113 @@ class LinkAttributesTest extends LinkTestBase { } // --------------------------------------------------------------------------------------------------------------- - // Langtag keys of a multilanguage attribute value are validated against the table's langtags. Writing was tolerant - // before, which let a value be stored under a langtag no migration would ever look at again. + // Langtag keys are deliberately NOT restricted to the addressed table's langtags. A value belongs to the link, + // which two tables with differing langtag sets share, and a langtag can be removed from a table after a value was + // stored under it - so rejecting an unknown key would make a value the API hands out unwritable. // --------------------------------------------------------------------------------------------------------------- @Test - def rejectUnknownLangtagInAttributeValue(implicit c: TestContext): Unit = - exceptionTest("error.json.link-attributes") { - val putLink = Json.obj( - "value" -> Json.obj( - "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("kling-ON" -> 50)))) - ) + def attributeValueUnderForeignLangtagIsAccepted(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj( + "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("kling-ON" -> 50)))) ) + ) - for { - _ <- setupTwoTables() - linkColumnId <- createLinkColumnWithAttributes(1, 2, multilanguage = true) - _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) - } yield () + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2, multilanguage = true) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + val value = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getJsonObject(0) + assertEquals(50, value.getInteger("kling-ON")) } + } + /** + * The round-trip invariant: whatever the API hands out has to be acceptable as a write again. Rejecting langtag keys + * broke this for a value stored under a langtag that was later removed from the table - both the explicit attributes + * endpoint and duplicateRow (which re-writes the values it just read) failed with 400. + */ @Test - def rejectUnknownLangtagOnPutAttributesEndpoint(implicit c: TestContext): Unit = - exceptionTest("error.json.link-attributes") { - val putLink = Json.obj( - "value" -> Json.obj( - "values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> 50)))) - ) + def attributeValueUnderRemovedLangtagStaysWritable(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> 50))))) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2, multilanguage = true) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + + // de-DE is no longer one of the table's langtags, but the stored value still sits under it + _ <- sendRequest("POST", "/tables/1", Json.obj("langtags" -> Json.arr("en-GB"))) + + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + readBack = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes") + + // writing back exactly what was read must work + _ <- sendRequest( + "PUT", + s"/tables/1/columns/$linkColumnId/rows/1/link/1/attributes", + Json.obj("attributes" -> readBack) ) - val putAttributes = Json.obj("attributes" -> Json.arr(Json.obj("kling-ON" -> 75))) - for { - _ <- setupTwoTables() - linkColumnId <- createLinkColumnWithAttributes(1, 2, multilanguage = true) - _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) - _ <- sendRequest("PUT", s"/tables/1/columns/$linkColumnId/rows/1/link/1/attributes", putAttributes) - } yield () + // duplicateRow does the same round-trip internally + duplicated <- sendRequest("POST", "/tables/1/rows/1/duplicate") + duplicatedCell <- + sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/${duplicated.getNumber("id")}") + } yield { + assertEquals(Json.obj("de-DE" -> 50), readBack.getJsonObject(0)) + assertEquals( + Json.obj("de-DE" -> 50), + duplicatedCell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getJsonObject(0) + ) } + } + + /** + * Same invariant, reached without anyone reconfiguring anything: two linked tables with different langtag sets. The + * value is written from the side that knows de-DE, then a row is duplicated on the side that does not. + */ + @Test + def attributeValueSurvivesDuplicateOnSideWithNarrowerLangtags(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj("de-DE" -> 50))))) + ) + + for { + _ <- createDefaultTable() + toTableId <- sendRequest("POST", "/tables", Json.obj("name" -> "Narrow", "langtags" -> Json.arr("en-GB"))) + .map(_.getLong("id").toLong) + _ <- sendRequest( + "POST", + s"/tables/$toTableId/columns", + Json.obj("columns" -> Json.arr(Json.obj("name" -> "name", "kind" -> "text", "identifier" -> true))) + ) + _ <- sendRequest("POST", s"/tables/$toTableId/rows", Json.obj()) + + linkColumnId <- sendRequest( + "POST", + "/tables/1/columns", + Json.obj("columns" -> Json.arr(Json.obj( + "name" -> "Test Link 1", + "kind" -> "link", + "toTable" -> toTableId, + "singleDirection" -> false, + "linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true)) + ))) + ).map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) + + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + + // duplicate a row of the table whose langtags do not include de-DE + duplicated <- sendRequest("POST", s"/tables/$toTableId/rows/1/duplicate") + } yield { + assertNotNull(duplicated.getNumber("id")) + } + } // --------------------------------------------------------------------------------------------------------------- // date/datetime values are normalized on write, so one instant has exactly one stored spelling. diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala index 4f2dce121..e1cf635c6 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala @@ -923,4 +923,100 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase { } } + // --------------------------------------------------------------------------------------------------------------- + // The multilanguage guard asks the LINK, not the addressed column: linkAttributes live in system_link_table and are + // shared by both sides, so evaluating only the addressed table's langtags made the answer depend on which side the + // request came through - and locked the backlink side out of editing a perfectly valid definition. + // --------------------------------------------------------------------------------------------------------------- + + // table 1 has langtags, table 2 does not; the link therefore has a langtag context + private def createLinkToTableWithoutLangtags(multilanguage: Boolean): Future[(TableId, ColumnId)] = { + for { + _ <- createDefaultTable() + toTableId <- createTableWithoutLangtags("No Langtags") + // createTableWithoutLangtags deliberately creates no rows; the flip test links to one + _ <- sendRequest("POST", s"/tables/$toTableId/rows", Json.obj()) + columnId <- sendRequest( + "POST", + "/tables/1/columns", + Json.obj("columns" -> Json.arr(Json.obj( + "name" -> "Test Link 1", + "kind" -> "link", + "toTable" -> toTableId, + "singleDirection" -> false, + "linkAttributes" -> Json.arr(percentageAttribute(multilanguage = multilanguage)) + ))) + ).map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) + } yield (toTableId, columnId) + } + + @Test + def createMultilanguageLinkAttributeSucceedsWhenOnlyOneSideHasLangtags(implicit c: TestContext): Unit = okTest { + for { + (_, columnId) <- createLinkToTableWithoutLangtags(multilanguage = true) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertTrue(column.getJsonArray("linkAttributes").getJsonObject(0).getBoolean("multilanguage")) + } + } + + @Test + def changeLinkAttributesFromBacklinkSideWithoutOwnLangtagsSucceeds(implicit c: TestContext): Unit = okTest { + for { + (toTableId, _) <- createLinkToTableWithoutLangtags(multilanguage = true) + backlinkId <- sendRequest("GET", s"/tables/$toTableId/columns").map( + _.getJsonArray("columns") + .asScala + .map(_.asInstanceOf[JsonObject]) + .collectFirst({ case col if col.getString("kind") == "link" => col.getLong("id").toLong }) + .get + ) + // a pure rename from the side that has no langtags of its own + _ <- sendRequest( + "POST", + s"/tables/$toTableId/columns/$backlinkId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true, name = "share"))) + ) + forwardColumn <- sendRequest("GET", "/tables/1/columns") + } yield { + val linkColumn = forwardColumn.getJsonArray("columns") + .asScala + .map(_.asInstanceOf[JsonObject]) + .collectFirst({ case col if col.getString("kind") == "link" => col }) + .get + + assertEquals("share", linkColumn.getJsonArray("linkAttributes").getJsonObject(0).getString("name")) + } + } + + // The multilanguage flip must target the link's langtags too, not just the addressed table's - otherwise a value + // flipped from the langtag-less side would end up in an empty object. + @Test + def multilanguageFlipFromBacklinkSideUsesTheLinksLangtags(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + + for { + (toTableId, columnId) <- createLinkToTableWithoutLangtags(multilanguage = false) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + backlinkId <- sendRequest("GET", s"/tables/$toTableId/columns").map( + _.getJsonArray("columns") + .asScala + .map(_.asInstanceOf[JsonObject]) + .collectFirst({ case col if col.getString("kind") == "link" => col.getLong("id").toLong }) + .get + ) + _ <- sendRequest( + "POST", + s"/tables/$toTableId/columns/$backlinkId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) + ) + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + val attributeValue = cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getJsonObject(0) + + assertEquals(50, attributeValue.getInteger("de-DE")) + assertEquals(50, attributeValue.getInteger("en-GB")) + } + } + } From bb43147500eb009c7c9d027876c8f99f231ae50c Mon Sep 17 00:00:00 2001 From: smnhgn Date: Tue, 18 Aug 2026 17:20:20 +0200 Subject: [PATCH 16/24] refactor: remove dead/unused code --- .../controller/StructureController.scala | 6 ++-- .../controller/TableauxController.scala | 2 +- .../tableaux/database/domain/column.scala | 4 +-- .../database/domain/createcolumn.scala | 10 +++---- .../model/structure/ColumnModel.scala | 8 ++--- .../tableaux/testtools/RequestCreation.scala | 29 ------------------- 6 files changed, 15 insertions(+), 44 deletions(-) diff --git a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala index 8fc22e632..87ac4631c 100644 --- a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala +++ b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala @@ -890,8 +890,10 @@ class StructureController( // migrating/wiping values) can affect every row, not just one. Without this, a column shared // across tables (the backlink side of a bidirectional link, or a group column referencing this // one) keeps serving cell values cached before the change. - private def invalidateDependentColumnCaches(tableId: TableId, columnId: ColumnId, column: ColumnType[?])( - implicit user: TableauxUser + private def invalidateDependentColumnCaches( + tableId: TableId, + columnId: ColumnId, + column: ColumnType[?] ): Future[Unit] = { def invalidateColumnCache: (TableId, ColumnId) => Future[?] = eventClient.invalidateColumn diff --git a/src/main/scala/com/campudus/tableaux/controller/TableauxController.scala b/src/main/scala/com/campudus/tableaux/controller/TableauxController.scala index ac81b85da..b2917037e 100644 --- a/src/main/scala/com/campudus/tableaux/controller/TableauxController.scala +++ b/src/main/scala/com/campudus/tableaux/controller/TableauxController.scala @@ -16,7 +16,7 @@ import com.campudus.tableaux.helper.UnionTableHelper import com.campudus.tableaux.router.auth.permission._ import com.campudus.tableaux.verticles.EventClient -import io.vertx.lang.scala.json.{JsonArray, JsonObject} +import io.vertx.lang.scala.json.JsonArray import scala.concurrent.Future import scala.util.Try diff --git a/src/main/scala/com/campudus/tableaux/database/domain/column.scala b/src/main/scala/com/campudus/tableaux/database/domain/column.scala index 118d3f9c8..dcfc499d9 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/column.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/column.scala @@ -567,8 +567,8 @@ case class LinkColumn( to: ColumnType[?], linkId: LinkId, linkDirection: LinkDirection, - linkAttributes: Seq[LinkAttributeDefinition] = Seq.empty, - formatPattern: Option[String] = None + linkAttributes: Seq[LinkAttributeDefinition], + formatPattern: Option[String] )(implicit override val roleModel: RoleModel, val user: TableauxUser) extends ColumnType[Seq[LinkValue]] with LazyLogging { override val kind: LinkType.type = LinkType diff --git a/src/main/scala/com/campudus/tableaux/database/domain/createcolumn.scala b/src/main/scala/com/campudus/tableaux/database/domain/createcolumn.scala index 3ce0a67de..9535dc115 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/createcolumn.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/createcolumn.scala @@ -56,9 +56,7 @@ object CreateLinkColumn { displayInfos: Seq[DisplayInfo], constraint: Constraint, attributes: Option[JsonObject], - hidden: Boolean = false, - linkAttributes: Seq[LinkAttributeDefinition] = Seq.empty, - formatPattern: Option[String] = None + hidden: Boolean = false ): CreateLinkColumn = { val createBackLinkColumn = CreateBackLinkColumn( name = toName, @@ -77,8 +75,10 @@ object CreateLinkColumn { createBackLinkColumn, attributes, hidden, - linkAttributes, - formatPattern + // This factory exists for the internal bootstrap callers (settings/taxonomy tables), which never carry link + // attributes. Requests that do go through the case class constructor directly - see JsonUtils.toCreateColumnSeq. + linkAttributes = Seq.empty, + formatPattern = None ) } } diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index 8dbbf010c..7f22d2167 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -2110,11 +2110,9 @@ class ColumnModel(val connection: DatabaseConnection)( "SELECT link_id FROM system_columns WHERE table_id = ? AND column_id = ?", Json.arr(table.id, columnId) ) - linkId = Option(selectNotNull(linkIdResult).head.getLong(0)) - .map(_.longValue()) - .getOrElse(throw UnprocessableEntityException( - s"Column $columnId of table ${table.id} is not a link column, it has no linkAttributes." - )) + // No "is this a link column?" guard here: change() resolves the link's langtags before opening the transaction, + // which already fails for a column without a link_id - so by this point there is one. + linkId = selectNotNull(linkIdResult).head.getLong(0).longValue() linkTable = s"link_table_$linkId" (t, currentResult) <- t.query("SELECT attributes FROM system_link_table WHERE link_id = ?", Json.arr(linkId)) diff --git a/src/test/scala/com/campudus/tableaux/testtools/RequestCreation.scala b/src/test/scala/com/campudus/tableaux/testtools/RequestCreation.scala index 31e552a05..efb4c1d40 100644 --- a/src/test/scala/com/campudus/tableaux/testtools/RequestCreation.scala +++ b/src/test/scala/com/campudus/tableaux/testtools/RequestCreation.scala @@ -172,33 +172,4 @@ object RequestCreation { } } - case class LinkAttributeDef( - name: String, - displayName: Map[String, String] = Map.empty, - kind: String = "integer", - multilanguage: Boolean = false - ) extends DomainObject { - - override def getJson: JsonObject = Json.obj( - "name" -> name, - "displayName" -> Json.obj(displayName.toSeq*), - "kind" -> kind, - "multilanguage" -> multilanguage - ) - } - - case class WithLinkAttributes(column: ColumnType, linkAttributes: Seq[LinkAttributeDef]) - extends ColumnType(column.kind) { - val name: String = column.name - - override def getJson: JsonObject = - column.getJson.mergeIn(Json.obj("linkAttributes" -> Json.arr(linkAttributes.map(_.getJson)*))) - } - - case class WithFormatPattern(column: ColumnType, formatPattern: String) extends ColumnType(column.kind) { - val name: String = column.name - - override def getJson: JsonObject = column.getJson.mergeIn(Json.obj("formatPattern" -> formatPattern)) - } - } From 95865368ebbac47ee7b06b28e0958347752de2ef Mon Sep 17 00:00:00 2001 From: smnhgn Date: Wed, 19 Aug 2026 11:58:48 +0200 Subject: [PATCH 17/24] fix: allow delete of linkAttributes and formatPattern --- src/main/resources/swagger.json | 6 +- .../controller/StructureController.scala | 23 ++-- .../model/structure/ColumnModel.scala | 10 +- .../campudus/tableaux/helper/JsonUtils.scala | 18 ++- .../ChangeLinkAttributesStructureTest.scala | 122 ++++++++++++++++++ .../api/structure/GroupColumnTest.scala | 29 +++++ 6 files changed, 192 insertions(+), 16 deletions(-) diff --git a/src/main/resources/swagger.json b/src/main/resources/swagger.json index 669d8388b..e0a176c87 100644 --- a/src/main/resources/swagger.json +++ b/src/main/resources/swagger.json @@ -1238,14 +1238,14 @@ }, "linkAttributes": { "type": "array", - "description": "Definitions of attributes attached to a link column (only applicable if the column is of kind \"link\"). Send an empty array to clear the definition and any stored attribute values; omit the field entirely to leave it untouched. Validated against the column's formatPattern in both directions: changing only the definitions is rejected with 422 if the stored pattern references an attribute that would no longer exist, and changing only the pattern is rejected if it references an attribute the column does not have.", + "description": "Definitions of attributes attached to a link column (only applicable if the column is of kind \"link\"). Send an empty array (or null) to clear the definition and any stored attribute values; omit the field entirely to leave it untouched. Validated against the column's formatPattern in both directions: changing only the definitions is rejected with 422 if the stored pattern references an attribute that would no longer exist, and changing only the pattern is rejected if it references an attribute the column does not have.", "items": { "$ref": "#/definitions/LinkAttributeDefinition" } }, "formatPattern": { "type": "string", - "description": "Pattern for rendering a group or link column's content in the frontend. Only allowed for columns of kind \"group\" or \"link\".", + "description": "Pattern for rendering a group or link column's content in the frontend. Only allowed for columns of kind \"group\" or \"link\". Send null to delete the pattern; omit the field entirely to leave it untouched. Deleting it is what makes clearing a link column's linkAttributes possible when the pattern references them - send both as null in the same request.", "example": "{{value}} ({{attributes.percentage}}%)" } } @@ -5597,7 +5597,7 @@ }, "linkAttributes": { "type": "array", - "description": "Definitions of attributes attached to this link (only applicable if kind is \"link\"). Currently limited to a maximum of 1 entry. Can also be set/changed on an existing link column via the change-column endpoint; sending an empty array clears the definition and any stored attribute values, omitting the field leaves it untouched. A change is validated against the column's formatPattern - renaming or clearing an attribute that the stored pattern references as {{attributes.}} is rejected with 422; send a matching formatPattern in the same request. Note that linkAttributes are shared with the auto-created backlink column while formatPattern is per column, so changing the definitions from one side is not validated against a formatPattern set on the other side.", + "description": "Definitions of attributes attached to this link (only applicable if kind is \"link\"). Currently limited to a maximum of 1 entry. Can also be set/changed on an existing link column via the change-column endpoint; sending an empty array (or null) clears the definition and any stored attribute values, omitting the field leaves it untouched. A change is validated against the column's formatPattern - renaming or clearing an attribute that the stored pattern references as {{attributes.}} is rejected with 422; send a matching formatPattern in the same request, or formatPattern: null to delete the pattern along with the definitions. Note that linkAttributes are shared with the auto-created backlink column while formatPattern is per column, so changing the definitions from one side is not validated against a formatPattern set on the other side.", "items": { "$ref": "#/definitions/LinkAttributeDefinition" }, diff --git a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala index 87ac4631c..151f9ef1b 100644 --- a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala +++ b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala @@ -697,7 +697,8 @@ class StructureController( minLength: Option[Int] = None, showMemberColumns: Option[Boolean] = None, decimalDigits: Option[Int] = None, - formatPattern: Option[String] = None, + // outer None: not submitted, leave untouched; Some(None): submitted as null, delete it + formatPattern: Option[Option[String]] = None, linkAttributes: Option[Seq[LinkAttributeDefinition]] = None )(implicit user: TableauxUser): Future[ColumnType[?]] = { checkArguments( @@ -807,8 +808,10 @@ class StructureController( // A link column's formatPattern and its linkAttributes constrain each other - the pattern references the // definitions by name as {{attributes.}} - so both directions have to be validated. Changing only the // definitions (renaming an attribute, or clearing them with an empty array) invalidates a pattern stored - // earlier just as thoroughly as changing only the pattern does, and since formatPattern can't be set back to - // null through this endpoint, such a pattern can't easily be repaired afterwards either. + // earlier just as thoroughly as changing only the pattern does. Which is why a request that would leave the + // two inconsistent is rejected and the caller has to submit both together - and why `formatPattern: null` + // has to be accepted as "delete it": clearing the definitions is only possible if the pattern referencing + // them can be cleared in the same request. // // Scope: this covers the column being changed. linkAttributes live on the link (system_link_table, shared with // the backlink column) while formatPattern lives on the column (system_columns), so changing the definitions @@ -818,11 +821,14 @@ class StructureController( if (formatPattern.isDefined || linkAttributes.isDefined) { column match { case groupColumn: GroupColumn if formatPattern.isDefined => { - if (!isColumnGroupMatchingToFormatPattern(formatPattern, groupColumn.columns)) { + // formatPattern.flatten: a submitted null is None here, i.e. no pattern to check at all - deleting a + // pattern can never make it inconsistent with the grouped columns. + if (!isColumnGroupMatchingToFormatPattern(formatPattern.flatten, groupColumn.columns)) { val columnsIds = groupColumn.columns.map(_.id).mkString(", "); Future.failed(UnprocessableEntityException( - s"Invalid formatPattern: columns ($columnsIds) don't match with formatPattern '$formatPattern'" + s"Invalid formatPattern: columns ($columnsIds) don't match with formatPattern " + + s"'${formatPattern.flatten.orNull}'" )) } else { Future.successful(()) @@ -830,8 +836,9 @@ class StructureController( } case linkColumn: LinkColumn => { // Whichever of the two the request omits is taken from the column as it stands, so the check always - // sees the pair as it will be after the change. - val effectiveFormatPattern = formatPattern.orElse(linkColumn.formatPattern) + // sees the pair as it will be after the change. getOrElse (not flatten.orElse) on purpose: a + // submitted null means the pattern is gone afterwards, it must not fall back to the stored one. + val effectiveFormatPattern = formatPattern.getOrElse(linkColumn.formatPattern) val effectiveLinkAttributes = linkAttributes.getOrElse(linkColumn.linkAttributes) if (!isLinkColumnMatchingToFormatPattern(effectiveFormatPattern, effectiveLinkAttributes)) { @@ -844,7 +851,7 @@ class StructureController( } case _ if formatPattern.isDefined => Future.failed(ForbiddenException( - s"Update of formatPattern '$formatPattern' is not allowed for column ${column.kind}.", + s"Update of formatPattern '${formatPattern.flatten.orNull}' is not allowed for column ${column.kind}.", "column" )) // linkAttributes on a non-link column was already rejected above diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index 7f22d2167..7ff76934e 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -222,7 +222,8 @@ class CachedColumnModel( minLength: Option[Int], showMemberColumns: Option[Boolean], decimalDigits: Option[Int], - formatPattern: Option[String], + // outer None: not submitted, leave untouched; Some(None): submitted as null, delete it + formatPattern: Option[Option[String]], linkAttributes: Option[Seq[LinkAttributeDefinition]] )(implicit user: TableauxUser): Future[ColumnType[?]] = { for { @@ -2166,7 +2167,8 @@ class ColumnModel(val connection: DatabaseConnection)( minLength: Option[Int], showMemberColumns: Option[Boolean], decimalDigits: Option[Int], - formatPattern: Option[String], + // outer None: not submitted, leave untouched; Some(None): submitted as null, delete it + formatPattern: Option[Option[String]], linkAttributes: Option[Seq[LinkAttributeDefinition]] )(implicit user: TableauxUser): Future[ColumnType[?]] = { val tableId = table.id @@ -2208,7 +2210,9 @@ class ColumnModel(val connection: DatabaseConnection)( (t, resultHidden) <- maybeUpdateColumn(t, "hidden", hidden) (t, resultShowMemberColumns) <- maybeUpdateColumn(t, "show_member_columns", showMemberColumns) (t, resultDecimalDigits) <- maybeUpdateColumn(t, "decimal_digits", decimalDigits) - (t, resultFormatPattern) <- maybeUpdateColumn(t, "format_pattern", formatPattern) + // trans unwraps the inner Option so a submitted null actually writes NULL instead of being skipped + (t, resultFormatPattern) <- + maybeUpdateColumn(t, "format_pattern", formatPattern, (p: Option[String]) => p.orNull) // cannot use optionToValidFuture here, we need to be able to set these settings to null (t, resultMaxLength) <- maxLength match { diff --git a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala index 55ce9460a..e67fe1552 100644 --- a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala +++ b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala @@ -491,7 +491,7 @@ object JsonUtils extends LazyLogging { Option[Int], Option[Boolean], Option[Int], - Option[String], + Option[Option[String]], Option[Seq[LinkAttributeDefinition]] ) = { @@ -528,7 +528,21 @@ object JsonUtils extends LazyLogging { val maxLength = getNullableJsonIntegerValue("maxLength", json).toOption val minLength = getNullableJsonIntegerValue("minLength", json).toOption val decimalDigits = parseDecimalDigits(json) - val formatPattern = hasString("formatPattern", json).toOption + + // Same None/Some(None)/Some(Some(...)) distinction as linkAttributes below: None means "formatPattern wasn't + // submitted at all, leave it untouched", Some(None) means "submitted as null", the wire-level way to delete an + // existing formatPattern. Deleting it has to be expressible because a formatPattern and the linkAttributes it + // references constrain each other - without it, clearing the definitions would leave a dangling pattern behind + // that could never be repaired (see StructureController.changeColumn). + val formatPattern = booleanToValueOption( + json.containsKey("formatPattern"), + json.getValue("formatPattern") match { + case null => None + case value: String => Some(value) + case other => + throw InvalidJsonException(s"formatPattern must be a string or null, but got $other.", "formatPattern") + } + ) // None means "linkAttributes wasn't submitted at all, leave existing definition untouched" - as opposed to // Some(Seq.empty) which means "submitted as an explicit empty array", the wire-level way to delete an diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala index e1cf635c6..c42b54070 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala @@ -714,6 +714,128 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase { } } + // ... and when one does depend on them, clearing both together is the way out. `formatPattern: null` deletes the + // pattern (as opposed to omitting the key, which leaves it untouched) - without that, a link column that once had + // a pattern referencing an attribute could never get rid of its definitions again. + @Test + def clearingLinkAttributesTogetherWithFormatPatternSucceeds(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + + for { + columnId <- createLinkColumn( + Json.arr(percentageAttribute()), + formatPattern = Some("{{value}} ({{attributes.percentage}}%)") + ) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(), "formatPattern" -> null) + ) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertFalse(column.containsKey("linkAttributes")) + assertFalse(column.containsKey("formatPattern")) + // the definitions are gone, so the values stored under them have to be gone as well + assertFalse(cell.getJsonArray("value").getJsonObject(0).containsKey("attributes")) + } + } + + // `linkAttributes: null` is the same request as `linkAttributes: []` - both mean "submitted, and empty". + @Test + def clearingLinkAttributesWithNullTogetherWithFormatPatternSucceeds(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn( + Json.arr(percentageAttribute()), + formatPattern = Some("{{attributes.percentage}}") + ) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> null, "formatPattern" -> null) + ) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertFalse(column.containsKey("linkAttributes")) + assertFalse(column.containsKey("formatPattern")) + } + } + + // Same as clearingLinkAttributesLeavingFormatPatternDanglingFails, but with null instead of an empty array: the + // two spellings mean the same thing, so they have to be rejected the same way. + @Test + def clearingLinkAttributesWithNullLeavingFormatPatternDanglingFails(implicit c: TestContext): Unit = + exceptionTest("unprocessable.entity") { + for { + columnId <- createLinkColumn( + Json.arr(percentageAttribute()), + formatPattern = Some("{{attributes.percentage}}") + ) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("linkAttributes" -> null)) + } yield () + } + + // Deleting only the pattern is always safe - definitions without a pattern referencing them are a valid state. + @Test + def clearingOnlyFormatPatternKeepsLinkAttributes(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn( + Json.arr(percentageAttribute()), + formatPattern = Some("{{value}} ({{attributes.percentage}}%)") + ) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("formatPattern" -> null)) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertFalse(column.containsKey("formatPattern")) + assertJSONEquals(Json.arr(percentageAttribute()), column.getJsonArray("linkAttributes")) + } + } + + // Replacing the definitions while dropping the pattern in the same request: the pattern is checked as deleted, + // not against the definitions being written. + @Test + def changeLinkAttributesTogetherWithClearedFormatPatternSucceeds(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn( + Json.arr(percentageAttribute()), + formatPattern = Some("{{value}} ({{attributes.percentage}}%)") + ) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "share")), "formatPattern" -> null) + ) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertFalse(column.containsKey("formatPattern")) + assertEquals("share", column.getJsonArray("linkAttributes").getJsonObject(0).getString("name")) + } + } + + // Omitting formatPattern still means "leave it untouched" - deleting it has to be requested explicitly. + @Test + def omittingFormatPatternLeavesItUntouched(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some("{{value}}")) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("name" -> "renamed")) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertEquals("{{value}}", column.getString("formatPattern")) + } + } + + // Anything that is neither a string nor null can't be a pattern - and must not be silently ignored, which is what + // would make a "delete" that was spelled wrong look like it worked. + @Test + def changeFormatPatternToNonStringFails(implicit c: TestContext): Unit = + exceptionTest("error.json.formatPattern") { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute())) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("formatPattern" -> 42)) + } yield () + } + // --------------------------------------------------------------------------------------------------------------- // A multilanguage attribute value is keyed by langtag, so a table without langtags can't carry one. Allowing it // used to let the multilanguage reshape run with an empty langtag list, which replaced every stored value with an diff --git a/src/test/scala/com/campudus/tableaux/api/structure/GroupColumnTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/GroupColumnTest.scala index 27ba0b99a..d5adeeff1 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/GroupColumnTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/GroupColumnTest.scala @@ -641,6 +641,35 @@ class GroupColumnTest extends TableauxTestBase { } } + // A formatPattern is deletable by submitting it as null (omitting the key means "leave it untouched"). Without a + // pattern a group column just falls back to concatenating its members, so this is a valid state to return to. + @Test + def changeGroupColumnClearingFormatPattern(implicit c: TestContext): Unit = { + okTest { + for { + _ <- sendRequest("POST", "/tables", createTableJson) + + textCol1 <- sendCreateColumnRequest(1, createTextColumnJson("textcolumn1")) + textCol2 <- sendCreateColumnRequest(1, createTextColumnJson("textcolumn2")) + + groupColumnCreated <- sendRequest( + "POST", + "/tables/1/columns", + createGroupColumnWithFormatPatternJson("groupcolumn", Seq(textCol1, textCol2), "{{1}} × {{2}} mm") + ).map(_.getJsonArray("columns").getJsonObject(0)) + + groupColumnChanged <- sendRequest( + "POST", + s"/tables/1/columns/${groupColumnCreated.getInteger("id")}", + Json.obj("formatPattern" -> null) + ) + } yield { + assertEquals("{{1}} × {{2}} mm", groupColumnCreated.getString("formatPattern")) + assertFalse(groupColumnChanged.containsKey("formatPattern")) + } + } + } + @Test def createAndChangeTripleGroupColumnWithInvalidFormat(implicit c: TestContext): Unit = { exceptionTest("unprocessable.entity") { From 525148be88d9ff76b931a10b9aa4e9563aa0de11 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Mon, 24 Aug 2026 10:33:59 +0200 Subject: [PATCH 18/24] fix: make sure multiple linkAttributes work if maxCount limit is increased --- .../tableaux/database/domain/link.scala | 39 +- .../model/structure/ColumnModel.scala | 151 ++++-- .../campudus/tableaux/helper/JsonUtils.scala | 6 +- .../MultipleLinkAttributesTest.scala | 462 ++++++++++++++++++ 4 files changed, 602 insertions(+), 56 deletions(-) create mode 100644 src/test/scala/com/campudus/tableaux/api/structure/MultipleLinkAttributesTest.scala diff --git a/src/main/scala/com/campudus/tableaux/database/domain/link.scala b/src/main/scala/com/campudus/tableaux/database/domain/link.scala index a604f6f68..ccdc62e8c 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/link.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/link.scala @@ -181,7 +181,41 @@ object LinkAttributeDefinition { val allowedKinds: Set[TableauxDbType] = Set(TextType, NumericType, IntegerType, BooleanType, DateType, DateTimeType) - val maxCount = 1 + val defaultMaxCount = 1 + + private var maxCountOverride: Option[Int] = None + + /** + * How many definitions one link column may carry. The cap is a rollout decision, not a structural limit: nothing + * below it is written for exactly one definition - values are positional arrays, migrations walk them slot by slot, + * and a formatPattern addresses a definition by name - so it can be raised without touching any of that. + * + * Which is exactly why it is read through a `def` with an override behind it instead of being a constant: the only + * way to keep the N-definition code paths honest is to actually exercise them, and with a hard-coded 1 every one of + * them is dead code that no test can reach. Nothing in production ever calls the override, so over HTTP this is the + * constant it looks like. + */ + def maxCount: Int = maxCountOverride.getOrElse(defaultMaxCount) + + /** + * Test-only seam for [[maxCount]] - see MultipleLinkAttributesTest, which raises the cap for its own tests and + * resets it afterwards. Deliberately global mutable state rather than a config value threaded through + * TableauxConfig: the cap is enforced while parsing a request (JsonUtils) and while migrating stored values + * (ColumnModel), neither of which has a config in reach, and a config key would be an API for operators to raise a + * cap that the frontend isn't ready for yet. + */ + def setMaxCountForTest(count: Int): Unit = { + require(count >= 1, s"link attribute maxCount must be at least 1, but was $count") + maxCountOverride = Some(count) + } + + /** + * Restores [[maxCount]] to [[defaultMaxCount]]. Tests that call [[setMaxCountForTest]] have to call this afterwards + * (in an `@After`), or they leak the raised cap into every test that runs after them in the same JVM. + */ + def resetMaxCountForTest(): Unit = { + maxCountOverride = None + } def fromJson(json: JsonObject): LinkAttributeDefinition = { LinkAttributeDefinition( @@ -245,7 +279,8 @@ object LinkAttributeDefinition { def checkMaxCount(count: Int): Unit = { if (count > maxCount) { throw InvalidJsonException( - s"Only $maxCount linkAttributes entry is currently supported, but got $count.", + s"Only $maxCount linkAttributes ${if (maxCount == 1) "entry is" else "entries are"} currently supported, " + + s"but got $count.", "linkAttributes" ) } diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index 7ff76934e..091fcd852 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -1946,16 +1946,17 @@ class ColumnModel(val connection: DatabaseConnection)( // Guards the value migrations below: only a slot that actually holds a value gets migrated. A slot holding JSON // null is a value that is explicitly empty, and no migration can improve on that - reshaping it would just respell - // "empty" (as per-langtag nulls, or as null again), and casting it would take `attributes->>0` as a SQL NULL, which - // strict jsonb_set turns into a wiped attributes column. Note that `attributes->0 IS NOT NULL` does not cover the - // JSON null case on its own: `->` hands JSON null back as a jsonb value, so that check is true for it. - private val slotHoldsAValue = - "attributes IS NOT NULL AND attributes->0 IS NOT NULL AND jsonb_typeof(attributes->0) <> 'null'" - - // Reshapes existing attribute values (position 0, the only slot while linkAttributes is capped at 1) to match a - // multilanguage flip, before any kind cast runs on top. There's no cast for this - it's a structural change - so - // false -> true duplicates the scalar under every langtag of the link, and true -> false collapses to the langtag - // that actually has a non-null value, discarding the rest. + // "empty" (as per-langtag nulls, or as null again), and casting it would take `attributes->>$slot` as a SQL NULL, + // which strict jsonb_set turns into a wiped attributes column. Note that `attributes->$slot IS NOT NULL` does not + // cover the JSON null case on its own: `->` hands JSON null back as a jsonb value, so that check is true for it. + // It does rule out a slot that isn't stored at all, which is what a row looks like that was written before this + // definition was added and hasn't been padded by resizeLinkAttributeValues yet. + private def slotHoldsAValue(slot: Int) = + s"attributes IS NOT NULL AND attributes->$slot IS NOT NULL AND jsonb_typeof(attributes->$slot) <> 'null'" + + // Reshapes existing attribute values in one slot to match a multilanguage flip, before any kind cast runs on top. + // There's no cast for this - it's a structural change - so false -> true duplicates the scalar under every langtag + // of the link, and true -> false collapses to the langtag that actually has a non-null value, discarding the rest. // // `langtags` is the union over both linked tables (see retrieveLinkLangtags), the same set the multilanguage guard // was evaluated against - a narrower set here would flip a value into langtags one side cannot read, or make the @@ -1964,6 +1965,7 @@ class ColumnModel(val connection: DatabaseConnection)( t: DbTransaction, langtags: Seq[String], linkTable: String, + slot: Int, oldDefinition: LinkAttributeDefinition, newDefinition: LinkAttributeDefinition ): Future[(DbTransaction, JsonObject)] = { @@ -1986,11 +1988,11 @@ class ColumnModel(val connection: DatabaseConnection)( // Postgres can't infer a bare `?` placeholder's type from a variadic "any" function like // jsonb_build_object - it needs an explicit cast, or every prepared execution fails with // "could not determine data type of parameter $1". - val pairs = langtags.map(_ => "?::text, attributes->0").mkString(", ") + val pairs = langtags.map(_ => s"?::text, attributes->$slot").mkString(", ") t.query( s"""|UPDATE $linkTable - |SET attributes = jsonb_set(attributes, '{0}', jsonb_build_object($pairs)) - |WHERE $slotHoldsAValue""".stripMargin, + |SET attributes = jsonb_set(attributes, '{$slot}', jsonb_build_object($pairs)) + |WHERE ${slotHoldsAValue(slot)}""".stripMargin, Json.arr(langtags*) ) } else { @@ -2009,15 +2011,15 @@ class ColumnModel(val connection: DatabaseConnection)( t.query( s"""|UPDATE $linkTable - |SET attributes = jsonb_set(attributes, '{0}', COALESCE( + |SET attributes = jsonb_set(attributes, '{$slot}', COALESCE( | (SELECT entry.value - | FROM jsonb_each(attributes->0) entry + | FROM jsonb_each(attributes->$slot) entry | WHERE jsonb_typeof(entry.value) <> 'null' | ORDER BY COALESCE(array_position($langtagArray, entry.key), 2147483647), entry.key | LIMIT 1), | 'null'::jsonb |)) - |WHERE $slotHoldsAValue AND jsonb_typeof(attributes->0) = 'object'""".stripMargin, + |WHERE ${slotHoldsAValue(slot)} AND jsonb_typeof(attributes->$slot) = 'object'""".stripMargin, Json.arr(langtags*) ) } @@ -2039,12 +2041,13 @@ class ColumnModel(val connection: DatabaseConnection)( s"to_jsonb($castedValue)" } - // Casts existing attribute values (position 0) to a new kind, all-or-nothing - a single value anywhere that can't + // Casts existing attribute values in one slot to a new kind, all-or-nothing - a single value anywhere that can't // cast fails the whole UPDATE, which (combined with the caller's rollbackAndFail) rolls back the entire change, // exactly mirroring how a plain column's kind change behaves today (ALTER COLUMN ... USING ...::type). private def castLinkAttributeValues( t: DbTransaction, linkTable: String, + slot: Int, oldDefinition: LinkAttributeDefinition, newDefinition: LinkAttributeDefinition ): Future[(DbTransaction, JsonObject)] = { @@ -2054,9 +2057,9 @@ class ColumnModel(val connection: DatabaseConnection)( t.query( s"""|UPDATE $linkTable |SET attributes = jsonb_set( - | attributes, '{0}', ${castLinkAttributeValueSql(newDefinition.kind, "attributes->>0")} + | attributes, '{$slot}', ${castLinkAttributeValueSql(newDefinition.kind, s"attributes->>$slot")} |) - |WHERE $slotHoldsAValue""".stripMargin + |WHERE ${slotHoldsAValue(slot)}""".stripMargin ) } else { // Cleared langtags cast to null and keep their key: jsonb_each_text hands them over as SQL NULL, the cast @@ -2068,24 +2071,72 @@ class ColumnModel(val connection: DatabaseConnection)( t.query( s"""|UPDATE $linkTable |SET attributes = jsonb_set( - | attributes, '{0}', + | attributes, '{$slot}', | COALESCE( | (SELECT jsonb_object_agg(key, ${castLinkAttributeValueSql(newDefinition.kind, "value")}) - | FROM jsonb_each_text(attributes->0)), + | FROM jsonb_each_text(attributes->$slot)), | '{}'::jsonb | ) |) - |WHERE attributes IS NOT NULL AND jsonb_typeof(attributes->0) = 'object'""".stripMargin + |WHERE attributes IS NOT NULL AND jsonb_typeof(attributes->$slot) = 'object'""".stripMargin + ) + } + } + + // Grows or shrinks every stored value array to match the new number of definitions, after the slots the two + // definition lists have in common have been migrated in place. A stored array is positional - `attributes[i]` + // belongs to `linkAttributes[i]` - so its length is part of that contract: LinkAttributeValueValidator insists on + // exactly one value per definition, which means a row left at the old length can be read but no longer written. + // + // Growing pads with JSON null ("no value yet") rather than leaving the slot absent, so that a value read from the + // API stays acceptable as a write. Shrinking to zero wipes the column instead of storing `[]`, which is what "no + // attributes stored at all" has always looked like on this table. + private def resizeLinkAttributeValues( + t: DbTransaction, + linkTable: String, + oldSize: Int, + newSize: Int + ): Future[(DbTransaction, JsonObject)] = { + if (newSize == oldSize) { + Future.successful((t, Json.obj())) + } else if (newSize == 0) { + // The WHERE clause matters: without it this rewrites every row of a link table that may hold millions of + // them, for the common case (definition added, never used, removed again) where there is nothing to wipe. + t.query(s"UPDATE $linkTable SET attributes = NULL WHERE attributes IS NOT NULL") + } else if (newSize < oldSize) { + t.query( + s"""|UPDATE $linkTable + |SET attributes = ( + | SELECT COALESCE(jsonb_agg(slot.value ORDER BY slot.ordinality), '[]'::jsonb) + | FROM jsonb_array_elements(attributes) WITH ORDINALITY slot(value, ordinality) + | WHERE slot.ordinality <= $newSize + |) + |WHERE attributes IS NOT NULL AND jsonb_array_length(attributes) > $newSize""".stripMargin + ) + } else { + // Pads up to newSize from whatever length a row actually has rather than appending a fixed + // (newSize - oldSize) nulls, so a row that is short for any other reason ends up correct too. + t.query( + s"""|UPDATE $linkTable + |SET attributes = attributes || COALESCE( + | (SELECT jsonb_agg('null'::jsonb) FROM generate_series(jsonb_array_length(attributes) + 1, $newSize)), + | '[]'::jsonb + |) + |WHERE attributes IS NOT NULL AND jsonb_array_length(attributes) < $newSize""".stripMargin ) } } // Applies a linkAttributes definition change to system_link_table plus, when needed, migrates existing values - // already stored on link_table_. Diffing is by position (max-1 keeps this simple, there's only ever - // position 0): no old + new = pure add (nothing to migrate); old + no new = pure remove, so stored values are - // wiped since no definition is left to interpret them; old + new = same slot regardless of name/displayName + // already stored on link_table_. Diffing is by position, the same way a stored value array is positional: + // slot i of the old definitions and slot i of the new ones are the same slot regardless of name/displayName // (those are cosmetic and don't affect how a stored value is interpreted) - reshape (multilanguage) then cast - // (kind) in place. + // (kind) in place. Slots beyond the shorter of the two lists are not a migration but a resize: added ones have no + // value yet, removed ones have no definition left to interpret their values (see resizeLinkAttributeValues). + // + // What this deliberately does not do is match definitions up by name: a rename would then be indistinguishable + // from "remove one attribute, add another", and the whole point of the positional contract is that a rename is + // cosmetic and keeps its values. private def updateLinkAttributesDefinition( t: DbTransaction, table: Table, @@ -2095,10 +2146,10 @@ class ColumnModel(val connection: DatabaseConnection)( ): Future[(DbTransaction, JsonObject)] = { for { // Both invariants are already enforced while parsing the request (JsonUtils.parseLinkAttributes) and in the - // controller, so over HTTP neither can fire. They are re-asserted here because this is where the max-1 - // assumption is actually load-bearing: the migrations below only ever look at position 0, so a second entry - // would be persisted but never reshaped or cast - a silent data bug rather than an error - and a multilanguage - // definition without langtags is what used to make the reshape wipe values. + // controller, so over HTTP neither can fire. They are re-asserted here because this is the last point before + // stored values are rewritten: a definition list that got past the cap some other way would be persisted + // against values migrated under a different one, and a multilanguage definition without langtags is what used + // to make the reshape wipe values. // // Inside a Future rather than a plain `_ =`: the caller applies rollbackAndFail() to the Future this method // returns, so a throw that escaped synchronously would skip the rollback and leave the transaction open. @@ -2121,27 +2172,25 @@ class ColumnModel(val connection: DatabaseConnection)( .map(str => LinkAttributeDefinition.seqFromJson(new JsonArray(str))) .getOrElse(Seq.empty) - (t, _) <- (currentDefinitions.headOption, newDefinitions.headOption) match { - case (Some(oldDef), Some(newDef)) => - // Same slot whether or not the name changed - a rename or displayName edit is cosmetic - // and must not invalidate stored values. Multilanguage/kind changes still reshape/cast - // in place; an incompatible kind change fails and rolls back the whole update, same as - // when the name stays the same. - for { - (t, _) <- reshapeLinkAttributeValues(t, langtags, linkTable, oldDef, newDef) - (t, result) <- castLinkAttributeValues(t, linkTable, oldDef, newDef) - } yield (t, result) - - case (Some(_), None) => - // pure remove - no definition is left to interpret the old values. The WHERE clause matters: without it - // this rewrites every row of a link table that may hold millions of them, for the common case (definition - // added, never used, removed again) where there is nothing to wipe at all. - t.query(s"UPDATE $linkTable SET attributes = NULL WHERE attributes IS NOT NULL") - - case (None, _) => - // pure add - no existing link rows can have a value yet - Future.successful((t, Json.obj())) - } + // Slot by slot, in order, because they all rewrite the same `attributes` column of the same rows - running + // them concurrently on one transaction would have them overwrite each other's jsonb_set results. + (t, _) <- currentDefinitions.zip(newDefinitions).zipWithIndex.foldLeft( + Future.successful((t, Json.obj())) + )({ + case (previous, ((oldDef, newDef), slot)) => + previous.flatMap({ + case (t, _) => + // A rename or displayName edit is cosmetic and must not invalidate stored values, so it produces no + // query at all. Multilanguage/kind changes reshape/cast in place; an incompatible kind change fails + // and rolls back the whole update, same as when the name stays the same. + for { + (t, _) <- reshapeLinkAttributeValues(t, langtags, linkTable, slot, oldDef, newDef) + (t, result) <- castLinkAttributeValues(t, linkTable, slot, oldDef, newDef) + } yield (t, result) + }) + }) + + (t, _) <- resizeLinkAttributeValues(t, linkTable, currentDefinitions.size, newDefinitions.size) (t, result) <- t.query( "UPDATE system_link_table SET attributes = ?::jsonb WHERE link_id = ?", diff --git a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala index e67fe1552..6f6a8aa5a 100644 --- a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala +++ b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala @@ -365,9 +365,9 @@ object JsonUtils extends LazyLogging { throw InvalidJsonException(s"linkAttributes entries must be JSON objects, but got $other.", "linkAttributes") }) - // Unreachable while maxCount is 1 (two entries fail the size check above first), but a value is addressed by - // name only - both in a formatPattern and in {{attributes.}} - so duplicates would be unresolvable as - // soon as the cap is raised. + // Unreachable over HTTP while maxCount is at its default of 1 (two entries fail the size check above first), + // but a value is addressed by name only - both in a formatPattern and in {{attributes.}} - so duplicates + // would be unresolvable as soon as the cap is raised. MultipleLinkAttributesTest raises it and covers this. val duplicateNames = definitions.groupBy(_.name).collect({ case (name, group) if group.size > 1 => name }) if (duplicateNames.nonEmpty) { diff --git a/src/test/scala/com/campudus/tableaux/api/structure/MultipleLinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/MultipleLinkAttributesTest.scala new file mode 100644 index 000000000..8ede22382 --- /dev/null +++ b/src/test/scala/com/campudus/tableaux/api/structure/MultipleLinkAttributesTest.scala @@ -0,0 +1,462 @@ +package com.campudus.tableaux.api.structure + +import com.campudus.tableaux.database.domain.LinkAttributeDefinition +import com.campudus.tableaux.database.model.TableauxModel.{ColumnId, RowId, TableId} +import com.campudus.tableaux.helper.Json +import com.campudus.tableaux.testtools.TableauxTestBase + +import io.vertx.ext.unit.TestContext +import io.vertx.ext.unit.junit.VertxUnitRunner +import io.vertx.lang.scala.json.{JsonArray, JsonObject} + +import scala.concurrent.Future +import scala.jdk.CollectionConverters._ + +import org.junit.{After, Before, Test} +import org.junit.Assert._ +import org.junit.runner.RunWith + +/** + * `linkAttributes` is capped at [[LinkAttributeDefinition.defaultMaxCount]] entries per link column, but nothing below + * that cap is written for exactly one definition: values are positional arrays, definition changes migrate them slot + * by slot, and a formatPattern addresses a definition by name. With the cap hard-coded at 1 all of that is unreachable + * code that no test can tell apart from code that doesn't work - so this test class raises the cap for itself (and + * only for itself, see the `@After`) and exercises the N-definition paths for real. + * + * Everything here is about the second and third definition. Single-definition behaviour is covered by + * ChangeLinkAttributesStructureTest (structure) and LinkAttributesTest (values), including the fact that the cap is + * enforced at all - which is why the one cap test below is about the raised cap still being a cap. + */ +@RunWith(classOf[VertxUnitRunner]) +class MultipleLinkAttributesTest extends TableauxTestBase { + + private val raisedMaxCount = 3 + + @Before + def raiseLinkAttributeMaxCount(): Unit = LinkAttributeDefinition.setMaxCountForTest(raisedMaxCount) + + // Without this the raised cap leaks into every test class that runs after this one in the same JVM + @After + def resetLinkAttributeMaxCount(): Unit = LinkAttributeDefinition.resetMaxCountForTest() + + private def attribute( + name: String, + kind: String = "integer", + multilanguage: Boolean = false + ): JsonObject = { + Json.obj( + "name" -> name, + "displayName" -> Json.obj("de-DE" -> s"Attribut $name"), + "kind" -> kind, + "multilanguage" -> multilanguage + ) + } + + private def createLinkColumn( + linkAttributes: JsonArray, + formatPattern: Option[String] = None + ): Future[ColumnId] = { + val baseJson = Json.obj( + "name" -> "Test Link 1", + "kind" -> "link", + "toTable" -> 2, + // explicit rather than relying on the default - the backlink column actually being created is + // load-bearing for the backlink assertions further down + "singleDirection" -> false, + "linkAttributes" -> linkAttributes + ) + val columnJson = formatPattern match { + case Some(pattern) => baseJson.mergeIn(Json.obj("formatPattern" -> pattern)) + case None => baseJson + } + + for { + _ <- createDefaultTable() + _ <- createDefaultTable("Test Table 2", 2) + result <- sendRequest("POST", "/tables/1/columns", Json.obj("columns" -> Json.arr(columnJson))) + } yield result.getJsonArray("columns").getJsonObject(0).getLong("id").toLong + } + + private def findBacklinkColumnId(toTable: TableId): Future[ColumnId] = { + sendRequest("GET", "/tables/2/columns").map( + _.getJsonArray("columns") + .asScala + .map(_.asInstanceOf[JsonObject]) + .collectFirst({ + case col if col.getString("kind") == "link" && col.getLong("toTable") == toTable => col.getLong("id").toLong + }) + .get + ) + } + + private def putLink(attributes: JsonArray, toRow: RowId = 1): JsonObject = { + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> toRow, "attributes" -> attributes)))) + } + + private def changeLinkAttributes(columnId: ColumnId, linkAttributes: JsonArray): Future[JsonObject] = { + sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("linkAttributes" -> linkAttributes)) + } + + private def attributesOfFirstLink(cell: JsonObject): JsonArray = { + cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes") + } + + private def retrieveAttributes(columnId: ColumnId): Future[JsonArray] = { + sendRequest("GET", s"/tables/1/columns/$columnId/rows/1").map(attributesOfFirstLink) + } + + // --------------------------------------------------------------------------------------------------------------- + // Definition + // --------------------------------------------------------------------------------------------------------------- + + @Test + def createLinkColumnWithSeveralLinkAttributes(implicit c: TestContext): Unit = okTest { + val definitions = Json.arr( + attribute("percentage"), + attribute("note", kind = "text"), + attribute("since", kind = "date") + ) + + for { + columnId <- createLinkColumn(definitions) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + // order is the contract, not just the content: it is what a value array is positional against + assertJSONEquals(definitions, column.getJsonArray("linkAttributes")) + } + } + + // The raised cap is still a cap - the seam moves the limit, it doesn't remove the check. + @Test + def createLinkColumnAboveTheRaisedCapFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + val definitions = Json.arr( + attribute("a"), + attribute("b"), + attribute("c"), + attribute("d") + ) + + createLinkColumn(definitions) + } + + // Unreachable while the cap is 1 (two entries fail the size check first), so this is the first test that can see + // it: a value is addressed by name in a formatPattern, which duplicates make unresolvable. + @Test + def createLinkColumnWithDuplicateLinkAttributeNamesFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + createLinkColumn(Json.arr(attribute("percentage"), attribute("percentage", kind = "text"))) + } + + @Test + def changeLinkColumnToDuplicateLinkAttributeNamesFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + for { + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) + _ <- changeLinkAttributes(columnId, Json.arr(attribute("percentage"), attribute("percentage"))) + } yield () + } + + @Test + def formatPatternCanReferenceEveryLinkAttribute(implicit c: TestContext): Unit = okTest { + val pattern = "{{value}}: {{attributes.percentage}}% ({{attributes.note}})" + + for { + columnId <- createLinkColumn( + Json.arr(attribute("percentage"), attribute("note", kind = "text")), + formatPattern = Some(pattern) + ) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertEquals(pattern, column.getString("formatPattern")) + } + } + + // Only the second definition is renamed, so only the second token dangles - the check has to look at all of them, + // not just the first. + @Test + def renamingAnAttributeReferencedByTheFormatPatternFails(implicit c: TestContext): Unit = + exceptionTest("unprocessable.entity") { + for { + columnId <- createLinkColumn( + Json.arr(attribute("percentage"), attribute("note", kind = "text")), + formatPattern = Some("{{attributes.percentage}} ({{attributes.note}})") + ) + _ <- changeLinkAttributes(columnId, Json.arr(attribute("percentage"), attribute("comment", kind = "text"))) + } yield () + } + + // --------------------------------------------------------------------------------------------------------------- + // Values + // --------------------------------------------------------------------------------------------------------------- + + @Test + def attributeValuesRoundtripPositionallyForEveryDefinition(implicit c: TestContext): Unit = okTest { + val definitions = Json.arr( + attribute("percentage"), + attribute("note", kind = "text"), + attribute("checked", kind = "boolean") + ) + val values = Json.arr(50, "some note", true) + + for { + columnId <- createLinkColumn(definitions) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(values)) + backlinkColumnId <- findBacklinkColumnId(toTable = 1) + + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + backlinkCell <- sendRequest("GET", s"/tables/2/columns/$backlinkColumnId/rows/1") + } yield { + assertEquals(values, attributesOfFirstLink(cell)) + // values live once per edge, so all three come back identically from the other side + assertEquals(values, attributesOfFirstLink(backlinkCell)) + } + } + + // A value array is parallel to the definitions, so a missing trailing value is not "the rest is empty" - there + // would be no way to tell which definition the values that were sent belong to. + @Test + def attributeValuesMustCoverEveryDefinition(implicit c: TestContext): Unit = + exceptionTest("error.json.link-attributes") { + for { + columnId <- createLinkColumn(Json.arr(attribute("percentage"), attribute("note", kind = "text"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(Json.arr(50))) + } yield () + } + + // Type checking is per slot, so the second value has to be checked against the second definition and not against + // whatever the first one happens to allow. + @Test + def attributeValuesAreCheckedAgainstTheirOwnDefinition(implicit c: TestContext): Unit = + exceptionTest("error.json.link-attributes") { + for { + columnId <- createLinkColumn(Json.arr(attribute("note", kind = "text"), attribute("percentage"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(Json.arr("a note", "not a number"))) + } yield () + } + + @Test + def clearedAndFilledSlotsCoexist(implicit c: TestContext): Unit = okTest { + val definitions = Json.arr(attribute("percentage"), attribute("note", kind = "text")) + val values = Json.arr().addNull().add("some note") + + for { + columnId <- createLinkColumn(definitions) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(values)) + attributes <- retrieveAttributes(columnId) + } yield { + // the cleared slot keeps its position instead of collapsing the array onto the filled one + assertEquals(values, attributes) + } + } + + @Test + def multilanguageAndSingleLanguageAttributesCoexist(implicit c: TestContext): Unit = okTest { + val definitions = Json.arr( + attribute("percentage"), + attribute("note", kind = "text", multilanguage = true) + ) + val values = Json.arr(50, Json.obj("de-DE" -> "Notiz", "en-GB" -> "note")) + + for { + columnId <- createLinkColumn(definitions) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(values)) + attributes <- retrieveAttributes(columnId) + } yield { + assertEquals(values, attributes) + } + } + + @Test + def putLinkAttributesEndpointWritesEverySlot(implicit c: TestContext): Unit = okTest { + val definitions = Json.arr(attribute("percentage"), attribute("note", kind = "text")) + + for { + columnId <- createLinkColumn(definitions) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(Json.arr(50, "some note"))) + _ <- sendRequest( + "PUT", + s"/tables/1/columns/$columnId/rows/1/link/1/attributes", + Json.obj("attributes" -> Json.arr(75, "another note")) + ) + attributes <- retrieveAttributes(columnId) + } yield { + assertEquals(Json.arr(75, "another note"), attributes) + } + } + + // --------------------------------------------------------------------------------------------------------------- + // Definition changes: every migration addresses one slot, so the ones it doesn't address have to stay untouched + // --------------------------------------------------------------------------------------------------------------- + + @Test + def changingTheKindOfOneAttributeMigratesOnlyItsSlot(implicit c: TestContext): Unit = okTest { + val definitions = Json.arr( + attribute("percentage"), + attribute("amount", kind = "text"), + attribute("note", kind = "text") + ) + + for { + columnId <- createLinkColumn(definitions) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(Json.arr(50, "12", "some note"))) + _ <- changeLinkAttributes( + columnId, + Json.arr(attribute("percentage"), attribute("amount", kind = "numeric"), attribute("note", kind = "text")) + ) + attributes <- retrieveAttributes(columnId) + } yield { + assertEquals(50, attributes.getInteger(0)) + assertEquals(12.0, attributes.getValue(1).asInstanceOf[Number].doubleValue(), 0.001) + assertEquals("some note", attributes.getString(2)) + } + } + + @Test + def flippingMultilanguageOnOneAttributeReshapesOnlyItsSlot(implicit c: TestContext): Unit = okTest { + val definitions = Json.arr(attribute("percentage"), attribute("note", kind = "text")) + + for { + columnId <- createLinkColumn(definitions) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(Json.arr(50, "some note"))) + _ <- changeLinkAttributes( + columnId, + Json.arr(attribute("percentage"), attribute("note", kind = "text", multilanguage = true)) + ) + attributes <- retrieveAttributes(columnId) + } yield { + assertEquals(50, attributes.getInteger(0)) + assertEquals(Json.obj("de-DE" -> "some note", "en-GB" -> "some note"), attributes.getJsonObject(1)) + } + } + + // A rename is cosmetic: definitions are diffed by position, so slot 1 stays slot 1 and keeps its value. + @Test + def renamingOneOfSeveralAttributesKeepsEveryValueInPlace(implicit c: TestContext): Unit = okTest { + val definitions = Json.arr(attribute("percentage"), attribute("note", kind = "text")) + + for { + columnId <- createLinkColumn(definitions) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(Json.arr(50, "some note"))) + _ <- changeLinkAttributes(columnId, Json.arr(attribute("percentage"), attribute("comment", kind = "text"))) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + attributes <- retrieveAttributes(columnId) + } yield { + assertEquals("comment", column.getJsonArray("linkAttributes").getJsonObject(1).getString("name")) + assertEquals(Json.arr(50, "some note"), attributes) + } + } + + // Adding a definition to a link that already carries values: the stored array is parallel to the definitions, so + // it has to grow a cleared slot. Without that it stays short, and the value the API hands out is one the API then + // refuses as a write - which is what the round-trip at the end pins down. + @Test + def addingAnAttributePadsAlreadyStoredValues(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(Json.arr(50))) + _ <- changeLinkAttributes(columnId, Json.arr(attribute("percentage"), attribute("note", kind = "text"))) + + attributes <- retrieveAttributes(columnId) + + // what was just read has to be acceptable as a write again (read-modify-write): a stored array left at the + // old length reads back one value short, and one value per definition is exactly what a write insists on + _ <- sendRequest( + "PUT", + s"/tables/1/columns/$columnId/rows/1/link/1/attributes", + Json.obj("attributes" -> attributes) + ) + attributesAfterRewrite <- retrieveAttributes(columnId) + } yield { + assertEquals(Json.arr().add(50).addNull(), attributes) + assertEquals(Json.arr().add(50).addNull(), attributesAfterRewrite) + } + } + + // Links written before the added definition and links written after it end up with the same shape - the padding + // must not leave two generations of value arrays side by side. + @Test + def addingAnAttributeLeavesOldAndNewLinksWithTheSameShape(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(Json.arr(50), toRow = 1)) + _ <- changeLinkAttributes(columnId, Json.arr(attribute("percentage"), attribute("note", kind = "text"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/2", putLink(Json.arr(75, "fresh"), toRow = 2)) + + migratedCell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + freshCell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/2") + } yield { + assertEquals(Json.arr().add(50).addNull(), attributesOfFirstLink(migratedCell)) + assertEquals(Json.arr(75, "fresh"), attributesOfFirstLink(freshCell)) + } + } + + // Removing the trailing definition drops exactly its values; the remaining slot keeps its own. + @Test + def removingTheLastAttributeKeepsTheRemainingValues(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(attribute("percentage"), attribute("note", kind = "text"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(Json.arr(50, "some note"))) + _ <- changeLinkAttributes(columnId, Json.arr(attribute("percentage"))) + + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + attributes <- retrieveAttributes(columnId) + + // the shortened array is what the API accepts as a write now + _ <- sendRequest( + "PUT", + s"/tables/1/columns/$columnId/rows/1/link/1/attributes", + Json.obj("attributes" -> attributes) + ) + attributesAfterRewrite <- retrieveAttributes(columnId) + } yield { + assertEquals(1, column.getJsonArray("linkAttributes").size()) + assertEquals(Json.arr(50), attributes) + assertEquals(Json.arr(50), attributesAfterRewrite) + } + } + + // Clearing the definitions of a column that has several of them wipes every value, not just the first slot. + @Test + def clearingAllAttributesWipesEveryValue(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(attribute("percentage"), attribute("note", kind = "text"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(Json.arr(50, "some note"))) + _ <- changeLinkAttributes(columnId, Json.arr()) + + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") + } yield { + assertFalse(column.containsKey("linkAttributes")) + assertFalse(cell.getJsonArray("value").getJsonObject(0).containsKey("attributes")) + } + } + + // An incompatible cast in any slot fails the whole change, and the transaction rolls it back - the definitions of + // the other slots must not be left applied to values that were never migrated. + @Test + def anImpossibleCastInOneSlotRollsBackTheWholeChange(implicit c: TestContext): Unit = okTest { + val definitions = Json.arr(attribute("percentage"), attribute("note", kind = "text")) + + for { + columnId <- createLinkColumn(definitions) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink(Json.arr(50, "not a number"))) + + // second slot can't be cast to numeric, first one could + failed <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj( + "linkAttributes" -> Json.arr(attribute("percentage", kind = "text"), attribute("note", kind = "numeric")) + ) + ).map(_ => false).recover({ case _ => true }) + + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + attributes <- retrieveAttributes(columnId) + } yield { + assertTrue("changing the definition to an impossible kind must fail", failed) + assertJSONEquals(definitions, column.getJsonArray("linkAttributes")) + assertEquals(Json.arr(50, "not a number"), attributes) + } + } +} From 978f39a72baba20d66cdf1f6159dc60f710a25d5 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Mon, 24 Aug 2026 11:06:18 +0200 Subject: [PATCH 19/24] fix: prevent multilanguage for linkAttributesDefinitions (for now) --- src/main/resources/swagger.json | 6 +- .../tableaux/database/domain/link.scala | 51 +++++++++++ .../model/structure/ColumnModel.scala | 11 +-- .../campudus/tableaux/helper/JsonUtils.scala | 5 ++ .../api/content/LinkAttributesTest.scala | 11 ++- .../ChangeLinkAttributesStructureTest.scala | 10 ++- .../LinkAttributeRolloutGatesTest.scala | 85 +++++++++++++++++++ .../MultipleLinkAttributesTest.scala | 14 ++- .../LinkAttributeTestOverrides.scala | 36 ++++++++ 9 files changed, 210 insertions(+), 19 deletions(-) create mode 100644 src/test/scala/com/campudus/tableaux/api/structure/LinkAttributeRolloutGatesTest.scala create mode 100644 src/test/scala/com/campudus/tableaux/testtools/LinkAttributeTestOverrides.scala diff --git a/src/main/resources/swagger.json b/src/main/resources/swagger.json index e0a176c87..2fc20e36e 100644 --- a/src/main/resources/swagger.json +++ b/src/main/resources/swagger.json @@ -5417,7 +5417,7 @@ }, "LinkAttributeDefinition": { "type": "object", - "description": "Definition of an attribute that can be attached to a link, in addition to which rows it connects. Its structure orients on ColumnDefinition, but it isn't a real column - it has no own id and is referenced by name only. Currently limited to a maximum of 1 entry per link column.", + "description": "Definition of an attribute that can be attached to a link, in addition to which rows it connects. Its structure orients on ColumnDefinition, but it isn't a real column - it has no own id and is referenced by name only. Currently limited to a maximum of 1 entry per link column, and multilanguage attributes are not supported yet.", "required": [ "name", "kind" @@ -5448,7 +5448,7 @@ }, "multilanguage": { "type": "boolean", - "description": "If true, the attribute's value is a multi-language object keyed by langtag instead of a single value; langtags may be omitted or set to null to mean \"no value in this language\". Defaults to false. Requires the link to have langtags - a multi-language value is addressed by langtag, so it is rejected with 422 when neither of the two linked tables has any (both created with \"langtags\": []). Langtag keys of a stored value are not restricted to those langtags: the definition is shared by both linked tables, whose langtag sets may differ, and a langtag can be removed from a table after a value was stored under it. Changing this on an existing column reshapes stored values: false to true copies the value to every langtag of the link, true to false keeps the value of the langtag that has one (the link's langtags first in their configured order, then any other langtag still present in the stored value); a value that is null is left as null in both directions.", + "description": "Not supported yet: sending true is rejected with 400 (error.json.linkAttributes) - the documentation below describes what it will do once it is enabled. If true, the attribute's value is a multi-language object keyed by langtag instead of a single value; langtags may be omitted or set to null to mean \"no value in this language\". Defaults to false. Requires the link to have langtags - a multi-language value is addressed by langtag, so it is rejected with 422 when neither of the two linked tables has any (both created with \"langtags\": []). Langtag keys of a stored value are not restricted to those langtags: the definition is shared by both linked tables, whose langtag sets may differ, and a langtag can be removed from a table after a value was stored under it. Changing this on an existing column reshapes stored values: false to true copies the value to every langtag of the link, true to false keeps the value of the langtag that has one (the link's langtags first in their configured order, then any other langtag still present in the stored value); a value that is null is left as null in both directions.", "example": false } }, @@ -5597,7 +5597,7 @@ }, "linkAttributes": { "type": "array", - "description": "Definitions of attributes attached to this link (only applicable if kind is \"link\"). Currently limited to a maximum of 1 entry. Can also be set/changed on an existing link column via the change-column endpoint; sending an empty array (or null) clears the definition and any stored attribute values, omitting the field leaves it untouched. A change is validated against the column's formatPattern - renaming or clearing an attribute that the stored pattern references as {{attributes.}} is rejected with 422; send a matching formatPattern in the same request, or formatPattern: null to delete the pattern along with the definitions. Note that linkAttributes are shared with the auto-created backlink column while formatPattern is per column, so changing the definitions from one side is not validated against a formatPattern set on the other side.", + "description": "Definitions of attributes attached to this link (only applicable if kind is \"link\"). Currently limited to a maximum of 1 entry, and multilanguage attributes are not supported yet. Can also be set/changed on an existing link column via the change-column endpoint; sending an empty array (or null) clears the definition and any stored attribute values, omitting the field leaves it untouched. A change is validated against the column's formatPattern - renaming or clearing an attribute that the stored pattern references as {{attributes.}} is rejected with 422; send a matching formatPattern in the same request, or formatPattern: null to delete the pattern along with the definitions. Note that linkAttributes are shared with the auto-created backlink column while formatPattern is per column, so changing the definitions from one side is not validated against a formatPattern set on the other side.", "items": { "$ref": "#/definitions/LinkAttributeDefinition" }, diff --git a/src/main/scala/com/campudus/tableaux/database/domain/link.scala b/src/main/scala/com/campudus/tableaux/database/domain/link.scala index ccdc62e8c..07488f17c 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/link.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/link.scala @@ -217,6 +217,40 @@ object LinkAttributeDefinition { maxCountOverride = None } + val defaultMultilanguageSupported = false + + private var multilanguageSupportedOverride: Option[Boolean] = None + + /** + * Whether a definition may be `multilanguage`. Like [[maxCount]] this is a rollout decision rather than a structural + * limit - a multilanguage value is stored, read, reshaped and cast by code that exists and works (see + * LinkAttributesTest and ChangeLinkAttributesStructureTest, which both lift this gate), it is only not offered yet + * because the frontend has no editor for a per-langtag attribute value. + * + * Kept switchable for exactly the same reason as the cap: gating it with a hard-coded `false` would turn every + * multilanguage code path - the reshape on a flip, the collapse back, the per-langtag cast - into dead code that no + * test could reach. Nothing in production ever flips it. + */ + def multilanguageSupported: Boolean = multilanguageSupportedOverride.getOrElse(defaultMultilanguageSupported) + + /** + * Test-only seam for [[multilanguageSupported]] - see LinkAttributeTestOverrides, which lifts the gate per test + * class and resets it afterwards. LinkAttributeRolloutGatesTest is the class that lifts nothing and pins down what + * the API answers with the gate in place. + */ + def setMultilanguageSupportedForTest(supported: Boolean): Unit = { + multilanguageSupportedOverride = Some(supported) + } + + /** + * Restores [[multilanguageSupported]] to [[defaultMultilanguageSupported]]. Tests that call + * [[setMultilanguageSupportedForTest]] have to call this afterwards (in an `@After`), or they leak the enabled + * feature into every test that runs after them in the same JVM. + */ + def resetMultilanguageSupportedForTest(): Unit = { + multilanguageSupportedOverride = None + } + def fromJson(json: JsonObject): LinkAttributeDefinition = { LinkAttributeDefinition( name = json.getString("name"), @@ -274,6 +308,23 @@ object LinkAttributeDefinition { } } + /** + * Rejects a multilanguage definition while [[multilanguageSupported]] is off. Separate from + * [[checkMultilanguageAllowed]] on purpose: that one is a permanent rule about a link without langtags, this one is + * a rollout gate that is meant to be lifted - so it reports itself as a `linkAttributes` problem, exactly like the + * count cap does, rather than as something about the table's langtags. + */ + def checkMultilanguageSupported(definitions: Seq[LinkAttributeDefinition]): Unit = { + if (!multilanguageSupported) { + definitions.filter(_.multilanguage).foreach(definition => + throw InvalidJsonException( + s"Multilanguage linkAttributes are not supported yet, but '${definition.name}' is multilanguage.", + "linkAttributes" + ) + ) + } + } + // Takes a count rather than the definitions so JsonUtils can reject an oversized array before it starts // validating individual entries, and still produce the exact same error as the model-level assertion. def checkMaxCount(count: Int): Unit = { diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index 091fcd852..97f06a222 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -2145,16 +2145,17 @@ class ColumnModel(val connection: DatabaseConnection)( langtags: Seq[String] ): Future[(DbTransaction, JsonObject)] = { for { - // Both invariants are already enforced while parsing the request (JsonUtils.parseLinkAttributes) and in the - // controller, so over HTTP neither can fire. They are re-asserted here because this is the last point before - // stored values are rewritten: a definition list that got past the cap some other way would be persisted - // against values migrated under a different one, and a multilanguage definition without langtags is what used - // to make the reshape wipe values. + // All three invariants are already enforced while parsing the request (JsonUtils.parseLinkAttributes) and in + // the controller, so over HTTP none of them can fire. They are re-asserted here because this is the last point + // before stored values are rewritten: a definition list that got past the cap or the multilanguage gate some + // other way would be persisted against values migrated under a different one, and a multilanguage definition + // without langtags is what used to make the reshape wipe values. // // Inside a Future rather than a plain `_ =`: the caller applies rollbackAndFail() to the Future this method // returns, so a throw that escaped synchronously would skip the rollback and leave the transaction open. _ <- Future { LinkAttributeDefinition.checkMaxCount(newDefinitions.size) + LinkAttributeDefinition.checkMultilanguageSupported(newDefinitions) LinkAttributeDefinition.checkMultilanguageAllowed(langtags, newDefinitions) } diff --git a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala index 6f6a8aa5a..c7730b9b1 100644 --- a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala +++ b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala @@ -377,6 +377,11 @@ object JsonUtils extends LazyLogging { ) } + // Rollout gate, checked here for the same reason as the count cap: this is the one place every create and every + // change request passes through, so it is where a not-yet-offered feature is turned away - before anything + // downstream has to decide what to do with it. + LinkAttributeDefinition.checkMultilanguageSupported(definitions) + definitions } diff --git a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala index ed13e6a73..e14549a3c 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala @@ -2,6 +2,7 @@ package com.campudus.tableaux.api.content import com.campudus.tableaux.database.model.TableauxModel.{ColumnId, TableId} import com.campudus.tableaux.helper.Json +import com.campudus.tableaux.testtools.LinkAttributeTestOverrides import com.campudus.tableaux.testtools.RequestCreation.{Identifier, NumericCol, TextCol} import io.vertx.ext.unit.TestContext @@ -14,8 +15,16 @@ import org.junit.Assert._ import org.junit.Test import org.junit.runner.RunWith +/** + * Value-level tests for a link column's `linkAttributes`. Multilanguage definitions are gated off for rollout, so this + * class lifts that gate - per-langtag attribute values are stored, read and normalized by code that has to keep + * working for the gate to be liftable later. That the gate holds by default is covered by + * LinkAttributeRolloutGatesTest. + */ @RunWith(classOf[VertxUnitRunner]) -class LinkAttributesTest extends LinkTestBase { +class LinkAttributesTest extends LinkTestBase with LinkAttributeTestOverrides { + + override protected def testMultilanguageLinkAttributesSupported: Boolean = true private def percentageAttribute(multilanguage: Boolean = false, kind: String = "integer"): JsonObject = { Json.obj( diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala index c42b54070..53723fbf6 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala @@ -2,7 +2,7 @@ package com.campudus.tableaux.api.structure import com.campudus.tableaux.database.model.TableauxModel.{ColumnId, TableId} import com.campudus.tableaux.helper.Json -import com.campudus.tableaux.testtools.TableauxTestBase +import com.campudus.tableaux.testtools.{LinkAttributeTestOverrides, TableauxTestBase} import io.vertx.ext.unit.TestContext import io.vertx.ext.unit.junit.VertxUnitRunner @@ -18,9 +18,15 @@ import org.junit.runner.RunWith /** * Structure-level tests for a link column's `linkAttributes` definition: creating it, changing it, and migrating * values that were already stored under the previous definition. + * + * Multilanguage definitions are gated off for rollout, so this class lifts that gate - the reshape and collapse + * migrations it covers are the whole reason the gate can be lifted later at all. That the gate holds by default is + * covered by LinkAttributeRolloutGatesTest; the one-definition cap is not lifted here and still applies. */ @RunWith(classOf[VertxUnitRunner]) -class ChangeLinkAttributesStructureTest extends TableauxTestBase { +class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttributeTestOverrides { + + override protected def testMultilanguageLinkAttributesSupported: Boolean = true private def percentageAttribute( kind: String = "integer", diff --git a/src/test/scala/com/campudus/tableaux/api/structure/LinkAttributeRolloutGatesTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/LinkAttributeRolloutGatesTest.scala new file mode 100644 index 000000000..ee051f951 --- /dev/null +++ b/src/test/scala/com/campudus/tableaux/api/structure/LinkAttributeRolloutGatesTest.scala @@ -0,0 +1,85 @@ +package com.campudus.tableaux.api.structure + +import com.campudus.tableaux.database.model.TableauxModel.ColumnId +import com.campudus.tableaux.helper.Json +import com.campudus.tableaux.testtools.TableauxTestBase + +import io.vertx.ext.unit.TestContext +import io.vertx.ext.unit.junit.VertxUnitRunner +import io.vertx.lang.scala.json.{JsonArray, JsonObject} + +import scala.concurrent.Future + +import org.junit.Assert._ +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Two link attribute features exist in the backend but are not offered yet: more than one definition per column, and a + * multilanguage definition. Both are gated in LinkAttributeDefinition, and both gates can be lifted per test class + * (LinkAttributeTestOverrides) - which is what makes this class necessary: it is deliberately the one that lifts + * nothing, so it pins down what the API actually answers today. + * + * The count cap's rejection is covered by ChangeLinkAttributesStructureTest (which doesn't lift it either), so what is + * left here is the multilanguage gate. + */ +@RunWith(classOf[VertxUnitRunner]) +class LinkAttributeRolloutGatesTest extends TableauxTestBase { + + private def percentageAttribute(multilanguage: Boolean): JsonObject = { + Json.obj( + "name" -> "percentage", + "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), + "kind" -> "integer", + "multilanguage" -> multilanguage + ) + } + + private def createLinkColumn(linkAttributes: JsonArray): Future[ColumnId] = { + val columnJson = Json.obj( + "name" -> "Test Link 1", + "kind" -> "link", + "toTable" -> 2, + "linkAttributes" -> linkAttributes + ) + + for { + _ <- createDefaultTable() + _ <- createDefaultTable("Test Table 2", 2) + result <- sendRequest("POST", "/tables/1/columns", Json.obj("columns" -> Json.arr(columnJson))) + } yield result.getJsonArray("columns").getJsonObject(0).getLong("id").toLong + } + + // Rejected while parsing, like an oversized linkAttributes array - not as an "unprocessable" langtag problem, which + // is what a multilanguage definition on a link without langtags is (see ChangeLinkAttributesStructureTest). + @Test + def createLinkColumnWithMultilanguageAttributeFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + createLinkColumn(Json.arr(percentageAttribute(multilanguage = true))) + } + + @Test + def changeLinkColumnToMultilanguageAttributeFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = false))) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$columnId", + Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) + ) + } yield () + } + + // The gate is about `multilanguage: true` only - a language-neutral definition on a table that has langtags is + // still the normal, supported case and must not be caught by it. + @Test + def createLinkColumnWithLanguageNeutralAttributeSucceeds(implicit c: TestContext): Unit = okTest { + for { + columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = false))) + column <- sendRequest("GET", s"/tables/1/columns/$columnId") + } yield { + assertJSONEquals(Json.arr(percentageAttribute(multilanguage = false)), column.getJsonArray("linkAttributes")) + } + } +} diff --git a/src/test/scala/com/campudus/tableaux/api/structure/MultipleLinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/MultipleLinkAttributesTest.scala index 8ede22382..85e20f952 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/MultipleLinkAttributesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/MultipleLinkAttributesTest.scala @@ -3,7 +3,7 @@ package com.campudus.tableaux.api.structure import com.campudus.tableaux.database.domain.LinkAttributeDefinition import com.campudus.tableaux.database.model.TableauxModel.{ColumnId, RowId, TableId} import com.campudus.tableaux.helper.Json -import com.campudus.tableaux.testtools.TableauxTestBase +import com.campudus.tableaux.testtools.{LinkAttributeTestOverrides, TableauxTestBase} import io.vertx.ext.unit.TestContext import io.vertx.ext.unit.junit.VertxUnitRunner @@ -12,8 +12,8 @@ import io.vertx.lang.scala.json.{JsonArray, JsonObject} import scala.concurrent.Future import scala.jdk.CollectionConverters._ -import org.junit.{After, Before, Test} import org.junit.Assert._ +import org.junit.Test import org.junit.runner.RunWith /** @@ -28,16 +28,14 @@ import org.junit.runner.RunWith * enforced at all - which is why the one cap test below is about the raised cap still being a cap. */ @RunWith(classOf[VertxUnitRunner]) -class MultipleLinkAttributesTest extends TableauxTestBase { +class MultipleLinkAttributesTest extends TableauxTestBase with LinkAttributeTestOverrides { private val raisedMaxCount = 3 - @Before - def raiseLinkAttributeMaxCount(): Unit = LinkAttributeDefinition.setMaxCountForTest(raisedMaxCount) + override protected def testLinkAttributeMaxCount: Int = raisedMaxCount - // Without this the raised cap leaks into every test class that runs after this one in the same JVM - @After - def resetLinkAttributeMaxCount(): Unit = LinkAttributeDefinition.resetMaxCountForTest() + // one test pairs a multilanguage definition with a single-language one, which needs that gate lifted too + override protected def testMultilanguageLinkAttributesSupported: Boolean = true private def attribute( name: String, diff --git a/src/test/scala/com/campudus/tableaux/testtools/LinkAttributeTestOverrides.scala b/src/test/scala/com/campudus/tableaux/testtools/LinkAttributeTestOverrides.scala new file mode 100644 index 000000000..b8d191f30 --- /dev/null +++ b/src/test/scala/com/campudus/tableaux/testtools/LinkAttributeTestOverrides.scala @@ -0,0 +1,36 @@ +package com.campudus.tableaux.testtools + +import com.campudus.tableaux.database.domain.LinkAttributeDefinition + +import org.junit.{After, Before} + +/** + * Mix into a test class that needs a link attribute feature which is gated off for rollout - more than one definition + * per column, or a multilanguage one. Both gates are rollout decisions rather than structural limits (see + * LinkAttributeDefinition), so the code behind them exists and has to keep working; without a way to lift them in a + * test, none of it could be reached at all. + * + * Override only what the class actually needs - what isn't overridden stays at the production default, so a class that + * lifts the multilanguage gate is still held to the one-definition cap. The `@After` puts both back: the gates are + * global mutable state, so a class that left one lifted would silently lift it for every test class running after it + * in the same JVM. + */ +trait LinkAttributeTestOverrides { + + protected def testLinkAttributeMaxCount: Int = LinkAttributeDefinition.defaultMaxCount + + protected def testMultilanguageLinkAttributesSupported: Boolean = + LinkAttributeDefinition.defaultMultilanguageSupported + + @Before + def applyLinkAttributeOverrides(): Unit = { + LinkAttributeDefinition.setMaxCountForTest(testLinkAttributeMaxCount) + LinkAttributeDefinition.setMultilanguageSupportedForTest(testMultilanguageLinkAttributesSupported) + } + + @After + def resetLinkAttributeOverrides(): Unit = { + LinkAttributeDefinition.resetMaxCountForTest() + LinkAttributeDefinition.resetMultilanguageSupportedForTest() + } +} From 166bc6db100c3955d08791280b52d6ef69fe4a7a Mon Sep 17 00:00:00 2001 From: smnhgn Date: Mon, 24 Aug 2026 11:24:35 +0200 Subject: [PATCH 20/24] refactor: generalize attribute test helper --- .../api/content/CreateHistoryTest.scala | 20 ++- .../api/content/LinkAttributesTest.scala | 16 +- .../ChangeLinkAttributesStructureTest.scala | 154 +++++++++--------- .../LinkAttributeRolloutGatesTest.scala | 22 ++- 4 files changed, 113 insertions(+), 99 deletions(-) diff --git a/src/test/scala/com/campudus/tableaux/api/content/CreateHistoryTest.scala b/src/test/scala/com/campudus/tableaux/api/content/CreateHistoryTest.scala index 0fd7b5b40..ea94a5630 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/CreateHistoryTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/CreateHistoryTest.scala @@ -1335,12 +1335,18 @@ class CreateSimpleLinkOrderHistoryTest extends LinkTestBase with TestHelper { @RunWith(classOf[VertxUnitRunner]) class CreateLinkAttributesHistoryTest extends LinkTestBase with TestHelper { - private def percentageAttribute: JsonObject = Json.obj( - "name" -> "percentage", - "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), - "kind" -> "integer", - "multilanguage" -> false - ) + private def attribute( + name: String, + kind: String = "integer", + multilanguage: Boolean = false + ): JsonObject = { + Json.obj( + "name" -> name, + "displayName" -> Json.obj("de-DE" -> s"Attribut $name"), + "kind" -> kind, + "multilanguage" -> multilanguage + ) + } private def createLinkColumnWithAttributes(tableId: Long, toTableId: Long): Future[Long] = { val json = Json.obj( @@ -1349,7 +1355,7 @@ class CreateLinkAttributesHistoryTest extends LinkTestBase with TestHelper { "name" -> "Test Link 1", "kind" -> "link", "toTable" -> toTableId, - "linkAttributes" -> Json.arr(percentageAttribute) + "linkAttributes" -> Json.arr(attribute("percentage")) ) ) ) diff --git a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala index e14549a3c..680ef7eab 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala @@ -26,10 +26,14 @@ class LinkAttributesTest extends LinkTestBase with LinkAttributeTestOverrides { override protected def testMultilanguageLinkAttributesSupported: Boolean = true - private def percentageAttribute(multilanguage: Boolean = false, kind: String = "integer"): JsonObject = { + private def attribute( + name: String, + kind: String = "integer", + multilanguage: Boolean = false + ): JsonObject = { Json.obj( - "name" -> "percentage", - "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), + "name" -> name, + "displayName" -> Json.obj("de-DE" -> s"Attribut $name"), "kind" -> kind, "multilanguage" -> multilanguage ) @@ -49,7 +53,7 @@ class LinkAttributesTest extends LinkTestBase with LinkAttributeTestOverrides { "kind" -> "link", "toTable" -> toTableId, "singleDirection" -> singleDirection, - "linkAttributes" -> Json.arr(percentageAttribute(multilanguage, kind)) + "linkAttributes" -> Json.arr(attribute("percentage", kind = kind, multilanguage = multilanguage)) ) ) ) @@ -544,7 +548,7 @@ class LinkAttributesTest extends LinkTestBase with LinkAttributeTestOverrides { "kind" -> "link", "toTable" -> toTableId, "singleDirection" -> false, - "linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true)) + "linkAttributes" -> Json.arr(attribute("percentage", multilanguage = true)) ))) ).map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) @@ -639,7 +643,7 @@ class LinkAttributesTest extends LinkTestBase with LinkAttributeTestOverrides { _ <- sendRequest( "POST", s"/tables/1/columns/$migratedColumnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "datetime"))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", kind = "datetime"))) ) migratedCell <- sendRequest("GET", s"/tables/1/columns/$migratedColumnId/rows/1") } yield { diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala index 53723fbf6..a8581ef5b 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala @@ -28,14 +28,14 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib override protected def testMultilanguageLinkAttributesSupported: Boolean = true - private def percentageAttribute( + private def attribute( + name: String, kind: String = "integer", - multilanguage: Boolean = false, - name: String = "percentage" + multilanguage: Boolean = false ): JsonObject = { Json.obj( "name" -> name, - "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), + "displayName" -> Json.obj("de-DE" -> s"Attribut $name"), "kind" -> kind, "multilanguage" -> multilanguage ) @@ -85,30 +85,30 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib @Test def createLinkColumnWithLinkAttributes(implicit c: TestContext): Unit = okTest { for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) result <- sendRequest("GET", s"/tables/1/columns/$columnId") } yield { - assertJSONEquals(Json.arr(percentageAttribute()), result.getJsonArray("linkAttributes")) + assertJSONEquals(Json.arr(attribute("percentage")), result.getJsonArray("linkAttributes")) } } @Test def createLinkColumnWithTooManyLinkAttributesFails(implicit c: TestContext): Unit = exceptionTest("error.json.linkAttributes") { - createLinkColumn(Json.arr(percentageAttribute(), percentageAttribute())) + createLinkColumn(Json.arr(attribute("percentage"), attribute("percentage"))) } @Test def createLinkColumnWithDisallowedLinkAttributeKindFails(implicit c: TestContext): Unit = exceptionTest("error.json.linkAttributes") { - createLinkColumn(Json.arr(percentageAttribute(kind = "link"))) + createLinkColumn(Json.arr(attribute("percentage", kind = "link"))) } // the name is the only handle a value has - {{attributes.}} in a formatPattern, matched by a \w-based // regex - so a name that regex can never produce is rejected instead of being stored unreferenceable private def createLinkColumnWithNameFails(name: String)(implicit c: TestContext): Unit = exceptionTest("error.json.linkAttributes") { - createLinkColumn(Json.arr(percentageAttribute(name = name))) + createLinkColumn(Json.arr(attribute(name))) } @Test @@ -131,7 +131,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib @Test def createLinkColumnWithLinkAttributeNameOfLettersDigitsUnderscoreSucceeds(implicit c: TestContext): Unit = okTest { for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(name = "percentage_2"))) + columnId <- createLinkColumn(Json.arr(attribute("percentage_2"))) result <- sendRequest("GET", s"/tables/1/columns/$columnId") } yield { assertEquals("percentage_2", result.getJsonArray("linkAttributes").getJsonObject(0).getString("name")) @@ -143,11 +143,11 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib def changeLinkColumnToInvalidLinkAttributeNameFails(implicit c: TestContext): Unit = exceptionTest("error.json.linkAttributes") { for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "percent age"))) + Json.obj("linkAttributes" -> Json.arr(attribute("percent age"))) ) } yield () } @@ -159,11 +159,11 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute())) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage"))) ) result <- sendRequest("GET", s"/tables/1/columns/$columnId") } yield { - assertJSONEquals(Json.arr(percentageAttribute()), result.getJsonArray("linkAttributes")) + assertJSONEquals(Json.arr(attribute("percentage")), result.getJsonArray("linkAttributes")) } } @@ -171,11 +171,11 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib def changeLinkColumnToTooManyLinkAttributesFails(implicit c: TestContext): Unit = exceptionTest("error.json.linkAttributes") { for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(), percentageAttribute())) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage"), attribute("percentage"))) ) } yield () } @@ -188,7 +188,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib _ <- sendRequest( "POST", "/tables/1/columns/1", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute())) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage"))) ) } yield () } @@ -198,7 +198,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("linkAttributes" -> Json.arr())) column <- sendRequest("GET", s"/tables/1/columns/$columnId") @@ -212,11 +212,11 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib @Test def changeLinkColumnOmittingLinkAttributesLeavesItUntouched(implicit c: TestContext): Unit = okTest { for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("name" -> "renamed")) result <- sendRequest("GET", s"/tables/1/columns/$columnId") } yield { - assertJSONEquals(Json.arr(percentageAttribute()), result.getJsonArray("linkAttributes")) + assertJSONEquals(Json.arr(attribute("percentage")), result.getJsonArray("linkAttributes")) } } @@ -225,13 +225,13 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(name = "percentage"))) + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) // rename only - same kind, same multilanguage flag, just a different name/displayName label _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "percent"))) + Json.obj("linkAttributes" -> Json.arr(attribute("percent"))) ) column <- sendRequest("GET", s"/tables/1/columns/$columnId") cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") @@ -246,12 +246,12 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer"))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", kind = "integer"))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric"))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", kind = "numeric"))) ) column <- sendRequest("GET", s"/tables/1/columns/$columnId") cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") @@ -269,12 +269,12 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib val failed = Json.obj("failed" -> "failed") for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "text"))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", kind = "text"))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) changeResult <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "integer"))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", kind = "integer"))) ).recoverWith({ case _ => Future.successful(failed) }) column <- sendRequest("GET", s"/tables/1/columns/$columnId") cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") @@ -290,12 +290,12 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = false))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", multilanguage = false))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", multilanguage = true))) ) cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") } yield { @@ -314,12 +314,12 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib ) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = true))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", multilanguage = true))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", multilanguage = false))) ) cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") } yield { @@ -335,12 +335,12 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr().addNull())))) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = false))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", multilanguage = false))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", multilanguage = true))) ) cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") } yield { @@ -360,12 +360,12 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib ) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = true))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", multilanguage = true))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", multilanguage = false))) ) cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") } yield { @@ -382,12 +382,12 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib ) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer", multilanguage = true))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", kind = "integer", multilanguage = true))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric", multilanguage = true))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", kind = "numeric", multilanguage = true))) ) cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") } yield { @@ -407,12 +407,12 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(Json.obj()))))) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer", multilanguage = true))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", kind = "integer", multilanguage = true))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric", multilanguage = true))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", kind = "numeric", multilanguage = true))) ) cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") } yield { @@ -438,12 +438,12 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr().addNull())))) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer", multilanguage = multilanguage))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", kind = "integer", multilanguage = multilanguage))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "numeric", multilanguage = multilanguage))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", kind = "numeric", multilanguage = multilanguage))) ) column <- sendRequest("GET", s"/tables/1/columns/$columnId") cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") @@ -458,7 +458,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib val pattern = "{{value}} ({{attributes.percentage}}%)" for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("formatPattern" -> pattern)) result <- sendRequest("GET", s"/tables/1/columns/$columnId") } yield { @@ -470,7 +470,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib def changeLinkColumnFormatPatternRejectedForUnknownToken(implicit c: TestContext): Unit = exceptionTest("unprocessable.entity") { for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", @@ -484,7 +484,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib val pattern = "{{value}} ({{attributes.percentage}}%)" for { - columnId <- createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some(pattern)) + columnId <- createLinkColumn(Json.arr(attribute("percentage")), formatPattern = Some(pattern)) result <- sendRequest("GET", s"/tables/1/columns/$columnId") } yield { assertEquals(pattern, result.getString("formatPattern")) @@ -496,7 +496,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib // same pattern that changeLinkColumnFormatPatternRejectedForUnknownToken rejects on the change path - // creating a link column must be rejected the same way instead of silently storing a broken pattern exceptionTest("unprocessable.entity") { - createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some("{{attributes.doesNotExist}}")) + createLinkColumn(Json.arr(attribute("percentage")), formatPattern = Some("{{attributes.doesNotExist}}")) } // linkAttributes definitions live once in system_link_table, keyed by link_id, shared by both @@ -509,7 +509,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib val putLink = Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(30))))) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "integer"))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", kind = "integer"))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) backlinkColumnId <- findBacklinkColumnId(toTable = 1) @@ -541,7 +541,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr("50"))))) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "text"))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", kind = "text"))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) backlinkColumnId <- findBacklinkColumnId(toTable = 1) @@ -551,7 +551,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "integer"))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", kind = "integer"))) ) forwardCellAfter <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") @@ -637,14 +637,14 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib exceptionTest("unprocessable.entity") { for { columnId <- createLinkColumn( - Json.arr(percentageAttribute()), + Json.arr(attribute("percentage")), formatPattern = Some("{{value}} ({{attributes.percentage}}%)") ) // no formatPattern in this request - the stored one still references {{attributes.percentage}} _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "share"))) + Json.obj("linkAttributes" -> Json.arr(attribute("share"))) ) } yield () } @@ -654,7 +654,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib exceptionTest("unprocessable.entity") { for { columnId <- createLinkColumn( - Json.arr(percentageAttribute()), + Json.arr(attribute("percentage")), formatPattern = Some("{{attributes.percentage}}") ) _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("linkAttributes" -> Json.arr())) @@ -669,7 +669,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib for { columnId <- createLinkColumn( - Json.arr(percentageAttribute()), + Json.arr(attribute("percentage")), formatPattern = Some("{{value}} ({{attributes.percentage}}%)") ) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) @@ -677,7 +677,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib "POST", s"/tables/1/columns/$columnId", Json.obj( - "linkAttributes" -> Json.arr(percentageAttribute(name = "share")), + "linkAttributes" -> Json.arr(attribute("share")), "formatPattern" -> "{{value}} ({{attributes.share}}%)" ) ) @@ -695,11 +695,11 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib @Test def changeLinkAttributesRenameWithValueOnlyFormatPatternSucceeds(implicit c: TestContext): Unit = okTest { for { - columnId <- createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some("{{value}}")) + columnId <- createLinkColumn(Json.arr(attribute("percentage")), formatPattern = Some("{{value}}")) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "share"))) + Json.obj("linkAttributes" -> Json.arr(attribute("share"))) ) column <- sendRequest("GET", s"/tables/1/columns/$columnId") } yield { @@ -712,7 +712,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib @Test def clearingLinkAttributesWithoutFormatPatternSucceeds(implicit c: TestContext): Unit = okTest { for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("linkAttributes" -> Json.arr())) column <- sendRequest("GET", s"/tables/1/columns/$columnId") } yield { @@ -729,7 +729,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib for { columnId <- createLinkColumn( - Json.arr(percentageAttribute()), + Json.arr(attribute("percentage")), formatPattern = Some("{{value}} ({{attributes.percentage}}%)") ) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) @@ -753,7 +753,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib def clearingLinkAttributesWithNullTogetherWithFormatPatternSucceeds(implicit c: TestContext): Unit = okTest { for { columnId <- createLinkColumn( - Json.arr(percentageAttribute()), + Json.arr(attribute("percentage")), formatPattern = Some("{{attributes.percentage}}") ) _ <- sendRequest( @@ -775,7 +775,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib exceptionTest("unprocessable.entity") { for { columnId <- createLinkColumn( - Json.arr(percentageAttribute()), + Json.arr(attribute("percentage")), formatPattern = Some("{{attributes.percentage}}") ) _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("linkAttributes" -> null)) @@ -787,14 +787,14 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib def clearingOnlyFormatPatternKeepsLinkAttributes(implicit c: TestContext): Unit = okTest { for { columnId <- createLinkColumn( - Json.arr(percentageAttribute()), + Json.arr(attribute("percentage")), formatPattern = Some("{{value}} ({{attributes.percentage}}%)") ) _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("formatPattern" -> null)) column <- sendRequest("GET", s"/tables/1/columns/$columnId") } yield { assertFalse(column.containsKey("formatPattern")) - assertJSONEquals(Json.arr(percentageAttribute()), column.getJsonArray("linkAttributes")) + assertJSONEquals(Json.arr(attribute("percentage")), column.getJsonArray("linkAttributes")) } } @@ -804,13 +804,13 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib def changeLinkAttributesTogetherWithClearedFormatPatternSucceeds(implicit c: TestContext): Unit = okTest { for { columnId <- createLinkColumn( - Json.arr(percentageAttribute()), + Json.arr(attribute("percentage")), formatPattern = Some("{{value}} ({{attributes.percentage}}%)") ) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(name = "share")), "formatPattern" -> null) + Json.obj("linkAttributes" -> Json.arr(attribute("share")), "formatPattern" -> null) ) column <- sendRequest("GET", s"/tables/1/columns/$columnId") } yield { @@ -823,7 +823,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib @Test def omittingFormatPatternLeavesItUntouched(implicit c: TestContext): Unit = okTest { for { - columnId <- createLinkColumn(Json.arr(percentageAttribute()), formatPattern = Some("{{value}}")) + columnId <- createLinkColumn(Json.arr(attribute("percentage")), formatPattern = Some("{{value}}")) _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("name" -> "renamed")) column <- sendRequest("GET", s"/tables/1/columns/$columnId") } yield { @@ -837,7 +837,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib def changeFormatPatternToNonStringFails(implicit c: TestContext): Unit = exceptionTest("error.json.formatPattern") { for { - columnId <- createLinkColumn(Json.arr(percentageAttribute())) + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("formatPattern" -> 42)) } yield () } @@ -875,7 +875,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib "name" -> "Test Link 1", "kind" -> "link", "toTable" -> toTableId, - "linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true)) + "linkAttributes" -> Json.arr(attribute("percentage", multilanguage = true)) ))) ) } yield () @@ -894,13 +894,13 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib "name" -> "Test Link 1", "kind" -> "link", "toTable" -> toTableId, - "linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false)) + "linkAttributes" -> Json.arr(attribute("percentage", multilanguage = false)) ))) ).map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) _ <- sendRequest( "POST", s"/tables/$tableId/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", multilanguage = true))) ) } yield () } @@ -919,7 +919,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib "name" -> "Test Link 1", "kind" -> "link", "toTable" -> toTableId, - "linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false)) + "linkAttributes" -> Json.arr(attribute("percentage", multilanguage = false)) ))) ) } yield { @@ -944,7 +944,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib ) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = true))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", multilanguage = true))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) // de-DE is dropped from the table, so the only stored value now sits under a langtag the table @@ -954,7 +954,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = false))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", multilanguage = false))) ) cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") } yield { @@ -1012,12 +1012,12 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib ) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "text"))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", kind = "text"))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "datetime"))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", kind = "datetime"))) ) cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") } yield { @@ -1035,12 +1035,12 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib ) for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(kind = "text"))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", kind = "text"))) _ <- sendRequest("POST", s"/tables/1/columns/$columnId/rows/1", putLink) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(kind = "date"))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", kind = "date"))) ) cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") } yield { @@ -1072,7 +1072,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib "kind" -> "link", "toTable" -> toTableId, "singleDirection" -> false, - "linkAttributes" -> Json.arr(percentageAttribute(multilanguage = multilanguage)) + "linkAttributes" -> Json.arr(attribute("percentage", multilanguage = multilanguage)) ))) ).map(_.getJsonArray("columns").getJsonObject(0).getLong("id").toLong) } yield (toTableId, columnId) @@ -1103,7 +1103,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib _ <- sendRequest( "POST", s"/tables/$toTableId/columns/$backlinkId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true, name = "share"))) + Json.obj("linkAttributes" -> Json.arr(attribute("share", multilanguage = true))) ) forwardColumn <- sendRequest("GET", "/tables/1/columns") } yield { @@ -1136,7 +1136,7 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib _ <- sendRequest( "POST", s"/tables/$toTableId/columns/$backlinkId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", multilanguage = true))) ) cell <- sendRequest("GET", s"/tables/1/columns/$columnId/rows/1") } yield { diff --git a/src/test/scala/com/campudus/tableaux/api/structure/LinkAttributeRolloutGatesTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/LinkAttributeRolloutGatesTest.scala index ee051f951..7cc387a6c 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/LinkAttributeRolloutGatesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/LinkAttributeRolloutGatesTest.scala @@ -26,11 +26,15 @@ import org.junit.runner.RunWith @RunWith(classOf[VertxUnitRunner]) class LinkAttributeRolloutGatesTest extends TableauxTestBase { - private def percentageAttribute(multilanguage: Boolean): JsonObject = { + private def attribute( + name: String, + kind: String = "integer", + multilanguage: Boolean = false + ): JsonObject = { Json.obj( - "name" -> "percentage", - "displayName" -> Json.obj("de-DE" -> "Prozentanteil"), - "kind" -> "integer", + "name" -> name, + "displayName" -> Json.obj("de-DE" -> s"Attribut $name"), + "kind" -> kind, "multilanguage" -> multilanguage ) } @@ -55,18 +59,18 @@ class LinkAttributeRolloutGatesTest extends TableauxTestBase { @Test def createLinkColumnWithMultilanguageAttributeFails(implicit c: TestContext): Unit = exceptionTest("error.json.linkAttributes") { - createLinkColumn(Json.arr(percentageAttribute(multilanguage = true))) + createLinkColumn(Json.arr(attribute("percentage", multilanguage = true))) } @Test def changeLinkColumnToMultilanguageAttributeFails(implicit c: TestContext): Unit = exceptionTest("error.json.linkAttributes") { for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = false))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", multilanguage = false))) _ <- sendRequest( "POST", s"/tables/1/columns/$columnId", - Json.obj("linkAttributes" -> Json.arr(percentageAttribute(multilanguage = true))) + Json.obj("linkAttributes" -> Json.arr(attribute("percentage", multilanguage = true))) ) } yield () } @@ -76,10 +80,10 @@ class LinkAttributeRolloutGatesTest extends TableauxTestBase { @Test def createLinkColumnWithLanguageNeutralAttributeSucceeds(implicit c: TestContext): Unit = okTest { for { - columnId <- createLinkColumn(Json.arr(percentageAttribute(multilanguage = false))) + columnId <- createLinkColumn(Json.arr(attribute("percentage", multilanguage = false))) column <- sendRequest("GET", s"/tables/1/columns/$columnId") } yield { - assertJSONEquals(Json.arr(percentageAttribute(multilanguage = false)), column.getJsonArray("linkAttributes")) + assertJSONEquals(Json.arr(attribute("percentage", multilanguage = false)), column.getJsonArray("linkAttributes")) } } } From f5c85941fba40f38addb887db364034d89a21c7c Mon Sep 17 00:00:00 2001 From: smnhgn Date: Mon, 24 Aug 2026 14:21:39 +0200 Subject: [PATCH 21/24] fix: send 400 instead of 500 for some invalid linkAttribute requests --- .../model/structure/ColumnModel.scala | 12 ++++-- .../campudus/tableaux/helper/JsonUtils.scala | 12 ++++-- .../tableaux/router/TableauxRouter.scala | 9 ++++- .../api/content/LinkAttributesTest.scala | 38 +++++++++++++++++++ .../ChangeLinkAttributesStructureTest.scala | 30 +++++++++++++++ 5 files changed, 94 insertions(+), 7 deletions(-) diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index 97f06a222..9c83c6635 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -2163,9 +2163,15 @@ class ColumnModel(val connection: DatabaseConnection)( "SELECT link_id FROM system_columns WHERE table_id = ? AND column_id = ?", Json.arr(table.id, columnId) ) - // No "is this a link column?" guard here: change() resolves the link's langtags before opening the transaction, - // which already fails for a column without a link_id - so by this point there is one. - linkId = selectNotNull(linkIdResult).head.getLong(0).longValue() + // change() resolves the link's langtags before opening the transaction, which already fails for a column + // without a link_id, and the controller turns a non-link column away before even that - so over HTTP this + // is unreachable. Kept anyway because change() isn't private to the controller: without it a NULL link_id + // becomes an NPE on getLong, i.e. a 500 for what is really a wrong-kind-of-column request. + linkId = Option(selectNotNull(linkIdResult).head.getLong(0)) + .map(_.longValue()) + .getOrElse(throw UnprocessableEntityException( + s"Column $columnId of table ${table.id} is not a link column, it has no linkAttributes." + )) linkTable = s"link_table_$linkId" (t, currentResult) <- t.query("SELECT attributes FROM system_link_table WHERE link_id = ?", Json.arr(linkId)) diff --git a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala index c7730b9b1..9b4fb654d 100644 --- a/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala +++ b/src/main/scala/com/campudus/tableaux/helper/JsonUtils.scala @@ -323,9 +323,15 @@ object JsonUtils extends LazyLogging { } private def parseLinkAttributes(json: JsonObject): Seq[LinkAttributeDefinition] = { - val entries = Option(json.getJsonArray("linkAttributes")) - .map(_.asScala.toSeq) - .getOrElse(Seq.empty) + // Deliberately not getJsonArray: that casts, so anything but an array escapes as a ClassCastException and + // surfaces as a 500 for what is a plain request error. null is not one of those - it is the wire-level way to + // clear the definitions, exactly like an empty array (see toColumnChanges). + val entries = json.getValue("linkAttributes") match { + case null => Seq.empty + case array: JsonArray => array.asScala.toSeq + case other => + throw InvalidJsonException(s"linkAttributes must be an array or null, but got $other.", "linkAttributes") + } LinkAttributeDefinition.checkMaxCount(entries.size) diff --git a/src/main/scala/com/campudus/tableaux/router/TableauxRouter.scala b/src/main/scala/com/campudus/tableaux/router/TableauxRouter.scala index d79b0c7fd..5b3cbabbf 100644 --- a/src/main/scala/com/campudus/tableaux/router/TableauxRouter.scala +++ b/src/main/scala/com/campudus/tableaux/router/TableauxRouter.scala @@ -873,7 +873,14 @@ class TableauxRouter(override val config: TableauxConfig, val controller: Tablea context, asyncGetReply { val json = getJson(context) - val attributes = json.getJsonArray("attributes") + // Deliberately not getJsonArray: that casts, so a non-array body would escape as a 500 instead of a 400. + // A missing key is turned away here as well rather than passed on as null, which the value validator + // could only report as a length mismatch against the column's definitions. + val attributes = json.getValue("attributes") match { + case null => throw InvalidJsonException("attributes is required and must be an array.", "attributes") + case array: JsonArray => array + case other => throw InvalidJsonException(s"attributes must be an array, but got $other.", "attributes") + } controller.updateCellLinkAttributes(tableId, columnId, rowId, toId, attributes) } ) diff --git a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala index 680ef7eab..4c819ed1e 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala @@ -438,6 +438,44 @@ class LinkAttributesTest extends LinkTestBase with LinkAttributeTestOverrides { } yield () } + // A body whose "attributes" is not an array is a request error, so it has to be reported as one - reading it + // with getJsonArray would let a ClassCastException through as a 500. + @Test + def putLinkAttributesEndpointWithNonArrayAttributesFails(implicit c: TestContext): Unit = + exceptionTest("error.json.attributes") { + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$linkColumnId/rows/1", + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + ) + _ <- sendRequest( + "PUT", + s"/tables/1/columns/$linkColumnId/rows/1/link/1/attributes", + Json.obj("attributes" -> 75) + ) + } yield () + } + + // Reported as a missing field rather than being passed on as null, which the value validator could only describe + // as "expected 1 value(s) but got 0" - true, but not what the caller got wrong. + @Test + def putLinkAttributesEndpointWithoutAttributesKeyFails(implicit c: TestContext): Unit = + exceptionTest("error.json.attributes") { + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest( + "POST", + s"/tables/1/columns/$linkColumnId/rows/1", + Json.obj("value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50))))) + ) + _ <- sendRequest("PUT", s"/tables/1/columns/$linkColumnId/rows/1/link/1/attributes", Json.obj()) + } yield () + } + @Test def putLinkAttributesEndpointOnColumnWithoutDefinitionFails(implicit c: TestContext): Unit = exceptionTest("unprocessable.entity") { diff --git a/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala b/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala index a8581ef5b..7507f3137 100644 --- a/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/structure/ChangeLinkAttributesStructureTest.scala @@ -842,6 +842,36 @@ class ChangeLinkAttributesStructureTest extends TableauxTestBase with LinkAttrib } yield () } + // Same reasoning one field over: a linkAttributes value that isn't an array is a request error and has to be + // reported as one. Reading it with getJsonArray would let a ClassCastException through as a 500 instead. + @Test + def changeLinkAttributesToNonArrayFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + for { + columnId <- createLinkColumn(Json.arr(attribute("percentage"))) + _ <- sendRequest("POST", s"/tables/1/columns/$columnId", Json.obj("linkAttributes" -> "percentage")) + } yield () + } + + // Same check on the create path, which reaches parseLinkAttributes through toCreateColumnSeq rather than + // toColumnChanges. A single definition object instead of an array of them is the plausible way to get this wrong. + @Test + def createLinkColumnWithNonArrayLinkAttributesFails(implicit c: TestContext): Unit = + exceptionTest("error.json.linkAttributes") { + val columnJson = Json.obj( + "name" -> "Test Link 1", + "kind" -> "link", + "toTable" -> 2, + "linkAttributes" -> attribute("percentage") + ) + + for { + _ <- createDefaultTable() + _ <- createDefaultTable("Test Table 2", 2) + _ <- sendRequest("POST", "/tables/1/columns", Json.obj("columns" -> Json.arr(columnJson))) + } yield () + } + // --------------------------------------------------------------------------------------------------------------- // A multilanguage attribute value is keyed by langtag, so a table without langtags can't carry one. Allowing it // used to let the multilanguage reshape run with an empty langtag list, which replaced every stored value with an From 5c7fef5bcf3d1dc901ede49d352334d85dbb00a1 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Wed, 2 Sep 2026 14:29:25 +0200 Subject: [PATCH 22/24] chore: add feature description and feature decisions --- CONTEXT.md | 69 +++++ GETTING_STARTED.md | 51 ++- README.md | 6 + .../0006-link-attributes-are-not-columns.md | 7 + ...07-link-attribute-values-are-positional.md | 7 + docs/features/link-attributes.md | 292 ++++++++++++++++++ src/main/resources/swagger.json | 56 +++- .../api/content/LinkAttributesTest.scala | 47 +++ 8 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0006-link-attributes-are-not-columns.md create mode 100644 docs/adr/0007-link-attribute-values-are-positional.md create mode 100644 docs/features/link-attributes.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..efc13e9a9 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,69 @@ +# Tableaux + +Tableaux is a REST service for storing data in tables that can link to each other. This glossary fixes the words used for the concepts that are easy to confuse — especially around links, where the same word means different things in the API, the database and everyday speech. + +## Language + +### Links + +**Link**: +A single connection between one row and one row in another (or the same) table. This is what carries link attribute values. +_Avoid_: edge, connection, relation + +**Link relation**: +The relationship behind a pair of link columns, identified by a `link_id`. It owns the link attribute definitions and backs one `link_table_` table. +_Avoid_: using "link" for this — a link is the individual connection + +**Link column**: +A column of kind `link` that exposes, for each of its rows, the links reaching out from that row. +_Avoid_: relation column, reference column + +**Backlink column**: +The link column automatically created in the target table for the opposite direction of the same link relation. +_Avoid_: reverse column, inverse link, mirror column + +### Attributes + +**Link attribute**: +A named scalar carried by a link itself rather than by either of the two rows it connects. +_Avoid_: link property, edge attribute + +**Link attribute definition**: +The declaration of one link attribute — its `name`, `kind`, `multilanguage` flag and display info. Lives on the link relation and is therefore identical on both link columns, where it appears as `linkAttributes`. +_Avoid_: link attribute column — a definition is deliberately not a column + +**Link attribute value**: +The value of one link attribute on one link, exposed as `attributes` on that link. Bound by position: `attributes[i]` belongs to `linkAttributes[i]`. +_Avoid_: attribute value (unqualified) + +**Column attributes**: +The free-form JSON object that can be stored on any column as `attributes`. Older than and unrelated to link attributes, despite the identical key name. +_Avoid_: attributes (unqualified) — always say which of the three you mean + +### Column model + +**Identifier column**: +A column flagged `identifier`, whose value represents its row wherever that row is shown as a link value. +_Avoid_: key column, primary column, display column + +**Concat column**: +The virtual column with id 0 that a table gets when its identifier columns need to be presented as one value. +_Avoid_: identifier column, combined column + +**Group column**: +A column that combines several other columns into one field for display purposes. +_Avoid_: composite column + +**Format pattern**: +A display template on a column, given as `formatPattern`, with `{{...}}` placeholders that are filled from the column's own value or its parts. +_Avoid_: template, display pattern + +**Langtag**: +An RFC 5646 language tag. A multilanguage value is an object keyed by langtag. +_Avoid_: locale, language code + +### Process + +**Rollout gate**: +A deliberately temporary restriction that limits a capability the backend already supports, until the frontend is ready for it. Implemented as a constant with a test-only override rather than as a configuration flag, so the code behind the gate stays reachable by tests. +_Avoid_: feature flag — a feature flag is operator-facing and configurable, a rollout gate is neither diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index a066fdf14..28aa5c26f 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -19,6 +19,7 @@ - [`link`](#link) - [`concat` and `group`](#concat-and-group) - [`attachment`](#attachment) + - [2.3. Errors](#23-errors) # 1. Preface @@ -399,9 +400,45 @@ A link column always points to a specific table — in this case table `1`. A li ] ``` +A link can also carry values of its own — a percentage, a date or a note per association — by defining `linkAttributes` on the link column. The definitions belong to the link itself and are therefore identical on the link column and its backlink column, while the values belong to each individual association: + +```json +{ + "id": 8, + "name": "country", + "kind": "link", + "toTable": 1, + "linkAttributes": [ // only present if at least one attribute is defined + { + "name": "percentage", // referenced by name only, has no id and no ordering + "kind": "integer", // text, numeric, integer, boolean, date or datetime + "multilanguage": false, + "displayName": {}, + "description": {} + } + ], + "formatPattern": "{{value}} ({{attributes.percentage}}%)" // only present if set + // ... +} +``` + +The cell value carries the attribute values positionally — `attributes[i]` belongs to `linkAttributes[i]`: + +```json +[ + { + "id": 13, + "value": "Czech Republic", + "attributes": [50] // only present if something is stored for this link + } +] +``` + +See [Link attributes](docs/features/link-attributes.md) for the full behaviour: how stored values are migrated when a definition changes, and which requests can write them. + #### `concat` and `group` -This column kinds combine multiple columns into one column. Setting multiple `identifier` columns in a table will automatically add a `concat` column at the beginning of the columns array. A `concat` column combines the values of the `identifier` columns into one column and is used to reference a foreign row in a link. The `group` column lets you combine multiple columns into one, for example grouping three columns `height`, `length`, and `depth` together into a single field for the UI as ` x x `. +This column kinds combine multiple columns into one column. Setting multiple `identifier` columns in a table will automatically add a `concat` column at the beginning of the columns array. A `concat` column combines the values of the `identifier` columns into one column and is used to reference a foreign row in a link. The `group` column lets you combine multiple columns into one, for example grouping three columns `height`, `length`, and `depth` together into a single field for the UI as ` x x `. How such a combined value is rendered can be controlled with a `formatPattern` on the column, using `{{...}}` placeholders. `formatPattern` also exists on [`link`](#link) columns, where the available placeholders are `{{value}}` and `{{attributes.}}`. Here is an example of a `concat` cell which combines three columns (`link`, `shorttext`, and `numeric`): @@ -450,3 +487,15 @@ Here is an example of an attachment cell: "updatedAt": "2017-03-23T10:01:47.604+01:00" } ``` + +## 2.3. Errors + +Errors are not returned as JSON. The response body is the plain error message, and the machine-readable error id is sent as the **HTTP reason phrase** next to the status code: + +```txt +HTTP/1.1 400 error.json.linkAttributes + +Multilanguage linkAttributes are not supported yet, but 'note' is multilanguage. +``` + +**Take note of the behavior!** The reason phrase, not the body, is what a client should switch on — the body is human-readable text and its wording is not part of the contract. And `404` is the exception: its reason phrase is always the literal `NOT FOUND`, so a not-found response does not carry its error id at all. diff --git a/README.md b/README.md index 3f7b39622..b47e1aa36 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,12 @@ Feature flags are used to enable or disable certain features. They have to be co - `isRowPermissionCheckEnabled`: Enable or disable row permission checks (default: false) - `isPublicFileServerEnabled`: Enable or disable the public file server. If enabled, files are accessible without authentication (default: false) +## Feature documentation + +Behaviour of individual features is documented in `docs/features/`. The field-by-field API reference stays in `swagger.json`, served at `/docs`. + +- [Link attributes](docs/features/link-attributes.md): values carried by a link itself rather than by the rows it connects + ## Highlevel Features - Content Creation System diff --git a/docs/adr/0006-link-attributes-are-not-columns.md b/docs/adr/0006-link-attributes-are-not-columns.md new file mode 100644 index 000000000..f654360af --- /dev/null +++ b/docs/adr/0006-link-attributes-are-not-columns.md @@ -0,0 +1,7 @@ +# Link attributes are not columns; they are referenced by name + +A link attribute is declared with a `name`, a `kind`, a `multilanguage` flag and display infos, so `LinkAttributeDefinition` in `src/main/scala/com/campudus/tableaux/database/domain/link.scala` ends up looking a lot like `ColumnDefinition`. The obvious next step would have been to make it one: a row in `system_columns` with a real `id` and an `ordering`, reusing everything that already exists for columns. **We deliberately did not.** A link attribute has no id, no ordering and no `system_columns` row; the definitions are stored as a JSON array in `system_link_table.attributes` and are addressed only by their `name`. + +Two reasons. First, a column is not a small thing to add: every part of the system that iterates columns would have to decide what a link attribute means to it — column ordering, role-based authorization, history, identifier flattening and the concat column, group columns, the column cache, the structure endpoints. None of those wants a column that cannot stand on its own. Second, a column belongs to exactly one table, but a link attribute belongs to the *link relation* and has to be identical for the link column and its backlink column. As a column it would have been either two rows kept in sync, or one row surfacing in a table it does not belong to. Hanging it off `system_link_table` makes "both directions see the same definitions" a property of the storage rather than something to maintain. + +The price is that there is no stable identifier. Renaming an attribute is lossless only because the definition list is diffed by position and not by name (see ADR 0007). The name is also the handle used in `formatPattern` as `{{attributes.}}`, which is why it is restricted to `\w+` — a dot would make `{{attributes.a.b}}` ambiguous to the `\{\{([\w.]+)\}\}` parser. And should a link attribute ever need something a column has — read-only, per-attribute permissions — there is no row to put it on. diff --git a/docs/adr/0007-link-attribute-values-are-positional.md b/docs/adr/0007-link-attribute-values-are-positional.md new file mode 100644 index 000000000..6ced832c4 --- /dev/null +++ b/docs/adr/0007-link-attribute-values-are-positional.md @@ -0,0 +1,7 @@ +# Link attribute values are transferred and stored positionally, not keyed by name + +A link carries its attribute values as a bare array — `"attributes": [50]` next to the column's `"linkAttributes": [{"name": "percentage", ...}]` — and the same array shape is what lands in `link_table_.attributes`. The obvious alternative was an object keyed by name, `{"percentage": 50}`, which is self-describing and survives reordering. **We chose position: `attributes[i]` belongs to `linkAttributes[i]`.** + +The reason is the migration path, not the wire format. When a definition list is replaced, `ColumnModel.updateLinkAttributesDefinition` has to decide what happens to every value already stored against the old list. Diffing by position gives an unambiguous old-slot-to-new-slot correspondence, so the migration can walk the slots and cast or reshape each one in place, and a *rename* is simply a slot whose `name` changed — cosmetic, no query, values kept. Matching by name would make a rename indistinguishable from removing one attribute and adding another, and the values would be dropped every time somebody fixed a typo. Since values are diffed positionally anyway, storing and transferring them any other way would only add a translation layer that can disagree with the migration. + +What this costs: a write has to supply exactly as many values as there are definitions (a length mismatch is a 400, not a partial update), adding or removing a definition has to touch every stored array to pad it with `null` or truncate it, and clients have to keep two distinct meanings apart — `null` in a slot is "cleared", a missing `attributes` key is "nothing stored". It also means **reordering definitions is not cosmetic**: swapping two entries reinterprets every stored value against its new slot's kind, and is migrated as such. Whether the frontend should offer reordering at all is therefore a product question, not a technical one. diff --git a/docs/features/link-attributes.md b/docs/features/link-attributes.md new file mode 100644 index 000000000..6b55df693 --- /dev/null +++ b/docs/features/link-attributes.md @@ -0,0 +1,292 @@ +# Link attributes + +A link — the connection between one row and one row in another table — can carry values of its own: a percentage, a date, or a free-text note per connection. Those values belong to neither of the two rows; they belong to the link between them. + +The *definitions* hang off the link relation and are therefore identical for the link column and its backlink column. The *values* hang off each individual link and are readable and editable from both sides. + +> **What this document owns.** Flows and the rules that hold *between* fields: the positional contract, what `null` means, how a definition change migrates stored values, which verb updates what. The field-by-field reference — every property, type, and example — lives in `swagger.json`, served at `/docs/swagger.json`. When the two disagree, swagger is right about field shapes and this document is right about behaviour. Please keep it that way: do not grow a field table here. + +## How the pieces fit together + +Take a table `product` with a link column `suppliedBy` pointing at a table `supplier`. Creating that column also creates the backlink column `supplies` in `supplier`. The two columns are not two relationships — they are two views of one **link relation**. + +**Definitions are stored once, on that link relation.** Define a link attribute `percentage` on `suppliedBy`, and it appears as `linkAttributes` on `suppliedBy` *and* on `supplies`, identically. Change it from either side and it changes for both. There is no way for the two directions to disagree, because there is only one stored copy. + +**Values are stored one level down, on each individual link.** If product 1 is linked to supplier 4 and to supplier 9, and product 2 is also linked to supplier 4, those are three separate links, and each carries its own `attributes` array: + +| Link | `attributes` in the cell response | Meaning | +| --- | --- | --- | +| product 1 → supplier 4 | `[50]` | the attribute has a value | +| product 1 → supplier 9 | `[null]` | the value was explicitly cleared | +| product 2 → supplier 4 | key absent | nothing was ever stored for this link | + +**The array is bound to the definition list by position, not by name.** `attributes[0]` is the `percentage` value precisely because `percentage` is `linkAttributes[0]`. Nothing in the value array says which attribute it belongs to; the definition list at the same index is the only thing that gives it meaning. A second definition would take slot 1 on every link, and so on — though today a link column may define only one, see [Current rollout limits](#current-rollout-limits). Everything else in this document follows from the positional binding — see [ADR 0007](../adr/0007-link-attribute-values-are-positional.md). + +--- + +## Part 1 — Using link attributes + +### What a link attribute is, and what it is not + +A link attribute is deliberately **not a column**. It has no `id`, no `ordering`, and no row in `system_columns`; it is referenced only by its `name`. That has consequences you will notice: you cannot sort by it, it does not appear in the columns list, and renaming it is a normal edit rather than a delete-and-recreate. The reasoning is recorded in [ADR 0006](../adr/0006-link-attributes-are-not-columns.md). + +### Three different things are called `attributes` + +The key name is overloaded. When you are staring at a response, this is which is which: + +| Where you see it | What it is | +| --- | --- | +| `column.attributes` | A free-form JSON object storable on **any** column. Predates link attributes and has nothing to do with them. | +| `column.linkAttributes` | The **definitions**: what this link column's links may carry. | +| `attributes` inside a link value, and the body of the dedicated endpoint | The **values** on one individual link. | + +### Defining link attributes + +Pass `linkAttributes` when creating a link column: + +``` +POST /tables/1/columns +``` + +```json +{ + "columns": [ + { + "name": "Test Link 1", + "kind": "link", + "toTable": 2, + "singleDirection": false, + "linkAttributes": [ + { + "name": "percentage", + "displayName": {"de-DE": "Prozentanteil"}, + "kind": "integer", + "multilanguage": false + } + ] + } + ] +} +``` + +or change them afterwards with a `POST` on the column. There, the field is tri-state: + +| `linkAttributes` in the request | Effect | +| --- | --- | +| omitted | definitions untouched | +| an array | replaces the definitions and migrates stored values (see below) | +| `[]` or `null` | deletes the definitions **and wipes every stored value** | + +Allowed `kind`s are `text`, `numeric`, `integer`, `boolean`, `date` and `datetime` — notably *not* `shorttext`, `richtext` or `currency`. A `name` must match `\w+` (letters, digits, underscores) and be unique within the column. The `\w+` restriction exists because the name is the handle used in format patterns, where a dot would make `{{attributes.a.b}}` ambiguous. + +### Format patterns on link columns + +`formatPattern` now works for link columns too, with two placeholders: `{{value}}` for the linked row's identifier value and `{{attributes.}}` for a link attribute. + +```json +{"formatPattern": "{{value}} ({{attributes.percentage}}%)"} +``` + +Pattern and definitions are validated against each other in **both** directions: a pattern referring to an attribute that does not exist is rejected, and removing an attribute that a pattern still refers to is rejected too. Since a pattern has to be removable before its definitions can be cleared, `formatPattern: null` is accepted and means "delete". So clearing everything in one request is: + +```json +{"linkAttributes": [], "formatPattern": null} +``` + +Like `linkAttributes`, omitting `formatPattern` leaves it untouched. + +### Writing values + +#### The dedicated endpoint + +``` +PUT /tables/{tableId}/columns/{columnId}/rows/{rowId}/link/{linkId}/attributes +``` + +```json +{"attributes": [75]} +``` + +The response is the full cell value, as a cell `GET` would return it. + +> **Careful with `{linkId}`.** In this path it is the **id of the linked (target) row**, not the `link_id` of the link relation. This matches the existing `DELETE …/link/{linkId}` and `PUT …/link/{linkId}/order`, so it is the convention rather than a one-off — but it is genuinely confusing, because `link_id` means something else everywhere in the database. + +#### Inline on a cell write + +A link entry may be an object instead of a bare id, in both accepted shapes: + +```json +{"value": {"values": [{"id": 1, "attributes": [50]}]}} +{"value": {"to": 1, "attributes": [50]}} +``` + +Bare ids and objects can be mixed freely; an entry without `attributes` simply stores none: + +```json +{"value": {"values": [1, {"id": 2, "attributes": [75]}]}} +``` + +```json +{ + "status": "ok", + "value": [ + {"id": 1, "value": "table2row1"}, + {"id": 2, "value": "table2row2", "attributes": [75]} + ] +} +``` + +#### Which verb actually updates an existing link + +Inline `attributes` are written **only when the link itself is created**. The insert into the link table is guarded by `WHERE NOT EXISTS`, and that insert is the only place an inline value is bound. So for a link that already exists: + +| Request | Result for `{"id": X, "attributes": [99]}` when the link to X already exists | +| --- | --- | +| `POST` / `PATCH` on the cell (appends links) | **500 `error.database.checkSize`**, whole transaction rolled back — even if the attributes are identical | +| `PUT` on the cell (replaces the cell) | attributes become `99`, but only because the link is deleted and re-created | +| `PUT …/link/{linkId}/attributes` | attributes become `99`, a real in-place update | + +The 500 is pre-existing behaviour of the link insert guard (`insertCheckSize`), not something this feature introduced: appending a link that already exists has always failed that way. It is called out here because "just write the cell again" is the intuitive way to change an attribute, and it does not work. + +**Use the dedicated endpoint to change the attributes of an existing link.** + +### The positional contract, and `null` + +`attributes[i]` belongs to `linkAttributes[i]`. A write must supply exactly one value per definition; a different length is rejected with `Expected N link attribute value(s) but got M.` + +`null` is valid for every kind and means "cleared". It keeps its slot: + +```json +{"value": {"values": [{"id": 1, "attributes": [null]}]}} +``` + +```json +{"status": "ok", "value": [{"id": 1, "value": "table2row1", "attributes": [null]}]} +``` + +That is different from having no `attributes` at all: + +- nothing stored for the link → the `attributes` key is **absent** from the response +- a slot explicitly set to `null` → the key is **present**, with `null` in that slot + +Sending `"attributes": []` on a column that has no definitions is accepted and stores nothing, so old clients that always send an empty array are unaffected. + +### Normalisation + +`date` values are normalised to `YYYY-MM-DD`, `datetime` values are converted to UTC and stored as `YYYY-MM-DDTHH:mm:ss.sssZ`. Writing `"2020-01-01T13:00:00.000+01:00"` gives you `"2020-01-01T12:00:00.000Z"` back. + +### Changing a definition migrates existing values + +Definitions are not versioned and values are not discarded. A change to the definition list migrates what is already stored: + +| Change | What happens to stored values | +| --- | --- | +| `kind` changed | every value in that slot is cast to the new kind | +| `multilanguage` flipped | values in that slot are reshaped — wrapped into a langtag object, or collapsed back to a single value | +| definition added | every stored array is padded with `null` in the new slot | +| definition removed | every stored array is truncated | +| `name` or display info changed | **nothing** — a rename is cosmetic and keeps its values | + +Two consequences worth internalising. First, the diff runs **by position, never by name**, which is exactly why a rename is free — see [ADR 0007](../adr/0007-link-attribute-values-are-positional.md). The flip side is that **reordering definitions is not free**: it reinterprets every stored value against its new slot. + +Second, a value that cannot be cast fails the **entire** change, not just that row. This mirrors what happens when you change the kind of a normal column, where Postgres' `ALTER COLUMN … USING …::type` behaves the same way. + +### Current rollout limits + +Two restrictions apply today. Neither is structural — the code behind them works — they are waiting on the frontend: + +- **At most one definition per link column.** More is rejected with `400 error.json.linkAttributes`. +- **`multilanguage: true` is rejected**, also with `400 error.json.linkAttributes`. + +### `400` versus `422` on multilanguage + +Error ids do not travel in the response body — see [Errors](../../GETTING_STARTED.md#23-errors) for where to read them. + +Two multilanguage failures look alike and mean opposite things: + +- **`400 error.json.linkAttributes`** — the rollout gate above. Temporary. It will disappear when the gate is lifted, and your request will start succeeding without you changing anything. +- **`422 unprocessable.entity`** — a permanent rule: neither of the two linked tables has any langtags, so a multilanguage attribute has no languages to hold. This will still fail after the gate is lifted. + +Do not treat them as the same class of error. For everything else, the per-operation responses in `/docs/swagger.json` list what each endpoint can return. + +### Backwards compatibility + +A link column without link attributes behaves exactly as before. `linkAttributes` is omitted from the column response when the list is empty, `formatPattern` is omitted when unset, and no new key appears in the cell response. Every request shape that was valid for link values before is still valid. + +--- + +## Part 2 — How it works + +### Where things are stored + +| What | Where | Scope | +| --- | --- | --- | +| Definitions | `system_link_table.attributes` (jsonb array) | one per **link relation** — both directions read the same row | +| Values | `link_table_.attributes` (jsonb array) | one per **link**, positional | +| `formatPattern` | `system_columns.format_pattern` | one per **column** — the backlink column has its own, created as NULL | + +That split is the whole design in one table: definitions are shared, patterns are not. + +`schema_v43` adds `attributes` to `system_link_table` and, via a temporary plpgsql helper, to every existing `link_table_%`. New link tables get the column directly from the DDL in `ColumnModel.createLinkColumn`. + +### Value migration + +Three helpers in [`ColumnModel.scala`](../../src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala) do the work, each skipped when the corresponding property did not change: + +- **`castLinkAttributeValues`** — `jsonb_set` with the value cast to the new type; `date`/`datetime` go through `TO_CHAR` so the stored format matches exactly what the write path produces. (A test pins the write path and this path to each other, because one uses Joda and the other Postgres.) +- **`reshapeLinkAttributeValues`** — language-neutral to multilanguage wraps the value into an object for each langtag; the other direction collapses it, preferring the configured langtags in their configured order and skipping cleared ones. +- **`resizeLinkAttributeValues`** — pads with `'null'::jsonb` or truncates. Each variant carries a `WHERE` clause so a link table with millions of rows is not rewritten when nothing needs to change; clearing all definitions is a single `SET attributes = NULL`. + +`updateLinkAttributesDefinition` orchestrates them, folding over `oldDefinitions.zip(newDefinitions).zipWithIndex` sequentially — they all rewrite the same jsonb column, so they cannot run in parallel. A failure anywhere rolls the whole column change back. + +### The round-trip invariant + +**Whatever the API hands out must be acceptable as a write.** Several decisions that look arbitrary in isolation exist to hold this: + +- `null` is valid for every kind — otherwise a cleared value could be read but not written back. +- Langtag keys in values are **not** validated against the tables' langtags. A value belongs to the link, which is shared by two tables whose langtag sets may differ, and langtags can be removed after a value was written. Rejecting unknown keys would break read-modify-write and row duplication. +- Adding a definition pads existing arrays, so a value read before the change is still the right length after it. +- The read projection merges `attributes` in **outside** `jsonb_strip_nulls`. Otherwise a langtag deliberately set to `null` would silently vanish from the response and an all-null value would collapse to `{}`. + +Breaking this invariant is not theoretical: it previously made `duplicateRow` fail with a 400 on its own output. + +### Cache invalidation + +`invalidateDependentColumnCaches` in [`StructureController.scala`](../../src/main/scala/com/campudus/tableaux/controller/StructureController.scala) runs after any structure change, `linkAttributes` included. It invalidates the dependent columns, each dependent table's column 0 (the concat column) and the group columns on both. Without it the backlink side, concat values and group columns kept serving stale cell values after a definition change. + +### History + +Attribute values appear in cell history entries alongside the link's `id` and `value`, and the dedicated endpoint writes history too. `retrieveLinkAttributesByRowId` short-circuits to an empty map when the column has no definitions, so link columns that predate this feature do not pay a guaranteed-empty round trip per column per row. + +### Rollout gates and the test seam + +The two limits live in `object LinkAttributeDefinition` in [`link.scala`](../../src/main/scala/com/campudus/tableaux/database/domain/link.scala): + +| | Default | Read through | Test override | +| --- | --- | --- | --- | +| max definitions | `defaultMaxCount = 1` | `maxCount` | `setMaxCountForTest` / `resetMaxCountForTest` | +| multilanguage | `defaultMultilanguageSupported = false` | `multilanguageSupported` | `setMultilanguageSupportedForTest` / `resetMultilanguageSupportedForTest` | + +They are `def`s over a `private var` override rather than constants, because the limits are rollout decisions and not structural ones. The N-definition and multilanguage paths are fully implemented — values are positional arrays, migrations walk them slot by slot, patterns address them by name — and hard-coding the limits would turn all of that into unreachable code no test could exercise. Over HTTP they behave exactly like constants. + +They are not `TableauxConfig` entries because the checks happen in `JsonUtils` (request parsing) and `ColumnModel` (value migration), neither of which has a config in reach — and a config key would be an operator-facing switch for raising a limit the frontend cannot handle yet. + +Tests opt in via the `LinkAttributeTestOverrides` trait, which applies both overrides in `@Before` and resets both in `@After`, so a class that lifts one gate is still held to the production default of the other. `LinkAttributeRolloutGatesTest` deliberately lifts nothing: it pins what the API answers *with* the gates in place. + +#### Lifting a gate + +1. Change the `default…` value in `link.scala`. Nothing else in production code should need to move. +2. Drop the corresponding `testLinkAttributeMaxCount` / `testMultilanguageLinkAttributesSupported` override from the test classes that raise it — `MultipleLinkAttributesTest` covers the N-definition paths, `LinkAttributesTest` and `ChangeLinkAttributesStructureTest` cover multilanguage values and definition changes. Those tests then run against the real default. +3. Delete the matching cases in `LinkAttributeRolloutGatesTest`; they assert a rejection that no longer happens. Keep `createLinkColumnWithLanguageNeutralAttributeSucceeds`. +4. Check what stops being a `400`: a request that used to fail with `error.json.linkAttributes` will now be accepted. The `422` for "no langtags on either table" stays. +5. Confirm the frontend actually handles it — several definitions per column need UI for ordering and for the fact that reordering migrates values; multilanguage needs the langtag editor wired to a value that is not a column. + +--- + +## Known limitations + +1. **Patterns are not cross-validated across directions.** Definitions are shared between a link column and its backlink column, but `formatPattern` is per column. Changing definitions from one side is validated only against *that* side's pattern; a pattern on the other side referring to a removed attribute is not caught. +2. **Langtag keys in values are not validated.** Any key is accepted. This is deliberate — see the round-trip invariant above — but it means a typo in a langtag is stored rather than rejected. +3. **An uncastable value during a kind change surfaces as a 500.** The Postgres cast error propagates as `error.database.*` and the whole column change is rolled back. It is a client error in substance but not in status code. +4. **The two rollout gates** described above: one definition per column, no multilanguage. +5. **Inline `attributes` only apply when the link is created.** Changing them on an existing link needs the dedicated endpoint; see the verb table in Part 1. diff --git a/src/main/resources/swagger.json b/src/main/resources/swagger.json index 2fc20e36e..764f17d2b 100644 --- a/src/main/resources/swagger.json +++ b/src/main/resources/swagger.json @@ -1180,6 +1180,12 @@ "status" ] } + }, + "400": { + "description": "The column definition is invalid. For link columns this covers the linkAttributes entries, sent as error.json.linkAttributes: more entries than are currently supported, an entry that is not a JSON object, a name that does not match \\w+, duplicate names, a kind outside text, numeric, integer, boolean, date and datetime, or multilanguage set to true while multilanguage link attributes are not supported yet." + }, + "422": { + "description": "The column definition is well-formed but cannot be applied (unprocessable.entity). For link columns: formatPattern uses a placeholder other than {{value}} or one of the defined {{attributes.}}, or a linkAttributes entry is multilanguage while neither of the two linked tables has any langtags." } } } @@ -1255,6 +1261,15 @@ "responses": { "200": { "$ref": "#/responses/ok-empty-body" + }, + "400": { + "description": "The change is invalid. For linkAttributes this is sent as error.json.linkAttributes and covers the same cases as creating a column: too many entries, an entry that is not a JSON object, a name that does not match \\w+, duplicate names, a disallowed kind, or multilanguage set to true while that is not supported yet. A formatPattern that is neither a string nor null is sent as error.json.formatPattern." + }, + "403": { + "description": "The submitted field is not allowed for this column kind, sent as error.request.forbidden.column. linkAttributes may only be changed on link columns, and formatPattern only on link and group columns." + }, + "422": { + "description": "The change is well-formed but cannot be applied (unprocessable.entity). For link columns: the resulting formatPattern and linkAttributes do not match each other - both are validated as they will be after the change, so removing an attribute that the remaining pattern still refers to is rejected as well - or an attribute is made multilanguage while the link has no langtags. A stored value that cannot be cast to a changed kind fails the whole change and is reported as a database error instead." } } }, @@ -1412,7 +1427,7 @@ }, "post": { "summary": "DEPRECATED - Update cell or add link/attachment", - "description": "DEPRECATED: Use same route with PATCH instead. Updates the value of specified cell. This can add links, attachments, multi-language contents and replaces simple values such as numbers, booleans or strings.", + "description": "DEPRECATED: Use same route with PATCH instead. Updates the value of specified cell. This can add links, attachments, multi-language contents and replaces simple values such as numbers, booleans or strings.\nFor link columns a value entry may be an object carrying inline attributes. Those are only stored while the link is being created - this request appends links, so a link that already exists is rejected and its attributes are not updated. Use PUT /tables/{tableId}/columns/{columnId}/rows/{rowId}/link/{linkId}/attributes to change the attributes of an existing link.", "tags": [ "content" ], @@ -1429,6 +1444,9 @@ "200": { "$ref": "#/responses/cell-value" }, + "400": { + "description": "The value does not match the column. For link columns, inline attributes that do not match the column's linkAttributes definitions are sent as error.json.link-attributes, and a link entry that is neither an id nor an object with an id field is sent as error.json.link-value." + }, "404": { "$ref": "#/responses/not-found-in-database" } @@ -1436,7 +1454,7 @@ }, "patch": { "summary": "Update cell or add link/attachment", - "description": "Updates the value of specified cell. This can add links, attachments, multi-language contents and replaces simple values such as numbers, booleans or strings.", + "description": "Updates the value of specified cell. This can add links, attachments, multi-language contents and replaces simple values such as numbers, booleans or strings.\nFor link columns a value entry may be an object carrying inline attributes. Those are only stored while the link is being created - this request appends links, so a link that already exists is rejected and its attributes are not updated. Use PUT /tables/{tableId}/columns/{columnId}/rows/{rowId}/link/{linkId}/attributes to change the attributes of an existing link.", "tags": [ "content" ], @@ -1453,6 +1471,9 @@ "200": { "$ref": "#/responses/cell-value" }, + "400": { + "description": "The value does not match the column. For link columns, inline attributes that do not match the column's linkAttributes definitions are sent as error.json.link-attributes, and a link entry that is neither an id nor an object with an id field is sent as error.json.link-value." + }, "404": { "$ref": "#/responses/not-found-in-database" } @@ -1460,7 +1481,7 @@ }, "put": { "summary": "Replace cell value", - "description": "Replace the current value of the specified cell. This overwrites all languages in a multi-language cell or deletes all links and replaces the data with the provided value.", + "description": "Replace the current value of the specified cell. This overwrites all languages in a multi-language cell or deletes all links and replaces the data with the provided value.\nFor link columns a value entry may be an object carrying inline attributes. Because every link of the cell is deleted and re-created, those attributes are stored even for a link that existed before - but so is everything else about the cell, including link ordering.", "tags": [ "content" ], @@ -1477,6 +1498,9 @@ "200": { "$ref": "#/responses/cell-value" }, + "400": { + "description": "The value does not match the column. For link columns, inline attributes that do not match the column's linkAttributes definitions are sent as error.json.link-attributes, and a link entry that is neither an id nor an object with an id field is sent as error.json.link-value." + }, "404": { "$ref": "#/responses/not-found-in-database" } @@ -2555,7 +2579,15 @@ "$ref": "#/definitions/LocationType" } } - ] + ], + "responses": { + "200": { + "$ref": "#/responses/cell-value" + }, + "404": { + "$ref": "#/responses/not-found-in-database" + } + } } }, "/tables/{tableId}/columns/{columnId}/rows/{rowId}/link/{linkId}": { @@ -2621,7 +2653,15 @@ "$ref": "#/definitions/LocationType" } } - ] + ], + "responses": { + "200": { + "$ref": "#/responses/cell-value" + }, + "404": { + "$ref": "#/responses/not-found-in-database" + } + } } }, "/tables/{tableId}/columns/{columnId}/rows/{rowId}/link/{linkId}/attributes": { @@ -2672,8 +2712,14 @@ "200": { "$ref": "#/responses/cell-value" }, + "400": { + "description": "The request body is malformed, or the attribute values do not match the column's linkAttributes definitions. Sent as error.json.attributes if the attributes field is missing or is not an array, as error.json.link-attributes if the array length differs from the number of definitions or a value does not match its definition's kind, and as error.request.column.wrongtype if the addressed column is not a link column." + }, "404": { "$ref": "#/responses/not-found-in-database" + }, + "422": { + "description": "The addressed column is a link column but has no linkAttributes defined, so there is nothing to set (unprocessable.entity)." } } } diff --git a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala index 4c819ed1e..e9b894e56 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala @@ -752,4 +752,51 @@ class LinkAttributesTest extends LinkTestBase with LinkAttributeTestOverrides { } } + // --------------------------------------------------------------------------------------------------------------- + // Inline attributes are bound to the INSERT that creates the link, and that insert is guarded by + // `WHERE NOT EXISTS`. Writing the cell again therefore cannot update the attributes of a link that is already + // there: appending (POST/PATCH) hits the guard, gets zero rows back and fails the whole request via + // insertCheckSize, while replacing (PUT) only ends up rewriting them because it deletes and re-creates every + // link of the cell. Changing them in place is what the dedicated attributes endpoint is for. These two tests + // pin the difference because docs/features/link-attributes.md documents it per verb. + // --------------------------------------------------------------------------------------------------------------- + + @Test + def postingAnExistingLinkAgainFailsInsteadOfUpdatingItsAttributes(implicit c: TestContext): Unit = + exceptionTest("error.database.checkSize") { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50)))) + ) + val postSameLinkAgain = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(99)))) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", postSameLinkAgain) + } yield () + } + + @Test + def replacingTheCellRewritesLinkAttributes(implicit c: TestContext): Unit = okTest { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(50)))) + ) + val replaceWithSameLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> Json.arr(99)))) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + _ <- sendRequest("PUT", s"/tables/1/columns/$linkColumnId/rows/1", replaceWithSameLink) + cell <- sendRequest("GET", s"/tables/1/columns/$linkColumnId/rows/1") + } yield { + assertEquals(99, cell.getJsonArray("value").getJsonObject(0).getJsonArray("attributes").getInteger(0)) + } + } + } From c4b5b3a37384e23dca232312e5e9329a12367c94 Mon Sep 17 00:00:00 2001 From: smnhgn Date: Wed, 2 Sep 2026 15:24:29 +0200 Subject: [PATCH 23/24] fix: implement PR Feedback --- docs/features/link-attributes.md | 6 ++ .../controller/StructureController.scala | 14 ++++- .../tableaux/database/domain/column.scala | 28 ++++++++- .../model/structure/ColumnModel.scala | 16 +++-- .../api/content/LinkAttributesTest.scala | 61 +++++++++++++++++++ 5 files changed, 118 insertions(+), 7 deletions(-) diff --git a/docs/features/link-attributes.md b/docs/features/link-attributes.md index 6b55df693..b4d2dde82 100644 --- a/docs/features/link-attributes.md +++ b/docs/features/link-attributes.md @@ -136,6 +136,8 @@ Bare ids and objects can be mixed freely; an entry without `attributes` simply s } ``` +A malformed object entry is a request error, not a 500: a missing `id`, a non-numeric `id` (`{"id": "1"}`), or an `attributes` that isn't an array (`{"attributes": "50"}`) is rejected with `400 error.json.link-value`. + #### Which verb actually updates an existing link Inline `attributes` are written **only when the link itself is created**. The insert into the link table is guarded by `WHERE NOT EXISTS`, and that insert is the only place an inline value is bound. So for a link that already exists: @@ -254,6 +256,10 @@ Breaking this invariant is not theoretical: it previously made `duplicateRow` fa `invalidateDependentColumnCaches` in [`StructureController.scala`](../../src/main/scala/com/campudus/tableaux/controller/StructureController.scala) runs after any structure change, `linkAttributes` included. It invalidates the dependent columns, each dependent table's column 0 (the concat column) and the group columns on both. Without it the backlink side, concat values and group columns kept serving stale cell values after a definition change. +It also invalidates *this* table's column 0 when the column is an identifier either before or after the change — `retrieveDependencies` filters the own table out (`d.table_id != ?`), so the dependency walk never reaches it. Both states are checked because turning `identifier` off changes what the concat column resolves to just as much as turning it on does. This mirrors the `identifier` step of `TableauxModel.invalidateCellAndDependentColumns`, which does the same for a single cell. + +`CachedColumnModel.change` clears its process-local Guava cache both before and after `super.change`, the way `delete` does. Only clearing it up front leaves a window in which a concurrent `retrieve` repopulates the cache with the pre-change definition and nothing evicts it again — and a stale definition means values get validated against the wrong arity and kind. `eventClient.invalidateColumn` does not help here: it reaches the CacheVerticle's cell cache, not the in-process one. + ### History Attribute values appear in cell history entries alongside the link's `id` and `value`, and the dedicated endpoint writes history too. `retrieveLinkAttributesByRowId` short-circuits to an empty map when the column has no definitions, so link columns that predate this feature do not pay a guaranteed-empty round trip per column per row. diff --git a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala index 151f9ef1b..42a0e4b4c 100644 --- a/src/main/scala/com/campudus/tableaux/controller/StructureController.scala +++ b/src/main/scala/com/campudus/tableaux/controller/StructureController.scala @@ -885,7 +885,7 @@ class StructureController( // drifting apart - note that this counts `name` and `ordering` as structural, so a rename still invalidates. _ <- if (isAtLeastOneStructureProperty) { - invalidateDependentColumnCaches(tableId, columnId, changedColumn) + invalidateDependentColumnCaches(tableId, columnId, column, changedColumn) } else { Future.successful(()) } @@ -900,11 +900,23 @@ class StructureController( private def invalidateDependentColumnCaches( tableId: TableId, columnId: ColumnId, + columnBeforeChange: ColumnType[?], column: ColumnType[?] ): Future[Unit] = { def invalidateColumnCache: (TableId, ColumnId) => Future[?] = eventClient.invalidateColumn for { + // The whole-column analog of that method's "invalidate the concat cell if column is an identifier" step. It has + // to happen here because retrieveDependencies filters the own table out (d.table_id != ?), so the walk below + // never reaches this table's concat column. Both states of the column are checked: turning `identifier` off + // changes what the concat column resolves to just as much as turning it on does. + _ <- + if (columnBeforeChange.identifier || column.identifier) { + invalidateColumnCache(tableId, 0) + } else { + Future.successful(()) + } + _ <- if (column.columnInformation.groupColumnIds.nonEmpty) { Future.sequence(column.columnInformation.groupColumnIds.map(invalidateColumnCache(tableId, _))) diff --git a/src/main/scala/com/campudus/tableaux/database/domain/column.scala b/src/main/scala/com/campudus/tableaux/database/domain/column.scala index dcfc499d9..1562b6950 100644 --- a/src/main/scala/com/campudus/tableaux/database/domain/column.scala +++ b/src/main/scala/com/campudus/tableaux/database/domain/column.scala @@ -606,7 +606,14 @@ case class LinkColumn( // "attributes": [...]}` (single-value) shapes, so attributes are honored the same way // regardless of which key carries the target row id. private def buildLinkValue(id: RowId, obj: JsonObject): LinkValue = { - val attributes = Option(obj.getJsonArray("attributes")) + // Deliberately not getJsonArray: that casts, so anything but an array would escape as a ClassCastException and + // surface as a 500 for what is a plain request error - same reason as in TableauxRouter.changeLinkAttributes + // and JsonUtils.parseLinkAttributes. A missing key is the way to send no attributes at all, so it stays legal. + val attributes = (obj.getValue("attributes") match { + case null => None + case array: JsonArray => Some(array) + case other => throw InvalidJsonException(s"attributes must be an array, but got $other.", "link-value") + }) .map(attrs => LinkAttributeValueValidator .normalize(linkAttributes, attrs) @@ -620,13 +627,30 @@ case class LinkColumn( LinkValue(id, attributes) } + // Deliberately not getLong: that returns null for a missing key (so `.longValue()` throws an NPE) and casts + // otherwise, so both a forgotten and a non-numeric id would escape as a 500 for what is a plain request error. + // Matched as an Any (like extractLinkValue below) because getValue is statically an Object, against which a + // primitive Long pattern is reported as an unreachable case - it does match, but the warning is noise. And + // deliberately not matched as a Number, which would let `{"id": 1.5}` through, silently truncated to row 1. + private def extractLinkId(obj: JsonObject): RowId = (obj.getValue("id"): Any) match { + case null => + throw InvalidJsonException(s"A link value object expects an 'id' field, but got $obj", "link-value") + case id: RowId => id + case id: Integer => id.toLong + case other => + throw InvalidJsonException( + s"A link value's 'id' must be an id (Int/Long), but got ${other.getClass.getSimpleName}", + "link-value" + ) + } + // Handles a bare id (Integer or Long) as well as a `{"id": ..., "attributes": [...]}` object; attributes are // optional on every element so all pre-existing request shapes (bare ids, or objects with only "id") keep // working unchanged. private def extractLinkValue(v: Any): LinkValue = v match { case id: RowId => LinkValue(id) case id: Integer => LinkValue(id.toLong) - case obj: JsonObject => buildLinkValue(obj.getLong("id").longValue(), obj) + case obj: JsonObject => buildLinkValue(extractLinkId(obj), obj) case other => throw InvalidJsonException( s"Link value must be an id (Int/Long) or a JSON object with an 'id' field, but got ${other.getClass.getSimpleName}", diff --git a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala index 9c83c6635..f671a7987 100644 --- a/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala +++ b/src/main/scala/com/campudus/tableaux/database/model/structure/ColumnModel.scala @@ -249,6 +249,11 @@ class CachedColumnModel( formatPattern, linkAttributes ) + // Again afterwards, exactly like delete: a concurrent retrieve between the first removeCache and the commit of + // super.change would repopulate the cache with the pre-change definition, and nothing else would ever evict it. + // eventClient.invalidateColumn only reaches the CacheVerticle's cell cache, not this process-local one - and a + // stale linkAttributes definition means values get validated against the wrong arity and kind. + _ <- removeCache(table.id, Some(columnId)) } yield r } @@ -1935,8 +1940,8 @@ class ColumnModel(val connection: DatabaseConnection)( toLangtags <- retrieveEffectiveLangtags(toTable) } yield (fromLangtags ++ toLangtags).distinct - // Same union, resolved for an existing link column. Deliberately called before the caller opens its transaction: - // retrieveLinkInformation reads on its own connection, which must not happen while we hold one. + // Same union, resolved for an existing link column. retrieveLinkInformation reads on its own connection, so + // `change` calls this before opening its transaction to avoid occupying two pool connections at once (see there). private def retrieveLinkLangtags(table: Table, columnId: ColumnId)( implicit user: TableauxUser ): Future[Seq[String]] = @@ -2244,8 +2249,11 @@ class ColumnModel(val connection: DatabaseConnection)( } for { - // Resolved before the transaction opens on purpose: this reads on its own connection (it has to retrieve the - // link's other table), which must not happen while we are holding one. + // Resolved before the transaction opens: this reads on its own connection (it has to retrieve the link's other + // table), so doing it up front means the request doesn't occupy two connections from the pool at once. A + // preference, not a rule - createLinkColumn reads inside its transaction, as it already did before this feature + // for tableStruc.retrieve and retrieveAll. The reads carry no transactional guarantee either way, since a + // separate connection is not part of the transaction's snapshot. linkLangtags <- if (linkAttributes.isDefined) retrieveLinkLangtags(table, columnId) else Future.successful(Seq.empty[String]) diff --git a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala index e9b894e56..d099c46c3 100644 --- a/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala +++ b/src/test/scala/com/campudus/tableaux/api/content/LinkAttributesTest.scala @@ -254,6 +254,67 @@ class LinkAttributesTest extends LinkTestBase with LinkAttributeTestOverrides { } yield () } + // --------------------------------------------------------------------------------------------------------------- + // A malformed inline link value is a request error and has to be reported as one. Reading "attributes" with + // getJsonArray or the id with getLong would let a ClassCastException (or an NPE for a missing id) through as a 500 - + // the same trap the PUT .../attributes endpoint and the linkAttributes definition parser already avoid. + // --------------------------------------------------------------------------------------------------------------- + + @Test + def rejectNonArrayAttributesInInlineValue(implicit c: TestContext): Unit = + exceptionTest("error.json.link-value") { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> 1, "attributes" -> "50"))) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + } yield () + } + + // Same code path as above, reached through the other shape that can carry attributes. + @Test + def rejectNonArrayAttributesOnToShape(implicit c: TestContext): Unit = + exceptionTest("error.json.link-value") { + val putLink = Json.obj("value" -> Json.obj("to" -> 1, "attributes" -> "50")) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + } yield () + } + + @Test + def rejectInlineValueObjectWithoutId(implicit c: TestContext): Unit = + exceptionTest("error.json.link-value") { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("attributes" -> Json.arr(50)))) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + } yield () + } + + @Test + def rejectInlineValueObjectWithNonNumericId(implicit c: TestContext): Unit = + exceptionTest("error.json.link-value") { + val putLink = Json.obj( + "value" -> Json.obj("values" -> Json.arr(Json.obj("id" -> "1", "attributes" -> Json.arr(50)))) + ) + + for { + _ <- setupTwoTables() + linkColumnId <- createLinkColumnWithAttributes(1, 2) + _ <- sendRequest("POST", s"/tables/1/columns/$linkColumnId/rows/1", putLink) + } yield () + } + @Test def multilanguageAttributeValueRoundtrips(implicit c: TestContext): Unit = okTest { val putLink = Json.obj( From c83cf10359e226724406619ffa298f6cd988990a Mon Sep 17 00:00:00 2001 From: smnhgn Date: Wed, 2 Sep 2026 16:37:47 +0200 Subject: [PATCH 24/24] chore: setup matt pocock skills --- .gitignore | 3 ++ CLAUDE.md | 21 ++++++++++++++ docs/agents/domain.md | 54 ++++++++++++++++++++++++++++++++++++ docs/agents/issue-tracker.md | 36 ++++++++++++++++++++++++ docs/agents/triage-labels.md | 18 ++++++++++++ 5 files changed, 132 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md diff --git a/.gitignore b/.gitignore index 6683d8975..ea8629d03 100644 --- a/.gitignore +++ b/.gitignore @@ -64,5 +64,8 @@ local_logging.properties *.local* +# Agent skills: local issue tracker (specs and tickets), see docs/agents/issue-tracker.md +.scratch/ + # VSCode specific .vscode/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..d57bd1c9d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# CLAUDE.md + +Orientation for this repo: `README.md` (API overview), `GETTING_STARTED.md` +(setup and tests), `CONTEXT.md` (domain glossary). + +## Agent skills + +### Issue tracker + +Issues and specs live as markdown files in `.scratch/`; there is no remote +tracker. See `docs/agents/issue-tracker.md`. + +### Triage labels + +The five canonical roles under their default names, written as a `Status:` line +in the ticket file. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: `CONTEXT.md` and `docs/adr/` at the repo root. See +`docs/agents/domain.md`. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 000000000..39b258068 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,54 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when +exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root: the domain glossary. +- **`docs/adr/`**: read ADRs that touch the area you're about to work in. +- **`docs/features/.md`**: durable documentation of a shipped feature's + behaviour. Read the one covering your area if it exists. + +If any of these files don't exist, **proceed silently**. Don't flag their +absence; don't suggest creating them upfront. The `/domain-modeling` skill +(reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates +them lazily when terms or decisions actually get resolved. + +## File structure + +This is a single-context repo: + +``` +/ +├── CONTEXT.md +├── docs/ +│ ├── adr/ +│ │ ├── 0001-target-platform-vertx-4-5-11-scala-3-3-lts.md +│ │ └── … +│ └── features/ +│ └── link-attributes.md +└── src/main/scala/ +``` + +If this ever grows into several independent contexts, the multi-context layout is +a root `CONTEXT-MAP.md` pointing at one `CONTEXT.md` per context, with +context-scoped ADRs under `src//docs/adr/`. + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, +a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift +to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal: either you're +inventing language the project doesn't use (reconsider) or there's a real gap +(note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than +silently overriding: + +> _Contradicts ADR-0007 (link attribute values are positional), but worth +> reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 000000000..6aeeaf614 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,36 @@ +# Issue tracker: Local Markdown + +Issues and specs for this repo live as markdown files in `.scratch/`. + +There is no remote issue tracker for these skills to call: the GitHub remote +(`campudus/tableaux`) carries code and PRs only, and the YouTrack tickets that +branch names refer to (`GRUD_DEV-1199/link-attributes`) are outside what any +skill reads or writes. Never try to fetch a `GRUD_DEV-*` ticket; ask the user to +paste it if its content matters. + +## Conventions + +- One feature per directory: `.scratch//` +- The spec is `.scratch//spec.md` +- Implementation issues are one file per ticket at `.scratch//issues/-.md`, numbered from `01`, never a single combined tickets file +- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings) +- Comments and conversation history append to the bottom of the file under a `## Comments` heading + +## When a skill says "publish to the issue tracker" + +Create a new file under `.scratch//` (creating the directory if needed). + +## When a skill says "fetch the relevant ticket" + +Read the file at the referenced path. The user will normally pass the path or the issue number directly. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a file with one **child** file per ticket. + +- **Map**: `.scratch//map.md` (the Notes / Decisions-so-far / Fog body). +- **Child ticket**: `.scratch//issues/NN-.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`. +- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`. +- **Frontier**: scan `.scratch//issues/` for files that are open, unblocked, and unclaimed; first by number wins. +- **Claim**: set `Status: claimed` and save before any work. +- **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 000000000..cb2d4288d --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,18 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This repo tracks issues +as local markdown files, so a "label" is the value of the `Status:` line near the +top of the issue file — there is no label API to call. + +| Label in mattpocock/skills | `Status:` value in our files | Meaning | +| -------------------------- | ---------------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), write the +corresponding value from the right-hand column into the file's `Status:` line. + +Edit the right-hand column to match whatever vocabulary you actually use.