feat: CDX support + ORDSCOPE + cross-read Harbour compatibility
CDX Integration:
- IndexEngine interface: common for NTX Index and CDX Tag
- OrderListAdd: auto-detects .cdx/.ntx extension, opens CDX tags
- decodeCompoundLeaf: proper bit-packed tag directory decoding
(was stub falling through to scanCompoundLeaves with wrong names)
- CDX Tag: added KeyLen(), KeyExpr(), ForExpr(), IsDescending(), Close()
- CDX compound recNo = direct byte offset (not page number)
ORDSCOPE:
- SetScope/ClearScope/SetScopeTop/SetScopeBottom on DBFArea
- GoTopIndexed: seeks to scopeTop, validates within scopeBottom
- GoBottomIndexed: seeks to scopeBottom boundary
- SkipIndexed: stops at scope boundaries (top and bottom)
- OrdScope RTL function registered (nScope: 0=TOP, 1=BOTTOM)
- scopeKeyFromValue: converts Value to padded key bytes
Index Order Management:
- OrderListFocus: handles numeric order ("2" → order 2)
- SET ORDER TO n: gengo emits hbrt.NtoS for int-to-string conversion
- IndexOrd/OrdCount/OrdName/OrdKey: real implementations (were stubs)
- OrderCount/CurrentOrder/OrderName/OrderKeyExpr accessors on DBFArea
- ClearScope on order switch (prevents stale scope)
Cross-read test: Harbour-created CDX → Five reads, 20/20 items match:
NAME/CITY/ID seek, ORDSCOPE count, GoTop/GoBottom all identical
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -379,6 +379,9 @@ func (idx *Index) GetTag(i int) *Tag {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tags returns all tags in the CDX.
|
||||
func (idx *Index) Tags() []*Tag { return idx.tags }
|
||||
|
||||
// FindTag returns a tag by name.
|
||||
func (idx *Index) FindTag(name string) *Tag {
|
||||
upper := strings.ToUpper(name)
|
||||
@@ -498,12 +501,27 @@ func scanCompoundLeaves(f *os.File, rootHdr *TagHeader) []tagDirEntry {
|
||||
}
|
||||
|
||||
// decodeCompoundLeaf decodes tag entries from a compound leaf page.
|
||||
// Compound index uses the same bit-packed format as data leaves,
|
||||
// with keyLen=10 (tag name) and recNo = page offset / PageLen.
|
||||
func decodeCompoundLeaf(data []byte, nKeys int) []tagDirEntry {
|
||||
if nKeys <= 0 || len(data) < ExtHeadSize {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use the standard leaf key decoder with keyLen=10 (compound tag name size)
|
||||
hdr := DecodeLeafHeader(data)
|
||||
keys := DecodeLeafKeys(data, hdr, 10)
|
||||
|
||||
var entries []tagDirEntry
|
||||
// Compound index leaf format is simpler than data index
|
||||
// Each entry: offset varies by CDX implementation
|
||||
// For now return empty — scanCompoundLeaves handles it
|
||||
_ = nKeys
|
||||
for _, dk := range keys {
|
||||
name := trimNull(dk.Key)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// RecNo in compound index = direct byte offset to tag header
|
||||
entries = append(entries, tagDirEntry{name: name, offset: int64(dk.RecNo)})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
@@ -795,6 +813,21 @@ func (t *Tag) IsEOF() bool { return t.tagEOF }
|
||||
// IsBOF returns true if before start.
|
||||
func (t *Tag) IsBOF() bool { return t.tagBOF }
|
||||
|
||||
// KeyLen returns the key length.
|
||||
func (t *Tag) KeyLen() int { return t.keyLen }
|
||||
|
||||
// KeyExpr returns the key expression string stored in the CDX header.
|
||||
func (t *Tag) KeyExpr() string { return t.header.KeyExpr }
|
||||
|
||||
// ForExpr returns the FOR condition expression.
|
||||
func (t *Tag) ForExpr() string { return t.header.ForExpr }
|
||||
|
||||
// IsDescending returns true if the tag sorts in descending order.
|
||||
func (t *Tag) IsDescending() bool { return t.header.Descending }
|
||||
|
||||
// Close is a no-op for tags (the parent Index owns the file).
|
||||
func (t *Tag) Close() error { return nil }
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func trimNull(b []byte) string {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"bytes"
|
||||
"five/hbrt"
|
||||
"five/hbrdd"
|
||||
"five/hbrdd/cdx"
|
||||
"five/hbrdd/ntx"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -18,13 +19,31 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IndexEngine is the common interface for NTX Index and CDX Tag.
|
||||
type IndexEngine interface {
|
||||
Seek(searchKey []byte) (uint32, bool)
|
||||
GoTop() bool
|
||||
GoBottom() bool
|
||||
SkipNext() bool
|
||||
SkipPrev() bool
|
||||
CurRecNo() uint32
|
||||
CurKey() []byte
|
||||
IsEOF() bool
|
||||
IsBOF() bool
|
||||
KeyLen() int
|
||||
Close() error
|
||||
}
|
||||
|
||||
// indexState holds active index state for a DBFArea.
|
||||
type indexState struct {
|
||||
indexes []*ntx.Index // open NTX index files
|
||||
indexes []IndexEngine // open NTX/CDX index engines
|
||||
names []string // index file paths
|
||||
tags []string // tag names (for display)
|
||||
current int // active index (-1 = natural order)
|
||||
keyExprs []string // key expressions for each index
|
||||
// Scope support
|
||||
scopeTop []byte // top scope key (nil = no scope)
|
||||
scopeBottom []byte // bottom scope key (nil = no scope)
|
||||
}
|
||||
|
||||
// ensureIndexState initializes the index state if nil.
|
||||
@@ -117,14 +136,40 @@ func (a *DBFArea) OrderCreate(params hbrdd.OrderCreateParams) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OrderListAdd opens an existing index file.
|
||||
// OrderListAdd opens an existing index file (NTX single-order or CDX compound).
|
||||
func (a *DBFArea) OrderListAdd(path string) error {
|
||||
a.ensureIndexState()
|
||||
|
||||
// Auto-detect extension: try .cdx first, then .ntx
|
||||
if !strings.Contains(filepath.Base(path), ".") {
|
||||
path += ".ntx"
|
||||
if _, err := os.Stat(path + ".cdx"); err == nil {
|
||||
path += ".cdx"
|
||||
} else {
|
||||
path += ".ntx"
|
||||
}
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
|
||||
if ext == ".cdx" {
|
||||
// CDX compound index — opens all tags
|
||||
ci, err := cdx.OpenIndex(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open CDX failed: %w", err)
|
||||
}
|
||||
for _, tag := range ci.Tags() {
|
||||
a.idxState.indexes = append(a.idxState.indexes, tag)
|
||||
a.idxState.names = append(a.idxState.names, path)
|
||||
a.idxState.tags = append(a.idxState.tags, tag.Name)
|
||||
a.idxState.keyExprs = append(a.idxState.keyExprs, tag.KeyExpr())
|
||||
}
|
||||
if len(ci.Tags()) > 0 {
|
||||
a.idxState.current = len(a.idxState.indexes) - len(ci.Tags()) // first tag
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NTX single index
|
||||
idx, err := ntx.OpenIndex(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open index failed: %w", err)
|
||||
@@ -151,31 +196,68 @@ func (a *DBFArea) OrderListClear() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OrderListFocus sets the active index by tag name or number.
|
||||
// OrderListFocus sets the active index by tag name, number, or file name.
|
||||
// Harbour: OrdSetFocus(nOrder) or OrdSetFocus("tagName")
|
||||
func (a *DBFArea) OrderListFocus(tagName string) error {
|
||||
a.ensureIndexState()
|
||||
if tagName == "" || tagName == "0" {
|
||||
a.idxState.current = -1 // natural order
|
||||
a.ClearScope()
|
||||
return nil
|
||||
}
|
||||
upper := strings.ToUpper(tagName)
|
||||
for i, name := range a.idxState.tags {
|
||||
if strings.ToUpper(name) == upper {
|
||||
a.idxState.current = i
|
||||
|
||||
// Try as numeric order (1-based)
|
||||
if n, err := parseOrderNum(tagName); err == nil {
|
||||
if n == 0 {
|
||||
a.idxState.current = -1
|
||||
a.ClearScope()
|
||||
return nil
|
||||
}
|
||||
if n >= 1 && n <= len(a.idxState.indexes) {
|
||||
a.idxState.current = n - 1
|
||||
a.ClearScope()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// Try by file name
|
||||
|
||||
upper := strings.ToUpper(tagName)
|
||||
// Match by tag name
|
||||
for i, name := range a.idxState.tags {
|
||||
if strings.ToUpper(name) == upper {
|
||||
a.idxState.current = i
|
||||
a.ClearScope()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// Match by file name
|
||||
for i, name := range a.idxState.names {
|
||||
base := strings.ToUpper(filepath.Base(name))
|
||||
if base == upper || strings.TrimSuffix(base, ".NTX") == upper {
|
||||
ext := strings.ToUpper(filepath.Ext(name))
|
||||
if base == upper || strings.TrimSuffix(base, ext) == upper {
|
||||
a.idxState.current = i
|
||||
a.ClearScope()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("index not found: %s", tagName)
|
||||
}
|
||||
|
||||
// parseOrderNum tries to parse a string as a positive integer (order number).
|
||||
func parseOrderNum(s string) (int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) == 0 {
|
||||
return 0, fmt.Errorf("empty")
|
||||
}
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, fmt.Errorf("not a number")
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// OrderListRebuild rebuilds all indexes.
|
||||
// Harbour: ORDLISTREBUILD / REINDEX — recreates all open indexes from current data.
|
||||
func (a *DBFArea) OrderListRebuild() error {
|
||||
@@ -344,25 +426,83 @@ func (a *DBFArea) Seek(key hbrt.Value, softSeek bool, findLast bool) (bool, erro
|
||||
}
|
||||
|
||||
// GoTopIndexed positions at the first key in the active index.
|
||||
// Harbour: if SCOPE is set, positions at the first key >= scopeTop.
|
||||
func (a *DBFArea) GoTopIndexed() error {
|
||||
if a.idxState == nil || a.idxState.current < 0 {
|
||||
return a.GoTop()
|
||||
}
|
||||
idx := a.idxState.indexes[a.idxState.current]
|
||||
|
||||
if a.idxState.scopeTop != nil {
|
||||
// Seek to scope top boundary
|
||||
recNo, _ := idx.Seek(a.idxState.scopeTop)
|
||||
if recNo == 0 || idx.IsEOF() {
|
||||
rc, _ := a.RecCount()
|
||||
a.FEof = true
|
||||
return a.GoTo(rc + 1)
|
||||
}
|
||||
// Check if within bottom scope
|
||||
if a.idxState.scopeBottom != nil {
|
||||
if bytes.Compare(idx.CurKey(), a.idxState.scopeBottom) > 0 {
|
||||
rc, _ := a.RecCount()
|
||||
a.FEof = true
|
||||
return a.GoTo(rc + 1)
|
||||
}
|
||||
}
|
||||
return a.GoTo(idx.CurRecNo())
|
||||
}
|
||||
|
||||
idx.GoTop()
|
||||
if idx.IsEOF() {
|
||||
rc, _ := a.RecCount()
|
||||
a.FEof = true
|
||||
return a.GoTo(rc + 1)
|
||||
}
|
||||
return a.GoTo(idx.CurRecNo())
|
||||
}
|
||||
|
||||
// GoBottomIndexed positions at the last key in the active index.
|
||||
// Harbour: if SCOPE is set, positions at the last key <= scopeBottom.
|
||||
func (a *DBFArea) GoBottomIndexed() error {
|
||||
if a.idxState == nil || a.idxState.current < 0 {
|
||||
return a.GoBottom()
|
||||
}
|
||||
idx := a.idxState.indexes[a.idxState.current]
|
||||
|
||||
if a.idxState.scopeBottom != nil {
|
||||
// Seek to scope bottom boundary
|
||||
_, exact := idx.Seek(a.idxState.scopeBottom)
|
||||
if idx.IsEOF() {
|
||||
// All keys less than bottom scope — go to physical bottom
|
||||
idx.GoBottom()
|
||||
} else if !exact {
|
||||
// Positioned past bottom — go back one
|
||||
idx.SkipPrev()
|
||||
} else {
|
||||
// Exact match — skip forward to last matching key, then position there
|
||||
for {
|
||||
idx.SkipNext()
|
||||
if idx.IsEOF() || bytes.Compare(idx.CurKey(), a.idxState.scopeBottom) > 0 {
|
||||
idx.SkipPrev()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx.IsBOF() || idx.IsEOF() {
|
||||
a.FBof = true
|
||||
return a.GoTo(1)
|
||||
}
|
||||
// Verify within top scope
|
||||
if a.idxState.scopeTop != nil {
|
||||
if bytes.Compare(idx.CurKey(), a.idxState.scopeTop) < 0 {
|
||||
a.FEof = true
|
||||
rc, _ := a.RecCount()
|
||||
return a.GoTo(rc + 1)
|
||||
}
|
||||
}
|
||||
return a.GoTo(idx.CurRecNo())
|
||||
}
|
||||
|
||||
idx.GoBottom()
|
||||
if idx.IsBOF() {
|
||||
return a.GoTo(1)
|
||||
@@ -371,11 +511,13 @@ func (a *DBFArea) GoBottomIndexed() error {
|
||||
}
|
||||
|
||||
// SkipIndexed skips using the active index order.
|
||||
// Harbour: respects SCOPE boundaries — stops at scope edges.
|
||||
func (a *DBFArea) SkipIndexed(count int64) error {
|
||||
if a.idxState == nil || a.idxState.current < 0 {
|
||||
return a.Skip(count)
|
||||
}
|
||||
idx := a.idxState.indexes[a.idxState.current]
|
||||
hasScope := a.idxState.scopeTop != nil || a.idxState.scopeBottom != nil
|
||||
|
||||
if count > 0 {
|
||||
for i := int64(0); i < count; i++ {
|
||||
@@ -386,18 +528,188 @@ func (a *DBFArea) SkipIndexed(count int64) error {
|
||||
a.FEof = true
|
||||
return nil
|
||||
}
|
||||
// Check bottom scope
|
||||
if hasScope && a.idxState.scopeBottom != nil {
|
||||
if bytes.Compare(idx.CurKey(), a.idxState.scopeBottom) > 0 {
|
||||
rc, _ := a.RecCount()
|
||||
a.GoTo(rc + 1)
|
||||
a.FEof = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if count < 0 {
|
||||
for i := int64(0); i > count; i-- {
|
||||
idx.SkipPrev()
|
||||
if idx.IsBOF() {
|
||||
a.FBof = true
|
||||
// Stay at first record in scope
|
||||
if a.idxState.scopeTop != nil {
|
||||
idx.Seek(a.idxState.scopeTop)
|
||||
} else {
|
||||
idx.GoTop()
|
||||
}
|
||||
if !idx.IsEOF() {
|
||||
return a.GoTo(idx.CurRecNo())
|
||||
}
|
||||
return a.GoTo(1)
|
||||
}
|
||||
// Check top scope
|
||||
if hasScope && a.idxState.scopeTop != nil {
|
||||
if bytes.Compare(idx.CurKey(), a.idxState.scopeTop) < 0 {
|
||||
a.FBof = true
|
||||
idx.Seek(a.idxState.scopeTop)
|
||||
if !idx.IsEOF() {
|
||||
return a.GoTo(idx.CurRecNo())
|
||||
}
|
||||
return a.GoTo(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return a.GoTo(idx.CurRecNo())
|
||||
}
|
||||
|
||||
// --- Scope support (ORDSCOPE) ---
|
||||
|
||||
// SetScope sets top and/or bottom scope boundaries for the active index.
|
||||
// Harbour: OrdScope(TOPSCOPE, val) / OrdScope(BOTTOMSCOPE, val)
|
||||
// Pass zero-value hbrt.Value{} (not MakeNil) to skip setting that boundary.
|
||||
func (a *DBFArea) SetScope(top, bottom hbrt.Value) error {
|
||||
a.ensureIndexState()
|
||||
if a.idxState.current < 0 {
|
||||
return fmt.Errorf("no active index")
|
||||
}
|
||||
idx := a.idxState.indexes[a.idxState.current]
|
||||
keyLen := idx.KeyLen()
|
||||
|
||||
if !top.IsNil() && top.Type() != 0 {
|
||||
a.idxState.scopeTop = scopeKeyFromValue(top, keyLen)
|
||||
}
|
||||
if !bottom.IsNil() && bottom.Type() != 0 {
|
||||
a.idxState.scopeBottom = scopeKeyFromValue(bottom, keyLen)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetScopeTop sets only the top scope.
|
||||
func (a *DBFArea) SetScopeTop(val hbrt.Value) {
|
||||
a.ensureIndexState()
|
||||
if a.idxState.current < 0 {
|
||||
return
|
||||
}
|
||||
keyLen := a.idxState.indexes[a.idxState.current].KeyLen()
|
||||
a.idxState.scopeTop = scopeKeyFromValue(val, keyLen)
|
||||
}
|
||||
|
||||
// SetScopeBottom sets only the bottom scope.
|
||||
func (a *DBFArea) SetScopeBottom(val hbrt.Value) {
|
||||
a.ensureIndexState()
|
||||
if a.idxState.current < 0 {
|
||||
return
|
||||
}
|
||||
keyLen := a.idxState.indexes[a.idxState.current].KeyLen()
|
||||
a.idxState.scopeBottom = scopeKeyFromValue(val, keyLen)
|
||||
}
|
||||
|
||||
// ClearScope removes all scope boundaries.
|
||||
func (a *DBFArea) ClearScope() error {
|
||||
if a.idxState != nil {
|
||||
a.idxState.scopeTop = nil
|
||||
a.idxState.scopeBottom = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearScopeTop removes only the top scope boundary.
|
||||
func (a *DBFArea) ClearScopeTop() {
|
||||
if a.idxState != nil {
|
||||
a.idxState.scopeTop = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ClearScopeBottom removes only the bottom scope boundary.
|
||||
func (a *DBFArea) ClearScopeBottom() {
|
||||
if a.idxState != nil {
|
||||
a.idxState.scopeBottom = nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetScopeTop returns the current top scope key (nil if none).
|
||||
func (a *DBFArea) GetScopeTop() []byte {
|
||||
if a.idxState != nil {
|
||||
return a.idxState.scopeTop
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetScopeBottom returns the current bottom scope key (nil if none).
|
||||
func (a *DBFArea) GetScopeBottom() []byte {
|
||||
if a.idxState != nil {
|
||||
return a.idxState.scopeBottom
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scopeKeyFromValue converts a Harbour Value to a scope key byte slice.
|
||||
func scopeKeyFromValue(v hbrt.Value, keyLen int) []byte {
|
||||
var key []byte
|
||||
if v.IsString() {
|
||||
key = []byte(v.AsString())
|
||||
} else if v.IsNumeric() {
|
||||
key = []byte(fmt.Sprintf("%*d", keyLen, v.AsNumInt()))
|
||||
} else {
|
||||
key = []byte(v.AsString())
|
||||
}
|
||||
// Pad to keyLen
|
||||
if len(key) < keyLen {
|
||||
padded := make([]byte, keyLen)
|
||||
copy(padded, key)
|
||||
for i := len(key); i < keyLen; i++ {
|
||||
padded[i] = ' '
|
||||
}
|
||||
return padded
|
||||
}
|
||||
if len(key) > keyLen {
|
||||
return key[:keyLen]
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// --- Index info accessors ---
|
||||
|
||||
// IndexCount returns the number of open indexes.
|
||||
func (a *DBFArea) IndexCount() int {
|
||||
if a.idxState == nil {
|
||||
return 0
|
||||
}
|
||||
return len(a.idxState.indexes)
|
||||
}
|
||||
|
||||
// CurrentOrder returns the 1-based current order number (0 = natural).
|
||||
func (a *DBFArea) CurrentOrder() int {
|
||||
if a.idxState == nil || a.idxState.current < 0 {
|
||||
return 0
|
||||
}
|
||||
return a.idxState.current + 1
|
||||
}
|
||||
|
||||
// OrderName returns the tag name for order n (1-based).
|
||||
func (a *DBFArea) OrderName(n int) string {
|
||||
if a.idxState == nil || n < 1 || n > len(a.idxState.tags) {
|
||||
return ""
|
||||
}
|
||||
return a.idxState.tags[n-1]
|
||||
}
|
||||
|
||||
// OrderKeyExpr returns the key expression for order n (1-based).
|
||||
func (a *DBFArea) OrderKeyExpr(n int) string {
|
||||
if a.idxState == nil || n < 1 || n > len(a.idxState.keyExprs) {
|
||||
return ""
|
||||
}
|
||||
return a.idxState.keyExprs[n-1]
|
||||
}
|
||||
|
||||
// evalKeyExpr evaluates an index key expression for a given record.
|
||||
// Supports: field names, UPPER(), LOWER(), LTRIM(), RTRIM(), ALLTRIM(),
|
||||
// STR(), DTOS(), SUBSTR(), LEFT(), RIGHT(), PADL(), PADR(),
|
||||
|
||||
Reference in New Issue
Block a user