Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/docs/intent-dsl-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Loaded on demand, not on every session: read this before changing a DSL construc

**Expand/contract on a live table (`renamedFrom:` / `dropped:`, [#7635](https://github.com/eclipse-dirigible/dirigible/issues/7635)):** a publish no longer drops a column its definition stops declaring - `TableAlterProcessor` keeps it with its data in every tenant, WARNs naming the owning artefact, relaxes a kept `NOT NULL` (dialect `dropNotNull`, `null` on MySQL/MariaDB/MSSQL/HANA, where it would need the whole type: then only the WARN), and counts it as the `orphanColumns` detail of the `artefacts` health component (`PlatformReadiness.recordOrphanColumns`, per catalog.schema.table so tenants do not overwrite each other). A field's `renamedFrom: <old name>` becomes the `.model` property's `dataRenamedFrom` (the old COLUMN, derived like `dataName`) and the schema column's `renamedFrom`, and the alter renames the live column in place before the ADD pass (dialect `renameColumn`; on MSSQL/MongoDB it returns `null` and the ADD pass adds the column nullable, copies the values and keeps the old one); an entity's `dropped: [names]` becomes `dataDropped` (comma-separated - it must survive the scalar-only `.edm`) and the table's `dropped`, the only way besides the dev opt-in `DIRIGIBLE_DATABASE_DROP_UNDECLARED_COLUMNS=true` that a column leaves. Both act only while there is something to do, so they are harmless to leave in the intent. The parser refuses either one naming a column the entity still declares (compared by column, so a relation counts), a rename from its own name or from a dropped name, and two renames from one name. Unique constraints are still reconciled by drop - they hold no data.

**A field's label, and a label the TENANT'S COUNTRY resolves (`label:` / `countryLabels:`, [#6424](https://github.com/eclipse-dirigible/dirigible/issues/6424)):** a field may now declare its display `label:` - emitted as the property's own `widgetLabel`, which every generated surface renders and the en-US catalog is seeded from, so an acronym or a unit (`nationalId` as "National ID", not the humanized "National Id") is expressed in the intent instead of hand-edited into a catalog the next Generate overwrites. Alongside it, `countryLabels: { BG: ЕГН, DE: Steuer-ID }` declares variants resolved from the **tenant's country** (`DIRIGIBLE_APPLICATION_COUNTRY`, ISO 3166-1 alpha-2, tenant-overridable in the application shell's Tenant Configuration) rather than from the UI language - which term a national identifier goes by is a property of the company, so keying it off the language catalogs is wrong in both directions at once (the Bulgarian-reading user of a German company gets the local term; the English-reading accountant of a Bulgarian one gets the generic one). It also cannot live in a catalog mechanically: the shared `i18n.js` does not load catalogs at all in the default language. So the variants travel as a language-independent overlay - a structured `widgetCountryLabels` on the property (Map, hence `.model`-only), flattened at UI generation into the `countryLabels` object `config.js` carries, keyed by the very translation key the views bind, which `T()` consults ahead of both i18next and the baked fallback in every language. An app declaring no variant issues no extra request and generates byte-identically. A key that is not a country is refused at parse time (it could never match a tenant); report column labels are deliberately out of scope, a column alias being its SQL alias too.
**A field's label, and a label the TENANT'S COUNTRY resolves (`label:` / `countryLabels:`, [#6424](https://github.com/eclipse-dirigible/dirigible/issues/6424)):** a field may now declare its display `label:` - emitted as the property's own `widgetLabel`, which every generated surface renders and the en-US catalog is seeded from, so an acronym or a unit (`nationalId` as "National ID", not the humanized "National Id") is expressed in the intent instead of hand-edited into a catalog the next Generate overwrites. Alongside it, `countryLabels: { BG: ЕГН, DE: Steuer-ID }` declares variants resolved from the **tenant's country** (`DIRIGIBLE_APPLICATION_COUNTRY`, ISO 3166-1 alpha-2, tenant-overridable in the application shell's Tenant Configuration) rather than from the UI language - which term a national identifier goes by is a property of the company, so keying it off the language catalogs is wrong in both directions at once (the Bulgarian-reading user of a German company gets the local term; the English-reading accountant of a Bulgarian one gets the generic one). It also cannot live in a catalog mechanically: the shared `i18n.js` does not load catalogs at all in the default language. So the variants travel as a language-independent overlay - a structured `widgetCountryLabels` on the property (Map, hence `.model`-only), flattened at UI generation into the `countryLabels` object `config.js` carries, keyed by the very translation key the views bind, which `T()` consults ahead of both i18next and the baked fallback in every language. An app declaring no variant issues no extra request and generates byte-identically. A key that is not a country is refused at parse time (it could never match a tenant); report column labels are deliberately out of scope, a column alias being its SQL alias too. **A to-one relation takes the same two keys ([#7650](https://github.com/eclipse-dirigible/dirigible/issues/7650)):** the caption of a picker is the same problem one construct over - the relation carries the model's identifier, so `Year.company` rendered as "Company" with no way to say "Issuing company" in the intent, and the English caption had to be hand-edited into a catalog the next Generate overwrites. The keys are emitted onto the relation's own FK property, which is where every surface already reads its label, so nothing downstream changed; a `oneToMany` is refused, having no control of its own to caption.

**Naming a rendered document (`fileName:`, [#6899](https://github.com/eclipse-dirigible/dirigible/issues/6899)):** the two server-side PDF renders — the snapshot copy a document mints on issue and the `attach: print` PDF a `notify` block sends — accept a **`fileName:`** pattern on the `function: Snapshot` child / inside the notify block: literals plus `{token}` interpolations, no expression language. `{field}` and one-hop `{relation.field}` use the same path vocabulary and **authored names** a notify subject does, `{field:pattern}` formats a `date`/`timestamp` through a `DateTimeFormatter` pattern, `{A|B}` renders the first non-blank operand, `{Version}` (snapshot only) places the copy's version and a pattern without it gets `_v<n>` appended. Interpolated **values** are sanitized at run time by `sdk.print.FileNames` (trim, whitespace → one `_`, path/control characters stripped, non-ASCII deliberately kept); the literal separators are the author's. A pattern's relation loads are **merged into the notify block's own**, deduped by local, so a name may read a relation the message never mentions — but `attach: recordPrint` renders the anchor once, before the per-row loop, so only fields of the anchor are readable there. Absent a pattern, the snapshot default **changes** from the old primary-key form (`Order 42 v1.pdf`) to the same number-or-id expression the mail already used, plus the version — the deliberate fix for the two names disagreeing. An unresolvable path, an unbalanced brace, a format on a non-date field or a pattern that interpolates nothing are validation errors. Details in the engine-intent guide's fileName bullet.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1342,10 +1342,34 @@ private static List<Map<String, Object>> applyOrder(List<Map<String, Object>> pr
* @param field the authored field
* @return the variants by country code, empty when none are declared
*/
/**
* The authored caption of the control a RELATION renders (#7650) - the picker, its list column and
* its details row - plus its country variants, written exactly as a field's are (#6424) so every
* generated surface and the en-US catalog read one attribute whatever the property came from.
* Absent, the pipeline derives the humanized relation name as before.
*
* @param property the property map being built
* @param relation the relation it was built from
*/
private static void putRelationLabel(Map<String, Object> property, RelationIntent relation) {
if (notBlank(relation.getLabel())) {
property.put("widgetLabel", relation.getLabel()
.trim());
}
Map<String, String> variants = countryLabels(relation.getCountryLabels());
if (!variants.isEmpty()) {
property.put("widgetCountryLabels", variants);
}
}

private static Map<String, String> countryLabels(FieldIntent field) {
return countryLabels(field.getCountryLabels());
}

/** The canonical country-variant map - keys upper-cased, blank variants dropped. */
private static Map<String, String> countryLabels(Map<String, String> authored) {
Map<String, String> canonical = new LinkedHashMap<>();
for (Map.Entry<String, String> variant : field.getCountryLabels()
.entrySet()) {
for (Map.Entry<String, String> variant : (authored == null ? Map.<String, String>of() : authored).entrySet()) {
if (variant.getKey() == null || !notBlank(variant.getValue())) {
continue;
}
Expand Down Expand Up @@ -1685,6 +1709,7 @@ private static Map<String, Object> relationProperty(String ownerEntity, Relation
boolean oneToOne = "oneToOne".equals(relation.getKind());
Map<String, Object> p = new LinkedHashMap<>();
p.put("name", IntentNaming.pascalCase(relation.getName()));
putRelationLabel(p, relation);
p.put("description", relation.getDescription() == null ? "" : relation.getDescription());
p.put("tooltip", "");
p.put("dataName", column);
Expand Down Expand Up @@ -1767,6 +1792,7 @@ private static Map<String, Object> subsetProperty(String ownerEntity, RelationIn
String targetPerspective) {
Map<String, Object> p = new LinkedHashMap<>();
p.put("name", IntentNaming.pascalCase(relation.getName()));
putRelationLabel(p, relation);
p.put("description", relation.getDescription() == null ? "" : relation.getDescription());
p.put("tooltip", "");
p.put("dataName", IntentNaming.upperSnake(ownerEntity) + "_" + IntentNaming.upperSnake(relation.getName()));
Expand Down Expand Up @@ -1809,6 +1835,7 @@ private static Map<String, Object> crossModelRelationProperty(String ownerEntity
boolean oneToOne = "oneToOne".equals(relation.getKind());
Map<String, Object> p = new LinkedHashMap<>();
p.put("name", IntentNaming.pascalCase(relation.getName()));
putRelationLabel(p, relation);
p.put("description", relation.getDescription() == null ? "" : relation.getDescription());
p.put("tooltip", "");
p.put("dataName", column);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,27 @@ public class RelationIntent {
* {@code grid-column: span N}. Absent (the default) leaves it unset (the form falls back to half
* width). Use a small span (e.g. 4) to pack several short dropdowns onto one row.
*/
/**
* Optional display label for the PICKER this relation renders as, replacing the humanized relation
* name in every generated surface - the form control's caption, the list column header and the
* details row - and seeding its en-US catalog entry (issue #7650).
*
* <p>
* The same key a field carries (#6424), for the same reason: the humanized name is derived from an
* identifier the author chose for the MODEL, and a picklist relation that cannot be called after
* the thing it picks (because a stored column already owns that name) renders as the identifier -
* {@code VatGroundPick} as "Vat Ground Pick". The generated en-US catalog is rewritten on every
* Generate, so there is nowhere else to author the English caption.
*/
private String label;

/**
* Optional label variants keyed by ISO 3166-1 alpha-2 <b>country</b> code (issue #7650), exactly as
* on a field (#6424): what a picker is called may follow the tenant's country rather than the
* reader's language, so the variant overrides {@link #label} in EVERY language.
*/
private java.util.Map<String, String> countryLabels = new java.util.LinkedHashMap<>();

private Integer size;
/**
* Whether this to-one relation shows as a column in the entity's list / document-items table
Expand Down Expand Up @@ -320,6 +341,22 @@ public void setSize(Integer size) {
this.size = size;
}

public String getLabel() {
return label;
}

public void setLabel(String label) {
this.label = label;
}

public java.util.Map<String, String> getCountryLabels() {
return countryLabels;
}

public void setCountryLabels(java.util.Map<String, String> countryLabels) {
this.countryLabels = countryLabels == null ? new java.util.LinkedHashMap<>() : countryLabels;
}

public boolean isMajor() {
return major;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4337,6 +4337,11 @@ private static Set<String> validateEntities(IntentModel model, Set<String> usesA
// checks below (composition, cross-model, dependsOn, leafOnly, personal/partner).
if ("subset".equals(relation.getKind())) {
validateSubset(entity, relation, entityNames, byName, issues);
if (relation.getLabel() != null || !relation.getCountryLabels()
.isEmpty()) {
validateLabels("entity [" + entity.getName() + "] relation [" + relation.getName() + "]", relation.getLabel(),
relation.getCountryLabels(), issues);
}
continue;
}
// ManyToManyExpander consumed every n:m before this ran, so a surviving manyToMany is one
Expand Down Expand Up @@ -4403,6 +4408,19 @@ private static Set<String> validateEntities(IntentModel model, Set<String> usesA
validateDependsOn(entity, subject, relation.getDependsOn(), relation, byName, issues);
}
}
// The picker's caption (#7650) - a to-one FK property or a subset's multiselect column.
// A collection relation emits no property at all (the FK lives on the child), so a label
// authored there would be carried nowhere: refused rather than silently dropped.
if (relation.getLabel() != null || !relation.getCountryLabels()
.isEmpty()) {
String labelSubject = "entity [" + entity.getName() + "] relation [" + relation.getName() + "]";
if ("oneToMany".equals(relation.getKind())) {
issues.add(labelSubject + " declares a `label`, but a collection relation renders no control of its own -"
+ " label the field or relation the generated page actually shows");
} else {
validateLabels(labelSubject, relation.getLabel(), relation.getCountryLabels(), issues);
}
}
if (relation.getWhere() != null) {
validateWhere(entity, relation, byName, issues);
}
Expand Down Expand Up @@ -6887,12 +6905,21 @@ private static void validateFormat(String subject, FieldIntent field, List<Strin
* match any tenant, so it is refused here rather than silently rendering the base label forever.
*/
private static void validateLabels(String subject, FieldIntent field, List<String> issues) {
if (field.getLabel() != null && field.getLabel()
.isBlank()) {
issues.add(subject + " declares a blank `label` - remove it to keep the humanized field name");
validateLabels(subject, field.getLabel(), field.getCountryLabels(), issues);
}

/**
* The same rule for a RELATION's picker caption (#7650): a relation renders a control of its own -
* the picker, its list column and its details row - and the humanized relation name is as wrong
* there as a humanized field name is, for the same reason (the identifier was chosen for the model,
* and a picklist that cannot be named after what it picks reads as the identifier).
*/
private static void validateLabels(String subject, String label, java.util.Map<String, String> countryLabels, List<String> issues) {
if (label != null && label.isBlank()) {
issues.add(subject + " declares a blank `label` - remove it to keep the humanized name");
}
for (java.util.Map.Entry<String, String> variant : field.getCountryLabels()
.entrySet()) {
for (java.util.Map.Entry<String, String> variant : (countryLabels == null ? java.util.Map.<String, String>of()
: countryLabels).entrySet()) {
String country = variant.getKey() == null ? ""
: variant.getKey()
.trim()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,10 @@ private static EntityIntent linkEntity(String linkName, EntityIntent owner, Rela
toTarget.setSize(relation.getSize());
toTarget.setMajor(relation.isMajor());
toTarget.setLeafOnly(relation.isLeafOnly());
// The caption belongs to the control the author sees, which after the expansion is the LINK's
// target picker - the declaring side becomes a navigation-only collection that renders none.
toTarget.setLabel(relation.getLabel());
toTarget.setCountryLabels(relation.getCountryLabels());
link.getRelations()
.add(toOwner);
link.getRelations()
Expand Down Expand Up @@ -299,6 +303,8 @@ private static void rewriteToNavigation(RelationIntent relation, String linkName
relation.setShow(null);
relation.setSize(null);
relation.setLeafOnly(false);
relation.setLabel(null);
relation.setCountryLabels(null);
}

private static boolean isBlank(String value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,13 @@ field may declare:
still resolve. Not allowed on a composition parent (preset, never picked) or an `EntityStatus`.
Canonical shape - a stock line's Product picker excluding services:
`- { name: Product, kind: manyToOne, to: Product, where: { Type: 1 } }`
- `label: <text>` / `countryLabels: { <ISO 3166-1 alpha-2>: <text> }` (on a to-one relation or a
`subset`, #7650) - **the picker's caption**, exactly as the field keys above: the relation is named
for the model, so the chooser, its list column and its details row all read as that identifier
until it is labelled. It rides on the relation's own FK property, so it is translated and
country-resolved like any field label. A collection relation (`oneToMany`) renders no control of
its own and is refused - label the field or relation the generated page actually shows.
`- { name: issuer, kind: manyToOne, to: Company, label: Issuing company }`
- `pickable: { when: [<target property> != null, ...], else: mark|hide, message: <text> }` on a
manyToOne/oneToOne (#7496) - **a picker rule over the TARGET's rows**: this is how "a customer
with incomplete registration data cannot be picked onto an invoice" is declared, so the clerk
Expand Down
Loading
Loading