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

2392 lines
70 KiB
Plaintext

/*
* TSqlParser.prg — Recursive descent SQL parser
*
* FiveSql — SQL Engine for Harbour DBF/NTX
*
* Copyright (c) 2025-2026 Charles KWON (Charles KWON OhJun)
* Email: charleskwonohjun@gmail.com
*
* All rights reserved.
*/
#include "hbclass.ch"
#include "FiveSqlDef.ch"
/*
* SQL:1999-2023 Grammar (PEG notation) — Living documentation.
* When adding new SQL features, add the grammar rule here first,
* then implement the corresponding Parse* method.
*
* statement <- with_stmt / select_stmt / insert_stmt / update_stmt /
* delete_stmt / merge_stmt / create_stmt / drop_stmt /
* alter_stmt / truncate_stmt / transaction_stmt / set_stmt /
* values_stmt / table_stmt / call_stmt
*
* with_stmt <- 'WITH' 'RECURSIVE'? cte_def (',' cte_def)* 'SELECT' select_body
* cte_def <- NAME 'AS' '(' select_body ')'
*
* select_body <- 'DISTINCT'? 'TOP' NUMBER? column_list
* ('FROM' table_refs)?
* ('WHERE' expr)?
* ('GROUP' 'BY' group_by_list ('HAVING' expr)?)?
* ('WINDOW' window_def (',' window_def)*)?
* ('ORDER' 'BY' order_item (',' order_item)*)?
* ('LIMIT' NUMBER ('OFFSET' NUMBER)?)?
* ('OFFSET' NUMBER 'ROWS'? ('FETCH' 'FIRST' NUMBER 'ROWS' ('ONLY'/'WITH' 'TIES'))?)?
* ('FETCH' 'FIRST' NUMBER 'ROWS' ('ONLY'/'WITH' 'TIES'))?
* ('FOR' ('UPDATE'/'SHARE') ('OF' name_list)?)?
* (('UNION' 'ALL'? / 'INTERSECT' / 'EXCEPT') 'SELECT' select_body)?
*
* column_list <- column_item (',' column_item)*
* column_item <- ('*' / expr) ('AS' NAME)?
*
* table_refs <- table_ref (',' table_ref)* join_clause*
* table_ref <- 'LATERAL'? ('(' select_body ')' / NAME) ('AS'? NAME)?
* join_clause <- ('LEFT'/'RIGHT'/'INNER'/'FULL'/'CROSS')? 'OUTER'? 'JOIN'
* ('LATERAL'? table_ref) 'ON' expr
*
* group_by_list <- group_item (',' group_item)*
* group_item <- 'GROUPING' 'SETS' '(' group_set (',' group_set)* ')' /
* 'ROLLUP' '(' expr_list ')' /
* 'CUBE' '(' expr_list ')' /
* expr
* group_set <- '(' expr_list? ')' / expr
*
* expr <- or_expr
* or_expr <- and_expr ('OR' and_expr)*
* and_expr <- not_expr ('AND' not_expr)*
* not_expr <- 'NOT'? cmp_expr
* cmp_expr <- add_expr (cmp_op add_expr /
* 'LIKE' add_expr ('ESCAPE' add_expr)? /
* 'SIMILAR' 'TO' add_expr ('ESCAPE' add_expr)? /
* 'IS' 'DISTINCT' 'FROM' add_expr /
* 'IS' 'NOT' 'DISTINCT' 'FROM' add_expr /
* 'NOT'? 'IN' ('(' select_body ')' / '(' expr_list ')') /
* 'NOT'? 'BETWEEN' add_expr 'AND' add_expr /
* 'IS' 'NOT'? 'NULL')?
* add_expr <- mul_expr (('+'/'-'/'||') mul_expr)*
* mul_expr <- unary_expr (('*'/'/') unary_expr)*
* unary_expr <- '-'? primary
* primary <- 'NULL' / NUMBER / STRING / '?' /
* '(' (select_body / expr) ')' /
* 'EXISTS' '(' select_body ')' /
* 'CASE' ('WHEN' expr 'THEN' expr)+ ('ELSE' expr)? 'END' /
* 'CAST' '(' expr 'AS' type_name ')' /
* 'EXTRACT' '(' part 'FROM' expr ')' /
* 'TRIM' '(' ('LEADING'/'TRAILING'/'BOTH')? expr? 'FROM' expr ')' /
* 'POSITION' '(' expr 'IN' expr ')' /
* 'OVERLAY' '(' expr 'PLACING' expr 'FROM' expr ('FOR' expr)? ')' /
* 'ARRAY' '(' expr_list? ')' /
* 'ROW' '(' expr_list ')' /
* 'JSON_VALUE' '(' expr ',' expr ')' /
* 'JSON_QUERY' '(' expr ',' expr ')' /
* 'JSON_EXISTS' '(' expr ',' expr ')' /
* 'JSON_TABLE' '(' expr ',' expr 'COLUMNS' '(' col_def+ ')' ')' /
* 'JSON_OBJECT' '(' (key ':' expr (',' key ':' expr)*)? ')' /
* 'JSON_ARRAY' '(' expr_list? ')' /
* 'JSON_ARRAYAGG' '(' expr ')' /
* 'JSON_OBJECTAGG' '(' expr ':' expr ')' /
* 'XMLELEMENT' '(' 'NAME' name (',' expr)* ')' /
* 'XMLFOREST' '(' expr ('AS' name)? (',' expr ('AS' name)?)* ')' /
* 'XMLAGG' '(' expr ('ORDER' 'BY' order_item)? ')' /
* 'LISTAGG' '(' expr (',' expr)? ')' ('WITHIN' 'GROUP' '(' 'ORDER' 'BY' order_item+ ')')? /
* NAME '.' NAME / NAME '(' (('*' / expr_list))? ')' ('OVER' window)? /
* NAME
*
* window <- '(' (NAME /
* ('PARTITION' 'BY' expr_list)?
* ('ORDER' 'BY' order_item (',' order_item)*)?
* frame_clause? ) ')'
* window_def <- NAME 'AS' window
* frame_clause <- ('ROWS'/'RANGE'/'GROUPS') frame_extent
* ('EXCLUDE' ('NO' 'OTHERS' / 'CURRENT' 'ROW' / 'GROUP' / 'TIES'))?
* frame_extent <- 'BETWEEN' frame_bound 'AND' frame_bound / frame_bound
* frame_bound <- 'UNBOUNDED' 'PRECEDING' / NUMBER 'PRECEDING' /
* 'CURRENT' 'ROW' / NUMBER 'FOLLOWING' / 'UNBOUNDED' 'FOLLOWING'
*
* insert_stmt <- 'INSERT' 'INTO' NAME '(' name_list ')' 'VALUES' '(' expr_list ')'
* update_stmt <- 'UPDATE' NAME 'SET' set_list ('WHERE' expr)?
* delete_stmt <- 'DELETE' 'FROM' NAME ('WHERE' expr)?
* merge_stmt <- 'MERGE' 'INTO' NAME 'USING' NAME 'ON' expr when_clause+
* when_clause <- 'WHEN' ('MATCHED' ('AND' expr)? 'THEN' ('UPDATE' 'SET' set_list / 'DELETE') /
* 'NOT' 'MATCHED' ('AND' expr)? 'THEN' 'INSERT' '(' name_list ')' 'VALUES' '(' expr_list ')')
*
* values_stmt <- 'VALUES' row_list (standalone VALUES query, SQL:2003)
* row_list <- '(' expr_list ')' (',' '(' expr_list ')')*
*
* table_stmt <- 'TABLE' NAME (shorthand for SELECT * FROM NAME, SQL:2003)
*
* call_stmt <- 'CALL' NAME '(' expr_list? ')' (SQL:2003 procedure call)
*
* order_item <- expr ('ASC'/'DESC')? ('NULLS' ('FIRST'/'LAST'))?
* expr_list <- expr (',' expr)*
* name_list <- NAME (',' NAME)*
* set_list <- NAME '=' expr (',' NAME '=' expr)*
*/
CLASS TSqlParser2
DATA aTokens
DATA nPos
DATA aParams
DATA nParamSeq INIT 0 /* positional ? counter (1-based) */
DATA hInfixTT /* hash: token_type => { op, lbp, rbp, ndType } */
DATA hInfixKW /* hash: keyword => { op, lbp, rbp, ndType } */
METHOD New( aTokens, aParams ) CONSTRUCTOR
METHOD InitInfixTables()
METHOD Parse()
METHOD ParseSelect()
METHOD ParseInsert()
METHOD ParseUpdate()
METHOD ParseDelete()
METHOD ParseExpr()
METHOD PrattExpr( nMinBP )
METHOD PrattPrefix()
METHOD InfixBP()
METHOD ParsePrimary()
METHOD ParseSubquery()
METHOD ParseColumnList()
METHOD ParseFrom()
METHOD ParseOrderBy()
METHOD ParseExprList()
METHOD TType( n )
METHOD TVal( n )
METHOD IsKW( n, c )
METHOD EatKW( c )
METHOD IsFromKW( cVal )
METHOD ParseWindow( cFuncName, aFuncArgs )
METHOD ParseMerge()
METHOD ParseGroupingSets()
METHOD ParseWindowSpec()
METHOD ParseFrameClause()
METHOD ParseFrameBound()
ENDCLASS
METHOD New( aTokens, aParams ) CLASS TSqlParser2
::aTokens := aTokens
::nPos := 1
::aParams := iif( aParams == NIL, {}, aParams )
IF ::hInfixTT == NIL
::InitInfixTables()
ENDIF
RETURN SELF
/* Build hash-based operator lookup tables once (O(1) per lookup). */
METHOD InitInfixTables() CLASS TSqlParser2
::hInfixTT := { => }
::hInfixTT[ TK_EQ ] := { "=", 40, 41, ND_BIN }
::hInfixTT[ TK_NEQ ] := { "<>", 40, 41, ND_BIN }
::hInfixTT[ TK_LT ] := { "<", 40, 41, ND_BIN }
::hInfixTT[ TK_GT ] := { ">", 40, 41, ND_BIN }
::hInfixTT[ TK_LTE ] := { "<=", 40, 41, ND_BIN }
::hInfixTT[ TK_GTE ] := { ">=", 40, 41, ND_BIN }
::hInfixTT[ TK_PLUS ] := { "+", 50, 51, ND_BIN }
::hInfixTT[ TK_MINUS ] := { "-", 50, 51, ND_BIN }
::hInfixTT[ TK_PIPES ] := { "||", 50, 51, ND_BIN }
::hInfixTT[ TK_STAR ] := { "*", 60, 61, ND_BIN }
::hInfixTT[ TK_SLASH ] := { "/", 60, 61, ND_BIN }
::hInfixKW := { => }
::hInfixKW[ "OR" ] := { "OR", 10, 11, ND_BIN }
::hInfixKW[ "AND" ] := { "AND", 20, 21, ND_BIN }
::hInfixKW[ "LIKE" ] := { "LIKE", 40, 50, ND_BIN }
::hInfixKW[ "IN" ] := { "IN", 40, 50, ND_BIN }
::hInfixKW[ "BETWEEN" ] := { "BETWEEN", 40, 50, ND_BIN }
::hInfixKW[ "COLLATE" ] := { "COLLATE", 90, 91, ND_BIN }
RETURN NIL
/* Token type at position n */
METHOD TType( n ) CLASS TSqlParser2
IF n > 0 .AND. n <= Len( ::aTokens )
RETURN ::aTokens[ n ][ TK_TYPE ]
ENDIF
RETURN TK_END
/* Token value at position n */
METHOD TVal( n ) CLASS TSqlParser2
IF n > 0 .AND. n <= Len( ::aTokens )
RETURN ::aTokens[ n ][ TK_VALUE ]
ENDIF
RETURN ""
/* Check whether token at position n is a keyword matching c */
METHOD IsKW( n, c ) CLASS TSqlParser2
RETURN ::TType( n ) == TK_NAME .AND. ::TVal( n ) == c
/* Consume keyword c at current position; advance and return .T. on match */
METHOD EatKW( c ) CLASS TSqlParser2
IF ::IsKW( ::nPos, c )
::nPos++
RETURN .T.
ENDIF
RETURN .F.
/* Test whether a value is a SQL clause keyword that terminates a FROM list */
METHOD IsFromKW( cVal ) CLASS TSqlParser2
LOCAL aKW := { "WHERE", "ORDER", "GROUP", "HAVING", "JOIN", "LEFT", ;
"RIGHT", "INNER", "ON", "OUTER", "CROSS", "FULL", ;
"SET", "VALUES", "LIMIT", "TOP", "UNION", ;
"INTERSECT", "EXCEPT", "WITH", "FETCH", "OFFSET", ;
"WINDOW", "FOR", "LATERAL" }
RETURN AScan( aKW, {|x| x == cVal } ) > 0
/* Top-level statement dispatcher */
METHOD Parse() CLASS TSqlParser2
LOCAL cType, h
LOCAL aCTE, cName, xSub, lRecursive, aCTEColNames
IF Len( ::aTokens ) < 2
RETURN NIL
ENDIF
cType := ::TVal( ::nPos )
/* WITH (Common Table Expression), including RECURSIVE */
IF cType == "WITH"
::nPos++
aCTE := {}
lRecursive := .F.
WHILE .T.
/* Detect RECURSIVE keyword */
IF ::IsKW( ::nPos, "RECURSIVE" )
lRecursive := .T.
::nPos++
ENDIF
cName := ::TVal( ::nPos )
::nPos++
/* Parse optional column aliases: name(col1, col2, ...) */
aCTEColNames := {}
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
WHILE ::TType( ::nPos ) != TK_RPAR .AND. ::nPos <= Len( ::aTokens )
AAdd( aCTEColNames, ::TVal( ::nPos ) )
::nPos++
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ENDIF
ENDDO
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ENDIF
::EatKW( "AS" )
xSub := ::ParseSubquery()
AAdd( aCTE, { cName, xSub, aCTEColNames } )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
/* 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
ENDIF
RETURN h
ENDIF
DO CASE
CASE cType == "SELECT"
::nPos++
RETURN ::ParseSelect()
CASE cType == "INSERT"
::nPos++
RETURN ::ParseInsert()
CASE cType == "UPDATE"
::nPos++
RETURN ::ParseUpdate()
CASE cType == "DELETE"
::nPos++
RETURN ::ParseDelete()
CASE cType == "CREATE"
h := { => }
::nPos++
h[ "type" ] := "CREATE"
h[ "tokens" ] := ::aTokens
h[ "pos" ] := ::nPos
RETURN h
CASE cType == "DROP"
h := { => }
::nPos++
h[ "type" ] := "DROP"
h[ "tokens" ] := ::aTokens
h[ "pos" ] := ::nPos
RETURN h
CASE cType == "SET"
h := { => }
::nPos++
IF ::IsKW( ::nPos, "COLLATION" )
::nPos++
::EatKW( "TO" )
h[ "type" ] := "SET_COLLATION"
h[ "value" ] := ::TVal( ::nPos )
::nPos++
ELSE
h[ "type" ] := "SET"
h[ "tokens" ] := ::aTokens
h[ "pos" ] := ::nPos
ENDIF
RETURN h
CASE cType == "ALTER"
h := { => }
::nPos++
h[ "type" ] := "ALTER"
h[ "tokens" ] := ::aTokens
h[ "pos" ] := ::nPos
RETURN h
CASE cType == "BEGIN"
h := { => }
h[ "type" ] := "BEGIN"
RETURN h
CASE cType == "COMMIT"
h := { => }
h[ "type" ] := "COMMIT"
RETURN h
CASE cType == "ROLLBACK"
h := { => }
::nPos++
IF ::IsKW( ::nPos, "TO" )
::nPos++
/* ROLLBACK TO [SAVEPOINT] name */
IF ::IsKW( ::nPos, "SAVEPOINT" )
::nPos++
ENDIF
h[ "type" ] := "ROLLBACK_TO"
h[ "savepoint" ] := ::TVal( ::nPos )
::nPos++
ELSE
h[ "type" ] := "ROLLBACK"
ENDIF
RETURN h
CASE cType == "SAVEPOINT"
h := { => }
::nPos++
h[ "type" ] := "SAVEPOINT"
h[ "name" ] := ::TVal( ::nPos )
::nPos++
RETURN h
CASE cType == "TRUNCATE"
h := { => }
::nPos++
::EatKW( "TABLE" )
h[ "type" ] := "TRUNCATE"
h[ "table" ] := ::TVal( ::nPos )
::nPos++
RETURN h
CASE cType == "MERGE"
RETURN ::ParseMerge()
/* VALUES (row), (row), ... — standalone query (SQL:2003) */
CASE cType == "VALUES"
h := { => }
h[ "type" ] := "VALUES"
::nPos++
h[ "rows" ] := {}
WHILE .T.
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
AAdd( h[ "rows" ], ::ParseExprList() )
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ELSE
EXIT
ENDIF
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
RETURN h
/* TABLE name — shorthand for SELECT * FROM name (SQL:2003) */
CASE cType == "TABLE"
h := { => }
::nPos++
h[ "type" ] := "TABLE"
h[ "table" ] := ::TVal( ::nPos )
::nPos++
RETURN h
/* CALL procedure(args) (SQL:2003) */
CASE cType == "CALL"
h := { => }
::nPos++
h[ "type" ] := "CALL"
h[ "procedure" ] := ::TVal( ::nPos )
::nPos++
h[ "args" ] := {}
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
IF ::TType( ::nPos ) != TK_RPAR
AAdd( h[ "args" ], ::ParseExpr() )
DO WHILE ::TType( ::nPos ) == TK_COMMA
::nPos++
AAdd( h[ "args" ], ::ParseExpr() )
ENDDO
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ENDIF
RETURN h
ENDCASE
RETURN NIL
/* Parse SELECT statement */
METHOD ParseSelect() CLASS TSqlParser2
LOCAL h := { => }
LOCAL nTop := 0, lDistinct := .F.
LOCAL aCols, aTables := {}, aJoins := {}
LOCAL xWhere := NIL, aGroupBy := {}, xHaving := NIL, aOrderBy := {}
LOCAL nLimit := NIL, nOffset := 0
LOCAL hUnion := NIL
LOCAL lAll
LOCAL aWindowDefs, cWinName, hWinDef
LOCAL nFetch, cFetchTies
LOCAL cForLock, aForCols
h[ "type" ] := "SELECT"
/* DISTINCT */
IF ::IsKW( ::nPos, "DISTINCT" )
lDistinct := .T.
::nPos++
ENDIF
h[ "distinct" ] := lDistinct
/* TOP n */
IF ::IsKW( ::nPos, "TOP" )
::nPos++
IF ::TType( ::nPos ) == TK_NUM
nTop := Val( ::TVal( ::nPos ) )
::nPos++
ENDIF
ENDIF
h[ "top" ] := nTop
/* Column list */
aCols := ::ParseColumnList()
h[ "columns" ] := aCols
/* FROM */
IF ::IsKW( ::nPos, "FROM" )
::nPos++
::ParseFrom( @aTables, @aJoins )
ENDIF
h[ "tables" ] := aTables
h[ "joins" ] := aJoins
/* WHERE */
IF ::IsKW( ::nPos, "WHERE" )
::nPos++
xWhere := ::ParseExpr()
ENDIF
h[ "where" ] := xWhere
/* GROUP BY — with GROUPING SETS / CUBE / ROLLUP support (SQL:2003) */
IF ::IsKW( ::nPos, "GROUP" )
::nPos++
::EatKW( "BY" )
aGroupBy := {}
DO WHILE ::TType( ::nPos ) != TK_END .AND. ;
! ::IsKW( ::nPos, "HAVING" ) .AND. ! ::IsKW( ::nPos, "ORDER" ) .AND. ;
! ::IsKW( ::nPos, "LIMIT" ) .AND. ! ::IsKW( ::nPos, "UNION" ) .AND. ;
! ::IsKW( ::nPos, "INTERSECT" ) .AND. ! ::IsKW( ::nPos, "EXCEPT" ) .AND. ;
! ::IsKW( ::nPos, "WINDOW" ) .AND. ! ::IsKW( ::nPos, "FETCH" ) .AND. ;
! ::IsKW( ::nPos, "OFFSET" ) .AND. ! ::IsKW( ::nPos, "FOR" )
/* GROUPING SETS (...) */
IF ::IsKW( ::nPos, "GROUPING" ) .AND. ::IsKW( ::nPos + 1, "SETS" )
::nPos += 2
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
AAdd( aGroupBy, SqlNode( ND_FN, "GROUPING SETS", ;
::ParseGroupingSets(), NIL, NIL ) )
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ENDIF
/* ROLLUP (...) */
ELSEIF ::IsKW( ::nPos, "ROLLUP" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
AAdd( aGroupBy, SqlNode( ND_FN, "ROLLUP", ;
::ParseExprList(), NIL, NIL ) )
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ENDIF
/* CUBE (...) */
ELSEIF ::IsKW( ::nPos, "CUBE" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
AAdd( aGroupBy, SqlNode( ND_FN, "CUBE", ;
::ParseExprList(), NIL, NIL ) )
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ENDIF
ELSE
AAdd( aGroupBy, ::ParseExpr() )
ENDIF
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
ENDIF
h[ "group_by" ] := aGroupBy
/* HAVING */
IF ::IsKW( ::nPos, "HAVING" )
::nPos++
xHaving := ::ParseExpr()
ENDIF
h[ "having" ] := xHaving
/* WINDOW clause — named windows (SQL:2003) */
IF ::IsKW( ::nPos, "WINDOW" )
::nPos++
aWindowDefs := {}
WHILE .T.
cWinName := ::TVal( ::nPos )
::nPos++
::EatKW( "AS" )
hWinDef := ::ParseWindowSpec()
AAdd( aWindowDefs, { cWinName, hWinDef } )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
h[ "window_defs" ] := aWindowDefs
ENDIF
/* ORDER BY — with NULLS FIRST/LAST support (SQL:2003) */
IF ::IsKW( ::nPos, "ORDER" )
::nPos++
::EatKW( "BY" )
aOrderBy := ::ParseOrderBy()
ENDIF
h[ "order_by" ] := aOrderBy
/* LIMIT / OFFSET (MySQL/PostgreSQL syntax) */
IF ::IsKW( ::nPos, "LIMIT" )
::nPos++
IF ::TType( ::nPos ) == TK_NUM
nLimit := Val( ::TVal( ::nPos ) )
::nPos++
ENDIF
IF ::IsKW( ::nPos, "OFFSET" )
::nPos++
IF ::TType( ::nPos ) == TK_NUM
nOffset := Val( ::TVal( ::nPos ) )
::nPos++
ENDIF
ENDIF
ENDIF
h[ "limit" ] := nLimit
h[ "offset" ] := nOffset
/* OFFSET n ROWS (SQL:2008) */
IF ::IsKW( ::nPos, "OFFSET" )
::nPos++
IF ::TType( ::nPos ) == TK_NUM
nOffset := Val( ::TVal( ::nPos ) )
::nPos++
h[ "offset" ] := nOffset
ENDIF
::EatKW( "ROWS" ) /* optional ROWS keyword */
::EatKW( "ROW" ) /* or ROW */
ENDIF
/* FETCH FIRST n ROWS ONLY / WITH TIES (SQL:2008) */
IF ::IsKW( ::nPos, "FETCH" )
::nPos++
::EatKW( "FIRST" )
IF ! ::EatKW( "NEXT" )
/* already consumed or not present */
ENDIF
nFetch := 1
IF ::TType( ::nPos ) == TK_NUM
nFetch := Val( ::TVal( ::nPos ) )
::nPos++
ENDIF
::EatKW( "ROWS" )
::EatKW( "ROW" )
cFetchTies := "ONLY"
IF ::IsKW( ::nPos, "ONLY" )
::nPos++
ELSEIF ::IsKW( ::nPos, "WITH" )
::nPos++
IF ::IsKW( ::nPos, "TIES" )
cFetchTies := "WITH TIES"
::nPos++
ENDIF
ENDIF
h[ "fetch" ] := nFetch
h[ "fetch_ties" ] := cFetchTies
ENDIF
/* FOR UPDATE / FOR SHARE (SQL:2003) */
IF ::IsKW( ::nPos, "FOR" )
::nPos++
cForLock := ""
IF ::IsKW( ::nPos, "UPDATE" )
cForLock := "UPDATE"
::nPos++
ELSEIF ::IsKW( ::nPos, "SHARE" )
cForLock := "SHARE"
::nPos++
ENDIF
aForCols := {}
IF ::IsKW( ::nPos, "OF" )
::nPos++
WHILE ::TType( ::nPos ) == TK_NAME .AND. ;
! ::IsKW( ::nPos, "UNION" ) .AND. ;
! ::IsKW( ::nPos, "INTERSECT" ) .AND. ;
! ::IsKW( ::nPos, "EXCEPT" )
AAdd( aForCols, ::TVal( ::nPos ) )
::nPos++
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
ENDIF
h[ "for_lock" ] := cForLock
h[ "for_lock_cols" ] := aForCols
ENDIF
/* UNION / UNION ALL / INTERSECT / EXCEPT */
IF ::IsKW( ::nPos, "UNION" )
::nPos++
lAll := .F.
IF ::IsKW( ::nPos, "ALL" )
lAll := .T.
::nPos++
ENDIF
::EatKW( "SELECT" )
hUnion := ::ParseSelect()
IF hUnion != NIL
hUnion[ "union_all" ] := lAll
ENDIF
ELSEIF ::IsKW( ::nPos, "INTERSECT" )
::nPos++
::EatKW( "SELECT" )
hUnion := ::ParseSelect()
IF hUnion != NIL
hUnion[ "set_op" ] := "INTERSECT"
ENDIF
ELSEIF ::IsKW( ::nPos, "EXCEPT" )
::nPos++
::EatKW( "SELECT" )
hUnion := ::ParseSelect()
IF hUnion != NIL
hUnion[ "set_op" ] := "EXCEPT"
ENDIF
ENDIF
h[ "union" ] := hUnion
RETURN h
/* Parse GROUPING SETS argument list: (col), (col, col), () etc. */
METHOD ParseGroupingSets() CLASS TSqlParser2
LOCAL aSets := {}, aInner
WHILE .T.
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
aInner := {}
IF ::TType( ::nPos ) != TK_RPAR
aInner := ::ParseExprList()
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
AAdd( aSets, aInner )
ELSE
AAdd( aSets, { ::ParseExpr() } )
ENDIF
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
RETURN aSets
/* Parse column list */
METHOD ParseColumnList() CLASS TSqlParser2
LOCAL aCols := {}, xExpr, cAlias
DO WHILE .T.
xExpr := ::ParseExpr()
cAlias := ""
IF ::IsKW( ::nPos, "AS" )
::nPos++
cAlias := ::TVal( ::nPos )
::nPos++
/* Implicit alias: `SELECT salary total, ...` where `total` is
* a non-keyword identifier followed by comma or clause keyword.
* SQL standard allows omitting AS for column aliases. */
ELSEIF ::TType( ::nPos ) == TK_NAME .AND. ! ::IsFromKW( ::TVal( ::nPos ) ) .AND. ;
! ::IsKW( ::nPos, "FROM" ) .AND. ! ::IsKW( ::nPos, "WHERE" ) .AND. ;
! ::IsKW( ::nPos, "GROUP" ) .AND. ! ::IsKW( ::nPos, "ORDER" ) .AND. ;
! ::IsKW( ::nPos, "HAVING" ) .AND. ! ::IsKW( ::nPos, "LIMIT" ) .AND. ;
! ::IsKW( ::nPos, "UNION" ) .AND. ! ::IsKW( ::nPos, "INTERSECT" ) .AND. ;
! ::IsKW( ::nPos, "EXCEPT" ) .AND. ! ::IsKW( ::nPos, "WINDOW" ) .AND. ;
! ::IsKW( ::nPos, "OFFSET" ) .AND. ! ::IsKW( ::nPos, "FETCH" ) .AND. ;
! ::IsKW( ::nPos, "INTO" ) .AND. ! ::IsKW( ::nPos, "FOR" )
cAlias := ::TVal( ::nPos )
::nPos++
ENDIF
AAdd( aCols, { xExpr, cAlias } )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
RETURN aCols
/* Parse expression list (GROUP BY) */
METHOD ParseExprList() CLASS TSqlParser2
LOCAL aList := {}
DO WHILE ::TType( ::nPos ) != TK_END .AND. ;
::TType( ::nPos ) != TK_RPAR .AND. ;
! ::IsKW( ::nPos, "HAVING" ) .AND. ! ::IsKW( ::nPos, "ORDER" ) .AND. ;
! ::IsKW( ::nPos, "LIMIT" ) .AND. ! ::IsKW( ::nPos, "UNION" ) .AND. ;
! ::IsKW( ::nPos, "INTERSECT" ) .AND. ! ::IsKW( ::nPos, "EXCEPT" ) .AND. ;
! ::IsKW( ::nPos, "WINDOW" ) .AND. ! ::IsKW( ::nPos, "FETCH" ) .AND. ;
! ::IsKW( ::nPos, "OFFSET" ) .AND. ! ::IsKW( ::nPos, "FOR" )
AAdd( aList, ::ParseExpr() )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
RETURN aList
/* Parse FROM clause */
METHOD ParseFrom( aTables, aJoins ) CLASS TSqlParser2
LOCAL cTable, cAlias, cJoinType, xOnCond, xSubQ
LOCAL lLateral
/* LATERAL keyword before derived table (SQL:2003) */
lLateral := .F.
IF ::IsKW( ::nPos, "LATERAL" )
lLateral := .T.
::nPos++
ENDIF
/* Derived table: FROM (subquery) [AS] alias */
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 := "__DRV1"
ENDIF
AAdd( aTables, { "__SUBQUERY__", cAlias, xSubQ } )
IF lLateral
ATail( aTables )[ 1 ] := "__LATERAL__"
ENDIF
ELSE
/* Primary table */
cTable := ::TVal( ::nPos )
::nPos++
cAlias := ""
IF ::TType( ::nPos ) == TK_NAME .AND. ! ::IsFromKW( ::TVal( ::nPos ) )
cAlias := ::TVal( ::nPos )
::nPos++
ENDIF
AAdd( aTables, { cTable, cAlias, "" } )
ENDIF
/* Additional comma-separated tables */
DO WHILE ::TType( ::nPos ) == TK_COMMA
::nPos++
/* LATERAL before a comma-joined derived table */
lLateral := .F.
IF ::IsKW( ::nPos, "LATERAL" )
lLateral := .T.
::nPos++
ENDIF
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
AAdd( aTables, { iif( lLateral, "__LATERAL__", "__SUBQUERY__" ), cAlias, xSubQ } )
ELSE
cTable := ::TVal( ::nPos ) ; ::nPos++
cAlias := ""
IF ::TType( ::nPos ) == TK_NAME .AND. ! ::IsFromKW( ::TVal( ::nPos ) )
cAlias := ::TVal( ::nPos ) ; ::nPos++
ENDIF
AAdd( aTables, { cTable, cAlias, "" } )
ENDIF
ENDDO
/* Explicit JOIN clauses */
DO WHILE ::IsKW( ::nPos, "JOIN" ) .OR. ::IsKW( ::nPos, "LEFT" ) .OR. ;
::IsKW( ::nPos, "RIGHT" ) .OR. ::IsKW( ::nPos, "INNER" ) .OR. ;
::IsKW( ::nPos, "CROSS" ) .OR. ::IsKW( ::nPos, "FULL" )
cJoinType := ::TVal( ::nPos )
::nPos++
IF ::IsKW( ::nPos, "OUTER" )
::nPos++
ENDIF
IF ::IsKW( ::nPos, "JOIN" )
::nPos++
ENDIF
/* LATERAL before join table (SQL:2003) */
lLateral := .F.
IF ::IsKW( ::nPos, "LATERAL" )
lLateral := .T.
::nPos++
ENDIF
/* 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
xOnCond := NIL
IF ::IsKW( ::nPos, "ON" )
::nPos++
xOnCond := ::ParseExpr()
ENDIF
AAdd( aJoins, { cJoinType, cTable, cAlias, xOnCond } )
ENDDO
RETURN NIL
/* Parse ORDER BY clause — with NULLS FIRST/LAST (SQL:2003) */
METHOD ParseOrderBy() CLASS TSqlParser2
LOCAL aOrder := {}, xExpr, cDir, cNulls
DO WHILE ::TType( ::nPos ) != TK_END .AND. ;
! ::IsKW( ::nPos, "LIMIT" ) .AND. ! ::IsKW( ::nPos, "UNION" ) .AND. ;
! ::IsKW( ::nPos, "INTERSECT" ) .AND. ! ::IsKW( ::nPos, "EXCEPT" ) .AND. ;
! ::IsKW( ::nPos, "FETCH" ) .AND. ! ::IsKW( ::nPos, "OFFSET" ) .AND. ;
! ::IsKW( ::nPos, "FOR" ) .AND. ! ::IsKW( ::nPos, "WINDOW" ) .AND. ;
::TType( ::nPos ) != TK_RPAR
xExpr := ::ParseExpr()
cDir := "ASC"
IF ::IsKW( ::nPos, "ASC" )
::nPos++
ELSEIF ::IsKW( ::nPos, "DESC" )
cDir := "DESC"
::nPos++
ENDIF
/* NULLS FIRST / NULLS LAST (SQL:2003) */
cNulls := ""
IF ::IsKW( ::nPos, "NULLS" )
::nPos++
IF ::IsKW( ::nPos, "FIRST" )
cNulls := "FIRST"
::nPos++
ELSEIF ::IsKW( ::nPos, "LAST" )
cNulls := "LAST"
::nPos++
ENDIF
ENDIF
IF Empty( cNulls )
AAdd( aOrder, { xExpr, cDir } )
ELSE
AAdd( aOrder, { xExpr, cDir, cNulls } )
ENDIF
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
RETURN aOrder
/* Parse INSERT INTO */
METHOD ParseInsert() CLASS TSqlParser2
LOCAL h := { => }, cTable, aFields := {}, aRows := {}, aTuple, xE
h[ "type" ] := "INSERT"
::EatKW( "INTO" )
cTable := ::TVal( ::nPos ) ; ::nPos++
h[ "table" ] := cTable
/* Optional column list */
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
DO WHILE ::TType( ::nPos ) == TK_NAME
AAdd( aFields, ::TVal( ::nPos ) ) ; ::nPos++
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ENDIF
h[ "fields" ] := aFields
/* 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++
DO WHILE ::TType( ::nPos ) == TK_LPAR
::nPos++
aTuple := {}
DO WHILE ::TType( ::nPos ) != TK_RPAR .AND. ::TType( ::nPos ) != TK_END
xE := ::ParseExpr()
AAdd( aTuple, xE )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ENDIF
ENDDO
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
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[ "rows" ] := aRows
RETURN h
/* Parse UPDATE */
METHOD ParseUpdate() CLASS TSqlParser2
LOCAL h := { => }, cTable, aSet := {}, cCol, xVal
h[ "type" ] := "UPDATE"
cTable := ::TVal( ::nPos ) ; ::nPos++
h[ "table" ] := cTable
IF ::IsKW( ::nPos, "SET" )
::nPos++
DO WHILE ::TType( ::nPos ) == TK_NAME
cCol := ::TVal( ::nPos ) ; ::nPos++
IF ::TType( ::nPos ) == TK_EQ
::nPos++
ENDIF
xVal := ::ParseExpr()
AAdd( aSet, { cCol, xVal } )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
ENDIF
h[ "set" ] := aSet
h[ "where" ] := NIL
IF ::IsKW( ::nPos, "WHERE" )
::nPos++
h[ "where" ] := ::ParseExpr()
ENDIF
RETURN h
/* Parse DELETE FROM */
METHOD ParseDelete() CLASS TSqlParser2
LOCAL h := { => }
h[ "type" ] := "DELETE"
::EatKW( "FROM" )
h[ "table" ] := ::TVal( ::nPos ) ; ::nPos++
h[ "where" ] := NIL
IF ::IsKW( ::nPos, "WHERE" )
::nPos++
h[ "where" ] := ::ParseExpr()
ENDIF
RETURN h
/* ======================================================================
* Pratt Parser — Data-driven expression parsing
*
* Operator precedence and associativity are defined as DATA in the
* InfixBP() method. To add a new operator, just add one line there.
*
* Binary precedence levels (higher = binds tighter):
* 10 OR
* 20 AND
* 30 NOT (prefix)
* 40 = <> < > <= >= LIKE IN BETWEEN IS SIMILAR TO IS DISTINCT FROM
* 50 + - ||
* 60 * /
* 70 unary -
*
* Adding a new operator (e.g., XOR at precedence 15):
* Just add to InfixBP: { "XOR", 15, 16, ND_BIN }
* No structural code changes needed.
* ====================================================================== */
METHOD ParseExpr() CLASS TSqlParser2
RETURN ::PrattExpr( 0 )
/* Pratt expression parser: parse expression with minimum binding power */
METHOD PrattExpr( nMinBP ) CLASS TSqlParser2
LOCAL xL, xR, cOp, nLBP, nRBP, nType
LOCAL aInf
LOCAL xLow, xHigh, xR2, aList, lNeg
/* Parse prefix / primary (NUD in Pratt terminology) */
xL := ::PrattPrefix()
/* Parse infix operators (LED in Pratt terminology) */
WHILE .T.
aInf := ::InfixBP()
IF aInf == NIL
EXIT
ENDIF
cOp := aInf[ 1 ]
nLBP := aInf[ 2 ]
nRBP := aInf[ 3 ]
nType := aInf[ 4 ]
IF nLBP < nMinBP
EXIT
ENDIF
/* Special postfix operators: LIKE, IN, BETWEEN, IS */
IF cOp == "LIKE"
::nPos++
xR := ::PrattExpr( nRBP )
IF ::IsKW( ::nPos, "ESCAPE" )
::nPos++
xR2 := ::PrattExpr( nRBP )
xL := SqlNode( ND_BIN, "LIKE", xL, xR, xR2 )
ELSE
xL := SqlNode( ND_BIN, "LIKE", xL, xR, NIL )
ENDIF
LOOP
ENDIF
IF cOp == "NOT LIKE"
::nPos += 2
xR := ::PrattExpr( nRBP )
IF ::IsKW( ::nPos, "ESCAPE" )
::nPos++
xR2 := ::PrattExpr( nRBP )
xL := SqlNode( ND_UNI, "NOT", SqlNode( ND_BIN, "LIKE", xL, xR, xR2 ), NIL, NIL )
ELSE
xL := SqlNode( ND_UNI, "NOT", SqlNode( ND_BIN, "LIKE", xL, xR, NIL ), NIL, NIL )
ENDIF
LOOP
ENDIF
/* SIMILAR TO (SQL:2003) — regex-like pattern matching */
IF cOp == "SIMILAR TO"
::nPos += 2 /* skip SIMILAR TO */
xR := ::PrattExpr( nRBP )
IF ::IsKW( ::nPos, "ESCAPE" )
::nPos++
xR2 := ::PrattExpr( nRBP )
xL := SqlNode( ND_BIN, "SIMILAR TO", xL, xR, xR2 )
ELSE
xL := SqlNode( ND_BIN, "SIMILAR TO", xL, xR, NIL )
ENDIF
LOOP
ENDIF
/* NOT SIMILAR TO (SQL:2003) */
IF cOp == "NOT SIMILAR TO"
::nPos += 3 /* skip NOT SIMILAR TO */
xR := ::PrattExpr( nRBP )
IF ::IsKW( ::nPos, "ESCAPE" )
::nPos++
xR2 := ::PrattExpr( nRBP )
xL := SqlNode( ND_UNI, "NOT", SqlNode( ND_BIN, "SIMILAR TO", xL, xR, xR2 ), NIL, NIL )
ELSE
xL := SqlNode( ND_UNI, "NOT", SqlNode( ND_BIN, "SIMILAR TO", xL, xR, NIL ), NIL, NIL )
ENDIF
LOOP
ENDIF
/* IS DISTINCT FROM / IS NOT DISTINCT FROM (SQL:2003) */
IF cOp == "IS DISTINCT FROM"
::nPos += 3 /* skip IS DISTINCT FROM */
xR := ::PrattExpr( nRBP )
xL := SqlNode( ND_BIN, "IS DISTINCT FROM", xL, xR, NIL )
LOOP
ENDIF
IF cOp == "IS NOT DISTINCT FROM"
::nPos += 4 /* skip IS NOT DISTINCT FROM */
xR := ::PrattExpr( nRBP )
xL := SqlNode( ND_BIN, "IS NOT DISTINCT FROM", xL, xR, NIL )
LOOP
ENDIF
IF cOp == "IN" .OR. cOp == "NOT IN"
lNeg := ( cOp == "NOT IN" )
IF lNeg
::nPos++ /* skip NOT */
ENDIF
::nPos++ /* skip IN */
IF ::TType( ::nPos ) == TK_LPAR
IF ::IsKW( ::nPos + 1, "SELECT" )
xR := ::ParseSubquery()
ELSE
::nPos++
aList := {}
WHILE ::TType( ::nPos ) != TK_RPAR .AND. ::TType( ::nPos ) != TK_END
AAdd( aList, ::ParseExpr() )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ENDIF
ENDDO
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
xR := SqlNode( ND_LIST, aList, NIL, NIL, NIL )
ENDIF
ENDIF
xL := SqlNode( ND_BIN, "IN", xL, xR, NIL )
IF lNeg
xL := SqlNode( ND_UNI, "NOT", xL, NIL, NIL )
ENDIF
LOOP
ENDIF
IF cOp == "BETWEEN" .OR. cOp == "NOT BETWEEN"
lNeg := ( cOp == "NOT BETWEEN" )
IF lNeg
::nPos++
ENDIF
::nPos++
xLow := ::PrattExpr( 50 )
::EatKW( "AND" )
xHigh := ::PrattExpr( 50 )
xL := SqlNode( ND_RANGE, "BETWEEN", xL, xLow, xHigh )
IF lNeg
xL := SqlNode( ND_UNI, "NOT", xL, NIL, NIL )
ENDIF
LOOP
ENDIF
IF cOp == "IS NULL"
::nPos++
::EatKW( "NULL" )
xL := SqlNode( ND_BIN, "IS NULL", xL, NIL, NIL )
LOOP
ENDIF
IF cOp == "IS NOT NULL"
::nPos++
::nPos++
::EatKW( "NULL" )
xL := SqlNode( ND_BIN, "IS NOT NULL", xL, NIL, NIL )
LOOP
ENDIF
IF cOp == "COLLATE"
::nPos++
IF ::TType( ::nPos ) == TK_NAME
::nPos++
ENDIF
LOOP
ENDIF
/* Standard binary operator */
::nPos++
xR := ::PrattExpr( nRBP )
xL := SqlNode( nType, cOp, xL, xR, NIL )
ENDDO
RETURN xL
/*
* InfixBP — Operator precedence table (the ONLY place to add operators).
*
* Returns { operator, leftBP, rightBP, nodeType } or NIL if not an operator.
* leftBP: minimum binding power to associate left
* rightBP: binding power for right operand (leftBP+1 for left-assoc)
*/
METHOD InfixBP() CLASS TSqlParser2
LOCAL nT, cV
nT := ::TType( ::nPos )
cV := ::TVal( ::nPos )
/* Symbol operators: O(1) hash lookup */
IF hb_HHasKey( ::hInfixTT, nT )
RETURN ::hInfixTT[ nT ]
ENDIF
/* Keyword operators: O(1) hash lookup for simple ones */
IF nT == TK_NAME
IF hb_HHasKey( ::hInfixKW, cV )
RETURN ::hInfixKW[ cV ]
ENDIF
/* Multi-token keyword operators (require lookahead) */
IF cV == "SIMILAR"
IF ::IsKW( ::nPos + 1, "TO" ) ; RETURN { "SIMILAR TO", 40, 50, ND_BIN }
ENDIF
ELSEIF cV == "IS"
IF ::IsKW( ::nPos + 1, "NOT" ) .AND. ::IsKW( ::nPos + 2, "DISTINCT" ) .AND. ::IsKW( ::nPos + 3, "FROM" )
RETURN { "IS NOT DISTINCT FROM", 40, 41, ND_BIN }
ELSEIF ::IsKW( ::nPos + 1, "DISTINCT" ) .AND. ::IsKW( ::nPos + 2, "FROM" )
RETURN { "IS DISTINCT FROM", 40, 41, ND_BIN }
ELSEIF ::IsKW( ::nPos + 1, "NOT" ) ; RETURN { "IS NOT NULL", 40, 41, ND_BIN }
ELSE ; RETURN { "IS NULL", 40, 41, ND_BIN }
ENDIF
ELSEIF cV == "NOT"
IF ::IsKW( ::nPos + 1, "LIKE" ) ; RETURN { "NOT LIKE", 40, 50, ND_BIN }
ELSEIF ::IsKW( ::nPos + 1, "IN" ) ; RETURN { "NOT IN", 40, 50, ND_BIN }
ELSEIF ::IsKW( ::nPos + 1, "BETWEEN" ) ; RETURN { "NOT BETWEEN", 40, 50, ND_BIN }
ELSEIF ::IsKW( ::nPos + 1, "SIMILAR" ) .AND. ::IsKW( ::nPos + 2, "TO" )
RETURN { "NOT SIMILAR TO", 40, 50, ND_BIN }
ENDIF
ENDIF
ENDIF
RETURN NIL
/* Pratt prefix / primary parser (NUD) */
METHOD PrattPrefix() CLASS TSqlParser2
LOCAL xE
/* NOT prefix */
IF ::IsKW( ::nPos, "NOT" )
::nPos++
xE := ::PrattExpr( 30 )
RETURN SqlNode( ND_UNI, "NOT", xE, NIL, NIL )
ENDIF
/* Unary minus */
IF ::TType( ::nPos ) == TK_MINUS
::nPos++
xE := ::PrattExpr( 70 )
RETURN SqlNode( ND_UNI, "-", xE, NIL, NIL )
ENDIF
RETURN ::ParsePrimary()
/* Parse primary expressions */
METHOD ParsePrimary() CLASS TSqlParser2
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
/* NULL literal */
IF ::IsKW( ::nPos, "NULL" )
::nPos++
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 )
::nPos++
RETURN SqlNode( ND_LIT, Val( cVal ), NIL, NIL, NIL )
ENDIF
/* String literal */
IF ::TType( ::nPos ) == TK_TEXT
cVal := ::TVal( ::nPos )
::nPos++
RETURN SqlNode( ND_LIT, cVal, NIL, NIL, NIL )
ENDIF
/* Positional parameter — each ? gets a sequential 1-based index
* so EvalExpr can return the correct ::aParams[n] value. */
IF ::TType( ::nPos ) == TK_QMARK
::nPos++
::nParamSeq++
RETURN SqlNode( ND_PAR, ::nParamSeq, NIL, NIL, NIL )
ENDIF
/* Parenthesized expression or scalar subquery */
IF ::TType( ::nPos ) == TK_LPAR
IF ::IsKW( ::nPos + 1, "SELECT" )
RETURN ::ParseSubquery()
ENDIF
::nPos++
xE := ::ParseExpr()
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN xE
ENDIF
/* EXISTS (subquery) */
IF ::IsKW( ::nPos, "EXISTS" )
::nPos++
xE := ::ParseSubquery()
RETURN SqlNode( ND_FN, "EXISTS", { xE }, NIL, NIL )
ENDIF
/* 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 } )
ENDDO
IF ::IsKW( ::nPos, "ELSE" )
::nPos++
xElse := ::ParseExpr()
ENDIF
::EatKW( "END" )
RETURN SqlNode( ND_CASE, aCases, xElse, NIL, NIL )
ENDIF
/* TIMESTAMP literal */
IF ::IsKW( ::nPos, "TIMESTAMP" )
IF ::TType( ::nPos + 1 ) == TK_TEXT
::nPos++
cVal := ::TVal( ::nPos )
::nPos++
RETURN SqlNode( ND_FN, "TIMESTAMP", ;
{ SqlNode( ND_LIT, cVal, NIL, NIL, NIL ) }, NIL, NIL )
ENDIF
ENDIF
/* CAST(expr AS type) — SQL:1999 special syntax */
IF ::IsKW( ::nPos, "CAST" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
xE := ::ParseExpr()
::EatKW( "AS" )
/* Parse type name (may include size e.g. VARCHAR(100)) */
cName := ::TVal( ::nPos )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
cName += "(" + ::TVal( ::nPos )
::nPos++
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
cName += "," + ::TVal( ::nPos )
::nPos++
ENDIF
cName += ")"
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN SqlNode( ND_FN, "CAST", ;
{ xE, SqlNode( ND_LIT, cName, NIL, NIL, NIL ) }, NIL, NIL )
ENDIF
ENDIF
/* EXTRACT(part FROM expr) — SQL:1992 */
IF ::IsKW( ::nPos, "EXTRACT" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
cPart := ::TVal( ::nPos )
::nPos++
::EatKW( "FROM" )
xE := ::ParseExpr()
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN SqlNode( ND_FN, "EXTRACT", ;
{ SqlNode( ND_LIT, cPart, NIL, NIL, NIL ), xE }, NIL, NIL )
ENDIF
ENDIF
/* TRIM(LEADING/TRAILING/BOTH char FROM expr) — SQL:1992 extended syntax
* Only activates when LEADING/TRAILING/BOTH keyword is present, or when
* FROM keyword separates arguments. Simple TRIM(expr) falls through to
* the generic function handler to preserve backward compatibility. */
IF ::IsKW( ::nPos, "TRIM" ) .AND. ::TType( ::nPos + 1 ) == TK_LPAR
cTrimSpec := ""
IF ::IsKW( ::nPos + 2, "LEADING" ) .OR. ::IsKW( ::nPos + 2, "TRAILING" ) .OR. ;
::IsKW( ::nPos + 2, "BOTH" )
cTrimSpec := ::TVal( ::nPos + 2 )
ENDIF
IF ! Empty( cTrimSpec )
::nPos++ /* eat TRIM */
::nPos++ /* eat ( */
::nPos++ /* eat LEADING/TRAILING/BOTH */
xTrimChar := NIL
/* Check for trim character before FROM */
IF ! ::IsKW( ::nPos, "FROM" ) .AND. ::TType( ::nPos ) != TK_RPAR
xTrimChar := ::ParseExpr()
ENDIF
::EatKW( "FROM" )
xFrom := ::ParseExpr()
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
aArgs := { SqlNode( ND_LIT, cTrimSpec, NIL, NIL, NIL ), xFrom }
IF xTrimChar != NIL
AAdd( aArgs, xTrimChar )
ENDIF
RETURN SqlNode( ND_FN, "TRIM", aArgs, NIL, NIL )
ENDIF
ENDIF
/* POSITION(str IN str) — SQL:1992
* Uses PrattExpr(50) for first arg to stop before IN keyword which
* has binding power 40 in the infix table. */
IF ::IsKW( ::nPos, "POSITION" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
xE := ::PrattExpr( 50 )
::EatKW( "IN" )
xFrom := ::ParseExpr()
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN SqlNode( ND_FN, "POSITION", { xE, xFrom }, NIL, NIL )
ENDIF
ENDIF
/* OVERLAY(str PLACING str FROM n FOR n) — SQL:2003 */
IF ::IsKW( ::nPos, "OVERLAY" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
aArgs := {}
AAdd( aArgs, ::ParseExpr() )
::EatKW( "PLACING" )
AAdd( aArgs, ::ParseExpr() )
::EatKW( "FROM" )
AAdd( aArgs, ::ParseExpr() )
IF ::IsKW( ::nPos, "FOR" )
::nPos++
AAdd( aArgs, ::ParseExpr() )
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN SqlNode( ND_FN, "OVERLAY", aArgs, NIL, NIL )
ENDIF
ENDIF
/* ARRAY(expr, ...) — SQL:2003 array constructor */
IF ::IsKW( ::nPos, "ARRAY" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
aArgs := {}
IF ::TType( ::nPos ) != TK_RPAR
AAdd( aArgs, ::ParseExpr() )
DO WHILE ::TType( ::nPos ) == TK_COMMA
::nPos++
AAdd( aArgs, ::ParseExpr() )
ENDDO
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN SqlNode( ND_FN, "ARRAY", aArgs, NIL, NIL )
ENDIF
ENDIF
/* ROW(expr, ...) — SQL:2003 row value constructor */
IF ::IsKW( ::nPos, "ROW" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
aArgs := {}
IF ::TType( ::nPos ) != TK_RPAR
AAdd( aArgs, ::ParseExpr() )
DO WHILE ::TType( ::nPos ) == TK_COMMA
::nPos++
AAdd( aArgs, ::ParseExpr() )
ENDDO
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN SqlNode( ND_FN, "ROW", aArgs, NIL, NIL )
ENDIF
ENDIF
/* JSON_TABLE(expr, path COLUMNS(...)) — SQL:2016 */
IF ::IsKW( ::nPos, "JSON_TABLE" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
aArgs := {}
AAdd( aArgs, ::ParseExpr() ) /* JSON expression */
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ENDIF
AAdd( aArgs, ::ParseExpr() ) /* path expression */
/* COLUMNS clause */
aColDefs := {}
IF ::IsKW( ::nPos, "COLUMNS" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
DO WHILE ::TType( ::nPos ) != TK_RPAR .AND. ::TType( ::nPos ) != TK_END
cColName := ::TVal( ::nPos )
::nPos++
/* Eat type name tokens until comma or ) */
cColPath := ""
DO WHILE ::TType( ::nPos ) != TK_COMMA .AND. ;
::TType( ::nPos ) != TK_RPAR .AND. ;
::TType( ::nPos ) != TK_END
cColPath += ::TVal( ::nPos ) + " "
::nPos++
ENDDO
AAdd( aColDefs, { cColName, AllTrim( cColPath ) } )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ENDIF
ENDDO
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ENDIF
ENDIF
AAdd( aArgs, SqlNode( ND_LIST, aColDefs, NIL, NIL, NIL ) )
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN SqlNode( ND_FN, "JSON_TABLE", aArgs, NIL, NIL )
ENDIF
ENDIF
/* JSON_OBJECT(key: value, ...) / JSON_OBJECT(key VALUE value, ...) — SQL:2016 */
IF ::IsKW( ::nPos, "JSON_OBJECT" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
aArgs := {}
IF ::TType( ::nPos ) != TK_RPAR
DO WHILE .T.
/* Parse key */
AAdd( aArgs, ::ParseExpr() )
/* Separator is ':' or VALUE keyword — skip it */
IF ::IsKW( ::nPos, "VALUE" )
::nPos++
ENDIF
/* Parse value */
AAdd( aArgs, ::ParseExpr() )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN SqlNode( ND_FN, "JSON_OBJECT", aArgs, NIL, NIL )
ENDIF
ENDIF
/* JSON_OBJECTAGG(key: value) — SQL:2016 */
IF ::IsKW( ::nPos, "JSON_OBJECTAGG" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
aArgs := {}
AAdd( aArgs, ::ParseExpr() ) /* key */
IF ::IsKW( ::nPos, "VALUE" )
::nPos++
ENDIF
AAdd( aArgs, ::ParseExpr() ) /* value */
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN SqlNode( ND_FN, "JSON_OBJECTAGG", aArgs, NIL, NIL )
ENDIF
ENDIF
/* XMLELEMENT(NAME name, expr, ...) — SQL:2003 */
IF ::IsKW( ::nPos, "XMLELEMENT" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
::EatKW( "NAME" )
aArgs := {}
AAdd( aArgs, SqlNode( ND_LIT, ::TVal( ::nPos ), NIL, NIL, NIL ) )
::nPos++
DO WHILE ::TType( ::nPos ) == TK_COMMA
::nPos++
AAdd( aArgs, ::ParseExpr() )
ENDDO
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN SqlNode( ND_FN, "XMLELEMENT", aArgs, NIL, NIL )
ENDIF
ENDIF
/* XMLFOREST(expr AS name, ...) — SQL:2003 */
IF ::IsKW( ::nPos, "XMLFOREST" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
aArgs := {}
DO WHILE .T.
xE := ::ParseExpr()
cName := ""
IF ::IsKW( ::nPos, "AS" )
::nPos++
cName := ::TVal( ::nPos )
::nPos++
ENDIF
AAdd( aArgs, { xE, cName } )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN SqlNode( ND_FN, "XMLFOREST", aArgs, NIL, NIL )
ENDIF
ENDIF
/* XMLAGG(expr ORDER BY ...) — SQL:2003 */
IF ::IsKW( ::nPos, "XMLAGG" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
xE := ::ParseExpr()
aArgs := { xE }
IF ::IsKW( ::nPos, "ORDER" )
::nPos++
::EatKW( "BY" )
AAdd( aArgs, ::ParseOrderBy() )
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
RETURN SqlNode( ND_FN, "XMLAGG", aArgs, NIL, NIL )
ENDIF
ENDIF
/* LISTAGG(expr, sep) WITHIN GROUP (ORDER BY ...) — SQL:2016 */
IF ::IsKW( ::nPos, "LISTAGG" )
::nPos++
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
aArgs := {}
AAdd( aArgs, ::ParseExpr() )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
AAdd( aArgs, ::ParseExpr() )
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
/* WITHIN GROUP (ORDER BY ...) */
aOrdItems := {}
IF ::IsKW( ::nPos, "WITHIN" )
::nPos++
::EatKW( "GROUP" )
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
IF ::IsKW( ::nPos, "ORDER" )
::nPos++
::EatKW( "BY" )
DO WHILE ::TType( ::nPos ) != TK_RPAR .AND. ::TType( ::nPos ) != TK_END
xExpr := ::ParseExpr()
cDir := "ASC"
IF ::IsKW( ::nPos, "ASC" )
::nPos++
ELSEIF ::IsKW( ::nPos, "DESC" )
cDir := "DESC"
::nPos++
ENDIF
AAdd( aOrdItems, { xExpr, cDir } )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ENDIF
ENDIF
/* OVER clause for window usage */
IF ::IsKW( ::nPos, "OVER" )
RETURN ::ParseWindow( "LISTAGG", aArgs )
ENDIF
RETURN SqlNode( ND_FN, "LISTAGG", aArgs, aOrdItems, NIL )
ENDIF
ENDIF
/* Wildcard star */
IF ::TType( ::nPos ) == TK_STAR
::nPos++
RETURN SqlNode( ND_COL, "*", NIL, NIL, NIL )
ENDIF
/* Identifier: column reference or function call */
IF ::TType( ::nPos ) == TK_NAME
cName := ::TVal( ::nPos )
::nPos++
/* Qualified column: table.column */
IF ::TType( ::nPos ) == TK_DOT
::nPos++
cName += "." + ::TVal( ::nPos )
::nPos++
ENDIF
/* 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++
ELSEIF ::TType( ::nPos ) != TK_RPAR
AAdd( aArgs, ::ParseExpr() )
DO WHILE ::TType( ::nPos ) == TK_COMMA
::nPos++
AAdd( aArgs, ::ParseExpr() )
ENDDO
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
/* WITHIN GROUP (ORDER BY ...) for aggregate functions */
IF ::IsKW( ::nPos, "WITHIN" )
::nPos++
::EatKW( "GROUP" )
aOrdItems := {}
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
IF ::IsKW( ::nPos, "ORDER" )
::nPos++
::EatKW( "BY" )
DO WHILE ::TType( ::nPos ) != TK_RPAR .AND. ::TType( ::nPos ) != TK_END
xExpr := ::ParseExpr()
cDir := "ASC"
IF ::IsKW( ::nPos, "ASC" )
::nPos++
ELSEIF ::IsKW( ::nPos, "DESC" )
cDir := "DESC"
::nPos++
ENDIF
AAdd( aOrdItems, { xExpr, cDir } )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ENDIF
ENDIF
/* Window function: func(...) OVER (...) */
IF ::IsKW( ::nPos, "OVER" )
RETURN ::ParseWindow( cName, aArgs )
ENDIF
/* 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 )
ENDIF
/* Fallback: skip unrecognized token */
::nPos++
RETURN SqlNode( ND_NIL, NIL, NIL, NIL, NIL )
/* Parse a parenthesized subquery */
METHOD ParseSubquery() CLASS TSqlParser2
LOCAL nDepth := 0, aSubTokens := {}, oSub, aParsed
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
ENDIF
DO WHILE ::nPos <= Len( ::aTokens )
IF ::TType( ::nPos ) == TK_LPAR
nDepth++
ELSEIF ::TType( ::nPos ) == TK_RPAR
IF nDepth == 0
::nPos++
EXIT
ENDIF
nDepth--
ENDIF
AAdd( aSubTokens, ::aTokens[ ::nPos ] )
::nPos++
ENDDO
AAdd( aSubTokens, { TK_END, "" } )
oSub := TSqlParser2():New( aSubTokens, ::aParams )
aParsed := oSub:Parse()
RETURN SqlNode( ND_SUB, aParsed, NIL, NIL, NIL )
/* Parse window specification (shared by OVER and WINDOW clause) */
METHOD ParseWindowSpec() CLASS TSqlParser2
LOCAL aPartBy := {}, aOrdBy := {}, xExpr, cDir, cNulls
LOCAL hFrame := NIL
IF ::TType( ::nPos ) == TK_LPAR
::nPos++ /* eat ( */
/* PARTITION BY */
IF ::IsKW( ::nPos, "PARTITION" )
::nPos++
::EatKW( "BY" )
DO WHILE ::TType( ::nPos ) == TK_NAME .AND. ;
! ::IsKW( ::nPos, "ORDER" ) .AND. ;
! ::IsKW( ::nPos, "ROWS" ) .AND. ;
! ::IsKW( ::nPos, "RANGE" ) .AND. ;
! ::IsKW( ::nPos, "GROUPS" ) .AND. ;
::TType( ::nPos ) != TK_RPAR .AND. ;
::TType( ::nPos ) != TK_END
AAdd( aPartBy, ::ParseExpr() )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
ENDIF
/* ORDER BY with NULLS FIRST/LAST */
IF ::IsKW( ::nPos, "ORDER" )
::nPos++
::EatKW( "BY" )
DO WHILE ::TType( ::nPos ) != TK_RPAR .AND. ::TType( ::nPos ) != TK_END .AND. ;
! ::IsKW( ::nPos, "ROWS" ) .AND. ;
! ::IsKW( ::nPos, "RANGE" ) .AND. ;
! ::IsKW( ::nPos, "GROUPS" )
xExpr := ::ParseExpr()
cDir := "ASC"
IF ::IsKW( ::nPos, "ASC" )
::nPos++
ELSEIF ::IsKW( ::nPos, "DESC" )
cDir := "DESC"
::nPos++
ENDIF
cNulls := ""
IF ::IsKW( ::nPos, "NULLS" )
::nPos++
IF ::IsKW( ::nPos, "FIRST" )
cNulls := "FIRST"
::nPos++
ELSEIF ::IsKW( ::nPos, "LAST" )
cNulls := "LAST"
::nPos++
ENDIF
ENDIF
IF Empty( cNulls )
AAdd( aOrdBy, { xExpr, cDir } )
ELSE
AAdd( aOrdBy, { xExpr, cDir, cNulls } )
ENDIF
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
ENDIF
/* Frame clause: ROWS/RANGE/GROUPS ... (SQL:2003/2011) */
IF ::IsKW( ::nPos, "ROWS" ) .OR. ::IsKW( ::nPos, "RANGE" ) .OR. ;
::IsKW( ::nPos, "GROUPS" )
hFrame := ::ParseFrameClause()
ENDIF
IF ::TType( ::nPos ) == TK_RPAR
::nPos++ /* eat ) */
ENDIF
ENDIF
RETURN { aPartBy, aOrdBy, hFrame }
/* Parse OVER(...) for window functions */
METHOD ParseWindow( cFuncName, aFuncArgs ) CLASS TSqlParser2
LOCAL aSpec, xWinNode
::nPos++ /* eat OVER */
/* Named window reference: OVER window_name (not followed by parenthesis) */
IF ::TType( ::nPos ) == TK_NAME
aSpec := { {}, {}, ::TVal( ::nPos ) }
::nPos++
RETURN SqlNode( ND_WINDOW, cFuncName, aFuncArgs, aSpec[ 1 ], aSpec[ 2 ] )
ENDIF
aSpec := ::ParseWindowSpec()
/* Store frame spec (aSpec[3]) in a 6th slot on the node — can't fit
* in the 5-slot SqlNode, so AAdd post-construction. */
xWinNode := SqlNode( ND_WINDOW, cFuncName, aFuncArgs, aSpec[ 1 ], aSpec[ 2 ] )
AAdd( xWinNode, aSpec[ 3 ] )
RETURN xWinNode
/* Parse frame clause: ROWS/RANGE/GROUPS BETWEEN ... AND ... (SQL:2003/2011) */
METHOD ParseFrameClause() CLASS TSqlParser2
LOCAL hFrame := { => }
LOCAL cFrameType, cStart, cEnd, cExclude
cFrameType := ::TVal( ::nPos )
::nPos++
hFrame[ "type" ] := cFrameType
IF ::IsKW( ::nPos, "BETWEEN" )
::nPos++
cStart := ::ParseFrameBound()
::EatKW( "AND" )
cEnd := ::ParseFrameBound()
hFrame[ "start" ] := cStart
hFrame[ "end" ] := cEnd
ELSE
cStart := ::ParseFrameBound()
hFrame[ "start" ] := cStart
ENDIF
/* EXCLUDE clause (SQL:2011) */
IF ::IsKW( ::nPos, "EXCLUDE" )
::nPos++
cExclude := ""
IF ::IsKW( ::nPos, "NO" )
::nPos++
::EatKW( "OTHERS" )
cExclude := "NO OTHERS"
ELSEIF ::IsKW( ::nPos, "CURRENT" )
::nPos++
::EatKW( "ROW" )
cExclude := "CURRENT ROW"
ELSEIF ::IsKW( ::nPos, "GROUP" )
cExclude := "GROUP"
::nPos++
ELSEIF ::IsKW( ::nPos, "TIES" )
cExclude := "TIES"
::nPos++
ENDIF
hFrame[ "exclude" ] := cExclude
ENDIF
RETURN hFrame
/* Parse a single frame bound */
METHOD ParseFrameBound() CLASS TSqlParser2
LOCAL cBound
IF ::IsKW( ::nPos, "UNBOUNDED" )
::nPos++
IF ::IsKW( ::nPos, "PRECEDING" )
::nPos++
RETURN "UNBOUNDED PRECEDING"
ELSEIF ::IsKW( ::nPos, "FOLLOWING" )
::nPos++
RETURN "UNBOUNDED FOLLOWING"
ENDIF
RETURN "UNBOUNDED"
ENDIF
IF ::IsKW( ::nPos, "CURRENT" )
::nPos++
::EatKW( "ROW" )
RETURN "CURRENT ROW"
ENDIF
IF ::TType( ::nPos ) == TK_NUM
cBound := ::TVal( ::nPos )
::nPos++
IF ::IsKW( ::nPos, "PRECEDING" )
::nPos++
RETURN cBound + " PRECEDING"
ELSEIF ::IsKW( ::nPos, "FOLLOWING" )
::nPos++
RETURN cBound + " FOLLOWING"
ENDIF
RETURN cBound
ENDIF
RETURN "CURRENT ROW"
/* Parse MERGE INTO ... USING ... ON ... WHEN MATCHED/NOT MATCHED */
METHOD ParseMerge() CLASS TSqlParser2
LOCAL h := { => }, cTarget, cSource, cSrcAlias
LOCAL xOnCond, aUpdSet := {}, aInsFlds := {}, aInsVals := {}
LOCAL cCol, xVal
LOCAL lHasMatched := .F., lHasNotMatched := .F.
LOCAL xMatchCond, xNotMatchCond, lDelete
h[ "type" ] := "MERGE"
::nPos++ /* eat MERGE */
::EatKW( "INTO" )
cTarget := ::TVal( ::nPos )
::nPos++
h[ "target" ] := cTarget
::EatKW( "USING" )
cSource := ::TVal( ::nPos )
::nPos++
cSrcAlias := ""
IF ::IsKW( ::nPos, "AS" )
::nPos++
ENDIF
IF ::TType( ::nPos ) == TK_NAME .AND. ! ::IsKW( ::nPos, "ON" )
cSrcAlias := ::TVal( ::nPos )
::nPos++
ENDIF
h[ "source" ] := cSource
h[ "source_alias" ] := cSrcAlias
::EatKW( "ON" )
xOnCond := ::ParseExpr()
h[ "on" ] := xOnCond
xMatchCond := NIL
xNotMatchCond := NIL
lDelete := .F.
/* WHEN MATCHED [AND cond] THEN UPDATE SET ... / DELETE
* WHEN NOT MATCHED [AND cond] THEN INSERT ... */
DO WHILE ::IsKW( ::nPos, "WHEN" )
::nPos++ /* eat WHEN */
IF ::IsKW( ::nPos, "MATCHED" )
::nPos++ /* eat MATCHED */
/* Extended MERGE: optional AND condition (SQL:2008) */
IF ::IsKW( ::nPos, "AND" )
::nPos++
xMatchCond := ::ParseExpr()
ENDIF
::EatKW( "THEN" )
/* WHEN MATCHED THEN DELETE (SQL:2008) */
IF ::IsKW( ::nPos, "DELETE" )
::nPos++
lHasMatched := .T.
lDelete := .T.
ELSE
::EatKW( "UPDATE" )
::EatKW( "SET" )
lHasMatched := .T.
DO WHILE ::TType( ::nPos ) == TK_NAME
cCol := ::TVal( ::nPos )
::nPos++
/* skip table.col format */
IF ::TType( ::nPos ) == TK_DOT
::nPos++
cCol := ::TVal( ::nPos )
::nPos++
ENDIF
IF ::TType( ::nPos ) == TK_EQ
::nPos++
ENDIF
xVal := ::ParseExpr()
AAdd( aUpdSet, { cCol, xVal } )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
ENDIF
ELSEIF ::IsKW( ::nPos, "NOT" )
::nPos++ /* eat NOT */
::EatKW( "MATCHED" )
/* Extended MERGE: optional AND condition (SQL:2008) */
IF ::IsKW( ::nPos, "AND" )
::nPos++
xNotMatchCond := ::ParseExpr()
ENDIF
::EatKW( "THEN" )
::EatKW( "INSERT" )
lHasNotMatched := .T.
/* Optional column list */
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
DO WHILE ::TType( ::nPos ) == TK_NAME
AAdd( aInsFlds, ::TVal( ::nPos ) )
::nPos++
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ELSE
EXIT
ENDIF
ENDDO
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ENDIF
::EatKW( "VALUES" )
IF ::TType( ::nPos ) == TK_LPAR
::nPos++
DO WHILE ::TType( ::nPos ) != TK_RPAR .AND. ::TType( ::nPos ) != TK_END
AAdd( aInsVals, ::ParseExpr() )
IF ::TType( ::nPos ) == TK_COMMA
::nPos++
ENDIF
ENDDO
IF ::TType( ::nPos ) == TK_RPAR
::nPos++
ENDIF
ENDIF
ELSE
EXIT
ENDIF
ENDDO
h[ "has_matched" ] := lHasMatched
h[ "update_set" ] := aUpdSet
h[ "has_not_matched" ] := lHasNotMatched
h[ "insert_fields" ] := aInsFlds
h[ "insert_values" ] := aInsVals
h[ "match_condition" ] := xMatchCond
h[ "not_match_condition" ] := xNotMatchCond
h[ "matched_delete" ] := lDelete
RETURN h