Files
rdb/lib/rdb/cmd/access.rb
T
wkh 9f6c450492 Sicherheit: report/screen maskieren Shell-Eingaben, Sperren privat und atomar
report setzt Zellwerte in <!befehl!> maskiert ein, screen verlangt gültige
Variablennamen, lock/unlock nutzen /tmp/rdb-locks-<UID> mit flock, vilock
sperrt per Hardlink. EDITOR darf Argumente haben. Doku und Tests ergänzt.
2026-10-30 09:45:18 +01:00

538 lines
19 KiB
Ruby

# frozen_string_literal: true
# Finding, adding, changing and locking records. Many of these take the name
# of the table instead of reading it on stdin, because they change the file.
require 'stringio'
require_relative '../fast'
# Parse the common fast-access options. Stops at the first non-option, which
# is left in args; returns the option hash.
def fast_opts(args)
o = { m: 's', h: false, l: false, s: false, x: false, exact: false, a: false, v: false, o: nil }
until args.empty?
a = args.first
case a
when /\A-m([bhirs]?)\z/ then o[:m] = Regexp.last_match(1).empty? ? 's' : Regexp.last_match(1)
when '+x' then o[:exact] = true
when '-o' then args.shift; o[:o] = args.first
when /\A-[hslxav]+\z/ then a[1..].each_char { |c| o[c.to_sym] = true }
else break
end
args.shift
end
o
end
def need_index(table, method)
suffix = Rdb::Fast::SUFFIX[method]
return unless suffix && !File.exist?(table + suffix)
Rdb.die("no index #{table}#{suffix}: run index -m#{method} #{table}")
end
# The key columns and, unless keys come from the terminal, the keys. Without key
# columns on the command line a key table names them in its head line
# (handbook p. 105: "search -mh mailtable < name"); otherwise the first column.
def key_columns(args, data)
return [args.empty? ? [data.head[0]] : args, nil] if $stdin.tty?
text = $stdin.read.to_s
lines = text.lines.map { |l| l.chomp("\n") }
cols = args
if cols.empty?
keyed = lines.size >= 2 && Rdb::Table.dash_line?(lines[1].split("\t", -1))
cols = keyed ? lines[0].split("\t", -1).map(&:strip) : [data.head[0]]
end
[cols, Rdb::Fast.read_keys(StringIO.new(text), cols.size)]
end
def each_key(ncols, keys = nil, &block)
if keys
keys.each(&block)
elsif $stdin.tty?
$stdin.each_line { |l| yield l.chomp.split("\t", -1).first(ncols) }
else
Rdb::Fast.read_keys($stdin, ncols).each { |k| yield k }
end
end
Rdb.register('index', 'Set up table for search', 'index [-m[bhirs]] [-x] [-l] tableorlist [keycol ...]') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: index [-m[bhirs]] table [keycolumn]'
data = Rdb::Fast::Data.new(table)
key = args.first || data.head[0]
data.column(key)
Rdb::Fast.build(table, o[:m], key, fold: o[:x])
if o[:l]
Rdb::Fast::Data.new(table).rows.each { |r| warn "#{r.from} #{r.to} 0 0" }
end
end
Rdb.register('search', 'Search a table',
'search [-m[bhirs]] [-hsnx +x] [-l] tableorlist [keycolumn ...] [< keytable]') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: search [-m[bhirs]] table [keycolumn ...] < keys'
data = Rdb::Fast::Data.new(table)
need_index(table, o[:m])
keycols, keys = key_columns(args, data)
puts data.head.join("\t"), Rdb::Table.dash_row(data.head) unless o[:h]
each_key(keycols.size, keys) do |key|
hits = Rdb::Fast.search(data, o[:m], keycols, key, first: o[:s] || o[:l], partial: o[:x], exact: o[:exact])
hits.each do |h|
puts h.row.line
warn "#{h.row.from} #{h.row.to} #{h.xfrom} #{h.xto}" if o[:l]
end
$stdout.flush
end
end
Rdb.register('seek', 'Return the beginning and ending offset of a row', 'seek [-m[bihrs]] [-o outfile] tableorlist [keycol...] < file') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: seek [-m] [-o outfile] table [keycolumn ...]'
data = Rdb::Fast::Data.new(table)
need_index(table, o[:m])
keycols, keys = key_columns(args, data)
each_key(keycols.size, keys) do |key|
Rdb::Fast.search(data, o[:m], keycols, key, first: true).each do |h|
puts "#{h.row.from} #{h.row.to} #{h.xfrom} #{h.xto}"
if o[:o]
File.write(o[:o], data.head.join("\t") + "\n" + Rdb::Table.dash_row(data.head) + "\n" + h.row.line + "\n")
end
end
end
end
Rdb.register('append', 'Add a row to a table and update index tables', 'append [-h -m[bhirs]] table [keycolumn ...] < tableorrow') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: append [-h] [-m] table [keycolumn] < tableorrow'
data = Rdb::Fast::Data.new(table)
need_index(table, o[:m])
keycol = args.first || data.head[0]
text = $stdin.read
rows =
if o[:h] then text.lines.map { |l| l.chomp("\n").split("\t", -1) }
else Rdb::Table.parse(text).rows
end
rows.each { |cells| Rdb::Fast.append_row(table, cells, o[:m], keycol) }
end
Rdb.register('delete', 'Blank record and update index file', 'delete [-m[bhirs]] table keycolumn ... < keytable') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: delete [-m] table keycolumn ... < keytable'
need_index(table, o[:m])
keycols = args.empty? ? [Rdb::Fast::Data.new(table).head[0]] : args
each_key(keycols.size) do |key|
data = Rdb::Fast::Data.new(table)
Rdb::Fast.search(data, o[:m], keycols, key).each { |h| Rdb::Fast.delete_hit(table, h, o[:m]) }
end
end
Rdb.register('replace', 'Insert a record into a file at specified location',
'replace [-a] [-m[bhirs]] tableorlist from to [xfrom xto] < intableorlist') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: replace [-a] [-m] table from to [xfrom xto] < row'
from, to, xfrom, xto = args.map(&:to_i)
die 'usage: replace [-a] [-m] table from to [xfrom xto] < row' unless to
text = $stdin.read
lines = text.lines.map { |l| l.chomp("\n") }
lines = lines.drop(2) if lines.size >= 2 && Rdb::Table.dash_line?(lines[1].split("\t", -1))
die 'replace: no input row' if lines.empty?
keycol = Rdb::Fast::Data.new(table).head[0]
newfrom = Rdb::Fast.replace_row(table, from, to, lines.first.split("\t", -1), o[:m], xfrom.to_i, xto.to_i, keycol,
allow_append: !o[:a])
die 'replace: record too big for its slot (-a)' unless newfrom
end
Rdb.register('enter', 'Add rows to a table or list file without an editor', 'enter [-limit] tableorlist') do |args|
limit = args.first&.match?(/\A-\d+\z/) ? args.shift[1..].to_i : nil
file = args.first or die 'usage: enter [-limit] tableorlist'
t = Rdb::Table.open(file)
count = 0
loop do
break if limit && count >= limit
vals = []
i = 0
eof = false
while i < t.head.size
$stdout.print "#{t.head[i]}: "
$stdout.flush
line = $stdin.gets
if line.nil?
eof = true
break
end
line = line.chomp
if line == '^'
i = [i - 1, 0].max
next
end
vals[i] = line
i += 1
end
break if eof || vals.first.to_s.empty?
if t.list
File.open(file, 'a') { |f| t.head.each_with_index { |h, j| f.puts "#{h}\t#{vals[j]}" }; f.puts }
else
Rdb::Fast.append_row(file, vals, 's', t.head[0])
end
count += 1
end
end
require 'digest'
require 'shellwords'
# ---- locks -------------------------------------------------------------------
# Lock files live in a private per-user directory, not loose in the shared
# /tmp, so other users can neither forge nor delete them.
def lock_dir
dir = File.join(Dir.tmpdir, "rdb-locks-#{Process.uid}")
Dir.mkdir(dir, 0o700) unless File.directory?(dir)
st = File.lstat(dir)
Rdb.die("#{dir}: not a private directory of yours") if st.symlink? || st.uid != Process.uid || (st.mode & 0o077) != 0
dir
end
def lock_file(name)
File.join(lock_dir, "L#{File.basename(name)}.#{Digest::MD5.hexdigest(File.expand_path(name))[0, 8]}")
end
# Read-modify-write of a lock file under an exclusive flock, so two lock or
# unlock calls cannot interleave. The block gets the held locks and returns
# the new list of lines, or nil to leave the file alone.
def with_lock_file(name)
File.open(lock_file(name), File::RDWR | File::CREAT, 0o600) do |f|
f.flock(File::LOCK_EX)
held = f.read.lines(chomp: true).map { |l| l.split.map(&:to_i) }
lines = yield held
next unless lines
f.rewind
f.truncate(0)
f.write(lines.map { |l| "#{l}\n" }.join)
end
end
Rdb.register('lock', 'Lock a record or field of a file', 'lock filename processid from to indexfrom indexto') do |args|
die 'usage: lock filename processid from to indexfrom indexto' if args.size < 4
name, pid, from, to = args
clash = nil
with_lock_file(name) do |held|
clash = held.find { |p, f, t| p != pid.to_i && f < to.to_i && from.to_i < t }
clash ? nil : held.map { |l| l.join(' ') } << args.drop(1).join(' ')
end
if clash
warn "lock: #{name}: bytes #{from}-#{to} are locked by process #{clash[0]}"
exit 1
end
end
Rdb.register('unlock', 'Unlock a record or field of a file', 'unlock tableorlist processid from to indexfrom indexto') do |args|
die 'usage: unlock filename processid from to indexfrom indexto' if args.size < 4
name = args.shift
next unless File.exist?(lock_file(name))
with_lock_file(name) do |held|
held.map { |l| l.join(' ') }.reject { |l| l.split.first(3) == args.first(3) }
end
lf = lock_file(name)
File.delete(lf) if File.zero?(lf)
end
Rdb.register('vilock', 'Lock a table before editing, unlock afterward', 'vilock tableorlist') do |args|
file = args.first or die 'usage: vilock tableorlist'
locked = File.join(File.dirname(file), "LOCK#{File.basename(file)}")
die "#{file} is locked or missing" unless File.exist?(file)
# link fails if LOCK<file> exists, so exactly one caller gets the table
# (File.exist? followed by rename would leave a race in between).
begin
File.link(file, locked)
rescue Errno::EEXIST
die "#{file} is locked or missing"
end
File.unlink(file)
begin
system(*Shellwords.split(ENV['EDITOR'] || 'vi'), locked)
ensure
File.rename(locked, file)
end
end
# ---- interactive editing --------------------------------------------------------
def show_record(data_head, cells)
data_head.each_with_index { |h, i| puts "#{i + 1} #{h}\t#{cells[i].to_s.strip}" }
end
def edit_cells(head, cells)
require 'tempfile'
Tempfile.create('rdb-edit') do |f|
f.write Rdb::Table.new(head, [cells], list: true).to_s
f.flush
system(*Shellwords.split(ENV['EDITOR'] || 'vi'), f.path)
t = Rdb::Table.parse(File.read(f.path))
return nil if t.rows.empty?
head.map { |h| (j = t.head.index(h)) ? t.rows[0][j] : '' }
end
end
Rdb.register('update.rdb', 'Display and edit records in any sized file',
'update.rdb [-l -m[bhirs] -v] tableorlist [keycolumn ... ]') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: update.rdb [-l -m -v] table [keycolumn]'
need_index(table, o[:m])
keycols = args.empty? ? [Rdb::Fast::Data.new(table).head[0]] : args
cur = nil
help = lambda do
puts ' <Return> or n next record p or - previous record',
' number go to that record /pattern next record matching pattern',
' e edit this record d delete this record',
' anything else look up by key q or CTRL-d quit'
end
loop do
data = Rdb::Fast::Data.new(table)
rows = Rdb::Fast.live_rows(data)
print '> '
$stdout.flush
line = $stdin.gets or break
cmd = line.strip
case cmd
when '', /\An/i then cur = cur ? cur + 1 : 0
when /\A-/, /\Ap/i then cur = [(cur || 1) - 1, 0].max
when /\Aq/i then break
when /\Ah/i then help.call; next
when /\A\d+\z/ then cur = cmd.to_i - 1
when %r{\A/(.*)}
re = Regexp.new(Regexp.last_match(1), Regexp::IGNORECASE)
found = rows.each_index.find { |i| i > (cur || -1) && rows[i].line.match?(re) }
found ? cur = found : (puts 'not found'; next)
when /\Ae/i
next puts('no current record') unless cur && rows[cur]
r = rows[cur]
cells = r.cells
Rdb::Fast.splice(table, r.from, r.to, Rdb::Fast.blanked(r.line) + "\n") unless o[:l]
new = edit_cells(data.head, cells.map(&:strip))
if new
hit = Rdb::Fast::Hit.new(r, 0, 0)
xf, xt = 0, 0
if Rdb::Fast::SUFFIX[o[:m]]
h = Rdb::Fast.search(Rdb::Fast::Data.new(table), o[:m], keycols, cells.values_at(*keycols.map { |c| data.column(c) }), first: true).first
xf, xt = h.xfrom, h.xto if h
end
Rdb::Fast.replace_row(table, hit.row.from, hit.row.to, new, o[:m], xf, xt, keycols[0])
puts 'record replaced'
else
Rdb::Fast.splice(table, r.from, r.to, r.line + "\n")
puts 'unchanged'
end
next
when /\Ad/i
next puts('no current record') unless cur && rows[cur]
h = Rdb::Fast::Hit.new(rows[cur], 0, 0)
if Rdb::Fast::SUFFIX[o[:m]]
h = Rdb::Fast.search(data, o[:m], keycols, rows[cur].cells.values_at(*keycols.map { |c| data.column(c) }), first: true).first || h
end
Rdb::Fast.delete_hit(table, h, o[:m])
puts 'record deleted'
next
else
key = cmd.split("\t", -1)
hit = Rdb::Fast.search(data, o[:m], keycols, key, first: true).first
next puts('not found') unless hit
cur = rows.index { |r| r.from == hit.row.from }
end
if cur && rows[cur]
puts "-- record #{cur + 1} of #{rows.size}"
show_record(data.head, rows[cur].cells)
else
puts 'no such record'
cur = nil
end
end
end
Rdb.register('ve', 'Visually edit a table', 've [table [-n[n] -fc -mc -i -s [file] -h [file] -a [file] -v [file]]] [-b]') do |args|
file = args.shift or die 'usage: ve table (a plain prompt-driven editor; see update.rdb for fast access)'
auto = args.find { |a| a.match?(/\A-n\d*\z/) }
unless File.exist?(file)
head = Rdb::Table.new(%w[Item], []).head
print 'Column names for the new table, tab separated: '
head = ($stdin.gets || '').chomp.split("\t")
File.write(file, Rdb::Table.new(head, []).to_s)
end
cur = 0
loop do
t = Rdb::Table.open(file)
rows = t.rows
puts "-- #{file}: record #{rows.empty? ? 0 : cur + 1} of #{rows.size}"
show_record(t.head, rows[cur]) if rows[cur]
print '[n]ext [p]rev [g]o N [e]dit [a]dd [d]elete [q]uit > '
$stdout.flush
line = $stdin.gets or break
case line.strip
when 'n', '' then cur = [cur + 1, rows.size - 1].min
when 'p' then cur = [cur - 1, 0].max
when /\Ag\s*(\d+)/ then cur = [[Regexp.last_match(1).to_i - 1, 0].max, rows.size - 1].min
when 'e'
next unless rows[cur]
puts 'field number, then the new value:'
print 'field> '
f = ($stdin.gets || '').to_i - 1
next puts('no such field') unless (0...t.head.size).cover?(f)
print "#{t.head[f]}> "
rows[cur][f] = ($stdin.gets || '').chomp
File.write(file, t.to_s)
when 'a'
row = t.head.each_with_index.map do |h, i|
if i.zero? && auto
start = auto[2..].to_i
next ([rows.map { |r| r[0].to_i }.max.to_i + 1, start].max).to_s
end
print "#{h}> "
($stdin.gets || '').chomp
end
rows << row
File.write(file, t.to_s)
cur = rows.size - 1
when 'd'
rows.delete_at(cur) if rows[cur]
File.write(file, t.to_s)
cur = [cur, rows.size - 1].min
cur = 0 if cur.negative?
when 'q' then break
end
end
end
# ---- checking data ------------------------------------------------------------
Rdb.register('domain', 'Display invalid values in a column', 'domain domaintable [string ...] [< one-column-table]') do |args|
dom = args.shift or die 'usage: domain domaintable [string ...]'
valid = open_table(dom).rows.map { |r| r[0].strip }.to_h { |v| [v, true] }
if args.any?
args.reject { |s| valid[s.strip] }.each { |s| puts s }
else
t = table_in
t.with(rows: t.rows.reject { |r| valid[r[0].strip] }).write
end
end
Rdb.register('validate', 'Find invalid data', "validate 'pattern { action } ...' < tableorlist") do |args|
die "usage: validate 'pattern { action } ...' < table" unless args.size == 1
t = table_in
puts t.head.join("\t"), Rdb::Table.dash_row(t.head)
prog = "BEGIN{OFS=\"\\t\"} #{to_awk(args[0], t.head)} {print}"
out, ok = awk_text(prog, t.rows)
$stdout.write out
exit 1 unless ok
end
Rdb.register('fd', 'Test for functional dependency of columns', 'fd determinecolumn dependcolumn < table') do |args|
die 'usage: fd determinecolumn dependcolumn < table' unless args.size == 2
t = table_in
a = t.index_of(args[0])
b = t.index_of(args[1])
seen = {}
ok = t.rows.all? { |r| (seen[r[a]] ||= r[b]) == r[b] }
puts ok ? 'true' : 'false'
exit(ok ? 0 : 1)
end
Rdb.register('searchtree', 'Seek a string node in tree table', 'searchtree [-m[bhirs]] table root goal column1 column2') do |args|
fast_opts(args)
die 'usage: searchtree table root goal column1 column2' unless args.size == 5
file, root, goal, c1, c2 = args
t = Rdb::Table.open(file)
i1 = t.index_of(c1)
i2 = t.index_of(c2)
seen = { root => true }
queue = [root]
found = root == goal
until found || queue.empty?
node = queue.shift
t.rows.each do |r|
next unless r[i1].strip == node
child = r[i2].strip
next if seen[child]
seen[child] = true
found ||= child == goal
queue << child
end
end
puts found ? 'TRUE' : 'FALSE'
exit(found ? 0 : 1)
end
Rdb.register('vindex', 'Create and display ve look-up tables', 'vindex table [ key-column [: decode-column ] ... ]') do |args|
file = args.shift or die 'usage: vindex table [key-column [: decode-column] ...]'
t = Rdb::Table.open(file)
if args.empty?
Dir["#{File.dirname(file)}/*-[AB]"].sort.each { |f| puts "== #{File.basename(f)}"; $stdout.write File.read(f) }
next
end
until args.empty?
key = args.shift
decode = nil
if args.first == ':'
args.shift
decode = args.shift
end
k = t.index_of(key)
prefix = key.tr(' ', '_')[0, 14]
vals = t.rows.map { |r| [r[k].strip, decode ? r[t.index_of(decode)].strip : nil] }.reject { |v, _| v.empty? }.uniq.sort
File.write(File.join(File.dirname(file), "#{prefix}-A"), Rdb::Table.new([key], vals.map { |v, _| [v] }.uniq).to_s)
File.write(File.join(File.dirname(file), "#{prefix}-B"), Rdb::Table.new([key, decode], vals.map { |v, d| [v, d] }).to_s) if decode
end
end
# ---- exercising the fast-access methods ---------------------------------------------
def sample_table(path, n = 2000)
rows = (1..n).map { |i| [i.to_s, "name#{i * 7919 % n}", "city#{i % 97}"] }
File.write(path, Rdb::Table.new(%w[Id Name City], rows).to_s)
end
def time_methods(n = 2000)
Dir.mktmpdir do |dir|
path = File.join(dir, 'unixtable')
sample_table(path, n)
%w[s b h i r].each do |m|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
Rdb::Fast.build(path, m, 'Name') unless m == 's'
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
data = Rdb::Fast::Data.new(path)
hits = 0
(1..200).each do |i|
key = m == 'r' ? i.to_s : "name#{i * 13 % n}"
hits += Rdb::Fast.search(data, m, ['Name'], key).size
end
t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield m, t1 - t0, t2 - t1, hits
end
end
end
Rdb.register('timesearch', 'Time the fast-access methods', 'timesearch [ > timesearch.new ]') do |_args|
puts 'method index(s) 200 searches(s) rows found'
time_methods { |m, ti, ts, hits| puts format('-m%-5s %8.4f %15.4f %10d', m, ti, ts, hits) }
end
Rdb.register('testsearch', 'Test the fast-access methods', 'testsearch') do |_args|
time_methods(500) { |m, _ti, _ts, hits| puts "-m#{m}: #{hits.positive? ? 'ok' : 'FAILED'} (#{hits} rows found)" }
end