datapipeline 3.0.0: transform, Parallelmodus, chained und String-Bedingungen entfernt

This commit is contained in:
wkh
2026-10-09 22:04:12 +02:00
parent 0b72635e08
commit 25554b6e7a
13 changed files with 117 additions and 347 deletions
+8
View File
@@ -2,6 +2,14 @@
Framework `datapipeline` (Gem). Neueste Version oben. Framework `datapipeline` (Gem). Neueste Version oben.
## 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.0.0)
csv (~> 3.3) csv (~> 3.3)
logger (~> 1.6) logger (~> 1.6)
+43 -42
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.0.0 ([CHANGELOG.md](CHANGELOG.md)) · Ruby 3.4
`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) – 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)
@@ -783,7 +783,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)` |
@@ -918,8 +917,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
@@ -1507,53 +1506,56 @@ 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.1 Von 2.x auf 3.0
2.1 ist abwärtskompatibel; bestehende Skripte laufen ohne Änderung. Anders als bisher: 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.
| Thema | 2.0 | 2.1 | | 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 +1579,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,7 +1606,7 @@ 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).
- 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`.
@@ -1625,5 +1627,4 @@ Offene Punkte, Reihenfolge = empfohlene Umsetzung:
4. **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. 5. **Weitere Plugins:** SFTP-Ablage.
6. **3.0:** `transform`, Parallelmodus und `condition:` als String entfernen. 6. **Veröffentlichung:** eigenes Remote-Repository, Lizenz festlegen, Versionen taggen.
7. **Veröffentlichung:** eigenes Remote-Repository, Lizenz festlegen, Versionen taggen.
+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
+1 -1
View File
@@ -1,5 +1,5 @@
class DataPipeline class DataPipeline
VERSION = '2.1.0'.freeze VERSION = '3.0.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
+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
-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]