Layout: Formatieren, Festbreiten-Plugin, Lesen von Satzarten-Dateien
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user