Zum Inhalt

ctx.model — Datenmodell im Skript

ctx.form enthält die Werte eines Datensatzes — was jemand ins Formular eingetragen hat. ctx.model beschreibt den Bauplan dahinter: wie das Formular aufgebaut ist, wie die zugehörige Datenbanktabelle heißt, welche Spalten es gibt und welche Detail-Tabellen daran hängen.

ctx.form ctx.model
Antwortet auf „Was steht drin?" „Wie ist das gebaut?"
Beispiel ctx.form.betrag → 250 ctx.model.primary.table → "form_urlaubsantrag"
Änderbar ja nein, nur lesen

Wozu das gut ist. Wer in einem Skript eine eigene Datenbankabfrage schreibt, braucht den Tabellennamen. Den kann man hinschreiben — oder erfragen:

// tippt den Namen fest ins Skript
db.query("SELECT COUNT(*) AS n FROM form_urlaubsantrag");

// fragt ihn beim Formular ab
db.query("SELECT COUNT(*) AS n FROM " + ctx.model.primary.table);

Beide Zeilen tun dasselbe — bis jemand das Formular umbenennt oder das Skript in ein anderes Formular kopiert. Dann läuft die zweite weiter und die erste nicht mehr. Aus demselben Grund kann ein Skript fragen, welche Felder es überhaupt gibt, statt die Liste im Code zu wiederholen.

Der Aufbau auf einen Blick

flowchart TD
  M["ctx.model"] --> P["primary<br/><i>das Formular selbst</i>"]
  M --> C["children<br/><i>Detail-Tabellen</i>"]
  M --> L["lookups<br/><i>Auswahllisten</i>"]
  P --> PE["elements<br/><i>die Felder</i>"]
  C --> CE["elements + rows<br/><i>Spalten und Zeilen</i>"]
  PE --> D["def<br/><i>Sicht des Formulars</i>"]
  PE --> DB["column<br/><i>Sicht der Datenbank</i>"]
Zugriff Was du bekommst
ctx.model.primary das Formular selbst — Tabellenname und seine Felder
ctx.model.children die Detail-Tabellen (kc:childTable), samt ihrer Zeilen
ctx.model.lookups die Auswahllisten (kc:lookup), die das Formular benutzt
ctx.model.get("name") eine Detail-Tabelle direkt beim Namen — dasselbe wie children.get("name")

Dieselbe Struktur kannst du dir im Formular-Editor ansehen: Das Panel Modell zeigt Haupt- und Detail-Tabellen mit ihren Spalten und gleicht sie laufend mit der Datenbank ab. Was dort steht, liest ctx.model im Skript.

Das Panel „Modell" im Formular-Editor: Haupttabelle mit Spalten, darunter die Detail-Tabelle

Läuft ein Skript ohne Formular — etwa frei in der Skript-Konsole —, gibt es keinen Bauplan: ctx.model ist dann null. Deshalb steht in Skripten, die an beiden Orten laufen sollen, eine Absicherung davor:

if (ctx.model) {
  // hier ist der Bauplan verfügbar
}

primary — das Formular selbst

Hier steht, wohin die ausgefüllten Formulare gespeichert werden und welche Felder es gibt:

var p = ctx.model.primary;

log.info(p.id);          // "urlaubsantrag"   (= ctx.formId)
log.info(p.table);       // "form_urlaubsantrag"
log.info(p.fqTable);     // "kcforms.form_urlaubsantrag"
log.info(p.kind);        // "primary"
log.info(p.elements.length);
Eigenschaft Inhalt
id der Kurzname des Formulars, also dasselbe wie ctx.formId
table die Tabelle, in der die Datensätze liegen (form_urlaubsantrag)
fqTable dieselbe Tabelle mit Datenbanknamen davor — für Abfragen über mehrere Datenbanken
elements die Felder des Formulars — siehe gleich
kind woher dieser Eintrag stammt: "primary", "child" oder "lookup"
parentField nur bei einer Detail-Tabelle: an welchem Feld des Formulars sie hängt
filter / order / select Platzhalter für Einschränkung, Sortierung und eigene Abfrage; beim Formular selbst heute immer leer

elements — die Felder, in zwei Sichten

Ein Feld hat zwei Gesichter, und beide sind über ctx.model zu sehen:

  • def — wie das Feld im Formular gedacht ist: Beschriftung, Typ, Pflichtangabe, alle Einstellungen aus dem Formular-Editor.
  • column — wie es in der Datenbank tatsächlich angelegt wurde: Spaltenname, Spaltentyp, ob die Spalte leer bleiben darf.

Meist decken sich beide. Auseinander laufen sie dort, wo Formular und Datenbank verschiedene Dinge meinen: Eine Überschrift oder eine Schaltfläche ist ein Element des Formulars, aber keine Spalte. Und ein Feld kann in der Datenbank anders heißen, wenn das Datenmodell es so vorgibt (siehe kc:column).

flowchart LR
    ED["Formular-Editor<br/>Eigenschaften eines Feldes"] -- "Formular-Definition" --> EL
    DB[("Formular-Tabelle<br/>in der Datenbank")] -- "Spalten-Sicht" --> EL
    EL["Feld (Element)<br/>id · label · type · required"]
    EL --> DEF["def<br/>alle Einstellungen"]
    EL --> COL["column<br/>name, ddl, nullable"]

column kann null sein — prüfe es, bevor du es benutzt

Ohne Spalte gibt es keine DB-Sicht. Das trifft auf drei Fälle zu: Präsentations-Elemente ohne eigenen Wert (Überschrift, Leittext, Schaltfläche), Felder, deren Spalte noch nicht angelegt ist, und den Fall, dass die Tabelle selbst noch nicht existiert — etwa in einem frisch gebauten Formular, das noch nie gespeichert wurde.

Deshalb steht in Schleifen über alle Felder ein if (el.column), sonst bricht das Skript an der ersten Überschrift ab. Die Formular-Sicht def ist dagegen immer vorhanden.

var el = ctx.model.primary.elements.get('wf_status');

log.info(el.id);                  // "wf_status"
log.info(el.label);               // "Workflow-Status"
log.info(el.type);                // "integer"
log.info(el.required);            // true

log.info(el.def['kc:lookup']);    // { id: "STATUS", values: [...] }
log.info(el.def['kc:visibleIf']); // "Number(wf_status) >= 1"

log.info(el.column.name);         // "wf_status"
log.info(el.column.ddl);          // "bigint"
log.info(el.column.nullable);     // true

Ein Feld herausgreifen oder alle durchgehen

Der Zugriff läuft über get(…) — mit einer Zahl für die Position oder mit dem Feldnamen:

var p = ctx.model.primary;

// alle der Reihe nach durchgehen
for (var i = 0; i < p.elements.length; i++) {
    log.debug("[" + i + "] " + p.elements.get(i).id);
}

// ein bestimmtes Feld
var el = p.elements.get('wf_status');

// nur die Namen, in der Reihenfolge des Formulars
p.elements.ids();   // ["genehmiger", "wf_status", "signatur_antragsteller", ...]

// Die rohe Liste, falls du sie weiterreichen willst:
p.elements.list;

Die eckige Klammer und der Punkt gehen ebenfalls — seit die drei Sammlungen das nachgerüstet bekommen haben (vorher lieferten sie dort stillschweigend undefined):

p.elements[0];              // nach Position
p.elements['wf_status'];    // nach Namen
p.elements.wf_status;       // dasselbe, kürzer

Dieselben Zugriffsformen gelten für ctx.model.lookups und ctx.model.children — es ist derselbe Sammlungs-Typ. get(…) funktioniert überall und ist die sicherste Wahl, wenn ein Feldname aus einer Variablen kommt.

Reservierte Namen

list, length, get und ids sind an der Sammlung selbst belegt. Ein Feld, das zufällig so heißt, erreichst du über get("list") — die Kurzschreibweise .list liefert dort die Liste der Sammlung.

Was ein Feld über sich verrät

Zugriff Typ Inhalt
id string Feldname im Formular — der Schlüssel, unter dem das Feld in der Sammlung steht
label string die Beschriftung; hat das Feld keine, steht hier die Feld-ID
type string die Art des Feldes — siehe unten, und nicht einfach der Schema-Typ
required boolean steht das Feld in der Pflichtfeld-Liste des Formulars? Eine bedingte Pflicht (kc:requiredIf) zählt hier nicht mit
def Sammlung alle Einstellungen aus dem Formular-Editor — siehe unten
column Spalte die Datenbank-Spalte oder null — siehe unten

type — was für ein Feld ist das?

el.type beantwortet „was ist das für ein Feld?", nicht „welcher Schema-Typ steht da?". Eine Schaltfläche ist im Schema type: "null" — als Element ist sie ein button. Ein Datum ist im Schema ein string mit format: "date" — als Element ist es ein date. So kannst du über Feldarten gehen, ohne die kc:*-Einstellungen selbst auseinanderzunehmen.

el.type entsteht aus hat eine Spalte?
string, integer, number, boolean, array dem Schema-Typ des Feldes ja
date Text mit format: date ja
datetime Text mit format: date-time ja
email Text mit format: email ja
signature einem Unterschriftsfeld ja
section einer Überschrift nein
text einem Leittext nein
button einer Schaltfläche nein
link einem Link nein
image einem Bild nein
svg einer Grafik nein
spacer einem Platzhalter nein

Damit wird die Feld-Inventur zum Dreizeiler:

// Alle Datumsfelder des Formulars
var p = ctx.model.primary;
var ids = p.elements.ids();
for (var i = 0; i < ids.length; i++) {
    var el = p.elements.get(ids[i]);
    if (el.type === 'date' || el.type === 'datetime') {
        log.info(el.id + ' ist ein Datum (' + el.label + ')');
    }
}

Detail-Tabellen sind keine Felder

Ein Feld, das eine Detail-Tabelle trägt, taucht in primary.elements nicht auf — es ist eine eigene Tabelle und steht unter children. Deren Spalten sind dort wiederum ganz normale elements.

Was in .column steht

Zugriff Inhalt
name Spaltenname in der Tabelle (immer klein geschrieben)
ddl der Spaltentyp, wie die Datenbank ihn meldet: varchar(80), bigint, decimal(18,2), date, timestamp, tinyint(1) …
nullable true, wenn die Spalte leer bleiben darf
defaultValue der Vorgabewert der Spalte. Bei KCForms-Tabellen praktisch immer null — vorbelegt wird über kc:defaultValue im Formular, nicht in der Datenbank

Die Felder folgen dem Formular, nicht der Tabelle

Die technischen Spalten id (Hauptdatensatz) sowie parent_id und lfd_nr (Detail-Tabellen) sind keine Felder — sie stehen im Formular nicht und tauchen deshalb in elements nicht auf. Wer sie braucht, nimmt sie im SQL direkt (WHERE parent_id = ${id}).

Was in .def steht

In def steht genau das, was im Eigenschaften-Bereich des Formular-Editors eingestellt wurde — Feld-ID, Titel und jede gesetzte Einstellung:

Die Eigenschaften eines Feldes im Formular-Editor

// Das Feld aus dem Bild hat im Formular drei Einträge:
//   { "type": "string", "title": "Bestellnummer", "kc:maxLength": 30 }
var d = ctx.model.primary.elements.get('bestellnummer').def;
log.info(d.type);                 // "string"
log.info(d.title);                // "Bestellnummer"
log.info(d['kc:maxLength']);      // 30

Verschachteltes bleibt verschachtelt: def['kc:lookup'].id, def['kc:childTable'].properties und so weiter.

def ist eine Sammlung, kein JavaScript-Objekt

Lesen per Punkt (def.type) und per Klammer (def['kc:maxLength']) geht, for (var k in def) ebenfalls. Nicht funktionieren Object.keys(def) — das liefert die Methoden der zugrunde liegenden Java-Klasse statt der Einstellungen — und JSON.stringify(def), das schlicht {} ergibt. Dieselbe Regel gilt für Datenbank-Zeilen aus db.query und für node.attrs im XML-Kapitel.

lookups — die Auswahllisten des Formulars

Jede Auswahlliste, die irgendein Feld des Formulars benutzt, steht hier einmal — mehrfach verwendete Listen erscheinen nur einmal. So kommst du an die Tabelle dahinter, ohne sie im Skript zu wiederholen. Ansprechen lässt sich das wie bei den Feldern: der Reihe nach oder direkt über den Namen der Liste.

var L = ctx.model.lookups;

log.info("Insgesamt " + L.length + " Auswahllisten");

for (var i = 0; i < L.length; i++) {
    var lk = L.get(i);
    log.info(lk.id + " → " + (lk.table || "(eigene SQL-Abfrage)"));
}

// eine bestimmte Liste beim Namen
var users = L.get('USERS');
log.info(users.table);   // "USERS"
log.info(users.filter);  // die Einschränkung, falls eine gesetzt ist
Eigenschaft Beispiel
id "USERS" — der Name der Liste
table "USERS" — die Tabelle, aus der sie liest
fqTable "kcforms.USERS" — dieselbe Tabelle mit Datenbanknamen
filter die Einschränkung der Liste, falls eine gesetzt ist
order die Sortierung, falls eine gesetzt ist
select bei einer eigenen SQL-Abfrage deren vollständiger Text
kind immer "lookup"

Listen aus einem Skript haben keine Tabelle

Holt eine Auswahlliste ihre Einträge über ein Skript statt aus einer Tabelle, steht in table kein Tabellenname, sondern "script:<name>". Wer diesen Wert ungeprüft in eine SQL-Abfrage einsetzt, baut sich eine kaputte Anweisung. Deshalb vorher nachsehen — so wie es das Beispiel „Auswahlliste direkt abfragen" weiter unten tut.

children — die Detail-Tabellen

Eine Detail-Tabelle ist der Bereich, in dem ein Formular mehrere gleichartige Zeilen sammelt — die Positionen einer Bestellung etwa. Jede davon hat eine eigene Tabelle in der Datenbank, und children führt sie auf. Du erreichst sie über den Feldnamen oder der Reihe nach:

// beim Feldnamen — der kürzeste Weg
var pos = ctx.model.get("positionen");

// dasselbe über den Container
var pos2 = ctx.model.children.get("positionen");

// alle der Reihe nach
for (var i = 0; i < ctx.model.children.length; i++) {
    var ds = ctx.model.children.get(i);
    log.info(ds.id + " → " + ds.table);
}

Eine Detail-Tabelle hat dieselben Angaben wie das Formular selbst, dazu zwei weitere:

pos.kind;          // "child" — zur Unterscheidung vom Formular selbst
pos.parentField;   // "positionen" — an welchem Feld sie hängt
pos.elements;      // ihre Spalten, wieder mit def und column

rows — die Zeilen einer Detail-Tabelle

Bis hierher ging es um den Bauplan. Eine Detail-Tabelle liefert zusätzlich die Zeilen des aktuellen Datensatzes — den Inhalt also. Jede Zeile hat unter elements ihre Werte (.value), und falls an einer Zeile noch eine weitere Tabelle hängt, steht die unter subordinates:

var ds = ctx.model.children.kostenstellen;
for (var i = 0; i < ds.rows.length; i++) {
    var zeile = ds.rows[i];
    log.info(zeile.elements.titel.value);
    var pos = zeile.subordinates.kostenpositionen;   // nächste Ebene
    log.info('  Positionen: ' + pos.rows.length);
}

Das funktioniert gleich für Detail-Tabellen und Section-Datasets.

ctx.model ist zum Lesen da

Du kannst einem Wert unter ….elements.<x>.value etwas zuweisen — nur passiert dann nichts: Die Zuweisung bleibt in der Lese-Sicht hängen, erreicht ctx.form nicht und wird nicht gespeichert. Das ist die unangenehme Sorte Fehler, weil sie keine Meldung erzeugt.

Geändert wird immer über ctx.form, siehe Detail-Tabellen → Skript-Zugriff.

Von einer Zeile aus weiterkommen

Abwärts geht es über rows und subordinates — und wieder hinauf über parent:

ctx.form                                 ← der Hauptdatensatz (keine Zeile!)
└─ ctx.model.children.kostenstellen      Detail-Tabelle
   └─ .rows[0]                           Zeile ← parent = null (oberste Ebene)
      ├─ .elements.titel.value           Wert
      └─ .subordinates.kostenpositionen  Detail-Tabelle ← .parentRow = rows[0]
         └─ .rows[2]                     Zeile ← .parent = rows[0]
            └─ .elements.wert.value      Wert         .index ist 2

Jede Zeile weiß, wo sie steht:

Zugriff Was kommt zurück
zeile.parent die Zeile darüber — null, wenn die Zeile schon auf der obersten Ebene liegt
zeile.index die Position in zeile.dataSet.rows, von 0 an gezählt
zeile.dataSet die Tabelle, in der die Zeile liegt (mit table, elements, …)
dataSet.parentRow umgekehrt: die Zeile, an der eine Detail-Tabelle hängt

Damit muss eine verschachtelte Schleife die äußere Zeile nicht mehr in einer Variablen mitschleppen — und eine Hilfsfunktion, die nur eine Zeile bekommt, kann ihren Platz selbst erfragen:

// Wo steckt diese Position? Hinauf bis zur obersten Zeile.
function herkunft(zeile) {
    var pfad = [];
    for (var r = zeile; r; r = r.parent) {
        pfad.unshift(r.dataSet.parentField + '[' + r.index + ']');
    }
    return pfad.join(' → ');
}

var stellen = ctx.model.children.kostenstellen;
for (var i = 0; i < stellen.rows.length; i++) {
    var positionen = stellen.rows[i].subordinates.kostenpositionen;
    for (var j = 0; j < positionen.rows.length; j++) {
        log.info(herkunft(positionen.rows[j]));
        // → "kostenstellen[0] → kostenpositionen[2]"
    }
}

Und der Griff nach oben, wenn eine Unterzeile etwas von ihrer Mutter braucht:

var positionen = ctx.model.children.kostenstellen.rows[0]
                    .subordinates.kostenpositionen;

for (var i = 0; i < positionen.rows.length; i++) {
    var pos = positionen.rows[i];
    log.info('Position ' + (pos.index + 1)
        + ' der Kostenstelle „' + pos.parent.elements.titel.value + '"'
        + ' (Tabelle ' + pos.dataSet.table + ')');
}

Über der obersten Zeile steht ctx.form, keine Zeile

Der Aufstieg endet bei parent === null. Darüber liegt der Hauptdatensatz — und der gehört nicht zum Zeilen-Baum: ctx.model.primary.rows bleibt immer leer, die Werte des Formularkopfs stehen in ctx.form. ctx.model.primary liefert also den Bauplan des Kopfes, nicht seinen Inhalt.

rows ist eine Liste, kein JavaScript-Array

rows[i] und rows.length funktionieren, rows.map(…), rows.filter(…) und rows.forEach(…) nicht. Deshalb stehen hier überall Zählschleifen. Wer die Array-Methoden braucht, kopiert einmal um: var alle = []; for (var i = 0; i < rows.length; i++) alle.push(rows[i]);

Sieben Muster aus der Praxis

1. Tabellenname erfragen statt zusammenbauen

Der Tabellenname sieht aus wie form_ plus Formular-ID — und genau das baut man dann im Skript nach. Es stimmt nur nicht zuverlässig: Bevor die Tabelle angelegt wird, wird der Name bereinigt. Alles, was kein Buchstabe, keine Ziffer und kein Unterstrich ist, wird zu _; Großbuchstaben werden klein; und beginnt der Name nicht mit einem Buchstaben, kommt x_ davor.

Formular-ID selbst zusammengebaut tatsächliche Tabelle
urlaubsantrag form_urlaubsantrag form_urlaubsantrag — zufällig gleich
model-demo form_model-demo form_model_demo
2026-Budget form_2026-Budget form_x_2026_budget

Bei jeder Formular-ID mit Bindestrich läuft die Handarbeit also ins Leere, und die Datenbank meldet eine Tabelle, die es nicht gibt:

// ✗ zusammengebaut — stimmt nur, wenn die Formular-ID zufällig schon ein
//   gültiger Tabellenname ist
db.update("UPDATE form_" + ctx.formId
    + " SET wf_status = ${s} WHERE id = ${id}",
    { s: 2, id: ctx.instanceId });
// ✓ erfragt — stimmt immer, auch nach einem Umbenennen
db.update("UPDATE " + ctx.model.primary.table
    + " SET wf_status = ${s} WHERE id = ${id}",
    { s: 2, id: ctx.instanceId });

2. Datensätze der eigenen Tabelle zählen

var t = ctx.model.primary.table;
var rows = db.query("SELECT COUNT(*) AS n FROM " + t);
log.info("Datensätze: " + rows[0].n);

3. Eine Detail-Tabelle zusammenrechnen

var pos = ctx.model.get("positionen");
var rows = db.query(
    "SELECT COALESCE(SUM(summe), 0) AS gesamt FROM " + pos.table
    + " WHERE parent_id = ${pid}",
    { pid: ctx.instanceId });
ctx.form.gesamtwert = rows[0].gesamt;

4. Eine Auswahlliste direkt abfragen

var users = ctx.model.lookups.get("USERS");
if (users && users.table) {
    var rows = db.query(
        "SELECT ID_USER AS id_user, LOGIN_NAME AS name, EMAIL AS email "
        + "FROM " + users.table + " WHERE ID_USER = ${id}",
        { id: ctx.form.verantwortlich });
    if (rows.length) ctx.form.verantwortlich_email = rows[0].email;
}

5. Alle Pflichtfelder auflisten — ohne sie zu kennen

var p = ctx.model.primary;
var fehlende = [];
var ids = p.elements.ids();
for (var i = 0; i < ids.length; i++) {
    var el = p.elements.get(ids[i]);
    if (el.required && !ctx.form[el.id]) fehlende.push(el.label);
}
if (fehlende.length) {
    var m = new view.Message("Pflicht-Felder fehlen: " + fehlende.join(", "));
    m.caption = "Eingabe unvollständig";
    view.add(m);
}

6. Ein Skript, das in jedem Formular läuft

// Laeuft in JEDEM Formular — nirgends steht ein fester Name
var m = ctx.model;
log.info("== Audit für " + m.primary.id + " ==");
log.info("Tabelle:        " + m.primary.table + " (" + m.primary.elements.length + " Felder)");
log.info("Detail-Tabellen: " + m.children.length);
log.info("Auswahllisten:   " + m.lookups.length);

var stats = db.query("SELECT COUNT(*) AS n FROM " + m.primary.table)[0];
log.info("Datensätze: " + stats.n);

7. Nachsehen, wie die Spalten wirklich angelegt sind

// Die DB-Sicht liegt schon bereit — keine eigene Abfrage noetig
var p = ctx.model.primary;
var ids = p.elements.ids();
for (var i = 0; i < ids.length; i++) {
    var el = p.elements.get(ids[i]);
    if (el.column) {
        log.info(el.column.name + ": " + el.column.ddl
            + (el.column.nullable ? " NULL" : " NOT NULL"));
    }
}

Demo-Formular

Zum Ausprobieren gibt es das Beispielformular „ctx.model — Datenmodell im Skript" (Katalog Service, Ordner Scripting; installierbar unter System-Administration → Formular-Bausteine → Beispiele). Es hat für die Muster dieser Seite je eine Schaltfläche, die das zugehörige Skript ausführt und das Ergebnis in ein Ausgabefeld schreibt. Die Skripte selbst siehst du im Formular-Editor im Reiter Skripte.

Das Demo-Formular „ctx.model" mit dem Ergebnis des Audit-Skripts

Wann der Bauplan entsteht

Der Bauplan wird vor jedem Skript-Lauf neu zusammengestellt — aus der Formular-Definition und aus dem, was die Datenbank über ihre Spalten berichtet. Nichts davon wird zwischengespeichert. Wer also im Formular-Editor ein Feld ergänzt und anschließend ein Skript startet, sieht es sofort in ctx.model.

Läuft ein Skript ohne Formular — frei in der Skript-Konsole —, gibt es nichts zu bauen: ctx.model ist null. Skripte, die an beiden Orten laufen sollen, prüfen deshalb mit if (ctx.model) { … }, bevor sie darauf zugreifen.