Files
five/_FiveSql2/src/TSqlAgg.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

773 lines
24 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.
/*
* TSqlAgg.prg — GROUP BY aggregation and HAVING filter
*
* 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 "FiveSqlDef.ch"
CLASS TSqlAgg
METHOD New() CONSTRUCTOR
METHOD GroupBy( aRows, aFN, aCols, aGroupBy, xHaving, aTables, aParams )
METHOD FindGroupIdx( xGroupExpr, aCols, aFN )
METHOD ExpandGroupingSets( aGroupBy )
METHOD ExprInSet( xSelExpr, aSet )
METHOD ComputeAgg( xE, aGR, aFN )
METHOD FindColIdx( xExpr, aFN )
METHOD FindColIdx2( cN, aFN )
METHOD EvalHaving( xHaving, aNewRow, aCols, aGroupRows, aFN, aParams )
METHOD HasAgg( aCols )
METHOD EvalHavingExpr( xE, aNewRow, aCols, aGR, aFN, aParams )
ENDCLASS
METHOD New() CLASS TSqlAgg
RETURN SELF
METHOD HasAgg( aCols ) CLASS TSqlAgg
LOCAL i
FOR i := 1 TO Len( aCols )
IF SqlExprHasAgg( aCols[ i ][ 1 ] )
RETURN .T.
ENDIF
NEXT
RETURN .F.
METHOD GroupBy( aRows, aFN, aCols, aGroupBy, xHaving, aTables, aParams ) CLASS TSqlAgg
LOCAL i, j, aGroupRows, aResult := {}
LOCAL aNewRow
LOCAL nGCol, cN, nCI, lPass
LOCAL aGroupIdx := {}
LOCAL aSets, aCurSet, nSet, hOmitIdx, aSubResult
LOCAL aGroupedRows
LOCAL aColInfo /* { lIsAgg, nCI } per SELECT column, pre-resolved */
LOCAL xAggNode, cAggFn
/* Aggregate on empty set — SQL standard semantics:
* COUNT(*) / COUNT(col) → 0
* SUM / AVG / MIN / MAX → NULL
* The old code returned 0 uniformly for every aggregate, which
* looked right for COUNT but silently corrupted the other four. */
IF Len( aRows ) == 0 .AND. ::HasAgg( aCols )
aNewRow := {}
FOR j := 1 TO Len( aCols )
IF SqlExprHasAgg( aCols[ j ][ 1 ] )
xAggNode := aCols[ j ][ 1 ]
cAggFn := ""
IF ValType( xAggNode ) == "A" .AND. Len( xAggNode ) >= 2 .AND. ;
xAggNode[ 1 ] == ND_FN .AND. ValType( xAggNode[ 2 ] ) == "C"
cAggFn := Upper( xAggNode[ 2 ] )
ENDIF
IF cAggFn == "COUNT"
AAdd( aNewRow, 0 )
ELSE
AAdd( aNewRow, NIL )
ENDIF
ELSE
AAdd( aNewRow, NIL )
ENDIF
NEXT
RETURN { aNewRow }
ENDIF
/* SQL:2003 ROLLUP / CUBE / GROUPING SETS — expand into a list of
* flat grouping key sets and run aggregation once per set. Columns
* absent from the current set emit NIL (the standard "subtotal"
* placeholder). */
aSets := ::ExpandGroupingSets( aGroupBy )
IF Len( aSets ) > 1
FOR nSet := 1 TO Len( aSets )
aCurSet := aSets[ nSet ]
/* Recurse with the plain expanded set; no ROLLUP/CUBE nodes */
aSubResult := ::GroupBy( aRows, aFN, aCols, aCurSet, xHaving, aTables, aParams )
/* For each result row, NIL-out any SELECT column whose source
* GROUP BY expression is not in the current set. */
hOmitIdx := { => }
FOR i := 1 TO Len( aCols )
IF ! SqlExprHasAgg( aCols[ i ][ 1 ] )
IF ! ::ExprInSet( aCols[ i ][ 1 ], aCurSet )
hOmitIdx[ i ] := .T.
ENDIF
ENDIF
NEXT
FOR i := 1 TO Len( aSubResult )
FOR j := 1 TO Len( aSubResult[ i ] )
IF hb_HHasKey( hOmitIdx, j )
aSubResult[ i ][ j ] := NIL
ENDIF
NEXT
AAdd( aResult, aSubResult[ i ] )
NEXT
NEXT
RETURN aResult
ENDIF
/* Build group buckets.
* Pre-resolve the GROUP BY columns to their position in the SELECT
* list by matching against the SOURCE expressions in aCols, not the
* alias list in aFN. */
FOR j := 1 TO Len( aGroupBy )
nGCol := ::FindGroupIdx( aGroupBy[ j ], aCols, aFN )
AAdd( aGroupIdx, nGCol )
NEXT
/* Grouping step — delegate to Go RTL SqlGroupRows to collapse
* N·M per-row boundary crossings (SqlValToStr / hb_HHasKey / AAdd)
* into a single call. Aggregates and HAVING stay in PRG because
* they touch too many expression kinds to port cleanly. */
IF Len( aGroupBy ) == 0 .AND. ::HasAgg( aCols )
aGroupedRows := { aRows }
ELSE
aGroupedRows := SqlGroupRows( aRows, aGroupIdx )
ENDIF
/* Pre-resolve per SELECT column: aggregate flag + column index.
* Avoids SqlExprHasAgg + SqlExprName + FindColIdx2 per group. */
aColInfo := Array( Len( aCols ) )
FOR j := 1 TO Len( aCols )
IF SqlExprHasAgg( aCols[ j ][ 1 ] )
aColInfo[ j ] := { .T., 0 }
ELSE
cN := SqlExprName( aCols[ j ][ 1 ] )
nCI := ::FindColIdx2( cN, aFN )
aColInfo[ j ] := { .F., nCI }
ENDIF
NEXT
/* Compute aggregates for each group */
FOR EACH aGroupRows IN aGroupedRows
aNewRow := Array( Len( aCols ) )
FOR j := 1 TO Len( aCols )
IF aColInfo[ j ][ 1 ]
aNewRow[ j ] := ::ComputeAgg( aCols[ j ][ 1 ], aGroupRows, aFN )
ELSE
nCI := aColInfo[ j ][ 2 ]
IF nCI > 0 .AND. Len( aGroupRows ) > 0 .AND. nCI <= Len( aGroupRows[ 1 ] )
aNewRow[ j ] := aGroupRows[ 1 ][ nCI ]
ENDIF
ENDIF
NEXT
/* HAVING filter */
IF xHaving != NIL
lPass := ::EvalHaving( xHaving, aNewRow, aCols, aGroupRows, aFN, aParams )
IF ! lPass
LOOP
ENDIF
ENDIF
AAdd( aResult, aNewRow )
NEXT
RETURN aResult
/* Expand SQL:2003 ROLLUP / CUBE / GROUPING SETS into a list of flat
* grouping sets. Each returned set is an array of expressions that
* would be the plain GROUP BY for one pass of aggregation.
*
* GROUP BY a, ROLLUP(b, c) → {(a,b,c), (a,b), (a)}
* GROUP BY CUBE(a, b) → {(a,b), (a), (b), ()}
* GROUP BY GROUPING SETS ((a,b), (a), ()) → as-is
*
* If aGroupBy is a plain column list with no aggregate-set modifiers,
* returns a single-element list with aGroupBy itself — letting the
* caller short-circuit to the fast path unchanged.
*/
METHOD ExpandGroupingSets( aGroupBy ) CLASS TSqlAgg
LOCAL aSets, aCurrent, i, j, xTerm, aExpand, aNewSets, aBase
LOCAL nBits, nMask, bit, aCubeSet
/* Fast path: no ROLLUP/CUBE/GROUPING SETS node → single set */
aExpand := .F.
FOR i := 1 TO Len( aGroupBy )
IF aGroupBy[ i ] != NIL .AND. ValType( aGroupBy[ i ] ) == "A" .AND. ;
aGroupBy[ i ][ 1 ] == ND_FN .AND. ;
( Upper( aGroupBy[ i ][ 2 ] ) == "ROLLUP" .OR. ;
Upper( aGroupBy[ i ][ 2 ] ) == "CUBE" .OR. ;
Upper( aGroupBy[ i ][ 2 ] ) == "GROUPING SETS" )
aExpand := .T.
EXIT
ENDIF
NEXT
IF ! aExpand
RETURN { aGroupBy }
ENDIF
/* Seed with a single empty set — we'll cross-expand each term */
aSets := { {} }
FOR i := 1 TO Len( aGroupBy )
xTerm := aGroupBy[ i ]
aNewSets := {}
IF xTerm != NIL .AND. ValType( xTerm ) == "A" .AND. xTerm[ 1 ] == ND_FN
DO CASE
CASE Upper( xTerm[ 2 ] ) == "ROLLUP"
/* ROLLUP(c1..cN) → N+1 sets:
* (c1..cN), (c1..cN-1), ..., (c1), ()
* Cross-product: existing × each prefix including empty */
aBase := xTerm[ 3 ]
FOR j := 1 TO Len( aSets )
FOR nBits := Len( aBase ) TO 0 STEP -1
aCurrent := AClone( aSets[ j ] )
FOR nMask := 1 TO nBits
AAdd( aCurrent, aBase[ nMask ] )
NEXT
AAdd( aNewSets, aCurrent )
NEXT
NEXT
CASE Upper( xTerm[ 2 ] ) == "CUBE"
/* CUBE(c1..cN) → 2^N sets (every subset).
* For each bitmask, include cols where bit is set. */
aBase := xTerm[ 3 ]
FOR j := 1 TO Len( aSets )
FOR nMask := 0 TO ( 2 ^ Len( aBase ) ) - 1
aCurrent := AClone( aSets[ j ] )
FOR bit := 1 TO Len( aBase )
IF hb_BitAnd( nMask, hb_BitShift( 1, bit - 1 ) ) != 0
AAdd( aCurrent, aBase[ bit ] )
ENDIF
NEXT
AAdd( aNewSets, aCurrent )
NEXT
NEXT
CASE Upper( xTerm[ 2 ] ) == "GROUPING SETS"
/* Explicit list — each element is a flat list of cols (or ()) */
aBase := xTerm[ 3 ]
FOR j := 1 TO Len( aSets )
FOR nBits := 1 TO Len( aBase )
aCurrent := AClone( aSets[ j ] )
IF ValType( aBase[ nBits ] ) == "A"
FOR nMask := 1 TO Len( aBase[ nBits ] )
AAdd( aCurrent, aBase[ nBits ][ nMask ] )
NEXT
ENDIF
AAdd( aNewSets, aCurrent )
NEXT
NEXT
OTHERWISE
/* Unknown ND_FN in GROUP BY — treat as opaque term */
FOR j := 1 TO Len( aSets )
aCurrent := AClone( aSets[ j ] )
AAdd( aCurrent, xTerm )
AAdd( aNewSets, aCurrent )
NEXT
ENDCASE
ELSE
/* Plain column — append to every existing set */
FOR j := 1 TO Len( aSets )
aCurrent := AClone( aSets[ j ] )
AAdd( aCurrent, xTerm )
AAdd( aNewSets, aCurrent )
NEXT
ENDIF
aSets := aNewSets
NEXT
RETURN aSets
/* Does a SELECT expression reference a column that appears in the
* given grouping set? Used to decide which SELECT cols to NIL out
* when reporting a partial grouping (subtotal) row. */
METHOD ExprInSet( xSelExpr, aSet ) CLASS TSqlAgg
LOCAL i, xG, cSelName, cGName, nDot
IF xSelExpr == NIL .OR. xSelExpr[ 1 ] != ND_COL
RETURN .F.
ENDIF
cSelName := Upper( xSelExpr[ 2 ] )
nDot := At( ".", cSelName )
IF nDot > 0
cSelName := SubStr( cSelName, nDot + 1 )
ENDIF
FOR i := 1 TO Len( aSet )
xG := aSet[ i ]
IF xG != NIL .AND. ValType( xG ) == "A" .AND. xG[ 1 ] == ND_COL
cGName := Upper( xG[ 2 ] )
IF "." $ cGName
cGName := SubStr( cGName, At( ".", cGName ) + 1 )
ENDIF
IF cGName == cSelName
RETURN .T.
ENDIF
ENDIF
NEXT
RETURN .F.
/* Resolve a GROUP BY expression to its column position in the output row.
* Walks the SELECT list's source expressions (aCols[i][1]) rather than
* the alias list (aFN[i]). For `SELECT d.name AS foo GROUP BY d.name`,
* aFN is {"FOO"} but aCols[1][1] is ND_COL "d.name" — we need to match
* the latter, otherwise the group key collapses every row into one
* bucket. Falls back to FindColIdx (alias/name lookup) for cases where
* the GROUP BY uses a simple identifier that isn't in the SELECT list.
*/
METHOD FindGroupIdx( xGroupExpr, aCols, aFN ) CLASS TSqlAgg
LOCAL i, xSel, cGName, cSName, nDot, nOrdinal
IF xGroupExpr == NIL
RETURN 0
ENDIF
/* GROUP BY <ordinal> — `GROUP BY 1` refers to the 1-based position
* of the SELECT-list column, per SQL:1999. Without this the
* literal numeric expression got passed to FindColIdx which only
* understands ND_COL → returned 0 → everything collapsed into
* a single bucket. */
IF xGroupExpr[ 1 ] == ND_LIT .AND. ValType( xGroupExpr[ 2 ] ) == "N"
nOrdinal := Int( xGroupExpr[ 2 ] )
IF nOrdinal >= 1 .AND. nOrdinal <= Len( aCols )
RETURN nOrdinal
ENDIF
RETURN 0
ENDIF
/* GROUP BY <expression> — match against the SELECT list by
* canonical name. Both sides go through SqlExprName so that
* `GROUP BY UPPER(dept)` finds `SELECT UPPER(dept)` even when
* the SELECT-list column is anonymous. Same for arithmetic
* expressions like `salary / 1000`. */
IF xGroupExpr[ 1 ] != ND_COL
cGName := Upper( SqlExprName( xGroupExpr ) )
IF ! Empty( cGName )
FOR i := 1 TO Len( aCols )
cSName := Upper( SqlExprName( aCols[ i ][ 1 ] ) )
IF cSName == cGName
RETURN i
ENDIF
NEXT
ENDIF
RETURN ::FindColIdx( xGroupExpr, aFN )
ENDIF
cGName := Upper( xGroupExpr[ 2 ] )
nDot := At( ".", cGName )
IF nDot > 0
cGName := SubStr( cGName, nDot + 1 )
ENDIF
FOR i := 1 TO Len( aCols )
xSel := aCols[ i ][ 1 ]
IF xSel != NIL .AND. xSel[ 1 ] == ND_COL
cSName := Upper( xSel[ 2 ] )
IF "." $ cSName
cSName := SubStr( cSName, At( ".", cSName ) + 1 )
ENDIF
IF cSName == cGName
RETURN i
ENDIF
ENDIF
NEXT
/* Last resort: alias-based lookup (handles GROUP BY of unrelated cols) */
RETURN ::FindColIdx( xGroupExpr, aFN )
METHOD FindColIdx( xExpr, aFN ) CLASS TSqlAgg
LOCAL cN, i
IF xExpr != NIL .AND. xExpr[ 1 ] == ND_COL
cN := Upper( xExpr[ 2 ] )
IF "." $ cN
cN := SubStr( cN, At( ".", cN ) + 1 )
ENDIF
FOR i := 1 TO Len( aFN )
IF Upper( aFN[ i ] ) == cN
RETURN i
ENDIF
NEXT
ENDIF
RETURN 0
METHOD FindColIdx2( cN, aFN ) CLASS TSqlAgg
LOCAL i
cN := Upper( cN )
FOR i := 1 TO Len( aFN )
IF Upper( aFN[ i ] ) == cN
RETURN i
ENDIF
NEXT
RETURN 0
METHOD ComputeAgg( xE, aGR, aFN ) CLASS TSqlAgg
LOCAL cFunc, cArgName, nCol, i, xVal
LOCAL nCount := 0, nSum := 0, xMin := NIL, xMax := NIL
LOCAL cResult, cSep
LOCAL xArg
LOCAL xL, xR, aFnArgs
LOCAL lDistinct, hSeen, cKey
IF xE == NIL
RETURN 0
ENDIF
/* Outer expression containing aggregates (e.g. MAX(id)+1,
* COUNT(*)*2, SUM(v)-30): the dispatcher routes us here whenever
* SqlExprHasAgg is .T., even if the top-level node is not the
* aggregate itself. Recursively compute inner aggregates, then
* apply the wrapping operator via SqlEvalRowExpr against literal
* stand-ins. Without this guard the early-return below collapsed
* every wrapped aggregate to 0 — silently. */
IF xE[ 1 ] == ND_BIN
xL := ::ComputeAgg( xE[ 3 ], aGR, aFN )
xR := ::ComputeAgg( xE[ 4 ], aGR, aFN )
RETURN SqlEvalRowExpr( ;
{ ND_BIN, xE[ 2 ], { ND_LIT, xL }, { ND_LIT, xR } }, {}, {} )
ENDIF
IF xE[ 1 ] == ND_UNI
xL := ::ComputeAgg( xE[ 3 ], aGR, aFN )
RETURN SqlEvalRowExpr( ;
{ ND_UNI, xE[ 2 ], { ND_LIT, xL } }, {}, {} )
ENDIF
IF xE[ 1 ] == ND_LIT
RETURN xE[ 2 ]
ENDIF
IF xE[ 1 ] == ND_NIL
RETURN NIL
ENDIF
/* Non-aggregate function wrapping aggregates: ROUND(AVG(p),2),
* COALESCE(SUM(x), 0), etc. Recurse into each arg, then dispatch. */
IF xE[ 1 ] == ND_FN .AND. ! SqlIsAggName( xE[ 2 ] )
aFnArgs := {}
FOR i := 1 TO Len( xE[ 3 ] )
AAdd( aFnArgs, ::ComputeAgg( xE[ 3 ][ i ], aGR, aFN ) )
NEXT
RETURN SqlEvalFunc( xE[ 2 ], aFnArgs )
ENDIF
/* CASE wrapping aggregates: `CASE WHEN COUNT(*) > 2 THEN 'many'
* ELSE 'few' END`. SqlExprHasAgg flags the whole CASE as having
* an aggregate, the dispatcher routes here, and the early-out
* below collapsed it to 0 — the literal 'many'/'few' came back
* as 0. Evaluate each WHEN cond + branch / ELSE through the same
* recursive ComputeAgg so the aggregate inside the cond gets
* computed once per group. */
IF xE[ 1 ] == ND_CASE
IF ValType( xE[ 2 ] ) == "A"
FOR i := 1 TO Len( xE[ 2 ] )
xL := ::ComputeAgg( xE[ 2 ][ i ][ 1 ], aGR, aFN )
IF SqlIsTrue( xL )
RETURN ::ComputeAgg( xE[ 2 ][ i ][ 2 ], aGR, aFN )
ENDIF
NEXT
ENDIF
IF Len( xE ) >= 3 .AND. xE[ 3 ] != NIL
RETURN ::ComputeAgg( xE[ 3 ], aGR, aFN )
ENDIF
RETURN NIL
ENDIF
IF xE[ 1 ] != ND_FN
RETURN 0
ENDIF
cFunc := Upper( xE[ 2 ] )
IF Len( xE[ 3 ] ) > 0
xArg := xE[ 3 ][ 1 ]
IF xArg[ 1 ] == ND_COL .AND. xArg[ 2 ] == "*"
IF cFunc == "COUNT"
RETURN Len( aGR )
ENDIF
RETURN 0
ENDIF
cArgName := SqlExprName( xArg )
ELSE
IF cFunc == "COUNT"
RETURN Len( aGR )
ENDIF
RETURN 0
ENDIF
nCol := ::FindColIdx2( cArgName, aFN )
/* Also try the QUALIFIED name from the AST (e.g. "o.amount") since
* the Go SqlHashJoin fast path preserves qualifier prefixes in
* aFieldNames for JOIN disambiguation. SqlExprName strips them
* but the hidden column might be stored with the full qualified form. */
IF nCol == 0 .AND. xArg[ 1 ] == ND_COL .AND. xArg[ 2 ] != cArgName
nCol := ::FindColIdx2( Upper( xArg[ 2 ] ), aFN )
ENDIF
IF nCol == 0 .AND. xArg[ 1 ] == ND_COL
IF cFunc == "COUNT"
RETURN Len( aGR )
ENDIF
RETURN 0
ENDIF
/* DISTINCT modifier: parser stashes a .T. flag in xE[5] when the
* aggregate was written `COUNT(DISTINCT col)` etc. PRG path needs
* a per-value seen-set so duplicates contribute once. Fast path
* (SqlComputeAggSimple) has no DISTINCT support — skip it when
* the modifier is set so PRG handles dedup. */
lDistinct := ( Len( xE ) >= 5 .AND. xE[ 5 ] == .T. )
/* Fast path: plain column + common aggregate → Go RTL single-pass loop.
* Gate on column-ref argument + pre-resolved nCol > 0; complex args
* (CASE/BIN/UDF) still fall through to the PRG loop below. */
IF ! lDistinct .AND. nCol > 0 .AND. xArg[ 1 ] == ND_COL .AND. ;
( cFunc == "COUNT" .OR. cFunc == "SUM" .OR. cFunc == "AVG" .OR. ;
cFunc == "MIN" .OR. cFunc == "MAX" )
RETURN SqlComputeAggSimple( aGR, nCol, cFunc )
ENDIF
IF lDistinct
hSeen := { => }
ENDIF
FOR i := 1 TO Len( aGR )
IF nCol > 0 .AND. nCol <= Len( aGR[ i ] )
xVal := aGR[ i ][ nCol ]
ELSEIF nCol == 0
/* Complex expression (CASE, BIN, etc.) inside aggregate:
* evaluate the expression tree against the current row data. */
xVal := SqlEvalRowExpr( xArg, aFN, aGR[ i ] )
ELSE
xVal := NIL
ENDIF
IF xVal != NIL
IF lDistinct
cKey := SqlValToStr( xVal )
IF hb_HHasKey( hSeen, cKey )
LOOP
ENDIF
hSeen[ cKey ] := .T.
ENDIF
nCount++
nSum += SqlCoerceNum( xVal )
/* Use SqlCmpLt for type-safe comparison (handles strings, dates) */
IF xMin == NIL .OR. SqlCmpLt( xVal, xMin )
xMin := xVal
ENDIF
IF xMax == NIL .OR. SqlCmpLt( xMax, xVal )
xMax := xVal
ENDIF
ENDIF
NEXT
DO CASE
CASE cFunc == "COUNT"
RETURN nCount
CASE cFunc == "SUM"
/* SQL standard: SUM of all NULLs = NULL, not 0 */
RETURN iif( nCount > 0, nSum, NIL )
CASE cFunc == "AVG"
RETURN iif( nCount > 0, nSum / nCount, NIL )
CASE cFunc == "MIN"
RETURN xMin
CASE cFunc == "MAX"
RETURN xMax
CASE cFunc == "GROUP_CONCAT" .OR. cFunc == "STRING_AGG"
cResult := ""
cSep := ", "
FOR i := 1 TO Len( aGR )
IF nCol > 0 .AND. nCol <= Len( aGR[ i ] )
xVal := aGR[ i ][ nCol ]
ELSEIF nCol == 0
xVal := SqlEvalRowExpr( xArg, aFN, aGR[ i ] )
ELSE
xVal := NIL
ENDIF
IF xVal != NIL
IF ! Empty( cResult )
cResult += cSep
ENDIF
cResult += SqlCoerceStr( xVal )
ENDIF
NEXT
RETURN cResult
ENDCASE
RETURN 0
METHOD EvalHaving( xHaving, aNewRow, aCols, aGroupRows, aFN, aParams ) CLASS TSqlAgg
LOCAL xResult, aGo
/* Fast path: Go-native tree walker. Returns {lOk, lPass}; falls back
* to PRG when it hits an unsupported node (subqueries, complex agg
* args, CASE expressions inside HAVING, etc.). */
aGo := SqlEvalHaving( xHaving, aNewRow, aCols, aGroupRows, aFN, aParams )
IF ValType( aGo ) == "A" .AND. Len( aGo ) == 2 .AND. aGo[ 1 ]
RETURN aGo[ 2 ]
ENDIF
xResult := ::EvalHavingExpr( xHaving, aNewRow, aCols, aGroupRows, aFN, aParams )
RETURN SqlIsTrue( xResult )
METHOD EvalHavingExpr( xE, aNewRow, aCols, aGR, aFN, aParams ) CLASS TSqlAgg
LOCAL xL, xR, cOp, i, nCI, cN
IF xE == NIL
RETURN NIL
ENDIF
DO CASE
CASE xE[ 1 ] == ND_LIT
RETURN xE[ 2 ]
CASE xE[ 1 ] == ND_NIL
RETURN NIL
CASE xE[ 1 ] == ND_COL
cN := xE[ 2 ]
IF "." $ cN
cN := SubStr( cN, At( ".", cN ) + 1 )
ENDIF
FOR i := 1 TO Len( aCols )
IF Upper( aCols[ i ][ 2 ] ) == Upper( cN ) .AND. i <= Len( aNewRow )
RETURN aNewRow[ i ]
ENDIF
NEXT
nCI := ::FindColIdx2( cN, aFN )
IF nCI > 0 .AND. Len( aGR ) > 0 .AND. nCI <= Len( aGR[ 1 ] )
RETURN aGR[ 1 ][ nCI ]
ENDIF
RETURN NIL
CASE xE[ 1 ] == ND_FN
IF SqlIsAggName( xE[ 2 ] )
RETURN ::ComputeAgg( xE, aGR, aFN )
ENDIF
RETURN NIL
CASE xE[ 1 ] == ND_BIN
cOp := xE[ 2 ]
IF cOp == "AND"
xL := ::EvalHavingExpr( xE[ 3 ], aNewRow, aCols, aGR, aFN, aParams )
xR := ::EvalHavingExpr( xE[ 4 ], aNewRow, aCols, aGR, aFN, aParams )
RETURN SqlIsTrue( xL ) .AND. SqlIsTrue( xR )
ENDIF
IF cOp == "OR"
xL := ::EvalHavingExpr( xE[ 3 ], aNewRow, aCols, aGR, aFN, aParams )
xR := ::EvalHavingExpr( xE[ 4 ], aNewRow, aCols, aGR, aFN, aParams )
RETURN SqlIsTrue( xL ) .OR. SqlIsTrue( xR )
ENDIF
xL := ::EvalHavingExpr( xE[ 3 ], aNewRow, aCols, aGR, aFN, aParams )
xR := ::EvalHavingExpr( xE[ 4 ], aNewRow, aCols, aGR, aFN, aParams )
xL := SqlCoerceForCmp( xL )
xR := SqlCoerceForCmp( xR )
IF cOp == "=" .OR. cOp == "=="
RETURN SqlCmpEq( xL, xR )
ENDIF
IF cOp == "<>" .OR. cOp == "!="
RETURN ! SqlCmpEq( xL, xR )
ENDIF
IF cOp == ">"
RETURN SqlCmpLt( xR, xL )
ENDIF
IF cOp == "<"
RETURN SqlCmpLt( xL, xR )
ENDIF
IF cOp == ">="
RETURN SqlCmpEq( xL, xR ) .OR. SqlCmpLt( xR, xL )
ENDIF
IF cOp == "<="
RETURN SqlCmpEq( xL, xR ) .OR. SqlCmpLt( xL, xR )
ENDIF
/* Arithmetic inside HAVING: `HAVING SUM(amt)+1 > 200`,
* `HAVING COUNT(*)*100 > 250`, etc. Without these branches
* the wrapped expression returned NIL and the comparison
* with the constant collapsed to false → 0 rows silent.
* SQL NULL propagation: any NIL operand → NIL. */
IF cOp == "+"
IF xL == NIL .OR. xR == NIL
RETURN NIL
ENDIF
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
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 xE[ 1 ] == ND_UNI
IF xE[ 2 ] == "NOT"
xL := ::EvalHavingExpr( xE[ 3 ], aNewRow, aCols, aGR, aFN, aParams )
RETURN ! SqlIsTrue( xL )
ENDIF
RETURN NIL
ENDCASE
RETURN NIL