diff --git a/IscDbc/Connection.h b/IscDbc/Connection.h index 8c980fb2..ef875966 100644 --- a/IscDbc/Connection.h +++ b/IscDbc/Connection.h @@ -528,6 +528,7 @@ class HeadSqlVar virtual char * getSqlData() = 0; virtual short * getSqlInd() = 0; virtual void setSqlData( char *data ) = 0; + virtual void writeStringData( const char *src, int len ) = 0; //virtual void setSqlInd( short *ind ) = 0; virtual bool isReplaceForParamArray () = 0; diff --git a/IscDbc/IscHeadSqlVar.h b/IscDbc/IscHeadSqlVar.h index 38fb53b8..f46e3656 100644 --- a/IscDbc/IscHeadSqlVar.h +++ b/IscDbc/IscHeadSqlVar.h @@ -103,6 +103,27 @@ class IscHeadSqlVar : public HeadSqlVar inline char * getSqlData() { return sqlvar->sqldata; } inline short * getSqlInd() { return sqlvar->sqlind; } + // Write `len` bytes from `src` into the sqlvar's pre-allocated buffer, + // honoring Firebird's in-buffer layout for the prepared type: + // SQL_VARYING : [uint16 len][data...] — leaves sqltype/sqllen untouched + // so checkAndRebuild() sees no override and Firebird reads + // the buffer using the original VARCHAR(N) metadata. + // SQL_TEXT : bare bytes; reports the effective length via sqllen. + inline void writeStringData(const char *src, int len) + { + char *buf = sqlvar->sqldata; + if (sqlvar->sqltype == SQL_VARYING) + { + *(unsigned short*)buf = (unsigned short)len; + memcpy(buf + sizeof(short), src, len); + } + else + { + memcpy(buf, src, len); + sqlvar->sqllen = (short)len; + } + } + // not used //void setSqlInd( short *ind ) { sqlvar->sqlind = ind; } //void setSqlType ( short type ) { sqlvar->sqltype = type; } diff --git a/IscDbc/Sqlda.cpp b/IscDbc/Sqlda.cpp index 4754c833..562bc368 100644 --- a/IscDbc/Sqlda.cpp +++ b/IscDbc/Sqlda.cpp @@ -759,7 +759,18 @@ const char* Sqlda::getColumnName(int index) int Sqlda::getPrecision(int index) { - CAttrSqlVar *var = Var(index); + CAttrSqlVar *curVar = Var(index); + // INPUT parameter precision is immutable from prepare time, even if the + // conversion layer later mutates sqllen on the CAttrSqlVar (e.g. the + // SQL_TEXT branch of writeStringData reports the effective length that + // way). Read from the orgSqlProperties snapshot so re-binding never + // sees a shrunken precision — this mirrors getColumnDisplaySize above. + // OUTPUT always uses the current sqlvar; SQL_ARRAY needs the mutable + // CAttrSqlVar for its `array` pointer. + const SqlProperties *var = + (SqldaDir == SQLDA_INPUT && curVar->sqltype != SQL_ARRAY) + ? orgVarSqlProperties(index) + : curVar; switch (var->sqltype) { @@ -798,8 +809,8 @@ int Sqlda::getPrecision(int index) MAX_DECIMAL_LENGTH, MAX_QUAD_LENGTH); - case SQL_ARRAY: - return var->array->arrOctetLength; + case SQL_ARRAY: + return curVar->array->arrOctetLength; // return MAX_ARRAY_LENGTH; case SQL_BLOB: diff --git a/OdbcConvert.cpp b/OdbcConvert.cpp index 1f5a8594..a2d31f3d 100644 --- a/OdbcConvert.cpp +++ b/OdbcConvert.cpp @@ -114,6 +114,41 @@ inline bool checkIndicatorPtr(SQLLEN* ptr, SQLLEN value, DescRecord* rec) return rec->isIndicatorSqlDa ? *(short*)ptr == (short)value : *ptr == value; } +// Single write-back path for every numeric/date/time → string converter. +// For Firebird-side targets the VARYING-vs-TEXT layout is handled inside +// HeadSqlVar::writeStringData so the conversion helpers do not need to +// know about the uint16 length prefix. For application-owned targets +// we copy into the user buffer and report the length via the indicator. +inline void writeConvertedString(SQLPOINTER pointer, SQLLEN *indicatorTo, + DescRecord *to, const char *src, int len) +{ + if (to->isIndicatorSqlDa) + { + to->headSqlVarPtr->writeStringData(src, len); + } + else + { + if (pointer && len) + memcpy(pointer, src, len); + if (indicatorTo) + setIndicatorPtr(indicatorTo, len, to); + } +} + +// When a numeric / date / time C-type binds to SQL_C_WCHAR, route +// Firebird-side targets through the byte variant. The wide variant would +// write UTF-16 code units that Firebird would reinterpret as raw bytes and +// store embedded NUL bytes as data. ASCII digits and formatted date / time +// strings are identical across every supported charset, so the byte variant +// is correct regardless of the column charset. Application-owned targets +// keep the wide variant. +inline ADRESS_FUNCTION selectToStringConv(DescRecord *to, + ADRESS_FUNCTION byteFn, + ADRESS_FUNCTION wideFn) +{ + return to->isIndicatorSqlDa ? byteFn : wideFn; +} + ////////////////////////////////////////////////////////////////////// // Construction/Destruction ////////////////////////////////////////////////////////////////////// @@ -208,7 +243,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t case SQL_C_CHAR: return &OdbcConvert::convTinyIntToString; case SQL_C_WCHAR: - return &OdbcConvert::convTinyIntToStringW; + return selectToStringConv(to, + &OdbcConvert::convTinyIntToString, + &OdbcConvert::convTinyIntToStringW); case SQL_DECIMAL: case SQL_C_NUMERIC: return &OdbcConvert::convTinyIntToTagNumeric; @@ -263,7 +300,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t case SQL_C_CHAR: return &OdbcConvert::convShortToString; case SQL_C_WCHAR: - return &OdbcConvert::convShortToStringW; + return selectToStringConv(to, + &OdbcConvert::convShortToString, + &OdbcConvert::convShortToStringW); case SQL_DECIMAL: case SQL_C_NUMERIC: return &OdbcConvert::convShortToTagNumeric; @@ -320,7 +359,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t case SQL_C_CHAR: return &OdbcConvert::convLongToString; case SQL_C_WCHAR: - return &OdbcConvert::convLongToStringW; + return selectToStringConv(to, + &OdbcConvert::convLongToString, + &OdbcConvert::convLongToStringW); case SQL_DECIMAL: case SQL_C_NUMERIC: return &OdbcConvert::convLongToTagNumeric; @@ -357,7 +398,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t case SQL_C_CHAR: return &OdbcConvert::convFloatToString; case SQL_C_WCHAR: - return &OdbcConvert::convFloatToStringW; + return selectToStringConv(to, + &OdbcConvert::convFloatToString, + &OdbcConvert::convFloatToStringW); default: return &OdbcConvert::notYetImplemented; } @@ -391,7 +434,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t case SQL_C_CHAR: return &OdbcConvert::convDoubleToString; case SQL_C_WCHAR: - return &OdbcConvert::convDoubleToStringW; + return selectToStringConv(to, + &OdbcConvert::convDoubleToString, + &OdbcConvert::convDoubleToStringW); case SQL_DECIMAL: case SQL_C_NUMERIC: return &OdbcConvert::convDoubleToTagNumeric; @@ -435,7 +480,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t case SQL_C_CHAR: return &OdbcConvert::convBigintToString; case SQL_C_WCHAR: - return &OdbcConvert::convBigintToStringW; + return selectToStringConv(to, + &OdbcConvert::convBigintToString, + &OdbcConvert::convBigintToStringW); case SQL_DECIMAL: case SQL_C_NUMERIC: return &OdbcConvert::convBigintToTagNumeric; @@ -523,7 +570,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t case SQL_C_CHAR: return &OdbcConvert::convDateToString; case SQL_C_WCHAR: - return &OdbcConvert::convDateToStringW; + return selectToStringConv(to, + &OdbcConvert::convDateToString, + &OdbcConvert::convDateToStringW); default: return &OdbcConvert::notYetImplemented; } @@ -560,7 +609,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t case SQL_C_CHAR: return &OdbcConvert::convTimeToString; case SQL_C_WCHAR: - return &OdbcConvert::convTimeToStringW; + return selectToStringConv(to, + &OdbcConvert::convTimeToString, + &OdbcConvert::convTimeToStringW); default: return &OdbcConvert::notYetImplemented; } @@ -596,7 +647,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t case SQL_C_CHAR: return &OdbcConvert::convDateTimeToString; case SQL_C_WCHAR: - return &OdbcConvert::convDateTimeToStringW; + return selectToStringConv(to, + &OdbcConvert::convDateTimeToString, + &OdbcConvert::convDateTimeToStringW); default: return &OdbcConvert::notYetImplemented; } @@ -1303,75 +1356,57 @@ int OdbcConvert::conv##TYPE_FROM##ToString(DescRecord * from, DescRecord * to) \ ODBCCONVERT_CHECKNULL( pointer ); \ \ - int len = to->length; \ + int maxLen = to->length; \ + int len = 0; \ + char buf[128]; \ \ - if ( !len && to->dataPtr) \ - *(char*)to->dataPtr = 0; \ + if ( !maxLen ) \ + { \ + if (to->dataPtr) *(char*)to->dataPtr = 0; \ + } \ else \ { /* Original source from IscDbc/Value.cpp */ \ C_TYPE_FROM number = *(C_TYPE_FROM*)getAdressBindDataFrom((char*)from->dataPtr); \ - char *string = (char*)pointer; \ int scale = -from->scale; \ \ if (number == 0) \ { \ len = 1; \ - strcpy (string, "0"); \ + buf[0] = '0'; \ } \ else if (scale < -DEF_SCALE) \ { \ len = 3; \ - strcpy (string, "***"); \ + buf[0] = buf[1] = buf[2] = '*'; \ } \ else \ { \ bool negative = false; \ + if (number < 0) { number = -number; negative = true; } \ \ - if (number < 0) \ - { \ - number = -number; \ - negative = true; \ - } \ - \ - char temp [100], *p = temp; \ + char temp[100], *p = temp; \ int n; \ for (n = 0; number; number /= 10, --n) \ { \ - if (scale && scale == n) \ - *p++ = '.'; \ - *p++ = '0' + (char) (number % 10); \ + if (scale && scale == n) *p++ = '.'; \ + *p++ = '0' + (char)(number % 10); \ } \ - \ if (scale <= n) \ { \ - for (; n > scale; --n) \ - *p++ = '0'; \ + for (; n > scale; --n) *p++ = '0'; \ *p++ = '.'; \ } \ \ - char *q = string; \ - int l=0; \ - \ - if (negative) \ - *q++ = '-',++l; \ - \ - if ( p - temp > len - l ) \ - p = temp + len - l; \ - \ - while (p > temp) \ - *q++ = *--p; \ - \ - *q = 0; \ - len = q - string; \ + char *q = buf; \ + int l = 0; \ + if (negative) { *q++ = '-'; ++l; } \ + if (p - temp > maxLen - l) p = temp + maxLen - l; \ + while (p > temp) *q++ = *--p; \ + len = (int)(q - buf); \ } \ } \ \ - if ( to->isIndicatorSqlDa ) { \ - to->headSqlVarPtr->setSqlLen(len); \ - } else \ - if ( indicatorTo ) \ - setIndicatorPtr( indicatorTo, len, to ); \ - \ + writeConvertedString(pointer, indicatorTo, to, buf, len); \ return SQL_SUCCESS; \ } \ @@ -1384,6 +1419,9 @@ int OdbcConvert::conv##TYPE_FROM##ToStringW(DescRecord * from, DescRecord * to) \ ODBCCONVERT_CHECKNULLW( pointer ); \ \ + /* getAdressFunction routes Firebird-side targets to the byte variant */ \ + /* (see selectToStringConv), so this wide variant only runs against */ \ + /* application-owned buffers. */ \ int len = to->length; \ \ if ( !len && to->dataPtr) \ @@ -1766,17 +1804,17 @@ int OdbcConvert::convFloatToString(DescRecord * from, DescRecord * to) ODBCCONVERT_CHECKNULL( pointerTo ); - int len = to->length; - - if ( len ) - ConvertFloatToString(*(float*)getAdressBindDataFrom((char*)from->dataPtr), pointerTo, len, &len); + int maxLen = to->length; + int len = 0; + char buf[128]; - if ( to->isIndicatorSqlDa ) { - to->headSqlVarPtr->setSqlLen(len); - } else - if ( indicatorTo ) - setIndicatorPtr(indicatorTo, len, to); + if ( maxLen ) + { + int cap = maxLen < (int)sizeof(buf) ? maxLen : (int)sizeof(buf); + ConvertFloatToString(*(float*)getAdressBindDataFrom((char*)from->dataPtr), buf, cap, &len); + } + writeConvertedString(pointerTo, indicatorTo, to, buf, len); return SQL_SUCCESS; } @@ -1873,17 +1911,17 @@ int OdbcConvert::convDoubleToString(DescRecord * from, DescRecord * to) ODBCCONVERT_CHECKNULL( pointerTo ); - int len = to->length; - - if ( len ) // MAX_DOUBLE_DIGIT_LENGTH = 15 - ConvertFloatToString(*(double*)getAdressBindDataFrom((char*)from->dataPtr), pointerTo, len, &len); + int maxLen = to->length; + int len = 0; + char buf[128]; - if ( to->isIndicatorSqlDa ) { - to->headSqlVarPtr->setSqlLen(len); - } else - if ( indicatorTo ) - setIndicatorPtr(indicatorTo, len, to); + if ( maxLen ) // MAX_DOUBLE_DIGIT_LENGTH = 15 + { + int cap = maxLen < (int)sizeof(buf) ? maxLen : (int)sizeof(buf); + ConvertFloatToString(*(double*)getAdressBindDataFrom((char*)from->dataPtr), buf, cap, &len); + } + writeConvertedString(pointerTo, indicatorTo, to, buf, len); return SQL_SUCCESS; } @@ -1989,20 +2027,15 @@ int OdbcConvert::convDateToString(DescRecord * from, DescRecord * to) SQLUSMALLINT mday, month; SQLSMALLINT year; - decode_sql_date(*(int*)getAdressBindDataFrom((char*)from->dataPtr), mday, month, year); - int len, outlen = to->length; - len = snprintf(pointer, outlen, "%04d-%02d-%02d",year,month,mday); - - if ( len == -1 ) len = outlen; - - if ( to->isIndicatorSqlDa ) { - to->headSqlVarPtr->setSqlLen(len); - } else - if ( indicatorTo ) - setIndicatorPtr(indicatorTo, len, to); + int outlen = to->length; + char buf[32]; + int cap = outlen < (int)sizeof(buf) ? outlen : (int)sizeof(buf); + int len = snprintf(buf, cap, "%04d-%02d-%02d", year, month, mday); + if ( len < 0 || len > cap ) len = cap; + writeConvertedString(pointer, indicatorTo, to, buf, len); return SQL_SUCCESS; } @@ -2167,24 +2200,20 @@ int OdbcConvert::convTimeToString(DescRecord * from, DescRecord * to) SQLUSMALLINT hour, minute, second; int ntime = *(int*)getAdressBindDataFrom((char*)from->dataPtr); int nnano = ntime % ISC_TIME_SECONDS_PRECISION; - decode_sql_time(ntime, hour, minute, second); - int len, outlen = to->length; + int outlen = to->length; + char buf[32]; + int cap = outlen < (int)sizeof(buf) ? outlen : (int)sizeof(buf); + int len; if ( nnano ) - len = snprintf(pointer, outlen, "%02d:%02d:%02d.%04lu",hour, minute, second, nnano); + len = snprintf(buf, cap, "%02d:%02d:%02d.%04lu", hour, minute, second, nnano); else - len = snprintf(pointer, outlen, "%02d:%02d:%02d",hour, minute, second); - - if ( len == -1 ) len = outlen; - - if ( to->isIndicatorSqlDa ) { - to->headSqlVarPtr->setSqlLen(len); - } else - if ( indicatorTo ) - setIndicatorPtr(indicatorTo, len, to); + len = snprintf(buf, cap, "%02d:%02d:%02d", hour, minute, second); + if ( len < 0 || len > cap ) len = cap; + writeConvertedString(pointer, indicatorTo, to, buf, len); return SQL_SUCCESS; } @@ -2363,24 +2392,21 @@ int OdbcConvert::convDateTimeToString(DescRecord * from, DescRecord * to) SQLUSMALLINT mday, month; SQLSMALLINT year; SQLUSMALLINT hour, minute, second; - decode_sql_date(ndate, mday, month, year); decode_sql_time(ntime, hour, minute, second); - int len, outlen = to->length; + + int outlen = to->length; + char buf[48]; + int cap = outlen < (int)sizeof(buf) ? outlen : (int)sizeof(buf); + int len; if ( nnano ) - len = snprintf(pointer, outlen, "%04d-%02d-%02d %02d:%02d:%02d.%04lu",year,month,mday,hour, minute, second, nnano); + len = snprintf(buf, cap, "%04d-%02d-%02d %02d:%02d:%02d.%04lu", year, month, mday, hour, minute, second, nnano); else - len = snprintf(pointer, outlen, "%04d-%02d-%02d %02d:%02d:%02d",year,month,mday,hour, minute, second); - - if ( len == -1 ) len = outlen; - - if ( to->isIndicatorSqlDa ) { - to->headSqlVarPtr->setSqlLen(len); - } else - if ( indicatorTo ) - setIndicatorPtr(indicatorTo, len, to); + len = snprintf(buf, cap, "%04d-%02d-%02d %02d:%02d:%02d", year, month, mday, hour, minute, second); + if ( len < 0 || len > cap ) len = cap; + writeConvertedString(pointer, indicatorTo, to, buf, len); return SQL_SUCCESS; } diff --git a/tests/test_helpers.h b/tests/test_helpers.h index 4844898a..574fe46e 100644 --- a/tests/test_helpers.h +++ b/tests/test_helpers.h @@ -223,12 +223,34 @@ class TempTable { std::string name_; }; -// Returns the Firebird server major version number (e.g. 5 for Firebird 5.x, 6 for 6.x) -// via SQL_DBMS_VER which returns strings like "05.00.0001683" or "06.00.0001884". +// Returns the Firebird server major version number (e.g. 5 for Firebird 5.x, 6 for 6.x). +// +// The Firebird ODBC driver's SQL_DBMS_VER string is NOT the Firebird product +// version — it is constructed from the implementation / protocol version +// fields of `isc_info_version` (see IscDbc/Attachment.cpp:480) and currently +// reads e.g. "06.03.1683 WI-V Firebird 5.0" on Firebird 5.0.3. atoi-ing that +// to get the major returns 6 for every supported server, so anything gated on +// `>= 6` (notably SKIP_ON_FIREBIRD6) silently fires on FB 3 / 4 / 5 too and +// the test is effectively disabled. +// +// Ask the server directly instead — `rdb$get_context('SYSTEM', 'ENGINE_VERSION')` +// returns a straight "5.0.3" / "6.0.0.xxxx" / "3.0.12" string, trivial to parse. inline int GetServerMajorVersion(SQLHDBC hDbc) { - SQLCHAR version[32] = {}; - SQLSMALLINT len = 0; - SQLGetInfo(hDbc, SQL_DBMS_VER, version, sizeof(version), &len); + SQLHSTMT hStmt = SQL_NULL_HSTMT; + if (!SQL_SUCCEEDED(SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hStmt))) return 0; + + SQLRETURN ret = SQLExecDirect(hStmt, + (SQLCHAR*)"SELECT RDB$GET_CONTEXT('SYSTEM', 'ENGINE_VERSION') FROM RDB$DATABASE", + SQL_NTS); + if (!SQL_SUCCEEDED(ret) || !SQL_SUCCEEDED(SQLFetch(hStmt))) { + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); + return 0; + } + + SQLCHAR version[64] = {}; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_CHAR, version, sizeof(version), &ind); + SQLFreeHandle(SQL_HANDLE_STMT, hStmt); return std::atoi((char*)version); } diff --git a/tests/test_param_conversions.cpp b/tests/test_param_conversions.cpp index 5a2661aa..7c538ff3 100644 --- a/tests/test_param_conversions.cpp +++ b/tests/test_param_conversions.cpp @@ -37,6 +37,99 @@ class ParamConversionsTest : public OdbcConnectedTest { std::unique_ptr table_; int nextId_ = 1; + // Shared body for Issue161 rebind-shape tests across different PK column + // types / charsets. pkColumnDef is the SQL text for the PK column type, + // e.g. "VARCHAR(20)", "CHAR(20) CHARACTER SET UTF8", or + // "VARCHAR(20) CHARACTER SET NONE". The NAME column always follows the + // same definition so the test varies one dimension at a time. + void RunIssue161RebindVariant(const char* pkColumnDef, const char* nameColumnDef) { + // Clean up any leftover SP first (the Issue161_*ViaStoredProcedure + // test above creates ODBC_ISSUE161_SP referencing ODBC_ISSUE161_T, + // and if that test crashes mid-run the DROP TABLE here would + // otherwise fail with a dependency error). + ExecIgnoreError("DROP PROCEDURE ODBC_ISSUE161_SP"); + ExecIgnoreError("DROP TABLE ODBC_ISSUE161_T"); + Commit(); + ReallocStmt(); + + std::string createSql = std::string("CREATE TABLE ODBC_ISSUE161_T (") + + "ID " + pkColumnDef + " NOT NULL PRIMARY KEY, " + + "NAME " + nameColumnDef + ")"; + ExecDirect(createSql.c_str()); + Commit(); + ReallocStmt(); + + constexpr int kRowCount = 500; + SQLINTEGER idVal = 0; + SQLLEN idInd = sizeof(idVal); + SQLCHAR nameBuf[32] = {}; + SQLLEN nameInd = SQL_NTS; + + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"UPDATE OR INSERT INTO ODBC_ISSUE161_T (ID, NAME) " + "VALUES (?, ?) MATCHING (ID)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLPrepare failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + for (int i = 1; i <= kRowCount; ++i) { + ret = SQLFreeStmt(hStmt, SQL_RESET_PARAMS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLFreeStmt(RESET_PARAMS) failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + idVal = i; + snprintf((char*)nameBuf, sizeof(nameBuf), "name-%d", i); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_SLONG, SQL_INTEGER, 0, 0, &idVal, sizeof(idVal), &idInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLBindParameter(1) failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_VARCHAR, 100, 0, nameBuf, sizeof(nameBuf), &nameInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLBindParameter(2) failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLExecute failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + } + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COUNT(*), MIN(CAST(ID AS INTEGER)), MAX(CAST(ID AS INTEGER)) " + "FROM ODBC_ISSUE161_T"); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLFetch on aggregate failed"; + + SQLINTEGER cnt = 0, minId = 0, maxId = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &cnt, sizeof(cnt), &ind); + SQLGetData(hStmt, 2, SQL_C_SLONG, &minId, sizeof(minId), &ind); + SQLGetData(hStmt, 3, SQL_C_SLONG, &maxId, sizeof(maxId), &ind); + SQLCloseCursor(hStmt); + + EXPECT_EQ(cnt, kRowCount); + EXPECT_EQ(minId, 1); + EXPECT_EQ(maxId, kRowCount); + + ExecDirect("SELECT COUNT(*) FROM ODBC_ISSUE161_T " + "WHERE POSITION(_OCTETS x'00' IN ID) > 0"); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLFetch on NUL check failed"; + SQLINTEGER nulCount = -1; + SQLGetData(hStmt, 1, SQL_C_SLONG, &nulCount, sizeof(nulCount), &ind); + SQLCloseCursor(hStmt); + EXPECT_EQ(nulCount, 0) << "rows with embedded NUL bytes were stored"; + + ExecIgnoreError("DROP TABLE ODBC_ISSUE161_T"); + Commit(); + ReallocStmt(); + } + // Insert a value using parameter binding and read it back as a string std::string insertAndReadBack(const char* colName, SQLSMALLINT cType, SQLSMALLINT sqlType, @@ -277,6 +370,422 @@ TEST_F(ParamConversionsTest, NumericAsCharParam) { EXPECT_NEAR(atof(result.c_str()), 1234.5678, 0.001); } +// ===== numeric C type → VARCHAR parameter through a stored procedure ===== +// +// Binds SQL_C_SLONG (or wider numeric) to a Firebird VARCHAR stored-procedure +// parameter, executes the statement many times reusing the bind, and verifies +// every row is stored intact. Before the fix the driver wrote the ASCII digits +// over the VARYING length prefix and truncated record->length after the first +// execute, so multi-digit values collapsed to a single character and most rows +// were silently lost (odbc-scanner issue #161). +TEST_F(ParamConversionsTest, Issue161_SLongToVarcharViaStoredProcedure) { + // CI's Firebird-6 master snapshot aborts parameterized EXECUTE PROCEDURE + // with "Stack overflow" (Windows) or SEGFAULT (Linux) on the very first + // SQLExecute — the same parameterized-query regression already documented + // by SKIP_ON_FIREBIRD6(). The underlying driver fix is exercised on FB 3 + // / 4 / 5 anyway, so punt this particular harness until the FB6 + // parameterized-query path is rewritten. + SKIP_ON_FIREBIRD6(); + + // Provision the sandbox: a VARCHAR-keyed table and an UPDATE-OR-INSERT SP. + ExecIgnoreError("EXECUTE BLOCK AS BEGIN " + "IF (EXISTS(SELECT 1 FROM RDB$PROCEDURES WHERE RDB$PROCEDURE_NAME = 'ODBC_ISSUE161_SP')) THEN " + "EXECUTE STATEMENT 'DROP PROCEDURE ODBC_ISSUE161_SP'; END"); + ExecIgnoreError("DROP TABLE ODBC_ISSUE161_T"); + Commit(); + ReallocStmt(); + + ExecDirect("CREATE TABLE ODBC_ISSUE161_T (" + "ID VARCHAR(20) NOT NULL PRIMARY KEY, " + "NAME VARCHAR(100))"); + Commit(); + ReallocStmt(); + + ExecDirect("CREATE PROCEDURE ODBC_ISSUE161_SP (P_ID VARCHAR(20), P_NAME VARCHAR(100)) AS BEGIN " + "UPDATE OR INSERT INTO ODBC_ISSUE161_T (ID, NAME) VALUES (:P_ID, :P_NAME) MATCHING (ID); " + "END"); + Commit(); + ReallocStmt(); + + // Bind SQL_C_SLONG to the VARCHAR SP parameter (the issue-161 scenario) + // and exercise the bind across a range of single, two, and three-digit + // values to catch the "truncated after first row" regression. + constexpr int kRowCount = 500; + SQLINTEGER idVal = 0; + SQLLEN idInd = sizeof(idVal); + SQLCHAR nameBuf[32] = {}; + SQLLEN nameInd = SQL_NTS; + + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"EXECUTE PROCEDURE ODBC_ISSUE161_SP(?, ?)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLPrepare failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_SLONG, SQL_INTEGER, 0, 0, &idVal, sizeof(idVal), &idInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLBindParameter(1) failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_VARCHAR, 100, 0, nameBuf, sizeof(nameBuf), &nameInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLBindParameter(2) failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + for (int i = 1; i <= kRowCount; ++i) { + idVal = i; + snprintf((char*)nameBuf, sizeof(nameBuf), "name-%d", i); + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLExecute failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + } + Commit(); + ReallocStmt(); + + // Every row must be present, intact, with no NUL-byte corruption. + ExecDirect("SELECT COUNT(*), MIN(CAST(ID AS INTEGER)), MAX(CAST(ID AS INTEGER)) " + "FROM ODBC_ISSUE161_T"); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLFetch on aggregate failed"; + + SQLINTEGER cnt = 0, minId = 0, maxId = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &cnt, sizeof(cnt), &ind); + SQLGetData(hStmt, 2, SQL_C_SLONG, &minId, sizeof(minId), &ind); + SQLGetData(hStmt, 3, SQL_C_SLONG, &maxId, sizeof(maxId), &ind); + SQLCloseCursor(hStmt); + + EXPECT_EQ(cnt, kRowCount); + EXPECT_EQ(minId, 1); + EXPECT_EQ(maxId, kRowCount); + + // Also sanity check there is no NUL-byte corruption in any row: the + // octet length of each stored ID should equal the character length of + // the corresponding decimal representation (no embedded '\0' bytes). + ExecDirect("SELECT COUNT(*) FROM ODBC_ISSUE161_T " + "WHERE POSITION(_OCTETS x'00' IN ID) > 0"); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLFetch on NUL check failed"; + SQLINTEGER nulCount = -1; + SQLGetData(hStmt, 1, SQL_C_SLONG, &nulCount, sizeof(nulCount), &ind); + SQLCloseCursor(hStmt); + EXPECT_EQ(nulCount, 0) << "rows with embedded NUL bytes were stored"; + + // Clean up sandbox. + ExecIgnoreError("DROP PROCEDURE ODBC_ISSUE161_SP"); + ExecIgnoreError("DROP TABLE ODBC_ISSUE161_T"); + Commit(); + ReallocStmt(); +} + +// Same scenario as Issue161_SLongToVarcharViaStoredProcedure, but without a +// stored procedure — `UPDATE OR INSERT ... VALUES (?, ?) MATCHING (ID)` with +// SQL_C_SLONG bound to a VARCHAR primary key. The issue body claimed plain +// DML was unaffected, but empirical testing against the old v3.0.1.21 driver +// shows the identical silent data loss (500 rows sent, 11 stored, one with +// embedded NUL byte) — the bug lives entirely in the conv*ToString path and +// does not care about the statement kind. +// +// Also skipped on FB 6: it turns out the FB 6 "Stack overflow" regression is +// not limited to parameterized EXECUTE PROCEDURE — any loop-prepared +// parameterized statement trips it on CI, DML included. Local FB 6 +// snapshots happen to behave, but the matrix runners download a newer +// snapshot and crash. The driver fix is exercised on the FB 3 / 4 / 5 +// matrix jobs. +TEST_F(ParamConversionsTest, Issue161_SLongToVarcharViaDml) { + SKIP_ON_FIREBIRD6(); + + ExecIgnoreError("DROP TABLE ODBC_ISSUE161_T"); + Commit(); + ReallocStmt(); + + ExecDirect("CREATE TABLE ODBC_ISSUE161_T (" + "ID VARCHAR(20) NOT NULL PRIMARY KEY, " + "NAME VARCHAR(100))"); + Commit(); + ReallocStmt(); + + constexpr int kRowCount = 500; + SQLINTEGER idVal = 0; + SQLLEN idInd = sizeof(idVal); + SQLCHAR nameBuf[32] = {}; + SQLLEN nameInd = SQL_NTS; + + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"UPDATE OR INSERT INTO ODBC_ISSUE161_T (ID, NAME) " + "VALUES (?, ?) MATCHING (ID)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLPrepare failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_SLONG, SQL_INTEGER, 0, 0, &idVal, sizeof(idVal), &idInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLBindParameter(1) failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_VARCHAR, 100, 0, nameBuf, sizeof(nameBuf), &nameInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLBindParameter(2) failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + for (int i = 1; i <= kRowCount; ++i) { + idVal = i; + snprintf((char*)nameBuf, sizeof(nameBuf), "name-%d", i); + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLExecute failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + } + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COUNT(*), MIN(CAST(ID AS INTEGER)), MAX(CAST(ID AS INTEGER)) " + "FROM ODBC_ISSUE161_T"); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLFetch on aggregate failed"; + + SQLINTEGER cnt = 0, minId = 0, maxId = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &cnt, sizeof(cnt), &ind); + SQLGetData(hStmt, 2, SQL_C_SLONG, &minId, sizeof(minId), &ind); + SQLGetData(hStmt, 3, SQL_C_SLONG, &maxId, sizeof(maxId), &ind); + SQLCloseCursor(hStmt); + + EXPECT_EQ(cnt, kRowCount); + EXPECT_EQ(minId, 1); + EXPECT_EQ(maxId, kRowCount); + + ExecDirect("SELECT COUNT(*) FROM ODBC_ISSUE161_T " + "WHERE POSITION(_OCTETS x'00' IN ID) > 0"); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLFetch on NUL check failed"; + SQLINTEGER nulCount = -1; + SQLGetData(hStmt, 1, SQL_C_SLONG, &nulCount, sizeof(nulCount), &ind); + SQLCloseCursor(hStmt); + EXPECT_EQ(nulCount, 0) << "rows with embedded NUL bytes were stored"; + + ExecIgnoreError("DROP TABLE ODBC_ISSUE161_T"); + Commit(); + ReallocStmt(); +} + +// Rebind-per-row variant: SQLPrepare once, then for every row call +// SQLFreeStmt(SQL_RESET_PARAMS) + SQLBindParameter(...) + SQLExecute. +// This is the shape DuckDB's odbc-scanner odbc_copy_from uses internally, +// and the one that originally exposed issue #161 as row *loss* (500 sent, +// 11 stored) rather than silent NUL corruption. +// +// Why a separate test: this shape is the only one that exercises the +// Sqlda::getPrecision / orgVarSqlProperties fix. The two preponce_slong +// tests above (Via{StoredProcedure,Dml}) bind once and never re-enter +// defFromMetaDataIn, so a regression that reintroduced +// `record->length = getPrecision(mutated_sqlvar)` would slip past them. +TEST_F(ParamConversionsTest, Issue161_SLongToVarcharViaDmlRebind) { + SKIP_ON_FIREBIRD6(); + + ExecIgnoreError("DROP TABLE ODBC_ISSUE161_T"); + Commit(); + ReallocStmt(); + + ExecDirect("CREATE TABLE ODBC_ISSUE161_T (" + "ID VARCHAR(20) NOT NULL PRIMARY KEY, " + "NAME VARCHAR(100))"); + Commit(); + ReallocStmt(); + + constexpr int kRowCount = 500; + SQLINTEGER idVal = 0; + SQLLEN idInd = sizeof(idVal); + SQLCHAR nameBuf[32] = {}; + SQLLEN nameInd = SQL_NTS; + + SQLRETURN ret = SQLPrepare(hStmt, + (SQLCHAR*)"UPDATE OR INSERT INTO ODBC_ISSUE161_T (ID, NAME) " + "VALUES (?, ?) MATCHING (ID)", SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLPrepare failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + for (int i = 1; i <= kRowCount; ++i) { + ret = SQLFreeStmt(hStmt, SQL_RESET_PARAMS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLFreeStmt(RESET_PARAMS) failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + idVal = i; + snprintf((char*)nameBuf, sizeof(nameBuf), "name-%d", i); + + ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_SLONG, SQL_INTEGER, 0, 0, &idVal, sizeof(idVal), &idInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLBindParameter(1) failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_VARCHAR, 100, 0, nameBuf, sizeof(nameBuf), &nameInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLBindParameter(2) failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLExecute(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLExecute failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + } + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COUNT(*), MIN(CAST(ID AS INTEGER)), MAX(CAST(ID AS INTEGER)) " + "FROM ODBC_ISSUE161_T"); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLFetch on aggregate failed"; + + SQLINTEGER cnt = 0, minId = 0, maxId = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &cnt, sizeof(cnt), &ind); + SQLGetData(hStmt, 2, SQL_C_SLONG, &minId, sizeof(minId), &ind); + SQLGetData(hStmt, 3, SQL_C_SLONG, &maxId, sizeof(maxId), &ind); + SQLCloseCursor(hStmt); + + EXPECT_EQ(cnt, kRowCount); + EXPECT_EQ(minId, 1); + EXPECT_EQ(maxId, kRowCount); + + ExecDirect("SELECT COUNT(*) FROM ODBC_ISSUE161_T " + "WHERE POSITION(_OCTETS x'00' IN ID) > 0"); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLFetch on NUL check failed"; + SQLINTEGER nulCount = -1; + SQLGetData(hStmt, 1, SQL_C_SLONG, &nulCount, sizeof(nulCount), &ind); + SQLCloseCursor(hStmt); + EXPECT_EQ(nulCount, 0) << "rows with embedded NUL bytes were stored"; + + ExecIgnoreError("DROP TABLE ODBC_ISSUE161_T"); + Commit(); + ReallocStmt(); +} + +// Direct-per-row variant: no SQLPrepare cache, each row is bound and executed +// via SQLExecDirect. Exercises the same conv*ToString path as the preponce +// shape but without a persistent prepared-statement context between +// iterations, guarding against any state-leak regression where per-execute +// reset paths diverge from per-prepare paths. +TEST_F(ParamConversionsTest, Issue161_SLongToVarcharViaDmlDirect) { + SKIP_ON_FIREBIRD6(); + + ExecIgnoreError("DROP TABLE ODBC_ISSUE161_T"); + Commit(); + ReallocStmt(); + + ExecDirect("CREATE TABLE ODBC_ISSUE161_T (" + "ID VARCHAR(20) NOT NULL PRIMARY KEY, " + "NAME VARCHAR(100))"); + Commit(); + ReallocStmt(); + + constexpr int kRowCount = 500; + SQLINTEGER idVal = 0; + SQLLEN idInd = sizeof(idVal); + SQLCHAR nameBuf[32] = {}; + SQLLEN nameInd = SQL_NTS; + + const char* kInsertSql = + "UPDATE OR INSERT INTO ODBC_ISSUE161_T (ID, NAME) " + "VALUES (?, ?) MATCHING (ID)"; + + for (int i = 1; i <= kRowCount; ++i) { + idVal = i; + snprintf((char*)nameBuf, sizeof(nameBuf), "name-%d", i); + + SQLRETURN ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT, + SQL_C_SLONG, SQL_INTEGER, 0, 0, &idVal, sizeof(idVal), &idInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLBindParameter(1) failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT, + SQL_C_CHAR, SQL_VARCHAR, 100, 0, nameBuf, sizeof(nameBuf), &nameInd); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLBindParameter(2) failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + ret = SQLExecDirect(hStmt, (SQLCHAR*)kInsertSql, SQL_NTS); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) + << "SQLExecDirect failed on row " << i << ": " + << GetOdbcError(SQL_HANDLE_STMT, hStmt); + + SQLFreeStmt(hStmt, SQL_CLOSE); + SQLFreeStmt(hStmt, SQL_RESET_PARAMS); + } + Commit(); + ReallocStmt(); + + ExecDirect("SELECT COUNT(*), MIN(CAST(ID AS INTEGER)), MAX(CAST(ID AS INTEGER)) " + "FROM ODBC_ISSUE161_T"); + SQLRETURN ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLFetch on aggregate failed"; + + SQLINTEGER cnt = 0, minId = 0, maxId = 0; + SQLLEN ind = 0; + SQLGetData(hStmt, 1, SQL_C_SLONG, &cnt, sizeof(cnt), &ind); + SQLGetData(hStmt, 2, SQL_C_SLONG, &minId, sizeof(minId), &ind); + SQLGetData(hStmt, 3, SQL_C_SLONG, &maxId, sizeof(maxId), &ind); + SQLCloseCursor(hStmt); + + EXPECT_EQ(cnt, kRowCount); + EXPECT_EQ(minId, 1); + EXPECT_EQ(maxId, kRowCount); + + ExecDirect("SELECT COUNT(*) FROM ODBC_ISSUE161_T " + "WHERE POSITION(_OCTETS x'00' IN ID) > 0"); + ret = SQLFetch(hStmt); + ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLFetch on NUL check failed"; + SQLINTEGER nulCount = -1; + SQLGetData(hStmt, 1, SQL_C_SLONG, &nulCount, sizeof(nulCount), &ind); + SQLCloseCursor(hStmt); + EXPECT_EQ(nulCount, 0) << "rows with embedded NUL bytes were stored"; + + ExecIgnoreError("DROP TABLE ODBC_ISSUE161_T"); + Commit(); + ReallocStmt(); +} + +// Column-type / charset matrix for the rebind shape. +// +// irodushka flagged on FirebirdSQL/firebird-odbc-driver#292 that the original +// fix might only have held for VARCHAR targets under the database's default +// charset (UTF8 on the CI matrix's test databases), and that CHAR targets or +// CHARACTER SET NONE might behave differently. Empirically on Windows +// (FB 5.0.3 / FB master) all three additional corners of the matrix pass +// with this PR; these tests lock that in on the CI matrix (Windows x86/x64, +// Linux x64/arm64, Windows ARM64) so a future regression on any column +// type × charset combination fails loudly. +// +// Coverage before these tests (rebind shape only, default UTF8 DB): +// VARCHAR(20) ✓ Issue161_SLongToVarcharViaDmlRebind +// VARCHAR(20) CHARACTER SET NONE ✗ +// CHAR(20) CHARACTER SET UTF8 ✗ (the specific case irodushka tested) +// CHAR(20) CHARACTER SET NONE ✗ + +TEST_F(ParamConversionsTest, Issue161_SLongToCharViaDmlRebind) { + SKIP_ON_FIREBIRD6(); + RunIssue161RebindVariant( + "CHAR(20) CHARACTER SET UTF8", + "CHAR(100) CHARACTER SET UTF8"); +} + +TEST_F(ParamConversionsTest, Issue161_SLongToVarcharViaDmlRebindCharsetNone) { + SKIP_ON_FIREBIRD6(); + RunIssue161RebindVariant( + "VARCHAR(20) CHARACTER SET NONE", + "VARCHAR(100) CHARACTER SET NONE"); +} + +TEST_F(ParamConversionsTest, Issue161_SLongToCharViaDmlRebindCharsetNone) { + SKIP_ON_FIREBIRD6(); + RunIssue161RebindVariant( + "CHAR(20) CHARACTER SET NONE", + "CHAR(100) CHARACTER SET NONE"); +} + // ===== Already-covered round-trip tests from test_data_types.cpp ===== // (IntegerParamInsertAndSelect, VarcharParamInsertAndSelect, // DoubleParamInsertAndSelect, DateParamInsertAndSelect,