Skip to content

Commit 8bce4b2

Browse files
committed
Fix silent data loss binding numeric to VARCHAR via stored procedure
Binding a SQL_C_SLONG (or any numeric/datetime C-type) to a Firebird VARCHAR parameter and executing the statement many times — e.g. a stored-procedure `EXECUTE PROCEDURE sp(?, ?)` called per row — silently corrupted or dropped most rows on the second execute onwards. Two cooperating bugs caused this, both fixed in this commit: 1. `conv<Numeric>ToString` writes raw ASCII digits at offset 0 of the target buffer. That layout is only valid for SQL_TEXT; for the SQL_VARYING parameters Firebird reports for VARCHAR columns the first two bytes are a length prefix. Writing digits over the prefix made Firebird re-interpret the digits as an (absurd) length and then read random data as the parameter value — or accept it and produce a key collision under UPDATE OR INSERT MATCHING, silently dropping rows. The fix calls `setTypeText()` inside each conv_*_ToString / conv_*_ToStringW entry point when the target is a Firebird buffer (`to->isIndicatorSqlDa`), and in `getAdressFunction` routes numeric/date/time → SQL_C_WCHAR Firebird-side writes through the byte variant (UTF-16 would be misread as UTF-8 bytes with embedded NULs anyway, since digits are identical ASCII in every charset). 2. After the first execute, `setSqlLen(actual_length)` shrank the sqlvar's `sqllen` to the length of the first written value (e.g. 1 for "3"). The next call to `defFromMetaDataIn` (triggered whenever odbc_scanner / SQLPrepare re-binds parameters per row) re-read the precision via `Sqlda::getPrecision`, which was using the current sqlvar, so `record->length` collapsed to 1 for every subsequent row. Multi-digit values then got truncated to their first character (e.g. 10 → "1", 27 → "2"), collapsing hundreds of rows onto a handful of keys. `Sqlda::getPrecision` now reads INPUT parameter precision from the immutable `orgSqlProperties` snapshot captured at prepare time — matching what `getColumnDisplaySize` already does for INPUT descriptors. Adds ParamConversionsTest.Issue161_SLongToVarcharViaStoredProcedure which inserts 500 rows through an UPDATE-OR-INSERT stored procedure with SQL_C_SLONG → VARCHAR(20) and asserts count, MIN/MAX, and the absence of any NUL-byte corruption in the stored ids. Fixes: duckdb/odbc-scanner#161
1 parent ca97f46 commit 8bce4b2

3 files changed

Lines changed: 204 additions & 3 deletions

File tree

IscDbc/Sqlda.cpp

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,20 @@ const char* Sqlda::getColumnName(int index)
759759

760760
int Sqlda::getPrecision(int index)
761761
{
762-
CAttrSqlVar *var = Var(index);
762+
CAttrSqlVar *curVar = Var(index);
763+
// Issue #161: for INPUT parameters the conversion code mutates sqlvar
764+
// (via setTypeText/setSqlLen). Reading precision from the current
765+
// sqlvar makes `record->length` shrink after the first row was bound,
766+
// which then causes subsequent numeric→VARCHAR conversions to write
767+
// only the first character of multi-digit values (silent data loss).
768+
// The precision of a prepared parameter is immutable, so read the
769+
// length/type snapshot captured at prepare time for INPUT params.
770+
// SQL_ARRAY still needs the mutable CAttrSqlVar (for the `array`
771+
// pointer), so fall through to Var(index) in that case.
772+
const SqlProperties *var =
773+
(SqldaDir == SQLDA_INPUT && curVar->sqltype != SQL_ARRAY)
774+
? orgVarSqlProperties(index)
775+
: curVar;
763776

764777
switch (var->sqltype)
765778
{
@@ -798,8 +811,8 @@ int Sqlda::getPrecision(int index)
798811
MAX_DECIMAL_LENGTH,
799812
MAX_QUAD_LENGTH);
800813

801-
case SQL_ARRAY:
802-
return var->array->arrOctetLength;
814+
case SQL_ARRAY:
815+
return curVar->array->arrOctetLength;
803816
// return MAX_ARRAY_LENGTH;
804817

805818
case SQL_BLOB:

OdbcConvert.cpp

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,14 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t
208208
case SQL_C_CHAR:
209209
return &OdbcConvert::convTinyIntToString;
210210
case SQL_C_WCHAR:
211+
// Issue #161: when writing into a Firebird input buffer use the
212+
// byte variant. The W variant would write UTF-16 code units,
213+
// which Firebird would then interpret as UTF-8 bytes and store
214+
// the embedded 0x00 bytes as data, corrupting the parameter.
215+
// ASCII digits are identical in UTF-8 and ISO-8859-1, so the
216+
// byte variant works regardless of the column charset.
217+
if ( to->isIndicatorSqlDa )
218+
return &OdbcConvert::convTinyIntToString;
211219
return &OdbcConvert::convTinyIntToStringW;
212220
case SQL_DECIMAL:
213221
case SQL_C_NUMERIC:
@@ -263,6 +271,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t
263271
case SQL_C_CHAR:
264272
return &OdbcConvert::convShortToString;
265273
case SQL_C_WCHAR:
274+
// Issue #161: see SQL_C_WCHAR note for SQL_C_TINYINT above.
275+
if ( to->isIndicatorSqlDa )
276+
return &OdbcConvert::convShortToString;
266277
return &OdbcConvert::convShortToStringW;
267278
case SQL_DECIMAL:
268279
case SQL_C_NUMERIC:
@@ -320,6 +331,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t
320331
case SQL_C_CHAR:
321332
return &OdbcConvert::convLongToString;
322333
case SQL_C_WCHAR:
334+
// Issue #161: see SQL_C_WCHAR note for SQL_C_TINYINT above.
335+
if ( to->isIndicatorSqlDa )
336+
return &OdbcConvert::convLongToString;
323337
return &OdbcConvert::convLongToStringW;
324338
case SQL_DECIMAL:
325339
case SQL_C_NUMERIC:
@@ -357,6 +371,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t
357371
case SQL_C_CHAR:
358372
return &OdbcConvert::convFloatToString;
359373
case SQL_C_WCHAR:
374+
// Issue #161: see SQL_C_WCHAR note for SQL_C_TINYINT above.
375+
if ( to->isIndicatorSqlDa )
376+
return &OdbcConvert::convFloatToString;
360377
return &OdbcConvert::convFloatToStringW;
361378
default:
362379
return &OdbcConvert::notYetImplemented;
@@ -391,6 +408,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t
391408
case SQL_C_CHAR:
392409
return &OdbcConvert::convDoubleToString;
393410
case SQL_C_WCHAR:
411+
// Issue #161: see SQL_C_WCHAR note for SQL_C_TINYINT above.
412+
if ( to->isIndicatorSqlDa )
413+
return &OdbcConvert::convDoubleToString;
394414
return &OdbcConvert::convDoubleToStringW;
395415
case SQL_DECIMAL:
396416
case SQL_C_NUMERIC:
@@ -435,6 +455,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t
435455
case SQL_C_CHAR:
436456
return &OdbcConvert::convBigintToString;
437457
case SQL_C_WCHAR:
458+
// Issue #161: see SQL_C_WCHAR note for SQL_C_TINYINT above.
459+
if ( to->isIndicatorSqlDa )
460+
return &OdbcConvert::convBigintToString;
438461
return &OdbcConvert::convBigintToStringW;
439462
case SQL_DECIMAL:
440463
case SQL_C_NUMERIC:
@@ -523,6 +546,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t
523546
case SQL_C_CHAR:
524547
return &OdbcConvert::convDateToString;
525548
case SQL_C_WCHAR:
549+
// Issue #161: see SQL_C_WCHAR note for SQL_C_TINYINT above.
550+
if ( to->isIndicatorSqlDa )
551+
return &OdbcConvert::convDateToString;
526552
return &OdbcConvert::convDateToStringW;
527553
default:
528554
return &OdbcConvert::notYetImplemented;
@@ -560,6 +586,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t
560586
case SQL_C_CHAR:
561587
return &OdbcConvert::convTimeToString;
562588
case SQL_C_WCHAR:
589+
// Issue #161: see SQL_C_WCHAR note for SQL_C_TINYINT above.
590+
if ( to->isIndicatorSqlDa )
591+
return &OdbcConvert::convTimeToString;
563592
return &OdbcConvert::convTimeToStringW;
564593
default:
565594
return &OdbcConvert::notYetImplemented;
@@ -596,6 +625,9 @@ ADRESS_FUNCTION OdbcConvert::getAdressFunction(DescRecord * from, DescRecord * t
596625
case SQL_C_CHAR:
597626
return &OdbcConvert::convDateTimeToString;
598627
case SQL_C_WCHAR:
628+
// Issue #161: see SQL_C_WCHAR note for SQL_C_TINYINT above.
629+
if ( to->isIndicatorSqlDa )
630+
return &OdbcConvert::convDateTimeToString;
599631
return &OdbcConvert::convDateTimeToStringW;
600632
default:
601633
return &OdbcConvert::notYetImplemented;
@@ -1303,6 +1335,16 @@ int OdbcConvert::conv##TYPE_FROM##ToString(DescRecord * from, DescRecord * to)
13031335
\
13041336
ODBCCONVERT_CHECKNULL( pointer ); \
13051337
\
1338+
/* Issue #161: when writing into a Firebird input buffer, force sqltype to */ \
1339+
/* SQL_TEXT. This routine writes raw ASCII digits at offset 0 of the target */ \
1340+
/* buffer — that is the correct layout for SQL_TEXT, but for SQL_VARYING the */ \
1341+
/* first 2 bytes are reserved for a length prefix. Leaving the buffer as */ \
1342+
/* SQL_VARYING causes Firebird to read the digits as the length prefix, */ \
1343+
/* silently corrupting the parameter (e.g. INTEGER→VARCHAR stored-procedure */ \
1344+
/* parameters would swallow the data and commit only a fraction of rows). */ \
1345+
if ( to->isIndicatorSqlDa ) \
1346+
to->headSqlVarPtr->setTypeText(); \
1347+
\
13061348
int len = to->length; \
13071349
\
13081350
if ( !len && to->dataPtr) \
@@ -1384,6 +1426,12 @@ int OdbcConvert::conv##TYPE_FROM##ToStringW(DescRecord * from, DescRecord * to)
13841426
\
13851427
ODBCCONVERT_CHECKNULLW( pointer ); \
13861428
\
1429+
/* Issue #161: see ODBCCONVERT_CONV_TO_STRING — same reasoning for the wide */ \
1430+
/* variant. Data is written at offset 0 without a length prefix, so the */ \
1431+
/* target must be SQL_TEXT when it is a Firebird input buffer. */ \
1432+
if ( to->isIndicatorSqlDa ) \
1433+
to->headSqlVarPtr->setTypeText(); \
1434+
\
13871435
int len = to->length; \
13881436
\
13891437
if ( !len && to->dataPtr) \
@@ -1766,6 +1814,10 @@ int OdbcConvert::convFloatToString(DescRecord * from, DescRecord * to)
17661814

17671815
ODBCCONVERT_CHECKNULL( pointerTo );
17681816

1817+
// Issue #161: see ODBCCONVERT_CONV_TO_STRING — force SQL_TEXT layout.
1818+
if ( to->isIndicatorSqlDa )
1819+
to->headSqlVarPtr->setTypeText();
1820+
17691821
int len = to->length;
17701822

17711823
if ( len )
@@ -1788,6 +1840,10 @@ int OdbcConvert::convFloatToStringW(DescRecord * from, DescRecord * to)
17881840

17891841
ODBCCONVERT_CHECKNULLW( pointerTo );
17901842

1843+
// Issue #161: see ODBCCONVERT_CONV_TO_STRING — force SQL_TEXT layout.
1844+
if ( to->isIndicatorSqlDa )
1845+
to->headSqlVarPtr->setTypeText();
1846+
17911847
int len = to->length;
17921848

17931849
if ( len )
@@ -1873,6 +1929,10 @@ int OdbcConvert::convDoubleToString(DescRecord * from, DescRecord * to)
18731929

18741930
ODBCCONVERT_CHECKNULL( pointerTo );
18751931

1932+
// Issue #161: see ODBCCONVERT_CONV_TO_STRING — force SQL_TEXT layout.
1933+
if ( to->isIndicatorSqlDa )
1934+
to->headSqlVarPtr->setTypeText();
1935+
18761936
int len = to->length;
18771937

18781938
if ( len ) // MAX_DOUBLE_DIGIT_LENGTH = 15
@@ -1895,6 +1955,10 @@ int OdbcConvert::convDoubleToStringW(DescRecord * from, DescRecord * to)
18951955

18961956
ODBCCONVERT_CHECKNULLW( pointerTo );
18971957

1958+
// Issue #161: see ODBCCONVERT_CONV_TO_STRING — force SQL_TEXT layout.
1959+
if ( to->isIndicatorSqlDa )
1960+
to->headSqlVarPtr->setTypeText();
1961+
18981962
int len = to->length;
18991963

19001964
if ( len ) // MAX_DOUBLE_DIGIT_LENGTH = 15
@@ -1987,6 +2051,10 @@ int OdbcConvert::convDateToString(DescRecord * from, DescRecord * to)
19872051

19882052
ODBCCONVERT_CHECKNULL( pointer );
19892053

2054+
// Issue #161: see ODBCCONVERT_CONV_TO_STRING — force SQL_TEXT layout.
2055+
if ( to->isIndicatorSqlDa )
2056+
to->headSqlVarPtr->setTypeText();
2057+
19902058
SQLUSMALLINT mday, month;
19912059
SQLSMALLINT year;
19922060

@@ -2014,6 +2082,10 @@ int OdbcConvert::convDateToStringW(DescRecord * from, DescRecord * to)
20142082

20152083
ODBCCONVERT_CHECKNULLW( pointer );
20162084

2085+
// Issue #161: see ODBCCONVERT_CONV_TO_STRING — force SQL_TEXT layout.
2086+
if ( to->isIndicatorSqlDa )
2087+
to->headSqlVarPtr->setTypeText();
2088+
20172089
SQLUSMALLINT mday, month;
20182090
SQLSMALLINT year;
20192091

@@ -2164,6 +2236,10 @@ int OdbcConvert::convTimeToString(DescRecord * from, DescRecord * to)
21642236

21652237
ODBCCONVERT_CHECKNULL( pointer );
21662238

2239+
// Issue #161: see ODBCCONVERT_CONV_TO_STRING — force SQL_TEXT layout.
2240+
if ( to->isIndicatorSqlDa )
2241+
to->headSqlVarPtr->setTypeText();
2242+
21672243
SQLUSMALLINT hour, minute, second;
21682244
int ntime = *(int*)getAdressBindDataFrom((char*)from->dataPtr);
21692245
int nnano = ntime % ISC_TIME_SECONDS_PRECISION;
@@ -2196,6 +2272,10 @@ int OdbcConvert::convTimeToStringW(DescRecord * from, DescRecord * to)
21962272

21972273
ODBCCONVERT_CHECKNULLW( pointer );
21982274

2275+
// Issue #161: see ODBCCONVERT_CONV_TO_STRING — force SQL_TEXT layout.
2276+
if ( to->isIndicatorSqlDa )
2277+
to->headSqlVarPtr->setTypeText();
2278+
21992279
SQLUSMALLINT hour, minute, second;
22002280
int ntime = *(int*)getAdressBindDataFrom((char*)from->dataPtr);
22012281
int nnano = ntime % ISC_TIME_SECONDS_PRECISION;
@@ -2356,6 +2436,10 @@ int OdbcConvert::convDateTimeToString(DescRecord * from, DescRecord * to)
23562436

23572437
ODBCCONVERT_CHECKNULL( pointer );
23582438

2439+
// Issue #161: see ODBCCONVERT_CONV_TO_STRING — force SQL_TEXT layout.
2440+
if ( to->isIndicatorSqlDa )
2441+
to->headSqlVarPtr->setTypeText();
2442+
23592443
QUAD pointerFrom = *(QUAD*)getAdressBindDataFrom((char*)from->dataPtr);
23602444
int ndate = LO_LONG(pointerFrom);
23612445
int ntime = HI_LONG(pointerFrom);
@@ -2392,6 +2476,10 @@ int OdbcConvert::convDateTimeToStringW(DescRecord * from, DescRecord * to)
23922476

23932477
ODBCCONVERT_CHECKNULLW( pointer );
23942478

2479+
// Issue #161: see ODBCCONVERT_CONV_TO_STRING — force SQL_TEXT layout.
2480+
if ( to->isIndicatorSqlDa )
2481+
to->headSqlVarPtr->setTypeText();
2482+
23952483
QUAD pointerFrom = *(QUAD*)getAdressBindDataFrom((char*)from->dataPtr);
23962484
int ndate = LO_LONG(pointerFrom);
23972485
int ntime = HI_LONG(pointerFrom);

tests/test_param_conversions.cpp

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,106 @@ TEST_F(ParamConversionsTest, NumericAsCharParam) {
277277
EXPECT_NEAR(atof(result.c_str()), 1234.5678, 0.001);
278278
}
279279

280+
// ===== Issue #161: numeric C type → VARCHAR parameter through a stored procedure =====
281+
//
282+
// Binds SQL_C_SLONG (or wider numeric) to a Firebird VARCHAR stored-procedure
283+
// parameter, executes the statement many times reusing the bind, and verifies
284+
// every row is stored intact. Before the fix the driver wrote the ASCII digits
285+
// over the VARYING length prefix and truncated record->length after the first
286+
// execute, so multi-digit values collapsed to a single character and most rows
287+
// were silently lost (odbc-scanner issue #161).
288+
TEST_F(ParamConversionsTest, Issue161_SLongToVarcharViaStoredProcedure) {
289+
// Provision the sandbox: a VARCHAR-keyed table and an UPDATE-OR-INSERT SP.
290+
ExecIgnoreError("EXECUTE BLOCK AS BEGIN "
291+
"IF (EXISTS(SELECT 1 FROM RDB$PROCEDURES WHERE RDB$PROCEDURE_NAME = 'ODBC_ISSUE161_SP')) THEN "
292+
"EXECUTE STATEMENT 'DROP PROCEDURE ODBC_ISSUE161_SP'; END");
293+
ExecIgnoreError("DROP TABLE ODBC_ISSUE161_T");
294+
Commit();
295+
ReallocStmt();
296+
297+
ExecDirect("CREATE TABLE ODBC_ISSUE161_T ("
298+
"ID VARCHAR(20) NOT NULL PRIMARY KEY, "
299+
"NAME VARCHAR(100))");
300+
Commit();
301+
ReallocStmt();
302+
303+
ExecDirect("CREATE PROCEDURE ODBC_ISSUE161_SP (P_ID VARCHAR(20), P_NAME VARCHAR(100)) AS BEGIN "
304+
"UPDATE OR INSERT INTO ODBC_ISSUE161_T (ID, NAME) VALUES (:P_ID, :P_NAME) MATCHING (ID); "
305+
"END");
306+
Commit();
307+
ReallocStmt();
308+
309+
// Bind SQL_C_SLONG to the VARCHAR SP parameter (the issue-161 scenario)
310+
// and exercise the bind across a range of single, two, and three-digit
311+
// values to catch the "truncated after first row" regression.
312+
constexpr int kRowCount = 500;
313+
SQLINTEGER idVal = 0;
314+
SQLLEN idInd = sizeof(idVal);
315+
SQLCHAR nameBuf[32] = {};
316+
SQLLEN nameInd = SQL_NTS;
317+
318+
SQLRETURN ret = SQLPrepare(hStmt,
319+
(SQLCHAR*)"EXECUTE PROCEDURE ODBC_ISSUE161_SP(?, ?)", SQL_NTS);
320+
ASSERT_TRUE(SQL_SUCCEEDED(ret))
321+
<< "SQLPrepare failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt);
322+
323+
ret = SQLBindParameter(hStmt, 1, SQL_PARAM_INPUT,
324+
SQL_C_SLONG, SQL_INTEGER, 0, 0, &idVal, sizeof(idVal), &idInd);
325+
ASSERT_TRUE(SQL_SUCCEEDED(ret))
326+
<< "SQLBindParameter(1) failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt);
327+
328+
ret = SQLBindParameter(hStmt, 2, SQL_PARAM_INPUT,
329+
SQL_C_CHAR, SQL_VARCHAR, 100, 0, nameBuf, sizeof(nameBuf), &nameInd);
330+
ASSERT_TRUE(SQL_SUCCEEDED(ret))
331+
<< "SQLBindParameter(2) failed: " << GetOdbcError(SQL_HANDLE_STMT, hStmt);
332+
333+
for (int i = 1; i <= kRowCount; ++i) {
334+
idVal = i;
335+
snprintf((char*)nameBuf, sizeof(nameBuf), "name-%d", i);
336+
ret = SQLExecute(hStmt);
337+
ASSERT_TRUE(SQL_SUCCEEDED(ret))
338+
<< "SQLExecute failed on row " << i << ": "
339+
<< GetOdbcError(SQL_HANDLE_STMT, hStmt);
340+
}
341+
Commit();
342+
ReallocStmt();
343+
344+
// Every row must be present, intact, with no NUL-byte corruption.
345+
ExecDirect("SELECT COUNT(*), MIN(CAST(ID AS INTEGER)), MAX(CAST(ID AS INTEGER)) "
346+
"FROM ODBC_ISSUE161_T");
347+
ret = SQLFetch(hStmt);
348+
ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLFetch on aggregate failed";
349+
350+
SQLINTEGER cnt = 0, minId = 0, maxId = 0;
351+
SQLLEN ind = 0;
352+
SQLGetData(hStmt, 1, SQL_C_SLONG, &cnt, sizeof(cnt), &ind);
353+
SQLGetData(hStmt, 2, SQL_C_SLONG, &minId, sizeof(minId), &ind);
354+
SQLGetData(hStmt, 3, SQL_C_SLONG, &maxId, sizeof(maxId), &ind);
355+
SQLCloseCursor(hStmt);
356+
357+
EXPECT_EQ(cnt, kRowCount);
358+
EXPECT_EQ(minId, 1);
359+
EXPECT_EQ(maxId, kRowCount);
360+
361+
// Also sanity check there is no NUL-byte corruption in any row: the
362+
// octet length of each stored ID should equal the character length of
363+
// the corresponding decimal representation (no embedded '\0' bytes).
364+
ExecDirect("SELECT COUNT(*) FROM ODBC_ISSUE161_T "
365+
"WHERE POSITION(_OCTETS x'00' IN ID) > 0");
366+
ret = SQLFetch(hStmt);
367+
ASSERT_TRUE(SQL_SUCCEEDED(ret)) << "SQLFetch on NUL check failed";
368+
SQLINTEGER nulCount = -1;
369+
SQLGetData(hStmt, 1, SQL_C_SLONG, &nulCount, sizeof(nulCount), &ind);
370+
SQLCloseCursor(hStmt);
371+
EXPECT_EQ(nulCount, 0) << "rows with embedded NUL bytes were stored";
372+
373+
// Clean up sandbox.
374+
ExecIgnoreError("DROP PROCEDURE ODBC_ISSUE161_SP");
375+
ExecIgnoreError("DROP TABLE ODBC_ISSUE161_T");
376+
Commit();
377+
ReallocStmt();
378+
}
379+
280380
// ===== Already-covered round-trip tests from test_data_types.cpp =====
281381
// (IntegerParamInsertAndSelect, VarcharParamInsertAndSelect,
282382
// DoubleParamInsertAndSelect, DateParamInsertAndSelect,

0 commit comments

Comments
 (0)