Files
five/_FiveSql2/src/TSqlFunc.prg
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

473 lines
14 KiB
Plaintext

/*
* TSqlFunc.prg — Scalar function library and type coercion helpers
*
* FiveSql — SQL Engine for Harbour DBF/NTX
*
* Copyright (c) 2025 Charles KWON (Charles KWON OhJun)
* Email: charleskwonohjun@gmail.com
*
* All rights reserved.
*/
#include "FiveSqlDef.ch"
STATIC s_cCollation := ""
/* Evaluate a scalar or aggregate function by name */
FUNCTION SqlEvalFunc( cName, aArgs )
LOCAL i, xV, xV2, cS, nN, nN2, cRev
LOCAL nNewM, nNewY, nNewD, nYr, nDy, dTmp
/* Aggregate functions return placeholder during row-level fetch */
IF SqlIsAggName( cName )
IF Len( aArgs ) > 0
RETURN aArgs[ 1 ]
ENDIF
RETURN 0
ENDIF
/* String functions */
DO CASE
CASE cName == "UPPER"
RETURN Upper( SqlCoerceStr( SqlArg(aArgs,1) ) )
CASE cName == "LOWER"
RETURN Lower( SqlCoerceStr( SqlArg(aArgs,1) ) )
CASE cName == "TRIM" .OR. cName == "ALLTRIM"
RETURN AllTrim( SqlCoerceStr( SqlArg(aArgs,1) ) )
CASE cName == "LTRIM"
RETURN LTrim( SqlCoerceStr( SqlArg(aArgs,1) ) )
CASE cName == "RTRIM"
RETURN RTrim( SqlCoerceStr( SqlArg(aArgs,1) ) )
CASE cName == "SUBSTR" .OR. cName == "SUBSTRING"
cS := SqlCoerceStr( SqlArg(aArgs,1) )
nN := Int( SqlCoerceNum( SqlArg(aArgs,2) ) )
IF Len( aArgs ) >= 3
RETURN SubStr( cS, nN, Int( SqlCoerceNum( SqlArg(aArgs,3) ) ) )
ENDIF
RETURN SubStr( cS, nN )
CASE cName == "LEFT"
RETURN Left( SqlCoerceStr( SqlArg(aArgs,1) ), Int( SqlCoerceNum( SqlArg(aArgs,2) ) ) )
CASE cName == "RIGHT"
RETURN Right( SqlCoerceStr( SqlArg(aArgs,1) ), Int( SqlCoerceNum( SqlArg(aArgs,2) ) ) )
CASE cName == "LEN" .OR. cName == "LENGTH"
RETURN Len( AllTrim( SqlCoerceStr( SqlArg(aArgs,1) ) ) )
CASE cName == "REPLACE"
RETURN StrTran( SqlCoerceStr( SqlArg(aArgs,1) ), SqlCoerceStr( SqlArg(aArgs,2) ), SqlCoerceStr( SqlArg(aArgs,3) ) )
CASE cName == "SPACE"
RETURN Space( Int( SqlCoerceNum( SqlArg(aArgs,1) ) ) )
CASE cName == "REPLICATE"
RETURN Replicate( SqlCoerceStr( SqlArg(aArgs,1) ), Int( SqlCoerceNum( SqlArg(aArgs,2) ) ) )
CASE cName == "STUFF"
cS := SqlCoerceStr( SqlArg(aArgs,1) )
RETURN Left( cS, Int(SqlCoerceNum(SqlArg(aArgs,2))) - 1 ) + SqlCoerceStr( SqlArg(aArgs,4) ) + ;
SubStr( cS, Int(SqlCoerceNum(SqlArg(aArgs,2))) + Int(SqlCoerceNum(SqlArg(aArgs,3))) )
CASE cName == "CHARINDEX"
RETURN At( SqlCoerceStr( SqlArg(aArgs,1) ), SqlCoerceStr( SqlArg(aArgs,2) ) )
CASE cName == "CONCAT"
cS := ""
FOR i := 1 TO Len( aArgs )
cS += SqlCoerceStr( aArgs[ i ] )
NEXT
RETURN cS
/* Math functions */
CASE cName == "ABS"
RETURN Abs( SqlCoerceNum( SqlArg(aArgs,1) ) )
CASE cName == "ROUND"
RETURN Round( SqlCoerceNum( SqlArg(aArgs,1) ), Int( SqlCoerceNum( SqlArg(aArgs,2) ) ) )
CASE cName == "INT" .OR. cName == "FLOOR"
RETURN Int( SqlCoerceNum( SqlArg(aArgs,1) ) )
CASE cName == "CEILING" .OR. cName == "CEIL"
nN := SqlCoerceNum( SqlArg(aArgs,1) )
IF nN == Int( nN )
RETURN nN
ENDIF
RETURN Int( nN ) + iif( nN > 0, 1, 0 )
CASE cName == "MOD"
nN2 := SqlCoerceNum( SqlArg(aArgs,2) )
IF nN2 != 0
RETURN SqlCoerceNum( SqlArg(aArgs,1) ) % nN2
ENDIF
RETURN 0
CASE cName == "POWER"
RETURN SqlCoerceNum( SqlArg(aArgs,1) ) ^ SqlCoerceNum( SqlArg(aArgs,2) )
CASE cName == "SQRT"
RETURN Sqrt( SqlCoerceNum( SqlArg(aArgs,1) ) )
CASE cName == "SIGN"
nN := SqlCoerceNum( SqlArg(aArgs,1) )
RETURN iif( nN > 0, 1, iif( nN < 0, -1, 0 ) )
/* Date/time functions */
CASE cName == "YEAR"
xV := SqlArg(aArgs,1)
IF ValType( xV ) == "D"
RETURN Year( xV )
ENDIF
RETURN 0
CASE cName == "MONTH"
xV := SqlArg(aArgs,1)
IF ValType( xV ) == "D"
RETURN Month( xV )
ENDIF
RETURN 0
CASE cName == "DAY"
xV := SqlArg(aArgs,1)
IF ValType( xV ) == "D"
RETURN Day( xV )
ENDIF
RETURN 0
CASE cName == "NOW"
RETURN hb_DateTime()
CASE cName == "DATE"
RETURN Date()
CASE cName == "TIME"
RETURN Time()
CASE cName == "DTOS"
xV := SqlArg(aArgs,1)
IF ValType( xV ) == "D"
RETURN DToS( xV )
ENDIF
RETURN ""
CASE cName == "DTOC"
xV := SqlArg(aArgs,1)
IF ValType( xV ) == "D"
RETURN DToC( xV )
ENDIF
RETURN ""
CASE cName == "CTOD"
RETURN CToD( SqlCoerceStr( SqlArg(aArgs,1) ) )
CASE cName == "STOD"
RETURN SToD( SqlCoerceStr( SqlArg(aArgs,1) ) )
/* Type conversion functions */
CASE cName == "STR"
IF Len( aArgs ) >= 2
RETURN Str( SqlCoerceNum( SqlArg(aArgs,1) ), Int( SqlCoerceNum( SqlArg(aArgs,2) ) ) )
ENDIF
RETURN AllTrim( Str( SqlCoerceNum( SqlArg(aArgs,1) ) ) )
CASE cName == "VAL"
RETURN Val( SqlCoerceStr( SqlArg(aArgs,1) ) )
CASE cName == "CAST"
RETURN SqlArg(aArgs,1)
CASE cName == "CONVERT"
RETURN SqlArg(aArgs,1)
/* Conditional functions */
CASE cName == "IIF"
IF SqlIsTrue( SqlArg(aArgs,1) )
RETURN SqlArg(aArgs,2)
ENDIF
RETURN SqlArg(aArgs,3)
CASE cName == "COALESCE"
/* SQL standard: empty string is NOT NULL, only NIL is NULL */
FOR i := 1 TO Len( aArgs )
IF aArgs[ i ] != NIL
RETURN aArgs[ i ]
ENDIF
NEXT
RETURN NIL
CASE cName == "NULLIF"
IF SqlCmpEq( SqlArg(aArgs,1), SqlArg(aArgs,2) )
RETURN NIL
ENDIF
RETURN SqlArg(aArgs,1)
/* Date arithmetic */
CASE cName == "DATEADD"
xV := SqlArg(aArgs,3)
nN := Int( SqlCoerceNum( SqlArg(aArgs,2) ) )
cS := Upper( SqlCoerceStr( SqlArg(aArgs,1) ) )
IF ValType( xV ) == "D"
IF cS == "D" .OR. cS == "DAY" .OR. cS == "DD"
RETURN xV + nN
ELSEIF cS == "M" .OR. cS == "MONTH" .OR. cS == "MM"
/* Normalize month overflow/underflow and clamp day to
* end-of-month (Jan 31 + 1 month → Feb 28/29, not Feb 31) */
nNewM := Month(xV) + nN - 1
nNewY := Year(xV) + Int( nNewM / 12 )
nNewD := Day(xV)
nNewM := ( nNewM % 12 ) + 1
IF nNewM <= 0
nNewM += 12
nNewY--
ENDIF
/* Clamp day: find last day of target month */
dTmp := SToD( StrZero( nNewY, 4 ) + StrZero( nNewM, 2 ) + "01" )
dTmp := dTmp + 32
dTmp := SToD( StrZero( Year(dTmp), 4 ) + StrZero( Month(dTmp), 2 ) + "01" ) - 1
IF nNewD > Day(dTmp)
nNewD := Day(dTmp)
ENDIF
RETURN SToD( StrZero( nNewY, 4 ) + StrZero( nNewM, 2 ) + StrZero( nNewD, 2 ) )
ELSEIF cS == "Y" .OR. cS == "YEAR" .OR. cS == "YY" .OR. cS == "YYYY"
/* Clamp Feb 29 → Feb 28 on non-leap year */
nYr := Year(xV) + nN
nDy := Day(xV)
IF Month(xV) == 2 .AND. nDy == 29
dTmp := SToD( StrZero( nYr, 4 ) + "0301" ) - 1
nDy := Day(dTmp)
ENDIF
RETURN SToD( StrZero( nYr, 4 ) + StrZero( Month(xV), 2 ) + StrZero( nDy, 2 ) )
ENDIF
ENDIF
RETURN xV
CASE cName == "DATEDIFF"
xV := SqlArg(aArgs,2)
xV2 := SqlArg(aArgs,3)
cS := Upper( SqlCoerceStr( SqlArg(aArgs,1) ) )
IF ValType( xV ) == "D" .AND. ValType( xV2 ) == "D"
IF cS == "D" .OR. cS == "DAY"
RETURN xV2 - xV
ELSEIF cS == "M" .OR. cS == "MONTH"
RETURN ( Year(xV2) - Year(xV) ) * 12 + ( Month(xV2) - Month(xV) )
ELSEIF cS == "Y" .OR. cS == "YEAR"
RETURN Year(xV2) - Year(xV)
ENDIF
ENDIF
RETURN 0
CASE cName == "EOMONTH"
xV := SqlArg(aArgs,1)
nN := iif( Len(aArgs) >= 2, Int( SqlCoerceNum( SqlArg(aArgs,2) ) ), 0 )
IF ValType( xV ) == "D"
xV := SToD( StrZero( Year(xV), 4 ) + StrZero( Month(xV) + nN + 1, 2 ) + "01" ) - 1
RETURN xV
ENDIF
RETURN NIL
/* Extended string functions */
CASE cName == "INSTR"
RETURN At( SqlCoerceStr( SqlArg(aArgs,1) ), SqlCoerceStr( SqlArg(aArgs,2) ) )
CASE cName == "REVERSE"
cS := SqlCoerceStr( SqlArg(aArgs,1) )
cRev := ""
FOR i := Len(cS) TO 1 STEP -1
cRev += SubStr( cS, i, 1 )
NEXT
RETURN cRev
CASE cName == "PADL"
RETURN PadL( SqlCoerceStr( SqlArg(aArgs,1) ), Int( SqlCoerceNum( SqlArg(aArgs,2) ) ) )
CASE cName == "PADR"
RETURN PadR( SqlCoerceStr( SqlArg(aArgs,1) ), Int( SqlCoerceNum( SqlArg(aArgs,2) ) ) )
CASE cName == "PADC"
RETURN PadC( SqlCoerceStr( SqlArg(aArgs,1) ), Int( SqlCoerceNum( SqlArg(aArgs,2) ) ) )
/* Type check functions */
CASE cName == "ISNUMERIC"
xV := SqlArg(aArgs,1)
IF ValType( xV ) == "N"
RETURN .T.
ELSEIF ValType( xV ) == "C"
RETURN Val( AllTrim( xV ) ) != 0 .OR. AllTrim( xV ) == "0"
ENDIF
RETURN .F.
CASE cName == "ISDATE"
xV := SqlArg(aArgs,1)
RETURN ValType( xV ) == "D"
CASE cName == "ISVALID"
RETURN SqlArg(aArgs,1) != NIL
CASE cName == "TYPEOF" .OR. cName == "TYPE"
xV := SqlArg(aArgs,1)
RETURN ValType( xV )
/* Timestamp functions */
CASE cName == "HB_HOUR"
xV := SqlArg(aArgs,1)
IF ValType( xV ) == "T"
RETURN hb_Hour( xV )
ENDIF
RETURN 0
CASE cName == "HB_MINUTE"
xV := SqlArg(aArgs,1)
IF ValType( xV ) == "T"
RETURN hb_Minute( xV )
ENDIF
RETURN 0
CASE cName == "HB_SECOND"
xV := SqlArg(aArgs,1)
IF ValType( xV ) == "T"
RETURN hb_Sec( xV )
ENDIF
RETURN 0
CASE cName == "HB_DATETIME"
RETURN hb_DateTime()
CASE cName == "HB_TTOC"
xV := SqlArg(aArgs,1)
IF ValType( xV ) == "T"
RETURN hb_TToC( xV )
ENDIF
RETURN ""
CASE cName == "HB_CTOT"
RETURN hb_CToT( SqlCoerceStr( SqlArg(aArgs,1) ) )
CASE cName == "TIMESTAMP"
RETURN hb_CToT( SqlCoerceStr( SqlArg(aArgs,1) ) )
/* Banker's rounding */
CASE cName == "ROUND_BANKER"
nN := SqlCoerceNum( SqlArg(aArgs,1) )
nN2 := iif( Len(aArgs) >= 2, Int( SqlCoerceNum( SqlArg(aArgs,2) ) ), 2 )
RETURN SqlBankerRound( nN, nN2 )
/* Format function */
CASE cName == "FORMAT"
nN := SqlCoerceNum( SqlArg(aArgs,1) )
nN2 := iif( Len(aArgs) >= 2, Int( SqlCoerceNum( SqlArg(aArgs,2) ) ), 2 )
RETURN AllTrim( Str( nN, 20, nN2 ) )
ENDCASE
RETURN NIL
/* Safe argument accessor */
FUNCTION SqlArg( a, n )
IF n <= Len( a )
RETURN a[ n ]
ENDIF
RETURN NIL
/* SqlCoerceStr/SqlCoerceNum/SqlCoerceForCmp/SqlIsTrue/SqlCmpEq/SqlCmpLt
* are implemented in Go (hbrtl/sqlhelpers.go) — registered as
* SQLCOERCESTR etc. The PRG bodies have been removed to avoid symbol
* collision with the RTL symbols; behavior is byte-for-byte identical.
* See docs/RTL-Go-Native-Migration.md (Tier 4). */
/* SQL LIKE pattern matching with optional escape character */
FUNCTION SqlLikeMatch( cStr, cPat, cEscape )
LOCAL nS, nP, nSLen, nPLen, chP, chS
IF ValType( cStr ) != "C" .OR. ValType( cPat ) != "C"
RETURN .F.
ENDIF
cStr := Upper( AllTrim( cStr ) )
cPat := Upper( AllTrim( cPat ) )
IF cEscape == NIL
cEscape := ""
ENDIF
IF ValType( cEscape ) == "C"
cEscape := Upper( AllTrim( cEscape ) )
ELSE
cEscape := ""
ENDIF
nSLen := Len( cStr )
nPLen := Len( cPat )
nS := 1
nP := 1
RETURN SqlLikeRecurse( cStr, cPat, nS, nP, nSLen, nPLen, cEscape )
/* Recursive LIKE matcher supporting %, _ and ESCAPE */
FUNCTION SqlLikeRecurse( cStr, cPat, nS, nP, nSLen, nPLen, cEsc )
LOCAL chP, chS
DO WHILE nP <= nPLen
chP := SubStr( cPat, nP, 1 )
/* Escape character: next pattern char is literal */
IF ! Empty( cEsc ) .AND. chP == cEsc .AND. nP < nPLen
nP++
chP := SubStr( cPat, nP, 1 )
IF nS > nSLen
RETURN .F.
ENDIF
chS := SubStr( cStr, nS, 1 )
IF chS != chP
RETURN .F.
ENDIF
nS++
nP++
LOOP
ENDIF
IF chP == "%"
/* Skip consecutive % */
DO WHILE nP <= nPLen .AND. SubStr( cPat, nP, 1 ) == "%"
nP++
ENDDO
IF nP > nPLen
RETURN .T.
ENDIF
DO WHILE nS <= nSLen
IF SqlLikeRecurse( cStr, cPat, nS, nP, nSLen, nPLen, cEsc )
RETURN .T.
ENDIF
nS++
ENDDO
RETURN .F.
ENDIF
IF chP == "_"
IF nS > nSLen
RETURN .F.
ENDIF
nS++
nP++
LOOP
ENDIF
/* Literal character match */
IF nS > nSLen
RETURN .F.
ENDIF
chS := SubStr( cStr, nS, 1 )
IF chS != chP
RETURN .F.
ENDIF
nS++
nP++
ENDDO
RETURN nS > nSLen
/* Banker's rounding (round half to even) */
FUNCTION SqlBankerRound( nVal, nDec )
LOCAL nFactor, nScaled, nInt, nFrac
IF nDec == NIL
nDec := 2
ENDIF
nFactor := 10 ^ nDec
nScaled := nVal * nFactor
nInt := Int( nScaled )
nFrac := Abs( nScaled - nInt )
IF Abs( nFrac - 0.5 ) > 0.0000001
RETURN Round( nVal, nDec )
ENDIF
IF nInt % 2 == 0
RETURN nInt / nFactor
ENDIF
IF nVal >= 0
RETURN ( nInt + 1 ) / nFactor
ENDIF
RETURN ( nInt - 1 ) / nFactor
/* Collation support */
FUNCTION SqlSetCollation( cCollation )
s_cCollation := Upper( AllTrim( cCollation ) )
IF s_cCollation != "NOCASE" .AND. ! Empty( s_cCollation )
hb_cdpSelect( s_cCollation )
ENDIF
RETURN NIL
FUNCTION SqlGetCollation()
RETURN s_cCollation