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

Highlights:

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

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

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

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

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

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

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()
}