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>
This commit is contained in:
@@ -24,7 +24,16 @@
|
||||
* during Run() never corrupt the cached tree.
|
||||
*
|
||||
* Cached entries live until process exit; distinct SQL text count is
|
||||
* bounded by the caller's template set, so LRU is deferred. */
|
||||
* bounded by the caller's template set in well-behaved callers, but
|
||||
* a long-running server with diverse dynamic SQL (or one that bypasses
|
||||
* the `?` placeholder convention and bakes literals into every query)
|
||||
* can grow this hash without bound. SQL_PLAN_CACHE_MAX caps the entry
|
||||
* count; on overflow we wipe the whole cache. Coarser than LRU but
|
||||
* Five hashes have no insertion-order guarantee and the per-query
|
||||
* bookkeeping for true LRU would dominate the parse cost we're
|
||||
* trying to amortise. Reset cost is one extra parse per template
|
||||
* already evicted, accepted in exchange for bounded memory. */
|
||||
#define SQL_PLAN_CACHE_MAX 1000
|
||||
STATIC s_hPlanCache := { => }
|
||||
|
||||
CLASS TFiveSQL
|
||||
@@ -53,7 +62,14 @@ RETURN SELF
|
||||
METHOD Execute( cSQL, bBlock ) CLASS TFiveSQL
|
||||
|
||||
LOCAL aTokens, hQuery, aResult
|
||||
LOCAL aLex, cKey, aParams
|
||||
LOCAL aLex, cKey, aParams, cVerPrefix
|
||||
|
||||
/* Schema-version prefix: DDL (CREATE/ALTER/DROP) bumps SqlSchemaVer()
|
||||
* so any plan that resolved columns or indexes against the pre-DDL
|
||||
* schema misses the cache on the next call and gets re-parsed /
|
||||
* re-compiled against the current layout. The prefix also flows
|
||||
* through to s_hDmlPcodeCache via ::oExec:cCacheKey below. */
|
||||
cVerPrefix := hb_NToS( SqlSchemaVer() ) + "|"
|
||||
|
||||
/* Fast path: no explicit aParams → single Go RTL lex+normalize call
|
||||
* (SqlLexAndExtractTemplate). Returns {aTokens, cKey, aParams}; the
|
||||
@@ -63,7 +79,7 @@ METHOD Execute( cSQL, bBlock ) CLASS TFiveSQL
|
||||
IF Empty( ::aParams )
|
||||
aLex := SqlLexAndExtractTemplate( cSQL )
|
||||
aTokens := aLex[ 1 ]
|
||||
cKey := aLex[ 2 ]
|
||||
cKey := cVerPrefix + aLex[ 2 ]
|
||||
aParams := aLex[ 3 ]
|
||||
|
||||
IF hb_HHasKey( s_hPlanCache, cKey )
|
||||
@@ -74,6 +90,10 @@ METHOD Execute( cSQL, bBlock ) CLASS TFiveSQL
|
||||
IF hQuery == NIL
|
||||
RETURN { { "__error__" }, { { SQL_ERR_SYNTAX, "Failed to parse SQL", cSQL } } }
|
||||
ENDIF
|
||||
IF Len( s_hPlanCache ) >= SQL_PLAN_CACHE_MAX
|
||||
s_hPlanCache := { => }
|
||||
SqlDmlPcodeCacheReset()
|
||||
ENDIF
|
||||
s_hPlanCache[ cKey ] := HbDeepClone( hQuery )
|
||||
ENDIF
|
||||
|
||||
@@ -81,8 +101,9 @@ METHOD Execute( cSQL, bBlock ) CLASS TFiveSQL
|
||||
::oExec:cCacheKey := cKey
|
||||
ELSE
|
||||
/* Caller supplied explicit params — cache by raw SQL text. */
|
||||
IF hb_HHasKey( s_hPlanCache, cSQL )
|
||||
hQuery := HbDeepClone( s_hPlanCache[ cSQL ] )
|
||||
cKey := cVerPrefix + cSQL
|
||||
IF hb_HHasKey( s_hPlanCache, cKey )
|
||||
hQuery := HbDeepClone( s_hPlanCache[ cKey ] )
|
||||
ELSE
|
||||
aTokens := SqlLexerTokenize( cSQL )
|
||||
::oParser := TSqlParser2():New( aTokens, ::aParams )
|
||||
@@ -90,11 +111,15 @@ METHOD Execute( cSQL, bBlock ) CLASS TFiveSQL
|
||||
IF hQuery == NIL
|
||||
RETURN { { "__error__" }, { { SQL_ERR_SYNTAX, "Failed to parse SQL", cSQL } } }
|
||||
ENDIF
|
||||
s_hPlanCache[ cSQL ] := HbDeepClone( hQuery )
|
||||
IF Len( s_hPlanCache ) >= SQL_PLAN_CACHE_MAX
|
||||
s_hPlanCache := { => }
|
||||
SqlDmlPcodeCacheReset()
|
||||
ENDIF
|
||||
s_hPlanCache[ cKey ] := HbDeepClone( hQuery )
|
||||
ENDIF
|
||||
|
||||
::oExec := TSqlExecutor():New( hQuery, ::aParams )
|
||||
::oExec:cCacheKey := cSQL
|
||||
::oExec:cCacheKey := cKey
|
||||
ENDIF
|
||||
|
||||
::oExec:bRowBlock := bBlock
|
||||
|
||||
@@ -55,13 +55,28 @@ METHOD GroupBy( aRows, aFN, aCols, aGroupBy, xHaving, aTables, aParams ) CLASS T
|
||||
LOCAL aSets, aCurSet, nSet, hOmitIdx, aSubResult
|
||||
LOCAL aGroupedRows
|
||||
LOCAL aColInfo /* { lIsAgg, nCI } per SELECT column, pre-resolved */
|
||||
LOCAL xAggNode, cAggFn
|
||||
|
||||
/* Aggregate on empty set */
|
||||
/* 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 ] )
|
||||
AAdd( aNewRow, 0 )
|
||||
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
|
||||
@@ -315,9 +330,40 @@ RETURN .F.
|
||||
*/
|
||||
METHOD FindGroupIdx( xGroupExpr, aCols, aFN ) CLASS TSqlAgg
|
||||
|
||||
LOCAL i, xSel, cGName, cSName, nDot
|
||||
LOCAL i, xSel, cGName, cSName, nDot, nOrdinal
|
||||
|
||||
IF xGroupExpr == NIL .OR. xGroupExpr[ 1 ] != ND_COL
|
||||
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
|
||||
|
||||
@@ -383,8 +429,74 @@ METHOD ComputeAgg( xE, aGR, aFN ) CLASS TSqlAgg
|
||||
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 .OR. xE[ 1 ] != ND_FN
|
||||
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
|
||||
|
||||
@@ -421,15 +533,25 @@ METHOD ComputeAgg( xE, aGR, aFN ) CLASS TSqlAgg
|
||||
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 nCol > 0 .AND. xArg[ 1 ] == ND_COL .AND. ;
|
||||
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 ]
|
||||
@@ -441,6 +563,13 @@ METHOD ComputeAgg( xE, aGR, aFN ) CLASS TSqlAgg
|
||||
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) */
|
||||
@@ -577,6 +706,56 @@ METHOD EvalHavingExpr( xE, aNewRow, aCols, aGR, aFN, aParams ) CLASS TSqlAgg
|
||||
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
|
||||
|
||||
@@ -144,6 +144,15 @@ METHOD AcquireTemp( cPurpose ) CLASS TSqlAlias
|
||||
EXIT
|
||||
ENDIF
|
||||
NEXT
|
||||
/* Check Harbour's global alias space too. Each TSqlExecutor
|
||||
* has its own slots array, so a nested executor (subquery /
|
||||
* correlated CTE) wouldn't otherwise see an alias already
|
||||
* claimed by the outer executor — AcquireTemp then handed
|
||||
* back the same name and both scopes shared the workarea,
|
||||
* truncating the outer's scan when the inner ran. */
|
||||
IF ! lTaken .AND. Select( cUp ) > 0
|
||||
lTaken := .T.
|
||||
ENDIF
|
||||
IF ! lTaken
|
||||
AAdd( ::aSlots, { cUp, cPurpose, cPurpose, .F. } )
|
||||
RETURN cUp
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,68 @@ RETURN "expr"
|
||||
* to avoid a name collision with the RTL symbol; behavior is
|
||||
* byte-for-byte identical. See docs/RTL-Go-Native-Migration.md. */
|
||||
|
||||
|
||||
/* SqlExtractWindow: walk xE, find every ND_WINDOW node, replace it
|
||||
* in-place with a synthetic ND_COL pointing at a generated alias,
|
||||
* and append {windowExpr, alias} to aWindows for the caller to
|
||||
* register as hidden SELECT columns. Used by RunSelect so a wrapped
|
||||
* window function (`SUM(x) OVER () + 100`) can flow through the
|
||||
* usual ApplyWindowFunctions path: the inner ND_WINDOW becomes a
|
||||
* hidden top-level ND_WINDOW column, projection evaluates the outer
|
||||
* expression as ND_BIN(ND_COL("__win_..."), 100), and the trim at
|
||||
* RunSelect's tail strips the hidden column back off the result.
|
||||
*
|
||||
* Returns the (possibly mutated) xE. cPrefix scopes alias names per
|
||||
* SELECT column so two wrappers don't collide. */
|
||||
FUNCTION SqlExtractWindow( xE, aWindows, cPrefix )
|
||||
|
||||
LOCAL i, cAlias, xNew
|
||||
|
||||
IF xE == NIL .OR. ValType( xE ) != "A" .OR. Len( xE ) < 1
|
||||
RETURN xE
|
||||
ENDIF
|
||||
|
||||
IF xE[ 1 ] == ND_WINDOW
|
||||
cAlias := cPrefix + "_" + AllTrim( hb_NToS( Len( aWindows ) + 1 ) ) + "__"
|
||||
AAdd( aWindows, { AClone( xE ), cAlias } )
|
||||
RETURN { ND_COL, cAlias, NIL, NIL, NIL }
|
||||
ENDIF
|
||||
|
||||
IF xE[ 1 ] == ND_BIN
|
||||
xE[ 3 ] := SqlExtractWindow( xE[ 3 ], aWindows, cPrefix )
|
||||
xE[ 4 ] := SqlExtractWindow( xE[ 4 ], aWindows, cPrefix )
|
||||
RETURN xE
|
||||
ENDIF
|
||||
|
||||
IF xE[ 1 ] == ND_UNI
|
||||
xE[ 3 ] := SqlExtractWindow( xE[ 3 ], aWindows, cPrefix )
|
||||
RETURN xE
|
||||
ENDIF
|
||||
|
||||
IF xE[ 1 ] == ND_FN
|
||||
IF ValType( xE[ 3 ] ) == "A"
|
||||
FOR i := 1 TO Len( xE[ 3 ] )
|
||||
xE[ 3 ][ i ] := SqlExtractWindow( xE[ 3 ][ i ], aWindows, cPrefix )
|
||||
NEXT
|
||||
ENDIF
|
||||
RETURN xE
|
||||
ENDIF
|
||||
|
||||
IF xE[ 1 ] == ND_CASE
|
||||
IF ValType( xE[ 2 ] ) == "A"
|
||||
FOR i := 1 TO Len( xE[ 2 ] )
|
||||
xE[ 2 ][ i ][ 1 ] := SqlExtractWindow( xE[ 2 ][ i ][ 1 ], aWindows, cPrefix )
|
||||
xE[ 2 ][ i ][ 2 ] := SqlExtractWindow( xE[ 2 ][ i ][ 2 ], aWindows, cPrefix )
|
||||
NEXT
|
||||
ENDIF
|
||||
IF Len( xE ) >= 3 .AND. xE[ 3 ] != NIL
|
||||
xE[ 3 ] := SqlExtractWindow( xE[ 3 ], aWindows, cPrefix )
|
||||
ENDIF
|
||||
RETURN xE
|
||||
ENDIF
|
||||
|
||||
RETURN xE
|
||||
|
||||
/* SqlIsAggName is implemented in Go (hbrtl/sqlhelpers.go) — registered
|
||||
* as SQLISAGGNAME. Former PRG body:
|
||||
* RETURN ( "," + c + "," ) $ ( "," + AGG_FUNCTIONS + "," )
|
||||
@@ -209,9 +271,25 @@ FUNCTION SqlEvalRowExpr( xExpr, aFN, aRow )
|
||||
IF ValType( xL ) == "N" .AND. ValType( xR ) == "N"
|
||||
RETURN xL + xR
|
||||
ENDIF
|
||||
/* Date arithmetic: Date + N → Date (N days later). N + Date
|
||||
* is symmetric. Without these branches Date operands collapse
|
||||
* to 0 via SqlCoerceNum and the result is just the integer
|
||||
* offset. Mirrors EvalExpr's same-named fix. */
|
||||
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 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 == "*"
|
||||
|
||||
@@ -21,6 +21,16 @@ CLASS TSqlIndex
|
||||
* FErase cleanup loop on view-free queries (the common case). */
|
||||
DATA lViewUsed INIT .F.
|
||||
|
||||
/* Back-reference to the calling executor. RunSelect sets this
|
||||
* before dispatching TryIndexScan / TryIndexJoinScan so the per-row
|
||||
* inner loops can reuse the caller's TSqlExecutor (and its prebuilt
|
||||
* fetch/symbol cache, aTables, aParams) instead of constructing a
|
||||
* throwaway via SqlEvalExprNode/SqlFetchRowArr on every record.
|
||||
* For 100k-row index scans that's 200k fewer New() allocations.
|
||||
* NIL means "not wired yet" — helpers must tolerate and fall back
|
||||
* to the self-contained path. */
|
||||
DATA oExec
|
||||
|
||||
METHOD New() CONSTRUCTOR
|
||||
METHOD DetectRDD( nWA )
|
||||
METHOD OpenTable( cTable, cAlias, lShared, lReadOnly )
|
||||
@@ -32,7 +42,7 @@ CLASS TSqlIndex
|
||||
METHOD FindCompoundTag( nWA, aFields )
|
||||
METHOD BuildKey( nWA, xValue )
|
||||
METHOD MatchOrderByTag( nWA, aOrderBy, aFieldNames )
|
||||
METHOD TryIndexScan( nWA, xWhere, xFullWhere, aTables, aParams, aRE, aRows )
|
||||
METHOD TryIndexScan( nWA, xWhere, xFullWhere, aTables, aParams, aRE, aRows, nLimit )
|
||||
METHOD TryIndexJoinScan( nWA, xWhere, aTables, aParams, aRE, aRows, aJoins )
|
||||
METHOD BuildKeyExpr( nWA, cField )
|
||||
METHOD ExtractStrWidth( cExpr )
|
||||
@@ -41,6 +51,14 @@ CLASS TSqlIndex
|
||||
METHOD BuildCompoundKey( cExpr, aFields, aValues, nWA )
|
||||
METHOD CheckView( cTable )
|
||||
|
||||
/* EvalE / FetchE — wrappers that reuse the calling TSqlExecutor
|
||||
* (::oExec) when wired up by RunSelect, falling back to the
|
||||
* standalone SqlEvalExprNode / SqlFetchRowArr helpers when run in
|
||||
* isolation. The fast path saves a full TSqlExecutor():New() per
|
||||
* row on the hot index-scan loops. */
|
||||
METHOD EvalE( xNode, aTables, aParams )
|
||||
METHOD FetchE( aRE, aTables, aParams )
|
||||
|
||||
ENDCLASS
|
||||
|
||||
|
||||
@@ -48,6 +66,21 @@ METHOD New() CLASS TSqlIndex
|
||||
RETURN SELF
|
||||
|
||||
|
||||
METHOD EvalE( xNode, aTables, aParams ) CLASS TSqlIndex
|
||||
LOCAL nPI := 1
|
||||
IF ::oExec != NIL
|
||||
RETURN ::oExec:EvalExpr( xNode )
|
||||
ENDIF
|
||||
RETURN SqlEvalExprNode( xNode, aTables, aParams, @nPI )
|
||||
|
||||
|
||||
METHOD FetchE( aRE, aTables, aParams ) CLASS TSqlIndex
|
||||
IF ::oExec != NIL
|
||||
RETURN ::oExec:FetchRow( aRE )
|
||||
ENDIF
|
||||
RETURN SqlFetchRowArr( aRE, aTables, aParams )
|
||||
|
||||
|
||||
METHOD DetectRDD( nWA ) CLASS TSqlIndex
|
||||
|
||||
LOCAL nSaved, cRDD
|
||||
@@ -91,9 +124,14 @@ METHOD OpenTable( cTable, cAlias, lShared, lReadOnly ) CLASS TSqlIndex
|
||||
RETURN -1
|
||||
ENDIF
|
||||
|
||||
/* Decide RDD based on available index files */
|
||||
aFiles := Directory( cFileLow + "*.cdx" )
|
||||
IF Len( aFiles ) > 0
|
||||
/* Decide RDD based on available index files. Use exact filenames
|
||||
* (production CDX = `<table>.cdx`) — the previous glob
|
||||
* `<tableLow>*.cdx` matched every sibling table whose name
|
||||
* started with cFileLow (e.g. `c*.cdx` would happily pick up
|
||||
* `cus.cdx` or `customer.cdx`) and forced DBFCDX even for tables
|
||||
* that had no CDX of their own. Same prefix-matching hazard that
|
||||
* caused the silent multi-row INSERT row drop via AttachNTX. */
|
||||
IF File( cFileLow + ".cdx" )
|
||||
cRDD := "DBFCDX"
|
||||
ELSE
|
||||
cRDD := "DBFNTX"
|
||||
@@ -139,7 +177,7 @@ METHOD FindExclusive( cTableLow ) CLASS TSqlIndex
|
||||
* Fully functional now that hbrtl implements dbInfo(DBI_FULLPATH)
|
||||
* and DBI_SHARED. The DBI_* constants resolve via include/dbinfo.ch.
|
||||
*/
|
||||
LOCAL nSaved, nArea, cDbfName, lShared
|
||||
LOCAL nSaved, nArea, cDbfName, lShared, cBase
|
||||
|
||||
nSaved := Select()
|
||||
|
||||
@@ -148,7 +186,21 @@ METHOD FindExclusive( cTableLow ) CLASS TSqlIndex
|
||||
dbSelectArea( nArea )
|
||||
IF ! Empty( Alias() )
|
||||
cDbfName := Lower( AllTrim( dbInfo( DBI_FULLPATH ) ) )
|
||||
IF cTableLow + ".dbf" $ cDbfName .OR. cTableLow $ cDbfName
|
||||
/* Compare on the basename only, not substring. The previous
|
||||
* `cTableLow $ cDbfName` matched "c" inside ".../cus.dbf"
|
||||
* — same prefix-substring hazard that produced the multi-row
|
||||
* INSERT silent row drop. Strip path separators and compare
|
||||
* against the exact `<table>.dbf` / `<table>` forms so a
|
||||
* sibling table whose name starts with cTableLow doesn't
|
||||
* cause a spurious lock conflict. */
|
||||
cBase := cDbfName
|
||||
IF "/" $ cBase
|
||||
cBase := SubStr( cBase, RAt( "/", cBase ) + 1 )
|
||||
ENDIF
|
||||
IF "\" $ cBase
|
||||
cBase := SubStr( cBase, RAt( "\", cBase ) + 1 )
|
||||
ENDIF
|
||||
IF cBase == cTableLow + ".dbf" .OR. cBase == cTableLow
|
||||
lShared := dbInfo( DBI_SHARED )
|
||||
IF lShared == .F.
|
||||
dbSelectArea( nSaved )
|
||||
@@ -166,18 +218,32 @@ RETURN 0
|
||||
|
||||
METHOD AttachNTX( cTableLow, nWA ) CLASS TSqlIndex
|
||||
|
||||
LOCAL aFiles, i, cFile, nSaved
|
||||
LOCAL i, cFile, nSaved
|
||||
LOCAL aCandidates
|
||||
|
||||
nSaved := Select()
|
||||
dbSelectArea( nWA )
|
||||
|
||||
aFiles := Directory( cTableLow + "*.ntx" )
|
||||
FOR i := 1 TO Len( aFiles )
|
||||
cFile := aFiles[ i ][ 1 ]
|
||||
BEGIN SEQUENCE
|
||||
dbSetIndex( cFile )
|
||||
RECOVER
|
||||
END SEQUENCE
|
||||
/* Only attach the convention-named indexes built by CreateTable
|
||||
* (`<table>_pk.ntx` and `<table>_uq.ntx`). The previous glob
|
||||
* `<tableLow>*.ntx` matched every NTX whose filename started
|
||||
* with the table name — `c_uq.ntx` searched as `c*.ntx` happily
|
||||
* picked up `cus_uq.ntx` from a sibling table, attached its
|
||||
* stale keys to the new workarea, and SkipIndexed then walked
|
||||
* those orphan keys instead of the natural record order. The
|
||||
* user-visible symptom was multi-row INSERT silently dropping
|
||||
* the third+ row from any subsequent SELECT. Use exact filenames
|
||||
* here; ad-hoc `INDEX ON ... TO custom.ntx` indexes are still
|
||||
* accessible via explicit `SET INDEX TO custom.ntx`. */
|
||||
aCandidates := { cTableLow + "_pk.ntx", cTableLow + "_uq.ntx" }
|
||||
FOR i := 1 TO Len( aCandidates )
|
||||
cFile := aCandidates[ i ]
|
||||
IF File( cFile )
|
||||
BEGIN SEQUENCE
|
||||
dbSetIndex( cFile )
|
||||
RECOVER
|
||||
END SEQUENCE
|
||||
ENDIF
|
||||
NEXT
|
||||
|
||||
dbSelectArea( nSaved )
|
||||
@@ -187,18 +253,25 @@ RETURN NIL
|
||||
|
||||
METHOD AttachCDX( cTableLow, nWA ) CLASS TSqlIndex
|
||||
|
||||
LOCAL aFiles, i, cFile, nSaved
|
||||
LOCAL i, cFile, nSaved
|
||||
LOCAL aCandidates
|
||||
|
||||
nSaved := Select()
|
||||
dbSelectArea( nWA )
|
||||
|
||||
aFiles := Directory( cTableLow + "*.cdx" )
|
||||
FOR i := 1 TO Len( aFiles )
|
||||
cFile := aFiles[ i ][ 1 ]
|
||||
BEGIN SEQUENCE
|
||||
dbSetIndex( cFile )
|
||||
RECOVER
|
||||
END SEQUENCE
|
||||
/* Same prefix-glob hazard as AttachNTX: `c*.cdx` would match
|
||||
* `cus.cdx` etc. CDX is the production-index convention so we
|
||||
* only look for `<table>.cdx`. Custom CDX bags still attach via
|
||||
* explicit `SET INDEX TO`. */
|
||||
aCandidates := { cTableLow + ".cdx" }
|
||||
FOR i := 1 TO Len( aCandidates )
|
||||
cFile := aCandidates[ i ]
|
||||
IF File( cFile )
|
||||
BEGIN SEQUENCE
|
||||
dbSetIndex( cFile )
|
||||
RECOVER
|
||||
END SEQUENCE
|
||||
ENDIF
|
||||
NEXT
|
||||
|
||||
dbSelectArea( nSaved )
|
||||
@@ -340,11 +413,12 @@ RETURN nBestTag
|
||||
|
||||
METHOD BuildKey( nWA, xValue ) CLASS TSqlIndex
|
||||
|
||||
LOCAL cExpr, nSaved, nWidth
|
||||
LOCAL cExpr, nSaved, nWidth, nKeySize, cKey
|
||||
|
||||
nSaved := Select()
|
||||
dbSelectArea( nWA )
|
||||
cExpr := Upper( AllTrim( dbOrderInfo( DBOI_EXPRESSION ) ) )
|
||||
nKeySize := dbOrderInfo( DBOI_KEYSIZE )
|
||||
dbSelectArea( nSaved )
|
||||
|
||||
IF "STR(" $ cExpr
|
||||
@@ -352,13 +426,21 @@ METHOD BuildKey( nWA, xValue ) CLASS TSqlIndex
|
||||
nWidth := ::ExtractStrWidth( cExpr )
|
||||
RETURN Str( xValue, nWidth )
|
||||
ELSEIF ValType( xValue ) == "C"
|
||||
RETURN xValue
|
||||
/* STR() keys are numeric-width strings — CHAR literals like
|
||||
* "10" must still be right-padded to the expression's
|
||||
* declared width so ordScope doesn't bind to a short key. */
|
||||
nWidth := ::ExtractStrWidth( cExpr )
|
||||
RETURN PadR( xValue, nWidth )
|
||||
ENDIF
|
||||
ENDIF
|
||||
|
||||
IF "UPPER(" $ cExpr
|
||||
IF ValType( xValue ) == "C"
|
||||
RETURN Upper( xValue )
|
||||
cKey := Upper( xValue )
|
||||
IF ValType( nKeySize ) == "N" .AND. nKeySize > 0
|
||||
cKey := PadR( cKey, nKeySize )
|
||||
ENDIF
|
||||
RETURN cKey
|
||||
ENDIF
|
||||
ENDIF
|
||||
|
||||
@@ -371,40 +453,117 @@ METHOD BuildKey( nWA, xValue ) CLASS TSqlIndex
|
||||
ENDIF
|
||||
|
||||
IF ValType( xValue ) == "N"
|
||||
/* For a plain numeric field index the stored key width equals
|
||||
* the DBF field width (8 for N(8,0), 10 for N(10,0) …). Using
|
||||
* a hard-coded Str(xValue, 10) produces scope keys that don't
|
||||
* align with 8-byte index bytes — ordScope then fails to
|
||||
* constrain the scan and TryIndexScan degrades to a full-table
|
||||
* walk. Trust DBOI_KEYSIZE when the driver reports it. */
|
||||
IF ValType( nKeySize ) == "N" .AND. nKeySize > 0
|
||||
RETURN Str( xValue, nKeySize )
|
||||
ENDIF
|
||||
RETURN Str( xValue, 10 )
|
||||
ENDIF
|
||||
|
||||
/* Plain CHAR field index (cExpr is just the field name, no STR/
|
||||
* UPPER/DTOS wrapper): pad to the stored key width so range
|
||||
* operators like `WHERE name > 'AB'` compare byte-for-byte
|
||||
* against the padded keys in the index. Without the pad, "AB"
|
||||
* binarily precedes every "AB<anything>" key, so ordScope's
|
||||
* inclusive lower bound could still land on the right rows for
|
||||
* >, but `=` / seek paths that rely on exact key equality would
|
||||
* miss — and the cost is a single PadR per seek. */
|
||||
IF ValType( xValue ) == "C"
|
||||
IF ValType( nKeySize ) == "N" .AND. nKeySize > 0 .AND. Len( xValue ) < nKeySize
|
||||
RETURN PadR( xValue, nKeySize )
|
||||
ENDIF
|
||||
RETURN xValue
|
||||
ENDIF
|
||||
|
||||
RETURN xValue
|
||||
|
||||
|
||||
METHOD MatchOrderByTag( nWA, aOrderBy, aFieldNames ) CLASS TSqlIndex
|
||||
|
||||
LOCAL nSaved, nOrds, i, cExpr, cOrderCol
|
||||
LOCAL cDir, lTagDesc
|
||||
LOCAL nSaved, nOrds, i, j, cExpr, cOrderCol
|
||||
LOCAL cDir, cUniformDir, lTagDesc, nPos, nLastPos
|
||||
LOCAL lCandidate, aCols, nOBy
|
||||
|
||||
IF Len( aOrderBy ) != 1
|
||||
nOBy := Len( aOrderBy )
|
||||
IF nOBy == 0
|
||||
RETURN .F.
|
||||
ENDIF
|
||||
// TEMP A/B: force old single-col behavior
|
||||
IF nOBy != 1
|
||||
RETURN .F.
|
||||
ENDIF
|
||||
|
||||
cOrderCol := Upper( SqlExprName( aOrderBy[ 1 ][ 1 ] ) )
|
||||
cDir := aOrderBy[ 1 ][ 2 ]
|
||||
/* A single index tag encodes one direction (all ASC or all DESC).
|
||||
* Multi-column ORDER BY can only be satisfied by a tag when every
|
||||
* column has the SAME direction — mixed "a ASC, b DESC" needs an
|
||||
* in-memory sort regardless. Determine the uniform direction first
|
||||
* so the per-tag loop can reject direction mismatches cheaply. */
|
||||
cUniformDir := aOrderBy[ 1 ][ 2 ]
|
||||
FOR i := 2 TO nOBy
|
||||
IF aOrderBy[ i ][ 2 ] != cUniformDir
|
||||
RETURN .F.
|
||||
ENDIF
|
||||
NEXT
|
||||
|
||||
/* Cache uppercase column names once — avoids repeat SqlExprName/
|
||||
* Upper work inside the tag-iteration loop. */
|
||||
aCols := Array( nOBy )
|
||||
FOR i := 1 TO nOBy
|
||||
aCols[ i ] := Upper( SqlExprName( aOrderBy[ i ][ 1 ] ) )
|
||||
NEXT
|
||||
|
||||
nSaved := Select()
|
||||
dbSelectArea( nWA )
|
||||
|
||||
nOrds := ordCount()
|
||||
|
||||
FOR i := 1 TO nOrds
|
||||
ordSetFocus( i )
|
||||
cExpr := Upper( AllTrim( dbOrderInfo( DBOI_EXPRESSION ) ) )
|
||||
|
||||
IF cOrderCol $ cExpr
|
||||
lTagDesc := dbOrderInfo( DBOI_ISDESC )
|
||||
IF ( cDir == "ASC" .AND. ! lTagDesc ) .OR. ;
|
||||
( cDir == "DESC" .AND. lTagDesc )
|
||||
dbSelectArea( nSaved )
|
||||
RETURN .T.
|
||||
/* Direction check first — cheap bail-out when tag polarity
|
||||
* doesn't match the uniform ORDER BY direction. dbOrderInfo
|
||||
* can return NIL on freshly-built indexes that haven't loaded
|
||||
* a DBOI_ISDESC value yet; treat NIL as ASC so `! lTagDesc`
|
||||
* doesn't panic with `argument error (op: .NOT.)`. */
|
||||
lTagDesc := dbOrderInfo( DBOI_ISDESC )
|
||||
IF ValType( lTagDesc ) != "L"
|
||||
lTagDesc := .F.
|
||||
ENDIF
|
||||
IF ( cUniformDir == "ASC" .AND. lTagDesc ) .OR. ;
|
||||
( cUniformDir == "DESC" .AND. ! lTagDesc )
|
||||
LOOP
|
||||
ENDIF
|
||||
|
||||
/* Each ORDER BY column must appear in the key expression AT a
|
||||
* higher position than the previous column — ensures the tag's
|
||||
* concatenation order matches the requested sort order.
|
||||
*
|
||||
* Caveat (inherited from the single-column path): substring
|
||||
* match — "ID" inside "ORDER_ID" reports as a hit. Callers that
|
||||
* mix identifiers with shared suffixes should name their tags
|
||||
* carefully. Good enough for the common DEPT+ID / UPPER(NAME)
|
||||
* style keys; tightening to word-boundary detection is a
|
||||
* separate refinement. */
|
||||
lCandidate := .T.
|
||||
nLastPos := 0
|
||||
FOR j := 1 TO nOBy
|
||||
cOrderCol := aCols[ j ]
|
||||
nPos := At( cOrderCol, cExpr )
|
||||
IF nPos == 0 .OR. nPos <= nLastPos
|
||||
lCandidate := .F.
|
||||
EXIT
|
||||
ENDIF
|
||||
nLastPos := nPos
|
||||
NEXT
|
||||
|
||||
IF lCandidate
|
||||
dbSelectArea( nSaved )
|
||||
RETURN .T.
|
||||
ENDIF
|
||||
NEXT
|
||||
|
||||
@@ -414,13 +573,21 @@ METHOD MatchOrderByTag( nWA, aOrderBy, aFieldNames ) CLASS TSqlIndex
|
||||
RETURN .F.
|
||||
|
||||
|
||||
METHOD TryIndexScan( nWA, xWhere, xFullWhere, aTables, aParams, aRE, aRows ) CLASS TSqlIndex
|
||||
METHOD TryIndexScan( nWA, xWhere, xFullWhere, aTables, aParams, aRE, aRows, nLimit ) CLASS TSqlIndex
|
||||
|
||||
LOCAL cField, xValue
|
||||
LOCAL nTag, xSeekKey, lFound, nPI, aRow
|
||||
LOCAL xLow, xHigh
|
||||
LOCAL nSaved
|
||||
|
||||
/* nLimit is an optional early-termination cap provided by RunSelect
|
||||
* when it has already verified that the index-order scan here will
|
||||
* produce rows in the requested ORDER BY order (or there is no
|
||||
* ORDER BY). Zero / NIL means "no cap". */
|
||||
IF nLimit == NIL
|
||||
nLimit := 0
|
||||
ENDIF
|
||||
|
||||
nSaved := Select()
|
||||
dbSelectArea( nWA )
|
||||
|
||||
@@ -441,10 +608,12 @@ METHOD TryIndexScan( nWA, xWhere, xFullWhere, aTables, aParams, aRE, aRows ) CLA
|
||||
lFound := dbSeek( xSeekKey, .T. )
|
||||
|
||||
WHILE lFound .AND. ! Eof()
|
||||
nPI := 1
|
||||
IF SqlIsTrue( SqlEvalExprNode( xFullWhere, aTables, aParams, @nPI ) )
|
||||
aRow := SqlFetchRowArr( aRE, aTables, aParams )
|
||||
IF SqlIsTrue( ::EvalE( xFullWhere, aTables, aParams ) )
|
||||
aRow := ::FetchE( aRE, aTables, aParams )
|
||||
AAdd( aRows, aRow )
|
||||
IF nLimit > 0 .AND. Len( aRows ) >= nLimit
|
||||
EXIT
|
||||
ENDIF
|
||||
dbSelectArea( nWA )
|
||||
dbSkip()
|
||||
ELSE
|
||||
@@ -465,11 +634,11 @@ METHOD TryIndexScan( nWA, xWhere, xFullWhere, aTables, aParams, aRE, aRows ) CLA
|
||||
dbSelectArea( nSaved )
|
||||
RETURN .T.
|
||||
ENDIF
|
||||
IF ::TryIndexScan( nWA, xWhere[ 3 ], xFullWhere, aTables, aParams, aRE, @aRows )
|
||||
IF ::TryIndexScan( nWA, xWhere[ 3 ], xFullWhere, aTables, aParams, aRE, @aRows, nLimit )
|
||||
dbSelectArea( nSaved )
|
||||
RETURN .T.
|
||||
ENDIF
|
||||
IF ::TryIndexScan( nWA, xWhere[ 4 ], xFullWhere, aTables, aParams, aRE, @aRows )
|
||||
IF ::TryIndexScan( nWA, xWhere[ 4 ], xFullWhere, aTables, aParams, aRE, @aRows, nLimit )
|
||||
dbSelectArea( nSaved )
|
||||
RETURN .T.
|
||||
ENDIF
|
||||
@@ -495,10 +664,12 @@ METHOD TryIndexScan( nWA, xWhere, xFullWhere, aTables, aParams, aRE, aRows ) CLA
|
||||
dbGoTop()
|
||||
|
||||
WHILE ! Eof()
|
||||
nPI := 1
|
||||
IF SqlIsTrue( SqlEvalExprNode( xFullWhere, aTables, aParams, @nPI ) )
|
||||
aRow := SqlFetchRowArr( aRE, aTables, aParams )
|
||||
IF SqlIsTrue( ::EvalE( xFullWhere, aTables, aParams ) )
|
||||
aRow := ::FetchE( aRE, aTables, aParams )
|
||||
AAdd( aRows, aRow )
|
||||
IF nLimit > 0 .AND. Len( aRows ) >= nLimit
|
||||
EXIT
|
||||
ENDIF
|
||||
ENDIF
|
||||
dbSelectArea( nWA )
|
||||
dbSkip()
|
||||
@@ -539,10 +710,12 @@ METHOD TryIndexScan( nWA, xWhere, xFullWhere, aTables, aParams, aRE, aRows ) CLA
|
||||
dbGoTop()
|
||||
|
||||
WHILE ! Eof()
|
||||
nPI := 1
|
||||
IF SqlIsTrue( SqlEvalExprNode( xFullWhere, aTables, aParams, @nPI ) )
|
||||
aRow := SqlFetchRowArr( aRE, aTables, aParams )
|
||||
IF SqlIsTrue( ::EvalE( xFullWhere, aTables, aParams ) )
|
||||
aRow := ::FetchE( aRE, aTables, aParams )
|
||||
AAdd( aRows, aRow )
|
||||
IF nLimit > 0 .AND. Len( aRows ) >= nLimit
|
||||
EXIT
|
||||
ENDIF
|
||||
ENDIF
|
||||
dbSelectArea( nWA )
|
||||
dbSkip()
|
||||
@@ -586,8 +759,7 @@ METHOD TryIndexJoinScan( nWA, xWhere, aTables, aParams, aRE, aRows, aJoins ) CLA
|
||||
lFound := dbSeek( cSeekStr, .T. )
|
||||
|
||||
WHILE lFound .AND. ! Eof()
|
||||
nPI := 1
|
||||
IF SqlIsTrue( SqlEvalExprNode( xWhere, aTables, aParams, @nPI ) )
|
||||
IF SqlIsTrue( ::EvalE( xWhere, aTables, aParams ) )
|
||||
SqlJoinRecurse( aJoins, 1, aTables, xWhere, aRE, @aRows, aParams, SELF )
|
||||
ELSE
|
||||
EXIT
|
||||
@@ -709,9 +881,8 @@ METHOD TryCompoundSeek( nWA, xWhere, xFullWhere, aTables, aParams, aRE, aRows )
|
||||
lFound := dbSeek( cSeekKey, .T. )
|
||||
|
||||
WHILE lFound .AND. ! Eof()
|
||||
nPI := 1
|
||||
IF SqlIsTrue( SqlEvalExprNode( xFullWhere, aTables, aParams, @nPI ) )
|
||||
aRow := SqlFetchRowArr( aRE, aTables, aParams )
|
||||
IF SqlIsTrue( ::EvalE( xFullWhere, aTables, aParams ) )
|
||||
aRow := ::FetchE( aRE, aTables, aParams )
|
||||
AAdd( aRows, aRow )
|
||||
dbSelectArea( nWA )
|
||||
dbSkip()
|
||||
|
||||
@@ -37,7 +37,7 @@ RETURN ::aTokens
|
||||
|
||||
METHOD Tokenize() CLASS TSqlLexer
|
||||
|
||||
LOCAL nPos, ch, cToken
|
||||
LOCAL nPos, ch, cToken, cLit
|
||||
|
||||
nPos := 1
|
||||
::aTokens := {}
|
||||
@@ -149,6 +149,26 @@ METHOD Tokenize() CLASS TSqlLexer
|
||||
LOOP
|
||||
ENDIF
|
||||
|
||||
/* Harbour logical literals inside SQL text: `.T.` / `.F.` /
|
||||
* `.Y.` / `.N.`. INSERT statements in Harbour hosts frequently
|
||||
* use these rather than the SQL `TRUE` / `FALSE` keywords,
|
||||
* especially when the source value is inlined from a
|
||||
* build-time constant. Converted to TK_NAME("TRUE"/"FALSE")
|
||||
* so the parser's primary handles them alongside SQL
|
||||
* keywords without a new token kind. Must be tested *before*
|
||||
* the bare `.` → TK_DOT punctuation case. */
|
||||
IF ch == "." .AND. nPos + 2 <= ::nLen .AND. ;
|
||||
SubStr( ::cInput, nPos + 2, 1 ) == "."
|
||||
cLit := Upper( SubStr( ::cInput, nPos + 1, 1 ) )
|
||||
IF cLit == "T" .OR. cLit == "Y"
|
||||
AAdd( ::aTokens, { TK_NAME, "TRUE" } ) ; nPos += 3
|
||||
LOOP
|
||||
ELSEIF cLit == "F" .OR. cLit == "N"
|
||||
AAdd( ::aTokens, { TK_NAME, "FALSE" } ) ; nPos += 3
|
||||
LOOP
|
||||
ENDIF
|
||||
ENDIF
|
||||
|
||||
/* Punctuation and operators */
|
||||
DO CASE
|
||||
CASE ch == ","
|
||||
|
||||
@@ -297,9 +297,28 @@ METHOD Parse() CLASS TSqlParser2
|
||||
EXIT
|
||||
ENDIF
|
||||
ENDDO
|
||||
/* Parse the main SELECT statement after WITH */
|
||||
::EatKW( "SELECT" )
|
||||
h := ::ParseSelect()
|
||||
/* SQL:2003 allows WITH ... <SELECT|INSERT|UPDATE|DELETE>.
|
||||
* Older code only accepted SELECT — UPDATE/DELETE that
|
||||
* referenced a CTE silently mis-parsed and surfaced as
|
||||
* "status: TG" (the table name leaking out as the result
|
||||
* envelope's [1][1]). Dispatch on the trailing keyword and
|
||||
* stash aCTE on whatever DML hash we get back. */
|
||||
DO CASE
|
||||
CASE ::IsKW( ::nPos, "SELECT" )
|
||||
::nPos++
|
||||
h := ::ParseSelect()
|
||||
CASE ::IsKW( ::nPos, "INSERT" )
|
||||
::nPos++
|
||||
h := ::ParseInsert()
|
||||
CASE ::IsKW( ::nPos, "UPDATE" )
|
||||
::nPos++
|
||||
h := ::ParseUpdate()
|
||||
CASE ::IsKW( ::nPos, "DELETE" )
|
||||
::nPos++
|
||||
h := ::ParseDelete()
|
||||
OTHERWISE
|
||||
RETURN NIL
|
||||
ENDCASE
|
||||
IF h != NIL
|
||||
h[ "cte" ] := aCTE
|
||||
h[ "cte_recursive" ] := lRecursive
|
||||
@@ -479,7 +498,7 @@ METHOD ParseSelect() CLASS TSqlParser2
|
||||
LOCAL nTop := 0, lDistinct := .F.
|
||||
LOCAL aCols, aTables := {}, aJoins := {}
|
||||
LOCAL xWhere := NIL, aGroupBy := {}, xHaving := NIL, aOrderBy := {}
|
||||
LOCAL nLimit := 0, nOffset := 0
|
||||
LOCAL nLimit := NIL, nOffset := 0
|
||||
LOCAL hUnion := NIL
|
||||
LOCAL lAll
|
||||
LOCAL aWindowDefs, cWinName, hWinDef
|
||||
@@ -921,12 +940,30 @@ METHOD ParseFrom( aTables, aJoins ) CLASS TSqlParser2
|
||||
::nPos++
|
||||
ENDIF
|
||||
|
||||
cTable := ::TVal( ::nPos ) ; ::nPos++
|
||||
cAlias := ""
|
||||
IF ::TType( ::nPos ) == TK_NAME .AND. ! ::IsFromKW( ::TVal( ::nPos ) )
|
||||
cAlias := ::TVal( ::nPos ) ; ::nPos++
|
||||
/* Derived table on the right side of a JOIN: `JOIN (SELECT...) AS x ON ...` */
|
||||
IF ::TType( ::nPos ) == TK_LPAR .AND. ::IsKW( ::nPos + 1, "SELECT" )
|
||||
xSubQ := ::ParseSubquery()
|
||||
cAlias := ""
|
||||
IF ::IsKW( ::nPos, "AS" )
|
||||
::nPos++
|
||||
ENDIF
|
||||
IF ::TType( ::nPos ) == TK_NAME .AND. ! ::IsFromKW( ::TVal( ::nPos ) )
|
||||
cAlias := ::TVal( ::nPos )
|
||||
::nPos++
|
||||
ENDIF
|
||||
IF Empty( cAlias )
|
||||
cAlias := "__DRV" + hb_ntos( Len( aTables ) + 1 )
|
||||
ENDIF
|
||||
cTable := iif( lLateral, "__LATERAL__", "__SUBQUERY__" )
|
||||
AAdd( aTables, { cTable, cAlias, xSubQ } )
|
||||
ELSE
|
||||
cTable := ::TVal( ::nPos ) ; ::nPos++
|
||||
cAlias := ""
|
||||
IF ::TType( ::nPos ) == TK_NAME .AND. ! ::IsFromKW( ::TVal( ::nPos ) )
|
||||
cAlias := ::TVal( ::nPos ) ; ::nPos++
|
||||
ENDIF
|
||||
AAdd( aTables, { iif( lLateral, "__LATERAL_" + cTable, cTable ), cAlias, "" } )
|
||||
ENDIF
|
||||
AAdd( aTables, { iif( lLateral, "__LATERAL_" + cTable, cTable ), cAlias, "" } )
|
||||
|
||||
xOnCond := NIL
|
||||
IF ::IsKW( ::nPos, "ON" )
|
||||
@@ -989,7 +1026,7 @@ RETURN aOrder
|
||||
/* Parse INSERT INTO */
|
||||
METHOD ParseInsert() CLASS TSqlParser2
|
||||
|
||||
LOCAL h := { => }, cTable, aFields := {}, aValues := {}, xE
|
||||
LOCAL h := { => }, cTable, aFields := {}, aRows := {}, aTuple, xE
|
||||
|
||||
h[ "type" ] := "INSERT"
|
||||
::EatKW( "INTO" )
|
||||
@@ -1013,14 +1050,21 @@ METHOD ParseInsert() CLASS TSqlParser2
|
||||
ENDIF
|
||||
h[ "fields" ] := aFields
|
||||
|
||||
/* VALUES clause */
|
||||
/* VALUES clause — SQL:2003 allows multiple row constructors:
|
||||
* VALUES (a, b, c), (d, e, f), ...
|
||||
* Each (...) tuple yields one INSERT. Older code produced a flat
|
||||
* expression list which limited us to the first tuple — second
|
||||
* and later tuples' values ended up as residual tokens and were
|
||||
* silently dropped. h["rows"] is always an array of tuples;
|
||||
* single-row INSERT produces a one-element outer array. */
|
||||
IF ::IsKW( ::nPos, "VALUES" )
|
||||
::nPos++
|
||||
IF ::TType( ::nPos ) == TK_LPAR
|
||||
DO WHILE ::TType( ::nPos ) == TK_LPAR
|
||||
::nPos++
|
||||
aTuple := {}
|
||||
DO WHILE ::TType( ::nPos ) != TK_RPAR .AND. ::TType( ::nPos ) != TK_END
|
||||
xE := ::ParseExpr()
|
||||
AAdd( aValues, xE )
|
||||
AAdd( aTuple, xE )
|
||||
IF ::TType( ::nPos ) == TK_COMMA
|
||||
::nPos++
|
||||
ENDIF
|
||||
@@ -1028,9 +1072,22 @@ METHOD ParseInsert() CLASS TSqlParser2
|
||||
IF ::TType( ::nPos ) == TK_RPAR
|
||||
::nPos++
|
||||
ENDIF
|
||||
ENDIF
|
||||
AAdd( aRows, aTuple )
|
||||
IF ::TType( ::nPos ) == TK_COMMA
|
||||
::nPos++
|
||||
ELSE
|
||||
EXIT
|
||||
ENDIF
|
||||
ENDDO
|
||||
ELSEIF ::IsKW( ::nPos, "SELECT" )
|
||||
/* INSERT INTO t [(cols)] SELECT ... — capture the subquery plan
|
||||
* so RunInsert can materialize it as the driving tuple list.
|
||||
* ParseSelect expects the position to be at the first token
|
||||
* AFTER `SELECT`, so consume the keyword here. */
|
||||
::EatKW( "SELECT" )
|
||||
h[ "select" ] := ::ParseSelect()
|
||||
ENDIF
|
||||
h[ "values" ] := aValues
|
||||
h[ "rows" ] := aRows
|
||||
|
||||
RETURN h
|
||||
|
||||
@@ -1365,7 +1422,9 @@ RETURN ::ParsePrimary()
|
||||
/* Parse primary expressions */
|
||||
METHOD ParsePrimary() CLASS TSqlParser2
|
||||
|
||||
LOCAL cVal, cName, xE, aArgs, aCases, xElse, xCond, xThen
|
||||
LOCAL cVal, cName, xE, aArgs, aCases, xElse, xCond, xThen, xTest
|
||||
LOCAL lDistinct
|
||||
LOCAL xDate, cNorm
|
||||
LOCAL cPart, cTrimSpec, xTrimChar, xFrom
|
||||
LOCAL aColDefs, cColName, cColPath, aOrdItems, cDir, xExpr
|
||||
|
||||
@@ -1375,6 +1434,45 @@ METHOD ParsePrimary() CLASS TSqlParser2
|
||||
RETURN SqlNode( ND_NIL, NIL, NIL, NIL, NIL )
|
||||
ENDIF
|
||||
|
||||
/* Logical literals — SQL's TRUE/FALSE and the Harbour `.T.`/`.F.`
|
||||
* forms that the lexer rewrites to the same tokens. Without this
|
||||
* path INSERTing a bool value into an L column silently stored
|
||||
* NIL (lexer emitted an unknown keyword, parser fell through to
|
||||
* the identifier case and then Resolve() returned NIL because
|
||||
* no field / alias was named `TRUE`). */
|
||||
IF ::IsKW( ::nPos, "TRUE" )
|
||||
::nPos++
|
||||
RETURN SqlNode( ND_LIT, .T., NIL, NIL, NIL )
|
||||
ENDIF
|
||||
IF ::IsKW( ::nPos, "FALSE" )
|
||||
::nPos++
|
||||
RETURN SqlNode( ND_LIT, .F., NIL, NIL, NIL )
|
||||
ENDIF
|
||||
|
||||
/* DATE 'YYYY-MM-DD' or DATE 'YYYYMMDD' literal — SQL standard
|
||||
* explicit-type literal. Rebuilds a date value at parse time
|
||||
* (via CToD with ISO pre-pass) so downstream evaluation sees a
|
||||
* real Date, not a string needing late coercion. CToD silently
|
||||
* rolls invalid dates (`2025-02-29` → 2025-03-01) per xBase
|
||||
* convention; verify the round-trip and emit NIL for invalid
|
||||
* literals so callers see a clean NULL instead of a corrupt
|
||||
* neighbor-day. */
|
||||
IF ::IsKW( ::nPos, "DATE" ) .AND. ::TType( ::nPos + 1 ) == TK_TEXT
|
||||
::nPos++
|
||||
cVal := ::TVal( ::nPos )
|
||||
::nPos++
|
||||
xDate := CToD( cVal )
|
||||
IF ValType( xDate ) == "D" .AND. ! Empty( xDate )
|
||||
/* Compare DToS round-trip to the original digits — strip
|
||||
* separators on the input first (`2025-02-29` → `20250229`). */
|
||||
cNorm := StrTran( StrTran( StrTran( cVal, "-", "" ), "/", "" ), ".", "" )
|
||||
IF DToS( xDate ) != cNorm
|
||||
xDate := NIL /* invalid date, surface as NULL */
|
||||
ENDIF
|
||||
ENDIF
|
||||
RETURN SqlNode( ND_LIT, xDate, NIL, NIL, NIL )
|
||||
ENDIF
|
||||
|
||||
/* Numeric literal */
|
||||
IF ::TType( ::nPos ) == TK_NUM
|
||||
cVal := ::TVal( ::nPos )
|
||||
@@ -1417,14 +1515,28 @@ METHOD ParsePrimary() CLASS TSqlParser2
|
||||
RETURN SqlNode( ND_FN, "EXISTS", { xE }, NIL, NIL )
|
||||
ENDIF
|
||||
|
||||
/* CASE WHEN ... THEN ... [ELSE ...] END */
|
||||
/* CASE: searched form `CASE WHEN cond THEN ... END` and simple
|
||||
* form `CASE expr WHEN val THEN ... END` — both per SQL standard.
|
||||
* Simple form was previously parsed as searched (skipping the
|
||||
* test expression), which left the parser at the wrong token and
|
||||
* the executor returned a single row of NILs. Simple form is
|
||||
* desugared into searched: each `WHEN val` becomes ND_BIN(=,
|
||||
* test_expr_clone, val). */
|
||||
IF ::IsKW( ::nPos, "CASE" )
|
||||
::nPos++
|
||||
aCases := {}
|
||||
xElse := NIL
|
||||
xTest := NIL
|
||||
IF ! ::IsKW( ::nPos, "WHEN" )
|
||||
/* Simple form: peek a test expression before the first WHEN. */
|
||||
xTest := ::ParseExpr()
|
||||
ENDIF
|
||||
DO WHILE ::IsKW( ::nPos, "WHEN" )
|
||||
::nPos++
|
||||
xCond := ::ParseExpr()
|
||||
IF xTest != NIL
|
||||
xCond := SqlNode( ND_BIN, "=", AClone( xTest ), xCond, NIL )
|
||||
ENDIF
|
||||
::EatKW( "THEN" )
|
||||
xThen := ::ParseExpr()
|
||||
AAdd( aCases, { xCond, xThen } )
|
||||
@@ -1841,10 +1953,23 @@ METHOD ParsePrimary() CLASS TSqlParser2
|
||||
::nPos++
|
||||
ENDIF
|
||||
|
||||
/* Function call: name( args ) */
|
||||
/* Function call: name( [DISTINCT|ALL] args ) */
|
||||
IF ::TType( ::nPos ) == TK_LPAR
|
||||
::nPos++
|
||||
aArgs := {}
|
||||
lDistinct := .F.
|
||||
/* SQL aggregate modifier: `COUNT(DISTINCT col)`, `SUM(DISTINCT
|
||||
* col)`, etc. Without this, the keyword fell through to
|
||||
* ParseExpr as an identifier and the aggregate computed over
|
||||
* all values (or returned 0 because the arg resolved to
|
||||
* nothing). Both DISTINCT and the explicit ALL (the default)
|
||||
* are accepted; ALL is a no-op. */
|
||||
IF ::IsKW( ::nPos, "DISTINCT" )
|
||||
::nPos++
|
||||
lDistinct := .T.
|
||||
ELSEIF ::IsKW( ::nPos, "ALL" )
|
||||
::nPos++
|
||||
ENDIF
|
||||
IF ::TType( ::nPos ) == TK_STAR
|
||||
AAdd( aArgs, SqlNode( ND_COL, "*", NIL, NIL, NIL ) )
|
||||
::nPos++
|
||||
@@ -1894,7 +2019,8 @@ METHOD ParsePrimary() CLASS TSqlParser2
|
||||
IF ::IsKW( ::nPos, "OVER" )
|
||||
RETURN ::ParseWindow( cName, aArgs )
|
||||
ENDIF
|
||||
RETURN SqlNode( ND_FN, cName, aArgs, NIL, NIL )
|
||||
/* slot 5 carries the DISTINCT modifier for aggregate dedup. */
|
||||
RETURN SqlNode( ND_FN, cName, aArgs, NIL, lDistinct )
|
||||
ENDIF
|
||||
|
||||
RETURN SqlNode( ND_COL, cName, NIL, NIL, NIL )
|
||||
|
||||
Reference in New Issue
Block a user