bin/rdb läuft mit --disable-gems (ca. 85 statt 170 ms je Befehl), dafür rechnen Summen und /act mit Rational. Hilfsfunktionen und Konstanten liegen nicht mehr auf Object, Kopf-/Strichzeile und awk-Aufruf an einer Stelle. Tests laufen parallel: 21 statt 70 Sekunden.
107 lines
3.4 KiB
Ruby
107 lines
3.4 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# A Ruby re-creation of /rdb, the relational database tools Mark Williams
|
|
# Company sold for COHERENT (~1991).
|
|
|
|
require 'open3'
|
|
require 'date'
|
|
require 'tmpdir'
|
|
require 'fileutils'
|
|
require_relative 'rdb/version'
|
|
|
|
module Rdb
|
|
ROOT = File.expand_path('..', __dir__)
|
|
NUM = /\A\s*[+-]?(\d+\.?\d*|\.\d+)\s*\z/
|
|
|
|
Command = Struct.new(:name, :desc, :syntax, :block)
|
|
COMMANDS = {}
|
|
|
|
class << self
|
|
# RDB_ICASE=1 makes column names match without regard to case. An exact
|
|
# match always wins; several case-insensitive matches are an error.
|
|
def icase?
|
|
!%w[0 no false].include?(ENV['RDB_ICASE'].to_s.downcase) && !ENV['RDB_ICASE'].to_s.empty?
|
|
end
|
|
|
|
def column_index(head, name)
|
|
exact = head.index(name)
|
|
return exact if exact || !icase?
|
|
|
|
hits = head.each_index.select { |i| head[i].casecmp?(name) }
|
|
die "column name '#{name}' is ambiguous: #{hits.map { |i| head[i] }.join(', ')}" if hits.size > 1
|
|
hits.first
|
|
end
|
|
|
|
# Exact decimal arithmetic with Rational (core Ruby, so bin/rdb can start
|
|
# without RubyGems): 0.1 + 0.2 is 0.3 and money adds up to the cent.
|
|
def decimal(str)
|
|
s = str.to_s.strip
|
|
return 0r if s.empty?
|
|
|
|
Rational(s.match?(NUM) ? s.sub(/\.\z/, '') : s.to_f.to_s)
|
|
end
|
|
|
|
# num with dec places, rounded half up ("%.2f" rounds the binary value).
|
|
def fixed(num, dec)
|
|
r = num.is_a?(Float) ? Rational(num.to_s) : num.to_r
|
|
r = r.round(dec, half: :up)
|
|
return r.to_i.to_s if dec.zero?
|
|
|
|
digits = (r.abs * 10**dec).to_i.to_s.rjust(dec + 1, '0')
|
|
"#{'-' if r.negative?}#{digits[0...-dec]}.#{digits[-dec..]}"
|
|
end
|
|
|
|
def die(msg)
|
|
abort "#{File.basename($PROGRAM_NAME)}: #{msg}"
|
|
end
|
|
|
|
# register 'name', 'description', 'syntax' do |args| ... end
|
|
def register(name, desc, syntax = nil, &block)
|
|
COMMANDS[name] = Command.new(name, desc, syntax || name, block)
|
|
end
|
|
|
|
def alias_for(name, target)
|
|
cmd = COMMANDS.fetch(target)
|
|
COMMANDS[name] = Command.new(name, cmd.desc, cmd.syntax.sub(target, name), cmd.block)
|
|
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)
|
|
cmd = COMMANDS[name] or die("unknown command '#{name}'")
|
|
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
|
|
|
|
require_relative 'rdb/table'
|
|
require_relative 'rdb/env'
|
|
Dir[File.join(__dir__, 'rdb/cmd/*.rb')].sort.each { |f| require f }
|