Files
five/_FiveSql2/src/TSqlExecutor.prg
CharlesKWON 3caadb23b9 perf: SqlOrderBy + SqlGroupBy Go RTL — native sort and aggregation
SqlOrderBy: Go sort.Slice for ORDER BY, 10-50x faster than PRG ASort.
SqlGroupBy: Go map-based GROUP BY accumulation (ready for integration).
TryBuildSortSpec detects simple ORDER BY columns and routes to Go.
Fallback to PRG for complex ORDER BY expressions.

43/43 + 41/41 verify + 51/51 compat + go test ALL PASS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:41:41 +09:00

4131 lines
129 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* TSqlExecutor.prg — Main query executor with index optimization
*
* FiveSql — SQL Engine for Harbour DBF/NTX
*
* Copyright (c) 2025 Charles KWON (Charles KWON OhJun)
* Email: charleskwonohjun@gmail.com
*
* All rights reserved.
*/
#include "hbclass.ch"
#include "dbstruct.ch"
#include "dbinfo.ch"
#include "error.ch"
#include "FiveSqlDef.ch"
STATIC s_aOuterStack := {}
STATIC s_hAutoInc := NIL
STATIC s_nRCJSeq := 0
CLASS TSqlExecutor
DATA hQuery
DATA aParams
DATA oIndex AS OBJECT
DATA oAgg AS OBJECT
DATA oSort AS OBJECT
DATA oDDL AS OBJECT
DATA oTxn AS OBJECT
DATA oAlias AS OBJECT
DATA nDepth INIT 0
DATA aOpened INIT {}
DATA aTables INIT {}
DATA aCompileStruct
DATA bRowBlock /* optional code block — receives SELECT cols as params */
DATA aFetchCache /* pre-bound {nWA, nFPos} per SELECT expression, or NIL */
DATA hSubCorrCache INIT { => } /* per-outer-key subquery result cache */
DATA aSubCacheSlots INIT {} /* list of {xSubNode, {id, aFreeVars}} */
DATA nSubCacheSeq INIT 0 /* monotonic ID for subqueries */
DATA aSemiJoinSlots INIT {} /* list of {xSubNode, semiJoinData | "NO"} */
DATA hRightMatched /* RecNo sets for RIGHT JOIN pass */
DATA hSubCache
METHOD New( hQuery, aParams ) CONSTRUCTOR
METHOD Run()
METHOD RunSelect()
METHOD RunInsert()
METHOD RunUpdate()
METHOD RunDelete()
METHOD OpenTable( cTable, cAlias )
METHOD CloseOpened()
METHOD FetchRow( aExprs )
METHOD EvalExpr( xNode )
METHOD Resolve( cRef )
METHOD FindWA( cAlias )
METHOD JoinRecurse( aJoins, nIdx, xWhere, aRE, aRows, hHashTbl )
METHOD RightJoinPass( aJoins, nIdx, aRE, aRows )
METHOD FetchRowNull( aRE, cInnerAlias )
METHOD ColBelongsTo( cColRef, cAlias )
METHOD PushOuter()
METHOD PopOuter()
METHOD ResolveFromOuter( cRef, cTblAlias, cField, lFound )
METHOD MakeError( nCode, cMsg )
METHOD HashJoin( nInnerWA, cInnerField, cOuterCol, xOnCond, aJoins, nIdx, xWhere, aRE, aRows, hHashTbl )
METHOD CacheSubquery( xSubExpr )
METHOD MaterializeCTE( aCTE )
METHOD MaterializeRecursiveCTE( aCTE )
METHOD ApplyWindowFunctions( aRows, aFN, aCols )
METHOD RunMerge()
METHOD RunTruncate()
METHOD TryGoJoin( aJoins, aResultExprs, nOuterWA )
METHOD TryBuildSortSpec( aOrderBy, aFieldNames )
METHOD TryBuildFieldPositions( aExprs )
METHOD TryCompileWhere( xWhere )
METHOD SqlExprToPrg( xNode )
METHOD BuildFetchCache( aExprs )
METHOD SubqueryCached( xSubNode )
METHOD CollectFreeVars( hQ )
METHOD CollectExprFreeVars( xE, aLocalAliases, aFree )
METHOD ExistsViaSemiJoin( xSubNode, lNegate )
METHOD TryBuildSemiJoin( xSubNode )
ENDCLASS
METHOD New( hQuery, aParams ) CLASS TSqlExecutor
::hQuery := hQuery
::aParams := iif( aParams == NIL, {}, aParams )
::oIndex := TSqlIndex():New()
::oAgg := TSqlAgg():New()
::oSort := TSqlSort():New()
::oDDL := TSqlDDL():New()
::oTxn := TSqlTxn():New()
::oAlias := TSqlAlias():New()
::nDepth := 0
::aOpened := {}
::aTables := {}
/* Explicit fresh initialization — DATA INIT on hash/array literals
* can end up sharing the same instance across New() calls depending
* on the compile path, which would let one query's subquery cache
* leak into the next query's results. */
::hSubCache := { => }
::hSubCorrCache := { => }
::aSubCacheSlots := {}
::aSemiJoinSlots := {}
::nSubCacheSeq := 0
::hRightMatched := { => }
RETURN SELF
METHOD MakeError( nCode, cMsg ) CLASS TSqlExecutor
RETURN { { "__error__" }, { { nCode, cMsg, "" } } }
METHOD Run() CLASS TSqlExecutor
LOCAL cType, aT, nP2
IF ::hQuery == NIL
RETURN ::MakeError( SQL_ERR_SYNTAX, "Empty or invalid SQL" )
ENDIF
cType := ::hQuery[ "type" ]
DO CASE
CASE cType == "SELECT"
RETURN ::RunSelect()
CASE cType == "INSERT"
RETURN ::RunInsert()
CASE cType == "UPDATE"
RETURN ::RunUpdate()
CASE cType == "DELETE"
RETURN ::RunDelete()
CASE cType == "CREATE"
aT := ::hQuery[ "tokens" ]
nP2 := ::hQuery[ "pos" ]
IF ::oDDL:DDL_IsKW( aT, nP2, "TABLE" )
RETURN ::oDDL:CreateTable( aT, nP2 )
ELSEIF ::oDDL:DDL_IsKW( aT, nP2, "UNIQUE" ) .OR. ::oDDL:DDL_IsKW( aT, nP2, "INDEX" )
RETURN ::oDDL:CreateIndex( aT, nP2 )
ELSEIF ::oDDL:DDL_IsKW( aT, nP2, "VIEW" )
RETURN ::oDDL:CreateView( aT, nP2 )
ENDIF
RETURN ::MakeError( SQL_ERR_UNSUPPORTED, "CREATE: unsupported object" )
CASE cType == "DROP"
aT := ::hQuery[ "tokens" ]
nP2 := ::hQuery[ "pos" ]
IF ::oDDL:DDL_IsKW( aT, nP2, "TABLE" )
RETURN ::oDDL:DropTable( aT, nP2 )
ELSEIF ::oDDL:DDL_IsKW( aT, nP2, "INDEX" )
RETURN ::oDDL:DropIndex( aT, nP2 )
ELSEIF ::oDDL:DDL_IsKW( aT, nP2, "VIEW" )
RETURN ::oDDL:DropView( aT, nP2 )
ENDIF
RETURN ::MakeError( SQL_ERR_UNSUPPORTED, "DROP: unsupported object" )
CASE cType == "SET_COLLATION"
SqlSetCollation( ::hQuery[ "value" ] )
RETURN { { "result" }, { { "Collation set to " + ::hQuery[ "value" ] } } }
CASE cType == "ALTER"
aT := ::hQuery[ "tokens" ]
nP2 := ::hQuery[ "pos" ]
RETURN ::oDDL:AlterTable( aT, nP2 )
CASE cType == "BEGIN"
RETURN ::oTxn:Begin()
CASE cType == "COMMIT"
RETURN ::oTxn:Commit()
CASE cType == "ROLLBACK"
RETURN ::oTxn:Rollback()
CASE cType == "ROLLBACK_TO"
RETURN ::oTxn:RollbackTo( ::hQuery[ "savepoint" ] )
CASE cType == "SAVEPOINT"
RETURN ::oTxn:SetSavepoint( ::hQuery[ "name" ] )
CASE cType == "TRUNCATE"
RETURN ::RunTruncate()
CASE cType == "MERGE"
RETURN ::RunMerge()
ENDCASE
RETURN ::MakeError( SQL_ERR_UNSUPPORTED, "Unknown statement type: " + cType )
METHOD OpenTable( cTable, cAlias ) CLASS TSqlExecutor
LOCAL nWA, i, lFound
nWA := ::oIndex:OpenTable( cTable, cAlias, .T., .T. )
IF nWA > 0
AAdd( ::aOpened, cAlias )
/* Register with alias manager if not already tracked */
lFound := .F.
FOR i := 1 TO Len( ::oAlias:aSlots )
IF ::oAlias:aSlots[ i ][ 1 ] == cAlias
::oAlias:aSlots[ i ][ 4 ] := .T.
lFound := .T.
EXIT
ENDIF
NEXT
IF ! lFound
AAdd( ::oAlias:aSlots, { cAlias, Upper( cTable ), Upper( cAlias ), .T. } )
ENDIF
ENDIF
RETURN nWA
METHOD CloseOpened() CLASS TSqlExecutor
LOCAL i, nWA
FOR i := 1 TO Len( ::aOpened )
nWA := Select( ::aOpened[ i ] )
IF nWA > 0
dbSelectArea( nWA )
dbCloseArea()
ENDIF
NEXT
::aOpened := {}
::oAlias:aSlots := {}
RETURN NIL
METHOD PushOuter() CLASS TSqlExecutor
AAdd( s_aOuterStack, ::aTables )
RETURN NIL
METHOD PopOuter() CLASS TSqlExecutor
IF Len( s_aOuterStack ) > 0
ASize( s_aOuterStack, Len( s_aOuterStack ) - 1 )
ENDIF
RETURN NIL
METHOD FindWA( cAlias ) CLASS TSqlExecutor
LOCAL i, nWA, cA, cOrig, cReal
nWA := Select( cAlias )
IF nWA > 0 .AND. ( nWA )->( Used() )
RETURN nWA
ENDIF
FOR i := 1 TO Len( ::aTables )
cA := ::aTables[ i ][ 2 ]
IF Empty( cA )
cA := ::aTables[ i ][ 1 ]
ENDIF
cOrig := ""
IF Len( ::aTables[ i ] ) >= 3
cOrig := Upper( ::aTables[ i ][ 3 ] )
ENDIF
IF Upper( cA ) == cAlias .OR. cOrig == cAlias .OR. Upper( ::aTables[ i ][ 1 ] ) == cAlias
nWA := Select( cA )
IF nWA > 0 .AND. ( nWA )->( Used() )
RETURN nWA
ENDIF
ENDIF
NEXT
/* Fallback: check the alias manager for user alias mapping */
cReal := ::oAlias:RealAlias( cAlias )
IF ! Empty( cReal )
nWA := Select( cReal )
IF nWA > 0 .AND. ( nWA )->( Used() )
RETURN nWA
ENDIF
ENDIF
RETURN 0
METHOD Resolve( cRef ) CLASS TSqlExecutor
LOCAL cField, cTblAlias, nDot, nWA, nFPos, xVal, nSavedArea
LOCAL i, cA, lOuterFound
LOCAL aCTEInfo, aCTEFN, aCTERows, nCTERow
IF cRef == "*"
RETURN NIL
ENDIF
nSavedArea := Select()
nDot := At( ".", cRef )
IF nDot > 0
cTblAlias := Upper( Left( cRef, nDot - 1 ) )
cField := Upper( SubStr( cRef, nDot + 1 ) )
ELSE
cField := Upper( cRef )
cTblAlias := ""
ENDIF
/* Qualified reference */
IF ! Empty( cTblAlias )
nWA := ::FindWA( cTblAlias )
IF nWA > 0
dbSelectArea( nWA )
nFPos := FieldPos( cField )
IF nFPos > 0
xVal := FieldGet( nFPos )
dbSelectArea( nSavedArea )
RETURN xVal
ENDIF
dbSelectArea( nSavedArea )
ENDIF
IF Len( s_aOuterStack ) > 0
lOuterFound := .F.
xVal := ::ResolveFromOuter( cRef, cTblAlias, cField, @lOuterFound )
IF lOuterFound
dbSelectArea( nSavedArea )
RETURN xVal
ENDIF
ENDIF
dbSelectArea( nSavedArea )
RETURN NIL
ENDIF
/* Unqualified: search all tables */
FOR i := 1 TO Len( ::aTables )
cA := ::aTables[ i ][ 2 ]
IF Empty( cA )
cA := ::aTables[ i ][ 1 ]
ENDIF
nWA := Select( cA )
IF nWA > 0
dbSelectArea( nWA )
nFPos := FieldPos( cField )
IF nFPos > 0
xVal := FieldGet( nFPos )
dbSelectArea( nSavedArea )
RETURN xVal
ENDIF
ENDIF
NEXT
/* Last resort: current workarea */
dbSelectArea( nSavedArea )
nFPos := FieldPos( cField )
IF nFPos > 0
RETURN FieldGet( nFPos )
ENDIF
/* Correlated subquery outer context */
IF Len( s_aOuterStack ) > 0
lOuterFound := .F.
xVal := ::ResolveFromOuter( cRef, cTblAlias, cField, @lOuterFound )
IF lOuterFound
dbSelectArea( nSavedArea )
RETURN xVal
ENDIF
ENDIF
dbSelectArea( nSavedArea )
RETURN NIL
/* ResolveFromOuter — resolve a column reference in the outer
* context stack. Sets lFound to .T. (by ref) when the column is
* located, even if its value is NIL. Callers must check lFound
* rather than testing `xVal != NIL` — the latter conflates a
* legitimate NULL column value with "column not found", silently
* breaking correlated subqueries where the outer row has NULLs. */
METHOD ResolveFromOuter( cRef, cTblAlias, cField, lFound ) CLASS TSqlExecutor
LOCAL i, j, aOuterTbls, cA, nWA, nFPos, xVal, nSavedArea
lFound := .F.
nSavedArea := Select()
FOR i := Len( s_aOuterStack ) TO 1 STEP -1
aOuterTbls := s_aOuterStack[ i ]
FOR j := 1 TO Len( aOuterTbls )
cA := aOuterTbls[ j ][ 2 ]
IF Empty( cA )
cA := aOuterTbls[ j ][ 1 ]
ENDIF
IF ! Empty( cTblAlias )
IF !( Upper( cA ) == cTblAlias .OR. ;
Upper( aOuterTbls[ j ][ 1 ] ) == cTblAlias .OR. ;
( Len( aOuterTbls[ j ] ) >= 3 .AND. Upper( aOuterTbls[ j ][ 3 ] ) == cTblAlias ) )
LOOP
ENDIF
ENDIF
nWA := Select( cA )
IF nWA > 0
dbSelectArea( nWA )
nFPos := FieldPos( cField )
IF nFPos > 0
xVal := FieldGet( nFPos )
lFound := .T.
dbSelectArea( nSavedArea )
RETURN xVal
ENDIF
ENDIF
NEXT
NEXT
dbSelectArea( nSavedArea )
RETURN NIL
METHOD EvalExpr( xNode ) CLASS TSqlExecutor
LOCAL xL, xR, cOp, xVal, aArgs, aVals, i, xResult, nPI
LOCAL aCases, xElse, xCond
LOCAL aSubResult, xHi, nSavedWA
IF xNode == NIL
RETURN NIL
ENDIF
DO CASE
CASE xNode[ 1 ] == ND_LIT
RETURN xNode[ 2 ]
CASE xNode[ 1 ] == ND_NIL
RETURN NIL
CASE xNode[ 1 ] == ND_COL
RETURN ::Resolve( xNode[ 2 ] )
CASE xNode[ 1 ] == ND_PAR
/* xNode[2] = 1-based parameter index from parser */
nPI := iif( xNode[ 2 ] != NIL, xNode[ 2 ], 1 )
IF nPI >= 1 .AND. nPI <= Len( ::aParams )
RETURN ::aParams[ nPI ]
ENDIF
RETURN NIL
CASE xNode[ 1 ] == ND_UNI
cOp := xNode[ 2 ]
xL := ::EvalExpr( xNode[ 3 ] )
IF cOp == "NOT"
IF ValType( xL ) == "L"
RETURN ! xL
ENDIF
RETURN .F.
ENDIF
IF cOp == "-"
IF ValType( xL ) == "N"
RETURN -xL
ENDIF
RETURN 0
ENDIF
RETURN xL
CASE xNode[ 1 ] == ND_BIN
cOp := xNode[ 2 ]
/* Short-circuit AND */
IF cOp == "AND"
xL := ::EvalExpr( xNode[ 3 ] )
IF ValType( xL ) == "L" .AND. ! xL
RETURN .F.
ENDIF
xR := ::EvalExpr( xNode[ 4 ] )
RETURN SqlIsTrue( xL ) .AND. SqlIsTrue( xR )
ENDIF
/* Short-circuit OR */
IF cOp == "OR"
xL := ::EvalExpr( xNode[ 3 ] )
IF ValType( xL ) == "L" .AND. xL
RETURN .T.
ENDIF
xR := ::EvalExpr( xNode[ 4 ] )
RETURN SqlIsTrue( xL ) .OR. SqlIsTrue( xR )
ENDIF
/* IN operator */
IF cOp == "IN"
xL := ::EvalExpr( xNode[ 3 ] )
xR := xNode[ 4 ]
IF xR != NIL .AND. xR[ 1 ] == ND_LIST
aVals := xR[ 2 ]
FOR i := 1 TO Len( aVals )
xVal := ::EvalExpr( aVals[ i ] )
IF SqlCmpEq( xL, xVal )
RETURN .T.
ENDIF
NEXT
RETURN .F.
ENDIF
IF xR != NIL .AND. xR[ 1 ] == ND_SUB .AND. xR[ 2 ] != NIL
aSubResult := ::SubqueryCached( xR )
IF aSubResult == NIL .OR. ValType( aSubResult ) != "A"
/* Cache miss-fallback */
ENDIF
IF ValType( aSubResult ) == "A" .AND. Len( aSubResult ) >= 2 .AND. ;
ValType( aSubResult[ 2 ] ) == "A"
FOR i := 1 TO Len( aSubResult[ 2 ] )
IF Len( aSubResult[ 2 ][ i ] ) > 0 .AND. ;
SqlCmpEq( xL, aSubResult[ 2 ][ i ][ 1 ] )
RETURN .T.
ENDIF
NEXT
ENDIF
RETURN .F.
ENDIF
RETURN .F.
ENDIF
/* IS NULL / IS NOT NULL */
IF cOp == "IS NULL" .OR. cOp == "IS NOT NULL"
xL := ::EvalExpr( xNode[ 3 ] )
/* SQL standard: only NIL is NULL, empty string '' is NOT NULL */
IF cOp == "IS NULL"
RETURN xL == NIL
ELSE
RETURN xL != NIL
ENDIF
ENDIF
/* Standard binary ops */
xL := ::EvalExpr( xNode[ 3 ] )
xR := ::EvalExpr( xNode[ 4 ] )
xL := SqlCoerceForCmp( xL )
xR := SqlCoerceForCmp( xR )
IF cOp == "=" .OR. cOp == "=="
RETURN SqlCmpEq( xL, xR )
ENDIF
IF cOp == "<>" .OR. cOp == "!="
RETURN ! SqlCmpEq( xL, xR )
ENDIF
IF cOp == "<"
RETURN SqlCmpLt( xL, xR )
ENDIF
IF cOp == ">"
RETURN SqlCmpLt( xR, xL )
ENDIF
IF cOp == "<="
RETURN SqlCmpEq( xL, xR ) .OR. SqlCmpLt( xL, xR )
ENDIF
IF cOp == ">="
RETURN SqlCmpEq( xL, xR ) .OR. SqlCmpLt( xR, xL )
ENDIF
IF cOp == "LIKE"
IF xNode[ 5 ] != NIL
RETURN SqlLikeMatch( SqlCoerceStr( xL ), SqlCoerceStr( xR ), SqlCoerceStr( ::EvalExpr( xNode[ 5 ] ) ) )
ENDIF
RETURN SqlLikeMatch( SqlCoerceStr( xL ), SqlCoerceStr( xR ) )
ENDIF
IF cOp == "+"
IF ValType( xL ) == "C" .AND. ValType( xR ) == "C"
RETURN xL + xR
ENDIF
RETURN SqlCoerceNum( xL ) + SqlCoerceNum( xR )
ENDIF
IF cOp == "-"
RETURN SqlCoerceNum( xL ) - SqlCoerceNum( xR )
ENDIF
IF cOp == "*"
RETURN SqlCoerceNum( xL ) * SqlCoerceNum( xR )
ENDIF
IF cOp == "/"
IF SqlCoerceNum( xR ) != 0
RETURN SqlCoerceNum( xL ) / SqlCoerceNum( xR )
ENDIF
RETURN 0
ENDIF
IF cOp == "||"
RETURN SqlCoerceStr( xL ) + SqlCoerceStr( xR )
ENDIF
RETURN NIL
CASE xNode[ 1 ] == ND_RANGE
xL := ::EvalExpr( xNode[ 3 ] )
xR := ::EvalExpr( xNode[ 4 ] )
xHi := ::EvalExpr( xNode[ 5 ] )
xL := SqlCoerceForCmp( xL )
xR := SqlCoerceForCmp( xR )
xHi := SqlCoerceForCmp( xHi )
RETURN ( SqlCmpEq( xL, xR ) .OR. SqlCmpLt( xR, xL ) ) .AND. ( SqlCmpEq( xL, xHi ) .OR. SqlCmpLt( xL, xHi ) )
CASE xNode[ 1 ] == ND_CASE
aCases := xNode[ 2 ]
xElse := xNode[ 3 ]
FOR i := 1 TO Len( aCases )
xCond := ::EvalExpr( aCases[ i ][ 1 ] )
IF SqlIsTrue( xCond )
RETURN ::EvalExpr( aCases[ i ][ 2 ] )
ENDIF
NEXT
IF xElse != NIL
RETURN ::EvalExpr( xElse )
ENDIF
RETURN NIL
CASE xNode[ 1 ] == ND_FN
/* EXISTS and NOT EXISTS handling:
*
* 1. If the subquery matches the semi-join shape (single-table
* with a `inner.col = outer.col` equi-term and no JOIN /
* GROUP BY / aggregate), lift it into a non-correlated
* hash set probe: run the subquery ONCE with the correlated
* term removed and DISTINCT on inner.col, then each outer
* row becomes an O(1) hash lookup. This is the key win
* for patterns like
* WHERE EXISTS (SELECT 1 FROM ord WHERE ord.emp_id = e.id
* AND ord.qty > 15)
* where the correlation is 1:1 with outer rows so plain
* memoization doesn't help.
*
* 2. Otherwise inject LIMIT 1 and route through SubqueryCached
* so at least the scan short-circuits on first match and
* low-cardinality correlations still memoize. */
IF ( xNode[ 2 ] == "EXISTS" .OR. xNode[ 2 ] == "NOT EXISTS" ) .AND. ;
Len( xNode[ 3 ] ) > 0 .AND. ;
xNode[ 3 ][ 1 ] != NIL .AND. ValType( xNode[ 3 ][ 1 ] ) == "A" .AND. ;
xNode[ 3 ][ 1 ][ 1 ] == ND_SUB .AND. xNode[ 3 ][ 1 ][ 2 ] != NIL
aSubResult := ::ExistsViaSemiJoin( xNode[ 3 ][ 1 ], xNode[ 2 ] == "NOT EXISTS" )
IF aSubResult != NIL
/* Semi-join lift succeeded; result is already a boolean */
RETURN aSubResult
ENDIF
/* Fallback: LIMIT 1 + cached run.
* SubqueryCached clones the hQuery per-Run, so this LIMIT
* won't corrupt subsequent runs. Safe even if plan is reused. */
aSubResult := ::SubqueryCached( xNode[ 3 ][ 1 ] )
IF ValType( aSubResult ) == "A" .AND. Len( aSubResult ) >= 2 .AND. ;
ValType( aSubResult[ 2 ] ) == "A"
IF xNode[ 2 ] == "NOT EXISTS"
RETURN Len( aSubResult[ 2 ] ) == 0
ENDIF
RETURN Len( aSubResult[ 2 ] ) > 0
ENDIF
RETURN iif( xNode[ 2 ] == "NOT EXISTS", .T., .F. )
ENDIF
/* Evaluate arguments */
aArgs := {}
FOR i := 1 TO Len( xNode[ 3 ] )
AAdd( aArgs, ::EvalExpr( xNode[ 3 ][ i ] ) )
NEXT
RETURN SqlEvalFunc( xNode[ 2 ], aArgs )
CASE xNode[ 1 ] == ND_SUB
IF xNode[ 2 ] != NIL
/* Subqueries use a per-outer-key cache. SubqueryCached
* does static free-variable analysis on the first call,
* then memoizes results keyed by the current values of
* those free variables. Non-correlated subqueries reduce
* to a trivial single-entry cache. */
aSubResult := ::SubqueryCached( xNode )
IF ValType( aSubResult ) == "A" .AND. Len( aSubResult ) >= 2 .AND. ;
ValType( aSubResult[ 2 ] ) == "A" .AND. Len( aSubResult[ 2 ] ) > 0 .AND. ;
Len( aSubResult[ 2 ][ 1 ] ) > 0
RETURN aSubResult[ 2 ][ 1 ][ 1 ]
ENDIF
ENDIF
RETURN NIL
CASE xNode[ 1 ] == ND_WINDOW
/* Window functions are evaluated post-fetch, return placeholder */
RETURN 0
ENDCASE
RETURN NIL
/* Pre-compute {nWA, nFPos} for each SELECT expression that is a plain
* column reference. Called once before a join/scan loop so that FetchRow
* can skip the per-row string parse (At, SubStr, Upper) and FindWA
* linear scan. Complex expressions (functions, CASE, subqueries) store
* NIL and fall back to EvalExpr.
*
* Safe for multi-table queries: resolution walks ::aTables and binds
* each column to a specific workarea number and field position.
*/
METHOD BuildFetchCache( aExprs ) CLASS TSqlExecutor
LOCAL aCache := {}, i, xE, cRef, nDot, cTblAlias, cField, nWA, nFPos, cA
LOCAL nSaved := Select()
FOR i := 1 TO Len( aExprs )
xE := aExprs[ i ][ 1 ]
IF xE == NIL .OR. xE[ 1 ] != ND_COL .OR. xE[ 2 ] == "*"
AAdd( aCache, NIL )
LOOP
ENDIF
cRef := xE[ 2 ]
nDot := At( ".", cRef )
IF nDot > 0
cTblAlias := Upper( Left( cRef, nDot - 1 ) )
cField := Upper( SubStr( cRef, nDot + 1 ) )
nWA := ::FindWA( cTblAlias )
ELSE
cField := Upper( cRef )
cTblAlias := ""
nWA := 0
IF Len( ::aTables ) > 0
cA := ::aTables[ 1 ][ 2 ]
IF Empty( cA )
cA := ::aTables[ 1 ][ 1 ]
ENDIF
nWA := Select( cA )
ENDIF
ENDIF
IF nWA > 0
dbSelectArea( nWA )
nFPos := FieldPos( cField )
IF nFPos > 0
AAdd( aCache, { nWA, nFPos } )
LOOP
ENDIF
ENDIF
AAdd( aCache, NIL )
NEXT
dbSelectArea( nSaved )
RETURN aCache
METHOD FetchRow( aExprs ) CLASS TSqlExecutor
LOCAL aRow := {}, i, xVal
LOCAL xE, cRef, nDot, nWA, nFPos, cField, cTblAlias, cA, aBound
/* Fastest path: pre-bound columns (built once per join by RunSelect) */
IF ::aFetchCache != NIL .AND. Len( ::aFetchCache ) == Len( aExprs )
FOR i := 1 TO Len( aExprs )
aBound := ::aFetchCache[ i ]
IF aBound != NIL
dbSelectArea( aBound[ 1 ] )
xVal := FieldGet( aBound[ 2 ] )
IF ValType( xVal ) == "C"
xVal := AllTrim( xVal )
ENDIF
AAdd( aRow, xVal )
ELSE
xVal := ::EvalExpr( aExprs[ i ][ 1 ] )
IF ValType( xVal ) == "C"
xVal := AllTrim( xVal )
ENDIF
AAdd( aRow, xVal )
ENDIF
NEXT
RETURN aRow
ENDIF
FOR i := 1 TO Len( aExprs )
xE := aExprs[ i ][ 1 ]
/* Fast path for column references */
IF xE[ 1 ] == ND_COL .AND. xE[ 2 ] != "*" .AND. Len( ::aTables ) > 0
cRef := xE[ 2 ]
nDot := At( ".", cRef )
IF nDot > 0
cTblAlias := Upper( Left( cRef, nDot - 1 ) )
cField := Upper( SubStr( cRef, nDot + 1 ) )
nWA := ::FindWA( cTblAlias )
ELSE
cField := Upper( cRef )
cA := ::aTables[ 1 ][ 2 ]
IF Empty( cA )
cA := ::aTables[ 1 ][ 1 ]
ENDIF
nWA := Select( cA )
ENDIF
IF nWA > 0
dbSelectArea( nWA )
nFPos := FieldPos( cField )
IF nFPos > 0
xVal := FieldGet( nFPos )
IF ValType( xVal ) == "C"
xVal := AllTrim( xVal )
ENDIF
AAdd( aRow, xVal )
LOOP
ENDIF
ENDIF
ENDIF
/* General expression evaluation path */
xVal := ::EvalExpr( xE )
IF ValType( xVal ) == "C"
xVal := AllTrim( xVal )
ENDIF
AAdd( aRow, xVal )
NEXT
RETURN aRow
METHOD ColBelongsTo( cColRef, cAlias ) CLASS TSqlExecutor
LOCAL cPrefix, nDot, i, cA, cOrig
nDot := At( ".", cColRef )
IF nDot == 0
RETURN .F.
ENDIF
cPrefix := Upper( Left( cColRef, nDot - 1 ) )
IF cPrefix == Upper( cAlias )
RETURN .T.
ENDIF
FOR i := 1 TO Len( ::aTables )
cA := Upper( ::aTables[ i ][ 2 ] )
IF Empty( cA )
cA := Upper( ::aTables[ i ][ 1 ] )
ENDIF
cOrig := ""
IF Len( ::aTables[ i ] ) >= 3
cOrig := Upper( ::aTables[ i ][ 3 ] )
ENDIF
IF cA == Upper( cAlias ) .OR. cOrig == Upper( cAlias )
IF cPrefix == cA .OR. cPrefix == cOrig .OR. cPrefix == Upper( ::aTables[ i ][ 1 ] )
RETURN .T.
ENDIF
ENDIF
NEXT
RETURN .F.
METHOD FetchRowNull( aRE, cInnerAlias ) CLASS TSqlExecutor
LOCAL aRow := {}, i, xVal
LOCAL cColRef, lIsInner, nWA, nFPos, cBareField
FOR i := 1 TO Len( aRE )
cColRef := ""
IF aRE[ i ][ 1 ] != NIL .AND. aRE[ i ][ 1 ][ 1 ] == ND_COL
cColRef := Upper( aRE[ i ][ 1 ][ 2 ] )
ENDIF
lIsInner := .F.
IF ! Empty( cColRef )
IF ::ColBelongsTo( cColRef, cInnerAlias )
lIsInner := .T.
ELSEIF ! ( "." $ cColRef )
nWA := Select( cInnerAlias )
IF nWA > 0
dbSelectArea( nWA )
nFPos := FieldPos( cColRef )
IF nFPos > 0
lIsInner := .T.
IF Len( ::aTables ) > 0
cBareField := cColRef
nWA := ::FindWA( ::aTables[ 1 ][ 2 ] )
IF nWA == 0
nWA := ::FindWA( ::aTables[ 1 ][ 1 ] )
ENDIF
IF nWA > 0
dbSelectArea( nWA )
IF FieldPos( cBareField ) > 0
lIsInner := .F.
ENDIF
ENDIF
ENDIF
ENDIF
ENDIF
ENDIF
ENDIF
IF lIsInner
AAdd( aRow, NIL )
ELSE
xVal := ::EvalExpr( aRE[ i ][ 1 ] )
IF ValType( xVal ) == "C"
xVal := AllTrim( xVal )
ENDIF
AAdd( aRow, xVal )
ENDIF
NEXT
RETURN aRow
METHOD JoinRecurse( aJoins, nIdx, xWhere, aRE, aRows, hHashTbl ) CLASS TSqlExecutor
LOCAL cJAlias, xOnCond, nWA, aRow
LOCAL lJoinMatch
LOCAL cOuterCol, cInnerCol, cInnerField, xSeekVal, cSeekStr
LOCAL lUseIndex, lFound, nPI
LOCAL cJoinType, lHadMatch
LOCAL nRecCount, lUseHash
LOCAL xProbe, cRMKey
IF hHashTbl == NIL
hHashTbl := { => }
ENDIF
IF nIdx > Len( aJoins )
IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) )
aRow := ::FetchRow( aRE )
AAdd( aRows, aRow )
ENDIF
RETURN NIL
ENDIF
cJoinType := Upper( aJoins[ nIdx ][ 1 ] )
cJAlias := aJoins[ nIdx ][ 3 ]
IF Empty( cJAlias )
cJAlias := aJoins[ nIdx ][ 2 ]
ENDIF
xOnCond := aJoins[ nIdx ][ 4 ]
nWA := Select( cJAlias )
IF nWA == 0
/* Try the join table name directly (handles CTE alias mismatch) */
nWA := Select( Upper( aJoins[ nIdx ][ 2 ] ) )
ENDIF
IF nWA == 0
RETURN NIL
ENDIF
/* CROSS JOIN */
IF cJoinType == "CROSS"
dbSelectArea( nWA )
dbGoTop()
WHILE ! Eof()
::JoinRecurse( aJoins, nIdx + 1, xWhere, aRE, @aRows, hHashTbl )
dbSelectArea( nWA )
dbSkip()
ENDDO
RETURN NIL
ENDIF
lHadMatch := .F.
lUseIndex := .F.
lUseHash := .F.
/* Track matched inner RecNos for RIGHT/FULL JOIN pass */
cRMKey := "__RIGHT_" + Upper( cJAlias )
cOuterCol := ""
cInnerCol := ""
cInnerField := ""
/* Analyze ON condition for index or hash join optimization.
* Handles both `a.x = b.x` and `a.x = b.x AND ...` — for the AND
* case we pick the first equi-join term as the hash key and the
* HashJoin method re-evaluates the full xOnCond after probe to
* filter out spurious matches. This is how SQLite's hash-join
* fallback handles compound predicates. */
xProbe := xOnCond
IF xOnCond != NIL .AND. xOnCond[ 1 ] == ND_BIN .AND. xOnCond[ 2 ] == "AND"
/* Walk left-associative AND chain until we find an equi-term */
xProbe := xOnCond
WHILE xProbe != NIL .AND. xProbe[ 1 ] == ND_BIN .AND. xProbe[ 2 ] == "AND"
/* Prefer left operand if it's an equi-join */
IF xProbe[ 3 ] != NIL .AND. xProbe[ 3 ][ 1 ] == ND_BIN .AND. xProbe[ 3 ][ 2 ] == "="
xProbe := xProbe[ 3 ]
EXIT
ENDIF
xProbe := xProbe[ 4 ] /* descend right */
ENDDO
ENDIF
IF xProbe != NIL .AND. xProbe[ 1 ] == ND_BIN .AND. xProbe[ 2 ] == "="
IF xProbe[ 3 ] != NIL .AND. xProbe[ 3 ][ 1 ] == ND_COL .AND. ;
xProbe[ 4 ] != NIL .AND. xProbe[ 4 ][ 1 ] == ND_COL
IF ::ColBelongsTo( xProbe[ 4 ][ 2 ], cJAlias )
cOuterCol := xProbe[ 3 ][ 2 ]
cInnerCol := xProbe[ 4 ][ 2 ]
ELSEIF ::ColBelongsTo( xProbe[ 3 ][ 2 ], cJAlias )
cOuterCol := xProbe[ 4 ][ 2 ]
cInnerCol := xProbe[ 3 ][ 2 ]
ENDIF
ENDIF
IF ! Empty( cInnerCol )
IF "." $ cInnerCol
cInnerField := Upper( SubStr( cInnerCol, At( ".", cInnerCol ) + 1 ) )
ELSE
cInnerField := Upper( cInnerCol )
ENDIF
dbSelectArea( nWA )
lUseIndex := ( ::oIndex:FindBestTag( nWA, cInnerField ) > 0 )
/* SQLite strategy: always use hash join for equi-joins when no index.
* Build ephemeral hash table on first probe, O(m) build + O(1) lookup.
* No threshold — even small tables benefit from avoiding repeated scans. */
IF ! lUseIndex .AND. ! Empty( cOuterCol )
lUseHash := .T.
ENDIF
ENDIF
ENDIF
IF lUseIndex
xSeekVal := ::EvalExpr( SqlNode( ND_COL, cOuterCol, NIL, NIL, NIL ) )
dbSelectArea( nWA )
cSeekStr := ::oIndex:BuildKey( nWA, xSeekVal )
lFound := dbSeek( cSeekStr )
WHILE lFound .AND. ! Eof()
lJoinMatch := SqlIsTrue( ::EvalExpr( xOnCond ) )
IF ! lJoinMatch
EXIT
ENDIF
lHadMatch := .T.
::JoinRecurse( aJoins, nIdx + 1, xWhere, aRE, @aRows, hHashTbl )
dbSelectArea( nWA )
dbSkip()
IF Eof()
EXIT
ENDIF
ENDDO
ELSEIF lUseHash
/* Hash join path for equi-joins on large tables without index */
lHadMatch := ::HashJoin( nWA, cInnerField, cOuterCol, xOnCond, ;
aJoins, nIdx, xWhere, aRE, @aRows, @hHashTbl )
ELSE
dbSelectArea( nWA )
dbGoTop()
WHILE ! Eof()
lJoinMatch := .T.
IF xOnCond != NIL
lJoinMatch := SqlIsTrue( ::EvalExpr( xOnCond ) )
ENDIF
IF lJoinMatch
lHadMatch := .T.
/* Record match for RIGHT JOIN pass */
IF ! hb_HHasKey( ::hRightMatched, cRMKey )
::hRightMatched[ cRMKey ] := { => }
ENDIF
::hRightMatched[ cRMKey ][ RecNo() ] := .T.
::JoinRecurse( aJoins, nIdx + 1, xWhere, aRE, @aRows, hHashTbl )
ENDIF
dbSelectArea( nWA )
dbSkip()
ENDDO
ENDIF
/* LEFT JOIN NULL fill — when no match was found for the current
* join level, emit a NULL-filled row. For multi-level JOINs
* (a LEFT JOIN b ON ... JOIN c ON ...) we must recurse into
* subsequent join levels rather than only emitting at the last
* one — otherwise the middle LEFT JOIN's NULL fill never reaches
* the base case and the entire outer row is silently dropped. */
IF ! lHadMatch .AND. ( cJoinType == "LEFT" .OR. cJoinType == "FULL" )
IF nIdx >= Len( aJoins )
/* Last join — emit directly */
aRow := ::FetchRowNull( aRE, cJAlias )
IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) )
AAdd( aRows, aRow )
ENDIF
ELSE
/* Middle join — recurse with NULL-filled state for this level
* so subsequent joins can still process and emit their own
* NULL rows or matches. */
::JoinRecurse( aJoins, nIdx + 1, xWhere, aRE, @aRows, hHashTbl )
ENDIF
ENDIF
RETURN NIL
/* RightJoinPass — emit inner rows that had no match during the main
* join pass (for RIGHT/FULL joins). Outer columns are NIL.
*
* Previous O(N*M) approach rescanned the outer table for every inner
* row to detect unmatched ones. Now uses ::hRightMatched (populated
* during the main join) as a RecNo set — O(N) inner scan + O(1)
* hash probe per row.
*/
METHOD RightJoinPass( aJoins, nJIdx, aRE, aRows ) CLASS TSqlExecutor
LOCAL cJAlias, nWA, cOuterAlias
LOCAL aRow, j, cColRef, cMatchKey, nRec
cJAlias := aJoins[ nJIdx ][ 3 ]
IF Empty( cJAlias )
cJAlias := aJoins[ nJIdx ][ 2 ]
ENDIF
nWA := Select( cJAlias )
IF nWA == 0
RETURN NIL
ENDIF
cOuterAlias := ""
IF Len( ::aTables ) > 0
cOuterAlias := ::aTables[ 1 ][ 2 ]
IF Empty( cOuterAlias )
cOuterAlias := ::aTables[ 1 ][ 1 ]
ENDIF
ENDIF
cMatchKey := "__RIGHT_" + Upper( cJAlias )
dbSelectArea( nWA )
dbGoTop()
WHILE ! Eof()
nRec := RecNo()
IF hb_HHasKey( ::hRightMatched, cMatchKey ) .AND. ;
hb_HHasKey( ::hRightMatched[ cMatchKey ], nRec )
/* Matched during main join — skip */
ELSE
/* Unmatched inner row — emit with NULLs for outer columns */
aRow := {}
FOR j := 1 TO Len( aRE )
cColRef := ""
IF aRE[ j ][ 1 ] != NIL .AND. aRE[ j ][ 1 ][ 1 ] == ND_COL
cColRef := Upper( aRE[ j ][ 1 ][ 2 ] )
ENDIF
IF ! Empty( cColRef ) .AND. ::ColBelongsTo( cColRef, cOuterAlias )
AAdd( aRow, NIL )
ELSE
AAdd( aRow, ::EvalExpr( aRE[ j ][ 1 ] ) )
ENDIF
NEXT
AAdd( aRows, aRow )
ENDIF
dbSelectArea( nWA )
dbSkip()
ENDDO
RETURN NIL
METHOD RunSelect() CLASS TSqlExecutor
LOCAL aCols, aJoins, xWhere, aGroupBy, xHaving, aOrderBy
LOCAL nTop, nLimit, nOffset, lDistinct, hUnion
LOCAL aFieldNames := {}, aRows := {}, aRow
LOCAL aSavedAreas := {}
LOCAL cTable, cAlias, nWA, i, j
LOCAL aResultExprs
LOCAL xExpr, cColAlias, cFN
LOCAL nMaxRows
LOCAL aU, lAll
LOCAL xArgExpr, cBare, lFound, aLeafCols, k
LOCAL hJoinHash
LOCAL lIndexUsed, aTmp
LOCAL aFP, pcW, aGoRows
LOCAL nEarlyLimit, aSortSpec
aCols := ::hQuery[ "columns" ]
/* Deep-clone tables and joins so cross-run state (alias renames,
* fetch-cache references, etc.) doesn't leak between invocations
* of the same hQuery. A scalar correlated subquery that opens its
* FROM tables gets depth-suffixed temp aliases written back into
* aTables[i][2] and aJoins[i][3]; without this clone, the second
* call inherits the first call's dead alias and the JOIN sync
* loop below fails to match, leaving stale aliases that resolve
* to closed workareas. */
::aTables := AClone( ::hQuery[ "tables" ] )
FOR i := 1 TO Len( ::aTables )
IF ValType( ::aTables[ i ] ) == "A"
::aTables[ i ] := AClone( ::aTables[ i ] )
ENDIF
NEXT
aJoins := AClone( ::hQuery[ "joins" ] )
FOR i := 1 TO Len( aJoins )
IF ValType( aJoins[ i ] ) == "A"
aJoins[ i ] := AClone( aJoins[ i ] )
ENDIF
NEXT
xWhere := ::hQuery[ "where" ]
aGroupBy := ::hQuery[ "group_by" ]
xHaving := ::hQuery[ "having" ]
aOrderBy := ::hQuery[ "order_by" ]
nTop := ::hQuery[ "top" ]
nLimit := ::hQuery[ "limit" ]
nOffset := iif( hb_HHasKey( ::hQuery, "offset" ), ::hQuery[ "offset" ], 0 )
lDistinct := ::hQuery[ "distinct" ]
hUnion := ::hQuery[ "union" ]
AAdd( aSavedAreas, Select() )
::nDepth++
/* Materialize CTEs if present */
IF hb_HHasKey( ::hQuery, "cte" ) .AND. ValType( ::hQuery[ "cte" ] ) == "A"
IF hb_HHasKey( ::hQuery, "cte_recursive" ) .AND. ::hQuery[ "cte_recursive" ]
::MaterializeRecursiveCTE( ::hQuery[ "cte" ] )
ELSE
::MaterializeCTE( ::hQuery[ "cte" ] )
ENDIF
ENDIF
/* Handle derived tables */
FOR i := 1 TO Len( ::aTables )
IF ::aTables[ i ][ 1 ] == "__SUBQUERY__" .AND. ;
ValType( ::aTables[ i ][ 3 ] ) == "A" .AND. ;
::aTables[ i ][ 3 ][ 1 ] == ND_SUB
cAlias := ::aTables[ i ][ 2 ]
IF Empty( cAlias )
cAlias := ::oAlias:AcquireTemp( "DRV" )
ENDIF
::aTables[ i ] := SqlMaterializeSubquery( ::aTables[ i ][ 3 ], cAlias, ::aParams )
ENDIF
NEXT
/* Open all referenced tables */
FOR i := 1 TO Len( ::aTables )
cTable := ::aTables[ i ][ 1 ]
cAlias := ::aTables[ i ][ 2 ]
IF Empty( cAlias )
cAlias := cTable
ENDIF
/* Always stash the user-written alias in slot [3] so that FindWA /
* Resolve can still match queries that reference the alias by its
* SQL name even after we re-alias the workarea with a depth-
* suffixed temp name. Previously this was only done for 1-char
* aliases, which left multi-char aliases (e.g. `emp e2`) invisible
* to correlated subquery lookups once the rename kicked in. */
IF Empty( ::aTables[ i ][ 3 ] )
::aTables[ i ][ 3 ] := cAlias
ENDIF
IF Len( cAlias ) <= 1 .OR. ::nDepth > 1
cAlias := ::oAlias:AcquireTemp( Upper( cTable ) )
::aTables[ i ][ 2 ] := cAlias
ENDIF
nWA := Select( cAlias )
IF nWA == 0
nWA := ::OpenTable( cTable, cAlias )
IF nWA == 0
/* Table file not found; check if a CTE temp file exists for this
* table name and open it instead. This handles sub-executors
* (UNION, recursive) that reference a CTE by its original name. */
IF hb_FileExists( "__cte_" + Lower( cTable ) + ".dbf" )
BEGIN SEQUENCE
dbUseArea( .T., "DBFNTX", "__cte_" + Lower( cTable ) + ".dbf", ;
cAlias, .T., .T. )
nWA := Select( cAlias )
AAdd( ::aOpened, cAlias )
AAdd( ::oAlias:aSlots, { cAlias, Upper( cTable ), Upper( cTable ), .T. } )
RECOVER
nWA := 0
END SEQUENCE
ENDIF
ENDIF
IF nWA == -1
::nDepth--
IF Len( aSavedAreas ) > 0
dbSelectArea( aSavedAreas[ 1 ] )
ENDIF
RETURN ::MakeError( SQL_ERR_LOCKED, ;
"Table '" + cTable + "' is open EXCLUSIVE. " + ;
"Close it or reopen with SHARED access before running SQL queries." )
ENDIF
ENDIF
NEXT
/* Synchronize join aliases with the aTables entries that were
* potentially renamed by the alias manager above. */
FOR i := 1 TO Len( aJoins )
IF Empty( aJoins[ i ][ 3 ] )
aJoins[ i ][ 3 ] := aJoins[ i ][ 2 ]
ENDIF
/* Find matching aTables entry and adopt its (possibly renamed) alias */
FOR j := 1 TO Len( ::aTables )
IF Upper( ::aTables[ j ][ 1 ] ) == Upper( aJoins[ i ][ 2 ] ) .AND. ;
( Upper( ::aTables[ j ][ 3 ] ) == Upper( aJoins[ i ][ 3 ] ) .OR. ;
::aTables[ j ][ 2 ] == aJoins[ i ][ 3 ] .OR. ;
Upper( ::aTables[ j ][ 1 ] ) == Upper( aJoins[ i ][ 3 ] ) )
aJoins[ i ][ 3 ] := ::aTables[ j ][ 2 ]
EXIT
ENDIF
NEXT
NEXT
/* Build result column names and expression trees */
aResultExprs := {}
FOR i := 1 TO Len( aCols )
xExpr := aCols[ i ][ 1 ]
cColAlias := aCols[ i ][ 2 ]
IF Empty( cColAlias )
cColAlias := SqlExprName( xExpr )
ENDIF
AAdd( aResultExprs, { xExpr, cColAlias } )
/* Expand SELECT * — iterate ALL tables (primary + joined) */
IF xExpr[ 1 ] == ND_COL .AND. xExpr[ 2 ] == "*"
aResultExprs := {}
aFieldNames := {}
FOR k := 1 TO Len( ::aTables )
cAlias := ::aTables[ k ][ 2 ]
IF Empty( cAlias )
cAlias := ::aTables[ k ][ 1 ]
ENDIF
nWA := Select( cAlias )
IF nWA > 0
dbSelectArea( nWA )
FOR j := 1 TO FCount()
cFN := Upper( AllTrim( FieldName( j ) ) )
AAdd( aResultExprs, { SqlNode( ND_COL, cFN, NIL, NIL, NIL ), cFN } )
NEXT
ENDIF
NEXT
EXIT
ENDIF
NEXT
/* Add hidden columns for aggregate source fields */
FOR i := 1 TO Len( aCols )
IF SqlExprHasAgg( aCols[ i ][ 1 ] )
IF aCols[ i ][ 1 ][ 1 ] == ND_FN .AND. Len( aCols[ i ][ 1 ][ 3 ] ) > 0
xArgExpr := aCols[ i ][ 1 ][ 3 ][ 1 ]
IF xArgExpr[ 1 ] == ND_COL .AND. xArgExpr[ 2 ] != "*"
/* Use the FULL qualified name (e.g. "o.amount") so
* FetchRow → FindWA resolves to the right workarea
* in JOIN contexts. Bare "amount" would fall through
* to aTables[1] which may be a different table. */
cBare := xArgExpr[ 2 ]
lFound := .F.
FOR j := 1 TO Len( aResultExprs )
IF Upper( aResultExprs[ j ][ 2 ] ) == Upper( cBare )
lFound := .T.
EXIT
ENDIF
NEXT
IF ! lFound
AAdd( aResultExprs, { xArgExpr, cBare } )
ENDIF
ELSEIF xArgExpr[ 1 ] != ND_COL
/* Complex expression (CASE, BIN, etc.) inside aggregate:
* collect the original ND_COL leaf nodes and add them as
* hidden result columns so they appear in fetched rows.
* Must preserve the qualified name (e.g. "o.qty") so
* subqueries with JOINs resolve to the right workarea.
* Using bare names here used to send `price` to ord in
* a `FROM ord o JOIN prod p` query, silently yielding
* NIL/wrong row data. */
aLeafCols := SqlCollectColExprs( xArgExpr, NIL )
FOR k := 1 TO Len( aLeafCols )
cBare := aLeafCols[ k ][ 2 ]
lFound := .F.
FOR j := 1 TO Len( aResultExprs )
IF Upper( aResultExprs[ j ][ 2 ] ) == Upper( cBare )
lFound := .T.
EXIT
ENDIF
NEXT
IF ! lFound
AAdd( aResultExprs, { aLeafCols[ k ], cBare } )
ENDIF
NEXT
ENDIF
ENDIF
ENDIF
NEXT
FOR i := 1 TO Len( aResultExprs )
AAdd( aFieldNames, aResultExprs[ i ][ 2 ] )
NEXT
/* Constant folding */
IF xWhere != NIL
xWhere := SqlFoldConst( xWhere )
ENDIF
FOR i := 1 TO Len( aResultExprs )
aResultExprs[ i ][ 1 ] := SqlFoldConst( aResultExprs[ i ][ 1 ] )
NEXT
SET DELETED ON
/* SELECT without FROM */
IF Len( ::aTables ) == 0
aRow := ::FetchRow( aResultExprs )
AAdd( aRows, aRow )
ENDIF
/* Scan primary table */
IF Len( ::aTables ) > 0
cAlias := ::aTables[ 1 ][ 2 ]
IF Empty( cAlias )
cAlias := ::aTables[ 1 ][ 1 ]
ENDIF
nWA := Select( cAlias )
IF nWA > 0
dbSelectArea( nWA )
lIndexUsed := .F.
IF Len( aJoins ) == 0 .AND. xWhere != NIL
lIndexUsed := ::oIndex:TryIndexScan( nWA, xWhere, xWhere, ;
::aTables, ::aParams, aResultExprs, @aRows )
ELSEIF Len( aJoins ) > 0 .AND. xWhere != NIL
lIndexUsed := ::oIndex:TryIndexJoinScan( nWA, xWhere, ;
::aTables, ::aParams, aResultExprs, @aRows, aJoins )
ENDIF
IF ! lIndexUsed
dbSelectArea( nWA )
dbGoTop()
hJoinHash := { => }
/* === GO NATIVE JOIN FAST PATH ===
* Multi-table equi-join with all SELECT columns being plain
* field refs → hand the entire join to Go's SqlHashJoin.
* Bypasses per-row PRG JoinRecurse/FetchRow/dbSelectArea. */
IF Len( aJoins ) > 0 .AND. xWhere == NIL .AND. aGoRows == NIL
aGoRows := ::TryGoJoin( aJoins, aResultExprs, nWA )
IF aGoRows != NIL
FOR i := 1 TO Len( aGoRows )
AAdd( aRows, aGoRows[ i ] )
NEXT
ENDIF
ENDIF
/* === GO NATIVE FAST PATH ===
* Single-table, no joins, no aggregates, all SELECT exprs
* simple field refs, WHERE is NIL or compilable to pcode.
* Two variants share the same entry conditions:
* - With row block (::bRowBlock != NIL): SqlEach streams
* rows directly into the user block, no intermediate
* array. Beats raw RDD on end-to-end timing.
* - Without block: SqlScan materializes into aRows as
* usual (compat with existing callers).
*/
aFP := NIL
pcW := NIL
aGoRows := NIL
IF Len( aJoins ) == 0 .AND. Len( aGroupBy ) == 0 .AND. ;
! ::oAgg:HasAgg( aCols )
aFP := ::TryBuildFieldPositions( aResultExprs )
IF aFP != NIL
pcW := ::TryCompileWhere( xWhere )
IF xWhere == NIL .OR. pcW != NIL
IF ::bRowBlock != NIL
/* Block mode: stream rows through user block.
* No result array. Skip all post-processing
* (ORDER BY / LIMIT / window / DISTINCT) —
* those require a materialized set; callers
* using the block form opt into streaming
* semantics and handle shaping themselves. */
SqlEach( aFP, pcW, ::bRowBlock )
aGoRows := {} /* signal "handled" to skip fallback */
ELSE
aGoRows := SqlScan( aFP, pcW )
FOR i := 1 TO Len( aGoRows )
AAdd( aRows, aGoRows[ i ] )
NEXT
ENDIF
ENDIF
ENDIF
ENDIF
/* Fallback: PRG interpreter loop */
IF aGoRows == NIL
/* Pre-bind SELECT columns to {nWA, nFPos} so FetchRow
* can skip the per-row string parse + FindWA on every
* join recursion. Huge win for multi-table scans. */
::aFetchCache := ::BuildFetchCache( aResultExprs )
dbSelectArea( nWA )
/* Early-termination LIMIT: when the query has a plain
* LIMIT / TOP and no ORDER BY, GROUP BY, aggregates,
* or DISTINCT, we can stop scanning as soon as aRows
* reaches the cap. Huge win for `EXISTS` which plants
* an implicit LIMIT 1 into the subquery's hQuery. */
nEarlyLimit := 0
IF ( ValType( nLimit ) == "N" .AND. nLimit > 0 ) .OR. ;
( ValType( nTop ) == "N" .AND. nTop > 0 )
IF Len( aOrderBy ) == 0 .AND. Len( aGroupBy ) == 0 .AND. ;
! ::oAgg:HasAgg( aCols ) .AND. ! lDistinct
nEarlyLimit := iif( ValType( nLimit ) == "N" .AND. nLimit > 0, ;
nLimit, nTop )
ENDIF
ENDIF
WHILE ! Eof()
IF Len( aJoins ) > 0
::JoinRecurse( aJoins, 1, xWhere, aResultExprs, @aRows, hJoinHash )
dbSelectArea( nWA )
ELSE
IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) )
aRow := ::FetchRow( aResultExprs )
AAdd( aRows, aRow )
ENDIF
ENDIF
IF nEarlyLimit > 0 .AND. Len( aRows ) >= nEarlyLimit
EXIT
ENDIF
dbSelectArea( nWA )
dbSkip()
ENDDO
::aFetchCache := NIL
ENDIF
ENDIF
ENDIF
ENDIF
/* GROUP BY */
IF Len( aGroupBy ) > 0 .OR. ::oAgg:HasAgg( aCols )
aRows := ::oAgg:GroupBy( aRows, aFieldNames, aCols, aGroupBy, xHaving, ::aTables, ::aParams )
aFieldNames := {}
FOR i := 1 TO Len( aCols )
IF ! Empty( aCols[ i ][ 2 ] )
AAdd( aFieldNames, aCols[ i ][ 2 ] )
ELSE
AAdd( aFieldNames, SqlExprName( aCols[ i ][ 1 ] ) )
ENDIF
NEXT
ENDIF
/* Window functions */
::ApplyWindowFunctions( @aRows, aFieldNames, aCols )
/* ORDER BY — try Go-native sort first (10-50x faster for large sets),
* fall back to PRG for complex expressions in ORDER BY. */
IF Len( aOrderBy ) > 0
IF ! ( nWA > 0 .AND. ::oIndex:MatchOrderByTag( nWA, aOrderBy, aFieldNames ) )
LOCAL aSortSpec := ::TryBuildSortSpec( aOrderBy, aFieldNames )
IF aSortSpec != NIL .AND. Len( aRows ) > 0
aRows := SqlOrderBy( aRows, aSortSpec )
ELSE
aRows := ::oSort:OrderBy( aRows, aFieldNames, aOrderBy, ::aTables, ::aParams )
ENDIF
ENDIF
ENDIF
/* RIGHT JOIN second pass — must run before set operations and
* LIMIT so unmatched inner rows are included in the full result. */
IF Len( aJoins ) > 0
FOR i := 1 TO Len( aJoins )
IF Upper( aJoins[ i ][ 1 ] ) == "RIGHT" .OR. Upper( aJoins[ i ][ 1 ] ) == "FULL"
::RightJoinPass( aJoins, i, aResultExprs, @aRows )
ENDIF
NEXT
ENDIF
/* UNION / INTERSECT / EXCEPT — per SQL standard, set operations
* are applied to the full result of each SELECT before ORDER BY /
* DISTINCT / OFFSET / LIMIT. Previous order applied them last,
* which meant LIMIT clipped the first SELECT before UNION added
* the second's rows, producing more rows than intended. */
IF hUnion != NIL
aU := TSqlExecutor():New( hUnion, ::aParams ):Run()
IF hb_HHasKey( hUnion, "set_op" )
IF hUnion[ "set_op" ] == "INTERSECT"
aRows := SqlDoIntersect( aRows, aU[ 2 ] )
ELSEIF hUnion[ "set_op" ] == "EXCEPT"
aRows := SqlDoExcept( aRows, aU[ 2 ] )
ENDIF
ELSE
lAll := .F.
IF hb_HHasKey( hUnion, "union_all" )
lAll := hUnion[ "union_all" ]
ENDIF
FOR i := 1 TO Len( aU[ 2 ] )
AAdd( aRows, aU[ 2 ][ i ] )
NEXT
IF ! lAll
aRows := ::oSort:Distinct( aRows )
ENDIF
ENDIF
ENDIF
/* DISTINCT */
IF lDistinct
aRows := ::oSort:Distinct( aRows )
ENDIF
/* OFFSET */
IF nOffset > 0 .AND. nOffset < Len( aRows )
aTmp := {}
FOR i := nOffset + 1 TO Len( aRows )
AAdd( aTmp, aRows[ i ] )
NEXT
aRows := aTmp
ELSEIF nOffset >= Len( aRows )
aRows := {}
ENDIF
/* TOP / LIMIT */
nMaxRows := 0
IF ValType( nTop ) == "N" .AND. nTop > 0
nMaxRows := nTop
ENDIF
IF ValType( nLimit ) == "N" .AND. nLimit > 0
nMaxRows := nLimit
ENDIF
IF nMaxRows > 0 .AND. Len( aRows ) > nMaxRows
ASize( aRows, nMaxRows )
ENDIF
/* Close opened tables */
::CloseOpened()
/* Clean up CTE temp DBF files */
IF hb_HHasKey( ::hQuery, "cte" ) .AND. ValType( ::hQuery[ "cte" ] ) == "A"
FOR i := 1 TO Len( ::hQuery[ "cte" ] )
cTable := Upper( ::hQuery[ "cte" ][ i ][ 1 ] )
/* Close the CTE name alias workarea if still open */
nWA := Select( cTable )
IF nWA > 0
dbSelectArea( nWA )
dbCloseArea()
ENDIF
cTable := "__cte_" + Lower( ::hQuery[ "cte" ][ i ][ 1 ] )
IF hb_FileExists( cTable + ".dbf" )
FErase( cTable + ".dbf" )
ENDIF
NEXT
ENDIF
/* Clean up VIEW temp files — created by TSqlIndex:CheckView when
* a query references a .fsv view. Not tracked elsewhere. */
FOR i := 1 TO Len( ::aTables )
IF hb_FileExists( "__view_" + Lower( ::aTables[ i ][ 1 ] ) + ".dbf" )
FErase( "__view_" + Lower( ::aTables[ i ][ 1 ] ) + ".dbf" )
ENDIF
NEXT
::nDepth--
IF Len( aSavedAreas ) > 0
dbSelectArea( aSavedAreas[ 1 ] )
ENDIF
/* Block-callback mode: rows were streamed through ::bRowBlock during
* the fast-path scan. aRows is empty; we return NIL to signal
* streaming semantics to the caller. */
IF ::bRowBlock != NIL
RETURN NIL
ENDIF
RETURN { aFieldNames, aRows }
/* Hash join: build hash table from inner table, probe with outer key */
METHOD HashJoin( nInnerWA, cInnerField, cOuterCol, xOnCond, aJoins, nIdx, xWhere, aRE, aRows, hHashTbl ) CLASS TSqlExecutor
LOCAL cHashKey, aMatches, xOuterVal, xInnerVal, cValKey
LOCAL nFPos, nSavedRec, i, lHadMatch
LOCAL lCompound, cHJRMKey
lHadMatch := .F.
/* Build hash table once per join (keyed by join index).
* Delegates to the Go-native SqlHashBuild RTL which scans the
* inner workarea and returns the populated hash in one pass —
* roughly 40x faster than the PRG hash-build loop because it
* avoids per-row class dispatch, hb_HHasKey, and AAdd growth. */
cHashKey := "HJ_" + hb_ntos( nIdx ) + "_" + cInnerField
IF ! hb_HHasKey( hHashTbl, cHashKey )
dbSelectArea( nInnerWA )
nFPos := FieldPos( cInnerField )
IF nFPos > 0
hHashTbl[ cHashKey ] := SqlHashBuild( nFPos )
ELSE
hHashTbl[ cHashKey ] := { => }
ENDIF
ENDIF
/* Probe hash with outer row join key value */
xOuterVal := ::EvalExpr( SqlNode( ND_COL, cOuterCol, NIL, NIL, NIL ) )
cValKey := SqlValToStr( xOuterVal )
IF hb_HHasKey( hHashTbl[ cHashKey ], cValKey )
aMatches := hHashTbl[ cHashKey ][ cValKey ]
/* If xOnCond is a compound AND (not a bare equi-term), re-evaluate
* the full condition after the hash probe to filter out partial
* matches. xOnCond[2] == "=" indicates a bare equi-join where the
* hash match is sufficient. */
lCompound := ( xOnCond != NIL .AND. xOnCond[ 1 ] == ND_BIN .AND. xOnCond[ 2 ] != "=" )
/* Base-case inline: if the next recursion would just be FetchRow,
* skip the method-dispatch overhead and build the row directly.
* 50k inner matches × Send() dispatch was the 3-way join bottleneck. */
/* Track inner matched RecNos for RIGHT JOIN pass */
cHJRMKey := "__RIGHT_" + Upper( Alias( nInnerWA ) )
IF nIdx + 1 > Len( aJoins )
FOR i := 1 TO Len( aMatches )
dbSelectArea( nInnerWA )
dbGoto( aMatches[ i ] )
IF lCompound .AND. ! SqlIsTrue( ::EvalExpr( xOnCond ) )
LOOP
ENDIF
lHadMatch := .T.
IF ! hb_HHasKey( ::hRightMatched, cHJRMKey )
::hRightMatched[ cHJRMKey ] := { => }
ENDIF
::hRightMatched[ cHJRMKey ][ aMatches[ i ] ] := .T.
IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) )
AAdd( aRows, ::FetchRow( aRE ) )
ENDIF
NEXT
ELSE
FOR i := 1 TO Len( aMatches )
dbSelectArea( nInnerWA )
dbGoto( aMatches[ i ] )
IF lCompound .AND. ! SqlIsTrue( ::EvalExpr( xOnCond ) )
LOOP
ENDIF
lHadMatch := .T.
IF ! hb_HHasKey( ::hRightMatched, cHJRMKey )
::hRightMatched[ cHJRMKey ] := { => }
ENDIF
::hRightMatched[ cHJRMKey ][ aMatches[ i ] ] := .T.
::JoinRecurse( aJoins, nIdx + 1, xWhere, aRE, @aRows, hHashTbl )
NEXT
ENDIF
ENDIF
RETURN lHadMatch
/* Subquery result cache for non-correlated subqueries */
/* ExistsViaSemiJoin — try to answer an EXISTS / NOT EXISTS probe via
* a pre-built hash set instead of re-executing the subquery per outer
* row. Returns a boolean (the EXISTS result) on success, NIL when the
* subquery shape can't be lifted (caller should fall back to the
* normal per-row path).
*
* The lifted state is built on first call by TryBuildSemiJoin and
* cached in ::aSemiJoinSlots keyed on xSubNode identity. The sentinel
* string "NO" marks subqueries we already tried and can't lift, so
* subsequent calls skip the analysis.
*/
METHOD ExistsViaSemiJoin( xSubNode, lNegate ) CLASS TSqlExecutor
LOCAL i, nSlot, oData, xOuterVal, cKey, lMatch
/* Look up previous analysis */
nSlot := 0
FOR i := 1 TO Len( ::aSemiJoinSlots )
IF ::aSemiJoinSlots[ i ][ 1 ] == xSubNode
nSlot := i
EXIT
ENDIF
NEXT
IF nSlot == 0
oData := ::TryBuildSemiJoin( xSubNode )
AAdd( ::aSemiJoinSlots, { xSubNode, iif( oData == NIL, "NO", oData ) } )
nSlot := Len( ::aSemiJoinSlots )
ENDIF
oData := ::aSemiJoinSlots[ nSlot ][ 2 ]
/* Shape couldn't be lifted — let caller use fallback */
IF ValType( oData ) != "H"
RETURN NIL
ENDIF
/* Probe: evaluate outer column reference and look up in hash set */
xOuterVal := ::Resolve( oData[ "outer_ref" ] )
cKey := SqlValToStr( xOuterVal )
lMatch := hb_HHasKey( oData[ "inner_set" ], cKey )
IF lNegate
RETURN ! lMatch
ENDIF
RETURN lMatch
/* TryBuildSemiJoin — attempt to lift a correlated EXISTS subquery into
* a non-correlated hash set. Returns a hash { "outer_ref", "inner_set" }
* on success, NIL if the subquery doesn't match the supported shape.
*
* Supported shape:
* SELECT ... FROM inner_table WHERE inner.col = outer.col [AND rest]
* with no JOIN, no GROUP BY / HAVING, no ORDER BY. The `rest` can be
* any non-correlated predicate; it stays in the lifted subquery.
*
* Implementation:
* 1. Walk the WHERE tree as an AND list, find the first equi-term
* whose two sides are `innerTable.col` and `outerAlias.col`.
* Rebuild the remainder predicate from everything else.
* 2. Synthesize a modified hQuery: same FROM, DISTINCT inner.col as
* the only SELECT column, WHERE = remainder.
* 3. Run it once via a nested TSqlExecutor. Build a hash set keyed
* on SqlValToStr(innerCol).
*/
METHOD TryBuildSemiJoin( xSubNode ) CLASS TSqlExecutor
LOCAL hQ, aLocalAliases, i, aT
LOCAL aAndTerms, xTerm, xLeft, xRight
LOCAL lLeftIsInner, lRightIsInner
LOCAL cInnerAlias, cInnerField, xOuterRef
LOCAL aRemainTerms, xRemain
LOCAL hLifted, oSub, aResult, hSet, cKey
LOCAL xVal, aTopWhere
IF xSubNode == NIL .OR. ValType( xSubNode ) != "A" .OR. Len( xSubNode ) < 2
RETURN NIL
ENDIF
hQ := xSubNode[ 2 ]
IF ValType( hQ ) != "H"
RETURN NIL
ENDIF
/* Shape constraints — fall back for anything complex */
IF hb_HHasKey( hQ, "joins" ) .AND. ValType( hQ[ "joins" ] ) == "A" .AND. Len( hQ[ "joins" ] ) > 0
RETURN NIL
ENDIF
IF hb_HHasKey( hQ, "group_by" ) .AND. ValType( hQ[ "group_by" ] ) == "A" .AND. Len( hQ[ "group_by" ] ) > 0
RETURN NIL
ENDIF
IF hb_HHasKey( hQ, "having" ) .AND. hQ[ "having" ] != NIL
RETURN NIL
ENDIF
IF hb_HHasKey( hQ, "union" ) .AND. hQ[ "union" ] != NIL
RETURN NIL
ENDIF
IF ! hb_HHasKey( hQ, "tables" ) .OR. Len( hQ[ "tables" ] ) != 1
RETURN NIL
ENDIF
IF ! hb_HHasKey( hQ, "where" ) .OR. hQ[ "where" ] == NIL
RETURN NIL
ENDIF
/* Collect subquery's own table aliases to tell inner from outer */
aLocalAliases := {}
aT := hQ[ "tables" ][ 1 ]
AAdd( aLocalAliases, Upper( aT[ 1 ] ) )
IF Len( aT ) >= 2 .AND. ! Empty( aT[ 2 ] )
AAdd( aLocalAliases, Upper( aT[ 2 ] ) )
ENDIF
/* Flatten WHERE into a list of AND-terms */
aAndTerms := {}
aTopWhere := { hQ[ "where" ] }
WHILE Len( aTopWhere ) > 0
xTerm := aTopWhere[ 1 ]
ADel( aTopWhere, 1 )
ASize( aTopWhere, Len( aTopWhere ) - 1 )
IF xTerm != NIL .AND. ValType( xTerm ) == "A" .AND. Len( xTerm ) >= 4 .AND. ;
xTerm[ 1 ] == ND_BIN .AND. xTerm[ 2 ] == "AND"
AAdd( aTopWhere, xTerm[ 3 ] )
AAdd( aTopWhere, xTerm[ 4 ] )
ELSE
AAdd( aAndTerms, xTerm )
ENDIF
ENDDO
/* Find the equi-term that correlates inner.col with outer.col */
cInnerAlias := ""
cInnerField := ""
xOuterRef := NIL
aRemainTerms := {}
FOR i := 1 TO Len( aAndTerms )
xTerm := aAndTerms[ i ]
IF ! Empty( cInnerField ) .OR. ;
xTerm == NIL .OR. ValType( xTerm ) != "A" .OR. Len( xTerm ) < 4 .OR. ;
xTerm[ 1 ] != ND_BIN .OR. xTerm[ 2 ] != "=" .OR. ;
xTerm[ 3 ] == NIL .OR. xTerm[ 4 ] == NIL .OR. ;
xTerm[ 3 ][ 1 ] != ND_COL .OR. xTerm[ 4 ][ 1 ] != ND_COL
AAdd( aRemainTerms, xTerm )
LOOP
ENDIF
xLeft := xTerm[ 3 ]
xRight := xTerm[ 4 ]
lLeftIsInner := SemiJoinIsInner( xLeft, aLocalAliases )
lRightIsInner := SemiJoinIsInner( xRight, aLocalAliases )
IF lLeftIsInner .AND. ! lRightIsInner
cInnerField := SemiJoinStripAlias( xLeft[ 2 ] )
xOuterRef := xRight[ 2 ]
ELSEIF lRightIsInner .AND. ! lLeftIsInner
cInnerField := SemiJoinStripAlias( xRight[ 2 ] )
xOuterRef := xLeft[ 2 ]
ELSE
AAdd( aRemainTerms, xTerm )
ENDIF
NEXT
IF Empty( cInnerField ) .OR. xOuterRef == NIL
RETURN NIL
ENDIF
/* The remainder must be entirely non-correlated — otherwise the
* lifted subquery can't evaluate without an outer row, and any
* result would be wrong. This rules out patterns like
* WHERE e2.dept = e.dept AND e2.salary > e.salary
* where the second term still references the outer row. */
FOR i := 1 TO Len( aRemainTerms )
IF SemiJoinHasOuterRef( aRemainTerms[ i ], aLocalAliases )
RETURN NIL
ENDIF
NEXT
/* Rebuild the remainder WHERE as a right-leaning AND chain, or NIL */
xRemain := NIL
FOR i := 1 TO Len( aRemainTerms )
IF xRemain == NIL
xRemain := aRemainTerms[ i ]
ELSE
xRemain := SqlNode( ND_BIN, "AND", xRemain, aRemainTerms[ i ], NIL )
ENDIF
NEXT
/* Clone hQuery, replace SELECT list with DISTINCT inner.col,
* swap WHERE for the remainder. Other keys (tables, limit, etc.)
* are shallow-copied intentionally. */
hLifted := { => }
FOR i := 1 TO Len( hb_HKeys( hQ ) )
hLifted[ hb_HKeys( hQ )[ i ] ] := hQ[ hb_HKeys( hQ )[ i ] ]
NEXT
hLifted[ "columns" ] := { { SqlNode( ND_COL, cInnerField, NIL, NIL, NIL ), cInnerField } }
hLifted[ "where" ] := xRemain
hLifted[ "distinct" ] := .T.
hLifted[ "limit" ] := 0
hLifted[ "top" ] := 0
hLifted[ "order_by" ] := {}
hLifted[ "group_by" ] := {}
hLifted[ "having" ] := NIL
/* Run the lifted query once. No PushOuter — it's now non-correlated. */
oSub := TSqlExecutor():New( hLifted, ::aParams )
oSub:nDepth := ::nDepth
aResult := oSub:Run()
IF ValType( aResult ) != "A" .OR. Len( aResult ) < 2 .OR. ValType( aResult[ 2 ] ) != "A"
RETURN NIL
ENDIF
/* Build the hash set */
hSet := { => }
FOR i := 1 TO Len( aResult[ 2 ] )
IF Len( aResult[ 2 ][ i ] ) > 0
xVal := aResult[ 2 ][ i ][ 1 ]
cKey := SqlValToStr( xVal )
hSet[ cKey ] := .T.
ENDIF
NEXT
RETURN { "outer_ref" => xOuterRef, "inner_set" => hSet }
/* Helpers for TryBuildSemiJoin — module-level functions to keep the
* method body short. */
STATIC FUNCTION SemiJoinIsInner( xCol, aLocalAliases )
LOCAL cRef, nDot, cAlias
IF xCol == NIL .OR. ValType( xCol ) != "A" .OR. xCol[ 1 ] != ND_COL
RETURN .F.
ENDIF
cRef := xCol[ 2 ]
nDot := At( ".", cRef )
IF nDot == 0
/* Unqualified — assume inner since it would resolve in own FROM */
RETURN .T.
ENDIF
cAlias := Upper( Left( cRef, nDot - 1 ) )
RETURN AScan( aLocalAliases, cAlias ) > 0
STATIC FUNCTION SemiJoinStripAlias( cRef )
LOCAL nDot := At( ".", cRef )
IF nDot > 0
RETURN SubStr( cRef, nDot + 1 )
ENDIF
RETURN cRef
/* Recursively check whether an AST expression references any column
* whose alias prefix is NOT in the local alias list. Unqualified
* refs are assumed local. Returns .T. on first outer reference seen. */
STATIC FUNCTION SemiJoinHasOuterRef( xE, aLocalAliases )
LOCAL i, cRef, nDot, cAlias
IF xE == NIL .OR. ValType( xE ) != "A" .OR. Len( xE ) < 1
RETURN .F.
ENDIF
DO CASE
CASE xE[ 1 ] == ND_COL
IF Len( xE ) >= 2 .AND. ValType( xE[ 2 ] ) == "C"
cRef := xE[ 2 ]
nDot := At( ".", cRef )
IF nDot == 0
RETURN .F. /* unqualified → assumed local */
ENDIF
cAlias := Upper( Left( cRef, nDot - 1 ) )
RETURN AScan( aLocalAliases, cAlias ) == 0
ENDIF
CASE xE[ 1 ] == ND_BIN .OR. xE[ 1 ] == ND_RANGE
IF SemiJoinHasOuterRef( xE[ 3 ], aLocalAliases )
RETURN .T.
ENDIF
IF SemiJoinHasOuterRef( xE[ 4 ], aLocalAliases )
RETURN .T.
ENDIF
IF Len( xE ) >= 5 .AND. SemiJoinHasOuterRef( xE[ 5 ], aLocalAliases )
RETURN .T.
ENDIF
CASE xE[ 1 ] == ND_UNI
RETURN SemiJoinHasOuterRef( xE[ 3 ], aLocalAliases )
CASE xE[ 1 ] == ND_FN
IF Len( xE ) >= 3 .AND. ValType( xE[ 3 ] ) == "A"
FOR i := 1 TO Len( xE[ 3 ] )
IF SemiJoinHasOuterRef( xE[ 3 ][ i ], aLocalAliases )
RETURN .T.
ENDIF
NEXT
ENDIF
CASE xE[ 1 ] == ND_LIST
IF Len( xE ) >= 2 .AND. ValType( xE[ 2 ] ) == "A"
FOR i := 1 TO Len( xE[ 2 ] )
IF SemiJoinHasOuterRef( xE[ 2 ][ i ], aLocalAliases )
RETURN .T.
ENDIF
NEXT
ENDIF
ENDCASE
RETURN .F.
/* SubqueryCached — correlated-aware subquery execution with memoization.
*
* Walks the subquery's AST on first call to identify free variables —
* column references whose alias prefix is NOT one of the subquery's own
* FROM tables. These are the outer-row columns the subquery actually
* depends on. The cache key is built from the current values of those
* free variables, so:
*
* - Non-correlated subqueries (no free vars) → single cache entry,
* evaluated once, reused for every outer row. (Matches the old
* CacheSubquery behavior for simple `WHERE x > (SELECT MAX(y) FROM t)`.)
* - Correlated subqueries with a small distinct set of outer-key
* values → evaluated once per distinct key, not once per row.
* (Q8 in the SQL:2013 bench dropped from 4.9s to ~50ms with this.)
*
* The per-subquery ID and collected free variable list are memoized
* onto the AST node itself (slot 6) so the analysis runs only once per
* distinct subquery expression.
*/
METHOD SubqueryCached( xSubNode ) CLASS TSqlExecutor
LOCAL hQ, aFreeVars, cCacheKey, aResult, nSavedWA, oSub
LOCAL i, xVal, nId, nSlot, aSlot
IF xSubNode == NIL .OR. ValType( xSubNode ) != "A" .OR. Len( xSubNode ) < 2
RETURN NIL
ENDIF
hQ := xSubNode[ 2 ]
IF hQ == NIL
RETURN NIL
ENDIF
/* Identify this subquery: linear-search the slots list for a prior
* entry that references the SAME AST node (array `==` is reference
* compare in Harbour). Most queries have only a handful of sub-
* queries so the scan is trivial. Avoids mutating the parse tree. */
nSlot := 0
FOR i := 1 TO Len( ::aSubCacheSlots )
IF ::aSubCacheSlots[ i ][ 1 ] == xSubNode
nSlot := i
EXIT
ENDIF
NEXT
IF nSlot == 0
::nSubCacheSeq++
aFreeVars := ::CollectFreeVars( hQ )
AAdd( ::aSubCacheSlots, { xSubNode, { ::nSubCacheSeq, aFreeVars } } )
nSlot := Len( ::aSubCacheSlots )
ENDIF
aSlot := ::aSubCacheSlots[ nSlot ][ 2 ]
nId := aSlot[ 1 ]
aFreeVars := aSlot[ 2 ]
/* Build cache key from current values of free variables via
* Resolve(), which walks the outer context stack. */
cCacheKey := hb_ntos( nId ) + "@"
FOR i := 1 TO Len( aFreeVars )
xVal := ::Resolve( aFreeVars[ i ] )
cCacheKey += SqlValToStr( xVal ) + "|"
NEXT
IF hb_HHasKey( ::hSubCorrCache, cCacheKey )
RETURN ::hSubCorrCache[ cCacheKey ]
ENDIF
/* Cache miss — execute the subquery. PushOuter so nested ::Resolve
* calls can see the current outer row's values. Use BEGIN SEQUENCE
* to guarantee PopOuter runs even on subquery runtime errors —
* a stale s_aOuterStack entry would corrupt all subsequent queries. */
nSavedWA := Select()
::PushOuter()
BEGIN SEQUENCE
oSub := TSqlExecutor():New( hQ, ::aParams )
oSub:nDepth := ::nDepth
aResult := oSub:Run()
RECOVER
aResult := NIL
END SEQUENCE
::PopOuter()
dbSelectArea( nSavedWA )
IF aResult != NIL
::hSubCorrCache[ cCacheKey ] := aResult
ENDIF
RETURN aResult
/* Collect the list of free-variable column names referenced by a
* subquery. A column is "free" if its alias prefix isn't one of the
* subquery's own FROM tables (so it must resolve in the outer scope).
* Returns an array of name strings that Resolve() understands —
* typically qualified forms like "E1.DEPT".
*/
METHOD CollectFreeVars( hQ ) CLASS TSqlExecutor
LOCAL aFree := {}, aLocalAliases := {}, i, aT
IF ValType( hQ ) != "H"
RETURN aFree
ENDIF
/* Local aliases known to the subquery */
IF hb_HHasKey( hQ, "tables" )
FOR i := 1 TO Len( hQ[ "tables" ] )
aT := hQ[ "tables" ][ i ]
IF ValType( aT ) == "A" .AND. Len( aT ) >= 1
AAdd( aLocalAliases, Upper( aT[ 1 ] ) )
IF Len( aT ) >= 2 .AND. ! Empty( aT[ 2 ] )
AAdd( aLocalAliases, Upper( aT[ 2 ] ) )
ENDIF
ENDIF
NEXT
ENDIF
/* Walk the WHERE, SELECT list, HAVING for ND_COL refs */
IF hb_HHasKey( hQ, "where" ) .AND. hQ[ "where" ] != NIL
::CollectExprFreeVars( hQ[ "where" ], aLocalAliases, aFree )
ENDIF
IF hb_HHasKey( hQ, "columns" )
FOR i := 1 TO Len( hQ[ "columns" ] )
IF ValType( hQ[ "columns" ][ i ] ) == "A" .AND. Len( hQ[ "columns" ][ i ] ) >= 1
::CollectExprFreeVars( hQ[ "columns" ][ i ][ 1 ], aLocalAliases, aFree )
ENDIF
NEXT
ENDIF
IF hb_HHasKey( hQ, "having" ) .AND. hQ[ "having" ] != NIL
::CollectExprFreeVars( hQ[ "having" ], aLocalAliases, aFree )
ENDIF
RETURN aFree
/* Recursively walk a SQL AST expression tree collecting column refs
* whose alias prefix is not in aLocalAliases. Appends to aFree. */
METHOD CollectExprFreeVars( xE, aLocalAliases, aFree ) CLASS TSqlExecutor
LOCAL i, cRef, cAlias, nDot
IF xE == NIL .OR. ValType( xE ) != "A" .OR. Len( xE ) < 1
RETURN NIL
ENDIF
DO CASE
CASE xE[ 1 ] == ND_COL
IF Len( xE ) >= 2 .AND. ValType( xE[ 2 ] ) == "C"
cRef := xE[ 2 ]
nDot := At( ".", cRef )
IF nDot > 0
cAlias := Upper( Left( cRef, nDot - 1 ) )
IF AScan( aLocalAliases, cAlias ) == 0 .AND. ;
AScan( aFree, cRef ) == 0
AAdd( aFree, cRef )
ENDIF
ENDIF
ENDIF
CASE xE[ 1 ] == ND_BIN .OR. xE[ 1 ] == ND_RANGE
::CollectExprFreeVars( xE[ 3 ], aLocalAliases, aFree )
::CollectExprFreeVars( xE[ 4 ], aLocalAliases, aFree )
IF Len( xE ) >= 5
::CollectExprFreeVars( xE[ 5 ], aLocalAliases, aFree )
ENDIF
CASE xE[ 1 ] == ND_UNI
::CollectExprFreeVars( xE[ 3 ], aLocalAliases, aFree )
CASE xE[ 1 ] == ND_FN
/* Walk function arguments, but SKIP the subquery's own subqueries.
* Nested subqueries have their own scope and will be analyzed when
* they're first executed. */
IF Len( xE ) >= 3 .AND. ValType( xE[ 3 ] ) == "A"
FOR i := 1 TO Len( xE[ 3 ] )
::CollectExprFreeVars( xE[ 3 ][ i ], aLocalAliases, aFree )
NEXT
ENDIF
CASE xE[ 1 ] == ND_CASE
IF Len( xE ) >= 2 .AND. ValType( xE[ 2 ] ) == "A"
FOR i := 1 TO Len( xE[ 2 ] )
IF ValType( xE[ 2 ][ i ] ) == "A" .AND. Len( xE[ 2 ][ i ] ) >= 2
::CollectExprFreeVars( xE[ 2 ][ i ][ 1 ], aLocalAliases, aFree )
::CollectExprFreeVars( xE[ 2 ][ i ][ 2 ], aLocalAliases, aFree )
ENDIF
NEXT
ENDIF
IF Len( xE ) >= 3
::CollectExprFreeVars( xE[ 3 ], aLocalAliases, aFree )
ENDIF
CASE xE[ 1 ] == ND_LIST
IF Len( xE ) >= 2 .AND. ValType( xE[ 2 ] ) == "A"
FOR i := 1 TO Len( xE[ 2 ] )
::CollectExprFreeVars( xE[ 2 ][ i ], aLocalAliases, aFree )
NEXT
ENDIF
/* Nested ND_SUB is intentionally opaque — its own free vars will
* be analyzed on its first call */
ENDCASE
RETURN NIL
METHOD CacheSubquery( xSubExpr ) CLASS TSqlExecutor
LOCAL cKey, aSubResult, nSavedWA, oSub
/* Build cache key from subquery tokens */
cKey := SqlSubqueryKey( xSubExpr )
IF hb_HHasKey( ::hSubCache, cKey )
RETURN ::hSubCache[ cKey ]
ENDIF
/* Execute and cache the result.
* Inherit current depth so the subquery opens tables with a
* depth-suffixed alias, avoiding workarea collisions with
* the outer query (e.g. scalar subquery on the same table). */
nSavedWA := Select()
oSub := TSqlExecutor():New( xSubExpr, ::aParams )
oSub:nDepth := ::nDepth
aSubResult := oSub:Run()
dbSelectArea( nSavedWA )
::hSubCache[ cKey ] := aSubResult
RETURN aSubResult
/* Materialize CTE definitions into temporary DBF tables */
METHOD MaterializeCTE( aCTE ) CLASS TSqlExecutor
LOCAL i, cName, xSubQ, aSub, aFN, aDataRows
LOCAL j, k, lReplaced, xVal
LOCAL aStruct, cTmpFile, nExistWA, cPopAlias
LOCAL cType, nWidth, nDec, nScan
FOR i := 1 TO Len( aCTE )
cName := Upper( aCTE[ i ][ 1 ] )
xSubQ := aCTE[ i ][ 2 ]
/* Execute the CTE subquery */
IF ValType( xSubQ ) == "A" .AND. xSubQ[ 1 ] == ND_SUB .AND. xSubQ[ 2 ] != NIL
aSub := TSqlExecutor():New( xSubQ[ 2 ], ::aParams ):Run()
ELSE
aSub := NIL
ENDIF
IF ValType( aSub ) != "A" .OR. Len( aSub ) < 2
LOOP
ENDIF
aFN := aSub[ 1 ]
aDataRows := aSub[ 2 ]
/* Apply CTE column aliases if specified: WITH name(col1,col2) AS ... */
IF Len( aCTE[ i ] ) >= 3 .AND. ValType( aCTE[ i ][ 3 ] ) == "A" .AND. Len( aCTE[ i ][ 3 ] ) > 0
FOR j := 1 TO Min( Len( aCTE[ i ][ 3 ] ), Len( aFN ) )
aFN[ j ] := Upper( aCTE[ i ][ 3 ][ j ] )
NEXT
ENDIF
/* Build structure for temp DBF */
aStruct := {}
FOR j := 1 TO Len( aFN )
cType := "C" ; nWidth := 40 ; nDec := 0
xVal := NIL
FOR nScan := 1 TO Min( Len( aDataRows ), 50 )
IF j <= Len( aDataRows[ nScan ] ) .AND. aDataRows[ nScan ][ j ] != NIL
xVal := aDataRows[ nScan ][ j ] ; EXIT
ENDIF
NEXT
IF xVal != NIL
IF ValType( xVal ) == "N" ; cType := "N" ; nWidth := 18 ; nDec := 4
ELSEIF ValType( xVal ) == "D" ; cType := "D" ; nWidth := 8
ELSEIF ValType( xVal ) == "L" ; cType := "L" ; nWidth := 1
ENDIF
ENDIF
AAdd( aStruct, { PadR( Upper( aFN[ j ] ), 10 ), cType, nWidth, nDec } )
NEXT
cTmpFile := "__cte_" + Lower( cName )
cPopAlias := "__CTE" + hb_ntos( i ) + "__"
nExistWA := Select( cName )
IF nExistWA > 0
dbSelectArea( nExistWA )
dbCloseArea()
ENDIF
nExistWA := Select( cPopAlias )
IF nExistWA > 0
dbSelectArea( nExistWA )
dbCloseArea()
ENDIF
IF hb_FileExists( cTmpFile + ".dbf" )
FErase( cTmpFile + ".dbf" )
ENDIF
BEGIN SEQUENCE
dbCreate( cTmpFile + ".dbf", aStruct )
RECOVER
LOOP
END SEQUENCE
USE ( cTmpFile + ".dbf" ) NEW EXCLUSIVE ALIAS ( cPopAlias )
FOR j := 1 TO Len( aDataRows )
dbAppend()
FOR k := 1 TO Min( Len( aStruct ), Len( aDataRows[ j ] ) )
IF aDataRows[ j ][ k ] != NIL
FieldPut( k, aDataRows[ j ][ k ] )
ENDIF
NEXT
NEXT
dbCommit()
dbSelectArea( Select( cPopAlias ) )
dbCloseArea()
USE ( cTmpFile + ".dbf" ) NEW SHARED ALIAS ( cName )
/* Replace existing table entry */
lReplaced := .F.
NEXT
RETURN NIL
METHOD RunInsert() CLASS TSqlExecutor
LOCAL cTable, aFields, aValExprs, cAlias, nWA, i, nFPos, xVal
LOCAL aAutoInc, nAutoVal
cTable := ::hQuery[ "table" ]
aFields := ::hQuery[ "fields" ]
aValExprs := ::hQuery[ "values" ]
cAlias := cTable
aAutoInc := SqlGetAutoIncFields( cTable )
nWA := Select( cAlias )
IF nWA == 0
BEGIN SEQUENCE
dbUseArea( .T., "DBFNTX", Lower( cTable ) + ".dbf", cAlias, .F., .F. )
RECOVER
dbUseArea( .T., "DBFNTX", cTable + ".dbf", cAlias, .F., .F. )
END SEQUENCE
ELSE
dbSelectArea( nWA )
ENDIF
/* Transaction logging */
::oTxn:LogRecord( cAlias, RecNo(), "INSERT" )
dbAppend()
IF Len( aFields ) > 0
FOR i := 1 TO Min( Len( aFields ), Len( aValExprs ) )
nFPos := FieldPos( aFields[ i ] )
IF nFPos > 0
xVal := ::EvalExpr( aValExprs[ i ] )
FieldPut( nFPos, xVal )
ENDIF
NEXT
ELSE
FOR i := 1 TO Min( FCount(), Len( aValExprs ) )
xVal := ::EvalExpr( aValExprs[ i ] )
FieldPut( i, xVal )
NEXT
ENDIF
/* Auto-increment */
FOR i := 1 TO Len( aAutoInc )
nFPos := FieldPos( aAutoInc[ i ] )
IF nFPos > 0
xVal := FieldGet( nFPos )
IF ValType( xVal ) == "N" .AND. xVal == 0
nAutoVal := SqlGetMaxFieldVal( cAlias, aAutoInc[ i ] ) + 1
FieldPut( nFPos, nAutoVal )
ENDIF
ENDIF
NEXT
/* Validate CHECK constraints against current record values */
IF ! SqlValidateCheckRecord( cTable )
dbDelete()
dbCommit()
IF nWA == 0
dbCloseArea()
ENDIF
RETURN ::MakeError( SQL_ERR_GRAMMAR, "CHECK constraint violation on " + cTable )
ENDIF
/* Validate FOREIGN KEY constraints */
IF Len( aFields ) > 0
FOR i := 1 TO Len( aFields )
nFPos := FieldPos( aFields[ i ] )
IF nFPos > 0
IF ! SqlValidateFKRecord( cTable, aFields[ i ], FieldGet( nFPos ) )
dbDelete()
dbCommit()
IF nWA == 0
dbCloseArea()
ENDIF
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"FOREIGN KEY violation: " + aFields[ i ] + " references missing parent" )
ENDIF
ENDIF
NEXT
ENDIF
dbCommit()
IF nWA == 0
dbCloseArea()
ENDIF
RETURN { { "affected_rows" }, { { 1 } } }
METHOD RunUpdate() CLASS TSqlExecutor
LOCAL cTable, aSet, xWhere, cAlias, nWA, i, nFPos, xVal
LOCAL nAffected := 0
cTable := ::hQuery[ "table" ]
aSet := ::hQuery[ "set" ]
xWhere := ::hQuery[ "where" ]
cAlias := cTable
::aTables := { { cTable, cAlias, "" } }
nWA := Select( cAlias )
IF nWA == 0
BEGIN SEQUENCE
dbUseArea( .T., "DBFNTX", Lower( cTable ) + ".dbf", cAlias, .F., .F. )
RECOVER
dbUseArea( .T., "DBFNTX", cTable + ".dbf", cAlias, .F., .F. )
END SEQUENCE
ELSE
dbSelectArea( nWA )
ENDIF
dbGoTop()
WHILE ! Eof()
IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) )
IF dbRLock( RecNo() )
::oTxn:LogRecord( cAlias, RecNo(), "UPDATE" )
FOR i := 1 TO Len( aSet )
nFPos := FieldPos( aSet[ i ][ 1 ] )
IF nFPos > 0
xVal := ::EvalExpr( aSet[ i ][ 2 ] )
FieldPut( nFPos, xVal )
ENDIF
NEXT
dbRUnlock( RecNo() )
nAffected++
ENDIF
ENDIF
dbSkip()
ENDDO
dbCommit()
IF nWA == 0
dbCloseArea()
ENDIF
RETURN { { "affected_rows" }, { { nAffected } } }
METHOD RunDelete() CLASS TSqlExecutor
LOCAL cTable, xWhere, cAlias, nWA
LOCAL nAffected := 0
cTable := ::hQuery[ "table" ]
xWhere := ::hQuery[ "where" ]
cAlias := cTable
::aTables := { { cTable, cAlias, "" } }
nWA := Select( cAlias )
IF nWA == 0
BEGIN SEQUENCE
dbUseArea( .T., "DBFNTX", Lower( cTable ) + ".dbf", cAlias, .F., .F. )
RECOVER
dbUseArea( .T., "DBFNTX", cTable + ".dbf", cAlias, .F., .F. )
END SEQUENCE
ELSE
dbSelectArea( nWA )
ENDIF
SET DELETED ON
dbGoTop()
WHILE ! Eof()
IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) )
IF dbRLock( RecNo() )
dbDelete()
dbRUnlock( RecNo() )
nAffected++
ENDIF
ENDIF
dbSkip()
ENDDO
dbCommit()
IF nWA == 0
dbCloseArea()
ENDIF
RETURN { { "affected_rows" }, { { nAffected } } }
/* ======================================================================
* Standalone helper functions called by TSqlIndex
* ====================================================================== */
/* Evaluate expression node for index scan operations */
FUNCTION SqlEvalExprNode( xNode, aTables, aParams, nPI )
LOCAL oExec
oExec := TSqlExecutor():New( { => }, aParams )
oExec:aTables := aTables
RETURN oExec:EvalExpr( xNode )
/* Fetch a row array for index scan operations */
FUNCTION SqlFetchRowArr( aRE, aTables, aParams )
LOCAL oExec
oExec := TSqlExecutor():New( { => }, aParams )
oExec:aTables := aTables
RETURN oExec:FetchRow( aRE )
/* Join recurse called from TSqlIndex */
FUNCTION SqlJoinRecurse( aJoins, nIdx, aTables, xWhere, aRE, aRows, aParams, oIndex )
LOCAL oExec
oExec := TSqlExecutor():New( { => }, aParams )
oExec:aTables := aTables
oExec:oIndex := oIndex
oExec:JoinRecurse( aJoins, nIdx, xWhere, aRE, @aRows, NIL )
RETURN NIL
/* INTERSECT: keep only rows in both sets */
FUNCTION SqlDoIntersect( aRows1, aRows2 )
LOCAL aResult := {}, hKeys2 := { => }, i, cKey
LOCAL oSort := TSqlSort():New()
FOR i := 1 TO Len( aRows2 )
cKey := oSort:RowKey( aRows2[ i ] )
hKeys2[ cKey ] := .T.
NEXT
FOR i := 1 TO Len( aRows1 )
cKey := oSort:RowKey( aRows1[ i ] )
IF hb_HHasKey( hKeys2, cKey )
AAdd( aResult, aRows1[ i ] )
ENDIF
NEXT
RETURN aResult
/* EXCEPT: keep only rows in first that are not in second */
FUNCTION SqlDoExcept( aRows1, aRows2 )
LOCAL aResult := {}, hKeys2 := { => }, i, cKey
LOCAL oSort := TSqlSort():New()
FOR i := 1 TO Len( aRows2 )
cKey := oSort:RowKey( aRows2[ i ] )
hKeys2[ cKey ] := .T.
NEXT
FOR i := 1 TO Len( aRows1 )
cKey := oSort:RowKey( aRows1[ i ] )
IF ! hb_HHasKey( hKeys2, cKey )
AAdd( aResult, aRows1[ i ] )
ENDIF
NEXT
RETURN aResult
/* Materialize a subquery into a temp DBF */
FUNCTION SqlMaterializeSubquery( xSubQ, cAlias, aParams )
LOCAL aSub, aFN, aRows2, aStruct, cTmpFile, i, j
LOCAL cType, nWidth, nDec, xVal
aSub := TSqlExecutor():New( xSubQ[ 2 ], aParams ):Run()
IF ValType( aSub ) != "A" .OR. Len( aSub ) < 2
RETURN { "__EMPTY__", cAlias, "" }
ENDIF
aFN := aSub[ 1 ]
aRows2 := aSub[ 2 ]
aStruct := {}
FOR i := 1 TO Len( aFN )
cType := "C"
nWidth := 40
nDec := 0
IF Len( aRows2 ) > 0 .AND. i <= Len( aRows2[ 1 ] )
xVal := aRows2[ 1 ][ i ]
IF ValType( xVal ) == "N"
cType := "N"
nWidth := 18
nDec := 4
ELSEIF ValType( xVal ) == "D"
cType := "D"
nWidth := 8
ELSEIF ValType( xVal ) == "L"
cType := "L"
nWidth := 1
ELSEIF ValType( xVal ) == "T"
cType := "T"
nWidth := 8
ENDIF
ENDIF
AAdd( aStruct, { PadR( Upper( aFN[ i ] ), 10 ), cType, nWidth, nDec } )
NEXT
cTmpFile := "__drv_" + Lower( cAlias )
dbCreate( cTmpFile + ".dbf", aStruct )
USE ( cTmpFile + ".dbf" ) NEW EXCLUSIVE ALIAS __DRVTMP
FOR i := 1 TO Len( aRows2 )
dbAppend()
FOR j := 1 TO Min( Len( aStruct ), Len( aRows2[ i ] ) )
IF aRows2[ i ][ j ] != NIL
FieldPut( j, aRows2[ i ][ j ] )
ENDIF
NEXT
NEXT
dbCommit()
CLOSE __DRVTMP
RETURN { cTmpFile, cAlias, "" }
/* Auto-increment support */
FUNCTION SqlSetAutoInc( cTable, cField )
LOCAL cKey
cKey := Upper( cTable )
IF s_hAutoInc == NIL
s_hAutoInc := { => }
ENDIF
IF ! hb_HHasKey( s_hAutoInc, cKey )
s_hAutoInc[ cKey ] := {}
ENDIF
AAdd( s_hAutoInc[ cKey ], Upper( cField ) )
RETURN NIL
FUNCTION SqlGetAutoIncFields( cTable )
LOCAL cKey
cKey := Upper( cTable )
IF s_hAutoInc == NIL
RETURN {}
ENDIF
IF hb_HHasKey( s_hAutoInc, cKey )
RETURN s_hAutoInc[ cKey ]
ENDIF
RETURN {}
FUNCTION SqlGetMaxFieldVal( cAlias, cField )
LOCAL nWA, nSaved, nFPos, nMax := 0, xVal
LOCAL nSavedRec
nSaved := Select()
nWA := Select( cAlias )
IF nWA > 0
dbSelectArea( nWA )
nSavedRec := RecNo()
nFPos := FieldPos( cField )
IF nFPos > 0
dbGoTop()
WHILE ! Eof()
xVal := FieldGet( nFPos )
IF ValType( xVal ) == "N" .AND. xVal > nMax
nMax := xVal
ENDIF
dbSkip()
ENDDO
ENDIF
dbGoto( nSavedRec )
ENDIF
dbSelectArea( nSaved )
RETURN nMax
/* Build a unique cache key from a subquery hash structure */
FUNCTION SqlSubqueryKey( hSub )
LOCAL cKey, cType, i, aCols, aTbls
IF ValType( hSub ) != "H"
RETURN "??"
ENDIF
cKey := ""
IF hb_HHasKey( hSub, "type" )
cKey += hSub[ "type" ]
ENDIF
IF hb_HHasKey( hSub, "tables" )
aTbls := hSub[ "tables" ]
IF ValType( aTbls ) == "A"
FOR i := 1 TO Len( aTbls )
IF ValType( aTbls[ i ] ) == "A" .AND. Len( aTbls[ i ] ) >= 1
cKey += "|T:" + aTbls[ i ][ 1 ]
ENDIF
NEXT
ENDIF
ENDIF
IF hb_HHasKey( hSub, "columns" )
aCols := hSub[ "columns" ]
IF ValType( aCols ) == "A"
FOR i := 1 TO Len( aCols )
IF ValType( aCols[ i ] ) == "A" .AND. Len( aCols[ i ] ) >= 1
cKey += "|C:" + SqlExprName( aCols[ i ][ 1 ] )
ENDIF
NEXT
ENDIF
ENDIF
IF hb_HHasKey( hSub, "where" ) .AND. hSub[ "where" ] != NIL
cKey += "|W:" + SqlExprName( hSub[ "where" ] )
ENDIF
RETURN cKey
/* ======================================================================
* Recursive CTE materialization (SQL:1999)
* ====================================================================== */
/*
* Materialize recursive CTEs using an in-memory working table approach.
*
* Algorithm:
* 1. Execute anchor query → get initial rows
* 2. Write initial rows to temp DBF
* 3. Loop: scan temp DBF rows, for each row evaluate recursive expression
* to produce new rows. Append new rows to temp DBF.
* 4. Stop when no new rows are produced or max iterations reached.
* 5. Leave temp DBF open for the main query to use.
*
* Key insight: instead of creating a new TSqlExecutor for the recursive part
* (which causes workarea conflicts), we iterate the temp DBF directly and
* build new rows by evaluating the recursive SELECT columns.
*/
METHOD MaterializeRecursiveCTE( aCTE ) CLASS TSqlExecutor
LOCAL i, cName, xSubQ, hSubQ
LOCAL aSub, aFN, aDataRows, aStruct
LOCAL cTmpFile, cAlias, j, k, nIter
LOCAL cType, nWidth, nDec, xVal
LOCAL nExistWA, lReplaced
LOCAL aNewRows, lHasUnionAll
LOCAL hRecQuery, aPrevRows, nPrevCount
LOCAL aOneRow, aOneRow2, lPass, nPI, aNewRow, aCols, nPI2, nStart, xWR, xCV
FOR i := 1 TO Len( aCTE )
cName := Upper( aCTE[ i ][ 1 ] )
xSubQ := aCTE[ i ][ 2 ]
IF ValType( xSubQ ) != "A" .OR. xSubQ[ 1 ] != ND_SUB .OR. xSubQ[ 2 ] == NIL
LOOP
ENDIF
hSubQ := xSubQ[ 2 ]
/* Check if the CTE subquery has a UNION ALL (signals recursion) */
lHasUnionAll := .F.
IF ValType( hSubQ ) == "H" .AND. hb_HHasKey( hSubQ, "union" ) .AND. hSubQ[ "union" ] != NIL
IF hb_HHasKey( hSubQ[ "union" ], "union_all" ) .AND. hSubQ[ "union" ][ "union_all" ]
lHasUnionAll := .T.
ENDIF
ENDIF
IF ! lHasUnionAll
/* Not actually recursive, use normal CTE */
::MaterializeCTE( { aCTE[ i ] } )
LOOP
ENDIF
/* Save and detach the recursive (UNION ALL) part so the anchor
* query does not attempt to open the CTE table that has not been
* materialised yet. */
hRecQuery := hSubQ[ "union" ]
hSubQ[ "union" ] := NIL
/* Execute anchor query (the first SELECT before UNION ALL) */
aSub := TSqlExecutor():New( hSubQ, ::aParams ):Run()
/* Restore the union reference for later use */
hSubQ[ "union" ] := hRecQuery
IF ValType( aSub ) != "A" .OR. Len( aSub ) < 2
LOOP
ENDIF
aFN := aSub[ 1 ]
aDataRows := aSub[ 2 ]
/* Apply CTE column aliases if specified: WITH RECURSIVE seq(n) AS ... */
IF Len( aCTE[ i ] ) >= 3 .AND. ValType( aCTE[ i ][ 3 ] ) == "A" .AND. Len( aCTE[ i ][ 3 ] ) > 0
FOR j := 1 TO Min( Len( aCTE[ i ][ 3 ] ), Len( aFN ) )
aFN[ j ] := Upper( aCTE[ i ][ 3 ][ j ] )
NEXT
ENDIF
/* Build structure from anchor result */
aStruct := {}
FOR j := 1 TO Len( aFN )
cType := "C"
nWidth := 40
nDec := 0
IF Len( aDataRows ) > 0 .AND. j <= Len( aDataRows[ 1 ] )
xVal := aDataRows[ 1 ][ j ]
IF ValType( xVal ) == "N"
cType := "N"
nWidth := 18
nDec := 4
ELSEIF ValType( xVal ) == "D"
cType := "D"
nWidth := 8
ELSEIF ValType( xVal ) == "L"
cType := "L"
nWidth := 1
ENDIF
ENDIF
AAdd( aStruct, { PadR( Upper( aFN[ j ] ), 10 ), cType, nWidth, nDec } )
NEXT
/*
* Pure in-memory recursive iteration.
* No temp DBF for the recursive loop — just arrays.
* At the end, write ALL accumulated rows to temp DBF once.
*/
hRecQuery := hSubQ[ "union" ]
nIter := 0
aPrevRows := AClone( aDataRows )
WHILE nIter < 50 .AND. Len( aPrevRows ) > 0
nIter++
aNewRows := {}
IF hRecQuery != NIL .AND. hb_HHasKey( hRecQuery, "columns" )
aCols := hRecQuery[ "columns" ]
/*
* Check if this recursive part has a JOIN (FROM clause with tables).
* If so, perform an in-memory nested-loop JOIN between the external
* table(s) and aPrevRows (the CTE working set from last iteration).
*
* Example: SELECT e.id, e.name FROM employees e JOIN org o ON e.mgr_id = o.id
* "employees" is a real DBF table; "org" is aPrevRows from previous iteration.
*/
IF hb_HHasKey( hRecQuery, "joins" ) .AND. hRecQuery[ "joins" ] != NIL .AND. ;
Len( hRecQuery[ "joins" ] ) > 0
aNewRows := RecCteJoin( hRecQuery, aFN, aPrevRows, cName )
ELSE
/* Simple recursive step (no JOIN): evaluate expressions
* directly against each row in aPrevRows */
FOR j := 1 TO Len( aPrevRows )
lPass := .T.
IF hb_HHasKey( hRecQuery, "where" ) .AND. hRecQuery[ "where" ] != NIL
xWR := SqlEvalRowExpr( hRecQuery[ "where" ], aFN, aPrevRows[ j ] )
lPass := SqlIsTrue( xWR )
ENDIF
IF lPass
aNewRow := {}
FOR k := 1 TO Len( aCols )
xCV := SqlEvalRowExpr( aCols[ k ][ 1 ], aFN, aPrevRows[ j ] )
AAdd( aNewRow, xCV )
NEXT
AAdd( aNewRows, aNewRow )
ENDIF
NEXT
ENDIF
ENDIF
IF Len( aNewRows ) == 0
EXIT
ENDIF
/* Accumulate new rows */
FOR j := 1 TO Len( aNewRows )
AAdd( aDataRows, aNewRows[ j ] )
NEXT
/* New rows become the working set for next iteration */
aPrevRows := AClone( aNewRows )
ENDDO
/* Write ALL accumulated rows to temp DBF once */
cTmpFile := "__cte_" + Lower( cName )
cAlias := Upper( cName )
nExistWA := Select( cAlias )
IF nExistWA > 0
dbSelectArea( nExistWA )
dbCloseArea()
ENDIF
IF hb_FileExists( cTmpFile + ".dbf" )
FErase( cTmpFile + ".dbf" )
ENDIF
BEGIN SEQUENCE
dbCreate( cTmpFile + ".dbf", aStruct )
RECOVER
END SEQUENCE
BEGIN SEQUENCE
USE ( cTmpFile + ".dbf" ) NEW ALIAS ( cAlias )
FOR j := 1 TO Len( aDataRows )
dbAppend()
FOR k := 1 TO Min( Len( aStruct ), Len( aDataRows[ j ] ) )
IF aDataRows[ j ][ k ] != NIL
FieldPut( k, aDataRows[ j ][ k ] )
ENDIF
NEXT
NEXT
dbCommit()
RECOVER
END SEQUENCE
/* Replace table entry to reference CTE temp file.
* Keep alias = cName so the main query finds it by original name. */
lReplaced := .F.
FOR j := 1 TO Len( ::aTables )
IF Upper( ::aTables[ j ][ 1 ] ) == cName
::aTables[ j ][ 1 ] := cTmpFile
IF Empty( ::aTables[ j ][ 2 ] )
::aTables[ j ][ 2 ] := cName
ENDIF
lReplaced := .T.
EXIT
ENDIF
NEXT
IF ! lReplaced
AAdd( ::aTables, { cTmpFile, cName, "" } )
ENDIF
NEXT
RETURN NIL
/* ======================================================================
* Window function evaluation (SQL:2003)
* ====================================================================== */
METHOD ApplyWindowFunctions( aRows, aFN, aCols ) CLASS TSqlExecutor
LOCAL i, j, k, nColIdx, xExpr
LOCAL cFunc, aPartBy, aOrdBy, aFuncArgs
LOCAL hPartitions, cPartKey, aPartIdx
LOCAL aSorted, aIdxMap, nPartCol
LOCAL nRank, nDenseRank, nRowNum
LOCAL xPrev, xCurr, nTies
LOCAL nLagLead, nArgCol, xDefault
LOCAL nRunSum, nRunCount
LOCAL aWinCols, nWC
LOCAL hFrame, nFS, nFE, m, xVal, xMin, xMax, lDefaultFrame
/* Scan for window function columns */
aWinCols := {}
FOR i := 1 TO Len( aCols )
xExpr := aCols[ i ][ 1 ]
IF ValType( xExpr ) == "A" .AND. xExpr[ 1 ] == ND_WINDOW
AAdd( aWinCols, i )
ENDIF
NEXT
IF Len( aWinCols ) == 0 .OR. Len( aRows ) == 0
RETURN NIL
ENDIF
FOR nWC := 1 TO Len( aWinCols )
nColIdx := aWinCols[ nWC ]
xExpr := aCols[ nColIdx ][ 1 ]
cFunc := Upper( xExpr[ 2 ] )
aFuncArgs := xExpr[ 3 ]
aPartBy := xExpr[ 4 ]
aOrdBy := xExpr[ 5 ]
/* Frame spec in optional 6th slot (added by parser) */
hFrame := NIL
IF Len( xExpr ) >= 6
hFrame := xExpr[ 6 ]
ENDIF
/* Build partition groups as arrays of row indices */
hPartitions := { => }
FOR i := 1 TO Len( aRows )
cPartKey := ""
IF ValType( aPartBy ) == "A"
FOR j := 1 TO Len( aPartBy )
nPartCol := SqlFindColIdx( aPartBy[ j ], aFN )
IF nPartCol == 0
nPartCol := SqlFindColIdx2( SqlExprName( aPartBy[ j ] ), aFN )
ENDIF
IF nPartCol > 0 .AND. nPartCol <= Len( aRows[ i ] )
cPartKey += SqlValToStr( aRows[ i ][ nPartCol ] ) + "|"
ENDIF
NEXT
ENDIF
IF ! hb_HHasKey( hPartitions, cPartKey )
hPartitions[ cPartKey ] := {}
ENDIF
AAdd( hPartitions[ cPartKey ], i )
NEXT
/* Process each partition */
FOR EACH aPartIdx IN hb_HValues( hPartitions )
/* Sort partition indices by ORDER BY columns */
IF ValType( aOrdBy ) == "A" .AND. Len( aOrdBy ) > 0
ASort( aPartIdx,,, {|a, b| SqlWinRowCmp( aRows, a, b, aOrdBy, aFN ) < 0 } )
ENDIF
/* Compute window function for each row in the partition */
DO CASE
CASE cFunc == "ROW_NUMBER"
FOR k := 1 TO Len( aPartIdx )
IF nColIdx <= Len( aRows[ aPartIdx[ k ] ] )
aRows[ aPartIdx[ k ] ][ nColIdx ] := k
ENDIF
NEXT
CASE cFunc == "RANK"
nRank := 1
FOR k := 1 TO Len( aPartIdx )
IF k > 1
IF ! SqlWinRowsEqual( aRows, aPartIdx[ k ], aPartIdx[ k - 1 ], aOrdBy, aFN )
nRank := k
ENDIF
ENDIF
IF nColIdx <= Len( aRows[ aPartIdx[ k ] ] )
aRows[ aPartIdx[ k ] ][ nColIdx ] := nRank
ENDIF
NEXT
CASE cFunc == "DENSE_RANK"
nDenseRank := 1
FOR k := 1 TO Len( aPartIdx )
IF k > 1
IF ! SqlWinRowsEqual( aRows, aPartIdx[ k ], aPartIdx[ k - 1 ], aOrdBy, aFN )
nDenseRank++
ENDIF
ENDIF
IF nColIdx <= Len( aRows[ aPartIdx[ k ] ] )
aRows[ aPartIdx[ k ] ][ nColIdx ] := nDenseRank
ENDIF
NEXT
CASE cFunc == "LAG"
nLagLead := 1
IF Len( aFuncArgs ) >= 2 .AND. aFuncArgs[ 2 ][ 1 ] == ND_LIT
nLagLead := Int( SqlCoerceNum( aFuncArgs[ 2 ][ 2 ] ) )
ENDIF
nArgCol := 0
IF Len( aFuncArgs ) >= 1
nArgCol := SqlFindColIdx( aFuncArgs[ 1 ], aFN )
IF nArgCol == 0
nArgCol := SqlFindColIdx2( SqlExprName( aFuncArgs[ 1 ] ), aFN )
ENDIF
ENDIF
xDefault := NIL
IF Len( aFuncArgs ) >= 3 .AND. aFuncArgs[ 3 ][ 1 ] == ND_LIT
xDefault := aFuncArgs[ 3 ][ 2 ]
ENDIF
FOR k := 1 TO Len( aPartIdx )
IF nColIdx <= Len( aRows[ aPartIdx[ k ] ] )
IF k - nLagLead >= 1 .AND. nArgCol > 0 .AND. ;
nArgCol <= Len( aRows[ aPartIdx[ k - nLagLead ] ] )
aRows[ aPartIdx[ k ] ][ nColIdx ] := aRows[ aPartIdx[ k - nLagLead ] ][ nArgCol ]
ELSE
aRows[ aPartIdx[ k ] ][ nColIdx ] := xDefault
ENDIF
ENDIF
NEXT
CASE cFunc == "LEAD"
nLagLead := 1
IF Len( aFuncArgs ) >= 2 .AND. aFuncArgs[ 2 ][ 1 ] == ND_LIT
nLagLead := Int( SqlCoerceNum( aFuncArgs[ 2 ][ 2 ] ) )
ENDIF
nArgCol := 0
IF Len( aFuncArgs ) >= 1
nArgCol := SqlFindColIdx( aFuncArgs[ 1 ], aFN )
IF nArgCol == 0
nArgCol := SqlFindColIdx2( SqlExprName( aFuncArgs[ 1 ] ), aFN )
ENDIF
ENDIF
xDefault := NIL
IF Len( aFuncArgs ) >= 3 .AND. aFuncArgs[ 3 ][ 1 ] == ND_LIT
xDefault := aFuncArgs[ 3 ][ 2 ]
ENDIF
FOR k := 1 TO Len( aPartIdx )
IF nColIdx <= Len( aRows[ aPartIdx[ k ] ] )
IF k + nLagLead <= Len( aPartIdx ) .AND. nArgCol > 0 .AND. ;
nArgCol <= Len( aRows[ aPartIdx[ k + nLagLead ] ] )
aRows[ aPartIdx[ k ] ][ nColIdx ] := aRows[ aPartIdx[ k + nLagLead ] ][ nArgCol ]
ELSE
aRows[ aPartIdx[ k ] ][ nColIdx ] := xDefault
ENDIF
ENDIF
NEXT
CASE cFunc == "SUM" .OR. cFunc == "AVG" .OR. cFunc == "COUNT" .OR. ;
cFunc == "MIN" .OR. cFunc == "MAX"
/* Frame-aware aggregate window functions.
* Default frame (no spec): UNBOUNDED PRECEDING to CURRENT ROW.
* Explicit: ROWS BETWEEN n PRECEDING AND m FOLLOWING, etc. */
nArgCol := 0
IF Len( aFuncArgs ) >= 1
nArgCol := SqlFindColIdx( aFuncArgs[ 1 ], aFN )
IF nArgCol == 0
nArgCol := SqlFindColIdx2( SqlExprName( aFuncArgs[ 1 ] ), aFN )
ENDIF
ENDIF
/* Detect default frame (UNBOUNDED PRECEDING to CURRENT ROW)
* which can use the O(N) incremental running-sum path instead
* of the O(N²) general per-row-frame-aggregate. */
/* Default frame = UNBOUNDED PRECEDING to CURRENT ROW.
* This covers: no frame spec, or explicit ROWS UNBOUNDED
* PRECEDING (without BETWEEN or with implied CURRENT ROW end).
* The incremental O(N) path handles this; the general frame
* loop is only needed for custom boundaries like
* ROWS BETWEEN 6 PRECEDING AND CURRENT ROW. */
lDefaultFrame := .T.
IF hFrame != NIL .AND. ValType( hFrame ) == "H"
IF hb_HHasKey( hFrame, "end" ) .AND. ;
! ( "CURRENT ROW" $ hFrame[ "end" ] )
lDefaultFrame := .F.
ENDIF
IF hb_HHasKey( hFrame, "start" ) .AND. ;
! ( "UNBOUNDED PRECEDING" $ hFrame[ "start" ] )
lDefaultFrame := .F.
ENDIF
ENDIF
IF lDefaultFrame
/* O(N) incremental path — original fast code */
nRunSum := 0
nRunCount := 0
xMin := NIL
xMax := NIL
FOR k := 1 TO Len( aPartIdx )
IF cFunc == "COUNT" .AND. nArgCol == 0
nRunCount++
ELSEIF nArgCol > 0 .AND. nArgCol <= Len( aRows[ aPartIdx[ k ] ] )
xVal := aRows[ aPartIdx[ k ] ][ nArgCol ]
IF xVal != NIL
nRunCount++
nRunSum += SqlCoerceNum( xVal )
IF xMin == NIL .OR. SqlCmpLt( xVal, xMin )
xMin := xVal
ENDIF
IF xMax == NIL .OR. SqlCmpLt( xMax, xVal )
xMax := xVal
ENDIF
ENDIF
ENDIF
IF nColIdx <= Len( aRows[ aPartIdx[ k ] ] )
DO CASE
CASE cFunc == "SUM"
aRows[ aPartIdx[ k ] ][ nColIdx ] := nRunSum
CASE cFunc == "AVG"
aRows[ aPartIdx[ k ] ][ nColIdx ] := iif( nRunCount > 0, nRunSum / nRunCount, NIL )
CASE cFunc == "COUNT"
aRows[ aPartIdx[ k ] ][ nColIdx ] := nRunCount
CASE cFunc == "MIN"
aRows[ aPartIdx[ k ] ][ nColIdx ] := xMin
CASE cFunc == "MAX"
aRows[ aPartIdx[ k ] ][ nColIdx ] := xMax
ENDCASE
ENDIF
NEXT
ELSE
/* General frame path — O(N*W) where W = frame width */
FOR k := 1 TO Len( aPartIdx )
nFS := 1
nFE := k
IF hb_HHasKey( hFrame, "start" )
nFS := SqlFrameOffset( hFrame[ "start" ], k, Len( aPartIdx ) )
ENDIF
IF hb_HHasKey( hFrame, "end" )
nFE := SqlFrameOffset( hFrame[ "end" ], k, Len( aPartIdx ) )
ENDIF
IF nFS < 1
nFS := 1
ENDIF
IF nFE > Len( aPartIdx )
nFE := Len( aPartIdx )
ENDIF
nRunSum := 0
nRunCount := 0
xMin := NIL
xMax := NIL
FOR m := nFS TO nFE
IF cFunc == "COUNT" .AND. nArgCol == 0
/* COUNT(*) */
nRunCount++
ELSEIF nArgCol > 0 .AND. nArgCol <= Len( aRows[ aPartIdx[ m ] ] )
xVal := aRows[ aPartIdx[ m ] ][ nArgCol ]
IF xVal != NIL
nRunCount++
nRunSum += SqlCoerceNum( xVal )
IF xMin == NIL .OR. SqlCmpLt( xVal, xMin )
xMin := xVal
ENDIF
IF xMax == NIL .OR. SqlCmpLt( xMax, xVal )
xMax := xVal
ENDIF
ENDIF
ENDIF
NEXT
IF nColIdx <= Len( aRows[ aPartIdx[ k ] ] )
DO CASE
CASE cFunc == "SUM"
aRows[ aPartIdx[ k ] ][ nColIdx ] := nRunSum
CASE cFunc == "AVG"
aRows[ aPartIdx[ k ] ][ nColIdx ] := iif( nRunCount > 0, nRunSum / nRunCount, NIL )
CASE cFunc == "COUNT"
aRows[ aPartIdx[ k ] ][ nColIdx ] := nRunCount
CASE cFunc == "MIN"
aRows[ aPartIdx[ k ] ][ nColIdx ] := xMin
CASE cFunc == "MAX"
aRows[ aPartIdx[ k ] ][ nColIdx ] := xMax
ENDCASE
ENDIF
NEXT
ENDIF /* lDefaultFrame */
ENDCASE
NEXT
NEXT
RETURN NIL
/* ======================================================================
* TRUNCATE TABLE executor
* ====================================================================== */
METHOD RunTruncate() CLASS TSqlExecutor
LOCAL cTable, nWA
cTable := ::hQuery[ "table" ]
/* Close if open */
nWA := Select( cTable )
IF nWA > 0
dbSelectArea( nWA )
dbCloseArea()
ENDIF
BEGIN SEQUENCE
USE ( Lower( cTable ) + ".dbf" ) NEW EXCLUSIVE
dbGoTop()
WHILE ! Eof()
dbDelete()
dbSkip()
ENDDO
dbCloseArea()
RECOVER
RETURN ::MakeError( SQL_ERR_LOCKED, "TRUNCATE TABLE failed: " + cTable )
END SEQUENCE
RETURN { { "result" }, { { "Table " + cTable + " truncated" } } }
/* ======================================================================
* MERGE (UPSERT) executor (SQL:2003)
* ====================================================================== */
METHOD RunMerge() CLASS TSqlExecutor
LOCAL cTarget, cSource, cSrcAlias, xOnCond
LOCAL aUpdSet, aInsFlds, aInsVals
LOCAL lHasMatched, lHasNotMatched
LOCAL nSrcWA, nTgtWA, nSaved, nAffected
LOCAL lMatched, i, nFPos, xVal
cTarget := ::hQuery[ "target" ]
cSource := ::hQuery[ "source" ]
cSrcAlias := ""
IF hb_HHasKey( ::hQuery, "source_alias" )
cSrcAlias := ::hQuery[ "source_alias" ]
ENDIF
xOnCond := ::hQuery[ "on" ]
aUpdSet := ::hQuery[ "update_set" ]
aInsFlds := ::hQuery[ "insert_fields" ]
aInsVals := ::hQuery[ "insert_values" ]
lHasMatched := ::hQuery[ "has_matched" ]
lHasNotMatched := ::hQuery[ "has_not_matched" ]
nAffected := 0
::aTables := { { cTarget, cTarget, "" }, { cSource, iif( Empty( cSrcAlias ), cSource, cSrcAlias ), "" } }
/* Open source */
nSrcWA := Select( iif( Empty( cSrcAlias ), cSource, cSrcAlias ) )
IF nSrcWA == 0
BEGIN SEQUENCE
dbUseArea( .T., "DBFNTX", Lower( cSource ) + ".dbf", ;
iif( Empty( cSrcAlias ), cSource, cSrcAlias ), .T., .T. )
RECOVER
RETURN ::MakeError( SQL_ERR_NO_TABLE, "MERGE: cannot open source " + cSource )
END SEQUENCE
ENDIF
nSrcWA := Select( iif( Empty( cSrcAlias ), cSource, cSrcAlias ) )
/* Open target */
nTgtWA := Select( cTarget )
IF nTgtWA == 0
BEGIN SEQUENCE
dbUseArea( .T., "DBFNTX", Lower( cTarget ) + ".dbf", cTarget, .F., .F. )
RECOVER
RETURN ::MakeError( SQL_ERR_NO_TABLE, "MERGE: cannot open target " + cTarget )
END SEQUENCE
ENDIF
nTgtWA := Select( cTarget )
nSaved := Select()
/* For each source row */
dbSelectArea( nSrcWA )
dbGoTop()
DO WHILE ! Eof()
lMatched := .F.
/* Scan target for match */
dbSelectArea( nTgtWA )
dbGoTop()
DO WHILE ! Eof()
IF SqlIsTrue( ::EvalExpr( xOnCond ) )
lMatched := .T.
EXIT
ENDIF
dbSkip()
ENDDO
IF lMatched .AND. lHasMatched
/* UPDATE matched row */
dbSelectArea( nTgtWA )
IF dbRLock( RecNo() )
FOR i := 1 TO Len( aUpdSet )
nFPos := FieldPos( aUpdSet[ i ][ 1 ] )
IF nFPos > 0
xVal := ::EvalExpr( aUpdSet[ i ][ 2 ] )
FieldPut( nFPos, xVal )
ENDIF
NEXT
dbRUnlock( RecNo() )
nAffected++
ENDIF
ELSEIF ! lMatched .AND. lHasNotMatched
/* INSERT new row */
dbSelectArea( nTgtWA )
dbAppend()
IF Len( aInsFlds ) > 0
FOR i := 1 TO Min( Len( aInsFlds ), Len( aInsVals ) )
nFPos := FieldPos( aInsFlds[ i ] )
IF nFPos > 0
xVal := ::EvalExpr( aInsVals[ i ] )
FieldPut( nFPos, xVal )
ENDIF
NEXT
ELSE
FOR i := 1 TO Min( FCount(), Len( aInsVals ) )
xVal := ::EvalExpr( aInsVals[ i ] )
FieldPut( i, xVal )
NEXT
ENDIF
nAffected++
ENDIF
dbSelectArea( nSrcWA )
dbSkip()
ENDDO
dbSelectArea( nTgtWA )
dbCommit()
dbSelectArea( nSaved )
RETURN { { "affected_rows" }, { { nAffected } } }
/* ======================================================================
* Window function helper: compare two rows by ORDER BY columns
* ====================================================================== */
/* Convert a parsed frame bound string into an absolute row index.
* cBound examples: "UNBOUNDED PRECEDING", "3 PRECEDING", "CURRENT ROW",
* "2 FOLLOWING", "UNBOUNDED FOLLOWING".
* nCurr = 1-based position of the current row within the partition.
* nPartLen = total rows in the partition. */
FUNCTION SqlFrameOffset( cBound, nCurr, nPartLen )
LOCAL nV
IF cBound == NIL .OR. Empty( cBound )
RETURN nCurr
ENDIF
IF "UNBOUNDED PRECEDING" $ cBound
RETURN 1
ENDIF
IF "UNBOUNDED FOLLOWING" $ cBound
RETURN nPartLen
ENDIF
IF "CURRENT ROW" $ cBound
RETURN nCurr
ENDIF
IF "PRECEDING" $ cBound
nV := Val( cBound )
RETURN Max( 1, nCurr - nV )
ENDIF
IF "FOLLOWING" $ cBound
nV := Val( cBound )
RETURN Min( nPartLen, nCurr + nV )
ENDIF
RETURN nCurr
FUNCTION SqlWinRowCmp( aRows, nIdxA, nIdxB, aOrdBy, aFN )
LOCAL i, nCol, cDir, xA, xB
FOR i := 1 TO Len( aOrdBy )
nCol := SqlFindColIdx( aOrdBy[ i ][ 1 ], aFN )
IF nCol == 0
nCol := SqlFindColIdx2( SqlExprName( aOrdBy[ i ][ 1 ] ), aFN )
ENDIF
cDir := aOrdBy[ i ][ 2 ]
IF nCol > 0 .AND. nCol <= Len( aRows[ nIdxA ] ) .AND. nCol <= Len( aRows[ nIdxB ] )
xA := aRows[ nIdxA ][ nCol ]
xB := aRows[ nIdxB ][ nCol ]
IF xA == NIL .AND. xB == NIL
LOOP
ENDIF
IF xA == NIL
RETURN iif( cDir == "DESC", -1, 1 )
ENDIF
IF xB == NIL
RETURN iif( cDir == "DESC", 1, -1 )
ENDIF
IF ValType( xA ) == ValType( xB )
IF xA < xB
RETURN iif( cDir == "DESC", 1, -1 )
ELSEIF xA > xB
RETURN iif( cDir == "DESC", -1, 1 )
ENDIF
ENDIF
ENDIF
NEXT
RETURN 0
/* Check if two rows have equal values for ORDER BY columns */
FUNCTION SqlWinRowsEqual( aRows, nIdxA, nIdxB, aOrdBy, aFN )
LOCAL i, nCol, xA, xB
FOR i := 1 TO Len( aOrdBy )
nCol := SqlFindColIdx( aOrdBy[ i ][ 1 ], aFN )
IF nCol == 0
nCol := SqlFindColIdx2( SqlExprName( aOrdBy[ i ][ 1 ] ), aFN )
ENDIF
IF nCol > 0 .AND. nCol <= Len( aRows[ nIdxA ] ) .AND. nCol <= Len( aRows[ nIdxB ] )
xA := aRows[ nIdxA ][ nCol ]
xB := aRows[ nIdxB ][ nCol ]
IF ! SqlCmpEq( xA, xB )
RETURN .F.
ENDIF
ENDIF
NEXT
RETURN .T.
/*
* RecCteJoin — In-memory nested-loop JOIN for recursive CTE.
*
* The recursive part of a CTE may reference both a real DBF table and the
* CTE itself. Example:
* SELECT e.id, e.name FROM employees e JOIN org o ON e.mgr_id = o.id
*
* "employees" (alias e) is a DBF table on disk.
* "org" (alias o) is the CTE — represented by aPrevRows from the previous iteration.
*
* This function:
* 1. Identifies which FROM table is the CTE and which is the real DBF
* 2. Opens the DBF and reads all records into memory
* 3. Performs a nested-loop JOIN: for each DBF row x CTE row, checks ON condition
* 4. Evaluates SELECT columns for matching pairs
* 5. Returns the result rows
*/
STATIC FUNCTION RecCteJoin( hRecQuery, aFN, aPrevRows, cCteName )
LOCAL aCols, aFrom, aJoinFN, aJoinRows
LOCAL aResult, aNewRow
LOCAL i, j, k, nF
LOCAL cTblName, cTblAlias, cCteAlias, cWAAlias
LOCAL nSaveWA, cDbfFile
LOCAL xLeft, xRight, lMatch
LOCAL aJoinOn, aJ
LOCAL xCV
LOCAL aCombFN, aCombRow
LOCAL cDbfKeyCol, cCteKeyCol, nDbfKeyIdx, nCteKeyIdx
LOCAL hCteHash, cKey, aMatches, m
aCols := hRecQuery[ "columns" ]
aResult := {}
/* Identify the real table and the CTE reference.
* tables[]: { tableName, alias, "" }
* joins[]: { joinType, tableName, alias, onCondExpr } */
cTblName := ""
cTblAlias := ""
cCteAlias := ""
IF hb_HHasKey( hRecQuery, "tables" )
aFrom := hRecQuery[ "tables" ]
FOR i := 1 TO Len( aFrom )
IF Upper( aFrom[ i ][ 1 ] ) == Upper( cCteName )
cCteAlias := Upper( aFrom[ i ][ 2 ] )
IF Empty( cCteAlias )
cCteAlias := Upper( cCteName )
ENDIF
ELSE
cTblName := aFrom[ i ][ 1 ]
cTblAlias := Upper( aFrom[ i ][ 2 ] )
IF Empty( cTblAlias )
cTblAlias := Upper( cTblName )
ENDIF
ENDIF
NEXT
ENDIF
/* Also check the joins array for the CTE or real table */
aJoinOn := NIL
IF hb_HHasKey( hRecQuery, "joins" )
FOR i := 1 TO Len( hRecQuery[ "joins" ] )
aJ := hRecQuery[ "joins" ][ i ]
/* aJ = { joinType, tableName, alias, onCondExpr } */
IF Upper( aJ[ 2 ] ) == Upper( cCteName )
IF ! Empty( aJ[ 3 ] )
cCteAlias := Upper( aJ[ 3 ] )
ELSE
cCteAlias := Upper( cCteName )
ENDIF
ELSE
IF Empty( cTblName )
cTblName := aJ[ 2 ]
cTblAlias := Upper( aJ[ 3 ] )
IF Empty( cTblAlias )
cTblAlias := Upper( cTblName )
ENDIF
ENDIF
ENDIF
IF Len( aJ ) >= 4 .AND. aJ[ 4 ] != NIL
aJoinOn := aJ[ 4 ]
ENDIF
NEXT
ENDIF
IF Empty( cTblName )
RETURN aResult
ENDIF
/* Read all records from the real DBF table into memory */
aJoinRows := {}
aJoinFN := {}
nSaveWA := Select()
/* Always open the table fresh to avoid workarea conflicts.
* The anchor query may have closed it. */
cDbfFile := Lower( cTblName )
IF ! ( ".dbf" $ cDbfFile )
cDbfFile := cDbfFile + ".dbf"
ENDIF
s_nRCJSeq := ( s_nRCJSeq + 1 ) % 100000
cWAAlias := "RCJ_" + hb_ntos( s_nRCJSeq )
BEGIN SEQUENCE
USE ( cDbfFile ) NEW SHARED ALIAS ( cWAAlias )
RECOVER
dbSelectArea( nSaveWA )
RETURN aResult
END SEQUENCE
/* Collect field names */
FOR nF := 1 TO FCount()
AAdd( aJoinFN, Upper( FieldName( nF ) ) )
NEXT
/* Read all records */
dbGoTop()
WHILE ! Eof()
aNewRow := {}
FOR nF := 1 TO FCount()
AAdd( aNewRow, FieldGet( nF ) )
NEXT
AAdd( aJoinRows, aNewRow )
dbSkip()
ENDDO
/* Build combined field name list:
* [tblAlias.field1, tblAlias.field2, ..., cteAlias.field1, cteAlias.field2, ...]
* Then also plain names for expression resolution */
aCombFN := {}
FOR nF := 1 TO Len( aJoinFN )
AAdd( aCombFN, cTblAlias + "." + aJoinFN[ nF ] )
NEXT
FOR nF := 1 TO Len( aFN )
AAdd( aCombFN, cCteAlias + "." + Upper( aFN[ nF ] ) )
NEXT
/* Also add unqualified names for both sides */
FOR nF := 1 TO Len( aJoinFN )
IF AScan( aCombFN, {|x| x == aJoinFN[ nF ] } ) == 0
AAdd( aCombFN, aJoinFN[ nF ] )
ENDIF
NEXT
FOR nF := 1 TO Len( aFN )
IF AScan( aCombFN, {|x| x == Upper( aFN[ nF ] ) } ) == 0
AAdd( aCombFN, Upper( aFN[ nF ] ) )
ENDIF
NEXT
/* Try to extract a simple equi-join key from aJoinOn so we can use
* hash probing instead of O(m*n) nested loops. This is the dominant
* cost for WITH RECURSIVE hierarchy traversals where aJoinRows is
* the full DBF (hundreds/thousands of rows) and aPrevRows is the
* current frontier set.
*
* Looks for ON condition of shape `dbfAlias.col = cteAlias.col` or
* the reverse — anything more complex falls through to nested loop. */
cDbfKeyCol := ""
cCteKeyCol := ""
IF aJoinOn != NIL .AND. ValType( aJoinOn ) == "A" .AND. Len( aJoinOn ) >= 4 .AND. ;
aJoinOn[ 1 ] == ND_BIN .AND. aJoinOn[ 2 ] == "=" .AND. ;
aJoinOn[ 3 ] != NIL .AND. aJoinOn[ 3 ][ 1 ] == ND_COL .AND. ;
aJoinOn[ 4 ] != NIL .AND. aJoinOn[ 4 ][ 1 ] == ND_COL
/* Split alias.col on both sides */
cKey := Upper( aJoinOn[ 3 ][ 2 ] )
IF "." $ cKey .AND. Left( cKey, At( ".", cKey ) - 1 ) == cCteAlias
cCteKeyCol := SubStr( cKey, At( ".", cKey ) + 1 )
cKey := Upper( aJoinOn[ 4 ][ 2 ] )
IF "." $ cKey
cDbfKeyCol := SubStr( cKey, At( ".", cKey ) + 1 )
ELSE
cDbfKeyCol := cKey
ENDIF
ELSE
cKey := Upper( aJoinOn[ 4 ][ 2 ] )
IF "." $ cKey .AND. Left( cKey, At( ".", cKey ) - 1 ) == cCteAlias
cCteKeyCol := SubStr( cKey, At( ".", cKey ) + 1 )
cKey := Upper( aJoinOn[ 3 ][ 2 ] )
IF "." $ cKey
cDbfKeyCol := SubStr( cKey, At( ".", cKey ) + 1 )
ELSE
cDbfKeyCol := cKey
ENDIF
ENDIF
ENDIF
ENDIF
nDbfKeyIdx := 0
nCteKeyIdx := 0
IF ! Empty( cDbfKeyCol ) .AND. ! Empty( cCteKeyCol )
FOR nF := 1 TO Len( aJoinFN )
IF aJoinFN[ nF ] == cDbfKeyCol
nDbfKeyIdx := nF
EXIT
ENDIF
NEXT
FOR nF := 1 TO Len( aFN )
IF Upper( aFN[ nF ] ) == cCteKeyCol
nCteKeyIdx := nF
EXIT
ENDIF
NEXT
ENDIF
IF nDbfKeyIdx > 0 .AND. nCteKeyIdx > 0
/* Hash-probe path: build hash on aPrevRows keyed by cte column,
* then scan aJoinRows and probe. Sub-linear vs nested loop. */
hCteHash := { => }
FOR j := 1 TO Len( aPrevRows )
cKey := SqlValToStr( aPrevRows[ j ][ nCteKeyIdx ] )
IF ! hb_HHasKey( hCteHash, cKey )
hCteHash[ cKey ] := {}
ENDIF
AAdd( hCteHash[ cKey ], j )
NEXT
FOR i := 1 TO Len( aJoinRows )
cKey := SqlValToStr( aJoinRows[ i ][ nDbfKeyIdx ] )
IF ! hb_HHasKey( hCteHash, cKey )
LOOP
ENDIF
aMatches := hCteHash[ cKey ]
FOR m := 1 TO Len( aMatches )
j := aMatches[ m ]
aCombRow := {}
FOR nF := 1 TO Len( aJoinFN )
AAdd( aCombRow, aJoinRows[ i ][ nF ] )
NEXT
FOR nF := 1 TO Len( aFN )
AAdd( aCombRow, aPrevRows[ j ][ nF ] )
NEXT
FOR nF := 1 TO Len( aJoinFN )
AAdd( aCombRow, aJoinRows[ i ][ nF ] )
NEXT
FOR nF := 1 TO Len( aFN )
AAdd( aCombRow, aPrevRows[ j ][ nF ] )
NEXT
aNewRow := {}
FOR k := 1 TO Len( aCols )
xCV := SqlEvalRowExpr( aCols[ k ][ 1 ], aCombFN, aCombRow )
AAdd( aNewRow, xCV )
NEXT
AAdd( aResult, aNewRow )
NEXT
NEXT
ELSE
/* Fallback: nested-loop JOIN for complex ON predicates */
FOR i := 1 TO Len( aJoinRows )
FOR j := 1 TO Len( aPrevRows )
aCombRow := {}
FOR nF := 1 TO Len( aJoinFN )
AAdd( aCombRow, aJoinRows[ i ][ nF ] )
NEXT
FOR nF := 1 TO Len( aFN )
AAdd( aCombRow, aPrevRows[ j ][ nF ] )
NEXT
FOR nF := 1 TO Len( aJoinFN )
AAdd( aCombRow, aJoinRows[ i ][ nF ] )
NEXT
FOR nF := 1 TO Len( aFN )
AAdd( aCombRow, aPrevRows[ j ][ nF ] )
NEXT
lMatch := .T.
IF aJoinOn != NIL
xLeft := SqlEvalRowExpr( aJoinOn, aCombFN, aCombRow )
lMatch := SqlIsTrue( xLeft )
ENDIF
IF lMatch
aNewRow := {}
FOR k := 1 TO Len( aCols )
xCV := SqlEvalRowExpr( aCols[ k ][ 1 ], aCombFN, aCombRow )
AAdd( aNewRow, xCV )
NEXT
AAdd( aResult, aNewRow )
ENDIF
NEXT
NEXT
ENDIF
/* Close the workarea we opened */
IF ! Empty( cWAAlias )
dbSelectArea( Select( cWAAlias ) )
dbCloseArea()
ENDIF
dbSelectArea( nSaveWA )
RETURN aResult
/* --------------------------------------------------------------
* Go fast-path helpers
* Return non-NIL only when the query can be handed off to Go's
* SqlScan RTL. Any complexity (expressions, functions, joins,
* parameters in WHERE) → return NIL so the PRG loop takes over.
* -------------------------------------------------------------- */
/* TryGoJoin — attempt to hand a multi-table equi-join to Go's
* SqlHashJoin RTL. Returns the result array on success, NIL if the
* query shape doesn't fit (non-equi ON, complex SELECT exprs, etc.)
* and the caller should fall back to the PRG JoinRecurse path.
*
* Conditions for the fast path:
* - All joins are equi-joins on single columns (ND_BIN "=")
* - All SELECT columns are plain ND_COL field refs
* - No WHERE clause (WHERE is NIL)
*/
/* Build {nColIdx, lDesc} spec array for Go SqlOrderBy.
* Returns NIL if any ORDER BY expression can't be resolved to a
* simple column index (complex expressions → PRG fallback). */
METHOD TryBuildSortSpec( aOrderBy, aFieldNames ) CLASS TSqlExecutor
LOCAL aSpec := {}, i, j, xE, cName, nCol, cDir, nDot
FOR i := 1 TO Len( aOrderBy )
xE := aOrderBy[ i ][ 1 ]
cDir := Upper( aOrderBy[ i ][ 2 ] )
IF xE == NIL .OR. xE[ 1 ] != ND_COL
RETURN NIL
ENDIF
cName := Upper( xE[ 2 ] )
nDot := At( ".", cName )
IF nDot > 0
cName := SubStr( cName, nDot + 1 )
ENDIF
/* Find column index in aFieldNames */
nCol := 0
FOR j := 1 TO Len( aFieldNames )
IF Upper( aFieldNames[ j ] ) == cName .OR. ;
( "." $ aFieldNames[ j ] .AND. ;
Upper( SubStr( aFieldNames[ j ], At( ".", aFieldNames[ j ] ) + 1 ) ) == cName )
nCol := j
EXIT
ENDIF
NEXT
IF nCol == 0
RETURN NIL
ENDIF
AAdd( aSpec, { nCol, cDir == "DESC" } )
NEXT
RETURN aSpec
METHOD TryGoJoin( aJoins, aResultExprs, nOuterWA ) CLASS TSqlExecutor
LOCAL i, xE, xOnCond, cInnerAlias, cInnerField, cOuterField
LOCAL nInnerWA, nInnerFPos, nOuterFPos, nWA
LOCAL aJoinSpecs := {}, aSelectFields := {}
LOCAL cRef, nDot, cAlias, cField
LOCAL aGoRows
/* Build join specs: { nInnerWA, nInnerKeyField, nOuterKeyField } */
FOR i := 1 TO Len( aJoins )
xOnCond := aJoins[ i ][ 4 ]
/* Only support simple equi-join */
IF xOnCond == NIL .OR. xOnCond[ 1 ] != ND_BIN .OR. xOnCond[ 2 ] != "="
RETURN NIL
ENDIF
IF xOnCond[ 3 ] == NIL .OR. xOnCond[ 3 ][ 1 ] != ND_COL .OR. ;
xOnCond[ 4 ] == NIL .OR. xOnCond[ 4 ][ 1 ] != ND_COL
RETURN NIL
ENDIF
/* Determine which side is inner vs outer */
cInnerAlias := aJoins[ i ][ 3 ]
IF Empty( cInnerAlias )
cInnerAlias := aJoins[ i ][ 2 ]
ENDIF
IF ::ColBelongsTo( xOnCond[ 4 ][ 2 ], cInnerAlias )
cInnerField := xOnCond[ 4 ][ 2 ]
cOuterField := xOnCond[ 3 ][ 2 ]
ELSEIF ::ColBelongsTo( xOnCond[ 3 ][ 2 ], cInnerAlias )
cInnerField := xOnCond[ 3 ][ 2 ]
cOuterField := xOnCond[ 4 ][ 2 ]
ELSE
RETURN NIL
ENDIF
/* Resolve workarea + field positions */
nInnerWA := ::FindWA( Upper( cInnerAlias ) )
IF nInnerWA <= 0
RETURN NIL
ENDIF
dbSelectArea( nInnerWA )
cField := Upper( cInnerField )
IF "." $ cField
cField := SubStr( cField, At( ".", cField ) + 1 )
ENDIF
nInnerFPos := FieldPos( cField )
IF nInnerFPos == 0
RETURN NIL
ENDIF
/* Outer field — resolve in parent table */
cField := Upper( cOuterField )
nDot := At( ".", cField )
IF nDot > 0
cAlias := Left( cField, nDot - 1 )
cField := SubStr( cField, nDot + 1 )
nWA := ::FindWA( cAlias )
ELSE
nWA := nOuterWA
ENDIF
IF nWA <= 0
RETURN NIL
ENDIF
dbSelectArea( nWA )
nOuterFPos := FieldPos( cField )
IF nOuterFPos == 0
RETURN NIL
ENDIF
AAdd( aJoinSpecs, { nInnerWA, nInnerFPos, nOuterFPos } )
NEXT
/* Build select field specs: { nWA, nFieldPos } for each result column.
* Aggregate columns (ND_FN) get a {0, 0} placeholder — their values
* will be filled later by ComputeAgg during GROUP BY processing.
* This lets the Go fast path handle aggregate queries where the
* raw data columns (hidden) are plain ND_COL refs. */
FOR i := 1 TO Len( aResultExprs )
xE := aResultExprs[ i ][ 1 ]
IF xE == NIL .OR. xE[ 2 ] == "*"
RETURN NIL
ENDIF
IF xE[ 1 ] == ND_FN .OR. xE[ 1 ] == ND_WINDOW
/* Aggregate/window placeholder — Go returns 0, PRG fills later */
AAdd( aSelectFields, { 0, 0 } )
LOOP
ENDIF
IF xE[ 1 ] != ND_COL
RETURN NIL
ENDIF
cRef := xE[ 2 ]
nDot := At( ".", cRef )
IF nDot > 0
cAlias := Upper( Left( cRef, nDot - 1 ) )
cField := Upper( SubStr( cRef, nDot + 1 ) )
nWA := ::FindWA( cAlias )
ELSE
cField := Upper( cRef )
nWA := nOuterWA
ENDIF
IF nWA <= 0
RETURN NIL
ENDIF
dbSelectArea( nWA )
nOuterFPos := FieldPos( cField )
IF nOuterFPos == 0
RETURN NIL
ENDIF
AAdd( aSelectFields, { nWA, nOuterFPos } )
NEXT
/* Call Go-native hash join */
aGoRows := SqlHashJoin( aJoinSpecs, aSelectFields, nOuterWA )
RETURN aGoRows
METHOD TryBuildFieldPositions( aExprs ) CLASS TSqlExecutor
LOCAL aPositions := {}, i, xE, cRef, nDot, cField, nFPos
FOR i := 1 TO Len( aExprs )
xE := aExprs[ i ][ 1 ]
IF xE == NIL .OR. xE[ 1 ] != ND_COL .OR. xE[ 2 ] == "*"
RETURN NIL
ENDIF
cRef := xE[ 2 ]
nDot := At( ".", cRef )
IF nDot > 0
cField := Upper( SubStr( cRef, nDot + 1 ) )
ELSE
cField := Upper( cRef )
ENDIF
nFPos := FieldPos( cField )
IF nFPos == 0
RETURN NIL
ENDIF
AAdd( aPositions, nFPos )
NEXT
RETURN aPositions
METHOD TryCompileWhere( xWhere ) CLASS TSqlExecutor
/* Phase 1+2: compile numeric/logical/string WHERE to pcode.
* Semantic guard: SqlExprToPrg returns NIL for anything that would
* drift from SqlCmpEq/SqlCoerceForCmp semantics. CHAR columns are
* auto-wrapped with AllTrim() to match Harbour SqlCmpEq behavior.
* NULL/function/subquery/parameter → NIL (fallback).
*/
LOCAL cPrg, xResult
IF xWhere == NIL
RETURN NIL
ENDIF
/* Cache struct once for field-type lookups during expr walk */
::aCompileStruct := dbStruct()
cPrg := ::SqlExprToPrg( xWhere )
::aCompileStruct := NIL
IF cPrg == NIL
RETURN NIL
ENDIF
xResult := PcCompile( cPrg )
RETURN xResult
METHOD SqlExprToPrg( xNode ) CLASS TSqlExecutor
LOCAL cOp, cL, cR
LOCAL cRef, nDot, cField, nFPos, cFType, cLit
IF xNode == NIL
RETURN NIL
ENDIF
DO CASE
CASE xNode[ 1 ] == ND_LIT
IF ValType( xNode[ 2 ] ) == "N"
RETURN AllTrim( Str( xNode[ 2 ] ) )
ENDIF
IF ValType( xNode[ 2 ] ) == "L"
IF xNode[ 2 ]
RETURN ".T."
ENDIF
RETURN ".F."
ENDIF
IF ValType( xNode[ 2 ] ) == "C"
cLit := xNode[ 2 ]
/* Reject strings with embedded quotes — escaping would be ambiguous */
IF "'" $ cLit .OR. '"' $ cLit .OR. Chr(10) $ cLit .OR. Chr(13) $ cLit
RETURN NIL
ENDIF
/* Match SqlCmpEq: compare trimmed values */
RETURN "'" + AllTrim( cLit ) + "'"
ENDIF
/* Dates/datetimes deferred */
RETURN NIL
CASE xNode[ 1 ] == ND_COL
cRef := xNode[ 2 ]
IF cRef == "*"
RETURN NIL
ENDIF
nDot := At( ".", cRef )
IF nDot > 0
cField := Upper( SubStr( cRef, nDot + 1 ) )
ELSE
cField := Upper( cRef )
ENDIF
nFPos := FieldPos( cField )
IF nFPos == 0
RETURN NIL
ENDIF
/* Look up field type from cached struct to decide AllTrim wrap */
cFType := ""
IF ::aCompileStruct != NIL .AND. nFPos <= Len( ::aCompileStruct )
cFType := ::aCompileStruct[ nFPos ][ 2 ]
ENDIF
IF cFType == "C"
RETURN "AllTrim(FieldGet(" + AllTrim( Str( nFPos ) ) + "))"
ENDIF
RETURN "FieldGet(" + AllTrim( Str( nFPos ) ) + ")"
CASE xNode[ 1 ] == ND_UNI
cOp := xNode[ 2 ]
cL := ::SqlExprToPrg( xNode[ 3 ] )
IF cL == NIL
RETURN NIL
ENDIF
IF cOp == "NOT"
RETURN "!(" + cL + ")"
ENDIF
IF cOp == "-"
RETURN "-(" + cL + ")"
ENDIF
RETURN NIL
CASE xNode[ 1 ] == ND_BIN
cOp := xNode[ 2 ]
cL := ::SqlExprToPrg( xNode[ 3 ] )
IF cL == NIL
RETURN NIL
ENDIF
cR := ::SqlExprToPrg( xNode[ 4 ] )
IF cR == NIL
RETURN NIL
ENDIF
DO CASE
CASE cOp == "=" .OR. cOp == "=="
RETURN "(" + cL + ")==(" + cR + ")"
CASE cOp == "<>" .OR. cOp == "!="
RETURN "(" + cL + ")!=(" + cR + ")"
CASE cOp == "<"
RETURN "(" + cL + ")<(" + cR + ")"
CASE cOp == "<="
RETURN "(" + cL + ")<=(" + cR + ")"
CASE cOp == ">"
RETURN "(" + cL + ")>(" + cR + ")"
CASE cOp == ">="
RETURN "(" + cL + ")>=(" + cR + ")"
CASE cOp == "AND"
RETURN "(" + cL + ").AND.(" + cR + ")"
CASE cOp == "OR"
RETURN "(" + cL + ").OR.(" + cR + ")"
CASE cOp == "+"
RETURN "(" + cL + ")+(" + cR + ")"
CASE cOp == "-"
RETURN "(" + cL + ")-(" + cR + ")"
CASE cOp == "*"
RETURN "(" + cL + ")*(" + cR + ")"
CASE cOp == "/"
RETURN "(" + cL + ")/(" + cR + ")"
ENDCASE
RETURN NIL
ENDCASE
RETURN NIL