1119 lines
59 KiB
Markdown
1119 lines
59 KiB
Markdown
# DataPipeline – generisches ETL-Framework in Ruby
|
||
|
||
Stand: 2026-10-09 · Gem `datapipeline` 2.1.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` (`:integer`, `:decimal`, `:date`; siehe 2.7) |
|
||
| `validate { \|row\| … }` | eigene Regel: ein Text (oder ein Array) ist ein Fehler, `nil` heisst in Ordnung |
|
||
| `filter` | inaktive Kunden fallen weg, ohne Fehler |
|
||
| `map` | baut die Zielzeile; `laender.fetch` kann nicht scheitern, weil `validate` das Land schon geprüft hat |
|
||
| `write …` zweimal | jedes Ziel bekommt die Zeilen; `only:` wählt die Zeilen für die Excel-Liste |
|
||
| `create: true, truncate: true` | SQLite legt die Tabelle beim ersten Schreiben an und leert sie; im Dry-Run passiert nichts |
|
||
| `on_finish` | läuft nach jedem Lauf, auch nach einem Abbruch; `stats` siehe 2.7 |
|
||
|
||
Ergebnis von `bin/etl run examples/kunden_import.rb`:
|
||
|
||
```text
|
||
INFO start kunden_import 1.0
|
||
WARN rejected by validate#1: name: missing
|
||
WARN rejected by validate#1: kundennr: format /\A\d{6}\z/
|
||
WARN rejected by validate#2: Land XX unbekannt
|
||
WARN rejected by validate#1: umsatz: not decimal
|
||
INFO finish read=9 written=4 rejected=4 0.097s
|
||
kunden_import: read=9 written=4 rejected=4 0.097s
|
||
```
|
||
|
||
| Datei | Inhalt |
|
||
| --- | --- |
|
||
| `tmp/kunden.sqlite3`, Tabelle `kunden` | 4 aktive, gültige Kunden (DE, AT, CH, IT) mit Ländername, `eu`, `umsatz` als Zahl |
|
||
| `tmp/eu_kunden.xlsx`, Blatt `EU-Kunden` | 3 Kunden (ohne CH) |
|
||
| `tmp/kunden_fehler.csv` | 4 Zeilen: leerer Name, Kundennummer mit 5 Stellen, Land `XX`, Umsatz `abc` – je mit `_step` und `_error` |
|
||
|
||
Der Exit-Code ist `2`, weil Zeilen verworfen wurden; ein Abbruch ergäbe `1`, ein fehlerfreier Lauf `0`. Mit
|
||
`--dry-run` läuft alles gleich, es entstehen aber weder Datenbank noch Excel- oder Fehlerdatei; stattdessen stehen
|
||
die ersten Zeilen auf dem Bildschirm.
|
||
|
||
### 1.3 Weitere Beispiele
|
||
|
||
Alle Beispiele liegen in [examples/](examples/), arbeiten mit den Daten in `examples/data/` und schreiben nach `tmp/`.
|
||
`test/examples/doc_examples_test.rb` führt sie aus und prüft, dass der Code hier mit den Dateien übereinstimmt.
|
||
|
||
**Datei mit Satzarten** – Rechnungen aus verschachteltem JSON werden zu einem Kopfsatz mit Positionssätzen; Datei-
|
||
und Schlusssatz zählen die Datensätze. Das Layout legt Reihenfolge, Typ und Länge der Felder je Satzart fest; die
|
||
Datentypen `AN`, `N`, `DAT`, `TIM` und ihre Prüfung stehen in 2.9. Ohne `format: true` formatiert das Layout nicht,
|
||
deshalb bringt das `map` hier Datum und Beträge selbst ins Zielformat (`delete('-')`, `format('%.2f', …)`).
|
||
|
||
<!-- 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/lock.rb](lib/datapipeline/lock.rb) | Sperre gegen parallele 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/storage/fixed.rb](lib/datapipeline/storage/fixed.rb) | Quelle/Ziel Festbreiten-Datei nach Layout |
|
||
| [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) |
|
||
| `lock(file)` | Sperre während `run`; läuft schon ein Lauf mit derselben Datei, bricht dieser mit `DataPipeline::LockError` ab (siehe 2.7) |
|
||
| `dry_run(flag = true)`, `limit(n)` | nichts schreiben bzw. nur die ersten n Quellzeilen |
|
||
| `dry_run?` | in Schritten mit Nebenwirkungen (API-Aufrufe): `true` im Dry-Run |
|
||
| `logger(logger)` | eigener Ruby-`Logger`; Default: Warnungen auf `$stderr` |
|
||
| `params` | Parameter von der Kommandozeile: über `bin/etl` oder direkt aus `ARGV` (`--name=wert`, `--param key=wert`) |
|
||
| `run` | führt die Pipeline aus und liefert `DataPipeline::Stats` |
|
||
| jeder andere Aufruf | `NoMethodError` (Tippfehler fallen sofort auf) |
|
||
|
||
### 2.3 Ausführungsmodell (`run`)
|
||
|
||
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 | eine Transaktion je `batch_size` Zeilen (Default 1000); `batch_size: :all` eine für alles |
|
||
|
||
Postgres als Quelle liest im Streaming über einen serverseitigen Cursor (`fetch_size`, Default 1000). Abfragen und
|
||
Lookups auf derselben Verbindung bleiben währenddessen möglich.
|
||
|
||
### 2.4 Plugin-Schnittstelle
|
||
|
||
Ein Storage-Plugin ist eine Klasse `<Name>Storage < DataPipeline::StoragePlugin` mit:
|
||
|
||
| Methode | Pflicht | Verwendung |
|
||
| --- | --- | --- |
|
||
| `initialize(**opts, &block)` | ja | Verbindung/Datei vorbereiten |
|
||
| `read` → `Array<Hash<String, …>>` | als Quelle | Batch-Lesen |
|
||
| `read { \|row\| }` | als Quelle | zeilenweises Lesen |
|
||
| `write(rows)` | als Ziel | Batch-Schreiben |
|
||
| `write_row(row)` | als Ziel | zeilenweises Schreiben |
|
||
| `query(query: sql, params: [])` → `Array<Hash>` | optional | Ad-hoc-Abfragen aus Transformationen |
|
||
| `set(opts)` | Basisklasse | Optionen nachreichen, wird von `read(storage, opts)` genutzt |
|
||
| `close` / `finalize` | Basisklasse | Verbindung/Datei freigeben; `run` ruft `close` für Quelle und Ziel am Ende auf |
|
||
|
||
Alle Plugins liefern Zeilen mit **String-Schlüsseln**. Die mitgelieferten Plugins liegen im Namensraum `DataPipeline` (`DataPipeline::CsvStorage` usw.) und werden per `autoload` erst bei der ersten Nutzung geladen. Neues Plugin: Klasse `<Name>Storage < DataPipeline::StoragePlugin` in der Anwendung schreiben und mit `DataPipeline.register :name, NameStorage` (oder `'NameStorage', path: 'datei.rb'` für Laden bei Bedarf) bekannt machen – danach ist `container :name` verfügbar. Ohne `register` findet `container :name` eine Klasse `<Name>Storage` auf oberster Ebene.
|
||
|
||
### 2.5 Mitgelieferte Plugins
|
||
|
||
**DataPipeline::PostgresStorage** – Optionen `host`, `port` (Default 5432), `database`, `username`, `password`, `query`, `params`,
|
||
`table`, `typed`, `fetch_size`, `truncate`, `upsert`, `batch_size`.
|
||
- `read` / `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).
|
||
- `upsert: %w[belnr]` schreibt `insert … on conflict (belnr) do update set …` für alle übrigen Spalten der Zeile (ohne
|
||
übrige Spalten `do nothing`). Die Tabelle braucht einen Primärschlüssel oder Unique-Index auf genau diesen Spalten.
|
||
Damit kann ein Lauf nach einem Abbruch wiederholt werden, ohne doppelte Zeilen.
|
||
- `write_row` (Streaming) sammelt die Zeilen und schreibt je `batch_size` Zeilen (Default 1000) in einer Transaktion;
|
||
`finalize` und `close` schreiben den Rest. Mit `batch_size: :all` läuft der ganze Stream in **einer** Transaktion:
|
||
`finalize` am Ende eines erfolgreichen Laufs bestätigt sie, nach einem Abbruch rollt `close` sie zurück, auch ein
|
||
`truncate`. Dafür braucht das Ziel eine eigene Verbindung (nicht dieselbe wie die Quelle).
|
||
|
||
```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]
|
||
|
||
# wiederholbar: vorhandene Belege werden aktualisiert, im Streaming alles oder nichts
|
||
write container(:postgres, **db, table: 'beleg', upsert: %w[belnr], batch_size: :all)
|
||
```
|
||
|
||
**DataPipeline::CsvStorage** – Optionen `file` (Pflicht), `csv:` (an Rubys `CSV`, gemergt mit den Defaults `headers: true`,
|
||
`col_sep: ','`), `columns`, `encoding`, `replace`, `bom`, `counter`, `verbose`.
|
||
- `columns: %w[a b c]` legt die Spaltenreihenfolge fest: fehlende Felder bleiben leer, String- und Symbol-Schlüssel
|
||
werden gefunden, die Kopfzeile kommt 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).
|
||
- `encoding: 'ISO-8859-1'` (oder `'Windows-1252'`, …) ist der Zeichensatz der Datei, Default UTF-8. Gelesen wird als
|
||
UTF-8, geschrieben wird von UTF-8 umkodiert. Ein Zeichen, das der Zeichensatz nicht kennt (z. B. `€` in ISO-8859-1),
|
||
bricht mit `Encoding::UndefinedConversionError` ab; mit `replace: '?'` wird es ersetzt. Ein UTF-8-BOM am
|
||
Dateianfang wird beim Lesen übersprungen, `bom: true` schreibt eines (für Excel). Ein `encoding` in `csv:` wird wie
|
||
bisher unverändert an Ruby weitergegeben.
|
||
|
||
```ruby
|
||
write container(:csv, file: 'out/cudo.txt', encoding: 'Windows-1252', layout: LAYOUT, csv: { headers: false, col_sep: '|' })
|
||
```
|
||
|
||
Übersicht der übrigen Plugins (alle mit `read`, `read { }`, `write`, `write_row`, `close`):
|
||
|
||
| `container` | Klasse | Gem (bei Nutzung geladen) | Wichtige Optionen |
|
||
| --- | --- | --- | --- |
|
||
| `:json` | `DataPipeline::JsonStorage` | – | `file`, `format: :array \| :lines` (Default aus Endung: `.ndjson`/`.jsonl` → Zeilen), `root: 'data.items'`, `pretty`; ein einzelnes Objekt wird als eine Zeile gelesen |
|
||
| `:sqlite`, `:sqlite3` | `DataPipeline::SqliteStorage` | `sqlite3` | `database` (Datei oder `':memory:'`), `query`, `params` (Array für `?`, Hash für `:name`), `table`, `create`, `truncate`, `upsert` |
|
||
| `:excel`, `:xlsx` | `DataPipeline::ExcelStorage` | `roo` (lesen), `caxlsx` (schreiben) | `file`, `sheet` (Name/Index), `headers`, `columns`, `skip` (Titelzeilen) |
|
||
| `:fixed` | `DataPipeline::FixedStorage` | – | `file`, `layout` (Pflicht), `encoding`, `replace`, `line_end`; Festbreiten-Datei, siehe 2.9 |
|
||
| `:rest` | `DataPipeline::RestStorage` | `http` | `url`, `auth`, `headers`, `params`, `root`, Paging `page_param` oder `next: ->(body) { … }`, `retries`, `timeout`, `batch_size`, `write_method`, `write_url` (auch als Lambda je Zeile), `ssl_verify` |
|
||
|
||
**DataPipeline::JsonStorage** – NDJSON wird zeilenweise gelesen und geschrieben. Ein JSON-Array wird beim Streaming als
|
||
`[`…`]` geschrieben und in `close` abgeschlossen, die Datei ist also auch beim Streaming gültiges JSON.
|
||
|
||
**DataPipeline::SqliteStorage** – Verhält sich wie Postgres: Bind-Parameter, Schreiben in einer Transaktion, Tabellen- und
|
||
Spaltennamen gequotet, `nil` = `NULL`, Datum als ISO-Text. Werte kommen mit SQLite-Typen zurück (Integer, Float, String).
|
||
`create: true` legt die Tabelle beim ersten Schreiben aus der ersten Zeile an (Integer → `integer`, Float → `real`,
|
||
sonst `text`), `truncate: true` leert sie vorher. `upsert: %w[belnr]` wie bei Postgres; mit `create: true` wird der
|
||
nötige Unique-Index gleich mit angelegt. Die Datei wird erst bei der ersten Nutzung geöffnet; ein Dry-Run
|
||
legt keine an.
|
||
|
||
**DataPipeline::ExcelStorage** – Lesen von xlsx zeilenweise (`each_row_streaming`), leere Zeilen werden übersprungen. Beim
|
||
Schreiben bleibt Text immer Text: `'0000000001'` behält die Nullen, `'=1+1'` wird keine Formel. Zahlen und Datumswerte
|
||
behalten ihren Typ. Die Datei entsteht in `close` (caxlsx baut sie im Speicher auf).
|
||
|
||
**DataPipeline::RestStorage** – Liest JSON, der Pfad zum Array kommt aus `root`. Paging über eine Seitennummer (bis eine Seite
|
||
leer ist) oder über eine Folge-URL aus der Antwort; `max_pages` (Default 1000) stoppt endlose Schleifen. Bei 429, 5xx
|
||
und Netzwerkfehlern wird `retries`-mal wiederholt (Wartezeit `retry_wait` × Versuch), 4xx bricht sofort mit
|
||
`DataPipeline::RestStorage::Error` ab. Schreiben je Zeile oder mit `batch_size` als JSON-Array; `write_url: ->(row) { … }` baut
|
||
je Zeile eine eigene URL (z. B. `PUT /kunden/<id>`, nicht zusammen mit `batch_size`). `query(query: 'pfad', params:)`
|
||
hängt den Pfad an `url` an und erlaubt `lookup` gegen die API. Ein Fehler beim Schreiben bricht den Lauf ab (siehe 1.4).
|
||
|
||
Für APIs im RPC-Stil (Suchen und Anlegen per POST mit Body, z. B. die pds API) gibt es einzelne Aufrufe:
|
||
`call(:post, 'person/listkunden', json: { … })` liefert das geparste JSON. Mit `retries: 0` wird ein anlegender Aufruf
|
||
bei `5xx` nicht wiederholt. Ein Fehler wirft `DataPipeline::RestStorage::Error` mit `status` (HTTP-Code, `nil` bei Netzwerkfehlern).
|
||
`ssl_verify: false` akzeptiert jedes Server-Zertifikat (eigene CA, Zugriff über IP-Adresse).
|
||
|
||
```ruby
|
||
api = container :rest, url: 'https://api.example.com/v1/kunden', auth: "Bearer #{ENV['API_TOKEN']}",
|
||
root: 'data', page_param: 'page'
|
||
xlsx = container :excel, file: 'data/kunden.xlsx', sheet: 'Kunden', columns: %w[id name land]
|
||
read api
|
||
map { |r| r.slice('id', 'name', 'land') }
|
||
write xlsx
|
||
```
|
||
|
||
### 2.6 Abhängigkeiten
|
||
|
||
Das Gem selbst braucht nur `csv` und `logger` (Ruby `>= 3.4`). Die Gems der Plugins sind optional und gehören ins
|
||
Gemfile der Anwendung, die das Plugin nutzt:
|
||
|
||
| Plugin | Gem |
|
||
| --- | --- |
|
||
| `:postgres` | `pg` |
|
||
| `:sqlite` | `sqlite3` |
|
||
| `:excel` | `roo` (lesen), `caxlsx` (schreiben) |
|
||
| `:rest`, `DataPipeline::Slack` | `http` |
|
||
| `:csv`, `:json` | – |
|
||
|
||
Im Repository bindet das `Gemfile` das Gem über `gemspec` ein und ergänzt die Gruppe `plugins` (`pg`, `sqlite3`,
|
||
`roo`, `caxlsx`, `http`) für die Plugin-Tests und die Gruppe `test` (`minitest`, `rake`). `Gemfile.lock` ist versioniert.
|
||
Gem bauen: `gem build datapipeline.gemspec`.
|
||
|
||
Plugins und ihre Gems werden erst bei der ersten Nutzung geladen (`autoload` und `DataPipeline::StoragePlugin.require_gem`): eine
|
||
CSV- oder JSON-Pipeline lädt keines dieser Gems. Wer ein Plugin nicht braucht, lässt die Gruppe weg
|
||
(`bundle config set without plugins`); bei Nutzung meldet das Plugin dann, welches Gem fehlt.
|
||
|
||
### 2.7 Betrieb
|
||
|
||
**Fehler je Zeile** – `on_error` legt fest, was passiert, wenn ein Datenschritt (`transform`, `map`, `filter`,
|
||
`validate`) für eine Zeile eine Exception wirft. Fehler in `header`, `trailer`, prepend und append brechen immer ab.
|
||
|
||
| Modus | Wirkung |
|
||
| --- | --- |
|
||
| `:abort` (Default) | Lauf bricht ab, Exception wird weitergereicht |
|
||
| `:skip` | Zeile fällt weg, Warnung im Log, Zähler `rejected` |
|
||
| `:collect, to: storage` | wie `:skip`, zusätzlich die Eingangszeile mit `_step` und `_error` per `write_row` ins Ziel `storage` |
|
||
|
||
**Validierung** – `validate` prüft Spalten nach Regeln, nach einem Layout (2.9) und/oder mit einem Block. Eine
|
||
verletzte Regel löst `DataPipeline::ValidationError` aus und läuft damit über `on_error`. Leere Werte prüft nur
|
||
`required`. Regeln je Spalte: `required: true`, `max:`/`min:` (Länge als Text), `format:` (Regex auf den Text),
|
||
`in:` (erlaubte Werte) und `type:` – `:integer` (ganze Zahl, Basis 10), `:decimal` (alles, was Rubys `Float()`
|
||
annimmt) oder `:date` (`Date` oder ISO-Text `YYYY-MM-DD`). Die Feldtypen eines Layouts (`AN`, `N`, `DAT`, `TIM`) und
|
||
ihr Unterschied zu diesen Regeln stehen in 2.9. Mit `warn: true` bleibt die Zeile drin, es gibt eine Warnung und `stats.warnings` zählt mit (ins Log
|
||
kommen höchstens 100); `ignore: %w[FELD]` lässt bekannte Lücken aus.
|
||
|
||
```ruby
|
||
on_error :collect, to: container(:csv, file: 'data/fehler.csv', columns: %w[belnr konto dmbtr _step _error])
|
||
validate 'konto' => { required: true, format: /\A\d+\z/ },
|
||
'shkzg' => { in: %w[S H] },
|
||
'dmbtr' => { type: :decimal },
|
||
'name1' => { max: 35 }
|
||
validate { |row| 'Betrag 0' if row['dmbtr'].to_f.zero? }
|
||
```
|
||
|
||
**Ergebnis, Log, Hooks** – `run` liefert `DataPipeline::Stats`:
|
||
|
||
| Feld | Inhalt |
|
||
| --- | --- |
|
||
| `read` | gelesene Quellzeilen |
|
||
| `written` | Datenzeilen nach allen Schritten (ohne Kopf- und Schlusssätze) |
|
||
| `output` | an die Ziele gegebene Zeilen inkl. Kopf- und Schlusssätzen |
|
||
| `rejected`, `errors` | verworfene Zeilen; die ersten 100 mit Schritt, Meldung und Zeile |
|
||
| `warnings` | Zeilen mit Warnung aus `validate …, warn: true` |
|
||
| `duration`, `started_at`, `finished_at` | Laufzeit |
|
||
| `error` | Exception eines abgebrochenen Laufs |
|
||
| `ok?` | kein Abbruch und nichts verworfen |
|
||
| `to_s` | `read=9 written=4 rejected=4 0.097s` |
|
||
|
||
Der Logger schreibt `start` und `finish` (Info), jede verworfene Zeile (Warnung) und einen Abbruch (Fehler).
|
||
`on_finish` läuft nach jedem Lauf, auch nach einem Abbruch; ein Fehler im Hook wird nur geloggt.
|
||
|
||
```ruby
|
||
on_finish { |stats| DataPipeline::Slack.new(channel: '#etl').send_message("Buchungen: #{stats}") unless stats.ok? }
|
||
```
|
||
|
||
**Slack einrichten** – `DataPipeline::Slack` schickt Text über die Slack-API `chat.postMessage`. Voraussetzungen:
|
||
|
||
1. In Slack eine App anlegen (api.slack.com/apps), unter *OAuth & Permissions* den Bot-Scope `chat:write` vergeben,
|
||
die App im Workspace installieren und den *Bot User OAuth Token* (`xoxb-…`) kopieren.
|
||
2. Die App in den Ziel-Channel einladen (`/invite @appname`), sonst antwortet Slack mit `not_in_channel`.
|
||
3. Das Gem `http` in das Gemfile der Anwendung aufnehmen (siehe 2.6); die Klasse wird erst beim ersten Zugriff geladen.
|
||
4. Den Token als Umgebungsvariable `SLACK_TOKEN` setzen, z. B. in der `.env` der Anwendung (das Gem lädt keine
|
||
`.env`), oder direkt mit `token:` übergeben. Den Token nicht ins Pipeline-Skript schreiben.
|
||
|
||
```ruby
|
||
slack = DataPipeline::Slack.new # Token aus ENV['SLACK_TOKEN'], Channel '#general'
|
||
slack = DataPipeline::Slack.new(channel: '#etl') # anderer Channel (Name oder ID wie 'C0123ABCD')
|
||
slack = DataPipeline::Slack.new(channel: '#etl', token: ENV.fetch('SLACK_ETL_TOKEN')) # eigener Token je Anwendung
|
||
|
||
slack.send_message('Test aus der Pipeline', verbose: true) # verbose: Antwort von Slack als JSON auf $stdout
|
||
```
|
||
|
||
| Option | Default | Bedeutung |
|
||
| --- | --- | --- |
|
||
| `token:` | `ENV['SLACK_TOKEN']` | Bot-Token; fehlt er, bricht schon `Slack.new` mit `ArgumentError` ab |
|
||
| `channel:` | `'#general'` | Ziel-Channel als Name oder ID |
|
||
|
||
`send_message(text, verbose: false)` liefert die Antwort von Slack als Hash. Meldet Slack einen Fehler (z. B.
|
||
`invalid_auth`, `channel_not_found`, `not_in_channel`), wirft die Methode eine Exception mit diesem Code. In
|
||
`on_finish` wird sie nur geloggt (`on_finish: RuntimeError: Slack#send_message failed: …`), der Lauf und sein
|
||
Exit-Code bleiben unverändert. Es gibt keine Wiederholung bei Netzwerkfehlern.
|
||
|
||
Ein ausführlicheres Beispiel mit Status, Zählern, Laufzeit und den ersten verworfenen Zeilen. Der Notifier wird vor
|
||
der Pipeline angelegt; ein fehlender Token fällt so schon beim Start auf und nicht erst nach dem Lauf. Slack setzt
|
||
`*fett*`, `_kursiv_` und `` `code` `` um.
|
||
|
||
```ruby
|
||
slack = DataPipeline::Slack.new(channel: '#etl')
|
||
|
||
DataPipeline.new do
|
||
# … read, Schritte, write …
|
||
|
||
on_finish do |stats|
|
||
next if stats.ok? && stats.warnings.zero? # nur melden, wenn etwas zu tun ist
|
||
|
||
status = if stats.error then ':red_circle: abgebrochen'
|
||
elsif stats.rejected.positive? then ':large_orange_circle: mit verworfenen Zeilen'
|
||
else ':large_yellow_circle: mit Warnungen'
|
||
end
|
||
lines = ["*Buchungsexport #{stats.version}* #{status}#{' (Dry-Run)' if stats.dry_run}",
|
||
"Zeitraum #{params[:from]} – #{params[:till]}, Start #{stats.started_at.strftime('%d.%m.%Y %H:%M')}, " \
|
||
"Dauer #{stats.duration}s",
|
||
"gelesen #{stats.read} · geschrieben #{stats.written} · verworfen #{stats.rejected} · Warnungen #{stats.warnings}"]
|
||
lines << "Fehler: `#{stats.error.class}: #{stats.error.message}`" if stats.error
|
||
stats.errors.first(5).each do |e|
|
||
lines << "• #{e[:step]}: #{e[:error]} – Beleg #{e[:row]['belnr']}"
|
||
end
|
||
lines << "… und #{stats.rejected - 5} weitere, siehe data/fehler.csv" if stats.rejected > 5
|
||
|
||
slack.send_message(lines.join("\n"))
|
||
end
|
||
end.run
|
||
```
|
||
|
||
So sieht die Meldung in Slack aus:
|
||
|
||
```text
|
||
Buchungsexport 1.0 🟠 mit verworfenen Zeilen
|
||
Zeitraum 2025-11-01 – 2025-11-30, Start 01.12.2025 06:00, Dauer 4.213s
|
||
gelesen 1250 · geschrieben 1243 · verworfen 7 · Warnungen 0
|
||
• validate#1: konto: missing – Beleg 5100000123
|
||
• validate#1: dmbtr: not decimal – Beleg 5100000124
|
||
• validate#2: Betrag 0 – Beleg 5100000130
|
||
• validate#1: shkzg: not in ["S", "H"] – Beleg 5100000131
|
||
• validate#1: konto: format /\A\d+\z/ – Beleg 5100000140
|
||
… und 2 weitere, siehe data/fehler.csv
|
||
```
|
||
|
||
`stats.errors` enthält höchstens 100 Einträge als Hash mit `step`, `error` und der Eingangszeile `row`. `params` gibt
|
||
es im Pipeline-Block; ausserhalb heisst es `DataPipeline.params`.
|
||
|
||
**Inkrementelle Läufe** – `watermark` merkt sich je Name den höchsten Wert einer Spalte in einer JSON-Datei. Die
|
||
Pipeline verfolgt jede Quellzeile und speichert den neuen Wert nur nach einem erfolgreichen Lauf (nicht nach Abbruch,
|
||
nicht im Dry-Run). Zahlen als Text werden numerisch verglichen. Mit `limit` sollte die Quelle nach der Spalte
|
||
sortiert sein; verworfene Zeilen schieben das Wasserzeichen mit (sie liegen bei `:collect` in der Fehlerdatei).
|
||
|
||
```ruby
|
||
wm = watermark :rechnungen, column: 'rechnung_id', file: 'data/.state.json', initial: 0
|
||
read pg, query: 'select * from rechnung where rechnung_id > $1 order by rechnung_id', params: [wm.value]
|
||
```
|
||
|
||
**Sperre gegen parallele Läufe** – Startet ein Cronjob, während der vorige Lauf noch arbeitet, lesen beide dasselbe
|
||
Wasserzeichen und exportieren doppelt. `lock` verhindert das mit einer exklusiven Sperre (`flock`) auf eine Datei:
|
||
Ein zweiter Lauf wartet nicht, sondern bricht sofort mit `DataPipeline::LockError` ab (Exit-Code `3`), schreibt nichts
|
||
und ruft wie jeder Abbruch `on_finish` auf. Das Betriebssystem gibt die Sperre beim Ende des Prozesses frei, auch nach
|
||
einem Absturz; es bleiben also keine verwaisten Sperren. Die Datei bleibt liegen und enthält die PID des letzten Laufs.
|
||
|
||
```ruby
|
||
lock 'data/.cudo.lock' # in der Pipeline: von run bis zum Ende des Laufs
|
||
```
|
||
|
||
```sh
|
||
bin/etl run cudo.rb --lock=data/.cudo.lock # für das ganze Skript, z. B. mehrere Pipelines nacheinander
|
||
```
|
||
|
||
**Dry-Run und Limit** – Im Dry-Run läuft alles, aber nichts wird geschrieben: kein Ziel, keine Fehlerdatei, kein
|
||
Wasserzeichen; eine CSV-Datei wird nicht angelegt. Am Ende stehen die Zusammenfassung und die ersten Zeilen
|
||
(`preview`, Default 10) als JSON auf `$stdout`. `limit n` verarbeitet nur die ersten n Quellzeilen, im Streaming
|
||
wird das Lesen dann beendet.
|
||
|
||
**Kommandozeile** – `bin/etl` startet ein Pipeline-Skript; `run` ist optional (`bin/etl skript.rb` genügt):
|
||
|
||
```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 |
|
||
| `--lock=FILE` | Sperre für das ganze Skript; ist sie belegt, endet `etl` sofort mit Exit-Code `3` |
|
||
| `--verbose`, `--quiet` | Log-Level Debug bzw. Warnung (Default über `bin/etl`: Info) |
|
||
| jede andere `--name=wert` | `params[:name]` und unverändert an das Skript, z. B. `--out=data`, `--incremental=1` |
|
||
|
||
Exit-Code: `0` ok, `1` Abbruch, `2` fertig, aber mit verworfenen Zeilen, `3` ein anderer Lauf hält die Sperre. Das `Rakefile` ruft `bin/etl` auf:
|
||
`rake process:posting[2025-11-01,2025-11-30]`, `rake process:all[2025-11-01,2025-11-30,dry]`. Ein fehlgeschlagener Lauf stoppt Rake.
|
||
|
||
### 2.8 Tests
|
||
|
||
`rake test` (oder `bundle exec rake`) startet die Minitest-Suite in [test/](test/): rund 165 Fälle für Plugins, Kern,
|
||
Streaming, Betrieb, Kommandozeile und die Beispiele.
|
||
|
||
| Ordner | Inhalt |
|
||
| --- | --- |
|
||
| `test/plugins` | je Plugin das gemeinsame Testkit `test/support/storage_contract.rb` (Schreiben und Zurücklesen, zeilenweise, als Quelle und Ziel einer Pipeline) plus eigene Fälle; REST gegen einen lokalen Testserver; Laden der Gems erst bei Nutzung in einem frischen Prozess |
|
||
| `test/core` | DSL, Einstellungen, Plugin-Registry, Parallel- und Verkettungsmodus, Streaming, Lookups, mehrere Ziele, Betrieb; `compat_test.rb` hält die Ausgabe des `transform`-Modells von v1.2.1 fest |
|
||
| `test/cli` | `bin/etl` als Prozess: Weitergabe von `--from`/`--till`, Parameter, Dry-Run, Exit-Codes |
|
||
| `test/examples` | die Beispiele aus Abschnitt 1 (Code in der Doku = Datei, Lauf und Ergebnis; REST gegen die Demo-API) |
|
||
|
||
Die `.env` wird in Tests nicht geladen; kein Test erreicht die Produktivdatenbank. Postgres-Tests legen je Test eine
|
||
Wegwerf-Datenbank auf einem lokalen Server an (`ETL_TEST_PG_HOST`, `_PORT`, `_USER`, `_PASSWORD`; Default
|
||
`localhost`, `$USER`). Ohne erreichbaren Server werden sie übersprungen (21 Tests). Ein eigenes Plugin lässt sich mit
|
||
`MemoryStorage` aus `test/test_helper.rb` als Quelle oder Ziel testen; mit `include StorageContract` und einer
|
||
Methode `contract_storage(env, :write | :read)` bekommt es die gemeinsamen Tests.
|
||
|
||
### 2.9 Satzarten-Layouts
|
||
|
||
Dateien mit mehreren Satzarten (Kopf, Positionen, Schluss) haben je Satzart eine feste Feldliste. `DataPipeline::Layout`
|
||
beschreibt sie; die Satzart einer Zeile ist ihr erster Wert (oder `type_field:`), Schlüssel werden ohne Rücksicht auf
|
||
Gross-/Kleinschreibung zugeordnet.
|
||
|
||
```ruby
|
||
LAYOUT = DataPipeline::Layout.new do
|
||
record 'DOHD', [['SATYP', 'AN', 4, true], ['LFDNR', 'N', 10, true], ['BKTXT', 'AN', 25, true], ...]
|
||
record 'LARC', [['SATYP', 'AN', 4, true], ['SUMME', 'N', 10, true]]
|
||
end
|
||
|
||
validate LAYOUT, warn: true # Pflicht, Länge, Format je Feld
|
||
write container(:csv, file: 'x.ok', layout: LAYOUT, csv: { headers: false, col_sep: '|' })
|
||
```
|
||
|
||
**Felddefinition** – jedes Feld ist ein Array `[Name, Typ, Länge, Pflicht]`:
|
||
|
||
| Teil | Bedeutung |
|
||
| --- | --- |
|
||
| Name | Spaltenname; in der Zeile ohne Rücksicht auf Gross-/Kleinschreibung, als String oder Symbol gesucht |
|
||
| Typ | `AN`, `N`, `DAT` oder `TIM` (siehe unten), Gross-/Kleinschreibung egal; fehlt er (`nil`), gilt `AN` |
|
||
| Länge | Zahl oder bei Nachkommastellen ein String (`'11.2'`); fehlt sie, wird die Länge nicht geprüft |
|
||
| Pflicht | `true` = darf nicht leer sein; fehlt sie, ist das Feld optional |
|
||
|
||
**Datentypen** – geprüft wird immer der Wert als Text (`to_s`). Ein leerer Wert (`nil`, `''`, nur Leerzeichen)
|
||
wird nur auf Pflicht geprüft, nie auf den Typ.
|
||
|
||
| Typ | Inhalt | Gültig | Länge | Meldung |
|
||
| --- | --- | --- | --- | --- |
|
||
| `AN` | Text (alphanumerisch) | jeder Wert | höchstens `Länge` Zeichen | `NAME: longer than 10` |
|
||
| `N` | Zahl | `\A[+-]?\d+(\.\d+)?\z`: Ziffern, optional Vorzeichen und Punkt als Dezimaltrenner | `10` = höchstens 10 Stellen, keine Nachkommastellen; `'11.2'` = höchstens 9 Vor- und 2 Nachkommastellen (Gesamtlänge 11 wie in SAP); das Vorzeichen zählt nicht, führende Nullen schon | `NAME: not a number`, `… more than 9 digits`, `… more than 2 decimals` |
|
||
| `DAT` | Datum | genau 8 Ziffern `YYYYMMDD`, gültiges Kalenderdatum | wird nicht geprüft | `NAME: not a date YYYYMMDD` |
|
||
| `TIM` | Uhrzeit | genau 6 Ziffern `HHMMSS`, 00:00:00 bis 23:59:59 | wird nicht geprüft | `NAME: not a time HHMMSS` |
|
||
|
||
Ein unbekannter Typ wird wie `AN` behandelt.
|
||
|
||
**Formatieren** – Ohne Option prüft das Layout nur: Werte werden unverändert geschrieben, ein `Date` würde also als
|
||
`2026-10-09` geprüft und abgelehnt. Mit `Layout.new(format: true)` bringt das Layout die Werte vor dem Prüfen und
|
||
Schreiben selbst in das Textformat ihres Typs. Geprüft wird dann genau der Text, der in die Datei kommt.
|
||
|
||
| Typ | Eingabe | Ergebnis |
|
||
| --- | --- | --- |
|
||
| `N` | Integer, Float, Rational, BigDecimal | Nachkommastellen aus der Länge (`11.2` → 2), kaufmännisch gerundet: `12.345` → `12.35`, `-0.005` → `-0.01`, `7` → `7.00`; ein Float zählt mit seinem kürzesten Text, `12.345` also nicht als `12.3449999…` |
|
||
| `N` | Zahl als Text | auf die Nachkommastellen aufgefüllt (`'3.5'` → `3.50`); zu viele Nachkommastellen bleiben stehen und werden gemeldet, Text wird nie gerundet |
|
||
| `DAT` | `Date`, `Time`, `'2026-10-09'` | `20261009` |
|
||
| `TIM` | `Time`, `'07:05:03'` | `070503` |
|
||
| alle | `nil` | bleibt leer |
|
||
|
||
Anderes bleibt unverändert und wird wie gewohnt geprüft. Führende Nullen ergänzt erst die Festbreiten-Datei (unten);
|
||
in CSV braucht es dafür weiterhin ein `map` (`format('%06d', …)`).
|
||
|
||
```ruby
|
||
LAYOUT = DataPipeline::Layout.new(format: true) do
|
||
record 'KOPF', [['SATYP', 'AN', 4, true], ['DATUM', 'DAT', 8, true], ['SUMME', 'N', '11.2', true]]
|
||
end
|
||
LAYOUT.values({ 'satyp' => 'KOPF', 'datum' => Date.new(2026, 10, 9), 'summe' => 1234.5 })
|
||
# => ["KOPF", "20261009", "1234.50"]
|
||
```
|
||
|
||
**Festbreiten-Dateien** – `container(:fixed, file:, layout:)` (`DataPipeline::FixedStorage`) schreibt und liest
|
||
Dateien ohne Trennzeichen, in denen jedes Feld eine feste Anzahl Zeichen hat. Jedes `AN`- und `N`-Feld braucht dafür
|
||
eine Länge, sonst bricht schon `container` mit `LayoutError` ab.
|
||
|
||
| Typ | Breite | Ausrichtung |
|
||
| --- | --- | --- |
|
||
| `AN` | Länge | links, rechts mit Leerzeichen aufgefüllt |
|
||
| `N` | Vor- + Nachkommastellen + Punkt: `'11.2'` → 12, `5` → 5 | rechts, links mit Nullen aufgefüllt, Vorzeichen vorne: `-0000012.50` |
|
||
| `DAT`, `TIM` | 8 bzw. 6 | genau so viele Ziffern |
|
||
| leeres Feld | Breite des Felds | nur Leerzeichen |
|
||
|
||
Beim Schreiben werden die Werte immer formatiert (wie `format: true`). Ein Wert, der länger als sein Feld ist, wird
|
||
nicht abgeschnitten, sondern bricht mit `LayoutError` ab; mit `validate LAYOUT` vorher fällt er schon als Zeilenfehler
|
||
auf. Beim Lesen erkennt das Plugin die Satzart an ihrem Feld, die Schlüssel sind die Feldnamen klein geschrieben, die
|
||
Werte ohne Auffüllung (Zahlen behalten ihre führenden Nullen), leere Felder sind `nil`. Breiten zählen Zeichen, nicht
|
||
Bytes. Weitere Optionen: `encoding`, `replace` (wie CSV, 2.5) und `line_end: "\r\n"`.
|
||
|
||
```ruby
|
||
write container(:fixed, file: 'out/cudo.txt', layout: LAYOUT, encoding: 'Windows-1252')
|
||
# KOPF20261009000001234.50
|
||
|
||
read container(:fixed, file: 'in/rueckmeldung.txt', layout: LAYOUT)
|
||
# { 'satyp' => 'KOPF', 'datum' => '20261009', 'summe' => '000001234.50' }
|
||
```
|
||
|
||
**Lesen mit Layout** – Auch eine CSV-Datei mit Satzarten lässt sich mit `layout:` wieder einlesen (nur mit
|
||
`headers: false`). Jede Zeile bekommt die Feldnamen ihrer Satzart als Schlüssel; eine unbekannte Satzart oder mehr
|
||
Werte als Felder bricht mit `LayoutError` ab.
|
||
|
||
```ruby
|
||
read container(:csv, file: 'in/cudo.ok', layout: LAYOUT, csv: { headers: false, col_sep: '|' })
|
||
```
|
||
|
||
**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).
|
||
`format: true` formatiert die Werte (siehe oben).
|
||
|
||
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:** SFTP-Ablage.
|
||
2. **Veröffentlichung:** eigenes Remote-Repository für das Gem, Lizenz festlegen, Versionen taggen.
|