Files
rdb/lib/rdb/cmd/relational.rb
T

343 lines
12 KiB
Ruby

# frozen_string_literal: true
# Selecting, combining and summarising tables.
Rdb.register('row', 'Make a new table where rows match logical condition',
"row 'column condition column-or-value ..' < table") do |args|
die "usage: row 'condition' < table" unless args.size == 1
t = table_in
prog = "BEGIN{OFS=\"\\t\"} #{to_awk(args[0], t.head)} {print}"
t.with(rows: awk(prog, t.rows)).write
end
Rdb.alias_for('select', 'row')
Rdb.register('column', 'Display columns of a table in any order', 'column [ Column ... ] < tableorlist') do |args|
t = table_in
next t.write if args.empty?
idx = args.map { |a| Rdb.column_index(t.head, a) }
head = args.each_with_index.map { |a, n| idx[n] ? t.head[idx[n]] : a }
t.with(head: head, rows: t.rows.map { |r| idx.map { |i| i ? r[i] : '' } }).write
end
Rdb.alias_for('project', 'column')
Rdb.register('sorttable', 'Sort a table by one or more columns',
'sorttable [-nrf] [columnname ...] < tableorlist') do |args|
t = table_in
numeric = reverse = fold = false
cols = []
args.each do |a|
if a.match?(/\A-[nrf]+\z/)
numeric ||= a.include?('n')
reverse ||= a.include?('r')
fold ||= a.include?('f')
else
cols << t.index_of(a)
end
end
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]) } }
sorted = t.rows.each_with_index.sort_by { |r, n| [key.(r), n] }.map(&:first)
sorted.reverse! if reverse
t.with(rows: sorted).write
end
Rdb.register('compute', 'Calculate columns of a table', "compute 'column = expression [ ; ... ]' < tableorlist") do |args|
die "usage: compute 'column = expression [; ...]' < table" unless args.size == 1
t = table_in
prog = "BEGIN{OFS=\"\\t\"} { #{to_awk(args[0], t.head)}; print }"
t.with(rows: awk(prog, t.rows)).write
end
Rdb.register('addcol', 'Add a column to a table', 'addcol newcolumn < table') do |args|
die 'usage: addcol newcolumn ... < table' if args.empty?
t = table_in
t.with(head: t.head + args, rows: t.rows.map { |r| r + [''] * args.size }).write
end
Rdb.register('rename', 'Rename a column', 'rename [ oldcolumn newcolumn ... ] < tableorlist') do |args|
die 'usage: rename oldcolumn newcolumn ... < table' if args.empty? || args.size.odd?
t = table_in
head = t.head.dup
args.each_slice(2) { |old, new| head[t.index_of(old)] = new }
t.with(head: head).write
end
{ 'number' => 'number', 'Number' => 'Number' }.each do |cmd, title|
Rdb.register(cmd, 'Insert a column-row number into a table or list', "#{cmd} < tableorlist") do |_args|
t = table_in
t.with(head: [title] + t.head, rows: t.rows.each_with_index.map { |r, i| [(i + 1).to_s] + r }).write
end
end
Rdb.register('record', 'Find and output a record from a table', 'record number < table') do |args|
die 'usage: record number < table' unless args.size == 1
t = table_in
row = t.rows[args[0].to_i - 1]
t.with(rows: row ? [row] : []).write
end
# ---- combining tables ------------------------------------------------------
Rdb.register('jointable', 'Join two tables into one where keys match',
'jointable [-a[1|2]] [-j[1|2] column]... [-n] [-] table1 [table2]') do |args|
all1 = all2 = false
k1 = k2 = nil
files = []
until args.empty?
a = args.shift
case a
when '-a' then all1 = all2 = true
when '-a1' then all1 = true
when '-a2' then all2 = true
when '-j' then k1 = k2 = args.shift
when '-j1' then k1 = args.shift
when '-j2' then k2 = args.shift
when '-n' then nil
else files << a
end
end
die 'usage: jointable [-a[1|2]] [-j[1|2] column] table1 [table2]' if files.empty? || files.size > 2
files << '-' if files.size == 1
t1, t2 = files.map { |f| open_table(f) }
i1 = k1 ? t1.index_of(k1) : 0
i2 = k2 ? t2.index_of(k2) : 0
keep2 = (0...t2.head.size).to_a - [i2]
by_key = Hash.new { |h, k| h[k] = [] }
t2.rows.each { |r| by_key[r[i2]] << r }
used = {}
out = []
t1.rows.each do |r|
if (m = by_key[r[i1]]).any?
used[r[i1]] = true
m.each { |r2| out << r + keep2.map { |i| r2[i] } }
elsif all1
out << r + [''] * keep2.size
end
end
if all2
t2.rows.each do |r2|
next if used[r2[i2]]
blank = [''] * t1.head.size
blank[i1] = r2[i2]
out << blank + keep2.map { |i| r2[i] }
end
end
Rdb::Table.new(t1.head + keep2.map { |i| t2.head[i] }, out).write
end
Rdb.register('union', 'Concatenate tables', 'union tableorlist ... [-] [< tableorlist]') do |args|
args = ['-'] if args.empty?
tabs = args.map { |a| open_table(a) }
tabs.first.with(rows: tabs.flat_map(&:rows)).write
end
Rdb.register('intersect', 'Write table of rows that are in both input tables', 'intersect table1 table2') do |args|
die 'usage: intersect table1 table2' unless args.size == 2
a, b = args.map { |x| open_table(x) }
seen = b.rows.to_h { |r| [r, true] }
a.with(rows: a.rows.select { |r| seen[r] }.uniq).write
end
Rdb.register('difference', 'Output table of rows that are in only one table', 'difference table1 table2') do |args|
die 'usage: difference table1 table2' unless args.size == 2
a, b = args.map { |x| open_table(x) }
sb = b.rows.to_h { |r| [r, true] }
a.with(rows: a.rows.reject { |r| sb[r] }.uniq).write
end
Rdb.register('paste.rdb', 'Paste together two or more tables', 'paste.rdb table1 table2 ...') do |args|
die 'usage: paste.rdb table1 table2 ...' if args.size < 2
tabs = args.map { |a| open_table(a) }
n = tabs.map { |t| t.rows.size }.max
head = tabs.flat_map(&:head)
rows = (0...n).map { |i| tabs.flat_map { |t| t.rows[i] || [''] * t.head.size } }
Rdb::Table.new(head, rows).write
end
Rdb.register('splittable', 'Divide a table horizontally', 'splittable [-n] table') do |args|
n = 50
n = args.shift[1..].to_i if args.first&.match?(/\A-\d+\z/)
die 'usage: splittable [-n] table' unless args.size == 1
t = open_table(args[0])
suffix = ('aa'..'zz').each
t.rows.each_slice(n) do |chunk|
File.write("#{args[0]}#{suffix.next}", t.with(rows: chunk).to_s)
end
end
# ---- aggregates ------------------------------------------------------------
# total, mean, maximum, minimum, width, precision and datatype are one program
# that decides what to compute from the name it was called by.
STATS = {
'total' => 'Sum a column',
'mean' => 'Display the mean of a column',
'maximum' => 'Display the maximum value in a column',
'minimum' => 'Display the minimum of each column selected',
'width' => 'Display the width of each column',
'precision' => 'Display the precision of a column',
'datatype' => 'Display the data type of each column selected'
}.freeze
STATS.each do |cmd, desc|
Rdb.register(cmd, desc, "#{cmd} [ -l ] [ column ... ] < table") do |args|
list = args.first == '-l'
args.shift if list
t = table_in
sel = args.empty? ? (0...t.head.size).to_a : args.map { |a| t.index_of(a) }
result = Array.new(t.head.size, '')
sel.each do |i|
vals = t.rows.map { |r| r[i] }
filled = vals.reject { |v| v.strip.empty? }
nums = filled.all? { |v| numeric?(v) } ? vals.map { |v| v.strip.to_f } : nil
dec = filled.map { |v| decimals(v) }.max.to_i
result[i] =
case name
when 'width' then (vals.map(&:size) + [t.head[i].size]).max.to_s
when 'precision' then nums ? dec.to_s : ''
when 'datatype' then filled.empty? || !nums ? '-1' : dec.to_s
when 'total' then nums ? fmt(nums.sum, dec) : ''
when 'mean' then nums && !nums.empty? ? fmt(nums.sum / nums.size, [dec, 2].max) : ''
when 'maximum' then nums && !nums.empty? ? fmt(nums.max, dec) : ''
when 'minimum' then nums && !nums.empty? ? fmt(nums.min, dec) : ''
end
end
if list
$stdout.write t.to_s
puts Rdb::Table.dash_row(t.head)
puts result.join("\t")
else
Rdb::Table.new(t.head, [result]).write
end
end
end
Rdb.register('widest', 'Output the width of the widest entries in a table', 'widest < table') do |_args|
t = table_in
res = t.head.each_index.map { |i| (t.rows.map { |r| r[i].size } + [t.head[i].size]).max.to_s }
Rdb::Table.new(t.head, [res]).write
end
Rdb.register('subtotal', 'Output subtotals of columns in a table',
'subtotal [ -l ] [ break-column column ... ] < table') do |args|
list = args.first == '-l'
args.shift if list
t = table_in
brk = args.empty? ? 0 : t.index_of(args.shift)
sel = args.empty? ? (0...t.head.size).to_a - [brk] : args.map { |a| t.index_of(a) }
out = []
group = []
flush = lambda do
next if group.empty?
sub = Array.new(t.head.size, '')
sub[brk] = group.first[brk]
sel.each do |i|
vals = group.map { |r| r[i] }.reject { |v| v.strip.empty? }
next if vals.empty? || !vals.all? { |v| numeric?(v) }
sub[i] = fmt(vals.sum { |v| v.strip.to_f }, vals.map { |v| decimals(v) }.max)
end
if list
out.concat(group)
out << t.head.map { |h| '-' * [h.size, 1].max }
end
out << sub
group = []
end
t.rows.each do |r|
flush.() if group.any? && group.first[brk] != r[brk]
group << r
end
flush.()
t.with(rows: out).write
end
# ---- describing tables ------------------------------------------------------
Rdb.register('schema', "Print a table's schema", 'schema tableorlist ...') do |args|
die 'usage: schema tableorlist ...' if args.empty?
rows = args.flat_map do |f|
open_table(f).head.each_with_index.map { |h, i| [File.basename(f), (i + 1).to_s, h] }
end
Rdb::Table.new(%w[Table Field Name], rows).write
end
Rdb.register('dbdict', 'Print a data-base dictionary', 'dbdict < table') do |_args|
t = table_in
Rdb::Table.new(%w[field name], t.head.each_with_index.map { |h, i| [(i + 1).to_s, h] }).write
end
Rdb.register('tableorlist', 'Report whether a file has table or list format',
'tableorlist [tableorlist ...] [< tableorlist]') do |args|
texts = args.empty? ? [$stdin.read] : args.map { |a| File.read(a) }
status = 0
texts.each do |tx|
if tx.start_with?("\n")
puts 'list'
status = 1
else
puts 'table'
end
end
exit status
end
Rdb.register('check.rdb', 'Report any rows in which columns do not match head line', 'check.rdb < tableorlist') do |_args|
lines = $stdin.read.lines.map { |l| l.chomp("\n") }
expected = lines.first.to_s.count("\t") + 1
bad = 0
lines.each_with_index do |l, n|
next if n.zero? || l.count("\t") + 1 == expected
bad += 1
warn "check.rdb: Columns (#{l.count("\t") + 1}) do not equal headline columns (#{expected}) in row #{n - 1} (line #{n + 1})."
warn l
end
exit(bad.zero? ? 0 : 1)
end
# ---- head and dash lines -------------------------------------------------
Rdb.register('headon', 'Add an /rdb header to a table', 'headon [column ...] < table') do |args|
rows = $stdin.each_line.map { |l| l.chomp("\n").split("\t", -1) }
n = rows.map(&:size).max || 0
head = args.dup
head << (head.size + 1).to_s while head.size < n
Rdb::Table.new(head, rows.map { |r| r.fill('', r.size...head.size) }).write
end
Rdb.register('headoff', 'Remove an /rdb head from both table and list file', 'headoff < tableorlist') do |_args|
text = $stdin.read
lines = text.lines
$stdout.write lines.drop(text.start_with?("\n") ? 1 : 2).join
end
Rdb.register('insertdash', 'Insert dash line as second line in table', 'insertdash < table-without-dashline') do |_args|
lines = $stdin.read.lines
head = lines.shift.to_s.chomp("\n")
puts head
puts head.gsub(/[^\t]/, '-')
$stdout.write lines.join
end
Rdb.register('display', 'Write table or list file to standard output', 'display < tableorlist') do |_args|
$stdout.write $stdin.read
end
Rdb.register('see', 'Display nonprinting as well as printing characters', 'see < tableorlist') do |_args|
$stdin.read.each_char do |c|
o = c.ord
$stdout.write(
if c == "\n" then "$\n"
elsif o < 32 then "^#{(o + 64).chr}"
elsif o == 127 then '^?'
else c
end
)
end
end