From f82fe506558d2a61521fb27afd66b85d7155a071 Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Fri, 9 Oct 2026 21:42:02 +0200 Subject: [PATCH] Layout: Formatieren, Festbreiten-Plugin, Lesen von Satzarten-Dateien --- CHANGELOG.md | 4 + README.md | 70 ++++++++++-- lib/datapipeline.rb | 1 + lib/datapipeline/layout.rb | 165 +++++++++++++++++++++++++++-- lib/datapipeline/storage/csv.rb | 4 +- lib/datapipeline/storage/fixed.rb | 77 ++++++++++++++ test/core/layout_test.rb | 50 +++++++++ test/plugins/fixed_storage_test.rb | 63 +++++++++++ 8 files changed, 414 insertions(+), 20 deletions(-) create mode 100644 lib/datapipeline/storage/fixed.rb create mode 100644 test/plugins/fixed_storage_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c30a5b..fd0b188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Framework `datapipeline` (Gem). Neueste Version oben. einzeln; `batch_size: :all` = eine Transaktion für den ganzen Stream, nach einem Abbruch zurückgerollt - csv: `encoding:` (z. B. `'ISO-8859-1'`, `'Windows-1252'`), `replace:` für nicht darstellbare Zeichen, `bom: true`; ein UTF-8-BOM wird beim Lesen übersprungen +- Layout: `format: true` bringt Werte vor Prüfen und Schreiben ins Format ihres Typs (`N` mit Nachkommastellen, + kaufmännisch gerundet; `DAT`/`TIM` aus `Date`, `Time` und ISO-Text) +- neues Plugin `:fixed` (`DataPipeline::FixedStorage`): Festbreiten-Dateien nach Layout schreiben und lesen +- csv: mit `layout:` und `headers: false` werden Dateien mit Satzarten gelesen, Schlüssel aus den Feldnamen ## 2.0.0 – 2026-10-08 diff --git a/README.md b/README.md index 1e73441..3bc8ffc 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,8 @@ Alle Beispiele liegen in [examples/](examples/), arbeiten mit den Daten in `exam **Datei mit Satzarten** – Rechnungen aus verschachteltem JSON werden zu einem Kopfsatz mit Positionssätzen; Datei- und Schlusssatz zählen die Datensätze. Das Layout legt Reihenfolge, Typ und Länge der Felder je Satzart fest; die -Datentypen `AN`, `N`, `DAT`, `TIM` und ihre Prüfung stehen in 2.9. Das Layout formatiert nicht, deshalb bringt das -`map` Datum und Beträge selbst ins Zielformat (`delete('-')`, `format('%.2f', …)`). +Datentypen `AN`, `N`, `DAT`, `TIM` und ihre Prüfung stehen in 2.9. Ohne `format: true` formatiert das Layout nicht, +deshalb bringt das `map` hier Datum und Beträge selbst ins Zielformat (`delete('-')`, `format('%.2f', …)`). ```ruby @@ -586,6 +586,7 @@ flowchart LR | [lib/datapipeline/storage/sqlite.rb](lib/datapipeline/storage/sqlite.rb) | Quelle/Ziel SQLite (`sqlite3`) | | [lib/datapipeline/storage/excel.rb](lib/datapipeline/storage/excel.rb) | Quelle/Ziel Excel xlsx (`roo`, `caxlsx`) | | [lib/datapipeline/storage/rest.rb](lib/datapipeline/storage/rest.rb) | Quelle/Ziel REST-API (`http`) | +| [lib/datapipeline/storage/fixed.rb](lib/datapipeline/storage/fixed.rb) | Quelle/Ziel Festbreiten-Datei nach Layout | | [lib/datapipeline/notification/slack.rb](lib/datapipeline/notification/slack.rb) | Benachrichtigung, z. B. aus `on_finish` | | [lib/datapipeline/version.rb](lib/datapipeline/version.rb) | `DataPipeline::VERSION` | @@ -740,6 +741,7 @@ write container(:csv, file: 'out/cudo.txt', encoding: 'Windows-1252', layout: LA | `:json` | `DataPipeline::JsonStorage` | – | `file`, `format: :array \| :lines` (Default aus Endung: `.ndjson`/`.jsonl` → Zeilen), `root: 'data.items'`, `pretty`; ein einzelnes Objekt wird als eine Zeile gelesen | | `:sqlite`, `:sqlite3` | `DataPipeline::SqliteStorage` | `sqlite3` | `database` (Datei oder `':memory:'`), `query`, `params` (Array für `?`, Hash für `:name`), `table`, `create`, `truncate`, `upsert` | | `:excel`, `:xlsx` | `DataPipeline::ExcelStorage` | `roo` (lesen), `caxlsx` (schreiben) | `file`, `sheet` (Name/Index), `headers`, `columns`, `skip` (Titelzeilen) | +| `:fixed` | `DataPipeline::FixedStorage` | – | `file`, `layout` (Pflicht), `encoding`, `replace`, `line_end`; Festbreiten-Datei, siehe 2.9 | | `:rest` | `DataPipeline::RestStorage` | `http` | `url`, `auth`, `headers`, `params`, `root`, Paging `page_param` oder `next: ->(body) { … }`, `retries`, `timeout`, `batch_size`, `write_method`, `write_url` (auch als Lambda je Zeile), `ssl_verify` | **DataPipeline::JsonStorage** – NDJSON wird zeilenweise gelesen und geschrieben. Ein JSON-Array wird beim Streaming als @@ -1025,17 +1027,62 @@ wird nur auf Pflicht geprüft, nie auf den Typ. | `DAT` | Datum | genau 8 Ziffern `YYYYMMDD`, gültiges Kalenderdatum | wird nicht geprüft | `NAME: not a date YYYYMMDD` | | `TIM` | Uhrzeit | genau 6 Ziffern `HHMMSS`, 00:00:00 bis 23:59:59 | wird nicht geprüft | `NAME: not a time HHMMSS` | -Ein unbekannter Typ wird wie `AN` behandelt. Das Layout **prüft nur, es formatiert nicht**: Werte werden beim -Schreiben unverändert ausgegeben, ohne Auffüllen mit Nullen oder Leerzeichen. Zahlen, Datum und Uhrzeit müssen -deshalb vorher in einem `map` als Text in das Zielformat gebracht werden: +Ein unbekannter Typ wird wie `AN` behandelt. + +**Formatieren** – Ohne Option prüft das Layout nur: Werte werden unverändert geschrieben, ein `Date` würde also als +`2026-10-09` geprüft und abgelehnt. Mit `Layout.new(format: true)` bringt das Layout die Werte vor dem Prüfen und +Schreiben selbst in das Textformat ihres Typs. Geprüft wird dann genau der Text, der in die Datei kommt. + +| Typ | Eingabe | Ergebnis | +| --- | --- | --- | +| `N` | Integer, Float, Rational, BigDecimal | Nachkommastellen aus der Länge (`11.2` → 2), kaufmännisch gerundet: `12.345` → `12.35`, `-0.005` → `-0.01`, `7` → `7.00`; ein Float zählt mit seinem kürzesten Text, `12.345` also nicht als `12.3449999…` | +| `N` | Zahl als Text | auf die Nachkommastellen aufgefüllt (`'3.5'` → `3.50`); zu viele Nachkommastellen bleiben stehen und werden gemeldet, Text wird nie gerundet | +| `DAT` | `Date`, `Time`, `'2026-10-09'` | `20261009` | +| `TIM` | `Time`, `'07:05:03'` | `070503` | +| alle | `nil` | bleibt leer | + +Anderes bleibt unverändert und wird wie gewohnt geprüft. Führende Nullen ergänzt erst die Festbreiten-Datei (unten); +in CSV braucht es dafür weiterhin ein `map` (`format('%06d', …)`). ```ruby -map do |row| - row.merge('datum' => row['datum'].strftime('%Y%m%d'), # Date -> '20261009'; Date#to_s wäre '2026-10-09' - 'zeit' => row['zeit'].strftime('%H%M%S'), - 'betrag' => format('%.2f', row['betrag']), # BigDecimal#to_s wäre '0.1e2', Float evtl. '1.0e-05' - 'lfdnr' => format('%06d', row['lfdnr'])) # führende Nullen, falls das Zielsystem sie verlangt +LAYOUT = DataPipeline::Layout.new(format: true) do + record 'KOPF', [['SATYP', 'AN', 4, true], ['DATUM', 'DAT', 8, true], ['SUMME', 'N', '11.2', true]] end +LAYOUT.values({ 'satyp' => 'KOPF', 'datum' => Date.new(2026, 10, 9), 'summe' => 1234.5 }) +# => ["KOPF", "20261009", "1234.50"] +``` + +**Festbreiten-Dateien** – `container(:fixed, file:, layout:)` (`DataPipeline::FixedStorage`) schreibt und liest +Dateien ohne Trennzeichen, in denen jedes Feld eine feste Anzahl Zeichen hat. Jedes `AN`- und `N`-Feld braucht dafür +eine Länge, sonst bricht schon `container` mit `LayoutError` ab. + +| Typ | Breite | Ausrichtung | +| --- | --- | --- | +| `AN` | Länge | links, rechts mit Leerzeichen aufgefüllt | +| `N` | Vor- + Nachkommastellen + Punkt: `'11.2'` → 12, `5` → 5 | rechts, links mit Nullen aufgefüllt, Vorzeichen vorne: `-0000012.50` | +| `DAT`, `TIM` | 8 bzw. 6 | genau so viele Ziffern | +| leeres Feld | Breite des Felds | nur Leerzeichen | + +Beim Schreiben werden die Werte immer formatiert (wie `format: true`). Ein Wert, der länger als sein Feld ist, wird +nicht abgeschnitten, sondern bricht mit `LayoutError` ab; mit `validate LAYOUT` vorher fällt er schon als Zeilenfehler +auf. Beim Lesen erkennt das Plugin die Satzart an ihrem Feld, die Schlüssel sind die Feldnamen klein geschrieben, die +Werte ohne Auffüllung (Zahlen behalten ihre führenden Nullen), leere Felder sind `nil`. Breiten zählen Zeichen, nicht +Bytes. Weitere Optionen: `encoding`, `replace` (wie CSV, 2.5) und `line_end: "\r\n"`. + +```ruby +write container(:fixed, file: 'out/cudo.txt', layout: LAYOUT, encoding: 'Windows-1252') +# KOPF20261009000001234.50 + +read container(:fixed, file: 'in/rueckmeldung.txt', layout: LAYOUT) +# { 'satyp' => 'KOPF', 'datum' => '20261009', 'summe' => '000001234.50' } +``` + +**Lesen mit Layout** – Auch eine CSV-Datei mit Satzarten lässt sich mit `layout:` wieder einlesen (nur mit +`headers: false`). Jede Zeile bekommt die Feldnamen ihrer Satzart als Schlüssel; eine unbekannte Satzart oder mehr +Werte als Felder bricht mit `LayoutError` ab. + +```ruby +read container(:csv, file: 'in/cudo.ok', layout: LAYOUT, csv: { headers: false, col_sep: '|' }) ``` **Verhältnis zu `validate`-Regeln** – Layout-Typen beschreiben das Textformat einer Datei mit festen Feldern, die @@ -1052,6 +1099,7 @@ sich kombinieren, z. B. Rohdaten mit Regeln prüfen und die fertigen Sätze gege **Optionen** – `Layout.new(type_field: 'satyp')` nimmt die Satzart aus dieser Spalte statt aus dem ersten Wert der Zeile; eine Satzart, die nicht im Layout steht, ist ein Fehler (`record type "X" not in layout (…)`). `strict: false` lässt beim Schreiben Felder zu, die nicht im Layout stehen (sie werden weggelassen). +`format: true` formatiert die Werte (siehe oben). Beim Schreiben stehen die Werte in der Reihenfolge des Layouts, fehlende Felder bleiben leer, ein unbekanntes Feld bricht mit `DataPipeline::LayoutError` ab. Eine falsch benannte SQL-Spalte verschiebt also nicht mehr still alle @@ -1066,5 +1114,5 @@ Ziel: aus dem Exportwerkzeug ein generisches, testbares ETL-Framework machen. Re ### 3.1 Erweiterbarkeit -1. **Weitere Plugins:** Festbreiten-Format (feste Spaltenbreiten, nutzt die Längen aus dem Layout), SFTP-Ablage. +1. **Weitere Plugins:** SFTP-Ablage. 2. **Veröffentlichung:** eigenes Remote-Repository für das Gem, Lizenz festlegen, Versionen taggen. diff --git a/lib/datapipeline.rb b/lib/datapipeline.rb index b49538d..86bd848 100644 --- a/lib/datapipeline.rb +++ b/lib/datapipeline.rb @@ -17,6 +17,7 @@ class DataPipeline autoload :SqliteStorage, File.expand_path('datapipeline/storage/sqlite', __dir__) autoload :ExcelStorage, File.expand_path('datapipeline/storage/excel', __dir__) autoload :RestStorage, File.expand_path('datapipeline/storage/rest', __dir__) + autoload :FixedStorage, File.expand_path('datapipeline/storage/fixed', __dir__) autoload :Slack, File.expand_path('datapipeline/notification/slack', __dir__) # passed to header, trailer, map and filter diff --git a/lib/datapipeline/layout.rb b/lib/datapipeline/layout.rb index cdb4832..839653f 100644 --- a/lib/datapipeline/layout.rb +++ b/lib/datapipeline/layout.rb @@ -18,8 +18,15 @@ class DataPipeline # keys of a row are matched without regard to case, string or symbol. # # write container(:csv, file: ..., layout: layout) -> values in field order, unknown keys raise + # write container(:fixed, file: ..., layout: layout) -> fixed width lines (see FixedStorage) + # read container(:csv, ..., layout: layout) -> rows with the field names as keys # validate layout -> required, length, format per field # + # format: true converts values to the text format of the field type, before checking and writing: + # N numbers with the decimals of the length (13.2 -> 2), rounded half up; numeric texts too + # DAT Date, Time or 'YYYY-MM-DD' -> YYYYMMDD + # TIM Time or 'HH:MM:SS' -> HHMMSS + # class Layout Field = Struct.new(:name, :type, :length, :required) do def key = name.downcase @@ -30,13 +37,30 @@ class DataPipeline after ||= 0 [before - after, after] end + + # characters in a fixed width file: N 13.2 -> 14 (11 digits, point, 2 decimals), DAT 8, TIM 6 + def width + case type + when 'DAT' then 8 + when 'TIM' then 6 + when 'N' + return unless length + + before, after = digits + before + after + (after.positive? ? 1 : 0) + else length&.to_i + end + end end + NUMBER = /\A[+-]?\d+(\.\d+)?\z/ + attr_reader :records - def initialize type_field: nil, strict: true, &block + def initialize type_field: nil, strict: true, format: false, &block @type_field = type_field&.to_s&.downcase @strict = strict + @format = format @records = {} instance_eval(&block) if block end @@ -56,23 +80,148 @@ class DataPipeline def values row fields = fields_for(row) normalized = normalize(row) - if @strict - unknown = normalized.keys - fields.map(&:key) - raise LayoutError, "#{record_type(row)}: fields not in layout: #{unknown.join(', ')}" if unknown.any? - end - fields.map { |field| normalized[field.key] } + unknown!(row, fields, normalized) + fields.map { |field| output(field, normalized[field.key]) } end def errors row fields = fields_for(row) normalized = normalize(row) - fields.flat_map { |field| check(field, normalized[field.key]) } + fields.flat_map { |field| check(field, output(field, normalized[field.key])) } rescue LayoutError => e [e.message] end + # value as text in the format of the field type (see format: true) + def format_value field, value + case value + when nil then nil + when Numeric then field.type == 'N' ? format_number(field, value) : value.to_s + when Date, Time then format_time(field, value) + else format_text(field, value.to_s) + end + end + + # one line of a fixed width file: text left aligned, numbers right aligned with zeros, empty fields blank + def line row + fields = fields_for(row) + normalized = normalize(row) + unknown!(row, fields, normalized) + fields.map { |field| fixed(field, format_value(field, normalized[field.key])) }.join + end + + # values of a line in field order (csv without header) -> row with the field names as keys + def parse values + type, fields = find_record { |fields| values[type_index(fields)].to_s.strip } + raise LayoutError, "record type #{values[0].inspect} not in layout (#{@records.keys.join(', ')})" unless fields + raise LayoutError, "#{type}: #{values.size} values, layout has #{fields.size} fields" if @strict && values.size > fields.size + + fields.each_with_index.to_h { |field, i| [field.key, values[i]] } + end + + # a line of a fixed width file -> row with the field names as keys, values without padding, blank -> nil + def parse_line line + type, fields = find_record { |fields| slices(line, fields)[type_index(fields)].to_s.strip } + raise LayoutError, "record type of #{line[0, 20].inspect} not in layout (#{@records.keys.join(', ')})" unless fields + + fields.zip(slices(line, fields)).to_h do |field, text| + text = field.type == 'AN' ? text.to_s.rstrip : text.to_s.strip + [field.key, text.empty? ? nil : text] + end + end + + # fixed width needs a length for every AN and N field + def check_widths! + missing = @records.flat_map { |type, fields| fields.reject(&:width).map { |f| "#{type}.#{f.name}" } } + raise LayoutError, "fixed width needs a length for: #{missing.join(', ')}" if missing.any? + + self + end + private + def output field, value + @format ? format_value(field, value) : value + end + + def unknown! row, fields, normalized + return unless @strict + + unknown = normalized.keys - fields.map(&:key) + raise LayoutError, "#{record_type(row)}: fields not in layout: #{unknown.join(', ')}" if unknown.any? + end + + def find_record + @records.find { |type, fields| yield(fields) == type } + end + + def type_index fields + @type_field ? fields.index { |f| f.key == @type_field } || 0 : 0 + end + + def slices line, fields + offset = 0 + fields.map do |field| + text = line[offset, field.width] + offset += field.width + text + end + end + + # 12.345 -> '12.35' for N 11.2; floats by their shortest text, so 12.345 is not 12.3449999... + def format_number field, value + _before, after = field.length ? field.digits : [nil, 0] + number = value.is_a?(Float) ? value.to_s.to_r : value.to_r + number = number.round(after, half: :up) + sign = number.negative? ? '-' : '' + int, frac = number.abs.divmod(1) + return "#{sign}#{int}" if after.zero? + + "#{sign}#{int}.#{(frac * 10**after).round.to_s.rjust(after, '0')}" + end + + def format_time field, value + case field.type + when 'DAT' then value.strftime('%Y%m%d') + when 'TIM' then value.respond_to?(:hour) ? value.strftime('%H%M%S') : value.to_s + else value.respond_to?(:hour) ? value.iso8601 : value.to_s + end + end + + # numeric texts get the decimals of the field, ISO dates and times lose their separators + def format_text field, text + case field.type + when 'N' + _before, after = field.length ? field.digits : [nil, 0] + return text unless text.match?(NUMBER) && after.positive? + + decimals = text.split('.')[1].to_s + decimals.length > after ? text : "#{text.split('.')[0]}.#{decimals.ljust(after, '0')}" + when 'DAT' then text.match?(/\A\d{4}-\d{2}-\d{2}\z/) ? text.delete('-') : text + when 'TIM' then text.match?(/\A\d{2}:\d{2}:\d{2}\z/) ? text.delete(':') : text + else text + end + end + + def fixed field, text + width = field.width + return ' ' * width if text.nil? || text.empty? + raise LayoutError, "#{field.name}: #{text.inspect} longer than #{width}" if text.length > width + + case field.type + when 'N' then raise LayoutError, "#{field.name}: #{text.inspect} is not a number" unless text.match?(NUMBER) + when 'DAT', 'TIM' then raise LayoutError, "#{field.name}: #{text.inspect} needs #{width} digits" unless text.match?(/\A\d{#{width}}\z/) + else return text.ljust(width) + end + return text if field.type != 'N' + + sign = text.start_with?('-') ? '-' : '' + digits = text.delete('+-') + raise LayoutError, "#{field.name}: #{text.inspect} longer than #{width}" if sign.length + digits.length > width + + sign + digits.rjust(width - sign.length, '0') + end + def record_type row @type_field ? normalize(row)[@type_field] : row.values.first end @@ -95,7 +244,7 @@ class DataPipeline end def check_number field, text - return ["#{field.name}: not a number"] unless text.match?(/\A[+-]?\d+(\.\d+)?\z/) + return ["#{field.name}: not a number"] unless text.match?(NUMBER) return [] unless field.length before, after = field.digits diff --git a/lib/datapipeline/storage/csv.rb b/lib/datapipeline/storage/csv.rb index c45d279..2c1de83 100644 --- a/lib/datapipeline/storage/csv.rb +++ b/lib/datapipeline/storage/csv.rb @@ -10,7 +10,8 @@ class DataPipeline # columns: fixed column order for writing (and keys for reading without headers) # without columns the values are written in hash order # layout: DataPipeline::Layout, field order per record type (several record types in one file), - # unknown fields raise; no header line + # unknown fields raise; no header line. reading with headers: false: keys from the fields + # of the record type # encoding: encoding of the file, e.g. 'ISO-8859-1' or 'Windows-1252' (default UTF-8); # read as UTF-8 strings, written transcoded from UTF-8; a UTF-8 BOM is skipped when reading # replace: '?' -> characters the encoding cannot hold are replaced when writing (default: error) @@ -131,6 +132,7 @@ class DataPipeline # rows with headers are CSV::Row, without headers plain arrays def to_hash row return row.to_h if row.is_a?(CSV::Row) + return @options[:layout].parse(row) if @options[:layout] keys = columns&.map(&:to_s) || (0...row.length).map(&:to_s) keys.zip(row).to_h diff --git a/lib/datapipeline/storage/fixed.rb b/lib/datapipeline/storage/fixed.rb new file mode 100644 index 0000000..5501134 --- /dev/null +++ b/lib/datapipeline/storage/fixed.rb @@ -0,0 +1,77 @@ +require_relative 'storage_plugin' + +class DataPipeline + # + # fixed width file, one line per row, field widths from a DataPipeline::Layout + # + # options + # file: path of the file + # layout: DataPipeline::Layout, every AN and N field needs a length + # encoding: encoding of the file (default UTF-8), read as UTF-8, written transcoded + # replace: '?' -> characters the encoding cannot hold are replaced when writing (default: error) + # line_end: "\n" (default) or "\r\n" + # + # writing: values formatted as with Layout format: true, text left aligned with blanks, numbers right + # aligned with zeros (sign in front), empty fields blank; a value longer than its field raises LayoutError + # reading: record type from the type field, keys are the field names in lower case, values without padding, + # blank fields nil + # + class FixedStorage < StoragePlugin + def initialize **options, &block + @options = options + @file_name = options.fetch(:file) { raise ArgumentError, 'FixedStorage: option file missing' } + @layout = options.fetch(:layout) { raise ArgumentError, 'FixedStorage: option layout missing' }.check_widths! + @line_end = options.fetch(:line_end, "\n") + + yield if block_given? + end + + def read(&block) + return each_line.map { |line| @layout.parse_line(line) } unless block_given? + + each_line { |line| yield @layout.parse_line(line) } + nil + end + + def write data + data.each { |row| write_row row } + ensure + close + end + + def write_row row + @file ||= File.open(@file_name, 'w', **write_options) + @file.write(@layout.line(row), @line_end) + end + + def close + @file&.close + @file = nil + end + + private + + def utf8? + @options[:encoding].nil? || Encoding.find(@options[:encoding]) == Encoding::UTF_8 + end + + # empty lines are skipped + def each_line + return enum_for(:each_line) unless block_given? + + encoding = utf8? ? 'bom|utf-8' : "#{@options[:encoding]}:utf-8" + File.foreach(@file_name, encoding:) do |line| + line = line.chomp + yield line unless line.strip.empty? + end + end + + def write_options + return { encoding: 'utf-8' } if utf8? + + options = { encoding: @options[:encoding] } + options.merge!(undef: :replace, invalid: :replace, replace: @options[:replace]) if @options[:replace] + options + end + end +end diff --git a/test/core/layout_test.rb b/test/core/layout_test.rb index 95b7343..ebe2220 100644 --- a/test/core/layout_test.rb +++ b/test/core/layout_test.rb @@ -71,4 +71,54 @@ class LayoutTest < PipelineTest assert_equal [{ 'a' => '1' }], target.output assert_equal 1, stats.rejected end + + FORMATTED = DataPipeline::Layout.new(format: true) do + record 'K', [['SATYP', 'AN', 2, true], ['DATUM', 'DAT'], ['ZEIT', 'TIM'], ['BETRAG', 'N', '11.2'], ['NR', 'N', 5], ['TEXT', 'AN', 6]] + end + + def test_format_values_by_type + row = { 'satyp' => 'K', 'datum' => Date.new(2026, 10, 9), 'zeit' => Time.new(2026, 10, 9, 7, 5, 3), 'betrag' => 12.345, 'nr' => 7, 'text' => 'abc' } + assert_equal ['K', '20261009', '070503', '12.35', '7', 'abc'], FORMATTED.values(row) + assert_equal ['K', '20261009', '070503', '3.50', nil, nil], FORMATTED.values({ 'satyp' => 'K', 'datum' => '2026-10-09', 'zeit' => '07:05:03', 'betrag' => '3.5' }) + assert_equal '-0.01', FORMATTED.values({ 'satyp' => 'K', 'betrag' => -0.005 })[3] + assert_equal '0.33', FORMATTED.values({ 'satyp' => 'K', 'betrag' => Rational(1, 3) })[3] + assert_equal '1.234', FORMATTED.values({ 'satyp' => 'K', 'betrag' => '1.234' })[3] # texts are not rounded + assert_equal ['K', nil, nil, nil, nil, nil], FORMATTED.values({ 'satyp' => 'K' }) + assert_equal [nil, 12.345], [LAYOUT.values({ 'satyp' => 'HEAD' })[1], DataPipeline::Layout.new { record 'K', [['S'], ['B', 'N', '5.2']] }.values({ 's' => 'K', 'b' => 12.345 })[1]] + end + + def test_errors_check_the_formatted_values + assert_empty FORMATTED.errors({ 'satyp' => 'K', 'datum' => Date.today, 'betrag' => 0.1 + 0.2 }) + assert_equal ['BETRAG: more than 2 decimals'], DataPipeline::Layout.new { record 'K', [['S'], ['BETRAG', 'N', '11.2']] }.errors({ 's' => 'K', 'betrag' => 0.1 + 0.2 }) + end + + def test_fixed_line_and_parse_line + row = { 'satyp' => 'K', 'datum' => Date.new(2026, 10, 9), 'betrag' => -12.5, 'nr' => 7, 'text' => 'Müller' } + line = FORMATTED.line(row) + assert_equal 'K 20261009 -00000012.5000007Müller', line + assert_equal 2 + 8 + 6 + 12 + 5 + 6, line.length + assert_equal({ 'satyp' => 'K', 'datum' => '20261009', 'zeit' => nil, 'betrag' => '-00000012.50', 'nr' => '00007', 'text' => 'Müller' }, + FORMATTED.parse_line(line)) + assert_includes assert_raises(DataPipeline::LayoutError) { FORMATTED.line(row.merge('text' => 'zu lang')) }.message, 'longer than 6' + assert_includes assert_raises(DataPipeline::LayoutError) { FORMATTED.line(row.merge('nr' => 'x')) }.message, 'not a number' + assert_includes assert_raises(DataPipeline::LayoutError) { FORMATTED.line(row.merge('extra' => 1)) }.message, 'extra' + assert_includes assert_raises(DataPipeline::LayoutError) { FORMATTED.parse_line('XX') }.message, 'not in layout' + end + + def test_check_widths + layout = DataPipeline::Layout.new { record 'A', [['SATYP', 'AN', 1], ['TEXT'], ['NR', 'N'], ['D', 'DAT']] } + assert_equal 'fixed width needs a length for: A.TEXT, A.NR', assert_raises(DataPipeline::LayoutError) { layout.check_widths! }.message + assert_same FORMATTED, FORMATTED.check_widths! + end + + def test_parse_values_and_csv_read_with_layout + assert_equal({ 'satyp' => 'POS', 'nr' => '1', 'text' => nil, 'betrag' => nil }, LAYOUT.parse(%w[POS 1])) + assert_includes assert_raises(DataPipeline::LayoutError) { LAYOUT.parse(%w[HEAD 1 2 3]) }.message, '4 values' + with_tmpdir do |dir| + File.write("#{dir}/in.txt", "HEAD|20251103|\nPOS|1|abc|1.50\n") + rows = DataPipeline::CsvStorage.new(file: "#{dir}/in.txt", layout: LAYOUT, csv: { headers: false, col_sep: '|' }).read + assert_equal [{ 'satyp' => 'HEAD', 'datum' => '20251103', 'zeit' => nil }, + { 'satyp' => 'POS', 'nr' => '1', 'text' => 'abc', 'betrag' => '1.50' }], rows + end + end end diff --git a/test/plugins/fixed_storage_test.rb b/test/plugins/fixed_storage_test.rb new file mode 100644 index 0000000..17a6a0d --- /dev/null +++ b/test/plugins/fixed_storage_test.rb @@ -0,0 +1,63 @@ +require_relative '../test_helper' + +class FixedStorageTest < PipelineTest + LAYOUT = DataPipeline::Layout.new do + record 'KOPF', [['SATYP', 'AN', 4, true], ['BELNR', 'AN', 6, true], ['DATUM', 'DAT', 8]] + record 'POS', [['SATYP', 'AN', 4, true], ['TEXT', 'AN', 10], ['BETRAG', 'N', '7.2']] + end + + ROWS = [{ 'satyp' => 'KOPF', 'belnr' => 'R1', 'datum' => Date.new(2026, 10, 9) }, + { 'satyp' => 'POS', 'text' => 'Müller', 'betrag' => 12.5 }, + { 'satyp' => 'POS', 'betrag' => -3 }].freeze + + def test_write_and_read_back + with_tmpdir do |dir| + DataPipeline::FixedStorage.new(file: "#{dir}/out.txt", layout: LAYOUT).write(ROWS) + assert_equal "KOPFR1 20261009\nPOS Müller 00012.50\nPOS -0003.00\n", File.read("#{dir}/out.txt") + assert_equal [{ 'satyp' => 'KOPF', 'belnr' => 'R1', 'datum' => '20261009' }, + { 'satyp' => 'POS', 'text' => 'Müller', 'betrag' => '00012.50' }, + { 'satyp' => 'POS', 'text' => nil, 'betrag' => '-0003.00' }], + DataPipeline::FixedStorage.new(file: "#{dir}/out.txt", layout: LAYOUT).read + end + end + + def test_encoding_line_end_and_streaming_read + with_tmpdir do |dir| + DataPipeline::FixedStorage.new(file: "#{dir}/out.txt", layout: LAYOUT, encoding: 'ISO-8859-1', line_end: "\r\n").write(ROWS.first(2)) + assert_equal "KOPFR1 20261009\r\nPOS M\xFCller 00012.50\r\n".b, File.binread("#{dir}/out.txt") + rows = [] + DataPipeline::FixedStorage.new(file: "#{dir}/out.txt", layout: LAYOUT, encoding: 'ISO-8859-1').read { |row| rows << row } + assert_equal 'Müller', rows.last['text'] + end + end + + def test_pipeline_with_container_and_dry_run + with_tmpdir do |dir| + rows = ROWS.map(&:dup) + out = capture_stdout do + DataPipeline.new do + logger PipelineHelper::QUIET + dry_run + read MemoryStorage.new(rows:) + write container(:fixed, file: "#{dir}/dry.txt", layout: LAYOUT) + end.run + end + refute File.exist?("#{dir}/dry.txt") + assert_includes out, 'DRY RUN' + + DataPipeline.new do + logger PipelineHelper::QUIET + read MemoryStorage.new(rows:) + write container(:fixed, file: "#{dir}/out.txt", layout: LAYOUT) + end.run + assert_equal 3, File.readlines("#{dir}/out.txt").size + end + end + + def test_options_and_lengths_required + assert_raises(ArgumentError) { DataPipeline::FixedStorage.new(layout: LAYOUT) } + assert_raises(ArgumentError) { DataPipeline::FixedStorage.new(file: 'x') } + layout = DataPipeline::Layout.new { record 'A', [['SATYP', 'AN']] } + assert_raises(DataPipeline::LayoutError) { DataPipeline::FixedStorage.new(file: 'x', layout:) } + end +end