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>
3095 lines
78 KiB
Go
3095 lines
78 KiB
Go
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
||
// All rights reserved.
|
||
|
||
// Go-native SQL scan loop for FiveSql2 hot path.
|
||
//
|
||
// Motivation: FiveSql2 is a PRG-based SQL interpreter. For simple
|
||
// "SELECT cols FROM table WHERE cond" queries, the per-row cost is
|
||
// dominated by PRG interpreter overhead (AST tree walk, field name
|
||
// lookup, workarea switching). Moving just the inner scan loop to Go
|
||
// bypasses all that overhead and gets us ~15x speedup for the common
|
||
// case while keeping the rest of FiveSql2 untouched.
|
||
//
|
||
// The SQL engine remains responsible for:
|
||
// - Parsing SQL and building AST
|
||
// - Resolving field names to positions (column binding)
|
||
// - Compiling WHERE expression to pcode (via PcCompile)
|
||
// - GROUP BY, ORDER BY, aggregates (not per-row)
|
||
//
|
||
// This helper only handles the hot loop:
|
||
// - Full table scan (workarea already positioned)
|
||
// - Per-row WHERE evaluation via ExecPcode
|
||
// - Column extraction via cached field positions
|
||
// - Result array construction
|
||
|
||
package hbrtl
|
||
|
||
import (
|
||
"five/hbrdd"
|
||
"five/hbrdd/dbf"
|
||
"five/hbrt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// SqlScan(aFieldPositions, pcWhere, nLimitHint) → aRows
|
||
//
|
||
// Scans the current workarea top-to-bottom, evaluates pcWhere per row
|
||
// (nil = no filter), collects selected column values into rows.
|
||
//
|
||
// aFieldPositions: array of 1-based field positions to extract per row.
|
||
// Resolve once before calling (FieldPos cache is O(1)
|
||
// but still has PRG → Go call overhead).
|
||
// pcWhere: pcode function pointer from PcCompile, or NIL.
|
||
// nLimitHint: optional early-termination cap. Zero / NIL means
|
||
// scan the whole table. The caller is responsible for
|
||
// verifying that the scan order matches the requested
|
||
// result order (either no ORDER BY, or an index tag
|
||
// that was already focused by OrdSetFocus).
|
||
//
|
||
// Returns:
|
||
// Array of rows, each row = Array of field values.
|
||
//
|
||
// Notes on CHAR trimming: DBF character fields are space-padded. The
|
||
// caller decides whether to trim (via a SELECT-list AllTrim wrapper).
|
||
// We don't trim here — that's a semantic choice, and callers who need
|
||
// raw bytes shouldn't pay for a strings.TrimSpace().
|
||
func SqlScan(t *hbrt.Thread) {
|
||
t.Frame(3, 0)
|
||
defer t.EndProc()
|
||
|
||
// Parse arguments
|
||
fieldsVal := t.Local(1)
|
||
if !fieldsVal.IsArray() {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
fieldsArr := fieldsVal.AsArray().Items
|
||
nFields := len(fieldsArr)
|
||
|
||
whereVal := t.Local(2)
|
||
var whereFn *hbrt.PcodeFunc
|
||
if !whereVal.IsNil() {
|
||
if p := whereVal.AsPointer(); p != nil {
|
||
whereFn, _ = p.(*hbrt.PcodeFunc)
|
||
}
|
||
}
|
||
|
||
limitHint := 0
|
||
if limitVal := t.Local(3); !limitVal.IsNil() {
|
||
if n := int(limitVal.AsNumInt()); n > 0 {
|
||
limitHint = n
|
||
}
|
||
}
|
||
|
||
// Pre-convert field positions to []int (avoid Value->int per row)
|
||
fieldPos := make([]int, nFields)
|
||
for i := 0; i < nFields; i++ {
|
||
fieldPos[i] = int(fieldsArr[i].AsNumInt())
|
||
if fieldPos[i] < 1 {
|
||
fieldPos[i] = 1
|
||
}
|
||
}
|
||
|
||
wam, ok := t.WA.(*hbrdd.WorkAreaManager)
|
||
if !ok {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
area := wam.Current()
|
||
if area == nil {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
// Type-assert to concrete DBFArea once so the hot loop calls
|
||
// GoTop/EOF/Skip/GetValue directly on *dbf.DBFArea without paying
|
||
// the interface dispatch on every row. Falls back to the generic
|
||
// Area path for non-DBF drivers (rare in FiveSql2 context).
|
||
dbfArea, _ := area.(*dbf.DBFArea)
|
||
|
||
// SQLite-inspired: instead of one slice allocation per row, maintain
|
||
// a single flat backing buffer and hand each row a sub-slice into it.
|
||
// This halves allocations (row header + backing → just row header)
|
||
// and keeps row data contiguous in memory for better cache locality.
|
||
//
|
||
// Safety: we cap each sub-slice to exactly nFields via the 3-index
|
||
// slice form (flat[off:end:end]). Any later `append` on an individual
|
||
// row will then trigger a reallocation of that row's backing, so we
|
||
// don't clobber neighboring rows if PRG code mutates via AAdd.
|
||
// Size the initial backing based on the workarea's record count —
|
||
// even if WHERE filters most rows out, over-allocating beats five
|
||
// regrowths of a 200 KB buffer mid-scan.
|
||
estRows := 1024
|
||
if rc, err := area.RecCount(); err == nil && rc > 0 {
|
||
estRows = int(rc)
|
||
if estRows > 1<<20 {
|
||
estRows = 1 << 20
|
||
}
|
||
}
|
||
// LIMIT pushdown: cap the initial backing allocation when the
|
||
// caller guarantees we'll stop after at most `limitHint` rows.
|
||
// Avoids allocating RecCount-sized buffers for `LIMIT 10` queries
|
||
// on million-row tables.
|
||
if limitHint > 0 && limitHint < estRows {
|
||
estRows = limitHint
|
||
}
|
||
rows := make([]hbrt.Value, 0, estRows)
|
||
flat := make([]hbrt.Value, 0, estRows*nFields)
|
||
slab := hbrt.NewArraySlab(estRows)
|
||
|
||
// Install the hot-path field getter so PcOpFieldGet in the compiled
|
||
// WHERE predicate bypasses PushSymbol + Function dispatch + the
|
||
// FieldGet RTL's own Frame. The closure captures the concrete
|
||
// DBFArea directly so there's no interface dispatch per access.
|
||
prevFG := t.FastFieldGetter
|
||
if dbfArea != nil {
|
||
t.FastFieldGetter = func(idx int) hbrt.Value {
|
||
v, _ := dbfArea.GetValue(idx - 1)
|
||
return v
|
||
}
|
||
} else {
|
||
t.FastFieldGetter = func(idx int) hbrt.Value {
|
||
v, _ := area.GetValue(idx - 1)
|
||
return v
|
||
}
|
||
}
|
||
defer func() { t.FastFieldGetter = prevFG }()
|
||
|
||
// Scan — four specialized loops. Two axes of specialization:
|
||
//
|
||
// DBF vs generic Area: devirtualization — Go inlines method calls
|
||
// on the concrete type but pays an interface
|
||
// dispatch on every call of the generic one.
|
||
//
|
||
// WHERE vs no-WHERE : branch hoisting — the no-WHERE case is a
|
||
// hot full-scan path (SELECT * or similar),
|
||
// where even the predictable `whereFn != nil`
|
||
// check and the `keep` shadow variable show
|
||
// up in pprof.
|
||
//
|
||
// Four combinations = four loop copies. Painful but each row save
|
||
// counts when we're reaching for raw RDD parity.
|
||
// LIMIT pushdown: when limitHint > 0 each loop bails out as soon
|
||
// as we've collected enough rows. The caller guarantees scan order
|
||
// matches result order (no ORDER BY, or matched index tag focused
|
||
// before the call), so clipping early preserves correctness.
|
||
switch {
|
||
case dbfArea != nil && whereFn != nil:
|
||
dbfArea.GoTop()
|
||
for !dbfArea.EOF() {
|
||
hbrt.ExecPcodeFast(t, whereFn, nil)
|
||
if t.GetRetValue().AsBool() {
|
||
off := len(flat)
|
||
end := off + nFields
|
||
if end > cap(flat) {
|
||
flat = append(flat, make([]hbrt.Value, nFields)...)
|
||
} else {
|
||
flat = flat[:end]
|
||
}
|
||
row := flat[off:end:end]
|
||
for i := 0; i < nFields; i++ {
|
||
v, _ := dbfArea.GetValue(fieldPos[i] - 1)
|
||
row[i] = v
|
||
}
|
||
rows = append(rows, slab.WrapNext(row))
|
||
if limitHint > 0 && len(rows) >= limitHint {
|
||
break
|
||
}
|
||
}
|
||
dbfArea.Skip(1)
|
||
}
|
||
case dbfArea != nil:
|
||
// DBF + no WHERE — tightest inner loop
|
||
dbfArea.GoTop()
|
||
for !dbfArea.EOF() {
|
||
off := len(flat)
|
||
end := off + nFields
|
||
if end > cap(flat) {
|
||
flat = append(flat, make([]hbrt.Value, nFields)...)
|
||
} else {
|
||
flat = flat[:end]
|
||
}
|
||
row := flat[off:end:end]
|
||
for i := 0; i < nFields; i++ {
|
||
v, _ := dbfArea.GetValue(fieldPos[i] - 1)
|
||
row[i] = v
|
||
}
|
||
rows = append(rows, slab.WrapNext(row))
|
||
if limitHint > 0 && len(rows) >= limitHint {
|
||
break
|
||
}
|
||
dbfArea.Skip(1)
|
||
}
|
||
case whereFn != nil:
|
||
area.GoTop()
|
||
for !area.EOF() {
|
||
hbrt.ExecPcodeFast(t, whereFn, nil)
|
||
if t.GetRetValue().AsBool() {
|
||
off := len(flat)
|
||
end := off + nFields
|
||
if end > cap(flat) {
|
||
flat = append(flat, make([]hbrt.Value, nFields)...)
|
||
} else {
|
||
flat = flat[:end]
|
||
}
|
||
row := flat[off:end:end]
|
||
for i := 0; i < nFields; i++ {
|
||
v, _ := area.GetValue(fieldPos[i] - 1)
|
||
row[i] = v
|
||
}
|
||
rows = append(rows, slab.WrapNext(row))
|
||
if limitHint > 0 && len(rows) >= limitHint {
|
||
break
|
||
}
|
||
}
|
||
area.Skip(1)
|
||
}
|
||
default:
|
||
area.GoTop()
|
||
for !area.EOF() {
|
||
off := len(flat)
|
||
end := off + nFields
|
||
if end > cap(flat) {
|
||
flat = append(flat, make([]hbrt.Value, nFields)...)
|
||
} else {
|
||
flat = flat[:end]
|
||
}
|
||
row := flat[off:end:end]
|
||
for i := 0; i < nFields; i++ {
|
||
v, _ := area.GetValue(fieldPos[i] - 1)
|
||
row[i] = v
|
||
}
|
||
rows = append(rows, slab.WrapNext(row))
|
||
if limitHint > 0 && len(rows) >= limitHint {
|
||
break
|
||
}
|
||
area.Skip(1)
|
||
}
|
||
}
|
||
|
||
t.PushValue(hbrt.MakeArrayFrom(rows))
|
||
t.RetValue()
|
||
}
|
||
|
||
// SqlHashBuild(nFieldPos) → hHash
|
||
//
|
||
// Scans the current workarea and returns a hash mapping each field
|
||
// value (as a string key) to an array of RecNos that have that value.
|
||
// Used by FiveSql2's HashJoin: FiveSql2 currently builds this in PRG,
|
||
// paying ~40μs per row from class dispatch + hb_HHasKey + AAdd growth.
|
||
// 50k rows × 40μs = 2 seconds wasted on what should be a sub-50ms op.
|
||
//
|
||
// Go-native build goes through *dbf.DBFArea directly and uses a native
|
||
// Go `map[string][]int64` which GC's as one unit. Final conversion to
|
||
// a Five hash is done once at the end.
|
||
func SqlHashBuild(t *hbrt.Thread) {
|
||
t.Frame(1, 0)
|
||
defer t.EndProc()
|
||
|
||
nFieldPos := int(t.Local(1).AsNumInt()) - 1
|
||
if nFieldPos < 0 {
|
||
t.PushValue(hbrt.MakeHash())
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
wam, ok := t.WA.(*hbrdd.WorkAreaManager)
|
||
if !ok {
|
||
t.PushValue(hbrt.MakeHash())
|
||
t.RetValue()
|
||
return
|
||
}
|
||
area := wam.Current()
|
||
if area == nil {
|
||
t.PushValue(hbrt.MakeHash())
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
// Type-assert once so the per-row field reads inline.
|
||
dbfArea, _ := area.(*dbf.DBFArea)
|
||
|
||
goMap := make(map[string][]int64, 4096)
|
||
|
||
if dbfArea != nil {
|
||
dbfArea.GoTop()
|
||
for !dbfArea.EOF() {
|
||
v, _ := dbfArea.GetValue(nFieldPos)
|
||
key := valueHashKey(v)
|
||
goMap[key] = append(goMap[key], int64(dbfArea.RecNo()))
|
||
dbfArea.Skip(1)
|
||
}
|
||
} else {
|
||
area.GoTop()
|
||
for !area.EOF() {
|
||
v, _ := area.GetValue(nFieldPos)
|
||
key := valueHashKey(v)
|
||
// Generic RecNo via interface
|
||
var rn int64
|
||
if rmgr, ok := area.(interface{ RecNo() uint32 }); ok {
|
||
rn = int64(rmgr.RecNo())
|
||
}
|
||
goMap[key] = append(goMap[key], rn)
|
||
area.Skip(1)
|
||
}
|
||
}
|
||
|
||
// Materialize as a Five hash — build Keys/Values slices directly on
|
||
// the HbHash struct, skipping the per-key map-lookup path that PRG
|
||
// hb_HSet would take.
|
||
nKeys := len(goMap)
|
||
keys := make([]hbrt.Value, 0, nKeys)
|
||
vals := make([]hbrt.Value, 0, nKeys)
|
||
order := make([]int, 0, nKeys)
|
||
idx := 0
|
||
for k, recs := range goMap {
|
||
items := make([]hbrt.Value, len(recs))
|
||
for i, r := range recs {
|
||
items[i] = hbrt.MakeNumInt(r)
|
||
}
|
||
keys = append(keys, hbrt.MakeString(k))
|
||
vals = append(vals, hbrt.MakeArrayFrom(items))
|
||
order = append(order, idx)
|
||
idx++
|
||
}
|
||
result := hbrt.MakeHash()
|
||
hh := result.AsHash()
|
||
hh.Keys = keys
|
||
hh.Values = vals
|
||
hh.Order = order
|
||
|
||
t.PushValue(result)
|
||
t.RetValue()
|
||
}
|
||
|
||
// valueHashKey converts a Value to a stable string key for Go map use.
|
||
// Matches what SqlValToStr does in PRG, but without allocation detours.
|
||
func valueHashKey(v hbrt.Value) string {
|
||
switch {
|
||
case v.IsNil():
|
||
return "\x00NIL"
|
||
case v.IsString():
|
||
// Match PRG SqlValToStr: trim trailing spaces so CHAR hash probes
|
||
// compare the same as the equivalent SqlCmpEq call.
|
||
s := v.AsString()
|
||
end := len(s)
|
||
for end > 0 && s[end-1] == ' ' {
|
||
end--
|
||
}
|
||
return s[:end]
|
||
case v.IsNumeric():
|
||
if v.IsNumInt() {
|
||
return strconvItoa(v.AsNumInt())
|
||
}
|
||
return strconvFtoa(v.AsNumDouble())
|
||
case v.IsLogical():
|
||
if v.AsBool() {
|
||
return "T"
|
||
}
|
||
return "F"
|
||
case v.IsDate():
|
||
return strconvItoa(v.AsJulian())
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func strconvItoa(n int64) string {
|
||
// strconv.Itoa is heavy on allocation for small ints — this is the
|
||
// hot path for hash keys so use a tight formatter.
|
||
if n == 0 {
|
||
return "0"
|
||
}
|
||
neg := n < 0
|
||
if neg {
|
||
n = -n
|
||
}
|
||
var buf [20]byte
|
||
i := len(buf)
|
||
for n > 0 {
|
||
i--
|
||
buf[i] = byte('0' + n%10)
|
||
n /= 10
|
||
}
|
||
if neg {
|
||
i--
|
||
buf[i] = '-'
|
||
}
|
||
return string(buf[i:])
|
||
}
|
||
|
||
func strconvFtoa(f float64) string {
|
||
// Only used for non-integer numeric field values (rare in join keys);
|
||
// OK to call into strconv.
|
||
return strconv.FormatFloat(f, 'g', -1, 64)
|
||
}
|
||
|
||
// SqlHashJoin(aOuterFields, aJoinSpecs, aSelectFields) → aRows
|
||
//
|
||
// Go-native multi-table hash join. Replaces the per-row PRG overhead
|
||
// of JoinRecurse → FetchRow → dbSelectArea × N when the query has
|
||
// only equi-join conditions and all SELECT columns are plain field refs.
|
||
//
|
||
// Arguments (all PRG arrays):
|
||
// aJoinSpecs: array of {nInnerWA, nInnerKeyField, nOuterKeyField}
|
||
// Each entry describes one join level (1-based field positions).
|
||
// nOuterKeyField refers to a field in the PREVIOUS level's
|
||
// table (or the outer for the first entry).
|
||
// aSelectFields: array of {nWA, nFieldPos} — columns to extract per
|
||
// matched row combination. 1-based field positions.
|
||
// nOuterWA: workarea number of the outermost (driving) table
|
||
//
|
||
// Returns: array of rows, each row = array of field values.
|
||
//
|
||
// The function builds hash tables for each inner level, then walks
|
||
// the outer table and probes each level recursively. All field access
|
||
// goes through *dbf.DBFArea.GetValue directly — no PRG frame overhead.
|
||
func SqlHashJoin(t *hbrt.Thread) {
|
||
t.Frame(3, 0)
|
||
defer t.EndProc()
|
||
|
||
joinSpecsVal := t.Local(1)
|
||
selectFieldsVal := t.Local(2)
|
||
nOuterWA := int(t.Local(3).AsNumInt())
|
||
|
||
if !joinSpecsVal.IsArray() || !selectFieldsVal.IsArray() {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
wam, ok := t.WA.(*hbrdd.WorkAreaManager)
|
||
if !ok {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
// Parse join specs
|
||
jsArr := joinSpecsVal.AsArray().Items
|
||
type joinLevel struct {
|
||
area *dbf.DBFArea
|
||
innerKey int // 0-based field index for hash key
|
||
outerKey int // 0-based field index on parent level
|
||
hashTable map[string][]uint32 // key → list of RecNos
|
||
parentArea *dbf.DBFArea
|
||
}
|
||
levels := make([]joinLevel, len(jsArr))
|
||
|
||
for i, js := range jsArr {
|
||
row := js.AsArray()
|
||
if row == nil || len(row.Items) < 3 {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
innerWA := int(row.Items[0].AsNumInt())
|
||
innerKeyF := int(row.Items[1].AsNumInt()) - 1
|
||
outerKeyF := int(row.Items[2].AsNumInt()) - 1
|
||
|
||
innerArea, _ := wam.AreaAt(uint16(innerWA)).(*dbf.DBFArea)
|
||
if innerArea == nil {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
// Build hash table for this level
|
||
ht := make(map[string][]uint32, 4096)
|
||
innerArea.GoTop()
|
||
for !innerArea.EOF() {
|
||
v, _ := innerArea.GetValue(innerKeyF)
|
||
key := valueHashKey(v)
|
||
ht[key] = append(ht[key], innerArea.RecNo())
|
||
innerArea.Skip(1)
|
||
}
|
||
|
||
levels[i] = joinLevel{
|
||
area: innerArea,
|
||
innerKey: innerKeyF,
|
||
outerKey: outerKeyF,
|
||
hashTable: ht,
|
||
}
|
||
}
|
||
|
||
// Set parent area references
|
||
outerArea, _ := wam.AreaAt(uint16(nOuterWA)).(*dbf.DBFArea)
|
||
if outerArea == nil {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
for i := range levels {
|
||
if i == 0 {
|
||
levels[i].parentArea = outerArea
|
||
} else {
|
||
levels[i].parentArea = levels[i-1].area
|
||
}
|
||
}
|
||
|
||
// Parse select fields
|
||
sfArr := selectFieldsVal.AsArray().Items
|
||
type selectCol struct {
|
||
area *dbf.DBFArea
|
||
fieldIdx int // 0-based
|
||
}
|
||
selCols := make([]selectCol, len(sfArr))
|
||
for i, sf := range sfArr {
|
||
row := sf.AsArray()
|
||
if row == nil || len(row.Items) < 2 {
|
||
continue
|
||
}
|
||
waNum := int(row.Items[0].AsNumInt())
|
||
fIdx := int(row.Items[1].AsNumInt()) - 1
|
||
if waNum == 0 {
|
||
// Aggregate placeholder — leave area nil, emit 0 per row
|
||
selCols[i] = selectCol{area: nil, fieldIdx: -1}
|
||
continue
|
||
}
|
||
a, _ := wam.AreaAt(uint16(waNum)).(*dbf.DBFArea)
|
||
selCols[i] = selectCol{area: a, fieldIdx: fIdx}
|
||
}
|
||
|
||
nFields := len(selCols)
|
||
estRows := 1024
|
||
rows := make([]hbrt.Value, 0, estRows)
|
||
flat := make([]hbrt.Value, 0, estRows*nFields)
|
||
slab := hbrt.NewArraySlab(estRows)
|
||
|
||
// Recursive join traversal — iterative via explicit stack
|
||
type frame struct {
|
||
level int
|
||
matches []uint32
|
||
matchIdx int
|
||
}
|
||
|
||
outerArea.GoTop()
|
||
for !outerArea.EOF() {
|
||
// Start the join chain from the outer row
|
||
stack := []frame{{level: 0, matches: nil, matchIdx: 0}}
|
||
|
||
// Get outer key for first level
|
||
outerVal, _ := outerArea.GetValue(levels[0].outerKey)
|
||
outerKey := valueHashKey(outerVal)
|
||
matches, found := levels[0].hashTable[outerKey]
|
||
if !found {
|
||
outerArea.Skip(1)
|
||
continue
|
||
}
|
||
stack[0].matches = matches
|
||
|
||
for len(stack) > 0 {
|
||
top := &stack[len(stack)-1]
|
||
|
||
if top.matchIdx >= len(top.matches) {
|
||
// Exhausted this level — pop
|
||
stack = stack[:len(stack)-1]
|
||
continue
|
||
}
|
||
|
||
// Position the inner area at the current match
|
||
recNo := top.matches[top.matchIdx]
|
||
top.matchIdx++
|
||
levels[top.level].area.GoTo(recNo)
|
||
|
||
if top.level == len(levels)-1 {
|
||
// Last level — emit result row
|
||
off := len(flat)
|
||
end := off + nFields
|
||
if end > cap(flat) {
|
||
flat = append(flat, make([]hbrt.Value, nFields)...)
|
||
} else {
|
||
flat = flat[:end]
|
||
}
|
||
row := flat[off:end:end]
|
||
for c := 0; c < nFields; c++ {
|
||
if selCols[c].area != nil {
|
||
v, _ := selCols[c].area.GetValue(selCols[c].fieldIdx)
|
||
row[c] = v
|
||
} else {
|
||
// Aggregate placeholder — 0 for numeric aggregation
|
||
row[c] = hbrt.MakeInt(0)
|
||
}
|
||
}
|
||
rows = append(rows, slab.WrapNext(row))
|
||
} else {
|
||
// Probe next level
|
||
nextLevel := top.level + 1
|
||
probeVal, _ := levels[top.level].area.GetValue(levels[nextLevel].outerKey)
|
||
probeKey := valueHashKey(probeVal)
|
||
nextMatches, found := levels[nextLevel].hashTable[probeKey]
|
||
if found {
|
||
stack = append(stack, frame{
|
||
level: nextLevel,
|
||
matches: nextMatches,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
outerArea.Skip(1)
|
||
}
|
||
|
||
t.PushValue(hbrt.MakeArrayFrom(rows))
|
||
t.RetValue()
|
||
}
|
||
|
||
// SqlOrderBy(aRows, aSortSpec) → aRows (sorted in place)
|
||
//
|
||
// Go-native sort for SQL ORDER BY. Each aSortSpec element is
|
||
// {nColIdx, lDesc} where nColIdx is 1-based and lDesc is .T. for DESC.
|
||
// Uses Go's sort.Slice which is ~10-50x faster than PRG ASort with
|
||
// block callback for large result sets.
|
||
func SqlOrderBy(t *hbrt.Thread) {
|
||
t.Frame(2, 0)
|
||
defer t.EndProc()
|
||
|
||
rowsVal := t.Local(1)
|
||
specVal := t.Local(2)
|
||
|
||
if !rowsVal.IsArray() || !specVal.IsArray() {
|
||
t.PushValue(rowsVal)
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
rows := rowsVal.AsArray().Items
|
||
specs := specVal.AsArray().Items
|
||
|
||
// Per-column sort spec. nullsFirst is derived once from direction
|
||
// and explicit NULLS clause so the hot path is just a bool test.
|
||
// Default (cNulls == ""): NIL is the largest value — NULLs LAST in
|
||
// ASC, NULLs FIRST in DESC. Matches the pre-Go PRG SqlRowCompare.
|
||
// Explicit NULLS FIRST/LAST (SQL:2003) overrides the direction.
|
||
type sortCol struct {
|
||
idx int
|
||
desc bool
|
||
nullsFirst bool
|
||
}
|
||
cols := make([]sortCol, len(specs))
|
||
for i, s := range specs {
|
||
arr := s.AsArray()
|
||
if arr == nil || len(arr.Items) < 2 {
|
||
continue
|
||
}
|
||
c := sortCol{
|
||
idx: int(arr.Items[0].AsNumInt()) - 1,
|
||
desc: arr.Items[1].AsBool(),
|
||
}
|
||
c.nullsFirst = c.desc
|
||
if len(arr.Items) >= 3 {
|
||
switch arr.Items[2].AsString() {
|
||
case "FIRST":
|
||
c.nullsFirst = true
|
||
case "LAST":
|
||
c.nullsFirst = false
|
||
}
|
||
}
|
||
cols[i] = c
|
||
}
|
||
|
||
sort.SliceStable(rows, func(a, b int) bool {
|
||
ra := rows[a].AsArray()
|
||
rb := rows[b].AsArray()
|
||
if ra == nil || rb == nil {
|
||
return false
|
||
}
|
||
for _, c := range cols {
|
||
if c.idx < 0 || c.idx >= len(ra.Items) || c.idx >= len(rb.Items) {
|
||
continue
|
||
}
|
||
va := ra.Items[c.idx]
|
||
vb := rb.Items[c.idx]
|
||
|
||
// NULL handling follows nullsFirst independent of direction.
|
||
aNil, bNil := va.IsNil(), vb.IsNil()
|
||
if aNil || bNil {
|
||
if aNil && bNil {
|
||
continue
|
||
}
|
||
// exactly one is NIL
|
||
if c.nullsFirst {
|
||
return aNil // NIL side comes first
|
||
}
|
||
return !aNil // non-NIL side comes first
|
||
}
|
||
|
||
cmp := compareValuesNonNil(va, vb)
|
||
if cmp == 0 {
|
||
continue
|
||
}
|
||
if c.desc {
|
||
return cmp > 0
|
||
}
|
||
return cmp < 0
|
||
}
|
||
return false
|
||
})
|
||
|
||
t.PushValue(rowsVal)
|
||
t.RetValue()
|
||
}
|
||
|
||
// compareValues returns -1, 0, or 1 for two Five Values.
|
||
//
|
||
// Historical NIL handling (NIL sorts as smallest) is retained here for
|
||
// existing callers that are fine with that. New sort paths should treat
|
||
// NIL specially based on NULLS FIRST/LAST instead — see compareValuesNonNil
|
||
// plus the sortCol.nullsFirst flag in SqlOrderBy.
|
||
func compareValues(a, b hbrt.Value) int {
|
||
if a.IsNil() && b.IsNil() {
|
||
return 0
|
||
}
|
||
if a.IsNil() {
|
||
return -1
|
||
}
|
||
if b.IsNil() {
|
||
return 1
|
||
}
|
||
return compareValuesNonNil(a, b)
|
||
}
|
||
|
||
// compareValuesNonNil compares two non-NIL Values. Callers must check
|
||
// IsNil() first and apply their own NULL-ordering policy.
|
||
func compareValuesNonNil(a, b hbrt.Value) int {
|
||
// Numeric
|
||
if a.IsNumeric() && b.IsNumeric() {
|
||
fa := a.AsNumDouble()
|
||
fb := b.AsNumDouble()
|
||
if fa < fb {
|
||
return -1
|
||
}
|
||
if fa > fb {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// String
|
||
if a.IsString() && b.IsString() {
|
||
sa := a.AsString()
|
||
sb := b.AsString()
|
||
if sa < sb {
|
||
return -1
|
||
}
|
||
if sa > sb {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// Date
|
||
if a.IsDate() && b.IsDate() {
|
||
ja := a.AsJulian()
|
||
jb := b.AsJulian()
|
||
if ja < jb {
|
||
return -1
|
||
}
|
||
if ja > jb {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// Logical
|
||
if a.IsLogical() && b.IsLogical() {
|
||
ba := a.AsBool()
|
||
bb := b.AsBool()
|
||
if ba == bb {
|
||
return 0
|
||
}
|
||
if !ba {
|
||
return -1
|
||
}
|
||
return 1
|
||
}
|
||
|
||
// Mixed numeric/string: attempt Harbour-style coercion by reading
|
||
// the string as a numeric. Mirrors the PRG SqlRowCompare branches
|
||
// at TSqlSort.prg:145-148 for legacy DBFs that stored numbers in
|
||
// CHAR columns.
|
||
if a.IsNumeric() && b.IsString() {
|
||
fb := parseLeadingNumeric(b.AsString())
|
||
fa := a.AsNumDouble()
|
||
if fa < fb {
|
||
return -1
|
||
}
|
||
if fa > fb {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
if a.IsString() && b.IsNumeric() {
|
||
fa := parseLeadingNumeric(a.AsString())
|
||
fb := b.AsNumDouble()
|
||
if fa < fb {
|
||
return -1
|
||
}
|
||
if fa > fb {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
// parseLeadingNumeric mimics Harbour Val(AllTrim(s)): strips leading /
|
||
// trailing spaces, then parses the longest prefix that looks like a
|
||
// number. Anything non-numeric yields 0.
|
||
func parseLeadingNumeric(s string) float64 {
|
||
i := 0
|
||
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
|
||
i++
|
||
}
|
||
start := i
|
||
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
||
i++
|
||
}
|
||
seenDigit, seenDot := false, false
|
||
for i < len(s) {
|
||
c := s[i]
|
||
if c >= '0' && c <= '9' {
|
||
seenDigit = true
|
||
i++
|
||
continue
|
||
}
|
||
if c == '.' && !seenDot {
|
||
seenDot = true
|
||
i++
|
||
continue
|
||
}
|
||
break
|
||
}
|
||
if !seenDigit {
|
||
return 0
|
||
}
|
||
f, err := strconv.ParseFloat(s[start:i], 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return f
|
||
}
|
||
|
||
// SqlGroupBy(aRows, aGroupColIdx, aAggSpecs) → aResult
|
||
//
|
||
// Go-native GROUP BY. Builds groups by hashing group-key columns,
|
||
// then computes aggregates (SUM/AVG/COUNT/MIN/MAX) per group.
|
||
//
|
||
// aGroupColIdx: array of 1-based column indices for group key
|
||
// aAggSpecs: array of {nColIdx, cFunc, nArgColIdx}
|
||
// nColIdx: 1-based output position
|
||
// cFunc: "SUM"/"AVG"/"COUNT"/"MIN"/"MAX"
|
||
// nArgColIdx: 1-based column index of the argument (0 for COUNT(*))
|
||
func SqlGroupBy(t *hbrt.Thread) {
|
||
t.Frame(3, 0)
|
||
defer t.EndProc()
|
||
|
||
rowsVal := t.Local(1)
|
||
groupColsVal := t.Local(2)
|
||
aggSpecsVal := t.Local(3)
|
||
|
||
if !rowsVal.IsArray() || !groupColsVal.IsArray() || !aggSpecsVal.IsArray() {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
rows := rowsVal.AsArray().Items
|
||
nRows := len(rows)
|
||
|
||
// Parse group column indices
|
||
gcArr := groupColsVal.AsArray().Items
|
||
groupCols := make([]int, len(gcArr))
|
||
for i, v := range gcArr {
|
||
groupCols[i] = int(v.AsNumInt()) - 1
|
||
}
|
||
|
||
// Parse aggregate specs
|
||
type aggSpec struct {
|
||
outCol int
|
||
fn string
|
||
argCol int
|
||
}
|
||
asArr := aggSpecsVal.AsArray().Items
|
||
aggs := make([]aggSpec, len(asArr))
|
||
for i, s := range asArr {
|
||
arr := s.AsArray()
|
||
if arr == nil || len(arr.Items) < 3 {
|
||
continue
|
||
}
|
||
aggs[i] = aggSpec{
|
||
outCol: int(arr.Items[0].AsNumInt()) - 1,
|
||
fn: arr.Items[1].AsString(),
|
||
argCol: int(arr.Items[2].AsNumInt()) - 1,
|
||
}
|
||
}
|
||
|
||
// Build groups
|
||
type groupData struct {
|
||
firstRow int
|
||
count int
|
||
sums []float64
|
||
mins []hbrt.Value
|
||
maxs []hbrt.Value
|
||
counts []int
|
||
}
|
||
|
||
groups := make(map[string]*groupData)
|
||
groupOrder := make([]string, 0, 256)
|
||
|
||
nAggs := len(aggs)
|
||
|
||
for i := 0; i < nRows; i++ {
|
||
ra := rows[i].AsArray()
|
||
if ra == nil {
|
||
continue
|
||
}
|
||
|
||
// Build group key
|
||
key := ""
|
||
for _, gc := range groupCols {
|
||
if gc >= 0 && gc < len(ra.Items) {
|
||
key += valueHashKey(ra.Items[gc]) + "|"
|
||
}
|
||
}
|
||
|
||
gd, exists := groups[key]
|
||
if !exists {
|
||
gd = &groupData{
|
||
firstRow: i,
|
||
sums: make([]float64, nAggs),
|
||
mins: make([]hbrt.Value, nAggs),
|
||
maxs: make([]hbrt.Value, nAggs),
|
||
counts: make([]int, nAggs),
|
||
}
|
||
groups[key] = gd
|
||
groupOrder = append(groupOrder, key)
|
||
}
|
||
gd.count++
|
||
|
||
// Accumulate aggregates
|
||
for ai, ag := range aggs {
|
||
if ag.fn == "COUNT" && ag.argCol < 0 {
|
||
gd.counts[ai]++
|
||
continue
|
||
}
|
||
if ag.argCol >= 0 && ag.argCol < len(ra.Items) {
|
||
v := ra.Items[ag.argCol]
|
||
if !v.IsNil() {
|
||
gd.counts[ai]++
|
||
if v.IsNumeric() {
|
||
gd.sums[ai] += v.AsNumDouble()
|
||
}
|
||
if gd.mins[ai].IsNil() || compareValues(v, gd.mins[ai]) < 0 {
|
||
gd.mins[ai] = v
|
||
}
|
||
if gd.maxs[ai].IsNil() || compareValues(v, gd.maxs[ai]) > 0 {
|
||
gd.maxs[ai] = v
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build result rows
|
||
nCols := 0
|
||
if nRows > 0 && rows[0].AsArray() != nil {
|
||
nCols = len(rows[0].AsArray().Items)
|
||
}
|
||
|
||
result := make([]hbrt.Value, 0, len(groups))
|
||
for _, key := range groupOrder {
|
||
gd := groups[key]
|
||
srcRow := rows[gd.firstRow].AsArray()
|
||
if srcRow == nil {
|
||
continue
|
||
}
|
||
|
||
// Copy group columns from first row
|
||
outItems := make([]hbrt.Value, nCols)
|
||
copy(outItems, srcRow.Items)
|
||
|
||
// Fill aggregate values
|
||
for ai, ag := range aggs {
|
||
if ag.outCol >= 0 && ag.outCol < nCols {
|
||
switch ag.fn {
|
||
case "COUNT":
|
||
outItems[ag.outCol] = hbrt.MakeNumInt(int64(gd.counts[ai]))
|
||
case "SUM":
|
||
if gd.counts[ai] > 0 {
|
||
outItems[ag.outCol] = hbrt.MakeDoubleAuto(gd.sums[ai])
|
||
} else {
|
||
outItems[ag.outCol] = hbrt.MakeNil()
|
||
}
|
||
case "AVG":
|
||
if gd.counts[ai] > 0 {
|
||
outItems[ag.outCol] = hbrt.MakeDoubleAuto(gd.sums[ai] / float64(gd.counts[ai]))
|
||
} else {
|
||
outItems[ag.outCol] = hbrt.MakeNil()
|
||
}
|
||
case "MIN":
|
||
outItems[ag.outCol] = gd.mins[ai]
|
||
case "MAX":
|
||
outItems[ag.outCol] = gd.maxs[ai]
|
||
}
|
||
}
|
||
}
|
||
|
||
result = append(result, hbrt.MakeArrayFrom(outItems))
|
||
}
|
||
|
||
t.PushValue(hbrt.MakeArrayFrom(result))
|
||
t.RetValue()
|
||
}
|
||
|
||
// appendValueHashKey writes the canonical key form of v into sb.
|
||
// Same mapping as valueHashKey but without the intermediate string
|
||
// allocation; used in tight row-key construction loops.
|
||
func appendValueHashKey(sb *strings.Builder, v hbrt.Value) {
|
||
switch {
|
||
case v.IsNil():
|
||
sb.WriteString("\x00NIL")
|
||
case v.IsString():
|
||
s := v.AsString()
|
||
end := len(s)
|
||
for end > 0 && s[end-1] == ' ' {
|
||
end--
|
||
}
|
||
sb.WriteString(s[:end])
|
||
case v.IsNumeric():
|
||
if v.IsNumInt() {
|
||
sb.WriteString(strconvItoa(v.AsNumInt()))
|
||
} else {
|
||
sb.WriteString(strconvFtoa(v.AsNumDouble()))
|
||
}
|
||
case v.IsLogical():
|
||
if v.AsBool() {
|
||
sb.WriteByte('T')
|
||
} else {
|
||
sb.WriteByte('F')
|
||
}
|
||
case v.IsDate():
|
||
sb.WriteString(strconvItoa(v.AsJulian()))
|
||
}
|
||
}
|
||
|
||
// SqlDistinct(aRows) → aRows
|
||
//
|
||
// Go-native replacement for the PRG TSqlSort:Distinct method. Walks
|
||
// aRows once, builds a composite key per row by joining each column's
|
||
// SqlValToStr form with '|', and keeps only the first occurrence of
|
||
// each key. Output preserves input order (SQL DISTINCT semantic).
|
||
//
|
||
// Key construction matches PRG SqlValToStr via appendValueHashKey,
|
||
// so the dedup decision is byte-for-byte identical to the prior PRG
|
||
// hb_HHasKey check — same trailing-space trim on CHAR, same numeric
|
||
// formatting, same NIL marker.
|
||
//
|
||
// Empty / single-row inputs return the input array unchanged.
|
||
func SqlDistinct(t *hbrt.Thread) {
|
||
t.Frame(1, 0)
|
||
defer t.EndProc()
|
||
|
||
rowsVal := t.Local(1)
|
||
if !rowsVal.IsArray() {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
rows := rowsVal.AsArray().Items
|
||
nRows := len(rows)
|
||
if nRows < 2 {
|
||
t.PushValue(rowsVal)
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
seen := make(map[string]struct{}, nRows)
|
||
result := make([]hbrt.Value, 0, nRows)
|
||
var sb strings.Builder
|
||
|
||
for i := 0; i < nRows; i++ {
|
||
ra := rows[i].AsArray()
|
||
if ra == nil {
|
||
continue
|
||
}
|
||
sb.Reset()
|
||
for _, item := range ra.Items {
|
||
appendValueHashKey(&sb, item)
|
||
sb.WriteByte('|')
|
||
}
|
||
if _, dup := seen[sb.String()]; dup {
|
||
continue
|
||
}
|
||
seen[sb.String()] = struct{}{}
|
||
result = append(result, rows[i])
|
||
}
|
||
|
||
t.PushValue(hbrt.MakeArrayFrom(result))
|
||
t.RetValue()
|
||
}
|
||
|
||
// SqlUnionDistinct(aLeft, aRight) → aMerged
|
||
//
|
||
// Streaming DISTINCT for the SQL UNION operator. Builds a hash set
|
||
// keyed on each row's canonical composite key (same format used by
|
||
// SqlDistinct) over aLeft, then walks aRight once pushing only rows
|
||
// whose key isn't already seen. Replaces the PRG idiom of appending
|
||
// both arrays in full then calling SqlDistinct, which materialised
|
||
// the intermediate merged array and walked every row twice — once
|
||
// to append, once to rebuild the dedup hash.
|
||
//
|
||
// Output matches `aLeft ++ filter(aRight, unseen)`: left rows stay
|
||
// first and in their original order, right rows are appended in
|
||
// their original order after dedup against left + each other.
|
||
// Same byte-for-byte dedup decision as SqlDistinct.
|
||
func SqlUnionDistinct(t *hbrt.Thread) {
|
||
t.Frame(2, 0)
|
||
defer t.EndProc()
|
||
|
||
leftVal := t.Local(1)
|
||
rightVal := t.Local(2)
|
||
if !leftVal.IsArray() {
|
||
if rightVal.IsArray() {
|
||
t.PushValue(rightVal)
|
||
} else {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
}
|
||
t.RetValue()
|
||
return
|
||
}
|
||
leftRows := leftVal.AsArray().Items
|
||
var rightRows []hbrt.Value
|
||
if rightVal.IsArray() {
|
||
rightRows = rightVal.AsArray().Items
|
||
}
|
||
|
||
nL := len(leftRows)
|
||
nR := len(rightRows)
|
||
seen := make(map[string]struct{}, nL+nR)
|
||
out := make([]hbrt.Value, 0, nL+nR)
|
||
var sb strings.Builder
|
||
|
||
keyOf := func(v hbrt.Value) string {
|
||
sb.Reset()
|
||
if ra := v.AsArray(); ra != nil {
|
||
for _, item := range ra.Items {
|
||
appendValueHashKey(&sb, item)
|
||
sb.WriteByte('|')
|
||
}
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
for i := 0; i < nL; i++ {
|
||
if leftRows[i].AsArray() == nil {
|
||
continue
|
||
}
|
||
k := keyOf(leftRows[i])
|
||
if _, dup := seen[k]; dup {
|
||
continue
|
||
}
|
||
seen[k] = struct{}{}
|
||
out = append(out, leftRows[i])
|
||
}
|
||
for i := 0; i < nR; i++ {
|
||
if rightRows[i].AsArray() == nil {
|
||
continue
|
||
}
|
||
k := keyOf(rightRows[i])
|
||
if _, dup := seen[k]; dup {
|
||
continue
|
||
}
|
||
seen[k] = struct{}{}
|
||
out = append(out, rightRows[i])
|
||
}
|
||
|
||
t.PushValue(hbrt.MakeArrayFrom(out))
|
||
t.RetValue()
|
||
}
|
||
|
||
// SqlBuildSubCacheKey(nId, aValues) → cKey
|
||
//
|
||
// Builds the composite cache key for a correlated subquery:
|
||
// "<nId>@<key(v1)>|<key(v2)>|..."
|
||
// where key(v) uses the canonical appendValueHashKey encoding (same
|
||
// as SqlDistinct / SqlWindow hash keys). Replaces the per-outer-row
|
||
// PRG loop of `hb_ntos(nId) + "@" + SqlValToStr(v1) + "|" + ...` which
|
||
// allocated a fresh string on every concatenation and paid the PRG
|
||
// dispatch on every SqlValToStr / ValType probe. For correlated
|
||
// subqueries over large outer tables this was the dominant cost on
|
||
// cache hits — where the point of the cache is to be cheap.
|
||
func SqlBuildSubCacheKey(t *hbrt.Thread) {
|
||
t.Frame(2, 0)
|
||
defer t.EndProc()
|
||
|
||
nId := t.Local(1).AsNumInt()
|
||
valsArg := t.Local(2)
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString(strconvItoa(nId))
|
||
sb.WriteByte('@')
|
||
if valsArg.IsArray() {
|
||
for _, v := range valsArg.AsArray().Items {
|
||
appendValueHashKey(&sb, v)
|
||
sb.WriteByte('|')
|
||
}
|
||
}
|
||
t.RetString(sb.String())
|
||
}
|
||
|
||
// SqlComputeAggSimple(aGR, nCol, cFunc) → xResult
|
||
//
|
||
// Fast path for COUNT / SUM / AVG / MIN / MAX when the argument is a
|
||
// plain column reference (already resolved to a 1-based index by the
|
||
// caller). Replaces the PRG inner loop of TSqlAgg:ComputeAgg, which
|
||
// walks aGR rows and performs type-aware accumulation per iteration.
|
||
//
|
||
// The caller gates on: cFunc ∈ {COUNT,SUM,AVG,MIN,MAX}, argument is
|
||
// ND_COL, column index resolved > 0. Complex-argument aggregates
|
||
// (CASE / BIN / UDF) and GROUP_CONCAT/STRING_AGG stay in PRG.
|
||
//
|
||
// Arguments:
|
||
// aGR : array of group rows (each row is an array)
|
||
// nCol : 1-based column index (0 → COUNT treats every row; others → 0)
|
||
// cFunc : uppercase function name
|
||
//
|
||
// Returns:
|
||
// COUNT: non-NIL count, or Len(aGR) when nCol<=0 (COUNT(*))
|
||
// SUM : double sum, NIL if no non-NIL values (SQL NULL-safe)
|
||
// AVG : double sum/count, NIL if empty
|
||
// MIN : smallest value via type-aware compare, NIL if empty
|
||
// MAX : largest value via type-aware compare, NIL if empty
|
||
// other: NIL (caller falls back to PRG)
|
||
func SqlComputeAggSimple(t *hbrt.Thread) {
|
||
t.Frame(3, 0)
|
||
defer t.EndProc()
|
||
|
||
grVal := t.Local(1)
|
||
if !grVal.IsArray() {
|
||
t.RetNil()
|
||
return
|
||
}
|
||
gr := grVal.AsArray().Items
|
||
nCol := int(t.Local(2).AsNumInt()) - 1 // 0-based
|
||
fn := t.Local(3).AsString()
|
||
|
||
if fn == "COUNT" && nCol < 0 {
|
||
t.RetInt(int64(len(gr)))
|
||
return
|
||
}
|
||
if nCol < 0 {
|
||
t.RetNil()
|
||
return
|
||
}
|
||
|
||
count := 0
|
||
sum := 0.0
|
||
var minV, maxV hbrt.Value
|
||
haveMin := false
|
||
|
||
for i := 0; i < len(gr); i++ {
|
||
ra := gr[i].AsArray()
|
||
if ra == nil || nCol >= len(ra.Items) {
|
||
continue
|
||
}
|
||
v := ra.Items[nCol]
|
||
if v.IsNil() {
|
||
continue
|
||
}
|
||
count++
|
||
if v.IsNumeric() {
|
||
sum += v.AsNumDouble()
|
||
}
|
||
if !haveMin {
|
||
minV = v
|
||
maxV = v
|
||
haveMin = true
|
||
continue
|
||
}
|
||
if compareValuesNonNil(v, minV) < 0 {
|
||
minV = v
|
||
}
|
||
if compareValuesNonNil(v, maxV) > 0 {
|
||
maxV = v
|
||
}
|
||
}
|
||
|
||
switch fn {
|
||
case "COUNT":
|
||
t.RetInt(int64(count))
|
||
case "SUM":
|
||
if count == 0 {
|
||
t.RetNil()
|
||
} else {
|
||
t.RetVal(hbrt.MakeDoubleAuto(sum))
|
||
}
|
||
case "AVG":
|
||
if count == 0 {
|
||
t.RetNil()
|
||
} else {
|
||
t.RetVal(hbrt.MakeDoubleAuto(sum / float64(count)))
|
||
}
|
||
case "MIN":
|
||
if !haveMin {
|
||
t.RetNil()
|
||
} else {
|
||
t.RetVal(minV)
|
||
}
|
||
case "MAX":
|
||
if !haveMin {
|
||
t.RetNil()
|
||
} else {
|
||
t.RetVal(maxV)
|
||
}
|
||
default:
|
||
t.RetNil()
|
||
}
|
||
}
|
||
|
||
// SqlGroupRows(aRows, aGroupColIdx) → aGroupedRows
|
||
//
|
||
// Groups rows (values, not indices) by their GROUP BY column values,
|
||
// preserving first-seen order. Replaces the PRG hot loop in
|
||
// TSqlAgg:GroupBy:
|
||
//
|
||
// FOR i := 1 TO Len( aRows )
|
||
// cKey := ""
|
||
// FOR j := 1 TO Len( aGroupBy )
|
||
// cKey += SqlValToStr( aRows[ i ][ aGroupIdx[ j ] ] ) + "|"
|
||
// NEXT
|
||
// IF ! hb_HHasKey( hGroups, cKey )
|
||
// hGroups[ cKey ] := {}
|
||
// ENDIF
|
||
// AAdd( hGroups[ cKey ], aRows[ i ] )
|
||
// NEXT
|
||
//
|
||
// Aggregate computation + HAVING evaluation stay in PRG (too many
|
||
// expression kinds to port cleanly); this RTL only collapses the
|
||
// grouping step — the dominant per-row boundary-crossing cost.
|
||
//
|
||
// Returns: array of groups, each group is an array of original rows
|
||
// (by reference — no copy). First-seen group key order.
|
||
func SqlGroupRows(t *hbrt.Thread) {
|
||
t.Frame(2, 0)
|
||
defer t.EndProc()
|
||
|
||
rowsVal := t.Local(1)
|
||
colsVal := t.Local(2)
|
||
if !rowsVal.IsArray() {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
rows := rowsVal.AsArray().Items
|
||
nRows := len(rows)
|
||
|
||
var groupCols []int
|
||
if colsVal.IsArray() {
|
||
colsArr := colsVal.AsArray().Items
|
||
groupCols = make([]int, len(colsArr))
|
||
for i, v := range colsArr {
|
||
groupCols[i] = int(v.AsNumInt()) - 1
|
||
}
|
||
}
|
||
|
||
// No GROUP BY columns → single group containing all rows. Matches
|
||
// PRG semantic where HAVING or aggregate query with no GROUP BY
|
||
// still aggregates over the whole result.
|
||
if len(groupCols) == 0 {
|
||
all := make([]hbrt.Value, nRows)
|
||
copy(all, rows)
|
||
t.PushValue(hbrt.MakeArrayFrom([]hbrt.Value{
|
||
hbrt.MakeArrayFrom(all),
|
||
}))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
order := make([]string, 0, 16)
|
||
groups := make(map[string][]hbrt.Value, 16)
|
||
var sb strings.Builder
|
||
|
||
for i := 0; i < nRows; i++ {
|
||
ra := rows[i].AsArray()
|
||
if ra == nil {
|
||
continue
|
||
}
|
||
sb.Reset()
|
||
for _, c := range groupCols {
|
||
if c >= 0 && c < len(ra.Items) {
|
||
appendValueHashKey(&sb, ra.Items[c])
|
||
}
|
||
sb.WriteByte('|')
|
||
}
|
||
key := sb.String()
|
||
if _, ok := groups[key]; !ok {
|
||
groups[key] = make([]hbrt.Value, 0, 8)
|
||
order = append(order, key)
|
||
}
|
||
groups[key] = append(groups[key], rows[i])
|
||
}
|
||
|
||
out := make([]hbrt.Value, len(order))
|
||
for oi, key := range order {
|
||
out[oi] = hbrt.MakeArrayFrom(groups[key])
|
||
}
|
||
t.PushValue(hbrt.MakeArrayFrom(out))
|
||
t.RetValue()
|
||
}
|
||
|
||
// SqlEvalHaving(xHaving, aNewRow, aCols, aGR, aFN, aParams) → {lOk, lPass}
|
||
//
|
||
// Go-native tree walker for HAVING clause evaluation, mirroring
|
||
// PRG TSqlAgg:EvalHavingExpr. Returns a 2-element array:
|
||
// [1] lOk: .T. if fully handled in Go, .F. to fall back to PRG
|
||
// [2] lPass: truthiness when handled
|
||
//
|
||
// Supported nodes: ND_LIT, ND_NIL, ND_COL (lookup in aCols / aFN),
|
||
// ND_FN (COUNT/SUM/AVG/MIN/MAX with plain column args), ND_BIN
|
||
// (AND/OR/comparison), ND_UNI (NOT/-). Anything unsupported → returns
|
||
// {.F., .F.} so PRG takes over.
|
||
//
|
||
// Aggregates inside HAVING are recomputed per group using the same
|
||
// sqlComputeAggSimple path as the SELECT list. Redundant vs SELECT-
|
||
// list aggregate compute, but simple and bounded (HAVING is usually
|
||
// a single comparison).
|
||
func SqlEvalHaving(t *hbrt.Thread) {
|
||
t.Frame(6, 0)
|
||
defer t.EndProc()
|
||
|
||
xE := t.Local(1)
|
||
aNewRow := t.Local(2)
|
||
aCols := t.Local(3)
|
||
aGR := t.Local(4)
|
||
aFN := t.Local(5)
|
||
|
||
ctx := &havingCtx{
|
||
aNewRow: aNewRow,
|
||
aCols: aCols,
|
||
aGR: aGR,
|
||
aFN: aFN,
|
||
}
|
||
ok, v := ctx.eval(xE)
|
||
result := hbrt.MakeArray(2)
|
||
arr := result.AsArray()
|
||
arr.Items[0] = hbrt.MakeBool(ok)
|
||
if ok {
|
||
arr.Items[1] = hbrt.MakeBool(havingIsTrue(v))
|
||
} else {
|
||
arr.Items[1] = hbrt.MakeBool(false)
|
||
}
|
||
t.PushValue(result)
|
||
t.RetValue()
|
||
}
|
||
|
||
type havingCtx struct {
|
||
aNewRow hbrt.Value
|
||
aCols hbrt.Value
|
||
aGR hbrt.Value
|
||
aFN hbrt.Value
|
||
}
|
||
|
||
// eval walks the HAVING AST. Returns (ok, value). ok=false means
|
||
// "encountered unsupported node, caller must fall back to PRG."
|
||
func (c *havingCtx) eval(xE hbrt.Value) (bool, hbrt.Value) {
|
||
if xE.IsNil() {
|
||
return true, hbrt.MakeNil()
|
||
}
|
||
arr := xE.AsArray()
|
||
if arr == nil || len(arr.Items) < 2 {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
kind := int(arr.Items[0].AsNumInt())
|
||
|
||
switch kind {
|
||
case ndLit:
|
||
return true, arr.Items[1]
|
||
case ndNil:
|
||
return true, hbrt.MakeNil()
|
||
|
||
case ndCol:
|
||
// Look up in aCols by upper-cased name, return aNewRow[i]
|
||
name := arr.Items[1].AsString()
|
||
if idx := strings.Index(name, "."); idx >= 0 {
|
||
name = name[idx+1:]
|
||
}
|
||
name = strings.ToUpper(name)
|
||
colsArr := c.aCols.AsArray()
|
||
rowArr := c.aNewRow.AsArray()
|
||
if colsArr != nil && rowArr != nil {
|
||
for i, col := range colsArr.Items {
|
||
ca := col.AsArray()
|
||
if ca == nil || len(ca.Items) < 2 {
|
||
continue
|
||
}
|
||
if strings.EqualFold(ca.Items[1].AsString(), name) && i < len(rowArr.Items) {
|
||
return true, rowArr.Items[i]
|
||
}
|
||
}
|
||
}
|
||
// Fallback: lookup in aFN → aGR[0]
|
||
fnArr := c.aFN.AsArray()
|
||
if fnArr != nil {
|
||
for i, n := range fnArr.Items {
|
||
if strings.EqualFold(n.AsString(), name) {
|
||
grArr := c.aGR.AsArray()
|
||
if grArr != nil && len(grArr.Items) > 0 {
|
||
firstRow := grArr.Items[0].AsArray()
|
||
if firstRow != nil && i < len(firstRow.Items) {
|
||
return true, firstRow.Items[i]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return true, hbrt.MakeNil()
|
||
|
||
case ndFn:
|
||
return c.evalAgg(arr)
|
||
|
||
case ndBin:
|
||
if len(arr.Items) < 4 {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
op := arr.Items[1].AsString()
|
||
// Short-circuit for AND/OR
|
||
if op == "AND" {
|
||
okL, vL := c.eval(arr.Items[2])
|
||
if !okL {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
if !havingIsTrue(vL) {
|
||
return true, hbrt.MakeBool(false)
|
||
}
|
||
okR, vR := c.eval(arr.Items[3])
|
||
if !okR {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
return true, hbrt.MakeBool(havingIsTrue(vR))
|
||
}
|
||
if op == "OR" {
|
||
okL, vL := c.eval(arr.Items[2])
|
||
if !okL {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
if havingIsTrue(vL) {
|
||
return true, hbrt.MakeBool(true)
|
||
}
|
||
okR, vR := c.eval(arr.Items[3])
|
||
if !okR {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
return true, hbrt.MakeBool(havingIsTrue(vR))
|
||
}
|
||
okL, vL := c.eval(arr.Items[2])
|
||
if !okL {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
okR, vR := c.eval(arr.Items[3])
|
||
if !okR {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
switch op {
|
||
case "=", "==":
|
||
return true, hbrt.MakeBool(sqlCmpEq(vL, vR))
|
||
case "<>", "!=":
|
||
return true, hbrt.MakeBool(!sqlCmpEq(vL, vR))
|
||
case "<":
|
||
return true, hbrt.MakeBool(sqlCmpLt(vL, vR))
|
||
case ">":
|
||
return true, hbrt.MakeBool(sqlCmpLt(vR, vL))
|
||
case "<=":
|
||
return true, hbrt.MakeBool(sqlCmpEq(vL, vR) || sqlCmpLt(vL, vR))
|
||
case ">=":
|
||
return true, hbrt.MakeBool(sqlCmpEq(vL, vR) || sqlCmpLt(vR, vL))
|
||
}
|
||
return false, hbrt.MakeNil()
|
||
|
||
case ndUni:
|
||
if len(arr.Items) < 3 {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
op := arr.Items[1].AsString()
|
||
okX, vX := c.eval(arr.Items[2])
|
||
if !okX {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
if op == "NOT" {
|
||
return true, hbrt.MakeBool(!havingIsTrue(vX))
|
||
}
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
|
||
// evalAgg runs a simple aggregate (COUNT/SUM/AVG/MIN/MAX) on aGR when
|
||
// the argument is a plain column (or "*" for COUNT). Anything else
|
||
// triggers a PRG fallback.
|
||
func (c *havingCtx) evalAgg(arr *hbrt.HbArray) (bool, hbrt.Value) {
|
||
if len(arr.Items) < 3 {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
name := strings.ToUpper(arr.Items[1].AsString())
|
||
switch name {
|
||
case "COUNT", "SUM", "AVG", "MIN", "MAX":
|
||
default:
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
|
||
// Parse first arg to find column index (0 → COUNT(*))
|
||
argsArr := arr.Items[2].AsArray()
|
||
if argsArr == nil || len(argsArr.Items) == 0 {
|
||
if name == "COUNT" {
|
||
grArr := c.aGR.AsArray()
|
||
if grArr == nil {
|
||
return true, hbrt.MakeNumInt(0)
|
||
}
|
||
return true, hbrt.MakeNumInt(int64(len(grArr.Items)))
|
||
}
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
firstArg := argsArr.Items[0].AsArray()
|
||
if firstArg == nil || len(firstArg.Items) < 2 {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
argKind := int(firstArg.Items[0].AsNumInt())
|
||
if argKind != ndCol {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
colName := firstArg.Items[1].AsString()
|
||
if colName == "*" {
|
||
if name == "COUNT" {
|
||
grArr := c.aGR.AsArray()
|
||
if grArr == nil {
|
||
return true, hbrt.MakeNumInt(0)
|
||
}
|
||
return true, hbrt.MakeNumInt(int64(len(grArr.Items)))
|
||
}
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
|
||
// Resolve column name → index in aFN
|
||
if idx := strings.Index(colName, "."); idx >= 0 {
|
||
colName = colName[idx+1:]
|
||
}
|
||
colName = strings.ToUpper(colName)
|
||
fnArr := c.aFN.AsArray()
|
||
nCol := -1
|
||
if fnArr != nil {
|
||
for i, n := range fnArr.Items {
|
||
if strings.EqualFold(n.AsString(), colName) {
|
||
nCol = i
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if nCol < 0 {
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
|
||
grArr := c.aGR.AsArray()
|
||
if grArr == nil {
|
||
return true, hbrt.MakeNumInt(0)
|
||
}
|
||
|
||
// Run the simple aggregate loop (mirrors SqlComputeAggSimple).
|
||
count := 0
|
||
sum := 0.0
|
||
var minV, maxV hbrt.Value
|
||
haveAny := false
|
||
for _, rowVal := range grArr.Items {
|
||
ra := rowVal.AsArray()
|
||
if ra == nil || nCol >= len(ra.Items) {
|
||
continue
|
||
}
|
||
v := ra.Items[nCol]
|
||
if v.IsNil() {
|
||
continue
|
||
}
|
||
count++
|
||
if v.IsNumeric() {
|
||
sum += v.AsNumDouble()
|
||
}
|
||
if !haveAny {
|
||
minV = v
|
||
maxV = v
|
||
haveAny = true
|
||
continue
|
||
}
|
||
if compareValuesNonNil(v, minV) < 0 {
|
||
minV = v
|
||
}
|
||
if compareValuesNonNil(v, maxV) > 0 {
|
||
maxV = v
|
||
}
|
||
}
|
||
|
||
switch name {
|
||
case "COUNT":
|
||
return true, hbrt.MakeNumInt(int64(count))
|
||
case "SUM":
|
||
if count == 0 {
|
||
return true, hbrt.MakeNil()
|
||
}
|
||
return true, hbrt.MakeDoubleAuto(sum)
|
||
case "AVG":
|
||
if count == 0 {
|
||
return true, hbrt.MakeNil()
|
||
}
|
||
return true, hbrt.MakeDoubleAuto(sum / float64(count))
|
||
case "MIN":
|
||
if !haveAny {
|
||
return true, hbrt.MakeNil()
|
||
}
|
||
return true, minV
|
||
case "MAX":
|
||
if !haveAny {
|
||
return true, hbrt.MakeNil()
|
||
}
|
||
return true, maxV
|
||
}
|
||
return false, hbrt.MakeNil()
|
||
}
|
||
|
||
// havingIsTrue mirrors PRG SqlIsTrue — NIL/0/empty-string all false.
|
||
func havingIsTrue(v hbrt.Value) bool {
|
||
return sqlIsTrue(v)
|
||
}
|
||
|
||
// SqlWindowPartitions(aRows, aPartColIdx) → aPartitions
|
||
//
|
||
// Groups row indices by their PARTITION BY column values, preserving
|
||
// first-seen order. Replaces the PRG hot loop in
|
||
// TSqlExecutor:ApplyWindowFunctions that per row does:
|
||
//
|
||
// cPartKey := ""
|
||
// FOR j := 1 TO Len( aPartBy )
|
||
// cPartKey += SqlValToStr( aRows[ i ][ aPartCol[ j ] ] ) + "|"
|
||
// NEXT
|
||
// IF ! hb_HHasKey( hPartitions, cPartKey )
|
||
// hPartitions[ cPartKey ] := {}
|
||
// ENDIF
|
||
// AAdd( hPartitions[ cPartKey ], i )
|
||
//
|
||
// Key construction reuses the shared valueHashKey → matches the PRG
|
||
// SqlValToStr equivalence classes byte-for-byte so partition
|
||
// identity is unchanged.
|
||
//
|
||
// Arguments:
|
||
// aRows: result rows (array of arrays)
|
||
// aPartColIdx: 1-based column indices for partition key (empty
|
||
// array → single "all rows" partition)
|
||
//
|
||
// Returns:
|
||
// Array of partitions. Each partition is an array of 1-based
|
||
// row indices into aRows, in first-seen order inside the partition.
|
||
// Partitions themselves are also in first-seen order of their key.
|
||
//
|
||
// Called at most once per window column per query — amortizes the
|
||
// Go↔PRG boundary cost across N·M operations.
|
||
func SqlWindowPartitions(t *hbrt.Thread) {
|
||
t.Frame(2, 0)
|
||
defer t.EndProc()
|
||
|
||
rowsVal := t.Local(1)
|
||
colsVal := t.Local(2)
|
||
if !rowsVal.IsArray() {
|
||
t.PushValue(hbrt.MakeArray(0))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
rows := rowsVal.AsArray().Items
|
||
nRows := len(rows)
|
||
|
||
var partCols []int
|
||
if colsVal.IsArray() {
|
||
colsArr := colsVal.AsArray().Items
|
||
partCols = make([]int, len(colsArr))
|
||
for i, v := range colsArr {
|
||
partCols[i] = int(v.AsNumInt()) - 1
|
||
}
|
||
}
|
||
|
||
// Fast path: no PARTITION BY → one partition holding all row indices.
|
||
if len(partCols) == 0 {
|
||
idxs := make([]hbrt.Value, nRows)
|
||
for i := 0; i < nRows; i++ {
|
||
idxs[i] = hbrt.MakeInt(i + 1)
|
||
}
|
||
t.PushValue(hbrt.MakeArrayFrom([]hbrt.Value{
|
||
hbrt.MakeArrayFrom(idxs),
|
||
}))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
// Preserve first-seen order via parallel slice + map.
|
||
order := make([]string, 0, 16)
|
||
groups := make(map[string][]int, 16)
|
||
var sb strings.Builder
|
||
|
||
for i := 0; i < nRows; i++ {
|
||
ra := rows[i].AsArray()
|
||
if ra == nil {
|
||
continue
|
||
}
|
||
sb.Reset()
|
||
for _, c := range partCols {
|
||
if c >= 0 && c < len(ra.Items) {
|
||
appendValueHashKey(&sb, ra.Items[c])
|
||
}
|
||
sb.WriteByte('|')
|
||
}
|
||
key := sb.String()
|
||
if _, ok := groups[key]; !ok {
|
||
groups[key] = make([]int, 0, 8)
|
||
order = append(order, key)
|
||
}
|
||
groups[key] = append(groups[key], i+1) // 1-based for PRG
|
||
}
|
||
|
||
out := make([]hbrt.Value, len(order))
|
||
for oi, key := range order {
|
||
g := groups[key]
|
||
idxs := make([]hbrt.Value, len(g))
|
||
for j, n := range g {
|
||
idxs[j] = hbrt.MakeInt(n)
|
||
}
|
||
out[oi] = hbrt.MakeArrayFrom(idxs)
|
||
}
|
||
t.PushValue(hbrt.MakeArrayFrom(out))
|
||
t.RetValue()
|
||
}
|
||
|
||
// SqlWindowSortPartition(aRows, aPartIdx, aSortSpec) → aPartIdx
|
||
//
|
||
// Sorts a partition (array of 1-based row indices into aRows) by the
|
||
// ORDER BY spec. aSortSpec is an array of {nColIdx, lDesc} pairs
|
||
// with 1-based column indices. Mutates aPartIdx in place and returns
|
||
// it for chainability.
|
||
//
|
||
// Matches PRG SqlWinRowCmp semantics byte-for-byte:
|
||
// - NIL sorts as the largest value (NULLs last in ASC, NULLs first
|
||
// in DESC) — consistent with the #3 migration for ORDER BY.
|
||
// - Mixed-type comparison: same ValType only; otherwise treated
|
||
// equal on that column (moves to next sort key).
|
||
// - Stable sort so the first-seen partition order (from SqlWindow-
|
||
// Partitions) carries through equal-value ties.
|
||
//
|
||
// Replaces ASort(aPartIdx,,, {|a,b| SqlWinRowCmp(...) < 0}) — the PRG
|
||
// block is invoked O(N log N) times per partition; Go sort skips that
|
||
// bridge and uses pre-resolved column indices.
|
||
func SqlWindowSortPartition(t *hbrt.Thread) {
|
||
t.Frame(3, 0)
|
||
defer t.EndProc()
|
||
|
||
rowsVal := t.Local(1)
|
||
idxVal := t.Local(2)
|
||
specVal := t.Local(3)
|
||
|
||
if !rowsVal.IsArray() || !idxVal.IsArray() || !specVal.IsArray() {
|
||
t.PushValue(idxVal)
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
rows := rowsVal.AsArray().Items
|
||
idxs := idxVal.AsArray().Items
|
||
specs := specVal.AsArray().Items
|
||
|
||
type sortCol struct {
|
||
idx int
|
||
desc bool
|
||
}
|
||
cols := make([]sortCol, 0, len(specs))
|
||
for _, s := range specs {
|
||
arr := s.AsArray()
|
||
if arr == nil || len(arr.Items) < 2 {
|
||
continue
|
||
}
|
||
cols = append(cols, sortCol{
|
||
idx: int(arr.Items[0].AsNumInt()) - 1,
|
||
desc: arr.Items[1].AsBool(),
|
||
})
|
||
}
|
||
if len(cols) == 0 || len(idxs) < 2 {
|
||
t.PushValue(idxVal)
|
||
t.RetValue()
|
||
return
|
||
}
|
||
|
||
sort.SliceStable(idxs, func(ai, bi int) bool {
|
||
ra := rows[int(idxs[ai].AsNumInt())-1].AsArray()
|
||
rb := rows[int(idxs[bi].AsNumInt())-1].AsArray()
|
||
if ra == nil || rb == nil {
|
||
return false
|
||
}
|
||
for _, c := range cols {
|
||
if c.idx < 0 || c.idx >= len(ra.Items) || c.idx >= len(rb.Items) {
|
||
continue
|
||
}
|
||
va := ra.Items[c.idx]
|
||
vb := rb.Items[c.idx]
|
||
|
||
// NIL handling: NIL is the largest value.
|
||
aNil, bNil := va.IsNil(), vb.IsNil()
|
||
if aNil && bNil {
|
||
continue
|
||
}
|
||
if aNil {
|
||
// a > b — in DESC, a comes first (less-than = true)
|
||
return c.desc
|
||
}
|
||
if bNil {
|
||
// b > a — in ASC, a comes first (less-than = true)
|
||
return !c.desc
|
||
}
|
||
|
||
// Only compare if same type, otherwise skip (PRG semantic).
|
||
if va.Type() != vb.Type() {
|
||
continue
|
||
}
|
||
cmp := compareValuesNonNil(va, vb)
|
||
if cmp == 0 {
|
||
continue
|
||
}
|
||
if c.desc {
|
||
return cmp > 0
|
||
}
|
||
return cmp < 0
|
||
}
|
||
return false
|
||
})
|
||
|
||
t.PushValue(idxVal)
|
||
t.RetValue()
|
||
}
|
||
|
||
// SqlWindowAssignRank(aRows, aPartIdx, aSortSpec, nColIdx, cFunc) → NIL
|
||
//
|
||
// Assigns ROW_NUMBER / RANK / DENSE_RANK values to each row in a
|
||
// sorted partition. Replaces the PRG loop in ApplyWindowFunctions:
|
||
//
|
||
// FOR k := 1 TO Len( aPartIdx )
|
||
// IF ! SqlWinRowsEqual( aRows, aPartIdx[k], aPartIdx[k-1], ... )
|
||
// nRank := k (or nRank++)
|
||
// ENDIF
|
||
// aRows[ aPartIdx[k] ][ nColIdx ] := nRank
|
||
// NEXT
|
||
//
|
||
// Collapses the per-row SqlWinRowsEqual + PRG indexing cost. aSortSpec
|
||
// is the same sort spec (array of {nCol, lDesc}) that
|
||
// SqlWindowSortPartition already consumes — caller reuses it without
|
||
// re-resolving column indices.
|
||
//
|
||
// Arguments:
|
||
// aRows : full result row set
|
||
// aPartIdx : partition (array of 1-based row indices, sorted)
|
||
// aSortSpec : ORDER BY spec; only column indices matter for
|
||
// equality check (direction unused). Empty spec means
|
||
// no ORDER BY → ROW_NUMBER semantic for RANK/DENSE too.
|
||
// nColIdx : 1-based output column index to receive the rank value
|
||
// cFunc : "ROW_NUMBER" | "RANK" | "DENSE_RANK"
|
||
//
|
||
// Mutates aRows in place. Returns NIL.
|
||
func SqlWindowAssignRank(t *hbrt.Thread) {
|
||
t.Frame(5, 0)
|
||
defer t.EndProc()
|
||
|
||
rowsVal := t.Local(1)
|
||
idxVal := t.Local(2)
|
||
specVal := t.Local(3)
|
||
nColIdx := int(t.Local(4).AsNumInt()) - 1
|
||
fn := t.Local(5).AsString()
|
||
|
||
if !rowsVal.IsArray() || !idxVal.IsArray() {
|
||
t.RetNil()
|
||
return
|
||
}
|
||
rows := rowsVal.AsArray().Items
|
||
idxs := idxVal.AsArray().Items
|
||
if len(idxs) == 0 || nColIdx < 0 {
|
||
t.RetNil()
|
||
return
|
||
}
|
||
|
||
// Unpack sort spec — we only need column indices for equality check.
|
||
var sortCols []int
|
||
if specVal.IsArray() {
|
||
specs := specVal.AsArray().Items
|
||
sortCols = make([]int, 0, len(specs))
|
||
for _, s := range specs {
|
||
arr := s.AsArray()
|
||
if arr == nil || len(arr.Items) < 2 {
|
||
continue
|
||
}
|
||
sortCols = append(sortCols, int(arr.Items[0].AsNumInt())-1)
|
||
}
|
||
}
|
||
|
||
// Helper: does row i equal row j on all sort columns? Reuses the
|
||
// compareValuesNonNil path; NIL matches NIL, NIL ≠ non-NIL.
|
||
rowsEqual := func(ri, rj int) bool {
|
||
ra := rows[ri].AsArray()
|
||
rb := rows[rj].AsArray()
|
||
if ra == nil || rb == nil {
|
||
return false
|
||
}
|
||
for _, c := range sortCols {
|
||
if c < 0 || c >= len(ra.Items) || c >= len(rb.Items) {
|
||
continue
|
||
}
|
||
va := ra.Items[c]
|
||
vb := rb.Items[c]
|
||
aNil, bNil := va.IsNil(), vb.IsNil()
|
||
if aNil != bNil {
|
||
return false
|
||
}
|
||
if aNil && bNil {
|
||
continue
|
||
}
|
||
if compareValuesNonNil(va, vb) != 0 {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Compute rank per row and write to aRows[ idx ][ nColIdx ].
|
||
writeRank := func(rowIdx, rank int) {
|
||
if rowIdx < 0 || rowIdx >= len(rows) {
|
||
return
|
||
}
|
||
ra := rows[rowIdx].AsArray()
|
||
if ra == nil || nColIdx >= len(ra.Items) {
|
||
return
|
||
}
|
||
ra.Items[nColIdx] = hbrt.MakeNumInt(int64(rank))
|
||
}
|
||
|
||
switch fn {
|
||
case "ROW_NUMBER":
|
||
for k, ri := range idxs {
|
||
writeRank(int(ri.AsNumInt())-1, k+1)
|
||
}
|
||
case "RANK":
|
||
// Same value group → same rank, then jump to k+1.
|
||
rank := 1
|
||
prevRowIdx := int(idxs[0].AsNumInt()) - 1
|
||
writeRank(prevRowIdx, rank)
|
||
for k := 1; k < len(idxs); k++ {
|
||
curIdx := int(idxs[k].AsNumInt()) - 1
|
||
if len(sortCols) == 0 || !rowsEqual(curIdx, prevRowIdx) {
|
||
rank = k + 1
|
||
}
|
||
writeRank(curIdx, rank)
|
||
prevRowIdx = curIdx
|
||
}
|
||
case "DENSE_RANK":
|
||
rank := 1
|
||
prevRowIdx := int(idxs[0].AsNumInt()) - 1
|
||
writeRank(prevRowIdx, rank)
|
||
for k := 1; k < len(idxs); k++ {
|
||
curIdx := int(idxs[k].AsNumInt()) - 1
|
||
if len(sortCols) == 0 || !rowsEqual(curIdx, prevRowIdx) {
|
||
rank++
|
||
}
|
||
writeRank(curIdx, rank)
|
||
prevRowIdx = curIdx
|
||
}
|
||
}
|
||
|
||
t.RetNil()
|
||
}
|
||
|
||
// SqlBulkUpdate(aFieldPositions, pcWhere, aValuePcodes) → nAffected
|
||
//
|
||
// Go-native replacement for the PRG UPDATE scan loop:
|
||
//
|
||
// dbGoTop()
|
||
// WHILE ! Eof()
|
||
// IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) )
|
||
// IF dbRLock( RecNo() )
|
||
// FOR i := 1 TO Len( aSet )
|
||
// FieldPut( nFPos[i], ::EvalExpr( aSet[i][2] ) )
|
||
// NEXT
|
||
// dbRUnlock( RecNo() )
|
||
// nAffected++
|
||
// ENDIF
|
||
// ENDIF
|
||
// dbSkip()
|
||
// ENDDO
|
||
// dbCommit()
|
||
//
|
||
// Collapses: Eof + Skip + RLock + FieldPut×N + RUnlock cross the Go
|
||
// boundary once per record via the PRG VM. Moving to one RTL call
|
||
// keeps the scan inside Go and uses compiled pcode for both WHERE
|
||
// and every SET value expression.
|
||
//
|
||
// Arguments:
|
||
// aFieldPositions: 1-based field positions to write (aligned with aValuePcodes)
|
||
// pcWhere: compiled WHERE pcode (NIL = unconditional update)
|
||
// aValuePcodes: compiled pcode per SET value expression
|
||
//
|
||
// Caller must ensure every SET value expression compiled successfully;
|
||
// any nil slot in aValuePcodes is silently skipped (leaves field unchanged).
|
||
//
|
||
// Txn caveat: does not call ::oTxn:LogRecord per row — caller is
|
||
// responsible for ensuring no active transaction when invoking this
|
||
// fast path, else undo semantics break.
|
||
func SqlBulkUpdate(t *hbrt.Thread) {
|
||
t.Frame(3, 0)
|
||
defer t.EndProc()
|
||
|
||
fieldsVal := t.Local(1)
|
||
whereVal := t.Local(2)
|
||
pcodesVal := t.Local(3)
|
||
|
||
if !fieldsVal.IsArray() || !pcodesVal.IsArray() {
|
||
t.RetInt(0)
|
||
return
|
||
}
|
||
fieldsArr := fieldsVal.AsArray().Items
|
||
pcodesArr := pcodesVal.AsArray().Items
|
||
nSets := len(fieldsArr)
|
||
if nSets != len(pcodesArr) || nSets == 0 {
|
||
t.RetInt(0)
|
||
return
|
||
}
|
||
|
||
fieldPos := make([]int, nSets)
|
||
for i := 0; i < nSets; i++ {
|
||
fieldPos[i] = int(fieldsArr[i].AsNumInt()) - 1
|
||
}
|
||
valuePcodes := make([]*hbrt.PcodeFunc, nSets)
|
||
for i, pv := range pcodesArr {
|
||
if p := pv.AsPointer(); p != nil {
|
||
if pc, ok := p.(*hbrt.PcodeFunc); ok {
|
||
valuePcodes[i] = pc
|
||
}
|
||
}
|
||
}
|
||
|
||
var whereFn *hbrt.PcodeFunc
|
||
if !whereVal.IsNil() {
|
||
if p := whereVal.AsPointer(); p != nil {
|
||
whereFn, _ = p.(*hbrt.PcodeFunc)
|
||
}
|
||
}
|
||
|
||
wam, ok := t.WA.(*hbrdd.WorkAreaManager)
|
||
if !ok {
|
||
t.RetInt(0)
|
||
return
|
||
}
|
||
area := wam.Current()
|
||
if area == nil {
|
||
t.RetInt(0)
|
||
return
|
||
}
|
||
dbfArea, _ := area.(*dbf.DBFArea)
|
||
if dbfArea == nil {
|
||
// Non-DBF area falls back to the generic Area interface — use
|
||
// the interface path; still a win over PRG boundary crossings.
|
||
t.RetInt(sqlBulkUpdateGeneric(t, area, whereFn, fieldPos, valuePcodes))
|
||
return
|
||
}
|
||
|
||
// Fast field getter — compiled pcode's PcOpFieldGet hits this
|
||
// closure instead of the generic FieldGet RTL dispatch.
|
||
prevFG := t.FastFieldGetter
|
||
t.FastFieldGetter = func(idx int) hbrt.Value {
|
||
v, _ := dbfArea.GetValue(idx - 1)
|
||
return v
|
||
}
|
||
defer func() { t.FastFieldGetter = prevFG }()
|
||
|
||
nAffected := 0
|
||
shared := dbfArea.IsShared()
|
||
|
||
dbfArea.GoTop()
|
||
for !dbfArea.EOF() {
|
||
match := true
|
||
if whereFn != nil {
|
||
hbrt.ExecPcodeFast(t, whereFn, nil)
|
||
match = t.GetRetValue().AsBool()
|
||
}
|
||
if match {
|
||
recNo := dbfArea.RecNo()
|
||
locked := true
|
||
if shared {
|
||
lockOk, _ := dbfArea.LockRecord(recNo)
|
||
locked = lockOk
|
||
}
|
||
if locked {
|
||
for i := 0; i < nSets; i++ {
|
||
pc := valuePcodes[i]
|
||
if pc == nil {
|
||
continue
|
||
}
|
||
hbrt.ExecPcodeFast(t, pc, nil)
|
||
dbfArea.PutValue(fieldPos[i], t.GetRetValue())
|
||
}
|
||
if shared {
|
||
dbfArea.UnlockRecord(recNo)
|
||
}
|
||
nAffected++
|
||
}
|
||
}
|
||
dbfArea.Skip(1)
|
||
}
|
||
/* Skip fsync when the WA cache is active — caller batches flush
|
||
* at SqlWACacheDisable / dbCloseAll. Per-call Flush on macOS APFS
|
||
* is ~1-2 ms (fsync), dominating the 100-row scan cost. */
|
||
if !waCacheEnabledSafe() {
|
||
dbfArea.Flush()
|
||
}
|
||
|
||
// Index maintenance. DBFArea.PutValue patches record bytes but does
|
||
// not delete + re-add index keys, so any index whose expression
|
||
// references one of the updated fields goes stale. We rebuild those
|
||
// indexes on the spot rather than leaving divergent state behind.
|
||
//
|
||
// Triggering condition: an index is open AND at least one updated
|
||
// field name appears in any index's key expression. We over-match
|
||
// by substring (so "ID" matches a compound expression like
|
||
// "DEPT+ID"), which is conservative — spurious rebuilds of indexes
|
||
// that happened to share a substring but don't really reference
|
||
// the field, never the reverse. Tables with no open indexes or
|
||
// with indexes that don't cover the updated columns skip the
|
||
// rebuild entirely, preserving the B13 UPDATE hot-path timing.
|
||
if nAffected > 0 && sqlBulkUpdateNeedsIndexRebuild(dbfArea, fieldPos) {
|
||
_ = dbfArea.OrderListRebuild()
|
||
}
|
||
|
||
t.RetInt(int64(nAffected))
|
||
}
|
||
|
||
// sqlBulkUpdateNeedsIndexRebuild reports whether any open index on the
|
||
// workarea references any of the just-written columns. Called once at
|
||
// the end of SqlBulkUpdate, so the hot path stays per-record-free.
|
||
func sqlBulkUpdateNeedsIndexRebuild(a *dbf.DBFArea, fieldPos []int) bool {
|
||
nOrd := a.IndexCount()
|
||
if nOrd == 0 {
|
||
return false
|
||
}
|
||
// Collect upper-cased names of the updated fields.
|
||
fieldNames := make([]string, 0, len(fieldPos))
|
||
for _, idx := range fieldPos {
|
||
if idx < 0 || idx >= a.FieldCount() {
|
||
continue
|
||
}
|
||
name := strings.ToUpper(strings.TrimRight(a.GetFieldInfo(idx).Name, "\x00 "))
|
||
if name != "" {
|
||
fieldNames = append(fieldNames, name)
|
||
}
|
||
}
|
||
if len(fieldNames) == 0 {
|
||
return false
|
||
}
|
||
for i := 1; i <= nOrd; i++ {
|
||
expr := strings.ToUpper(a.OrderKeyExpr(i))
|
||
if expr == "" {
|
||
// Index opened without a KeyExpr (legacy OrderListAdd path
|
||
// prior to the NTX header read). Conservatively rebuild —
|
||
// we can't prove the index doesn't cover these fields.
|
||
return true
|
||
}
|
||
for _, name := range fieldNames {
|
||
if strings.Contains(expr, name) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// waCacheEnabledSafe reads the cache flag under its lock — fast enough
|
||
// to call on every Bulk path, avoids the PRG→Go round-trip.
|
||
func waCacheEnabledSafe() bool {
|
||
waCacheMu.Lock()
|
||
on := waCacheEnabled
|
||
waCacheMu.Unlock()
|
||
return on
|
||
}
|
||
|
||
// sqlBulkUpdateGeneric handles non-DBF workareas via the Area interface.
|
||
func sqlBulkUpdateGeneric(t *hbrt.Thread, area hbrdd.Area, whereFn *hbrt.PcodeFunc, fieldPos []int, valuePcodes []*hbrt.PcodeFunc) int64 {
|
||
prevFG := t.FastFieldGetter
|
||
t.FastFieldGetter = func(idx int) hbrt.Value {
|
||
v, _ := area.GetValue(idx - 1)
|
||
return v
|
||
}
|
||
defer func() { t.FastFieldGetter = prevFG }()
|
||
|
||
nAffected := int64(0)
|
||
area.GoTop()
|
||
for !area.EOF() {
|
||
match := true
|
||
if whereFn != nil {
|
||
hbrt.ExecPcodeFast(t, whereFn, nil)
|
||
match = t.GetRetValue().AsBool()
|
||
}
|
||
if match {
|
||
for i := 0; i < len(fieldPos); i++ {
|
||
pc := valuePcodes[i]
|
||
if pc == nil {
|
||
continue
|
||
}
|
||
hbrt.ExecPcodeFast(t, pc, nil)
|
||
area.PutValue(fieldPos[i], t.GetRetValue())
|
||
}
|
||
nAffected++
|
||
}
|
||
area.Skip(1)
|
||
}
|
||
return nAffected
|
||
}
|
||
|
||
// SqlBulkDelete(pcWhere) → nAffected
|
||
//
|
||
// Go-native DELETE scan loop — counterpart to SqlBulkUpdate for pure
|
||
// DELETE FROM t WHERE ... statements. Replaces the PRG pattern:
|
||
//
|
||
// dbGoTop()
|
||
// WHILE ! Eof()
|
||
// IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) )
|
||
// dbRLock( RecNo() )
|
||
// dbDelete()
|
||
// dbRUnlock( RecNo() )
|
||
// nAffected++
|
||
// ENDIF
|
||
// dbSkip()
|
||
// ENDDO
|
||
//
|
||
// Same caveats as SqlBulkUpdate: caller must guarantee no active
|
||
// transaction (LogRecord is omitted) and SET DELETED handling stays
|
||
// with the PRG wrapper if it needs it.
|
||
//
|
||
// NIL whereFn ⇒ delete every row (caller should usually route that
|
||
// through TRUNCATE instead, but the behaviour is preserved for
|
||
// compat).
|
||
func SqlBulkDelete(t *hbrt.Thread) {
|
||
t.Frame(1, 0)
|
||
defer t.EndProc()
|
||
|
||
whereVal := t.Local(1)
|
||
var whereFn *hbrt.PcodeFunc
|
||
if !whereVal.IsNil() {
|
||
if p := whereVal.AsPointer(); p != nil {
|
||
whereFn, _ = p.(*hbrt.PcodeFunc)
|
||
}
|
||
}
|
||
|
||
wam, ok := t.WA.(*hbrdd.WorkAreaManager)
|
||
if !ok {
|
||
t.RetInt(0)
|
||
return
|
||
}
|
||
area := wam.Current()
|
||
if area == nil {
|
||
t.RetInt(0)
|
||
return
|
||
}
|
||
dbfArea, _ := area.(*dbf.DBFArea)
|
||
if dbfArea == nil {
|
||
t.RetInt(sqlBulkDeleteGeneric(t, area, whereFn))
|
||
return
|
||
}
|
||
|
||
prevFG := t.FastFieldGetter
|
||
t.FastFieldGetter = func(idx int) hbrt.Value {
|
||
v, _ := dbfArea.GetValue(idx - 1)
|
||
return v
|
||
}
|
||
defer func() { t.FastFieldGetter = prevFG }()
|
||
|
||
nAffected := 0
|
||
shared := dbfArea.IsShared()
|
||
dbfArea.GoTop()
|
||
for !dbfArea.EOF() {
|
||
match := true
|
||
if whereFn != nil {
|
||
hbrt.ExecPcodeFast(t, whereFn, nil)
|
||
match = t.GetRetValue().AsBool()
|
||
}
|
||
if match {
|
||
recNo := dbfArea.RecNo()
|
||
locked := true
|
||
if shared {
|
||
lockOk, _ := dbfArea.LockRecord(recNo)
|
||
locked = lockOk
|
||
}
|
||
if locked {
|
||
dbfArea.Delete()
|
||
if shared {
|
||
dbfArea.UnlockRecord(recNo)
|
||
}
|
||
nAffected++
|
||
}
|
||
}
|
||
dbfArea.Skip(1)
|
||
}
|
||
if !waCacheEnabledSafe() {
|
||
dbfArea.Flush()
|
||
}
|
||
t.RetInt(int64(nAffected))
|
||
}
|
||
|
||
// sqlBulkDeleteGeneric handles non-DBF workareas via the Area interface.
|
||
func sqlBulkDeleteGeneric(t *hbrt.Thread, area hbrdd.Area, whereFn *hbrt.PcodeFunc) int64 {
|
||
prevFG := t.FastFieldGetter
|
||
t.FastFieldGetter = func(idx int) hbrt.Value {
|
||
v, _ := area.GetValue(idx - 1)
|
||
return v
|
||
}
|
||
defer func() { t.FastFieldGetter = prevFG }()
|
||
|
||
nAffected := int64(0)
|
||
area.GoTop()
|
||
for !area.EOF() {
|
||
match := true
|
||
if whereFn != nil {
|
||
hbrt.ExecPcodeFast(t, whereFn, nil)
|
||
match = t.GetRetValue().AsBool()
|
||
}
|
||
if match {
|
||
area.Delete()
|
||
nAffected++
|
||
}
|
||
area.Skip(1)
|
||
}
|
||
return nAffected
|
||
}
|
||
|
||
// Frame-offset sentinels for SqlWindowSlideAgg. PRG encodes the SQL
|
||
// frame bounds "UNBOUNDED PRECEDING / FOLLOWING" into these values;
|
||
// any other offset is a relative row count (-N preceding, +N
|
||
// following, 0 current row).
|
||
const (
|
||
frameUnboundedPreceding = -(1 << 30)
|
||
frameUnboundedFollowing = (1 << 30)
|
||
)
|
||
|
||
// SqlWindowSlideAgg(aRows, aPartIdx, nArgCol, nColIdx, cFunc, leftOff, rightOff) → lHandled
|
||
//
|
||
// O(N) replacement for the ApplyWindowFunctions general-frame inner
|
||
// loop. Two algorithms share one entry point:
|
||
//
|
||
// SUM / AVG / COUNT — prefix-sum sweep. O(N) build, O(1) query per
|
||
// row. Two subtractions per frame instead of the O(N·W) inner
|
||
// loop that dominates wide-frame workloads like `ROWS BETWEEN
|
||
// 50 PRECEDING AND 50 FOLLOWING`.
|
||
//
|
||
// MIN / MAX — monotonic deque. SQL frame bounds are linear in the
|
||
// row index for every standard frame spec (UNBOUNDED PRECEDING,
|
||
// fixed N PRECEDING, CURRENT ROW, fixed N FOLLOWING, UNBOUNDED
|
||
// FOLLOWING), so L and R are both non-decreasing in k and the
|
||
// classic sliding-window deque applies in one sweep. Amortized
|
||
// O(1) per row.
|
||
//
|
||
// Returns .T. on success, .F. if the aggregate / value types aren't
|
||
// supported by the fast path — PRG falls back to the O(N·W) loop.
|
||
// Currently the MIN/MAX path only accepts numeric partition values;
|
||
// a non-numeric, non-NIL value in the scan column sends the RTL back
|
||
// to PRG so string / date comparisons still work correctly via the
|
||
// existing SqlCmpLt dispatch.
|
||
//
|
||
// Semantics match the PRG fallback:
|
||
// - COUNT(*) counts every row in frame (nArgCol == 0, i.e. <=0 here).
|
||
// - COUNT(expr), SUM, AVG, MIN, MAX skip NIL values.
|
||
// - SUM / AVG / MIN / MAX with an empty or all-NIL frame return NIL.
|
||
// - COUNT over empty frame returns 0.
|
||
// - Frame clamped to [1..partLen] just like SqlFrameOffset did.
|
||
func SqlWindowSlideAgg(t *hbrt.Thread) {
|
||
t.Frame(7, 0)
|
||
defer t.EndProc()
|
||
|
||
rowsVal := t.Local(1)
|
||
partVal := t.Local(2)
|
||
nArgCol := int(t.Local(3).AsNumInt()) - 1 // 0-based; -1 = COUNT(*)
|
||
nColIdx := int(t.Local(4).AsNumInt()) - 1
|
||
cFunc := strings.ToUpper(t.Local(5).AsString())
|
||
leftOff := int(t.Local(6).AsNumInt())
|
||
rightOff := int(t.Local(7).AsNumInt())
|
||
|
||
if !rowsVal.IsArray() || !partVal.IsArray() {
|
||
t.RetBool(false)
|
||
return
|
||
}
|
||
rowsArr := rowsVal.AsArray().Items
|
||
partArr := partVal.AsArray().Items
|
||
N := len(partArr)
|
||
if N == 0 {
|
||
t.RetBool(true)
|
||
return
|
||
}
|
||
|
||
// Snapshot partition indices as 0-based int once.
|
||
part := make([]int, N)
|
||
for i, v := range partArr {
|
||
part[i] = int(v.AsNumInt()) - 1
|
||
}
|
||
|
||
switch cFunc {
|
||
case "SUM", "AVG", "COUNT":
|
||
sqlWindowPrefixAgg(rowsArr, part, nArgCol, nColIdx, cFunc, leftOff, rightOff)
|
||
t.RetBool(true)
|
||
case "MIN", "MAX":
|
||
if nArgCol < 0 {
|
||
// MIN/MAX(*) has no meaning — matches PRG which treats it
|
||
// as "always NIL" via the no-argcol branch.
|
||
t.RetBool(false)
|
||
return
|
||
}
|
||
ok := sqlWindowMonotonicMinMax(rowsArr, part, nArgCol, nColIdx, cFunc, leftOff, rightOff)
|
||
t.RetBool(ok)
|
||
default:
|
||
t.RetBool(false)
|
||
}
|
||
}
|
||
|
||
// sqlWindowPrefixAgg runs the O(N) prefix-sum sweep for SUM / AVG /
|
||
// COUNT. Extracted from the SqlWindowSlideAgg body so the MIN/MAX
|
||
// path can share the setup without duplicating it.
|
||
func sqlWindowPrefixAgg(
|
||
rowsArr []hbrt.Value, part []int, nArgCol, nColIdx int,
|
||
cFunc string, leftOff, rightOff int,
|
||
) {
|
||
N := len(part)
|
||
// Build prefix arrays: prefSum[i] = sum of values[0..i-1],
|
||
// prefCnt[i] = count of non-NIL values[0..i-1].
|
||
prefSum := make([]float64, N+1)
|
||
prefCnt := make([]int, N+1)
|
||
for i := 0; i < N; i++ {
|
||
prefSum[i+1] = prefSum[i]
|
||
prefCnt[i+1] = prefCnt[i]
|
||
if nArgCol >= 0 {
|
||
rowIdx := part[i]
|
||
if rowIdx >= 0 && rowIdx < len(rowsArr) {
|
||
rowArr := rowsArr[rowIdx].AsArray()
|
||
if rowArr != nil && nArgCol < len(rowArr.Items) {
|
||
v := rowArr.Items[nArgCol]
|
||
if !v.IsNil() && v.IsNumeric() {
|
||
prefSum[i+1] += v.AsNumDouble()
|
||
prefCnt[i+1]++
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for k := 0; k < N; k++ {
|
||
L, R := resolveFrameBounds(k, N, leftOff, rightOff)
|
||
rowIdx := part[k]
|
||
if rowIdx < 0 || rowIdx >= len(rowsArr) {
|
||
continue
|
||
}
|
||
rowArr := rowsArr[rowIdx].AsArray()
|
||
if rowArr == nil || nColIdx < 0 || nColIdx >= len(rowArr.Items) {
|
||
continue
|
||
}
|
||
var result hbrt.Value
|
||
if L > R {
|
||
switch cFunc {
|
||
case "COUNT":
|
||
result = hbrt.MakeInt(0)
|
||
default:
|
||
result = hbrt.MakeNil()
|
||
}
|
||
} else if cFunc == "COUNT" && nArgCol < 0 {
|
||
result = hbrt.MakeInt(R - L + 1)
|
||
} else {
|
||
winSum := prefSum[R+1] - prefSum[L]
|
||
winCnt := prefCnt[R+1] - prefCnt[L]
|
||
switch cFunc {
|
||
case "SUM":
|
||
if winCnt == 0 {
|
||
result = hbrt.MakeNil()
|
||
} else {
|
||
result = hbrt.MakeDouble(winSum, 0, 0)
|
||
}
|
||
case "AVG":
|
||
if winCnt == 0 {
|
||
result = hbrt.MakeNil()
|
||
} else {
|
||
result = hbrt.MakeDouble(winSum/float64(winCnt), 0, 0)
|
||
}
|
||
case "COUNT":
|
||
result = hbrt.MakeInt(winCnt)
|
||
default:
|
||
result = hbrt.MakeNil()
|
||
}
|
||
}
|
||
rowArr.Items[nColIdx] = result
|
||
}
|
||
}
|
||
|
||
// sqlWindowMonotonicMinMax answers each row's MIN / MAX over its
|
||
// window frame in amortized O(1) using a monotonic deque of partition
|
||
// indices. Returns false (and writes nothing) if a non-numeric,
|
||
// non-NIL value is encountered — the PRG loop handles string / date
|
||
// comparisons via SqlCmpLt.
|
||
//
|
||
// The deque holds indices `i` into part[]; values stored at those
|
||
// indices form a monotonically non-increasing sequence (for MIN) or
|
||
// non-decreasing (for MAX), so the front is always the extremum of
|
||
// the currently valid window.
|
||
func sqlWindowMonotonicMinMax(
|
||
rowsArr []hbrt.Value, part []int, nArgCol, nColIdx int,
|
||
cFunc string, leftOff, rightOff int,
|
||
) bool {
|
||
N := len(part)
|
||
// Extract numeric values + NIL flags up front. If any non-NIL,
|
||
// non-numeric value appears, bail so the PRG loop can handle it.
|
||
vals := make([]float64, N)
|
||
hasVal := make([]bool, N)
|
||
origVal := make([]hbrt.Value, N) // preserve original Value for result
|
||
for i := 0; i < N; i++ {
|
||
rowIdx := part[i]
|
||
if rowIdx < 0 || rowIdx >= len(rowsArr) {
|
||
continue
|
||
}
|
||
rowArr := rowsArr[rowIdx].AsArray()
|
||
if rowArr == nil || nArgCol >= len(rowArr.Items) {
|
||
continue
|
||
}
|
||
v := rowArr.Items[nArgCol]
|
||
if v.IsNil() {
|
||
continue
|
||
}
|
||
if !v.IsNumeric() {
|
||
return false
|
||
}
|
||
vals[i] = v.AsNumDouble()
|
||
hasVal[i] = true
|
||
origVal[i] = v
|
||
}
|
||
|
||
isMin := cFunc == "MIN"
|
||
// Ring-buffer deque keyed by partition index. The index is also
|
||
// its position in the monotonic sequence; values at those indices
|
||
// are the comparison key. Capacity N is an upper bound.
|
||
deque := make([]int, 0, N)
|
||
nextToPush := 0
|
||
|
||
for k := 0; k < N; k++ {
|
||
L, R := resolveFrameBounds(k, N, leftOff, rightOff)
|
||
|
||
// Ingest all partition indices up to R that haven't been
|
||
// pushed yet. NIL values never enter the deque, matching
|
||
// PRG's MIN/MAX which skip NILs.
|
||
for nextToPush <= R && nextToPush < N {
|
||
if hasVal[nextToPush] {
|
||
x := vals[nextToPush]
|
||
for len(deque) > 0 {
|
||
back := deque[len(deque)-1]
|
||
if (isMin && vals[back] >= x) || (!isMin && vals[back] <= x) {
|
||
deque = deque[:len(deque)-1]
|
||
continue
|
||
}
|
||
break
|
||
}
|
||
deque = append(deque, nextToPush)
|
||
}
|
||
nextToPush++
|
||
}
|
||
// Retire deque entries that fell outside the window's left edge.
|
||
for len(deque) > 0 && deque[0] < L {
|
||
deque = deque[1:]
|
||
}
|
||
|
||
rowIdx := part[k]
|
||
if rowIdx < 0 || rowIdx >= len(rowsArr) {
|
||
continue
|
||
}
|
||
rowArr := rowsArr[rowIdx].AsArray()
|
||
if rowArr == nil || nColIdx < 0 || nColIdx >= len(rowArr.Items) {
|
||
continue
|
||
}
|
||
|
||
if L > R || len(deque) == 0 {
|
||
rowArr.Items[nColIdx] = hbrt.MakeNil()
|
||
} else {
|
||
rowArr.Items[nColIdx] = origVal[deque[0]]
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// resolveFrameBounds turns the encoded relative offsets into 0-based
|
||
// inclusive [L, R] bounds clamped to the partition. The sentinel
|
||
// values map to absolute boundaries; everything else is k + offset.
|
||
func resolveFrameBounds(k, N, leftOff, rightOff int) (int, int) {
|
||
var L, R int
|
||
switch leftOff {
|
||
case frameUnboundedPreceding:
|
||
L = 0
|
||
case frameUnboundedFollowing:
|
||
L = N
|
||
default:
|
||
L = k + leftOff
|
||
}
|
||
switch rightOff {
|
||
case frameUnboundedPreceding:
|
||
R = -1
|
||
case frameUnboundedFollowing:
|
||
R = N - 1
|
||
default:
|
||
R = k + rightOff
|
||
}
|
||
if L < 0 {
|
||
L = 0
|
||
}
|
||
if R >= N {
|
||
R = N - 1
|
||
}
|
||
return L, R
|
||
}
|
||
|
||
// SqlBulkInsert(aRows) → nInserted
|
||
//
|
||
// Go-native bulk INSERT into the current workarea. Replaces the
|
||
// PRG pattern used by CTE materialization, CREATE TABLE AS SELECT,
|
||
// and subquery-driven temp tables:
|
||
//
|
||
// FOR j := 1 TO Len( aRows )
|
||
// dbAppend()
|
||
// FOR k := 1 TO Min( Len(aStruct), Len(aRows[j]) )
|
||
// IF aRows[j][k] != NIL
|
||
// FieldPut( k, aRows[j][k] )
|
||
// ENDIF
|
||
// NEXT
|
||
// NEXT
|
||
// dbCommit()
|
||
//
|
||
// Collapses ~N·M Go RTL boundary crossings to a single call plus
|
||
// native Append/PutValue/Flush on *DBFArea. Semantics preserved:
|
||
// - NIL element → field left at its default value
|
||
// - Row length > field count → extra columns ignored
|
||
// - Row length < field count → trailing fields left at default
|
||
// - Flushes once at end (matches PRG dbCommit() after the loop)
|
||
//
|
||
// Returns the number of rows appended (excluding rows where aRows[i]
|
||
// is not an array — those are skipped silently, matching the PRG
|
||
// loop which would panic on non-array access).
|
||
func SqlBulkInsert(t *hbrt.Thread) {
|
||
t.Frame(1, 0)
|
||
defer t.EndProc()
|
||
|
||
rowsVal := t.Local(1)
|
||
if !rowsVal.IsArray() {
|
||
t.RetInt(0)
|
||
return
|
||
}
|
||
|
||
wam, ok := t.WA.(*hbrdd.WorkAreaManager)
|
||
if !ok || wam == nil {
|
||
t.RetInt(0)
|
||
return
|
||
}
|
||
area := wam.Current()
|
||
if area == nil {
|
||
t.RetInt(0)
|
||
return
|
||
}
|
||
|
||
nFields := area.FieldCount()
|
||
rows := rowsVal.AsArray().Items
|
||
inserted := 0
|
||
|
||
// Type-assert the concrete DBF type once so the inner loop avoids
|
||
// interface-dispatch per call. Non-DBF backends (MEMRDD) take the
|
||
// generic hbrdd.Area path.
|
||
// NIL values must still be routed through PutValue so the DBF
|
||
// driver sets the _NullFlags bit for nullable columns. Skipping
|
||
// the call leaves the raw bytes at their dbAppend() defaults
|
||
// (spaces / zeros), which reads back as empty string / 0 rather
|
||
// than SQL NULL. Pre-nullable code skipped NIL purely as an
|
||
// optimization (no-op write); with the nullable bitmap that
|
||
// "optimization" silently discards NULL markers on multi-row
|
||
// INSERT VALUES (...), (...), ...
|
||
if dbfArea, isDbf := area.(*dbf.DBFArea); isDbf {
|
||
for _, rowVal := range rows {
|
||
ra := rowVal.AsArray()
|
||
if ra == nil {
|
||
continue
|
||
}
|
||
if err := dbfArea.Append(); err != nil {
|
||
break
|
||
}
|
||
limit := len(ra.Items)
|
||
if limit > nFields {
|
||
limit = nFields
|
||
}
|
||
for k := 0; k < limit; k++ {
|
||
dbfArea.PutValue(k, ra.Items[k])
|
||
}
|
||
inserted++
|
||
}
|
||
dbfArea.Flush()
|
||
} else {
|
||
for _, rowVal := range rows {
|
||
ra := rowVal.AsArray()
|
||
if ra == nil {
|
||
continue
|
||
}
|
||
if err := area.Append(); err != nil {
|
||
break
|
||
}
|
||
limit := len(ra.Items)
|
||
if limit > nFields {
|
||
limit = nFields
|
||
}
|
||
for k := 0; k < limit; k++ {
|
||
area.PutValue(k, ra.Items[k])
|
||
}
|
||
inserted++
|
||
}
|
||
if flusher, ok := area.(interface{ Flush() error }); ok {
|
||
flusher.Flush()
|
||
}
|
||
}
|
||
|
||
t.RetInt(int64(inserted))
|
||
}
|
||
|
||
// SqlEach(aFieldPositions, pcWhere, bBlock) → NIL
|
||
//
|
||
// Streaming variant of SqlScan — instead of materializing all matching
|
||
// rows into a result array (which costs N HbArray allocations plus a
|
||
// second pass when the PRG caller iterates it), we invoke a user-provided
|
||
// code block once per matching row, passing the selected field values as
|
||
// block parameters.
|
||
//
|
||
// This is the Harbour block-iteration idiom (`AEval`, `AScan`) applied
|
||
// to SQL. Total heap traffic collapses to ~0 — no result rows, no slab,
|
||
// no flat value buffer. Per-row overhead becomes just (field reads +
|
||
// WHERE eval + block invoke).
|
||
//
|
||
// Expected to hit raw-RDD parity on end-to-end "SQL → user code" timing.
|
||
//
|
||
// Arguments:
|
||
// aFieldPositions: 1-based field positions to pass as block params
|
||
// pcWhere: compiled WHERE predicate, or NIL
|
||
// bBlock: code block receiving nFields positional params
|
||
func SqlEach(t *hbrt.Thread) {
|
||
t.Frame(3, 0)
|
||
defer t.EndProc()
|
||
|
||
fieldsVal := t.Local(1)
|
||
if !fieldsVal.IsArray() {
|
||
t.RetNil()
|
||
return
|
||
}
|
||
fieldsArr := fieldsVal.AsArray().Items
|
||
nFields := len(fieldsArr)
|
||
|
||
whereVal := t.Local(2)
|
||
var whereFn *hbrt.PcodeFunc
|
||
if !whereVal.IsNil() {
|
||
if p := whereVal.AsPointer(); p != nil {
|
||
whereFn, _ = p.(*hbrt.PcodeFunc)
|
||
}
|
||
}
|
||
|
||
blockVal := t.Local(3)
|
||
if !blockVal.IsBlock() {
|
||
t.RetNil()
|
||
return
|
||
}
|
||
blk := blockVal.AsBlock()
|
||
|
||
fieldPos := make([]int, nFields)
|
||
for i := 0; i < nFields; i++ {
|
||
fieldPos[i] = int(fieldsArr[i].AsNumInt())
|
||
if fieldPos[i] < 1 {
|
||
fieldPos[i] = 1
|
||
}
|
||
}
|
||
|
||
wam, ok := t.WA.(*hbrdd.WorkAreaManager)
|
||
if !ok {
|
||
t.RetNil()
|
||
return
|
||
}
|
||
area := wam.Current()
|
||
if area == nil {
|
||
t.RetNil()
|
||
return
|
||
}
|
||
dbfArea, _ := area.(*dbf.DBFArea)
|
||
|
||
// Install FastFieldGetter for the WHERE predicate's PcOpFieldGet ops
|
||
prevFG := t.FastFieldGetter
|
||
if dbfArea != nil {
|
||
t.FastFieldGetter = func(idx int) hbrt.Value {
|
||
v, _ := dbfArea.GetValue(idx - 1)
|
||
return v
|
||
}
|
||
} else {
|
||
t.FastFieldGetter = func(idx int) hbrt.Value {
|
||
v, _ := area.GetValue(idx - 1)
|
||
return v
|
||
}
|
||
}
|
||
defer func() { t.FastFieldGetter = prevFG }()
|
||
|
||
// Block eval protocol: push N args on the stack, set pendingParams,
|
||
// call blk.Fn(t). Matches what EvalBlock does inline, skipping the
|
||
// per-call `make([]Value, nArgs)` temp slice.
|
||
//
|
||
// Four specialized loops on {DBF, generic}×{WHERE, none}, same
|
||
// reasoning as SqlScan's loop split.
|
||
switch {
|
||
case dbfArea != nil && whereFn != nil:
|
||
dbfArea.GoTop()
|
||
for !dbfArea.EOF() {
|
||
hbrt.ExecPcodeFast(t, whereFn, nil)
|
||
if t.GetRetValue().AsBool() {
|
||
for i := 0; i < nFields; i++ {
|
||
v, _ := dbfArea.GetValue(fieldPos[i] - 1)
|
||
t.PushValue(v)
|
||
}
|
||
t.PendingParams2(nFields)
|
||
blk.Fn(t)
|
||
}
|
||
dbfArea.Skip(1)
|
||
}
|
||
case dbfArea != nil:
|
||
dbfArea.GoTop()
|
||
for !dbfArea.EOF() {
|
||
for i := 0; i < nFields; i++ {
|
||
v, _ := dbfArea.GetValue(fieldPos[i] - 1)
|
||
t.PushValue(v)
|
||
}
|
||
t.PendingParams2(nFields)
|
||
blk.Fn(t)
|
||
dbfArea.Skip(1)
|
||
}
|
||
case whereFn != nil:
|
||
area.GoTop()
|
||
for !area.EOF() {
|
||
hbrt.ExecPcodeFast(t, whereFn, nil)
|
||
if t.GetRetValue().AsBool() {
|
||
for i := 0; i < nFields; i++ {
|
||
v, _ := area.GetValue(fieldPos[i] - 1)
|
||
t.PushValue(v)
|
||
}
|
||
t.PendingParams2(nFields)
|
||
blk.Fn(t)
|
||
}
|
||
area.Skip(1)
|
||
}
|
||
default:
|
||
area.GoTop()
|
||
for !area.EOF() {
|
||
for i := 0; i < nFields; i++ {
|
||
v, _ := area.GetValue(fieldPos[i] - 1)
|
||
t.PushValue(v)
|
||
}
|
||
t.PendingParams2(nFields)
|
||
blk.Fn(t)
|
||
area.Skip(1)
|
||
}
|
||
}
|
||
|
||
t.RetNil()
|
||
}
|
||
|
||
// SqlFetchRowFast(oSelf, aExprs, aFetchCache) → aRow
|
||
//
|
||
// Go-native replacement for TSqlExecutor:FetchRow. Profile showed
|
||
// FetchRow at ~30% of B4 GROUP+HAVING CPU — 100 rows × 1000 iters of
|
||
// PRG method dispatch per column per row, even with the aFetchCache
|
||
// fast path. This collapses the per-row loop into one Go call: bound
|
||
// cache entries (`{nWA, nFPos}`) do a direct SelectByNum+GetValue;
|
||
// unbound entries fall back to `self:EvalExpr(exprs[i][1])` via Send.
|
||
// Character values get trimmed inline (mirrors PRG AllTrim, which is
|
||
// really TrimSpace in practice since DBF pads with ASCII space).
|
||
func SqlFetchRowFast(t *hbrt.Thread) {
|
||
t.Frame(3, 0)
|
||
defer t.EndProc()
|
||
|
||
self := t.Local(1)
|
||
exprsVal := t.Local(2)
|
||
cacheVal := t.Local(3)
|
||
|
||
if !exprsVal.IsArray() {
|
||
t.PushValue(hbrt.MakeArrayFrom(nil))
|
||
t.RetValue()
|
||
return
|
||
}
|
||
exprs := exprsVal.AsArray().Items
|
||
n := len(exprs)
|
||
|
||
var cache []hbrt.Value
|
||
useCache := false
|
||
if cacheVal.IsArray() {
|
||
cache = cacheVal.AsArray().Items
|
||
useCache = len(cache) == n
|
||
}
|
||
|
||
wa := getWA(t)
|
||
out := make([]hbrt.Value, 0, n)
|
||
|
||
for i := 0; i < n; i++ {
|
||
var val hbrt.Value
|
||
hit := false
|
||
|
||
if useCache {
|
||
entry := cache[i]
|
||
if !entry.IsNil() && entry.IsArray() {
|
||
items := entry.AsArray().Items
|
||
if len(items) >= 2 && wa != nil {
|
||
nWA := uint16(items[0].AsNumInt())
|
||
nFPos := int(items[1].AsNumInt())
|
||
wa.SelectByNum(nWA)
|
||
if area := wa.Current(); area != nil {
|
||
if v, err := area.GetValue(nFPos - 1); err == nil {
|
||
val = v
|
||
hit = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !hit {
|
||
// Fallback: self:EvalExpr(exprs[i][1])
|
||
var exprNode hbrt.Value
|
||
if exprs[i].IsArray() {
|
||
items := exprs[i].AsArray().Items
|
||
if len(items) > 0 {
|
||
exprNode = items[0]
|
||
}
|
||
}
|
||
t.PushValue(self)
|
||
t.PushValue(exprNode)
|
||
t.Send("EVALEXPR", 1)
|
||
val = t.Pop2()
|
||
}
|
||
|
||
if val.IsString() {
|
||
s := val.AsString()
|
||
trimmed := strings.TrimSpace(s)
|
||
if len(trimmed) != len(s) {
|
||
val = hbrt.MakeString(trimmed)
|
||
}
|
||
}
|
||
out = append(out, val)
|
||
}
|
||
|
||
t.PushValue(hbrt.MakeArrayFrom(out))
|
||
t.RetValue()
|
||
}
|