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
|
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`;
|
- 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
|
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
|
## 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-
|
**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
|
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
|
Datentypen `AN`, `N`, `DAT`, `TIM` und ihre Prüfung stehen in 2.9. Ohne `format: true` formatiert das Layout nicht,
|
||||||
`map` Datum und Beträge selbst ins Zielformat (`delete('-')`, `format('%.2f', …)`).
|
deshalb bringt das `map` hier Datum und Beträge selbst ins Zielformat (`delete('-')`, `format('%.2f', …)`).
|
||||||
|
|
||||||
<!-- beispiel: examples/rechnungen_satzarten.rb -->
|
<!-- beispiel: examples/rechnungen_satzarten.rb -->
|
||||||
```ruby
|
```ruby
|
||||||
@@ -586,6 +586,7 @@ flowchart LR
|
|||||||
| [lib/datapipeline/storage/sqlite.rb](lib/datapipeline/storage/sqlite.rb) | Quelle/Ziel SQLite (`sqlite3`) |
|
| [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/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/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/notification/slack.rb](lib/datapipeline/notification/slack.rb) | Benachrichtigung, z. B. aus `on_finish` |
|
||||||
| [lib/datapipeline/version.rb](lib/datapipeline/version.rb) | `DataPipeline::VERSION` |
|
| [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 |
|
| `: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` |
|
| `: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) |
|
| `: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` |
|
| `: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
|
**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` |
|
| `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` |
|
| `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
|
Ein unbekannter Typ wird wie `AN` behandelt.
|
||||||
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:
|
**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
|
```ruby
|
||||||
map do |row|
|
LAYOUT = DataPipeline::Layout.new(format: true) do
|
||||||
row.merge('datum' => row['datum'].strftime('%Y%m%d'), # Date -> '20261009'; Date#to_s wäre '2026-10-09'
|
record 'KOPF', [['SATYP', 'AN', 4, true], ['DATUM', 'DAT', 8, true], ['SUMME', 'N', '11.2', true]]
|
||||||
'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
|
|
||||||
end
|
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
|
**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
|
**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 (…)`).
|
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).
|
`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
|
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
|
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
|
### 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.
|
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 :SqliteStorage, File.expand_path('datapipeline/storage/sqlite', __dir__)
|
||||||
autoload :ExcelStorage, File.expand_path('datapipeline/storage/excel', __dir__)
|
autoload :ExcelStorage, File.expand_path('datapipeline/storage/excel', __dir__)
|
||||||
autoload :RestStorage, File.expand_path('datapipeline/storage/rest', __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__)
|
autoload :Slack, File.expand_path('datapipeline/notification/slack', __dir__)
|
||||||
|
|
||||||
# passed to header, trailer, map and filter
|
# 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.
|
# 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(: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
|
# 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
|
class Layout
|
||||||
Field = Struct.new(:name, :type, :length, :required) do
|
Field = Struct.new(:name, :type, :length, :required) do
|
||||||
def key = name.downcase
|
def key = name.downcase
|
||||||
@@ -30,13 +37,30 @@ class DataPipeline
|
|||||||
after ||= 0
|
after ||= 0
|
||||||
[before - after, after]
|
[before - after, after]
|
||||||
end
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
NUMBER = /\A[+-]?\d+(\.\d+)?\z/
|
||||||
|
|
||||||
attr_reader :records
|
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
|
@type_field = type_field&.to_s&.downcase
|
||||||
@strict = strict
|
@strict = strict
|
||||||
|
@format = format
|
||||||
@records = {}
|
@records = {}
|
||||||
instance_eval(&block) if block
|
instance_eval(&block) if block
|
||||||
end
|
end
|
||||||
@@ -56,23 +80,148 @@ class DataPipeline
|
|||||||
def values row
|
def values row
|
||||||
fields = fields_for(row)
|
fields = fields_for(row)
|
||||||
normalized = normalize(row)
|
normalized = normalize(row)
|
||||||
if @strict
|
unknown!(row, fields, normalized)
|
||||||
unknown = normalized.keys - fields.map(&:key)
|
fields.map { |field| output(field, normalized[field.key]) }
|
||||||
raise LayoutError, "#{record_type(row)}: fields not in layout: #{unknown.join(', ')}" if unknown.any?
|
|
||||||
end
|
|
||||||
fields.map { |field| normalized[field.key] }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def errors row
|
def errors row
|
||||||
fields = fields_for(row)
|
fields = fields_for(row)
|
||||||
normalized = normalize(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
|
rescue LayoutError => e
|
||||||
[e.message]
|
[e.message]
|
||||||
end
|
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
|
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
|
def record_type row
|
||||||
@type_field ? normalize(row)[@type_field] : row.values.first
|
@type_field ? normalize(row)[@type_field] : row.values.first
|
||||||
end
|
end
|
||||||
@@ -95,7 +244,7 @@ class DataPipeline
|
|||||||
end
|
end
|
||||||
|
|
||||||
def check_number field, text
|
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
|
return [] unless field.length
|
||||||
|
|
||||||
before, after = field.digits
|
before, after = field.digits
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ class DataPipeline
|
|||||||
# columns: fixed column order for writing (and keys for reading without headers)
|
# columns: fixed column order for writing (and keys for reading without headers)
|
||||||
# without columns the values are written in hash order
|
# without columns the values are written in hash order
|
||||||
# layout: DataPipeline::Layout, field order per record type (several record types in one file),
|
# 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);
|
# 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
|
# 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)
|
# 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
|
# rows with headers are CSV::Row, without headers plain arrays
|
||||||
def to_hash row
|
def to_hash row
|
||||||
return row.to_h if row.is_a?(CSV::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 = columns&.map(&:to_s) || (0...row.length).map(&:to_s)
|
||||||
keys.zip(row).to_h
|
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 [{ 'a' => '1' }], target.output
|
||||||
assert_equal 1, stats.rejected
|
assert_equal 1, stats.rejected
|
||||||
end
|
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
|
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