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>
773 lines
24 KiB
Plaintext
773 lines
24 KiB
Plaintext
/*
|
||
* 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
|
||
|
||
|