Fehler behoben: Umlaute, Latin-1, Fehlermeldungen, atomares Schreiben

Spaltennamen mit Umlauten in Ausdrücken, Latin-1-Dateien als UTF-8,
Fehler als eine Zeile (RDB_DEBUG=1 für Backtrace), Werte mit Tab oder
Zeilenumbruch abgelehnt, Tabellen und Indexe über Temp-Datei und rename,
update.rdb gibt den Satz bei Editorfehler zurück, sorttable -r stabil.
This commit is contained in:
wkh
2026-10-30 09:48:42 +01:00
parent 9f6c450492
commit 75c2424307
11 changed files with 164 additions and 42 deletions
+10 -1
View File
@@ -12,6 +12,7 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in
- `demo/handbuch/`: die Beispiele aus dem Lehrbuchteil (Kapitel 2, 3, 6, 7, 10, 11, 15) mit ihren Tabellen. `beispiele.txt` hält Befehl und gedruckte Ausgabe, `test/handbuch_test.rb` vergleicht sie; `mise run test` führt ihn mit aus. Druckfehler des Buchs und Stellen, an denen wir abweichen, sind dort vermerkt. - `demo/handbuch/`: die Beispiele aus dem Lehrbuchteil (Kapitel 2, 3, 6, 7, 10, 11, 15) mit ihren Tabellen. `beispiele.txt` hält Befehl und gedruckte Ausgabe, `test/handbuch_test.rb` vergleicht sie; `mise run test` führt ihn mit aus. Druckfehler des Buchs und Stellen, an denen wir abweichen, sind dort vermerkt.
### Geändert ### Geändert
- `testall` und die Tests setzen `EDITOR=true`, damit kein echter Editor startet.
- `search` und `seek` ohne Schlüsselspalte nehmen die Spaltennamen aus dem Kopf einer Schlüsseltabelle auf der Standardeingabe (`search -mh mailtable < name`, Handbuch S. 105). Vorher galt immer die erste Spalte der Datentabelle. - `search` und `seek` ohne Schlüsselspalte nehmen die Spaltennamen aus dem Kopf einer Schlüsseltabelle auf der Standardeingabe (`search -mh mailtable < name`, Handbuch S. 105). Vorher galt immer die erste Spalte der Datentabelle.
- `gregorian -u` schreibt den Monat ohne führende Null (`6/01/89`), wie im Handbuch. - `gregorian -u` schreibt den Monat ohne führende Null (`6/01/89`), wie im Handbuch.
- `mean` schreibt einen ganzzahligen Mittelwert ohne `.00` (`4786`, Handbuch S. 26). - `mean` schreibt einen ganzzahligen Mittelwert ohne `.00` (`4786`, Handbuch S. 26).
@@ -23,7 +24,15 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in
- `vilock`: die Sperre wird atomar über einen Hardlink genommen (vorher `exist?` und `rename` mit Lücke dazwischen). - `vilock`: die Sperre wird atomar über einen Hardlink genommen (vorher `exist?` und `rename` mit Lücke dazwischen).
### Behoben ### Behoben
- `EDITOR` mit Argumenten (`nvim -f`) in `vilock` und `update.rdb` wurde als ein Programmname behandelt; der Editor startete nie. - `EDITOR` mit Argumenten (`nvim -f`) in `vilock` und `update.rdb` wurde als ein Programmname behandelt; der Editor startete nie. Scheitert der Editor, meldet `update.rdb` das, statt „record replaced“ zu schreiben.
- `update.rdb e`: Der Satz blieb leer, wenn der Editor scheiterte oder abgebrochen wurde. Er wird jetzt in jedem Fall zurückgeschrieben.
- Spaltennamen mit Umlauten (`row 'Über > 3'`) scheiterten mit einem awk-Fehler. Der Ausdrucksumsetzer kennt jetzt Unicode-Buchstaben.
- Dateien, die kein UTF-8 sind (Latin-1 aus COHERENT-Zeiten), brachen mit `invalid byte sequence` ab. Sie werden als Latin-1 gelesen und als UTF-8 ausgegeben.
- Fehler wie ungültige Regex, ungültiges Datum oder Ein-/Ausgabefehler endeten mit einem Ruby-Backtrace. Jetzt eine Zeile; `RDB_DEBUG=1` zeigt den Backtrace. Ein geschlossenes Pipe-Ende (`| head`) und Ctrl-C beenden still.
- `append`, `enter`, `replace` und `ve` schrieben Werte mit Tab oder Zeilenumbruch und machten die Tabelle kaputt. Sie lehnen solche Werte jetzt ab.
- Tabellen, Indexdateien und `substitute` werden über eine temporäre Datei mit `rename` geschrieben; ein Abbruch lässt die alte Datei statt einer halben neuen.
- `index -mb` und `-mi` sortieren nach dem Schlüssel ohne umgebende Leerzeichen, so wie `search` vergleicht.
- `sorttable -r` kehrte auch die Reihenfolge gleicher Schlüssel um; sie bleibt jetzt erhalten.
- Spaltennamen, die `justify` mit Leerzeichen aufgefüllt hat, wurden nicht mehr gefunden: `column Name` lieferte eine leere Spalte, `index` brach ab. Leerraum um die Namen im Kopf zählt jetzt nicht mehr. - Spaltennamen, die `justify` mit Leerzeichen aufgefüllt hat, wurden nicht mehr gefunden: `column Name` lieferte eine leere Spalte, `index` brach ab. Leerraum um die Namen im Kopf zählt jetzt nicht mehr.
## [0.7.0] - 2026-10-12 ## [0.7.0] - 2026-10-12
+4 -1
View File
@@ -13,7 +13,7 @@ sind nach dem Handbuch neu geschrieben.
mise trust # einmal nach dem Clone: mise.toml freigeben mise trust # einmal nach dem Clone: mise.toml freigeben
mise install # Ruby 3.4 mise install # Ruby 3.4
mise run install # legt die Symlinks row, column, total ... in bin/ an mise run install # legt die Symlinks row, column, total ... in bin/ an
mise run test # 114 Tests und die Beispiele aus dem Handbuch mise run test # 126 Tests und die Beispiele aus dem Handbuch
``` ```
Ohne `mise trust` brechen in einem frisch geklonten Ordner alle `mise`-Befehle ab. Ohne `mise trust` brechen in einem frisch geklonten Ordner alle `mise`-Befehle ab.
@@ -245,6 +245,9 @@ er nicht mehr passt (außer mit `-a`). Beide halten die Indizes aktuell.
- Die Indexdateien sind dem Original nachgebaut, aber nicht gleich (Handbuch S. 103–108): - Die Indexdateien sind dem Original nachgebaut, aber nicht gleich (Handbuch S. 103–108):
die Hashtabelle `.h` hat 2n statt 2n+1 Plätze, und in `.i` steht der Schlüssel vor die Hashtabelle `.h` hat 2n statt 2n+1 Plätze, und in `.i` steht der Schlüssel vor
dem Offset statt dahinter. Alte /rdb-Indizes muss man neu bauen. dem Offset statt dahinter. Alte /rdb-Indizes muss man neu bauen.
- Dateien, die kein UTF-8 sind, liest `rdb` als Latin-1 und schreibt sie als UTF-8. Fehler
erscheinen als eine Zeile; mit `RDB_DEBUG=1` kommt der Ruby-Backtrace. Werte mit Tab oder
Zeilenumbruch lehnen `append`, `enter`, `replace` und `ve` ab.
- `lock`/`unlock` legen ihre Sperrdateien in `/tmp/rdb-locks-<UID>/` ab (Modus 0700, `flock`), - `lock`/`unlock` legen ihre Sperrdateien in `/tmp/rdb-locks-<UID>/` ab (Modus 0700, `flock`),
nicht als `/tmp/L<Tabelle>` wie in den Man-Pages. Eine Sperre eines abgestürzten Prozesses nicht als `/tmp/L<Tabelle>` wie in den Man-Pages. Eine Sperre eines abgestürzten Prozesses
wird nicht erkannt; `unlock` hebt sie auf. wird nicht erkannt; `unlock` hebt sie auf.
+13 -3
View File
@@ -27,7 +27,7 @@ Stand: 2026-10-30, Version 0.7.0. Die 114 Tests und der Handbuch-Test liefen gr
Race zwischen `File.exist?` und `rename`; nach `kill -9` bleibt `LOCK…` liegen. Race zwischen `File.exist?` und `rename`; nach `kill -9` bleibt `LOCK…` liegen.
**Behoben:** atomare Sperre über `File.link`. Offen: nach `kill -9` bleibt `LOCK…` liegen. **Behoben:** atomare Sperre über `File.link`. Offen: nach `kill -9` bleibt `LOCK…` liegen.
## Fehler ## Fehler (1–8 behoben, 9 teilweise)
1. **Umlaute in Spaltennamen (bestätigt).** `to_awk` erkennt nur `[A-Za-z_#]` 1. **Umlaute in Spaltennamen (bestätigt).** `to_awk` erkennt nur `[A-Za-z_#]`
und ASCII-`\w`. `row 'Über > 3'` scheitert mit awk-Fehler. Relevant für die und ASCII-`\w`. `row 'Über > 3'` scheitert mit awk-Fehler. Relevant für die
@@ -62,6 +62,16 @@ Stand: 2026-10-30, Version 0.7.0. Die 114 Tests und der Handbuch-Test liefen gr
ignoriert; `total`/`mean` lehnen `1e3` ab; eine Zeile aus lauter leeren ignoriert; `total`/`mean` lehnen `1e3` ab; eine Zeile aus lauter leeren
Zellen gilt als gelöscht ([fast.rb:21](../lib/rdb/fast.rb)). Zellen gilt als gelöscht ([fast.rb:21](../lib/rdb/fast.rb)).
**Stand der Behebung:** 1 (Umlaute: Unicode-fähiger Umsetzer), 2 (Latin-1 wird als UTF-8
gelesen), 3 (Fehler als eine Zeile, `RDB_DEBUG=1` für den Backtrace), 4 (`EDITOR` mit
Argumenten, Editorfehler wird gemeldet), 5 (Satz wird in jedem Fall zurückgeschrieben),
6 (`Rdb.check_cells!`), 7 (`Rdb.write_file`: temporäre Datei plus `rename`) und 8
(Sortierschlüssel ohne Leerzeichen) sind behoben, jeweils mit Test.
Bei 9 ist nur `sorttable -r` behoben. Bewusst offen: `-nrf` gilt global (so beschreibt es
das Handbuch), `jointable -n` ist unklar spezifiziert, `1e3` als Zahl und die Zeile aus
leeren Zellen als „gelöscht“ sind Eigenheiten des Formats. Bei 8 bleibt `-x` (Faltung) beim
Index ohne Entsprechung in der Suche offen.
## Verbesserungspotential ## Verbesserungspotential
1. **Indexe sind nicht schnell.** Jede Suche liest Tabelle und Indexdatei 1. **Indexe sind nicht schnell.** Jede Suche liest Tabelle und Indexdatei
@@ -88,5 +98,5 @@ Stand: 2026-10-30, Version 0.7.0. Die 114 Tests und der Handbuch-Test liefen gr
## Empfohlene Reihenfolge ## Empfohlene Reihenfolge
Die Schwachstellen sind erledigt. Weiter mit: Umlaute (Fehler 1), Encoding (2), Top-Level-Rescue (3), `report`-Einschleusung Schwachstellen und Fehler sind erledigt. Weiter mit: Hash-Funktion und
(Schwachstelle 1); danach Hash-Funktion und Index-Lesezugriffe. Index-Lesezugriffe (Verbesserung 1 und 2), dann Float-Geld.
+29
View File
@@ -46,9 +46,38 @@ module Rdb
COMMANDS[name] = Command.new(name, cmd.desc, cmd.syntax.sub(target, name), cmd.block) COMMANDS[name] = Command.new(name, cmd.desc, cmd.syntax.sub(target, name), cmd.block)
end end
# Write a file all at once: a temporary file next to it, then rename, so
# an interrupted run leaves the old file rather than half a new one.
def write_file(path, data)
path = File.realpath(path) if File.symlink?(path)
tmp = "#{path}.tmp#{Process.pid}"
File.binwrite(tmp, data)
File.chmod(File.stat(path).mode & 0o7777, tmp) if File.exist?(path)
File.rename(tmp, path)
ensure
File.delete(tmp) if tmp && File.exist?(tmp)
end
# A value is one cell of a tab-separated line: no tab, no line break.
def check_cells!(cells)
bad = cells.find { |c| c.to_s.match?(/[\t\r\n]/) }
die "value #{bad.to_s.inspect} contains a tab or line break" if bad
end
# Errors reach the user as one line; RDB_DEBUG=1 keeps the backtrace.
def run(name, args) def run(name, args)
cmd = COMMANDS[name] or die("unknown command '#{name}'") cmd = COMMANDS[name] or die("unknown command '#{name}'")
Env.new(name).instance_exec(args.dup, &cmd.block) Env.new(name).instance_exec(args.dup, &cmd.block)
rescue Errno::EPIPE
exit 0
rescue Interrupt
exit 130
rescue SystemExit
raise
rescue StandardError => e
raise if ENV['RDB_DEBUG'].to_s.match?(/\A(1|yes|true)\z/i)
die("#{e.message} (#{e.class}; RDB_DEBUG=1 shows where)")
end end
end end
end end
+14 -4
View File
@@ -259,7 +259,7 @@ Rdb.register('vilock', 'Lock a table before editing, unlock afterward', 'vilock
end end
File.unlink(file) File.unlink(file)
begin begin
system(*Shellwords.split(ENV['EDITOR'] || 'vi'), locked) system(*Shellwords.split(ENV['EDITOR'] || 'vi'), locked) or warn "vilock: editor #{ENV['EDITOR'] || 'vi'} failed"
ensure ensure
File.rename(locked, file) File.rename(locked, file)
end end
@@ -276,7 +276,7 @@ def edit_cells(head, cells)
Tempfile.create('rdb-edit') do |f| Tempfile.create('rdb-edit') do |f|
f.write Rdb::Table.new(head, [cells], list: true).to_s f.write Rdb::Table.new(head, [cells], list: true).to_s
f.flush f.flush
system(*Shellwords.split(ENV['EDITOR'] || 'vi'), f.path) system(*Shellwords.split(ENV['EDITOR'] || 'vi'), f.path) or Rdb.die("editor #{ENV['EDITOR'] || 'vi'} failed")
t = Rdb::Table.parse(File.read(f.path)) t = Rdb::Table.parse(File.read(f.path))
return nil if t.rows.empty? return nil if t.rows.empty?
@@ -319,7 +319,11 @@ Rdb.register('update.rdb', 'Display and edit records in any sized file',
r = rows[cur] r = rows[cur]
cells = r.cells cells = r.cells
# Unless -l, the record is blanked while it is edited (that is the lock).
# Whatever happens, it must not stay blank: put it back unless replaced.
Rdb::Fast.splice(table, r.from, r.to, Rdb::Fast.blanked(r.line) + "\n") unless o[:l] Rdb::Fast.splice(table, r.from, r.to, Rdb::Fast.blanked(r.line) + "\n") unless o[:l]
replaced = false
begin
new = edit_cells(data.head, cells.map(&:strip)) new = edit_cells(data.head, cells.map(&:strip))
if new if new
hit = Rdb::Fast::Hit.new(r, 0, 0) hit = Rdb::Fast::Hit.new(r, 0, 0)
@@ -329,11 +333,14 @@ Rdb.register('update.rdb', 'Display and edit records in any sized file',
xf, xt = h.xfrom, h.xto if h xf, xt = h.xfrom, h.xto if h
end end
Rdb::Fast.replace_row(table, hit.row.from, hit.row.to, new, o[:m], xf, xt, keycols[0]) Rdb::Fast.replace_row(table, hit.row.from, hit.row.to, new, o[:m], xf, xt, keycols[0])
replaced = true
puts 'record replaced' puts 'record replaced'
else else
Rdb::Fast.splice(table, r.from, r.to, r.line + "\n")
puts 'unchanged' puts 'unchanged'
end end
ensure
Rdb::Fast.splice(table, r.from, r.to, r.line + "\n") unless o[:l] || replaced
end
next next
when /\Ad/i when /\Ad/i
next puts('no current record') unless cur && rows[cur] next puts('no current record') unless cur && rows[cur]
@@ -393,7 +400,9 @@ Rdb.register('ve', 'Visually edit a table', 've [table [-n[n] -fc -mc -i -s [fil
next puts('no such field') unless (0...t.head.size).cover?(f) next puts('no such field') unless (0...t.head.size).cover?(f)
print "#{t.head[f]}> " print "#{t.head[f]}> "
rows[cur][f] = ($stdin.gets || '').chomp value = ($stdin.gets || '').chomp
Rdb.check_cells!([value])
rows[cur][f] = value
File.write(file, t.to_s) File.write(file, t.to_s)
when 'a' when 'a'
row = t.head.each_with_index.map do |h, i| row = t.head.each_with_index.map do |h, i|
@@ -404,6 +413,7 @@ Rdb.register('ve', 'Visually edit a table', 've [table [-n[n] -fc -mc -i -s [fil
print "#{h}> " print "#{h}> "
($stdin.gets || '').chomp ($stdin.gets || '').chomp
end end
Rdb.check_cells!(row)
rows << row rows << row
File.write(file, t.to_s) File.write(file, t.to_s)
cur = rows.size - 1 cur = rows.size - 1
+2 -2
View File
@@ -48,8 +48,8 @@ Rdb.register('sorttable', 'Sort a table by one or more columns',
end end
cols = (0...t.head.size).to_a if cols.empty? cols = (0...t.head.size).to_a if cols.empty?
key = ->(r) { cols.map { |i| numeric ? r[i].to_f : (fold ? r[i].downcase : r[i]) } } key = ->(r) { cols.map { |i| numeric ? r[i].to_f : (fold ? r[i].downcase : r[i]) } }
sorted = t.rows.each_with_index.sort_by { |r, n| [key.(r), n] }.map(&:first) keyed = t.rows.each_with_index.map { |r, n| [key.(r), n, r] }
sorted.reverse! if reverse sorted = keyed.sort { |a, b| (reverse ? b[0] <=> a[0] : a[0] <=> b[0]).nonzero? || a[1] <=> b[1] }.map(&:last)
t.with(rows: sorted).write t.with(rows: sorted).write
end end
+1 -1
View File
@@ -142,7 +142,7 @@ end
Rdb.register('substitute', 'Replace old string with new string', 'substitute oldstring newstring file ...') do |args| Rdb.register('substitute', 'Replace old string with new string', 'substitute oldstring newstring file ...') do |args|
die 'usage: substitute oldstring newstring file ...' if args.size < 3 die 'usage: substitute oldstring newstring file ...' if args.size < 3
old, new, *files = args old, new, *files = args
files.each { |f| File.write(f, File.read(f).gsub(old, new)) } files.each { |f| Rdb.write_file(f, File.read(f).gsub(old, new)) }
end end
Rdb.register('trimblank', 'Remove leading and trailing blanks from a string', 'trimblank [string] [< file]') do |args| Rdb.register('trimblank', 'Remove leading and trailing blanks from a string', 'trimblank [string] [< file]') do |args|
+4 -4
View File
@@ -50,7 +50,7 @@ module Rdb
def icase_column(names, expr, i) def icase_column(names, expr, i)
return unless Rdb.icase? return unless Rdb.icase?
word = expr[i..][/\A\w+/].to_s word = expr[i..][/\A[[:alnum:]_]+/].to_s
return if word.empty? || AWK_WORDS.include?(word) || expr[i + word.size..].to_s.match?(/\A\s*\(/) return if word.empty? || AWK_WORDS.include?(word) || expr[i + word.size..].to_s.match?(/\A\s*\(/)
hits = names.select { |n, _| n.casecmp?(word) } hits = names.select { |n, _| n.casecmp?(word) }
@@ -75,14 +75,14 @@ module Rdb
j += expr[j] == '\\' ? 2 : 1 while j < expr.size && expr[j] != '/' j += expr[j] == '\\' ? 2 : 1 while j < expr.size && expr[j] != '/'
out << expr[i..j] out << expr[i..j]
i = j + 1 i = j + 1
elsif c.match?(/[A-Za-z_#]/) && (i.zero? || !expr[i - 1].match?(/[\w$]/)) elsif c.match?(/[[:alpha:]_#]/) && (i.zero? || !expr[i - 1].match?(/[[:alnum:]_$]/))
hit = names.find { |n, _| !n.empty? && expr[i, n.size] == n && !expr[i + n.size].to_s.match?(/\w/) } hit = names.find { |n, _| !n.empty? && expr[i, n.size] == n && !expr[i + n.size].to_s.match?(/[[:alnum:]_]/) }
hit ||= icase_column(names, expr, i) hit ||= icase_column(names, expr, i)
if hit if hit
out << "$#{hit[1] + 1}" out << "$#{hit[1] + 1}"
i += hit[0].size i += hit[0].size
else else
m = expr[i..][/\A\w+/] || c m = expr[i..][/\A[[:alnum:]_]+/] || c
out << m out << m
i += m.size i += m.size
end end
+17 -15
View File
@@ -42,7 +42,7 @@ module Rdb
off += l.bytesize off += l.bytesize
end end
Rdb.die("#{path}: is a list; fast access needs a table") if bytes.start_with?("\n") Rdb.die("#{path}: is a list; fast access needs a table") if bytes.start_with?("\n")
@head = lines.shift&.line.to_s.split("\t", -1).map(&:strip) @head = lines.shift&.line.to_s.split("\t", -1).map { |h| h.dup.force_encoding(Encoding::UTF_8).strip }
lines.shift if lines.first && Table.dash_line?(lines.first.cells) lines.shift if lines.first && Table.dash_line?(lines.first.cells)
@rows = lines @rows = lines
@by_from = lines.to_h { |r| [r.from, r] } @by_from = lines.to_h { |r| [r.from, r] }
@@ -95,22 +95,22 @@ module Rdb
rows = live_rows(data) rows = live_rows(data)
case method case method
when 'r' when 'r'
File.binwrite(path + '.r', FIXED_HEAD + rows.map { |r| fmt_entry(r.from) }.join) Rdb.write_file(path + '.r', FIXED_HEAD + rows.map { |r| fmt_entry(r.from) }.join)
when 'i' when 'i'
k = data.column(keycol) k = data.column(keycol)
ents = rows.map { |r| [r.cells[k].to_s, r.from] } ents = rows.map { |r| [r.cells[k].to_s, r.from] }
ents = ents.sort_by { |key, off| [fold ? key.downcase : key, off] } ents = ents.sort_by { |key, off| [fold ? key.strip.downcase : key.strip, off] }
File.binwrite(path + '.i', "#{keycol}\tOffset\n#{'-' * keycol.size}\t------\n" + ents.map { |key, off| "#{key}\t#{off}\n" }.join) Rdb.write_file(path + '.i', "#{keycol}\tOffset\n#{'-' * keycol.size}\t------\n" + ents.map { |key, off| "#{key}\t#{off}\n" }.join)
when 'h' when 'h'
k = data.column(keycol) k = data.column(keycol)
slots = [rows.size * 2, 2].max slots = [rows.size * 2, 2].max
table = Array.new(slots, 0) table = Array.new(slots, 0)
rows.each { |r| table[free_slot(table, hash_slot(slots, r.cells[k].to_s))] = r.from } rows.each { |r| table[free_slot(table, hash_slot(slots, r.cells[k].to_s))] = r.from }
File.binwrite(path + '.h', FIXED_HEAD + table.map { |o| fmt_entry(o) }.join) Rdb.write_file(path + '.h', FIXED_HEAD + table.map { |o| fmt_entry(o) }.join)
when 'b' when 'b'
k = data.column(keycol) k = data.column(keycol)
body = live_rows(data).sort_by { |r| r.cells[k].to_s }.map { |r| r.line + "\n" } body = live_rows(data).sort_by { |r| r.cells[k].to_s.strip }.map { |r| r.line + "\n" }
File.binwrite(path, data.head.join("\t") + "\n" + Table.dash_row(data.head) + "\n" + body.join) Rdb.write_file(path, data.head.join("\t") + "\n" + Table.dash_row(data.head) + "\n" + body.join)
end end
end end
@@ -217,7 +217,7 @@ module Rdb
def splice(path, from, to, text) def splice(path, from, to, text)
b = File.binread(path) b = File.binread(path)
File.binwrite(path, b.byteslice(0, from) + text + b.byteslice(to..).to_s) Rdb.write_file(path, b.byteslice(0, from) + text + b.byteslice(to..).to_s)
end end
# Remove a record's entry from the secondary index for method. # Remove a record's entry from the secondary index for method.
@@ -227,11 +227,11 @@ module Rdb
ip = path + '.r' ip = path + '.r'
ents = read_fixed(ip) ents = read_fixed(ip)
ents.delete(row_from) ents.delete(row_from)
File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join) Rdb.write_file(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
when 'h' when 'h'
ip = path + '.h' ip = path + '.h'
ents = read_fixed(ip).map { |o| o == row_from ? -1 : o } ents = read_fixed(ip).map { |o| o == row_from ? -1 : o }
File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join) Rdb.write_file(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
when 'i' when 'i'
ip = path + '.i' ip = path + '.i'
e = read_inverted(ip).find { |x| x[:offset] == row_from } e = read_inverted(ip).find { |x| x[:offset] == row_from }
@@ -242,16 +242,16 @@ module Rdb
def index_add(path, method, row_from, cells, data_head, keycol) def index_add(path, method, row_from, cells, data_head, keycol)
case method case method
when 'r' when 'r'
File.binwrite(path + '.r', File.binread(path + '.r') + fmt_entry(row_from)) Rdb.write_file(path + '.r', File.binread(path + '.r') + fmt_entry(row_from))
when 'i' when 'i'
ip = path + '.i' ip = path + '.i'
name = File.binread(ip).lines.first.split("\t")[0] name = File.binread(ip).lines.first.split("\t")[0]
key = cells[data_head.index(name)].to_s key = cells[data_head.index(name)].to_s
ents = read_inverted(ip) ents = read_inverted(ip)
pos = ents.find { |e| e[:key] > key } pos = ents.find { |e| e[:key].strip > key.strip }
line = "#{key}\t#{row_from}\n" line = "#{key}\t#{row_from}\n"
if pos then splice(ip, pos[:from], pos[:from], line) if pos then splice(ip, pos[:from], pos[:from], line)
else File.binwrite(ip, File.binread(ip) + line) else Rdb.write_file(ip, File.binread(ip) + line)
end end
when 'h' when 'h'
ip = path + '.h' ip = path + '.h'
@@ -259,7 +259,7 @@ module Rdb
if ents.any? { |o| o <= 0 } if ents.any? { |o| o <= 0 }
key = cells[data_head.index(keycol)].to_s key = cells[data_head.index(keycol)].to_s
ents[free_slot(ents, hash_slot(ents.size, key))] = row_from ents[free_slot(ents, hash_slot(ents.size, key))] = row_from
File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join) Rdb.write_file(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
else else
build(path, 'h', keycol) build(path, 'h', keycol)
end end
@@ -269,11 +269,12 @@ module Rdb
end end
def append_row(path, cells, method, keycol) def append_row(path, cells, method, keycol)
Rdb.check_cells!(cells)
line = cells.join("\t") line = cells.join("\t")
b = File.binread(path) b = File.binread(path)
b += "\n" unless b.empty? || b.end_with?("\n") b += "\n" unless b.empty? || b.end_with?("\n")
from = b.bytesize from = b.bytesize
File.binwrite(path, b + line + "\n") Rdb.write_file(path, b + line + "\n")
index_add(path, method, from, cells, Data.new(path).head, keycol) if SUFFIX[method] || method == 'b' index_add(path, method, from, cells, Data.new(path).head, keycol) if SUFFIX[method] || method == 'b'
from from
end end
@@ -287,6 +288,7 @@ module Rdb
# Put cells where the record at hit was. If the new record is too long # Put cells where the record at hit was. If the new record is too long
# even after trimming blanks, append it (unless append is false). # even after trimming blanks, append it (unless append is false).
def replace_row(path, from, to, cells, method, xfrom, xto, keycol, allow_append: true) def replace_row(path, from, to, cells, method, xfrom, xto, keycol, allow_append: true)
Rdb.check_cells!(cells)
room = to - from - 1 room = to - from - 1
line = cells.join("\t") line = cells.join("\t")
line = cells.map(&:strip).join("\t") if line.bytesize > room line = cells.map(&:strip).join("\t") if line.bytesize > room
+11
View File
@@ -18,7 +18,18 @@ module Rdb
cells.all? { |c| c.match?(/\A\s*-*\s*\z/) } && cells.any? { |c| c.include?('-') } cells.all? { |c| c.match?(/\A\s*-*\s*\z/) } && cells.any? { |c| c.include?('-') }
end end
# COHERENT-era files are often Latin-1. Text that is not valid UTF-8 is
# read as Latin-1 and comes out as UTF-8.
def self.utf8(text)
return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
text.dup.force_encoding(Encoding::UTF_8).then do |t|
t.valid_encoding? ? t : text.dup.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8)
end
end
def self.parse(text) def self.parse(text)
text = utf8(text)
return parse_list(text) if text.start_with?("\n") return parse_list(text) if text.start_with?("\n")
lines = text.lines.map { |l| l.chomp("\n") } lines = text.lines.map { |l| l.chomp("\n") }
+48
View File
@@ -876,3 +876,51 @@ class TaxTablesTest < RdbCase
end end
end end
end end
class RobustnessTest < RdbCase
def test_umlauts_in_column_names
write('uml', "Größe\tÜber\n-----\t----\n5\t7\n1\t2\n")
assert_equal ['5', '7'], data(rdb('row', 'Über > 3', file: 'uml')).first
assert_equal 1, data(rdb('row', 'Größe > 3', file: 'uml')).size
end
def test_latin1_data_is_read_and_written_as_utf8
File.binwrite(File.join(@dir, 'lat'), "Name\n----\n\xE4pfel\n")
assert_equal "Äpfel".downcase, data(rdb('row', 'Name ~ /pfel/', file: 'lat')).first.first
end
def test_errors_are_one_line_not_a_backtrace
_o, e, st = run3('update.rdb', 'inventory', stdin: "/[\n")
refute st.success?
assert_match(/update\.rdb: .*char-class/, e)
refute_match(/\.rb:\d+:in/, e)
end
def test_enter_refuses_a_value_with_a_tab
_o, e, st = run3('enter', '-1', 'inventory', stdin: "9\t9\n1\n2\n3\nthing\n")
refute st.success?
assert_match(/contains a tab/, e)
end
def test_sorttable_reverse_keeps_ties_in_order
out = rdb('sorttable', '-r', 'B', stdin: "A\tB\n-\t-\n1\tb\n2\ta\n3\tb\n4\ta\n")
assert_equal %w[1 3 2 4], data(out).map(&:first)
end
def test_update_keeps_the_record_when_the_editor_fails
before = File.read(File.join(@dir, 'inventory'))
_o, e, = run3('update.rdb', 'inventory', stdin: "1\ne\nq\n", env: { 'EDITOR' => 'false' })
assert_match(/editor false failed/, e)
assert_equal before, File.read(File.join(@dir, 'inventory'))
end
def test_write_file_is_complete_and_keeps_the_mode
path = File.join(@dir, 'wf')
File.write(path, 'old')
File.chmod(0o640, path)
Rdb.write_file(path, 'new')
assert_equal 'new', File.read(path)
assert_equal 0o640, File.stat(path).mode & 0o777
assert_empty Dir[File.join(@dir, 'wf.tmp*')]
end
end