enhancement
major
minor
defect
critical
major
minor
major
#30031
The database does not enforce uniqueness of reference values without duplicates: a unique index on the link tables, separate link tables for bags
Problem
By default, the links for all references with link storage (SetStorage, ListStorage with and without isBag()) are stored in the same tables, e.g., hasWrapperAttValue (LinkStorage.LinkStorageConfig#getTable(), selection in LinkStorage.defaultConfig()). Their indexes are not unique (com.top_logic.element/src/main/webapp/WEB-INF/kbase/ElementMeta.xml, hasWrapperAttValueBaseAssociation): {{{#!xml <mo_index name="source" unique="false"> source, metaAttribute </mo_index> <mo_index name="dest" unique="false"> dest, metaAttribute </mo_index> }}} The annotation <key-attributes attributes="source,dest,metaAttribute"/> is purely metadata and is not enforced by the database.
Consequently, the database cannot prevent a reference without duplicates (TLStructuredTypePart#isBag() = false) from containing the same value multiple times. Uniqueness depends solely on the storage code. Prior to #29975, ListStorage did not enforce this: calling tAdd() on a value already present created a second link ([a, b, a]). Such data may exist in existing databases. Accesses based on the assumption of uniqueness—such as the target index behind TLObject#tContains() (a BidiMap target → link)—return incorrect results for such data.
Solution
In applications, bags are the rare exception; almost all references are unique. Therefore, the existing link tables become unique, and bags get their own tables.
Unique Index, Dedicated Tables for Bags
- The source index of hasWrapperAttValueBaseAssociation, hasHistoricValue, and hasMixedValue is unique on (source, metaAttribute, dest). Thus, it applies to all link tables derived from them (including the unversioned ones, the composition tables, and the applications’ link tables). For historical/mixed references, it covers the entire target, including the target revision (DEST_REV), since “X currently” and “X in revision 5” are different values. Branch and REV_MAX automatically supplement the index strategy (DefaultIndexColumnsStrategy) so that the index applies only to the current rows of a branch.
- Bag tables with the previous, non-unique indexes: hasWrapperAttValueBag, hasWrapperDeleteRefererBag, hasWrapperVetoBag, hasHistoricValueBag, hasMixedValueBag, each also ...Unversioned; shared abstract base ` hasWrapperAttValueBagBaseAssociation`. Composite references do not require one: ` uniqueParent ` already prevents a part from being included twice.
- LinkStorage.defaultConfig() additionally selects the table based on isBag(). If a bag reference is explicitly stored (<storage table="..."/>) in a table with a unique index, LinkStorage.init() reports an error in the log.
- The knowledge base writes a commit in the order of deletions, modifications, and insertions across all tables (DefaultDBContext.commitTransaction()); replication, import, and ChangeSet all run under the same commit. Therefore, deleting and reinserting the same value never temporarily violates the index. A link created twice without being stored causes the commit to fail with a KnowledgeBaseException.
Changing the Storage of a Reference
A reference’s table depends on ` bag`, `deletionPolicy `, and `historyType`, while the storage type depends on ` multiple ` and `ordered`.
- In the running system (automatic model upgrade at startup from *.model.xml, module import in the interface, AI model editor): If any of these properties of a persistent reference changes, its storage is recalculated within the same transaction (PersistentEnd, ReferenceStorageChange), and the existing values are read from the old storage and written to the new one. Parts of a composition are preserved in the process. When switching to a reference without duplicates, the first occurrence of a value is retained; when switching from historical to current, a value is mapped to its current object; when switching to a single-value reference, the first value is retained (with a warning in the log). A change to `composite` is not adjusted. Previously, the cached storage of a reference was only recalculated when its annotations changed, so such changes took effect only after a restart, and the values were then no longer visible.
- In a migration (ApplyModelPatch with migration processors): A change to a reference’s ` bag ` or ` deletionPolicy ` with default storage generates `<move-links>` in the new table; when switching to a reference without duplicates, it generates `<remove-duplicates>` beforehand. A warning is issued if an explicit table is used, if there are overrides, or if the ` historyType ` is changed simultaneously.
Migration Processors
- <align-indexes> (new): aligns the indexes of the selected tables with the application schema (creates missing indexes, recreates modified ones, and deletes obsolete ones). Automatic schema migration does not create indexes for existing tables.
- Table selection: tables, subtypes-of, exclude for <align-indexes> and <remove-duplicates>.
- <remove-duplicates>: Duplicates are detected per attribute (reference column) and, if applicable, per target revision (previously, identical values of different attributes of the same object were grouped together); Fixed an error in the overlap check following a deleted row; `skip-bags` option; candidates are pre-determined using `GROUP BY ... HAVING COUNT(DISTINCT IDENTIFIER) > 1 `, so that tables without duplicates are not processed.
- <move-links-by-bag> (new): moves the links of all bag references (or all non-bag references) between configured table pairs.
- <move-links> invalidates the revision indexes of the involved tables.
Migration
The Ticket_30031_unique_link_tables (tl-element) migration runs automatically:
- The bag tables are created.
- The links of all Bag references are moved from the standard tables to the Bag tables.
- In the standard tables (including the application tables derived from them), duplicates (same source, metaAttribute, and dest in overlapping revisions) are cleaned up across the entire history: The first link remains; the others are deleted or their validity start date is shortened. The migration logs every cleaned-up link.
- The “source” index of these tables is replaced by the unique index.
Applications with their own link tables derived from ` hasWrapperAttValueBaseAssociation ` inherit the unique index. If Bag references are stored in them (explicitly via `<storage table="..."/>`), their links are not cleaned up; the migration reports them as errors, and creating the index fails if duplicates are found. Such references must be reconfigured to a bag table (derived from ` hasWrapperAttValueBagBaseAssociation`) and their links moved using `<move-links> `.
Java API: ListStorage.listConfig(...), SetStorage.setConfig(...), and ImageGalleryStorage.imageGalleryConfig(...) have an additional parameter, a boolean `bag ` (previous behavior: ` false`; `true` for a bag reference).
Test
- TestLinkTableUniqueness: A duplicate link created outside the storage process fails on commit, but succeeds in the bag table; table selection; deletion and reinsertion in a single commit; historical reference to the same object in two revisions.
- TestUniqueLinkTablesMigration: the migration script on a database with the old layout, containing bag links, duplicates (fully and partially overlapping), and historical values.
- TestAlignIndexesProcessor, TestRemoveDuplicatesInLinkTables, TestMoveLinksProcessor: the migration processors.
- TestModelPatch: Model patches that modify `bag ` or ` deletionPolicy ` and move the links.
- TestReferenceStorageChange: Modification of `bag`, `deletionPolicy`, `historyType`, and `ordered` on a reference with values in the running system, composition, and rollback.
- TestListStorage and TestSetStorage run unchanged.