README: Datentypen im Layout, validate-Typen und Slack-Benachrichtigung ausführlich
This commit is contained in:
@@ -142,7 +142,7 @@ Was die einzelnen Teile tun:
|
|||||||
| `DataPipeline.params.fetch(:out, 'tmp')` | Parameter von der Kommandozeile: `--out=verzeichnis` |
|
| `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 |
|
| `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 |
|
| `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 '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 |
|
| `validate { \|row\| … }` | eigene Regel: ein Text (oder ein Array) ist ein Fehler, `nil` heisst in Ordnung |
|
||||||
| `filter` | inaktive Kunden fallen weg, ohne Fehler |
|
| `filter` | inaktive Kunden fallen weg, ohne Fehler |
|
||||||
| `map` | baut die Zielzeile; `laender.fetch` kann nicht scheitern, weil `validate` das Land schon geprüft hat |
|
| `map` | baut die Zielzeile; `laender.fetch` kann nicht scheitern, weil `validate` das Land schon geprüft hat |
|
||||||
@@ -178,7 +178,9 @@ Alle Beispiele liegen in [examples/](examples/), arbeiten mit den Daten in `exam
|
|||||||
`test/examples/doc_examples_test.rb` führt sie aus und prüft, dass der Code hier mit den Dateien übereinstimmt.
|
`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-
|
**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).
|
und Schlusssatz zählen die Datensätze. Das Layout legt Reihenfolge, Typ und Länge der Felder je Satzart fest; die
|
||||||
|
Datentypen `AN`, `N`, `DAT`, `TIM` und ihre Prüfung stehen in 2.9. Das Layout formatiert nicht, deshalb bringt das
|
||||||
|
`map` Datum und Beträge selbst ins Zielformat (`delete('-')`, `format('%.2f', …)`).
|
||||||
|
|
||||||
<!-- beispiel: examples/rechnungen_satzarten.rb -->
|
<!-- beispiel: examples/rechnungen_satzarten.rb -->
|
||||||
```ruby
|
```ruby
|
||||||
@@ -787,7 +789,10 @@ CSV- oder JSON-Pipeline lädt keines dieser Gems. Wer ein Plugin nicht braucht,
|
|||||||
|
|
||||||
**Validierung** – `validate` prüft Spalten nach Regeln, nach einem Layout (2.9) und/oder mit einem Block. Eine
|
**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
|
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
|
`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.
|
kommen höchstens 100); `ignore: %w[FELD]` lässt bekannte Lücken aus.
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
@@ -820,6 +825,82 @@ Der Logger schreibt `start` und `finish` (Info), jede verworfene Zeile (Warnung)
|
|||||||
on_finish { |stats| DataPipeline::Slack.new(channel: '#etl').send_message("Buchungen: #{stats}") unless stats.ok? }
|
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
|
**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,
|
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
|
nicht im Dry-Run). Zahlen als Text werden numerisch verglichen. Mit `limit` sollte die Quelle nach der Spalte
|
||||||
@@ -888,12 +969,52 @@ validate LAYOUT, warn: true # Pflicht, Länge,
|
|||||||
write container(:csv, file: 'x.ok', layout: LAYOUT, csv: { headers: false, col_sep: '|' })
|
write container(:csv, file: 'x.ok', layout: LAYOUT, csv: { headers: false, col_sep: '|' })
|
||||||
```
|
```
|
||||||
|
|
||||||
| Typ | Prüfung |
|
**Felddefinition** – jedes Feld ist ein Array `[Name, Typ, Länge, Pflicht]`:
|
||||||
|
|
||||||
|
| Teil | Bedeutung |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `AN` | Länge als Text |
|
| Name | Spaltenname; in der Zeile ohne Rücksicht auf Gross-/Kleinschreibung, als String oder Symbol gesucht |
|
||||||
| `N` | Zahl; Länge `10` = Stellen, `13.2` = 11 Vor- und 2 Nachkommastellen |
|
| Typ | `AN`, `N`, `DAT` oder `TIM` (siehe unten), Gross-/Kleinschreibung egal; fehlt er (`nil`), gilt `AN` |
|
||||||
| `DAT` | `YYYYMMDD`, gültiges Datum |
|
| Länge | Zahl oder bei Nachkommastellen ein String (`'11.2'`); fehlt sie, wird die Länge nicht geprüft |
|
||||||
| `TIM` | `HHMMSS` |
|
| Pflicht | `true` = darf nicht leer sein; fehlt sie, ist das Feld optional |
|
||||||
|
|
||||||
|
**Datentypen** – geprüft wird immer der Wert als Text (`to_s`). Ein leerer Wert (`nil`, `''`, nur Leerzeichen)
|
||||||
|
wird nur auf Pflicht geprüft, nie auf den Typ.
|
||||||
|
|
||||||
|
| Typ | Inhalt | Gültig | Länge | Meldung |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `AN` | Text (alphanumerisch) | jeder Wert | höchstens `Länge` Zeichen | `NAME: longer than 10` |
|
||||||
|
| `N` | Zahl | `\A[+-]?\d+(\.\d+)?\z`: Ziffern, optional Vorzeichen und Punkt als Dezimaltrenner | `10` = höchstens 10 Stellen, keine Nachkommastellen; `'11.2'` = höchstens 9 Vor- und 2 Nachkommastellen (Gesamtlänge 11 wie in SAP); das Vorzeichen zählt nicht, führende Nullen schon | `NAME: not a number`, `… more than 9 digits`, `… more than 2 decimals` |
|
||||||
|
| `DAT` | Datum | genau 8 Ziffern `YYYYMMDD`, gültiges Kalenderdatum | wird nicht geprüft | `NAME: not a date YYYYMMDD` |
|
||||||
|
| `TIM` | Uhrzeit | genau 6 Ziffern `HHMMSS`, 00:00:00 bis 23:59:59 | wird nicht geprüft | `NAME: not a time HHMMSS` |
|
||||||
|
|
||||||
|
Ein unbekannter Typ wird wie `AN` behandelt. Das Layout **prüft nur, es formatiert nicht**: Werte werden beim
|
||||||
|
Schreiben unverändert ausgegeben, ohne Auffüllen mit Nullen oder Leerzeichen. Zahlen, Datum und Uhrzeit müssen
|
||||||
|
deshalb vorher in einem `map` als Text in das Zielformat gebracht werden:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
map do |row|
|
||||||
|
row.merge('datum' => row['datum'].strftime('%Y%m%d'), # Date -> '20261009'; Date#to_s wäre '2026-10-09'
|
||||||
|
'zeit' => row['zeit'].strftime('%H%M%S'),
|
||||||
|
'betrag' => format('%.2f', row['betrag']), # BigDecimal#to_s wäre '0.1e2', Float evtl. '1.0e-05'
|
||||||
|
'lfdnr' => format('%06d', row['lfdnr'])) # führende Nullen, falls das Zielsystem sie verlangt
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verhältnis zu `validate`-Regeln** – Layout-Typen beschreiben das Textformat einer Datei mit festen Feldern, die
|
||||||
|
Regel `type:` von `validate` (2.7) prüft dagegen, ob sich ein Wert in einen Ruby-Typ umwandeln lässt. Beides lässt
|
||||||
|
sich kombinieren, z. B. Rohdaten mit Regeln prüfen und die fertigen Sätze gegen das Layout.
|
||||||
|
|
||||||
|
| Layout-Typ | ähnliche `validate`-Regel | Unterschied |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `AN` + Länge | `max: 10` | gleich |
|
||||||
|
| `N` | `type: :integer` bzw. `type: :decimal` | `:decimal` nimmt jedes Ruby-`Float` an (auch `1e3`, `.5`); `N` nur Ziffern mit optionalem Punkt, dafür mit Stellenzahl |
|
||||||
|
| `DAT` | `type: :date` | `:date` erwartet ISO 8601 (`2026-10-09`) oder ein `Date`, `DAT` dagegen `20261009` |
|
||||||
|
| `TIM` | `format: /\A\d{6}\z/` | `TIM` prüft zusätzlich Stunden, Minuten und Sekunden |
|
||||||
|
|
||||||
|
**Optionen** – `Layout.new(type_field: 'satyp')` nimmt die Satzart aus dieser Spalte statt aus dem ersten Wert der
|
||||||
|
Zeile; eine Satzart, die nicht im Layout steht, ist ein Fehler (`record type "X" not in layout (…)`).
|
||||||
|
`strict: false` lässt beim Schreiben Felder zu, die nicht im Layout stehen (sie werden weggelassen).
|
||||||
|
|
||||||
Beim Schreiben stehen die Werte in der Reihenfolge des Layouts, fehlende Felder bleiben leer, ein unbekanntes Feld
|
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
|
bricht mit `DataPipeline::LayoutError` ab. Eine falsch benannte SQL-Spalte verschiebt also nicht mehr still alle
|
||||||
|
|||||||
Reference in New Issue
Block a user