datapipeline 2.0.0: Gem aus dem Projekt etl herausgelöst
This commit is contained in:
@@ -0,0 +1,912 @@
|
||||
# DataPipeline – generisches ETL-Framework in Ruby
|
||||
|
||||
Stand: 2026-10-08 · Gem `datapipeline` 2.0.0 ([CHANGELOG.md](CHANGELOG.md)) · Ruby 3.4
|
||||
|
||||
---
|
||||
|
||||
## 1. Beschreibung
|
||||
|
||||
`DataPipeline` ist ein schlankes ETL-Framework (Extract – Transform – Load) mit einer Ruby-DSL. Eine Pipeline
|
||||
liest Daten aus einer Quelle, schickt jede Zeile durch eine Kette von Schritten (prüfen, filtern, umbauen,
|
||||
anreichern) und schreibt das Ergebnis in ein oder mehrere Ziele. Quellen und Ziele sind austauschbare
|
||||
**Storage-Plugins**: PostgreSQL, SQLite, CSV, JSON/NDJSON, Excel und REST-APIs.
|
||||
|
||||
Die Pipeline beschreibt *was* passiert, die Plugins kapseln *wie* gelesen und geschrieben wird. Für den Betrieb
|
||||
gibt es Fehlerdateien, Prüfregeln, Dry-Run, inkrementelle Läufe, Streaming für grosse Datenmengen und eine
|
||||
Kommandozeile mit Exit-Codes.
|
||||
|
||||
**Anwendungen:** Das Projekt `etl` nutzt das Gem für die PDS → SAP-Schnittstelle (CUDO) und für das Anlegen von
|
||||
Rechnungen über die pds API; dort liegen auch die zugehörige Doku und Tests.
|
||||
|
||||
### 1.1 Schnellstart
|
||||
|
||||
```sh
|
||||
mise install # Ruby 3.4.9 aus mise.toml
|
||||
bundle install
|
||||
bundle exec rake test # Tests, lokales Postgres optional
|
||||
bin/etl run examples/kunden_import.rb # Beispiel unten, Ergebnis in tmp/
|
||||
bin/etl run examples/kunden_import.rb --dry-run # alles prüfen, nichts schreiben
|
||||
```
|
||||
|
||||
Das Framework ist das Gem `datapipeline`. In einer anderen Anwendung wird es so eingebunden:
|
||||
|
||||
```ruby
|
||||
# Gemfile
|
||||
gem 'datapipeline', path: '../datapipeline' # oder git: '…'
|
||||
gem 'pg' # nur die Gems der benötigten Plugins, siehe 2.6
|
||||
```
|
||||
|
||||
```sh
|
||||
bundle install
|
||||
bundle exec etl pipeline.rb --dry-run # das Kommando etl kommt mit dem Gem
|
||||
```
|
||||
|
||||
Eine Pipeline ist ein Ruby-Skript. Das kleinste sinnvolle Beispiel kopiert eine CSV-Datei nach JSON:
|
||||
|
||||
```ruby
|
||||
require 'datapipeline'
|
||||
|
||||
DataPipeline.new do
|
||||
read container(:csv, file: 'kunden.csv')
|
||||
write container(:json, file: 'kunden.json')
|
||||
end.run
|
||||
```
|
||||
|
||||
Zwischen `read` und `write` stehen die Schritte; sie laufen in der Reihenfolge, in der sie im Skript stehen:
|
||||
|
||||
| Schritt | Wirkung |
|
||||
| --- | --- |
|
||||
| `validate regeln` | prüft jede Zeile; Fehler gehen an `on_error` |
|
||||
| `filter { \|row\| … }` | Zeile bleibt, wenn der Block wahr ist |
|
||||
| `map { \|row, ctx\| … }` | gibt eine Zeile, mehrere (Array) oder keine (`nil`) zurück |
|
||||
| `header { \|ctx\| … }`, `trailer { \|ctx\| … }` | Kopf- und Schlusssatz mit den Zählern `ctx.read`, `ctx.written` |
|
||||
|
||||
### 1.2 Ausführliches Beispiel: Kundenimport
|
||||
|
||||
Eine Kundenliste (`examples/data/kunden.csv`, 9 Zeilen) soll geprüft, mit Ländernamen angereichert und in eine
|
||||
SQLite-Datenbank geschrieben werden; EU-Kunden kommen zusätzlich in eine Excel-Liste. Fehlerhafte Zeilen sollen den
|
||||
Lauf nicht abbrechen, sondern mit Grund in einer eigenen Datei landen.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CSV[kunden.csv] --> V1[validate<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
|
||||
```
|
||||
|
||||
<!-- beispiel: examples/kunden_import.rb -->
|
||||
```ruby
|
||||
#
|
||||
# Kundenliste aus CSV prüfen, mit Ländern anreichern, nach SQLite und Excel schreiben
|
||||
#
|
||||
# bin/etl run examples/kunden_import.rb [--out=tmp] [--dry-run]
|
||||
#
|
||||
require 'fileutils'
|
||||
require 'datapipeline'
|
||||
|
||||
DATA = File.expand_path('data', __dir__)
|
||||
out = DataPipeline.params.fetch(:out, 'tmp')
|
||||
FileUtils.mkdir_p(out)
|
||||
|
||||
DataPipeline.new do
|
||||
version 'kunden_import 1.0'
|
||||
|
||||
# Nachschlagetabelle, einmal geladen: { 'DE' => { 'code' => 'DE', 'name' => 'Deutschland', 'eu' => true }, ... }
|
||||
laender = lookup container(:json, file: "#{DATA}/laender.json"), key: 'code', unique: true
|
||||
|
||||
read container(:csv, file: "#{DATA}/kunden.csv", csv: { col_sep: ';' })
|
||||
|
||||
# fehlerhafte Zeilen mit Grund in eine eigene Datei, der Lauf geht weiter
|
||||
on_error :collect, to: container(:csv, file: "#{out}/kunden_fehler.csv")
|
||||
|
||||
# 1. prüfen
|
||||
validate 'kundennr' => { required: true, format: /\A\d{6}\z/ },
|
||||
'name' => { required: true, max: 40 },
|
||||
'umsatz' => { type: :decimal }
|
||||
validate { |row| "Land #{row['land']} unbekannt" unless laender.key?(row['land']) }
|
||||
|
||||
# 2. nur aktive Kunden
|
||||
filter { |row| row['status'] == 'aktiv' }
|
||||
|
||||
# 3. anreichern und umbauen
|
||||
map do |row|
|
||||
land = laender.fetch(row['land'])
|
||||
{
|
||||
'kundennr' => row['kundennr'],
|
||||
'name' => row['name'].strip,
|
||||
'land' => land['name'],
|
||||
'eu' => land['eu'] ? 'ja' : 'nein',
|
||||
'umsatz' => row['umsatz'].to_f.round(2)
|
||||
}
|
||||
end
|
||||
|
||||
# 4. schreiben: alle Kunden nach SQLite, EU-Kunden zusätzlich als Excel-Liste
|
||||
write container(:sqlite, database: "#{out}/kunden.sqlite3", table: 'kunden', create: true, truncate: true)
|
||||
write container(:excel, file: "#{out}/eu_kunden.xlsx", sheet: 'EU-Kunden'), only: ->(row) { row['eu'] == 'ja' }
|
||||
|
||||
on_finish { |stats| puts "kunden_import: #{stats}" }
|
||||
end.run
|
||||
```
|
||||
|
||||
Was die einzelnen Teile tun:
|
||||
|
||||
| Zeile | Erklärung |
|
||||
| --- | --- |
|
||||
| `DataPipeline.params.fetch(:out, 'tmp')` | Parameter von der Kommandozeile: `--out=verzeichnis` |
|
||||
| `lookup …, key: 'code', unique: true` | lädt die Länder einmal als Hash nach Ländercode; kein Zugriff je Zeile |
|
||||
| `on_error :collect, to: …` | jede Zeile, die in einem Schritt scheitert, kommt mit `_step` und `_error` in die Fehlerdatei |
|
||||
| `validate 'kundennr' => { … }` | Regeln je Spalte: `required`, `format`, `max`, `min`, `in`, `type` |
|
||||
| `validate { \|row\| … }` | eigene Regel: ein Text (oder ein Array) ist ein Fehler, `nil` heisst in Ordnung |
|
||||
| `filter` | inaktive Kunden fallen weg, ohne Fehler |
|
||||
| `map` | baut die Zielzeile; `laender.fetch` kann nicht scheitern, weil `validate` das Land schon geprüft hat |
|
||||
| `write …` zweimal | jedes Ziel bekommt die Zeilen; `only:` wählt die Zeilen für die Excel-Liste |
|
||||
| `create: true, truncate: true` | SQLite legt die Tabelle beim ersten Schreiben an und leert sie; im Dry-Run passiert nichts |
|
||||
| `on_finish` | läuft nach jedem Lauf, auch nach einem Abbruch; `stats` siehe 2.7 |
|
||||
|
||||
Ergebnis von `bin/etl run examples/kunden_import.rb`:
|
||||
|
||||
```text
|
||||
INFO start kunden_import 1.0
|
||||
WARN rejected by validate#1: name: missing
|
||||
WARN rejected by validate#1: kundennr: format /\A\d{6}\z/
|
||||
WARN rejected by validate#2: Land XX unbekannt
|
||||
WARN rejected by validate#1: umsatz: not decimal
|
||||
INFO finish read=9 written=4 rejected=4 0.097s
|
||||
kunden_import: read=9 written=4 rejected=4 0.097s
|
||||
```
|
||||
|
||||
| Datei | Inhalt |
|
||||
| --- | --- |
|
||||
| `tmp/kunden.sqlite3`, Tabelle `kunden` | 4 aktive, gültige Kunden (DE, AT, CH, IT) mit Ländername, `eu`, `umsatz` als Zahl |
|
||||
| `tmp/eu_kunden.xlsx`, Blatt `EU-Kunden` | 3 Kunden (ohne CH) |
|
||||
| `tmp/kunden_fehler.csv` | 4 Zeilen: leerer Name, Kundennummer mit 5 Stellen, Land `XX`, Umsatz `abc` – je mit `_step` und `_error` |
|
||||
|
||||
Der Exit-Code ist `2`, weil Zeilen verworfen wurden; ein Abbruch ergäbe `1`, ein fehlerfreier Lauf `0`. Mit
|
||||
`--dry-run` läuft alles gleich, es entstehen aber weder Datenbank noch Excel- oder Fehlerdatei; stattdessen stehen
|
||||
die ersten Zeilen auf dem Bildschirm.
|
||||
|
||||
### 1.3 Weitere Beispiele
|
||||
|
||||
Alle Beispiele liegen in [examples/](examples/), arbeiten mit den Daten in `examples/data/` und schreiben nach `tmp/`.
|
||||
`test/examples/doc_examples_test.rb` führt sie aus und prüft, dass der Code hier mit den Dateien übereinstimmt.
|
||||
|
||||
**Datei mit Satzarten** – Rechnungen aus verschachteltem JSON werden zu einem Kopfsatz mit Positionssätzen; Datei-
|
||||
und Schlusssatz zählen die Datensätze. Das Layout legt Reihenfolge, Typ und Länge der Felder je Satzart fest (2.9).
|
||||
|
||||
<!-- beispiel: examples/rechnungen_satzarten.rb -->
|
||||
```ruby
|
||||
#
|
||||
# Rechnungen aus verschachteltem JSON in eine Datei mit Satzarten (Datei-, Kopf-, Positions-, Schlusssatz)
|
||||
#
|
||||
# bin/etl run examples/rechnungen_satzarten.rb [--out=tmp]
|
||||
#
|
||||
require 'fileutils'
|
||||
require 'datapipeline'
|
||||
|
||||
DATA = File.expand_path('data', __dir__)
|
||||
out = DataPipeline.params.fetch(:out, 'tmp')
|
||||
FileUtils.mkdir_p(out)
|
||||
|
||||
# Feld: [Name, Typ, Länge, Pflicht]; Typ AN Text, N Zahl (11.2 = 9 Vor-, 2 Nachkommastellen), DAT YYYYMMDD
|
||||
LAYOUT = DataPipeline::Layout.new do
|
||||
record 'DATEI', [['SATYP', 'AN', 5, true], ['ERSTELLT', 'DAT', 8, true], ['ANZAHL', 'N', 6, true]]
|
||||
record 'KOPF', [['SATYP', 'AN', 5, true], ['LFDNR', 'N', 6, true], ['BELNR', 'AN', 10, true],
|
||||
['DATUM', 'DAT', 8, true], ['KUNNR', 'AN', 10, true], ['SUMME', 'N', '11.2', true]]
|
||||
record 'POS', [['SATYP', 'AN', 5, true], ['LFDNR', 'N', 6, true], ['BELNR', 'AN', 10, true],
|
||||
['POSNR', 'N', 3, true], ['TEXT', 'AN', 30], ['BETRAG', 'N', '11.2', true]]
|
||||
record 'ENDE', [['SATYP', 'AN', 5, true], ['ANZAHL', 'N', 6, true]]
|
||||
end
|
||||
|
||||
DataPipeline.new do
|
||||
read container(:json, file: "#{DATA}/rechnungen.json")
|
||||
|
||||
# 1. eine Rechnung -> Kopfsatz + n Positionssätze
|
||||
map do |rechnung|
|
||||
positionen = rechnung['positionen'].each_with_index.map do |pos, i|
|
||||
{ 'satyp' => 'POS', 'belnr' => rechnung['nr'], 'posnr' => format('%03d', i + 1),
|
||||
'text' => pos['text'], 'betrag' => format('%.2f', pos['menge'] * pos['preis']) }
|
||||
end
|
||||
kopf = { 'satyp' => 'KOPF', 'belnr' => rechnung['nr'], 'datum' => rechnung['datum'].delete('-'),
|
||||
'kunnr' => rechnung['kunde'], 'summe' => format('%.2f', positionen.sum { |p| p['betrag'].to_f }) }
|
||||
[kopf, *positionen]
|
||||
end
|
||||
|
||||
# 2. laufende Nummer über alle Kopf- und Positionssätze
|
||||
map { |row, ctx| row.merge('lfdnr' => format('%06d', ctx.index + 1)) }
|
||||
|
||||
# 3. gegen das Layout prüfen: zu langer Text wird gemeldet, die Zeile bleibt
|
||||
validate LAYOUT, warn: true
|
||||
|
||||
# Datei- und Schlusssatz kennen die Anzahl der Datensätze
|
||||
header { |ctx| { 'satyp' => 'DATEI', 'erstellt' => Date.today.strftime('%Y%m%d'), 'anzahl' => format('%06d', ctx.written) } }
|
||||
trailer { |ctx| { 'satyp' => 'ENDE', 'anzahl' => format('%06d', ctx.written) } }
|
||||
|
||||
# Felder in der Reihenfolge des Layouts; Positionen zusätzlich als NDJSON
|
||||
write container(:csv, file: "#{out}/rechnungen.txt", layout: LAYOUT, csv: { headers: false, col_sep: '|' })
|
||||
write container(:json, file: "#{out}/positionen.ndjson"), only: ->(row) { row['satyp'] == 'POS' }
|
||||
end.run
|
||||
```
|
||||
|
||||
Ergebnis `tmp/rechnungen.txt` (die Warnung meldet den Positionstext mit mehr als 30 Zeichen):
|
||||
|
||||
```text
|
||||
DATEI|20261008|000005
|
||||
KOPF|000001|R-1001|20251103|100001|380.50
|
||||
POS|000002|R-1001|001|Beratung|300.00
|
||||
POS|000003|R-1001|002|Reisekosten|80.50
|
||||
KOPF|000004|R-1002|20251105|100002|1200.00
|
||||
POS|000005|R-1002|001|Jahreslizenz Software mit Wartung und Support|1200.00
|
||||
ENDE|000005
|
||||
```
|
||||
|
||||
**Inkrementell und gestreamt** – nur Bewegungen, die seit dem letzten erfolgreichen Lauf dazugekommen sind; jede
|
||||
Zeile wird sofort geschrieben, der Speicherbedarf bleibt auch bei Millionen Zeilen klein.
|
||||
|
||||
<!-- beispiel: examples/bewegungen_inkrementell.rb -->
|
||||
```ruby
|
||||
#
|
||||
# Nur neue Buchungsbewegungen exportieren, Zeile für Zeile (Streaming)
|
||||
# Ein zweiter Lauf ohne neue Daten schreibt nichts.
|
||||
#
|
||||
# bin/etl run examples/bewegungen_inkrementell.rb [--out=tmp]
|
||||
#
|
||||
require 'fileutils'
|
||||
require 'datapipeline'
|
||||
|
||||
DATA = File.expand_path('data', __dir__)
|
||||
out = DataPipeline.params.fetch(:out, 'tmp')
|
||||
FileUtils.mkdir_p(out)
|
||||
|
||||
DataPipeline.new do
|
||||
streamed
|
||||
|
||||
# höchste id des letzten erfolgreichen Laufs, gespeichert in tmp/.state.json
|
||||
stand = watermark :bewegungen, column: 'id', file: "#{out}/.state.json", initial: 0
|
||||
|
||||
# bei einer Datenbank gehört der Filter ins SQL: where id > $1, params: [stand.value]
|
||||
read container(:json, file: "#{DATA}/bewegungen.ndjson")
|
||||
filter { |row| row['id'] > stand.value.to_i }
|
||||
map { |row| row.merge('soll_haben' => row['betrag'].negative? ? 'H' : 'S', 'betrag' => row['betrag'].abs) }
|
||||
|
||||
write container(:json,
|
||||
file: "#{out}/bewegungen_#{Time.now.strftime('%Y%m%d_%H%M%S')}.ndjson"
|
||||
)
|
||||
|
||||
on_finish { |stats| puts "neu: #{stats.written}, Stand jetzt: #{stand.current}" }
|
||||
end.run
|
||||
```
|
||||
|
||||
Der erste Lauf schreibt 4 Bewegungen und merkt sich `4`; der zweite schreibt nichts (`neu: 0`) und legt auch keine
|
||||
Datei an. Bricht ein Lauf ab, bleibt der alte Stand stehen.
|
||||
|
||||
**Eigenes Plugin** – eine Quelle, die alle CSV-Dateien eines Verzeichnisses liest. Ein Plugin braucht nur die
|
||||
Methoden, die es anbietet (hier `read`); die Schnittstelle steht in 2.4.
|
||||
|
||||
<!-- beispiel: examples/eigenes_plugin.rb -->
|
||||
```ruby
|
||||
#
|
||||
# Eigenes Plugin: liest alle CSV-Dateien eines Verzeichnisses als eine Quelle
|
||||
#
|
||||
# bin/etl run examples/eigenes_plugin.rb [--out=tmp]
|
||||
#
|
||||
require 'fileutils'
|
||||
require 'datapipeline'
|
||||
|
||||
DATA = File.expand_path('data', __dir__)
|
||||
out = DataPipeline.params.fetch(:out, 'tmp')
|
||||
FileUtils.mkdir_p(out)
|
||||
|
||||
class VerzeichnisStorage < DataPipeline::StoragePlugin
|
||||
def initialize dir:, pattern: '*.csv', **csv_options
|
||||
@dir = dir
|
||||
@pattern = pattern
|
||||
@csv_options = csv_options
|
||||
end
|
||||
|
||||
# jede Zeile bekommt den Dateinamen dazu; mit Block zeilenweise (für streamed)
|
||||
def read(&block)
|
||||
rows = Dir.glob(File.join(@dir, @pattern)).sort.flat_map do |file|
|
||||
DataPipeline::CsvStorage.new(file:, **@csv_options).read.map { |row| row.merge('datei' => File.basename(file, '.*')) }
|
||||
end
|
||||
return rows unless block
|
||||
|
||||
rows.each(&block)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
DataPipeline.register :verzeichnis, VerzeichnisStorage
|
||||
|
||||
DataPipeline.new do
|
||||
read container(:verzeichnis, dir: "#{DATA}/filialen")
|
||||
map { |row| row.merge('menge' => row['menge'].to_i) }
|
||||
write container(:csv, file: "#{out}/alle_filialen.csv", columns: %w[datei artikel menge])
|
||||
end.run
|
||||
```
|
||||
|
||||
Ergebnis `tmp/alle_filialen.csv`:
|
||||
|
||||
```text
|
||||
datei,artikel,menge
|
||||
vaduz,A-100,5
|
||||
vaduz,B-200,2
|
||||
zuerich,A-100,7
|
||||
zuerich,C-300,1
|
||||
```
|
||||
|
||||
Weitere Ausschnitte: REST-API nach Excel in 2.5 (REST ausführlich in 1.4), Benachrichtigung über Slack in 2.7.
|
||||
|
||||
### 1.4 Beispiele mit REST-APIs
|
||||
|
||||
Die drei Beispiele laufen gegen eine kleine Demo-API ([examples/demo_api.rb](examples/demo_api.rb)), die ohne Internet
|
||||
und ohne weitere Gems auskommt. Sie verlangt ein Token, liefert Seiten und Folge-Links, nimmt Daten per POST und PUT an
|
||||
und antwortet einmal absichtlich mit `503`, damit die Wiederholung sichtbar wird.
|
||||
|
||||
```sh
|
||||
ruby examples/demo_api.rb & # http://127.0.0.1:4567, Token demo-token
|
||||
bin/etl run examples/rest_lesen.rb
|
||||
bin/etl run examples/rest_schreiben.rb
|
||||
bin/etl run examples/rest_zu_rest.rb
|
||||
```
|
||||
|
||||
Gegen eine echte API ändern sich nur `--url=…` und das Token (`DEMO_API_TOKEN`, in der Praxis aus der `.env`).
|
||||
|
||||
| Endpunkt der Demo-API | Antwort |
|
||||
| --- | --- |
|
||||
| `GET /api/kunden?page=1&per_page=3` | `{"data": [...], "page": 1}`; Seite 2 antwortet einmal mit `503` |
|
||||
| `GET /api/laender` | `[{"code": "DE", "name": "Deutschland"}, ...]` |
|
||||
| `GET /api/bestellungen?cursor=n` | `{"items": [...], "next": "/api/bestellungen?cursor=n+2"}`, auf der letzten Seite `"next": null` |
|
||||
| `POST /api/import` | ein Objekt oder Array; `422`, wenn `kundennr` oder `name` fehlt |
|
||||
| `PUT /api/archiv/<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.
|
||||
|
||||
<!-- beispiel: examples/rest_lesen.rb -->
|
||||
```ruby
|
||||
#
|
||||
# Kunden seitenweise aus einer REST-API lesen, mit Ländern aus einem zweiten Endpunkt anreichern,
|
||||
# nach CSV und JSON schreiben
|
||||
#
|
||||
# ruby examples/demo_api.rb & # Demo-API starten
|
||||
# bin/etl run examples/rest_lesen.rb [--url=http://127.0.0.1:4567] [--out=tmp]
|
||||
#
|
||||
require 'fileutils'
|
||||
require 'datapipeline'
|
||||
|
||||
API = DataPipeline.params.fetch(:url, 'http://127.0.0.1:4567')
|
||||
AUTH = "Bearer #{ENV.fetch('DEMO_API_TOKEN', 'demo-token')}"
|
||||
out = DataPipeline.params.fetch(:out, 'tmp')
|
||||
FileUtils.mkdir_p(out)
|
||||
|
||||
DataPipeline.new do
|
||||
version 'rest_lesen 1.0'
|
||||
|
||||
# GET /api/kunden?page=1&per_page=3, dann page=2, ... bis eine Seite leer ist
|
||||
# die Zeilen stehen in der Antwort unter "data"; 429/5xx und Netzfehler werden bis zu 3-mal wiederholt
|
||||
kunden = container :rest, url: "#{API}/api/kunden", auth: AUTH,
|
||||
root: 'data', page_param: 'page', params: { 'per_page' => 3 },
|
||||
retries: 3, retry_wait: 0.2
|
||||
|
||||
# derselbe Server, anderer Endpunkt: query hängt den Pfad an die url an (GET /api/laender)
|
||||
stamm = container :rest, url: "#{API}/api", auth: AUTH
|
||||
laender = lookup stamm, query: 'laender', key: 'code', unique: true
|
||||
|
||||
read kunden
|
||||
|
||||
on_error :collect, to: container(:csv, file: "#{out}/api_fehler.csv")
|
||||
validate 'id' => { required: true }, 'umsatz' => { type: :decimal }
|
||||
|
||||
map do |kunde|
|
||||
{
|
||||
'id' => kunde['id'],
|
||||
'name' => kunde['name'],
|
||||
'land' => laender.dig(kunde['land'], 'name') || kunde['land'],
|
||||
'umsatz' => kunde['umsatz'].to_f
|
||||
}
|
||||
end
|
||||
|
||||
write container(:csv, file: "#{out}/api_kunden.csv", columns: %w[id name land umsatz])
|
||||
write container(:json, file: "#{out}/api_kunden.json", pretty: true)
|
||||
|
||||
on_finish { |stats| puts "rest_lesen: #{stats}" }
|
||||
end.run
|
||||
```
|
||||
|
||||
```text
|
||||
INFO start rest_lesen 1.0
|
||||
WARN rejected by validate#1: umsatz: not decimal
|
||||
INFO finish read=7 written=6 rejected=1 0.213s
|
||||
```
|
||||
|
||||
Gelesen werden 7 Kunden in 3 Seiten (plus eine leere Seite als Ende); die `503` auf Seite 2 bemerkt man nur an der
|
||||
Laufzeit. Kunde 5 hat den Umsatz `n/a` und steht in `tmp/api_fehler.csv`. Ergebnis `tmp/api_kunden.csv`:
|
||||
|
||||
```text
|
||||
id,name,land,umsatz
|
||||
1,Müller GmbH,Deutschland,12500.5
|
||||
2,Huber & Partner,Österreich,8300.0
|
||||
3,Swiss Trade AG,Schweiz,22000.75
|
||||
4,Rossi Srl,Italien,4100.1
|
||||
6,Nordic AB,SE,990.0
|
||||
7,Vaduz Treuhand,Liechtenstein,15000.0
|
||||
```
|
||||
|
||||
Schweden fehlt in `/api/laender`, deshalb bleibt bei Kunde 6 der Code `SE` stehen.
|
||||
|
||||
**Schreiben: prüfen, dann in Paketen senden** – `batch_size: 2` schickt je 2 Zeilen als JSON-Array. Was die API
|
||||
ablehnen würde, wird vorher geprüft: Lehnt die API ein Paket trotzdem ab (`4xx`), bricht der Lauf ab, weil ein Fehler
|
||||
beim Schreiben nicht einer einzelnen Zeile zugeordnet werden kann.
|
||||
|
||||
<!-- beispiel: examples/rest_schreiben.rb -->
|
||||
```ruby
|
||||
#
|
||||
# Kunden aus CSV prüfen und in Paketen per POST an eine REST-API senden
|
||||
#
|
||||
# ruby examples/demo_api.rb &
|
||||
# bin/etl run examples/rest_schreiben.rb [--url=http://127.0.0.1:4567] [--dry-run]
|
||||
#
|
||||
require 'fileutils'
|
||||
require 'datapipeline'
|
||||
|
||||
API = DataPipeline.params.fetch(:url, 'http://127.0.0.1:4567')
|
||||
AUTH = "Bearer #{ENV.fetch('DEMO_API_TOKEN', 'demo-token')}"
|
||||
DATA = File.expand_path('data', __dir__)
|
||||
out = DataPipeline.params.fetch(:out, 'tmp')
|
||||
FileUtils.mkdir_p(out)
|
||||
|
||||
DataPipeline.new do
|
||||
version 'rest_schreiben 1.0'
|
||||
|
||||
read container(:csv, file: "#{DATA}/kunden.csv", csv: { col_sep: ';' })
|
||||
|
||||
# vor dem Senden prüfen: was die API ablehnen würde, landet hier in der Fehlerdatei
|
||||
on_error :collect, to: container(:csv, file: "#{out}/import_fehler.csv")
|
||||
validate 'kundennr' => { required: true, format: /\A\d{6}\z/ }, 'name' => { required: true }
|
||||
|
||||
filter { |row| row['status'] == 'aktiv' }
|
||||
map do |row|
|
||||
{ 'kundennr' => row['kundennr'], 'name' => row['name'].strip, 'land' => row['land'], 'umsatz' => row['umsatz'].to_f }
|
||||
end
|
||||
|
||||
# POST /api/import mit je 2 Kunden als JSON-Array; im Dry-Run wird nichts gesendet
|
||||
write container(:rest, url: "#{API}/api/import", auth: AUTH, batch_size: 2)
|
||||
|
||||
on_finish do |stats|
|
||||
puts stats.dry_run ? 'Dry-Run: nichts gesendet' : "gesendet: #{stats.written} Kunden in #{(stats.written / 2.0).ceil} Paketen"
|
||||
end
|
||||
end.run
|
||||
```
|
||||
|
||||
Ergebnis: 6 aktive Kunden in 3 Anfragen an `POST /api/import`, 2 Kunden in `tmp/import_fehler.csv` (leerer Name,
|
||||
Kundennummer mit 5 Stellen). Mit `--dry-run` wird nichts gesendet; auf dem Bildschirm stehen die Daten, die gesendet
|
||||
würden:
|
||||
|
||||
```text
|
||||
DRY RUN – nothing written: read=9 written=6 rejected=2 0.0s (dry run)
|
||||
{"kundennr":"100001","name":"Müller GmbH","land":"DE","umsatz":12500.5}
|
||||
...
|
||||
```
|
||||
|
||||
**Von API zu API: Folge-Links, Streaming, eine URL je Datensatz** – Die Antwort nennt die nächste Seite selbst; das
|
||||
Lambda in `next:` macht daraus eine vollständige URL. `write_url` als Lambda schickt jede Bestellung an ihre eigene
|
||||
Adresse. Mit dem Wasserzeichen archiviert ein zweiter Lauf nur, was neu dazugekommen ist.
|
||||
|
||||
<!-- beispiel: examples/rest_zu_rest.rb -->
|
||||
```ruby
|
||||
#
|
||||
# Von API zu API: neue Bestellungen über Folge-Links lesen und einzeln per PUT archivieren
|
||||
# Ein zweiter Lauf ohne neue Bestellungen sendet nichts.
|
||||
#
|
||||
# ruby examples/demo_api.rb &
|
||||
# bin/etl run examples/rest_zu_rest.rb [--url=http://127.0.0.1:4567] [--out=tmp]
|
||||
#
|
||||
require 'fileutils'
|
||||
require 'datapipeline'
|
||||
|
||||
API = DataPipeline.params.fetch(:url, 'http://127.0.0.1:4567')
|
||||
AUTH = "Bearer #{ENV.fetch('DEMO_API_TOKEN', 'demo-token')}"
|
||||
out = DataPipeline.params.fetch(:out, 'tmp')
|
||||
FileUtils.mkdir_p(out)
|
||||
|
||||
DataPipeline.new do
|
||||
streamed
|
||||
|
||||
# höchste Bestellnummer des letzten erfolgreichen Laufs
|
||||
stand = watermark :bestellungen, column: 'id', file: "#{out}/.state.json", initial: 0
|
||||
|
||||
# die Antwort nennt die nächste Seite selbst: {"items": [...], "next": "/api/bestellungen?cursor=2"}
|
||||
read container(:rest, url: "#{API}/api/bestellungen", auth: AUTH, root: 'items',
|
||||
next: ->(body) { body['next'] && "#{API}#{body['next']}" })
|
||||
|
||||
filter { |row| row['id'] > stand.value.to_i }
|
||||
map { |row| row.merge('archiviert' => Date.today.iso8601) }
|
||||
|
||||
# eine url je Bestellung: PUT /api/archiv/<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
|
||||
```
|
||||
|
||||
```text
|
||||
archiviert: 5, Stand jetzt: 5 # erster Lauf: PUT /api/archiv/1 … /5
|
||||
archiviert: 0, Stand jetzt: 5 # zweiter Lauf: keine Anfrage
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 2. Technische Dokumentation
|
||||
|
||||
### 2.1 Aufbau
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Pipeline-Skript
|
||||
DSL[DataPipeline.new do ... end]
|
||||
end
|
||||
DSL -->|container :typ| REG[Registry / autoload<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](lib/datapipeline.rb) | DSL und Ausführung (`run`), `Stats`, `Context` |
|
||||
| [lib/datapipeline/validation.rb](lib/datapipeline/validation.rb) | Regeln für `validate` |
|
||||
| [lib/datapipeline/watermark.rb](lib/datapipeline/watermark.rb) | Wasserzeichen für inkrementelle Läufe |
|
||||
| [lib/datapipeline/layout.rb](lib/datapipeline/layout.rb) | Satzarten-Layouts (Feldliste je Satzart, siehe 2.9) |
|
||||
| [exe/etl](exe/etl) | Kommando `etl` des Gems mit Exit-Codes (siehe 2.7); [bin/etl](bin/etl) startet es im Repository mit dem Bundle |
|
||||
| [datapipeline.gemspec](datapipeline.gemspec), [CHANGELOG.md](CHANGELOG.md) | Gem-Beschreibung, Versionsverlauf |
|
||||
| [examples/](examples/) | Beispiele aus Abschnitt 1 mit Daten in `examples/data/`, Demo-API für die REST-Beispiele |
|
||||
| [lib/datapipeline/storage/storage_plugin.rb](lib/datapipeline/storage/storage_plugin.rb) | Basisklasse der Storage-Plugins |
|
||||
| [lib/datapipeline/storage/postgres.rb](lib/datapipeline/storage/postgres.rb) | Quelle/Ziel PostgreSQL (`pg`) |
|
||||
| [lib/datapipeline/storage/csv.rb](lib/datapipeline/storage/csv.rb) | Quelle/Ziel CSV |
|
||||
| [lib/datapipeline/storage/json.rb](lib/datapipeline/storage/json.rb) | Quelle/Ziel JSON und NDJSON |
|
||||
| [lib/datapipeline/storage/sqlite.rb](lib/datapipeline/storage/sqlite.rb) | Quelle/Ziel SQLite (`sqlite3`) |
|
||||
| [lib/datapipeline/storage/excel.rb](lib/datapipeline/storage/excel.rb) | Quelle/Ziel Excel xlsx (`roo`, `caxlsx`) |
|
||||
| [lib/datapipeline/storage/rest.rb](lib/datapipeline/storage/rest.rb) | Quelle/Ziel REST-API (`http`) |
|
||||
| [lib/datapipeline/notification/slack.rb](lib/datapipeline/notification/slack.rb) | Benachrichtigung, z. B. aus `on_finish` |
|
||||
| [lib/datapipeline/version.rb](lib/datapipeline/version.rb) | `DataPipeline::VERSION` |
|
||||
|
||||
### 2.2 DSL-Referenz
|
||||
|
||||
| Aufruf | Wirkung |
|
||||
| --- | --- |
|
||||
| `DataPipeline.settings(opts)` | Defaults für alle folgenden Pipelines; der Hash wird ersetzt, nicht verändert (laufende Pipelines behalten ihre Kopie); `reset_settings!` setzt zurück |
|
||||
| `DataPipeline.register(:typ, Klasse \| 'Klassenname', path:)` | eigenes Plugin; mit `path:` erst bei der ersten Nutzung geladen |
|
||||
| `DataPipeline.new(**settings) { ... }` | führt den Block per `instance_eval` aus; `settings` gelten nur für diese Pipeline, z. B. `new(limit: 10)` |
|
||||
| `configure(opts)` | Einstellungen dieser Pipeline als Hash: `verbose`, `streamed`, `chained`, `version` |
|
||||
| `version(str)`, `verbose(flag = true)`, `streamed(flag = true)`, `chained(flag = true)` | dasselbe als DSL-Aufruf |
|
||||
| `container(:typ, **opts)` | erzeugt das registrierte Plugin, sonst `<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).
|
||||
|
||||
```ruby
|
||||
pg = container :postgres, host: 'localhost', database: 'crm', username: 'etl', table: 'kunde_import'
|
||||
read pg, query: 'select * from kunde where geaendert >= $1', params: [Date.today - 7]
|
||||
```
|
||||
|
||||
**DataPipeline::CsvStorage** – Optionen `file` (Pflicht), `csv:` (an Rubys `CSV`, gemergt mit den Defaults `headers: true`,
|
||||
`col_sep: ','`), `columns`, `counter`, `verbose`.
|
||||
- `columns: %w[a b c]` legt die Spaltenreihenfolge fest: fehlende Felder bleiben leer, String- und Symbol-Schlüssel
|
||||
werden gefunden, die Kopfzeile kommt aus `columns`. Ohne `columns` gilt die Hash-Reihenfolge (nötig für Dateien mit
|
||||
mehreren Satzarten wie CUDO).
|
||||
- Lesen ohne Kopfzeile (`headers: false`): Schlüssel aus `columns`, sonst `"0"`, `"1"`, …
|
||||
- Leere Daten: ohne `columns` leere Datei, mit `columns` nur die Kopfzeile.
|
||||
- `layout: DataPipeline::Layout` schreibt Dateien mit mehreren Satzarten in der Feldreihenfolge je Satzart, ohne
|
||||
Kopfzeile; ein Feld, das nicht im Layout steht, bricht ab (siehe 2.9).
|
||||
|
||||
Übersicht der übrigen Plugins (alle mit `read`, `read { }`, `write`, `write_row`, `close`):
|
||||
|
||||
| `container` | Klasse | Gem (bei Nutzung geladen) | Wichtige Optionen |
|
||||
| --- | --- | --- | --- |
|
||||
| `:json` | `DataPipeline::JsonStorage` | – | `file`, `format: :array \| :lines` (Default aus Endung: `.ndjson`/`.jsonl` → Zeilen), `root: 'data.items'`, `pretty`; ein einzelnes Objekt wird als eine Zeile gelesen |
|
||||
| `:sqlite`, `:sqlite3` | `DataPipeline::SqliteStorage` | `sqlite3` | `database` (Datei oder `':memory:'`), `query`, `params` (Array für `?`, Hash für `:name`), `table`, `create`, `truncate` |
|
||||
| `:excel`, `:xlsx` | `DataPipeline::ExcelStorage` | `roo` (lesen), `caxlsx` (schreiben) | `file`, `sheet` (Name/Index), `headers`, `columns`, `skip` (Titelzeilen) |
|
||||
| `:rest` | `DataPipeline::RestStorage` | `http` | `url`, `auth`, `headers`, `params`, `root`, Paging `page_param` oder `next: ->(body) { … }`, `retries`, `timeout`, `batch_size`, `write_method`, `write_url` (auch als Lambda je Zeile), `ssl_verify` |
|
||||
|
||||
**DataPipeline::JsonStorage** – NDJSON wird zeilenweise gelesen und geschrieben. Ein JSON-Array wird beim Streaming als
|
||||
`[`…`]` geschrieben und in `close` abgeschlossen, die Datei ist also auch beim Streaming gültiges JSON.
|
||||
|
||||
**DataPipeline::SqliteStorage** – Verhält sich wie Postgres: Bind-Parameter, Schreiben in einer Transaktion, Tabellen- und
|
||||
Spaltennamen gequotet, `nil` = `NULL`, Datum als ISO-Text. Werte kommen mit SQLite-Typen zurück (Integer, Float, String).
|
||||
`create: true` legt die Tabelle beim ersten Schreiben aus der ersten Zeile an (Integer → `integer`, Float → `real`,
|
||||
sonst `text`), `truncate: true` leert sie vorher. Die Datei wird erst bei der ersten Nutzung geöffnet; ein Dry-Run
|
||||
legt keine an.
|
||||
|
||||
**DataPipeline::ExcelStorage** – Lesen von xlsx zeilenweise (`each_row_streaming`), leere Zeilen werden übersprungen. Beim
|
||||
Schreiben bleibt Text immer Text: `'0000000001'` behält die Nullen, `'=1+1'` wird keine Formel. Zahlen und Datumswerte
|
||||
behalten ihren Typ. Die Datei entsteht in `close` (caxlsx baut sie im Speicher auf).
|
||||
|
||||
**DataPipeline::RestStorage** – Liest JSON, der Pfad zum Array kommt aus `root`. Paging über eine Seitennummer (bis eine Seite
|
||||
leer ist) oder über eine Folge-URL aus der Antwort; `max_pages` (Default 1000) stoppt endlose Schleifen. Bei 429, 5xx
|
||||
und Netzwerkfehlern wird `retries`-mal wiederholt (Wartezeit `retry_wait` × Versuch), 4xx bricht sofort mit
|
||||
`DataPipeline::RestStorage::Error` ab. Schreiben je Zeile oder mit `batch_size` als JSON-Array; `write_url: ->(row) { … }` baut
|
||||
je Zeile eine eigene URL (z. B. `PUT /kunden/<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).
|
||||
|
||||
```ruby
|
||||
api = container :rest, url: 'https://api.example.com/v1/kunden', auth: "Bearer #{ENV['API_TOKEN']}",
|
||||
root: 'data', page_param: 'page'
|
||||
xlsx = container :excel, file: 'data/kunden.xlsx', sheet: 'Kunden', columns: %w[id name land]
|
||||
read api
|
||||
map { |r| r.slice('id', 'name', 'land') }
|
||||
write xlsx
|
||||
```
|
||||
|
||||
### 2.6 Abhängigkeiten
|
||||
|
||||
Das Gem selbst braucht nur `csv` und `logger` (Ruby `>= 3.4`). Die Gems der Plugins sind optional und gehören ins
|
||||
Gemfile der Anwendung, die das Plugin nutzt:
|
||||
|
||||
| Plugin | Gem |
|
||||
| --- | --- |
|
||||
| `:postgres` | `pg` |
|
||||
| `:sqlite` | `sqlite3` |
|
||||
| `:excel` | `roo` (lesen), `caxlsx` (schreiben) |
|
||||
| `:rest`, `DataPipeline::Slack` | `http` |
|
||||
| `:csv`, `:json` | – |
|
||||
|
||||
Im Repository bindet das `Gemfile` das Gem über `gemspec` ein und ergänzt die Gruppe `plugins` (`pg`, `sqlite3`,
|
||||
`roo`, `caxlsx`, `http`) für die Plugin-Tests und die Gruppe `test` (`minitest`, `rake`). `Gemfile.lock` ist versioniert.
|
||||
Gem bauen: `gem build datapipeline.gemspec`.
|
||||
|
||||
Plugins und ihre Gems werden erst bei der ersten Nutzung geladen (`autoload` und `DataPipeline::StoragePlugin.require_gem`): eine
|
||||
CSV- oder JSON-Pipeline lädt keines dieser Gems. Wer ein Plugin nicht braucht, lässt die Gruppe weg
|
||||
(`bundle config set without plugins`); bei Nutzung meldet das Plugin dann, welches Gem fehlt.
|
||||
|
||||
### 2.7 Betrieb
|
||||
|
||||
**Fehler je Zeile** – `on_error` legt fest, was passiert, wenn ein Datenschritt (`transform`, `map`, `filter`,
|
||||
`validate`) für eine Zeile eine Exception wirft. Fehler in `header`, `trailer`, prepend und append brechen immer ab.
|
||||
|
||||
| Modus | Wirkung |
|
||||
| --- | --- |
|
||||
| `:abort` (Default) | Lauf bricht ab, Exception wird weitergereicht |
|
||||
| `:skip` | Zeile fällt weg, Warnung im Log, Zähler `rejected` |
|
||||
| `:collect, to: storage` | wie `:skip`, zusätzlich die Eingangszeile mit `_step` und `_error` per `write_row` ins Ziel `storage` |
|
||||
|
||||
**Validierung** – `validate` prüft Spalten nach Regeln, nach einem Layout (2.9) und/oder mit einem Block. Eine
|
||||
verletzte Regel löst `DataPipeline::ValidationError` aus und läuft damit über `on_error`. Leere Werte prüft nur
|
||||
`required`. Mit `warn: true` bleibt die Zeile drin, es gibt eine Warnung und `stats.warnings` zählt mit (ins Log
|
||||
kommen höchstens 100); `ignore: %w[FELD]` lässt bekannte Lücken aus.
|
||||
|
||||
```ruby
|
||||
on_error :collect, to: container(:csv, file: 'data/fehler.csv', columns: %w[belnr konto dmbtr _step _error])
|
||||
validate 'konto' => { required: true, format: /\A\d+\z/ },
|
||||
'shkzg' => { in: %w[S H] },
|
||||
'dmbtr' => { type: :decimal },
|
||||
'name1' => { max: 35 }
|
||||
validate { |row| 'Betrag 0' if row['dmbtr'].to_f.zero? }
|
||||
```
|
||||
|
||||
**Ergebnis, Log, Hooks** – `run` liefert `DataPipeline::Stats`:
|
||||
|
||||
| Feld | Inhalt |
|
||||
| --- | --- |
|
||||
| `read` | gelesene Quellzeilen |
|
||||
| `written` | Datenzeilen nach allen Schritten (ohne Kopf- und Schlusssätze) |
|
||||
| `output` | an die Ziele gegebene Zeilen inkl. Kopf- und Schlusssätzen |
|
||||
| `rejected`, `errors` | verworfene Zeilen; die ersten 100 mit Schritt, Meldung und Zeile |
|
||||
| `warnings` | Zeilen mit Warnung aus `validate …, warn: true` |
|
||||
| `duration`, `started_at`, `finished_at` | Laufzeit |
|
||||
| `error` | Exception eines abgebrochenen Laufs |
|
||||
| `ok?` | kein Abbruch und nichts verworfen |
|
||||
| `to_s` | `read=9 written=4 rejected=4 0.097s` |
|
||||
|
||||
Der Logger schreibt `start` und `finish` (Info), jede verworfene Zeile (Warnung) und einen Abbruch (Fehler).
|
||||
`on_finish` läuft nach jedem Lauf, auch nach einem Abbruch; ein Fehler im Hook wird nur geloggt.
|
||||
|
||||
```ruby
|
||||
on_finish { |stats| DataPipeline::Slack.new(channel: '#etl').send_message("Buchungen: #{stats}") unless stats.ok? }
|
||||
```
|
||||
|
||||
**Inkrementelle Läufe** – `watermark` merkt sich je Name den höchsten Wert einer Spalte in einer JSON-Datei. Die
|
||||
Pipeline verfolgt jede Quellzeile und speichert den neuen Wert nur nach einem erfolgreichen Lauf (nicht nach Abbruch,
|
||||
nicht im Dry-Run). Zahlen als Text werden numerisch verglichen. Mit `limit` sollte die Quelle nach der Spalte
|
||||
sortiert sein; verworfene Zeilen schieben das Wasserzeichen mit (sie liegen bei `:collect` in der Fehlerdatei).
|
||||
|
||||
```ruby
|
||||
wm = watermark :rechnungen, column: 'rechnung_id', file: 'data/.state.json', initial: 0
|
||||
read pg, query: 'select * from rechnung where rechnung_id > $1 order by rechnung_id', params: [wm.value]
|
||||
```
|
||||
|
||||
**Dry-Run und Limit** – Im Dry-Run läuft alles, aber nichts wird geschrieben: kein Ziel, keine Fehlerdatei, kein
|
||||
Wasserzeichen; eine CSV-Datei wird nicht angelegt. Am Ende stehen die Zusammenfassung und die ersten Zeilen
|
||||
(`preview`, Default 10) als JSON auf `$stdout`. `limit n` verarbeitet nur die ersten n Quellzeilen, im Streaming
|
||||
wird das Lesen dann beendet.
|
||||
|
||||
**Kommandozeile** – `bin/etl` startet ein Pipeline-Skript; `run` ist optional (`bin/etl skript.rb` genügt):
|
||||
|
||||
```sh
|
||||
bin/etl run export.rb --from=2025-11-01 --till=2025-11-30
|
||||
bin/etl run examples/kunden_import.rb --dry-run --limit=20
|
||||
bin/etl run pipeline.rb --param land=DE # im Skript: params[:land]
|
||||
```
|
||||
|
||||
| Option | Wirkung |
|
||||
| --- | --- |
|
||||
| `--from`, `--till` | in `params[:from]`/`params[:till]` und unverändert an das Skript weitergereicht (eigener `OptionParser` der Importer) |
|
||||
| `--param KEY=VALUE` | `params[:key]`, mehrfach möglich |
|
||||
| `--dry-run`, `--limit=N` | für alle Pipelines des Skripts |
|
||||
| `--verbose`, `--quiet` | Log-Level Debug bzw. Warnung (Default über `bin/etl`: Info) |
|
||||
| jede andere `--name=wert` | `params[:name]` und unverändert an das Skript, z. B. `--out=data`, `--incremental=1` |
|
||||
|
||||
Exit-Code: `0` ok, `1` Abbruch, `2` fertig, aber mit verworfenen Zeilen. Das `Rakefile` ruft `bin/etl` auf:
|
||||
`rake process:posting[2025-11-01,2025-11-30]`, `rake process:all[2025-11-01,2025-11-30,dry]`. Ein fehlgeschlagener Lauf stoppt Rake.
|
||||
|
||||
### 2.8 Tests
|
||||
|
||||
`rake test` (oder `bundle exec rake`) startet die Minitest-Suite in [test/](test/): rund 165 Fälle für Plugins, Kern,
|
||||
Streaming, Betrieb, Kommandozeile und die Beispiele.
|
||||
|
||||
| Ordner | Inhalt |
|
||||
| --- | --- |
|
||||
| `test/plugins` | je Plugin das gemeinsame Testkit `test/support/storage_contract.rb` (Schreiben und Zurücklesen, zeilenweise, als Quelle und Ziel einer Pipeline) plus eigene Fälle; REST gegen einen lokalen Testserver; Laden der Gems erst bei Nutzung in einem frischen Prozess |
|
||||
| `test/core` | DSL, Einstellungen, Plugin-Registry, Parallel- und Verkettungsmodus, Streaming, Lookups, mehrere Ziele, Betrieb; `compat_test.rb` hält die Ausgabe des `transform`-Modells von v1.2.1 fest |
|
||||
| `test/cli` | `bin/etl` als Prozess: Weitergabe von `--from`/`--till`, Parameter, Dry-Run, Exit-Codes |
|
||||
| `test/examples` | die Beispiele aus Abschnitt 1 (Code in der Doku = Datei, Lauf und Ergebnis; REST gegen die Demo-API) |
|
||||
|
||||
Die `.env` wird in Tests nicht geladen; kein Test erreicht die Produktivdatenbank. Postgres-Tests legen je Test eine
|
||||
Wegwerf-Datenbank auf einem lokalen Server an (`ETL_TEST_PG_HOST`, `_PORT`, `_USER`, `_PASSWORD`; Default
|
||||
`localhost`, `$USER`). Ohne erreichbaren Server werden sie übersprungen (21 Tests). Ein eigenes Plugin lässt sich mit
|
||||
`MemoryStorage` aus `test/test_helper.rb` als Quelle oder Ziel testen; mit `include StorageContract` und einer
|
||||
Methode `contract_storage(env, :write | :read)` bekommt es die gemeinsamen Tests.
|
||||
|
||||
### 2.9 Satzarten-Layouts
|
||||
|
||||
Dateien mit mehreren Satzarten (Kopf, Positionen, Schluss) haben je Satzart eine feste Feldliste. `DataPipeline::Layout`
|
||||
beschreibt sie; die Satzart einer Zeile ist ihr erster Wert (oder `type_field:`), Schlüssel werden ohne Rücksicht auf
|
||||
Gross-/Kleinschreibung zugeordnet.
|
||||
|
||||
```ruby
|
||||
LAYOUT = DataPipeline::Layout.new do
|
||||
record 'DOHD', [['SATYP', 'AN', 4, true], ['LFDNR', 'N', 10, true], ['BKTXT', 'AN', 25, true], ...]
|
||||
record 'LARC', [['SATYP', 'AN', 4, true], ['SUMME', 'N', 10, true]]
|
||||
end
|
||||
|
||||
validate LAYOUT, warn: true # Pflicht, Länge, Format je Feld
|
||||
write container(:csv, file: 'x.ok', layout: LAYOUT, csv: { headers: false, col_sep: '|' })
|
||||
```
|
||||
|
||||
| Typ | Prüfung |
|
||||
| --- | --- |
|
||||
| `AN` | Länge als Text |
|
||||
| `N` | Zahl; Länge `10` = Stellen, `13.2` = 11 Vor- und 2 Nachkommastellen |
|
||||
| `DAT` | `YYYYMMDD`, gültiges Datum |
|
||||
| `TIM` | `HHMMSS` |
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user