diff --git a/source/pdo_sqlsrv/pdo_dbh.cpp b/source/pdo_sqlsrv/pdo_dbh.cpp index b424866e..bf25d5a8 100644 --- a/source/pdo_sqlsrv/pdo_dbh.cpp +++ b/source/pdo_sqlsrv/pdo_dbh.cpp @@ -80,7 +80,8 @@ enum PDO_STMT_OPTIONS { PDO_STMT_OPTION_CLIENT_BUFFER_MAX_KB_SIZE, PDO_STMT_OPTION_EMULATE_PREPARES, PDO_STMT_OPTION_FETCHES_NUMERIC_TYPE, - PDO_STMT_OPTION_FETCHES_DATETIME_TYPE + PDO_STMT_OPTION_FETCHES_DATETIME_TYPE, + PDO_STMT_OPTION_FORMAT_DECIMALS }; // List of all the statement options supported by this driver. @@ -95,6 +96,7 @@ const stmt_option PDO_STMT_OPTS[] = { { NULL, 0, PDO_STMT_OPTION_EMULATE_PREPARES, std::unique_ptr( new stmt_option_emulate_prepares ) }, { NULL, 0, PDO_STMT_OPTION_FETCHES_NUMERIC_TYPE, std::unique_ptr( new stmt_option_fetch_numeric ) }, { NULL, 0, PDO_STMT_OPTION_FETCHES_DATETIME_TYPE, std::unique_ptr( new stmt_option_fetch_datetime ) }, + { NULL, 0, PDO_STMT_OPTION_FORMAT_DECIMALS, std::unique_ptr( new stmt_option_format_decimals ) }, { NULL, 0, SQLSRV_STMT_OPTION_INVALID, std::unique_ptr{} }, }; @@ -1095,6 +1097,7 @@ int pdo_sqlsrv_dbh_set_attr( _Inout_ pdo_dbh_t *dbh, _In_ zend_long attr, _Inout case PDO_ATTR_EMULATE_PREPARES: case PDO_ATTR_CURSOR: case SQLSRV_ATTR_CURSOR_SCROLL_TYPE: + case SQLSRV_ATTR_FORMAT_DECIMALS: { THROW_PDO_ERROR( driver_dbh, PDO_SQLSRV_ERROR_STMT_LEVEL_ATTR ); } @@ -1153,6 +1156,7 @@ int pdo_sqlsrv_dbh_get_attr( _Inout_ pdo_dbh_t *dbh, _In_ zend_long attr, _Inout case PDO_ATTR_EMULATE_PREPARES: case PDO_ATTR_CURSOR: case SQLSRV_ATTR_CURSOR_SCROLL_TYPE: + case SQLSRV_ATTR_FORMAT_DECIMALS: { THROW_PDO_ERROR( driver_dbh, PDO_SQLSRV_ERROR_STMT_LEVEL_ATTR ); } @@ -1586,6 +1590,10 @@ void add_stmt_option_key( _Inout_ sqlsrv_context& ctx, _In_ size_t key, _Inout_ option_key = PDO_STMT_OPTION_FETCHES_DATETIME_TYPE; break; + case SQLSRV_ATTR_FORMAT_DECIMALS: + option_key = PDO_STMT_OPTION_FORMAT_DECIMALS; + break; + default: CHECK_CUSTOM_ERROR( true, ctx, PDO_SQLSRV_ERROR_INVALID_STMT_OPTION ) { throw core::CoreException(); diff --git a/source/pdo_sqlsrv/pdo_init.cpp b/source/pdo_sqlsrv/pdo_init.cpp index a5fcbdd0..6d47cf5b 100644 --- a/source/pdo_sqlsrv/pdo_init.cpp +++ b/source/pdo_sqlsrv/pdo_init.cpp @@ -286,6 +286,7 @@ namespace { { "SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE", SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE }, { "SQLSRV_ATTR_FETCHES_NUMERIC_TYPE", SQLSRV_ATTR_FETCHES_NUMERIC_TYPE }, { "SQLSRV_ATTR_FETCHES_DATETIME_TYPE", SQLSRV_ATTR_FETCHES_DATETIME_TYPE }, + { "SQLSRV_ATTR_FORMAT_DECIMALS" , SQLSRV_ATTR_FORMAT_DECIMALS }, // used for the size for output parameters: PDO::PARAM_INT and PDO::PARAM_BOOL use the default size of int, // PDO::PARAM_STR uses the size of the string in the variable diff --git a/source/pdo_sqlsrv/pdo_stmt.cpp b/source/pdo_sqlsrv/pdo_stmt.cpp index 697ca40e..de1fc882 100644 --- a/source/pdo_sqlsrv/pdo_stmt.cpp +++ b/source/pdo_sqlsrv/pdo_stmt.cpp @@ -882,6 +882,10 @@ int pdo_sqlsrv_stmt_set_attr( _Inout_ pdo_stmt_t *stmt, _In_ zend_long attr, _In driver_stmt->fetch_datetime = ( zend_is_true( val )) ? true : false; break; + case SQLSRV_ATTR_FORMAT_DECIMALS: + core_sqlsrv_set_format_decimals(driver_stmt, val TSRMLS_CC); + break; + default: THROW_PDO_ERROR( driver_stmt, PDO_SQLSRV_ERROR_INVALID_STMT_ATTR ); break; diff --git a/source/pdo_sqlsrv/pdo_util.cpp b/source/pdo_sqlsrv/pdo_util.cpp index dee2e3e0..0295b406 100644 --- a/source/pdo_sqlsrv/pdo_util.cpp +++ b/source/pdo_sqlsrv/pdo_util.cpp @@ -437,6 +437,14 @@ pdo_error PDO_ERRORS[] = { SQLSRV_ERROR_EMPTY_ACCESS_TOKEN, { IMSSP, (SQLCHAR*) "The Azure AD Access Token is empty. Expected a byte string.", -91, false} }, + { + SQLSRV_ERROR_INVALID_FORMAT_DECIMALS, + { IMSSP, (SQLCHAR*) "Expected an integer to specify number of decimals to format the output values of decimal data types.", -92, false} + }, + { + SQLSRV_ERROR_FORMAT_DECIMALS_OUT_OF_RANGE, + { IMSSP, (SQLCHAR*) "For formatting decimal data values, %1!d! is out of range. Expected an integer from 0 to 38, inclusive.", -93, true} + }, { UINT_MAX, {} } }; diff --git a/source/pdo_sqlsrv/php_pdo_sqlsrv.h b/source/pdo_sqlsrv/php_pdo_sqlsrv.h index 3b13953a..ced89eee 100644 --- a/source/pdo_sqlsrv/php_pdo_sqlsrv.h +++ b/source/pdo_sqlsrv/php_pdo_sqlsrv.h @@ -41,14 +41,15 @@ extern "C" { // sqlsrv driver specific PDO attributes enum PDO_SQLSRV_ATTR { - // Currently there are only three custom attributes for this driver. + // The custom attributes for this driver: SQLSRV_ATTR_ENCODING = PDO_ATTR_DRIVER_SPECIFIC, SQLSRV_ATTR_QUERY_TIMEOUT, SQLSRV_ATTR_DIRECT_QUERY, SQLSRV_ATTR_CURSOR_SCROLL_TYPE, SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE, SQLSRV_ATTR_FETCHES_NUMERIC_TYPE, - SQLSRV_ATTR_FETCHES_DATETIME_TYPE + SQLSRV_ATTR_FETCHES_DATETIME_TYPE, + SQLSRV_ATTR_FORMAT_DECIMALS }; // valid set of values for TransactionIsolation connection option diff --git a/source/shared/core_sqlsrv.h b/source/shared/core_sqlsrv.h index 59b5afcd..91be215d 100644 --- a/source/shared/core_sqlsrv.h +++ b/source/shared/core_sqlsrv.h @@ -1527,7 +1527,7 @@ void core_sqlsrv_set_send_at_exec( _Inout_ sqlsrv_stmt* stmt, _In_ zval* value_z bool core_sqlsrv_send_stream_packet( _Inout_ sqlsrv_stmt* stmt TSRMLS_DC ); void core_sqlsrv_set_buffered_query_limit( _Inout_ sqlsrv_stmt* stmt, _In_ zval* value_z TSRMLS_DC ); void core_sqlsrv_set_buffered_query_limit( _Inout_ sqlsrv_stmt* stmt, _In_ SQLLEN limit TSRMLS_DC ); - +void core_sqlsrv_set_format_decimals(_Inout_ sqlsrv_stmt* stmt, _In_ zval* value_z TSRMLS_DC); //********************************************************************************************************************************* // Result Set @@ -1707,7 +1707,6 @@ struct sqlsrv_buffered_result_set : public sqlsrv_result_set { // utility functions shared by multiple callers across files bool convert_string_from_utf16_inplace( _In_ SQLSRV_ENCODING encoding, _Inout_updates_z_(len) char** string, _Inout_ SQLLEN& len); -bool convert_zval_string_from_utf16( _In_ SQLSRV_ENCODING encoding, _Inout_ zval* value_z, _Inout_ SQLLEN& len); bool validate_string( _In_ char* string, _In_ SQLLEN& len); bool convert_string_from_utf16( _In_ SQLSRV_ENCODING encoding, _In_reads_bytes_(cchInLen) const SQLWCHAR* inString, _In_ SQLINTEGER cchInLen, _Inout_updates_bytes_(cchOutLen) char** outString, _Out_ SQLLEN& cchOutLen ); SQLWCHAR* utf16_string_from_mbcs_string( _In_ SQLSRV_ENCODING php_encoding, _In_reads_bytes_(mbcs_len) const char* mbcs_string, _In_ unsigned int mbcs_len, _Out_ unsigned int* utf16_len ); diff --git a/source/shared/core_stmt.cpp b/source/shared/core_stmt.cpp index 58ec4520..206fe11d 100644 --- a/source/shared/core_stmt.cpp +++ b/source/shared/core_stmt.cpp @@ -1258,6 +1258,26 @@ void core_sqlsrv_set_query_timeout( _Inout_ sqlsrv_stmt* stmt, _In_ long timeout } } +void core_sqlsrv_set_format_decimals(_Inout_ sqlsrv_stmt* stmt, _In_ zval* value_z TSRMLS_DC) +{ + try { + // first check if the input is an integer + CHECK_CUSTOM_ERROR(Z_TYPE_P(value_z) != IS_LONG, stmt, SQLSRV_ERROR_INVALID_FORMAT_DECIMALS) { + throw core::CoreException(); + } + + zend_long format_decimals = Z_LVAL_P(value_z); + CHECK_CUSTOM_ERROR(format_decimals < 0 || format_decimals > SQL_SERVER_MAX_PRECISION, stmt, SQLSRV_ERROR_FORMAT_DECIMALS_OUT_OF_RANGE, format_decimals) { + throw core::CoreException(); + } + + stmt->num_decimals = static_cast(format_decimals); + } + catch( core::CoreException& ) { + throw; + } +} + void core_sqlsrv_set_send_at_exec( _Inout_ sqlsrv_stmt* stmt, _In_ zval* value_z TSRMLS_DC ) { TSRMLS_C; @@ -1427,17 +1447,7 @@ void stmt_option_date_as_string:: operator()( _Inout_ sqlsrv_stmt* stmt, stmt_op void stmt_option_format_decimals:: operator()( _Inout_ sqlsrv_stmt* stmt, stmt_option const* /**/, _In_ zval* value_z TSRMLS_DC ) { - // first check if the input is an integer - CHECK_CUSTOM_ERROR(Z_TYPE_P(value_z) != IS_LONG, stmt, SQLSRV_ERROR_INVALID_FORMAT_DECIMALS) { - throw core::CoreException(); - } - - zend_long format_decimals = Z_LVAL_P(value_z); - CHECK_CUSTOM_ERROR(format_decimals < 0 || format_decimals > SQL_SERVER_MAX_PRECISION, stmt, SQLSRV_ERROR_FORMAT_DECIMALS_OUT_OF_RANGE, format_decimals) { - throw core::CoreException(); - } - - stmt->num_decimals = static_cast(format_decimals); + core_sqlsrv_set_format_decimals(stmt, value_z TSRMLS_CC); } // internal function to release the active stream. Called by each main API function @@ -2293,27 +2303,39 @@ void finalize_output_parameters( _Inout_ sqlsrv_stmt* stmt TSRMLS_DC ) str_len = output_param->original_buffer_len - null_size; } - // if it's not in the 8 bit encodings, then it's in UTF-16 - if( output_param->encoding != SQLSRV_ENCODING_CHAR && output_param->encoding != SQLSRV_ENCODING_BINARY ) { - bool converted = convert_zval_string_from_utf16(output_param->encoding, value_z, str_len); - CHECK_CUSTOM_ERROR( !converted, stmt, SQLSRV_ERROR_OUTPUT_PARAM_ENCODING_TRANSLATE, get_last_error_message()) { - throw core::CoreException(); - } - } - else if( output_param->encoding == SQLSRV_ENCODING_BINARY && str_len < output_param->original_buffer_len ) { + if (output_param->encoding == SQLSRV_ENCODING_BINARY) { // ODBC doesn't null terminate binary encodings, but PHP complains if a string isn't null terminated // so we do that here if the length of the returned data is less than the original allocation. The // original allocation null terminates the buffer already. - str[str_len] = '\0'; + if (str_len < output_param->original_buffer_len) { + str[str_len] = '\0'; + } core::sqlsrv_zval_stringl(value_z, str, str_len); } else { SQLSMALLINT decimal_digits = output_param->getDecimalDigits(); - if (stmt->num_decimals >= 0 && decimal_digits >= 0) { - format_decimal_numbers(stmt->num_decimals, decimal_digits, str, &str_len); - } - core::sqlsrv_zval_stringl(value_z, str, str_len); + if (output_param->encoding != SQLSRV_ENCODING_CHAR) { + char* outString = NULL; + SQLLEN outLen = 0; + bool result = convert_string_from_utf16(output_param->encoding, reinterpret_cast(str), int(str_len / sizeof(SQLWCHAR)), &outString, outLen ); + CHECK_CUSTOM_ERROR(!result, stmt, SQLSRV_ERROR_OUTPUT_PARAM_ENCODING_TRANSLATE, get_last_error_message()) { + throw core::CoreException(); + } + + if (stmt->num_decimals >= 0 && decimal_digits >= 0) { + format_decimal_numbers(stmt->num_decimals, decimal_digits, outString, &outLen); + } + core::sqlsrv_zval_stringl(value_z, outString, outLen); + sqlsrv_free(outString); + } + else { + if (stmt->num_decimals >= 0 && decimal_digits >= 0) { + format_decimal_numbers(stmt->num_decimals, decimal_digits, str, &str_len); + } + + core::sqlsrv_zval_stringl(value_z, str, str_len); + } } } break; diff --git a/source/shared/core_util.cpp b/source/shared/core_util.cpp index ca097a24..03675a08 100644 --- a/source/shared/core_util.cpp +++ b/source/shared/core_util.cpp @@ -91,25 +91,6 @@ bool convert_string_from_utf16_inplace( _In_ SQLSRV_ENCODING encoding, _Inout_up return result; } -bool convert_zval_string_from_utf16( _In_ SQLSRV_ENCODING encoding, _Inout_ zval* value_z, _Inout_ SQLLEN& len) -{ - char* string = Z_STRVAL_P(value_z); - - if( validate_string(string, len)) { - return true; - } - - char* outString = NULL; - SQLLEN outLen = 0; - bool result = convert_string_from_utf16( encoding, reinterpret_cast(string), int(len / sizeof(SQLWCHAR)), &outString, outLen ); - if( result ) { - core::sqlsrv_zval_stringl( value_z, outString, outLen ); - sqlsrv_free( outString ); - len = outLen; - } - return result; -} - bool validate_string( _In_ char* string, _In_ SQLLEN& len ) { SQLSRV_ASSERT(string != NULL, "String must be specified"); diff --git a/test/functional/pdo_sqlsrv/pdostatement_format_decimals.phpt b/test/functional/pdo_sqlsrv/pdostatement_format_decimals.phpt new file mode 100644 index 00000000..b4410e27 --- /dev/null +++ b/test/functional/pdo_sqlsrv/pdostatement_format_decimals.phpt @@ -0,0 +1,390 @@ +--TEST-- +Test statement attribute PDO::SQLSRV_ATTR_FORMAT_DECIMALS for decimal types +--DESCRIPTION-- +Test statement attribute PDO::SQLSRV_ATTR_FORMAT_DECIMALS for decimal or +money types (feature request issue 415), which are always fetched as strings +to preserve accuracy and precision, unlike other primitive numeric types, +where there is an option to retrieve them as numbers. + +This attribute expects an integer value from the range [0,38], the money or +decimal types in the fetched result set can be formatted. + +No effect on other operations like insertion or update. + +1. By default, data will be returned with the original precision and scale +2. The data column original scale still takes precedence – for example, if the user +specifies 3 decimal digits for a column of decimal(5,2), the result still shows only 2 +decimals to the right of the dot +3. After formatting, the missing leading zeroes will be padded +4. The underlying data will not be altered, but formatted results may likely be rounded +up (e.g. .2954 will be displayed as 0.30 if the user wants only two decimals) +5. Do not support output params +--ENV-- +PHPT_EXEC=true +--SKIPIF-- + +--FILE-- +getMessage(), $expected) === false) { + print_r($exception->getMessage()); + echo "\n"; + } +} + +function testPdoAttribute($conn, $setAttr) +{ + // Expects exception because PDO::SQLSRV_ATTR_FORMAT_DECIMALS + // is a statement level attribute + try { + $res = true; + if ($setAttr) { + $res = $conn->setAttribute(PDO::SQLSRV_ATTR_FORMAT_DECIMALS, 1); + } else { + $res = $conn->getAttribute(PDO::SQLSRV_ATTR_FORMAT_DECIMALS); + } + if ($res) { + echo "setAttribute at PDO level should have failed!\n"; + } + } catch (PdoException $e) { + if ($setAttr) { + $expected = 'The given attribute is only supported on the PDOStatement object.'; + } else { + $expected = 'driver does not support that attribute'; + } + + checkException($e, $expected); + } +} + +function testErrorCases($conn) +{ + $query = "SELECT 0.0001"; + + try { + $options = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => 0.9); + $stmt = $conn->prepare($query, $options); + } catch (PdoException $e) { + $expected = 'Expected an integer to specify number of decimals to format the output values of decimal data types'; + checkException($e, $expected); + } + + try { + $options = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => 100); + $stmt = $conn->prepare($query, $options); + } catch (PdoException $e) { + $expected = 'For formatting decimal data values, 100 is out of range. Expected an integer from 0 to 38, inclusive.'; + checkException($e, $expected); + } +} + +function verifyMoneyValues($conn, $query, $values, $numDigits) +{ + $options = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => $numDigits); + $stmt = $conn->prepare($query, $options); + $stmt->execute(); + $results = $stmt->fetch(PDO::FETCH_NUM); + + trace("\nverifyMoneyValues:\n"); + for ($i = 0; $i < count($values); $i++) { + $value = number_format($values[$i], $numDigits); + trace("$results[$i], $value\n"); + + if ($value !== $results[$i]) { + echo "testMoneyTypes: Expected $value but got $results[$i]\n"; + } + } +} + +function testFloatTypes($conn) +{ + // This test with the float types of various number of bits, which are retrieved + // as numbers by default. When fetched as strings, no formatting is done even with + // the statement option FormatDecimals set + $epsilon = 0.001; + $values = array(); + for ($i = 0; $i < 5; $i++) { + $n1 = rand(1, 100); + $n2 = rand(1, 100); + $neg = ($i % 2 == 0) ? -1 : 1; + + $n = $neg * $n1 / $n2; + array_push($values, $n); + } + + $query = "SELECT CONVERT(float(1), $values[0]), + CONVERT(float(12), $values[1]), + CONVERT(float(24), $values[2]), + CONVERT(float(36), $values[3]), + CONVERT(float(53), $values[4])"; + $stmt = $conn->query($query); + $floats = $stmt->fetch(PDO::FETCH_NUM); + unset($stmt); + + // Set PDO::SQLSRV_ATTR_FORMAT_DECIMALS to 2 should + // have no effect on floating point numbers + $numDigits = 2; + $options = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => $numDigits); + $stmt = $conn->prepare($query, $options); + + // By default the floating point numbers are fetched as strings + for ($i = 0; $i < 5; $i++) { + $stmt->execute(); + $floatStr = $stmt->fetchColumn($i); + + $floatVal = floatVal($floats[$i]); + $floatVal1 = floatval($floatStr); + + trace("testFloatTypes: $floatVal1, $floatVal\n"); + + // Check if the numbers of decimal digits are the same + // It is highly unlikely but not impossible + $numbers = explode('.', $floatStr); + $len = strlen($numbers[1]); + if ($len == $numDigits && $floatVal1 != $floatVal) { + echo "Expected $floatVal but $floatVal1 returned. \n"; + } else { + $diff = abs($floatVal1 - $floatVal) / $floatVal; + if ($diff > $epsilon) { + echo "Expected $floatVal but $floatVal1 returned. \n"; + } + } + } +} + +function testMoneyTypes($conn) +{ + // With money and smallmoney types, which are essentially decimal types + // ODBC driver does not support Always Encrypted feature with money / smallmoney + $values = array('24.559', '0', '-0.946', '0.2985', '-99.675', '79.995'); + $defaults = array('24.5590', '.0000', '-.9460', '.2985', '-99.6750', '79.9950'); + + $query = "SELECT CONVERT(smallmoney, $values[0]), + CONVERT(money, $values[1]), + CONVERT(smallmoney, $values[2]), + CONVERT(money, $values[3]), + CONVERT(smallmoney, $values[4]), + CONVERT(money, $values[5])"; + + $stmt = $conn->query($query); + $results = $stmt->fetch(PDO::FETCH_NUM); + for ($i = 0; $i < count($values); $i++) { + if ($defaults[$i] !== $results[$i]) { + echo "testMoneyTypes: Expected $defaults[$i] but got $results[$i]\n"; + } + } + unset($stmt); + + // Set PDO::SQLSRV_ATTR_FORMAT_DECIMALS to 0 then 2 + verifyMoneyValues($conn, $query, $values, 0); + verifyMoneyValues($conn, $query, $values, 2); +} + +function compareNumbers($actual, $input, $column, $fieldScale, $formatDecimal = -1) +{ + $matched = false; + if ($actual === $input) { + $matched = true; + trace("$actual, $input\n"); + } else { + // When $formatDecimal is negative, that means no formatting done + // Otherwise, if $formatDecimal > $fieldScale, will show $fieldScale decimal digits + if ($formatDecimal >= 0) { + $numDecimals = ($formatDecimal > $fieldScale) ? $fieldScale : $formatDecimal; + } else { + $numDecimals = $fieldScale; + } + $expected = number_format($input, $numDecimals); + trace("$actual, $expected\n"); + if ($actual === $expected) { + $matched = true; + } else { + echo "For $column: expected $expected but the value is $actual\n"; + } + } + return $matched; +} + +function testNoOption($conn, $tableName, $inputs, $columns) +{ + // Without the statement option, should return decimal values as they are + $query = "SELECT * FROM $tableName"; + $stmt = $conn->query($query); + + // Compare values + $results = $stmt->fetch(PDO::FETCH_NUM); + trace("\ntestNoOption:\n"); + for ($i = 0; $i < count($inputs); $i++) { + compareNumbers($results[$i], $inputs[$i], $columns[$i], $i); + } +} + +function testStmtOption($conn, $tableName, $inputs, $columns, $formatDecimal, $withBuffer) +{ + // Decimal values should return decimal digits based on the valid statement + // option PDO::SQLSRV_ATTR_FORMAT_DECIMALS + $query = "SELECT * FROM $tableName"; + if ($withBuffer){ + $options = array(PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL, + PDO::SQLSRV_ATTR_CURSOR_SCROLL_TYPE => PDO::SQLSRV_CURSOR_BUFFERED, + PDO::SQLSRV_ATTR_FORMAT_DECIMALS => $formatDecimal); + } else { + $options = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => $formatDecimal); + } + + $size = count($inputs); + $stmt = $conn->prepare($query, $options); + + // Fetch by getting one field at a time + trace("\ntestStmtOption: $formatDecimal and buffered $withBuffer\n"); + for ($i = 0; $i < $size; $i++) { + $stmt->execute(); + + $stmt->bindColumn($columns[$i], $field); + $result = $stmt->fetch(PDO::FETCH_BOUND); + + compareNumbers($field, $inputs[$i], $columns[$i], $i, $formatDecimal); + } +} + +function getOutputParam($conn, $storedProcName, $inputValue, $prec, $scale, $inout) +{ + $outString = ''; + $numDigits = 2; + + $outSql = getCallProcSqlPlaceholders($storedProcName, 1); + + $options = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => $numDigits); + $stmt = $conn->prepare($outSql, $options); + + $len = 1024; + if ($inout) { + $paramType = PDO::PARAM_STR | PDO::PARAM_INPUT_OUTPUT; + + // For inout parameters the input type should match the output one + $outString = '0.0'; + } else { + $paramType = PDO::PARAM_STR; + } + + $stmt->bindParam(1, $outString, $paramType, $len); + $stmt->execute(); + + // The output param value should be the same as the input value, + // unaffected by the statement attr PDO::SQLSRV_ATTR_FORMAT_DECIMALS, + // unless ColumnEncryption is enabled, in which case the driver is able + // to derive the decimal type + if (isAEConnected()) { + trace("\ngetOutputParam ($inout) with AE:\n"); + $column = 'outputParamAE'; + compareNumbers($outString, $inputValue, $column, $scale, $numDigits); + } else { + trace("\ngetOutputParam ($inout) without AE:\n"); + $column = 'outputParam'; + compareNumbers($outString, $inputValue, $column, $scale); + } +} + +function testOutputParam($conn, $tableName, $inputs, $columns, $dataTypes, $inout = false) +{ + for ($i = 0, $p = 3; $i < count($columns); $i++, $p++) { + // Create the stored procedure first + $storedProcName = "spFormatDecimals" . $i; + $procArgs = "@col $dataTypes[$i] OUTPUT"; + $procCode = "SELECT @col = $columns[$i] FROM $tableName"; + createProc($conn, $storedProcName, $procArgs, $procCode); + + // Call stored procedure to retrieve output param + getOutputParam($conn, $storedProcName, $inputs[$i], $p, $i, $inout); + + dropProc($conn, $storedProcName); + } +} + +try { + // This helper method sets PDO::ATTR_ERRMODE to PDO::ERRMODE_EXCEPTION + $conn = connect(); + + // Test some error conditions + testPdoAttribute($conn, true); + testPdoAttribute($conn, false); + testErrorCases($conn); + + // First test with money types + testMoneyTypes($conn); + + // Also test using regular floats + testFloatTypes($conn); + + // Create the test table of decimal / numeric data columns + $tableName = 'pdoFormatDecimals'; + + $columns = array('c1', 'c2', 'c3', 'c4', 'c5', 'c6'); + $dataTypes = array('decimal(3,0)', 'decimal(4,1)', 'decimal(5,2)', 'numeric(6,3)', 'numeric(7,4)', 'numeric(8, 5)'); + + $colMeta = array(new ColumnMeta($dataTypes[0], $columns[0]), + new ColumnMeta($dataTypes[1], $columns[1]), + new ColumnMeta($dataTypes[2], $columns[2]), + new ColumnMeta($dataTypes[3], $columns[3]), + new ColumnMeta($dataTypes[4], $columns[4]), + new ColumnMeta($dataTypes[5], $columns[5])); + createTable($conn, $tableName, $colMeta); + + // Generate random input values based on precision and scale + trace("\nGenerating random input values: \n"); + $values = array(); + $max = 1; + for ($s = 0, $p = 3; $s < count($columns); $s++, $p++) { + // First get a random number + $n = rand(1, 6); + $neg = ($n % 2 == 0) ? -1 : 1; + + // $n1 may or may not be negative + $n1 = rand(0, 1000) * $neg; + + if ($s > 0) { + $max *= 10; + $n2 = rand(0, $max); + $number = sprintf("%d.%d", $n1, $n2); + } else { + $number = sprintf("%d", $n1); + } + + trace("$s: $number\n"); + array_push($values, $number); + } + + $query = "INSERT INTO $tableName VALUES(?, ?, ?, ?, ?, ?)"; + $stmt = $conn->prepare($query); + for ($i = 0; $i < count($columns); $i++) { + $stmt->bindParam($i+1, $values[$i]); + } + $stmt->execute(); + + testNoOption($conn, $tableName, $values, $columns, true); + + // Now try with setting number decimals to 3 then 2 + testStmtOption($conn, $tableName, $values, $columns, 3, false); + testStmtOption($conn, $tableName, $values, $columns, 3, true); + + testStmtOption($conn, $tableName, $values, $columns, 2, false); + testStmtOption($conn, $tableName, $values, $columns, 2, true); + + // Test output parameters + testOutputParam($conn, $tableName, $values, $columns, $dataTypes); + testOutputParam($conn, $tableName, $values, $columns, $dataTypes, true); + + dropTable($conn, $tableName); + echo "Done\n"; + + unset($stmt); + unset($conn); +} catch (PdoException $e) { + echo $e->getMessage() . PHP_EOL; +} +?> +--EXPECT-- +Done diff --git a/test/functional/pdo_sqlsrv/pdostatement_format_decimals_scales.phpt b/test/functional/pdo_sqlsrv/pdostatement_format_decimals_scales.phpt new file mode 100644 index 00000000..a8a40d48 --- /dev/null +++ b/test/functional/pdo_sqlsrv/pdostatement_format_decimals_scales.phpt @@ -0,0 +1,248 @@ +--TEST-- +Test various precisions of formatting decimal data output values (feature request issue 415) +--DESCRIPTION-- +In SQL Server, the maximum allowed precision is 38. The scale can range from 0 up to the +defined precision. Generate a long numeric string and get rid of the last digit to make it a +39-digit-string. Then replace one digit at a time with a dot '.' to make it a decimal +input string for testing with various scales. +For example, +string(39) ".23456789012345678901234567890123456789" +string(39) "1.3456789012345678901234567890123456789" +string(39) "12.456789012345678901234567890123456789" +string(39) "123.56789012345678901234567890123456789" +string(39) "1234.6789012345678901234567890123456789" +string(39) "12345.789012345678901234567890123456789" +... ... +string(39) "1234567890123456789012345678901234.6789" +string(39) "12345678901234567890123456789012345.789" +string(39) "123456789012345678901234567890123456.89" +string(39) "1234567890123456789012345678901234567.9" +string(38) "12345678901234567890123456789012345678" + +Note: PHP number_format() will not be used for verification in this test +because the function starts losing accuracy with large number of precisions / scales. +--ENV-- +PHPT_EXEC=true +--SKIPIF-- + +--FILE-- + $digits)); + + // Restore the $i-th digit with its original digit + $digits[$i] = $d; + } + + $stmt = insertRow($conn, $tableName, $inputData); + unset($stmt); + + return $inputData; +} + +function verifyNoDecimals($value, $input, $round) +{ + global $prec, $dot; + + // Use PHP explode() to separate the input string into an array + $parts = explode($dot, $input); + $len = strlen($parts[0]); + if ($len == 0) { + // The original input string is missing a leading zero + $parts[0] = '0'; + } + + // No need to worry about carry over for the input data of this test + // Check the first digit of $parts[1] + if ($len < $prec) { + // Only need to round up when $len < $prec + $ch = $parts[1][0]; + + // Round the last digit of $parts[0] if $ch is '5' or above + if ($ch >= '5') { + $len = strlen($parts[0]); + $parts[0][$len-1] = $parts[0][$len-1] + 1 + '0'; + } + } + + // No decimal digits left in the expected string + $expected = $parts[0]; + if ($value !== $expected) { + echo "Round $round scale 0: expected $expected but returned $value\n"; + } +} + +function verifyWithDecimals($value, $input, $round, $scale) +{ + global $dot; + + // Use PHP explode() to separate the input string into an array + $parts = explode($dot, $input); + if (strlen($parts[0]) == 0) { + // The original input string is missing a leading zero + $parts[0] = '0'; + } + + // No need to worry about carry over for the input data of this test + // Check the digit at the position $scale of $parts[1] + $len = strlen($parts[1]); + if ($scale < $len) { + // Only need to round up when $scale < $len + $ch = $parts[1][$scale]; + + // Round the previous digit if $ch is '5' or above + if ($ch >= '5') { + $parts[1][$scale-1] = $parts[1][$scale-1] + 1 + '0'; + } + } + + // Use substr() to get up to $scale + $parts[1] = substr($parts[1], 0, $scale); + + // Join the array elements together + $expected = implode($dot, $parts); + if ($value !== $expected) { + echo "Round $round scale $scale: expected $expected but returned $value\n"; + } +} + +/**** +The function testVariousScales() will fetch one column at a time, using scale from +0 up to the maximum scale allowed for that column type. + +For example, for column of type decimal(38,4), the input string is +1234567890123456789012345678901234.6789 + +When fetching data, using scale from 0 to 4, the following values are expected to return: +1234567890123456789012345678901235 +1234567890123456789012345678901234.7 +1234567890123456789012345678901234.68 +1234567890123456789012345678901234.679 +1234567890123456789012345678901234.6789 + +For example, for column of type decimal(38,6), the input string is +12345678901234567890123456789012.456789 + +When fetching data, using scale from 0 to 6, the following values are expected to return: +12345678901234567890123456789012 +12345678901234567890123456789012.5 +12345678901234567890123456789012.46 +12345678901234567890123456789012.457 +12345678901234567890123456789012.4568 +12345678901234567890123456789012.45679 +12345678901234567890123456789012.456789 + +etc. +****/ +function testVariousScales($conn, $tableName, $inputData) +{ + global $prec; + $max = $prec + 1; + + for ($i = 0; $i < $max; $i++) { + $scale = $prec - $i; + $column = "col_$scale"; + + $query = "SELECT $column as col1 FROM $tableName"; + $input = $inputData[$column]; + + // Default case: the fetched value should be the same as the corresponding input + $stmt = $conn->query($query); + if ($obj = $stmt->fetchObject()) { + trace("\n$obj->col1\n"); + if ($obj->col1 !== $input) { + echo "default case: expected $input but returned $obj->col1\n"; + } + } else { + echo "In testVariousScales: fetchObject failed\n"; + } + + // Next, format how many decimal digits to be displayed + $query = "SELECT $column FROM $tableName"; + for ($j = 0; $j <= $scale; $j++) { + $options = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => $j); + $stmt = $conn->prepare($query, $options); + $stmt->execute(); + + $stmt->bindColumn($column, $value); + if ($stmt->fetch(PDO::FETCH_BOUND)) { + trace("$value\n"); + if ($j == 0) { + verifyNoDecimals($value, $input, $i); + } else { + verifyWithDecimals($value, $input, $i, $j); + } + } else { + echo "Round $i scale $j: fetch failed\n"; + } + } + } +} + +try { + // This helper method sets PDO::ATTR_ERRMODE to PDO::ERRMODE_EXCEPTION + $conn = connect(); + + $tableName = createTestTable($conn); + $inputData = insertTestData($conn, $tableName); + testVariousScales($conn, $tableName, $inputData); + + dropTable($conn, $tableName); + + echo "Done\n"; + + unset($conn); +} catch (PdoException $e) { + echo $e->getMessage() . PHP_EOL; +} +?> +--EXPECT-- +Done diff --git a/test/functional/sqlsrv/sqlsrv_statement_format_decimals.phpt b/test/functional/sqlsrv/sqlsrv_statement_format_decimals.phpt index 7e9e4b24..23ddbba9 100644 --- a/test/functional/sqlsrv/sqlsrv_statement_format_decimals.phpt +++ b/test/functional/sqlsrv/sqlsrv_statement_format_decimals.phpt @@ -107,17 +107,21 @@ function testFloatTypes($conn) if (sqlsrv_fetch($stmt)) { for ($i = 0; $i < count($values); $i++) { $floatStr = sqlsrv_get_field($stmt, $i, SQLSRV_PHPTYPE_STRING(SQLSRV_ENC_CHAR)); + $floatVal = floatval($floatStr); + + // Check if the numbers of decimal digits are the same + // It is highly unlikely but not impossible $numbers = explode('.', $floatStr); $len = strlen($numbers[1]); - if ($len == $numDigits) { - // This is highly unlikely - var_dump($floatStr); - } - $floatVal = floatval($floatStr); - $diff = abs($floatVal - $floats[$i]) / $floats[$i]; - if ($diff > $epsilon) { - var_dump($diff); + if ($len == $numDigits && $floatVal != $floats[$i]) { + echo "Expected $floats[$i] but returned "; var_dump($floatVal); + } else { + $diff = abs($floatVal - $floats[$i]) / $floats[$i]; + if ($diff > $epsilon) { + echo "Expected $floats[$i] but returned "; + var_dump($floatVal); + } } } } else { @@ -216,17 +220,31 @@ function testStmtOption($conn, $tableName, $inputs, $columns, $formatDecimal, $w } } -function getOutputParam($conn, $storedProcName, $inputValue, $prec, $scale) +function getOutputParam($conn, $storedProcName, $inputValue, $prec, $scale, $inout) { $outString = ''; $numDigits = 2; + $dir = SQLSRV_PARAM_OUT; - // Derive the sqlsrv type SQLSRV_SQLTYPE_DECIMAL($prec, $scale) - $sqlType = call_user_func('SQLSRV_SQLTYPE_DECIMAL', $prec, $scale); + // The output param value should be the same as the input value, + // unaffected by the statement attr FormatDecimals, unless + // ColumnEncryption is enabled, in which case the driver is able + // to derive the decimal type. Another workaround is to specify + // the SQLSRV_SQLTYPE_DECIMAL type with the correct precision and scale + $sqlType = null; + if (!AE\isColEncrypted()) { + $sqlType = call_user_func('SQLSRV_SQLTYPE_DECIMAL', $prec, $scale); + } + + // For inout parameters the input type should match the output one + if ($inout) { + $dir = SQLSRV_PARAM_INOUT; + $outString = '0.0'; + } $outSql = AE\getCallProcSqlPlaceholders($storedProcName, 1); $stmt = sqlsrv_prepare($conn, $outSql, - array(array(&$outString, SQLSRV_PARAM_OUT, null, $sqlType)), + array(array(&$outString, $dir, null, $sqlType)), array('FormatDecimals' => $numDigits)); if (!$stmt) { fatalError("getOutputParam: failed when preparing to call $storedProcName"); @@ -242,14 +260,11 @@ function getOutputParam($conn, $storedProcName, $inputValue, $prec, $scale) sqlsrv_free_stmt($stmt); if (!AE\isColEncrypted()) { - // Get output param without specifying sqlsrv type, and the returned value will - // be a regular string -- its value should be the same as the input value, - // unaffected by the statement option FormatDecimals // With ColumnEncryption enabled, the driver is able to derive the decimal type, // so skip this part of the test - $outString2 = ''; + $outString2 = $inout ? '0.0' : ''; $stmt = sqlsrv_prepare($conn, $outSql, - array(array(&$outString2, SQLSRV_PARAM_OUT)), + array(array(&$outString2, $dir)), array('FormatDecimals' => $numDigits)); if (!$stmt) { fatalError("getOutputParam2: failed when preparing to call $storedProcName"); @@ -264,7 +279,7 @@ function getOutputParam($conn, $storedProcName, $inputValue, $prec, $scale) } } -function testOutputParam($conn, $tableName, $inputs, $columns, $dataTypes) +function testOutputParam($conn, $tableName, $inputs, $columns, $dataTypes, $inout = false) { for ($i = 0, $p = 3; $i < count($columns); $i++, $p++) { // Create the stored procedure first @@ -274,7 +289,7 @@ function testOutputParam($conn, $tableName, $inputs, $columns, $dataTypes) createProc($conn, $storedProcName, $procArgs, $procCode); // Call stored procedure to retrieve output param - getOutputParam($conn, $storedProcName, $inputs[$i], $p, $i); + getOutputParam($conn, $storedProcName, $inputs[$i], $p, $i, $inout); dropProc($conn, $storedProcName); } @@ -360,6 +375,7 @@ testStmtOption($conn, $tableName, $values, $columns, 2, true); // Test output parameters testOutputParam($conn, $tableName, $values, $columns, $dataTypes); +testOutputParam($conn, $tableName, $values, $columns, $dataTypes, true); dropTable($conn, $tableName); sqlsrv_close($conn);