Feature request 415 for sqlsrv (#861)
This commit is contained in:
parent
36fd97e69a
commit
18094a6cef
|
@ -1107,6 +1107,7 @@ enum SQLSRV_STMT_OPTIONS {
|
|||
SQLSRV_STMT_OPTION_SCROLLABLE,
|
||||
SQLSRV_STMT_OPTION_CLIENT_BUFFER_MAX_SIZE,
|
||||
SQLSRV_STMT_OPTION_DATE_AS_STRING,
|
||||
SQLSRV_STMT_OPTION_FORMAT_DECIMALS,
|
||||
|
||||
// Driver specific connection options
|
||||
SQLSRV_STMT_OPTION_DRIVER_SPECIFIC = 1000,
|
||||
|
@ -1296,6 +1297,11 @@ struct stmt_option_date_as_string : public stmt_option_functor {
|
|||
virtual void operator()( _Inout_ sqlsrv_stmt* stmt, stmt_option const* opt, _In_ zval* value_z TSRMLS_DC );
|
||||
};
|
||||
|
||||
struct stmt_option_format_decimals : public stmt_option_functor {
|
||||
|
||||
virtual void operator()( _Inout_ sqlsrv_stmt* stmt, stmt_option const* opt, _In_ zval* value_z TSRMLS_DC );
|
||||
};
|
||||
|
||||
// used to hold the table for statment options
|
||||
struct stmt_option {
|
||||
|
||||
|
@ -1334,39 +1340,6 @@ extern php_stream_wrapper g_sqlsrv_stream_wrapper;
|
|||
#define SQLSRV_STREAM_WRAPPER "sqlsrv"
|
||||
#define SQLSRV_STREAM "sqlsrv_stream"
|
||||
|
||||
// holds the output parameter information. Strings also need the encoding and other information for
|
||||
// after processing. Only integer, float, and strings are allowable output parameters.
|
||||
struct sqlsrv_output_param {
|
||||
|
||||
zval* param_z;
|
||||
SQLSRV_ENCODING encoding;
|
||||
SQLUSMALLINT param_num; // used to index into the ind_or_len of the statement
|
||||
SQLLEN original_buffer_len; // used to make sure the returned length didn't overflow the buffer
|
||||
SQLSRV_PHPTYPE php_out_type; // used to convert output param if necessary
|
||||
bool is_bool;
|
||||
|
||||
// string output param constructor
|
||||
sqlsrv_output_param( _In_ zval* p_z, _In_ SQLSRV_ENCODING enc, _In_ int num, _In_ SQLUINTEGER buffer_len ) :
|
||||
param_z(p_z), encoding(enc), param_num(num), original_buffer_len(buffer_len), is_bool(false), php_out_type(SQLSRV_PHPTYPE_INVALID)
|
||||
{
|
||||
}
|
||||
|
||||
// every other type output parameter constructor
|
||||
sqlsrv_output_param( _In_ zval* p_z, _In_ int num, _In_ bool is_bool, _In_ SQLSRV_PHPTYPE php_out_type) :
|
||||
param_z( p_z ),
|
||||
encoding( SQLSRV_ENCODING_INVALID ),
|
||||
param_num( num ),
|
||||
original_buffer_len( -1 ),
|
||||
is_bool( is_bool ),
|
||||
php_out_type(php_out_type)
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
// forward decls
|
||||
struct sqlsrv_result_set;
|
||||
struct field_meta_data;
|
||||
|
||||
// *** parameter metadata struct ***
|
||||
struct param_meta_data
|
||||
{
|
||||
|
@ -1389,6 +1362,59 @@ struct param_meta_data
|
|||
SQLULEN get_column_size() { return column_size; }
|
||||
};
|
||||
|
||||
// holds the output parameter information. Strings also need the encoding and other information for
|
||||
// after processing. Only integer, float, and strings are allowable output parameters.
|
||||
struct sqlsrv_output_param {
|
||||
|
||||
zval* param_z;
|
||||
SQLSRV_ENCODING encoding;
|
||||
SQLUSMALLINT param_num; // used to index into the ind_or_len of the statement
|
||||
SQLLEN original_buffer_len; // used to make sure the returned length didn't overflow the buffer
|
||||
SQLSRV_PHPTYPE php_out_type; // used to convert output param if necessary
|
||||
bool is_bool;
|
||||
param_meta_data meta_data; // parameter meta data
|
||||
|
||||
// string output param constructor
|
||||
sqlsrv_output_param( _In_ zval* p_z, _In_ SQLSRV_ENCODING enc, _In_ int num, _In_ SQLUINTEGER buffer_len ) :
|
||||
param_z(p_z), encoding(enc), param_num(num), original_buffer_len(buffer_len), is_bool(false), php_out_type(SQLSRV_PHPTYPE_INVALID)
|
||||
{
|
||||
}
|
||||
|
||||
// every other type output parameter constructor
|
||||
sqlsrv_output_param( _In_ zval* p_z, _In_ int num, _In_ bool is_bool, _In_ SQLSRV_PHPTYPE php_out_type) :
|
||||
param_z( p_z ),
|
||||
encoding( SQLSRV_ENCODING_INVALID ),
|
||||
param_num( num ),
|
||||
original_buffer_len( -1 ),
|
||||
is_bool( is_bool ),
|
||||
php_out_type(php_out_type)
|
||||
{
|
||||
}
|
||||
|
||||
void saveMetaData(SQLSMALLINT sql_type, SQLSMALLINT column_size, SQLSMALLINT decimal_digits, SQLSMALLINT nullable = SQL_NULLABLE)
|
||||
{
|
||||
meta_data.sql_type = sql_type;
|
||||
meta_data.column_size = column_size;
|
||||
meta_data.decimal_digits = decimal_digits;
|
||||
meta_data.nullable = nullable;
|
||||
}
|
||||
|
||||
SQLSMALLINT getDecimalDigits()
|
||||
{
|
||||
// Return decimal_digits only for decimal / numeric types. Otherwise, return -1
|
||||
if (meta_data.sql_type == SQL_DECIMAL || meta_data.sql_type == SQL_NUMERIC) {
|
||||
return meta_data.decimal_digits;
|
||||
}
|
||||
else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// forward decls
|
||||
struct sqlsrv_result_set;
|
||||
struct field_meta_data;
|
||||
|
||||
// *** Statement resource structure ***
|
||||
struct sqlsrv_stmt : public sqlsrv_context {
|
||||
|
||||
|
@ -1409,6 +1435,7 @@ struct sqlsrv_stmt : public sqlsrv_context {
|
|||
unsigned long query_timeout; // maximum allowed statement execution time
|
||||
zend_long buffered_query_limit; // maximum allowed memory for a buffered query (measured in KB)
|
||||
bool date_as_string; // false by default but the user can set this to true to retrieve datetime values as strings
|
||||
short num_decimals; // indicates number of decimals shown in fetched results (-1 by default, which means no formatting required)
|
||||
|
||||
// holds output pointers for SQLBindParameter
|
||||
// We use a deque because it 1) provides the at/[] access in constant time, and 2) grows dynamically without moving
|
||||
|
@ -1743,6 +1770,8 @@ enum SQLSRV_ERROR_CODES {
|
|||
SQLSRV_ERROR_DOUBLE_CONVERSION_FAILED,
|
||||
SQLSRV_ERROR_INVALID_OPTION_WITH_ACCESS_TOKEN,
|
||||
SQLSRV_ERROR_EMPTY_ACCESS_TOKEN,
|
||||
SQLSRV_ERROR_INVALID_FORMAT_DECIMALS,
|
||||
SQLSRV_ERROR_FORMAT_DECIMALS_OUT_OF_RANGE,
|
||||
|
||||
// Driver specific error codes starts from here.
|
||||
SQLSRV_ERROR_DRIVER_SPECIFIC = 1000,
|
||||
|
|
|
@ -107,6 +107,7 @@ void default_sql_type( _Inout_ sqlsrv_stmt* stmt, _In_opt_ SQLULEN paramno, _In_
|
|||
_Out_ SQLSMALLINT& sql_type TSRMLS_DC );
|
||||
void col_cache_dtor( _Inout_ zval* data_z );
|
||||
void field_cache_dtor( _Inout_ zval* data_z );
|
||||
void format_decimal_numbers(_In_ SQLSMALLINT decimals_digits, _In_ SQLSMALLINT field_scale, _Inout_updates_bytes_(*field_len) char*& field_value, _Inout_ SQLLEN* field_len);
|
||||
void finalize_output_parameters( _Inout_ sqlsrv_stmt* stmt TSRMLS_DC );
|
||||
void get_field_as_string( _Inout_ sqlsrv_stmt* stmt, _In_ SQLUSMALLINT field_index, _Inout_ sqlsrv_phptype sqlsrv_php_type,
|
||||
_Inout_updates_bytes_(*field_len) void*& field_value, _Inout_ SQLLEN* field_len TSRMLS_DC );
|
||||
|
@ -141,8 +142,9 @@ sqlsrv_stmt::sqlsrv_stmt( _In_ sqlsrv_conn* c, _In_ SQLHANDLE handle, _In_ error
|
|||
past_next_result_end( false ),
|
||||
query_timeout( QUERY_TIMEOUT_INVALID ),
|
||||
date_as_string(false),
|
||||
num_decimals(-1), // -1 means no formatting required
|
||||
buffered_query_limit( sqlsrv_buffered_result_set::BUFFERED_QUERY_LIMIT_INVALID ),
|
||||
param_ind_ptrs( 10 ), // initially hold 10 elements, which should cover 90% of the cases and only take < 100 byte
|
||||
param_ind_ptrs( 10 ), // initially hold 10 elements, which should cover 90% of the cases and only take < 100 byte
|
||||
send_streams_at_exec( true ),
|
||||
current_stream( NULL, SQLSRV_ENCODING_DEFAULT ),
|
||||
current_stream_read( 0 )
|
||||
|
@ -571,6 +573,8 @@ void core_sqlsrv_bind_param( _Inout_ sqlsrv_stmt* stmt, _In_ SQLUSMALLINT param_
|
|||
// save the parameter to be adjusted and/or converted after the results are processed
|
||||
sqlsrv_output_param output_param( param_ref, encoding, param_num, static_cast<SQLUINTEGER>( buffer_len ) );
|
||||
|
||||
output_param.saveMetaData(sql_type, column_size, decimal_digits);
|
||||
|
||||
save_output_param_for_later( stmt, output_param TSRMLS_CC );
|
||||
|
||||
// For output parameters, if we set the column_size to be same as the buffer_len,
|
||||
|
@ -1416,6 +1420,21 @@ void stmt_option_date_as_string:: operator()( _Inout_ sqlsrv_stmt* stmt, stmt_op
|
|||
}
|
||||
}
|
||||
|
||||
void stmt_option_format_decimals:: operator()( _Inout_ sqlsrv_stmt* stmt, stmt_option const* /**/, _In_ zval* value_z TSRMLS_DC )
|
||||
{
|
||||
// first check if the input is an integer
|
||||
CHECK_CUSTOM_ERROR(Z_TYPE_P(value_z) != IS_LONG, stmt, SQLSRV_ERROR_INVALID_FORMAT_DECIMALS) {
|
||||
throw core::CoreException();
|
||||
}
|
||||
|
||||
zend_long format_decimals = Z_LVAL_P(value_z);
|
||||
CHECK_CUSTOM_ERROR(format_decimals < 0 || format_decimals > SQL_SERVER_MAX_PRECISION, stmt, SQLSRV_ERROR_FORMAT_DECIMALS_OUT_OF_RANGE, format_decimals) {
|
||||
throw core::CoreException();
|
||||
}
|
||||
|
||||
stmt->num_decimals = static_cast<short>(format_decimals);
|
||||
}
|
||||
|
||||
// internal function to release the active stream. Called by each main API function
|
||||
// that will alter the statement and cancel any retrieval of data from a stream.
|
||||
void close_active_stream( _Inout_ sqlsrv_stmt* stmt TSRMLS_DC )
|
||||
|
@ -2079,6 +2098,130 @@ void field_cache_dtor( _Inout_ zval* data_z )
|
|||
sqlsrv_free( cache );
|
||||
}
|
||||
|
||||
// To be called for formatting decimal / numeric fetched values from finalize_output_parameters() and/or get_field_as_string()
|
||||
void format_decimal_numbers(_In_ SQLSMALLINT decimals_digits, _In_ SQLSMALLINT field_scale, _Inout_updates_bytes_(*field_len) char*& field_value, _Inout_ SQLLEN* field_len)
|
||||
{
|
||||
// In SQL Server, the default maximum precision of numeric and decimal data types is 38
|
||||
//
|
||||
// Note: stmt->num_decimals is -1 by default, which means no formatting on decimals / numerics is necessary
|
||||
// If the required number of decimals is larger than the field scale, will use the column field scale instead.
|
||||
// This is to ensure the number of decimals adheres to the column field scale. If smaller, the output value may be rounded up.
|
||||
//
|
||||
// Note: it's possible that the decimal / numeric value does not contain a decimal dot because the field scale is 0.
|
||||
// Thus, first check if the decimal dot exists. If not, no formatting necessary, regardless of decimals_digits
|
||||
//
|
||||
std::string str = field_value;
|
||||
size_t pos = str.find_first_of('.');
|
||||
|
||||
if (pos == std::string::npos || decimals_digits < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
SQLSMALLINT num_decimals = decimals_digits;
|
||||
if (num_decimals > field_scale) {
|
||||
num_decimals = field_scale;
|
||||
}
|
||||
|
||||
// We want the rounding to be consistent with php number_format(), http://php.net/manual/en/function.number-format.php
|
||||
// as well as SQL Server Management studio, such that the least significant digit will be rounded up if it is
|
||||
// followed by 5 or above.
|
||||
|
||||
bool isNegative = false;
|
||||
|
||||
// If negative, remove the minus sign for now so as not to complicate the rounding process
|
||||
if (str[0] == '-') {
|
||||
isNegative = true;
|
||||
std::ostringstream oss;
|
||||
oss << str.substr(1);
|
||||
str = oss.str();
|
||||
pos = str.find_first_of('.');
|
||||
}
|
||||
|
||||
// Adds the leading zero if not exists
|
||||
if (pos == 0) {
|
||||
std::ostringstream oss;
|
||||
oss << '0' << str;
|
||||
str = oss.str();
|
||||
pos++;
|
||||
}
|
||||
|
||||
size_t last = 0;
|
||||
if (num_decimals == 0) {
|
||||
// Chop all decimal digits, including the decimal dot
|
||||
size_t pos2 = pos + 1;
|
||||
short n = str[pos2] - '0';
|
||||
if (n >= 5) {
|
||||
// Start rounding up - starting from the digit left of the dot all the way to the first digit
|
||||
bool carry_over = true;
|
||||
for (short p = pos - 1; p >= 0 && carry_over; p--) {
|
||||
n = str[p] - '0';
|
||||
if (n == 9) {
|
||||
str[p] = '0' ;
|
||||
carry_over = true;
|
||||
}
|
||||
else {
|
||||
n++;
|
||||
carry_over = false;
|
||||
str[p] = '0' + n;
|
||||
}
|
||||
}
|
||||
if (carry_over) {
|
||||
std::ostringstream oss;
|
||||
oss << '1' << str.substr(0, pos);
|
||||
str = oss.str();
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
last = pos;
|
||||
}
|
||||
else {
|
||||
size_t pos2 = pos + num_decimals + 1;
|
||||
// No need to check if rounding is necessary when pos2 has passed the last digit in the input string
|
||||
if (pos2 < str.length()) {
|
||||
short n = str[pos2] - '0';
|
||||
if (n >= 5) {
|
||||
// Start rounding up - starting from the digit left of pos2 all the way to the first digit
|
||||
bool carry_over = true;
|
||||
for (short p = pos2 - 1; p >= 0 && carry_over; p--) {
|
||||
if (str[p] == '.') { // Skip the dot
|
||||
continue;
|
||||
}
|
||||
n = str[p] - '0';
|
||||
if (n == 9) {
|
||||
str[p] = '0' ;
|
||||
carry_over = true;
|
||||
}
|
||||
else {
|
||||
n++;
|
||||
carry_over = false;
|
||||
str[p] = '0' + n;
|
||||
}
|
||||
}
|
||||
if (carry_over) {
|
||||
std::ostringstream oss;
|
||||
oss << '1' << str.substr(0, pos2);
|
||||
str = oss.str();
|
||||
pos2++;
|
||||
}
|
||||
}
|
||||
}
|
||||
last = pos2;
|
||||
}
|
||||
|
||||
// Add the minus sign back if negative
|
||||
if (isNegative) {
|
||||
std::ostringstream oss;
|
||||
oss << '-' << str.substr(0, last);
|
||||
str = oss.str();
|
||||
} else {
|
||||
str = str.substr(0, last);
|
||||
}
|
||||
|
||||
size_t len = str.length();
|
||||
str.copy(field_value, len);
|
||||
field_value[len] = '\0';
|
||||
*field_len = len;
|
||||
}
|
||||
|
||||
// To be called after all results are processed. ODBC and SQL Server do not guarantee that all output
|
||||
// parameters will be present until all results are processed (since output parameters can depend on results
|
||||
|
@ -2160,6 +2303,11 @@ void finalize_output_parameters( _Inout_ sqlsrv_stmt* stmt TSRMLS_DC )
|
|||
core::sqlsrv_zval_stringl(value_z, str, str_len);
|
||||
}
|
||||
else {
|
||||
SQLSMALLINT decimal_digits = output_param->getDecimalDigits();
|
||||
if (stmt->num_decimals >= 0 && decimal_digits >= 0) {
|
||||
format_decimal_numbers(stmt->num_decimals, decimal_digits, str, &str_len);
|
||||
}
|
||||
|
||||
core::sqlsrv_zval_stringl(value_z, str, str_len);
|
||||
}
|
||||
}
|
||||
|
@ -2214,7 +2362,7 @@ void get_field_as_string( _Inout_ sqlsrv_stmt* stmt, _In_ SQLUSMALLINT field_ind
|
|||
{
|
||||
SQLRETURN r;
|
||||
SQLSMALLINT c_type;
|
||||
SQLLEN sql_field_type = 0;
|
||||
SQLSMALLINT sql_field_type = 0;
|
||||
SQLSMALLINT extra = 0;
|
||||
SQLLEN field_len_temp = 0;
|
||||
SQLLEN sql_display_size = 0;
|
||||
|
@ -2425,6 +2573,10 @@ void get_field_as_string( _Inout_ sqlsrv_stmt* stmt, _In_ SQLUSMALLINT field_ind
|
|||
throw core::CoreException();
|
||||
}
|
||||
}
|
||||
|
||||
if (stmt->num_decimals >= 0 && (sql_field_type == SQL_DECIMAL || sql_field_type == SQL_NUMERIC)) {
|
||||
format_decimal_numbers(stmt->num_decimals, stmt->current_meta_data[field_index]->field_scale, field_value_temp, &field_len_temp);
|
||||
}
|
||||
} // else if( sql_display_size >= 1 && sql_display_size <= SQL_SERVER_MAX_FIELD_SIZE )
|
||||
|
||||
else {
|
||||
|
|
|
@ -174,6 +174,7 @@ namespace SSStmtOptionNames {
|
|||
const char SCROLLABLE[] = "Scrollable";
|
||||
const char CLIENT_BUFFER_MAX_SIZE[] = INI_BUFFERED_QUERY_LIMIT;
|
||||
const char DATE_AS_STRING[] = "ReturnDatesAsStrings";
|
||||
const char FORMAT_DECIMALS[] = "FormatDecimals";
|
||||
}
|
||||
|
||||
namespace SSConnOptionNames {
|
||||
|
@ -250,6 +251,12 @@ const stmt_option SS_STMT_OPTS[] = {
|
|||
SQLSRV_STMT_OPTION_DATE_AS_STRING,
|
||||
std::unique_ptr<stmt_option_date_as_string>( new stmt_option_date_as_string )
|
||||
},
|
||||
{
|
||||
SSStmtOptionNames::FORMAT_DECIMALS,
|
||||
sizeof( SSStmtOptionNames::FORMAT_DECIMALS ),
|
||||
SQLSRV_STMT_OPTION_FORMAT_DECIMALS,
|
||||
std::unique_ptr<stmt_option_format_decimals>( new stmt_option_format_decimals )
|
||||
},
|
||||
{ NULL, 0, SQLSRV_STMT_OPTION_INVALID, std::unique_ptr<stmt_option_functor>{} },
|
||||
};
|
||||
|
||||
|
|
|
@ -428,6 +428,14 @@ ss_error SS_ERRORS[] = {
|
|||
SQLSRV_ERROR_EMPTY_ACCESS_TOKEN,
|
||||
{ IMSSP, (SQLCHAR*) "The Azure AD Access Token is empty. Expected a byte string.", -116, false}
|
||||
},
|
||||
{
|
||||
SQLSRV_ERROR_INVALID_FORMAT_DECIMALS,
|
||||
{ IMSSP, (SQLCHAR*) "Expected an integer to specify number of decimals to format the output values of decimal data types.", -117, false}
|
||||
},
|
||||
{
|
||||
SQLSRV_ERROR_FORMAT_DECIMALS_OUT_OF_RANGE,
|
||||
{ IMSSP, (SQLCHAR*) "For formatting decimal data values, %1!d! is out of range. Expected an integer from 0 to 38, inclusive.", -118, true}
|
||||
},
|
||||
|
||||
// terminate the list of errors/warnings
|
||||
{ UINT_MAX, {} }
|
||||
|
|
370
test/functional/sqlsrv/sqlsrv_statement_format_decimals.phpt
Normal file
370
test/functional/sqlsrv/sqlsrv_statement_format_decimals.phpt
Normal file
|
@ -0,0 +1,370 @@
|
|||
--TEST--
|
||||
Test how decimal data output values can be formatted (feature request issue 415)
|
||||
--DESCRIPTION--
|
||||
Test how numeric and decimal data output values can be formatted by using the
|
||||
statement option FormatDecimals, which expects an integer value from the range [0,38],
|
||||
affecting only the money / decimal types in the fetched result set because they are
|
||||
always strings to preserve accuracy and precision, unlike other primitive numeric
|
||||
types that can be retrieved as numbers.
|
||||
|
||||
No effect on other operations like insertion or update.
|
||||
|
||||
1. By default, data will be returned with the original precision and scale
|
||||
2. The data column original scale still takes precedence – for example, if the user
|
||||
specifies 3 decimal digits for a column of decimal(5,2), the result still shows only 2
|
||||
decimals to the right of the dot
|
||||
3. After formatting, the missing leading zeroes will be padded
|
||||
4. The underlying data will not be altered, but formatted results may likely be rounded
|
||||
up (e.g. .2954 will be displayed as 0.30 if the user wants only two decimals)
|
||||
5. For output params use SQLSRV_SQLTYPE_DECIMAL with the correct precision and scale
|
||||
--ENV--
|
||||
PHPT_EXEC=true
|
||||
--SKIPIF--
|
||||
<?php require('skipif_versions_old.inc'); ?>
|
||||
--FILE--
|
||||
<?php
|
||||
require_once('MsCommon.inc');
|
||||
|
||||
function compareNumbers($actual, $input, $column, $fieldScale, $formatDecimal = -1)
|
||||
{
|
||||
$matched = false;
|
||||
if ($actual === $input) {
|
||||
$matched = true;
|
||||
} else {
|
||||
// When $formatDecimal is negative, that means no formatting done
|
||||
// Otherwise, if $formatDecimal > $fieldScale, will show $fieldScale decimal digits
|
||||
if ($formatDecimal >= 0) {
|
||||
$numDecimals = ($formatDecimal > $fieldScale) ? $fieldScale : $formatDecimal;
|
||||
} else {
|
||||
$numDecimals = $fieldScale;
|
||||
}
|
||||
$expected = number_format($input, $numDecimals);
|
||||
if ($actual === $expected) {
|
||||
$matched = true;
|
||||
} else {
|
||||
echo "For $column: expected $expected but the value is $actual\n";
|
||||
}
|
||||
}
|
||||
return $matched;
|
||||
}
|
||||
|
||||
function testErrorCases($conn)
|
||||
{
|
||||
$query = "SELECT 0.0001";
|
||||
|
||||
$options = array('FormatDecimals' => 1.5);
|
||||
$stmt = sqlsrv_query($conn, $query, array(), $options);
|
||||
if ($stmt) {
|
||||
fatalError("Case 1: expected query to fail!!");
|
||||
} else {
|
||||
$error = sqlsrv_errors()[0]['message'];
|
||||
$message = 'Expected an integer to specify number of decimals to format the output values of decimal data types.';
|
||||
|
||||
if (strpos($error, $message) === false) {
|
||||
print_r(sqlsrv_errors());
|
||||
}
|
||||
}
|
||||
|
||||
$options = array('FormatDecimals' => -1);
|
||||
$stmt = sqlsrv_query($conn, $query, array(), $options);
|
||||
if ($stmt) {
|
||||
fatalError("Case 2: expected query to fail!!");
|
||||
} else {
|
||||
$error = sqlsrv_errors()[0]['message'];
|
||||
$message = 'For formatting decimal data values, -1 is out of range. Expected an integer from 0 to 38, inclusive.';
|
||||
|
||||
if (strpos($error, $message) === false) {
|
||||
print_r(sqlsrv_errors());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function testFloatTypes($conn)
|
||||
{
|
||||
// This test with the float types of various number of bits, which are retrieved
|
||||
// as numbers by default. When fetched as strings, no formatting is done even with
|
||||
// the statement option FormatDecimals set
|
||||
$values = array('2.9978', '-0.2982', '33.2434', '329.690734', '110.913498');
|
||||
$epsilon = 0.001;
|
||||
|
||||
$query = "SELECT CONVERT(float(1), $values[0]),
|
||||
CONVERT(float(12), $values[1]),
|
||||
CONVERT(float(24), $values[2]),
|
||||
CONVERT(float(36), $values[3]),
|
||||
CONVERT(float(53), $values[4])";
|
||||
|
||||
$stmt = sqlsrv_query($conn, $query);
|
||||
$floats = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_NUMERIC);
|
||||
if (!$floats) {
|
||||
echo "testFloatTypes: sqlsrv_fetch_array failed\n";
|
||||
}
|
||||
|
||||
// Set FormatDecimals to 2, but the number of decimals in each of the results
|
||||
// will vary -- FormatDecimals has no effect
|
||||
$numDigits = 2;
|
||||
$options = array('FormatDecimals' => $numDigits);
|
||||
$stmt = sqlsrv_query($conn, $query, array(), $options);
|
||||
if (sqlsrv_fetch($stmt)) {
|
||||
for ($i = 0; $i < count($values); $i++) {
|
||||
$floatStr = sqlsrv_get_field($stmt, $i, SQLSRV_PHPTYPE_STRING(SQLSRV_ENC_CHAR));
|
||||
$numbers = explode('.', $floatStr);
|
||||
$len = strlen($numbers[1]);
|
||||
if ($len == $numDigits) {
|
||||
// This is highly unlikely
|
||||
var_dump($floatStr);
|
||||
}
|
||||
$floatVal = floatval($floatStr);
|
||||
$diff = abs($floatVal - $floats[$i]) / $floats[$i];
|
||||
if ($diff > $epsilon) {
|
||||
var_dump($diff);
|
||||
var_dump($floatVal);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo "testFloatTypes: sqlsrv_fetch failed\n";
|
||||
}
|
||||
}
|
||||
|
||||
function testMoneyTypes($conn)
|
||||
{
|
||||
// With money and smallmoney types, which are essentially decimal types
|
||||
// ODBC driver does not support Always Encrypted feature with money / smallmoney
|
||||
$values = array('1.9954', '0', '-0.5', '0.2954', '9.6789', '99.991');
|
||||
$defaults = array('1.9954', '.0000', '-.5000', '.2954', '9.6789', '99.9910');
|
||||
|
||||
$query = "SELECT CONVERT(smallmoney, $values[0]),
|
||||
CONVERT(money, $values[1]),
|
||||
CONVERT(smallmoney, $values[2]),
|
||||
CONVERT(money, $values[3]),
|
||||
CONVERT(smallmoney, $values[4]),
|
||||
CONVERT(money, $values[5])";
|
||||
|
||||
$stmt = sqlsrv_query($conn, $query);
|
||||
$results = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_NUMERIC);
|
||||
for ($i = 0; $i < count($values); $i++) {
|
||||
if ($defaults[$i] !== $results[$i]) {
|
||||
echo "testMoneyTypes: Expected default $defaults[$i] but got $results[$i]\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Set FormatDecimals to 0 decimal digits
|
||||
$numDigits = 0;
|
||||
$options = array('FormatDecimals' => $numDigits);
|
||||
$stmt = sqlsrv_query($conn, $query, array(), $options);
|
||||
$results = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_NUMERIC);
|
||||
for ($i = 0; $i < count($values); $i++) {
|
||||
$value = number_format($values[$i], $numDigits);
|
||||
if ($value !== $results[$i]) {
|
||||
echo "testMoneyTypes: Expected $value but got $results[$i]\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Set FormatDecimals to 2 decimal digits
|
||||
$numDigits = 2;
|
||||
$options = array('FormatDecimals' => $numDigits);
|
||||
$stmt = sqlsrv_query($conn, $query, array(), $options);
|
||||
$results = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_NUMERIC);
|
||||
for ($i = 0; $i < count($values); $i++) {
|
||||
$value = number_format($values[$i], $numDigits);
|
||||
if ($value !== $results[$i]) {
|
||||
echo "testMoneyTypes: Expected $value but got $results[$i]\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function testNoOption($conn, $tableName, $inputs, $columns, $exec)
|
||||
{
|
||||
// Without the statement option, should return decimal values as they are
|
||||
$query = "SELECT * FROM $tableName";
|
||||
if ($exec) {
|
||||
$stmt = sqlsrv_query($conn, $query);
|
||||
} else {
|
||||
$stmt = sqlsrv_prepare($conn, $query);
|
||||
sqlsrv_execute($stmt);
|
||||
}
|
||||
|
||||
// Compare values
|
||||
$results = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_NUMERIC);
|
||||
for ($i = 0; $i < count($inputs); $i++) {
|
||||
compareNumbers($results[$i], $inputs[$i], $columns[$i], $i);
|
||||
}
|
||||
}
|
||||
|
||||
function testStmtOption($conn, $tableName, $inputs, $columns, $formatDecimal, $withBuffer)
|
||||
{
|
||||
// Decimal values should return decimal digits based on the valid statement
|
||||
// option FormatDecimals
|
||||
$query = "SELECT * FROM $tableName";
|
||||
if ($withBuffer){
|
||||
$options = array('Scrollable' => 'buffered', 'FormatDecimals' => $formatDecimal);
|
||||
} else {
|
||||
$options = array('FormatDecimals' => $formatDecimal);
|
||||
}
|
||||
|
||||
$size = count($inputs);
|
||||
$stmt = sqlsrv_prepare($conn, $query, array(), $options);
|
||||
|
||||
// Fetch by getting one field at a time
|
||||
sqlsrv_execute($stmt);
|
||||
|
||||
if (sqlsrv_fetch($stmt) === false) {
|
||||
fatalError("Failed in retrieving data\n");
|
||||
}
|
||||
for ($i = 0; $i < $size; $i++) {
|
||||
$field = sqlsrv_get_field($stmt, $i); // Expect a string
|
||||
compareNumbers($field, $inputs[$i], $columns[$i], $i, $formatDecimal);
|
||||
}
|
||||
}
|
||||
|
||||
function getOutputParam($conn, $storedProcName, $inputValue, $prec, $scale)
|
||||
{
|
||||
$outString = '';
|
||||
$numDigits = 2;
|
||||
|
||||
// Derive the sqlsrv type SQLSRV_SQLTYPE_DECIMAL($prec, $scale)
|
||||
$sqlType = call_user_func('SQLSRV_SQLTYPE_DECIMAL', $prec, $scale);
|
||||
|
||||
$outSql = AE\getCallProcSqlPlaceholders($storedProcName, 1);
|
||||
$stmt = sqlsrv_prepare($conn, $outSql,
|
||||
array(array(&$outString, SQLSRV_PARAM_OUT, null, $sqlType)),
|
||||
array('FormatDecimals' => $numDigits));
|
||||
if (!$stmt) {
|
||||
fatalError("getOutputParam: failed when preparing to call $storedProcName");
|
||||
}
|
||||
if (!sqlsrv_execute($stmt)) {
|
||||
fatalError("getOutputParam: failed to execute procedure $storedProcName");
|
||||
}
|
||||
|
||||
// The output param should have been formatted based on $numDigits, if less
|
||||
// than $scale
|
||||
$column = 'outputParam';
|
||||
compareNumbers($outString, $inputValue, $column, $scale, $numDigits);
|
||||
sqlsrv_free_stmt($stmt);
|
||||
|
||||
if (!AE\isColEncrypted()) {
|
||||
// Get output param without specifying sqlsrv type, and the returned value will
|
||||
// be a regular string -- its value should be the same as the input value,
|
||||
// unaffected by the statement option FormatDecimals
|
||||
// With ColumnEncryption enabled, the driver is able to derive the decimal type,
|
||||
// so skip this part of the test
|
||||
$outString2 = '';
|
||||
$stmt = sqlsrv_prepare($conn, $outSql,
|
||||
array(array(&$outString2, SQLSRV_PARAM_OUT)),
|
||||
array('FormatDecimals' => $numDigits));
|
||||
if (!$stmt) {
|
||||
fatalError("getOutputParam2: failed when preparing to call $storedProcName");
|
||||
}
|
||||
if (!sqlsrv_execute($stmt)) {
|
||||
fatalError("getOutputParam2: failed to execute procedure $storedProcName");
|
||||
}
|
||||
|
||||
$column = 'outputParam2';
|
||||
compareNumbers($outString2, $inputValue, $column, $scale);
|
||||
sqlsrv_free_stmt($stmt);
|
||||
}
|
||||
}
|
||||
|
||||
function testOutputParam($conn, $tableName, $inputs, $columns, $dataTypes)
|
||||
{
|
||||
for ($i = 0, $p = 3; $i < count($columns); $i++, $p++) {
|
||||
// Create the stored procedure first
|
||||
$storedProcName = "spFormatDecimals" . $i;
|
||||
$procArgs = "@col $dataTypes[$i] OUTPUT";
|
||||
$procCode = "SELECT @col = $columns[$i] FROM $tableName";
|
||||
createProc($conn, $storedProcName, $procArgs, $procCode);
|
||||
|
||||
// Call stored procedure to retrieve output param
|
||||
getOutputParam($conn, $storedProcName, $inputs[$i], $p, $i);
|
||||
|
||||
dropProc($conn, $storedProcName);
|
||||
}
|
||||
}
|
||||
|
||||
set_time_limit(0);
|
||||
sqlsrv_configure('WarningsReturnAsErrors', 1);
|
||||
|
||||
$conn = AE\connect();
|
||||
if (!$conn) {
|
||||
fatalError("Could not connect.\n");
|
||||
}
|
||||
|
||||
// First to test if leading zero is added
|
||||
testMoneyTypes($conn);
|
||||
|
||||
// Then test error conditions
|
||||
testErrorCases($conn);
|
||||
|
||||
// Also test using regular floats
|
||||
testFloatTypes($conn);
|
||||
|
||||
// Create the test table of decimal / numeric data columns
|
||||
$tableName = 'sqlsrvFormatDecimals';
|
||||
|
||||
$columns = array('c1', 'c2', 'c3', 'c4', 'c5', 'c6');
|
||||
$dataTypes = array('decimal(3,0)', 'decimal(4,1)', 'decimal(5,2)', 'numeric(6,3)', 'numeric(7,4)', 'numeric(8, 5)');
|
||||
|
||||
$colMeta = array(new AE\ColumnMeta($dataTypes[0], $columns[0]),
|
||||
new AE\ColumnMeta($dataTypes[1], $columns[1]),
|
||||
new AE\ColumnMeta($dataTypes[2], $columns[2]),
|
||||
new AE\ColumnMeta($dataTypes[3], $columns[3]),
|
||||
new AE\ColumnMeta($dataTypes[4], $columns[4]),
|
||||
new AE\ColumnMeta($dataTypes[5], $columns[5]));
|
||||
AE\createTable($conn, $tableName, $colMeta);
|
||||
|
||||
// Generate random input values based on precision and scale
|
||||
$values = array();
|
||||
$max2 = 1;
|
||||
for ($s = 0, $p = 3; $s < count($columns); $s++, $p++) {
|
||||
// First get a random number
|
||||
$n = rand(0, 10);
|
||||
$neg = ($n % 2 == 0) ? -1 : 1;
|
||||
|
||||
// $n1 may or may not be negative
|
||||
$max1 = 1000;
|
||||
$n1 = rand(0, $max1) * $neg;
|
||||
|
||||
if ($s > 0) {
|
||||
$max2 *= 10;
|
||||
$n2 = rand(0, $max2);
|
||||
$number = sprintf("%d.%d", $n1, $n2);
|
||||
} else {
|
||||
$number = sprintf("%d", $n1);
|
||||
}
|
||||
|
||||
array_push($values, $number);
|
||||
}
|
||||
|
||||
// Insert data values as strings
|
||||
$inputData = array($colMeta[0]->colName => $values[0],
|
||||
$colMeta[1]->colName => $values[1],
|
||||
$colMeta[2]->colName => $values[2],
|
||||
$colMeta[3]->colName => $values[3],
|
||||
$colMeta[4]->colName => $values[4],
|
||||
$colMeta[5]->colName => $values[5]);
|
||||
$stmt = AE\insertRow($conn, $tableName, $inputData);
|
||||
if (!$stmt) {
|
||||
var_dump($values);
|
||||
fatalError("Failed to insert data.\n");
|
||||
}
|
||||
sqlsrv_free_stmt($stmt);
|
||||
|
||||
testNoOption($conn, $tableName, $values, $columns, true);
|
||||
testNoOption($conn, $tableName, $values, $columns, false);
|
||||
|
||||
// Now try with setting number decimals to 3 then 2
|
||||
testStmtOption($conn, $tableName, $values, $columns, 3, false);
|
||||
testStmtOption($conn, $tableName, $values, $columns, 3, true);
|
||||
|
||||
testStmtOption($conn, $tableName, $values, $columns, 2, false);
|
||||
testStmtOption($conn, $tableName, $values, $columns, 2, true);
|
||||
|
||||
// Test output parameters
|
||||
testOutputParam($conn, $tableName, $values, $columns, $dataTypes);
|
||||
|
||||
dropTable($conn, $tableName);
|
||||
sqlsrv_close($conn);
|
||||
|
||||
echo "Done\n";
|
||||
?>
|
||||
--EXPECT--
|
||||
Done
|
|
@ -0,0 +1,255 @@
|
|||
--TEST--
|
||||
Test various precisions of formatting decimal data output values (feature request issue 415)
|
||||
--DESCRIPTION--
|
||||
In SQL Server, the maximum allowed precision is 38. The scale can range from 0 up to the
|
||||
defined precision. Generate a long numeric string and get rid of the last digit to make it a
|
||||
39-digit-string. Then replace one digit at a time with a dot '.' to make it a decimal
|
||||
input string for testing with various scales.
|
||||
For example,
|
||||
string(39) ".23456789012345678901234567890123456789"
|
||||
string(39) "1.3456789012345678901234567890123456789"
|
||||
string(39) "12.456789012345678901234567890123456789"
|
||||
string(39) "123.56789012345678901234567890123456789"
|
||||
string(39) "1234.6789012345678901234567890123456789"
|
||||
string(39) "12345.789012345678901234567890123456789"
|
||||
... ...
|
||||
string(39) "1234567890123456789012345678901234.6789"
|
||||
string(39) "12345678901234567890123456789012345.789"
|
||||
string(39) "123456789012345678901234567890123456.89"
|
||||
string(39) "1234567890123456789012345678901234567.9"
|
||||
string(38) "12345678901234567890123456789012345678"
|
||||
|
||||
Note: PHP number_format() will not be used for verification in this test
|
||||
because the function starts losing accuracy with large number of precisions / scales.
|
||||
--ENV--
|
||||
PHPT_EXEC=true
|
||||
--SKIPIF--
|
||||
<?php require('skipif_versions_old.inc'); ?>
|
||||
--FILE--
|
||||
<?php
|
||||
require_once('MsCommon.inc');
|
||||
|
||||
$prec = 38;
|
||||
$dot = '.';
|
||||
|
||||
function createTestTable($conn)
|
||||
{
|
||||
global $prec;
|
||||
|
||||
// Create the test table of one decimal column
|
||||
$tableName = "sqlsrvFormatDecimalScales";
|
||||
$colMeta = array();
|
||||
|
||||
$max = $prec + 1;
|
||||
for ($i = 0; $i < $max; $i++) {
|
||||
$scale = $prec - $i;
|
||||
|
||||
$column = "col_$scale";
|
||||
$dataType = "decimal($prec, $scale)";
|
||||
|
||||
array_push($colMeta, new AE\ColumnMeta($dataType, $column));
|
||||
}
|
||||
AE\createTable($conn, $tableName, $colMeta);
|
||||
|
||||
return $tableName;
|
||||
}
|
||||
|
||||
function insertTestData($conn, $tableName)
|
||||
{
|
||||
global $prec, $dot;
|
||||
|
||||
$temp = str_repeat('1234567890', 4);
|
||||
$digits = substr($temp, 0, $prec + 1);
|
||||
|
||||
$inputData = array();
|
||||
$max = $prec + 1;
|
||||
|
||||
// Generate input strings - replace the $i-th digit with a dot '.'
|
||||
for ($i = 0; $i < $max; $i++) {
|
||||
$d = $digits[$i];
|
||||
$digits[$i] = $dot;
|
||||
|
||||
if ($i == $prec) {
|
||||
$digits = substr($temp, 0, $prec);
|
||||
}
|
||||
|
||||
$scale = $prec - $i;
|
||||
|
||||
$column = "col_$scale";
|
||||
$inputData = array_merge($inputData, array($column => $digits));
|
||||
|
||||
// Restore the $i-th digit with its original digit
|
||||
$digits[$i] = $d;
|
||||
}
|
||||
|
||||
$stmt = AE\insertRow($conn, $tableName, $inputData);
|
||||
if (!$stmt) {
|
||||
fatalError("Failed to insert data\n");
|
||||
}
|
||||
sqlsrv_free_stmt($stmt);
|
||||
|
||||
return $inputData;
|
||||
}
|
||||
|
||||
function verifyNoDecimals($value, $input, $round)
|
||||
{
|
||||
global $prec, $dot;
|
||||
|
||||
// Use PHP explode() to separate the input string into an array
|
||||
$parts = explode($dot, $input);
|
||||
$len = strlen($parts[0]);
|
||||
if ($len == 0) {
|
||||
// The original input string is missing a leading zero
|
||||
$parts[0] = '0';
|
||||
}
|
||||
|
||||
// No need to worry about carry over for the input data of this test
|
||||
// Check the first digit of $parts[1]
|
||||
if ($len < $prec) {
|
||||
// Only need to round up when $len < $prec
|
||||
$ch = $parts[1][0];
|
||||
|
||||
// Round the last digit of $parts[0] if $ch is '5' or above
|
||||
if ($ch >= '5') {
|
||||
$len = strlen($parts[0]);
|
||||
$parts[0][$len-1] = $parts[0][$len-1] + 1 + '0';
|
||||
}
|
||||
}
|
||||
|
||||
// No decimal digits left in the expected string
|
||||
$expected = $parts[0];
|
||||
if ($value !== $expected) {
|
||||
echo "Round $round scale 0: expected $expected but returned $value\n";
|
||||
}
|
||||
}
|
||||
|
||||
function verifyWithDecimals($value, $input, $round, $scale)
|
||||
{
|
||||
global $dot;
|
||||
|
||||
// Use PHP explode() to separate the input string into an array
|
||||
$parts = explode($dot, $input);
|
||||
if (strlen($parts[0]) == 0) {
|
||||
// The original input string is missing a leading zero
|
||||
$parts[0] = '0';
|
||||
}
|
||||
|
||||
// No need to worry about carry over for the input data of this test
|
||||
// Check the digit at the position $scale of $parts[1]
|
||||
$len = strlen($parts[1]);
|
||||
if ($scale < $len) {
|
||||
// Only need to round up when $scale < $len
|
||||
$ch = $parts[1][$scale];
|
||||
|
||||
// Round the previous digit if $ch is '5' or above
|
||||
if ($ch >= '5') {
|
||||
$parts[1][$scale-1] = $parts[1][$scale-1] + 1 + '0';
|
||||
}
|
||||
}
|
||||
|
||||
// Use substr() to get up to $scale
|
||||
$parts[1] = substr($parts[1], 0, $scale);
|
||||
|
||||
// Join the array elements together
|
||||
$expected = implode($dot, $parts);
|
||||
if ($value !== $expected) {
|
||||
echo "Round $round scale $scale: expected $expected but returned $value\n";
|
||||
}
|
||||
}
|
||||
|
||||
/****
|
||||
The function testVariousScales() will fetch one column at a time, using scale from
|
||||
0 up to the maximum scale allowed for that column type.
|
||||
|
||||
For example, for column of type decimal(38,4), the input string is
|
||||
1234567890123456789012345678901234.6789
|
||||
|
||||
When fetching data, using scale from 0 to 4, the following values are expected to return:
|
||||
1234567890123456789012345678901235
|
||||
1234567890123456789012345678901234.7
|
||||
1234567890123456789012345678901234.68
|
||||
1234567890123456789012345678901234.679
|
||||
1234567890123456789012345678901234.6789
|
||||
|
||||
For example, for column of type decimal(38,6), the input string is
|
||||
12345678901234567890123456789012.456789
|
||||
|
||||
When fetching data, using scale from 0 to 6, the following values are expected to return:
|
||||
12345678901234567890123456789012
|
||||
12345678901234567890123456789012.5
|
||||
12345678901234567890123456789012.46
|
||||
12345678901234567890123456789012.457
|
||||
12345678901234567890123456789012.4568
|
||||
12345678901234567890123456789012.45679
|
||||
12345678901234567890123456789012.456789
|
||||
|
||||
etc.
|
||||
****/
|
||||
function testVariousScales($conn, $tableName, $inputData)
|
||||
{
|
||||
global $prec;
|
||||
$max = $prec + 1;
|
||||
|
||||
for ($i = 0; $i < $max; $i++) {
|
||||
$scale = $prec - $i;
|
||||
$column = "col_$scale";
|
||||
|
||||
$query = "SELECT $column as col1 FROM $tableName";
|
||||
$input = $inputData[$column];
|
||||
|
||||
// Default case: the fetched value should be the same as the corresponding input
|
||||
$stmt = sqlsrv_query($conn, $query);
|
||||
if (!$stmt) {
|
||||
fatalError("In testVariousScales: failed in default case\n");
|
||||
}
|
||||
if ($obj = sqlsrv_fetch_object($stmt)) {
|
||||
trace("\n$obj->col1\n");
|
||||
if ($obj->col1 !== $input) {
|
||||
echo "default case: expected $input but returned $obj->col1\n";
|
||||
}
|
||||
} else {
|
||||
fatalError("In testVariousScales: sqlsrv_fetch_object failed\n");
|
||||
}
|
||||
|
||||
// Next, format how many decimal digits to be displayed
|
||||
$query = "SELECT $column FROM $tableName";
|
||||
for ($j = 0; $j <= $scale; $j++) {
|
||||
$options = array('FormatDecimals' => $j);
|
||||
$stmt = sqlsrv_query($conn, $query, array(), $options);
|
||||
|
||||
if (sqlsrv_fetch($stmt)) {
|
||||
$value = sqlsrv_get_field($stmt, 0);
|
||||
trace("$value\n");
|
||||
|
||||
if ($j == 0) {
|
||||
verifyNoDecimals($value, $input, $i);
|
||||
} else {
|
||||
verifyWithDecimals($value, $input, $i, $j);
|
||||
}
|
||||
} else {
|
||||
fatalError("Round $i scale $j: sqlsrv_fetch failed\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_time_limit(0);
|
||||
sqlsrv_configure('WarningsReturnAsErrors', 1);
|
||||
|
||||
$conn = AE\connect();
|
||||
if (!$conn) {
|
||||
fatalError("Could not connect.\n");
|
||||
}
|
||||
|
||||
$tableName = createTestTable($conn);
|
||||
$inputData = insertTestData($conn, $tableName);
|
||||
testVariousScales($conn, $tableName, $inputData);
|
||||
|
||||
dropTable($conn, $tableName);
|
||||
|
||||
sqlsrv_close($conn);
|
||||
|
||||
echo "Done\n";
|
||||
?>
|
||||
--EXPECT--
|
||||
Done
|
Loading…
Reference in a new issue