Files
five/_FiveSql2/src/TSqlExecutor.prg
CharlesKWON f4ed42556b checkpoint: season-wide bug fix campaign + infra
Cumulative season's silent-bug hunting (~62 fixes) across the FiveSql2
SQL engine, the Five compiler/runtime, and the hbrdd RDD layer. Saved
as a single checkpoint before refactoring the parser to delegate xBase
command translation to the preprocessor.

Highlights:

FiveSql2 engine (_FiveSql2/src/)
- prefix-glob index attach -> explicit convention (<table>_pk.ntx,
  <table>_uq.ntx, <table>.cdx) — fixes silent multi-row INSERT row-drop
- DROP/CREATE TABLE FErase chain extended (.cdx, .fsc, .fsv, .dbt, .fpt)
- COUNT(DISTINCT col) parsed + aggregated via hSeen hash
- UNION column-count mismatch returns SQL_ERR_GRAMMAR (was silent)
- DISTINCT + ORDER BY hidden-col leak fixed (trim before DISTINCT)
- Derived table FROM (SELECT...) + JOIN right-side derived
- Self-FK CASCADE depth 2+ via SqlGetSingleColPK pre-collect
- LAG/LEAD default arg uses SqlEvalRowExpr (handles -N const exprs)
- DATE literal round-trip validation (Feb 29 non-leap rejected)
- CREATE OR REPLACE VIEW; CREATE VIEW errors on already-exists
- AlterTable type dispatcher comma-wrapped (1-char type "A" no longer
  matches CHARACTER)

Compiler / runtime
- gengo: HB_ -> FV_ prefix on emitted Go function names (Five identity)
- gengo split: emit_block.go, emit_stmt.go, folding.go extracted
- parser/stmtreg.go nudges
- hbrt: debug TUI/CLI restructure (debugcmd, debugkey, termios_*),
  windows debug stubs collapsed
- thread/vm/value/class/pcinterp tightening from panic traces

RDD layer (hbrdd/)
- dbf: null bitmap support (null.go + null_test.go), mmap split
  (mmap_posix.go / mmap_windows.go), byte-level numeric parse
- ntx/cdx: windows mmap parity
- workarea + mem RDD: cross-area state-bleed fixes

RTL (hbrtl/)
- errorlog rewrite with platform-specific FD (errorlog_fd_unix /
  errorlog_fd_other)
- sqlscan, sqlhelpers, indexrtl, datetime extensions

Gates green at checkpoint:
- go test ./...        : PASS
- FiveSql2 SQL:1999    : 43/43
- Harbour compat       : 56/56

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:26:25 +09:00

6190 lines
219 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* TSqlExecutor.prg — Main query executor with index optimization
*
* FiveSql — SQL Engine for Harbour DBF/NTX
*
* Copyright (c) 2025 Charles KWON (Charles KWON OhJun)
* Email: charleskwonohjun@gmail.com
*
* All rights reserved.
*/
#include "hbclass.ch"
#include "dbstruct.ch"
#include "dbinfo.ch"
#include "error.ch"
#include "FiveSqlDef.ch"
STATIC s_aOuterStack := {}
STATIC s_hAutoInc := NIL
STATIC s_nRCJSeq := 0
/* Set .T. the first time CTE cleanup sees a legacy __cte_<name>.dbf
* file on disk, or the legacy DBFNTX open path fires. Profile showed
* the stat loop at ~20% of total CPU otherwise — MEMRDD is the norm
* for CTEs so the .dbf doesn't exist and the stat is pure overhead. */
STATIC s_lCteDiskSeen := .F.
/* Per-plan DML pcode cache. Keyed by the plan-cache key that TFiveSQL
* uses (template key or cSQL text); value is a hash:
* { "set_fpos" => aFPos,
* "set_pc" => aValuePc, — parallel to set_fpos
* "where_pc" => pcWhere | NIL,
* "compiled" => .T. }
* RunUpdate populates on first hit, subsequent calls reuse. Compiled
* pcode depends on the target table's field layout; the plan-cache
* key carries a schema-version prefix (SqlSchemaVer) so DDL
* (ALTER / DROP / CREATE TABLE|INDEX|VIEW) invalidates this cache in
* one bump without iterating the hash. The DML cache piggybacks on
* the same cap as the plan cache (SQL_PLAN_CACHE_MAX from TFiveSQL).
* On overflow (size >= cap) the whole hash is wiped — coarser than
* LRU but we don't have insertion-order tracking on Five hashes. */
#define SQL_DML_PCODE_CACHE_MAX 1000
STATIC s_hDmlPcodeCache := { => }
FUNCTION SqlDmlPcodeCacheReset()
s_hDmlPcodeCache := { => }
RETURN NIL
/* Schema version — bumped by every DDL completion. Used as a prefix
* on all SQL plan-cache / DML-pcode-cache keys so any DDL invalidates
* every plan that referenced the pre-DDL schema, without walking the
* hash. Old entries become unreachable (never looked up again) and
* are collected at process exit; DDL is rare enough in the target
* workload that the bounded leak is acceptable.
*
* STATIC is file-scoped, so cross-file access goes through the
* SqlSchemaVer / SqlBumpSchemaVer top-level functions below. */
STATIC s_nSchemaVer := 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 cCacheKey /* plan-cache key set by TFiveSQL; used for DML pcode cache */
DATA aWrappedWindowCols INIT {} /* SELECT-col indices whose expr wraps ND_WINDOW */
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 RunImpl()
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, aPushByLevel )
METHOD SplitAndClauses( xE, aOut )
METHOD BuildAliasLevelMap( aJoins )
METHOD ClauseMaxLevel( xClause, hAliasToLevel, nDefault )
METHOD EvalPushedAtLevel( aPushByLevel, nIdx )
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, aPushByLevel )
METHOD CacheSubquery( xSubExpr )
METHOD SnapshotAreaRecNos()
METHOD RestoreAreaRecNos( aSnap )
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 PreResolveColumns( xNode )
METHOD PreResolveCol( xNode )
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 aResult, lOldDel
IF ::hQuery == NIL
RETURN ::MakeError( SQL_ERR_SYNTAX, "Empty or invalid SQL" )
ENDIF
/* Save caller's SET DELETED state, force ON for the duration of
* this statement, restore on exit. SQL semantics treat marked-
* deleted rows as absent; forcing it here is required regardless
* of caller state. Restoring prevents a five_SQL() call from
* silently flipping the caller's setting.
* Set index 8 = Harbour SetDeleted slot (HB_SET_DELETED). Literal
* used because Five doesn't ship a set.ch with _SET_DELETED macro. */
lOldDel := Set( 8, .T. )
aResult := ::RunImpl()
Set( 8, lOldDel )
RETURN aResult
METHOD RunImpl() CLASS TSqlExecutor
LOCAL cType, aT, nP2
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 )
ELSEIF ::oDDL:DDL_IsKW( aT, nP2, "OR" ) .AND. ;
::oDDL:DDL_IsKW( aT, nP2 + 1, "REPLACE" ) .AND. ;
::oDDL:DDL_IsKW( aT, nP2 + 2, "VIEW" )
/* `CREATE OR REPLACE VIEW v AS ...` — the OR/REPLACE prefix
* stays in the token stream so CreateView's own consumer
* picks them up. */
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
/* When the WA cache is on, hand lifetime to the cache so CloseOpened
* leaves the mmap alive for the next query. Profile showed
* rtlDbCloseArea + munmap at ~30% of SELECT CPU prior to this branch.
*
* Skip caching when the alias is an AcquireTemp-generated "FA_####"
* token: those change every query (self-joins, nested depth), so
* caching them just leaks entries while delivering zero reuse. */
IF SqlWACacheIsEnabled() .AND. ! ( Left( cAlias, 3 ) == "FA_" )
SqlWACachePut( cAlias, nWA )
ELSE
AAdd( ::aOpened, cAlias )
ENDIF
/* 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
/* PreResolveColumns walks an expression tree and caches {nWA, nFPos}
* on every ND_COL it can statically resolve. Called once per RunSelect
* entry (after tables are open and ::aTables is populated) so the
* per-row EvalExpr hot path can short-circuit Resolve()'s string
* parsing + alias lookup on every ND_COL visit.
*
* ND_SUB subtrees are intentionally skipped — they open their own
* scope at execution time (correlated/uncorrelated subqueries), so
* caching here with the outer query's workarea map would be wrong.
*
* Idempotent: re-invocation overwrites xNode[5] with the current
* resolution, so table reopens between runs (which may shift nWA)
* don't produce stale caches. */
METHOD PreResolveColumns( xNode ) CLASS TSqlExecutor
LOCAL i, j, xChild, nHi
IF xNode == NIL .OR. ValType( xNode ) != "A" .OR. Len( xNode ) < 1
RETURN Self
ENDIF
IF ValType( xNode[ 1 ] ) != "N"
RETURN Self
ENDIF
IF xNode[ 1 ] == ND_COL
::PreResolveCol( xNode )
RETURN Self
ENDIF
IF xNode[ 1 ] == ND_SUB
/* Subquery: leave its columns to resolve at its own scope. */
RETURN Self
ENDIF
/* Recurse into child slots 3..5. Each slot may be either:
* - a single node (array whose [1] is a numeric kind)
* - an array of nodes (CASE arms, FN args, LIST values) */
nHi := Len( xNode )
IF nHi > 5
nHi := 5
ENDIF
FOR i := 3 TO nHi
xChild := xNode[ i ]
IF ValType( xChild ) != "A" .OR. Len( xChild ) == 0
LOOP
ENDIF
IF ValType( xChild[ 1 ] ) == "N"
::PreResolveColumns( xChild )
ELSE
FOR j := 1 TO Len( xChild )
IF ValType( xChild[ j ] ) == "A"
::PreResolveColumns( xChild[ j ] )
ENDIF
NEXT
ENDIF
NEXT
RETURN Self
/* PreResolveCol — attempt to cache {nWA, nFPos} on a single ND_COL
* node. Leaves xNode[5] at NIL when resolution can't be determined
* statically (qualified to an unknown alias, no matching field,
* FIELD-list reference awaiting outer context) — EvalExpr then falls
* back to the full Resolve() path at runtime. */
METHOD PreResolveCol( xNode ) CLASS TSqlExecutor
LOCAL cRef, cField, cTblAlias, nDot, nWA, nFPos, i, cA, nSavedArea
xNode[ 5 ] := NIL /* clear prior cache */
cRef := xNode[ 2 ]
IF ValType( cRef ) != "C" .OR. cRef == "*"
RETURN Self
ENDIF
nDot := At( ".", cRef )
IF nDot > 0
cTblAlias := Upper( Left( cRef, nDot - 1 ) )
cField := Upper( SubStr( cRef, nDot + 1 ) )
ELSE
cField := Upper( cRef )
cTblAlias := ""
ENDIF
nSavedArea := Select()
IF ! Empty( cTblAlias )
nWA := ::FindWA( cTblAlias )
IF nWA > 0
dbSelectArea( nWA )
nFPos := FieldPos( cField )
dbSelectArea( nSavedArea )
IF nFPos > 0
xNode[ 5 ] := { nWA, nFPos }
ENDIF
ENDIF
RETURN Self
ENDIF
/* Unqualified: first table where field exists wins (matches
* Resolve's iteration order). */
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
dbSelectArea( nSavedArea )
xNode[ 5 ] := { nWA, nFPos }
RETURN Self
ENDIF
ENDIF
NEXT
dbSelectArea( nSavedArea )
RETURN Self
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, lSawNull
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
/* Fast path: PreResolveColumns cached {nWA, nFPos} at xNode[5].
* Skips the Resolve() string-parse + alias-lookup on every row. */
IF xNode[ 5 ] != NIL
nSavedWA := Select()
dbSelectArea( xNode[ 5 ][ 1 ] )
xVal := FieldGet( xNode[ 5 ][ 2 ] )
dbSelectArea( nSavedWA )
RETURN xVal
ENDIF
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"
/* SQL three-valued logic: NOT(NULL) = NULL.
* Critical for NOT IN with a NULL in the list. */
IF xL == NIL
RETURN NIL
ENDIF
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 — SQL three-valued logic.
*
* x IN (a, b, ...) TRUE if any element equals x
* NULL if x is NULL, or no equal found
* AND the list contains a NULL
* FALSE if no equal found and list is
* fully non-NULL
*
* NOT IN is built by the parser as NOT(IN(...)), so the NULL
* propagates through the unary NOT (NIL → NIL via SqlIsTrue
* gate in the WHERE driver, which filters NULL out — the
* SQL-correct behaviour). This matters when a subquery in the
* list contains a NULL: `x NOT IN (SELECT y FROM t)` must drop
* candidate rows whenever any y is NULL, instead of letting
* them through as TRUE.
*/
IF cOp == "IN"
xL := ::EvalExpr( xNode[ 3 ] )
xR := xNode[ 4 ]
lSawNull := ( xL == NIL )
IF xR != NIL .AND. xR[ 1 ] == ND_LIST
aVals := xR[ 2 ]
FOR i := 1 TO Len( aVals )
xVal := ::EvalExpr( aVals[ i ] )
IF xVal == NIL
lSawNull := .T.
LOOP
ENDIF
IF xL != NIL .AND. SqlCmpEq( xL, xVal )
RETURN .T.
ENDIF
NEXT
IF lSawNull
RETURN NIL
ENDIF
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
IF aSubResult[ 2 ][ i ][ 1 ] == NIL
lSawNull := .T.
LOOP
ENDIF
IF xL != NIL .AND. SqlCmpEq( xL, aSubResult[ 2 ][ i ][ 1 ] )
RETURN .T.
ENDIF
ENDIF
NEXT
ENDIF
IF lSawNull
RETURN NIL
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 )
/* SQL three-valued logic: any comparison with NULL is UNKNOWN,
* which the WHERE driver SqlIsTrue() treats as "does not match"
* (drops the row). Previously `v <> 10` on a row where v was
* NULL returned .T. because `! SqlCmpEq(NIL, 10)` = `! .F.` =
* `.T.`. That's wrong: `NULL <> anything` must be NULL.
* Applies to =, <>, <, <=, >, >=. IS NULL / IS NOT NULL /
* IS DISTINCT FROM handle NULLs explicitly elsewhere. */
IF ( cOp == "=" .OR. cOp == "==" .OR. cOp == "<>" .OR. cOp == "!=" .OR. ;
cOp == "<" .OR. cOp == ">" .OR. cOp == "<=" .OR. cOp == ">=" ) .AND. ;
( xL == NIL .OR. xR == NIL )
RETURN NIL
ENDIF
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
/* SQL:2003 IS DISTINCT FROM / IS NOT DISTINCT FROM — NULL-safe
* compare. `a IS DISTINCT FROM b` is .T. iff the values differ
* (treating NULL as a distinct value of its own); `a IS NOT
* DISTINCT FROM b` is its negation. Unlike `=` / `<>`, they
* never return UNKNOWN — the parser parsed these as their own
* ND_BIN op codes but EvalExpr had no handler so they fell
* through to RETURN NIL → WHERE always dropped the row. */
IF cOp == "IS DISTINCT FROM"
IF xL == NIL .AND. xR == NIL
RETURN .F.
ENDIF
IF xL == NIL .OR. xR == NIL
RETURN .T.
ENDIF
RETURN ! SqlCmpEq( xL, xR )
ENDIF
IF cOp == "IS NOT DISTINCT FROM"
IF xL == NIL .AND. xR == NIL
RETURN .T.
ENDIF
IF xL == NIL .OR. xR == NIL
RETURN .F.
ENDIF
RETURN SqlCmpEq( xL, xR )
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
/* Arithmetic + CONCAT: SQL NULL propagation. Any NULL operand
* yields NULL (except the Harbour-style string `+` between two
* non-NULL C values, which the caller explicitly relied on for
* name-concat idioms). Division by zero → NULL per SQL spec
* rather than silently returning 0. */
IF cOp == "+"
IF xL == NIL .OR. xR == NIL
IF ValType( xL ) == "C" .AND. ValType( xR ) == "C"
RETURN xL + xR
ENDIF
RETURN NIL
ENDIF
IF ValType( xL ) == "C" .AND. ValType( xR ) == "C"
RETURN xL + xR
ENDIF
/* Harbour Date arithmetic: Date + N → Date (N days later).
* Without this branch, SqlCoerceNum collapsed the date to 0
* and the projection returned the raw integer offset
* (`d + 7` came back as `7`). N + Date is symmetric. */
IF ValType( xL ) == "D" .AND. ValType( xR ) == "N"
RETURN xL + xR
ENDIF
IF ValType( xL ) == "N" .AND. ValType( xR ) == "D"
RETURN xR + xL
ENDIF
RETURN SqlCoerceNum( xL ) + SqlCoerceNum( xR )
ENDIF
IF cOp == "-"
IF xL == NIL .OR. xR == NIL
RETURN NIL
ENDIF
/* Date - N → Date (N days earlier); Date - Date → N (day
* gap). Both reduced to 0 - 0 = 0 before this branch. */
IF ValType( xL ) == "D" .AND. ValType( xR ) == "N"
RETURN xL - xR
ENDIF
IF ValType( xL ) == "D" .AND. ValType( xR ) == "D"
RETURN xL - xR
ENDIF
RETURN SqlCoerceNum( xL ) - SqlCoerceNum( xR )
ENDIF
IF cOp == "*"
IF xL == NIL .OR. xR == NIL
RETURN NIL
ENDIF
RETURN SqlCoerceNum( xL ) * SqlCoerceNum( xR )
ENDIF
IF cOp == "/"
IF xL == NIL .OR. xR == NIL
RETURN NIL
ENDIF
IF SqlCoerceNum( xR ) != 0
RETURN SqlCoerceNum( xL ) / SqlCoerceNum( xR )
ENDIF
RETURN NIL
ENDIF
IF cOp == "||"
IF xL == NIL .OR. xR == NIL
RETURN NIL
ENDIF
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 ] )
/* SQL 3-value logic: any NULL operand → NULL (UNKNOWN). The
* WHERE driver SqlIsTrue() drops the row. Without this guard
* `v NOT BETWEEN 10 AND 30` returned rows where v was NULL
* because the comparison fell through to .F. → !.F. = .T..
* The standard says NULL BETWEEN/NOT-BETWEEN must always be
* UNKNOWN regardless of the bounds. */
IF xL == NIL .OR. xR == NIL .OR. xHi == NIL
RETURN NIL
ENDIF
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 )
/* Skip the `__error__` envelope — extracting aResult[2][1][1]
* blindly would surface the numeric error code (e.g. 1005 =
* SQL_ERR_LOCKED) as the scalar value, silently passing
* garbage into the WHERE comparison. */
IF ValType( aSubResult ) == "A" .AND. Len( aSubResult ) >= 1 .AND. ;
ValType( aSubResult[ 1 ] ) == "A" .AND. Len( aSubResult[ 1 ] ) >= 1 .AND. ;
aSubResult[ 1 ][ 1 ] == "__error__"
RETURN NIL
ENDIF
IF ValType( aSubResult ) == "A" .AND. Len( aSubResult ) >= 2 .AND. ;
ValType( aSubResult[ 2 ] ) == "A" .AND. Len( aSubResult[ 2 ] ) > 0 .AND. ;
Len( aSubResult[ 2 ][ 1 ] ) > 0
/* SQL standard: a scalar subquery must return at most one
* row. Returning silently the first row of a multi-row
* result hid bugs (`INSERT ... VALUES ((SELECT id FROM
* t), 100)` quietly took an arbitrary row) — surface to
* stderr so the developer notices, then collapse to NIL
* to keep three-valued logic consistent (NULL beats
* "arbitrary garbage from row 1"). */
IF Len( aSubResult[ 2 ] ) > 1
OutErr( "FiveSQL: scalar subquery returned " + ;
hb_NToS( Len( aSubResult[ 2 ] ) ) + ;
" rows; SQL standard requires at most 1 — using NULL." + Chr( 10 ) )
RETURN NIL
ENDIF
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
/* Fastest path: pre-bound columns (built once per join by RunSelect).
* Go-native: SqlFetchRowFast collapses the per-row Harbour FOR loop
* into a single Go call, saving ~30% of GROUP BY CPU spent in PRG
* method dispatch. Falls back to self:EvalExpr for unbound entries. */
IF ::aFetchCache != NIL .AND. Len( ::aFetchCache ) == Len( aExprs )
RETURN SqlFetchRowFast( Self, aExprs, ::aFetchCache )
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
/* -----------------------------------------------------------------
* Helpers for WHERE predicate pushdown across JOIN levels.
*
* SplitAndClauses walks the top-level AND chain of a WHERE expression
* and returns each conjunct as its own tree. Non-AND trees come back
* as a single-element array, so callers don't need a special case.
*
* BuildAliasLevelMap assigns each table reference a depth: 0 for the
* primary (outer) table and 1..N for each JOIN entry in aJoins. Both
* the SQL alias (as quoted by the query text) and any temp/user
* alternative alias stored in aTables[i][3] are registered so
* qualified names like "o.id" and synonyms still resolve.
*
* ClauseMaxLevel returns the highest level referenced by any
* qualified column in the clause. Unqualified columns can't be
* pinned to a single table without the full FindWA dispatch, so they
* force the conservative default — callers pass Len(aJoins) to make
* such clauses fall back to the base-case evaluation.
* ----------------------------------------------------------------- */
METHOD SplitAndClauses( xE, aOut ) CLASS TSqlExecutor
IF aOut == NIL
aOut := {}
ENDIF
IF xE == NIL
RETURN aOut
ENDIF
IF ValType( xE ) == "A" .AND. Len( xE ) >= 4 .AND. ;
xE[ 1 ] == ND_BIN .AND. xE[ 2 ] == "AND"
::SplitAndClauses( xE[ 3 ], aOut )
::SplitAndClauses( xE[ 4 ], aOut )
ELSE
AAdd( aOut, xE )
ENDIF
RETURN aOut
METHOD BuildAliasLevelMap( aJoins ) CLASS TSqlExecutor
LOCAL hMap := { => }, i, nLvl
/* aTables is ordered [primary, join1, join2, ...]; index j maps to
* join level j-1 (primary = level 0). For each table entry,
* register every name that WHERE / ON expressions might use:
*
* [1] = original table name (e.g. "ORD")
* [2] = currently-selected alias — possibly a depth-suffixed
* temp ("ORD_2") introduced by oAlias:AcquireTemp when
* the SQL alias was too short or we're in a subquery.
* [3] = original SQL alias from the query text ("o") — this is
* what the parser writes into ND_COL qualifiers, so it's
* the name we most need to resolve, and it's the one that
* wasn't in the old map because aJoins[i][3] gets
* overwritten with the temp alias during JOIN sync. */
FOR i := 1 TO Len( ::aTables )
nLvl := i - 1
IF ! Empty( ::aTables[ i ][ 1 ] )
hMap[ Upper( ::aTables[ i ][ 1 ] ) ] := nLvl
ENDIF
IF ! Empty( ::aTables[ i ][ 2 ] )
hMap[ Upper( ::aTables[ i ][ 2 ] ) ] := nLvl
ENDIF
IF Len( ::aTables[ i ] ) >= 3 .AND. ! Empty( ::aTables[ i ][ 3 ] )
hMap[ Upper( ::aTables[ i ][ 3 ] ) ] := nLvl
ENDIF
NEXT
RETURN hMap
METHOD ClauseMaxLevel( xClause, hAliasToLevel, nDefault ) CLASS TSqlExecutor
LOCAL aCols, i, cName, nDot, cAlias
LOCAL nMax := 0
LOCAL lUncertain := .F.
aCols := SqlCollectColExprs( xClause, NIL )
FOR i := 1 TO Len( aCols )
cName := aCols[ i ][ 2 ]
nDot := At( ".", cName )
IF nDot == 0
/* Unqualified column — could resolve to any open workarea
* via FindWA. Force the conservative default so the clause
* is evaluated after every referenced table is positioned
* (base case), never at an intermediate level where the
* column might bind to a stale inner record. */
lUncertain := .T.
EXIT
ENDIF
cAlias := Upper( Left( cName, nDot - 1 ) )
IF hb_HHasKey( hAliasToLevel, cAlias )
IF hAliasToLevel[ cAlias ] > nMax
nMax := hAliasToLevel[ cAlias ]
ENDIF
ELSE
lUncertain := .T.
EXIT
ENDIF
NEXT
IF lUncertain
RETURN nDefault
ENDIF
RETURN nMax
METHOD EvalPushedAtLevel( aPushByLevel, nIdx ) CLASS TSqlExecutor
LOCAL p, aClauses
IF aPushByLevel == NIL .OR. nIdx < 1 .OR. nIdx > Len( aPushByLevel )
RETURN .T.
ENDIF
aClauses := aPushByLevel[ nIdx ]
FOR p := 1 TO Len( aClauses )
IF ! SqlIsTrue( ::EvalExpr( aClauses[ p ] ) )
RETURN .F.
ENDIF
NEXT
RETURN .T.
METHOD JoinRecurse( aJoins, nIdx, xWhere, aRE, aRows, hHashTbl, aPushByLevel ) 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()
IF ::EvalPushedAtLevel( aPushByLevel, nIdx )
::JoinRecurse( aJoins, nIdx + 1, xWhere, aRE, @aRows, hHashTbl, aPushByLevel )
ENDIF
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 )
/* Hash join for equi-joins when no index is available. For
* very small inner tables the Go map allocation + per-key
* string formatting dominates the cost of a cache-friendly
* nested-loop scan, so we keep the old per-iteration scan
* when RecCount falls below the threshold. 64 was picked
* empirically — SQLite uses a similar constant; tune via
* bench_join if workload changes. */
IF ! lUseIndex .AND. ! Empty( cOuterCol )
nRecCount := LastRec()
IF nRecCount > 64
lUseHash := .T.
ENDIF
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.
/* Predicate pushdown: WHERE conjuncts whose referenced columns
* all bind by level nIdx are evaluated before we recurse, so
* rows rejected here skip the exponential join expansion that
* used to happen when xWhere only fired at the base case. */
IF ::EvalPushedAtLevel( aPushByLevel, nIdx )
::JoinRecurse( aJoins, nIdx + 1, xWhere, aRE, @aRows, hHashTbl, aPushByLevel )
ENDIF
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, ;
aPushByLevel )
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.
IF ::EvalPushedAtLevel( aPushByLevel, nIdx )
::JoinRecurse( aJoins, nIdx + 1, xWhere, aRE, @aRows, hHashTbl, aPushByLevel )
ENDIF
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, aPushByLevel )
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
LOCAL lOrderFromIndex := .F.
LOCAL aPushByLevel, xRes, aClauses, hAliasLvl, nLvl, ii
LOCAL nScanRec
LOCAL nUserCols, nTrim, nRow
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 )
/* Track the derived MEMRDD area so CloseOpened (called via
* the top-level CloseOpened path at RunSelect's tail) frees
* the alias before the next query runs. Without this, a
* second SELECT that uses the same derived alias panics
* with "alias already in use" inside SqlMaterializeSubquery. */
AAdd( ::aOpened, cAlias )
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
/* Derived tables already opened their own MEMRDD area under the
* user's alias inside SqlMaterializeSubquery — don't rename it,
* or Select(cAlias) below will miss the area and the open loop
* will think the (synthetic) `__drv_<x>` "table" is missing on
* disk. The rename for short aliases / nested depth is meant
* to avoid alias collisions on real DBF tables. */
IF ( Len( cAlias ) <= 1 .OR. ::nDepth > 1 ) .AND. ;
Left( Lower( cTable ), 6 ) != "__drv_"
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 table exists for
* this table name and open it instead. This handles sub-
* executors (UNION, recursive) that reference a CTE by its
* original name. CTE temp tables now live in MEMRDD (no
* file on disk) — fall back to the legacy DBFNTX open for
* pre-existing .dbf files from prior runs. */
BEGIN SEQUENCE
dbUseArea( .T., "MEMRDD", "mem:__cte_" + Lower( cTable ), ;
cAlias, .T., .T. )
nWA := Select( cAlias )
IF nWA > 0
AAdd( ::aOpened, cAlias )
AAdd( ::oAlias:aSlots, { cAlias, Upper( cTable ), Upper( cTable ), .T. } )
ENDIF
RECOVER
nWA := 0
END SEQUENCE
IF nWA == 0 .AND. hb_FileExists( "__cte_" + Lower( cTable ) + ".dbf" )
s_lCteDiskSeen := .T.
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
/* VIEW expansion: `<table>.fsv` holds the SELECT text the
* caller registered with CREATE VIEW. Run that SELECT once,
* materialize into a MEMRDD temp area, and route subsequent
* column lookups through it under the view's alias. Allows
* reusable named queries without forcing every caller to
* embed the full SQL. */
IF nWA == 0 .AND. hb_FileExists( Lower( cTable ) + ".fsv" )
IF SqlMaterializeView( cTable, cAlias )
nWA := Select( cAlias )
IF nWA > 0
AAdd( ::aOpened, cAlias )
AAdd( ::oAlias:aSlots, ;
{ cAlias, Upper( cTable ), Upper( cTable ), .T. } )
ENDIF
ENDIF
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
/* nWA == 0 here means: not in workareas, not openable as DBF,
* not findable as CTE temp. Bubble up a clear error instead
* of silently dropping into a SELECT against no workarea
* (which previously yielded empty / undefined results and
* crashed downstream callers reading aR[1][1]). */
IF nWA == 0
::nDepth--
IF Len( aSavedAreas ) > 0
dbSelectArea( aSavedAreas[ 1 ] )
ENDIF
RETURN ::MakeError( SQL_ERR_NO_TABLE, ;
"Table '" + cTable + "' does not exist" )
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
/* Snapshot the user-visible column count BEFORE the hidden-column
* loops run. Hidden columns added below for aggregate sources,
* HAVING, and ORDER BY wrapped expressions get trimmed back off
* the final result so callers don't see synthetic `__ord_<i>__`
* etc. in their fetched rows. (nUserCols is declared at the top
* of RunSelect via the master LOCAL list — mid-function LOCAL
* is an established Five compiler quirk.) */
nUserCols := Len( aResultExprs )
/* Add hidden columns for aggregate source fields.
*
* Window aggregates (ND_WINDOW: SUM/AVG/COUNT/MIN/MAX OVER ...) are
* included explicitly because SqlExprHasAgg deliberately does not
* descend into ND_WINDOW (it carries its own aggregation scope),
* so without the extra top-level check queries like
* `SELECT id, SUM(v) OVER (…)` would never materialise `v` on
* each row and ApplyWindowFunctions' fast path
* (`SqlWindowSlideAgg`) read NIL / 0 for every slot. */
FOR i := 1 TO Len( aCols )
IF SqlExprHasAgg( aCols[ i ][ 1 ] ) .OR. aCols[ i ][ 1 ][ 1 ] == ND_WINDOW
/* Wrapped aggregate (e.g. `MAX(id)+1`, `ROUND(AVG(p),2)`):
* the top-level node is ND_BIN / ND_UNI / non-agg ND_FN, so
* the argument-walker below would skip it entirely and the
* wrapped aggregate's source column never becomes a hidden
* column. Result: ComputeAgg sees nCol==0 and returns 0
* silently. Walk the whole expression for ND_COL leaves and
* add them as hidden columns. */
IF aCols[ i ][ 1 ][ 1 ] != ND_FN .AND. aCols[ i ][ 1 ][ 1 ] != ND_WINDOW
aLeafCols := SqlCollectColExprs( aCols[ i ][ 1 ], 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
LOOP
ENDIF
IF ( aCols[ i ][ 1 ][ 1 ] == ND_FN .OR. aCols[ i ][ 1 ][ 1 ] == ND_WINDOW ) .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
/* Wrapped window function (`SUM(x) OVER () + 100`): the top-level
* node is ND_BIN/ND_UNI/ND_FN(non-agg)/ND_CASE wrapping ND_WINDOW.
* ApplyWindowFunctions only scans aCols[i] when its top-level node
* IS ND_WINDOW, so a wrapped window expression evaluates to the
* placeholder 0 returned by EvalExpr's ND_WINDOW branch. Walk every
* SELECT projection, extract any ND_WINDOW into a hidden column
* (`__win_<i>_<j>__`) and substitute its position with a plain
* ND_COL pointing at that hidden column. ApplyWindowFunctions then
* computes the inner window per row, projection picks up the value
* via the ND_COL lookup, and the outer arithmetic falls out of the
* normal ND_BIN/ND_UNI evaluator. Hidden columns get trimmed back
* off the result via nUserCols at RunSelect's tail. */
::aWrappedWindowCols := {}
FOR i := 1 TO Len( aCols )
IF aCols[ i ][ 1 ] != NIL .AND. ValType( aCols[ i ][ 1 ] ) == "A" .AND. ;
aCols[ i ][ 1 ][ 1 ] != ND_WINDOW
aLeafCols := {} /* reuse var: holds {windowExpr, alias} pairs */
aCols[ i ][ 1 ] := SqlExtractWindow( aCols[ i ][ 1 ], aLeafCols, ;
"__win_" + AllTrim( hb_NToS( i ) ) )
IF Len( aLeafCols ) > 0
/* Track this column for post-window re-evaluation. The
* fetch loop runs BEFORE ApplyWindowFunctions, so the
* outer expression sees NIL/0 in the hidden window slot
* and folds it through the wrapper as NIL. After
* ApplyWindowFunctions fills the hidden slot, we re-eval
* the outer expression per row to pick up the real value. */
AAdd( ::aWrappedWindowCols, i )
FOR k := 1 TO Len( aLeafCols )
AAdd( aResultExprs, { aLeafCols[ k ][ 1 ], aLeafCols[ k ][ 2 ] } )
AAdd( aCols, { aLeafCols[ k ][ 1 ], aLeafCols[ k ][ 2 ] } )
NEXT
ENDIF
ENDIF
NEXT
/* Hidden columns for HAVING expressions: any aggregate source
* column referenced only inside HAVING (and not in SELECT) must
* still be carried through so EvalHavingExpr → ComputeAgg can
* resolve its argument. Without this, `SELECT dept FROM s
* GROUP BY dept HAVING SUM(amt) > 200` returned 0 rows because
* `amt` was never materialised on grouped rows and ComputeAgg
* fell back to nCol=0 → 0. Same SqlCollectColExprs walk as
* the wrapped-aggregate branch above; collects every ND_COL
* leaf reachable from xHaving. */
IF xHaving != NIL
aLeafCols := SqlCollectColExprs( xHaving, 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
/* Hidden columns for ORDER BY expressions not already in the
* SELECT list. Without this, ORDER BY references a column that
* the materialised rows don't carry — TryBuildSortSpec returns
* NIL (col not in aFieldNames), PRG OrderBy likewise can't bind
* it, and the result ends up in undefined order (typically
* insertion order). Pattern mirrors the aggregate hidden-column
* loop above so ORDER BY over non-projected fields works out of
* the box. */
IF ValType( aOrderBy ) == "A"
FOR i := 1 TO Len( aOrderBy )
IF ValType( aOrderBy[ i ] ) != "A" .OR. Len( aOrderBy[ i ] ) == 0
LOOP
ENDIF
xExpr := aOrderBy[ i ][ 1 ]
IF ValType( xExpr ) != "A" .OR. Len( xExpr ) < 2
LOOP
ENDIF
/* Wrapped ORDER BY expression (ND_BIN / ND_UNI / ND_FN —
* e.g. `ORDER BY MAX(amt) + 1 DESC`): without a hidden
* column the sort layer can't bind the expression and rows
* stay in insertion order. Append the whole expression to
* aResultExprs under a synthetic alias `__ord_<i>__` and
* rewrite the ORDER BY entry in-place to a plain ND_COL
* pointing at that alias. GroupBy / projection then computes
* the wrapped aggregate per row, and TSqlSort:OrderBy can
* find the column by name. The hidden columns get trimmed
* back off the final result via ::nUserCols (set just below
* after this whole hidden-col block runs). */
IF xExpr[ 1 ] != ND_COL
cBare := "__ord_" + AllTrim( hb_NToS( i ) ) + "__"
AAdd( aResultExprs, { xExpr, cBare } )
/* GroupBy / aFieldNames rebuild downstream both walk
* `aCols` (the user-visible SELECT list), not aResultExprs.
* Without mirroring the hidden append into aCols, GroupBy
* never evaluates the wrapped expression and the new
* "__ord_<i>__" name doesn't appear in aFieldNames →
* SqlFindColIdx returns 0 → sort silently no-ops. The
* trim block at RunSelect's tail still strips the column
* back off the result, so callers see the same shape. */
AAdd( aCols, { xExpr, cBare } )
aOrderBy[ i ][ 1 ] := { ND_COL, cBare, NIL, NIL, NIL }
LOOP
ENDIF
cBare := xExpr[ 2 ]
IF cBare == "*"
LOOP
ENDIF
lFound := .F.
FOR j := 1 TO Len( aResultExprs )
IF Upper( aResultExprs[ j ][ 2 ] ) == Upper( cBare ) .OR. ;
( ValType( aResultExprs[ j ][ 1 ] ) == "A" .AND. ;
Len( aResultExprs[ j ][ 1 ] ) >= 2 .AND. ;
aResultExprs[ j ][ 1 ][ 1 ] == ND_COL .AND. ;
Upper( aResultExprs[ j ][ 1 ][ 2 ] ) == Upper( cBare ) )
lFound := .T.
EXIT
ENDIF
NEXT
IF ! lFound
AAdd( aResultExprs, { xExpr, cBare } )
ENDIF
NEXT
ENDIF
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
/* Pre-resolve column references to {nWA, nFPos} pairs so the
* per-row EvalExpr hot path avoids repeated At/Upper/FindWA/
* FieldPos work on every ND_COL visit. Scope: WHERE, HAVING,
* every JOIN's ON condition. Projection columns go through
* FetchRow's own cache and don't need this pre-walk. */
IF xWhere != NIL
::PreResolveColumns( xWhere )
ENDIF
IF xHaving != NIL
::PreResolveColumns( xHaving )
ENDIF
FOR i := 1 TO Len( aJoins )
IF aJoins[ i ][ 4 ] != NIL
::PreResolveColumns( aJoins[ i ][ 4 ] )
ENDIF
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.
/* Hand the current executor to TSqlIndex so its per-row
* seek loop can skip the SqlEvalExprNode/SqlFetchRowArr
* throwaway-executor allocations. */
::oIndex:oExec := Self
/* Resolve LIMIT / ORDER-BY-from-index BEFORE the index-scan
* dispatch so TryIndexScan can early-terminate, and the
* Go-fast-path below can reuse the same lOrderFromIndex
* value to skip the post-scan sort. Gates mirror the
* original fallback computation — single-table, no GROUP /
* Agg / DISTINCT, LIMIT or TOP present. */
lOrderFromIndex := .F.
nEarlyLimit := 0
IF Len( aJoins ) == 0 .AND. Len( aGroupBy ) == 0 .AND. ;
! ::oAgg:HasAgg( aCols ) .AND. ! lDistinct .AND. ;
( ( ValType( nLimit ) == "N" .AND. nLimit > 0 ) .OR. ;
( ValType( nTop ) == "N" .AND. nTop > 0 ) )
IF Len( aOrderBy ) > 0
lOrderFromIndex := ::oIndex:MatchOrderByTag( nWA, aOrderBy, aFieldNames )
ENDIF
IF Len( aOrderBy ) == 0 .OR. lOrderFromIndex
nEarlyLimit := iif( ValType( nLimit ) == "N" .AND. nLimit > 0, ;
nLimit, nTop )
/* OFFSET is post-processed; pull enough rows to
* satisfy LIMIT *after* the OFFSET skip. */
IF nOffset > 0
nEarlyLimit += nOffset
ENDIF
ENDIF
ENDIF
IF Len( aJoins ) == 0 .AND. xWhere != NIL
lIndexUsed := ::oIndex:TryIndexScan( nWA, xWhere, xWhere, ;
::aTables, ::aParams, aResultExprs, @aRows, nEarlyLimit )
ELSEIF Len( aJoins ) > 0 .AND. xWhere != NIL
lIndexUsed := ::oIndex:TryIndexJoinScan( nWA, xWhere, ;
::aTables, ::aParams, aResultExprs, @aRows, aJoins )
ENDIF
::oIndex:oExec := NIL
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
/* lOrderFromIndex / nEarlyLimit were resolved above,
* before the TryIndexScan dispatch, so both index-scan
* and Go-fast-path branches share the same values. The
* call there may have moved the record pointer via
* ordSetFocus+dbSeek; if lOrderFromIndex is set, we
* re-anchor to the logical top of the focused tag so
* SqlScan's GoTop+Skip loop walks from the first
* ordered row. */
IF lOrderFromIndex
dbSelectArea( nWA )
dbGoTop()
ENDIF
IF Len( aJoins ) == 0 .AND. Len( aGroupBy ) == 0 .AND. ;
! ::oAgg:HasAgg( aCols )
/* Plan pcode cache: cache aFP + pcW per cCacheKey.
* These results are pure functions of the plan tree
* (which is immutable between cache hits) and the
* target table schema (stable for the process). */
LOCAL hSelCached, cSelKey
IF ! Empty( ::cCacheKey )
cSelKey := ::cCacheKey + "#sel"
IF hb_HHasKey( s_hDmlPcodeCache, cSelKey )
hSelCached := s_hDmlPcodeCache[ cSelKey ]
aFP := hSelCached[ "fp" ]
pcW := hSelCached[ "where_pc" ]
ENDIF
ENDIF
IF aFP == NIL
aFP := ::TryBuildFieldPositions( aResultExprs )
IF aFP != NIL .AND. xWhere != NIL
pcW := ::TryCompileWhere( xWhere )
IF pcW == NIL
aFP := NIL /* WHERE couldn't compile — PRG path */
ENDIF
ENDIF
IF aFP != NIL .AND. ! Empty( ::cCacheKey )
IF Len( s_hDmlPcodeCache ) >= SQL_DML_PCODE_CACHE_MAX
s_hDmlPcodeCache := { => }
ENDIF
s_hDmlPcodeCache[ ::cCacheKey + "#sel" ] := { ;
"fp" => aFP, ;
"where_pc" => pcW }
ENDIF
ENDIF
IF aFP != NIL
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, nEarlyLimit )
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 )
/* WHERE predicate pushdown: split the top-level AND
* chain into clauses and group them by the deepest
* JOIN level whose columns they reference. Clauses
* pinned to a middle level are evaluated inside
* JoinRecurse as soon as that level's row is
* positioned — rejected rows skip the exponential
* deeper-join expansion that would otherwise happen
* until xWhere fired at the base case. Clauses that
* can't be classified (unqualified columns, aliases
* we can't resolve) fall back to the residual
* xRes evaluated at the base case, preserving
* existing semantics. */
aPushByLevel := NIL
xRes := xWhere
IF xWhere != NIL .AND. Len( aJoins ) > 0
hAliasLvl := ::BuildAliasLevelMap( aJoins )
aClauses := ::SplitAndClauses( xWhere, NIL )
aPushByLevel := Array( Len( aJoins ) )
FOR ii := 1 TO Len( aPushByLevel )
aPushByLevel[ ii ] := {}
NEXT
xRes := NIL
FOR ii := 1 TO Len( aClauses )
nLvl := ::ClauseMaxLevel( aClauses[ ii ], hAliasLvl, Len( aJoins ) )
IF nLvl >= 1 .AND. nLvl < Len( aJoins )
AAdd( aPushByLevel[ nLvl ], aClauses[ ii ] )
ELSE
/* Level 0 (outer-only) and Len(aJoins)
* (needs all joins) stay in the residual
* evaluated at the base case — the
* existing behavior. */
IF xRes == NIL
xRes := aClauses[ ii ]
ELSE
xRes := SqlNode( ND_BIN, "AND", xRes, aClauses[ ii ], NIL )
ENDIF
ENDIF
NEXT
ENDIF
/* lOrderFromIndex / nEarlyLimit were resolved above,
* before the Go-fast-path decision, so both paths
* share the same ORDER-BY-from-index detection and
* row-count cap. */
WHILE ! Eof()
IF Len( aJoins ) > 0
::JoinRecurse( aJoins, 1, xRes, aResultExprs, @aRows, hJoinHash, aPushByLevel )
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 )
/* Re-evaluate wrapped window expressions per row. ApplyWindowFunctions
* has now filled the hidden `__win_<i>_<j>__` slots with real values;
* the outer arithmetic / function call needs a second pass to pick up
* the post-window value. */
IF ::aWrappedWindowCols != NIL .AND. Len( ::aWrappedWindowCols ) > 0
FOR k := 1 TO Len( ::aWrappedWindowCols )
i := ::aWrappedWindowCols[ k ]
IF i > 0 .AND. i <= Len( aCols )
FOR j := 1 TO Len( aRows )
IF i <= Len( aRows[ j ] )
aRows[ j ][ i ] := SqlEvalRowExpr( aCols[ i ][ 1 ], aFieldNames, aRows[ j ] )
ENDIF
NEXT
ENDIF
NEXT
ENDIF
/* ORDER BY — try Go-native sort first (10-50x faster for large sets),
* fall back to PRG for complex expressions in ORDER BY.
*
* lOrderFromIndex set above (pre-scan) means we already walked the
* table in tag order so the result is sorted — short-circuit here
* to avoid a redundant MatchOrderByTag probe. */
IF Len( aOrderBy ) > 0 .AND. ! lOrderFromIndex
IF ! ( nWA > 0 .AND. ::oIndex:MatchOrderByTag( nWA, aOrderBy, aFieldNames ) )
LOCAL aSortSpec2 := ::TryBuildSortSpec( aOrderBy, aFieldNames )
IF aSortSpec2 != NIL .AND. Len( aRows ) > 0
aRows := SqlOrderBy( aRows, aSortSpec2 )
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()
/* SQL standard: set operations require the same column count on
* both sides. Previously a mismatch silently truncated the wider
* side to the narrower's width, masking schema bugs (`SELECT a
* UNION SELECT a, b` returned 1-col rows with `b` dropped). Bail
* out with a structured error instead. */
IF ValType( aU ) == "A" .AND. Len( aU ) >= 1 .AND. ;
ValType( aU[ 1 ] ) == "A" .AND. Len( aU[ 1 ] ) > 0 .AND. ;
aU[ 1 ][ 1 ] != "__error__" .AND. ;
Len( aU[ 1 ] ) != Len( aFieldNames )
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"UNION/INTERSECT/EXCEPT: each query must have the same number of columns (" + ;
hb_NToS( Len( aFieldNames ) ) + " vs " + hb_NToS( Len( aU[ 1 ] ) ) + ")" )
ENDIF
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
IF lAll
/* UNION ALL — plain append, no dedup. */
FOR i := 1 TO Len( aU[ 2 ] )
AAdd( aRows, aU[ 2 ][ i ] )
NEXT
ELSE
/* Streaming DISTINCT: build one hash set across both
* sides in the Go RTL. Saves the append-then-rescan pass
* the old path did (materialise merged array + run
* SqlDistinct, two walks over |L|+|R| rows). */
aRows := SqlUnionDistinct( aRows, aU[ 2 ] )
ENDIF
ENDIF
ENDIF
/* Trim hidden columns BEFORE DISTINCT so the dedup hash sees only
* user-visible columns. ORDER BY has already used the hidden cols
* (aggregate sources, `__ord_<i>__` for wrapped expressions, etc.)
* so they're free to drop. Without trimming first, `SELECT
* DISTINCT grp ORDER BY id` returned every original row because the
* synthetic `__ord_1__` column made each row uniquely keyed. */
IF nUserCols != NIL .AND. nUserCols > 0 .AND. ;
Len( aFieldNames ) > nUserCols
nTrim := nUserCols
FOR nRow := 1 TO Len( aRows )
IF Len( aRows[ nRow ] ) > nTrim
ASize( aRows[ nRow ], nTrim )
ENDIF
NEXT
ASize( aFieldNames, nTrim )
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.
* SQL semantics:
* LIMIT 0 → empty result
* LIMIT n>0 → first n rows
* LIMIT n<0 → ill-defined; clamp to 0 (matches SQLite tolerance)
*
* Old code only clipped when `nLimit > 0`, so LIMIT 0 returned
* the full result and LIMIT -1 was silently a no-op. */
nMaxRows := 0
IF ValType( nTop ) == "N" .AND. nTop > 0
nMaxRows := nTop
ENDIF
IF ValType( nLimit ) == "N"
IF nLimit > 0
nMaxRows := nLimit
ELSEIF nLimit <= 0
/* Explicit zero / negative → return no rows. */
aRows := {}
nMaxRows := 0
ENDIF
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
/* Legacy disk fallback cleanup — only runs when a __cte_*.dbf
* has actually been seen (either from a prior crash or a
* MEMRDD-failure legacy open). MEMRDD-only runs skip the stat. */
IF s_lCteDiskSeen
cTable := "__cte_" + Lower( ::hQuery[ "cte" ][ i ][ 1 ] )
IF hb_FileExists( cTable + ".dbf" )
FErase( cTable + ".dbf" )
ENDIF
ENDIF
NEXT
ENDIF
/* Clean up VIEW temp files — created by TSqlIndex:CheckView when
* a query references a .fsv view. The flag lets us skip the stat
* loop on view-free queries, which the profile showed as ~28% of
* SELECT CPU after the WA cache killed the munmap cost. */
IF ::oIndex:lViewUsed
FOR i := 1 TO Len( ::aTables )
IF hb_FileExists( "__view_" + Lower( ::aTables[ i ][ 1 ] ) + ".dbf" )
FErase( "__view_" + Lower( ::aTables[ i ][ 1 ] ) + ".dbf" )
ENDIF
NEXT
::oIndex:lViewUsed := .F.
ENDIF
::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
/* Trim hidden columns added during the projection-rewrite phase
* (aggregate sources, HAVING leaves, ORDER BY wrapped expressions).
* Without trimming, callers see synthetic `__ord_<i>__` slots in
* their fetched rows and the leading aggregate-source columns. */
IF nUserCols != NIL .AND. nUserCols > 0 .AND. ;
Len( aFieldNames ) > nUserCols
nTrim := nUserCols
FOR nRow := 1 TO Len( aRows )
IF Len( aRows[ nRow ] ) > nTrim
ASize( aRows[ nRow ], nTrim )
ENDIF
NEXT
ASize( aFieldNames, nTrim )
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, aPushByLevel ) 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
/* Pushdown: if any clause pinned to this level rejects
* the row, skip it before building the result tuple. */
IF ! ::EvalPushedAtLevel( aPushByLevel, nIdx )
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.
IF ::EvalPushedAtLevel( aPushByLevel, nIdx )
::JoinRecurse( aJoins, nIdx + 1, xWhere, aRE, @aRows, hHashTbl, aPushByLevel )
ENDIF
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.
/* `limit`/`top` use NIL ("no limit") instead of 0. After the recent
* LIMIT 0 fix, `0` is treated as an explicit "return no rows"
* sentinel — which collapsed every lifted EXISTS query to an empty
* inner_set and produced false negatives. */
hLifted[ "limit" ] := NIL
hLifted[ "top" ] := NIL
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, aKeyVals, aRecSave
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. The value ↦
* string encoding + final join happen in Go (SqlBuildSubCacheKey)
* so the hot cache-hit path avoids N PRG string concatenations
* and N SqlValToStr dispatches per outer row. */
aKeyVals := Array( Len( aFreeVars ) )
FOR i := 1 TO Len( aFreeVars )
aKeyVals[ i ] := ::Resolve( aFreeVars[ i ] )
NEXT
cCacheKey := SqlBuildSubCacheKey( nId, aKeyVals )
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.
*
* Workarea snapshot: the subquery may scan the SAME table the
* outer query is iterating, and SqlExecOpenTable only renames
* aliases deeper than nDepth=1 — so the first-level subquery's
* scan drives the shared workarea past EOF. Save every open
* workarea's RecNo up front and restore it before returning so
* the outer iterator resumes exactly where it left off. Depth
* bump is still set for good measure (avoids re-entering the
* same subquery's own workarea on recursion). */
nSavedWA := Select()
aRecSave := ::SnapshotAreaRecNos()
::PushOuter()
BEGIN SEQUENCE
oSub := TSqlExecutor():New( hQ, ::aParams )
/* +2 (not +1): the alias-rename gate in the table-open loop
* requires `nDepth > 1` to fire. Bumping by 1 from a top-level
* (depth-0) outer landed at depth 1 which still shares aliases
* with the outer scope — a subquery over the same table
* inherited the outer's workarea and the inner scan drove
* the outer's record pointer to EOF, truncating the outer
* result. Bumping by 2 forces the alias-acquire path so the
* subquery always gets a fresh workarea; deeper nesting stays
* strictly increasing. */
oSub:nDepth := ::nDepth + 2
aResult := oSub:Run()
RECOVER
aResult := NIL
END SEQUENCE
::PopOuter()
::RestoreAreaRecNos( aRecSave )
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
/* Snapshot every currently-open workarea's (alias, RecNo) pair so a
* nested subquery scan that shares one of those areas can't leave
* the record pointer past EOF. Harbour doesn't expose a clean "list
* open workareas" primitive, so we probe a fixed range (1..MAX) and
* skip the ones with empty aliases. MAX stays small — any workload
* needing more open areas simultaneously would already break the
* default Harbour limit. */
METHOD SnapshotAreaRecNos() CLASS TSqlExecutor
LOCAL aSnap := {}, i, nSaved, cAlias
nSaved := Select()
FOR i := 1 TO 64
dbSelectArea( i )
cAlias := Alias()
IF ! Empty( cAlias )
AAdd( aSnap, { i, RecNo(), cAlias } )
ENDIF
NEXT
dbSelectArea( nSaved )
RETURN aSnap
METHOD RestoreAreaRecNos( aSnap ) CLASS TSqlExecutor
LOCAL i, nSaved
IF aSnap == NIL .OR. Len( aSnap ) == 0
RETURN NIL
ENDIF
nSaved := Select()
FOR i := 1 TO Len( aSnap )
/* Bind by alias when possible — subquery execution can
* rebalance the workarea table (e.g. close a CTE temp area),
* so the original numeric slot may now hold something else.
* If the alias is gone, silently skip the restore for that
* entry rather than seeking into an unrelated area. */
IF Select( aSnap[ i ][ 3 ] ) == aSnap[ i ][ 1 ]
dbSelectArea( aSnap[ i ][ 1 ] )
IF aSnap[ i ][ 2 ] > 0 .AND. aSnap[ i ][ 2 ] <= LastRec()
dbGoto( aSnap[ i ][ 2 ] )
ENDIF
ENDIF
NEXT
dbSelectArea( nSaved )
RETURN NIL
METHOD CacheSubquery( xSubExpr ) CLASS TSqlExecutor
LOCAL cKey, aSubResult, nSavedWA, oSub, aRecSave
/* Build cache key from subquery tokens */
cKey := SqlSubqueryKey( xSubExpr )
IF hb_HHasKey( ::hSubCache, cKey )
RETURN ::hSubCache[ cKey ]
ENDIF
/* Snapshot all open workareas' RecNos: same rationale as
* SubqueryCached — the inner scan can move the outer's record
* pointer when the subquery opens a table that's already in
* play. Non-correlated subqueries run once so the overhead is
* negligible; correlated callers go through SubqueryCached. */
nSavedWA := Select()
aRecSave := ::SnapshotAreaRecNos()
oSub := TSqlExecutor():New( xSubExpr, ::aParams )
oSub:nDepth := ::nDepth + 2
aSubResult := oSub:Run()
::RestoreAreaRecNos( aRecSave )
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
/* In-memory temp table — no file I/O, `mem:` scheme dispatches
* to MEMRDD. Create overwrites any prior table with this name. */
BEGIN SEQUENCE
dbCreate( "mem:" + cTmpFile, aStruct, "MEMRDD" )
RECOVER
LOOP
END SEQUENCE
dbUseArea( .T., "MEMRDD", "mem:" + cTmpFile, cPopAlias, .F., .F. )
/* Go RTL SqlBulkInsert: collapses per-row dbAppend+FieldPut loop
* into a single RTL call — N·M boundary crossings → 1. */
SqlBulkInsert( aDataRows )
dbSelectArea( Select( cPopAlias ) )
dbCloseArea()
dbUseArea( .T., "MEMRDD", "mem:" + cTmpFile, cName, .T., .F. )
/* Replace existing table entry */
lReplaced := .F.
NEXT
RETURN NIL
METHOD RunInsert() CLASS TSqlExecutor
LOCAL cTable, aFields, aRows, aValExprs, cAlias, nWA, i, nFPos, xVal
LOCAL aAutoInc, nAutoVal, hSelect, aSelResult, aSelRows, nTuple
LOCAL aErrResult, nInserted := 0, nTotal
LOCAL aStructFlags, aStructRaw, aStructTypes
cTable := ::hQuery[ "table" ]
aFields := ::hQuery[ "fields" ]
cAlias := cTable
/* Materialize CTEs first — `WITH cte AS (...) INSERT ...`
* needs the CTE's temp table to exist before any SELECT
* subqueries inside aRows / aSelect resolve against it. */
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
/* Same pre-flight existence check as RunUpdate — if the user
* names a missing table, return SQL_ERR_NO_TABLE rather than
* letting dbUseArea bubble up an opaque runtime panic. */
IF ! File( Lower( cTable ) + ".dbf" ) .AND. ! File( cTable + ".dbf" )
RETURN ::MakeError( SQL_ERR_NO_TABLE, ;
"Table '" + cTable + "' does not exist" )
ENDIF
/* Schema migration note: old plans emitted h["values"] (a flat
* expression array, one tuple). Current parser emits h["rows"]
* (array of tuples) plus h["select"] for INSERT ... SELECT. The
* lookup here tolerates either shape for cached-plan callers and
* drives the per-row loop below. */
IF hb_HHasKey( ::hQuery, "rows" ) .AND. ValType( ::hQuery[ "rows" ] ) == "A"
aRows := ::hQuery[ "rows" ]
ELSE
aRows := {}
ENDIF
IF Len( aRows ) == 0 .AND. hb_HHasKey( ::hQuery, "values" )
aRows := { ::hQuery[ "values" ] }
ENDIF
/* INSERT ... SELECT: evaluate the subquery once, convert each
* result row into a tuple of literal-ish ND_LIT nodes, then fall
* through to the standard per-tuple loop. Keeping the rows-path
* as the single execution code path means CHECK / FK / UNIQUE /
* auto-inc / txn-log all run identically whether the values came
* from VALUES (...) tuples or from a SELECT. */
IF hb_HHasKey( ::hQuery, "select" )
hSelect := ::hQuery[ "select" ]
aSelResult := TSqlExecutor():New( hSelect, ::aParams ):Run()
IF ValType( aSelResult ) == "A" .AND. Len( aSelResult ) >= 1 .AND. ;
ValType( aSelResult[ 1 ] ) == "A" .AND. Len( aSelResult[ 1 ] ) > 0 .AND. ;
aSelResult[ 1 ][ 1 ] == "__error__"
RETURN aSelResult
ENDIF
IF ValType( aSelResult ) == "A" .AND. Len( aSelResult ) >= 2 .AND. ;
ValType( aSelResult[ 2 ] ) == "A"
aSelRows := aSelResult[ 2 ]
FOR i := 1 TO Len( aSelRows )
aValExprs := {}
FOR nTuple := 1 TO Len( aSelRows[ i ] )
AAdd( aValExprs, SqlNode( ND_LIT, aSelRows[ i ][ nTuple ], NIL, NIL, NIL ) )
NEXT
AAdd( aRows, aValExprs )
NEXT
ENDIF
ENDIF
aAutoInc := SqlGetAutoIncFields( cTable )
nWA := SqlExecOpenTable( cTable, cAlias )
nTotal := Len( aRows )
/* Snapshot the struct once: field flags (5th element) are used by
* the per-tuple loop below to reject a NIL write into a NOT NULL
* column. Without this the PutValue path would silently coerce
* NIL → 0 / blank and defeat the schema contract. Tables on
* pre-nullable-flag plans return 4-element rows; treat missing
* flag as "unknown → accept NIL" to stay backward-compatible. */
aStructFlags := {}
aStructTypes := {}
aStructRaw := dbStruct()
FOR i := 1 TO Len( aStructRaw )
IF Len( aStructRaw[ i ] ) >= 5 .AND. ValType( aStructRaw[ i ][ 5 ] ) == "N"
AAdd( aStructFlags, aStructRaw[ i ][ 5 ] )
ELSE
AAdd( aStructFlags, 2 ) /* unknown → assume nullable */
ENDIF
AAdd( aStructTypes, Upper( aStructRaw[ i ][ 2 ] ) )
NEXT
/* Pre-flight: explicit column list must reference real columns,
* and tuple width can't exceed the table's column count. Catch
* both up front so we don't silently drop user input — previously
* `INSERT INTO t VALUES (1, 'a', 'extra')` succeeded with the
* 'extra' value dropped, and `INSERT INTO t (no_such) VALUES (1)`
* succeeded with the value going nowhere. */
IF Len( aFields ) > 0
FOR i := 1 TO Len( aFields )
IF FieldPos( aFields[ i ] ) == 0
SqlExecCloseTable( cAlias, nWA )
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"INSERT: column '" + aFields[ i ] + ;
"' does not exist in " + cTable )
ENDIF
NEXT
ENDIF
IF nTotal > 0 .AND. Len( aFields ) == 0 .AND. Len( aRows[ 1 ] ) > FCount()
SqlExecCloseTable( cAlias, nWA )
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"INSERT: " + hb_NToS( Len( aRows[ 1 ] ) ) + ;
" values supplied but " + cTable + " has " + ;
hb_NToS( FCount() ) + " columns" )
ENDIF
FOR nTuple := 1 TO nTotal
aValExprs := aRows[ nTuple ]
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 ] )
/* NOT NULL guard: reject explicit NIL into a column
* whose Flags lack the nullable bit (0x02). dbDelete
* rolls back the phantom record so the user sees the
* old table state. */
IF xVal == NIL .AND. nFPos <= Len( aStructFlags ) .AND. ;
hb_BitAnd( aStructFlags[ nFPos ], 2 ) == 0
dbDelete()
dbCommit()
IF nWA == 0
dbCloseArea()
ENDIF
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"NOT NULL violation: column " + aFields[ i ] + ;
" on table " + cTable )
ENDIF
xVal := SqlCoerceToCol( xVal, aStructTypes, nFPos )
/* Numeric overflow guard: Str() returns a string of '*'
* when the value doesn't fit in the column's (width,dec).
* Without this, INSERT INTO n(N(4,0)) VALUES (99999999)
* silently stored 0 (or garbage) — DBF's N codec writes
* the truncated representation instead of erroring out. */
IF nFPos <= Len( aStructTypes ) .AND. aStructTypes[ nFPos ] == "N" .AND. ;
ValType( xVal ) == "N"
IF '*' $ Str( xVal, FieldLen( nFPos ), FieldDec( nFPos ) )
dbDelete()
dbCommit()
IF nWA == 0
dbCloseArea()
ENDIF
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"Numeric overflow: " + AllTrim( hb_NToS( xVal ) ) + ;
" does not fit in " + cTable + "." + aFields[ i ] + ;
" (N(" + AllTrim( hb_NToS( FieldLen( nFPos ) ) ) + ;
"," + AllTrim( hb_NToS( FieldDec( nFPos ) ) ) + "))" )
ENDIF
ENDIF
FieldPut( nFPos, xVal )
ENDIF
NEXT
ELSE
FOR i := 1 TO Min( FCount(), Len( aValExprs ) )
xVal := ::EvalExpr( aValExprs[ i ] )
IF xVal == NIL .AND. i <= Len( aStructFlags ) .AND. ;
hb_BitAnd( aStructFlags[ i ], 2 ) == 0
dbDelete()
dbCommit()
IF nWA == 0
dbCloseArea()
ENDIF
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"NOT NULL violation: column " + FieldName( i ) + ;
" on table " + cTable )
ENDIF
xVal := SqlCoerceToCol( xVal, aStructTypes, i )
/* Same overflow guard as the named-columns branch above. */
IF i <= Len( aStructTypes ) .AND. aStructTypes[ i ] == "N" .AND. ;
ValType( xVal ) == "N"
IF '*' $ Str( xVal, FieldLen( i ), FieldDec( i ) )
dbDelete()
dbCommit()
IF nWA == 0
dbCloseArea()
ENDIF
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"Numeric overflow: " + AllTrim( hb_NToS( xVal ) ) + ;
" does not fit in " + cTable + "." + FieldName( i ) + ;
" (N(" + AllTrim( hb_NToS( FieldLen( i ) ) ) + ;
"," + AllTrim( hb_NToS( FieldDec( i ) ) ) + "))" )
ENDIF
ENDIF
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. Iterate every field on the
* just-appended record — using only `aFields` (named-column form)
* would skip the positional `INSERT INTO t VALUES (...)` form and
* let bad FK values slip through silently. SqlValidateFKRecord
* short-circuits for fields with no FK so the per-column scan is
* cheap. Self-FK + multi-row INSERT remains a known limitation:
* the parent area = INSERT area, and SqlValidateFKRecord's
* dbGoTop scan races with the in-flight buffer; symptom is a
* spurious FK violation on row 2+. Single-row + cross-table
* cases work correctly. */
FOR i := 1 TO FCount()
IF ! SqlValidateFKRecord( cTable, FieldName( i ), FieldGet( i ) )
dbDelete()
dbCommit()
IF nWA == 0
dbCloseArea()
ENDIF
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"FOREIGN KEY violation: " + FieldName( i ) + " references missing parent" )
ENDIF
NEXT
/* Validate UNIQUE constraints — the .fsc metadata file lists
* columns declared UNIQUE at CREATE TABLE time. SqlValidateUnique
* scans the table for duplicates, excluding the record we just
* appended. Previously this validator was defined but never
* invoked, so duplicate keys slipped through silently.
* Excludes RecNo() (just-appended row) from the dup scan. */
IF Len( aFields ) > 0
FOR i := 1 TO Len( aFields )
nFPos := FieldPos( aFields[ i ] )
IF nFPos > 0
IF ! SqlValidateUnique( cTable, aFields[ i ], FieldGet( nFPos ), RecNo() )
dbDelete()
dbCommit()
IF nWA == 0
dbCloseArea()
ENDIF
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"UNIQUE constraint violation: " + aFields[ i ] + ;
" = " + SqlQuoteVal( FieldGet( nFPos ) ) )
ENDIF
ENDIF
NEXT
ELSE
FOR i := 1 TO FCount()
IF ! SqlValidateUnique( cTable, FieldName( i ), FieldGet( i ), RecNo() )
dbDelete()
dbCommit()
IF nWA == 0
dbCloseArea()
ENDIF
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"UNIQUE constraint violation: " + FieldName( i ) + ;
" = " + SqlQuoteVal( FieldGet( i ) ) )
ENDIF
NEXT
ENDIF
/* Transaction logging — after validation passes, so a rolled-back
* CHECK/FK failure doesn't leave a spurious INSERT in the log at
* the old record's position. LogRecord must also see the new
* RecNo, which only exists post-dbAppend. */
::oTxn:LogRecord( cAlias, RecNo(), "INSERT" )
nInserted++
NEXT /* end per-tuple loop */
/* Commit once after all tuples succeed. */
IF ! SqlWACacheIsEnabled()
dbCommit()
ENDIF
/* Index maintenance: DBFArea.Append / PutValue do not auto-update
* NTX keys (there's no ordKeyAdd hook yet). Rebuild any attached
* indexes once per INSERT batch so external `SET INDEX TO` readers
* see fresh keys. No-op when no indexes attached. */
IF OrdCount() > 0
OrderListRebuild()
ENDIF
SqlExecCloseTable( cAlias, nWA )
RETURN { { "affected_rows" }, { { nInserted } } }
METHOD RunUpdate() CLASS TSqlExecutor
LOCAL cTable, aSet, xWhere, cAlias, nWA, i, nFPos, xVal
LOCAL nAffected := 0
LOCAL aFPos, aValuePc, pcWhere, lAllOk, cValSrc
LOCAL aPrevVals, lValid
LOCAL hConstraints, lHasConstraints
LOCAL aUpdFlags, aUpdFlagsRaw, nUpdI, aUpdTypes, lForcePrg
LOCAL aUpdRefs, hUpdChanged, hUpdEnf, cParentColUpper, j
LOCAL hUpdConstraints, lHasUniq, lHasCheckOrFk
LOCAL hPcCached
cTable := ::hQuery[ "table" ]
aSet := ::hQuery[ "set" ]
xWhere := ::hQuery[ "where" ]
cAlias := cTable
::aTables := { { cTable, cAlias, "" } }
/* Materialize CTEs first so subqueries inside SET / WHERE that
* reference the CTE alias resolve correctly. Mirrors RunInsert. */
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
/* Pre-flight existence check — SqlExecOpenTable's RECOVER swallows
* the missing-file error (returns 0 with cache disabled or the
* cache num if present), but a dbUseArea panic still escapes if
* the file is genuinely absent. Surface a clean SQL_ERR_NO_TABLE
* here so callers see a structured error instead of a Five
* runtime panic. */
IF ! File( Lower( cTable ) + ".dbf" ) .AND. ! File( cTable + ".dbf" )
RETURN ::MakeError( SQL_ERR_NO_TABLE, ;
"Table '" + cTable + "' does not exist" )
ENDIF
nWA := SqlExecOpenTable( cTable, cAlias )
/* Struct snapshot used by both the fast-path coercion gate and
* the PRG fallback's NOT-NULL / string→date check. Built once up
* front so both paths see the same flags/types. */
aUpdFlags := {}
aUpdTypes := {}
aUpdFlagsRaw := dbStruct()
FOR nUpdI := 1 TO Len( aUpdFlagsRaw )
IF Len( aUpdFlagsRaw[ nUpdI ] ) >= 5 .AND. ValType( aUpdFlagsRaw[ nUpdI ][ 5 ] ) == "N"
AAdd( aUpdFlags, aUpdFlagsRaw[ nUpdI ][ 5 ] )
ELSE
AAdd( aUpdFlags, 2 )
ENDIF
AAdd( aUpdTypes, Upper( aUpdFlagsRaw[ nUpdI ][ 2 ] ) )
NEXT
/* Gate fast path out when any SET assigns a string ND_LIT to a
* DATE column: SqlExprToPrg would emit a raw string literal into
* the pcode, which SqlBulkUpdate then writes via the D-field
* codec as 8 blanks (its empty-date marker). The PRG path runs
* SqlCoerceToCol before FieldPut, so the string parses through
* CToD as the user intended. */
lForcePrg := .F.
IF aSet != NIL
FOR i := 1 TO Len( aSet )
nFPos := FieldPos( aSet[ i ][ 1 ] )
IF nFPos > 0 .AND. nFPos <= Len( aUpdTypes ) .AND. aUpdTypes[ nFPos ] == "D"
IF ValType( aSet[ i ][ 2 ] ) == "A" .AND. Len( aSet[ i ][ 2 ] ) >= 2 .AND. ;
aSet[ i ][ 2 ][ 1 ] == ND_LIT .AND. ValType( aSet[ i ][ 2 ][ 2 ] ) == "C"
lForcePrg := .T.
EXIT
ENDIF
ENDIF
NEXT
ENDIF
/* Fast path: compile WHERE + every SET value to pcode and delegate
* to Go RTL SqlBulkUpdate — skips per-record Go↔PRG boundary.
* Conditions: no active transaction (txn log records can't be
* emitted from inside the Go loop), no subquery / CASE / other
* nodes that PcCompile can't handle (try/fail pattern).
*
* Per-plan cache: when cCacheKey is set (TFiveSQL supplies it for
* plan-cached queries), we stash the compiled pcode under that key
* so subsequent identical UPDATEs skip the SqlExprToPrg + PcCompile
* walk entirely. The cached pcode is valid as long as the plan
* itself lives in the plan cache — which is forever in-process. */
/* Tables with UNIQUE constraints must go through the PRG loop so
* SqlValidateUnique can fire per record. SqlBulkUpdate is a
* per-row byte write with no constraint callback hook, so a
* fast-path UPDATE that writes a duplicate value would have
* silently committed. */
/* hUpdConstraints / lHasUniq / lHasCheckOrFk hoisted to function-top
* LOCALs above; mid-function LOCAL combined with newly-added top
* LOCALs caused slot aliasing → lHasUniq came back non-logical. */
hUpdConstraints := SqlLoadConstraints( cTable )
lHasUniq := Len( hUpdConstraints[ "unique" ] ) > 0
/* SqlBulkUpdate has no CHECK / FK callback hook — any table
* carrying CHECK or FK constraints must take the PRG path so
* SqlValidateCheckRecord / SqlValidateFKRecord actually fire. */
lHasCheckOrFk := Len( hUpdConstraints[ "check" ] ) > 0 .OR. ;
Len( hUpdConstraints[ "fk" ] ) > 0
/* ON UPDATE enforcement: if any sibling table FK-references this
* one, drop into the PRG path so we can compare per-row old/new
* values and fire CASCADE / SET NULL / RESTRICT. SqlBulkUpdate is
* a per-byte writer with no callback hook. */
aUpdRefs := SqlFindReferencingFKs( cTable )
IF ! ::oTxn:IsActive() .AND. ! lHasUniq .AND. ! lHasCheckOrFk .AND. ! lForcePrg .AND. ;
Len( aUpdRefs ) == 0
/* hPcCached hoisted to function-top LOCAL list. */
IF ! Empty( ::cCacheKey ) .AND. hb_HHasKey( s_hDmlPcodeCache, ::cCacheKey )
hPcCached := s_hDmlPcodeCache[ ::cCacheKey ]
nAffected := SqlBulkUpdate( hPcCached[ "set_fpos" ], ;
hPcCached[ "where_pc" ], ;
hPcCached[ "set_pc" ] )
IF ! SqlWACacheIsEnabled()
dbCommit()
ENDIF
SqlExecCloseTable( cAlias, nWA )
RETURN { { "affected_rows" }, { { nAffected } } }
ENDIF
aFPos := {}
aValuePc := {}
lAllOk := .T.
FOR i := 1 TO Len( aSet )
nFPos := FieldPos( aSet[ i ][ 1 ] )
IF nFPos <= 0
lAllOk := .F.
EXIT
ENDIF
cValSrc := ::SqlExprToPrg( aSet[ i ][ 2 ] )
IF cValSrc == NIL
lAllOk := .F.
EXIT
ENDIF
AAdd( aFPos, nFPos )
AAdd( aValuePc, PcCompile( cValSrc ) )
IF ATail( aValuePc ) == NIL
lAllOk := .F.
EXIT
ENDIF
NEXT
pcWhere := NIL
IF lAllOk .AND. xWhere != NIL
cValSrc := ::SqlExprToPrg( xWhere )
IF cValSrc == NIL
lAllOk := .F.
ELSE
pcWhere := PcCompile( cValSrc )
IF pcWhere == NIL
lAllOk := .F.
ENDIF
ENDIF
ENDIF
IF lAllOk
nAffected := SqlBulkUpdate( aFPos, pcWhere, aValuePc )
/* Populate the per-plan cache for subsequent calls. */
IF ! Empty( ::cCacheKey )
IF Len( s_hDmlPcodeCache ) >= SQL_DML_PCODE_CACHE_MAX
s_hDmlPcodeCache := { => }
ENDIF
s_hDmlPcodeCache[ ::cCacheKey ] := { ;
"set_fpos" => aFPos, ;
"set_pc" => aValuePc, ;
"where_pc" => pcWhere }
ENDIF
/* Defer commit under WA cache — batched at Disable/exit. */
IF ! SqlWACacheIsEnabled()
dbCommit()
ENDIF
SqlExecCloseTable( cAlias, nWA )
RETURN { { "affected_rows" }, { { nAffected } } }
ENDIF
ENDIF
/* Fallback: PRG scan loop — handles txn logging + non-compilable
* expressions (subquery, complex CASE, UDF in value or WHERE).
*
* Validates CHECK + FK only when the table has SQL-level
* constraints (a `.fsc` metadata file exists). Tables created
* via plain dbCreate have no constraints and skip the validator
* entirely — avoids a recursive five_SQL call inside every
* UPDATE's SqlValidateCheckRecord on transaction-active paths
* where the re-entry would deadlock the executor state. */
hConstraints := SqlLoadConstraints( cTable )
lHasConstraints := Len( hConstraints[ "check" ] ) > 0 .OR. ;
Len( hConstraints[ "fk" ] ) > 0
/* aUpdFlags / aUpdTypes populated earlier for both fast-path and
* PRG paths. Per-SET NIL-into-NOT-NULL check runs below. */
dbGoTop()
WHILE ! Eof()
IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) )
IF dbRLock( RecNo() )
::oTxn:LogRecord( cAlias, RecNo(), "UPDATE" )
aPrevVals := {}
lValid := .T.
FOR i := 1 TO Len( aSet )
nFPos := FieldPos( aSet[ i ][ 1 ] )
IF nFPos > 0
AAdd( aPrevVals, { nFPos, FieldGet( nFPos ) } )
xVal := ::EvalExpr( aSet[ i ][ 2 ] )
IF xVal == NIL .AND. nFPos <= Len( aUpdFlags ) .AND. ;
hb_BitAnd( aUpdFlags[ nFPos ], 2 ) == 0
lValid := .F.
EXIT
ENDIF
xVal := SqlCoerceToCol( xVal, aUpdTypes, nFPos )
FieldPut( nFPos, xVal )
ENDIF
NEXT
IF ! lValid
FOR i := 1 TO Len( aPrevVals )
FieldPut( aPrevVals[ i ][ 1 ], aPrevVals[ i ][ 2 ] )
NEXT
dbRUnlock( RecNo() )
SqlExecCloseTable( cAlias, nWA )
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"NOT NULL violation on UPDATE of " + cTable )
ENDIF
lValid := .T.
IF lHasConstraints
lValid := SqlValidateCheckRecord( cTable )
IF lValid
FOR i := 1 TO Len( aSet )
nFPos := FieldPos( aSet[ i ][ 1 ] )
IF nFPos > 0 .AND. ;
! SqlValidateFKRecord( cTable, aSet[ i ][ 1 ], FieldGet( nFPos ) )
lValid := .F.
EXIT
ENDIF
NEXT
ENDIF
ENDIF
/* UNIQUE validation runs independently of hasConstraints
* because UNIQUE is tracked in the same .fsc but a table
* can declare only UNIQUE without CHECK/FK. Excludes the
* row we're currently editing so a self-match on the
* same column doesn't trigger a false positive. */
IF lValid
FOR i := 1 TO Len( aSet )
nFPos := FieldPos( aSet[ i ][ 1 ] )
IF nFPos > 0 .AND. ;
! SqlValidateUnique( cTable, aSet[ i ][ 1 ], FieldGet( nFPos ), RecNo() )
lValid := .F.
EXIT
ENDIF
NEXT
ENDIF
IF ! lValid
/* Roll back the in-memory field changes before unlocking. */
FOR i := 1 TO Len( aPrevVals )
FieldPut( aPrevVals[ i ][ 1 ], aPrevVals[ i ][ 2 ] )
NEXT
dbRUnlock( RecNo() )
SqlExecCloseTable( cAlias, nWA )
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
"UPDATE constraint violation on " + cTable )
ENDIF
/* ON UPDATE enforcement: if a parent column referenced
* by any sibling FK changed, fire CASCADE / SET NULL /
* RESTRICT against the children. Build the change hash
* from aPrevVals (old) + current FieldGet (new). Only
* include columns whose value actually moved — equal
* old/new must NOT trigger enforcement (otherwise an
* idempotent UPDATE fires spurious cascades). */
IF Len( aUpdRefs ) > 0
hUpdChanged := { => }
FOR i := 1 TO Len( aPrevVals )
cParentColUpper := Upper( FieldName( aPrevVals[ i ][ 1 ] ) )
xVal := FieldGet( aPrevVals[ i ][ 1 ] )
/* NIL-safe change detection. `xVal == aPrevVals[..]`
* panics when either side is NIL — switch to type-aware
* comparison: both NIL → unchanged, exactly one NIL →
* changed, otherwise direct ==. */
lValid := .T.
IF xVal == NIL .AND. aPrevVals[ i ][ 2 ] == NIL
lValid := .F. /* unchanged: both NULL */
ELSEIF xVal == NIL .OR. aPrevVals[ i ][ 2 ] == NIL
lValid := .T. /* changed: NULL ↔ value */
ELSE
lValid := ! ( xVal == aPrevVals[ i ][ 2 ] )
ENDIF
IF lValid
/* Only register if some sibling FK actually
* targets this column — keeps the changes hash
* sparse and avoids enforcement work for
* non-key updates. */
FOR j := 1 TO Len( aUpdRefs )
IF Upper( aUpdRefs[ j ][ 3 ] ) == cParentColUpper
hUpdChanged[ cParentColUpper ] := { aPrevVals[ i ][ 2 ], xVal }
EXIT
ENDIF
NEXT
ENDIF
NEXT
IF Len( hUpdChanged ) > 0
hUpdEnf := SqlEnforceUpdateRefs( cTable, aUpdRefs, hUpdChanged )
IF ! hUpdEnf[ "ok" ]
/* Roll back this row before bubbling the FK
* RESTRICT error so the user-visible state
* matches "no change applied to this record". */
FOR i := 1 TO Len( aPrevVals )
FieldPut( aPrevVals[ i ][ 1 ], aPrevVals[ i ][ 2 ] )
NEXT
dbRUnlock( RecNo() )
SqlExecCloseTable( cAlias, nWA )
RETURN ::MakeError( SQL_ERR_GRAMMAR, ;
hUpdEnf[ "error" ] )
ENDIF
ENDIF
ENDIF
dbRUnlock( RecNo() )
nAffected++
ENDIF
ENDIF
dbSkip()
ENDDO
IF ! SqlWACacheIsEnabled()
dbCommit()
ENDIF
/* Same rationale as RunInsert: PutValue skipped index maintenance
* so rebuild attached indexes at the tail. PRG path only — the
* Go-RTL SqlBulkUpdate fast-path already does a targeted rebuild
* when updated fields intersect index key expressions. */
IF nAffected > 0 .AND. OrdCount() > 0
OrderListRebuild()
ENDIF
SqlExecCloseTable( cAlias, nWA )
RETURN { { "affected_rows" }, { { nAffected } } }
METHOD RunDelete() CLASS TSqlExecutor
LOCAL cTable, xWhere, cAlias, nWA
LOCAL nAffected := 0
LOCAL pcWhere, cValSrc, hPcCached
LOCAL aRefs, hEnf, nParentRec, nParentSel
cTable := ::hQuery[ "table" ]
xWhere := ::hQuery[ "where" ]
cAlias := cTable
::aTables := { { cTable, cAlias, "" } }
/* Materialize CTEs first so subqueries inside WHERE that
* reference the CTE alias resolve correctly. Mirrors RunInsert. */
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
/* Pre-flight existence check, mirroring RunInsert / RunUpdate. */
IF ! File( Lower( cTable ) + ".dbf" ) .AND. ! File( cTable + ".dbf" )
RETURN ::MakeError( SQL_ERR_NO_TABLE, ;
"Table '" + cTable + "' does not exist" )
ENDIF
nWA := SqlExecOpenTable( cTable, cAlias )
/* Referential integrity: when any sibling table has a FOREIGN KEY
* pointing at cTable, we MUST take the PRG scan path — it's the
* only one that can evaluate per-row ON DELETE actions (RESTRICT /
* CASCADE / SET NULL). SqlBulkDelete is a pure byte-level delete
* and has no callback hook. No referencing FKs → fast path as
* before, zero overhead for FK-free tables. */
aRefs := SqlFindReferencingFKs( cTable )
/* Fast path: compile WHERE to pcode and delegate to SqlBulkDelete.
* Mirrors the RunUpdate pattern — skipped under an active txn (we
* don't emit LogRecord from inside the Go loop) and when the WHERE
* contains constructs PcCompile can't handle (subquery, UDF) in
* which case SqlExprToPrg returns NIL. */
IF ! ::oTxn:IsActive() .AND. Len( aRefs ) == 0
IF ! Empty( ::cCacheKey ) .AND. hb_HHasKey( s_hDmlPcodeCache, ::cCacheKey )
hPcCached := s_hDmlPcodeCache[ ::cCacheKey ]
nAffected := SqlBulkDelete( hPcCached[ "where_pc" ] )
IF ! SqlWACacheIsEnabled()
dbCommit()
ENDIF
IF nAffected > 0 .AND. OrdCount() > 0
OrderListRebuild()
ENDIF
SqlExecCloseTable( cAlias, nWA )
RETURN { { "affected_rows" }, { { nAffected } } }
ENDIF
pcWhere := NIL
IF xWhere != NIL
cValSrc := ::SqlExprToPrg( xWhere )
IF cValSrc != NIL
pcWhere := PcCompile( cValSrc )
ENDIF
ENDIF
/* pcWhere == NIL when xWhere itself was NIL (delete everything)
* OR compilation failed. Distinguish below: if WHERE was present
* but couldn't compile, fall through to the PRG loop; if WHERE
* was absent, run the fast path with NIL pcode (unconditional). */
IF xWhere == NIL .OR. pcWhere != NIL
nAffected := SqlBulkDelete( pcWhere )
IF ! Empty( ::cCacheKey ) .AND. xWhere != NIL
IF Len( s_hDmlPcodeCache ) >= SQL_DML_PCODE_CACHE_MAX
s_hDmlPcodeCache := { => }
ENDIF
s_hDmlPcodeCache[ ::cCacheKey ] := { "where_pc" => pcWhere }
ENDIF
IF ! SqlWACacheIsEnabled()
dbCommit()
ENDIF
IF nAffected > 0 .AND. OrdCount() > 0
OrderListRebuild()
ENDIF
SqlExecCloseTable( cAlias, nWA )
RETURN { { "affected_rows" }, { { nAffected } } }
ENDIF
ENDIF
/* PRG scan loop — handles active txn (needs LogRecord per row),
* WHEREs that SqlExprToPrg can't compile (subquery, UDF), and
* every DELETE on a table with referencing FOREIGN KEYs. */
SET DELETED ON
dbGoTop()
WHILE ! Eof()
IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) )
IF dbRLock( RecNo() )
/* Enforce referential integrity before logging / deleting.
* CASCADE runs nested DELETEs on the child tables here,
* SET NULL runs a nested UPDATE; both MUST happen under
* the parent's record lock so concurrent inserts against
* the same FK value race against the commit order the
* caller expects. We capture the parent's RecNo + Select
* here because the nested five_SQL calls inside
* SqlEnforceDeleteRefs allocate / close workareas and
* leave the executor's current area on something other
* than the parent. */
IF Len( aRefs ) > 0
nParentRec := RecNo()
nParentSel := Select()
hEnf := SqlEnforceDeleteRefs( cTable, aRefs )
dbSelectArea( nParentSel )
dbGoto( nParentRec )
IF ! hEnf[ "ok" ]
dbRUnlock( nParentRec )
SqlExecCloseTable( cAlias, nWA )
RETURN ::MakeError( SQL_ERR_GRAMMAR, hEnf[ "error" ] )
ENDIF
ENDIF
/* Transaction log the deletion so BEGIN TRANSACTION /
* ROLLBACK can undo it — RunInsert/RunUpdate log, RunDelete
* used to silently skip. */
::oTxn:LogRecord( cAlias, RecNo(), "DELETE" )
dbDelete()
dbRUnlock( RecNo() )
nAffected++
ENDIF
ENDIF
dbSkip()
ENDDO
IF ! SqlWACacheIsEnabled()
dbCommit()
ENDIF
/* Deleted rows' keys must be purged from attached indexes. */
IF nAffected > 0 .AND. OrdCount() > 0
OrderListRebuild()
ENDIF
SqlExecCloseTable( cAlias, nWA )
RETURN { { "affected_rows" }, { { nAffected } } }
/* ======================================================================
* Workarea open/close helpers — consult the Go-native WA cache.
* When the cache is enabled (SqlWACacheEnable), SqlExecOpenTable
* reuses a previously opened workarea instead of running dbUseArea
* every call. SqlExecCloseTable leaves cached entries alive; plain
* (auto-opened, not cached) areas still close as before so tests
* that rely on immediate file release (FErase, UNIQUE index rebuild)
* stay correct when the cache is off — which is the default.
* ====================================================================== */
FUNCTION SqlExecOpenTable( cTable, cAlias )
LOCAL nWA, nCached
nWA := Select( cAlias )
IF nWA > 0
dbSelectArea( nWA )
RETURN nWA
ENDIF
/* Cache hit: the previously stored WA must still be valid and bound
* to the same alias. If a manual close or CLOSE ALL ran behind our
* back, Select() will now report 0 — fall through to fresh open. */
nCached := SqlWACacheGet( cAlias )
IF nCached > 0 .AND. Select( cAlias ) == nCached
dbSelectArea( nCached )
RETURN nCached
ENDIF
IF nCached > 0
SqlWACacheInvalidate( cAlias )
ENDIF
/* Open fresh. Two-step fallback mirrors the prior inline logic so
* callers using mixed-case filenames on case-sensitive filesystems
* still succeed.
*
* SHARED mode (5th arg .T.): a same-table subquery (e.g. `DELETE
* FROM t WHERE v > (SELECT MAX(v) FROM t)`) opens a second
* workarea on the same DBF inside SubqueryCached. With EXCLUSIVE
* the second open errored out → the inner SELECT returned the
* `__error__` envelope and ND_SUB extracted aResult[2][1][1] =
* SQL_ERR_LOCKED (1005). The DML still succeeds with SHARED
* because Five is single-process here; per-record dbRLock
* provides write serialization at the row level. */
BEGIN SEQUENCE
dbUseArea( .T., "DBFNTX", Lower( cTable ) + ".dbf", cAlias, .T., .F. )
RECOVER
dbUseArea( .T., "DBFNTX", cTable + ".dbf", cAlias, .T., .F. )
END SEQUENCE
nWA := Select( cAlias )
/* Auto-attach the known per-table NTX files so DML-side
* OrderListRebuild (called at the tail of RunInsert / RunUpdate /
* RunDelete) actually has indexes to refresh. Without this the
* PK / UNIQUE indexes — built at CREATE TABLE time with 0 rows —
* stay frozen forever and any external `SET INDEX TO ...` read
* sees a stale 0-key tree. Missing file => silent RECOVER, no
* harm to pre-index tables. */
IF nWA > 0
SqlAttachTableIndexes( cTable )
ENDIF
/* Register for reuse. The cache layer is a no-op when disabled, so
* an unconditional Put keeps the caller branch-free. */
IF nWA > 0 .AND. SqlWACacheIsEnabled()
SqlWACachePut( cAlias, nWA )
/* Return 1 sentinel so callers' "if nWA==0 close" gates skip
* — the cache owns the lifecycle now. */
RETURN nWA
ENDIF
RETURN 0 /* caller must close — matches legacy semantics */
/* Attach the convention-named PK / UNIQUE NTX files to the current
* workarea. Both files are produced by CreateTable at CREATE-TABLE
* time; missing files fall into RECOVER silently so pre-index tables
* (and tables with no UNIQUE columns) pay zero cost. */
FUNCTION SqlAttachTableIndexes( cTable )
LOCAL cPk, cUq
cPk := Lower( cTable ) + "_pk.ntx"
cUq := Lower( cTable ) + "_uq.ntx"
IF File( cPk )
BEGIN SEQUENCE
dbSetIndex( cPk )
RECOVER
END SEQUENCE
ENDIF
IF File( cUq )
BEGIN SEQUENCE
dbSetIndex( cUq )
RECOVER
END SEQUENCE
ENDIF
RETURN NIL
FUNCTION SqlExecCloseTable( cAlias, nWA )
/* Only close if THIS call opened it AND the cache didn't adopt it.
* When nWA > 0, the caller either reused a pre-existing area or
* handed ownership to the cache, so we leave it alone. */
IF nWA == 0 .AND. ! SqlWACacheIsEnabled()
dbCloseArea()
ELSEIF nWA == 0 .AND. SqlWACacheIsEnabled() .AND. ;
SqlWACacheGet( cAlias ) == 0
/* Cache enabled but the alias wasn't registered (e.g., open
* failed between Put checks). Keep legacy behavior — close. */
dbCloseArea()
ENDIF
RETURN NIL
/* SqlMaterializeView — execute the view's stored SQL once, dump the
* result into a MEMRDD temp area aliased as cAlias. Lets the rest of
* RunSelect's table-open / FROM-resolve machinery treat the view
* just like any other table. Returns .T. on success.
*
* The view text lives in `<view>.fsv`, written by CreateView. We re-
* parse + run it via a nested TFiveSQL each time the view is opened;
* no result caching at the FS level. Trade-off: simple + always
* fresh, but each SELECT-from-view re-executes the body. Acceptable
* because views are rare on this workload.
*/
FUNCTION SqlMaterializeView( cView, cAlias )
LOCAL cSQL, oV, aR, aFN, i, j, aStruct, cTmpName
LOCAL xS, cType, nWidth, nDec
/* Use MemoRead for the view body — FRead's @byref buffer
* argument has surfaced edge cases in this runtime where the
* destination string stays at its pre-call (Space-padded)
* value, leaving cSQL empty after AllTrim. MemoRead returns the
* file contents directly. */
cSQL := AllTrim( MemoRead( Lower( cView ) + ".fsv" ) )
IF Empty( cSQL )
RETURN .F.
ENDIF
oV := TFiveSQL():New()
aR := oV:Execute( cSQL )
IF ValType( aR ) != "A" .OR. Len( aR ) < 2 .OR. ;
ValType( aR[ 1 ] ) != "A" .OR. Len( aR[ 1 ] ) == 0 .OR. ;
aR[ 1 ][ 1 ] == "__error__"
RETURN .F.
ENDIF
aFN := aR[ 1 ]
/* Build a minimal struct from the result types. View columns get
* sensible widths: numeric → N(15,4), char → C(64), date → D,
* logical → L, anything else → C(255). Scalar widening acceptable
* for read-only view consumers. */
aStruct := {}
FOR i := 1 TO Len( aFN )
xS := NIL
IF Len( aR[ 2 ] ) > 0 .AND. Len( aR[ 2 ][ 1 ] ) >= i
xS := aR[ 2 ][ 1 ][ i ]
ENDIF
DO CASE
CASE ValType( xS ) == "N"
cType := "N" ; nWidth := 15 ; nDec := 4
CASE ValType( xS ) == "D"
cType := "D" ; nWidth := 8 ; nDec := 0
CASE ValType( xS ) == "L"
cType := "L" ; nWidth := 1 ; nDec := 0
CASE ValType( xS ) == "C"
cType := "C" ; nWidth := Max( Len( xS ), 64 ) ; nDec := 0
OTHERWISE
cType := "C" ; nWidth := 64 ; nDec := 0
ENDCASE
AAdd( aStruct, { aFN[ i ], cType, nWidth, nDec } )
NEXT
cTmpName := "mem:__view_" + Lower( cView )
BEGIN SEQUENCE
dbCreate( cTmpName, aStruct, "MEMRDD" )
dbUseArea( .T., "MEMRDD", cTmpName, cAlias, .F., .F. )
FOR i := 1 TO Len( aR[ 2 ] )
dbAppend()
FOR j := 1 TO Min( Len( aFN ), Len( aR[ 2 ][ i ] ) )
FieldPut( j, aR[ 2 ][ i ][ j ] )
NEXT
NEXT
RECOVER
RETURN .F.
END SEQUENCE
RETURN .T.
/* ======================================================================
* 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 := { => }, hEmitted := { => }, i, cKey
LOCAL oSort := TSqlSort():New()
FOR i := 1 TO Len( aRows2 )
cKey := oSort:RowKey( aRows2[ i ] )
hKeys2[ cKey ] := .T.
NEXT
/* SQL standard: INTERSECT is DISTINCT by default — skip rows whose
* composite key was already emitted, not just unmatched ones.
* INTERSECT ALL (retain duplicates) would skip this dedup; we
* don't currently expose that spelling so the plain INTERSECT
* follows the default spec. */
FOR i := 1 TO Len( aRows1 )
cKey := oSort:RowKey( aRows1[ i ] )
IF hb_HHasKey( hKeys2, cKey ) .AND. ! hb_HHasKey( hEmitted, cKey )
AAdd( aResult, aRows1[ i ] )
hEmitted[ cKey ] := .T.
ENDIF
NEXT
RETURN aResult
/* EXCEPT: keep only rows in first that are not in second.
* SQL spec: EXCEPT is DISTINCT by default. */
FUNCTION SqlDoExcept( aRows1, aRows2 )
LOCAL aResult := {}, hKeys2 := { => }, hEmitted := { => }, 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 ) .AND. ! hb_HHasKey( hEmitted, cKey )
AAdd( aResult, aRows1[ i ] )
hEmitted[ cKey ] := .T.
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 )
/* MEMRDD in-memory temp — avoids dbCreate + FErase disk syscalls. */
dbCreate( "mem:" + cTmpFile, aStruct, "MEMRDD" )
/* Open under a scratch alias just to feed SqlBulkInsert, then close
* and re-open under the user-visible alias so RunSelect's open
* loop finds it via Select(cAlias) without trying to dbUseArea on
* a non-existent disk file. */
dbUseArea( .T., "MEMRDD", "mem:" + cTmpFile, "__DRVTMP", .F., .F. )
/* Go RTL SqlBulkInsert — subquery driving-table materialization. */
SqlBulkInsert( aRows2 )
dbSelectArea( Select( "__DRVTMP" ) )
dbCloseArea()
dbUseArea( .T., "MEMRDD", "mem:" + cTmpFile, cAlias, .T., .F. )
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 )
/* Iteration cap — legitimate recursive queries easily exceed 50
* rows (`seq 1..N` for N>50 clipped silently at 51). SQL Server
* defaults to 100, PostgreSQL has no hard cap. Keep a defensive
* ceiling so a cycle-without-termination query doesn't lock up
* the process, but raise it high enough for real workloads. */
WHILE nIter < 10000 .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
/* MEMRDD in-memory temp for CTE — no file create/delete. */
BEGIN SEQUENCE
dbCreate( "mem:" + cTmpFile, aStruct, "MEMRDD" )
RECOVER
END SEQUENCE
BEGIN SEQUENCE
dbUseArea( .T., "MEMRDD", "mem:" + cTmpFile, cAlias, .F., .F. )
/* Go RTL SqlBulkInsert — CTE materialization path. */
SqlBulkInsert( aDataRows )
RECOVER
END SEQUENCE
/* Do NOT rewrite aTables[j][1] to cTmpFile here. RunSelect's
* open loop expects the original CTE name in aTables; with that
* name (a) `Select(cName)` directly returns the area we just
* opened, or (b) the MEMRDD fallback at the end of the open
* loop attaches a second workarea under the user's alias when
* the FROM clause aliased the CTE (e.g. `FROM sub s` with
* recursive CTE + JOIN to a sibling table previously failed
* with "Table '__cte_sub' does not exist"). */
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 aPartitions, 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, lWholePartition
LOCAL aPartColIdx, aSortSpec, nOrdCol
LOCAL nLeftOff, nRightOff, lBoundsOk
/* 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
/* Resolve PARTITION BY columns once, then delegate the row-index
* grouping to Go RTL SqlWindowPartitions — removes N·M per-row
* Go↔PRG boundary crossings for SqlValToStr / hb_HHasKey / AAdd. */
aPartColIdx := {}
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
AAdd( aPartColIdx, nPartCol )
ENDIF
NEXT
ENDIF
aPartitions := SqlWindowPartitions( aRows, aPartColIdx )
/* Pre-resolve ORDER BY column indices once per window column —
* Go SqlWindowSortPartition reads the resolved {nCol, lDesc}
* pairs directly, so every partition sort avoids the repeated
* SqlFindColIdx linear scan inside per-comparison PRG blocks. */
aSortSpec := {}
IF ValType( aOrdBy ) == "A" .AND. Len( aOrdBy ) > 0
FOR j := 1 TO Len( aOrdBy )
nOrdCol := SqlFindColIdx( aOrdBy[ j ][ 1 ], aFN )
IF nOrdCol == 0
nOrdCol := SqlFindColIdx2( SqlExprName( aOrdBy[ j ][ 1 ] ), aFN )
ENDIF
IF nOrdCol > 0
AAdd( aSortSpec, { nOrdCol, aOrdBy[ j ][ 2 ] == "DESC" } )
ENDIF
NEXT
ENDIF
/* Process each partition */
FOR EACH aPartIdx IN aPartitions
/* Sort partition indices by ORDER BY columns (Go RTL). */
IF Len( aSortSpec ) > 0
SqlWindowSortPartition( aRows, aPartIdx, aSortSpec )
ENDIF
/* Compute window function for each row in the partition.
* ROW_NUMBER/RANK/DENSE_RANK all go through one Go RTL call
* that walks the partition and writes the rank column —
* removes per-row SqlWinRowsEqual + PRG indexing overhead. */
DO CASE
CASE cFunc == "ROW_NUMBER" .OR. cFunc == "RANK" .OR. cFunc == "DENSE_RANK"
SqlWindowAssignRank( aRows, aPartIdx, aSortSpec, nColIdx, cFunc )
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
/* Default arg can be a literal (`-1`, `'end'`) but the
* lexer parses `-1` as ND_UNI(-, ND_LIT(1)), not a bare
* ND_LIT — so a flat type-check would silently drop the
* default. Run the value through SqlEvalRowExpr against
* an empty row so any constant expression (including
* unary minus and CAST) collapses to its scalar form. */
xDefault := SqlEvalRowExpr( aFuncArgs[ 3 ], {}, {} )
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
/* Default arg can be a literal (`-1`, `'end'`) but the
* lexer parses `-1` as ND_UNI(-, ND_LIT(1)), not a bare
* ND_LIT — so a flat type-check would silently drop the
* default. Run the value through SqlEvalRowExpr against
* an empty row so any constant expression (including
* unary minus and CAST) collapses to its scalar form. */
xDefault := SqlEvalRowExpr( aFuncArgs[ 3 ], {}, {} )
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 — accumulate, then write.
*
* Per SQL:2003 the default frame is
* RANGE UNBOUNDED PRECEDING (running) — when ORDER BY is present
* whole-partition — when ORDER BY is absent
*
* lWholePartition captures the ORDER-BY-absent case: we
* still make one pass but write the final aggregate to
* every row in the partition instead of the running
* value at each position. Previously the running value
* was always written, so `AVG(sal) OVER ()` returned a
* cumulative average (wrong per spec, matched Oracle's
* buggy output before 12c but diverged from Postgres /
* SQL Server / the SQL standard). */
lWholePartition := ( Len( aSortSpec ) == 0 )
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 ! lWholePartition .AND. 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
IF lWholePartition
/* Write the final (partition-wide) aggregate to every row. */
FOR k := 1 TO Len( aPartIdx )
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
ELSE
/* General frame: try the O(N) fast path in Go RTL.
* SUM/AVG/COUNT go through a prefix-sum sweep; MIN/MAX
* through a monotonic deque. The RTL returns .F. when
* it can't handle the value types (e.g., MIN/MAX over
* string / date columns) so we fall through to the
* O(N·W) loop below — keeps correctness while the
* common numeric case wins. */
IF cFunc == "SUM" .OR. cFunc == "AVG" .OR. cFunc == "COUNT" .OR. ;
cFunc == "MIN" .OR. cFunc == "MAX"
lBoundsOk := .T.
nLeftOff := SqlFrameOffsetEncode( hFrame[ "start" ], @lBoundsOk )
IF lBoundsOk
nRightOff := SqlFrameOffsetEncode( hFrame[ "end" ], @lBoundsOk )
ENDIF
IF lBoundsOk
IF SqlWindowSlideAgg( aRows, aPartIdx, nArgCol, nColIdx, ;
cFunc, nLeftOff, nRightOff )
LOOP /* done with this partition */
ENDIF
ENDIF
ENDIF
/* 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
LOCAL xMatchCond, xNotMatchCond, lDelete
LOCAL lValid, cInsFld
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" ]
/* Extended MERGE clauses the parser already captures but the
* executor was ignoring — WHEN MATCHED AND / WHEN NOT MATCHED
* AND filter conditions, plus WHEN MATCHED THEN DELETE. */
xMatchCond := NIL
xNotMatchCond := NIL
lDelete := .F.
IF hb_HHasKey( ::hQuery, "match_condition" )
xMatchCond := ::hQuery[ "match_condition" ]
ENDIF
IF hb_HHasKey( ::hQuery, "not_match_condition" )
xNotMatchCond := ::hQuery[ "not_match_condition" ]
ENDIF
IF hb_HHasKey( ::hQuery, "matched_delete" )
lDelete := ::hQuery[ "matched_delete" ]
ENDIF
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
/* Apply optional WHEN MATCHED AND <cond>. ON match already
* passed; the AND filter further narrows which matched
* rows get updated / deleted. */
dbSelectArea( nTgtWA )
IF xMatchCond == NIL .OR. SqlIsTrue( ::EvalExpr( xMatchCond ) )
IF dbRLock( RecNo() )
IF lDelete
/* WHEN MATCHED THEN DELETE — mark the row; cleanup
* and FK-cascade happen at dbCommit time. */
dbDelete()
ELSE
FOR i := 1 TO Len( aUpdSet )
nFPos := FieldPos( aUpdSet[ i ][ 1 ] )
IF nFPos > 0
xVal := ::EvalExpr( aUpdSet[ i ][ 2 ] )
FieldPut( nFPos, xVal )
ENDIF
NEXT
ENDIF
dbRUnlock( RecNo() )
nAffected++
ENDIF
ENDIF
ELSEIF ! lMatched .AND. lHasNotMatched
/* WHEN NOT MATCHED [AND <cond>] THEN INSERT */
IF xNotMatchCond != NIL .AND. ;
! SqlIsTrue( ::EvalExpr( xNotMatchCond ) )
/* condition false — skip this source row */
ELSE
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
/* Enforce UNIQUE on inserted row — mirrors RunInsert.
* Without this a MERGE could quietly produce a duplicate
* that regular INSERT would have rejected. */
lValid := .T.
IF Len( aInsFlds ) > 0
FOR i := 1 TO Len( aInsFlds )
cInsFld := aInsFlds[ i ]
nFPos := FieldPos( cInsFld )
IF nFPos > 0 .AND. ;
! SqlValidateUnique( cTarget, cInsFld, FieldGet( nFPos ), RecNo() )
lValid := .F.
EXIT
ENDIF
NEXT
ELSE
FOR i := 1 TO FCount()
IF ! SqlValidateUnique( cTarget, FieldName( i ), FieldGet( i ), RecNo() )
lValid := .F.
EXIT
ENDIF
NEXT
ENDIF
IF ! lValid
dbDelete()
ELSE
nAffected++
ENDIF
ENDIF
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. */
/* Sentinels shared with the Go SqlWindowSlideAgg RTL. Keep the
* values in sync — they must match the constants in hbrtl/sqlscan.go
* (frameUnboundedPreceding / frameUnboundedFollowing). */
#define FRAME_UNBOUNDED_PRECEDING -1073741824
#define FRAME_UNBOUNDED_FOLLOWING 1073741824
/* SqlFrameOffsetEncode parses a SQL frame bound string into a
* relative-offset integer suitable for SqlWindowSlideAgg. Sets lOk
* to .F. when the bound can't be encoded (empty, non-numeric N,
* unknown form) so the caller falls through to the O(N*W) loop.
* Supports: "UNBOUNDED PRECEDING", "UNBOUNDED FOLLOWING",
* "CURRENT ROW", "N PRECEDING", "N FOLLOWING". */
FUNCTION SqlFrameOffsetEncode( cBound, lOk )
LOCAL nV
lOk := .T.
IF cBound == NIL .OR. Empty( cBound )
/* Missing bound — treat as CURRENT ROW (same as SqlFrameOffset). */
RETURN 0
ENDIF
IF "UNBOUNDED PRECEDING" $ cBound
RETURN FRAME_UNBOUNDED_PRECEDING
ENDIF
IF "UNBOUNDED FOLLOWING" $ cBound
RETURN FRAME_UNBOUNDED_FOLLOWING
ENDIF
IF "CURRENT ROW" $ cBound
RETURN 0
ENDIF
IF "PRECEDING" $ cBound
nV := Val( cBound )
/* Val() returns 0 on parse failure — reject so we fall back
* rather than silently treating "? PRECEDING" as current row. */
IF nV <= 0
lOk := .F.
RETURN 0
ENDIF
RETURN -nV
ENDIF
IF "FOLLOWING" $ cBound
nV := Val( cBound )
IF nV <= 0
lOk := .F.
RETURN 0
ENDIF
RETURN nV
ENDIF
lOk := .F.
RETURN 0
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, cNulls, nDot
FOR i := 1 TO Len( aOrderBy )
xE := aOrderBy[ i ][ 1 ]
cDir := Upper( aOrderBy[ i ][ 2 ] )
cNulls := iif( Len( aOrderBy[ i ] ) >= 3, Upper( aOrderBy[ i ][ 3 ] ), "" )
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
/* Go SqlOrderBy reads {nCol, lDesc, cNulls}. cNulls empty means
* "default" — NIL sorts as the largest value (NULLs last in ASC,
* NULLs first in DESC). Explicit "FIRST"/"LAST" overrides. */
AAdd( aSpec, { nCol, cDir == "DESC", cNulls } )
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, cJoinType
LOCAL aGoRows
/* Build join specs: { nInnerWA, nInnerKeyField, nOuterKeyField } */
FOR i := 1 TO Len( aJoins )
/* The Go SqlHashJoin RTL is an INNER-join implementation —
* it emits one row per matching outer/inner pair and has no
* null-fill path. OUTER joins (LEFT / RIGHT / FULL) must fall
* back to PRG JoinRecurse so unmatched outer rows still
* appear with NIL inner columns. Before this gate a LEFT JOIN
* silently dropped every outer row without a match. */
cJoinType := ""
IF Len( aJoins[ i ] ) >= 1 .AND. ValType( aJoins[ i ][ 1 ] ) == "C"
cJoinType := Upper( aJoins[ i ][ 1 ] )
ENDIF
IF cJoinType == "LEFT" .OR. cJoinType == "RIGHT" .OR. cJoinType == "FULL"
RETURN NIL
ENDIF
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, cAliasU
LOCAL lLocalAlias, ii, cA
IF xNode == NIL
RETURN NIL
ENDIF
DO CASE
CASE xNode[ 1 ] == ND_LIT
IF ValType( xNode[ 2 ] ) == "N"
/* Use hb_NToS — preserves all decimal digits.
* Str(0.1) returns " 0" (default 0 decimals when
* the type is numeric), which AllTrim collapsed to "0";
* the pcode then ran `WHERE v = 0` for `WHERE v = 0.1` and
* silently returned no rows. Same class of bug for any
* fractional literal (0.5, 1.25, 3.14) and for negative /
* large values that Str's default width clips. */
RETURN hb_NToS( 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
/* Qualified reference — only compile if the alias prefix
* matches one of THIS executor's own tables. FindWA would
* otherwise return an outer-scope workarea with the same
* alias (Select() is case-insensitive across all open
* areas), causing pcode to bind `outer.col` to the inner
* workarea's same-named field. That silently collapses
* correlated predicates like `WHERE dept = e.dept` into
* `WHERE dept = dept` (always true). Returning NIL routes
* the caller to PRG EvalExpr, which handles outer lookup
* through Resolve / ResolveFromOuter / the outer stack. */
cAliasU := Upper( Left( cRef, nDot - 1 ) )
lLocalAlias := .F.
FOR ii := 1 TO Len( ::aTables )
cA := Upper( ::aTables[ ii ][ 2 ] )
IF Empty( cA )
cA := Upper( ::aTables[ ii ][ 1 ] )
ENDIF
IF cA == cAliasU .OR. Upper( ::aTables[ ii ][ 1 ] ) == cAliasU .OR. ;
( Len( ::aTables[ ii ] ) >= 3 .AND. ;
Upper( ::aTables[ ii ][ 3 ] ) == cAliasU )
lLocalAlias := .T.
EXIT
ENDIF
NEXT
IF ! lLocalAlias
RETURN NIL
ENDIF
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
/* Use SqlCmpEq / SqlCmpLt — not Harbour's `==` / `<` — so the
* fast path matches the PRG path: case-insensitive string
* compare after AllTrim, Date↔String coercion via DToS form,
* Numeric↔String leading-digits parse. iif-gate drops rows
* where either side is NIL to enforce SQL three-valued
* logic ("NULL cmp anything → not matched"). Without these
* wrappers `WHERE hired = '20240115'` silently missed rows
* and `WHERE v <> 10` leaked NULL rows — both regressions
* from the bare `==` / `!=` emission. */
CASE cOp == "=" .OR. cOp == "=="
RETURN "iif((" + cL + ")==NIL .OR. (" + cR + ")==NIL, .F., SqlCmpEq((" + cL + "),(" + cR + ")))"
CASE cOp == "<>" .OR. cOp == "!="
RETURN "iif((" + cL + ")==NIL .OR. (" + cR + ")==NIL, .F., !SqlCmpEq((" + cL + "),(" + cR + ")))"
CASE cOp == "<"
RETURN "iif((" + cL + ")==NIL .OR. (" + cR + ")==NIL, .F., SqlCmpLt((" + cL + "),(" + cR + ")))"
CASE cOp == "<="
RETURN "iif((" + cL + ")==NIL .OR. (" + cR + ")==NIL, .F., SqlCmpEq((" + cL + "),(" + cR + ")).OR.SqlCmpLt((" + cL + "),(" + cR + ")))"
CASE cOp == ">"
RETURN "iif((" + cL + ")==NIL .OR. (" + cR + ")==NIL, .F., SqlCmpLt((" + cR + "),(" + cL + ")))"
CASE cOp == ">="
RETURN "iif((" + cL + ")==NIL .OR. (" + cR + ")==NIL, .F., SqlCmpEq((" + cL + "),(" + cR + ")).OR.SqlCmpLt((" + cR + "),(" + cL + ")))"
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
/* --------------------------------------------------------------
* Schema version — accessors for the file-scoped s_nSchemaVer
* counter defined at the top of this module. All SQL plan-cache
* keys embed the current version as a prefix; every DDL calls
* SqlBumpSchemaVer() on success so subsequent SELECTs / DML miss
* the cache and re-resolve columns / indexes against the new
* schema. Called from TFiveSQL (plan cache key build) and
* TSqlDDL (invalidation after CREATE/ALTER/DROP).
* -------------------------------------------------------------- */
FUNCTION SqlSchemaVer()
RETURN s_nSchemaVer
FUNCTION SqlBumpSchemaVer()
s_nSchemaVer++
RETURN s_nSchemaVer