feat: transparent MEMO read/write + documentation update

DBFArea auto-manages FPT memo files:
- Create/Open: auto-creates/opens FPT when memo fields exist
- PutValue: string on MEMO field auto-writes to FPT
- GetValue: MEMO field auto-reads from FPT, returns string
- Close: auto-closes FPT

Documentation: Value methods, MEMVAR, SET, ErrorBlock, MEMO
added to five-syntax-ko.md and five-syntax-en.md (+480 lines)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 17:32:07 +09:00
parent 827adeeb99
commit 08c0ef13d4
4 changed files with 681 additions and 74 deletions

View File

@@ -17,6 +17,7 @@ import (
"five/hbrdd"
"fmt"
"os"
"strings"
)
// DBFArea implements the DBF database driver.
@@ -44,6 +45,9 @@ type DBFArea struct {
recCount uint32
ghost bool // at phantom record (after APPEND)
// Memo file (FPT)
memoFile *FPTFile
// Index integration (NTX/CDX)
idxState *indexState
}
@@ -92,6 +96,25 @@ func (d *dbfAliasDriver) Create(params hbrdd.CreateParams) (hbrdd.Area, error) {
return createDBF(&DBFDriver{}, params)
}
// fptPathFromDBF returns the FPT memo file path for a given DBF path.
func fptPathFromDBF(dbfPath string) string {
base := dbfPath
if idx := strings.LastIndex(base, "."); idx >= 0 {
base = base[:idx]
}
return base + ".fpt"
}
// hasMemoField checks if any field descriptor is a MEMO type.
func hasMemoField(fields []FieldDesc) bool {
for _, f := range fields {
if f.Type == 'M' || f.Type == 'm' {
return true
}
}
return false
}
// --- Open ---
// Harbour: hb_dbfOpen in dbf1.c
func openDBF(drv *DBFDriver, params hbrdd.OpenParams) (*DBFArea, error) {
@@ -178,7 +201,16 @@ func openDBF(drv *DBFDriver, params hbrdd.OpenParams) (*DBFArea, error) {
}
area.InitFields(fieldInfos)
// Step 8: Position at first record
// Step 8: Auto-open FPT if memo fields exist
if hasMemoField(fields) {
fptPath := fptPathFromDBF(path)
if fpt, err := OpenFPT(fptPath); err == nil {
area.memoFile = fpt
}
// If FPT doesn't exist, memo reads return empty string
}
// Step 9: Position at first record
area.FEof = (area.recCount == 0)
if area.recCount > 0 {
area.GoTo(1)
@@ -212,9 +244,14 @@ func createDBF(drv *DBFDriver, params hbrdd.CreateParams) (*DBFArea, error) {
}
// Build header
hasMemo := hasMemoField(fieldDescs)
headerLen := uint16(HeaderSize + len(fieldDescs)*FieldDescSize + 1) // +1 for terminator
version := byte(VersionDBF3)
if hasMemo {
version = VersionFPT
}
hdr := Header{
Version: VersionDBF3,
Version: version,
RecCount: 0,
HeaderLen: headerLen,
RecordLen: recordLen,
@@ -256,6 +293,17 @@ func createDBF(drv *DBFDriver, params hbrdd.CreateParams) (*DBFArea, error) {
area.InitFields(fieldInfos)
area.FEof = true
// Auto-create FPT if memo fields exist
if hasMemo {
fptPath := fptPathFromDBF(path)
fpt, err := CreateFPT(fptPath, FPTDefaultBlock)
if err != nil {
f.Close()
return nil, fmt.Errorf("create memo file: %w", err)
}
area.memoFile = fpt
}
return area, nil
}
@@ -268,11 +316,18 @@ func (a *DBFArea) Close() error {
a.flushRecord()
}
a.updateHeader()
if a.memoFile != nil {
a.memoFile.Close()
a.memoFile = nil
}
err := a.dataFile.Close()
a.BaseArea.Close()
return err
}
// MemoFile returns the FPT memo file, or nil if no memo fields.
func (a *DBFArea) MemoFile() *FPTFile { return a.memoFile }
func (a *DBFArea) Flush() error {
if a.dirty {
if err := a.flushRecord(); err != nil {
@@ -451,7 +506,21 @@ func (a *DBFArea) GetValue(fieldIndex int) (hbrt.Value, error) {
if a.FEof {
return hbrt.MakeNil(), nil
}
return GetFieldValue(a.recBuf, a.offsets[fieldIndex], &a.fieldDescs[fieldIndex]), nil
fd := &a.fieldDescs[fieldIndex]
// MEMO field: read from FPT and return string
if (fd.Type == 'M' || fd.Type == 'm') && a.memoFile != nil {
blockVal := GetFieldValue(a.recBuf, a.offsets[fieldIndex], fd)
blockNo := uint32(blockVal.AsNumInt())
if blockNo == 0 {
return hbrt.MakeString(""), nil
}
data, err := a.memoFile.ReadMemo(blockNo)
if err != nil {
return hbrt.MakeString(""), nil
}
return hbrt.MakeString(string(data)), nil
}
return GetFieldValue(a.recBuf, a.offsets[fieldIndex], fd), nil
}
func (a *DBFArea) PutValue(fieldIndex int, val hbrt.Value) error {
@@ -461,7 +530,19 @@ 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)
}
PutFieldValue(a.recBuf, a.offsets[fieldIndex], &a.fieldDescs[fieldIndex], val)
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() {
data := []byte(val.AsString())
blockNo, err := a.memoFile.WriteMemo(data)
if err != nil {
return fmt.Errorf("write memo: %w", err)
}
putMemoRef(a.recBuf[a.offsets[fieldIndex]:a.offsets[fieldIndex]+uint16(fd.Len)], fd.Len, blockNo)
a.dirty = true
return nil
}
PutFieldValue(a.recBuf, a.offsets[fieldIndex], fd, val)
a.dirty = true
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"math"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -199,77 +200,58 @@ func TestDBFCDX_Create100(t *testing.T) {
}
}
// TestDBF_MemoField tests memo field with FPT file.
// TestDBF_MemoField tests transparent memo field read/write with auto-managed FPT.
func TestDBF_MemoField(t *testing.T) {
dir := t.TempDir()
dbfPath := filepath.Join(dir, "memo_test")
fptPath := dbfPath + ".fpt"
// Create DBF with memo field
// Create DBF with memo field — FPT auto-created
drv := &DBFDriver{}
area, err := drv.Create(hbrdd.CreateParams{
Path: dbfPath,
Fields: []hbrdd.FieldInfo{
{Name: "ID", Type: 'N', Len: 5},
{Name: "TITLE", Type: 'C', Len: 50},
{Name: "NOTES", Type: 'M', Len: 10}, // Memo field
{Name: "NOTES", Type: 'M', Len: 10},
},
})
if err != nil {
t.Fatal(err)
}
// Create FPT file alongside DBF
fpt, err := CreateFPT(fptPath, 64)
if err != nil {
area.Close()
t.Fatal(err)
}
// Insert 100 records with memo data
// Insert 100 records with memo data (transparent write)
for i := 1; i <= 100; i++ {
area.Append()
area.PutValue(0, hbrt.MakeInt(i))
area.PutValue(1, hbrt.MakeString(fmt.Sprintf("Item %d", i)))
// Write memo to FPT and store block number in DBF
memoText := fmt.Sprintf("This is memo #%d with some longer text for testing. Record number: %d. "+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt.", i, i)
blockNo, err := fpt.WriteMemo([]byte(memoText))
if err != nil {
t.Fatalf("write memo %d: %v", i, err)
}
area.PutValue(2, hbrt.MakeLong(int64(blockNo)))
area.PutValue(2, hbrt.MakeString(memoText))
area.Flush()
}
area.Close()
fpt.Close()
// Reopen and verify
// Verify FPT file exists
if _, err := os.Stat(fptPath); err != nil {
t.Error("FPT memo file should exist")
}
// Verify no DBT file
dbtPath := dbfPath + ".dbt"
if _, err := os.Stat(dbtPath); err == nil {
t.Error("DBT file should NOT exist for FPT")
}
// Reopen and verify (transparent read)
area2, err := drv.Open(hbrdd.OpenParams{Path: dbfPath})
if err != nil {
t.Fatal(err)
}
defer area2.Close()
fpt2, err := OpenFPT(fptPath)
if err != nil {
t.Fatal(err)
}
defer fpt2.Close()
// Verify FPT file exists (CDX uses FPT, not DBT)
if _, err := os.Stat(fptPath); err != nil {
t.Error("FPT memo file should exist")
}
// Verify no DBT file (CDX doesn't use DBT)
dbtPath := dbfPath + ".dbt"
if _, err := os.Stat(dbtPath); err == nil {
t.Error("DBT file should NOT exist for CDX/FPT")
}
rc, _ := area2.RecCount()
if rc != 100 {
t.Fatalf("reccount = %d, want 100", rc)
@@ -277,17 +259,8 @@ func TestDBF_MemoField(t *testing.T) {
// Read and verify memo for record 1
area2.GoTo(1)
memoBlockVal, _ := area2.GetValue(2)
blockNo := uint32(memoBlockVal.AsNumInt())
if blockNo == 0 {
t.Fatal("rec1 memo block should not be 0")
}
memoData, err := fpt2.ReadMemo(blockNo)
if err != nil {
t.Fatal(err)
}
memoStr := string(memoData)
v, _ := area2.GetValue(2)
memoStr := v.AsString()
if len(memoStr) < 10 {
t.Errorf("rec1 memo too short: %d bytes", len(memoStr))
}
@@ -297,37 +270,21 @@ func TestDBF_MemoField(t *testing.T) {
// Read and verify memo for record 50
area2.GoTo(50)
memoBlockVal, _ = area2.GetValue(2)
blockNo = uint32(memoBlockVal.AsNumInt())
memoData, err = fpt2.ReadMemo(blockNo)
if err != nil {
t.Fatal(err)
}
memoStr = string(memoData)
v, _ = area2.GetValue(2)
memoStr = v.AsString()
if memoStr[:17] != "This is memo #50 " {
t.Errorf("rec50 memo start = %q", memoStr[:20])
}
// Read and verify memo for record 100
area2.GoTo(100)
memoBlockVal, _ = area2.GetValue(2)
blockNo = uint32(memoBlockVal.AsNumInt())
memoData, err = fpt2.ReadMemo(blockNo)
if err != nil {
t.Fatal(err)
}
memoStr = string(memoData)
v, _ = area2.GetValue(2)
memoStr = v.AsString()
if memoStr[:18] != "This is memo #100 " {
t.Errorf("rec100 memo start = %q", memoStr[:20])
}
// Verify FPT block size
if fpt2.blockSize != 64 {
t.Errorf("FPT block size = %d, want 64", fpt2.blockSize)
}
t.Logf("Memo test: 100 records with FPT memo verified")
t.Logf("FPT block size: %d, next block: %d", fpt2.blockSize, fpt2.header.NextBlock)
t.Logf("Memo test: 100 records with FPT memo verified (transparent API)")
}
// TestDBFCDX_MemoIsFPT confirms that DBFCDX uses FPT format, not DBT.
@@ -419,7 +376,7 @@ func TestDBF_AllFieldTypes(t *testing.T) {
area.PutValue(2, hbrt.MakeInt(999999))
area.PutValue(3, hbrt.MakeBool(true))
area.PutValue(4, hbrt.MakeDate(dateToJulian(2026, 3, 28)))
area.PutValue(5, hbrt.MakeLong(0)) // empty memo
area.PutValue(5, hbrt.MakeString("Memo for rec1")) // memo text
area.Flush()
area.Append()
@@ -428,7 +385,7 @@ func TestDBF_AllFieldTypes(t *testing.T) {
area.PutValue(2, hbrt.MakeInt(-1)) // negative
area.PutValue(3, hbrt.MakeBool(false))
area.PutValue(4, hbrt.MakeDate(0)) // empty date
area.PutValue(5, hbrt.MakeLong(0))
area.PutValue(5, hbrt.MakeString("")) // empty memo
area.Flush()
area.Close()
@@ -485,3 +442,88 @@ func TestDBF_AllFieldTypes(t *testing.T) {
t.Log("All field types test passed")
}
// TestDBF_TransparentMemo tests transparent MEMO read/write through PutValue/GetValue.
// User writes a string → DBF auto-writes to FPT → user reads back a string.
func TestDBF_TransparentMemo(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "memo_transparent")
drv := &DBFDriver{}
area, err := drv.Create(hbrdd.CreateParams{
Path: path,
Fields: []hbrdd.FieldInfo{
{Name: "NAME", Type: 'C', Len: 20},
{Name: "NOTES", Type: 'M', Len: 10},
},
})
if err != nil {
t.Fatal(err)
}
// Verify FPT was auto-created
dbfArea := area.(*DBFArea)
if dbfArea.MemoFile() == nil {
t.Fatal("FPT should be auto-created for MEMO fields")
}
// Record 1: normal memo
area.Append()
area.PutValue(0, hbrt.MakeString("Alice"))
area.PutValue(1, hbrt.MakeString("This is Alice's memo with some longer text for testing."))
area.Flush()
// Record 2: empty memo
area.Append()
area.PutValue(0, hbrt.MakeString("Bob"))
area.PutValue(1, hbrt.MakeString(""))
area.Flush()
// Record 3: large memo
largeMemo := strings.Repeat("Large memo data. ", 100) // ~1700 bytes
area.Append()
area.PutValue(0, hbrt.MakeString("Charlie"))
area.PutValue(1, hbrt.MakeString(largeMemo))
area.Flush()
area.Close()
// Reopen and verify transparent read
area2, err := drv.Open(hbrdd.OpenParams{Path: path})
if err != nil {
t.Fatal(err)
}
defer area2.Close()
// Verify FPT auto-opened
if area2.(*DBFArea).MemoFile() == nil {
t.Fatal("FPT should be auto-opened on Open")
}
// Record 1
area2.GoTo(1)
v, _ := area2.GetValue(0)
if strings.TrimSpace(v.AsString()) != "Alice" {
t.Errorf("rec1 NAME = %q", v.AsString())
}
v, _ = area2.GetValue(1)
if v.AsString() != "This is Alice's memo with some longer text for testing." {
t.Errorf("rec1 NOTES = %q", v.AsString())
}
// Record 2: empty memo
area2.GoTo(2)
v, _ = area2.GetValue(1)
if v.AsString() != "" {
t.Errorf("rec2 NOTES should be empty, got %q", v.AsString())
}
// Record 3: large memo
area2.GoTo(3)
v, _ = area2.GetValue(1)
if v.AsString() != largeMemo {
t.Errorf("rec3 NOTES length = %d, want %d", len(v.AsString()), len(largeMemo))
}
t.Logf("Transparent MEMO: 3 records verified (normal/empty/large)")
}