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>
179 lines
5.0 KiB
Go
179 lines
5.0 KiB
Go
// 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()
|
|
}
|