56 KiB
DataPipeline – generisches ETL-Framework in Ruby
Stand: 2026-10-09 · Gem datapipeline 2.1.0 (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
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:
# Gemfile
gem 'datapipeline', path: '../datapipeline' # oder git: '…'
gem 'pg' # nur die Gems der benötigten Plugins, siehe 2.6
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:
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.
flowchart LR
CSV[kunden.csv] --> V1[validate<br/>Pflicht, Format, Typ]
LJ[laender.json] -. lookup .-> V2
V1 --> V2[validate<br/>Land bekannt]
V2 --> F[filter<br/>nur aktiv]
F --> M[map<br/>anreichern, umbauen]
LJ -. lookup .-> M
M --> DB[(kunden.sqlite3<br/>alle)]
M -->|only: EU| XL[eu_kunden.xlsx]
V1 -. on_error .-> ERR[kunden_fehler.csv]
V2 -. on_error .-> ERR
#
# 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:
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/, 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', …)).
#
# 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):
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.
#
# 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.
#
# 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:
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), 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.
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/<id> |
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.
#
# 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
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:
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.
#
# 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:
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.
#
# 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/<id>
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
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
flowchart LR
subgraph Pipeline-Skript
DSL[DataPipeline.new do ... end]
end
DSL -->|container :typ| REG[Registry / autoload<br/><Typ>Storage]
REG --> SP[DataPipeline::StoragePlugin<br/>read · write · write_row · query · close]
SP --- PL[postgres · sqlite · csv · json<br/>excel · rest · eigene]
DSL -->|lookup| LK[(Nachschlage-Hash)]
SRC((Quelle)) --> STEPS[Schritte<br/>validate · filter · map · transform]
LK -.-> STEPS
STEPS --> EDGE[header · trailer<br/>ctx.read · ctx.written]
EDGE --> DST1((Ziel 1))
EDGE -->|only:| DST2((Ziel 2))
STEPS -. on_error .-> REJ((Fehlerdatei))
DSL -->|run| ST[Stats · on_finish<br/>Exit-Code über bin/etl]
| Datei | Inhalt |
|---|---|
| lib/datapipeline.rb | DSL und Ausführung (run), Stats, Context |
| lib/datapipeline/validation.rb | Regeln für validate |
| lib/datapipeline/watermark.rb | Wasserzeichen für inkrementelle Läufe |
| lib/datapipeline/lock.rb | Sperre gegen parallele Läufe |
| lib/datapipeline/layout.rb | Satzarten-Layouts (Feldliste je Satzart, siehe 2.9) |
| exe/etl | Kommando etl des Gems mit Exit-Codes (siehe 2.7); bin/etl startet es im Repository mit dem Bundle |
| datapipeline.gemspec, CHANGELOG.md | Gem-Beschreibung, Versionsverlauf |
| examples/ | Beispiele aus Abschnitt 1 mit Daten in examples/data/, Demo-API für die REST-Beispiele |
| lib/datapipeline/storage/storage_plugin.rb | Basisklasse der Storage-Plugins |
| lib/datapipeline/storage/postgres.rb | Quelle/Ziel PostgreSQL (pg) |
| lib/datapipeline/storage/csv.rb | Quelle/Ziel CSV |
| lib/datapipeline/storage/json.rb | Quelle/Ziel JSON und NDJSON |
| lib/datapipeline/storage/sqlite.rb | Quelle/Ziel SQLite (sqlite3) |
| lib/datapipeline/storage/excel.rb | Quelle/Ziel Excel xlsx (roo, caxlsx) |
| lib/datapipeline/storage/rest.rb | Quelle/Ziel REST-API (http) |
| lib/datapipeline/notification/slack.rb | Benachrichtigung, z. B. aus on_finish |
| 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 <Typ>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) |
lock(file) |
Sperre während run; läuft schon ein Lauf mit derselben Datei, bricht dieser mit DataPipeline::LockError ab (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)
-
Batch (Default):
data = source.readlädt die gesamte Quelle in den Speicher. Streaming: siehe unten. -
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). -
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 sobaldmap/filtervorkommt, oder ganz ohne Schrittedie 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× mitb, dann 2× mitc), verkettet 2 Zeilen mitbundc. -
Rückgabe eines Blocks: ein Hash ergibt eine Zeile, ein Array mehrere Zeilen (1:n, z. B. Kopf + Positionen),
niloder[]keine Zeile;nilinnerhalb eines Arrays wird übersprungen. -
condition:antransformodermap: String (pereval, Variablerowsichtbar) oder Lambda->(row) { ... }. Zeilen, die die Bedingung nicht erfüllen, fallen in diesem Schritt weg. -
Vorne und hinten:
Schritt Aufruf Zeitpunkt transform(processing: :prepend)(nil, 0)vor den Daten header(ctx)mitread,writtennach den Daten, steht aber vorne transform(processing: :append)(nil, n): parallel n = Quellzeilen, verkettet n = Datenzeilennach den Daten trailer(ctx)mitread,writtennach den Daten -
Ohne Schritte wird die Quelle unverändert ins Ziel kopiert.
-
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 | eine Transaktion je batch_size Zeilen (Default 1000); batch_size: :all eine für alles |
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 <Name>Storage < DataPipeline::StoragePlugin mit:
| Methode | Pflicht | Verwendung |
|---|---|---|
initialize(**opts, &block) |
ja | Verbindung/Datei vorbereiten |
read → Array<Hash<String, …>> |
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<Hash> |
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 <Name>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 <Name>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, upsert, batch_size.
read/querymit Platzhaltern$1, $2, …undparams: [...]; Ergebnis alsHash<String, String>(NULL=nil), mittyped: trueals Ruby-Typen (Integer, Float, Date, …).read { |row| }liest über einen Cursor in einer Transaktion, jefetch_sizeZeilen (Default 1000).write(rows)schreibt intable:(oder alte Formwrite(table, rows)), alle Zeilen in einer Transaktion, Werte als Bind-Parameter,nil=NULL, Tabellen-/Spaltennamen gequotet (schema.tabelleerlaubt).truncate: trueleert die Tabelle vor dem ersten Schreiben (beiwritein derselben Transaktion).upsert: %w[belnr]schreibtinsert … on conflict (belnr) do update set …für alle übrigen Spalten der Zeile (ohne übrige Spaltendo nothing). Die Tabelle braucht einen Primärschlüssel oder Unique-Index auf genau diesen Spalten. Damit kann ein Lauf nach einem Abbruch wiederholt werden, ohne doppelte Zeilen.write_row(Streaming) sammelt die Zeilen und schreibt jebatch_sizeZeilen (Default 1000) in einer Transaktion;finalizeundcloseschreiben den Rest. Mitbatch_size: :allläuft der ganze Stream in einer Transaktion:finalizeam Ende eines erfolgreichen Laufs bestätigt sie, nach einem Abbruch rolltclosesie zurück, auch eintruncate. Dafür braucht das Ziel eine eigene Verbindung (nicht dieselbe wie die Quelle).
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]
# wiederholbar: vorhandene Belege werden aktualisiert, im Streaming alles oder nichts
write container(:postgres, **db, table: 'beleg', upsert: %w[belnr], batch_size: :all)
DataPipeline::CsvStorage – Optionen file (Pflicht), csv: (an Rubys CSV, gemergt mit den Defaults headers: true,
col_sep: ','), columns, encoding, replace, bom, 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 auscolumns. Ohnecolumnsgilt die Hash-Reihenfolge (nötig für Dateien mit mehreren Satzarten wie CUDO).- Lesen ohne Kopfzeile (
headers: false): Schlüssel auscolumns, sonst"0","1", … - Leere Daten: ohne
columnsleere Datei, mitcolumnsnur die Kopfzeile. layout: DataPipeline::Layoutschreibt Dateien mit mehreren Satzarten in der Feldreihenfolge je Satzart, ohne Kopfzeile; ein Feld, das nicht im Layout steht, bricht ab (siehe 2.9).encoding: 'ISO-8859-1'(oder'Windows-1252', …) ist der Zeichensatz der Datei, Default UTF-8. Gelesen wird als UTF-8, geschrieben wird von UTF-8 umkodiert. Ein Zeichen, das der Zeichensatz nicht kennt (z. B.€in ISO-8859-1), bricht mitEncoding::UndefinedConversionErrorab; mitreplace: '?'wird es ersetzt. Ein UTF-8-BOM am Dateianfang wird beim Lesen übersprungen,bom: trueschreibt eines (für Excel). Einencodingincsv:wird wie bisher unverändert an Ruby weitergegeben.
write container(:csv, file: 'out/cudo.txt', encoding: 'Windows-1252', layout: LAYOUT, csv: { headers: false, col_sep: '|' })
Ü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, upsert |
: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. upsert: %w[belnr] wie bei Postgres; mit create: true wird der
nötige Unique-Index gleich mit angelegt. 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/<id>, 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).
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.
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.
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:
- In Slack eine App anlegen (api.slack.com/apps), unter OAuth & Permissions den Bot-Scope
chat:writevergeben, die App im Workspace installieren und den Bot User OAuth Token (xoxb-…) kopieren. - Die App in den Ziel-Channel einladen (
/invite @appname), sonst antwortet Slack mitnot_in_channel. - Das Gem
httpin das Gemfile der Anwendung aufnehmen (siehe 2.6); die Klasse wird erst beim ersten Zugriff geladen. - Den Token als Umgebungsvariable
SLACK_TOKENsetzen, z. B. in der.envder Anwendung (das Gem lädt keine.env), oder direkt mittoken:übergeben. Den Token nicht ins Pipeline-Skript schreiben.
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.
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:
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).
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]
Sperre gegen parallele Läufe – Startet ein Cronjob, während der vorige Lauf noch arbeitet, lesen beide dasselbe
Wasserzeichen und exportieren doppelt. lock verhindert das mit einer exklusiven Sperre (flock) auf eine Datei:
Ein zweiter Lauf wartet nicht, sondern bricht sofort mit DataPipeline::LockError ab (Exit-Code 3), schreibt nichts
und ruft wie jeder Abbruch on_finish auf. Das Betriebssystem gibt die Sperre beim Ende des Prozesses frei, auch nach
einem Absturz; es bleiben also keine verwaisten Sperren. Die Datei bleibt liegen und enthält die PID des letzten Laufs.
lock 'data/.cudo.lock' # in der Pipeline: von run bis zum Ende des Laufs
bin/etl run cudo.rb --lock=data/.cudo.lock # für das ganze Skript, z. B. mehrere Pipelines nacheinander
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):
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 |
--lock=FILE |
Sperre für das ganze Skript; ist sie belegt, endet etl sofort mit Exit-Code 3 |
--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, 3 ein anderer Lauf hält die Sperre. 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/: 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.
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:
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
- Weitere Plugins: Festbreiten-Format (feste Spaltenbreiten, nutzt die Längen aus dem Layout), SFTP-Ablage.
- Veröffentlichung: eigenes Remote-Repository für das Gem, Lizenz festlegen, Versionen taggen.