From 76c595fc2bfc0434979f5523feb03572f4bad673 Mon Sep 17 00:00:00 2001 From: Jenny Tam Date: Tue, 27 Nov 2018 17:18:38 -0800 Subject: [PATCH] Decimal places for money types only (#886) --- source/pdo_sqlsrv/pdo_dbh.cpp | 49 +++- source/pdo_sqlsrv/pdo_init.cpp | 1 + source/pdo_sqlsrv/pdo_stmt.cpp | 18 +- source/pdo_sqlsrv/pdo_util.cpp | 6 +- source/pdo_sqlsrv/php_pdo_sqlsrv.h | 7 +- source/shared/core_sqlsrv.h | 34 ++- source/shared/core_stmt.cpp | 189 ++++++++----- source/sqlsrv/conn.cpp | 68 +++++ source/sqlsrv/php_sqlsrv.h | 4 + source/sqlsrv/stmt.cpp | 4 +- source/sqlsrv/util.cpp | 6 +- .../pdostatement_format_decimals.phpt | 235 ++++------------ .../pdostatement_format_decimals_scales.phpt | 248 ----------------- .../pdostatement_format_money_scales.phpt | 162 +++++++++++ .../pdostatement_format_money_types.phpt | 244 ++++++++++++++++ .../sqlsrv_statement_format_decimals.phpt | 251 +++++------------ ...lsrv_statement_format_decimals_scales.phpt | 255 ----------------- .../sqlsrv_statement_format_money_scales.phpt | 167 +++++++++++ .../sqlsrv_statement_format_money_types.phpt | 261 ++++++++++++++++++ 19 files changed, 1240 insertions(+), 969 deletions(-) delete mode 100644 test/functional/pdo_sqlsrv/pdostatement_format_decimals_scales.phpt create mode 100644 test/functional/pdo_sqlsrv/pdostatement_format_money_scales.phpt create mode 100644 test/functional/pdo_sqlsrv/pdostatement_format_money_types.phpt delete mode 100644 test/functional/sqlsrv/sqlsrv_statement_format_decimals_scales.phpt create mode 100644 test/functional/sqlsrv/sqlsrv_statement_format_money_scales.phpt create mode 100644 test/functional/sqlsrv/sqlsrv_statement_format_money_types.phpt diff --git a/source/pdo_sqlsrv/pdo_dbh.cpp b/source/pdo_sqlsrv/pdo_dbh.cpp index bf25d5a8..77e30f3d 100644 --- a/source/pdo_sqlsrv/pdo_dbh.cpp +++ b/source/pdo_sqlsrv/pdo_dbh.cpp @@ -81,7 +81,8 @@ enum PDO_STMT_OPTIONS { PDO_STMT_OPTION_EMULATE_PREPARES, PDO_STMT_OPTION_FETCHES_NUMERIC_TYPE, PDO_STMT_OPTION_FETCHES_DATETIME_TYPE, - PDO_STMT_OPTION_FORMAT_DECIMALS + PDO_STMT_OPTION_FORMAT_DECIMALS, + PDO_STMT_OPTION_DECIMAL_PLACES }; // List of all the statement options supported by this driver. @@ -97,6 +98,7 @@ const stmt_option PDO_STMT_OPTS[] = { { 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, PDO_STMT_OPTION_DECIMAL_PLACES, std::unique_ptr( new stmt_option_decimal_places ) }, { NULL, 0, SQLSRV_STMT_OPTION_INVALID, std::unique_ptr{} }, }; @@ -500,7 +502,9 @@ pdo_sqlsrv_dbh::pdo_sqlsrv_dbh( _In_ SQLHANDLE h, _In_ error_callback e, _In_ vo query_timeout( QUERY_TIMEOUT_INVALID ), client_buffer_max_size( PDO_SQLSRV_G( client_buffer_max_size )), fetch_numeric( false ), - fetch_datetime( false ) + fetch_datetime( false ), + format_decimals( false ), + decimal_places( NO_CHANGE_DECIMAL_PLACES ) { if( client_buffer_max_size < 0 ) { client_buffer_max_size = sqlsrv_buffered_result_set::BUFFERED_QUERY_LIMIT_DEFAULT; @@ -1069,7 +1073,28 @@ int pdo_sqlsrv_dbh_set_attr( _Inout_ pdo_dbh_t *dbh, _In_ zend_long attr, _Inout case SQLSRV_ATTR_FETCHES_DATETIME_TYPE: driver_dbh->fetch_datetime = (zend_is_true(val)) ? true : false; break; - + + case SQLSRV_ATTR_FORMAT_DECIMALS: + driver_dbh->format_decimals = (zend_is_true(val)) ? true : false; + break; + + case SQLSRV_ATTR_DECIMAL_PLACES: + { + // first check if the input is an integer + if (Z_TYPE_P(val) != IS_LONG) { + THROW_PDO_ERROR(driver_dbh, SQLSRV_ERROR_INVALID_DECIMAL_PLACES); + } + + zend_long decimal_places = Z_LVAL_P(val); + if (decimal_places < 0 || decimal_places > SQL_SERVER_MAX_MONEY_SCALE) { + // ignore decimal_places as this is out of range + decimal_places = NO_CHANGE_DECIMAL_PLACES; + } + + driver_dbh->decimal_places = static_cast(decimal_places); + } + break; + // Not supported case PDO_ATTR_FETCH_TABLE_NAMES: case PDO_ATTR_FETCH_CATALOG_NAMES: @@ -1097,7 +1122,6 @@ 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 ); } @@ -1156,7 +1180,6 @@ 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 ); } @@ -1229,6 +1252,18 @@ int pdo_sqlsrv_dbh_get_attr( _Inout_ pdo_dbh_t *dbh, _In_ zend_long attr, _Inout break; } + case SQLSRV_ATTR_FORMAT_DECIMALS: + { + ZVAL_BOOL( return_value, driver_dbh->format_decimals ); + break; + } + + case SQLSRV_ATTR_DECIMAL_PLACES: + { + ZVAL_LONG( return_value, driver_dbh->decimal_places ); + break; + } + default: { THROW_PDO_ERROR( driver_dbh, PDO_SQLSRV_ERROR_INVALID_DBH_ATTR ); @@ -1594,6 +1629,10 @@ void add_stmt_option_key( _Inout_ sqlsrv_context& ctx, _In_ size_t key, _Inout_ option_key = PDO_STMT_OPTION_FORMAT_DECIMALS; break; + case SQLSRV_ATTR_DECIMAL_PLACES: + option_key = PDO_STMT_OPTION_DECIMAL_PLACES; + 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 6d47cf5b..000878fa 100644 --- a/source/pdo_sqlsrv/pdo_init.cpp +++ b/source/pdo_sqlsrv/pdo_init.cpp @@ -287,6 +287,7 @@ namespace { { "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 }, + { "SQLSRV_ATTR_DECIMAL_PLACES" , SQLSRV_ATTR_DECIMAL_PLACES }, // 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 de1fc882..d90fd50a 100644 --- a/source/pdo_sqlsrv/pdo_stmt.cpp +++ b/source/pdo_sqlsrv/pdo_stmt.cpp @@ -883,7 +883,11 @@ int pdo_sqlsrv_stmt_set_attr( _Inout_ pdo_stmt_t *stmt, _In_ zend_long attr, _In break; case SQLSRV_ATTR_FORMAT_DECIMALS: - core_sqlsrv_set_format_decimals(driver_stmt, val TSRMLS_CC); + driver_stmt->format_decimals = ( zend_is_true( val )) ? true : false; + break; + + case SQLSRV_ATTR_DECIMAL_PLACES: + core_sqlsrv_set_decimal_places(driver_stmt, val TSRMLS_CC); break; default: @@ -973,6 +977,18 @@ int pdo_sqlsrv_stmt_get_attr( _Inout_ pdo_stmt_t *stmt, _In_ zend_long attr, _In break; } + case SQLSRV_ATTR_FORMAT_DECIMALS: + { + ZVAL_BOOL( return_value, driver_stmt->format_decimals ); + break; + } + + case SQLSRV_ATTR_DECIMAL_PLACES: + { + ZVAL_LONG( return_value, driver_stmt->decimal_places ); + 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 0295b406..4120876b 100644 --- a/source/pdo_sqlsrv/pdo_util.cpp +++ b/source/pdo_sqlsrv/pdo_util.cpp @@ -438,13 +438,9 @@ pdo_error PDO_ERRORS[] = { { IMSSP, (SQLCHAR*) "The Azure AD Access Token is empty. Expected a byte string.", -91, false} }, { - SQLSRV_ERROR_INVALID_FORMAT_DECIMALS, + SQLSRV_ERROR_INVALID_DECIMAL_PLACES, { 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 ced89eee..d9bb55e5 100644 --- a/source/pdo_sqlsrv/php_pdo_sqlsrv.h +++ b/source/pdo_sqlsrv/php_pdo_sqlsrv.h @@ -49,7 +49,8 @@ enum PDO_SQLSRV_ATTR { SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE, SQLSRV_ATTR_FETCHES_NUMERIC_TYPE, SQLSRV_ATTR_FETCHES_DATETIME_TYPE, - SQLSRV_ATTR_FORMAT_DECIMALS + SQLSRV_ATTR_FORMAT_DECIMALS, + SQLSRV_ATTR_DECIMAL_PLACES }; // valid set of values for TransactionIsolation connection option @@ -206,6 +207,8 @@ struct pdo_sqlsrv_dbh : public sqlsrv_conn { zend_long client_buffer_max_size; bool fetch_numeric; bool fetch_datetime; + bool format_decimals; + short decimal_places; pdo_sqlsrv_dbh( _In_ SQLHANDLE h, _In_ error_callback e, _In_ void* driver TSRMLS_DC ); }; @@ -267,6 +270,8 @@ struct pdo_sqlsrv_stmt : public sqlsrv_stmt { direct_query = db->direct_query; fetch_numeric = db->fetch_numeric; fetch_datetime = db->fetch_datetime; + format_decimals = db->format_decimals; + decimal_places = db->decimal_places; } virtual ~pdo_sqlsrv_stmt( void ); diff --git a/source/shared/core_sqlsrv.h b/source/shared/core_sqlsrv.h index 91be215d..363ccad1 100644 --- a/source/shared/core_sqlsrv.h +++ b/source/shared/core_sqlsrv.h @@ -173,6 +173,8 @@ const int SQL_SERVER_MAX_FIELD_SIZE = 8000; const int SQL_SERVER_MAX_PRECISION = 38; const int SQL_SERVER_MAX_TYPE_SIZE = 0; const int SQL_SERVER_MAX_PARAMS = 2100; +const int SQL_SERVER_MAX_MONEY_SCALE = 4; + // increase the maximum message length to accommodate for the long error returned for operand type clash // or for conversion of a long string const int SQL_MAX_ERROR_MESSAGE_LENGTH = SQL_MAX_MESSAGE_LENGTH * 2; @@ -230,6 +232,9 @@ enum SQLSRV_FETCH_TYPE { // buffer size of a sql state (including the null character) const int SQL_SQLSTATE_BUFSIZE = SQL_SQLSTATE_SIZE + 1; +// default value of decimal places (no formatting required) +const short NO_CHANGE_DECIMAL_PLACES = -1; + // buffer size allocated to retrieve data from a PHP stream. This number // was chosen since PHP doesn't return more than 8k at a time even if // the amount requested was more. @@ -1108,6 +1113,7 @@ enum SQLSRV_STMT_OPTIONS { SQLSRV_STMT_OPTION_CLIENT_BUFFER_MAX_SIZE, SQLSRV_STMT_OPTION_DATE_AS_STRING, SQLSRV_STMT_OPTION_FORMAT_DECIMALS, + SQLSRV_STMT_OPTION_DECIMAL_PLACES, // Driver specific connection options SQLSRV_STMT_OPTION_DRIVER_SPECIFIC = 1000, @@ -1302,6 +1308,11 @@ 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 ); }; +struct stmt_option_decimal_places : 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 { @@ -1372,7 +1383,7 @@ struct sqlsrv_output_param { 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 + 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 ) : @@ -1399,15 +1410,9 @@ struct sqlsrv_output_param { meta_data.nullable = nullable; } - SQLSMALLINT getDecimalDigits() + param_meta_data& getMetaData() { - // 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; - } + return meta_data; } }; @@ -1435,7 +1440,8 @@ 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) + bool format_decimals; // false by default but the user can set this to true to add the missing leading zeroes and/or control number of decimal digits to show + short decimal_places; // indicates number of decimals shown in fetched results (-1 by default, which means no change to number of decimal digits) // 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 @@ -1476,9 +1482,10 @@ struct field_meta_data { SQLULEN field_precision; SQLSMALLINT field_scale; SQLSMALLINT field_is_nullable; + bool field_is_money_type; field_meta_data() : field_name_len(0), field_type(0), field_size(0), field_precision(0), - field_scale (0), field_is_nullable(0) + field_scale (0), field_is_nullable(0), field_is_money_type(false) { } @@ -1527,7 +1534,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); +void core_sqlsrv_set_decimal_places(_Inout_ sqlsrv_stmt* stmt, _In_ zval* value_z TSRMLS_DC); //********************************************************************************************************************************* // Result Set @@ -1769,8 +1776,7 @@ 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, + SQLSRV_ERROR_INVALID_DECIMAL_PLACES, // Driver specific error codes starts from here. SQLSRV_ERROR_DRIVER_SPECIFIC = 1000, diff --git a/source/shared/core_stmt.cpp b/source/shared/core_stmt.cpp index 206fe11d..1a4fb7a2 100644 --- a/source/shared/core_stmt.cpp +++ b/source/shared/core_stmt.cpp @@ -107,7 +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 format_decimal_numbers(_In_ SQLSMALLINT decimals_places, _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 ); @@ -142,7 +142,8 @@ 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 + format_decimals(false), // no formatting needed + decimal_places(NO_CHANGE_DECIMAL_PLACES), // the default is no formatting to resultset 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 send_streams_at_exec( true ), @@ -925,6 +926,19 @@ field_meta_data* core_sqlsrv_field_metadata( _Inout_ sqlsrv_stmt* stmt, _In_ SQL } } + if (meta_data->field_type == SQL_DECIMAL) { + // Check if it is money type -- get the name of the data type + char field_type_name[SS_MAXCOLNAMELEN] = {'\0'}; + SQLSMALLINT out_buff_len; + SQLLEN not_used; + core::SQLColAttribute(stmt, colno + 1, SQL_DESC_TYPE_NAME, field_type_name, + sizeof( field_type_name ), &out_buff_len, ¬_used TSRMLS_CC); + + if (!strcmp(field_type_name, "money") || !strcmp(field_type_name, "smallmoney")) { + meta_data->field_is_money_type = true; + } + } + // Set the field name lenth meta_data->field_name_len = static_cast( field_name_len ); @@ -1258,20 +1272,21 @@ 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) +void core_sqlsrv_set_decimal_places(_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) { + CHECK_CUSTOM_ERROR(Z_TYPE_P(value_z) != IS_LONG, stmt, SQLSRV_ERROR_INVALID_DECIMAL_PLACES) { 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(); + zend_long decimal_places = Z_LVAL_P(value_z); + if (decimal_places < 0 || decimal_places > SQL_SERVER_MAX_MONEY_SCALE) { + // ignore decimal_places because it is out of range + decimal_places = NO_CHANGE_DECIMAL_PLACES; } - stmt->num_decimals = static_cast(format_decimals); + stmt->decimal_places = static_cast(decimal_places); } catch( core::CoreException& ) { throw; @@ -1447,7 +1462,17 @@ 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 ) { - core_sqlsrv_set_format_decimals(stmt, value_z TSRMLS_CC); + if (zend_is_true(value_z)) { + stmt->format_decimals = true; + } + else { + stmt->format_decimals = false; + } +} + +void stmt_option_decimal_places:: operator()( _Inout_ sqlsrv_stmt* stmt, stmt_option const* /**/, _In_ zval* value_z TSRMLS_DC ) +{ + core_sqlsrv_set_decimal_places(stmt, value_z TSRMLS_CC); } // internal function to release the active stream. Called by each main API function @@ -2114,25 +2139,29 @@ void field_cache_dtor( _Inout_ zval* data_z ) } // 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) +void format_decimal_numbers(_In_ SQLSMALLINT decimals_places, _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: decimals_places is NO_CHANGE_DECIMAL_PLACES by default, which means no formatting on decimal data is necessary + // This function assumes stmt->format_decimals is true, so it first checks if it is necessary to add the leading zero. // - // 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 + // Likewise, if decimals_places is larger than the field scale, decimals_places wil be ignored. 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 data 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 + // format_decimals and decimals_places // std::string str = field_value; size_t pos = str.find_first_of('.'); - if (pos == std::string::npos || decimals_digits < 0) { + // The decimal dot is not found, simply return + if (pos == std::string::npos) { return; } - SQLSMALLINT num_decimals = decimals_digits; + SQLSMALLINT num_decimals = decimals_places; if (num_decimals > field_scale) { num_decimals = field_scale; } @@ -2160,47 +2189,24 @@ void format_decimal_numbers(_In_ SQLSMALLINT decimals_digits, _In_ SQLSMALLINT f 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()) { + if (num_decimals == NO_CHANGE_DECIMAL_PLACES) { + // Add the minus sign back if negative + if (isNegative) { + std::ostringstream oss; + oss << '-' << str.substr(0); + str = oss.str(); + } + } else { + // Start formatting + 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 pos2 all the way to the first digit + // 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 = pos2 - 1; p >= 0 && carry_over; p--) { - if (str[p] == '.') { // Skip the dot - continue; - } + for (short p = pos - 1; p >= 0 && carry_over; p--) { n = str[p] - '0'; if (n == 9) { str[p] = '0' ; @@ -2214,22 +2220,54 @@ void format_decimal_numbers(_In_ SQLSMALLINT decimals_digits, _In_ SQLSMALLINT f } if (carry_over) { std::ostringstream oss; - oss << '1' << str.substr(0, pos2); + oss << '1' << str.substr(0, pos); str = oss.str(); - pos2++; + 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); } - 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(); @@ -2313,7 +2351,7 @@ 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(); + param_meta_data metaData = output_param->getMetaData(); if (output_param->encoding != SQLSRV_ENCODING_CHAR) { char* outString = NULL; @@ -2323,15 +2361,16 @@ void finalize_output_parameters( _Inout_ sqlsrv_stmt* stmt TSRMLS_DC ) throw core::CoreException(); } - if (stmt->num_decimals >= 0 && decimal_digits >= 0) { - format_decimal_numbers(stmt->num_decimals, decimal_digits, outString, &outLen); + if (stmt->format_decimals && (metaData.sql_type == SQL_DECIMAL || metaData.sql_type == SQL_NUMERIC)) { + format_decimal_numbers(NO_CHANGE_DECIMAL_PLACES, metaData.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); + if (stmt->format_decimals && (metaData.sql_type == SQL_DECIMAL || metaData.sql_type == SQL_NUMERIC)) { + format_decimal_numbers(NO_CHANGE_DECIMAL_PLACES, metaData.decimal_digits, str, &str_len); } core::sqlsrv_zval_stringl(value_z, str, str_len); @@ -2601,8 +2640,10 @@ void get_field_as_string( _Inout_ sqlsrv_stmt* stmt, _In_ SQLUSMALLINT field_ind } } - 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); + if (stmt->format_decimals && (sql_field_type == SQL_DECIMAL || sql_field_type == SQL_NUMERIC)) { + // number of decimal places only affect money / smallmoney fields + SQLSMALLINT decimal_places = (stmt->current_meta_data[field_index]->field_is_money_type) ? stmt->decimal_places : NO_CHANGE_DECIMAL_PLACES; + format_decimal_numbers(decimal_places, 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 ) diff --git a/source/sqlsrv/conn.cpp b/source/sqlsrv/conn.cpp index d5f324a2..60b9f0d1 100644 --- a/source/sqlsrv/conn.cpp +++ b/source/sqlsrv/conn.cpp @@ -46,6 +46,45 @@ struct date_as_string_func { } }; +struct format_decimals_func +{ + static void func(connection_option const* /*option*/, _In_ zval* value, _Inout_ sqlsrv_conn* conn, std::string& /*conn_str*/ TSRMLS_DC) + { + TSRMLS_C; // show as used to avoid a warning + + ss_sqlsrv_conn* ss_conn = static_cast(conn); + + if (zend_is_true(value)) { + ss_conn->format_decimals = true; + } + else { + ss_conn->format_decimals = false; + } + } +}; + +struct decimal_places_func +{ + + static void func(connection_option const* /*option*/, _In_ zval* value, _Inout_ sqlsrv_conn* conn, std::string& /*conn_str*/ TSRMLS_DC) + { + TSRMLS_C; // show as used to avoid a warning + + // first check if the input is an integer + if (Z_TYPE_P(value) != IS_LONG) { + THROW_SS_ERROR(conn, SQLSRV_ERROR_INVALID_DECIMAL_PLACES); + } + + zend_long decimal_places = Z_LVAL_P(value); + if (decimal_places < 0 || decimal_places > SQL_SERVER_MAX_MONEY_SCALE) { + decimal_places = NO_CHANGE_DECIMAL_PLACES; + } + + ss_sqlsrv_conn* ss_conn = static_cast(conn); + ss_conn->decimal_places = static_cast(decimal_places); + } +}; + struct conn_char_set_func { static void func( connection_option const* /*option*/, _Inout_ zval* value, _Inout_ sqlsrv_conn* conn, std::string& /*conn_str*/ TSRMLS_DC ) @@ -175,6 +214,7 @@ namespace SSStmtOptionNames { const char CLIENT_BUFFER_MAX_SIZE[] = INI_BUFFERED_QUERY_LIMIT; const char DATE_AS_STRING[] = "ReturnDatesAsStrings"; const char FORMAT_DECIMALS[] = "FormatDecimals"; + const char DECIMAL_PLACES[] = "DecimalPlaces"; } namespace SSConnOptionNames { @@ -192,6 +232,8 @@ const char ConnectionPooling[] = "ConnectionPooling"; const char ConnectRetryCount[] = "ConnectRetryCount"; const char ConnectRetryInterval[] = "ConnectRetryInterval"; const char Database[] = "Database"; +const char DecimalPlaces[] = "DecimalPlaces"; +const char FormatDecimals[] = "FormatDecimals"; const char DateAsString[] = "ReturnDatesAsStrings"; const char Driver[] = "Driver"; const char Encrypt[] = "Encrypt"; @@ -217,6 +259,8 @@ const char WSID[] = "WSID"; enum SS_CONN_OPTIONS { SS_CONN_OPTION_DATE_AS_STRING = SQLSRV_CONN_OPTION_DRIVER_SPECIFIC, + SS_CONN_OPTION_FORMAT_DECIMALS, + SS_CONN_OPTION_DECIMAL_PLACES, }; //List of all statement options supported by this driver @@ -257,6 +301,12 @@ const stmt_option SS_STMT_OPTS[] = { SQLSRV_STMT_OPTION_FORMAT_DECIMALS, std::unique_ptr( new stmt_option_format_decimals ) }, + { + SSStmtOptionNames::DECIMAL_PLACES, + sizeof( SSStmtOptionNames::DECIMAL_PLACES), + SQLSRV_STMT_OPTION_DECIMAL_PLACES, + std::unique_ptr( new stmt_option_decimal_places ) + }, { NULL, 0, SQLSRV_STMT_OPTION_INVALID, std::unique_ptr{} }, }; @@ -515,6 +565,24 @@ const connection_option SS_CONN_OPTS[] = { CONN_ATTR_BOOL, date_as_string_func::func }, + { + SSConnOptionNames::FormatDecimals, + sizeof( SSConnOptionNames::FormatDecimals), + SS_CONN_OPTION_FORMAT_DECIMALS, + SSConnOptionNames::FormatDecimals, + sizeof( SSConnOptionNames::FormatDecimals), + CONN_ATTR_BOOL, + format_decimals_func::func + }, + { + SSConnOptionNames::DecimalPlaces, + sizeof( SSConnOptionNames::DecimalPlaces), + SS_CONN_OPTION_DECIMAL_PLACES, + SSConnOptionNames::DecimalPlaces, + sizeof( SSConnOptionNames::DecimalPlaces), + CONN_ATTR_INT, + decimal_places_func::func + }, { NULL, 0, SQLSRV_CONN_OPTION_INVALID, NULL, 0 , CONN_ATTR_INVALID, NULL }, //terminate the table }; diff --git a/source/sqlsrv/php_sqlsrv.h b/source/sqlsrv/php_sqlsrv.h index 1816942a..3f77ecbc 100644 --- a/source/sqlsrv/php_sqlsrv.h +++ b/source/sqlsrv/php_sqlsrv.h @@ -131,6 +131,8 @@ struct ss_sqlsrv_conn : sqlsrv_conn { HashTable* stmts; bool date_as_string; + bool format_decimals; // flag set to turn on formatting for values of decimal / numeric types + short decimal_places; // number of decimal digits to show in a result set unless format_numbers is false bool in_transaction; // flag set when inside a transaction and used for checking validity of tran API calls // static variables used in process_params @@ -142,6 +144,8 @@ struct ss_sqlsrv_conn : sqlsrv_conn sqlsrv_conn( h, e, drv, SQLSRV_ENCODING_SYSTEM TSRMLS_CC ), stmts( NULL ), date_as_string( false ), + format_decimals( false ), + decimal_places( NO_CHANGE_DECIMAL_PLACES ), in_transaction( false ) { } diff --git a/source/sqlsrv/stmt.cpp b/source/sqlsrv/stmt.cpp index 3da4ebcc..e9ce41e8 100644 --- a/source/sqlsrv/stmt.cpp +++ b/source/sqlsrv/stmt.cpp @@ -139,9 +139,11 @@ ss_sqlsrv_stmt::ss_sqlsrv_stmt( _In_ sqlsrv_conn* c, _In_ SQLHANDLE handle, _In_ { core_sqlsrv_set_buffered_query_limit( this, SQLSRV_G( buffered_query_limit ) TSRMLS_CC ); - // initialize date_as_string based on the corresponding connection option + // inherit other values based on the corresponding connection options ss_sqlsrv_conn* ss_conn = static_cast(conn); date_as_string = ss_conn->date_as_string; + format_decimals = ss_conn->format_decimals; + decimal_places = ss_conn->decimal_places; } ss_sqlsrv_stmt::~ss_sqlsrv_stmt( void ) diff --git a/source/sqlsrv/util.cpp b/source/sqlsrv/util.cpp index 545f699d..ff9e7c86 100644 --- a/source/sqlsrv/util.cpp +++ b/source/sqlsrv/util.cpp @@ -429,13 +429,9 @@ ss_error SS_ERRORS[] = { { IMSSP, (SQLCHAR*) "The Azure AD Access Token is empty. Expected a byte string.", -116, false} }, { - SQLSRV_ERROR_INVALID_FORMAT_DECIMALS, + SQLSRV_ERROR_INVALID_DECIMAL_PLACES, { 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, {} } diff --git a/test/functional/pdo_sqlsrv/pdostatement_format_decimals.phpt b/test/functional/pdo_sqlsrv/pdostatement_format_decimals.phpt index ff29a808..07303c4b 100644 --- a/test/functional/pdo_sqlsrv/pdostatement_format_decimals.phpt +++ b/test/functional/pdo_sqlsrv/pdostatement_format_decimals.phpt @@ -1,24 +1,17 @@ --TEST-- -Test statement attribute PDO::SQLSRV_ATTR_FORMAT_DECIMALS for decimal types +Test connection and statement attributes for formatting decimal and numeric data (feature request issue 415) --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. +Test the attributes PDO::SQLSRV_ATTR_FORMAT_DECIMALS and PDO::SQLSRV_ATTR_DECIMAL_PLACES, the latter affects money types only, not decimal or numeric types (feature request issue 415). +Money, decimal or numeric types 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. +Setting PDO::SQLSRV_ATTR_FORMAT_DECIMALS to false will turn off all formatting, regardless of PDO::SQLSRV_ATTR_DECIMAL_PLACES value. Also, any negative PDO::SQLSRV_ATTR_DECIMAL_PLACES value will be ignored. Likewise, since money or smallmoney fields have scale 4, if PDO::SQLSRV_ATTR_DECIMAL_PLACES value is larger than 4, it will be ignored as well. 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 +2. Set PDO::SQLSRV_ATTR_FORMAT_DECIMALS to true to add the leading zeroes to money and decimal types, if missing. +3. No support for output params + +The attributes PDO::SQLSRV_ATTR_FORMAT_DECIMALS and PDO::SQLSRV_ATTR_DECIMAL_PLACES will only format the +fetched results and have no effect on other operations like insertion or update. --ENV-- PHPT_EXEC=true --SKIPIF-- @@ -35,171 +28,52 @@ function checkException($exception, $expected) } } -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) { + $expected = 'Expected an integer to specify number of decimals to format the output values of decimal data types'; $query = "SELECT 0.0001"; try { - $options = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => 0.9); - $stmt = $conn->prepare($query, $options); + $conn->setAttribute(PDO::SQLSRV_ATTR_FORMAT_DECIMALS, 0); + $format = $conn->getAttribute(PDO::SQLSRV_ATTR_FORMAT_DECIMALS); + if ($format !== false) { + echo 'The value of PDO::SQLSRV_ATTR_FORMAT_DECIMALS should be false\n'; + var_dump($format); + } + + $conn->setAttribute(PDO::SQLSRV_ATTR_DECIMAL_PLACES, 1.5); } 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); + $options = array(PDO::SQLSRV_ATTR_DECIMAL_PLACES => 0.9); + $stmt = $conn->prepare($query, $options); + } catch (PdoException $e) { + checkException($e, $expected); + } + + try { + $options = array(PDO::SQLSRV_ATTR_DECIMAL_PLACES => true); $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) +function compareNumbers($actual, $input, $column, $fieldScale, $format = true) { $matched = false; if ($actual === $input) { $matched = true; trace("Matched: $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; - $expected = number_format($input, $numDecimals); - } else { - $expected = number_format($input, $fieldScale); + // if no formatting, there will be no leading zero + $expected = number_format($input, $fieldScale); + if (!$format) { if (abs($input) < 1) { // Since no formatting, the leading zero should not be there - trace("Drop leading zero of $input--"); + trace("Drop leading zero of $input: "); $expected = str_replace('0.', '.', $expected); } } @@ -207,7 +81,7 @@ function compareNumbers($actual, $input, $column, $fieldScale, $formatDecimal = if ($actual === $expected) { $matched = true; } else { - echo "For $column ($formatDecimal): expected $expected ($input) but the value is $actual\n"; + echo "For $column ($fieldScale): expected $expected ($input) but the value is $actual\n"; } } return $matched; @@ -223,35 +97,35 @@ function testNoOption($conn, $tableName, $inputs, $columns) $results = $stmt->fetch(PDO::FETCH_NUM); trace("\ntestNoOption:\n"); for ($i = 0; $i < count($inputs); $i++) { - compareNumbers($results[$i], $inputs[$i], $columns[$i], $i); + compareNumbers($results[$i], $inputs[$i], $columns[$i], $i, false); } } -function testStmtOption($conn, $tableName, $inputs, $columns, $formatDecimal, $withBuffer) +function testStmtOption($conn, $tableName, $inputs, $columns, $decimalPlaces, $withBuffer) { - // Decimal values should return decimal digits based on the valid statement - // option PDO::SQLSRV_ATTR_FORMAT_DECIMALS + // Decimal values should NOT be affected by the statement + // attribute 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); + PDO::SQLSRV_ATTR_DECIMAL_PLACES => $decimalPlaces); } else { - $options = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => $formatDecimal); + $options = array(PDO::SQLSRV_ATTR_DECIMAL_PLACES => $decimalPlaces); } $size = count($inputs); $stmt = $conn->prepare($query, $options); // Fetch by getting one field at a time - trace("\ntestStmtOption: $formatDecimal and buffered $withBuffer\n"); + trace("\ntestStmtOption: $decimalPlaces 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); + compareNumbers($field, $inputs[$i], $columns[$i], $i); } } @@ -262,7 +136,7 @@ function getOutputParam($conn, $storedProcName, $inputValue, $prec, $scale, $ino $outSql = getCallProcSqlPlaceholders($storedProcName, 1); - $options = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => $numDigits); + $options = array(PDO::SQLSRV_ATTR_DECIMAL_PLACES => $numDigits); $stmt = $conn->prepare($outSql, $options); $len = 1024; @@ -279,17 +153,17 @@ function getOutputParam($conn, $storedProcName, $inputValue, $prec, $scale, $ino $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 + // unaffected by the statement attr PDO::SQLSRV_ATTR_DECIMAL_PLACES. + // If ColumnEncryption is enabled, in which case the driver is able + // to derive the decimal type, leading zero will be added if missing if (isAEConnected()) { trace("\ngetOutputParam ($inout) with AE:\n"); $column = 'outputParamAE'; - compareNumbers($outString, $inputValue, $column, $scale, $numDigits); + compareNumbers($outString, $inputValue, $column, $scale, true); } else { trace("\ngetOutputParam ($inout) without AE:\n"); $column = 'outputParam'; - compareNumbers($outString, $inputValue, $column, $scale); + compareNumbers($outString, $inputValue, $column, $scale, false); } } @@ -314,16 +188,8 @@ try { $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'; @@ -347,8 +213,8 @@ try { $n = rand(1, 6); $neg = ($n % 2 == 0) ? -1 : 1; - // $n1 may or may not be negative - $n1 = rand(0, 1000) * $neg; + // $n1, a tiny number, which may or may not be negative, + $n1 = rand(0, 5) * $neg; if ($s > 0) { $max *= 10; @@ -371,6 +237,11 @@ try { testNoOption($conn, $tableName, $values, $columns, true); + // Turn on formatting, which only add leading zeroes, if missing + // decimal and numeric types should be unaffected by + // PDO::SQLSRV_ATTR_DECIMAL_PLACES whatsoever + $conn->setAttribute(PDO::SQLSRV_ATTR_FORMAT_DECIMALS, 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); diff --git a/test/functional/pdo_sqlsrv/pdostatement_format_decimals_scales.phpt b/test/functional/pdo_sqlsrv/pdostatement_format_decimals_scales.phpt deleted file mode 100644 index a8a40d48..00000000 --- a/test/functional/pdo_sqlsrv/pdostatement_format_decimals_scales.phpt +++ /dev/null @@ -1,248 +0,0 @@ ---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/pdo_sqlsrv/pdostatement_format_money_scales.phpt b/test/functional/pdo_sqlsrv/pdostatement_format_money_scales.phpt new file mode 100644 index 00000000..f7618fe0 --- /dev/null +++ b/test/functional/pdo_sqlsrv/pdostatement_format_money_scales.phpt @@ -0,0 +1,162 @@ +--TEST-- +Test various decimal places of money values (feature request issue 415) +--DESCRIPTION-- +In SQL Server, the maximum precision of money type is 19 with scale 4. Generate a long numeric string and get rid of the last digit to make it a 15-digit-string. Then replace one digit at a time with a dot '.' to make it a decimal input string for testing. + +For example, +string(15) ".23456789098765" +string(15) "1.3456789098765" +string(15) "12.456789098765" +string(15) "123.56789098765" +string(15) "1234.6789098765" +... +string(15) "1234567890987.5" +string(15) "12345678909876." + +The inserted money data will be +0.2346 +1.3457 +12.4568 +123.5679 +1234.6789 +... +1234567890987.5000 +12345678909876.0000 + +--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); +} + +function numberFormat($value, $numDecimals) +{ + return number_format($value, $numDecimals, '.', ''); +} + +/**** +The function testVariousScales() will fetch one column at a time, using scale from 0 up to 4 allowed for that column type. + +For example, if the input string is +1234567890.2345 + +When fetching data, using scale from 0 to 4, the following values are expected to return: +1234567890 +1234567890.2 +1234567890.23 +1234567890.235 +1234567890.2345 +****/ +function testVariousScales($conn, $tableName) +{ + global $prec, $scale; + $max = $prec - $scale; + + for ($i = 0; $i < $max; $i++) { + $column = "col_$i"; + + $query = "SELECT $column as col1 FROM $tableName"; + + // Default case: no formatting + $stmt = $conn->query($query); + if ($obj = $stmt->fetchObject()) { + trace("\n$obj->col1\n"); + $input = $obj->col1; + } else { + echo "In testVariousScales: fetchObject failed\n"; + } + + // Next, format how many decimals to be displayed + $query = "SELECT $column FROM $tableName"; + for ($j = 0; $j <= $scale; $j++) { + $options = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => true, PDO::SQLSRV_ATTR_DECIMAL_PLACES => $j); + $stmt = $conn->prepare($query, $options); + $stmt->execute(); + + $stmt->bindColumn($column, $value); + if ($stmt->fetch(PDO::FETCH_BOUND)) { + trace("$value\n"); + + $expected = numberFormat($input, $j); + if ($value !== $expected) { + echo "testVariousScales ($j): Expected $expected but got $value\n"; + } + } else { + echo "Round $i scale $j: fetch failed\n"; + } + } + } +} + +try { + // This helper method sets PDO::ATTR_ERRMODE to PDO::ERRMODE_EXCEPTION + // Default is no formatting, but set it to false anyway + $conn = connect(); + $conn->setAttribute(PDO::SQLSRV_ATTR_FORMAT_DECIMALS, false); + + $tableName = createTestTable($conn); + insertTestData($conn, $tableName); + testVariousScales($conn, $tableName); + + dropTable($conn, $tableName); + + echo "Done\n"; + + unset($conn); +} catch (PdoException $e) { + echo $e->getMessage() . PHP_EOL; +} +?> +--EXPECT-- +Done diff --git a/test/functional/pdo_sqlsrv/pdostatement_format_money_types.phpt b/test/functional/pdo_sqlsrv/pdostatement_format_money_types.phpt new file mode 100644 index 00000000..c02d634a --- /dev/null +++ b/test/functional/pdo_sqlsrv/pdostatement_format_money_types.phpt @@ -0,0 +1,244 @@ +--TEST-- +Test connection attributes for formatting money data (feature request issue 415) +--DESCRIPTION-- +Test how money data in the fetched values can be formatted by using the connection attributes PDO::SQLSRV_ATTR_FORMAT_DECIMALS and PDO::SQLSRV_ATTR_DECIMAL_PLACES, the latter works only with integer values. No effect on other operations like insertion or update. + +The PDO::SQLSRV_ATTR_DECIMAL_PLACES attribute only affects money/smallmoney fields. If its value is out of range, for example, it's negative or larger than the original scale, then its value will be ignored. + +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). For this reason, it is not recommended to use formatted money values as inputs to any calculation. + +The corresponding statement attributes always override the inherited values from the connection object. Setting PDO::SQLSRV_ATTR_FORMAT_DECIMALS to false will automatically turn off any formatting of decimal data in the result set, ignoring PDO::SQLSRV_ATTR_DECIMAL_PLACES value. + +By only setting PDO::SQLSRV_ATTR_FORMAT_DECIMALS to true will add the leading zeroes, if missing. + +Do not support output params. +--ENV-- +PHPT_EXEC=true +--SKIPIF-- + +--FILE-- +query($query); + $floats = $stmt->fetch(PDO::FETCH_NUM); + unset($stmt); + + // By default the floating point numbers are fetched as strings + $stmt = $conn->prepare($query); + 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 "$diff: Expected $floatVal but $floatVal1 returned. \n"; + } + } + } +} + +function verifyMoneyFormatting($conn, $query, $values, $format) +{ + if ($format) { + // Set SQLSRV_ATTR_FORMAT_DECIMALS to true but + // set SQLSRV_ATTR_DECIMAL_PLACES to a negative number + // to override the inherited attribute + $options = array(PDO::SQLSRV_ATTR_DECIMAL_PLACES => -1, PDO::SQLSRV_ATTR_FORMAT_DECIMALS => true); + } else { + // Set SQLSRV_ATTR_FORMAT_DECIMALS to false will + // turn off any formatting -- overriding the inherited + // attributes + $options = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => false); + } + + $stmt = $conn->prepare($query, $options); + $stmt->execute(); + $results = $stmt->fetch(PDO::FETCH_NUM); + + trace("\verifyMoneyFormatting:\n"); + for ($i = 0; $i < count($values); $i++) { + // money types have a scale of 4 + $default = numberFormat($values[$i], 4); + if (!$format) { + // No formatting - should drop the leading zero, if exists + if (abs($values[$i]) < 1) { + $default = str_replace('0.', '.', $default); + } + } + if ($default !== $results[$i]) { + echo "verifyMoneyFormatting ($format): Expected $default but got $results[$i]\n"; + } + } +} + +function verifyMoneyValues($conn, $numDigits, $query, $values, $override) +{ + if ($override) { + $options = array(PDO::SQLSRV_ATTR_DECIMAL_PLACES => $numDigits); + $stmt = $conn->prepare($query, $options); + } else { + // Use the connection defaults + $stmt = $conn->prepare($query); + } + $stmt->execute(); + $results = $stmt->fetch(PDO::FETCH_NUM); + + trace("\nverifyMoneyValues:\n"); + for ($i = 0; $i < count($values); $i++) { + $value = numberFormat($values[$i], $numDigits); + trace("$results[$i], $value\n"); + + if ($value !== $results[$i]) { + echo "testMoneyTypes ($override, $numDigits): Expected $value but got $results[$i]\n"; + } + } +} + +function testMoneyTypes($conn, $numDigits) +{ + // With money and smallmoney types, which are essentially decimal types + // As of today, ODBC driver does not support Always Encrypted feature with money / smallmoney + $values = array(); + $nColumns = 6; + for ($i = 0; $i < $nColumns; $i++) { + // First get a random number + $n = rand(0, 10); + $neg = ($n % 2 == 0) ? -1 : 1; + + // $n1 may or may not be negative + $max = 10; + $n1 = rand(0, $max) * $neg; + $n2 = rand(1, $max * 1000); + + $number = sprintf("%d.%d", $n1, $n2); + array_push($values, $number); + } + + $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])"; + + // Do not override the connection attributes + verifyMoneyValues($conn, $numDigits, $query, $values, false); + // Next, override statement attribute to set number of + // decimal places + verifyMoneyValues($conn, 0, $query, $values, true); + + // Set Formatting attribute to true then false + verifyMoneyFormatting($conn, $query, $values, true); + verifyMoneyFormatting($conn, $query, $values, false); +} + +function connGetAttributes($conn, $numDigits) +{ + $format = $conn->getAttribute(PDO::SQLSRV_ATTR_FORMAT_DECIMALS); + if ($format !== true) { + echo "The returned value of SQLSRV_ATTR_FORMAT_DECIMALS, $format, is wrong\n"; + + return false; + } + + $digits = $conn->getAttribute(PDO::SQLSRV_ATTR_DECIMAL_PLACES); + if ($digits != $numDigits) { + echo "The returned value of SQLSRV_ATTR_DECIMAL_PLACES, $digits, is wrong\n"; + + return false; + } + + return true; +} + +function connectWithAttrs($numDigits) +{ + $attr = array(PDO::SQLSRV_ATTR_FORMAT_DECIMALS => true, + PDO::SQLSRV_ATTR_DECIMAL_PLACES => $numDigits); + + // This helper method sets PDO::ATTR_ERRMODE to PDO::ERRMODE_EXCEPTION + $conn = connect('', $attr); + + if (connGetAttributes($conn, $numDigits)) { + // First test with money types + testMoneyTypes($conn, $numDigits); + + // Also test using regular floats + testFloatTypes($conn, $numDigits); + } + unset($conn); +} + +function connectSetAttrs($numDigits) +{ + // This helper method sets PDO::ATTR_ERRMODE to PDO::ERRMODE_EXCEPTION + $conn = connect(); + $conn->setAttribute(PDO::SQLSRV_ATTR_FORMAT_DECIMALS, true); + $conn->setAttribute(PDO::SQLSRV_ATTR_DECIMAL_PLACES, $numDigits); + + if (connGetAttributes($conn, $numDigits)) { + // First test with money types + testMoneyTypes($conn, $numDigits); + + // Also test using regular floats + testFloatTypes($conn, $numDigits); + } + + unset($conn); +} + +try { + connectWithAttrs(2); + connectSetAttrs(3); + + 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 223e4fb6..a2faf7cf 100644 --- a/test/functional/sqlsrv/sqlsrv_statement_format_decimals.phpt +++ b/test/functional/sqlsrv/sqlsrv_statement_format_decimals.phpt @@ -1,22 +1,18 @@ --TEST-- -Test how decimal data output values can be formatted (feature request issue 415) +Test connection and statement attributes for formatting decimal and numeric data (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. +Test the connection and statement options, FormatDecimals and +DecimalPlaces, the latter affects money types only, not +decimal or numeric types (feature request issue 415). +Money, decimal or numeric types 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. -No effect on other operations like insertion or update. +Setting FormatDecimals to false will turn off all formatting, regardless of DecimalPlaces value. Also, any negative DecimalPlaces value will be ignored. Likewise, since money or smallmoney fields have scale 4, if DecimalPlaces value is larger than 4, it will be ignored as well. -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 +1. By default, data will be returned with the original precision and scale +2. Set FormatDecimals to true to add the leading zeroes to money and decimal types, if missing. +3. For output params, leading zeroes will be added for any decimal fields if FormatDecimals is true, but only if either SQLSRV_SQLTYPE_DECIMAL or SQLSRV_SQLTYPE_NUMERIC is set correctly to match the original column type and its precision / scale. + +FormatDecimals and DecimalPlaces will only format the fetched results and have no effect on other operations like insertion or update. --ENV-- PHPT_EXEC=true --SKIPIF-- @@ -25,23 +21,19 @@ PHPT_EXEC=true $fieldScale, will show $fieldScale decimal digits - if ($formatDecimal >= 0) { - $numDecimals = ($formatDecimal > $fieldScale) ? $fieldScale : $formatDecimal; - $expected = number_format($input, $numDecimals); - } else { - $expected = number_format($input, $fieldScale); + // If no formatting, there will be no leading zero + $expected = number_format($input, $fieldScale); + if (!$format) { if (abs($input) < 1) { // Since no formatting, the leading zero should not be there - trace("Drop leading zero of $input--"); + trace("Drop leading zero of $input: "); $expected = str_replace('0.', '.', $expected); } } @@ -49,7 +41,7 @@ function compareNumbers($actual, $input, $column, $fieldScale, $formatDecimal = if ($actual === $expected) { $matched = true; } else { - echo "For $column ($formatDecimal): expected $expected ($input) but the value is $actual\n"; + echo "For $column ($fieldScale): expected $expected ($input) but the value is $actual\n"; } } return $matched; @@ -58,134 +50,34 @@ function compareNumbers($actual, $input, $column, $fieldScale, $formatDecimal = function testErrorCases($conn) { $query = "SELECT 0.0001"; - - $options = array('FormatDecimals' => 1.5); + $message = 'Expected an integer to specify number of decimals to format the output values of decimal data types.'; + + $options = array('DecimalPlaces' => 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); + $options = array('DecimalPlaces' => true); $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)); - $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 && $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 { - 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 + // This should return decimal values as they are $query = "SELECT * FROM $tableName"; if ($exec) { $stmt = sqlsrv_query($conn, $query); @@ -194,27 +86,27 @@ function testNoOption($conn, $tableName, $inputs, $columns, $exec) sqlsrv_execute($stmt); } - // Compare values + // 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); + compareNumbers($results[$i], $inputs[$i], $columns[$i], $i, false); } } -function testStmtOption($conn, $tableName, $inputs, $columns, $formatDecimal, $withBuffer) +function testStmtOption($conn, $tableName, $inputs, $columns, $decimalPlaces, $withBuffer) { - // Decimal values should return decimal digits based on the valid statement - // option FormatDecimals + // Decimal values should NOT be affected by the statement + // option DecimalPlaces $query = "SELECT * FROM $tableName"; if ($withBuffer){ - $options = array('Scrollable' => 'buffered', 'FormatDecimals' => $formatDecimal); + $options = array('Scrollable' => 'buffered', 'DecimalPlaces' => $decimalPlaces); } else { - $options = array('FormatDecimals' => $formatDecimal); + $options = array('DecimalPlaces' => $decimalPlaces); } $size = count($inputs); $stmt = sqlsrv_prepare($conn, $query, array(), $options); - + // Fetch by getting one field at a time sqlsrv_execute($stmt); @@ -223,26 +115,28 @@ function testStmtOption($conn, $tableName, $inputs, $columns, $formatDecimal, $w } for ($i = 0; $i < $size; $i++) { $field = sqlsrv_get_field($stmt, $i); // Expect a string - compareNumbers($field, $inputs[$i], $columns[$i], $i, $formatDecimal); + compareNumbers($field, $inputs[$i], $columns[$i], $i, true); } } -function getOutputParam($conn, $storedProcName, $inputValue, $prec, $scale, $inout) +function getOutputParam($conn, $storedProcName, $inputValue, $prec, $scale, $numeric, $inout) { $outString = ''; $numDigits = 2; $dir = SQLSRV_PARAM_OUT; - - // 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 + + // The output param value should be the same as the input, + // unaffected by the statement attr DecimalPlaces. If + // the correct sql type is specified or ColumnEncryption + // is enabled, in which case the driver is able to derive + // the correct field type, leading zero will be added + // if missing $sqlType = null; if (!AE\isColEncrypted()) { - $sqlType = call_user_func('SQLSRV_SQLTYPE_DECIMAL', $prec, $scale); + $type = ($numeric) ? 'SQLSRV_SQLTYPE_NUMERIC' : 'SQLSRV_SQLTYPE_DECIMAL'; + $sqlType = call_user_func($type, $prec, $scale); } - + // For inout parameters the input type should match the output one if ($inout) { $dir = SQLSRV_PARAM_INOUT; @@ -250,38 +144,37 @@ function getOutputParam($conn, $storedProcName, $inputValue, $prec, $scale, $ino } $outSql = AE\getCallProcSqlPlaceholders($storedProcName, 1); - $stmt = sqlsrv_prepare($conn, $outSql, - array(array(&$outString, $dir, null, $sqlType)), - array('FormatDecimals' => $numDigits)); + $stmt = sqlsrv_prepare($conn, $outSql, + array(array(&$outString, $dir, null, $sqlType)), + array('DecimalPlaces' => $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 + + // Verify value of output param $column = 'outputParam'; - compareNumbers($outString, $inputValue, $column, $scale, $numDigits); + compareNumbers($outString, $inputValue, $column, $scale, true); sqlsrv_free_stmt($stmt); - + if (!AE\isColEncrypted()) { // With ColumnEncryption enabled, the driver is able to derive the decimal type, // so skip this part of the test $outString2 = $inout ? '0.0' : ''; - $stmt = sqlsrv_prepare($conn, $outSql, - array(array(&$outString2, $dir)), - array('FormatDecimals' => $numDigits)); + $stmt = sqlsrv_prepare($conn, $outSql, + array(array(&$outString2, $dir)), + array('DecimalPlaces' => $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); + compareNumbers($outString2, $inputValue, $column, $scale, true); sqlsrv_free_stmt($stmt); } } @@ -296,8 +189,8 @@ function testOutputParam($conn, $tableName, $inputs, $columns, $dataTypes, $inou createProc($conn, $storedProcName, $procArgs, $procCode); // Call stored procedure to retrieve output param - getOutputParam($conn, $storedProcName, $inputs[$i], $p, $i, $inout); - + getOutputParam($conn, $storedProcName, $inputs[$i], $p, $i, $i > 2, $inout); + dropProc($conn, $storedProcName); } } @@ -310,16 +203,10 @@ if (!$conn) { fatalError("Could not connect.\n"); } -// First to test if leading zero is added -testMoneyTypes($conn); - -// Then test error conditions +// Test error conditions testErrorCases($conn); -// Also test using regular floats -testFloatTypes($conn); - -// Create the test table of decimal / numeric data columns +// Create the test table of decimal / numeric data columns $tableName = 'sqlsrvFormatDecimals'; $columns = array('c1', 'c2', 'c3', 'c4', 'c5', 'c6'); @@ -338,13 +225,13 @@ $values = array(); $max2 = 1; for ($s = 0, $p = 3; $s < count($columns); $s++, $p++) { // First get a random number - $n = rand(0, 10); + $n = rand(1, 6); $neg = ($n % 2 == 0) ? -1 : 1; - - // $n1 may or may not be negative - $max1 = 1000; + + // $n1 is a tiny number, which may or may not be negative + $max1 = 5; $n1 = rand(0, $max1) * $neg; - + if ($s > 0) { $max2 *= 10; $n2 = rand(0, $max2); @@ -352,7 +239,7 @@ for ($s = 0, $p = 3; $s < count($columns); $s++, $p++) { } else { $number = sprintf("%d", $n1); } - + array_push($values, $number); } @@ -373,6 +260,14 @@ sqlsrv_free_stmt($stmt); testNoOption($conn, $tableName, $values, $columns, true); testNoOption($conn, $tableName, $values, $columns, false); +sqlsrv_close($conn); + +// Reconnect with FormatDecimals option set to true +$conn = AE\connect(array('FormatDecimals' => true)); +if (!$conn) { + fatalError("Could not connect.\n"); +} + // Now try with setting number decimals to 3 then 2 testStmtOption($conn, $tableName, $values, $columns, 3, false); testStmtOption($conn, $tableName, $values, $columns, 3, true); @@ -384,7 +279,7 @@ testStmtOption($conn, $tableName, $values, $columns, 2, true); testOutputParam($conn, $tableName, $values, $columns, $dataTypes); testOutputParam($conn, $tableName, $values, $columns, $dataTypes, true); -dropTable($conn, $tableName); +dropTable($conn, $tableName); sqlsrv_close($conn); echo "Done\n"; diff --git a/test/functional/sqlsrv/sqlsrv_statement_format_decimals_scales.phpt b/test/functional/sqlsrv/sqlsrv_statement_format_decimals_scales.phpt deleted file mode 100644 index 4abb0398..00000000 --- a/test/functional/sqlsrv/sqlsrv_statement_format_decimals_scales.phpt +++ /dev/null @@ -1,255 +0,0 @@ ---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 = 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 \ No newline at end of file diff --git a/test/functional/sqlsrv/sqlsrv_statement_format_money_scales.phpt b/test/functional/sqlsrv/sqlsrv_statement_format_money_scales.phpt new file mode 100644 index 00000000..b27a3c48 --- /dev/null +++ b/test/functional/sqlsrv/sqlsrv_statement_format_money_scales.phpt @@ -0,0 +1,167 @@ +--TEST-- +Test various decimal places of money values (feature request issue 415) +--DESCRIPTION-- +In SQL Server, the maximum precision of money type is 19 with scale 4. Generate a long numeric string and get rid of the last digit to make it a 15-digit-string. Then replace one digit at a time with a dot '.' to make it a decimal input string for testing. + +For example, +string(15) ".23456789098765" +string(15) "1.3456789098765" +string(15) "12.456789098765" +string(15) "123.56789098765" +string(15) "1234.6789098765" +... +string(15) "1234567890987.5" +string(15) "12345678909876." + +The inserted money data will be +0.2346 +1.3457 +12.4568 +123.5679 +1234.6789 +... +1234567890987.5000 +12345678909876.0000 + +--ENV-- +PHPT_EXEC=true +--SKIPIF-- + +--FILE-- + $digits)); + trace($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); +} + +function numberFormat($value, $numDecimals) +{ + return number_format($value, $numDecimals, '.', ''); +} + +/**** +The function testVariousScales() will fetch one column at a time, using scale from 0 up to 4 allowed for that column type. + +For example, if the input string is +1234567890.2345 + +When fetching data, using scale from 0 to 4, the following values are expected to return: +1234567890 +1234567890.2 +1234567890.23 +1234567890.235 +1234567890.2345 +****/ +function testVariousScales($conn, $tableName) +{ + global $prec, $scale; + $max = $prec - $scale; + + for ($i = 0; $i < $max; $i++) { + $column = "col_$i"; + + $query = "SELECT $column as col1 FROM $tableName"; + // Default case: no formatting + $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"); + $input = $obj->col1; + } else { + fatalError("In testVariousScales: sqlsrv_fetch_object failed\n"); + } + + // Next, format how many decimals to be displayed + $query = "SELECT $column FROM $tableName"; + for ($j = 0; $j <= $scale; $j++) { + $options = array('FormatDecimals' => true,'DecimalPlaces' => $j); + $stmt = sqlsrv_query($conn, $query, array(), $options); + + if (sqlsrv_fetch($stmt)) { + $value = sqlsrv_get_field($stmt, 0); + trace("$value\n"); + + $expected = numberFormat($input, $j); + if ($value !== $expected) { + echo "testVariousScales ($j): Expected $expected but got $value\n"; + } + } else { + fatalError("Round $i scale $j: sqlsrv_fetch failed\n"); + } + } + } +} + +set_time_limit(0); +sqlsrv_configure('WarningsReturnAsErrors', 1); + +// Default is no formatting, but set it to false anyway +$conn = AE\connect(array('FormatDecimals' => false)); +if (!$conn) { + fatalError("Could not connect.\n"); +} + +$tableName = createTestTable($conn); +insertTestData($conn, $tableName); +testVariousScales($conn, $tableName); + +dropTable($conn, $tableName); + +sqlsrv_close($conn); + +echo "Done\n"; +?> +--EXPECT-- +Done \ No newline at end of file diff --git a/test/functional/sqlsrv/sqlsrv_statement_format_money_types.phpt b/test/functional/sqlsrv/sqlsrv_statement_format_money_types.phpt new file mode 100644 index 00000000..ac2c1cd0 --- /dev/null +++ b/test/functional/sqlsrv/sqlsrv_statement_format_money_types.phpt @@ -0,0 +1,261 @@ +--TEST-- +Test the options for formatting money data (feature request issue 415) +--DESCRIPTION-- +Test how money data in the fetched values can be formatted by using the connection +option FormatDecimals and DecimalPlaces, the latter works only with integer +values. No effect on other operations like insertion or update. + +The option DecimalPlaces only affects money/smallmoney fields. If its value is out of range, for example, it's negative or larger than the original scale, then its value will be ignored. + +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). For this reason, it is not recommended to use formatted money values as inputs to any calculation. + +The corresponding statement options always override the inherited values from the connection object. Setting FormatDecimals to false will automatically turn off any formatting of decimal data in the result set, ignoring DecimalPlaces value. + +By only setting FormatDecimals to true will add the leading zeroes, if missing. For output params, missing zeroes will be added if either SQLSRV_SQLTYPE_MONEY or SQLSRV_SQLTYPE_SMALLMONEY is set as the SQLSRV SQL Type. +--ENV-- +PHPT_EXEC=true +--SKIPIF-- + +--FILE-- + $epsilon) { + echo "$diff: Expected $floats[$i] but returned "; + var_dump($floatVal); + } + } + } + } else { + echo "testFloatTypes: sqlsrv_fetch failed\n"; + } +} + +function verifyMoneyValues($conn, $numDigits, $query, $values, $override) +{ + if ($override) { + $options = array('DecimalPlaces' => $numDigits); + $stmt = sqlsrv_query($conn, $query, array(), $options); + } else { + $stmt = sqlsrv_query($conn, $query); + } + + $results = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_NUMERIC); + trace("\nverifyMoneyValues:\n"); + for ($i = 0; $i < count($values); $i++) { + $value = numberFormat($values[$i], $numDigits); + trace("$results[$i], $value\n"); + + if ($value !== $results[$i]) { + echo "verifyMoneyValues ($override, $numDigits): Expected $value but got $results[$i]\n"; + } + } +} + +function verifyMoneyFormatting($conn, $query, $values, $format) +{ + if ($format) { + // Set FormatDecimals to true to turn on formatting, but setting + // DecimalPlaces to a negative number, which will be ignored. + $nDigits = -1; + $options = array('FormatDecimals' => true, 'DecimalPlaces' => $nDigits); + $stmt = sqlsrv_query($conn, $query, array(), $options); + } else { + // Set FormatDecimals to false to turn off formatting. + // This should override the inherited connection + // options, and by default, money and smallmoney types + // have scale of 4 digits + $options = array('FormatDecimals' => false); + $stmt = sqlsrv_query($conn, $query, array(), $options); + } + $results = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_NUMERIC); + + for ($i = 0; $i < count($values); $i++) { + $default = numberFormat($values[$i], 4); + if (!$format) { + // No formatting - should drop the leading zero, if exists + if (abs($values[$i]) < 1) { + $default = str_replace('0.', '.', $default); + } + } + if ($default !== $results[$i]) { + echo "verifyMoneyFormatting ($format): Expected default $default but got $results[$i]\n"; + } + } +} + +function getOutputParam($conn, $spProcName, $input, $money, $inout) +{ + $outString = '0.0'; + $dir = ($inout) ? SQLSRV_PARAM_INOUT : SQLSRV_PARAM_OUT; + $sqlType = ($money) ? SQLSRV_SQLTYPE_MONEY : SQLSRV_SQLTYPE_SMALLMONEY; + + $outSql = AE\getCallProcSqlPlaceholders($spProcName, 1); + $stmt = sqlsrv_prepare($conn, $outSql, + array(array(&$outString, $dir, null, $sqlType))); + if (!$stmt) { + fatalError("getOutputParam: failed when preparing to call $spProcName"); + } + if (!sqlsrv_execute($stmt)) { + fatalError("getOutputParam: failed to execute procedure $spProcName"); + } + + // FormatDecimals only add leading zeroes, but do + // not support controlling decimal places, so + // use scale 4 for money/smallmoney types + $expected = numberFormat($input, 4); + trace("getOutputParam result is $outString and expected $expected\n"); + + if ($outString !== $expected) { + echo "getOutputParam ($inout): Expected $expected but got $outString\n"; + var_dump($expected); + var_dump($outString); + } +} + +function testOutputParam($conn) +{ + // Create a table for testing output param + $tableName = 'sqlsrvMoneyFormats'; + $values = array(0.12345, 0.34567); + $query = "SELECT CONVERT(smallmoney, $values[0]) AS m1, + CONVERT(money, $values[1]) AS m2 + INTO $tableName"; + + $stmt = sqlsrv_query($conn, $query); + for ($i = 0; $i < 2; $i++) { + // Create the stored procedure first + $storedProcName = "spMoneyFormats" . $i; + $dataType = ($i == 0) ? 'smallmoney' : 'money'; + $procArgs = "@col $dataType OUTPUT"; + $column = 'm' . ($i + 1); + $procCode = "SELECT @col = $column FROM $tableName"; + createProc($conn, $storedProcName, $procArgs, $procCode); + + getOutputParam($conn, $storedProcName, $values[$i], $i, false); + getOutputParam($conn, $storedProcName, $values[$i], $i, true); + + dropProc($conn, $storedProcName); + } + + dropTable($conn, $tableName); +} + +function testMoneyTypes($conn) +{ + global $numDigits; // inherited from connection option + + // With money and smallmoney types, which are essentially decimal types + // As of today, ODBC driver does not support Always Encrypted feature with money / smallmoney + $values = array(); + $nColumns = 6; + for ($i = 0; $i < $nColumns; $i++) { + // First get a random number + $n = rand(0, 10); + $neg = ($n % 2 == 0) ? -1 : 1; + + // $n1 may or may not be negative + $max = 10; + $n1 = rand(0, $max) * $neg; + $n2 = rand(1, $max * 1000); + + $number = sprintf("%d.%d", $n1, $n2); + array_push($values, $number); + } + + $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])"; + + // Do not override the connection attributes + verifyMoneyValues($conn, $numDigits, $query, $values, false); + // Next, override statement attribute to set number of + // decimal places to 0 + verifyMoneyValues($conn, 0, $query, $values, true); + + // Set Formatting attribute to true then false + verifyMoneyFormatting($conn, $query, $values, true); + verifyMoneyFormatting($conn, $query, $values, false); +} + +set_time_limit(0); +sqlsrv_configure('WarningsReturnAsErrors', 1); + +$numDigits = 2; + +$conn = AE\connect(array('FormatDecimals' => true, 'DecimalPlaces' => $numDigits)); +if (!$conn) { + fatalError("Could not connect.\n"); +} + +// First to test if leading zero is added +testMoneyTypes($conn); + +// Also test using regular floats +testFloatTypes($conn); + +// Test output params +testOutputParam($conn); + +sqlsrv_close($conn); + +echo "Done\n"; +?> +--EXPECT-- +Done