diff --git a/_FiveSql2/src/TSqlExecutor.prg b/_FiveSql2/src/TSqlExecutor.prg index 1f0b7d8..2e75f46 100644 --- a/_FiveSql2/src/TSqlExecutor.prg +++ b/_FiveSql2/src/TSqlExecutor.prg @@ -2400,9 +2400,6 @@ METHOD RunInsert() CLASS TSqlExecutor nWA := SqlExecOpenTable( cTable, cAlias ) - /* Transaction logging */ - ::oTxn:LogRecord( cAlias, RecNo(), "INSERT" ) - dbAppend() IF Len( aFields ) > 0 FOR i := 1 TO Min( Len( aFields ), Len( aValExprs ) ) @@ -2459,6 +2456,12 @@ METHOD RunInsert() CLASS TSqlExecutor NEXT ENDIF + /* Transaction logging — after validation passes, so a rolled-back + * CHECK/FK failure doesn't leave a spurious INSERT in the log at + * the old record's position. LogRecord must also see the new + * RecNo, which only exists post-dbAppend. */ + ::oTxn:LogRecord( cAlias, RecNo(), "INSERT" ) + /* Commit per INSERT when the WA cache is off (legacy durability * guarantee). With the cache on, the caller batches via an * explicit SqlWACacheDisable+dbCloseAll at shutdown — skipping @@ -2477,6 +2480,8 @@ METHOD RunUpdate() CLASS TSqlExecutor LOCAL cTable, aSet, xWhere, cAlias, nWA, i, nFPos, xVal LOCAL nAffected := 0 LOCAL aFPos, aValuePc, pcWhere, lAllOk, cValSrc + LOCAL aPrevVals, lValid + LOCAL hConstraints, lHasConstraints cTable := ::hQuery[ "table" ] aSet := ::hQuery[ "set" ] @@ -2563,19 +2568,56 @@ METHOD RunUpdate() CLASS TSqlExecutor ENDIF /* Fallback: PRG scan loop — handles txn logging + non-compilable - * expressions (subquery, complex CASE, UDF in value or WHERE). */ + * expressions (subquery, complex CASE, UDF in value or WHERE). + * + * Validates CHECK + FK only when the table has SQL-level + * constraints (a `.fsc` metadata file exists). Tables created + * via plain dbCreate have no constraints and skip the validator + * entirely — avoids a recursive five_SQL call inside every + * UPDATE's SqlValidateCheckRecord on transaction-active paths + * where the re-entry would deadlock the executor state. */ + hConstraints := SqlLoadConstraints( cTable ) + lHasConstraints := Len( hConstraints[ "check" ] ) > 0 .OR. ; + Len( hConstraints[ "fk" ] ) > 0 + dbGoTop() WHILE ! Eof() IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) ) IF dbRLock( RecNo() ) ::oTxn:LogRecord( cAlias, RecNo(), "UPDATE" ) + aPrevVals := {} FOR i := 1 TO Len( aSet ) nFPos := FieldPos( aSet[ i ][ 1 ] ) IF nFPos > 0 + AAdd( aPrevVals, { nFPos, FieldGet( nFPos ) } ) xVal := ::EvalExpr( aSet[ i ][ 2 ] ) FieldPut( nFPos, xVal ) ENDIF NEXT + lValid := .T. + IF lHasConstraints + lValid := SqlValidateCheckRecord( cTable ) + IF lValid + FOR i := 1 TO Len( aSet ) + nFPos := FieldPos( aSet[ i ][ 1 ] ) + IF nFPos > 0 .AND. ; + ! SqlValidateFKRecord( cTable, aSet[ i ][ 1 ], FieldGet( nFPos ) ) + lValid := .F. + EXIT + ENDIF + NEXT + ENDIF + ENDIF + IF ! lValid + /* Roll back the in-memory field changes before unlocking. */ + FOR i := 1 TO Len( aPrevVals ) + FieldPut( aPrevVals[ i ][ 1 ], aPrevVals[ i ][ 2 ] ) + NEXT + dbRUnlock( RecNo() ) + SqlExecCloseTable( cAlias, nWA ) + RETURN ::MakeError( SQL_ERR_GRAMMAR, ; + "UPDATE constraint violation on " + cTable ) + ENDIF dbRUnlock( RecNo() ) nAffected++ ENDIF @@ -2608,6 +2650,10 @@ METHOD RunDelete() CLASS TSqlExecutor WHILE ! Eof() IF xWhere == NIL .OR. SqlIsTrue( ::EvalExpr( xWhere ) ) IF dbRLock( RecNo() ) + /* Transaction log the deletion so BEGIN TRANSACTION / + * ROLLBACK can undo it — RunInsert/RunUpdate log, RunDelete + * used to silently skip. */ + ::oTxn:LogRecord( cAlias, RecNo(), "DELETE" ) dbDelete() dbRUnlock( RecNo() ) nAffected++