Feature request 415 for sqlsrv (#861)

This commit is contained in:
Jenny Tam 2018-10-12 15:22:27 -07:00 committed by GitHub
parent 36fd97e69a
commit 18094a6cef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 856 additions and 35 deletions

View file

@ -1107,6 +1107,7 @@ enum SQLSRV_STMT_OPTIONS {
SQLSRV_STMT_OPTION_SCROLLABLE,
SQLSRV_STMT_OPTION_CLIENT_BUFFER_MAX_SIZE,
SQLSRV_STMT_OPTION_DATE_AS_STRING,
SQLSRV_STMT_OPTION_FORMAT_DECIMALS,
// Driver specific connection options
SQLSRV_STMT_OPTION_DRIVER_SPECIFIC = 1000,
@ -1296,6 +1297,11 @@ struct stmt_option_date_as_string : public stmt_option_functor {
virtual void operator()( _Inout_ sqlsrv_stmt* stmt, stmt_option const* opt, _In_ zval* value_z TSRMLS_DC );
};
struct stmt_option_format_decimals : public stmt_option_functor {
virtual void operator()( _Inout_ sqlsrv_stmt* stmt, stmt_option const* opt, _In_ zval* value_z TSRMLS_DC );
};
// used to hold the table for statment options
struct stmt_option {
@ -1334,39 +1340,6 @@ extern php_stream_wrapper g_sqlsrv_stream_wrapper;
#define SQLSRV_STREAM_WRAPPER "sqlsrv"
#define SQLSRV_STREAM "sqlsrv_stream"
// holds the output parameter information. Strings also need the encoding and other information for
// after processing. Only integer, float, and strings are allowable output parameters.
struct sqlsrv_output_param {
zval* param_z;
SQLSRV_ENCODING encoding;
SQLUSMALLINT param_num; // used to index into the ind_or_len of the statement
SQLLEN original_buffer_len; // used to make sure the returned length didn't overflow the buffer
SQLSRV_PHPTYPE php_out_type; // used to convert output param if necessary
bool is_bool;
// string output param constructor
sqlsrv_output_param( _In_ zval* p_z, _In_ SQLSRV_ENCODING enc, _In_ int num, _In_ SQLUINTEGER buffer_len ) :
param_z(p_z), encoding(enc), param_num(num), original_buffer_len(buffer_len), is_bool(false), php_out_type(SQLSRV_PHPTYPE_INVALID)
{
}
// every other type output parameter constructor
sqlsrv_output_param( _In_ zval* p_z, _In_ int num, _In_ bool is_bool, _In_ SQLSRV_PHPTYPE php_out_type) :
param_z( p_z ),
encoding( SQLSRV_ENCODING_INVALID ),
param_num( num ),
original_buffer_len( -1 ),
is_bool( is_bool ),
php_out_type(php_out_type)
{
}
};
// forward decls
struct sqlsrv_result_set;
struct field_meta_data;
// *** parameter metadata struct ***
struct param_meta_data
{
@ -1389,6 +1362,59 @@ struct param_meta_data
SQLULEN get_column_size() { return column_size; }
};
// holds the output parameter information. Strings also need the encoding and other information for
// after processing. Only integer, float, and strings are allowable output parameters.
struct sqlsrv_output_param {
zval* param_z;
SQLSRV_ENCODING encoding;
SQLUSMALLINT param_num; // used to index into the ind_or_len of the statement
SQLLEN original_buffer_len; // used to make sure the returned length didn't overflow the buffer
SQLSRV_PHPTYPE php_out_type; // used to convert output param if necessary
bool is_bool;
param_meta_data meta_data; // parameter meta data
// string output param constructor
sqlsrv_output_param( _In_ zval* p_z, _In_ SQLSRV_ENCODING enc, _In_ int num, _In_ SQLUINTEGER buffer_len ) :
param_z(p_z), encoding(enc), param_num(num), original_buffer_len(buffer_len), is_bool(false), php_out_type(SQLSRV_PHPTYPE_INVALID)
{
}
// every other type output parameter constructor
sqlsrv_output_param( _In_ zval* p_z, _In_ int num, _In_ bool is_bool, _In_ SQLSRV_PHPTYPE php_out_type) :
param_z( p_z ),
encoding( SQLSRV_ENCODING_INVALID ),
param_num( num ),
original_buffer_len( -1 ),
is_bool( is_bool ),
php_out_type(php_out_type)
{
}
void saveMetaData(SQLSMALLINT sql_type, SQLSMALLINT column_size, SQLSMALLINT decimal_digits, SQLSMALLINT nullable = SQL_NULLABLE)
{
meta_data.sql_type = sql_type;
meta_data.column_size = column_size;
meta_data.decimal_digits = decimal_digits;
meta_data.nullable = nullable;
}
SQLSMALLINT getDecimalDigits()
{
// Return decimal_digits only for decimal / numeric types. Otherwise, return -1
if (meta_data.sql_type == SQL_DECIMAL || meta_data.sql_type == SQL_NUMERIC) {
return meta_data.decimal_digits;
}
else {
return -1;
}
}
};
// forward decls
struct sqlsrv_result_set;
struct field_meta_data;
// *** Statement resource structure ***
struct sqlsrv_stmt : public sqlsrv_context {
@ -1409,6 +1435,7 @@ struct sqlsrv_stmt : public sqlsrv_context {
unsigned long query_timeout; // maximum allowed statement execution time
zend_long buffered_query_limit; // maximum allowed memory for a buffered query (measured in KB)
bool date_as_string; // false by default but the user can set this to true to retrieve datetime values as strings
short num_decimals; // indicates number of decimals shown in fetched results (-1 by default, which means no formatting required)
// holds output pointers for SQLBindParameter
// We use a deque because it 1) provides the at/[] access in constant time, and 2) grows dynamically without moving
@ -1743,6 +1770,8 @@ enum SQLSRV_ERROR_CODES {
SQLSRV_ERROR_DOUBLE_CONVERSION_FAILED,
SQLSRV_ERROR_INVALID_OPTION_WITH_ACCESS_TOKEN,
SQLSRV_ERROR_EMPTY_ACCESS_TOKEN,
SQLSRV_ERROR_INVALID_FORMAT_DECIMALS,
SQLSRV_ERROR_FORMAT_DECIMALS_OUT_OF_RANGE,
// Driver specific error codes starts from here.
SQLSRV_ERROR_DRIVER_SPECIFIC = 1000,

View file

@ -107,6 +107,7 @@ void default_sql_type( _Inout_ sqlsrv_stmt* stmt, _In_opt_ SQLULEN paramno, _In_
_Out_ SQLSMALLINT& sql_type TSRMLS_DC );
void col_cache_dtor( _Inout_ zval* data_z );
void field_cache_dtor( _Inout_ zval* data_z );
void format_decimal_numbers(_In_ SQLSMALLINT decimals_digits, _In_ SQLSMALLINT field_scale, _Inout_updates_bytes_(*field_len) char*& field_value, _Inout_ SQLLEN* field_len);
void finalize_output_parameters( _Inout_ sqlsrv_stmt* stmt TSRMLS_DC );
void get_field_as_string( _Inout_ sqlsrv_stmt* stmt, _In_ SQLUSMALLINT field_index, _Inout_ sqlsrv_phptype sqlsrv_php_type,
_Inout_updates_bytes_(*field_len) void*& field_value, _Inout_ SQLLEN* field_len TSRMLS_DC );
@ -141,8 +142,9 @@ sqlsrv_stmt::sqlsrv_stmt( _In_ sqlsrv_conn* c, _In_ SQLHANDLE handle, _In_ error
past_next_result_end( false ),
query_timeout( QUERY_TIMEOUT_INVALID ),
date_as_string(false),
num_decimals(-1), // -1 means no formatting required
buffered_query_limit( sqlsrv_buffered_result_set::BUFFERED_QUERY_LIMIT_INVALID ),
param_ind_ptrs( 10 ), // initially hold 10 elements, which should cover 90% of the cases and only take < 100 byte
param_ind_ptrs( 10 ), // initially hold 10 elements, which should cover 90% of the cases and only take < 100 byte
send_streams_at_exec( true ),
current_stream( NULL, SQLSRV_ENCODING_DEFAULT ),
current_stream_read( 0 )
@ -571,6 +573,8 @@ void core_sqlsrv_bind_param( _Inout_ sqlsrv_stmt* stmt, _In_ SQLUSMALLINT param_
// save the parameter to be adjusted and/or converted after the results are processed
sqlsrv_output_param output_param( param_ref, encoding, param_num, static_cast<SQLUINTEGER>( buffer_len ) );
output_param.saveMetaData(sql_type, column_size, decimal_digits);
save_output_param_for_later( stmt, output_param TSRMLS_CC );
// For output parameters, if we set the column_size to be same as the buffer_len,
@ -1416,6 +1420,21 @@ 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);
}
// internal function to release the active stream. Called by each main API function
// that will alter the statement and cancel any retrieval of data from a stream.
void close_active_stream( _Inout_ sqlsrv_stmt* stmt TSRMLS_DC )
@ -2079,6 +2098,130 @@ void field_cache_dtor( _Inout_ zval* data_z )
sqlsrv_free( cache );
}
// To be called for formatting decimal / numeric fetched values from finalize_output_parameters() and/or get_field_as_string()
void format_decimal_numbers(_In_ SQLSMALLINT decimals_digits, _In_ SQLSMALLINT field_scale, _Inout_updates_bytes_(*field_len) char*& field_value, _Inout_ SQLLEN* field_len)
{
// In SQL Server, the default maximum precision of numeric and decimal data types is 38
//
// Note: stmt->num_decimals is -1 by default, which means no formatting on decimals / numerics is necessary
// If the required number of decimals is larger than the field scale, will use the column field scale instead.
// This is to ensure the number of decimals adheres to the column field scale. If smaller, the output value may be rounded up.
//
// Note: it's possible that the decimal / numeric value does not contain a decimal dot because the field scale is 0.
// Thus, first check if the decimal dot exists. If not, no formatting necessary, regardless of decimals_digits
//
std::string str = field_value;
size_t pos = str.find_first_of('.');
if (pos == std::string::npos || decimals_digits < 0) {
return;
}
SQLSMALLINT num_decimals = decimals_digits;
if (num_decimals > field_scale) {
num_decimals = field_scale;
}
// We want the rounding to be consistent with php number_format(), http://php.net/manual/en/function.number-format.php
// as well as SQL Server Management studio, such that the least significant digit will be rounded up if it is
// followed by 5 or above.
bool isNegative = false;
// If negative, remove the minus sign for now so as not to complicate the rounding process
if (str[0] == '-') {
isNegative = true;
std::ostringstream oss;
oss << str.substr(1);
str = oss.str();
pos = str.find_first_of('.');
}
// Adds the leading zero if not exists
if (pos == 0) {
std::ostringstream oss;
oss << '0' << str;
str = oss.str();
pos++;
}
size_t last = 0;
if (num_decimals == 0) {
// Chop all decimal digits, including the decimal dot
size_t pos2 = pos + 1;
short n = str[pos2] - '0';
if (n >= 5) {
// Start rounding up - starting from the digit left of the dot all the way to the first digit
bool carry_over = true;
for (short p = pos - 1; p >= 0 && carry_over; p--) {
n = str[p] - '0';
if (n == 9) {
str[p] = '0' ;
carry_over = true;
}
else {
n++;
carry_over = false;
str[p] = '0' + n;
}
}
if (carry_over) {
std::ostringstream oss;
oss << '1' << str.substr(0, pos);
str = oss.str();
pos++;
}
}
last = pos;
}
else {
size_t pos2 = pos + num_decimals + 1;
// No need to check if rounding is necessary when pos2 has passed the last digit in the input string
if (pos2 < str.length()) {
short n = str[pos2] - '0';
if (n >= 5) {
// Start rounding up - starting from the digit left of pos2 all the way to the first digit
bool carry_over = true;
for (short p = pos2 - 1; p >= 0 && carry_over; p--) {
if (str[p] == '.') { // Skip the dot
continue;
}
n = str[p] - '0';
if (n == 9) {
str[p] = '0' ;
carry_over = true;
}
else {
n++;
carry_over = false;
str[p] = '0' + n;
}
}
if (carry_over) {
std::ostringstream oss;
oss << '1' << str.substr(0, pos2);
str = oss.str();
pos2++;
}
}
}
last = pos2;
}
// Add the minus sign back if negative
if (isNegative) {
std::ostringstream oss;
oss << '-' << str.substr(0, last);
str = oss.str();
} else {
str = str.substr(0, last);
}
size_t len = str.length();
str.copy(field_value, len);
field_value[len] = '\0';
*field_len = len;
}
// To be called after all results are processed. ODBC and SQL Server do not guarantee that all output
// parameters will be present until all results are processed (since output parameters can depend on results
@ -2160,6 +2303,11 @@ void finalize_output_parameters( _Inout_ sqlsrv_stmt* stmt TSRMLS_DC )
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);
}
}
@ -2214,7 +2362,7 @@ void get_field_as_string( _Inout_ sqlsrv_stmt* stmt, _In_ SQLUSMALLINT field_ind
{
SQLRETURN r;
SQLSMALLINT c_type;
SQLLEN sql_field_type = 0;
SQLSMALLINT sql_field_type = 0;
SQLSMALLINT extra = 0;
SQLLEN field_len_temp = 0;
SQLLEN sql_display_size = 0;
@ -2425,6 +2573,10 @@ void get_field_as_string( _Inout_ sqlsrv_stmt* stmt, _In_ SQLUSMALLINT field_ind
throw core::CoreException();
}
}
if (stmt->num_decimals >= 0 && (sql_field_type == SQL_DECIMAL || sql_field_type == SQL_NUMERIC)) {
format_decimal_numbers(stmt->num_decimals, stmt->current_meta_data[field_index]->field_scale, field_value_temp, &field_len_temp);
}
} // else if( sql_display_size >= 1 && sql_display_size <= SQL_SERVER_MAX_FIELD_SIZE )
else {

View file

@ -174,6 +174,7 @@ namespace SSStmtOptionNames {
const char SCROLLABLE[] = "Scrollable";
const char CLIENT_BUFFER_MAX_SIZE[] = INI_BUFFERED_QUERY_LIMIT;
const char DATE_AS_STRING[] = "ReturnDatesAsStrings";
const char FORMAT_DECIMALS[] = "FormatDecimals";
}
namespace SSConnOptionNames {
@ -250,6 +251,12 @@ const stmt_option SS_STMT_OPTS[] = {
SQLSRV_STMT_OPTION_DATE_AS_STRING,
std::unique_ptr<stmt_option_date_as_string>( new stmt_option_date_as_string )
},
{
SSStmtOptionNames::FORMAT_DECIMALS,
sizeof( SSStmtOptionNames::FORMAT_DECIMALS ),
SQLSRV_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>{} },
};

View file

@ -428,6 +428,14 @@ ss_error SS_ERRORS[] = {
SQLSRV_ERROR_EMPTY_ACCESS_TOKEN,
{ IMSSP, (SQLCHAR*) "The Azure AD Access Token is empty. Expected a byte string.", -116, 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.", -117, 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.", -118, true}
},
// terminate the list of errors/warnings
{ UINT_MAX, {} }

View file

@ -0,0 +1,370 @@
--TEST--
Test how decimal data output values can be formatted (feature request issue 415)
--DESCRIPTION--
Test how numeric and decimal data output values can be formatted by using the
statement option FormatDecimals, which expects an integer value from the range [0,38],
affecting only the money / decimal types in the fetched result set because they are
always strings to preserve accuracy and precision, unlike other primitive numeric
types that can be retrieved as numbers.
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. For output params use SQLSRV_SQLTYPE_DECIMAL with the correct precision and scale
--ENV--
PHPT_EXEC=true
--SKIPIF--
<?php require('skipif_versions_old.inc'); ?>
--FILE--
<?php
require_once('MsCommon.inc');
function compareNumbers($actual, $input, $column, $fieldScale, $formatDecimal = -1)
{
$matched = false;
if ($actual === $input) {
$matched = true;
} 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);
if ($actual === $expected) {
$matched = true;
} else {
echo "For $column: expected $expected but the value is $actual\n";
}
}
return $matched;
}
function testErrorCases($conn)
{
$query = "SELECT 0.0001";
$options = array('FormatDecimals' => 1.5);
$stmt = sqlsrv_query($conn, $query, array(), $options);
if ($stmt) {
fatalError("Case 1: expected query to fail!!");
} else {
$error = sqlsrv_errors()[0]['message'];
$message = 'Expected an integer to specify number of decimals to format the output values of decimal data types.';
if (strpos($error, $message) === false) {
print_r(sqlsrv_errors());
}
}
$options = array('FormatDecimals' => -1);
$stmt = sqlsrv_query($conn, $query, array(), $options);
if ($stmt) {
fatalError("Case 2: expected query to fail!!");
} else {
$error = sqlsrv_errors()[0]['message'];
$message = 'For formatting decimal data values, -1 is out of range. Expected an integer from 0 to 38, inclusive.';
if (strpos($error, $message) === false) {
print_r(sqlsrv_errors());
}
}
}
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
$values = array('2.9978', '-0.2982', '33.2434', '329.690734', '110.913498');
$epsilon = 0.001;
$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 = sqlsrv_query($conn, $query);
$floats = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_NUMERIC);
if (!$floats) {
echo "testFloatTypes: sqlsrv_fetch_array failed\n";
}
// Set FormatDecimals to 2, but the number of decimals in each of the results
// will vary -- FormatDecimals has no effect
$numDigits = 2;
$options = array('FormatDecimals' => $numDigits);
$stmt = sqlsrv_query($conn, $query, array(), $options);
if (sqlsrv_fetch($stmt)) {
for ($i = 0; $i < count($values); $i++) {
$floatStr = sqlsrv_get_field($stmt, $i, SQLSRV_PHPTYPE_STRING(SQLSRV_ENC_CHAR));
$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);
var_dump($floatVal);
}
}
} else {
echo "testFloatTypes: sqlsrv_fetch failed\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('1.9954', '0', '-0.5', '0.2954', '9.6789', '99.991');
$defaults = array('1.9954', '.0000', '-.5000', '.2954', '9.6789', '99.9910');
$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 = sqlsrv_query($conn, $query);
$results = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_NUMERIC);
for ($i = 0; $i < count($values); $i++) {
if ($defaults[$i] !== $results[$i]) {
echo "testMoneyTypes: Expected default $defaults[$i] but got $results[$i]\n";
}
}
// Set FormatDecimals to 0 decimal digits
$numDigits = 0;
$options = array('FormatDecimals' => $numDigits);
$stmt = sqlsrv_query($conn, $query, array(), $options);
$results = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_NUMERIC);
for ($i = 0; $i < count($values); $i++) {
$value = number_format($values[$i], $numDigits);
if ($value !== $results[$i]) {
echo "testMoneyTypes: Expected $value but got $results[$i]\n";
}
}
// Set FormatDecimals to 2 decimal digits
$numDigits = 2;
$options = array('FormatDecimals' => $numDigits);
$stmt = sqlsrv_query($conn, $query, array(), $options);
$results = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_NUMERIC);
for ($i = 0; $i < count($values); $i++) {
$value = number_format($values[$i], $numDigits);
if ($value !== $results[$i]) {
echo "testMoneyTypes: Expected $value but got $results[$i]\n";
}
}
}
function testNoOption($conn, $tableName, $inputs, $columns, $exec)
{
// Without the statement option, should return decimal values as they are
$query = "SELECT * FROM $tableName";
if ($exec) {
$stmt = sqlsrv_query($conn, $query);
} else {
$stmt = sqlsrv_prepare($conn, $query);
sqlsrv_execute($stmt);
}
// Compare values
$results = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_NUMERIC);
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 FormatDecimals
$query = "SELECT * FROM $tableName";
if ($withBuffer){
$options = array('Scrollable' => 'buffered', 'FormatDecimals' => $formatDecimal);
} else {
$options = array('FormatDecimals' => $formatDecimal);
}
$size = count($inputs);
$stmt = sqlsrv_prepare($conn, $query, array(), $options);
// Fetch by getting one field at a time
sqlsrv_execute($stmt);
if (sqlsrv_fetch($stmt) === false) {
fatalError("Failed in retrieving data\n");
}
for ($i = 0; $i < $size; $i++) {
$field = sqlsrv_get_field($stmt, $i); // Expect a string
compareNumbers($field, $inputs[$i], $columns[$i], $i, $formatDecimal);
}
}
function getOutputParam($conn, $storedProcName, $inputValue, $prec, $scale)
{
$outString = '';
$numDigits = 2;
// Derive the sqlsrv type SQLSRV_SQLTYPE_DECIMAL($prec, $scale)
$sqlType = call_user_func('SQLSRV_SQLTYPE_DECIMAL', $prec, $scale);
$outSql = AE\getCallProcSqlPlaceholders($storedProcName, 1);
$stmt = sqlsrv_prepare($conn, $outSql,
array(array(&$outString, SQLSRV_PARAM_OUT, null, $sqlType)),
array('FormatDecimals' => $numDigits));
if (!$stmt) {
fatalError("getOutputParam: failed when preparing to call $storedProcName");
}
if (!sqlsrv_execute($stmt)) {
fatalError("getOutputParam: failed to execute procedure $storedProcName");
}
// The output param should have been formatted based on $numDigits, if less
// than $scale
$column = 'outputParam';
compareNumbers($outString, $inputValue, $column, $scale, $numDigits);
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 = '';
$stmt = sqlsrv_prepare($conn, $outSql,
array(array(&$outString2, SQLSRV_PARAM_OUT)),
array('FormatDecimals' => $numDigits));
if (!$stmt) {
fatalError("getOutputParam2: failed when preparing to call $storedProcName");
}
if (!sqlsrv_execute($stmt)) {
fatalError("getOutputParam2: failed to execute procedure $storedProcName");
}
$column = 'outputParam2';
compareNumbers($outString2, $inputValue, $column, $scale);
sqlsrv_free_stmt($stmt);
}
}
function testOutputParam($conn, $tableName, $inputs, $columns, $dataTypes)
{
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);
dropProc($conn, $storedProcName);
}
}
set_time_limit(0);
sqlsrv_configure('WarningsReturnAsErrors', 1);
$conn = AE\connect();
if (!$conn) {
fatalError("Could not connect.\n");
}
// First to test if leading zero is added
testMoneyTypes($conn);
// Then test error conditions
testErrorCases($conn);
// Also test using regular floats
testFloatTypes($conn);
// Create the test table of decimal / numeric data columns
$tableName = 'sqlsrvFormatDecimals';
$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 AE\ColumnMeta($dataTypes[0], $columns[0]),
new AE\ColumnMeta($dataTypes[1], $columns[1]),
new AE\ColumnMeta($dataTypes[2], $columns[2]),
new AE\ColumnMeta($dataTypes[3], $columns[3]),
new AE\ColumnMeta($dataTypes[4], $columns[4]),
new AE\ColumnMeta($dataTypes[5], $columns[5]));
AE\createTable($conn, $tableName, $colMeta);
// Generate random input values based on precision and scale
$values = array();
$max2 = 1;
for ($s = 0, $p = 3; $s < count($columns); $s++, $p++) {
// First get a random number
$n = rand(0, 10);
$neg = ($n % 2 == 0) ? -1 : 1;
// $n1 may or may not be negative
$max1 = 1000;
$n1 = rand(0, $max1) * $neg;
if ($s > 0) {
$max2 *= 10;
$n2 = rand(0, $max2);
$number = sprintf("%d.%d", $n1, $n2);
} else {
$number = sprintf("%d", $n1);
}
array_push($values, $number);
}
// Insert data values as strings
$inputData = array($colMeta[0]->colName => $values[0],
$colMeta[1]->colName => $values[1],
$colMeta[2]->colName => $values[2],
$colMeta[3]->colName => $values[3],
$colMeta[4]->colName => $values[4],
$colMeta[5]->colName => $values[5]);
$stmt = AE\insertRow($conn, $tableName, $inputData);
if (!$stmt) {
var_dump($values);
fatalError("Failed to insert data.\n");
}
sqlsrv_free_stmt($stmt);
testNoOption($conn, $tableName, $values, $columns, true);
testNoOption($conn, $tableName, $values, $columns, false);
// 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);
dropTable($conn, $tableName);
sqlsrv_close($conn);
echo "Done\n";
?>
--EXPECT--
Done

View file

@ -0,0 +1,255 @@
--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_versions_old.inc'); ?>
--FILE--
<?php
require_once('MsCommon.inc');
$prec = 38;
$dot = '.';
function createTestTable($conn)
{
global $prec;
// Create the test table of one decimal column
$tableName = "sqlsrvFormatDecimalScales";
$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 AE\ColumnMeta($dataType, $column));
}
AE\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 = AE\insertRow($conn, $tableName, $inputData);
if (!$stmt) {
fatalError("Failed to insert data\n");
}
sqlsrv_free_stmt($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 = sqlsrv_query($conn, $query);
if (!$stmt) {
fatalError("In testVariousScales: failed in default case\n");
}
if ($obj = sqlsrv_fetch_object($stmt)) {
trace("\n$obj->col1\n");
if ($obj->col1 !== $input) {
echo "default case: expected $input but returned $obj->col1\n";
}
} else {
fatalError("In testVariousScales: sqlsrv_fetch_object failed\n");
}
// Next, format how many decimal digits to be displayed
$query = "SELECT $column FROM $tableName";
for ($j = 0; $j <= $scale; $j++) {
$options = array('FormatDecimals' => $j);
$stmt = sqlsrv_query($conn, $query, array(), $options);
if (sqlsrv_fetch($stmt)) {
$value = sqlsrv_get_field($stmt, 0);
trace("$value\n");
if ($j == 0) {
verifyNoDecimals($value, $input, $i);
} else {
verifyWithDecimals($value, $input, $i, $j);
}
} else {
fatalError("Round $i scale $j: sqlsrv_fetch failed\n");
}
}
}
}
set_time_limit(0);
sqlsrv_configure('WarningsReturnAsErrors', 1);
$conn = AE\connect();
if (!$conn) {
fatalError("Could not connect.\n");
}
$tableName = createTestTable($conn);
$inputData = insertTestData($conn, $tableName);
testVariousScales($conn, $tableName, $inputData);
dropTable($conn, $tableName);
sqlsrv_close($conn);
echo "Done\n";
?>
--EXPECT--
Done