Files
five/hbrdd/dbf/header.go
CharlesKWON f4ed42556b checkpoint: season-wide bug fix campaign + infra
Cumulative season's silent-bug hunting (~62 fixes) across the FiveSql2
SQL engine, the Five compiler/runtime, and the hbrdd RDD layer. Saved
as a single checkpoint before refactoring the parser to delegate xBase
command translation to the preprocessor.

Highlights:

FiveSql2 engine (_FiveSql2/src/)
- prefix-glob index attach -> explicit convention (<table>_pk.ntx,
  <table>_uq.ntx, <table>.cdx) — fixes silent multi-row INSERT row-drop
- DROP/CREATE TABLE FErase chain extended (.cdx, .fsc, .fsv, .dbt, .fpt)
- COUNT(DISTINCT col) parsed + aggregated via hSeen hash
- UNION column-count mismatch returns SQL_ERR_GRAMMAR (was silent)
- DISTINCT + ORDER BY hidden-col leak fixed (trim before DISTINCT)
- Derived table FROM (SELECT...) + JOIN right-side derived
- Self-FK CASCADE depth 2+ via SqlGetSingleColPK pre-collect
- LAG/LEAD default arg uses SqlEvalRowExpr (handles -N const exprs)
- DATE literal round-trip validation (Feb 29 non-leap rejected)
- CREATE OR REPLACE VIEW; CREATE VIEW errors on already-exists
- AlterTable type dispatcher comma-wrapped (1-char type "A" no longer
  matches CHARACTER)

Compiler / runtime
- gengo: HB_ -> FV_ prefix on emitted Go function names (Five identity)
- gengo split: emit_block.go, emit_stmt.go, folding.go extracted
- parser/stmtreg.go nudges
- hbrt: debug TUI/CLI restructure (debugcmd, debugkey, termios_*),
  windows debug stubs collapsed
- thread/vm/value/class/pcinterp tightening from panic traces

RDD layer (hbrdd/)
- dbf: null bitmap support (null.go + null_test.go), mmap split
  (mmap_posix.go / mmap_windows.go), byte-level numeric parse
- ntx/cdx: windows mmap parity
- workarea + mem RDD: cross-area state-bleed fixes

RTL (hbrtl/)
- errorlog rewrite with platform-specific FD (errorlog_fd_unix /
  errorlog_fd_other)
- sqlscan, sqlhelpers, indexrtl, datetime extensions

Gates green at checkpoint:
- go test ./...        : PASS
- FiveSql2 SQL:1999    : 43/43
- Harbour compat       : 56/56

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:26:25 +09:00

197 lines
5.7 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
// DBF file header and field descriptor structures.
// EXACT byte-compatible with Harbour's DBFHEADER and DBFFIELD.
//
// Reference: /mnt/d/harbour-core/include/hbdbf.h
// docs/dbf-engine-spec.md
package dbf
import (
"encoding/binary"
"fmt"
"io"
"time"
)
// HeaderSize is the fixed size of the DBF file header.
const HeaderSize = 32
// FieldDescSize is the fixed size of each field descriptor.
const FieldDescSize = 32
// Header terminator byte.
const HeaderTerminator = 0x0D
// EOF marker byte.
const EOFMarker = 0x1A
// Deletion flag values.
const (
RecordActive = ' ' // 0x20
RecordDeleted = '*' // 0x2A
)
// Version byte values.
const (
VersionDBF3 = 0x03 // Standard dBASE III
VersionVFP = 0x30 // Visual FoxPro
VersionVFPAuto = 0x31 // VFP + autoincrement
VersionVFPVar = 0x32 // VFP + varchar/varbinary
VersionDBT = 0x83 // dBASE III + DBT memo
VersionFPT = 0xF5 // DBF + FPT memo
)
// Field flag bits (byte at field descriptor offset 18).
// Harbour: HB_FF_* in hbapirdd.h — matches our FieldInfo.Flags convention.
const (
FieldFlagSystem = 0x01 // system/hidden (not exposed as user-visible)
FieldFlagNullable = 0x02 // accepts SQL NULL, tracked via _NullFlags bit
FieldFlagBinary = 0x04 // binary payload (no codepage conversion)
FieldFlagAutoInc = 0x08 // auto-increment (VFP)
)
// NullFlagsFieldName is the hidden column Harbour/VFP uses to track
// SQL NULL state: 1 bit per nullable user column. Kept in fieldDescs
// but excluded from the public FieldCount/FieldInfo view so SQL
// `SELECT *` / DDL column enumeration never see it.
const NullFlagsFieldName = "_NullFlags"
// Header represents the 32-byte DBF file header.
// Layout is byte-identical to Harbour's DBFHEADER.
type Header struct {
Version byte // offset 0
Year byte // offset 1 (year - 1900)
Month byte // offset 2
Day byte // offset 3
RecCount uint32 // offset 4 (LE)
HeaderLen uint16 // offset 8 (LE)
RecordLen uint16 // offset 10 (LE)
Reserved1 [2]byte
Transaction byte // offset 14
Encrypted byte // offset 15
Reserved2 [12]byte
HasTags byte // offset 28
CodePage byte // offset 29
Reserved3 [2]byte
}
// ReadHeader reads the 32-byte header from a reader.
func ReadHeader(r io.Reader) (*Header, error) {
h := &Header{}
if err := binary.Read(r, binary.LittleEndian, h); err != nil {
return nil, fmt.Errorf("read DBF header: %w", err)
}
return h, nil
}
// WriteHeader writes the 32-byte header to a writer.
func WriteHeader(w io.Writer, h *Header) error {
return binary.Write(w, binary.LittleEndian, h)
}
// UpdateDate sets the header's last-update date to now.
func (h *Header) UpdateDate() {
now := time.Now()
h.Year = byte(now.Year() - 1900)
h.Month = byte(now.Month())
h.Day = byte(now.Day())
}
// FieldCount calculates the number of fields from header length.
// Harbour: fieldCount = (headerLen - 32 - 1) / 32
func (h *Header) FieldCount() int {
if h.HeaderLen < 33 {
return 0
}
return int(h.HeaderLen-32-1) / FieldDescSize
}
// HasMemo returns true if the version byte indicates a memo file.
func (h *Header) HasMemo() bool {
return h.Version == VersionDBT || h.Version == VersionFPT ||
h.Version == 0x8B || h.Version == 0xE6 || h.Version == 0xF6
}
// RecordOffset calculates the file offset for a record (1-based).
// Harbour: headerLen + (recNo - 1) * recordLen
func (h *Header) RecordOffset(recNo uint32) int64 {
return int64(h.HeaderLen) + int64(recNo-1)*int64(h.RecordLen)
}
// EOFOffset returns the file offset where EOF marker should be written.
func (h *Header) EOFOffset() int64 {
return int64(h.HeaderLen) + int64(h.RecCount)*int64(h.RecordLen)
}
// FieldDesc represents a 32-byte field descriptor.
// Layout is byte-identical to Harbour's DBFFIELD.
type FieldDesc struct {
Name [11]byte // offset 0 (null-terminated)
Type byte // offset 11
Reserved1 [4]byte // offset 12
Len byte // offset 16
Dec byte // offset 17
Flags byte // offset 18
Counter [4]byte // offset 19 (autoincrement, LE)
Step byte // offset 23
Reserved2 [7]byte // offset 24
HasTag byte // offset 31
}
// ReadFieldDescs reads n field descriptors from a reader.
func ReadFieldDescs(r io.Reader, n int) ([]FieldDesc, error) {
fields := make([]FieldDesc, n)
for i := 0; i < n; i++ {
if err := binary.Read(r, binary.LittleEndian, &fields[i]); err != nil {
return nil, fmt.Errorf("read field descriptor %d: %w", i, err)
}
}
return fields, nil
}
// WriteFieldDescs writes field descriptors to a writer.
func WriteFieldDescs(w io.Writer, fields []FieldDesc) error {
for i := range fields {
if err := binary.Write(w, binary.LittleEndian, &fields[i]); err != nil {
return fmt.Errorf("write field descriptor %d: %w", i, err)
}
}
return nil
}
// GetName extracts the field name as a trimmed string.
func (f *FieldDesc) GetName() string {
n := len(f.Name)
for i, b := range f.Name {
if b == 0 {
n = i
break
}
}
// Trim trailing spaces (DBF field names are space-padded)
for n > 0 && f.Name[n-1] == ' ' {
n--
}
return string(f.Name[:n])
}
// SetName sets the field name (max 10 chars, null-terminated, space-padded).
func (f *FieldDesc) SetName(name string) {
f.Name = [11]byte{}
copy(f.Name[:], name)
}
// BuildFieldOffsets calculates the byte offset of each field within a record.
// offset[0] = 1 (after deletion flag), offset[i+1] = offset[i] + field[i].Len
// Harbour: pFieldOffset array built during OPEN.
func BuildFieldOffsets(fields []FieldDesc) []uint16 {
offsets := make([]uint16, len(fields)+1)
offsets[0] = 1 // skip deletion flag byte
for i, f := range fields {
offsets[i+1] = offsets[i] + uint16(f.Len)
}
return offsets
}