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