/* * 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