Skip to content

GRUD_DEV-1199/link attributes - #332

Open
smnhgn wants to merge 24 commits into
masterfrom
GRUD_DEV-1199/link-attributes
Open

smnhgn wants to merge 24 commits into
masterfrom
GRUD_DEV-1199/link-attributes

Conversation

@smnhgn

@smnhgn smnhgn commented Aug 7, 2026

Copy link
Copy Markdown
Member

Submit a pull request

Related Ticket: GRUD_DEV-1199

Please make sure the following is true

  • I gave the PR a meaningful name
  • I checked that the correct target branch is selected
  • I rebased the branch on the target branch and it can be merged
  • I ran the linter and it did pass
  • I checked for unused code / dead code / debug code
  • I checked that variables/functions have meaningful names
  • I checked that the behaviour is as the documentation/task describes and I tested it
  • I updated the docs / specifications if possible
  • I could explain all that code when someone wakes me up at 3am
  • I checked that the code considers failures and not just the happy path
  • There are no new dependencies OR I listed them and explained them below
  • PR introduces no breaking changes OR I listed them and described them below
  • I added/updated tests for new/modified unit-testable functions/helpers
  • I ran the tests and they did pass

Other information/comments (e.g. reasons why points are not checked from above)

Link-Attribute (linkAttributes)

Eine Link-Verbindung (Kante zwischen zwei Zeilen) kann jetzt eigene Werte tragen — z. B. einen
Prozentanteil, ein Datum oder einen Freitext pro Verknüpfung.

  • Die Definition der Attribute hängt am Link (linkAttributes) und ist damit für Hin- und
    Backlink-Spalte identisch.
  • Die Werte hängen an der einzelnen Verknüpfung (attributes im Zellwert) und sind von beiden
    Seiten lesbar und editierbar.

Ein Link-Attribut ist bewusst keine echte Spalte: keine id, keine ordering, kein Eintrag in
system_columns — es wird ausschließlich über seinen name referenziert.

API

Struktur

  • linkAttributes kann beim Anlegen einer Link-Spalte mitgegeben und über POST auf die Spalte
    nachträglich geändert werden. Feld weglassen = unverändert, leeres Array = Definition und alle
    gespeicherten Werte löschen.
  • Erlaubte kinds: text, numeric, integer, boolean, date, datetime. Namen müssen
    \w+ entsprechen und innerhalb der Spalte eindeutig sein.
  • formatPattern gibt es jetzt auch für Link-Spalten, mit den Platzhaltern {{value}} und
    {{attributes.<name>}}. Pattern und Definitionen werden in beide Richtungen gegeneinander
    validiert; damit ein Pattern gelöscht werden kann (Voraussetzung fürs Leeren der Definitionen),
    wird formatPattern: null als „löschen" akzeptiert.
  • Änderungen an einer Definition migrieren bestehende Werte statt sie zu verwerfen:
    Kind-Wechsel castet, multilanguage-Wechsel reshaped, Hinzufügen/Entfernen von Definitionen
    passt die Länge der positionsbasierten Wert-Arrays an. Ein nicht castbarer Wert lässt die
    gesamte Änderung fehlschlagen — analog zum Kind-Wechsel einer normalen Spalte.

Werte

  • Neuer Endpunkt für eine bestehende Verknüpfung:
    PUT /tables/{tableId}/columns/{columnId}/rows/{rowId}/link/{linkId}/attributes
  • Zusätzlich können Attribute direkt beim Schreiben des Zellwerts mitkommen: ein Link-Eintrag darf
    statt einer nackten ID ein Objekt { "id": …, "attributes": [...] } sein, ebenso
    { "to": …, "attributes": [...] }.
  • Werte werden positionsbasiert übertragen (attributes[i]linkAttributes[i]), gegen die
    Definition validiert, und date/datetime werden normalisiert (datetime nach UTC).
  • null ist für jeden Kind erlaubt und bedeutet „geleert"; das unterscheidet sich von einem
    komplett fehlenden attributes („nichts gespeichert").

Weiteres Verhalten

  • Historie: Attributwerte landen in den Cell-History-Einträgen; auch der dedizierte
    Attributes-Endpunkt schreibt Historie.
  • Caches: Nach einer Struktur-Änderung werden zusätzlich die Caches der abhängigen Spalten
    invalidiert (Backlink-Seite, Concat, Group-Spalten) — vorher konnten dort veraltete Zellwerte
    weiterausgeliefert werden.
  • Abwärtskompatibilität: Link-Spalten ohne linkAttributes verhalten sich exakt wie bisher;
    weder im Column- noch im Cell-Response taucht ein neues Feld auf. Alle bisherigen
    Request-Formen für Link-Werte bleiben unverändert gültig.
  • Attribute funktionieren auch mit Concat-Werten, Zeilen-Duplizieren und mehreren Identifier-Spalten
    in der Zieltabelle.

Persistenz

  • schema_v43: system_link_table.attributes (Definition) sowie eine attributes-Spalte auf jeder
    link_table_<linkId> (Werte). Bestehende Link-Tabellen werden per Migration nachgezogen, neue
    bekommen die Spalte direkt beim Anlegen.

Rollout-Grenzen (absichtlich, vorläufig)

Zwei Grenzen sind Rollout-Entscheidungen, keine strukturellen Limits — der Code dahinter existiert
und funktioniert, das Frontend ist nur noch nicht bereit dafür:

  • Max. 1 Definition pro Link-Spalte (400, error.json.linkAttributes).
  • multilanguage: true wird abgelehnt (400, error.json.linkAttributes).

Beide sind über einen Test-Seam abschaltbar, damit die N-Definitions- und Multilanguage-Pfade nicht
zu unerreichbarem Code werden; über HTTP verhalten sie sich wie Konstanten. Tests decken sowohl das
Verhalten mit gesetzten Grenzen als auch die Pfade dahinter ab.

Dokumentation

  • Swagger ist für alle neuen/geänderten Felder und den neuen Endpunkt ergänzt.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Test Results

0 tests  ±0   0 ✅ ±0   0s ⏱️ ±0s
0 suites ±0   0 💤 ±0 
0 files   ±0   0 ❌ ±0 

Results for commit c72fa37. ± Comparison against base commit 11f54d3.

♻️ This comment has been updated with latest results.

@McHunkyTrunk McHunkyTrunk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Von der Beschreibung / Architektur her klingt alles sehr plausibel. Opus-5-Review im Anhang.

REVIEW_GRUD_DEV-1199_link-attributes.MD

Base automatically changed from remove-legacy-json-wrapper to master August 12, 2026 09:05
@smnhgn
smnhgn force-pushed the GRUD_DEV-1199/link-attributes branch from 55ea0c1 to 1dd1c9c Compare August 17, 2026 11:47
@smnhgn
smnhgn force-pushed the GRUD_DEV-1199/link-attributes branch from 1143e53 to 87816a8 Compare August 17, 2026 12:51
smnhgn added 14 commits August 17, 2026 15:08
## 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.<name>}}` 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.
## 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.
@McHunkyTrunk

Copy link
Copy Markdown
Contributor

@smnhgn Opus5-Update.

REVIEW_GRUD_DEV-1199_link-attributes.MD

@McHunkyTrunk
McHunkyTrunk self-requested a review September 2, 2026 12:25
@smnhgn
smnhgn requested a review from hermann-p September 11, 2026 14:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants