datapipeline 2.0.0: Gem aus dem Projekt etl herausgelöst

This commit is contained in:
wkh
2026-10-08 15:30:02 +02:00
commit cce7b03ca9
56 changed files with 5205 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
#
# 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
+4
View File
@@ -0,0 +1,4 @@
{"id":1,"konto":"4000","betrag":120.0}
{"id":2,"konto":"4000","betrag":-20.0}
{"id":3,"konto":"4400","betrag":75.5}
{"id":4,"konto":"4000","betrag":10.0}
+3
View File
@@ -0,0 +1,3 @@
artikel,menge
A-100,5
B-200,2
1 artikel menge
2 A-100 5
3 B-200 2
+3
View File
@@ -0,0 +1,3 @@
artikel,menge
A-100,7
C-300,1
1 artikel menge
2 A-100 7
3 C-300 1
+10
View File
@@ -0,0 +1,10 @@
kundennr;name;land;status;umsatz
100001;Müller GmbH;DE;aktiv;12500.50
100002;Huber & Partner;AT;aktiv;8300
100003;Swiss Trade AG;CH;aktiv;22000.75
100004;Alte Firma KG;DE;inaktiv;0
100005; ;DE;aktiv;100
10006;Kurz;DE;aktiv;50
100007;Nowhere Ltd;XX;aktiv;10
100008;Dupont SARL;FR;aktiv;abc
100009;Rossi Srl;IT;aktiv;4100.10
1 kundennr name land status umsatz
2 100001 Müller GmbH DE aktiv 12500.50
3 100002 Huber & Partner AT aktiv 8300
4 100003 Swiss Trade AG CH aktiv 22000.75
5 100004 Alte Firma KG DE inaktiv 0
6 100005 DE aktiv 100
7 10006 Kurz DE aktiv 50
8 100007 Nowhere Ltd XX aktiv 10
9 100008 Dupont SARL FR aktiv abc
10 100009 Rossi Srl IT aktiv 4100.10
+7
View File
@@ -0,0 +1,7 @@
[
{ "code": "DE", "name": "Deutschland", "eu": true },
{ "code": "AT", "name": "Österreich", "eu": true },
{ "code": "CH", "name": "Schweiz", "eu": false },
{ "code": "FR", "name": "Frankreich", "eu": true },
{ "code": "IT", "name": "Italien", "eu": true }
]
+7
View File
@@ -0,0 +1,7 @@
[
{ "nr": "R-1001", "datum": "2025-11-03", "kunde": "100001",
"positionen": [{ "text": "Beratung", "menge": 2, "preis": 150.0 },
{ "text": "Reisekosten", "menge": 1, "preis": 80.5 }] },
{ "nr": "R-1002", "datum": "2025-11-05", "kunde": "100002",
"positionen": [{ "text": "Jahreslizenz Software mit Wartung und Support", "menge": 1, "preis": 1200 }] }
]
+137
View File
@@ -0,0 +1,137 @@
#
# Kleine Demo-API für die REST-Beispiele, ohne Internet und ohne weitere Gems
#
# ruby examples/demo_api.rb # http://127.0.0.1:4567, Token: demo-token
#
# Endpunkte (alle mit Authorization: Bearer demo-token, sonst 401)
# GET /api/kunden?page=1&per_page=3 {"data": [...], "page": 1} die 2. Seite antwortet einmal mit 503
# GET /api/laender [{"code": "DE", "name": "Deutschland"}, ...]
# GET /api/bestellungen[?cursor=n] {"items": [...], "next": "/api/bestellungen?cursor=3"} oder "next": null
# POST /api/import ein Objekt oder ein Array; 422 ohne kundennr/name
# GET /api/import alles, was angenommen wurde
# PUT /api/archiv/<id> ein Objekt ablegen
# GET /api/archiv alles im Archiv
#
require 'json'
require 'socket'
require 'uri'
class DemoApi
TOKEN = 'demo-token'.freeze
KUNDEN = [
{ 'id' => 1, 'name' => 'Müller GmbH', 'land' => 'DE', 'umsatz' => '12500.50' },
{ 'id' => 2, 'name' => 'Huber & Partner', 'land' => 'AT', 'umsatz' => '8300' },
{ 'id' => 3, 'name' => 'Swiss Trade AG', 'land' => 'CH', 'umsatz' => '22000.75' },
{ 'id' => 4, 'name' => 'Rossi Srl', 'land' => 'IT', 'umsatz' => '4100.10' },
{ 'id' => 5, 'name' => 'Dupont SARL', 'land' => 'FR', 'umsatz' => 'n/a' },
{ 'id' => 6, 'name' => 'Nordic AB', 'land' => 'SE', 'umsatz' => '990' },
{ 'id' => 7, 'name' => 'Vaduz Treuhand', 'land' => 'LI', 'umsatz' => '15000' }
].freeze
LAENDER = [
{ 'code' => 'DE', 'name' => 'Deutschland' }, { 'code' => 'AT', 'name' => 'Österreich' },
{ 'code' => 'CH', 'name' => 'Schweiz' }, { 'code' => 'IT', 'name' => 'Italien' },
{ 'code' => 'FR', 'name' => 'Frankreich' }, { 'code' => 'LI', 'name' => 'Liechtenstein' }
].freeze
BESTELLUNGEN = (1..5).map { |i| { 'id' => i, 'kunde' => (i % 3) + 1, 'betrag' => i * 100.0 } }.freeze
attr_reader :imported, :archive
def self.start(port: 4567) = new(port).tap(&:start)
def initialize port
@server = TCPServer.new('127.0.0.1', port)
@imported = []
@archive = {}
@failed_once = false
end
def url = "http://127.0.0.1:#{@server.addr[1]}"
def start
@thread = Thread.new { loop { Thread.new(@server.accept) { |client| serve(client) } } }
self
end
def stop
@thread&.kill
@server.close
end
private
def serve client
method, target = client.gets.to_s.split
return unless method
headers = {}
while (line = client.gets) && line != "\r\n"
key, value = line.split(':', 2)
headers[key.downcase] = value.strip
end
body = headers['content-length'] ? client.read(headers['content-length'].to_i) : ''
path, query = target.split('?', 2)
params = URI.decode_www_form(query.to_s).to_h
status, payload = headers['authorization'] == "Bearer #{TOKEN}" ? route(method, path, params, body) : [401, { 'error' => 'token' }]
text = JSON.generate(payload)
client.write "HTTP/1.1 #{status} X\r\nContent-Type: application/json\r\nContent-Length: #{text.bytesize}\r\n" \
"Connection: close\r\n\r\n#{text}"
rescue StandardError => e
warn "DemoApi: #{e.class}: #{e.message}"
ensure
client.close
end
def route method, path, params, body
case [method, path]
in ['GET', '/api/kunden'] then kunden(params)
in ['GET', '/api/laender'] then [200, LAENDER]
in ['GET', '/api/bestellungen'] then bestellungen(params)
in ['POST', '/api/import'] then import(JSON.parse(body))
in ['GET', '/api/import'] then [200, @imported]
in ['PUT', %r{\A/api/archiv/\w+\z}] then archivieren(path.split('/').last, JSON.parse(body))
in ['GET', '/api/archiv'] then [200, @archive.values]
else [404, { 'error' => "#{method} #{path}" }]
end
end
def kunden params
page = params.fetch('page', '1').to_i
per_page = params.fetch('per_page', '3').to_i
if page == 2 && !@failed_once
@failed_once = true
return [503, { 'error' => 'kurz nicht verfügbar' }]
end
[200, { 'data' => KUNDEN.drop((page - 1) * per_page).first(per_page), 'page' => page }]
end
def bestellungen params
cursor = params.fetch('cursor', '0').to_i
items = BESTELLUNGEN.drop(cursor).first(2)
following = cursor + 2 < BESTELLUNGEN.size ? "/api/bestellungen?cursor=#{cursor + 2}" : nil
[200, { 'items' => items, 'next' => following }]
end
def archivieren id, data
@archive[id] = data
[200, { 'ok' => true }]
end
def import data
rows = data.is_a?(Array) ? data : [data]
invalid = rows.reject { |r| r['kundennr'] && r['name'] }
return [422, { 'error' => 'kundennr und name sind Pflicht', 'zeilen' => invalid }] if invalid.any?
@imported.concat(rows)
[201, { 'angenommen' => rows.size }]
end
end
if $PROGRAM_NAME == __FILE__
api = DemoApi.start(port: ENV.fetch('PORT', 4567).to_i)
puts "Demo-API auf #{api.url}, Token #{DemoApi::TOKEN} (Ctrl-C beendet)"
sleep
end
+38
View File
@@ -0,0 +1,38 @@
#
# 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
+50
View File
@@ -0,0 +1,50 @@
#
# 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
+50
View File
@@ -0,0 +1,50 @@
#
# 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
+47
View File
@@ -0,0 +1,47 @@
#
# 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
+36
View File
@@ -0,0 +1,36 @@
#
# 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
+34
View File
@@ -0,0 +1,34 @@
#
# 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