Feature request 415 for pdo_sqlsrv (#873)

This commit is contained in:
Jenny Tam 2018-11-02 14:34:27 -07:00 committed by GitHub
parent 2a9398f7e0
commit f4ad2ae1d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 745 additions and 67 deletions

View file

@ -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<stmt_option_emulate_prepares>( new stmt_option_emulate_prepares ) },
{ NULL, 0, PDO_STMT_OPTION_FETCHES_NUMERIC_TYPE, std::unique_ptr<stmt_option_fetch_numeric>( new stmt_option_fetch_numeric ) },
{ NULL, 0, PDO_STMT_OPTION_FETCHES_DATETIME_TYPE, std::unique_ptr<stmt_option_fetch_datetime>( new stmt_option_fetch_datetime ) },
{ NULL, 0, PDO_STMT_OPTION_FORMAT_DECIMALS, std::unique_ptr<stmt_option_format_decimals>( new stmt_option_format_decimals ) },
{ NULL, 0, SQLSRV_STMT_OPTION_INVALID, std::unique_ptr<stmt_option_functor>{} },
};
@ -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();

View file

@ -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

View file

@ -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;

View file

@ -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, {} }
};

View file

@ -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

View file

@ -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 );

View file

@ -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<short>(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<short>(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<const SQLWCHAR*>(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;

View file

@ -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<const SQLWCHAR*>(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");

View file

@ -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--
<?php require('skipif_mid-refactor.inc'); ?>
--FILE--
<?php
require_once("MsCommon_mid-refactor.inc");
function checkException($exception, $expected)
{
if (strpos($exception->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

View file

@ -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--
<?php require('skipif_mid-refactor.inc'); ?>
--FILE--
<?php
require_once("MsCommon_mid-refactor.inc");
$prec = 38;
$dot = '.';
function createTestTable($conn)
{
global $prec;
// Create the test table of one decimal column
$tableName = "pdoFormatDecimalScales";
$colMeta = array();
$max = $prec + 1;
for ($i = 0; $i < $max; $i++) {
$scale = $prec - $i;
$column = "col_$scale";
$dataType = "decimal($prec, $scale)";
array_push($colMeta, new ColumnMeta($dataType, $column));
}
createTable($conn, $tableName, $colMeta);
return $tableName;
}
function insertTestData($conn, $tableName)
{
global $prec, $dot;
$temp = str_repeat('1234567890', 4);
$digits = substr($temp, 0, $prec + 1);
$inputData = array();
$max = $prec + 1;
// Generate input strings - replace the $i-th digit with a dot '.'
for ($i = 0; $i < $max; $i++) {
$d = $digits[$i];
$digits[$i] = $dot;
if ($i == $prec) {
$digits = substr($temp, 0, $prec);
}
$scale = $prec - $i;
$column = "col_$scale";
$inputData = array_merge($inputData, array($column => $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

View file

@ -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);