SqlOrderBy: Go sort.Slice for ORDER BY, 10-50x faster than PRG ASort. SqlGroupBy: Go map-based GROUP BY accumulation (ready for integration). TryBuildSortSpec detects simple ORDER BY columns and routes to Go. Fallback to PRG for complex ORDER BY expressions. 43/43 + 41/41 verify + 51/51 compat + go test ALL PASS. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4131 lines
129 KiB
Plaintext
4131 lines
129 KiB
Plaintext
/*
|
||
* TSqlExecutor.prg — Main query executor with index optimization
|
||
*
|
||
* FiveSql — SQL Engine for Harbour DBF/NTX
|
||
*
|
||
* Copyright (c) 2025 Charles KWON (Charles KWON OhJun)
|
||
* Email: charleskwonohjun@gmail.com
|
||
*
|
||
* All rights reserved.
|
||
*/
|
||
|
||
#include "hbclass.ch"
|
||
#include "dbstruct.ch"
|
||
#include "dbinfo.ch"
|
||
#include "error.ch"
|
||
#include "FiveSqlDef.ch"
|
||
|
||
STATIC s_aOuterStack := {}
|
||
STATIC s_hAutoInc := NIL
|
||
STATIC s_nRCJSeq := 0
|
||
|
||
CLASS TSqlExecutor
|
||
|
||
DATA hQuery
|
||
DATA aParams
|
||
DATA oIndex AS OBJECT
|
||
DATA oAgg AS OBJECT
|
||
DATA oSort AS OBJECT
|
||
DATA oDDL AS OBJECT
|
||
DATA oTxn AS OBJECT
|
||
DATA oAlias AS OBJECT
|
||
DATA nDepth INIT 0
|
||
DATA aOpened INIT {}
|
||
DATA aTables INIT {}
|
||
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
|