minor
#29793
Developer docs: recipe for an application-defined primitive type whose display depends on the owning object (custom datatype, value class, type-level storage, label provider, input control)
The developer documentation (tl-docs) has no article on defining an application-specific primitive model type. Doing it right needs knowledge spread over several engine areas (model datatypes, storage mappings, attribute storage, annotations, label providers, the React field control chain), and the obvious first attempts lead to inconsistent displays. Please add a recipe article, e.g. model/custom-primitive-type.
The abstract problem
An attribute holds a simple value that is stored as a plain database value (a number, a string), but must be displayed and entered in a domain notation - and the notation is not a property of the value alone but of its context, e.g. of the project, mandator or container the owning object belongs to. The requirements:
- The database keeps the plain value (no schema/data change, queries and sorting on the raw value keep working).
- The model defines the presentation once, on the type; every attribute of the type, every view (form field, read-only display, table cell, search/filter/export text, label) shows the value the same way, without per-view configuration.
- Where the notation comes from is declared in the model, per attribute, not hard-coded in Java (no "walk up from the object to the project" in a control).
- Input is parsed in the notation of the context, with validation.
Concrete case (E-Home application, TL 8.0.0-alpha10): KNX group addresses are 16-bit numbers; an ETS project chooses whether they are written main/middle/sub (8/0/9), main/sub (8/9) or as the plain number (16393). Before, the address was a tl.core:Integer and displayed as "16.393".
Why the obvious approaches fail
- A datatype with a <format>/`StorageMapping` only sees the value: StorageMapping.getBusinessObject(Object) has no owner, LabelProvider.getLabel(Object) has no owner, the read-only display path (FieldControlService.createDisplayControl, CellRenderer.render(value) of table columns) gets value (and attribute) but no row object. A context-dependent format cannot be applied there.
- A custom input control alone (ReactFieldControlProvider reading AttributeFieldModel.getObject()) covers forms and editable cells only; tables, labels and search texts stay raw - the display becomes inconsistent.
The approach that works
The context is attached to the value when it is read, at the one place that knows the owner - the attribute storage - so the value becomes self-describing and every value-only display path can render it:
- Value class (GA): wraps the raw value plus the context-derived notation; equals/`hashCode`/`compareTo` on the raw value only, toString() is the display text (the React state serializer falls back to toString() for unknown objects).
- Datatype in the model: <datatype name="GA" db_type="int" kind="Custom"> with a custom StorageMapping (getApplicationType() = value class; storage ↔ value without context; getStorageObject/`isCompatible` also accept the raw type so imports and scripts can keep writing plain numbers).
- Storage on the type: <annotations><storage-algorithm><group-address-storage/></storage-algorithm></annotations> on the datatype. TLStorage is also a type annotation; StorageImplementationFactory.visitProperty uses the property type's storage annotation when the attribute declares none - so every attribute of the type gets it automatically. The storage subclasses PrimitiveStorage and overrides getAttributeValue(TLObject, TLStructuredTypePart) to attach the context (ga.withStyle(style(object))).
- Context source as attribute annotation: an application TLAttributeAnnotation with @TagName("ga-style") and an Expr property (<ga-style><style>ga -> recursion($ga, x -> $x.container()).filter(x -> $x.instanceOf(…:Project`)).firstElement().get(…:Project#groupAddressStyle)</style></ga-style>`). The storage compiles it once in init(attribute) (QueryExecutor.compile, disableSecurity() - the notation is model data, not subject to the reader's rights). No annotation / no result → a default notation. The annotation is found via the TypeIndex (build processor), no registration needed.
- Label provider for the value class, registered with kind="CODE" in LabelProviderService → table cells, search/filter/export texts and labels.
- Input control on the datatype: <input-control><impl class="…ControlProvider"/></input-control> (TLInputControl has DefaultStrategy.VALUE_TYPE, so it applies to all attributes of the type). The control subclasses ReactTextInputControl and overrides applyRawClientValue to parse in the notation of the current value; for an empty value it asks the attribute's storage for the context (AttributeFieldModel → AttributeOperations.getStorageImplementation(part)), and if the object has no context yet (a new object in a create dialog), it accepts any notation. Parse errors go to AbstractFieldModel.setError.
Points the recipe should also mention
- Changing an existing attribute's type to the new datatype: the incremental model upgrade logs rename/delete of the old part and creation of a new one (and proposes migration processors). In this case (flex attribute, same value type) the stored values survived; the article should state when they do and recommend an explicit migration (update-property with the new type) for production data.
- Context changes are not observed: a form showing such a value is not refreshed when only the context object (e.g. the project's notation) changes; it shows the new notation after it is rebuilt.
- The messages_en.properties entries of new I18NConstants/config classes are generated after the test phase; a first build with tests can fail TestI18NConstantsTranslations until a build without tests has run.
- Tests: verify the label path with LabelProviderService.getLabelProvider(value) (it returns a LabelResourceProvider adapter wrapping the registered provider) and the storage path by reading the attribute in a KB test.
Reference implementation: E-Home de.haumacher.ehome.knx (GA, GAStorageMapping, GroupAddressStorage, GAStyle, GALabelProvider, GAControlProvider) and ehome.logical.model.xml (datatype GA, GroupAddress#address).
Lösung
Article model/custom-primitive-type (chapter model, shipped by com.top_logic.layout.view, visible via the tl-mcp read_doc tool and the Documentation window of a React application): the problem and requirements, why a format/`StorageMapping` or an input control alone fail, and the six steps above with quoted code. Further sections: asking the storage of the attribute instead of the object (getStorageImplementation(object, part) returns TransientStorage for an object in a create dialog), the policy for an object without context, typed text not echoed back converted while editing, context changes (a <table> follows them when it lists the context type in observed-types, a form only after it is displayed again), access grants, changing an existing attribute to the type (what the startup upgrade does, when values survive, an explicit update-property with new-type for production data), the I18N/test build order, and tests.
Worked example in the React demo com.top_logic.demo.react (Demos → Temperatures), which the article quotes: model module demo.temperature with datatype Temperature (stored as double in °C), DemoSite with a temperatureUnit (°C / °F / K) composing DemoSensor`s with `minTemperature/`maxTemperature`. Java in com.top_logic.demo.react.temperature: value class Temperature, TemperatureUnit, TemperatureMapping (<temperature-mapping/>), TemperatureStorage (<temperature-storage/>), annotation <temperature-unit unit="s -> $s.container().get(...)"/>, TemperatureLabelProvider, TemperatureControlProvider/`TemperatureInputControl`. Seed data with sites in each unit, a site/sensor master-detail view and a "New Sensor" create dialog, access grants for the users group.