Files
datapipeline/README.md
T

53 KiB
Raw Blame History

DataPipeline – generisches ETL-Framework in Ruby

Stand: 2026-10-08 · Gem datapipeline 2.0.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/>&lt;Typ&gt;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/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)
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 <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.

  • read / query mit Platzhaltern $1, $2, … und params: [...]; Ergebnis als Hash<String, String> (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).
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/<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:

  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.
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]

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

  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.