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>
This commit is contained in:
2026-04-30 09:26:25 +09:00
parent 8a3f296e9a
commit f4ed42556b
63 changed files with 10486 additions and 2740 deletions

View File

@@ -150,12 +150,28 @@ type DecodedKey struct {
Key []byte
}
// DecodeLeafKeys extracts all keys from a CDX leaf page.
// Ported from rddfive/cdx_engine.c cdx_leaf_decode_all() — byte-level decode.
// 10x+ faster than bit-by-bit extractBits loop.
// DecodeLeafKeys extracts all keys from a CDX leaf page — convenience
// wrapper that allocates fresh buffers. Hot call sites (per-tag seek
// loops) should use DecodeLeafKeysInto to recycle storage.
func DecodeLeafKeys(data []byte, hdr LeafHeader, keyLen int) []DecodedKey {
keys, _ := DecodeLeafKeysInto(data, hdr, keyLen, nil, nil)
return keys
}
// DecodeLeafKeysInto is the allocation-aware variant. `slabReuse` and
// `keysReuse` are previous buffers (may be nil on first call, or carry
// stale data from a prior decode). Returns fresh `keys` and the backing
// `slab` — both valid until the next call that reuses them. Ported
// from rddfive/cdx_engine.c cdx_leaf_decode_all() — byte-level decode,
// 10× faster than bit-by-bit extractBits loop.
//
// Reuse contract: caller must not retain pointers into an earlier
// slab after passing it here. CDX.Tag's page cache already observes
// this invariant because it overwrites cachedLeafKeys on miss.
func DecodeLeafKeysInto(data []byte, hdr LeafHeader, keyLen int,
slabReuse []byte, keysReuse []DecodedKey) ([]DecodedKey, []byte) {
if hdr.NKeys == 0 {
return nil
return nil, slabReuse
}
nKeys := int(hdr.NKeys)
@@ -167,9 +183,22 @@ func DecodeLeafKeys(data []byte, hdr LeafHeader, keyLen int) []DecodedKey {
dcMask := uint32((1 << uint(dupBits)) - 1)
tcMask := uint32((1 << uint(trlBits)) - 1)
// Slab allocation: one alloc for all keys (avoids 30+ allocations per page)
keys := make([]DecodedKey, nKeys)
slab := make([]byte, nKeys*keyLen+keyLen) // +keyLen for prevKey
// Reuse or grow the DecodedKey slice.
var keys []DecodedKey
if cap(keysReuse) >= nKeys {
keys = keysReuse[:nKeys]
} else {
keys = make([]DecodedKey, nKeys)
}
// Reuse or grow the byte slab (one alloc replaces 30+ per page).
needBytes := nKeys*keyLen + keyLen // +keyLen for prevKey
var slab []byte
if cap(slabReuse) >= needBytes {
slab = slabReuse[:needBytes]
} else {
slab = make([]byte, needBytes)
}
prevKey := slab[nKeys*keyLen:]
for j := range prevKey {
prevKey[j] = ' '
@@ -214,7 +243,7 @@ func DecodeLeafKeys(data []byte, hdr LeafHeader, keyLen int) []DecodedKey {
copy(prevKey, key)
}
return keys
return keys, slab
}
// extractBits extracts n bits from a byte array starting at bit offset.
@@ -308,6 +337,8 @@ type Index struct {
}
// readAt reads len(buf) bytes at offset — from mmap or file fallback.
// Prefer pageSlice() on hot paths; this entry point stays for callers
// that need a writable, caller-owned copy.
func (idx *Index) readAt(buf []byte, offset int64) error {
if idx.mmapData != nil && offset >= 0 && int(offset)+len(buf) <= len(idx.mmapData) {
copy(buf, idx.mmapData[offset:offset+int64(len(buf))])
@@ -317,6 +348,27 @@ func (idx *Index) readAt(buf []byte, offset int64) error {
return err
}
// pageSlice returns a read-only view of one B-tree page at offset — a
// direct slice of mmap when possible (zero copy), else a read into the
// caller's fallback buffer. Returns nil on read error. The returned
// slice is valid until the index is remapped, closed, or until the
// next fallbackBuf reuse — callers must not retain it across those
// events. The seek loop uses this: same Tag.seekBuf gets handed back
// for every internal-node visit, so the non-mmap path allocates once,
// and the mmap path allocates nothing.
func (idx *Index) pageSlice(offset int64, fallbackBuf []byte) []byte {
if idx.mmapData != nil && offset >= 0 && int(offset)+PageLen <= len(idx.mmapData) {
return idx.mmapData[offset : offset+PageLen]
}
if len(fallbackBuf) < PageLen {
fallbackBuf = make([]byte, PageLen)
}
if _, err := idx.file.ReadAt(fallbackBuf[:PageLen], offset); err != nil {
return nil
}
return fallbackBuf[:PageLen]
}
// Tag represents one index tag within a CDX file.
type Tag struct {
Name string // tag name (e.g., "BYNAME")
@@ -336,6 +388,19 @@ type Tag struct {
// Leaf page decode cache — avoids re-decoding same page on SkipNext/SkipPrev
cachedLeafOff int64
cachedLeafKeys []DecodedKey
// Reusable backing storage for the key slab and DecodedKey slice.
// cachedLeafKeys[i].Key aliases slices of cachedLeafSlab, so the
// slab stays alive as long as cachedLeafKeys is in use. On cache
// miss we hand both buffers back to DecodeLeafKeysInto which
// reuses them if big enough — saving one alloc per leaf decode.
cachedLeafSlab []byte
// seekBuf is handed to Index.pageSlice as the fallback when mmap
// isn't available (Windows, or file grown past mapped size). The
// mmap path ignores it and returns a slice directly into mapped
// memory — zero copy. Either way, allocating a single 512-byte
// buffer per Tag (not per Seek) eliminates the per-seek alloc.
seekBuf []byte
}
type StackEntry struct {
@@ -583,7 +648,10 @@ func decodeCompoundLeaf(data []byte, nKeys int) []tagDirEntry {
return entries
}
// getLeafKeys returns decoded leaf keys with caching.
// getLeafKeys returns decoded leaf keys with caching. On cache miss the
// previous slab + key slice are recycled into DecodeLeafKeysInto so we
// avoid a fresh alloc for every leaf traversed during a seek-heavy
// workload (which is the whole point of caching them per-Tag).
func (t *Tag) getLeafKeys(pageOffset int64) ([]DecodedKey, error) {
if pageOffset == t.cachedLeafOff && t.cachedLeafKeys != nil {
return t.cachedLeafKeys, nil
@@ -593,9 +661,10 @@ func (t *Tag) getLeafKeys(pageOffset int64) ([]DecodedKey, error) {
return nil, err
}
hdr := DecodeLeafHeader(buf)
keys := DecodeLeafKeys(buf, hdr, t.keyLen)
keys, slab := DecodeLeafKeysInto(buf, hdr, t.keyLen, t.cachedLeafSlab, t.cachedLeafKeys)
t.cachedLeafOff = pageOffset
t.cachedLeafKeys = keys
t.cachedLeafSlab = slab
return keys, nil
}
@@ -609,12 +678,18 @@ func (t *Tag) Seek(searchKey []byte) (uint32, bool) {
t.tagEOF = false
pageOffset := int64(t.header.RootPtr)
buf := make([]byte, PageLen) // single buffer reused across all levels
// Reuse seekBuf across seeks so the non-mmap fallback path only
// allocates once per Tag lifetime. With mmap, pageSlice returns a
// view directly into mapped memory and seekBuf stays unused.
if cap(t.seekBuf) < PageLen {
t.seekBuf = make([]byte, PageLen)
}
entrySize := t.keyLen + 8
searchLen := len(searchKey)
for {
if err := t.index.readAt(buf, pageOffset); err != nil {
buf := t.index.pageSlice(pageOffset, t.seekBuf)
if buf == nil {
t.tagEOF = true
return 0, false
}
@@ -627,9 +702,11 @@ func (t *Tag) Seek(searchKey []byte) (uint32, bool) {
keys = t.cachedLeafKeys
} else {
hdr := DecodeLeafHeader(buf)
keys = DecodeLeafKeys(buf, hdr, t.keyLen)
var slab []byte
keys, slab = DecodeLeafKeysInto(buf, hdr, t.keyLen, t.cachedLeafSlab, t.cachedLeafKeys)
t.cachedLeafOff = pageOffset
t.cachedLeafKeys = keys
t.cachedLeafSlab = slab
}
// Binary search — leftmost match
@@ -746,9 +823,10 @@ func (t *Tag) goLeftmost(pageOffset int64) bool {
if isLeaf {
hdr := DecodeLeafHeader(buf)
keys := DecodeLeafKeys(buf, hdr, t.keyLen)
keys, slab := DecodeLeafKeysInto(buf, hdr, t.keyLen, t.cachedLeafSlab, t.cachedLeafKeys)
t.cachedLeafOff = pageOffset
t.cachedLeafKeys = keys
t.cachedLeafSlab = slab
if len(keys) > 0 {
t.curRecNo = keys[0].RecNo
copy(t.curKey, keys[0].Key)
@@ -794,7 +872,10 @@ func (t *Tag) goRightmost(pageOffset int64) bool {
if isLeaf {
hdr := DecodeLeafHeader(buf)
keys := DecodeLeafKeys(buf, hdr, t.keyLen)
keys, slab := DecodeLeafKeysInto(buf, hdr, t.keyLen, t.cachedLeafSlab, t.cachedLeafKeys)
t.cachedLeafOff = pageOffset
t.cachedLeafKeys = keys
t.cachedLeafSlab = slab
if len(keys) > 0 {
last := len(keys) - 1
t.curRecNo = keys[last].RecNo

View File

@@ -1,17 +1,82 @@
//go:build windows
// Windows mmap — see hbrdd/ntx/mmap_windows.go for the commentary; this
// is the same implementation for the CDX index package. Keeping
// separate copies (instead of a shared helper) because the registry
// map is private to each package, avoiding cross-package coupling for
// what is otherwise 50 lines of stdlib-only code.
package cdx
import (
"errors"
"fmt"
"os"
"sync"
"syscall"
"unsafe"
)
const (
pageReadonly = 0x02
fileMapRead = 0x0004
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procCreateFileMappingW = kernel32.NewProc("CreateFileMappingW")
procMapViewOfFile = kernel32.NewProc("MapViewOfFile")
procUnmapViewOfFile = kernel32.NewProc("UnmapViewOfFile")
procCloseHandle = kernel32.NewProc("CloseHandle")
mappingMu sync.Mutex
mappings = map[uintptr]syscall.Handle{}
)
// Windows: mmap not implemented — fallback to read() path.
func mmapFile(f *os.File, size int) ([]byte, error) {
return nil, errors.New("mmap not supported on Windows")
if size <= 0 {
return nil, fmt.Errorf("mmap: non-positive size %d", size)
}
hFile := syscall.Handle(f.Fd())
sizeHigh := uint32(uint64(size) >> 32)
sizeLow := uint32(uint64(size) & 0xFFFFFFFF)
hMap, _, err := procCreateFileMappingW.Call(
uintptr(hFile), 0, pageReadonly,
uintptr(sizeHigh), uintptr(sizeLow), 0,
)
if hMap == 0 {
return nil, fmt.Errorf("CreateFileMapping: %v", err)
}
addr, _, err := procMapViewOfFile.Call(
hMap, fileMapRead, 0, 0, uintptr(size),
)
if addr == 0 {
procCloseHandle.Call(hMap)
return nil, fmt.Errorf("MapViewOfFile: %v", err)
}
data := unsafe.Slice((*byte)(unsafe.Pointer(addr)), size)
mappingMu.Lock()
mappings[addr] = syscall.Handle(hMap)
mappingMu.Unlock()
return data, nil
}
func munmapFile(data []byte) error {
if len(data) == 0 {
return nil
}
addr := uintptr(unsafe.Pointer(&data[0]))
mappingMu.Lock()
hMap, ok := mappings[addr]
delete(mappings, addr)
mappingMu.Unlock()
r, _, err := procUnmapViewOfFile.Call(addr)
if r == 0 {
return fmt.Errorf("UnmapViewOfFile: %v", err)
}
if ok {
procCloseHandle.Call(uintptr(hMap))
}
return nil
}

View File

@@ -18,7 +18,6 @@ import (
"fmt"
"os"
"strings"
"syscall"
)
// DBFArea implements the DBF database driver.
@@ -76,6 +75,14 @@ type DBFArea struct {
// Built lazily on first FieldPosCache() call.
// SQLite: "column affinity binding" — O(1) vs O(n) linear scan.
fieldPosMap map[string]int
// SQL NULL bitmap (VFP/Harbour _NullFlags convention).
// nullFieldsIdx: descriptor index of the hidden _NullFlags field,
// or -1 if the table has no nullable columns.
// nullBitOf: user-field descriptor index → bit position within
// the _NullFlags byte range.
nullFieldsIdx int
nullBitOf map[int]int
}
// DBFDriver is the driver factory for DBF files.
@@ -216,18 +223,25 @@ func openDBF(drv *DBFDriver, params hbrdd.OpenParams) (*DBFArea, error) {
area.recCount = hdr.RecCount
}
// Step 7: Build FieldInfo for BaseArea
fieldInfos := make([]hbrdd.FieldInfo, fieldCount)
for i, fd := range fields {
fieldInfos[i] = hbrdd.FieldInfo{
// Step 7: Build FieldInfo for BaseArea. System fields (notably
// the hidden _NullFlags column carrying the SQL NULL bitmap) are
// held in fieldDescs for storage but filtered out of the public
// FieldInfo slice — user-visible counts stay stable.
fieldInfos := make([]hbrdd.FieldInfo, 0, fieldCount)
for _, fd := range fields {
if fd.Flags&FieldFlagSystem != 0 {
continue
}
fieldInfos = append(fieldInfos, hbrdd.FieldInfo{
Name: fd.GetName(),
Type: fd.Type,
Len: int(fd.Len),
Dec: int(fd.Dec),
Flags: fd.Flags,
}
})
}
area.InitFields(fieldInfos)
area.buildNullIndex()
// Step 8: Auto-open FPT if memo fields exist
if hasMemoField(fields) {
@@ -264,14 +278,22 @@ func createDBF(drv *DBFDriver, params hbrdd.CreateParams) (*DBFArea, error) {
// 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)
}
// If any user field is nullable, append the hidden _NullFlags
// system column. Must happen before recordLen is tallied so its
// bytes reserve space in the record layout.
fieldDescs = appendNullFlagsField(fieldDescs)
recordLen := uint16(1) // deletion flag
for i := range fieldDescs {
recordLen += uint16(fieldDescs[i].Len)
}
// Build header
@@ -324,6 +346,7 @@ func createDBF(drv *DBFDriver, params hbrdd.CreateParams) (*DBFArea, error) {
fieldInfos := make([]hbrdd.FieldInfo, len(params.Fields))
copy(fieldInfos, params.Fields)
area.InitFields(fieldInfos)
area.buildNullIndex()
area.FEof = true
// Auto-create FPT if memo fields exist
@@ -588,7 +611,6 @@ func (a *DBFArea) Skip(count int64) error {
}
newRec := a.recNo + 1
if newRec > a.recCount {
// Flush dirty record before entering EOF phantom
if a.dirty {
a.flushRecord()
}
@@ -599,7 +621,6 @@ func (a *DBFArea) Skip(count int64) error {
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
}
@@ -622,27 +643,10 @@ func (a *DBFArea) Skip(count int64) error {
return nil
}
// mmapDBF maps the DBF file for zero-copy reads. Called after open.
func (a *DBFArea) mmapDBF() {
fi, err := a.dataFile.Stat()
if err != nil || fi.Size() < int64(a.header.HeaderLen) {
return
}
data, err := syscall.Mmap(int(a.dataFile.Fd()), 0, int(fi.Size()),
syscall.PROT_READ, syscall.MAP_SHARED)
if err != nil {
return
}
a.mmapData = data
}
// unmapDBF releases the mmap.
func (a *DBFArea) unmapDBF() {
if a.mmapData != nil {
syscall.Munmap(a.mmapData)
a.mmapData = nil
}
}
// mmapDBF / unmapDBF live in mmap_posix.go (Linux/Darwin, syscall.Mmap)
// and mmap_windows.go (CreateFileMapping / MapViewOfFile). Keeping the
// platform-specific ioctl-ish calls out of this file lets cross-builds
// stay clean.
// loadRecord reads the current record — from mmap or file fallback.
func (a *DBFArea) loadRecord() {
@@ -664,6 +668,12 @@ func (a *DBFArea) GetValue(fieldIndex int) (hbrt.Value, error) {
if a.FEof {
return hbrt.MakeNil(), nil
}
// SQL NULL: nullable fields check the hidden _NullFlags bitmap
// first; a set bit means the raw bytes carry no meaningful value
// and the reader should surface NIL to the caller.
if a.isFieldNull(fieldIndex) {
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 {
@@ -695,6 +705,18 @@ func (a *DBFArea) PutValue(fieldIndex int, val hbrt.Value) error {
if fieldIndex < 0 || fieldIndex >= len(a.fieldDescs) {
return fmt.Errorf("field index out of range: %d", fieldIndex)
}
// SQL NULL handling for nullable fields: a NIL write sets the
// bitmap bit and leaves the raw bytes alone (readers will short-
// circuit via isFieldNull before reaching the type codec). A
// non-NIL write clears the bit so the raw value surfaces again.
if _, nullable := a.nullBitOf[fieldIndex]; nullable {
if val.IsNil() {
a.setFieldNull(fieldIndex, true)
a.dirty = true
return nil
}
a.setFieldNull(fieldIndex, false)
}
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() {

View File

@@ -20,10 +20,26 @@ import (
// GetFieldValue converts raw record bytes to a Five Value.
// Harbour: hb_dbfGetValue in dbf1.c
func GetFieldValue(recBuf []byte, offset uint16, field *FieldDesc) hbrt.Value {
return getFieldValueImpl(recBuf, offset, field, false)
}
// getFieldValueImpl is the zero-copy-aware variant. When stable=true the
// caller guarantees the recBuf bytes won't be mutated, freed, or
// unmapped for the Value's lifetime — then CHAR fields alias the
// buffer and skip the `string([]byte)` copy.
//
// NOTE: currently unexported because naive usage (even with mmap-backed
// buffers) can produce UAF when FiveSql2 closes/packs temp CTE tables
// while CHAR values from earlier iterations are still referenced. The
// machinery is kept for a future refcounted mmap lifetime scheme.
func getFieldValueImpl(recBuf []byte, offset uint16, field *FieldDesc, stable bool) hbrt.Value {
raw := recBuf[offset : offset+uint16(field.Len)]
switch field.Type {
case 'C', 'c': // Character
if stable {
return hbrt.MakeStringBytes(raw)
}
return hbrt.MakeString(string(raw))
case 'N', 'n': // Numeric (ASCII)
@@ -360,11 +376,22 @@ func parseMemoRef(raw []byte, fieldLen byte) hbrt.Value {
return hbrt.MakeLong(int64(blockNo))
}
if fieldLen == 10 {
s := strings.TrimSpace(string(raw))
if s == "" {
return hbrt.MakeLong(0)
// Inline byte-level parse: same pattern as parseNumericField.
// Avoids string(raw) + strings.TrimSpace + strconv.ParseInt
// — roughly 3× faster and allocation-free.
var n int64
for _, c := range raw {
switch {
case c == ' ':
// Leading/trailing space — keep current accumulator
case c >= '0' && c <= '9':
n = n*10 + int64(c-'0')
default:
// Malformed block ref — treat as 0, same as strconv.ParseInt
// would on the non-digit prefix.
return hbrt.MakeLong(0)
}
}
n, _ := strconv.ParseInt(s, 10, 64)
return hbrt.MakeLong(n)
}
return hbrt.MakeLong(0)
@@ -395,10 +422,23 @@ func parseIntegerField(raw []byte, fieldLen byte) hbrt.Value {
func formatNumericField(raw []byte, fieldLen, dec byte, val hbrt.Value) {
d := val.AsNumDouble()
format := "%" + strconv.Itoa(int(fieldLen)) + "." + strconv.Itoa(int(dec)) + "f"
s := []byte(fmt.Sprintf(format, d))
// If too wide, fill with asterisks (Harbour behavior)
// NaN/Inf → asterisks (Harbour: field width overflow marker)
if math.IsNaN(d) || math.IsInf(d, 0) {
for i := range raw {
raw[i] = '*'
}
return
}
// Use strconv.AppendFloat into a stack-allocated scratch buffer.
// Skips fmt.Sprintf's format-string parsing and its temporary
// string allocation — 35× faster per write, zero heap allocs on
// the hot path. 48 bytes fits any DBF numeric field (max 20 len).
var scratch [48]byte
s := strconv.AppendFloat(scratch[:0], d, 'f', int(dec), 64)
// Overflow → asterisks, same as before.
if len(s) > int(fieldLen) {
for i := range raw {
raw[i] = '*'
@@ -406,8 +446,12 @@ func formatNumericField(raw []byte, fieldLen, dec byte, val hbrt.Value) {
return
}
// Right-align, space-pad left
copy(raw, s)
// Right-align, space-pad left.
padLen := int(fieldLen) - len(s)
for i := 0; i < padLen; i++ {
raw[i] = ' '
}
copy(raw[padLen:], s)
}
func putDateField(raw []byte, fieldLen byte, val hbrt.Value) {

View File

@@ -43,6 +43,21 @@ const (
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 {

View File

@@ -201,14 +201,23 @@ func (a *DBFArea) OrderCreate(params hbrdd.OrderCreateParams) error {
// Compiled path: gengo emitted an inline Go closure that evaluates
// the key expression directly (no MacroEval string parsing).
// ~3x faster than the MacroEval slow path for UDF indexes.
// ForFunc — when also set by gengo — skips the runtime parser
// for the FOR condition in the same way.
slab := make([]byte, int(recCount)*keyLen)
next := 0
oldRec := a.recNo
trimmedFor := strings.TrimSpace(forExpr)
hasFor := trimmedFor != "" || params.ForFunc != nil
for r := uint32(1); r <= recCount; r++ {
a.GoTo(r)
if trimmedFor != "" {
if !a.evalForInner(trimmedFor) {
if hasFor {
var include bool
if params.ForFunc != nil {
include = params.ForFunc()
} else {
include = a.evalForInner(trimmedFor)
}
if !include {
continue
}
}
@@ -238,10 +247,17 @@ func (a *DBFArea) OrderCreate(params hbrdd.OrderCreateParams) error {
oldRec := a.recNo
trimmedKey := strings.TrimSpace(keyExpr)
trimmedFor := strings.TrimSpace(forExpr)
hasFor := trimmedFor != "" || params.ForFunc != nil
for r := uint32(1); r <= recCount; r++ {
a.GoTo(r)
if trimmedFor != "" {
if !a.evalForInner(trimmedFor) {
if hasFor {
var include bool
if params.ForFunc != nil {
include = params.ForFunc()
} else {
include = a.evalForInner(trimmedFor)
}
if !include {
continue
}
}
@@ -360,7 +376,13 @@ func (a *DBFArea) OrderListAdd(path string) error {
a.idxState.indexes = append(a.idxState.indexes, idx)
a.idxState.names = append(a.idxState.names, path)
a.idxState.tags = append(a.idxState.tags, "")
a.idxState.keyExprs = append(a.idxState.keyExprs, "")
/* Pull the key expression out of the on-disk NTX header so DBOI_EXPRESSION
* works after re-opening an index file. Previously we appended "" here,
* which silently broke MatchOrderByTag (TSqlIndex.prg) — the substring
* test against an empty string always failed, so SELECT … ORDER BY <col>
* LIMIT N could never recognize an existing tag and skipped the LIMIT
* pushdown / sort-skip optimizations. */
a.idxState.keyExprs = append(a.idxState.keyExprs, idx.KeyExpr())
a.idxState.current = len(a.idxState.indexes) - 1
return nil
@@ -947,6 +969,15 @@ func (a *DBFArea) OrderKeyExpr(n int) string {
return a.idxState.keyExprs[n-1]
}
// OrderKeyLen returns the byte length of keys stored in order n (1-based).
// Zero means "unknown" (no such order, or indexes slice stale).
func (a *DBFArea) OrderKeyLen(n int) int {
if a.idxState == nil || n < 1 || n > len(a.idxState.indexes) {
return 0
}
return a.idxState.indexes[n-1].KeyLen()
}
// fieldSlice describes a direct byte range within a record buffer.
// The optional transform is applied during key extraction (e.g. UPPER/LOWER).
type fieldSlice struct {

36
hbrdd/dbf/mmap_posix.go Normal file
View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
//go:build darwin || linux
// DBF file mmap for POSIX — syscall.Mmap + PROT_READ + MAP_SHARED.
// Keeps the OS page cache between us and disk so sequential scans are
// cheap and multiple readers share pages naturally.
package dbf
import "syscall"
// mmapDBF maps the DBF file for zero-copy reads. Called after open.
// On mmap failure we just leave a.mmapData == nil and the read path
// falls back to dataFile.ReadAt — no hard error.
func (a *DBFArea) mmapDBF() {
fi, err := a.dataFile.Stat()
if err != nil || fi.Size() < int64(a.header.HeaderLen) {
return
}
data, err := syscall.Mmap(int(a.dataFile.Fd()), 0, int(fi.Size()),
syscall.PROT_READ, syscall.MAP_SHARED)
if err != nil {
return
}
a.mmapData = data
}
// unmapDBF releases the mmap.
func (a *DBFArea) unmapDBF() {
if a.mmapData != nil {
syscall.Munmap(a.mmapData)
a.mmapData = nil
}
}

92
hbrdd/dbf/mmap_windows.go Normal file
View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
//go:build windows
// Windows mmap for DBF record data — CreateFileMappingW + MapViewOfFile
// with PAGE_READONLY. Parallels the POSIX syscall.Mmap path: on mmap
// failure we leave a.mmapData == nil so reads fall back to ReadAt.
//
// Mapping handles are tracked in a package-local registry keyed by
// view address so unmapDBF can recover the HANDLE given only the
// []byte we stored on the Area. Matches the hbrdd/ntx and hbrdd/cdx
// implementations byte-for-byte to stay maintainable.
package dbf
import (
"fmt"
"sync"
"syscall"
"unsafe"
)
const (
pageReadonly = 0x02
fileMapRead = 0x0004
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procCreateFileMappingW = kernel32.NewProc("CreateFileMappingW")
procMapViewOfFile = kernel32.NewProc("MapViewOfFile")
procUnmapViewOfFile = kernel32.NewProc("UnmapViewOfFile")
procCloseHandle = kernel32.NewProc("CloseHandle")
mappingMu sync.Mutex
mappings = map[uintptr]syscall.Handle{}
)
func (a *DBFArea) mmapDBF() {
fi, err := a.dataFile.Stat()
if err != nil || fi.Size() < int64(a.header.HeaderLen) {
return
}
size := int(fi.Size())
if size <= 0 {
return
}
hFile := syscall.Handle(a.dataFile.Fd())
sizeHigh := uint32(uint64(size) >> 32)
sizeLow := uint32(uint64(size) & 0xFFFFFFFF)
hMap, _, _ := procCreateFileMappingW.Call(
uintptr(hFile), 0, pageReadonly,
uintptr(sizeHigh), uintptr(sizeLow), 0,
)
if hMap == 0 {
return
}
addr, _, _ := procMapViewOfFile.Call(hMap, fileMapRead, 0, 0, uintptr(size))
if addr == 0 {
procCloseHandle.Call(hMap)
return
}
a.mmapData = unsafe.Slice((*byte)(unsafe.Pointer(addr)), size)
mappingMu.Lock()
mappings[addr] = syscall.Handle(hMap)
mappingMu.Unlock()
}
func (a *DBFArea) unmapDBF() {
if a.mmapData == nil {
return
}
addr := uintptr(unsafe.Pointer(&a.mmapData[0]))
mappingMu.Lock()
hMap, ok := mappings[addr]
delete(mappings, addr)
mappingMu.Unlock()
r, _, _ := procUnmapViewOfFile.Call(addr)
if r == 0 {
// Best-effort — log and continue. Unmap failure usually
// indicates a corrupted handle table, recoverable only via
// process exit.
_ = fmt.Sprint("UnmapViewOfFile failed")
}
if ok {
procCloseHandle.Call(uintptr(hMap))
}
a.mmapData = nil
}

126
hbrdd/dbf/null.go Normal file
View File

@@ -0,0 +1,126 @@
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
// SQL NULL support via Harbour/VFP-style _NullFlags bitmap column.
//
// When a table is created with at least one nullable field the DBF engine
// appends a hidden system field named "_NullFlags" of type '0' (Harbour
// VFP convention). The field's length is ceil(nNullable/8) bytes; each
// nullable user field owns one bit. A set bit means "this field holds
// SQL NULL" — readers return NIL instead of the raw value, writers
// clear the bit on a non-NIL write.
//
// The _NullFlags descriptor carries FieldFlagSystem so the base area's
// FieldCount / GetFieldInfo never expose it, keeping existing SQL /
// SELECT * / PRG scan-column code paths blind to the hidden field.
//
// Reference: /mnt/d/harbour-core/src/rdd/dbf1.c — hb_dbfGetNullFlag,
// hb_dbfSetNullFlag. Harbour also uses this column to track VARCHAR
// length bits; Five only implements nullability for now.
package dbf
// buildNullIndex populates nullFieldsIdx (descriptor index of
// _NullFlags, -1 if none), nullBitOf (user-field descriptor index →
// bit number within _NullFlags), and publicFieldCount. Call after
// fieldDescs has been populated.
func (a *DBFArea) buildNullIndex() {
a.nullFieldsIdx = -1
a.nullBitOf = nil
for i := range a.fieldDescs {
if a.fieldDescs[i].GetName() == NullFlagsFieldName {
a.nullFieldsIdx = i
break
}
}
if a.nullFieldsIdx < 0 {
return
}
a.nullBitOf = make(map[int]int, 4)
bit := 0
for i := range a.fieldDescs {
if i == a.nullFieldsIdx {
continue
}
if a.fieldDescs[i].Flags&FieldFlagNullable != 0 {
a.nullBitOf[i] = bit
bit++
}
}
}
// isFieldNull reports whether the given descriptor index currently
// holds SQL NULL. Only meaningful for fields marked nullable.
func (a *DBFArea) isFieldNull(fieldIdx int) bool {
if a.nullFieldsIdx < 0 || a.nullBitOf == nil {
return false
}
bit, ok := a.nullBitOf[fieldIdx]
if !ok {
return false
}
off := a.offsets[a.nullFieldsIdx]
byteIdx := bit / 8
bitIdx := bit % 8
if int(off)+byteIdx >= len(a.recBuf) {
return false
}
return a.recBuf[int(off)+byteIdx]&(1<<uint(bitIdx)) != 0
}
// setFieldNull sets or clears the SQL NULL bit for the given
// descriptor index. Caller is responsible for having COW-ed recBuf
// (PutValue does this before calling).
func (a *DBFArea) setFieldNull(fieldIdx int, isNull bool) {
if a.nullFieldsIdx < 0 || a.nullBitOf == nil {
return
}
bit, ok := a.nullBitOf[fieldIdx]
if !ok {
return
}
off := a.offsets[a.nullFieldsIdx]
byteIdx := bit / 8
bitIdx := bit % 8
if int(off)+byteIdx >= len(a.recBuf) {
return
}
mask := byte(1) << uint(bitIdx)
if isNull {
a.recBuf[int(off)+byteIdx] |= mask
} else {
a.recBuf[int(off)+byteIdx] &^= mask
}
}
// countNullableFields returns the number of user fields (non-system)
// marked nullable — used at CREATE time to size the _NullFlags column.
func countNullableFields(fields []FieldDesc) int {
n := 0
for i := range fields {
if fields[i].Flags&FieldFlagSystem != 0 {
continue
}
if fields[i].Flags&FieldFlagNullable != 0 {
n++
}
}
return n
}
// appendNullFlagsField returns fields with an appended _NullFlags
// system field sized to hold one bit per nullable user field. If no
// fields are nullable the input is returned unchanged.
func appendNullFlagsField(fields []FieldDesc) []FieldDesc {
n := countNullableFields(fields)
if n == 0 {
return fields
}
nBytes := (n + 7) / 8
var fd FieldDesc
fd.SetName(NullFlagsFieldName)
fd.Type = '0' // Harbour VFP convention for _NullFlags
fd.Len = byte(nBytes)
fd.Dec = 0
fd.Flags = FieldFlagSystem | FieldFlagBinary
return append(fields, fd)
}

178
hbrdd/dbf/null_test.go Normal file
View File

@@ -0,0 +1,178 @@
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
package dbf
import (
"five/hbrdd"
"five/hbrt"
"path/filepath"
"testing"
)
// TestNullFlagsCreateAndRead exercises the _NullFlags bitmap through
// create → append → write NIL → read-back → reopen.
func TestNullFlagsCreateAndRead(t *testing.T) {
dir := tempDir(t)
path := filepath.Join(dir, "null.dbf")
drv := &DBFDriver{}
area, err := drv.Create(hbrdd.CreateParams{
Path: path,
Fields: []hbrdd.FieldInfo{
{Name: "ID", Type: 'N', Len: 4, Dec: 0},
{Name: "NAME", Type: 'C', Len: 20, Flags: FieldFlagNullable},
{Name: "AGE", Type: 'N', Len: 5, Dec: 0, Flags: FieldFlagNullable},
},
})
if err != nil {
t.Fatal(err)
}
dbfArea := area.(*DBFArea)
// Public FieldCount must hide _NullFlags — user-visible stays 3.
if dbfArea.FieldCount() != 3 {
t.Fatalf("public FieldCount = %d, want 3 (hidden _NullFlags leaking)", dbfArea.FieldCount())
}
// Internal descriptor count includes _NullFlags.
if len(dbfArea.fieldDescs) != 4 {
t.Fatalf("fieldDescs len = %d, want 4", len(dbfArea.fieldDescs))
}
if dbfArea.nullFieldsIdx != 3 {
t.Fatalf("nullFieldsIdx = %d, want 3", dbfArea.nullFieldsIdx)
}
// NAME is field index 1, AGE is index 2. Bit assignment goes in
// descriptor order among nullable columns → NAME=bit0, AGE=bit1.
if bit, ok := dbfArea.nullBitOf[1]; !ok || bit != 0 {
t.Fatalf("NAME bit = %d ok=%v, want bit 0", bit, ok)
}
if bit, ok := dbfArea.nullBitOf[2]; !ok || bit != 1 {
t.Fatalf("AGE bit = %d ok=%v, want bit 1", bit, ok)
}
// Append three records: one fully populated, one with NIL name,
// one with both name and age NIL. The non-null row round-trips
// normally; the null rows must read back NIL.
if err := dbfArea.Append(); err != nil {
t.Fatal(err)
}
dbfArea.PutValue(0, hbrt.MakeInt(1))
dbfArea.PutValue(1, hbrt.MakeString("alice"))
dbfArea.PutValue(2, hbrt.MakeInt(30))
if err := dbfArea.Append(); err != nil {
t.Fatal(err)
}
dbfArea.PutValue(0, hbrt.MakeInt(2))
dbfArea.PutValue(1, hbrt.MakeNil()) // NAME null
dbfArea.PutValue(2, hbrt.MakeInt(40))
if err := dbfArea.Append(); err != nil {
t.Fatal(err)
}
dbfArea.PutValue(0, hbrt.MakeInt(3))
dbfArea.PutValue(1, hbrt.MakeNil()) // NAME null
dbfArea.PutValue(2, hbrt.MakeNil()) // AGE null
dbfArea.Flush()
dbfArea.Close()
// Re-open and verify null bits survive round-trip on disk.
area2, err := drv.Open(hbrdd.OpenParams{Path: path})
if err != nil {
t.Fatal(err)
}
defer area2.Close()
d2 := area2.(*DBFArea)
if d2.nullFieldsIdx != 3 {
t.Fatalf("after reopen nullFieldsIdx = %d, want 3", d2.nullFieldsIdx)
}
// Record 1: all values present.
d2.GoTo(1)
if v, _ := d2.GetValue(1); v.IsNil() {
t.Errorf("rec1 NAME unexpectedly NIL")
}
if v, _ := d2.GetValue(2); v.IsNil() {
t.Errorf("rec1 AGE unexpectedly NIL")
}
// Record 2: NAME null, AGE present.
d2.GoTo(2)
if v, _ := d2.GetValue(1); !v.IsNil() {
t.Errorf("rec2 NAME = %v, want NIL", v)
}
if v, _ := d2.GetValue(2); v.IsNil() || v.AsNumInt() != 40 {
t.Errorf("rec2 AGE = %v, want 40", v)
}
// Record 3: both null.
d2.GoTo(3)
if v, _ := d2.GetValue(1); !v.IsNil() {
t.Errorf("rec3 NAME = %v, want NIL", v)
}
if v, _ := d2.GetValue(2); !v.IsNil() {
t.Errorf("rec3 AGE = %v, want NIL", v)
}
}
// TestNullFlagsNoNullableFields ensures tables without any nullable
// columns get no _NullFlags column — keeping byte-identical layout to
// pre-nullable Five / upstream Harbour DBFs.
func TestNullFlagsNoNullableFields(t *testing.T) {
dir := tempDir(t)
path := filepath.Join(dir, "nonull.dbf")
drv := &DBFDriver{}
area, err := drv.Create(hbrdd.CreateParams{
Path: path,
Fields: []hbrdd.FieldInfo{
{Name: "ID", Type: 'N', Len: 4},
{Name: "NAME", Type: 'C', Len: 20},
},
})
if err != nil {
t.Fatal(err)
}
d := area.(*DBFArea)
if len(d.fieldDescs) != 2 {
t.Fatalf("fieldDescs len = %d, want 2 (no _NullFlags should be added)", len(d.fieldDescs))
}
if d.nullFieldsIdx != -1 {
t.Fatalf("nullFieldsIdx = %d, want -1", d.nullFieldsIdx)
}
d.Close()
}
// TestNullFlagsClearsOnOverwrite verifies that writing a non-NIL
// value to a previously-NULL field clears the bit and the raw bytes
// become observable on read.
func TestNullFlagsClearsOnOverwrite(t *testing.T) {
dir := tempDir(t)
path := filepath.Join(dir, "overwrite.dbf")
drv := &DBFDriver{}
area, _ := drv.Create(hbrdd.CreateParams{
Path: path,
Fields: []hbrdd.FieldInfo{
{Name: "V", Type: 'N', Len: 5, Dec: 0, Flags: FieldFlagNullable},
},
})
d := area.(*DBFArea)
d.Append()
d.PutValue(0, hbrt.MakeNil())
if v, _ := d.GetValue(0); !v.IsNil() {
t.Fatalf("after NIL put, GetValue = %v, want NIL", v)
}
// Overwrite with numeric — bit must clear.
d.PutValue(0, hbrt.MakeInt(42))
if v, _ := d.GetValue(0); v.IsNil() || v.AsNumInt() != 42 {
t.Fatalf("after int put, GetValue = %v, want 42", v)
}
// And NIL again — bit must reset.
d.PutValue(0, hbrt.MakeNil())
if v, _ := d.GetValue(0); !v.IsNil() {
t.Fatalf("after second NIL put, GetValue = %v, want NIL", v)
}
d.Close()
}

View File

@@ -175,6 +175,16 @@ type OrderCreateParams struct {
// Contract: caller must position the workarea (GoTo) before calling.
// Returns the key value for the current record.
KeyFunc func() hbrt.Value
// ForFunc is the compiled counterpart of KeyFunc for the optional
// FOR expression. When non-nil the indexer calls it instead of
// parsing ForExpr as a string and running it through the macro
// evaluator — eliminates strings.Index/ToUpper/splitArgs per record
// in filtered-index builds and rebuilds. Returns true when the
// current record should be included.
//
// Contract: caller must position the workarea (GoTo) before calling.
ForFunc func() bool
}
// OrderInfo holds information about an index order.

View File

@@ -24,6 +24,7 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
)
// --- Driver ---
@@ -103,14 +104,43 @@ func normalizeName(s string) string {
// --- Table (shared data) ---
type memTable struct {
mu sync.RWMutex
// mu serializes WRITERS only (Append/Delete/Recall/PutValue/Pack).
// Readers use records() — a lock-free atomic load of the current
// snapshot. Matches Harbour SHARED semantics: readers see a
// point-in-time view of the record slice; in-place field mutations
// are last-writer-wins (callers that need row consistency take an
// explicit RLock via the runtime's record-lock RTL).
mu sync.Mutex
// recordsP holds the current []memRecord snapshot. Stored as
// *[]memRecord to work with atomic.Pointer's typed API. Writers
// publish new slices via setRecords() after mutation; readers Load
// once per scan entry point.
recordsP atomic.Pointer[[]memRecord]
name string
fields []hbrdd.FieldInfo
records []memRecord // all records
indexes []*memIndex // active indexes
indexes []*memIndex // active indexes
openCount int
}
// records returns the current record snapshot. Caller can iterate
// without holding any lock — the slice is immutable from the reader's
// perspective (mutations happen via COW + atomic swap for structural
// changes; in-place field writes are racy-but-tolerated per Harbour
// SHARED semantics).
func (tbl *memTable) records() []memRecord {
p := tbl.recordsP.Load()
if p == nil {
return nil
}
return *p
}
// setRecords publishes a new snapshot. Caller must hold tbl.mu.
func (tbl *memTable) setRecords(r []memRecord) {
tbl.recordsP.Store(&r)
}
type memRecord struct {
data []hbrt.Value // field values (0-based)
deleted bool
@@ -159,7 +189,7 @@ func newMemArea(tbl *memTable, alias string, drv *MemDriver) *memArea {
eof: true,
curIndex: -1,
}
if len(tbl.records) > 0 {
if len(tbl.records()) > 0 {
a.recNo = 1
a.eof = false
}
@@ -213,9 +243,7 @@ func (a *memArea) ClearFilter() error {
func (a *memArea) HasFilter() bool { return a.filterBlock != nil }
func (a *memArea) GoTo(recNo uint32) error {
a.tbl.mu.RLock()
count := uint32(len(a.tbl.records))
a.tbl.mu.RUnlock()
count := uint32(len(a.tbl.records()))
a.bof = false
a.found = false
@@ -230,9 +258,7 @@ func (a *memArea) GoTo(recNo uint32) error {
}
func (a *memArea) GoTop() error {
a.tbl.mu.RLock()
count := uint32(len(a.tbl.records))
a.tbl.mu.RUnlock()
count := uint32(len(a.tbl.records()))
a.bof = false
a.found = false
@@ -261,9 +287,7 @@ func (a *memArea) GoTop() error {
}
func (a *memArea) GoBottom() error {
a.tbl.mu.RLock()
count := uint32(len(a.tbl.records))
a.tbl.mu.RUnlock()
count := uint32(len(a.tbl.records()))
a.bof = false
a.found = false
@@ -296,9 +320,7 @@ func (a *memArea) Skip(count int64) error {
return a.skipIndexed(count)
}
a.tbl.mu.RLock()
total := uint32(len(a.tbl.records))
a.tbl.mu.RUnlock()
total := uint32(len(a.tbl.records()))
a.found = false
@@ -335,7 +357,7 @@ func (a *memArea) skipIndexed(count int64) error {
newPos := a.indexPos + int(count)
if newPos >= len(idx.entries) {
a.indexPos = len(idx.entries)
a.recNo = uint32(len(a.tbl.records)) + 1
a.recNo = uint32(len(a.tbl.records())) + 1
a.eof = true
} else {
a.indexPos = newPos
@@ -365,19 +387,16 @@ func (a *memArea) skipIndexed(count int64) error {
func (a *memArea) RecNo() uint32 { return a.recNo }
func (a *memArea) RecCount() (uint32, error) {
a.tbl.mu.RLock()
defer a.tbl.mu.RUnlock()
return uint32(len(a.tbl.records)), nil
return uint32(len(a.tbl.records())), nil
}
func (a *memArea) Deleted() bool {
a.tbl.mu.RLock()
defer a.tbl.mu.RUnlock()
recs := a.tbl.records()
i := int(a.recNo) - 1
if i < 0 || i >= len(a.tbl.records) {
if i < 0 || i >= len(recs) {
return false
}
return a.tbl.records[i].deleted
return recs[i].deleted
}
// --- Field access ---
@@ -392,14 +411,16 @@ func (a *memArea) GetFieldInfo(index int) hbrdd.FieldInfo {
}
func (a *memArea) GetValue(fieldIndex int) (hbrt.Value, error) {
a.tbl.mu.RLock()
defer a.tbl.mu.RUnlock()
// Hot path — lock-free read. The atomic load gives us a
// point-in-time snapshot; a concurrent PutValue mutating the same
// rec.data[fieldIndex] in place is tolerated (last-writer-wins,
// matches Harbour SHARED semantics).
recs := a.tbl.records()
i := int(a.recNo) - 1
if i < 0 || i >= len(a.tbl.records) {
if i < 0 || i >= len(recs) {
return hbrt.MakeNil(), nil // phantom record
}
rec := a.tbl.records[i]
rec := recs[i]
if fieldIndex < 0 || fieldIndex >= len(rec.data) {
return hbrt.MakeNil(), fmt.Errorf("field index %d out of range", fieldIndex)
}
@@ -410,14 +431,20 @@ func (a *memArea) PutValue(fieldIndex int, val hbrt.Value) error {
a.tbl.mu.Lock()
defer a.tbl.mu.Unlock()
recs := a.tbl.records()
i := int(a.recNo) - 1
if i < 0 || i >= len(a.tbl.records) {
if i < 0 || i >= len(recs) {
return fmt.Errorf("no current record")
}
if fieldIndex < 0 || fieldIndex >= len(a.tbl.records[i].data) {
if fieldIndex < 0 || fieldIndex >= len(recs[i].data) {
return fmt.Errorf("field index %d out of range", fieldIndex)
}
a.tbl.records[i].data[fieldIndex] = val
// In-place field write. Writers are serialized by mu; concurrent
// readers may observe the old or new value (no torn read since
// hbrt.Value fits in a single machine word + pointer, and Go
// guarantees pointer-sized stores are atomic). Matches Harbour
// SHARED: callers needing isolation take an explicit record lock.
recs[i].data[fieldIndex] = val
return nil
}
@@ -445,8 +472,14 @@ func (a *memArea) Append() error {
rec.data[j] = hbrt.MakeNil()
}
}
a.tbl.records = append(a.tbl.records, rec)
a.recNo = uint32(len(a.tbl.records))
// Append: publish a grown slice via atomic swap. When the backing
// has capacity, Go's append reuses it — safe here because prior
// readers hold snapshots whose len() bounds are fixed, so they
// never read past their known length into the new slot.
recs := a.tbl.records()
recs = append(recs, rec)
a.tbl.setRecords(recs)
a.recNo = uint32(len(recs))
a.eof = false
a.bof = false
return nil
@@ -455,9 +488,10 @@ func (a *memArea) Append() error {
func (a *memArea) Delete() error {
a.tbl.mu.Lock()
defer a.tbl.mu.Unlock()
recs := a.tbl.records()
i := int(a.recNo) - 1
if i >= 0 && i < len(a.tbl.records) {
a.tbl.records[i].deleted = true
if i >= 0 && i < len(recs) {
recs[i].deleted = true
}
return nil
}
@@ -465,9 +499,10 @@ func (a *memArea) Delete() error {
func (a *memArea) Recall() error {
a.tbl.mu.Lock()
defer a.tbl.mu.Unlock()
recs := a.tbl.records()
i := int(a.recNo) - 1
if i >= 0 && i < len(a.tbl.records) {
a.tbl.records[i].deleted = false
if i >= 0 && i < len(recs) {
recs[i].deleted = false
}
return nil
}
@@ -475,13 +510,16 @@ func (a *memArea) Recall() error {
func (a *memArea) Pack() error {
a.tbl.mu.Lock()
defer a.tbl.mu.Unlock()
var kept []memRecord
for _, r := range a.tbl.records {
// Pack builds a fresh slice and swaps — old snapshot still
// iterable by any in-flight readers until they finish.
old := a.tbl.records()
kept := make([]memRecord, 0, len(old))
for _, r := range old {
if !r.deleted {
kept = append(kept, r)
}
}
a.tbl.records = kept
a.tbl.setRecords(kept)
a.recNo = 1
if len(kept) == 0 {
a.eof = true
@@ -492,7 +530,7 @@ func (a *memArea) Pack() error {
func (a *memArea) Zap() error {
a.tbl.mu.Lock()
defer a.tbl.mu.Unlock()
a.tbl.records = nil
a.tbl.setRecords(nil)
a.tbl.indexes = nil
a.recNo = 1
a.eof = true
@@ -512,7 +550,7 @@ func (a *memArea) CreateIndex(tag string, fieldIndex int, desc bool) {
}
// Build entries
for i, rec := range a.tbl.records {
for i, rec := range a.tbl.records() {
if rec.deleted {
continue
}
@@ -595,7 +633,7 @@ func (a *memArea) Seek(key hbrt.Value, soft bool) bool {
// Not found
a.found = false
a.eof = true
a.recNo = uint32(len(a.tbl.records)) + 1
a.recNo = uint32(len(a.tbl.records())) + 1
return false
}

View File

@@ -1,16 +1,86 @@
//go:build windows
// Windows mmap — CreateFileMappingW + MapViewOfFile. Read-only view
// matches the POSIX PROT_READ|MAP_SHARED semantics the rest of the RDD
// code expects. The mapping HANDLE must stay alive alongside the view,
// so we stash it in a package-local registry keyed by the slice data
// pointer; Unmap looks it up and closes the handle after unmapping
// the view.
package ntx
import (
"errors"
"fmt"
"os"
"sync"
"syscall"
"unsafe"
)
const (
pageReadonly = 0x02
fileMapRead = 0x0004
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procCreateFileMappingW = kernel32.NewProc("CreateFileMappingW")
procMapViewOfFile = kernel32.NewProc("MapViewOfFile")
procUnmapViewOfFile = kernel32.NewProc("UnmapViewOfFile")
procCloseHandle = kernel32.NewProc("CloseHandle")
// handle registry — keyed by view pointer (uintptr of slice's
// first byte). Lets munmapFile recover the mapping handle given
// only the []byte the caller held.
mappingMu sync.Mutex
mappings = map[uintptr]syscall.Handle{}
)
func mmapFile(f *os.File, size int) ([]byte, error) {
return nil, errors.New("mmap not supported on Windows")
if size <= 0 {
return nil, fmt.Errorf("mmap: non-positive size %d", size)
}
hFile := syscall.Handle(f.Fd())
sizeHigh := uint32(uint64(size) >> 32)
sizeLow := uint32(uint64(size) & 0xFFFFFFFF)
hMap, _, err := procCreateFileMappingW.Call(
uintptr(hFile), 0, pageReadonly,
uintptr(sizeHigh), uintptr(sizeLow), 0,
)
if hMap == 0 {
return nil, fmt.Errorf("CreateFileMapping: %v", err)
}
addr, _, err := procMapViewOfFile.Call(
hMap, fileMapRead, 0, 0, uintptr(size),
)
if addr == 0 {
procCloseHandle.Call(hMap)
return nil, fmt.Errorf("MapViewOfFile: %v", err)
}
data := unsafe.Slice((*byte)(unsafe.Pointer(addr)), size)
mappingMu.Lock()
mappings[addr] = syscall.Handle(hMap)
mappingMu.Unlock()
return data, nil
}
func munmapFile(data []byte) error {
if len(data) == 0 {
return nil
}
addr := uintptr(unsafe.Pointer(&data[0]))
mappingMu.Lock()
hMap, ok := mappings[addr]
delete(mappings, addr)
mappingMu.Unlock()
r, _, err := procUnmapViewOfFile.Call(addr)
if r == 0 {
return fmt.Errorf("UnmapViewOfFile: %v", err)
}
if ok {
procCloseHandle.Call(uintptr(hMap))
}
return nil
}

View File

@@ -288,7 +288,8 @@ func (idx *Index) remapFile() {
idx.mmapFile()
}
func (idx *Index) KeyLen() int { return idx.keyLen }
func (idx *Index) KeyLen() int { return idx.keyLen }
func (idx *Index) KeyExpr() string { return idx.header.GetKeyExpr() }
func (idx *Index) TestGetMmap() []byte { return idx.mmapData }
func (idx *Index) Close() error {

View File

@@ -203,6 +203,25 @@ func parseAreaNum(s string) uint16 {
return uint16(n)
}
// EnumerateAreas invokes fn once per open workarea with (nWA, alias, area).
// Snapshot of the slot→area map is taken first so fn can safely manipulate
// workareas without mutating the loop. Used by the diagnostic ErrorLog
// writer to dump every open table's state.
func (wm *WorkAreaManager) EnumerateAreas(fn func(nWA uint16, alias string, area Area)) {
type slot struct {
num uint16
alias string
area Area
}
snapshot := make([]slot, 0, len(wm.areas))
for num, area := range wm.areas {
snapshot = append(snapshot, slot{num, area.Alias(), area})
}
for _, s := range snapshot {
fn(s.num, s.alias, s.area)
}
}
// CloseAll closes all open work areas.
func (wm *WorkAreaManager) CloseAll() {
for num, area := range wm.areas {