# DataPipeline – generisches ETL-Framework in Ruby Stand: 2026-10-08 · Gem `datapipeline` 2.0.0 ([CHANGELOG.md](CHANGELOG.md)) · Ruby 3.4 --- ## 1. Beschreibung `DataPipeline` ist ein schlankes ETL-Framework (Extract – Transform – Load) mit einer Ruby-DSL. Eine Pipeline liest Daten aus einer Quelle, schickt jede Zeile durch eine Kette von Schritten (prüfen, filtern, umbauen, anreichern) und schreibt das Ergebnis in ein oder mehrere Ziele. Quellen und Ziele sind austauschbare **Storage-Plugins**: PostgreSQL, SQLite, CSV, JSON/NDJSON, Excel und REST-APIs. Die Pipeline beschreibt *was* passiert, die Plugins kapseln *wie* gelesen und geschrieben wird. Für den Betrieb gibt es Fehlerdateien, Prüfregeln, Dry-Run, inkrementelle Läufe, Streaming für grosse Datenmengen und eine Kommandozeile mit Exit-Codes. **Anwendungen:** Das Projekt `etl` nutzt das Gem für die PDS → SAP-Schnittstelle (CUDO) und für das Anlegen von Rechnungen über die pds API; dort liegen auch die zugehörige Doku und Tests. ### 1.1 Schnellstart ```sh mise install # Ruby 3.4.9 aus mise.toml bundle install bundle exec rake test # Tests, lokales Postgres optional bin/etl run examples/kunden_import.rb # Beispiel unten, Ergebnis in tmp/ bin/etl run examples/kunden_import.rb --dry-run # alles prüfen, nichts schreiben ``` Das Framework ist das Gem `datapipeline`. In einer anderen Anwendung wird es so eingebunden: ```ruby # Gemfile gem 'datapipeline', path: '../datapipeline' # oder git: '…' gem 'pg' # nur die Gems der benötigten Plugins, siehe 2.6 ``` ```sh bundle install bundle exec etl pipeline.rb --dry-run # das Kommando etl kommt mit dem Gem ``` Eine Pipeline ist ein Ruby-Skript. Das kleinste sinnvolle Beispiel kopiert eine CSV-Datei nach JSON: ```ruby require 'datapipeline' DataPipeline.new do read container(:csv, file: 'kunden.csv') write container(:json, file: 'kunden.json') end.run ``` Zwischen `read` und `write` stehen die Schritte; sie laufen in der Reihenfolge, in der sie im Skript stehen: | Schritt | Wirkung | | --- | --- | | `validate regeln` | prüft jede Zeile; Fehler gehen an `on_error` | | `filter { \|row\| … }` | Zeile bleibt, wenn der Block wahr ist | | `map { \|row, ctx\| … }` | gibt eine Zeile, mehrere (Array) oder keine (`nil`) zurück | | `header { \|ctx\| … }`, `trailer { \|ctx\| … }` | Kopf- und Schlusssatz mit den Zählern `ctx.read`, `ctx.written` | ### 1.2 Ausführliches Beispiel: Kundenimport Eine Kundenliste (`examples/data/kunden.csv`, 9 Zeilen) soll geprüft, mit Ländernamen angereichert und in eine SQLite-Datenbank geschrieben werden; EU-Kunden kommen zusätzlich in eine Excel-Liste. Fehlerhafte Zeilen sollen den Lauf nicht abbrechen, sondern mit Grund in einer eigenen Datei landen. ```mermaid flowchart LR CSV[kunden.csv] --> V1[validate
Pflicht, Format, Typ] LJ[laender.json] -. lookup .-> V2 V1 --> V2[validate
Land bekannt] V2 --> F[filter
nur aktiv] F --> M[map
anreichern, umbauen] LJ -. lookup .-> M M --> DB[(kunden.sqlite3
alle)] M -->|only: EU| XL[eu_kunden.xlsx] V1 -. on_error .-> ERR[kunden_fehler.csv] V2 -. on_error .-> ERR ``` ```ruby # # Kundenliste aus CSV prüfen, mit Ländern anreichern, nach SQLite und Excel schreiben # # bin/etl run examples/kunden_import.rb [--out=tmp] [--dry-run] # require 'fileutils' require 'datapipeline' DATA = File.expand_path('data', __dir__) out = DataPipeline.params.fetch(:out, 'tmp') FileUtils.mkdir_p(out) DataPipeline.new do version 'kunden_import 1.0' # Nachschlagetabelle, einmal geladen: { 'DE' => { 'code' => 'DE', 'name' => 'Deutschland', 'eu' => true }, ... } laender = lookup container(:json, file: "#{DATA}/laender.json"), key: 'code', unique: true read container(:csv, file: "#{DATA}/kunden.csv", csv: { col_sep: ';' }) # fehlerhafte Zeilen mit Grund in eine eigene Datei, der Lauf geht weiter on_error :collect, to: container(:csv, file: "#{out}/kunden_fehler.csv") # 1. prüfen validate 'kundennr' => { required: true, format: /\A\d{6}\z/ }, 'name' => { required: true, max: 40 }, 'umsatz' => { type: :decimal } validate { |row| "Land #{row['land']} unbekannt" unless laender.key?(row['land']) } # 2. nur aktive Kunden filter { |row| row['status'] == 'aktiv' } # 3. anreichern und umbauen map do |row| land = laender.fetch(row['land']) { 'kundennr' => row['kundennr'], 'name' => row['name'].strip, 'land' => land['name'], 'eu' => land['eu'] ? 'ja' : 'nein', 'umsatz' => row['umsatz'].to_f.round(2) } end # 4. schreiben: alle Kunden nach SQLite, EU-Kunden zusätzlich als Excel-Liste write container(:sqlite, database: "#{out}/kunden.sqlite3", table: 'kunden', create: true, truncate: true) write container(:excel, file: "#{out}/eu_kunden.xlsx", sheet: 'EU-Kunden'), only: ->(row) { row['eu'] == 'ja' } on_finish { |stats| puts "kunden_import: #{stats}" } end.run ``` Was die einzelnen Teile tun: | Zeile | Erklärung | | --- | --- | | `DataPipeline.params.fetch(:out, 'tmp')` | Parameter von der Kommandozeile: `--out=verzeichnis` | | `lookup …, key: 'code', unique: true` | lädt die Länder einmal als Hash nach Ländercode; kein Zugriff je Zeile | | `on_error :collect, to: …` | jede Zeile, die in einem Schritt scheitert, kommt mit `_step` und `_error` in die Fehlerdatei | | `validate 'kundennr' => { … }` | Regeln je Spalte: `required`, `format`, `max`, `min`, `in`, `type` (`:integer`, `:decimal`, `:date`; siehe 2.7) | | `validate { \|row\| … }` | eigene Regel: ein Text (oder ein Array) ist ein Fehler, `nil` heisst in Ordnung | | `filter` | inaktive Kunden fallen weg, ohne Fehler | | `map` | baut die Zielzeile; `laender.fetch` kann nicht scheitern, weil `validate` das Land schon geprüft hat | | `write …` zweimal | jedes Ziel bekommt die Zeilen; `only:` wählt die Zeilen für die Excel-Liste | | `create: true, truncate: true` | SQLite legt die Tabelle beim ersten Schreiben an und leert sie; im Dry-Run passiert nichts | | `on_finish` | läuft nach jedem Lauf, auch nach einem Abbruch; `stats` siehe 2.7 | Ergebnis von `bin/etl run examples/kunden_import.rb`: ```text INFO start kunden_import 1.0 WARN rejected by validate#1: name: missing WARN rejected by validate#1: kundennr: format /\A\d{6}\z/ WARN rejected by validate#2: Land XX unbekannt WARN rejected by validate#1: umsatz: not decimal INFO finish read=9 written=4 rejected=4 0.097s kunden_import: read=9 written=4 rejected=4 0.097s ``` | Datei | Inhalt | | --- | --- | | `tmp/kunden.sqlite3`, Tabelle `kunden` | 4 aktive, gültige Kunden (DE, AT, CH, IT) mit Ländername, `eu`, `umsatz` als Zahl | | `tmp/eu_kunden.xlsx`, Blatt `EU-Kunden` | 3 Kunden (ohne CH) | | `tmp/kunden_fehler.csv` | 4 Zeilen: leerer Name, Kundennummer mit 5 Stellen, Land `XX`, Umsatz `abc` – je mit `_step` und `_error` | Der Exit-Code ist `2`, weil Zeilen verworfen wurden; ein Abbruch ergäbe `1`, ein fehlerfreier Lauf `0`. Mit `--dry-run` läuft alles gleich, es entstehen aber weder Datenbank noch Excel- oder Fehlerdatei; stattdessen stehen die ersten Zeilen auf dem Bildschirm. ### 1.3 Weitere Beispiele Alle Beispiele liegen in [examples/](examples/), arbeiten mit den Daten in `examples/data/` und schreiben nach `tmp/`. `test/examples/doc_examples_test.rb` führt sie aus und prüft, dass der Code hier mit den Dateien übereinstimmt. **Datei mit Satzarten** – Rechnungen aus verschachteltem JSON werden zu einem Kopfsatz mit Positionssätzen; Datei- und Schlusssatz zählen die Datensätze. Das Layout legt Reihenfolge, Typ und Länge der Felder je Satzart fest; die Datentypen `AN`, `N`, `DAT`, `TIM` und ihre Prüfung stehen in 2.9. Das Layout formatiert nicht, deshalb bringt das `map` Datum und Beträge selbst ins Zielformat (`delete('-')`, `format('%.2f', …)`). ```ruby # # Rechnungen aus verschachteltem JSON in eine Datei mit Satzarten (Datei-, Kopf-, Positions-, Schlusssatz) # # bin/etl run examples/rechnungen_satzarten.rb [--out=tmp] # require 'fileutils' require 'datapipeline' DATA = File.expand_path('data', __dir__) out = DataPipeline.params.fetch(:out, 'tmp') FileUtils.mkdir_p(out) # Feld: [Name, Typ, Länge, Pflicht]; Typ AN Text, N Zahl (11.2 = 9 Vor-, 2 Nachkommastellen), DAT YYYYMMDD LAYOUT = DataPipeline::Layout.new do record 'DATEI', [['SATYP', 'AN', 5, true], ['ERSTELLT', 'DAT', 8, true], ['ANZAHL', 'N', 6, true]] record 'KOPF', [['SATYP', 'AN', 5, true], ['LFDNR', 'N', 6, true], ['BELNR', 'AN', 10, true], ['DATUM', 'DAT', 8, true], ['KUNNR', 'AN', 10, true], ['SUMME', 'N', '11.2', true]] record 'POS', [['SATYP', 'AN', 5, true], ['LFDNR', 'N', 6, true], ['BELNR', 'AN', 10, true], ['POSNR', 'N', 3, true], ['TEXT', 'AN', 30], ['BETRAG', 'N', '11.2', true]] record 'ENDE', [['SATYP', 'AN', 5, true], ['ANZAHL', 'N', 6, true]] end DataPipeline.new do read container(:json, file: "#{DATA}/rechnungen.json") # 1. eine Rechnung -> Kopfsatz + n Positionssätze map do |rechnung| positionen = rechnung['positionen'].each_with_index.map do |pos, i| { 'satyp' => 'POS', 'belnr' => rechnung['nr'], 'posnr' => format('%03d', i + 1), 'text' => pos['text'], 'betrag' => format('%.2f', pos['menge'] * pos['preis']) } end kopf = { 'satyp' => 'KOPF', 'belnr' => rechnung['nr'], 'datum' => rechnung['datum'].delete('-'), 'kunnr' => rechnung['kunde'], 'summe' => format('%.2f', positionen.sum { |p| p['betrag'].to_f }) } [kopf, *positionen] end # 2. laufende Nummer über alle Kopf- und Positionssätze map { |row, ctx| row.merge('lfdnr' => format('%06d', ctx.index + 1)) } # 3. gegen das Layout prüfen: zu langer Text wird gemeldet, die Zeile bleibt validate LAYOUT, warn: true # Datei- und Schlusssatz kennen die Anzahl der Datensätze header { |ctx| { 'satyp' => 'DATEI', 'erstellt' => Date.today.strftime('%Y%m%d'), 'anzahl' => format('%06d', ctx.written) } } trailer { |ctx| { 'satyp' => 'ENDE', 'anzahl' => format('%06d', ctx.written) } } # Felder in der Reihenfolge des Layouts; Positionen zusätzlich als NDJSON write container(:csv, file: "#{out}/rechnungen.txt", layout: LAYOUT, csv: { headers: false, col_sep: '|' }) write container(:json, file: "#{out}/positionen.ndjson"), only: ->(row) { row['satyp'] == 'POS' } end.run ``` Ergebnis `tmp/rechnungen.txt` (die Warnung meldet den Positionstext mit mehr als 30 Zeichen): ```text DATEI|20261008|000005 KOPF|000001|R-1001|20251103|100001|380.50 POS|000002|R-1001|001|Beratung|300.00 POS|000003|R-1001|002|Reisekosten|80.50 KOPF|000004|R-1002|20251105|100002|1200.00 POS|000005|R-1002|001|Jahreslizenz Software mit Wartung und Support|1200.00 ENDE|000005 ``` **Inkrementell und gestreamt** – nur Bewegungen, die seit dem letzten erfolgreichen Lauf dazugekommen sind; jede Zeile wird sofort geschrieben, der Speicherbedarf bleibt auch bei Millionen Zeilen klein. ```ruby # # Nur neue Buchungsbewegungen exportieren, Zeile für Zeile (Streaming) # Ein zweiter Lauf ohne neue Daten schreibt nichts. # # bin/etl run examples/bewegungen_inkrementell.rb [--out=tmp] # require 'fileutils' require 'datapipeline' DATA = File.expand_path('data', __dir__) out = DataPipeline.params.fetch(:out, 'tmp') FileUtils.mkdir_p(out) DataPipeline.new do streamed # höchste id des letzten erfolgreichen Laufs, gespeichert in tmp/.state.json stand = watermark :bewegungen, column: 'id', file: "#{out}/.state.json", initial: 0 # bei einer Datenbank gehört der Filter ins SQL: where id > $1, params: [stand.value] read container(:json, file: "#{DATA}/bewegungen.ndjson") filter { |row| row['id'] > stand.value.to_i } map { |row| row.merge('soll_haben' => row['betrag'].negative? ? 'H' : 'S', 'betrag' => row['betrag'].abs) } write container(:json, file: "#{out}/bewegungen_#{Time.now.strftime('%Y%m%d_%H%M%S')}.ndjson" ) on_finish { |stats| puts "neu: #{stats.written}, Stand jetzt: #{stand.current}" } end.run ``` Der erste Lauf schreibt 4 Bewegungen und merkt sich `4`; der zweite schreibt nichts (`neu: 0`) und legt auch keine Datei an. Bricht ein Lauf ab, bleibt der alte Stand stehen. **Eigenes Plugin** – eine Quelle, die alle CSV-Dateien eines Verzeichnisses liest. Ein Plugin braucht nur die Methoden, die es anbietet (hier `read`); die Schnittstelle steht in 2.4. ```ruby # # Eigenes Plugin: liest alle CSV-Dateien eines Verzeichnisses als eine Quelle # # bin/etl run examples/eigenes_plugin.rb [--out=tmp] # require 'fileutils' require 'datapipeline' DATA = File.expand_path('data', __dir__) out = DataPipeline.params.fetch(:out, 'tmp') FileUtils.mkdir_p(out) class VerzeichnisStorage < DataPipeline::StoragePlugin def initialize dir:, pattern: '*.csv', **csv_options @dir = dir @pattern = pattern @csv_options = csv_options end # jede Zeile bekommt den Dateinamen dazu; mit Block zeilenweise (für streamed) def read(&block) rows = Dir.glob(File.join(@dir, @pattern)).sort.flat_map do |file| DataPipeline::CsvStorage.new(file:, **@csv_options).read.map { |row| row.merge('datei' => File.basename(file, '.*')) } end return rows unless block rows.each(&block) nil end end DataPipeline.register :verzeichnis, VerzeichnisStorage DataPipeline.new do read container(:verzeichnis, dir: "#{DATA}/filialen") map { |row| row.merge('menge' => row['menge'].to_i) } write container(:csv, file: "#{out}/alle_filialen.csv", columns: %w[datei artikel menge]) end.run ``` Ergebnis `tmp/alle_filialen.csv`: ```text datei,artikel,menge vaduz,A-100,5 vaduz,B-200,2 zuerich,A-100,7 zuerich,C-300,1 ``` Weitere Ausschnitte: REST-API nach Excel in 2.5 (REST ausführlich in 1.4), Benachrichtigung über Slack in 2.7. ### 1.4 Beispiele mit REST-APIs Die drei Beispiele laufen gegen eine kleine Demo-API ([examples/demo_api.rb](examples/demo_api.rb)), die ohne Internet und ohne weitere Gems auskommt. Sie verlangt ein Token, liefert Seiten und Folge-Links, nimmt Daten per POST und PUT an und antwortet einmal absichtlich mit `503`, damit die Wiederholung sichtbar wird. ```sh ruby examples/demo_api.rb & # http://127.0.0.1:4567, Token demo-token bin/etl run examples/rest_lesen.rb bin/etl run examples/rest_schreiben.rb bin/etl run examples/rest_zu_rest.rb ``` Gegen eine echte API ändern sich nur `--url=…` und das Token (`DEMO_API_TOKEN`, in der Praxis aus der `.env`). | Endpunkt der Demo-API | Antwort | | --- | --- | | `GET /api/kunden?page=1&per_page=3` | `{"data": [...], "page": 1}`; Seite 2 antwortet einmal mit `503` | | `GET /api/laender` | `[{"code": "DE", "name": "Deutschland"}, ...]` | | `GET /api/bestellungen?cursor=n` | `{"items": [...], "next": "/api/bestellungen?cursor=n+2"}`, auf der letzten Seite `"next": null` | | `POST /api/import` | ein Objekt oder Array; `422`, wenn `kundennr` oder `name` fehlt | | `PUT /api/archiv/` | legt das Objekt unter der id ab | **Lesen: seitenweise, mit Wiederholung und Nachschlagen** – `page_param` holt Seite für Seite, bis eine leer ist; `root` zeigt auf das Array in der Antwort. Ein zweiter Container auf denselben Server dient mit `query` als Lookup. ```ruby # # Kunden seitenweise aus einer REST-API lesen, mit Ländern aus einem zweiten Endpunkt anreichern, # nach CSV und JSON schreiben # # ruby examples/demo_api.rb & # Demo-API starten # bin/etl run examples/rest_lesen.rb [--url=http://127.0.0.1:4567] [--out=tmp] # require 'fileutils' require 'datapipeline' API = DataPipeline.params.fetch(:url, 'http://127.0.0.1:4567') AUTH = "Bearer #{ENV.fetch('DEMO_API_TOKEN', 'demo-token')}" out = DataPipeline.params.fetch(:out, 'tmp') FileUtils.mkdir_p(out) DataPipeline.new do version 'rest_lesen 1.0' # GET /api/kunden?page=1&per_page=3, dann page=2, ... bis eine Seite leer ist # die Zeilen stehen in der Antwort unter "data"; 429/5xx und Netzfehler werden bis zu 3-mal wiederholt kunden = container :rest, url: "#{API}/api/kunden", auth: AUTH, root: 'data', page_param: 'page', params: { 'per_page' => 3 }, retries: 3, retry_wait: 0.2 # derselbe Server, anderer Endpunkt: query hängt den Pfad an die url an (GET /api/laender) stamm = container :rest, url: "#{API}/api", auth: AUTH laender = lookup stamm, query: 'laender', key: 'code', unique: true read kunden on_error :collect, to: container(:csv, file: "#{out}/api_fehler.csv") validate 'id' => { required: true }, 'umsatz' => { type: :decimal } map do |kunde| { 'id' => kunde['id'], 'name' => kunde['name'], 'land' => laender.dig(kunde['land'], 'name') || kunde['land'], 'umsatz' => kunde['umsatz'].to_f } end write container(:csv, file: "#{out}/api_kunden.csv", columns: %w[id name land umsatz]) write container(:json, file: "#{out}/api_kunden.json", pretty: true) on_finish { |stats| puts "rest_lesen: #{stats}" } end.run ``` ```text INFO start rest_lesen 1.0 WARN rejected by validate#1: umsatz: not decimal INFO finish read=7 written=6 rejected=1 0.213s ``` Gelesen werden 7 Kunden in 3 Seiten (plus eine leere Seite als Ende); die `503` auf Seite 2 bemerkt man nur an der Laufzeit. Kunde 5 hat den Umsatz `n/a` und steht in `tmp/api_fehler.csv`. Ergebnis `tmp/api_kunden.csv`: ```text id,name,land,umsatz 1,Müller GmbH,Deutschland,12500.5 2,Huber & Partner,Österreich,8300.0 3,Swiss Trade AG,Schweiz,22000.75 4,Rossi Srl,Italien,4100.1 6,Nordic AB,SE,990.0 7,Vaduz Treuhand,Liechtenstein,15000.0 ``` Schweden fehlt in `/api/laender`, deshalb bleibt bei Kunde 6 der Code `SE` stehen. **Schreiben: prüfen, dann in Paketen senden** – `batch_size: 2` schickt je 2 Zeilen als JSON-Array. Was die API ablehnen würde, wird vorher geprüft: Lehnt die API ein Paket trotzdem ab (`4xx`), bricht der Lauf ab, weil ein Fehler beim Schreiben nicht einer einzelnen Zeile zugeordnet werden kann. ```ruby # # Kunden aus CSV prüfen und in Paketen per POST an eine REST-API senden # # ruby examples/demo_api.rb & # bin/etl run examples/rest_schreiben.rb [--url=http://127.0.0.1:4567] [--dry-run] # require 'fileutils' require 'datapipeline' API = DataPipeline.params.fetch(:url, 'http://127.0.0.1:4567') AUTH = "Bearer #{ENV.fetch('DEMO_API_TOKEN', 'demo-token')}" DATA = File.expand_path('data', __dir__) out = DataPipeline.params.fetch(:out, 'tmp') FileUtils.mkdir_p(out) DataPipeline.new do version 'rest_schreiben 1.0' read container(:csv, file: "#{DATA}/kunden.csv", csv: { col_sep: ';' }) # vor dem Senden prüfen: was die API ablehnen würde, landet hier in der Fehlerdatei on_error :collect, to: container(:csv, file: "#{out}/import_fehler.csv") validate 'kundennr' => { required: true, format: /\A\d{6}\z/ }, 'name' => { required: true } filter { |row| row['status'] == 'aktiv' } map do |row| { 'kundennr' => row['kundennr'], 'name' => row['name'].strip, 'land' => row['land'], 'umsatz' => row['umsatz'].to_f } end # POST /api/import mit je 2 Kunden als JSON-Array; im Dry-Run wird nichts gesendet write container(:rest, url: "#{API}/api/import", auth: AUTH, batch_size: 2) on_finish do |stats| puts stats.dry_run ? 'Dry-Run: nichts gesendet' : "gesendet: #{stats.written} Kunden in #{(stats.written / 2.0).ceil} Paketen" end end.run ``` Ergebnis: 6 aktive Kunden in 3 Anfragen an `POST /api/import`, 2 Kunden in `tmp/import_fehler.csv` (leerer Name, Kundennummer mit 5 Stellen). Mit `--dry-run` wird nichts gesendet; auf dem Bildschirm stehen die Daten, die gesendet würden: ```text DRY RUN – nothing written: read=9 written=6 rejected=2 0.0s (dry run) {"kundennr":"100001","name":"Müller GmbH","land":"DE","umsatz":12500.5} ... ``` **Von API zu API: Folge-Links, Streaming, eine URL je Datensatz** – Die Antwort nennt die nächste Seite selbst; das Lambda in `next:` macht daraus eine vollständige URL. `write_url` als Lambda schickt jede Bestellung an ihre eigene Adresse. Mit dem Wasserzeichen archiviert ein zweiter Lauf nur, was neu dazugekommen ist. ```ruby # # Von API zu API: neue Bestellungen über Folge-Links lesen und einzeln per PUT archivieren # Ein zweiter Lauf ohne neue Bestellungen sendet nichts. # # ruby examples/demo_api.rb & # bin/etl run examples/rest_zu_rest.rb [--url=http://127.0.0.1:4567] [--out=tmp] # require 'fileutils' require 'datapipeline' API = DataPipeline.params.fetch(:url, 'http://127.0.0.1:4567') AUTH = "Bearer #{ENV.fetch('DEMO_API_TOKEN', 'demo-token')}" out = DataPipeline.params.fetch(:out, 'tmp') FileUtils.mkdir_p(out) DataPipeline.new do streamed # höchste Bestellnummer des letzten erfolgreichen Laufs stand = watermark :bestellungen, column: 'id', file: "#{out}/.state.json", initial: 0 # die Antwort nennt die nächste Seite selbst: {"items": [...], "next": "/api/bestellungen?cursor=2"} read container(:rest, url: "#{API}/api/bestellungen", auth: AUTH, root: 'items', next: ->(body) { body['next'] && "#{API}#{body['next']}" }) filter { |row| row['id'] > stand.value.to_i } map { |row| row.merge('archiviert' => Date.today.iso8601) } # eine url je Bestellung: PUT /api/archiv/ write container(:rest, url: "#{API}/api/archiv", auth: AUTH, write_method: :put, write_url: ->(row) { "#{API}/api/archiv/#{row['id']}" }) on_finish { |stats| puts "archiviert: #{stats.written}, Stand jetzt: #{stand.current}" } end.run ``` ```text archiviert: 5, Stand jetzt: 5 # erster Lauf: PUT /api/archiv/1 … /5 archiviert: 0, Stand jetzt: 5 # zweiter Lauf: keine Anfrage ``` --- ## 2. Technische Dokumentation ### 2.1 Aufbau ```mermaid flowchart LR subgraph Pipeline-Skript DSL[DataPipeline.new do ... end] end DSL -->|container :typ| REG[Registry / autoload
<Typ>Storage] REG --> SP[DataPipeline::StoragePlugin
read · write · write_row · query · close] SP --- PL[postgres · sqlite · csv · json
excel · rest · eigene] DSL -->|lookup| LK[(Nachschlage-Hash)] SRC((Quelle)) --> STEPS[Schritte
validate · filter · map · transform] LK -.-> STEPS STEPS --> EDGE[header · trailer
ctx.read · ctx.written] EDGE --> DST1((Ziel 1)) EDGE -->|only:| DST2((Ziel 2)) STEPS -. on_error .-> REJ((Fehlerdatei)) DSL -->|run| ST[Stats · on_finish
Exit-Code über bin/etl] ``` | Datei | Inhalt | | --- | --- | | [lib/datapipeline.rb](lib/datapipeline.rb) | DSL und Ausführung (`run`), `Stats`, `Context` | | [lib/datapipeline/validation.rb](lib/datapipeline/validation.rb) | Regeln für `validate` | | [lib/datapipeline/watermark.rb](lib/datapipeline/watermark.rb) | Wasserzeichen für inkrementelle Läufe | | [lib/datapipeline/layout.rb](lib/datapipeline/layout.rb) | Satzarten-Layouts (Feldliste je Satzart, siehe 2.9) | | [exe/etl](exe/etl) | Kommando `etl` des Gems mit Exit-Codes (siehe 2.7); [bin/etl](bin/etl) startet es im Repository mit dem Bundle | | [datapipeline.gemspec](datapipeline.gemspec), [CHANGELOG.md](CHANGELOG.md) | Gem-Beschreibung, Versionsverlauf | | [examples/](examples/) | Beispiele aus Abschnitt 1 mit Daten in `examples/data/`, Demo-API für die REST-Beispiele | | [lib/datapipeline/storage/storage_plugin.rb](lib/datapipeline/storage/storage_plugin.rb) | Basisklasse der Storage-Plugins | | [lib/datapipeline/storage/postgres.rb](lib/datapipeline/storage/postgres.rb) | Quelle/Ziel PostgreSQL (`pg`) | | [lib/datapipeline/storage/csv.rb](lib/datapipeline/storage/csv.rb) | Quelle/Ziel CSV | | [lib/datapipeline/storage/json.rb](lib/datapipeline/storage/json.rb) | Quelle/Ziel JSON und NDJSON | | [lib/datapipeline/storage/sqlite.rb](lib/datapipeline/storage/sqlite.rb) | Quelle/Ziel SQLite (`sqlite3`) | | [lib/datapipeline/storage/excel.rb](lib/datapipeline/storage/excel.rb) | Quelle/Ziel Excel xlsx (`roo`, `caxlsx`) | | [lib/datapipeline/storage/rest.rb](lib/datapipeline/storage/rest.rb) | Quelle/Ziel REST-API (`http`) | | [lib/datapipeline/notification/slack.rb](lib/datapipeline/notification/slack.rb) | Benachrichtigung, z. B. aus `on_finish` | | [lib/datapipeline/version.rb](lib/datapipeline/version.rb) | `DataPipeline::VERSION` | ### 2.2 DSL-Referenz | Aufruf | Wirkung | | --- | --- | | `DataPipeline.settings(opts)` | Defaults für alle folgenden Pipelines; der Hash wird ersetzt, nicht verändert (laufende Pipelines behalten ihre Kopie); `reset_settings!` setzt zurück | | `DataPipeline.register(:typ, Klasse \| 'Klassenname', path:)` | eigenes Plugin; mit `path:` erst bei der ersten Nutzung geladen | | `DataPipeline.new(**settings) { ... }` | führt den Block per `instance_eval` aus; `settings` gelten nur für diese Pipeline, z. B. `new(limit: 10)` | | `configure(opts)` | Einstellungen dieser Pipeline als Hash: `verbose`, `streamed`, `chained`, `version` | | `version(str)`, `verbose(flag = true)`, `streamed(flag = true)`, `chained(flag = true)` | dasselbe als DSL-Aufruf | | `container(:typ, **opts)` | erzeugt das registrierte Plugin, sonst `Storage.new(**opts)` (z. B. `:postgres` → `DataPipeline::PostgresStorage`); fehlt es: „Unsupported storage type“; fehlende Gems, Verbindungs- und Optionsfehler kommen unverändert durch | | `read(storage, opts = {})` | legt die Quelle fest; `opts` gehen an `storage.set` | | `transform(opts = {}) { \|row, index\| ... }` | Transformation; `processing: :prepend \| :normal \| :append`, `condition:` (siehe 2.3) | | `map(opts = {}) { \|row, ctx\| ... }` | Schritt in der Kette: Hash, Array oder `nil` zurückgeben; schaltet Verkettung ein | | `filter(opts = {}) { \|row, ctx\| ... }` | Schritt in der Kette: Zeile bleibt, wenn der Block wahr ist; schaltet Verkettung ein | | `header { \|ctx\| ... }` | Zeile(n) vor den Daten; wird nach den Daten berechnet, kennt also `ctx.written` | | `trailer { \|ctx\| ... }` | Zeile(n) nach den Daten | | `query(storage, query: sql, params: [])` | führt sofort eine Abfrage aus (Subquery in einem Schritt) | | `lookup(storage, key:, unique: false, query:, params:)` | lädt eine Tabelle einmal als Hash: `key => [Zeilen]`, mit `unique: true` `key => Zeile`; `key` als Spaltenname oder Lambda; ohne `query:` wird `storage.read` genutzt | | `write(storage, only: ->(row) { ... })` | Ziel; mehrfach möglich, jedes Ziel bekommt alle Zeilen oder mit `only:` die passenden (auch Kopf- und Schlusssätze) | | `validate(rules \| layout, warn: false, ignore: []) { \|row\| ... }` | Prüfschritt (siehe 2.7, 2.9); `warn: true` nur Warnung, Zeile bleibt; schaltet Verkettung ein | | `on_error(:abort \| :skip \| :collect, to: storage)` | Umgang mit Fehlern in Datenschritten (siehe 2.7) | | `on_finish { \|stats\| ... }` | nach jedem Lauf, auch nach Abbruch | | `watermark(name, column:, file:, initial:)` | letzter verarbeiteter Wert für inkrementelle Läufe (siehe 2.7) | | `dry_run(flag = true)`, `limit(n)` | nichts schreiben bzw. nur die ersten n Quellzeilen | | `dry_run?` | in Schritten mit Nebenwirkungen (API-Aufrufe): `true` im Dry-Run | | `logger(logger)` | eigener Ruby-`Logger`; Default: Warnungen auf `$stderr` | | `params` | Parameter von der Kommandozeile: über `bin/etl` oder direkt aus `ARGV` (`--name=wert`, `--param key=wert`) | | `run` | führt die Pipeline aus und liefert `DataPipeline::Stats` | | jeder andere Aufruf | `NoMethodError` (Tippfehler fallen sofort auf) | ### 2.3 Ausführungsmodell (`run`) 1. Batch (Default): `data = source.read` lädt die gesamte Quelle in den Speicher. Streaming: siehe unten. 2. Die Schritte werden in drei Gruppen geteilt, innerhalb jeder Gruppe gilt die Reihenfolge der Registrierung: **vorne** (`:prepend`, `header`), **Daten** (`:normal`, `map`, `filter`), **hinten** (`:append`, `trailer`). 3. Die Daten-Schritte laufen in einem von zwei Modi: | Modus | Wann | Eingabe eines Schritts | | --- | --- | --- | | parallel | Default | immer eine eigene Kopie der Quellzeilen; die Ergebnisse aller Schritte werden hintereinander angehängt | | verkettet | `chained`, oder sobald `map`/`filter` vorkommt, oder ganz ohne Schritte | die Ausgabe des vorherigen Schritts | Beispiel mit 2 Quellzeilen und `transform { |r| r.merge('b'=>1) }` + `transform { |r| r.merge('c'=>2) }`: parallel 4 Zeilen (2× mit `b`, dann 2× mit `c`), verkettet 2 Zeilen mit `b` und `c`. 4. Rückgabe eines Blocks: ein Hash ergibt eine Zeile, ein Array mehrere Zeilen (1:n, z. B. Kopf + Positionen), `nil` oder `[]` keine Zeile; `nil` innerhalb eines Arrays wird übersprungen. 5. `condition:` an `transform` oder `map`: String (per `eval`, Variable `row` sichtbar) oder Lambda `->(row) { ... }`. Zeilen, die die Bedingung nicht erfüllen, fallen in diesem Schritt weg. 6. Vorne und hinten: | Schritt | Aufruf | Zeitpunkt | | --- | --- | --- | | `transform(processing: :prepend)` | `(nil, 0)` | vor den Daten | | `header` | `(ctx)` mit `read`, `written` | nach den Daten, steht aber vorne | | `transform(processing: :append)` | `(nil, n)`: parallel n = Quellzeilen, verkettet n = Datenzeilen | nach den Daten | | `trailer` | `(ctx)` mit `read`, `written` | nach den Daten | 7. Ohne Schritte wird die Quelle unverändert ins Ziel kopiert. 8. Batch: `target.write(ausgabe)` schreibt alles auf einmal. Der Kontext `ctx` (`DataPipeline::Context`) enthält `read` (Anzahl Quellzeilen), `written` (Anzahl Datenzeilen nach allen Schritten) und bei `map`/`filter` `index` (Position der Zeile im aktuellen Schritt). Mit `verbose` werden im Batch Quelle, Daten nach allen Schritten und Ziel als JSON ausgegeben. Nach dem Lauf schliesst `run` Quelle und Ziel (`close`), auch im Fehlerfall. **Streaming-Modus** (`streamed`): Jede Zeile geht einzeln von `source.read { |row| ... }` durch die Kette und sofort an `target.write_row`; am Ende `target.finalize`. Der Speicherbedarf bleibt konstant (getestet: 500 000 CSV-Zeilen mit 30 MB statt 614 MB im Batch). Die Datenzeilen sind dieselben wie im Batch, mit diesen Unterschieden: | Thema | Batch | Streaming | | --- | --- | --- | | `header` | kennt `read` und `written` | wird zuerst geschrieben, `read` und `written` sind `nil` | | `ctx.read` in `map`/`filter` | Anzahl Quellzeilen | `nil` | | mehrere `transform` im Parallelmodus | erlaubt | `ArgumentError`, dafür `chained` setzen | | Postgres als Ziel | eine Transaktion für alles | je Zeile ein `INSERT` ohne gemeinsame Transaktion | Postgres als Quelle liest im Streaming über einen serverseitigen Cursor (`fetch_size`, Default 1000). Abfragen und Lookups auf derselben Verbindung bleiben währenddessen möglich. ### 2.4 Plugin-Schnittstelle Ein Storage-Plugin ist eine Klasse `Storage < DataPipeline::StoragePlugin` mit: | Methode | Pflicht | Verwendung | | --- | --- | --- | | `initialize(**opts, &block)` | ja | Verbindung/Datei vorbereiten | | `read` → `Array>` | als Quelle | Batch-Lesen | | `read { \|row\| }` | als Quelle | zeilenweises Lesen | | `write(rows)` | als Ziel | Batch-Schreiben | | `write_row(row)` | als Ziel | zeilenweises Schreiben | | `query(query: sql, params: [])` → `Array` | optional | Ad-hoc-Abfragen aus Transformationen | | `set(opts)` | Basisklasse | Optionen nachreichen, wird von `read(storage, opts)` genutzt | | `close` / `finalize` | Basisklasse | Verbindung/Datei freigeben; `run` ruft `close` für Quelle und Ziel am Ende auf | Alle Plugins liefern Zeilen mit **String-Schlüsseln**. Die mitgelieferten Plugins liegen im Namensraum `DataPipeline` (`DataPipeline::CsvStorage` usw.) und werden per `autoload` erst bei der ersten Nutzung geladen. Neues Plugin: Klasse `Storage < DataPipeline::StoragePlugin` in der Anwendung schreiben und mit `DataPipeline.register :name, NameStorage` (oder `'NameStorage', path: 'datei.rb'` für Laden bei Bedarf) bekannt machen – danach ist `container :name` verfügbar. Ohne `register` findet `container :name` eine Klasse `Storage` auf oberster Ebene. ### 2.5 Mitgelieferte Plugins **DataPipeline::PostgresStorage** – Optionen `host`, `port` (Default 5432), `database`, `username`, `password`, `query`, `params`, `table`, `typed`, `fetch_size`, `truncate`. - `read` / `query` mit Platzhaltern `$1, $2, …` und `params: [...]`; Ergebnis als `Hash` (`NULL` = `nil`), mit `typed: true` als Ruby-Typen (Integer, Float, Date, …). - `read { |row| }` liest über einen Cursor in einer Transaktion, je `fetch_size` Zeilen (Default 1000). - `write(rows)` schreibt in `table:` (oder alte Form `write(table, rows)`), alle Zeilen in **einer Transaktion**, Werte als Bind-Parameter, `nil` = `NULL`, Tabellen-/Spaltennamen gequotet (`schema.tabelle` erlaubt). - `truncate: true` leert die Tabelle vor dem ersten Schreiben (bei `write` in derselben Transaktion). ```ruby pg = container :postgres, host: 'localhost', database: 'crm', username: 'etl', table: 'kunde_import' read pg, query: 'select * from kunde where geaendert >= $1', params: [Date.today - 7] ``` **DataPipeline::CsvStorage** – Optionen `file` (Pflicht), `csv:` (an Rubys `CSV`, gemergt mit den Defaults `headers: true`, `col_sep: ','`), `columns`, `counter`, `verbose`. - `columns: %w[a b c]` legt die Spaltenreihenfolge fest: fehlende Felder bleiben leer, String- und Symbol-Schlüssel werden gefunden, die Kopfzeile kommt aus `columns`. Ohne `columns` gilt die Hash-Reihenfolge (nötig für Dateien mit mehreren Satzarten wie CUDO). - Lesen ohne Kopfzeile (`headers: false`): Schlüssel aus `columns`, sonst `"0"`, `"1"`, … - Leere Daten: ohne `columns` leere Datei, mit `columns` nur die Kopfzeile. - `layout: DataPipeline::Layout` schreibt Dateien mit mehreren Satzarten in der Feldreihenfolge je Satzart, ohne Kopfzeile; ein Feld, das nicht im Layout steht, bricht ab (siehe 2.9). Übersicht der übrigen Plugins (alle mit `read`, `read { }`, `write`, `write_row`, `close`): | `container` | Klasse | Gem (bei Nutzung geladen) | Wichtige Optionen | | --- | --- | --- | --- | | `:json` | `DataPipeline::JsonStorage` | – | `file`, `format: :array \| :lines` (Default aus Endung: `.ndjson`/`.jsonl` → Zeilen), `root: 'data.items'`, `pretty`; ein einzelnes Objekt wird als eine Zeile gelesen | | `:sqlite`, `:sqlite3` | `DataPipeline::SqliteStorage` | `sqlite3` | `database` (Datei oder `':memory:'`), `query`, `params` (Array für `?`, Hash für `:name`), `table`, `create`, `truncate` | | `:excel`, `:xlsx` | `DataPipeline::ExcelStorage` | `roo` (lesen), `caxlsx` (schreiben) | `file`, `sheet` (Name/Index), `headers`, `columns`, `skip` (Titelzeilen) | | `:rest` | `DataPipeline::RestStorage` | `http` | `url`, `auth`, `headers`, `params`, `root`, Paging `page_param` oder `next: ->(body) { … }`, `retries`, `timeout`, `batch_size`, `write_method`, `write_url` (auch als Lambda je Zeile), `ssl_verify` | **DataPipeline::JsonStorage** – NDJSON wird zeilenweise gelesen und geschrieben. Ein JSON-Array wird beim Streaming als `[`…`]` geschrieben und in `close` abgeschlossen, die Datei ist also auch beim Streaming gültiges JSON. **DataPipeline::SqliteStorage** – Verhält sich wie Postgres: Bind-Parameter, Schreiben in einer Transaktion, Tabellen- und Spaltennamen gequotet, `nil` = `NULL`, Datum als ISO-Text. Werte kommen mit SQLite-Typen zurück (Integer, Float, String). `create: true` legt die Tabelle beim ersten Schreiben aus der ersten Zeile an (Integer → `integer`, Float → `real`, sonst `text`), `truncate: true` leert sie vorher. Die Datei wird erst bei der ersten Nutzung geöffnet; ein Dry-Run legt keine an. **DataPipeline::ExcelStorage** – Lesen von xlsx zeilenweise (`each_row_streaming`), leere Zeilen werden übersprungen. Beim Schreiben bleibt Text immer Text: `'0000000001'` behält die Nullen, `'=1+1'` wird keine Formel. Zahlen und Datumswerte behalten ihren Typ. Die Datei entsteht in `close` (caxlsx baut sie im Speicher auf). **DataPipeline::RestStorage** – Liest JSON, der Pfad zum Array kommt aus `root`. Paging über eine Seitennummer (bis eine Seite leer ist) oder über eine Folge-URL aus der Antwort; `max_pages` (Default 1000) stoppt endlose Schleifen. Bei 429, 5xx und Netzwerkfehlern wird `retries`-mal wiederholt (Wartezeit `retry_wait` × Versuch), 4xx bricht sofort mit `DataPipeline::RestStorage::Error` ab. Schreiben je Zeile oder mit `batch_size` als JSON-Array; `write_url: ->(row) { … }` baut je Zeile eine eigene URL (z. B. `PUT /kunden/`, nicht zusammen mit `batch_size`). `query(query: 'pfad', params:)` hängt den Pfad an `url` an und erlaubt `lookup` gegen die API. Ein Fehler beim Schreiben bricht den Lauf ab (siehe 1.4). Für APIs im RPC-Stil (Suchen und Anlegen per POST mit Body, z. B. die pds API) gibt es einzelne Aufrufe: `call(:post, 'person/listkunden', json: { … })` liefert das geparste JSON. Mit `retries: 0` wird ein anlegender Aufruf bei `5xx` nicht wiederholt. Ein Fehler wirft `DataPipeline::RestStorage::Error` mit `status` (HTTP-Code, `nil` bei Netzwerkfehlern). `ssl_verify: false` akzeptiert jedes Server-Zertifikat (eigene CA, Zugriff über IP-Adresse). ```ruby api = container :rest, url: 'https://api.example.com/v1/kunden', auth: "Bearer #{ENV['API_TOKEN']}", root: 'data', page_param: 'page' xlsx = container :excel, file: 'data/kunden.xlsx', sheet: 'Kunden', columns: %w[id name land] read api map { |r| r.slice('id', 'name', 'land') } write xlsx ``` ### 2.6 Abhängigkeiten Das Gem selbst braucht nur `csv` und `logger` (Ruby `>= 3.4`). Die Gems der Plugins sind optional und gehören ins Gemfile der Anwendung, die das Plugin nutzt: | Plugin | Gem | | --- | --- | | `:postgres` | `pg` | | `:sqlite` | `sqlite3` | | `:excel` | `roo` (lesen), `caxlsx` (schreiben) | | `:rest`, `DataPipeline::Slack` | `http` | | `:csv`, `:json` | – | Im Repository bindet das `Gemfile` das Gem über `gemspec` ein und ergänzt die Gruppe `plugins` (`pg`, `sqlite3`, `roo`, `caxlsx`, `http`) für die Plugin-Tests und die Gruppe `test` (`minitest`, `rake`). `Gemfile.lock` ist versioniert. Gem bauen: `gem build datapipeline.gemspec`. Plugins und ihre Gems werden erst bei der ersten Nutzung geladen (`autoload` und `DataPipeline::StoragePlugin.require_gem`): eine CSV- oder JSON-Pipeline lädt keines dieser Gems. Wer ein Plugin nicht braucht, lässt die Gruppe weg (`bundle config set without plugins`); bei Nutzung meldet das Plugin dann, welches Gem fehlt. ### 2.7 Betrieb **Fehler je Zeile** – `on_error` legt fest, was passiert, wenn ein Datenschritt (`transform`, `map`, `filter`, `validate`) für eine Zeile eine Exception wirft. Fehler in `header`, `trailer`, prepend und append brechen immer ab. | Modus | Wirkung | | --- | --- | | `:abort` (Default) | Lauf bricht ab, Exception wird weitergereicht | | `:skip` | Zeile fällt weg, Warnung im Log, Zähler `rejected` | | `:collect, to: storage` | wie `:skip`, zusätzlich die Eingangszeile mit `_step` und `_error` per `write_row` ins Ziel `storage` | **Validierung** – `validate` prüft Spalten nach Regeln, nach einem Layout (2.9) und/oder mit einem Block. Eine verletzte Regel löst `DataPipeline::ValidationError` aus und läuft damit über `on_error`. Leere Werte prüft nur `required`. Regeln je Spalte: `required: true`, `max:`/`min:` (Länge als Text), `format:` (Regex auf den Text), `in:` (erlaubte Werte) und `type:` – `:integer` (ganze Zahl, Basis 10), `:decimal` (alles, was Rubys `Float()` annimmt) oder `:date` (`Date` oder ISO-Text `YYYY-MM-DD`). Die Feldtypen eines Layouts (`AN`, `N`, `DAT`, `TIM`) und ihr Unterschied zu diesen Regeln stehen in 2.9. Mit `warn: true` bleibt die Zeile drin, es gibt eine Warnung und `stats.warnings` zählt mit (ins Log kommen höchstens 100); `ignore: %w[FELD]` lässt bekannte Lücken aus. ```ruby on_error :collect, to: container(:csv, file: 'data/fehler.csv', columns: %w[belnr konto dmbtr _step _error]) validate 'konto' => { required: true, format: /\A\d+\z/ }, 'shkzg' => { in: %w[S H] }, 'dmbtr' => { type: :decimal }, 'name1' => { max: 35 } validate { |row| 'Betrag 0' if row['dmbtr'].to_f.zero? } ``` **Ergebnis, Log, Hooks** – `run` liefert `DataPipeline::Stats`: | Feld | Inhalt | | --- | --- | | `read` | gelesene Quellzeilen | | `written` | Datenzeilen nach allen Schritten (ohne Kopf- und Schlusssätze) | | `output` | an die Ziele gegebene Zeilen inkl. Kopf- und Schlusssätzen | | `rejected`, `errors` | verworfene Zeilen; die ersten 100 mit Schritt, Meldung und Zeile | | `warnings` | Zeilen mit Warnung aus `validate …, warn: true` | | `duration`, `started_at`, `finished_at` | Laufzeit | | `error` | Exception eines abgebrochenen Laufs | | `ok?` | kein Abbruch und nichts verworfen | | `to_s` | `read=9 written=4 rejected=4 0.097s` | Der Logger schreibt `start` und `finish` (Info), jede verworfene Zeile (Warnung) und einen Abbruch (Fehler). `on_finish` läuft nach jedem Lauf, auch nach einem Abbruch; ein Fehler im Hook wird nur geloggt. ```ruby on_finish { |stats| DataPipeline::Slack.new(channel: '#etl').send_message("Buchungen: #{stats}") unless stats.ok? } ``` **Slack einrichten** – `DataPipeline::Slack` schickt Text über die Slack-API `chat.postMessage`. Voraussetzungen: 1. In Slack eine App anlegen (api.slack.com/apps), unter *OAuth & Permissions* den Bot-Scope `chat:write` vergeben, die App im Workspace installieren und den *Bot User OAuth Token* (`xoxb-…`) kopieren. 2. Die App in den Ziel-Channel einladen (`/invite @appname`), sonst antwortet Slack mit `not_in_channel`. 3. Das Gem `http` in das Gemfile der Anwendung aufnehmen (siehe 2.6); die Klasse wird erst beim ersten Zugriff geladen. 4. Den Token als Umgebungsvariable `SLACK_TOKEN` setzen, z. B. in der `.env` der Anwendung (das Gem lädt keine `.env`), oder direkt mit `token:` übergeben. Den Token nicht ins Pipeline-Skript schreiben. ```ruby slack = DataPipeline::Slack.new # Token aus ENV['SLACK_TOKEN'], Channel '#general' slack = DataPipeline::Slack.new(channel: '#etl') # anderer Channel (Name oder ID wie 'C0123ABCD') slack = DataPipeline::Slack.new(channel: '#etl', token: ENV.fetch('SLACK_ETL_TOKEN')) # eigener Token je Anwendung slack.send_message('Test aus der Pipeline', verbose: true) # verbose: Antwort von Slack als JSON auf $stdout ``` | Option | Default | Bedeutung | | --- | --- | --- | | `token:` | `ENV['SLACK_TOKEN']` | Bot-Token; fehlt er, bricht schon `Slack.new` mit `ArgumentError` ab | | `channel:` | `'#general'` | Ziel-Channel als Name oder ID | `send_message(text, verbose: false)` liefert die Antwort von Slack als Hash. Meldet Slack einen Fehler (z. B. `invalid_auth`, `channel_not_found`, `not_in_channel`), wirft die Methode eine Exception mit diesem Code. In `on_finish` wird sie nur geloggt (`on_finish: RuntimeError: Slack#send_message failed: …`), der Lauf und sein Exit-Code bleiben unverändert. Es gibt keine Wiederholung bei Netzwerkfehlern. Ein ausführlicheres Beispiel mit Status, Zählern, Laufzeit und den ersten verworfenen Zeilen. Der Notifier wird vor der Pipeline angelegt; ein fehlender Token fällt so schon beim Start auf und nicht erst nach dem Lauf. Slack setzt `*fett*`, `_kursiv_` und `` `code` `` um. ```ruby slack = DataPipeline::Slack.new(channel: '#etl') DataPipeline.new do # … read, Schritte, write … on_finish do |stats| next if stats.ok? && stats.warnings.zero? # nur melden, wenn etwas zu tun ist status = if stats.error then ':red_circle: abgebrochen' elsif stats.rejected.positive? then ':large_orange_circle: mit verworfenen Zeilen' else ':large_yellow_circle: mit Warnungen' end lines = ["*Buchungsexport #{stats.version}* #{status}#{' (Dry-Run)' if stats.dry_run}", "Zeitraum #{params[:from]} – #{params[:till]}, Start #{stats.started_at.strftime('%d.%m.%Y %H:%M')}, " \ "Dauer #{stats.duration}s", "gelesen #{stats.read} · geschrieben #{stats.written} · verworfen #{stats.rejected} · Warnungen #{stats.warnings}"] lines << "Fehler: `#{stats.error.class}: #{stats.error.message}`" if stats.error stats.errors.first(5).each do |e| lines << "• #{e[:step]}: #{e[:error]} – Beleg #{e[:row]['belnr']}" end lines << "… und #{stats.rejected - 5} weitere, siehe data/fehler.csv" if stats.rejected > 5 slack.send_message(lines.join("\n")) end end.run ``` So sieht die Meldung in Slack aus: ```text Buchungsexport 1.0 🟠 mit verworfenen Zeilen Zeitraum 2025-11-01 – 2025-11-30, Start 01.12.2025 06:00, Dauer 4.213s gelesen 1250 · geschrieben 1243 · verworfen 7 · Warnungen 0 • validate#1: konto: missing – Beleg 5100000123 • validate#1: dmbtr: not decimal – Beleg 5100000124 • validate#2: Betrag 0 – Beleg 5100000130 • validate#1: shkzg: not in ["S", "H"] – Beleg 5100000131 • validate#1: konto: format /\A\d+\z/ – Beleg 5100000140 … und 2 weitere, siehe data/fehler.csv ``` `stats.errors` enthält höchstens 100 Einträge als Hash mit `step`, `error` und der Eingangszeile `row`. `params` gibt es im Pipeline-Block; ausserhalb heisst es `DataPipeline.params`. **Inkrementelle Läufe** – `watermark` merkt sich je Name den höchsten Wert einer Spalte in einer JSON-Datei. Die Pipeline verfolgt jede Quellzeile und speichert den neuen Wert nur nach einem erfolgreichen Lauf (nicht nach Abbruch, nicht im Dry-Run). Zahlen als Text werden numerisch verglichen. Mit `limit` sollte die Quelle nach der Spalte sortiert sein; verworfene Zeilen schieben das Wasserzeichen mit (sie liegen bei `:collect` in der Fehlerdatei). ```ruby wm = watermark :rechnungen, column: 'rechnung_id', file: 'data/.state.json', initial: 0 read pg, query: 'select * from rechnung where rechnung_id > $1 order by rechnung_id', params: [wm.value] ``` **Dry-Run und Limit** – Im Dry-Run läuft alles, aber nichts wird geschrieben: kein Ziel, keine Fehlerdatei, kein Wasserzeichen; eine CSV-Datei wird nicht angelegt. Am Ende stehen die Zusammenfassung und die ersten Zeilen (`preview`, Default 10) als JSON auf `$stdout`. `limit n` verarbeitet nur die ersten n Quellzeilen, im Streaming wird das Lesen dann beendet. **Kommandozeile** – `bin/etl` startet ein Pipeline-Skript; `run` ist optional (`bin/etl skript.rb` genügt): ```sh bin/etl run export.rb --from=2025-11-01 --till=2025-11-30 bin/etl run examples/kunden_import.rb --dry-run --limit=20 bin/etl run pipeline.rb --param land=DE # im Skript: params[:land] ``` | Option | Wirkung | | --- | --- | | `--from`, `--till` | in `params[:from]`/`params[:till]` und unverändert an das Skript weitergereicht (eigener `OptionParser` der Importer) | | `--param KEY=VALUE` | `params[:key]`, mehrfach möglich | | `--dry-run`, `--limit=N` | für alle Pipelines des Skripts | | `--verbose`, `--quiet` | Log-Level Debug bzw. Warnung (Default über `bin/etl`: Info) | | jede andere `--name=wert` | `params[:name]` und unverändert an das Skript, z. B. `--out=data`, `--incremental=1` | Exit-Code: `0` ok, `1` Abbruch, `2` fertig, aber mit verworfenen Zeilen. Das `Rakefile` ruft `bin/etl` auf: `rake process:posting[2025-11-01,2025-11-30]`, `rake process:all[2025-11-01,2025-11-30,dry]`. Ein fehlgeschlagener Lauf stoppt Rake. ### 2.8 Tests `rake test` (oder `bundle exec rake`) startet die Minitest-Suite in [test/](test/): rund 165 Fälle für Plugins, Kern, Streaming, Betrieb, Kommandozeile und die Beispiele. | Ordner | Inhalt | | --- | --- | | `test/plugins` | je Plugin das gemeinsame Testkit `test/support/storage_contract.rb` (Schreiben und Zurücklesen, zeilenweise, als Quelle und Ziel einer Pipeline) plus eigene Fälle; REST gegen einen lokalen Testserver; Laden der Gems erst bei Nutzung in einem frischen Prozess | | `test/core` | DSL, Einstellungen, Plugin-Registry, Parallel- und Verkettungsmodus, Streaming, Lookups, mehrere Ziele, Betrieb; `compat_test.rb` hält die Ausgabe des `transform`-Modells von v1.2.1 fest | | `test/cli` | `bin/etl` als Prozess: Weitergabe von `--from`/`--till`, Parameter, Dry-Run, Exit-Codes | | `test/examples` | die Beispiele aus Abschnitt 1 (Code in der Doku = Datei, Lauf und Ergebnis; REST gegen die Demo-API) | Die `.env` wird in Tests nicht geladen; kein Test erreicht die Produktivdatenbank. Postgres-Tests legen je Test eine Wegwerf-Datenbank auf einem lokalen Server an (`ETL_TEST_PG_HOST`, `_PORT`, `_USER`, `_PASSWORD`; Default `localhost`, `$USER`). Ohne erreichbaren Server werden sie übersprungen (21 Tests). Ein eigenes Plugin lässt sich mit `MemoryStorage` aus `test/test_helper.rb` als Quelle oder Ziel testen; mit `include StorageContract` und einer Methode `contract_storage(env, :write | :read)` bekommt es die gemeinsamen Tests. ### 2.9 Satzarten-Layouts Dateien mit mehreren Satzarten (Kopf, Positionen, Schluss) haben je Satzart eine feste Feldliste. `DataPipeline::Layout` beschreibt sie; die Satzart einer Zeile ist ihr erster Wert (oder `type_field:`), Schlüssel werden ohne Rücksicht auf Gross-/Kleinschreibung zugeordnet. ```ruby LAYOUT = DataPipeline::Layout.new do record 'DOHD', [['SATYP', 'AN', 4, true], ['LFDNR', 'N', 10, true], ['BKTXT', 'AN', 25, true], ...] record 'LARC', [['SATYP', 'AN', 4, true], ['SUMME', 'N', 10, true]] end validate LAYOUT, warn: true # Pflicht, Länge, Format je Feld write container(:csv, file: 'x.ok', layout: LAYOUT, csv: { headers: false, col_sep: '|' }) ``` **Felddefinition** – jedes Feld ist ein Array `[Name, Typ, Länge, Pflicht]`: | Teil | Bedeutung | | --- | --- | | Name | Spaltenname; in der Zeile ohne Rücksicht auf Gross-/Kleinschreibung, als String oder Symbol gesucht | | Typ | `AN`, `N`, `DAT` oder `TIM` (siehe unten), Gross-/Kleinschreibung egal; fehlt er (`nil`), gilt `AN` | | Länge | Zahl oder bei Nachkommastellen ein String (`'11.2'`); fehlt sie, wird die Länge nicht geprüft | | Pflicht | `true` = darf nicht leer sein; fehlt sie, ist das Feld optional | **Datentypen** – geprüft wird immer der Wert als Text (`to_s`). Ein leerer Wert (`nil`, `''`, nur Leerzeichen) wird nur auf Pflicht geprüft, nie auf den Typ. | Typ | Inhalt | Gültig | Länge | Meldung | | --- | --- | --- | --- | --- | | `AN` | Text (alphanumerisch) | jeder Wert | höchstens `Länge` Zeichen | `NAME: longer than 10` | | `N` | Zahl | `\A[+-]?\d+(\.\d+)?\z`: Ziffern, optional Vorzeichen und Punkt als Dezimaltrenner | `10` = höchstens 10 Stellen, keine Nachkommastellen; `'11.2'` = höchstens 9 Vor- und 2 Nachkommastellen (Gesamtlänge 11 wie in SAP); das Vorzeichen zählt nicht, führende Nullen schon | `NAME: not a number`, `… more than 9 digits`, `… more than 2 decimals` | | `DAT` | Datum | genau 8 Ziffern `YYYYMMDD`, gültiges Kalenderdatum | wird nicht geprüft | `NAME: not a date YYYYMMDD` | | `TIM` | Uhrzeit | genau 6 Ziffern `HHMMSS`, 00:00:00 bis 23:59:59 | wird nicht geprüft | `NAME: not a time HHMMSS` | Ein unbekannter Typ wird wie `AN` behandelt. Das Layout **prüft nur, es formatiert nicht**: Werte werden beim Schreiben unverändert ausgegeben, ohne Auffüllen mit Nullen oder Leerzeichen. Zahlen, Datum und Uhrzeit müssen deshalb vorher in einem `map` als Text in das Zielformat gebracht werden: ```ruby map do |row| row.merge('datum' => row['datum'].strftime('%Y%m%d'), # Date -> '20261009'; Date#to_s wäre '2026-10-09' 'zeit' => row['zeit'].strftime('%H%M%S'), 'betrag' => format('%.2f', row['betrag']), # BigDecimal#to_s wäre '0.1e2', Float evtl. '1.0e-05' 'lfdnr' => format('%06d', row['lfdnr'])) # führende Nullen, falls das Zielsystem sie verlangt end ``` **Verhältnis zu `validate`-Regeln** – Layout-Typen beschreiben das Textformat einer Datei mit festen Feldern, die Regel `type:` von `validate` (2.7) prüft dagegen, ob sich ein Wert in einen Ruby-Typ umwandeln lässt. Beides lässt sich kombinieren, z. B. Rohdaten mit Regeln prüfen und die fertigen Sätze gegen das Layout. | Layout-Typ | ähnliche `validate`-Regel | Unterschied | | --- | --- | --- | | `AN` + Länge | `max: 10` | gleich | | `N` | `type: :integer` bzw. `type: :decimal` | `:decimal` nimmt jedes Ruby-`Float` an (auch `1e3`, `.5`); `N` nur Ziffern mit optionalem Punkt, dafür mit Stellenzahl | | `DAT` | `type: :date` | `:date` erwartet ISO 8601 (`2026-10-09`) oder ein `Date`, `DAT` dagegen `20261009` | | `TIM` | `format: /\A\d{6}\z/` | `TIM` prüft zusätzlich Stunden, Minuten und Sekunden | **Optionen** – `Layout.new(type_field: 'satyp')` nimmt die Satzart aus dieser Spalte statt aus dem ersten Wert der Zeile; eine Satzart, die nicht im Layout steht, ist ein Fehler (`record type "X" not in layout (…)`). `strict: false` lässt beim Schreiben Felder zu, die nicht im Layout stehen (sie werden weggelassen). Beim Schreiben stehen die Werte in der Reihenfolge des Layouts, fehlende Felder bleiben leer, ein unbekanntes Feld bricht mit `DataPipeline::LayoutError` ab. Eine falsch benannte SQL-Spalte verschiebt also nicht mehr still alle folgenden Felder. Ein vollständiges Beispiel sind die CUDO-Satzarten der SAP-Schnittstelle im Projekt `etl` (`cudo/layout.rb`), erzeugt aus der Schnittstellenbeschreibung. --- ## 3. Features und Verbesserungen Ziel: aus dem Exportwerkzeug ein generisches, testbares ETL-Framework machen. Reihenfolge = empfohlene Umsetzung. ### 3.1 Erweiterbarkeit 1. **Weitere Plugins:** Festbreiten-Format (feste Spaltenbreiten, nutzt die Längen aus dem Layout), SFTP-Ablage. 2. **Veröffentlichung:** eigenes Remote-Repository für das Gem, Lizenz festlegen, Versionen taggen.