Systematic pass through PRG hot paths, promoting them to Go RTL while
preserving Harbour/FiveSql2 semantics. Full log in
docs/RTL-Go-Native-Migration.md.
Bench (bench_sql) vs 2026-04-08 baseline
- B1 SELECT * 2,192 → 114 µs (19x)
- B6 INNER JOIN 9,291 → 233 µs (40x)
- B7 CTE simple 8,037 → 129 µs (62x)
- B9 ROW_NUMBER 3,705 → 265 µs (14x)
- B10 RANK PARTITION 4,748 → 309 µs (15x)
- B12 INSERT (WA cache) 4,319 → 63 µs (69x)
- B13 UPDATE (WA cache) 6,144 → 68 µs (90x)
- B15 CTE+WIN+JOIN 18,395 → 1,873 µs (10x)
Infrastructure
- HbHash O(1) Index preserving insertion order (Harbour KEEPORDER)
- HbDeepClone Go RTL (scalar-sharing, immutable hash keys)
- MEMRDD auto-imported via gengo; all Five programs get mem:name driver
- SQL plan + pcode caches (s_hPlanCache, s_hDmlPcodeCache)
- Opt-in SqlWACacheEnable — dbUseArea/Close/Commit batched for DML
SQL engine
- FiveSql2 lexer ported to Go (byte FSM) with combined automatic
template parameterization (literals → ?, concat queries share plan)
- Go RTL: SqlDistinct, SqlGroupRows, SqlWindowPartitions,
SqlWindowSortPartition, SqlWindowAssignRank, SqlComputeAggSimple,
SqlBulkInsert, SqlBulkUpdate, SqlExprHasAgg, SqlEvalHaving
- CTE / subquery / driving-table materialize paths use MEMRDD
- SqlCoerce/SqlCmp/SqlIsTrue helpers moved from PRG to Go
- SqlBulkUpdate defers Flush when WA cache active (APFS fsync was
dominant B13 cost — 1.6ms/call → gone)
Correctness fixes uncovered during migration
- ASort default path now sorts dates/logicals/timestamps (was no-op)
- ORDER BY default NULL placement matches PRG SqlRowCompare across
Go fast path; explicit NULLS FIRST/LAST honored by both paths
- SqlBulkUpdate respects EXCLUSIVE vs SHARED mode record locks
- SqlCmp/SqlCmpEq normalize NumInt vs Double (caught by test 6b)
Verification
- go test ./... ALL PASS
- FiveSql2 test_sql1999 43/43
- tests/compat_harbour 56/56 (+5 new: ASort dates/logicals,
AScan int cross-type)
- Regression test test_null_order.prg for ORDER BY NULL ordering
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
514 lines
12 KiB
Go
514 lines
12 KiB
Go
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
|
// All rights reserved.
|
|
|
|
// Array functions for the Five runtime library.
|
|
// Implements Harbour-compatible array manipulation functions.
|
|
// Reference: /mnt/d/harbour-core/src/vm/arrays.c
|
|
package hbrtl
|
|
|
|
import (
|
|
"five/hbrt"
|
|
"sort"
|
|
)
|
|
|
|
// AAdd appends an element to an array and returns the array.
|
|
// Harbour: AAdd(aArray, xValue) → aArray
|
|
func AAdd(t *hbrt.Thread) {
|
|
t.Frame(2, 0)
|
|
defer t.EndProc()
|
|
arrVal := t.Local(1)
|
|
val := t.Local(2)
|
|
if !arrVal.IsArray() {
|
|
panic("AAdd: argument is not an array")
|
|
}
|
|
arr := arrVal.AsArray()
|
|
arr.Items = append(arr.Items, val)
|
|
t.PushValue(arrVal)
|
|
t.RetValue()
|
|
}
|
|
|
|
// ADel deletes an element from array at position, shifts remaining left.
|
|
// Harbour: ADel(aArray, nPos) → aArray
|
|
func ADel(t *hbrt.Thread) {
|
|
t.Frame(2, 0)
|
|
defer t.EndProc()
|
|
arrVal := t.Local(1)
|
|
pos := int(t.Local(2).AsNumInt())
|
|
arr := arrVal.AsArray()
|
|
if pos >= 1 && pos <= len(arr.Items) {
|
|
copy(arr.Items[pos-1:], arr.Items[pos:])
|
|
arr.Items[len(arr.Items)-1] = hbrt.MakeNil()
|
|
}
|
|
t.PushValue(arrVal)
|
|
t.RetValue()
|
|
}
|
|
|
|
// AIns inserts NIL at position, shifts elements right.
|
|
// Harbour: AIns(aArray, nPos) → aArray
|
|
func AIns(t *hbrt.Thread) {
|
|
t.Frame(2, 0)
|
|
defer t.EndProc()
|
|
arrVal := t.Local(1)
|
|
pos := int(t.Local(2).AsNumInt())
|
|
arr := arrVal.AsArray()
|
|
if pos >= 1 && pos <= len(arr.Items) {
|
|
copy(arr.Items[pos:], arr.Items[pos-1:len(arr.Items)-1])
|
|
arr.Items[pos-1] = hbrt.MakeNil()
|
|
}
|
|
t.PushValue(arrVal)
|
|
t.RetValue()
|
|
}
|
|
|
|
// ASize resizes an array.
|
|
// Harbour: ASize(aArray, nLen) → aArray
|
|
func ASize(t *hbrt.Thread) {
|
|
t.Frame(2, 0)
|
|
defer t.EndProc()
|
|
arrVal := t.Local(1)
|
|
newLen := int(t.Local(2).AsNumInt())
|
|
arr := arrVal.AsArray()
|
|
if newLen < 0 {
|
|
newLen = 0
|
|
}
|
|
if newLen > len(arr.Items) {
|
|
ext := make([]hbrt.Value, newLen-len(arr.Items))
|
|
arr.Items = append(arr.Items, ext...)
|
|
} else {
|
|
arr.Items = arr.Items[:newLen]
|
|
}
|
|
t.PushValue(arrVal)
|
|
t.RetValue()
|
|
}
|
|
|
|
// AClone creates a shallow copy of an array.
|
|
// Harbour: AClone(aArray) → aNewArray
|
|
func AClone(t *hbrt.Thread) {
|
|
t.Frame(1, 0)
|
|
defer t.EndProc()
|
|
arrVal := t.Local(1)
|
|
if !arrVal.IsArray() {
|
|
t.PushValue(arrVal)
|
|
t.RetValue()
|
|
return
|
|
}
|
|
src := arrVal.AsArray()
|
|
items := make([]hbrt.Value, len(src.Items))
|
|
copy(items, src.Items)
|
|
t.PushValue(hbrt.MakeArrayFrom(items))
|
|
t.RetValue()
|
|
}
|
|
|
|
// HbDeepClone recursively clones a value. Arrays and hashes are cloned
|
|
// element-by-element; scalars (string, number, logical, date, NIL) are
|
|
// returned unchanged — Five strings/numbers are immutable so sharing
|
|
// pointers is safe. Used by FiveSql2's plan cache to hand callers a
|
|
// pristine copy of the parsed query tree on every cache hit, since
|
|
// Run() mutates some nodes (SqlFoldConst in particular).
|
|
//
|
|
// Harbour: hb_DeepCopy(xVal) → xNewVal
|
|
func HbDeepClone(t *hbrt.Thread) {
|
|
t.Frame(1, 0)
|
|
defer t.EndProc()
|
|
t.PushValue(deepCloneValue(t.Local(1)))
|
|
t.RetValue()
|
|
}
|
|
|
|
// deepCloneValue walks Array and Hash structures recursively; other
|
|
// Value kinds are returned as-is (scalars are immutable in Five so
|
|
// sharing is safe).
|
|
//
|
|
// Hot-path optimizations:
|
|
// - Array items that are themselves scalars skip the function call
|
|
// (just slot-copied). Recursion only fires for nested Array/Hash.
|
|
// - Hash keys are shared (never cloned). PRG hashes carry string /
|
|
// numeric keys in every observed call site; mutating a key after
|
|
// insertion is forbidden by the Hash API, so sharing is safe and
|
|
// saves the recursion plus per-key allocation.
|
|
func deepCloneValue(v hbrt.Value) hbrt.Value {
|
|
if v.IsArray() {
|
|
src := v.AsArray()
|
|
if src == nil {
|
|
return v
|
|
}
|
|
n := len(src.Items)
|
|
items := make([]hbrt.Value, n)
|
|
for i := 0; i < n; i++ {
|
|
item := src.Items[i]
|
|
if item.IsArray() || item.IsHash() {
|
|
items[i] = deepCloneValue(item)
|
|
} else {
|
|
items[i] = item
|
|
}
|
|
}
|
|
return hbrt.MakeArrayFrom(items)
|
|
}
|
|
if v.IsHash() {
|
|
src := v.AsHash()
|
|
if src == nil {
|
|
return v
|
|
}
|
|
nh := hbrt.MakeHash()
|
|
dst := nh.AsHash()
|
|
for i, k := range src.Keys {
|
|
val := src.Values[i]
|
|
if val.IsArray() || val.IsHash() {
|
|
val = deepCloneValue(val)
|
|
}
|
|
dst.Append(k, val)
|
|
}
|
|
return nh
|
|
}
|
|
return v
|
|
}
|
|
|
|
// ACopy copies elements from one array to another.
|
|
// Harbour: ACopy(aSource, aDest [, nStart [, nCount [, nTargetPos]]]) → aDest
|
|
func ACopy(t *hbrt.Thread) {
|
|
t.Frame(2, 0) // simplified: just source and dest
|
|
defer t.EndProc()
|
|
srcVal := t.Local(1)
|
|
dstVal := t.Local(2)
|
|
src := srcVal.AsArray()
|
|
dst := dstVal.AsArray()
|
|
n := len(src.Items)
|
|
if n > len(dst.Items) {
|
|
n = len(dst.Items)
|
|
}
|
|
copy(dst.Items[:n], src.Items[:n])
|
|
t.PushValue(dstVal)
|
|
t.RetValue()
|
|
}
|
|
|
|
// AFill fills an array with a value.
|
|
// Harbour: AFill(aArray, xValue [, nStart [, nCount]]) → aArray
|
|
func AFill(t *hbrt.Thread) {
|
|
t.Frame(2, 0) // simplified: array and value
|
|
defer t.EndProc()
|
|
arrVal := t.Local(1)
|
|
val := t.Local(2)
|
|
arr := arrVal.AsArray()
|
|
for i := range arr.Items {
|
|
arr.Items[i] = val
|
|
}
|
|
t.PushValue(arrVal)
|
|
t.RetValue()
|
|
}
|
|
|
|
// ASort sorts an array using an optional comparison block.
|
|
// Harbour: ASort(aArray [, nStart [, nCount [, bBlock]]]) → aArray
|
|
//
|
|
// Block path: invokes bBlock per compare (side-effect safe).
|
|
// Default path (no block): one pre-scan picks a specialized comparator
|
|
// for homogeneous arrays (string / numeric / date / timestamp /
|
|
// logical); mixed or unknown element types fall back to a generic
|
|
// less-than that matches Harbour's default `<` semantics across types.
|
|
func ASort(t *hbrt.Thread) {
|
|
nParams := t.ParamCount()
|
|
t.Frame(nParams, 0)
|
|
defer t.EndProc()
|
|
|
|
arrVal := t.Local(1)
|
|
arr := arrVal.AsArray()
|
|
if arr == nil || len(arr.Items) < 2 {
|
|
t.PushValue(arrVal)
|
|
t.RetValue()
|
|
return
|
|
}
|
|
|
|
if nParams >= 4 && t.Local(4).IsBlock() {
|
|
blk := t.Local(4).AsBlock()
|
|
sort.SliceStable(arr.Items, func(i, j int) bool {
|
|
t.PushValue(arr.Items[i])
|
|
t.PushValue(arr.Items[j])
|
|
t.PendingParams2(2)
|
|
blk.Fn(t)
|
|
return t.GetRetValue().AsBool()
|
|
})
|
|
t.PushValue(arrVal)
|
|
t.RetValue()
|
|
return
|
|
}
|
|
|
|
// Default sort — pick a type-specialized comparator when every
|
|
// element shares a shape. Falls back to a generic less-than for
|
|
// mixed or uncategorized types.
|
|
items := arr.Items
|
|
switch detectArrayKind(items) {
|
|
case arrKindString:
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
return items[i].AsString() < items[j].AsString()
|
|
})
|
|
case arrKindInt:
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
return items[i].AsNumInt() < items[j].AsNumInt()
|
|
})
|
|
case arrKindNumeric:
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
return items[i].AsNumDouble() < items[j].AsNumDouble()
|
|
})
|
|
case arrKindDate:
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
return items[i].AsJulian() < items[j].AsJulian()
|
|
})
|
|
case arrKindTimestamp:
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
ja, jb := items[i].AsJulian(), items[j].AsJulian()
|
|
if ja != jb {
|
|
return ja < jb
|
|
}
|
|
return items[i].AsTimeMs() < items[j].AsTimeMs()
|
|
})
|
|
case arrKindLogical:
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
return !items[i].AsBool() && items[j].AsBool()
|
|
})
|
|
default:
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
return valueLess(items[i], items[j])
|
|
})
|
|
}
|
|
|
|
t.PushValue(arrVal)
|
|
t.RetValue()
|
|
}
|
|
|
|
type arrKind int
|
|
|
|
const (
|
|
arrKindMixed arrKind = iota
|
|
arrKindString
|
|
arrKindInt
|
|
arrKindNumeric
|
|
arrKindDate
|
|
arrKindTimestamp
|
|
arrKindLogical
|
|
)
|
|
|
|
// detectArrayKind returns a specialized kind when every element matches
|
|
// one well-known type; otherwise arrKindMixed. Integer-only arrays
|
|
// prefer arrKindInt to skip the int→double conversion in the hot path.
|
|
// A single non-int numeric promotes the whole array to arrKindNumeric.
|
|
func detectArrayKind(items []hbrt.Value) arrKind {
|
|
if len(items) == 0 {
|
|
return arrKindMixed
|
|
}
|
|
allInt := true
|
|
for _, v := range items {
|
|
if !v.IsNumInt() {
|
|
allInt = false
|
|
break
|
|
}
|
|
}
|
|
if allInt {
|
|
return arrKindInt
|
|
}
|
|
allNum := true
|
|
for _, v := range items {
|
|
if !v.IsNumeric() {
|
|
allNum = false
|
|
break
|
|
}
|
|
}
|
|
if allNum {
|
|
return arrKindNumeric
|
|
}
|
|
check := func(pred func(hbrt.Value) bool) bool {
|
|
for _, v := range items {
|
|
if !pred(v) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
if check(func(v hbrt.Value) bool { return v.IsString() }) {
|
|
return arrKindString
|
|
}
|
|
if check(func(v hbrt.Value) bool { return v.IsDate() }) {
|
|
return arrKindDate
|
|
}
|
|
if check(func(v hbrt.Value) bool { return v.IsTimestamp() }) {
|
|
return arrKindTimestamp
|
|
}
|
|
if check(func(v hbrt.Value) bool { return v.IsLogical() }) {
|
|
return arrKindLogical
|
|
}
|
|
return arrKindMixed
|
|
}
|
|
|
|
// valueLess implements Harbour's default `<` across types. NILs sort
|
|
// first (smallest) so they group together — matches the historical
|
|
// Five compareValues behavior that ASort inherited.
|
|
func valueLess(a, b hbrt.Value) bool {
|
|
if a.IsNil() || b.IsNil() {
|
|
return a.IsNil() && !b.IsNil()
|
|
}
|
|
if a.IsNumeric() && b.IsNumeric() {
|
|
return a.AsNumDouble() < b.AsNumDouble()
|
|
}
|
|
if a.IsString() && b.IsString() {
|
|
return a.AsString() < b.AsString()
|
|
}
|
|
if a.IsDate() && b.IsDate() {
|
|
return a.AsJulian() < b.AsJulian()
|
|
}
|
|
if a.IsTimestamp() && b.IsTimestamp() {
|
|
ja, jb := a.AsJulian(), b.AsJulian()
|
|
if ja != jb {
|
|
return ja < jb
|
|
}
|
|
return a.AsTimeMs() < b.AsTimeMs()
|
|
}
|
|
if a.IsLogical() && b.IsLogical() {
|
|
return !a.AsBool() && b.AsBool()
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AEval evaluates a block for each element in array.
|
|
// Harbour: AEval(aArray, bBlock [, nStart [, nCount]]) → aArray
|
|
func AEval(t *hbrt.Thread) {
|
|
nParams := t.ParamCount()
|
|
t.Frame(nParams, 0)
|
|
defer t.EndProc()
|
|
|
|
arrVal := t.Local(1)
|
|
arr := arrVal.AsArray()
|
|
blkVal := t.Local(2)
|
|
if !blkVal.IsBlock() {
|
|
t.PushValue(arrVal)
|
|
t.RetValue()
|
|
return
|
|
}
|
|
blk := blkVal.AsBlock()
|
|
|
|
for i, item := range arr.Items {
|
|
// Harbour: AEval callback receives (element, index).
|
|
// Stack order: index first, element on top — Frame picks top-N.
|
|
t.PushValue(hbrt.MakeInt(i + 1)) // arg2: 1-based index
|
|
t.PushValue(item) // arg1: element value
|
|
t.PendingParams2(2)
|
|
blk.Fn(t)
|
|
}
|
|
|
|
t.PushValue(arrVal)
|
|
t.RetValue()
|
|
}
|
|
|
|
// AScan searches for a value in array, returns position (0 if not found).
|
|
// Harbour: AScan(aArray, xValue|bBlock [, nStart [, nCount]]) → nPos
|
|
//
|
|
// Block path: per-element block invoke (side-effect safe).
|
|
// Value path: specialized fast-paths for string / int / double search
|
|
// values — the loop stays inside Go without running through the
|
|
// generic valuesEqual type-dispatch each iteration. Mixed or rare
|
|
// types (date, timestamp, logical, nil) fall back to valuesEqual.
|
|
func AScan(t *hbrt.Thread) {
|
|
nParams := t.ParamCount()
|
|
t.Frame(nParams, 0)
|
|
defer t.EndProc()
|
|
|
|
arrVal := t.Local(1)
|
|
arr := arrVal.AsArray()
|
|
if arr == nil {
|
|
t.RetInt(0)
|
|
return
|
|
}
|
|
items := arr.Items
|
|
search := t.Local(2)
|
|
|
|
if search.IsBlock() {
|
|
blk := search.AsBlock()
|
|
for i, item := range items {
|
|
t.PushValue(item)
|
|
t.PendingParams2(1)
|
|
blk.Fn(t)
|
|
if t.GetRetValue().AsBool() {
|
|
t.RetInt(int64(i + 1))
|
|
return
|
|
}
|
|
}
|
|
t.RetInt(0)
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case search.IsString():
|
|
s := search.AsString()
|
|
for i, item := range items {
|
|
if item.IsString() && item.AsString() == s {
|
|
t.RetInt(int64(i + 1))
|
|
return
|
|
}
|
|
}
|
|
case search.IsNumInt():
|
|
n := search.AsNumInt()
|
|
for i, item := range items {
|
|
if !item.IsNumeric() {
|
|
continue
|
|
}
|
|
if item.IsNumInt() {
|
|
if item.AsNumInt() == n {
|
|
t.RetInt(int64(i + 1))
|
|
return
|
|
}
|
|
} else if item.AsNumDouble() == float64(n) {
|
|
t.RetInt(int64(i + 1))
|
|
return
|
|
}
|
|
}
|
|
case search.IsNumeric():
|
|
f := search.AsNumDouble()
|
|
for i, item := range items {
|
|
if item.IsNumeric() && item.AsNumDouble() == f {
|
|
t.RetInt(int64(i + 1))
|
|
return
|
|
}
|
|
}
|
|
default:
|
|
for i, item := range items {
|
|
if valuesEqual(item, search) {
|
|
t.RetInt(int64(i + 1))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
t.RetInt(0)
|
|
}
|
|
|
|
// ATail returns the last element of an array.
|
|
// Harbour: ATail(aArray) → xValue
|
|
func ATail(t *hbrt.Thread) {
|
|
t.Frame(1, 0)
|
|
defer t.EndProc()
|
|
arr := t.Local(1).AsArray()
|
|
if arr != nil && len(arr.Items) > 0 {
|
|
t.PushValue(arr.Items[len(arr.Items)-1])
|
|
} else {
|
|
t.PushNil()
|
|
}
|
|
t.RetValue()
|
|
}
|
|
|
|
// valuesEqual compares two values for equality (simplified for AScan).
|
|
func valuesEqual(a, b hbrt.Value) bool {
|
|
if a.Type() != b.Type() {
|
|
if a.IsNumeric() && b.IsNumeric() {
|
|
return a.AsNumDouble() == b.AsNumDouble()
|
|
}
|
|
return false
|
|
}
|
|
switch {
|
|
case a.IsNumInt():
|
|
return a.AsNumInt() == b.AsNumInt()
|
|
case a.IsDouble():
|
|
return a.AsDouble() == b.AsDouble()
|
|
case a.IsString():
|
|
return a.AsString() == b.AsString()
|
|
case a.IsLogical():
|
|
return a.AsBool() == b.AsBool()
|
|
default:
|
|
return false
|
|
}
|
|
}
|