Files
five/hbrtl/array.go
CharlesKWON dd270d5d9d perf: RTL Go-native migration — 27 optimizations, DML up to 70-90x
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>
2026-04-17 20:20:14 +09:00

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
}
}