Schnellzugriff per Seek, FNV-1a-Hash, Dezimalrechnung für Summen und /act
search, seek, delete und update.rdb lesen bei -mh, -mi, -mr nur Indexeintrag und Satz. Hashindizes (.h) müssen neu gebaut werden. total, mean, subtotal und die /act-Befehle rechnen mit BigDecimal, gerundet wird halb auf.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
# A Ruby re-creation of /rdb, the relational database tools Mark Williams
|
||||
# Company sold for COHERENT (~1991).
|
||||
|
||||
require 'bigdecimal'
|
||||
require 'open3'
|
||||
require 'date'
|
||||
require 'tmpdir'
|
||||
|
||||
+33
-22
@@ -8,12 +8,23 @@
|
||||
ACT_COMMANDS = %w[act adjust balance bom calcpay calculate cashflow chartdup close consolidate cstate explode
|
||||
fillform foot getjournal invoice sale tax update.inv w2].freeze
|
||||
|
||||
# Amounts are decimal numbers, not floats: sums of cents stay exact.
|
||||
def num(str)
|
||||
str.to_s.strip.empty? ? 0.0 : str.to_s.strip.to_f
|
||||
s = str.to_s.strip
|
||||
s.match?(Rdb::NUM) ? BigDecimal(s.sub(/\.\z/, '')) : BigDecimal(s.to_f.to_s)
|
||||
end
|
||||
|
||||
# Two decimals, always (money drops them from whole amounts).
|
||||
def fixed2(val)
|
||||
val = BigDecimal(val.to_s) unless val.is_a?(BigDecimal)
|
||||
val.round(2, :half_up).to_s('F').then { |t| t.sub(/\.(\d*)\z/) { ".#{Regexp.last_match(1).ljust(2, '0')}" } }
|
||||
end
|
||||
|
||||
def money(val)
|
||||
val == val.to_i ? val.to_i.to_s : format('%.2f', val)
|
||||
val = BigDecimal(val.to_s) unless val.is_a?(BigDecimal)
|
||||
return val.to_i.to_s if val == val.to_i
|
||||
|
||||
val.round(2, :half_up).to_s('F').sub(/\.(\d)\z/, '.\10')
|
||||
end
|
||||
|
||||
def need(file)
|
||||
@@ -73,7 +84,7 @@ Rdb.register('adjust', 'Create adjusted trial balance table', 'adjust') do |_arg
|
||||
m = (meta[h['Account']] ||= {})
|
||||
h.each { |k, v| m[k] = v if !v.to_s.strip.empty? && !%w[Debit Credit].include?(k) && !m.key?(k) }
|
||||
end
|
||||
net = Hash.new(0.0)
|
||||
net = Hash.new(BigDecimal(0))
|
||||
[tb, ja].each do |t|
|
||||
d = t.index_of('Debit')
|
||||
c = t.index_of('Credit')
|
||||
@@ -135,7 +146,7 @@ Rdb.register('getjournal', 'Copy journals for sales, purchasing, pay systems', '
|
||||
a = t.index_of('Amount')
|
||||
total = t.rows.sum { |r| num(r[a]) }
|
||||
Rdb::Table.new(%w[Date Amount Account Ref Description],
|
||||
[[next_date, format('%.2f', total), '', t.rows.first&.fetch(t.head.index('Ref') || 0, ''), "#{args[0].delete_prefix('journal')} from #{args[0]}"]]).write
|
||||
[[next_date, fixed2(total), '', t.rows.first&.fetch(t.head.index('Ref') || 0, ''), "#{args[0].delete_prefix('journal')} from #{args[0]}"]]).write
|
||||
end
|
||||
|
||||
Rdb.register('consolidate', 'Combine all subsidiary journals to general journal', 'consolidate nextdate') do |args|
|
||||
@@ -172,7 +183,7 @@ Rdb.register('fillform', 'Fill a tax from with adjusted trial balance data', 'fi
|
||||
lines = need("taxform.#{args[0]}")
|
||||
trial = need('adjustedtrial')
|
||||
ti = trial.index_of('Taxline')
|
||||
sums = Hash.new(0.0)
|
||||
sums = Hash.new(BigDecimal(0))
|
||||
trial.rows.each { |r| sums[r[ti]] += (num(r[trial.index_of('Debit')]) - num(r[trial.index_of('Credit')])).abs }
|
||||
rows = lines.rows.map { |r| [r[0], money(sums[r[0]]), r[1].to_s] }
|
||||
File.write(args[0], Rdb::Table.new(%w[Taxline Amount Description], rows).to_s)
|
||||
@@ -191,7 +202,7 @@ end
|
||||
|
||||
Rdb.register('tax', 'Compute tax from income and tax table', 'tax income < taxtable') do |args|
|
||||
die 'usage: tax income < taxtable' unless args.size == 1
|
||||
income = args[0].to_f
|
||||
income = num(args[0])
|
||||
rows = table_in.rows.map { |r| r.map { |c| num(c) } }.sort_by(&:first)
|
||||
row = rows.reverse.find { |r| r[0] <= income }
|
||||
puts format('%7d', row ? (row[1] + (income - row[0]) * row[2] / 100.0).round : 0)
|
||||
@@ -201,7 +212,7 @@ Rdb.register('cashflow', 'Compute balance column of cash table', 'cashflow < cas
|
||||
t = table_in
|
||||
a = t.index_of('Amount')
|
||||
b = t.index_of('Balance')
|
||||
bal = 0.0
|
||||
bal = BigDecimal(0)
|
||||
rows = t.rows.map do |r|
|
||||
r = r.dup
|
||||
if r[a].strip.empty? && !r[b].strip.empty?
|
||||
@@ -239,9 +250,9 @@ end
|
||||
Rdb.register('bom', 'Produce bill-of-materials from parts list', 'bom') do |_args|
|
||||
part = need('part')
|
||||
items = need('saleitem')
|
||||
qty = Hash.new(0.0)
|
||||
qty = Hash.new(BigDecimal(0))
|
||||
items.rows.each { |r| qty[r[items.index_of('Code')]] += num(r[items.index_of('Qty')]) }
|
||||
need_total = Hash.new(0.0)
|
||||
need_total = Hash.new(BigDecimal(0))
|
||||
part.rows.each do |r|
|
||||
code = r[part.index_of('Code')]
|
||||
need_total[r[part.index_of('Subpart')]] += qty[code] * num(r[part.index_of('Count')]) if qty.key?(code)
|
||||
@@ -252,8 +263,8 @@ end
|
||||
Rdb.register('explode', 'Produce table of subparts and their count for a part', 'explode Part Amount [ table ]') do |args|
|
||||
die 'usage: explode Part Amount [table]' if args.size < 2
|
||||
t = need(args[2] || 'parttable')
|
||||
totals = Hash.new(0.0)
|
||||
queue = [[args[0], args[1].to_f]]
|
||||
totals = Hash.new(BigDecimal(0))
|
||||
queue = [[args[0], num(args[1])]]
|
||||
until queue.empty?
|
||||
part, amount = queue.shift
|
||||
totals[part] += amount
|
||||
@@ -298,9 +309,9 @@ Rdb.register('invoice', 'Print invoice for a sale order', 'invoice') do |_args|
|
||||
puts address_block(customers, oh['Cust'])
|
||||
mine = items.rows.select { |r| r[0] == n }
|
||||
items.with(rows: mine).write
|
||||
puts format('%-40s Gross %.2f', '', num(oh['Gross']))
|
||||
puts format('%-40s Tax %.2f', '', num(oh['Tax']))
|
||||
puts format('%-40s Total %.2f', '', num(oh['Total']))
|
||||
puts format('%-40s Gross %s', '', fixed2(num(oh['Gross'])))
|
||||
puts format('%-40s Tax %s', '', fixed2(num(oh['Tax'])))
|
||||
puts format('%-40s Total %s', '', fixed2(num(oh['Total'])))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -314,15 +325,15 @@ Rdb.register('cstate', 'Produce customer statement', 'cstate') do |_args|
|
||||
|
||||
chosen = c == 'all' ? orders.rows.map { |r| r[1] }.uniq : [c]
|
||||
chosen.each do |cust|
|
||||
lines = orders.rows.select { |r| r[1] == cust }.map { |r| [cust, r[2], format('%.2f', -num(r[orders.index_of('Total')])), r[0]] } +
|
||||
deposits.rows.select { |r| r[0] == cust }.map { |r| [cust, r[1], format('%.2f', num(r[2])), r[3]] }
|
||||
lines = orders.rows.select { |r| r[1] == cust }.map { |r| [cust, r[2], fixed2(-num(r[orders.index_of('Total')])), r[0]] } +
|
||||
deposits.rows.select { |r| r[0] == cust }.map { |r| [cust, r[1], fixed2(num(r[2])), r[3]] }
|
||||
balance = lines.sum { |l| num(l[2]) }
|
||||
puts ' Customer Statement', ' ------------------'
|
||||
puts address_block(customers, cust)
|
||||
puts "Now Due and Payable is your balance of: #{format('%.2f', -balance)}"
|
||||
puts "Now Due and Payable is your balance of: #{fixed2(-balance)}"
|
||||
puts 'Details of Your Orders and Payments'
|
||||
Rdb::Table.new(%w[Cust Date Amount Ref], lines.sort_by { |l| l[1] }).write
|
||||
puts format('%-20s %s', '', format('%.2f', balance))
|
||||
puts format('%-20s %s', '', fixed2(balance))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -348,7 +359,7 @@ Rdb.register('sale', 'Enter sale order, and item, update customer', 'sale') do |
|
||||
end
|
||||
puts address_block(customers, cust)
|
||||
n = Rdb::Table.open('salesorder').rows.map { |r| r[0].to_i }.max.to_i + 1
|
||||
gross = 0.0
|
||||
gross = BigDecimal(0)
|
||||
items = []
|
||||
loop do
|
||||
code = ask('Code (Return when done): ')
|
||||
@@ -359,13 +370,13 @@ Rdb.register('sale', 'Enter sale order, and item, update customer', 'sale') do |
|
||||
|
||||
q = ask('Qty: ').to_i
|
||||
price = num(p[products.index_of('Price')])
|
||||
items << [n.to_s, (items.size + 1).to_s, code, '10', q.to_s, money(price), format('%.2f', price * q), p[products.index_of('Name')]]
|
||||
items << [n.to_s, (items.size + 1).to_s, code, '10', q.to_s, money(price), fixed2(price * q), p[products.index_of('Name')]]
|
||||
gross += price * q
|
||||
end
|
||||
next if items.empty?
|
||||
|
||||
tax = gross * 0.05
|
||||
order = [n.to_s, cust, next_date, '0', format('%.2f', gross), format('%.2f', tax), format('%.2f', gross + tax)]
|
||||
tax = gross * BigDecimal('0.05')
|
||||
order = [n.to_s, cust, next_date, '0', fixed2(gross), fixed2(tax), fixed2(gross + tax)]
|
||||
puts Rdb::Table.new(%w[Number Cust Date Shipped Gross Tax Total], [order]).to_s
|
||||
if ask('Is this correct? (y, n) ').downcase.start_with?('y')
|
||||
File.write('salesorder', Rdb::Table.open('salesorder').tap { |t| t.rows << order }.to_s)
|
||||
|
||||
@@ -209,7 +209,7 @@ STATS.each do |cmd, desc|
|
||||
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
|
||||
nums = filled.all? { |v| numeric?(v) } ? vals.map { |v| v.strip.empty? ? BigDecimal(0) : dec_num(v) } : nil
|
||||
dec = filled.map { |v| decimals(v) }.max.to_i
|
||||
result[i] =
|
||||
case name
|
||||
@@ -256,7 +256,7 @@ Rdb.register('subtotal', 'Output subtotals of columns in a table',
|
||||
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)
|
||||
sub[i] = fmt(vals.sum { |v| dec_num(v) }, vals.map { |v| decimals(v) }.max)
|
||||
end
|
||||
if list
|
||||
out.concat(group)
|
||||
|
||||
+7
-1
@@ -30,8 +30,14 @@ module Rdb
|
||||
str.strip[/\.(\d+)\z/, 1].to_s.size
|
||||
end
|
||||
|
||||
# Decimal arithmetic, so that 0.1 + 0.2 is 0.3 and money adds up to the cent.
|
||||
def dec_num(str)
|
||||
BigDecimal(str.strip.sub(/\.\z/, ''))
|
||||
end
|
||||
|
||||
def fmt(num, dec)
|
||||
dec.zero? ? num.round.to_s : format("%.#{dec}f", num)
|
||||
num = BigDecimal(num.to_s) unless num.is_a?(BigDecimal)
|
||||
dec.zero? ? num.round(0, :half_up).to_i.to_s : num.round(dec, :half_up).to_s('F').then { |t| t.sub(/\.(\d*)\z/) { ".#{Regexp.last_match(1).ljust(dec, '0')}" } }
|
||||
end
|
||||
|
||||
def blank_row?(row)
|
||||
|
||||
+108
-38
@@ -27,29 +27,39 @@ module Rdb
|
||||
|
||||
Hit = Struct.new(:row, :xfrom, :xto)
|
||||
|
||||
# A table file read as bytes with the offset of every row.
|
||||
# A table file. The head is read at once; the rows only when a method
|
||||
# needs all of them (sequential search, -mb, update.rdb). A lookup through
|
||||
# an index reads just its row, by byte offset.
|
||||
class Data
|
||||
attr_reader :path, :head, :rows
|
||||
attr_reader :path, :head
|
||||
|
||||
def initialize(path)
|
||||
@path = path
|
||||
File.exist?(path) or Rdb.die("#{path}: no such table")
|
||||
bytes = File.binread(path)
|
||||
lines = []
|
||||
off = 0
|
||||
bytes.each_line("\n") do |l|
|
||||
lines << Row.new(off, off + l.bytesize, l.chomp("\n"))
|
||||
off += l.bytesize
|
||||
File.open(path, 'rb') do |f|
|
||||
first = f.gets.to_s
|
||||
Rdb.die("#{path}: is a list; fast access needs a table") if first == "\n"
|
||||
@head = first.chomp("\n").split("\t", -1).map { |h| h.dup.force_encoding(Encoding::UTF_8).strip }
|
||||
@body = f.pos
|
||||
second = f.gets
|
||||
@body = f.pos if second && Table.dash_line?(second.chomp("\n").split("\t", -1))
|
||||
end
|
||||
end
|
||||
|
||||
def rows
|
||||
@rows ||= begin
|
||||
bytes = File.binread(path).byteslice(@body..).to_s
|
||||
off = @body
|
||||
bytes.each_line("\n").map do |l|
|
||||
row = Row.new(off, off + l.bytesize, l.chomp("\n"))
|
||||
off += l.bytesize
|
||||
row
|
||||
end
|
||||
end
|
||||
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 { |h| h.dup.force_encoding(Encoding::UTF_8).strip }
|
||||
lines.shift if lines.first && Table.dash_line?(lines.first.cells)
|
||||
@rows = lines
|
||||
@by_from = lines.to_h { |r| [r.from, r] }
|
||||
end
|
||||
|
||||
def live
|
||||
@live ||= @rows.reject(&:blank?)
|
||||
@live ||= rows.reject(&:blank?)
|
||||
end
|
||||
|
||||
def column(name)
|
||||
@@ -57,7 +67,14 @@ module Rdb
|
||||
end
|
||||
|
||||
def row_at(offset)
|
||||
@by_from[offset]
|
||||
return @by_from[offset] if @rows && (@by_from ||= @rows.to_h { |r| [r.from, r] })
|
||||
return nil if offset < @body
|
||||
|
||||
File.open(path, 'rb') do |f|
|
||||
f.seek(offset)
|
||||
line = f.gets or return nil
|
||||
Row.new(offset, offset + line.bytesize, line.chomp("\n"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -80,8 +97,10 @@ module Rdb
|
||||
format("%8d\n", num)
|
||||
end
|
||||
|
||||
# FNV-1a over the key without blanks. (A plain sum of the bytes put
|
||||
# 3000 five-digit numbers into 30 slots.)
|
||||
def hash_slot(slots, key)
|
||||
key.delete(" \t").bytes.sum % slots
|
||||
key.b.delete(" \t").each_byte.reduce(0xcbf29ce484222325) { |h, b| ((h ^ b) * 0x100000001b3) & 0xffffffffffffffff } % slots
|
||||
end
|
||||
|
||||
def live_rows(data)
|
||||
@@ -122,6 +141,56 @@ module Rdb
|
||||
|
||||
# ---- searching ----------------------------------------------------------
|
||||
|
||||
# One entry of a fixed-width index (.r, .h) without reading the rest.
|
||||
def fixed_count(path)
|
||||
[(File.size(path) - FIXED_HEAD.bytesize) / ENTRY_WIDTH, 0].max
|
||||
end
|
||||
|
||||
def fixed_entry(io, i)
|
||||
io.seek(FIXED_HEAD.bytesize + ENTRY_WIDTH * i)
|
||||
io.read(ENTRY_WIDTH).to_i
|
||||
end
|
||||
|
||||
# Entries of an inverted index (.i) from the first key that is not below
|
||||
# target, found by bisecting on byte positions. Yields hashes like
|
||||
# read_inverted does.
|
||||
def each_inverted_from(path, below)
|
||||
File.open(path, 'rb') do |f|
|
||||
f.gets
|
||||
f.gets
|
||||
lo = f.pos
|
||||
hi = f.size
|
||||
while lo < hi
|
||||
mid = (lo + hi) / 2
|
||||
if mid == lo
|
||||
at = lo
|
||||
else
|
||||
f.seek(mid - 1)
|
||||
f.gets
|
||||
at = f.pos
|
||||
end
|
||||
if at >= hi
|
||||
hi = mid
|
||||
next
|
||||
end
|
||||
f.seek(at)
|
||||
line = f.gets
|
||||
if below.call(line.split("\t", 2)[0])
|
||||
lo = at + line.bytesize
|
||||
else
|
||||
hi = at
|
||||
end
|
||||
end
|
||||
f.seek(lo)
|
||||
loop do
|
||||
from = f.pos
|
||||
line = f.gets or break
|
||||
key, off = line.chomp("\n").split("\t", 2)
|
||||
yield({ from: from, to: from + line.bytesize, key: key, offset: off.to_i })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def read_fixed(path)
|
||||
File.binread(path).byteslice(FIXED_HEAD.bytesize..).to_s.lines.map { |l| l.to_i }
|
||||
end
|
||||
@@ -166,36 +235,37 @@ module Rdb
|
||||
end
|
||||
end
|
||||
when 'r'
|
||||
ents = read_fixed(path + '.r')
|
||||
ip = path + '.r'
|
||||
n = keyvals[0].to_i
|
||||
if n.positive? && n <= ents.size
|
||||
r = data.row_at(ents[n - 1]) and hits << Hit.new(r, FIXED_HEAD.bytesize + ENTRY_WIDTH * (n - 1), FIXED_HEAD.bytesize + ENTRY_WIDTH * n)
|
||||
if n.positive? && n <= fixed_count(ip)
|
||||
off = File.open(ip, 'rb') { |f| fixed_entry(f, n - 1) }
|
||||
r = data.row_at(off) and hits << Hit.new(r, FIXED_HEAD.bytesize + ENTRY_WIDTH * (n - 1), FIXED_HEAD.bytesize + ENTRY_WIDTH * n)
|
||||
end
|
||||
when 'i'
|
||||
ents = read_inverted(path + '.i')
|
||||
lo = ents.bsearch_index { |e| (partial ? norm(e[:key], true)[0, keyvals[0].strip.size] : e[:key].strip) >= (partial ? norm(keyvals[0], true) : keyvals[0].strip) }
|
||||
if lo
|
||||
ents[lo..].each do |e|
|
||||
break unless matches?(e[:key], keyvals[0], partial, exact)
|
||||
target = partial ? norm(keyvals[0], true) : keyvals[0].strip
|
||||
below = ->(key) { (partial ? norm(key, true)[0, keyvals[0].strip.size] : key.strip) < target }
|
||||
each_inverted_from(path + '.i', below) do |e|
|
||||
break unless matches?(e[:key], keyvals[0], partial, exact)
|
||||
|
||||
r = data.row_at(e[:offset])
|
||||
hits << Hit.new(r, e[:from], e[:to]) if r
|
||||
break if first
|
||||
end
|
||||
r = data.row_at(e[:offset])
|
||||
hits << Hit.new(r, e[:from], e[:to]) if r
|
||||
break if first
|
||||
end
|
||||
when 'h'
|
||||
ents = read_fixed(path + '.h')
|
||||
slots = ents.size
|
||||
i = hash_slot(slots, keyvals[0])
|
||||
slots.times do
|
||||
o = ents[i]
|
||||
break if o.zero?
|
||||
ip = path + '.h'
|
||||
slots = fixed_count(ip)
|
||||
File.open(ip, 'rb') do |f|
|
||||
i = hash_slot(slots, keyvals[0])
|
||||
slots.times do
|
||||
o = fixed_entry(f, i)
|
||||
break if o.zero?
|
||||
|
||||
if o.positive? && (r = data.row_at(o)) && matches?(r.cells[idxs[0]], keyvals[0], false, exact)
|
||||
hits << Hit.new(r, FIXED_HEAD.bytesize + ENTRY_WIDTH * i, FIXED_HEAD.bytesize + ENTRY_WIDTH * (i + 1))
|
||||
break if first
|
||||
if o.positive? && (r = data.row_at(o)) && matches?(r.cells[idxs[0]], keyvals[0], false, exact)
|
||||
hits << Hit.new(r, FIXED_HEAD.bytesize + ENTRY_WIDTH * i, FIXED_HEAD.bytesize + ENTRY_WIDTH * (i + 1))
|
||||
break if first
|
||||
end
|
||||
i = (i + 1) % slots
|
||||
end
|
||||
i = (i + 1) % slots
|
||||
end
|
||||
else
|
||||
live_rows(data).each do |r|
|
||||
|
||||
Reference in New Issue
Block a user