Zum Hauptinhalt springen

Konzept: kit.common.ix.functions.applyQueryMap (Arbeitstitel)

Ziel

Generische ELO Indexserver-Funktion, die per JSON-Konfiguration eine SQL-Abfrage gegen die ELO-Datenbank ausführt und das Ergebnis kontrolliert in ELO-Map-/GRP-Tabellenfelder schreibt.

Sie ist der Nachfolger/Parallel-Baustein zu applyDynKwlMap, übernimmt aber nur die Datenquelle neu – die Schreiblogik (Modi, Normalisierung, DryRun, Status) bleibt fachlich identisch.

Neuer Namespace (Arbeitstitel):

kit.common.ix.functions.applyQueryMap

RF-Funktion:

RF_kit_common_ix_functions_applyQueryMap

Hintergrund: Warum nicht DynKwl?

Bei der Umsetzung von applyDynKwlMap hat sich gezeigt, dass die ELO-API rund um dynamische Stichwortlisten (KeywordsDynamicInfo, checkoutKeywordsDynamic, keyNames/table-Iteratoren, {i}-Platzhalter) sehr ELO-versions- und konfigurationsabhängig und schwer ohne Live-System zu verifizieren ist.

Die eigentliche „Apply"-Logik (Zeilen kontrolliert in Map-Tabellen schreiben) ist davon unabhängig und bereits robust gebaut. Die Idee: die Datenbeschaffung durch eine direkte SQL-Abfrage gegen die ELO-Datenbank ersetzen – deutlich vorhersehbarer, testbar (SQL lässt sich isoliert prüfen) und unabhängig von DynKwl-Skript-Konfiguration im Maskeneditor.

Verhältnis zu applyDynKwlMap

Gemeinsamer Baustein ("Apply"):
- loadSord / loadMap
- Modi: append, overwrite, singleLine, merge
- normalizeFieldName / getLineFieldName
- getMapValue / getMaxMapLine / createMapEntry
- findMergeLine / buildMergeKey
- writeStatus
- dryRun

Unterschied ("Datenquelle"):
applyDynKwlMap → callDynKwl + convertDynKwlResultToRows
applyQueryMap → executeQuery + mapResultToRows

applyDynKwlMap.js bleibt unverändert bestehen. Ob die gemeinsame Apply-Logik später in eine gemeinsame Basis (Mixin/Util-Klasse) extrahiert wird, ist eine spätere Entscheidung (siehe „Offene Fragen").

Fachliches Ziel

Die Funktion soll:

  1. das aktuelle Dokument/Sord laden
  2. aus Sord/Map die Parameter für die SQL-Abfrage ermitteln
  3. die SQL-Abfrage gegen die ELO-Datenbank ausführen
  4. das Ergebnis (Resultset) in strukturierte Zeilen umwandeln (Spalte → Feld)
  5. die Ergebniszeilen je nach Modus in die Zieltabelle schreiben
  6. optional Statusinformationen schreiben
  7. optional im DryRun nur protokollieren, aber nichts ändern

Beispiel für Workflow-Konfiguration

{
"source": {
"requiredFields": ["IX_MAP_PURCHASE_ORDER_NO"],
"maxLines": 20
},
"query": {
"sql": "SELECT po_no, po_line, delivery_date, vendor_no FROM erp_po_lines WHERE po_no = {{PURCHASE_ORDER_NO}} AND delivery_no = {{DELIVERY_NO}}",
"params": [
{
"name": "PURCHASE_ORDER_NO",
"type": "number",
"source": "map",
"field": "IX_MAP_PURCHASE_ORDER_NO"
},
{
"name": "DELIVERY_NO",
"type": "string",
"source": "map",
"field": "IX_MAP_DELIVERY_NO"
}
]
},
"mapping": [
"IX_MAP_PURCHASE_ORDER_NO",
"IX_MAP_PURCHASE_ORDER_LINE_NO",
"IX_MAP_PURCHASE_ORDER_DELIVERY_DATE",
"IX_GRP_VENDOR_NO"
],
"target": {
"mode": "merge",
"appendNewRows": true,
"overwriteExisting": false,
"mergeKeyFields": [
"IX_MAP_PURCHASE_ORDER_NO",
"IX_MAP_PURCHASE_ORDER_LINE_NO"
]
},
"options": {
"dryRun": true,
"writeStatus": true,
"statusPrefix": "IX_MAP_QUERY_APPLY"
}
}

mapping ist eine geordnete Liste von Zielfeldnamen – Position muss 1:1 zur Spaltenreihenfolge im SELECT passen (siehe mapResultToRows unten).

In diesem Beispiel hat die Quelltabelle (Map-Tabelle des aktuellen Dokuments) z. B. zwei Zeilen mit derselben Bestellnummer, aber unterschiedlicher Lieferscheinnummer:

Zeile 1: IX_MAP_PURCHASE_ORDER_NO1 = "45000123", IX_MAP_DELIVERY_NO1 = "L4"
Zeile 2: IX_MAP_PURCHASE_ORDER_NO2 = "45000123", IX_MAP_DELIVERY_NO2 = "L5"

source.requiredFields sorgt dafür, dass beide Zeilen als eigene Kontexte erkannt werden (siehe collectQueryContexts unten). Da PURCHASE_ORDER_NO und DELIVERY_NO in query.params kein festes line haben, wird pro Kontext die jeweilige Zeile verwendet – es entstehen zwei Queries mit (po_no=45000123, delivery_no='L4') bzw. (po_no=45000123, delivery_no='L5'). target.mergeKeyFields ist davon unabhängig und wirkt erst beim Schreiben der kombinierten Ergebniszeilen in die Zieltabelle.

Geplante interne Struktur

process()
loadSord()
loadMap()
collectQueryContexts(map, config.source) // 1..n Kontexte (je eine Quellzeile)
für jeden Kontext:
buildQueryParams(map, sord, config.query.params, context.line)
renderSql(config.query.sql, paramValues, config.query.params)
executeQuery(sql)
mapResultToRows(resultSet, config.mapping, context.line)
→ Ergebniszeilen aller Kontexte zusammenführen
applyRows(rows, map, sord) // wiederverwendet aus applyDynKwlMap
writeStatus() // wiederverwendet aus applyDynKwlMap

Geplante interne Methoden (neu)

collectQueryContexts(map, source)

Ermittelt, für welche Quellzeilen (Map-Tabellenzeilen des aktuellen Dokuments) jeweils eine eigene Query ausgeführt werden soll. Analog zu collectLookupContexts aus applyDynKwlMap, aber ohne DynKwl-Aufruf – liefert nur die Zeilennummern als Kontexte.

source.requiredFields nicht gesetzt → genau ein Kontext: { line: 1 }
(entspricht dem bisherigen Single-Query-Verhalten)

source.requiredFields gesetzt → für line = 1..maxLines:
- mindestens ein requiredFields-Wert in dieser Zeile befüllt?
→ Kontext { line: line }
- kein requiredFields-Wert befüllt?
→ stopOnEmptyRequired !== false: Iteration abbrechen (Tabellenende)
→ sonst: Zeile überspringen, weiter iterieren

buildQueryParams(map, sord, paramDefs, contextLine)

Ermittelt die Werte für die SQL-Parameter aus dem aktuellen Dokument, für den jeweils aktuellen Kontext aus collectQueryContexts.

Jeder Eintrag in query.params beschreibt, woher ein Wert kommt und welchen Typ er für renderSql hat:

source: "map"     → Wert aus Map-Feld (field + line, via getMapValue)
line: explizit gesetzt → immer diese feste Zeile (z. B. Kopfwert in Zeile 1)
line: nicht gesetzt → aktuelle contextLine (aus collectQueryContexts), Default 1
source: "objKey" → Wert aus sord.objKeys (zeilenunabhängig)
source: "static" → fester Wert aus der Konfiguration (zeilenunabhängig)

type: "number" → für renderSql: numerisch, ohne Quotes eingesetzt
"string" → für renderSql: Quote-escaped, in Hochkommas eingesetzt

Beispiel:

{
"name": "PURCHASE_ORDER_NO",
"type": "number",
"source": "map",
"field": "IX_MAP_PURCHASE_ORDER_NO",
"line": 1
}

Ergebnis (Eingabe für renderSql):

{
"PURCHASE_ORDER_NO": "45000123"
}

renderSql(sqlTemplate, paramValues, paramDefs)

Ersetzt {{PARAM_NAME}}-Platzhalter im SQL-Template anhand der ermittelten Parameterwerte. Da DBConnection.query() (siehe unten) einen reinen SQL-String entgegennimmt und – soweit bekannt – kein Prepared-Statement/Parameter-Binding unterstützt, muss die Einsetzung typsicher erfolgen:

type: "number" → Wert muss /^-?\d+(\.\d+)?$/ entsprechen, sonst Abbruch.
Wird ohne Quotes eingesetzt.
type: "string" → einfache Hochkommas werden verdoppelt (' → ''),
Wert wird in Hochkommas eingesetzt.

Beispiel:

Template: SELECT ... WHERE po_no = {{PURCHASE_ORDER_NO}}
Param: { name: "PURCHASE_ORDER_NO", type: "number", value: "45000123" }
Ergebnis: SELECT ... WHERE po_no = 45000123

Ein Parameter ohne passenden type oder mit ungültigem Wert führt zum Abbruch (Fehlerstatus statt stillem Fallback) – Werte aus Map-Feldern dürfen nie ungeprüft in sql landen.

executeQuery(sql)

Führt die fertig gerenderte SQL-Abfrage gegen die ELO-Datenbank aus.

Bestätigte API (vom Nutzer aus bestehendem Skript bereitgestellt):

var database = new Packages.de.elo.ix.jscript.DBConnection();
var resultSet = database.query(sql);

resultSet ist ein Array von Zeilen, jede Zeile ein Array von Spaltenwerten in der Reihenfolge der SELECT-Liste (Zugriff per Index, nicht per Spaltenname):

resultSet.length   // Anzahl Zeilen
resultSet[0][0] // Zeile 0, Spalte 0
resultSet[0][1] // Zeile 0, Spalte 1

Beispiel-Rückgabe für die Beispiel-Query oben:

[
["45000123", "10", "2026-05-01", "V-001"],
["45000123", "20", "2026-05-03", "V-001"]
]

mapResultToRows(resultSet, mapping, sourceLine)

mapping ist eine geordnete Liste von Zielfeldnamen – Position = Spaltenposition im resultSet (1:1 zur SELECT-Reihenfolge). Jede Zeile wird per Index auf die Felder übertragen und in das gleiche Zeilenformat überführt, das applyRows bereits von convertDynKwlResultToRows kennt:

mapResultToRows: function (resultSet, mapping, sourceLine) {
var rows = [];
for (var r = 0; r < resultSet.length; r++) {
var row = resultSet[r];
var normalized = {};
mapping.forEach(function (fieldName, idx) {
normalized[fieldName] = row[idx] != null ? String(row[idx]) : "";
});
normalized.__sourceLine = sourceLine || 1;
rows.push(normalized);
}
return rows;
}

Ergebnis für die Beispiel-Query oben:

[
{
"IX_MAP_PURCHASE_ORDER_NO": "45000123",
"IX_MAP_PURCHASE_ORDER_LINE_NO": "10",
"IX_MAP_PURCHASE_ORDER_DELIVERY_DATE": "2026-05-01",
"IX_GRP_VENDOR_NO": "V-001",
"__sourceLine": 1
},
{
"IX_MAP_PURCHASE_ORDER_NO": "45000123",
"IX_MAP_PURCHASE_ORDER_LINE_NO": "20",
"IX_MAP_PURCHASE_ORDER_DELIVERY_DATE": "2026-05-03",
"IX_GRP_VENDOR_NO": "V-001",
"__sourceLine": 1
}
]

__sourceLine wird nur für den Modus singleLine benötigt – bei applyQueryMap typischerweise 1 bzw. die Zeile, aus der die Query-Parameter stammen, da eine Query meist mehrere Ergebniszeilen für eine Quellzeile liefert.

Anzahl Spalten in mapping muss zur Spaltenzahl in resultSet passen (row.length === mapping.length); bei Abweichung → Fehlerstatus statt stillem Mismatch.

Wiederverwendete Bausteine (unverändert ggü. applyDynKwlMap)

Diese Teile werden 1:1 aus applyDynKwlMap.js übernommen bzw. in eine gemeinsame Basis ausgelagert:

  • Modi append / overwrite / singleLine / merge – siehe applyDynKwlMap-konzept.md, Abschnitt „Geplante Modi"
  • Feldnamen-Normalisierung normalizeFieldName / getLineFieldName – siehe dort, Abschnitt „Feldnamen-Normalisierung"
  • Merge-Key buildMergeKey / findMergeLine – siehe dort, Abschnitt „Merge-Key". Erweiterung in applyQueryMap (noch nicht in applyDynKwlMap übernommen): applyMerge trackt pro Lauf bereits zugeordnete Zeilen (usedLines) und übergibt sie an findMergeLine, das diese Zeilen bei der Suche überspringt. Ohne diese Erweiterung würden mehrere Ergebniszeilen mit demselben Merge-Key (z. B. mehrere Wareneingänge zu einer Bestellung) alle auf dieselbe bestehende Zeile matchen und sich gegenseitig überschreiben – mit usedLines matcht nur die erste Ergebniszeile die bestehende Zeile, weitere werden (bei appendNewRows: true) als neue Zeilen angehängt.
  • Schutzlogik ignoreTargetRowsWhere – siehe dort, Abschnitt „Schutzlogik gegen Überschreiben"
  • DryRun – gleiches Verhalten: nur loggen, nichts schreiben
  • Statusfelder – gleiches Schema (STATUS, MESSAGE, DATE, COUNT), Prefix via options.statusPrefix
  • Schreiben (applyRows) – nutzt wie applyDynKwlMap sol.common.IxUtils.execute("RF_sol_function_Set", { objId, flowId, entries }), um die gemappten Zeilen zurück in die Map-/GRP-Tabellenfelder zu schreiben. mapResultToRows muss nur das von applyRows/createMapEntry erwartete Zeilenformat (Feldname → Wert + __sourceLine) liefern, der Rest ist unverändert.

Offene Fragen / Risiken

  1. ELO-DB-Zugriff aus Skripten – gelöst Bestätigt (vom Nutzer aus bestehendem Skript): new Packages.de.elo.ix.jscript.DBConnection() mit .query(sql). Liefert ein Array von Zeilen, jede Zeile ein Array von Spaltenwerten per Index. Siehe executeQuery oben.

  2. SQL-Injection / Parametrisierung – Lösung: typsichere Platzhalter (renderSql) DBConnection.query() nimmt einen reinen SQL-String entgegen – kein erkennbares Prepared-Statement/Parameter-Binding. Deshalb übernimmt renderSql die Einsetzung von {{PARAM_NAME}}-Platzhaltern mit typbasierter Validierung/Escaping (number → Regex-Check, string → Quote-Escaping). Werte aus Map-Feldern dürfen nie direkt in sql konkateniert werden, ohne durch renderSql zu laufen. Falls sich später doch eine Prepared-Statement-fähige API findet, sollte diese bevorzugt werden – renderSql bleibt dann Fallback.

  3. Mehrere Quellzeilen → mehrere Queries? – gelöst (Version 1.1) collectQueryContexts iteriert über source.maxLines Map-Tabellenzeilen und bildet für jede Zeile, in der mindestens eines der source.requiredFields befüllt ist, einen eigenen Kontext. Pro Kontext wird buildQueryParams mit der jeweiligen Zeilennummer aufgerufen, danach renderSql/executeQuery/mapResultToRows – die Ergebniszeilen aller Kontexte werden vor applyRows zusammengeführt.

    Beispiel-Szenario: Zeile 1 enthält Bestellnummer 1 + Lieferscheinnummer 4, Zeile 2 dieselbe Bestellnummer 1 + Lieferscheinnummer 5. Sind beide Felder als query.params mit source: "map" ohne festes line konfiguriert, ergeben sich zwei Abfragen – eine je Quellzeile, mit jeweils passender Kombination aus Bestellnummer und Lieferscheinnummer. target.mergeKeyFields (z. B. dieselben beiden Felder) ist davon unabhängig: Es wirkt erst beim Schreiben der kombinierten Ergebniszeilen in die Zieltabelle und entscheidet dort pro Ergebniszeile, ob eine bestehende Zielzeile aktualisiert oder eine neue angehängt wird.

    Ist source.requiredFields nicht gesetzt, gibt es genau einen Kontext (Zeile 1) – identisches Verhalten zur ursprünglichen Version 1.

  4. Code-Duplizierung vs. gemeinsame Basis Soll die Apply-Logik in eine gemeinsame Klasse (z. B. sol.common.ix.MapTableWriter) extrahiert werden, die sowohl applyDynKwlMap als auch applyQueryMap nutzen? Spart Duplizierung, ist aber ein zusätzlicher Refactoring-Schritt an einer bereits funktionierenden Datei. Empfehlung: erst applyQueryMap eigenständig (mit kopierter Apply-Logik) bauen und testen, Extraktion erst danach bei Bedarf.

Umsetzungsphasen

Version 1

Ziel: Saubere generische SQL-Ausführung + Mapping + Schreiben.

Umfang:

- Namespace kit.common.ix.functions.applyQueryMap
- Workflow-Aufruf über onEnterNode/onExitNode
- RF-Aufruf RF_kit_common_ix_functions_applyQueryMap
- JSON-Parameter lesen (query, mapping, target, options)
- Sord und Map lesen
- buildQueryParams (source: map | objKey | static)
- renderSql (typsichere {{PARAM}}-Ersetzung)
- executeQuery via Packages.de.elo.ix.jscript.DBConnection
- mapResultToRows
- Modus append
- Modus overwrite
- Modus singleLine
- dryRun
- Logging

Noch nicht zwingend:

- gemeinsame Basis-Klasse mit applyDynKwlMap

Version 1.1

Ziel: Mehrere Queries/Kontexte pro Aufruf (mehrere Quellzeilen, z. B. mehrere Bestellungen/Lieferscheine).

- collectQueryContexts(map, config.source)
- source.requiredFields nicht gesetzt → ein Kontext (Zeile 1, Altverhalten)
- source.requiredFields gesetzt → ein Kontext pro Zeile mit befülltem Feld,
Abbruch bei leerer Zeile (stopOnEmptyRequired)
- buildQueryParams erhält contextLine: query.params[].line ohne Angabe →
aktuelle Kontext-Zeile; mit Angabe → feste Zeile (z. B. Kopfwert)
- process() führt buildQueryParams/renderSql/executeQuery/mapResultToRows
pro Kontext aus und führt die Ergebniszeilen vor applyRows zusammen

Version 2

Ziel: Merge-Modus (analog applyDynKwlMap Version 2) – im aktuellen Code bereits über die aus applyDynKwlMap übernommene Apply-Logik enthalten (applyMerge/findMergeLine), aber noch nicht mit echten Daten getestet.

- bestehende Zielzeilen lesen
- Merge-Key bilden
- neue Ergebniszeilen gegen Bestand prüfen
- appendNewRows / overwriteExisting
- ignoreTargetRowsWhere

Zielbild

applyDynKwlMap   → Datenquelle: dynamische Stichwortliste (KeywordsDynamicInfo)
applyQueryMap → Datenquelle: SQL-Abfrage gegen ELO-Datenbank

Beide schreiben über denselben Satz an Modi (append/overwrite/singleLine/merge)
kontrolliert in Map-/GRP-Tabellenfelder.

Welcher Baustein im Einzelfall genutzt wird, entscheidet die jeweilige Workflow-Konfiguration – beide Funktionen können parallel im Repo existieren.