2 Commits
19 changed files with 301 additions and 358 deletions
+15
View File
@@ -2,6 +2,21 @@
Framework `datapipeline` (Gem). Neueste Version oben. Framework `datapipeline` (Gem). Neueste Version oben.
## 3.1.0 – 2026-10-09
- sqlite: `write_row` schreibt gesammelt je `batch_size` Zeilen (Default 1000) in einer Transaktion;
`batch_size: :all` = eine Transaktion für den ganzen Stream, nach einem Abbruch zurückgerollt
- postgres, sqlite: `query` schreibt noch gesammelte Zeilen von `write_row` vorher
- Lizenz BSD-3-Clause (`LICENSE`, gemspec)
## 3.0.0 – 2026-10-09
- **Breaking:** `transform` (auch `processing: :prepend`/`:append`), der Parallelmodus, `chained` und `condition:` als
String (`eval`) entfernt; alle Schritte laufen verkettet. Umstellung: `map`, `header`, `trailer`,
`condition: ->(row) { … }` (README 7.1)
- `condition:` muss ein Lambda sein, sonst `ArgumentError` beim Aufbau der Pipeline
- Hinweise `WARN deprecated` und `test/core/compat_test.rb` entfernt
## 2.1.0 – 2026-10-09 ## 2.1.0 – 2026-10-09
- Sperre gegen parallele Läufe: `lock 'datei'` in der Pipeline, `etl --lock=datei` für das ganze Skript; ein zweiter - Sperre gegen parallele Läufe: `lock 'datei'` in der Pipeline, `etl --lock=datei` für das ganze Skript; ein zweiter
+1 -1
View File
@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
datapipeline (2.1.0) datapipeline (3.1.0)
csv (~> 3.3) csv (~> 3.3)
logger (~> 1.6) logger (~> 1.6)
+28
View File
@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, Karl-Heinz Wild
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+64 -48
View File
@@ -1,6 +1,6 @@
# DataPipeline – ETL-Framework in Ruby # DataPipeline – ETL-Framework in Ruby
Stand: 2026-10-09 · Gem `datapipeline` 2.1.0 ([CHANGELOG.md](CHANGELOG.md)) · Ruby 3.4 Stand: 2026-10-09 · Gem `datapipeline` 3.1.0 ([CHANGELOG.md](CHANGELOG.md)) · Ruby 3.4 · Lizenz BSD-3-Clause ([LICENSE](LICENSE))
`DataPipeline` liest Daten aus einer Quelle, schickt jede Zeile durch eine Kette von Schritten (prüfen, filtern, `DataPipeline` liest Daten aus einer Quelle, schickt jede Zeile durch eine Kette von Schritten (prüfen, filtern,
umbauen, anreichern, abgleichen) und schreibt das Ergebnis in ein oder mehrere Ziele. Eine Pipeline ist ein kurzes umbauen, anreichern, abgleichen) und schreibt das Ergebnis in ein oder mehrere Ziele. Eine Pipeline ist ein kurzes
@@ -15,7 +15,7 @@ Festbreiten-Dateien und REST-APIs.
4. [Plugins](#4-plugins) – mitgelieferte Quellen und Ziele, eigene Plugins, Abhängigkeiten 4. [Plugins](#4-plugins) – mitgelieferte Quellen und Ziele, eigene Plugins, Abhängigkeiten
5. [Satzarten-Layouts](#5-satzarten-layouts) – Dateien mit Kopf-, Positions- und Schlusssätzen 5. [Satzarten-Layouts](#5-satzarten-layouts) – Dateien mit Kopf-, Positions- und Schlusssätzen
6. [Betrieb](#6-betrieb) – Kommandozeile, Verbindungen, Sperre, Abgleich, Historie, Benachrichtigung, Fehlersuche 6. [Betrieb](#6-betrieb) – Kommandozeile, Verbindungen, Sperre, Abgleich, Historie, Benachrichtigung, Fehlersuche
7. [Veraltetes und Umstieg](#7-veraltetes-und-umstieg) – von 2.0 auf 2.1, `transform` und Parallelmodus 7. [Umstieg](#7-umstieg) – auf 3.1, von 2.x auf 3.0 (`transform` entfernt), von 2.0 auf 2.1
8. [Entwicklung](#8-entwicklung) – Aufbau, Tests, Gem bauen 8. [Entwicklung](#8-entwicklung) – Aufbau, Tests, Gem bauen
9. [Ausblick](#9-ausblick) 9. [Ausblick](#9-ausblick)
@@ -54,10 +54,13 @@ In einer anderen Anwendung:
```ruby ```ruby
# Gemfile # Gemfile
gem 'datapipeline', path: '../datapipeline' # oder git: '…' gem 'datapipeline', '~> 3.1', git: 'https://git.wicom.li/wkh/datapipeline.git', branch: 'main'
gem 'pg' # nur die Gems der benötigten Plugins (4.10) gem 'pg' # nur die Gems der benötigten Plugins (4.10)
``` ```
`'~> 3.1'` verhindert, dass `bundle update` unbemerkt eine neue Hauptversion mit Breaking Changes holt (8.3). Für die
Entwicklung gegen einen lokalen Checkout: `bundle config set local.datapipeline ../datapipeline`.
```sh ```sh
bundle install bundle install
bundle exec etl pipeline.rb --dry-run # das Kommando etl kommt mit dem Gem bundle exec etl pipeline.rb --dry-run # das Kommando etl kommt mit dem Gem
@@ -783,7 +786,6 @@ Tippfehler fallen also sofort auf.
| --- | --- | | --- | --- |
| `streamed(flag = true)` | Zeile für Zeile statt Batch (3.3) | | `streamed(flag = true)` | Zeile für Zeile statt Batch (3.3) |
| `verbose(flag = true)` | Quelle, Ergebnis und Ziel im Batch als JSON auf `$stdout` | | `verbose(flag = true)` | Quelle, Ergebnis und Ziel im Batch als JSON auf `$stdout` |
| `chained(flag = true)` | nur für `transform` nötig (7.2) |
| `configure(opts)` | mehrere Einstellungen als Hash, z. B. `configure(streamed: true, version: '1.0')` | | `configure(opts)` | mehrere Einstellungen als Hash, z. B. `configure(streamed: true, version: '1.0')` |
| `run` | führt die Pipeline aus und liefert `DataPipeline::Stats` | | `run` | führt die Pipeline aus und liefert `DataPipeline::Stats` |
| `DataPipeline.new(**settings) { … }` | Einstellungen nur für diese Pipeline, z. B. `new(limit: 10)` | | `DataPipeline.new(**settings) { … }` | Einstellungen nur für diese Pipeline, z. B. `new(limit: 10)` |
@@ -827,7 +829,7 @@ Unterschieden:
| `header` | kennt `read` und `written` | wird zuerst geschrieben, `read` und `written` sind `nil` | | `header` | kennt `read` und `written` | wird zuerst geschrieben, `read` und `written` sind `nil` |
| `ctx.read` in `map`/`filter` | Anzahl Quellzeilen | `nil` | | `ctx.read` in `map`/`filter` | Anzahl Quellzeilen | `nil` |
| `reconcile` | vor dem Schreiben | nach der letzten Zeile; die Zeilen sind schon geschrieben | | `reconcile` | vor dem Schreiben | nach der letzten Zeile; die Zeilen sind schon geschrieben |
| Postgres als Ziel | eine Transaktion für alles | eine Transaktion je `batch_size` Zeilen, mit `batch_size: :all` eine für alles (4.2) | | Postgres, SQLite als Ziel | eine Transaktion für alles | eine Transaktion je `batch_size` Zeilen, mit `batch_size: :all` eine für alles (4.2, 4.3) |
| Postgres als Quelle | eine Abfrage | serverseitiger Cursor, je `fetch_size` Zeilen | | Postgres als Quelle | eine Abfrage | serverseitiger Cursor, je `fetch_size` Zeilen |
`limit n` beendet im Streaming das Lesen nach n Zeilen. `limit n` beendet im Streaming das Lesen nach n Zeilen.
@@ -918,8 +920,8 @@ Dry-Run wird die Fehlerdatei nicht geschrieben.
| `to_s` | `read=9 written=4 rejected=4 0.097s`; mit Warnungen `warnings=n`, mit `totals` `spalte=quelle/ergebnis` | | `to_s` | `read=9 written=4 rejected=4 0.097s`; mit Warnungen `warnings=n`, mit `totals` `spalte=quelle/ergebnis` |
| `to_h` | alles als Hash, wie in der Laufhistorie | | `to_h` | alles als Hash, wie in der Laufhistorie |
Der Logger schreibt `start` und `finish` (Info), jede verworfene Zeile und Warnung (Warnung), veraltete Aufrufe Der Logger schreibt `start` und `finish` (Info), jede verworfene Zeile und Warnung (Warnung) und einen
einmal je Prozess (Warnung, 7.2) und einen Abbruch (Fehler). `on_finish` läuft nach jedem Lauf, auch nach einem Abbruch (Fehler). `on_finish` läuft nach jedem Lauf, auch nach einem
Abbruch; ein Fehler im Hook wird nur geloggt und ändert weder Ergebnis noch Exit-Code. Abbruch; ein Fehler im Hook wird nur geloggt und ändert weder Ergebnis noch Exit-Code.
```ruby ```ruby
@@ -1014,9 +1016,13 @@ write container(:sqlite, database: 'tmp/kunden.sqlite3', table: 'kunden', create
| `create: true` | Tabelle beim ersten Schreiben aus der ersten Zeile anlegen (Integer → `integer`, Float → `real`, sonst `text`) | | `create: true` | Tabelle beim ersten Schreiben aus der ersten Zeile anlegen (Integer → `integer`, Float → `real`, sonst `text`) |
| `truncate: true` | Tabelle vor dem ersten Schreiben leeren | | `truncate: true` | Tabelle vor dem ersten Schreiben leeren |
| `upsert: %w[belnr]` | wie bei Postgres; mit `create: true` wird der nötige Unique-Index mit angelegt | | `upsert: %w[belnr]` | wie bei Postgres; mit `create: true` wird der nötige Unique-Index mit angelegt |
| `batch_size` | Streaming: Zeilen je Transaktion (Default 1000) oder `:all` |
Werte kommen mit SQLite-Typen zurück (Integer, Float, String, `nil`); `Date` und `Time` werden als ISO-Text Werte kommen mit SQLite-Typen zurück (Integer, Float, String, `nil`); `Date` und `Time` werden als ISO-Text
geschrieben. `write` schreibt in einer Transaktion. geschrieben. `write` schreibt in einer Transaktion. Im Streaming gilt dasselbe wie bei Postgres (4.2): `write_row`
schreibt je `batch_size` Zeilen in einer Transaktion, `finalize` und `close` den Rest; mit `batch_size: :all` ist der
ganze Stream eine Transaktion, die nach einem Abbruch zurückgerollt wird. Eine Abfrage (`query`, `lookup`) auf
demselben Storage schreibt noch gesammelte Zeilen vorher, sie sieht also alles, was schon übergeben wurde.
### 4.4 CSV ### 4.4 CSV
@@ -1507,53 +1513,62 @@ gelesen 1250 · geschrieben 1243 · verworfen 7 · Warnungen 0
| `LayoutError: TEXT: "…" longer than 30` | Wert passt nicht in das Feld einer Festbreiten-Datei | vorher `validate LAYOUT` mit `on_error`, oder den Wert kürzen | | `LayoutError: TEXT: "…" longer than 30` | Wert passt nicht in das Feld einer Festbreiten-Datei | vorher `validate LAYOUT` mit `on_error`, oder den Wert kürzen |
| `Encoding::UndefinedConversionError: U+20AC from UTF-8 to ISO-8859-1` | Zeichen gibt es im Zielzeichensatz nicht | `Windows-1252` statt ISO-8859-1, oder `replace: '?'` | | `Encoding::UndefinedConversionError: U+20AC from UTF-8 to ISO-8859-1` | Zeichen gibt es im Zielzeichensatz nicht | `Windows-1252` statt ISO-8859-1, oder `replace: '?'` |
| `connection :x not in config/connections.yml (a, b)` | Name falsch oder Datei nicht gefunden | Namen prüfen; Pfad relativ zum Arbeitsverzeichnis, sonst `connections_file` setzen | | `connection :x not in config/connections.yml (a, b)` | Name falsch oder Datei nicht gefunden | Namen prüfen; Pfad relativ zum Arbeitsverzeichnis, sonst `connections_file` setzen |
| `streamed: several transformations need chained (or map/filter)` | mehrere `transform` im Streaming | `map` verwenden (7.2) | | `NoMethodError: undefined method 'transform'` (oder `chained`) | Skript für 2.x | auf `map`, `header`, `trailer` umstellen (7.1) |
| `WARN deprecated: …` | veralteter Aufruf | umstellen, siehe 7.2 | | `map: condition must be a lambda ->(row) { ... }` | `condition:` als String aus 2.x | `condition: ->(row) { … }` (7.1) |
Mehr sehen: `--verbose` (Log-Level Debug), `verbose` in der Pipeline (Daten als JSON), `--dry-run --limit=20` für einen Mehr sehen: `--verbose` (Log-Level Debug), `verbose` in der Pipeline (Daten als JSON), `--dry-run --limit=20` für einen
schnellen Blick auf das Ergebnis. schnellen Blick auf das Ergebnis.
--- ---
## 7. Veraltetes und Umstieg ## 7. Umstieg
### 7.1 Von 2.0 auf 2.1 ### 7.0 Von 3.0 auf 3.1
2.1 ist abwärtskompatibel; bestehende Skripte laufen ohne Änderung. Anders als bisher: 3.1 ist abwärtskompatibel. Anders als in 3.0: SQLite schreibt im Streaming (`write_row`) nicht mehr jede Zeile
einzeln, sondern je 1000 Zeilen in einer Transaktion (4.3). Wer während eines Laufs mit einer zweiten Verbindung
auf die Zieldatei schaut, sieht die Zeilen also paketweise. Das Gem steht jetzt unter der BSD-3-Clause-Lizenz.
| Thema | 2.0 | 2.1 | ### 7.1 Von 2.x auf 3.0
3.0 entfernt das alte Ausführungsmodell: `transform`, den Parallelmodus, `chained` und `condition:` als String
(`eval`). Alle Schritte laufen jetzt verkettet, jeder bekommt die Ausgabe des vorigen. Alles andere ist unverändert;
ein Skript, das unter 2.1 ohne `WARN deprecated` lief, läuft auch unter 3.0.
| 2.x | 3.0 |
| --- | --- |
| `transform { \|row, index\| … }` | `map { \|row, ctx\| … }`, Index als `ctx.index` |
| `transform(processing: :prepend) { \|_, _\| kopf }` | `header { \|ctx\| kopf }` |
| `transform(processing: :append) { \|_, n\| schluss }` | `trailer { \|ctx\| schluss }`, Anzahl als `ctx.written` |
| `transform(condition: "row['a'] == 1") { … }` | `map(condition: ->(row) { row['a'] == 1 }) { … }` |
| `chained`, `configure(chained: true)` | weglassen |
Worauf beim Umstellen zu achten ist:
- **Parallelmodus:** Ohne `map`, `filter`, `validate` und `chained` bekam unter 2.x jeder `transform` eine eigene
Kopie der Quellzeilen, und die Ergebnisse wurden hintereinander angehängt. Zwei `transform` auf 2 Quellzeilen
ergaben 4 Zeilen, zwei `map` ergeben 2 Zeilen mit beiden Änderungen. Wer wirklich jede Zeile mehrfach ausgeben will,
gibt in einem `map` ein Array zurück: `map { |r| [mit_b(r), mit_c(r)] }`.
- **Kopfsatz:** `transform(processing: :prepend)` lief vor den Daten, `header` läuft im Batch erst danach (steht aber
vorne). Das zählt nur, wenn der Block etwas anderes tut, als eine Zeile zu liefern.
- **Schlusssatz:** `processing: :append` bekam im Parallelmodus die Anzahl der Quellzeilen; `ctx.written` ist die
Anzahl der Datenzeilen nach allen Schritten, `ctx.read` die der Quellzeilen.
- **Bedingung:** Ein String in `condition:` bricht schon beim Aufbau der Pipeline mit `ArgumentError` ab.
Prüfen lässt sich die Umstellung mit `etl run skript.rb --dry-run` vorher und nachher: Die Zusammenfassung und die
ersten Zeilen sollten gleich sein.
### 7.2 Von 2.0 auf 2.1
2.1 war abwärtskompatibel. Anders als in 2.0:
| Thema | 2.0 | ab 2.1 |
| --- | --- | --- | | --- | --- | --- |
| Postgres, `write_row` im Streaming | jede Zeile sofort, ohne Transaktion | je 1000 Zeilen in einer Transaktion; der Rest in `finalize`/`close`, auch nach einem Abbruch | | Postgres, `write_row` im Streaming | jede Zeile sofort, ohne Transaktion | je 1000 Zeilen in einer Transaktion; der Rest in `finalize`/`close`, auch nach einem Abbruch |
| CSV lesen mit UTF-8-BOM | BOM steht im ersten Spaltennamen | BOM wird übersprungen | | CSV lesen mit UTF-8-BOM | BOM steht im ersten Spaltennamen | BOM wird übersprungen |
| `Stats#to_s` | – | mit `totals` zusätzlich `spalte=quelle/ergebnis` | | `Stats#to_s` | – | mit `totals` zusätzlich `spalte=quelle/ergebnis` |
| `transform`, Parallelmodus, `condition:` als String | ohne Hinweis | `WARN deprecated: …` einmal je Prozess |
| `etl` ohne `name` | – | `stats.name` = Skriptname | | `etl` ohne `name` | – | `stats.name` = Skriptname |
### 7.2 `transform` und Parallelmodus
`transform`, der Parallelmodus und `condition:` als String (`eval`) sind veraltet und entfallen in 3.0. Ab 3.0
laufen alle Schritte verkettet, wie heute schon, sobald `map`, `filter` oder `validate` vorkommt.
| Alt | Neu |
| --- | --- |
| `transform { \|row, index\| … }` | `map { \|row, ctx\| … }`, Index als `ctx.index` |
| `transform(processing: :prepend) { \|_, _\| kopf }` | `header { \|ctx\| kopf }` |
| `transform(processing: :append) { \|_, n\| schluss }` | `trailer { \|ctx\| schluss }` mit `ctx.written` |
| `transform(condition: "row['a'] == 1")` | `map(condition: ->(row) { row['a'] == 1 })` |
| mehrere `transform` ohne `chained` (Parallelmodus) | mehrere `map`; wer wirklich jede Zeile mehrfach ausgeben will, gibt in einem `map` ein Array zurück |
Bis dahin gilt für alte Skripte:
- **Parallelmodus** (Default ohne `map`/`filter`/`validate` und ohne `chained`): Jeder `transform` bekommt eine eigene
Kopie der Quellzeilen, die Ergebnisse aller Schritte werden hintereinander angehängt. 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`. Im Streaming sind mehrere `transform` nur verkettet erlaubt.
- **`transform(processing: :prepend)`** wird mit `(nil, 0)` vor den Daten aufgerufen, **`:append`** mit `(nil, n)`
nach den Daten (parallel n = Quellzeilen, verkettet n = Datenzeilen).
- **`condition:` als String** wird mit `eval` ausgewertet, die Variable `row` ist sichtbar.
`test/core/compat_test.rb` hält die Ausgabe des alten Modells fest, damit es sich bis 3.0 nicht ändert.
--- ---
## 8. Entwicklung ## 8. Entwicklung
@@ -1577,13 +1592,13 @@ Bis dahin gilt für alte Skripte:
### 8.2 Tests ### 8.2 Tests
`bundle exec rake test` startet die Minitest-Suite in [test/](test/), rund 205 Fälle. Einzelne Datei: `bundle exec rake test` startet die Minitest-Suite in [test/](test/), rund 190 Fälle. Einzelne Datei:
`bundle exec rake test TEST=test/core/steps_test.rb`. `bundle exec rake test TEST=test/core/steps_test.rb`.
| Ordner | Inhalt | | Ordner | Inhalt |
| --- | --- | | --- | --- |
| `test/plugins` | je Plugin das gemeinsame Testkit `test/support/storage_contract.rb` (Schreiben und Zurücklesen, zeilenweise, als Quelle und Ziel) plus eigene Fälle; REST gegen einen lokalen Testserver; Laden der Gems erst bei Nutzung | | `test/plugins` | je Plugin das gemeinsame Testkit `test/support/storage_contract.rb` (Schreiben und Zurücklesen, zeilenweise, als Quelle und Ziel) plus eigene Fälle; REST gegen einen lokalen Testserver; Laden der Gems erst bei Nutzung |
| `test/core` | DSL, Schritte und Kurzschritte, Batch und Streaming, Fehlerbehandlung, Layouts, Sperre, Verbindungen, Summen, Historie, Wasserzeichen; `compat_test.rb` hält das alte `transform`-Modell fest | | `test/core` | DSL, Schritte und Kurzschritte, Batch und Streaming, Fehlerbehandlung, Layouts, Sperre, Verbindungen, Summen, Historie, Wasserzeichen |
| `test/cli` | `bin/etl` als Prozess: Parameter, Dry-Run, Sperre, Historie, Exit-Codes | | `test/cli` | `bin/etl` als Prozess: Parameter, Dry-Run, Sperre, Historie, Exit-Codes |
| `test/examples` | die Beispiele aus Kapitel 2: Code in der Doku = Datei, Lauf und Ergebnis; REST gegen die Demo-API | | `test/examples` | die Beispiele aus Kapitel 2: Code in der Doku = Datei, Lauf und Ergebnis; REST gegen die Demo-API |
@@ -1604,10 +1619,14 @@ Ein eigenes Plugin lässt sich mit `MemoryStorage` aus `test/test_helper.rb` als
### 8.3 Gem und Versionen ### 8.3 Gem und Versionen
- Version in [lib/datapipeline/version.rb](lib/datapipeline/version.rb), Änderungen in [CHANGELOG.md](CHANGELOG.md) - Version in [lib/datapipeline/version.rb](lib/datapipeline/version.rb), Änderungen in [CHANGELOG.md](CHANGELOG.md)
(neueste oben). Neue Funktionen erhöhen die mittlere Stelle, Breaking Changes die erste (3.0 entfernt `transform`). (neueste oben). Neue Funktionen erhöhen die mittlere Stelle, Breaking Changes die erste (wie 3.0, 7.1).
- Jede Version bekommt ein Tag `vX.Y.Z` (`git tag -a v3.1.0 -m "datapipeline 3.1.0" && git push origin v3.1.0`).
Anwendungen binden das Gem mit einer Versionsgrenze ein (`'~> 3.1'`, 1.2) oder fest auf ein Tag
(`tag: 'v3.1.0'`; dann geht `local.datapipeline` nicht).
- Das `Gemfile` bindet das Gem über `gemspec` ein und ergänzt die Gruppen `plugins` (`pg`, `sqlite3`, `roo`, - Das `Gemfile` bindet das Gem über `gemspec` ein und ergänzt die Gruppen `plugins` (`pg`, `sqlite3`, `roo`,
`caxlsx`, `http`) und `test`; `Gemfile.lock` ist versioniert. Ohne Plugin-Gems: `bundle config set without plugins`. `caxlsx`, `http`) und `test`; `Gemfile.lock` ist versioniert. Ohne Plugin-Gems: `bundle config set without plugins`.
- Gem bauen: `gem build datapipeline.gemspec`. - Gem bauen: `gem build datapipeline.gemspec`.
- Lizenz: BSD-3-Clause, siehe [LICENSE](LICENSE); die Datei ist Teil des Gems.
- Beispiele in Kapitel 2 werden mit einem Marker `<!-- beispiel: examples/datei.rb -->` vor dem Codeblock eingebunden; - Beispiele in Kapitel 2 werden mit einem Marker `<!-- beispiel: examples/datei.rb -->` vor dem Codeblock eingebunden;
der Doku-Test vergleicht Block und Datei, Änderungen also immer in beiden machen. der Doku-Test vergleicht Block und Datei, Änderungen also immer in beiden machen.
@@ -1621,9 +1640,6 @@ Offene Punkte, Reihenfolge = empfohlene Umsetzung:
schreiben), `etl list` (Pipelines, Wasserzeichen, letzter Lauf aus der Historie), `--json` für die Stats. schreiben), `etl list` (Pipelines, Wasserzeichen, letzter Lauf aus der Historie), `--json` für die Stats.
2. **Qualität:** CI auf git.wicom.li mit `rake test` und Postgres-Service, RuboCop, `DataPipeline::Testing` mit 2. **Qualität:** CI auf git.wicom.li mit `rake test` und Postgres-Service, RuboCop, `DataPipeline::Testing` mit
`MemoryStorage` als öffentliche Testhilfe für Anwendungen. `MemoryStorage` als öffentliche Testhilfe für Anwendungen.
3. **SQLite im Streaming:** `write_row` wie bei Postgres in Paketen schreiben. 3. **Slack:** fertige Meldung aus den Stats (`notify slack, on: :problems`), Timeout und Wiederholung bei `429`,
4. **Slack:** fertige Meldung aus den Stats (`notify slack, on: :problems`), Timeout und Wiederholung bei `429`,
Maskierung von `&`, `<`, `>`, Incoming Webhooks. Maskierung von `&`, `<`, `>`, Incoming Webhooks.
5. **Weitere Plugins:** SFTP-Ablage. 4. **Weitere Plugins:** SFTP-Ablage.
6. **3.0:** `transform`, Parallelmodus und `condition:` als String entfernen.
7. **Veröffentlichung:** eigenes Remote-Repository, Lizenz festlegen, Versionen taggen.
+2 -2
View File
@@ -12,13 +12,13 @@ Gem::Specification.new do |spec|
Fehlerdateien, Dry-Run, inkrementellen Läufen und dem Kommando etl mit Exit-Codes. Fehlerdateien, Dry-Run, inkrementellen Läufen und dem Kommando etl mit Exit-Codes.
TEXT TEXT
spec.homepage = 'https://git.wicom.li/wkh/datapipeline' spec.homepage = 'https://git.wicom.li/wkh/datapipeline'
spec.license = 'Nonstandard' spec.license = 'BSD-3-Clause'
spec.required_ruby_version = '>= 3.4' spec.required_ruby_version = '>= 3.4'
spec.metadata['changelog_uri'] = "#{spec.homepage}/src/branch/main/CHANGELOG.md" spec.metadata['changelog_uri'] = "#{spec.homepage}/src/branch/main/CHANGELOG.md"
spec.metadata['rubygems_mfa_required'] = 'true' spec.metadata['rubygems_mfa_required'] = 'true'
spec.files = Dir['lib/**/*.rb', 'exe/*', 'README.md', 'CHANGELOG.md'] spec.files = Dir['lib/**/*.rb', 'exe/*', 'README.md', 'CHANGELOG.md', 'LICENSE']
spec.bindir = 'exe' spec.bindir = 'exe'
spec.executables = ['etl'] spec.executables = ['etl']
spec.require_paths = ['lib'] spec.require_paths = ['lib']
+26 -114
View File
@@ -64,28 +64,15 @@ class DataPipeline
# raised when a reconcile check fails # raised when a reconcile check fails
class ReconcileError < StandardError; end class ReconcileError < StandardError; end
EDGE_BEFORE = %i[prepend header].freeze
EDGE_AFTER = %i[append trailer].freeze
CHAINING = %i[map filter validate].freeze
ON_ERROR = %i[abort skip collect].freeze ON_ERROR = %i[abort skip collect].freeze
MAX_ERRORS = 100 MAX_ERRORS = 100
# removed in 3.0, logged once per process as a warning
DEPRECATIONS = {
transform: 'transform is deprecated and will be removed in 3.0: use map/filter, ' \
'header/trailer instead of processing: :prepend/:append',
parallel: 'parallel mode (several transform steps without chained) is deprecated and will be removed in 3.0: ' \
'set chained or use map',
condition: 'condition: as string (eval) is deprecated and will be removed in 3.0: use a lambda ->(row) { ... }'
}.freeze
# value conversions for cast, empty values become nil # value conversions for cast, empty values become nil
CASTS = Validation::TYPES.merge(string: ->(v) { v.to_s }).freeze CASTS = Validation::TYPES.merge(string: ->(v) { v.to_s }).freeze
DEFAULT_SETTINGS = { DEFAULT_SETTINGS = {
version: nil, version: nil,
streamed: false, streamed: false,
chained: false,
verbose: false, verbose: false,
dry_run: false, dry_run: false,
limit: nil, limit: nil,
@@ -100,7 +87,6 @@ class DataPipeline
SETTINGS_LOCK = Mutex.new SETTINGS_LOCK = Mutex.new
@defaults = DEFAULT_SETTINGS @defaults = DEFAULT_SETTINGS
@plugins = {} @plugins = {}
@deprecations_logged = []
# #
# defaults for every following DataPipeline.new, e.g. from bin/etl # defaults for every following DataPipeline.new, e.g. from bin/etl
@@ -166,18 +152,6 @@ class DataPipeline
@runs ||= [] @runs ||= []
end end
# true the first time a deprecation is reported in this process
def self.deprecation_once key
SETTINGS_LOCK.synchronize do
next false if @deprecations_logged.include?(key)
@deprecations_logged << key
true
end
end
def self.reset_deprecations! = SETTINGS_LOCK.synchronize { @deprecations_logged = [] }
# see DataPipeline::Connections # see DataPipeline::Connections
def self.connections_file = Connections.file def self.connections_file = Connections.file
def self.connections_file=(path) def self.connections_file=(path)
@@ -204,7 +178,7 @@ class DataPipeline
# DataPipeline.new(verbose: true) { ... } overrides the defaults for this pipeline only # DataPipeline.new(verbose: true) { ... } overrides the defaults for this pipeline only
def initialize(**settings, &block) def initialize(**settings, &block)
@settings = DataPipeline.settings.merge(settings) @settings = DataPipeline.settings.merge(settings)
@transformations = [] @steps = []
@targets = [] @targets = []
@on_finish = [] @on_finish = []
@logger = Logger.new($stderr, level: @settings[:log_level], progname: 'etl') @logger = Logger.new($stderr, level: @settings[:log_level], progname: 'etl')
@@ -232,11 +206,6 @@ class DataPipeline
@settings[:streamed] = flag @settings[:streamed] = flag
end end
# each body step gets the output of the previous step instead of the source rows
def chained flag = true
@settings[:chained] = flag
end
# run everything, write nothing (target, rejects, watermark); prints the first rows # run everything, write nothing (target, rejects, watermark); prints the first rows
def dry_run flag = true def dry_run flag = true
@settings[:dry_run] = flag @settings[:dry_run] = flag
@@ -343,29 +312,19 @@ class DataPipeline
klass.new(**options, &block) klass.new(**options, &block)
end end
# # map { |row, ctx| ... } -> hash, array of hashes or nil
# transform(options) { |row, index| ... } # condition: ->(row) { ... } on map and filter: the step only for matching rows, the others are dropped
# processing: :prepend | :normal (default) | :append
# condition: ruby expression as string (row is visible) or lambda { |row| ... }
#
def transform(options = {}, &block)
kind = options.fetch(:processing, :normal)
kind = :normal unless %i[prepend append].include?(kind)
add_step kind, options, &block
end
# map { |row, ctx| ... } -> hash, array of hashes or nil; turns chaining on
def map(options = {}, &block) def map(options = {}, &block)
add_step :map, options, &block add_step :map, options, &block
end end
# filter { |row, ctx| ... } -> keeps the row when the block is truthy; turns chaining on # filter { |row, ctx| ... } -> keeps the row when the block is truthy
def filter(options = {}, &block) def filter(options = {}, &block)
add_step :filter, options, &block add_step :filter, options, &block
end end
# #
# short steps, each one a map step (turns chaining on) # short steps, each one a map step
# #
# rename 'kunnr' => 'kundennr' -> new key at the same position # rename 'kunnr' => 'kundennr' -> new key at the same position
# select %w[belnr betrag] -> only these keys in this order, missing ones nil # select %w[belnr betrag] -> only these keys in this order, missing ones nil
@@ -419,7 +378,7 @@ class DataPipeline
# validate layout -> fields of a DataPipeline::Layout # validate layout -> fields of a DataPipeline::Layout
# validate { |row| 'soll ungleich haben' if ... } -> nil, message or array of messages # validate { |row| 'soll ungleich haben' if ... } -> nil, message or array of messages
# #
# a failing row raises ValidationError, handled by on_error; turns chaining on # a failing row raises ValidationError, handled by on_error
# warn: true -> only a warning in the log and stats.warnings, the row is kept # warn: true -> only a warning in the log and stats.warnings, the row is kept
# ignore: %w[A B] -> messages for these fields are left out (known gaps) # ignore: %w[A B] -> messages for these fields are left out (known gaps)
# #
@@ -427,7 +386,7 @@ class DataPipeline
warn = options.delete(:warn) || false warn = options.delete(:warn) || false
ignore = Array(options.delete(:ignore)).map(&:to_s) ignore = Array(options.delete(:ignore)).map(&:to_s)
rules ||= options # validate 'konto' => { ... } arrives as keywords rules ||= options # validate 'konto' => { ... } arrives as keywords
@transformations << { kind: :validate, block:, options: { rules:, warn:, ignore: } } @steps << { kind: :validate, block:, options: { rules:, warn:, ignore: } }
end end
def read storage, options = {} def read storage, options = {}
@@ -450,7 +409,7 @@ class DataPipeline
end end
# #
# loads a table once, for lookups in map/transform instead of one query per row # loads a table once, for lookups in map instead of one query per row
# #
# lookup pg, query: 'select ...', params: [...], key: 'rechnung_dbid' -> { key => [rows] } # lookup pg, query: 'select ...', params: [...], key: 'rechnung_dbid' -> { key => [rows] }
# lookup pg, query: 'select ...', key: 'id', unique: true -> { key => row } # lookup pg, query: 'select ...', key: 'id', unique: true -> { key => row }
@@ -472,10 +431,9 @@ class DataPipeline
(@lock = Lock.new(@settings[:lock])).acquire if @settings[:lock] (@lock = Lock.new(@settings[:lock])).acquire if @settings[:lock]
raise ArgumentError, 'write storage missing' if @targets.empty? raise ArgumentError, 'write storage missing' if @targets.empty?
before = @transformations.select { |t| EDGE_BEFORE.include?(t[:kind]) } before = @steps.select { |t| t[:kind] == :header }
after = @transformations.select { |t| EDGE_AFTER.include?(t[:kind]) } after = @steps.select { |t| t[:kind] == :trailer }
body = @transformations - before - after body = @steps - before - after
log_deprecations body
@settings[:streamed] ? run_streamed(before, body, after) : run_batch(before, body, after) @settings[:streamed] ? run_streamed(before, body, after) : run_batch(before, body, after)
@@ -493,8 +451,11 @@ class DataPipeline
def add_step kind, options, &block def add_step kind, options, &block
raise ArgumentError, "#{kind}: block missing" unless block raise ArgumentError, "#{kind}: block missing" unless block
if options.key?(:condition) && !options[:condition].respond_to?(:call)
raise ArgumentError, "#{kind}: condition must be a lambda ->(row) { ... }"
end
@transformations << { kind:, block:, options: } @steps << { kind:, block:, options: }
end end
def value_of row, key def value_of row, key
@@ -511,21 +472,6 @@ class DataPipeline
raise ArgumentError, "#{key}: #{value.inspect} is not #{type}" raise ArgumentError, "#{key}: #{value.inspect} is not #{type}"
end end
# each deprecated feature once per process, with the logger of the pipeline
def log_deprecations body
used = []
used << :transform if @transformations.any? { |t| %i[prepend normal append].include?(t[:kind]) }
used << :parallel if !chained? && body.size > 1
used << :condition if @transformations.any? { |t| t[:options].is_a?(Hash) && t[:options][:condition].is_a?(String) }
used.each { |key| @logger.warn "deprecated: #{DEPRECATIONS[key]}" if DataPipeline.deprecation_once(key) }
end
# without any transformation the source is copied unchanged
def chained?
@settings[:chained] || @transformations.empty? ||
@transformations.any? { |t| CHAINING.include?(t[:kind]) }
end
def write_row row def write_row row
@targets.each { |t| t[:storage].write_row(row) if t[:only].nil? || t[:only].call(row) } @targets.each { |t| t[:storage].write_row(row) if t[:only].nil? || t[:only].call(row) }
end end
@@ -607,33 +553,21 @@ class DataPipeline
data.each { |row| add_total(:read, row) } if @totals data.each { |row| add_total(:read, row) } if @totals
dump 'Source#data', data dump 'Source#data', data
# prepend keeps its old timing: called before the body
results = {}.compare_by_identity
before.select { |t| t[:kind] == :prepend }.each { |t| results[t] = edge(t, nil, 0) }
rows = [] rows = []
if chained? counters = Array.new(body.size, 0)
counters = Array.new(body.size, 0) data.each { |row| push(row, body, counters, data.length) { |out| rows << out } }
data.each { |row| push(row, body, counters, data.length) { |out| rows << out } }
else
body.each { |step| rows.concat apply(step, data) }
end
dump 'Transformation#data', rows dump 'Transformation#data', rows
@stats.written = rows.length @stats.written = rows.length
rows.each { |row| add_total(:written, row) } if @totals rows.each { |row| add_total(:written, row) } if @totals
check_reconcile check_reconcile
# header is computed after the body, so it knows the counters
ctx = Context.new(read: data.length, written: rows.length) ctx = Context.new(read: data.length, written: rows.length)
before.select { |t| t[:kind] == :header }.each { |t| results[t] = edge(t, ctx, 0) }
# parallel mode keeps the old count of source rows for append
count = chained? ? rows.length : data.length
new_data = [] new_data = []
before.each { |t| add_rows new_data, results[t] } before.each { |t| add_rows new_data, t[:block].call(ctx) }
new_data.concat rows new_data.concat rows
after.each { |t| add_rows new_data, edge(t, ctx, count) } after.each { |t| add_rows new_data, t[:block].call(ctx) }
@stats.written = rows.length
dump 'Target#data', new_data dump 'Target#data', new_data
output(new_data) do |all| output(new_data) do |all|
@targets.each { |t| t[:storage].write(t[:only] ? all.select(&t[:only]) : all) } @targets.each { |t| t[:storage].write(t[:only] ? all.select(&t[:only]) : all) }
@@ -642,13 +576,9 @@ class DataPipeline
# row by row from source.read { } to target.write_row, header only knows nothing yet # row by row from source.read { } to target.write_row, header only knows nothing yet
def run_streamed before, body, after def run_streamed before, body, after
if !chained? && body.size > 1
raise ArgumentError, 'streamed: several transformations need chained (or map/filter)'
end
emit = ->(result) { output(add_rows([], result)) { |rows| rows.each { |row| write_row(row) } } } emit = ->(result) { output(add_rows([], result)) { |rows| rows.each { |row| write_row(row) } } }
before.each { |t| emit.call edge(t, Context.new, 0) } before.each { |t| emit.call t[:block].call(Context.new) }
read = written = 0 read = written = 0
counters = Array.new(body.size, 0) counters = Array.new(body.size, 0)
@@ -673,7 +603,7 @@ class DataPipeline
check_reconcile check_reconcile
ctx = Context.new(read:, written:) ctx = Context.new(read:, written:)
after.each { |t| emit.call edge(t, ctx, chained? ? written : read) } after.each { |t| emit.call t[:block].call(ctx) }
@targets.each { |t| t[:storage].finalize } @targets.each { |t| t[:storage].finalize }
end end
@@ -688,11 +618,6 @@ class DataPipeline
end end
end end
# parallel mode: every step reads its own copy of the source rows
def apply step, rows
rows.each_with_index.flat_map { |row, index| safe_step_rows(step, row.dup, index, rows.length) }
end
# errors of a step for one row go to on_error # errors of a step for one row go to on_error
def safe_step_rows step, row, index, read def safe_step_rows step, row, index, read
step_rows(step, row, index, read) step_rows(step, row, index, read)
@@ -715,7 +640,7 @@ class DataPipeline
def step_name step def step_name step
name = step[:options].is_a?(Hash) && step[:options][:name] || step[:kind] name = step[:options].is_a?(Hash) && step[:options][:name] || step[:kind]
"#{name}##{@transformations.index(step) + 1}" "#{name}##{@steps.index(step) + 1}"
end end
# rows produced by one step for one input row # rows produced by one step for one input row
@@ -743,26 +668,13 @@ class DataPipeline
step[:block].call(row, Context.new(read:, index:)) ? [row] : [] step[:block].call(row, Context.new(read:, index:)) ? [row] : []
when :map when :map
add_rows [], step[:block].call(row, Context.new(read:, index:)) add_rows [], step[:block].call(row, Context.new(read:, index:))
else
add_rows [], step[:block].call(row, index)
end end
end end
def edge step, ctx, count # condition: ->(row) { ... } on map and filter
case step[:kind]
when :prepend then step[:block].call(nil, 0)
when :append then step[:block].call(nil, count)
else step[:block].call(ctx)
end
end
# string conditions are evaluated with the local variable row
def condition? step, row def condition? step, row
condition = step[:options].fetch(:condition, true) condition = step[:options][:condition]
return true if condition == true condition.nil? || condition.call(row)
return condition.call(row) if condition.respond_to?(:call)
eval(condition)
end end
# a hash is one row, an array are several rows, nil is no row # a hash is one row, an array are several rows, nil is no row
+3
View File
@@ -48,9 +48,12 @@ class DataPipeline
each_row(@options[:query], params, &block) each_row(@options[:query], params, &block)
end end
# pending rows of write_row are written first, so a query sees them
def query options = {} def query options = {}
raise "Query for PostgresStorage:query missing" unless options[:query] raise "Query for PostgresStorage:query missing" unless options[:query]
flush
result = @conn.exec_params(options[:query], options.fetch(:params, [])) result = @conn.exec_params(options[:query], options.fetch(:params, []))
return result.to_a unless block_given? return result.to_a unless block_given?
+66 -3
View File
@@ -12,6 +12,8 @@ class DataPipeline
# truncate: true -> delete all rows of the table before the first write # truncate: true -> delete all rows of the table before the first write
# upsert: %w[belnr] -> insert ... on conflict (belnr) do update; with create: true the unique index # upsert: %w[belnr] -> insert ... on conflict (belnr) do update; with create: true the unique index
# is created too, otherwise the table needs one on these columns # is created too, otherwise the table needs one on these columns
# batch_size: write_row collects rows and writes them in one transaction per batch (default 1000);
# :all -> one transaction for the whole stream, committed by finalize, rolled back by close
# #
# rows come back as Hash<String, value> with sqlite types (Integer, Float, String, nil). # rows come back as Hash<String, value> with sqlite types (Integer, Float, String, nil).
# the database file is opened on first use: a dry run creates no file. # the database file is opened on first use: a dry run creates no file.
@@ -33,9 +35,11 @@ class DataPipeline
query(query: @options[:query], params: @options.fetch(:params, []), &block) query(query: @options[:query], params: @options.fetch(:params, []), &block)
end end
# pending rows of write_row are written first, so a query sees them
def query options = {} def query options = {}
raise 'Query for SqliteStorage:query missing' unless options[:query] raise 'Query for SqliteStorage:query missing' unless options[:query]
flush
statement = db.prepare(options[:query]) statement = db.prepare(options[:query])
result = statement.execute(*bind_args(options.fetch(:params, []))) result = statement.execute(*bind_args(options.fetch(:params, [])))
return result.map(&:to_h) unless block_given? return result.map(&:to_h) unless block_given?
@@ -58,20 +62,47 @@ class DataPipeline
return if data.empty? return if data.empty?
db.transaction do transaction do
prepare_table table_name, data.first prepare_table table_name, data.first
data.each { |row| insert table_name, row } data.each { |row| insert table_name, row }
end end
end end
# rows are collected and written per batch_size in one transaction
def write_row row def write_row row
raise 'Table for SqliteStorage#write_row missing' unless @options[:table] raise 'Table for SqliteStorage#write_row missing' unless @options[:table]
prepare_table @options[:table], row if batch_size == :all
insert @options[:table], row begin_stream
prepare_table @options[:table], row
insert @options[:table], row
else
(@pending ||= []) << row
flush if @pending.size >= batch_size
end
end end
# end of a stream: remaining rows, commit of batch_size: :all
def finalize
flush
if @stream_open
db.commit
@stream_open = false
end
close
end
# pending rows are written; an open stream with batch_size: :all is rolled back (run aborted);
# an error while flushing does not hide the error that aborted the run
def close def close
aborted_by = $!
begin
flush
rescue StandardError
raise unless aborted_by
end
@db.rollback if @stream_open && @db.transaction_active?
@stream_open = false
@db.close if @db && !@db.closed? @db.close if @db && !@db.closed?
end end
@@ -81,6 +112,38 @@ class DataPipeline
@db ||= SQLite3::Database.new(@database).tap { |d| d.results_as_hash = true } @db ||= SQLite3::Database.new(@database).tap { |d| d.results_as_hash = true }
end end
def batch_size
size = @options.fetch(:batch_size, 1000)
size == :all ? :all : Integer(size)
end
def flush
return if @pending.nil? || @pending.empty?
rows = @pending
@pending = []
transaction do
rows.each do |row|
prepare_table @options[:table], row
insert @options[:table], row
end
end
end
def begin_stream
return if @stream_open
raise 'SqliteStorage: batch_size: :all needs its own connection (no open transaction)' if db.transaction_active?
db.transaction
@stream_open = true
end
# inside an open transaction no own transaction is opened
def transaction(&block)
db.transaction_active? ? yield : db.transaction(&block)
end
# create / truncate once per table, before its first row # create / truncate once per table, before its first row
def prepare_table table_name, row def prepare_table table_name, row
return if @prepared[table_name] return if @prepared[table_name]
+1 -1
View File
@@ -1,5 +1,5 @@
class DataPipeline class DataPipeline
VERSION = '2.1.0'.freeze VERSION = '3.1.0'.freeze
# kept for scripts that print the version (rake info:info); the history is in CHANGELOG.md # kept for scripts that print the version (rake info:info); the history is in CHANGELOG.md
class Version class Version
+4 -4
View File
@@ -14,8 +14,8 @@ class EtlCliTest < PipelineTest
"#{dir}/#{name}" "#{dir}/#{name}"
end end
# like import.*.rb: own OptionParser for --from/--till, transform api # scripts with an own OptionParser for --from/--till
def test_legacy_script_gets_from_till_and_limit def test_script_with_own_option_parser_gets_from_till_and_limit
with_tmpdir do |dir| with_tmpdir do |dir|
path = script(dir, 'legacy.rb', <<~RUBY) path = script(dir, 'legacy.rb', <<~RUBY)
require 'optparse/date' require 'optparse/date'
@@ -24,8 +24,8 @@ class EtlCliTest < PipelineTest
File.write('args.txt', "\#{opts[:from]} \#{opts[:till]}") File.write('args.txt', "\#{opts[:from]} \#{opts[:till]}")
DataPipeline.new do DataPipeline.new do
read container(:csv, file: 'in.csv') read container(:csv, file: 'in.csv')
transform(processing: :prepend) { |_, _| { 'h' => 'HEAD' } } header { { 'h' => 'HEAD' } }
transform { |r, i| r.merge('i' => i) } map { |r, ctx| r.merge('i' => ctx.index) }
write container(:csv, file: 'out.csv', csv: { headers: false, col_sep: '|' }) write container(:csv, file: 'out.csv', csv: { headers: false, col_sep: '|' })
end.run end.run
RUBY RUBY
+21 -15
View File
@@ -4,16 +4,22 @@ require_relative '../test_helper'
class ChainedTest < PipelineTest class ChainedTest < PipelineTest
ROWS = [{ 'a' => 1 }, { 'a' => 2 }, { 'a' => 3 }].freeze ROWS = [{ 'a' => 1 }, { 'a' => 2 }, { 'a' => 3 }].freeze
def test_chained_transform_gets_previous_output def test_each_step_gets_the_previous_output
out = output(ROWS) { chained; transform { |r| r.merge('b' => 1) }; transform { |r| r.merge('c' => 2) } } out = output(ROWS) { map { |r| r.merge('b' => 1) }; map { |r| r.merge('c' => 2) } }
assert_equal ROWS.map { |r| r.merge('b' => 1, 'c' => 2) }, out assert_equal ROWS.map { |r| r.merge('b' => 1, 'c' => 2) }, out
end end
def test_one_to_many_then_index def test_one_to_many_then_index
out = output(ROWS) { chained; transform { |r| [r, r] }; transform { |r, i| r.merge('i' => i) } } out = output(ROWS) { map { |r| [r, r] }; map { |r, ctx| r.merge('i' => ctx.index) } }
assert_equal (0...6).to_a, out.map { |r| r['i'] } assert_equal (0...6).to_a, out.map { |r| r['i'] }
end end
def test_nil_and_empty_array_drop_rows
assert_equal [{ 'a' => 1 }], output(ROWS) { map { |r| r['a'] == 1 ? r : nil } }
assert_equal ROWS, output(ROWS) { map { |r| [r, nil] } }
assert_equal [], output(ROWS) { map { [] } }
end
def test_map_and_filter def test_map_and_filter
out = output(ROWS) do out = output(ROWS) do
map { |r| r.merge('d' => r['a'] * 2) } map { |r| r.merge('d' => r['a'] * 2) }
@@ -23,12 +29,6 @@ class ChainedTest < PipelineTest
assert_equal [{ 'a' => 2, 'd' => 4, 'i' => 0 }, { 'a' => 3, 'd' => 6, 'i' => 1 }], out assert_equal [{ 'a' => 2, 'd' => 4, 'i' => 0 }, { 'a' => 3, 'd' => 6, 'i' => 1 }], out
end end
def test_map_turns_chaining_on_for_transform
out = output(ROWS) { transform { |r| r.merge('b' => 1) }; map { |r| r.merge('c' => 1) } }
assert_equal 3, out.size
assert(out.all? { |r| r['b'] && r['c'] })
end
def test_header_and_trailer_with_context def test_header_and_trailer_with_context
out = output(ROWS) do out = output(ROWS) do
header { |c| { 'H' => c.read, 'W' => c.written } } header { |c| { 'H' => c.read, 'W' => c.written } }
@@ -41,9 +41,14 @@ class ChainedTest < PipelineTest
assert_equal 6, out.size assert_equal 6, out.size
end end
def test_edge_order_follows_registration def test_several_headers_and_trailers_in_registration_order
out = output(ROWS) { header { { 'h' => 1 } }; transform(processing: :prepend) { { 'p' => 1 } }; map { |r| r } } out = output(ROWS) { header { { 'h' => 1 } }; trailer { { 't' => 1 } }; header { { 'h' => 2 } }; trailer { { 't' => 2 } } }
assert_equal [{ 'h' => 1 }, { 'p' => 1 }], out.first(2) assert_equal [{ 'h' => 1 }, { 'h' => 2 }, *ROWS, { 't' => 1 }, { 't' => 2 }], out
end
def test_stable_order_of_many_steps
out = output(ROWS) { 20.times { |k| map { |r| r.merge("k#{k}" => k) } } }
assert_equal (0...20).to_a, out.first.values.drop(1)
end end
def test_header_and_trailer_nil_and_arrays def test_header_and_trailer_nil_and_arrays
@@ -51,12 +56,13 @@ class ChainedTest < PipelineTest
assert_equal ROWS + [{ 'x' => 1 }, { 'y' => 2 }], out assert_equal ROWS + [{ 'x' => 1 }, { 'y' => 2 }], out
end end
def test_condition_on_map def test_condition_on_map_and_filter
assert_equal [{ 'a' => 1 }], output(ROWS) { map(condition: ->(r) { r['a'] == 1 }) { |r| r } } assert_equal [{ 'a' => 1 }], output(ROWS) { map(condition: ->(r) { r['a'] == 1 }) { |r| r } }
assert_equal [{ 'a' => 3 }], output(ROWS) { filter(condition: ->(r) { r['a'] > 1 }) { |r| r['a'] == 3 } }
end end
def test_chained_false def test_condition_must_be_a_lambda
assert_equal 6, output(ROWS) { chained false; transform { |r| r }; transform { |r| r } }.size assert_includes assert_raises(ArgumentError) { output(ROWS) { map(condition: "row['a'] == 1") { |r| r } } }.message, 'lambda'
end end
def test_csv_end_to_end def test_csv_end_to_end
-81
View File
@@ -1,81 +0,0 @@
require_relative '../test_helper'
#
# parallel mode (transform without chained/map/filter) must keep the output of v1.2.1,
# the version the CUDO importers were built on. expected values were recorded with v1.2.1.
#
class CompatTest < PipelineTest
ROWS = (1..6).map { |i| { 'id' => i, 'land' => i.even? ? 'DE' : 'AT' } }
def assert_output expected_json, &block
assert_equal expected_json, JSON.generate(output(ROWS, &block))
end
# pattern of import.customer.rb
def test_customer_pattern
assert_output '[{"SATYP":"FIRC","LFDNR":0},{"id":1,"land":"AT","lfdnr":"0000000001"},' \
'{"id":2,"land":"DE","lfdnr":"0000000002"},{"id":3,"land":"AT","lfdnr":"0000000003"},' \
'{"id":4,"land":"DE","lfdnr":"0000000004"},{"id":5,"land":"AT","lfdnr":"0000000005"},' \
'{"id":6,"land":"DE","lfdnr":"0000000006"},{"topic":"LARC","counter":"0000000006"}]' do
transform(processing: :prepend) { |_row, n| { 'SATYP': 'FIRC', 'LFDNR': n } }
transform(processing: :append) { |_row, n| { 'topic': 'LARC', 'counter': format('%010d', n) } }
transform { |row, _i| row.merge('lfdnr' => format('%010d', row['id'])) }
end
end
# pattern of import.posting.rb: closure counter, 1:n
def test_posting_pattern
expected = [{ SATYP: 'FIRC', LFDNR: 0 }]
lfdnr = 0
ROWS.each do |row|
expected << row.merge('lfdnr' => lfdnr += 1)
row['id'].times { |k| expected << { 'pos' => k, 'lfdnr' => lfdnr += 1 } }
end
expected << { SATYPE: 'LARC', SUMME: '0000000027' }
assert_output JSON.generate(expected) do
cnt = 1
transform(processing: :prepend) { |_row, _n| { 'SATYP': 'FIRC', 'LFDNR': 0 } }
transform do |row|
out = [row.merge('lfdnr' => cnt)]
cnt += 1
row['id'].times do |k|
out << { 'pos' => k, 'lfdnr' => cnt }
cnt += 1
end
out
end
transform(processing: :append) { |_row| { 'SATYPE': 'LARC', 'SUMME': format('%010d', cnt - 1) } }
end
end
def test_append_registered_before_prepend
assert_output '[{"h":0},{"id":1,"land":"AT"},{"id":2,"land":"DE"},{"id":3,"land":"AT"},' \
'{"id":4,"land":"DE"},{"id":5,"land":"AT"},{"id":6,"land":"DE"},{"t":6}]' do
transform(processing: :append) { |_, n| { 't' => n } }
transform { |r| r }
transform(processing: :prepend) { |_, n| { 'h' => n } }
end
end
def test_condition_string
assert_output '[{"id":2,"land":"DE","i":1},{"id":4,"land":"DE","i":3},{"id":6,"land":"DE","i":5}]' do
transform(condition: "row['land'] == 'DE'") { |r, i| r.merge('i' => i) }
end
end
def test_two_parallel_transforms
expected = ROWS.map { |r| r.merge('a' => 1) } + ROWS.map { |r| r.merge('b' => 1) }
assert_output JSON.generate(expected) do
transform { |r| r.merge('a' => 1) }
transform { |r| [r.merge('b' => 1), nil] }
end
end
def test_nil_and_empty_array
assert_output '[{"id":1,"land":"AT"}]' do
transform { |r| r['id'] > 3 ? nil : [] }
transform { |r| r['id'] == 1 ? r : nil }
end
end
end
@@ -1,4 +1,5 @@
require_relative '../test_helper' require_relative '../test_helper'
require 'time'
# connections file, totals with reconcile, run history # connections file, totals with reconcile, run history
class ConnectionsTotalsHistoryTest < PipelineTest class ConnectionsTotalsHistoryTest < PipelineTest
+4 -4
View File
@@ -41,8 +41,8 @@ class OperationsTest < PipelineTest
assert_raises(RuntimeError) { run_pipeline(ROWS) { on_error :skip; header { raise 'h' }; map { |r| r } } } assert_raises(RuntimeError) { run_pipeline(ROWS) { on_error :skip; header { raise 'h' }; map { |r| r } } }
end end
def test_skip_in_parallel_mode def test_skip_in_map
target, stats = run_pipeline(ROWS) { on_error :skip; transform { |r| r['id'] == '1' ? raise('x') : r } } target, stats = run_pipeline(ROWS) { on_error :skip; map { |r| r['id'] == '1' ? raise('x') : r } }
assert_equal 4, target.output.size assert_equal 4, target.output.size
assert_equal 1, stats.rejected assert_equal 1, stats.rejected
end end
@@ -67,8 +67,8 @@ class OperationsTest < PipelineTest
assert_empty DataPipeline::Validation.errors({ 'a' => '' }, 'a' => { max: 1, type: :integer }) assert_empty DataPipeline::Validation.errors({ 'a' => '' }, 'a' => { max: 1, type: :integer })
end end
def test_validate_turns_chaining_on def test_validate_gets_the_output_of_the_previous_step
target, = run_pipeline(ROWS) { on_error :skip; transform { |r| r.merge('x' => 1) }; validate RULES } target, = run_pipeline(ROWS) { on_error :skip; map { |r| r.merge('x' => 1) }; validate RULES }
assert_equal 3, target.output.size assert_equal 3, target.output.size
assert(target.output.all? { |r| r['x'] }) assert(target.output.all? { |r| r['x'] })
end end
+3 -40
View File
@@ -1,17 +1,11 @@
require_relative '../test_helper' require_relative '../test_helper'
# dsl, settings, plugin lookup, parallel mode # dsl, settings, plugin lookup
class PipelineCoreTest < PipelineTest class PipelineCoreTest < PipelineTest
ROWS = [{ 'a' => 1 }, { 'a' => 2 }, { 'a' => 3 }].freeze ROWS = [{ 'a' => 1 }, { 'a' => 2 }, { 'a' => 3 }].freeze
def settings_of(pipeline) = pipeline.instance_variable_get(:@settings) def settings_of(pipeline) = pipeline.instance_variable_get(:@settings)
def test_nil_filters_rows
assert_equal [{ 'a' => 1 }], output(ROWS) { transform { |r| r['a'] == 1 ? r : nil } }
assert_equal ROWS, output(ROWS) { transform { |r| [r, nil] } }
assert_equal ROWS, output(ROWS) { transform(processing: :prepend) { nil }; transform { |r| r }; transform(processing: :append) { nil } }
end
def test_dsl_switches def test_dsl_switches
assert_equal false, settings_of(DataPipeline.new { verbose false })[:verbose] assert_equal false, settings_of(DataPipeline.new { verbose false })[:verbose]
assert_equal true, settings_of(DataPipeline.new { verbose })[:verbose] assert_equal true, settings_of(DataPipeline.new { verbose })[:verbose]
@@ -73,39 +67,8 @@ class PipelineCoreTest < PipelineTest
assert_equal Array.new(8) { |i| (i % 3) + 1 }, reads assert_equal Array.new(8) { |i| (i % 3) + 1 }, reads
end end
def test_parallel_mode_is_side_by_side def test_removed_dsl_of_version_2
out = output(ROWS) { transform { |r| r.merge('b' => 1) }; transform { |r| r.merge('c' => 2) } } %i[transform chained].each { |name| assert_raises(NoMethodError) { DataPipeline.new { send(name) { |r| r } } } }
assert_equal 6, out.size
end
def test_parallel_mode_copies_rows_per_step
out = output(ROWS) { transform { |r| r['x'] = 1; r }; transform { |r| r.key?('x') ? nil : r } }
assert_equal 6, out.size
assert_equal [{ 'a' => 10 }, { 'a' => 20 }, { 'a' => 30 }], output(ROWS) { transform { |r| [r.transform_values! { |v| v * 10 }] } }
end
def test_stable_order_and_all_prepends_appends
out = output(ROWS) { 20.times { |k| transform { |r| r['a'] == 1 ? { 'k' => k } : nil } } }
assert_equal (0...20).to_a, out.map { |r| r['k'] }
out = output(ROWS) do
transform(processing: :prepend) { { 'h' => 1 } }
transform(processing: :prepend) { { 'h' => 2 } }
transform { |r| r }
transform(processing: :append) { { 't' => 1 } }
transform(processing: :append) { { 't' => 2 } }
end
assert_equal [{ 'h' => 1 }, { 'h' => 2 }, *ROWS, { 't' => 1 }, { 't' => 2 }], out
end
def test_append_count_parallel_vs_chained
assert_equal({ 'n' => 3 }, output(ROWS) { transform { |r| [r, r] }; transform(processing: :append) { |_, n| { 'n' => n } } }.last)
assert_equal({ 'n' => 6 }, output(ROWS) { chained; transform { |r| [r, r] }; transform(processing: :append) { |_, n| { 'n' => n } } }.last)
end
def test_conditions
assert_equal [{ 'a' => 1 }, { 'a' => 3 }], output(ROWS) { transform(condition: ->(r) { r['a'] != 2 }) { |r| r } }
assert_equal [{ 'a' => 2 }], output(ROWS) { transform(condition: "row['a'] == 2") { |r| r } }
end end
def test_copy_without_steps def test_copy_without_steps
+1 -30
View File
@@ -1,6 +1,6 @@
require_relative '../test_helper' require_relative '../test_helper'
# rename, select, cast, default and the deprecation warnings # rename, select, cast, default
class StepsTest < PipelineTest class StepsTest < PipelineTest
ROWS = [{ 'kunnr' => '1', 'betrag' => '12.50', 'datum' => '2026-10-09', 'land' => nil }, ROWS = [{ 'kunnr' => '1', 'betrag' => '12.50', 'datum' => '2026-10-09', 'land' => nil },
{ 'kunnr' => '2', 'betrag' => '', 'datum' => ' ', 'land' => 'DE' }].freeze { 'kunnr' => '2', 'betrag' => '', 'datum' => ' ', 'land' => 'DE' }].freeze
@@ -44,33 +44,4 @@ class StepsTest < PipelineTest
end end
assert_equal [{ 'id' => '1', 'betrag' => 12.5 }, { 'id' => '2', 'betrag' => 0.0 }], rows assert_equal [{ 'id' => '1', 'betrag' => 12.5 }, { 'id' => '2', 'betrag' => 0.0 }], rows
end end
def deprecation_log(&block)
log = StringIO.new
DataPipeline.new do
logger Logger.new(log)
read MemoryStorage.new(rows: [{ 'a' => 1 }])
write MemoryStorage.new
instance_eval(&block)
end.run
log.string.lines.grep(/deprecated/)
end
def test_deprecations_once_per_process
lines = deprecation_log do
transform(condition: 'true') { |r| r }
transform { |r| r }
end
assert_equal 3, lines.size
assert lines.any? { |l| l.include?('transform is deprecated') }
assert lines.any? { |l| l.include?('parallel mode') }
assert lines.any? { |l| l.include?('condition: as string') }
assert_empty deprecation_log { transform { |r| r } }
end
def test_no_deprecation_for_map_filter_and_single_transform_in_chained_mode
assert_empty deprecation_log { map { |r| r }; filter(condition: ->(_r) { true }) { true } }
lines = deprecation_log { chained; transform { |r| r }; transform { |r| r } }
assert_equal 1, lines.size # only transform, no parallel mode
end
end end
+5 -14
View File
@@ -21,23 +21,14 @@ class StreamingTest < PipelineTest
assert_equal({ 'T' => 4, 'R' => 3 }, stream.last) assert_equal({ 'T' => 4, 'R' => 3 }, stream.last)
end end
def test_stream_prepend_append def test_stream_several_steps_one_to_many
out = output(ROWS, streamed: true) do out = output(ROWS, streamed: true) do
chained map { |r| [r, r] }
transform(processing: :prepend) { |_, n| { 'p' => n } } map { |r, ctx| r.merge('i' => ctx.index) }
transform { |r| [r, r] } trailer { |ctx| { 'n' => ctx.written } }
transform(processing: :append) { |_, n| { 'n' => n } }
end end
assert_equal({ 'p' => 0 }, out.first)
assert_equal({ 'n' => 6 }, out.last) assert_equal({ 'n' => 6 }, out.last)
assert_equal 8, out.size assert_equal (0...6).to_a, out.first(6).map { |r| r['i'] }
end
def test_stream_parallel_single_step_and_error_for_several
out = output(ROWS, streamed: true) { transform { |r| [r, r] }; transform(processing: :append) { |_, n| { 'n' => n } } }
assert_equal({ 'n' => 3 }, out.last)
error = assert_raises(ArgumentError) { output(ROWS, streamed: true) { transform { |r| r }; transform { |r| r } } }
assert_includes error.message, 'chained'
end end
def test_stream_condition_and_copy def test_stream_condition_and_copy
+56
View File
@@ -103,4 +103,60 @@ class SqliteStorageTest < PipelineTest
writer.close writer.close
end end
end end
def count(file) = DataPipeline::SqliteStorage.new(database: file).then { |db| db.query(query: 'select count(*) as n from b').first['n'].tap { db.close } }
def test_write_row_in_batches_and_query_sees_pending_rows
with_tmpdir do |dir|
file = "#{dir}/b.sqlite3"
writer = DataPipeline::SqliteStorage.new(database: file, table: 'b', create: true, batch_size: 2)
3.times { |i| writer.write_row({ 'id' => i }) }
assert_equal 2, count(file) # third row still pending
assert_equal 3, writer.query(query: 'select count(*) as n from b').first['n']
writer.write_row({ 'id' => 3 })
writer.finalize
assert_equal 4, count(file)
end
end
def test_close_writes_pending_rows
with_tmpdir do |dir|
writer = DataPipeline::SqliteStorage.new(database: "#{dir}/b.sqlite3", table: 'b', create: true)
writer.write_row({ 'id' => 1 })
writer.close
assert_equal 1, count("#{dir}/b.sqlite3")
end
end
def test_batch_size_all_is_one_transaction_for_the_stream
with_tmpdir do |dir|
file = "#{dir}/b.sqlite3"
DataPipeline::SqliteStorage.new(database: file, table: 'b', create: true).tap { |w| w.write([{ 'id' => 99 }]); w.close }
aborted = DataPipeline::SqliteStorage.new(database: file, table: 'b', truncate: true, batch_size: :all)
2.times { |i| aborted.write_row({ 'id' => i }) }
aborted.close # without finalize: rolled back, truncate too
assert_equal 1, count(file)
done = DataPipeline::SqliteStorage.new(database: file, table: 'b', truncate: true, batch_size: :all)
2.times { |i| done.write_row({ 'id' => i }) }
done.finalize
assert_equal 2, count(file)
end
end
def test_streamed_pipeline_aborted_with_batch_size_all_writes_nothing
with_tmpdir do |dir|
file = "#{dir}/b.sqlite3"
assert_raises(RuntimeError) do
DataPipeline.new do
logger PipelineHelper::QUIET
streamed
read MemoryStorage.new(rows: Array.new(5) { |i| { 'id' => i } })
map { |row| row['id'] == 3 ? raise('kaputt') : row }
write DataPipeline::SqliteStorage.new(database: file, table: 'b', create: true, batch_size: :all)
end.run
end
refute DataPipeline::SqliteStorage.new(database: file).query(query: "select name from sqlite_master where name = 'b'").any?
end
end
end end
-1
View File
@@ -55,7 +55,6 @@ module PipelineHelper
DataPipeline.reset_settings! DataPipeline.reset_settings!
DataPipeline.runs.clear DataPipeline.runs.clear
DataPipeline.params.clear DataPipeline.params.clear
DataPipeline.reset_deprecations!
end end
# runs a pipeline from rows into a MemoryStorage, returns [target, stats] # runs a pipeline from rows into a MemoryStorage, returns [target, stats]