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:
wkh
2026-10-30 09:54:05 +01:00
parent 75c2424307
commit 1c489f6994
10 changed files with 204 additions and 72 deletions
+1
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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|