Files
five/_FiveSql2/src/TSqlExecutor.prg
Charles KWON OhJun 486e466592 feat: FiveSql2 43/43, @byref, mutable closure, RTL 479, DateTime fix
Major changes since last commit:
- FiveSql2 SQL:1999 engine (10,458 LOC) — 43/43 ALL PASS
- 21 compiler/runtime bugs fixed (short-circuit AND/OR, FOR LOOP, etc.)
- @byref pass-by-reference via RefCell pattern
- Mutable closure capture (EnsureLocalRef + RefCell sharing)
- RTL: 400 → 479 functions (+79: file, string, datetime, hash, UTF-8)
- DateTime/Timestamp fully working (hb_DateTime, hb_Hour/Min/Sec, display)
- Reserved word guard (39 keywords blocked from function calls)
- AEval arg order fix (element before index)
- Closure capture redecl fix (unique _cap_ names per block)
- Hash/string indexing in ArrayPush/ArrayPop
- Harbour compat test suite: 51/51
- 4 docs: Porting Report, Implementation Plan, Optimization Plan, Commercialization

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

2734 lines
78 KiB
Plaintext

/*
* 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 {}
CLASSDATA hSubCache INIT { => } SHARED
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 )
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()
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 := {}
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
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
xVal := ::ResolveFromOuter( cRef, cTblAlias, cField )
IF xVal != NIL
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
xVal := ::ResolveFromOuter( cRef, cTblAlias, cField )
IF xVal != NIL
dbSelectArea( nSavedArea )
RETURN xVal
ENDIF
ENDIF
dbSelectArea( nSavedArea )
RETURN NIL
METHOD ResolveFromOuter( cRef, cTblAlias, cField ) CLASS TSqlExecutor
LOCAL i, j, aOuterTbls, cA, nWA, nFPos, xVal, nSavedArea
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 )
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
IF Len( ::aParams ) > 0
/* Use static counter per expression evaluation chain */
xVal := ::aParams[ 1 ]
RETURN xVal
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
/* Use subquery cache for non-correlated subqueries */
IF Len( s_aOuterStack ) == 0
aSubResult := ::CacheSubquery( xR[ 2 ] )
ELSE
nSavedWA := Select()
::PushOuter()
aSubResult := TSqlExecutor():New( xR[ 2 ], ::aParams ):Run()
::PopOuter()
dbSelectArea( nSavedWA )
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 ] )
IF cOp == "IS NULL"
RETURN xL == NIL .OR. ( ValType( xL ) == "C" .AND. Empty( AllTrim( xL ) ) )
ELSE
RETURN !( xL == NIL .OR. ( ValType( xL ) == "C" .AND. Empty( AllTrim( xL ) ) ) )
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 must be handled before argument evaluation */
IF xNode[ 2 ] == "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
nSavedWA := Select()
::PushOuter()
aSubResult := TSqlExecutor():New( xNode[ 3 ][ 1 ][ 2 ], ::aParams ):Run()
::PopOuter()
dbSelectArea( nSavedWA )
IF ValType( aSubResult ) == "A" .AND. Len( aSubResult ) >= 2 .AND. ;
ValType( aSubResult[ 2 ] ) == "A"
RETURN Len( aSubResult[ 2 ] ) > 0
ENDIF
RETURN .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
/* Use subquery cache for non-correlated subqueries */
IF Len( s_aOuterStack ) == 0
aSubResult := ::CacheSubquery( xNode[ 2 ] )
ELSE
nSavedWA := Select()
::PushOuter()
aSubResult := TSqlExecutor():New( xNode[ 2 ], ::aParams ):Run()
::PopOuter()
dbSelectArea( nSavedWA )
ENDIF
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
METHOD FetchRow( aExprs ) CLASS TSqlExecutor
LOCAL aRow := {}, i, xVal
LOCAL xE, cRef, nDot, nWA, nFPos, cField, cTblAlias, cA
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
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.
cOuterCol := ""
cInnerCol := ""
cInnerField := ""
/* Analyze ON condition for index or hash join optimization */
IF xOnCond != NIL .AND. xOnCond[ 1 ] == ND_BIN .AND. xOnCond[ 2 ] == "="
IF xOnCond[ 3 ] != NIL .AND. xOnCond[ 3 ][ 1 ] == ND_COL .AND. ;
xOnCond[ 4 ] != NIL .AND. xOnCond[ 4 ][ 1 ] == ND_COL
IF ::ColBelongsTo( xOnCond[ 4 ][ 2 ], cJAlias )
cOuterCol := xOnCond[ 3 ][ 2 ]
cInnerCol := xOnCond[ 4 ][ 2 ]
ELSEIF ::ColBelongsTo( xOnCond[ 3 ][ 2 ], cJAlias )
cOuterCol := xOnCond[ 4 ][ 2 ]
cInnerCol := xOnCond[ 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.
::JoinRecurse( aJoins, nIdx + 1, xWhere, aRE, @aRows, hHashTbl )
ENDIF
dbSelectArea( nWA )
dbSkip()
ENDDO
ENDIF
/* LEFT JOIN NULL fill */
IF ! lHadMatch .AND. ( cJoinType == "LEFT" .OR. cJoinType == "FULL" )
IF nIdx >= Len( aJoins )
aRow := ::FetchRowNull( aRE, cJAlias )
IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) )
AAdd( aRows, aRow )
ENDIF
ENDIF
ENDIF
RETURN NIL
METHOD RightJoinPass( aJoins, nJIdx, aRE, aRows ) CLASS TSqlExecutor
LOCAL cJAlias, xOnCond, nWA, nOuterWA, cOuterAlias
LOCAL lMatched, aRow, j
LOCAL cColRef
cJAlias := aJoins[ nJIdx ][ 3 ]
IF Empty( cJAlias )
cJAlias := aJoins[ nJIdx ][ 2 ]
ENDIF
xOnCond := aJoins[ nJIdx ][ 4 ]
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
nOuterWA := Select( cOuterAlias )
IF nOuterWA == 0
RETURN NIL
ENDIF
dbSelectArea( nWA )
dbGoTop()
WHILE ! Eof()
lMatched := .F.
dbSelectArea( nOuterWA )
dbGoTop()
WHILE ! Eof()
IF xOnCond != NIL .AND. SqlIsTrue( ::EvalExpr( xOnCond ) )
lMatched := .T.
EXIT
ENDIF
dbSelectArea( nOuterWA )
dbSkip()
ENDDO
IF ! lMatched
dbSelectArea( nWA )
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
aCols := ::hQuery[ "columns" ]
::aTables := ::hQuery[ "tables" ]
aJoins := ::hQuery[ "joins" ]
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
IF Len( cAlias ) <= 1
::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 * */
IF xExpr[ 1 ] == ND_COL .AND. xExpr[ 2 ] == "*"
aResultExprs := {}
aFieldNames := {}
IF Len( ::aTables ) > 0
cAlias := ::aTables[ 1 ][ 2 ]
IF Empty( cAlias )
cAlias := ::aTables[ 1 ][ 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
ENDIF
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 ] != "*"
cBare := SqlExprName( xArgExpr )
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 all leaf column references and add them as
* hidden result columns so they appear in fetched rows. */
aLeafCols := SqlCollectCols( xArgExpr, NIL )
FOR k := 1 TO Len( aLeafCols )
cBare := aLeafCols[ k ]
lFound := .F.
FOR j := 1 TO Len( aResultExprs )
IF Upper( aResultExprs[ j ][ 2 ] ) == Upper( cBare )
lFound := .T.
EXIT
ENDIF
NEXT
IF ! lFound
AAdd( aResultExprs, { SqlNode( ND_COL, cBare, NIL, NIL, NIL ), 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 := { => }
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
dbSelectArea( nWA )
dbSkip()
ENDDO
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 */
IF Len( aOrderBy ) > 0
IF ! ( nWA > 0 .AND. ::oIndex:MatchOrderByTag( nWA, aOrderBy, aFieldNames ) )
aRows := ::oSort:OrderBy( aRows, aFieldNames, aOrderBy, ::aTables, ::aParams )
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 nTop > 0
nMaxRows := nTop
ENDIF
IF nLimit > 0
nMaxRows := nLimit
ENDIF
IF nMaxRows > 0 .AND. Len( aRows ) > nMaxRows
ASize( aRows, nMaxRows )
ENDIF
/* RIGHT JOIN second pass */
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 */
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
/* 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
::nDepth--
IF Len( aSavedAreas ) > 0
dbSelectArea( aSavedAreas[ 1 ] )
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
lHadMatch := .F.
/* Build hash table once per join (keyed by join index) */
cHashKey := "HJ_" + hb_ntos( nIdx ) + "_" + cInnerField
IF ! hb_HHasKey( hHashTbl, cHashKey )
hHashTbl[ cHashKey ] := { => }
dbSelectArea( nInnerWA )
nFPos := FieldPos( cInnerField )
IF nFPos > 0
dbGoTop()
WHILE ! Eof()
xInnerVal := FieldGet( nFPos )
cValKey := SqlValToStr( xInnerVal )
IF ! hb_HHasKey( hHashTbl[ cHashKey ], cValKey )
hHashTbl[ cHashKey ][ cValKey ] := {}
ENDIF
AAdd( hHashTbl[ cHashKey ][ cValKey ], RecNo() )
dbSkip()
ENDDO
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 ]
FOR i := 1 TO Len( aMatches )
dbSelectArea( nInnerWA )
dbGoto( aMatches[ i ] )
/* Hash key already matched — skip redundant ON re-evaluation for
* simple equi-joins (SQLite: ephemeral table probe is sufficient). */
lHadMatch := .T.
::JoinRecurse( aJoins, nIdx + 1, xWhere, aRE, @aRows, hHashTbl )
NEXT
ENDIF
RETURN lHadMatch
/* Subquery result cache for non-correlated subqueries */
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
/* 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 ]
/* 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"
nArgCol := 0
IF Len( aFuncArgs ) >= 1
nArgCol := SqlFindColIdx( aFuncArgs[ 1 ], aFN )
IF nArgCol == 0
nArgCol := SqlFindColIdx2( SqlExprName( aFuncArgs[ 1 ] ), aFN )
ENDIF
ENDIF
nRunSum := 0
FOR k := 1 TO Len( aPartIdx )
IF nArgCol > 0 .AND. nArgCol <= Len( aRows[ aPartIdx[ k ] ] )
nRunSum += SqlCoerceNum( aRows[ aPartIdx[ k ] ][ nArgCol ] )
ENDIF
IF nColIdx <= Len( aRows[ aPartIdx[ k ] ] )
aRows[ aPartIdx[ k ] ][ nColIdx ] := nRunSum
ENDIF
NEXT
CASE cFunc == "AVG"
nArgCol := 0
IF Len( aFuncArgs ) >= 1
nArgCol := SqlFindColIdx( aFuncArgs[ 1 ], aFN )
IF nArgCol == 0
nArgCol := SqlFindColIdx2( SqlExprName( aFuncArgs[ 1 ] ), aFN )
ENDIF
ENDIF
nRunSum := 0
nRunCount := 0
FOR k := 1 TO Len( aPartIdx )
IF nArgCol > 0 .AND. nArgCol <= Len( aRows[ aPartIdx[ k ] ] )
nRunSum += SqlCoerceNum( aRows[ aPartIdx[ k ] ][ nArgCol ] )
nRunCount++
ENDIF
IF nColIdx <= Len( aRows[ aPartIdx[ k ] ] )
IF nRunCount > 0
aRows[ aPartIdx[ k ] ][ nColIdx ] := nRunSum / nRunCount
ELSE
aRows[ aPartIdx[ k ] ][ nColIdx ] := 0
ENDIF
ENDIF
NEXT
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
* ====================================================================== */
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
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++
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
/* Nested-loop JOIN: dbfRow x cteRow */
FOR i := 1 TO Len( aJoinRows )
FOR j := 1 TO Len( aPrevRows )
/* Build combined row: [dbf fields..., cte fields..., dbf unqualified..., cte unqualified...] */
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
/* Evaluate JOIN ON condition */
lMatch := .T.
IF aJoinOn != NIL
xLeft := SqlEvalRowExpr( aJoinOn, aCombFN, aCombRow )
lMatch := SqlIsTrue( xLeft )
ENDIF
IF lMatch
/* Evaluate SELECT columns */
aNewRow := {}
FOR k := 1 TO Len( aCols )
xCV := SqlEvalRowExpr( aCols[ k ][ 1 ], aCombFN, aCombRow )
AAdd( aNewRow, xCV )
NEXT
AAdd( aResult, aNewRow )
ENDIF
NEXT
NEXT
/* Close the workarea we opened */
IF ! Empty( cWAAlias )
dbSelectArea( Select( cWAAlias ) )
dbCloseArea()
ENDIF
dbSelectArea( nSaveWA )
RETURN aResult