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.

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:
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:
// 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.
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.

