Layout: Formatieren, Festbreiten-Plugin, Lesen von Satzarten-Dateien

This commit is contained in:
wkh
2026-10-09 21:42:33 +02:00
parent 48c900558b
commit f82fe50655
8 changed files with 414 additions and 20 deletions
+4
View File
@@ -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
+59 -11
View File
@@ -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', …)`).
<!-- beispiel: examples/rechnungen_satzarten.rb -->
```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.
+1
View File
@@ -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
+157 -8
View File
@@ -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
+3 -1
View File
@@ -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
+77
View File
@@ -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
+50
View File
@@ -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
+63
View File
@@ -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