// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com) // All rights reserved. // DBFArea — the core DBF file driver. // Byte-compatible with Harbour/Clipper DBF files. // // Harbour equivalent: DBFAREA in dbf1.c // Inherits from BaseArea (WAAREA), implements Area + RecordManager + Locker. // // Reference: // /mnt/d/harbour-core/src/rdd/dbf1.c // docs/dbf-engine-spec.md package dbf import ( "five/hbrt" "five/hbrdd" "fmt" "os" "strings" ) // DBFArea implements the DBF database driver. // Harbour: struct DBFAREA in dbf1.c type DBFArea struct { hbrdd.BaseArea // embed WAAREA defaults // File handles dataFile *os.File filePath string shared bool readOnly bool // Header header Header fieldDescs []FieldDesc offsets []uint16 // field byte offsets within record // Record buffer recBuf []byte // current record (RecordLen bytes) recNo uint32 // current record number (1-based) dirty bool // record buffer modified // State recCount uint32 ghost bool // at phantom record (after APPEND) // Memo file (FPT) memoFile *FPTFile // Index integration (NTX/CDX) idxState *indexState } // DBFDriver is the driver factory for DBF files. type DBFDriver struct{} func (d *DBFDriver) Name() string { return "DBF" } func (d *DBFDriver) Open(params hbrdd.OpenParams) (hbrdd.Area, error) { return openDBF(d, params) } func (d *DBFDriver) Create(params hbrdd.CreateParams) (hbrdd.Area, error) { return createDBF(d, params) } func init() { hbrdd.RegisterDriver(&DBFDriver{}) // Register aliases used by Harbour hbrdd.RegisterDriver(&dbfAliasDriver{name: "DBFNTX"}) hbrdd.RegisterDriver(&dbfAliasDriver{name: "DBFCDX"}) hbrdd.RegisterDriver(&dbfAliasDriver{name: "DBFFPT"}) // SIX compatible drivers — same DBF engine, different semantics handled at higher level hbrdd.RegisterDriver(&dbfAliasDriver{name: "SIXCDX"}) hbrdd.RegisterDriver(&dbfAliasDriver{name: "DBFNSX"}) hbrdd.RegisterDriver(&dbfAliasDriver{name: "DBFSIX"}) // Transfer format aliases hbrdd.RegisterDriver(&dbfAliasDriver{name: "DBFDBT"}) // Bitmap/Rushmore RDD variants hbrdd.RegisterDriver(&dbfAliasDriver{name: "BMDBFNTX"}) hbrdd.RegisterDriver(&dbfAliasDriver{name: "BMDBFCDX"}) hbrdd.RegisterDriver(&dbfAliasDriver{name: "BMDBFNSX"}) } // dbfAliasDriver wraps DBFDriver with a different name. type dbfAliasDriver struct { name string } func (d *dbfAliasDriver) Name() string { return d.name } func (d *dbfAliasDriver) Open(params hbrdd.OpenParams) (hbrdd.Area, error) { return openDBF(&DBFDriver{}, params) } func (d *dbfAliasDriver) Create(params hbrdd.CreateParams) (hbrdd.Area, error) { return createDBF(&DBFDriver{}, params) } // fptPathFromDBF returns the FPT memo file path for a given DBF path. func fptPathFromDBF(dbfPath string) string { base := dbfPath if idx := strings.LastIndex(base, "."); idx >= 0 { base = base[:idx] } return base + ".fpt" } // hasMemoField checks if any field descriptor is a MEMO type. func hasMemoField(fields []FieldDesc) bool { for _, f := range fields { if f.Type == 'M' || f.Type == 'm' { return true } } return false } // --- Open --- // Harbour: hb_dbfOpen in dbf1.c func openDBF(drv *DBFDriver, params hbrdd.OpenParams) (*DBFArea, error) { path := params.Path if !hasExtension(path) { path += ".dbf" } flag := os.O_RDWR if params.ReadOnly { flag = os.O_RDONLY } f, err := os.OpenFile(path, flag, 0) if err != nil { return nil, fmt.Errorf("open %s: %w", path, err) } area := &DBFArea{ dataFile: f, filePath: path, shared: params.Shared, readOnly: params.ReadOnly, } area.BaseArea = hbrdd.BaseArea{} // Step 1: Read header (32 bytes) hdr, err := ReadHeader(f) if err != nil { f.Close() return nil, err } area.header = *hdr // Step 2: Read field descriptors fieldCount := hdr.FieldCount() if fieldCount <= 0 { f.Close() return nil, fmt.Errorf("invalid field count: %d", fieldCount) } fields, err := ReadFieldDescs(f, fieldCount) if err != nil { f.Close() return nil, err } area.fieldDescs = fields // Step 3: Build field offsets area.offsets = BuildFieldOffsets(fields) // Step 4: Validate record length expectedRecLen := area.offsets[len(fields)] if uint16(expectedRecLen) != hdr.RecordLen { // Allow minor discrepancy (some DBF writers add extra bytes) if uint16(expectedRecLen) > hdr.RecordLen { f.Close() return nil, fmt.Errorf("field offsets (%d) exceed record length (%d)", expectedRecLen, hdr.RecordLen) } } // Step 5: Allocate record buffer area.recBuf = make([]byte, hdr.RecordLen) // Step 6: Set record count (shared mode: recalculate from file size) if params.Shared { fileSize, _ := f.Seek(0, 2) area.recCount = uint32((fileSize - int64(hdr.HeaderLen)) / int64(hdr.RecordLen)) } else { area.recCount = hdr.RecCount } // Step 7: Build FieldInfo for BaseArea fieldInfos := make([]hbrdd.FieldInfo, fieldCount) for i, fd := range fields { fieldInfos[i] = hbrdd.FieldInfo{ Name: fd.GetName(), Type: fd.Type, Len: int(fd.Len), Dec: int(fd.Dec), Flags: fd.Flags, } } area.InitFields(fieldInfos) // Step 8: Auto-open FPT if memo fields exist if hasMemoField(fields) { fptPath := fptPathFromDBF(path) if fpt, err := OpenFPT(fptPath); err == nil { area.memoFile = fpt } // If FPT doesn't exist, memo reads return empty string } // Step 9: Position at first record area.FEof = (area.recCount == 0) if area.recCount > 0 { area.GoTo(1) } return area, nil } // --- Create --- func createDBF(drv *DBFDriver, params hbrdd.CreateParams) (*DBFArea, error) { path := params.Path if !hasExtension(path) { path += ".dbf" } f, err := os.Create(path) if err != nil { return nil, fmt.Errorf("create %s: %w", path, err) } // Build field descriptors fieldDescs := make([]FieldDesc, len(params.Fields)) recordLen := uint16(1) // deletion flag for i, fi := range params.Fields { fieldDescs[i].SetName(fi.Name) fieldDescs[i].Type = fi.Type fieldDescs[i].Len = byte(fi.Len) fieldDescs[i].Dec = byte(fi.Dec) fieldDescs[i].Flags = fi.Flags recordLen += uint16(fi.Len) } // Build header hasMemo := hasMemoField(fieldDescs) headerLen := uint16(HeaderSize + len(fieldDescs)*FieldDescSize + 1) // +1 for terminator version := byte(VersionDBF3) if hasMemo { version = VersionFPT } hdr := Header{ Version: version, RecCount: 0, HeaderLen: headerLen, RecordLen: recordLen, } hdr.UpdateDate() // Write header if err := WriteHeader(f, &hdr); err != nil { f.Close() return nil, err } // Write field descriptors if err := WriteFieldDescs(f, fieldDescs); err != nil { f.Close() return nil, err } // Write header terminator f.Write([]byte{HeaderTerminator}) // Write EOF marker f.Write([]byte{EOFMarker}) f.Seek(0, 0) area := &DBFArea{ dataFile: f, filePath: path, header: hdr, fieldDescs: fieldDescs, offsets: BuildFieldOffsets(fieldDescs), recBuf: make([]byte, recordLen), recCount: 0, } fieldInfos := make([]hbrdd.FieldInfo, len(params.Fields)) copy(fieldInfos, params.Fields) area.InitFields(fieldInfos) area.FEof = true // Auto-create FPT if memo fields exist if hasMemo { fptPath := fptPathFromDBF(path) fpt, err := CreateFPT(fptPath, FPTDefaultBlock) if err != nil { f.Close() return nil, fmt.Errorf("create memo file: %w", err) } area.memoFile = fpt } return area, nil } // --- Area interface --- func (a *DBFArea) Driver() hbrdd.Driver { return &DBFDriver{} } func (a *DBFArea) Close() error { if a.dirty { a.flushRecord() } a.updateHeader() if a.memoFile != nil { a.memoFile.Close() a.memoFile = nil } err := a.dataFile.Close() a.BaseArea.Close() return err } // MemoFile returns the FPT memo file, or nil if no memo fields. func (a *DBFArea) MemoFile() *FPTFile { return a.memoFile } func (a *DBFArea) Flush() error { if a.dirty { if err := a.flushRecord(); err != nil { return err } } return a.dataFile.Sync() } // --- Movement --- func (a *DBFArea) RecNo() uint32 { return a.recNo } func (a *DBFArea) RecCount() (uint32, error) { if a.shared { // Recalculate from file size (Harbour behavior) size, err := a.dataFile.Seek(0, 2) if err != nil { return a.recCount, err } a.recCount = uint32((size - int64(a.header.HeaderLen)) / int64(a.header.RecordLen)) } return a.recCount, nil } func (a *DBFArea) Deleted() bool { if len(a.recBuf) > 0 { return a.recBuf[0] == RecordDeleted } return false } // GoTo positions the cursor at a specific record number. // Harbour: hb_dbfGoTo in dbf1.c func (a *DBFArea) GoTo(recNo uint32) error { if a.dirty { a.flushRecord() } a.FFound = false if recNo == 0 || recNo > a.recCount { // EOF / phantom record a.recNo = a.recCount + 1 a.FEof = true a.FBof = (recNo == 0) // Clear buffer for i := range a.recBuf { a.recBuf[i] = ' ' } return nil } // Read record from file offset := a.header.RecordOffset(recNo) _, err := a.dataFile.ReadAt(a.recBuf, offset) if err != nil { return fmt.Errorf("read record %d: %w", recNo, err) } a.recNo = recNo a.FEof = false a.FBof = false a.dirty = false a.ghost = false return nil } // GoTop positions at the first record. // Harbour: hb_dbfGoTop func (a *DBFArea) GoTop() error { // Use index order if active if a.idxState != nil && a.idxState.current >= 0 { a.FTop = true a.FBottom = false return a.GoTopIndexed() } a.FTop = true a.FBottom = false if a.recCount == 0 { a.FEof = true a.recNo = 1 return nil } if err := a.GoTo(1); err != nil { return err } // Skip filtered/deleted records return a.skipFilter(1) } // GoBottom positions at the last record. // Harbour: hb_dbfGoBottom func (a *DBFArea) GoBottom() error { // Use index order if active if a.idxState != nil && a.idxState.current >= 0 { a.FTop = false a.FBottom = true return a.GoBottomIndexed() } a.FTop = false a.FBottom = true if a.recCount == 0 { a.FEof = true a.recNo = 1 return nil } if err := a.GoTo(a.recCount); err != nil { return err } return a.skipFilter(-1) } // Skip moves the cursor by count records. // Harbour: hb_dbfSkip func (a *DBFArea) Skip(count int64) error { // Use index order if active if a.idxState != nil && a.idxState.current >= 0 { a.FTop = false a.FBottom = false return a.SkipIndexed(count) } a.FTop = false a.FBottom = false if count == 0 { // Skip 0 = re-evaluate filter at current position return a.skipFilter(1) } if count > 0 { for i := int64(0); i < count; i++ { if a.FEof { break } newRec := a.recNo + 1 if newRec > a.recCount { a.FEof = true a.recNo = a.recCount + 1 break } if err := a.GoTo(newRec); err != nil { return err } // Skip deleted records when SET DELETED ON if err := a.skipFilter(1); err != nil { return err } } } else { for i := int64(0); i > count; i-- { if a.recNo <= 1 { a.FBof = true break } if err := a.GoTo(a.recNo - 1); err != nil { return err } if err := a.skipFilter(-1); err != nil { return err } } } return nil } // --- Field access --- func (a *DBFArea) GetValue(fieldIndex int) (hbrt.Value, error) { if fieldIndex < 0 || fieldIndex >= len(a.fieldDescs) { return hbrt.MakeNil(), fmt.Errorf("field index out of range: %d", fieldIndex) } if a.FEof { return hbrt.MakeNil(), nil } fd := &a.fieldDescs[fieldIndex] // MEMO field: read from FPT and return string if (fd.Type == 'M' || fd.Type == 'm') && a.memoFile != nil { blockVal := GetFieldValue(a.recBuf, a.offsets[fieldIndex], fd) blockNo := uint32(blockVal.AsNumInt()) if blockNo == 0 { return hbrt.MakeString(""), nil } data, err := a.memoFile.ReadMemo(blockNo) if err != nil { return hbrt.MakeString(""), nil } return hbrt.MakeString(string(data)), nil } return GetFieldValue(a.recBuf, a.offsets[fieldIndex], fd), nil } func (a *DBFArea) PutValue(fieldIndex int, val hbrt.Value) error { if a.readOnly { return fmt.Errorf("table is read-only") } if fieldIndex < 0 || fieldIndex >= len(a.fieldDescs) { return fmt.Errorf("field index out of range: %d", fieldIndex) } fd := &a.fieldDescs[fieldIndex] // MEMO field: write string to FPT, store block number in DBF if (fd.Type == 'M' || fd.Type == 'm') && a.memoFile != nil && val.IsString() { data := []byte(val.AsString()) blockNo, err := a.memoFile.WriteMemo(data) if err != nil { return fmt.Errorf("write memo: %w", err) } putMemoRef(a.recBuf[a.offsets[fieldIndex]:a.offsets[fieldIndex]+uint16(fd.Len)], fd.Len, blockNo) a.dirty = true return nil } PutFieldValue(a.recBuf, a.offsets[fieldIndex], fd, val) a.dirty = true return nil } // --- Record operations --- // Append adds a new blank record. // Harbour: hb_dbfAppend func (a *DBFArea) Append() error { if a.readOnly { return fmt.Errorf("table is read-only") } if a.dirty { a.flushRecord() } a.recCount++ a.recNo = a.recCount a.header.RecCount = a.recCount // Clear record buffer (all spaces) for i := range a.recBuf { a.recBuf[i] = ' ' } // Write blank record offset := a.header.RecordOffset(a.recNo) if _, err := a.dataFile.WriteAt(a.recBuf, offset); err != nil { a.recCount-- return fmt.Errorf("append record: %w", err) } // Write EOF marker eofOffset := a.header.EOFOffset() a.dataFile.WriteAt([]byte{EOFMarker}, eofOffset) // Update header a.updateHeader() a.FEof = false a.FBof = false a.dirty = false a.ghost = true return nil } // Delete marks the current record as deleted. func (a *DBFArea) Delete() error { if a.readOnly || a.FEof { return nil } a.recBuf[0] = RecordDeleted a.dirty = true return nil } // Recall undeletes the current record. func (a *DBFArea) Recall() error { if a.readOnly || a.FEof { return nil } a.recBuf[0] = RecordActive a.dirty = true return nil } // Pack removes all deleted records. // Harbour: hb_dbfPack — requires exclusive access. func (a *DBFArea) Pack() error { if a.readOnly { return fmt.Errorf("table is read-only") } if a.shared { return fmt.Errorf("PACK requires exclusive access") } if a.dirty { a.flushRecord() } // Temporarily disable index to avoid indexed navigation during PACK var savedIdx *indexState if a.idxState != nil { savedIdx = a.idxState a.idxState = nil } outRec := uint32(0) buf := make([]byte, a.header.RecordLen) for recNo := uint32(1); recNo <= a.recCount; recNo++ { offset := a.header.RecordOffset(recNo) if _, err := a.dataFile.ReadAt(buf, offset); err != nil { return err } if buf[0] != RecordDeleted { outRec++ outOffset := a.header.RecordOffset(outRec) if _, err := a.dataFile.WriteAt(buf, outOffset); err != nil { return err } } } a.recCount = outRec a.header.RecCount = outRec // Truncate file newSize := a.header.EOFOffset() + 1 // +1 for EOF marker a.dataFile.Truncate(newSize) // Write EOF a.dataFile.WriteAt([]byte{EOFMarker}, a.header.EOFOffset()) // Update header a.updateHeader() // Reposition (natural order, no index yet) if a.recCount > 0 { a.GoTo(1) } else { a.FEof = true a.recNo = 1 } // Rebuild indexes (record numbers changed after PACK) if savedIdx != nil { a.idxState = savedIdx if err := a.OrderListRebuild(); err != nil { return err } } return nil } // Zap removes all records. func (a *DBFArea) Zap() error { if a.readOnly || a.shared { return fmt.Errorf("ZAP requires exclusive access") } // Save index state var savedIdx *indexState if a.idxState != nil { savedIdx = a.idxState a.idxState = nil } a.recCount = 0 a.header.RecCount = 0 // Truncate to header + EOF a.dataFile.Truncate(int64(a.header.HeaderLen) + 1) a.dataFile.WriteAt([]byte{EOFMarker}, int64(a.header.HeaderLen)) a.updateHeader() a.FEof = true a.recNo = 1 // Rebuild indexes (empty after ZAP) if savedIdx != nil { a.idxState = savedIdx a.OrderListRebuild() // rebuilds empty indexes } return nil } // --- Internal --- func (a *DBFArea) flushRecord() error { if !a.dirty || a.FEof { return nil } offset := a.header.RecordOffset(a.recNo) _, err := a.dataFile.WriteAt(a.recBuf, offset) if err == nil { a.dirty = false } return err } func (a *DBFArea) updateHeader() { a.header.RecCount = a.recCount a.header.UpdateDate() a.dataFile.Seek(0, 0) WriteHeader(a.dataFile, &a.header) } // skipFilter advances cursor past deleted/filtered records. // Harbour: hb_dbfSkipFilter in dbf1.c // direction: 1 = forward, -1 = backward func (a *DBFArea) skipFilter(direction int64) error { if direction == 0 { direction = 1 } setDel := hbrdd.IsSetDeleted != nil && hbrdd.IsSetDeleted() if !setDel { return nil } for !a.FEof && !a.FBof { if !a.Deleted() { break } // Move to next/prev record if direction > 0 { newRec := a.recNo + 1 if newRec > a.recCount { a.FEof = true a.recNo = a.recCount + 1 break } if err := a.GoTo(newRec); err != nil { return err } } else { if a.recNo <= 1 { a.FBof = true if err := a.GoTo(1); err != nil { return err } break } if err := a.GoTo(a.recNo - 1); err != nil { return err } } } return nil } // --- Helpers --- func hasExtension(path string) bool { for i := len(path) - 1; i >= 0; i-- { if path[i] == '.' { return true } if path[i] == '/' || path[i] == '\\' { return false } } return false }