feat(pp): TOTAL TO via std.ch + __dbTotal RTL

`TOTAL TO <file> ON <key> [FIELDS <list>] [FOR ...] [WHILE ...]
[NEXT ...] [RECORD ...] [REST] [ALL]` joins the family of std.ch
DML rewrites. New RTL primitive __dbTotal:

  * Walk the source under dbEval-style FOR/WHILE/NEXT/RECORD/REST
    bounds. The source must already be sorted/indexed on the key —
    same precondition as Harbour's dbtotal.prg.
  * Track the current group key. On each key change, flush the
    accumulated row to the destination (writing the running totals
    back into the most recently appended record's sum-fields,
    preserving each field's declared length/decimals).
  * On the *first* record of every group, append a fresh dst row
    and copy all non-memo source fields into it; subsequent records
    in the group only contribute to the sums. Net effect: non-summed
    fields take the first record's value, summed fields hold the
    group total. Same shape as harbour-core/src/rdd/dbtotal.prg.
  * Memo fields are dropped from the destination structure (Harbour
    does the same).

Parser cleanup: TOTAL removed from the IDENT-statement no-op switch.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 15:24:41 +09:00
parent 1cc2d94927
commit 699ea90156
4 changed files with 217 additions and 1 deletions

View File

@@ -1155,7 +1155,7 @@ func (p *Parser) parseIdentStmt() ast.Stmt {
// rewritten by compiler/pp/std.ch into function calls before the
// parser sees them.
switch upper {
case "TOTAL", "UPDATE",
case "UPDATE",
"LABEL", "REPORT", "ACCEPT", "INPUT",
"JOIN", "RELEASE", "SAVE", "RESTORE",
"DIR", "STORE", "NOTE", "TEXT", "ENDTEXT",

View File

@@ -99,6 +99,17 @@
__dbList( <.off.>, { <{v}> }, <.all.>, ;
<{for}>, <{while}>, <next>, <rec>, <.rest.> )
/* TOTAL TO writes one record per consecutive run of equal key values
from the source. Numeric fields named in FIELDS are summed; every
other (non-memo) field takes the first record's value. The source
must already be sorted/indexed on the key for the grouping to
produce one row per distinct value. */
#command TOTAL [TO <(f)>] [ON <key>] [FIELDS <fields,...>] ;
[FOR <for>] [WHILE <while>] [NEXT <next>] ;
[RECORD <rec>] [<rest:REST>] [ALL] => ;
__dbTotal( <(f)>, <{key}>, { <(fields)> }, ;
<{for}>, <{while}>, <next>, <rec>, <.rest.> )
/* --- bulk maintenance --- */
#command REINDEX => DbReindex()
#command PACK => DbPack()

View File

@@ -1247,6 +1247,210 @@ func rtlDbList(t *hbrt.Thread) {
t.RetNil()
}
// rtlDbTotal implements __dbTotal(cFile, bKey, aFields, bFor, bWhile,
// nNext, xRec, lRest) — emit one record per *consecutive* run of
// equal key values from the current workarea, summing the named
// numeric fields and copying every other field from the run's first
// record. The source must already be sorted/indexed on the key for
// the grouping to produce one row per distinct value (Harbour's
// dbtotal.prg has the same precondition).
//
// Used by `TOTAL TO <f> ON <key> [FIELDS <list>] [FOR/WHILE/...]` in
// std.ch.
func rtlDbTotal(t *hbrt.Thread) {
nParams := t.ParamCount()
t.Frame(nParams, 0)
defer t.EndProcFast()
wam := getWA(t)
if wam == nil {
t.RetBool(false)
return
}
srcArea := wam.Current()
if srcArea == nil {
t.RetBool(false)
return
}
if nParams < 1 || t.Local(1).IsNil() {
t.RetBool(false)
return
}
cFile := t.Local(1).AsString()
if cFile == "" {
t.RetBool(false)
return
}
bKey := t.Local(2)
if !bKey.IsBlock() {
t.RetBool(false)
return
}
// Build dst struct — drop memos like Harbour does.
nFields := srcArea.FieldCount()
var dstFields []hbrdd.FieldInfo
var keptIdx []int
for i := 0; i < nFields; i++ {
fi := srcArea.GetFieldInfo(i)
if fi.Type == 'M' {
continue
}
dstFields = append(dstFields, fi)
keptIdx = append(keptIdx, i)
}
if len(dstFields) == 0 {
t.RetBool(false)
return
}
// Resolve sum-fields → list of (dstIdx, srcIdx) pairs preserving
// declaration order.
type sumPair struct{ dst, src int }
var sums []sumPair
if nParams >= 3 && t.Local(3).IsArray() {
arr := t.Local(3).AsArray()
if arr != nil {
wanted := map[string]struct{}{}
for _, it := range arr.Items {
s := strings.ToUpper(strings.TrimSpace(it.AsString()))
if s != "" {
wanted[s] = struct{}{}
}
}
for di, si := range keptIdx {
if _, ok := wanted[strings.ToUpper(dstFields[di].Name)]; ok {
sums = append(sums, sumPair{dst: di, src: si})
}
}
}
}
// Loop bounds.
var bFor, bWhile hbrt.Value
if nParams >= 4 {
bFor = t.Local(4)
}
if nParams >= 5 {
bWhile = t.Local(5)
}
nCount := -1
if nParams >= 6 && !t.Local(6).IsNil() {
nCount = t.Local(6).AsInt()
}
if nParams >= 7 && !t.Local(7).IsNil() {
srcArea.GoTo(uint32(t.Local(7).AsInt()))
}
lRest := false
if nParams >= 8 && !t.Local(8).IsNil() {
lRest = t.Local(8).AsBool()
}
if !lRest && (nParams < 7 || t.Local(7).IsNil()) {
srcArea.GoTop()
}
// Create + open destination.
drv, err := hbrdd.GetDriver("DBFNTX")
if err != nil {
t.RetBool(false)
return
}
if _, err := drv.Create(hbrdd.CreateParams{Path: cFile, Fields: dstFields}); err != nil {
t.RetBool(false)
return
}
srcSel := wam.CurrentNum()
dstSel, err := wam.Open("DBFNTX", cFile, "__totaltmp", false, false)
if err != nil {
t.RetBool(false)
return
}
dstArea := wam.AreaAt(dstSel)
wam.SelectByNum(srcSel)
// Group walk.
var prevKey hbrt.Value
haveGroup := false
running := make([]float64, len(sums))
flush := func() {
if !haveGroup {
return
}
wam.SelectByNum(dstSel)
// The dst row for this group is the most recent append.
// Overwrite the sum-field positions with the accumulated total,
// preserving the field's declared length/decimals.
for i, sp := range sums {
fi := dstFields[sp.dst]
dstArea.PutValue(sp.dst, hbrt.MakeDouble(running[i], uint16(fi.Len), uint16(fi.Dec)))
}
wam.SelectByNum(srcSel)
for i := range running {
running[i] = 0
}
haveGroup = false
}
scanned := 0
for !srcArea.EOF() {
if nCount >= 0 && scanned >= nCount {
break
}
if bWhile.IsBlock() {
t.PendingParams2(0)
bWhile.AsBlock().Fn(t)
if !t.GetRetValue().AsBool() {
break
}
}
match := true
if bFor.IsBlock() {
t.PendingParams2(0)
bFor.AsBlock().Fn(t)
match = t.GetRetValue().AsBool()
}
if match {
t.PendingParams2(0)
bKey.AsBlock().Fn(t)
curKey := t.GetRetValue()
if !haveGroup || compareValues(prevKey, curKey) != 0 {
flush()
// Append a fresh dst row and copy this first-of-group
// record's non-memo fields into it.
wam.SelectByNum(dstSel)
dstArea.Append()
wam.SelectByNum(srcSel)
for di, si := range keptIdx {
v, _ := srcArea.GetValue(si)
wam.SelectByNum(dstSel)
dstArea.PutValue(di, v)
wam.SelectByNum(srcSel)
}
prevKey = curKey
haveGroup = true
}
// Sum this record's contribution.
for i, sp := range sums {
v, _ := srcArea.GetValue(sp.src)
running[i] += v.AsNumDouble()
}
}
srcArea.Skip(1)
scanned++
}
flush()
wam.SelectByNum(dstSel)
wam.Close()
wam.SelectByNum(srcSel)
t.RetBool(true)
}
// stableSort is a tiny insertion sort for small N (typical DBF SORT
// targets are interactive datasets). Avoids a sort import dependency.
func stableSort(rows [][]hbrt.Value, less func(i, j int) bool) {

View File

@@ -202,6 +202,7 @@ func RegisterRTL(vm *hbrt.VM) {
hbrt.Sym("__DBCOPY", hbrt.FsPublic, rtlDbCopy),
hbrt.Sym("__DBSORT", hbrt.FsPublic, rtlDbSort),
hbrt.Sym("__DBLIST", hbrt.FsPublic, rtlDbList),
hbrt.Sym("__DBTOTAL", hbrt.FsPublic, rtlDbTotal),
hbrt.Sym("DBSETFILTER", hbrt.FsPublic, rtlDbSetFilter),
hbrt.Sym("DBCLEARFILTER", hbrt.FsPublic, rtlDbClearFilter),
hbrt.Sym("DBFILTER", hbrt.FsPublic, rtlDbFilter),