@@ -475,6 +475,186 @@ TEST_F(ParamConversionsTest, Issue161_SLongToVarcharViaDml) {
475475 ReallocStmt ();
476476}
477477
478+ // Rebind-per-row variant: SQLPrepare once, then for every row call
479+ // SQLFreeStmt(SQL_RESET_PARAMS) + SQLBindParameter(...) + SQLExecute.
480+ // This is the shape DuckDB's odbc-scanner odbc_copy_from uses internally,
481+ // and the one that originally exposed issue #161 as row *loss* (500 sent,
482+ // 11 stored) rather than silent NUL corruption.
483+ //
484+ // Why a separate test: this shape is the only one that exercises the
485+ // Sqlda::getPrecision / orgVarSqlProperties fix. The two preponce_slong
486+ // tests above (Via{StoredProcedure,Dml}) bind once and never re-enter
487+ // defFromMetaDataIn, so a regression that reintroduced
488+ // `record->length = getPrecision(mutated_sqlvar)` would slip past them.
489+ TEST_F (ParamConversionsTest, Issue161_SLongToVarcharViaDmlRebind) {
490+ SKIP_ON_FIREBIRD6 ();
491+
492+ ExecIgnoreError (" DROP TABLE ODBC_ISSUE161_T" );
493+ Commit ();
494+ ReallocStmt ();
495+
496+ ExecDirect (" CREATE TABLE ODBC_ISSUE161_T ("
497+ " ID VARCHAR(20) NOT NULL PRIMARY KEY, "
498+ " NAME VARCHAR(100))" );
499+ Commit ();
500+ ReallocStmt ();
501+
502+ constexpr int kRowCount = 500 ;
503+ SQLINTEGER idVal = 0 ;
504+ SQLLEN idInd = sizeof (idVal);
505+ SQLCHAR nameBuf[32 ] = {};
506+ SQLLEN nameInd = SQL_NTS;
507+
508+ SQLRETURN ret = SQLPrepare (hStmt,
509+ (SQLCHAR*)" UPDATE OR INSERT INTO ODBC_ISSUE161_T (ID, NAME) "
510+ " VALUES (?, ?) MATCHING (ID)" , SQL_NTS);
511+ ASSERT_TRUE (SQL_SUCCEEDED (ret))
512+ << " SQLPrepare failed: " << GetOdbcError (SQL_HANDLE_STMT, hStmt);
513+
514+ for (int i = 1 ; i <= kRowCount ; ++i) {
515+ ret = SQLFreeStmt (hStmt, SQL_RESET_PARAMS);
516+ ASSERT_TRUE (SQL_SUCCEEDED (ret))
517+ << " SQLFreeStmt(RESET_PARAMS) failed on row " << i << " : "
518+ << GetOdbcError (SQL_HANDLE_STMT, hStmt);
519+
520+ idVal = i;
521+ snprintf ((char *)nameBuf, sizeof (nameBuf), " name-%d" , i);
522+
523+ ret = SQLBindParameter (hStmt, 1 , SQL_PARAM_INPUT,
524+ SQL_C_SLONG, SQL_INTEGER, 0 , 0 , &idVal, sizeof (idVal), &idInd);
525+ ASSERT_TRUE (SQL_SUCCEEDED (ret))
526+ << " SQLBindParameter(1) failed on row " << i << " : "
527+ << GetOdbcError (SQL_HANDLE_STMT, hStmt);
528+
529+ ret = SQLBindParameter (hStmt, 2 , SQL_PARAM_INPUT,
530+ SQL_C_CHAR, SQL_VARCHAR, 100 , 0 , nameBuf, sizeof (nameBuf), &nameInd);
531+ ASSERT_TRUE (SQL_SUCCEEDED (ret))
532+ << " SQLBindParameter(2) failed on row " << i << " : "
533+ << GetOdbcError (SQL_HANDLE_STMT, hStmt);
534+
535+ ret = SQLExecute (hStmt);
536+ ASSERT_TRUE (SQL_SUCCEEDED (ret))
537+ << " SQLExecute failed on row " << i << " : "
538+ << GetOdbcError (SQL_HANDLE_STMT, hStmt);
539+ }
540+ Commit ();
541+ ReallocStmt ();
542+
543+ ExecDirect (" SELECT COUNT(*), MIN(CAST(ID AS INTEGER)), MAX(CAST(ID AS INTEGER)) "
544+ " FROM ODBC_ISSUE161_T" );
545+ ret = SQLFetch (hStmt);
546+ ASSERT_TRUE (SQL_SUCCEEDED (ret)) << " SQLFetch on aggregate failed" ;
547+
548+ SQLINTEGER cnt = 0 , minId = 0 , maxId = 0 ;
549+ SQLLEN ind = 0 ;
550+ SQLGetData (hStmt, 1 , SQL_C_SLONG, &cnt, sizeof (cnt), &ind);
551+ SQLGetData (hStmt, 2 , SQL_C_SLONG, &minId, sizeof (minId), &ind);
552+ SQLGetData (hStmt, 3 , SQL_C_SLONG, &maxId, sizeof (maxId), &ind);
553+ SQLCloseCursor (hStmt);
554+
555+ EXPECT_EQ (cnt, kRowCount );
556+ EXPECT_EQ (minId, 1 );
557+ EXPECT_EQ (maxId, kRowCount );
558+
559+ ExecDirect (" SELECT COUNT(*) FROM ODBC_ISSUE161_T "
560+ " WHERE POSITION(_OCTETS x'00' IN ID) > 0" );
561+ ret = SQLFetch (hStmt);
562+ ASSERT_TRUE (SQL_SUCCEEDED (ret)) << " SQLFetch on NUL check failed" ;
563+ SQLINTEGER nulCount = -1 ;
564+ SQLGetData (hStmt, 1 , SQL_C_SLONG, &nulCount, sizeof (nulCount), &ind);
565+ SQLCloseCursor (hStmt);
566+ EXPECT_EQ (nulCount, 0 ) << " rows with embedded NUL bytes were stored" ;
567+
568+ ExecIgnoreError (" DROP TABLE ODBC_ISSUE161_T" );
569+ Commit ();
570+ ReallocStmt ();
571+ }
572+
573+ // Direct-per-row variant: no SQLPrepare cache, each row is bound and executed
574+ // via SQLExecDirect. Exercises the same conv*ToString path as the preponce
575+ // shape but without a persistent prepared-statement context between
576+ // iterations, guarding against any state-leak regression where per-execute
577+ // reset paths diverge from per-prepare paths.
578+ TEST_F (ParamConversionsTest, Issue161_SLongToVarcharViaDmlDirect) {
579+ SKIP_ON_FIREBIRD6 ();
580+
581+ ExecIgnoreError (" DROP TABLE ODBC_ISSUE161_T" );
582+ Commit ();
583+ ReallocStmt ();
584+
585+ ExecDirect (" CREATE TABLE ODBC_ISSUE161_T ("
586+ " ID VARCHAR(20) NOT NULL PRIMARY KEY, "
587+ " NAME VARCHAR(100))" );
588+ Commit ();
589+ ReallocStmt ();
590+
591+ constexpr int kRowCount = 500 ;
592+ SQLINTEGER idVal = 0 ;
593+ SQLLEN idInd = sizeof (idVal);
594+ SQLCHAR nameBuf[32 ] = {};
595+ SQLLEN nameInd = SQL_NTS;
596+
597+ const char * kInsertSql =
598+ " UPDATE OR INSERT INTO ODBC_ISSUE161_T (ID, NAME) "
599+ " VALUES (?, ?) MATCHING (ID)" ;
600+
601+ for (int i = 1 ; i <= kRowCount ; ++i) {
602+ idVal = i;
603+ snprintf ((char *)nameBuf, sizeof (nameBuf), " name-%d" , i);
604+
605+ SQLRETURN ret = SQLBindParameter (hStmt, 1 , SQL_PARAM_INPUT,
606+ SQL_C_SLONG, SQL_INTEGER, 0 , 0 , &idVal, sizeof (idVal), &idInd);
607+ ASSERT_TRUE (SQL_SUCCEEDED (ret))
608+ << " SQLBindParameter(1) failed on row " << i << " : "
609+ << GetOdbcError (SQL_HANDLE_STMT, hStmt);
610+
611+ ret = SQLBindParameter (hStmt, 2 , SQL_PARAM_INPUT,
612+ SQL_C_CHAR, SQL_VARCHAR, 100 , 0 , nameBuf, sizeof (nameBuf), &nameInd);
613+ ASSERT_TRUE (SQL_SUCCEEDED (ret))
614+ << " SQLBindParameter(2) failed on row " << i << " : "
615+ << GetOdbcError (SQL_HANDLE_STMT, hStmt);
616+
617+ ret = SQLExecDirect (hStmt, (SQLCHAR*)kInsertSql , SQL_NTS);
618+ ASSERT_TRUE (SQL_SUCCEEDED (ret))
619+ << " SQLExecDirect failed on row " << i << " : "
620+ << GetOdbcError (SQL_HANDLE_STMT, hStmt);
621+
622+ SQLFreeStmt (hStmt, SQL_CLOSE);
623+ SQLFreeStmt (hStmt, SQL_RESET_PARAMS);
624+ }
625+ Commit ();
626+ ReallocStmt ();
627+
628+ ExecDirect (" SELECT COUNT(*), MIN(CAST(ID AS INTEGER)), MAX(CAST(ID AS INTEGER)) "
629+ " FROM ODBC_ISSUE161_T" );
630+ SQLRETURN ret = SQLFetch (hStmt);
631+ ASSERT_TRUE (SQL_SUCCEEDED (ret)) << " SQLFetch on aggregate failed" ;
632+
633+ SQLINTEGER cnt = 0 , minId = 0 , maxId = 0 ;
634+ SQLLEN ind = 0 ;
635+ SQLGetData (hStmt, 1 , SQL_C_SLONG, &cnt, sizeof (cnt), &ind);
636+ SQLGetData (hStmt, 2 , SQL_C_SLONG, &minId, sizeof (minId), &ind);
637+ SQLGetData (hStmt, 3 , SQL_C_SLONG, &maxId, sizeof (maxId), &ind);
638+ SQLCloseCursor (hStmt);
639+
640+ EXPECT_EQ (cnt, kRowCount );
641+ EXPECT_EQ (minId, 1 );
642+ EXPECT_EQ (maxId, kRowCount );
643+
644+ ExecDirect (" SELECT COUNT(*) FROM ODBC_ISSUE161_T "
645+ " WHERE POSITION(_OCTETS x'00' IN ID) > 0" );
646+ ret = SQLFetch (hStmt);
647+ ASSERT_TRUE (SQL_SUCCEEDED (ret)) << " SQLFetch on NUL check failed" ;
648+ SQLINTEGER nulCount = -1 ;
649+ SQLGetData (hStmt, 1 , SQL_C_SLONG, &nulCount, sizeof (nulCount), &ind);
650+ SQLCloseCursor (hStmt);
651+ EXPECT_EQ (nulCount, 0 ) << " rows with embedded NUL bytes were stored" ;
652+
653+ ExecIgnoreError (" DROP TABLE ODBC_ISSUE161_T" );
654+ Commit ();
655+ ReallocStmt ();
656+ }
657+
478658// ===== Already-covered round-trip tests from test_data_types.cpp =====
479659// (IntegerParamInsertAndSelect, VarcharParamInsertAndSelect,
480660// DoubleParamInsertAndSelect, DateParamInsertAndSelect,
0 commit comments