Files
five/hbrdd/mem/memrdd.go
CharlesKWON cde86730b8 fix(compiler,hbrt,hbrdd,cli): pre-1.0 audit — 13 critical fixes
Senior-engineer / QA audit landed 13 silent-miscompile and data-
integrity fixes spanning the whole compiler+runtime+storage stack.
Each fix is paired with either an integration test in the suite or
a focused regression check; all 6 release gates stay green:
go test ./..., FiveSql2 43/43, Harbour compat 56/56, std.ch 17/17,
FRB 7/7, examples 65/71.

Compiler
--------

* genpc IF/ELSEIF jumpEnd2 patching (compiler/genpc/genpc.go).
  Per-ELSEIF branch terminators were stashed into `_ = jumpEnd2`
  and never patched — the relative offset stayed 0 and the runtime
  walked the next ELSEIF's PcOpJumpFalse opcode as if it were
  jump-offset data. Bytecode-level corruption in pcode mode. Now
  collected into a slice and patched at end-of-IF. Verified via
  Grade(95..50) cases 11a-e added to tests/frb/test_frb_pcode_sweep.

* countLocalsInStmts / scanBodyLocals missing bodies
  (compiler/gengo/gen_util.go, compiler/gengo/gengo.go). Frame-size
  counter skipped WATCH/TIMEOUT/PARALLEL FOR bodies, so a LOCAL
  declared inside one of those constructs got a slot index past
  the runtime's allocated count — silent NIL reads or out-of-range
  stomps.

* emitMethodDeclStandalone nested LOCAL (compiler/gengo/gen_class.go).
  Same bug class but on the *method* side. Pre-fix repro:

      METHOD Stomp(n) CLASS T
         LOCAL a := 1, b := 2
         IF n > 0
            LOCAL c := 30, d := 40, e := 50, f := 60
            Inner( n )
            IF c != 30 .OR. d != 40 .OR. e != 50 .OR. f != 60 ...

  printed `c, d, e, f = 5, NIL, NIL, NIL` because Inner's frame
  collided with Stomp's underallocated slot range. Now counts
  body-nested LOCALs into the frame and pre-allocates indices via
  scanBodyLocals.

* genpc unsupported-AST diagnostic surface (compiler/genpc/genpc.go,
  hbrt/pcode.go, cmd/five/main.go, hbrtl/frb.go). The `default`
  cases in emitStmt / emitExpr silently emitted PushNil / no-op
  for nodes the pcode generator doesn't implement (ClassDecl,
  MethodDecl, xBase commands, concurrency primitives, …). Added
  `PcodeModule.Warnings []string` populated by noteUnsupported,
  surfaced on stderr from the build pipeline. Users now see
  "pcode: AST node not supported in --pcode/FRB-pcode mode: stmt
  *ast.GoBlockStmt" instead of getting a silently broken module.

Runtime
-------

* class.go Send/tryBinaryOp t.self defer-restore (hbrt/class.go).
  Restoration was a plain `t.self = oldSelf` after `fn(t)`. Any
  panic in the method body skipped the line, so the next BEGIN
  SEQUENCE / RECOVER handler ran with the THROWING object's Self
  — `::field` resolved against the wrong receiver. Wrapped both
  restore sites in `defer func() { t.self = oldSelf }()`.
  Verified: pre-fix RECOVER saw "THROWER", post-fix "OUTER".

* hbfunc.go HB_FUNC parameter Frame() (hbrt/hbfunc.go). The
  RegisterDynamicFunc wrapper called `fn(ctx)` without ever
  calling Frame, so `ctx.ParC(1)` / `ctx.Local(n)` read through
  `t.curFrame.localBase + n - 1` against the *caller's* frame.
  Every #pragma BEGINDUMP HB_FUNC taking parameters silently
  returned "" / 0 / "" for them — masked by ParNIDef-style
  defaults. Wrapper now does `t.Frame(t.pendingParams, 0); defer
  t.EndProc()` before dispatch.

* pcode codeblock closure capture (hbrt/pcinterp.go, hbrt/pcode.go,
  hbrt/thread.go, compiler/genpc/genpc.go). PcOpPushBlock recorded
  `nDetached` but never copied enclosing locals; free vars in the
  block body fell through to memvar lookup → NIL. Wired full
  capture pipeline:
  - New opcodes PcOpPushDetached (0x59) / PcOpPopDetached (0x5A).
  - PushBlock now reads per-slot source-local indices and
    snapshots into bb.Detached at construction time.
  - New detachedMap in genpc auto-promotes any free var that
    resolves to an enclosing-frame local into a capture slot.
  - emitAssignAsExpr leaves the assigned value on the eval stack
    so SeqExpr items like `{|v| acc += v, acc }` work.
  - Thread tracks curBlock with paired Set/restore in the block's
    Fn wrapper for nested-block evaluation.
  Mutating capture (acc += v across successive Evals) now works.

* vm.NewThread statics + waFactory propagation (hbrt/vm.go).
  GoLaunch / GoLaunchBlock call NewThread directly. Previously
  the statics map and WA factory were applied only in Run(), so
  goroutine-spawned PRG code panicked on STATIC access ("static
  index out of range") and crashed dereferencing nil WA on any
  DB call. Both now happen inside NewThread under the same lock
  as TID assignment.

Data layer
----------

* dbf concurrent Append lock (hbrdd/dbf/dbf.go,
  hbrdd/dbf/locks_posix.go, hbrdd/dbf/locks_windows.go). Append
  bumped a local recCount with no file-system serialization. Two
  shared-mode processes both wrote at the same RecordOffset; one
  record silently overwrote the other. Added an append-intent
  byte-range lock at offset 0x7FFFFFFE + bounded retry, on-disk
  header refresh inside the locked region, and immediate header
  write so peers refresh past our slot.

* indexer negative numeric key encoding (hbrdd/dbf/indexer.go +
  new hbrdd/dbf/encode_numeric_test.go). `%20.10f` formats `-100`
  as `"     -100.0000000000"` and `99` as `"        99.0000000000"`.
  ASCII ' ' (0x20) < '-' (0x2D), so `99` lex-compared LESS than
  `-100` — every NTX/CDX index over a column that ever held a
  negative number returned wrong rows for SEEK / range scans.
  Replaced with a 1-byte sign prefix + 21-byte zero-padded
  magnitude (negatives use digit-complement) so byte order
  matches numeric order across signs and magnitudes. Format
  change: existing indexes built with the old encoding must be
  REINDEXed. Three unit tests pin the order.

* dbf Append index maintenance hooks (hbrdd/dbf/dbf.go,
  hbrdd/dbf/indexer.go). Append never inserted into open NTX/CDX
  indexes — the audit's canonical scenario `SET INDEX TO …;
  APPEND BLANK; REPLACE …; dbSeek …` silently missed the new
  record. Added optional IndexWriter interface, queue the new
  recNo in pendingIdxInserts, drain after flushRecord by calling
  InsertKey on every open writer-supporting engine. NTX
  participates (its existing rebuild-on-insert is correct);
  CDX online maintenance is deferred to a follow-up — those
  indexes still need REINDEX. Verified: post-fix SEEK("Charlie")
  after APPEND BLANK + REPLACE finds the new record.

* dbf PACK crash-safety (hbrdd/dbf/dbf.go). The old in-place
  rewrite read record N, overwrote slot M<N, then truncated.
  Power loss after partial loop left a file with overwritten
  prefix and no original copies of the records already advanced
  past — silent data loss. Rewrote to:
    1) drop mmap, build `<file>.pack.tmp` with all surviving
       records,
    2) Sync(),
    3) close original handle + os.Rename(tmp, orig) (atomic on
       same FS),
    4) reopen + re-mmap.
  TestComp_Pack passes; readers always see either the pre-PACK
  or post-PACK contents, never a half-state.

* mem RDD torn reads (hbrdd/mem/memrdd.go). The comment claimed
  in-place PutValue was safe because hbrt.Value "fits in a
  single machine word + pointer". hbrt.Value is 24 bytes (3
  words) — a concurrent reader could observe new type tag with
  stale scalar/ptr and type-confuse on the next AsXxx() call.
  Switched mu to sync.RWMutex; GetValue takes RLock,
  Append/PutValue/Delete/Recall take Lock. `go test -race
  ./hbrdd/mem/` clean.

Files touched
-------------

  compiler/gengo/gen_class.go, gen_util.go, gengo.go
  compiler/genpc/genpc.go
  hbrt/class.go, hbfunc.go, pcinterp.go, pcode.go, thread.go, vm.go
  hbrdd/dbf/dbf.go, indexer.go, locks_posix.go, locks_windows.go
  hbrdd/dbf/encode_numeric_test.go  (new)
  hbrdd/mem/memrdd.go
  cmd/five/main.go
  hbrtl/frb.go
  tests/frb/test_frb_pcode_sweep.prg

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 05:29:56 +09:00

716 lines
16 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
// memrdd.go — In-memory RDD for Five.
//
// Stores records as Go slices in RAM. No disk I/O at all.
// Supports full Area interface: CRUD, navigation, index, filter.
//
// Usage:
// USE "mem:customers" VIA "MEMRDD" NEW
// dbCreate("mem:temp", aStruct, "MEMRDD")
//
// Compared to file-based DBF:
// - 10-100x faster (no disk, no byte packing)
// - Data lost on exit (intentional — for temp tables)
// - Perfect for: query results, pivot tables, reports, caching
package mem
import (
"five/hbrdd"
"five/hbrt"
"fmt"
"sort"
"strings"
"sync"
"sync/atomic"
)
// --- Driver ---
// MemDriver implements hbrdd.Driver for in-memory tables.
type MemDriver struct{}
var (
tables = make(map[string]*memTable) // uppercase name → table
tablesMu sync.RWMutex
)
func (d *MemDriver) Name() string { return "MEMRDD" }
func (d *MemDriver) Open(params hbrdd.OpenParams) (hbrdd.Area, error) {
name := normalizeName(params.Path)
tablesMu.RLock()
tbl, ok := tables[name]
tablesMu.RUnlock()
if !ok {
return nil, fmt.Errorf("table not found: %s", params.Path)
}
tbl.mu.Lock()
tbl.openCount++
tbl.mu.Unlock()
return newMemArea(tbl, params.Alias, d), nil
}
func (d *MemDriver) Create(params hbrdd.CreateParams) (hbrdd.Area, error) {
name := normalizeName(params.Path)
// Callers carrying DBF-style fixed-width names (PadR to 10 chars)
// are common — the SQL engine pads names so the DBF header encodes
// cleanly. Memory tables have no fixed-width constraint; strip the
// padding so FieldPos / outer SELECT lookups don't miss on the
// trailing whitespace.
fields := make([]hbrdd.FieldInfo, len(params.Fields))
for i, f := range params.Fields {
f.Name = strings.TrimRight(f.Name, " ")
fields[i] = f
}
tbl := &memTable{
name: name,
fields: fields,
}
tablesMu.Lock()
tables[name] = tbl
tbl.openCount = 1
tablesMu.Unlock()
return newMemArea(tbl, params.Alias, d), nil
}
// DropTable removes a table from memory.
func DropTable(name string) {
tablesMu.Lock()
delete(tables, normalizeName(name))
tablesMu.Unlock()
}
// TableExists checks if a table exists in memory.
func TableExists(name string) bool {
tablesMu.RLock()
_, ok := tables[normalizeName(name)]
tablesMu.RUnlock()
return ok
}
func normalizeName(s string) string {
s = strings.TrimPrefix(s, "mem:")
return strings.ToUpper(strings.TrimSpace(s))
}
// --- Table (shared data) ---
type memTable struct {
// mu was previously a sync.Mutex with the comment that readers
// could safely race against in-place PutValue because hbrt.Value
// "fits in a single machine word + pointer". That assumption was
// wrong: hbrt.Value is 24 bytes (3 words). A concurrent reader
// could observe a half-written struct — type tag from the new
// value, scalar/ptr from the old — and crash on subsequent type
// dispatch.
//
// Switched to sync.RWMutex: PutValue/Append/Delete/Recall take
// Lock; GetValue/Skip/Seek/scan take RLock. Adds ~30ns per read
// to make field reads consistent. Still matches Harbour SHARED
// semantics — readers never see a partial write.
mu sync.RWMutex
// 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
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
}
type memIndex struct {
tag string
keyExpr string
keyFunc func(rec []hbrt.Value) hbrt.Value
entries []memIndexEntry // sorted
desc bool
}
type memIndexEntry struct {
key hbrt.Value
recNo uint32
}
// --- Area (per work area state) ---
type memArea struct {
tbl *memTable
alias string
driver *MemDriver
recNo uint32 // 1-based, 0 = phantom
bof bool
eof bool
found bool
curIndex int // -1 = natural order, 0+ = index
indexPos int // position in current index
closed bool
// Filter/Locate
filterExpr string
filterBlock func(*hbrt.Thread) bool
locateExpr string
locateBlock func(*hbrt.Thread) bool
}
func newMemArea(tbl *memTable, alias string, drv *MemDriver) *memArea {
a := &memArea{
tbl: tbl,
alias: alias,
driver: drv,
recNo: 0,
eof: true,
curIndex: -1,
}
if len(tbl.records()) > 0 {
a.recNo = 1
a.eof = false
}
return a
}
// --- Identity ---
func (a *memArea) Driver() hbrdd.Driver { return a.driver }
func (a *memArea) Alias() string { return a.alias }
func (a *memArea) SetAlias(s string) { a.alias = s }
// --- Lifecycle ---
func (a *memArea) Close() error {
if a.closed {
return nil
}
a.closed = true
a.tbl.mu.Lock()
a.tbl.openCount--
a.tbl.mu.Unlock()
return nil
}
func (a *memArea) Flush() error { return nil } // no-op: memory only
// --- Navigation ---
func (a *memArea) BOF() bool { return a.bof }
func (a *memArea) EOF() bool { return a.eof }
func (a *memArea) Found() bool { return a.found }
func (a *memArea) SetFound(b bool) { a.found = b }
func (a *memArea) SetLocate(expr string, block func(*hbrt.Thread) bool) {
a.locateExpr = expr
a.locateBlock = block
}
func (a *memArea) LocateBlock() func(*hbrt.Thread) bool { return a.locateBlock }
func (a *memArea) SetFilter(expr string, block func(*hbrt.Thread) bool) error {
a.filterExpr = expr
a.filterBlock = block
return nil
}
func (a *memArea) ClearFilter() error {
a.filterExpr = ""
a.filterBlock = nil
return nil
}
func (a *memArea) HasFilter() bool { return a.filterBlock != nil }
func (a *memArea) GoTo(recNo uint32) error {
count := uint32(len(a.tbl.records()))
a.bof = false
a.found = false
if recNo < 1 || recNo > count {
a.recNo = count + 1
a.eof = true
return nil
}
a.recNo = recNo
a.eof = false
return nil
}
func (a *memArea) GoTop() error {
count := uint32(len(a.tbl.records()))
a.bof = false
a.found = false
if a.curIndex >= 0 && a.curIndex < len(a.tbl.indexes) {
idx := a.tbl.indexes[a.curIndex]
if len(idx.entries) == 0 {
a.eof = true
a.recNo = count + 1
return nil
}
a.indexPos = 0
a.recNo = idx.entries[0].recNo
a.eof = false
return nil
}
if count == 0 {
a.eof = true
a.recNo = 1
return nil
}
a.recNo = 1
a.eof = false
return nil
}
func (a *memArea) GoBottom() error {
count := uint32(len(a.tbl.records()))
a.bof = false
a.found = false
if a.curIndex >= 0 && a.curIndex < len(a.tbl.indexes) {
idx := a.tbl.indexes[a.curIndex]
if len(idx.entries) == 0 {
a.eof = true
a.recNo = count + 1
return nil
}
a.indexPos = len(idx.entries) - 1
a.recNo = idx.entries[a.indexPos].recNo
a.eof = false
return nil
}
if count == 0 {
a.eof = true
a.recNo = 1
return nil
}
a.recNo = count
a.eof = false
return nil
}
func (a *memArea) Skip(count int64) error {
if a.curIndex >= 0 && a.curIndex < len(a.tbl.indexes) {
return a.skipIndexed(count)
}
total := uint32(len(a.tbl.records()))
a.found = false
if count > 0 {
a.bof = false
newRec := int64(a.recNo) + count
if newRec > int64(total) {
a.recNo = total + 1
a.eof = true
} else {
a.recNo = uint32(newRec)
a.eof = false
}
} else if count < 0 {
a.eof = false
newRec := int64(a.recNo) + count
if newRec < 1 {
a.recNo = 1
a.bof = true
} else {
a.recNo = uint32(newRec)
a.bof = false
}
}
return nil
}
func (a *memArea) skipIndexed(count int64) error {
idx := a.tbl.indexes[a.curIndex]
a.found = false
if count > 0 {
a.bof = false
newPos := a.indexPos + int(count)
if newPos >= len(idx.entries) {
a.indexPos = len(idx.entries)
a.recNo = uint32(len(a.tbl.records())) + 1
a.eof = true
} else {
a.indexPos = newPos
a.recNo = idx.entries[newPos].recNo
a.eof = false
}
} else if count < 0 {
a.eof = false
newPos := a.indexPos + int(count)
if newPos < 0 {
a.indexPos = 0
if len(idx.entries) > 0 {
a.recNo = idx.entries[0].recNo
}
a.bof = true
} else {
a.indexPos = newPos
a.recNo = idx.entries[newPos].recNo
a.bof = false
}
}
return nil
}
// --- Record info ---
func (a *memArea) RecNo() uint32 { return a.recNo }
func (a *memArea) RecCount() (uint32, error) {
return uint32(len(a.tbl.records())), nil
}
func (a *memArea) Deleted() bool {
recs := a.tbl.records()
i := int(a.recNo) - 1
if i < 0 || i >= len(recs) {
return false
}
return recs[i].deleted
}
// --- Field access ---
func (a *memArea) FieldCount() int { return len(a.tbl.fields) }
func (a *memArea) GetFieldInfo(index int) hbrdd.FieldInfo {
if index >= 0 && index < len(a.tbl.fields) {
return a.tbl.fields[index]
}
return hbrdd.FieldInfo{}
}
func (a *memArea) GetValue(fieldIndex int) (hbrt.Value, error) {
// Read under RLock so a concurrent PutValue (which holds Lock)
// completes its 24-byte hbrt.Value store before this read
// observes the field. Without the RLock the reader could see a
// half-written value — new type tag with stale scalar/ptr —
// which type-confuses on the next AsXxx() call.
a.tbl.mu.RLock()
defer a.tbl.mu.RUnlock()
recs := a.tbl.records()
i := int(a.recNo) - 1
if i < 0 || i >= len(recs) {
return hbrt.MakeNil(), nil // phantom record
}
rec := recs[i]
if fieldIndex < 0 || fieldIndex >= len(rec.data) {
return hbrt.MakeNil(), fmt.Errorf("field index %d out of range", fieldIndex)
}
return rec.data[fieldIndex], nil
}
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(recs) {
return fmt.Errorf("no current record")
}
if fieldIndex < 0 || fieldIndex >= len(recs[i].data) {
return fmt.Errorf("field index %d out of range", fieldIndex)
}
// 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
}
// --- Record operations ---
func (a *memArea) Append() error {
a.tbl.mu.Lock()
defer a.tbl.mu.Unlock()
rec := memRecord{
data: make([]hbrt.Value, len(a.tbl.fields)),
}
// Initialize with defaults
for j, f := range a.tbl.fields {
switch f.Type {
case 'C':
rec.data[j] = hbrt.MakeString(strings.Repeat(" ", f.Len))
case 'N', 'I', 'B':
rec.data[j] = hbrt.MakeInt(0)
case 'L':
rec.data[j] = hbrt.MakeBool(false)
case 'D':
rec.data[j] = hbrt.MakeDate(0)
default:
rec.data[j] = hbrt.MakeNil()
}
}
// 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
}
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(recs) {
recs[i].deleted = true
}
return nil
}
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(recs) {
recs[i].deleted = false
}
return nil
}
func (a *memArea) Pack() error {
a.tbl.mu.Lock()
defer a.tbl.mu.Unlock()
// 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.setRecords(kept)
a.recNo = 1
if len(kept) == 0 {
a.eof = true
}
return nil
}
func (a *memArea) Zap() error {
a.tbl.mu.Lock()
defer a.tbl.mu.Unlock()
a.tbl.setRecords(nil)
a.tbl.indexes = nil
a.recNo = 1
a.eof = true
return nil
}
// --- Index support ---
// CreateIndex builds an in-memory index on a field.
func (a *memArea) CreateIndex(tag string, fieldIndex int, desc bool) {
a.tbl.mu.Lock()
defer a.tbl.mu.Unlock()
idx := &memIndex{
tag: strings.ToUpper(tag),
desc: desc,
}
// Build entries
for i, rec := range a.tbl.records() {
if rec.deleted {
continue
}
var key hbrt.Value
if fieldIndex >= 0 && fieldIndex < len(rec.data) {
key = rec.data[fieldIndex]
} else {
key = hbrt.MakeNil()
}
idx.entries = append(idx.entries, memIndexEntry{
key: key,
recNo: uint32(i + 1),
})
}
// Sort
sort.SliceStable(idx.entries, func(i, j int) bool {
cmp := compareValues(idx.entries[i].key, idx.entries[j].key)
if desc {
return cmp > 0
}
return cmp < 0
})
a.tbl.indexes = append(a.tbl.indexes, idx)
a.curIndex = len(a.tbl.indexes) - 1
if len(idx.entries) > 0 {
a.indexPos = 0
a.recNo = idx.entries[0].recNo
a.eof = false
}
}
// Seek finds a key in the current index using binary search.
func (a *memArea) Seek(key hbrt.Value, soft bool) bool {
if a.curIndex < 0 || a.curIndex >= len(a.tbl.indexes) {
a.found = false
return false
}
idx := a.tbl.indexes[a.curIndex]
entries := idx.entries
// Binary search
lo, hi := 0, len(entries)-1
pos := len(entries) // default: past end
for lo <= hi {
mid := (lo + hi) / 2
cmp := compareValues(entries[mid].key, key)
if idx.desc {
cmp = -cmp
}
if cmp < 0 {
lo = mid + 1
} else if cmp > 0 {
pos = mid
hi = mid - 1
} else {
pos = mid
hi = mid - 1 // find first occurrence
}
}
if pos < len(entries) && compareValues(entries[pos].key, key) == 0 {
a.indexPos = pos
a.recNo = entries[pos].recNo
a.eof = false
a.found = true
return true
}
// Soft seek: position at first key >= target
if soft && pos < len(entries) {
a.indexPos = pos
a.recNo = entries[pos].recNo
a.eof = false
a.found = false
return false
}
// Not found
a.found = false
a.eof = true
a.recNo = uint32(len(a.tbl.records())) + 1
return false
}
// SetOrder sets the active index by tag name. -1 = natural order.
func (a *memArea) SetOrder(tag string) {
if tag == "" {
a.curIndex = -1
return
}
upper := strings.ToUpper(tag)
for i, idx := range a.tbl.indexes {
if idx.tag == upper {
a.curIndex = i
return
}
}
a.curIndex = -1
}
// --- Value comparison ---
func compareValues(a, b hbrt.Value) int {
if a.IsString() && b.IsString() {
sa, sb := a.AsString(), b.AsString()
if sa < sb {
return -1
}
if sa > sb {
return 1
}
return 0
}
if a.IsNumeric() && b.IsNumeric() {
fa, fb := a.AsNumDouble(), b.AsNumDouble()
if fa < fb {
return -1
}
if fa > fb {
return 1
}
return 0
}
if a.IsDate() && b.IsDate() {
ja, jb := a.AsJulian(), b.AsJulian()
if ja < jb {
return -1
}
if ja > jb {
return 1
}
return 0
}
if a.IsLogical() && b.IsLogical() {
ba, bb := a.AsBool(), b.AsBool()
if !ba && bb {
return -1
}
if ba && !bb {
return 1
}
return 0
}
return 0
}
// --- Registration ---
func init() {
hbrdd.RegisterDriver(&MemDriver{})
}