diff --git a/language-extensions/dotnet-core-CSharp/src/managed/CSharpInputDataSet.cs b/language-extensions/dotnet-core-CSharp/src/managed/CSharpInputDataSet.cs index de1b0a1..29ac16a 100644 --- a/language-extensions/dotnet-core-CSharp/src/managed/CSharpInputDataSet.cs +++ b/language-extensions/dotnet-core-CSharp/src/managed/CSharpInputDataSet.cs @@ -9,8 +9,10 @@ // //********************************************************************* using System; +using System.Data.SqlTypes; using Microsoft.Data.Analysis; using static Microsoft.SqlServer.CSharpExtension.Sql; +using static Microsoft.SqlServer.CSharpExtension.SqlNumericHelper; namespace Microsoft.SqlServer.CSharpExtension { @@ -126,6 +128,9 @@ private unsafe void AddColumn( case SqlDataType.DotNetReal: AddDataFrameColumn(columnNumber, rowsNumber, colData, colMap); break; + case SqlDataType.DotNetNumeric: + AddNumericDataFrameColumn(columnNumber, rowsNumber, colData, colMap); + break; case SqlDataType.DotNetChar: int[] strLens = new int[rowsNumber]; Interop.Copy((int*)colMap, strLens, 0, (int)rowsNumber); @@ -185,5 +190,54 @@ private unsafe void AddDataFrameColumn( CSharpDataFrame.Columns.Add(colDataFrame); } + + /// + /// This method adds NUMERIC/DECIMAL column data by converting from SQL_NUMERIC_STRUCT + /// to SqlDecimal values (full 38-digit precision), creating a PrimitiveDataFrameColumn, + /// and adding it to the DataFrame. + /// + /// The column index. + /// Number of rows in this column. + /// Pointer to array of SQL_NUMERIC_STRUCT structures. + /// Pointer to null indicator array (SQL_NULL_DATA for null values). + private unsafe void AddNumericDataFrameColumn( + ushort columnNumber, + ulong rowsNumber, + void *colData, + int *colMap) + { + // Cast the raw pointer to SQL_NUMERIC_STRUCT array + // + SqlNumericStruct* numericArray = (SqlNumericStruct*)colData; + + // Create a DataFrame column for SqlDecimal values + // Using SqlDecimal instead of decimal provides full SQL Server precision (38 digits) + // + PrimitiveDataFrameColumn colDataFrame = + new PrimitiveDataFrameColumn(_columns[columnNumber].Name, (int)rowsNumber); + + // Convert each SQL_NUMERIC_STRUCT to SqlDecimal, handling nulls + // + Span nullSpan = new Span(colMap, (int)rowsNumber); + for (int i = 0; i < (int)rowsNumber; ++i) + { + // Check if this row has a null value + // + // Why check both Nullable == 0 and SQL_NULL_DATA? + // - Nullable == 0 means column is declared NOT NULL (cannot contain nulls) + // - For NOT NULL columns, skip null checking for performance (nullSpan[i] is undefined) + // - For nullable columns (Nullable != 0), check if nullSpan[i] == SQL_NULL_DATA (-1) + // - This matches the pattern used by other numeric types in the codebase + if (_columns[columnNumber].Nullable == 0 || nullSpan[i] != SQL_NULL_DATA) + { + // Convert SQL_NUMERIC_STRUCT to SqlDecimal with full precision support + // + colDataFrame[i] = ToSqlDecimal(numericArray[i]); + } + // else: leave as null (default for nullable primitive column) + } + + CSharpDataFrame.Columns.Add(colDataFrame); + } } } diff --git a/language-extensions/dotnet-core-CSharp/src/managed/CSharpOutputDataSet.cs b/language-extensions/dotnet-core-CSharp/src/managed/CSharpOutputDataSet.cs index 8eee4b1..eaa9599 100644 --- a/language-extensions/dotnet-core-CSharp/src/managed/CSharpOutputDataSet.cs +++ b/language-extensions/dotnet-core-CSharp/src/managed/CSharpOutputDataSet.cs @@ -9,12 +9,14 @@ // //********************************************************************* using System; +using System.Data.SqlTypes; using System.Linq; using System.Text; using System.Runtime.InteropServices; using System.Collections.Generic; using Microsoft.Data.Analysis; using static Microsoft.SqlServer.CSharpExtension.Sql; +using static Microsoft.SqlServer.CSharpExtension.SqlNumericHelper; namespace Microsoft.SqlServer.CSharpExtension { @@ -174,6 +176,9 @@ DataFrameColumn column case SqlDataType.DotNetDouble: SetDataPtrs(columnNumber, GetArray(column)); break; + case SqlDataType.DotNetNumeric: + ExtractNumericColumn(columnNumber, column); + break; case SqlDataType.DotNetChar: // Calculate column size from actual data. // columnSize = max UTF-8 byte length across all rows. @@ -203,7 +208,7 @@ DataFrameColumn column /// /// This method sets data pointer for the column and append the array to the handle list. /// - private unsafe void SetDataPtrs( + private void SetDataPtrs( ushort columnNumber, T[] array ) where T : unmanaged @@ -213,6 +218,68 @@ T[] array _handleList.Add(handle); } + /// + /// Extracts NUMERIC/DECIMAL column data by converting SqlDecimal values to ODBC-compatible SQL_NUMERIC_STRUCT array. + /// + /// The column index. + /// The DataFrameColumn containing SqlDecimal values. + private unsafe void ExtractNumericColumn( + ushort columnNumber, + DataFrameColumn column) + { + if (column == null) + { + SetDataPtrs(columnNumber, Array.Empty()); + return; + } + + // Determine target precision/scale from max values across all rows + // + byte precision = SqlNumericHelper.SQL_MIN_PRECISION; + byte scale = (byte)_columns[columnNumber].DecimalDigits; + + for (int rowNumber = 0; rowNumber < column.Length; ++rowNumber) + { + if (column[rowNumber] != null) + { + SqlDecimal value = (SqlDecimal)column[rowNumber]; + if (!value.IsNull) + { + scale = Math.Max(scale, value.Scale); + precision = Math.Max(precision, value.Precision); + } + } + } + + // Enforce T-SQL DECIMAL(p,s) constraints: 1 <= p <= 38, 0 <= s <= p + // + precision = Math.Max(precision, SqlNumericHelper.SQL_MIN_PRECISION); + precision = Math.Min(precision, SqlNumericHelper.SQL_MAX_PRECISION); + if (scale > precision) + { + precision = scale; + } + + // Update metadata: Size = precision (total digits), DecimalDigits = scale (fractional digits) + // + _columns[columnNumber].Size = precision; + _columns[columnNumber].DecimalDigits = scale; + + Logging.Trace($"ExtractNumericColumn: Column {columnNumber}, T-SQL type=DECIMAL({precision},{scale}), RowCount={column.Length}"); + + // Convert all values (including NULLs) to SQL_NUMERIC_STRUCT using FromSqlDecimal + // + SqlNumericStruct[] numericArray = new SqlNumericStruct[column.Length]; + for (int rowNumber = 0; rowNumber < column.Length; ++rowNumber) + { + SqlDecimal value = (column[rowNumber] != null) ? (SqlDecimal)column[rowNumber] : SqlDecimal.Null; + numericArray[rowNumber] = FromSqlDecimal(value, precision, scale); + Logging.Trace($"ExtractNumericColumn: Row {rowNumber}, Value={value}"); + } + + SetDataPtrs(columnNumber, numericArray); + } + /// /// This method gets the array from a DataFrameColumn Column for numeric types. /// diff --git a/language-extensions/dotnet-core-CSharp/src/managed/CSharpParamContainer.cs b/language-extensions/dotnet-core-CSharp/src/managed/CSharpParamContainer.cs index e1c53d5..cfea97a 100644 --- a/language-extensions/dotnet-core-CSharp/src/managed/CSharpParamContainer.cs +++ b/language-extensions/dotnet-core-CSharp/src/managed/CSharpParamContainer.cs @@ -9,11 +9,13 @@ // //************************************************************************************************* using System; +using System.Data.SqlTypes; using System.Runtime; using System.Text; using System.Collections.Generic; using System.Runtime.InteropServices; using static Microsoft.SqlServer.CSharpExtension.Sql; +using static Microsoft.SqlServer.CSharpExtension.SqlNumericHelper; namespace Microsoft.SqlServer.CSharpExtension { @@ -132,6 +134,11 @@ public unsafe void AddParam( case SqlDataType.DotNetBit: _params[paramNumber].Value = *(bool*)paramValue; break; + case SqlDataType.DotNetNumeric: + // Convert SQL_NUMERIC_STRUCT to SqlDecimal, handling OUTPUT parameter sentinel (precision=0) + // + _params[paramNumber].Value = ToSqlDecimalFromPointer((SqlNumericStruct*)paramValue); + break; case SqlDataType.DotNetChar: _params[paramNumber].Value = Interop.UTF8PtrToStr((char*)paramValue, (ulong)strLenOrNullMap); break; @@ -168,7 +175,19 @@ public unsafe void ReplaceParam( _params[paramNumber].Value = paramValue_; CSharpParam param = _params[paramNumber]; - if(param.Value == null) + + // Use null-coalescing pattern for safer null checking with value types + // SqlDecimal is a struct, so we need to check both object null and SqlDecimal.IsNull + // + if(ReferenceEquals(param.Value, null)) + { + *strLenOrNullMap = SQL_NULL_DATA; + return; + } + + // Special handling for SqlDecimal.Null (SqlDecimal is a struct, not a class) + // + if(param.DataType == SqlDataType.DotNetNumeric && param.Value is SqlDecimal sqlDecVal && sqlDecVal.IsNull) { *strLenOrNullMap = SQL_NULL_DATA; return; @@ -214,6 +233,21 @@ public unsafe void ReplaceParam( bool boolValue = Convert.ToBoolean(param.Value); ReplaceNumericParam(boolValue, paramValue); break; + case SqlDataType.DotNetNumeric: + // Use declared precision/scale from T-SQL parameter definition, not SqlDecimal's intrinsic values + // + if (param.Value is SqlDecimal sqlDecimalValue) + { + byte precision = (byte)param.Size; + byte scale = (byte)param.DecimalDigits; + *strLenOrNullMap = Sql.SqlNumericStructSize; + ReplaceNumericStructParam(sqlDecimalValue, precision, scale, paramValue); + } + else + { + throw new InvalidCastException($"Expected SqlDecimal for NUMERIC parameter, got {param.Value?.GetType().Name ?? "null"}"); + } + break; case SqlDataType.DotNetChar: // For CHAR/VARCHAR, strLenOrNullMap is in bytes (1 byte per character for ANSI). // param.Size is the declared parameter size in characters (from SQL Server's CHAR(n)/VARCHAR(n)). @@ -229,7 +263,6 @@ public unsafe void ReplaceParam( // In C#, sizeof(char) is always 2 bytes (UTF-16), regardless of platform. // Note: C++ wchar_t is 2 bytes on Windows but 4 bytes on Linux - this extension only supports Windows. // param.Size is the declared parameter size in characters (from SQL Server's NCHAR(n)/NVARCHAR(n)), - // so we multiply by sizeof(char) to convert to bytes. // int wcharByteLen = param.Value.Length * sizeof(char); int wcharMaxByteLen = (int)param.Size * sizeof(char); @@ -275,6 +308,26 @@ private unsafe void ReplaceNumericParam( *paramValue = (void*)handle.AddrOfPinnedObject(); } + /// + /// Replaces NUMERIC/DECIMAL parameter value by converting SqlDecimal to SQL_NUMERIC_STRUCT and pinning it. + /// + private unsafe void ReplaceNumericStructParam( + SqlDecimal value, + byte precision, + byte scale, + void **paramValue) + { + SqlNumericStruct numericStruct = FromSqlDecimal(value, precision, scale); + + // Box into array for heap allocation (stack-allocated struct destroyed at method exit) + // Pin to prevent GC from moving memory while native code holds the pointer + // + SqlNumericStruct[] valueArray = new SqlNumericStruct[1] { numericStruct }; + GCHandle handle = GCHandle.Alloc(valueArray, GCHandleType.Pinned); + _handleList.Add(handle); + *paramValue = (void*)handle.AddrOfPinnedObject(); + } + /// /// This method replaces parameter value for string data types. /// If the string is not empty, the address of underlying bytes will be assigned to paramValue. diff --git a/language-extensions/dotnet-core-CSharp/src/managed/Microsoft.SqlServer.CSharpExtension.csproj b/language-extensions/dotnet-core-CSharp/src/managed/Microsoft.SqlServer.CSharpExtension.csproj index 3b8c8e4..f503877 100644 --- a/language-extensions/dotnet-core-CSharp/src/managed/Microsoft.SqlServer.CSharpExtension.csproj +++ b/language-extensions/dotnet-core-CSharp/src/managed/Microsoft.SqlServer.CSharpExtension.csproj @@ -5,11 +5,13 @@ true + $(MSBuildThisFileDirectory)..\..\..\..\build-output\dotnet-core-CSharp-extension\windows $(BinRoot)/$(Configuration)/ false LatestMajor + \ No newline at end of file diff --git a/language-extensions/dotnet-core-CSharp/src/managed/utils/Sql.cs b/language-extensions/dotnet-core-CSharp/src/managed/utils/Sql.cs index 3d564ba..78ee0a9 100644 --- a/language-extensions/dotnet-core-CSharp/src/managed/utils/Sql.cs +++ b/language-extensions/dotnet-core-CSharp/src/managed/utils/Sql.cs @@ -5,12 +5,14 @@ // @File: Sql.cs // // Purpose: -// This is the the main include for SqlDataType and Sql return values +// SQL data type definitions, ODBC constants, and type mapping dictionaries. +// For NUMERIC/DECIMAL conversion utilities, see SqlNumericHelper.cs. // //********************************************************************* using System; using System.Collections.Generic; -using System.Text; +using System.Data.SqlTypes; +using System.Runtime.InteropServices; namespace Microsoft.SqlServer.CSharpExtension { @@ -27,6 +29,15 @@ public class Sql public const short MinUtf8CharSize = 1; public const short MinUtf16CharSize = 2; + + /// + /// Size of SQL_NUMERIC_STRUCT in bytes (ODBC specification). + /// Dynamically calculated from SqlNumericHelper.SqlNumericStruct layout: + /// precision(1) + scale(1) + sign(1) + val[16] = 19 bytes. + /// Must match the exact size of ODBC's SQL_NUMERIC_STRUCT for binary compatibility. + /// + public static readonly short SqlNumericStructSize = (short)Marshal.SizeOf(); + public enum SqlDataType: short { DotNetBigInt = -5 + SQL_SIGNED_OFFSET, //SQL_C_SBIGINT + SQL_SIGNED_OFFSET @@ -68,7 +79,8 @@ public enum SqlDataType: short {typeof(float), SqlDataType.DotNetReal}, {typeof(double), SqlDataType.DotNetDouble}, {typeof(bool), SqlDataType.DotNetBit}, - {typeof(string), SqlDataType.DotNetChar} + {typeof(string), SqlDataType.DotNetChar}, + {typeof(SqlDecimal), SqlDataType.DotNetNumeric} }; /// @@ -89,7 +101,8 @@ public enum SqlDataType: short {SqlDataType.DotNetDouble, sizeof(double)}, {SqlDataType.DotNetBit, sizeof(bool)}, {SqlDataType.DotNetChar, MinUtf8CharSize}, - {SqlDataType.DotNetWChar, MinUtf16CharSize} + {SqlDataType.DotNetWChar, MinUtf16CharSize}, + {SqlDataType.DotNetNumeric, SqlNumericStructSize} }; /// diff --git a/language-extensions/dotnet-core-CSharp/src/managed/utils/SqlNumericHelper.cs b/language-extensions/dotnet-core-CSharp/src/managed/utils/SqlNumericHelper.cs new file mode 100644 index 0000000..b294b19 --- /dev/null +++ b/language-extensions/dotnet-core-CSharp/src/managed/utils/SqlNumericHelper.cs @@ -0,0 +1,329 @@ +//********************************************************************* +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// @File: SqlNumericHelper.cs +// +// Purpose: +// SQL NUMERIC/DECIMAL type support: ODBC-compatible struct definition +// and bidirectional conversion between SQL_NUMERIC_STRUCT and SqlDecimal. +// +//********************************************************************* +using System; +using System.Data.SqlTypes; +using System.Linq; +using System.Runtime.InteropServices; + +namespace Microsoft.SqlServer.CSharpExtension +{ + /// + /// Helper class for converting between SQL Server NUMERIC/DECIMAL types and SqlDecimal. + /// Provides ODBC-compatible SQL_NUMERIC_STRUCT definition and conversion methods. + /// + /// IMPORTANT: We use SqlDecimal from Microsoft.Data.SqlClient which supports + /// full SQL Server precision (38 digits). + /// C# native decimal is NOT used as it has 28-digit limitations. + /// + public static class SqlNumericHelper + { + // Precision and scale constraints from SqlDecimal (Microsoft.Data.SqlClient) + // These are the canonical SQL Server DECIMAL/NUMERIC limits + + /// + /// SQL Server maximum precision for DECIMAL/NUMERIC types (digits). + /// Retrieved from SqlDecimal.MaxPrecision in Microsoft.Data.SqlClient. + /// + public static readonly byte SQL_MAX_PRECISION = SqlDecimal.MaxPrecision; + + /// + /// Minimum precision for DECIMAL/NUMERIC types (digits). + /// SQL Server requires at least 1 digit of precision. + /// + public const byte SQL_MIN_PRECISION = 1; + + /// + /// Maximum scale for DECIMAL/NUMERIC types (digits after decimal point). + /// Retrieved from SqlDecimal.MaxScale in Microsoft.Data.SqlClient. + /// Scale cannot exceed precision. + /// + public static readonly byte SQL_MAX_SCALE = SqlDecimal.MaxScale; + + /// + /// Minimum scale for DECIMAL/NUMERIC types (digits after decimal point). + /// SQL Server allows scale of 0 (integers). + /// + public const byte SQL_MIN_SCALE = 0; + + /// + /// Size of SQL_NUMERIC_STRUCT value array in bytes. + /// Defined as SQL_MAX_NUMERIC_LEN in ODBC specification (sql.h/sqltypes.h). + /// + public const int SQL_NUMERIC_VALUE_SIZE = 16; + + /// + /// Number of Int32 values needed to represent the SQL_NUMERIC_STRUCT value array. + /// Calculated as: 16 bytes / 4 bytes per Int32 = 4 Int32s. + /// + private const int INT32_ARRAY_SIZE = 4; + + /// + /// SQL_NUMERIC_STRUCT structure matching ODBC's SQL_NUMERIC_STRUCT. + /// Used for transferring NUMERIC/DECIMAL data between SQL Server and C#. + /// + /// Binary Layout (19 bytes total, Pack=1 for no padding): + /// Offset 0: precision (SQLCHAR / byte) - Total digits (1-38) + /// Offset 1: scale (SQLSCHAR / sbyte) - Digits after decimal point (0-precision) + /// Offset 2: sign (SQLCHAR / byte) - 1=positive, 0=negative + /// Offset 3-18: val (SQLCHAR[16] / byte[16]) - Little-endian 128-bit scaled integer + /// + /// References: + /// - ODBC Programmer's Reference: https://docs.microsoft.com/en-us/sql/odbc/reference/appendixes/c-data-types + /// - SQL_NUMERIC_STRUCT definition: https://learn.microsoft.com/en-us/sql/odbc/reference/appendixes/retrieve-numeric-data-sql-numeric-struct-kb222831 + /// - sqltypes.h header: SQL_MAX_NUMERIC_LEN = 16 + /// + /// CRITICAL: This struct must be binary-compatible with ODBC's SQL_NUMERIC_STRUCT. + /// Any layout mismatch will cause data corruption when marshaling to/from native code. + /// + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public unsafe struct SqlNumericStruct + { + /// + /// Total number of decimal digits (1-38). + /// In T-SQL terms: DECIMAL(precision, scale) - this is the 'precision' part. + /// Example: DECIMAL(10,2) has precision=10 (up to 10 total digits). + /// Maps to SQLCHAR (unsigned byte) in ODBC specification. + /// + public byte precision; + + /// + /// Number of digits after the decimal point (0-precision). + /// In T-SQL terms: DECIMAL(precision, scale) - this is the 'scale' part. + /// Example: DECIMAL(10,2) has scale=2 (2 digits after decimal point). + /// + /// Maps to SQLSCHAR (signed char) in ODBC specification. + /// We must use sbyte (not byte) for exact binary layout compatibility. + /// Although scale is always non-negative in T-SQL, ODBC defines it as signed. + /// + public sbyte scale; + + /// + /// Sign indicator: 1 = positive/zero, 0 = negative. + /// Maps to SQLCHAR (unsigned byte) in ODBC specification. + /// + public byte sign; + + /// + /// Little-endian 128-bit integer representing the scaled value. + /// The actual numeric value = (val as 128-bit integer) * 10^(-scale) * sign. + /// + /// Fixed buffer provides direct memory access. + /// + /// Note: Requires unsafe context to access fixed buffer. + /// Use: fixed (byte* ptr = numericStruct.val) { ... } + /// Or: byte b = numericStruct.val[i]; // Direct indexing in unsafe context + /// + public fixed byte val[SQL_NUMERIC_VALUE_SIZE]; + } + + /// + /// Validates precision and scale parameters for SQL Server DECIMAL/NUMERIC types. + /// + /// Total number of digits (1-38). + /// Number of digits after decimal point (0-precision). + /// Parameter name for error messages (e.g., "precision", "scale"). + /// Thrown when precision or scale are out of valid range. + private static void ValidatePrecisionAndScale(byte precision, sbyte scale, string parameterName = "value") + { + if (precision < SQL_MIN_PRECISION || precision > SQL_MAX_PRECISION) + { + throw new ArgumentException( + $"Precision must be between {SQL_MIN_PRECISION} and {SQL_MAX_PRECISION} (T-SQL DECIMAL(p,s) constraint), got {precision}", + parameterName); + } + + if (scale < SQL_MIN_SCALE) + { + throw new ArgumentException( + $"Scale must be non-negative (T-SQL DECIMAL(p,s) constraint), got {scale}", + parameterName); + } + + if (scale > precision) + { + throw new ArgumentException( + $"Scale ({scale}) cannot exceed precision ({precision}) (T-SQL DECIMAL(p,s) constraint)", + parameterName); + } + } + + /// + /// Converts SQL_NUMERIC_STRUCT to SqlDecimal. + /// + /// The SQL numeric structure from ODBC. + /// The equivalent SqlDecimal value. + /// + /// Thrown when precision or scale are out of valid T-SQL range: + /// - Precision must be 1-38 + /// - Scale must be between 0 and precision + /// + /// + /// SqlDecimal provides full SQL Server precision (38 digits) compared to the native C# decimal (28-29 digits). + /// + public static unsafe SqlDecimal ToSqlDecimal(SqlNumericStruct numeric) + { + // Validate precision and scale before creating SqlDecimal + // + ValidatePrecisionAndScale(numeric.precision, numeric.scale, nameof(numeric)); + + // SqlDecimal constructor requires int[] array (not byte[]) + // The val array in SqlNumericStruct is 16 bytes = 128 bits + // We need to convert to 4 int32s (4 x 32 bits = 128 bits) + // + int[] data = new int[INT32_ARRAY_SIZE]; + + // Fixed buffers are already fixed - access directly via pointer + // + byte* valPtr = numeric.val; + for (int i = 0; i < INT32_ARRAY_SIZE; i++) + { + // Convert each group of 4 bytes to an int32 (little-endian) + // + int offset = i * 4; + data[i] = valPtr[offset] | + (valPtr[offset + 1] << 8) | + (valPtr[offset + 2] << 16) | + (valPtr[offset + 3] << 24); + } + + // SqlDecimal constructor: + // SqlDecimal(byte precision, byte scale, bool positive, int[] data) + // + bool isPositive = numeric.sign == 1; + + // Note: SqlDecimal scale parameter is byte (unsigned), but SqlNumericStruct.scale is sbyte (signed) + // SQL Server scale is always non-negative (0-38), so this cast is safe after validation + // + byte scale = (byte)numeric.scale; + + return new SqlDecimal(numeric.precision, scale, isPositive, data); + } + + /// + /// Converts SqlDecimal to SQL_NUMERIC_STRUCT for ODBC transfer. + /// + /// The SqlDecimal value to convert. + /// Target precision (1-38). If null, uses value's intrinsic precision. + /// Target scale (0-precision). If null, uses value's intrinsic scale. + /// ODBC-compatible SQL_NUMERIC_STRUCT. + /// Thrown when precision or scale constraints violated. + /// Thrown when scale adjustment loses data or value exceeds target precision. + /// + /// NULL values return zero-initialized struct; caller must set null indicator (e.g., strLenOrNullMap = SQL_NULL_DATA). + /// Scale adjustment uses SqlDecimal.AdjustScale with fRound=false to prevent silent data loss. + /// + public static unsafe SqlNumericStruct FromSqlDecimal(SqlDecimal value, byte? precision = null, byte? scale = null) + { + // Use SqlDecimal's intrinsic precision/scale if not specified + // + byte targetPrecision = precision ?? value.Precision; + byte targetScale = scale ?? value.Scale; + + // Validate target precision and scale constraints + // + ValidatePrecisionAndScale(targetPrecision, (sbyte)targetScale, nameof(value)); + + // NULL values return zero-initialized struct; caller sets null indicator separately + // + if (value.IsNull) + { + return new SqlNumericStruct + { + precision = targetPrecision, + scale = (sbyte)targetScale, + sign = 1 + }; + } + + // Adjust scale if needed to match target + // + SqlDecimal adjustedValue = value; + if (targetScale != value.Scale) + { + int scaleShift = targetScale - value.Scale; + + try + { + // fRound=false ensures no silent data loss when reducing scale + // + adjustedValue = SqlDecimal.AdjustScale(value, scaleShift, fRound: false); + } + catch (OverflowException ex) + { + throw new OverflowException( + $"Cannot adjust scale from {value.Scale} to {targetScale} without data loss. Value: {value}", ex); + } + } + + // Validate adjusted value fits within target precision + // + if (adjustedValue.Precision > targetPrecision) + { + throw new OverflowException( + $"Value requires {adjustedValue.Precision} digits but target DECIMAL({targetPrecision},{targetScale}) allows only {targetPrecision}."); + } + + SqlNumericStruct result = new SqlNumericStruct + { + precision = targetPrecision, + scale = (sbyte)targetScale, + sign = (byte)(adjustedValue.IsPositive ? 1 : 0) + }; + + // Convert SqlDecimal's int[4] data to byte[16] for ODBC struct (little-endian) + // + int[] data = adjustedValue.Data; + byte* valPtr = result.val; + + for (int i = 0; i < INT32_ARRAY_SIZE; i++) + { + int value32 = data[i]; + int offset = i * 4; + valPtr[offset] = (byte)value32; + valPtr[offset + 1] = (byte)(value32 >> 8); + valPtr[offset + 2] = (byte)(value32 >> 16); + valPtr[offset + 3] = (byte)(value32 >> 24); + } + + return result; + } + + /// + /// Converts SQL_NUMERIC_STRUCT pointer to SqlDecimal, handling OUTPUT parameter convention. + /// + /// Pointer to SQL_NUMERIC_STRUCT from ODBC. + /// SqlDecimal value, or SqlDecimal.Null for OUTPUT parameters (precision=0 sentinel). + /// Thrown when pointer is null. + /// Thrown when precision or scale violate constraints. + /// + /// OUTPUT Parameter Convention: SQL Server passes uninitialized structs with precision=0 as a sentinel + /// indicating "output only, no input value". This violates ODBC spec (requires precision 1-38) but is + /// safe to detect. Returns SqlDecimal.Null in this case; caller will assign the actual output value. + /// + public static unsafe SqlDecimal ToSqlDecimalFromPointer(SqlNumericStruct* numericPtr) + { + if (numericPtr == null) + { + throw new ArgumentNullException(nameof(numericPtr)); + } + + // precision=0 is the OUTPUT parameter sentinel (uninitialized struct) + // + if (numericPtr->precision == 0) + { + return SqlDecimal.Null; + } + + return ToSqlDecimal(*numericPtr); + } + } +} diff --git a/language-extensions/dotnet-core-CSharp/test/include/CSharpExtensionApiTests.h b/language-extensions/dotnet-core-CSharp/test/include/CSharpExtensionApiTests.h index 34c0162..3e05d9a 100644 --- a/language-extensions/dotnet-core-CSharp/test/include/CSharpExtensionApiTests.h +++ b/language-extensions/dotnet-core-CSharp/test/include/CSharpExtensionApiTests.h @@ -396,6 +396,10 @@ namespace ExtensionApiTest const SQLDOUBLE m_MaxDouble = 1.79e308; const SQLDOUBLE m_MinDouble = -1.79e308; + // Maximum precision for SQL DECIMAL/NUMERIC types (1-38 per SQL Server specification) + // + static constexpr SQLULEN SqlDecimalMaxPrecision = 38; + // Path of .NET Core CSharp Extension // static std::string sm_extensionPath; @@ -481,4 +485,64 @@ namespace ExtensionApiTest std::vector m_nullable; std::vector m_partitionByIndexes; }; + + //---------------------------------------------------------------------------------------------- + // TestHelpers namespace - Utility functions for test data generation + // + namespace TestHelpers + { + //---------------------------------------------------------------------------------------------- + // Name: CreateNumericStruct + // + // Description: + // Helper function to create SQL_NUMERIC_STRUCT from decimal value components. + // Creates a properly initialized ODBC numeric structure with little-endian mantissa encoding. + // + // Arguments: + // mantissa - The unscaled integer representation of the decimal value. + // The actual decimal value is calculated as: mantissa ÷ 10^scale + // This allows exact decimal arithmetic without floating-point precision loss. + // Examples: + // • 12345.6789 → mantissa=123456789, scale=4 (123456789 ÷ 10^4 = 12345.6789) + // • 555.5000 → mantissa=5555000, scale=4 (5555000 ÷ 10^4 = 555.5000) + // • 0.00001 → mantissa=1, scale=5 (1 ÷ 10^5 = 0.00001) + // precision - Total number of digits (1-38, as per SQL NUMERIC/DECIMAL spec) + // scale - Number of digits after decimal point (0-precision) + // isNegative - true for negative values, false for positive/zero + // + // Returns: + // SQL_NUMERIC_STRUCT - Fully initialized 19-byte ODBC numeric structure + // + // Example: + // CreateNumericStruct(1234567, 10, 2, false) → represents 12345.67 + // CreateNumericStruct(5555000, 19, 4, true) → represents -555.5000 + // + inline SQL_NUMERIC_STRUCT CreateNumericStruct( + long long mantissa, + SQLCHAR precision, + SQLSCHAR scale, + bool isNegative) + { + // Zero-initialize all fields for safety + SQL_NUMERIC_STRUCT result{}; + + result.precision = precision; + result.scale = scale; + result.sign = isNegative ? 0 : 1; // 0 = negative, 1 = positive (ODBC convention) + + // Convert mantissa to little-endian byte array in val[0..15] + // Use std::abs for long long (not plain abs which is for int) + unsigned long long absMantissa = static_cast(std::abs(mantissa)); + + // Extract bytes in little-endian order + // Use sizeof for self-documenting code instead of magic number 16 + for (size_t i = 0; i < sizeof(result.val); i++) + { + result.val[i] = static_cast(absMantissa & 0xFF); + absMantissa >>= 8; + } + + return result; + } + } } diff --git a/language-extensions/dotnet-core-CSharp/test/src/managed/CSharpTestExecutor.cs b/language-extensions/dotnet-core-CSharp/test/src/managed/CSharpTestExecutor.cs index 9abe9ca..b77c3e6 100644 --- a/language-extensions/dotnet-core-CSharp/test/src/managed/CSharpTestExecutor.cs +++ b/language-extensions/dotnet-core-CSharp/test/src/managed/CSharpTestExecutor.cs @@ -9,6 +9,7 @@ // //********************************************************************* using System; +using System.Data.SqlTypes; using System.Runtime.InteropServices; using System.Collections.Generic; using Microsoft.Data.Analysis; @@ -108,6 +109,76 @@ public override DataFrame Execute(DataFrame input, Dictionary s } } + /// + /// Comprehensive test executor for DECIMAL/NUMERIC OUTPUT parameters. + /// Covers: max/min values, high precision/scale, financial values, zero, nulls. + /// Consolidated from CSharpTestExecutorDecimalParam + CSharpTestExecutorDecimalHighScaleParam. + /// + public class CSharpTestExecutorDecimalParam: AbstractSqlServerExtensionExecutor + { + public override DataFrame Execute(DataFrame input, Dictionary sqlParams) + { + // Maximum value: DECIMAL(38,0) max = 10^38 - 1 + sqlParams["@param0"] = SqlDecimal.Parse("99999999999999999999999999999999999999"); + + // Minimum value (negative max) + sqlParams["@param1"] = SqlDecimal.Parse("-99999999999999999999999999999999999999"); + + // High scale: DECIMAL(38,10) - 38 digits with 10 fractional + sqlParams["@param2"] = SqlDecimal.Parse("1234567890123456789012345678.1234567890"); + + // Zero + sqlParams["@param3"] = new SqlDecimal(0); + + // High fractional precision: DECIMAL(38,28) - 10 integer + 28 fractional + sqlParams["@param4"] = SqlDecimal.Parse("1234567890.1234567890123456789012345678"); + + // Typical financial: DECIMAL(19,4) + sqlParams["@param5"] = SqlDecimal.Parse("123456789012345.6789"); + + // Negative financial + sqlParams["@param6"] = SqlDecimal.Parse("-123456789012345.6789"); + + // Null + sqlParams["@param7"] = null; + + return null; + } + } + + /// + /// Test executor for DECIMAL precision overflow validation. + /// This executor deliberately sets values that exceed the target precision after scale adjustment. + /// This tests that FromSqlDecimal properly validates precision overflow. + /// + /// Bug scenario: Value 12345678.99 (10 digits) converted to DECIMAL(10,4) becomes 12345678.9900 + /// which requires 12 significant digits, exceeding the declared precision of 10. + /// + public class CSharpTestExecutorDecimalPrecisionOverflow: AbstractSqlServerExtensionExecutor + { + public override DataFrame Execute(DataFrame input, Dictionary sqlParams) + { + // param0: DECIMAL(10,4) max is 999999.9999 (6 before + 4 after = 10 total digits) + // Using value 12345678.99 has 10 significant digits (precision=10), scale=2 + // When adjusted to scale=4, would need precision=12 (12345678.9900), exceeding DECIMAL(10,4) + decimal dec0 = 12345678.99m; + sqlParams["@param0"] = new SqlDecimal(dec0); + + // param1: Using 999999999.999 has 12 significant digits (precision=12), scale=3 + // When adjusted to scale=4, would need precision=13 (999999999.9990), exceeding DECIMAL(10,4) + decimal dec1 = 999999999.999m; + sqlParams["@param1"] = new SqlDecimal(dec1); + + // param2: Value that fits in DECIMAL(8,3) + // Using 12345.67 has 7 significant digits (precision=7), scale=2 + // When adjusted to scale=3, would need precision=8 (12345.670), fits in DECIMAL(8,3) + decimal dec2 = 12345.67m; + sqlParams["@param2"] = new SqlDecimal(dec2); + + return null; + } + } + public class CSharpTestExecutorStringParam: AbstractSqlServerExtensionExecutor { public override DataFrame Execute(DataFrame input, Dictionary sqlParams){ diff --git a/language-extensions/dotnet-core-CSharp/test/src/managed/Microsoft.SqlServer.CSharpExtensionTest.csproj b/language-extensions/dotnet-core-CSharp/test/src/managed/Microsoft.SqlServer.CSharpExtensionTest.csproj index 39cc5d4..d0f49b7 100644 --- a/language-extensions/dotnet-core-CSharp/test/src/managed/Microsoft.SqlServer.CSharpExtensionTest.csproj +++ b/language-extensions/dotnet-core-CSharp/test/src/managed/Microsoft.SqlServer.CSharpExtensionTest.csproj @@ -4,15 +4,19 @@ true + $(MSBuildThisFileDirectory)..\..\..\..\..\build-output\dotnet-core-CSharp-extension-test\windows $(BinRoot)/$(Configuration)/ false + + Release + - ..\..\..\..\..\build-output\dotnet-core-CSharp-extension\windows\release\Microsoft.SqlServer.CSharpExtension.dll + ..\..\..\..\..\build-output\dotnet-core-CSharp-extension\windows\$(Configuration)\Microsoft.SqlServer.CSharpExtension.dll diff --git a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpDecimalTests.cpp b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpDecimalTests.cpp new file mode 100644 index 0000000..0559479 --- /dev/null +++ b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpDecimalTests.cpp @@ -0,0 +1,637 @@ +//********************************************************************* +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// @File: CSharpDecimalTests.cpp +// +// Purpose: +// Test the .NET Core CSharp extension NUMERIC/DECIMAL support using the Extension API +// +//********************************************************************* +#include "CSharpExtensionApiTests.h" + +using namespace std; + +namespace ExtensionApiTest +{ + // SQL_NUMERIC_STRUCT size per ODBC specification + // + const SQLINTEGER SQL_NUMERIC_STRUCT_SIZE = 19; + + // SQL Server maximum NUMERIC/DECIMAL precision + // + const SQLINTEGER SQL_NUMERIC_MAX_PRECISION = 38; + + //---------------------------------------------------------------------------------------------- + // Name: InitNumericParamTest + // + // Description: + // Tests multiple SQL_NUMERIC_STRUCT values with various precision and scale combinations. + // + TEST_F(CSharpExtensionApiTests, InitNumericParamTest) + { + using TestHelpers::CreateNumericStruct; + + InitializeSession( + 0, // inputSchemaColumnsNumber + 5); // parametersNumber + + // NUMERIC(10,2): 12345.67 + // + SQL_NUMERIC_STRUCT param0 = CreateNumericStruct(1234567, 10, 2, false); + InitParam( + 0, // paramNumber + param0); // paramValue (12345.67) + + // NUMERIC(38,0): 999999999999 (max precision integer) + // + SQL_NUMERIC_STRUCT param1 = CreateNumericStruct(999999999999LL, 38, 0, false); + InitParam( + 1, // paramNumber + param1); // paramValue (999999999999) + + // NUMERIC(19,4): -123456789012.3456 (negative) + // + SQL_NUMERIC_STRUCT param2 = CreateNumericStruct(1234567890123456LL, 19, 4, true); + InitParam( + 2, // paramNumber + param2); // paramValue (-123456789012.3456) + + // NUMERIC(5,5): 0.12345 (scale = precision) + // + SQL_NUMERIC_STRUCT param3 = CreateNumericStruct(12345, 5, 5, false); + InitParam( + 3, // paramNumber + param3); // paramValue (0.12345) + + // NULL NUMERIC value + // + InitParam( + 4, // paramNumber + SQL_NUMERIC_STRUCT(), // paramValue (ignored due to isNull) + true); // isNull + + // Invalid parameter number (5 out of bounds, valid: 0-4) + // + InitParam( + 5, // invalid paramNumber + param0, // paramValue + false, // isNull + SQL_PARAM_INPUT_OUTPUT, // inputOutputType + SQL_ERROR); // expected error + + // Negative parameter number + // + InitParam( + -1, // negative paramNumber + param0, // paramValue + false, // isNull + SQL_PARAM_INPUT_OUTPUT, // inputOutputType + SQL_ERROR); // expected error + } + + //---------------------------------------------------------------------------------------------- + // Name: GetDecimalOutputParamTest + // + // Description: + // Tests C# SqlDecimal to SQL_NUMERIC_STRUCT output parameter conversion + // + TEST_F(CSharpExtensionApiTests, GetDecimalOutputParamTest) + { + int paramsNumber = 8; + + string userClassFullName = "Microsoft.SqlServer.CSharpExtensionTest.CSharpTestExecutorDecimalParam"; + string scriptString = m_UserLibName + m_Separator + userClassFullName; + + InitializeSession( + 0, // inputSchemaColumnsNumber + paramsNumber, // parametersNumber + scriptString); // scriptString + + for(int i = 0; i < paramsNumber; ++i) + { + InitParam( + i, // paramNumber + SQL_NUMERIC_STRUCT(), // paramValue (will be set by C# executor) + false, // isNull + SQL_PARAM_INPUT_OUTPUT); // inputOutputType + } + + SQLUSMALLINT outputSchemaColumnsNumber = 0; + SQLRETURN result = (*sm_executeFuncPtr)( + *m_sessionId, + m_taskId, + 0, // rowsNumber + nullptr, // dataSet + nullptr, // strLen_or_Ind + &outputSchemaColumnsNumber); + ASSERT_EQ(result, SQL_SUCCESS); + + EXPECT_EQ(outputSchemaColumnsNumber, 0); + + // Expected strLenOrInd values: 19 bytes for valid, SQL_NULL_DATA for last param + // + vector strLenOrIndValues; + + // Non-null params: 19 bytes (sizeof SQL_NUMERIC_STRUCT) + // + for (int i = 0; i < paramsNumber - 1; ++i) + { + strLenOrIndValues.push_back(SQL_NUMERIC_STRUCT_SIZE); + } + + // Last parameter is null - validates NULL handling in C# SqlDecimal to SQL_NUMERIC_STRUCT conversion + // + strLenOrIndValues.push_back(SQL_NULL_DATA); + + // Verify output parameters match expected values and structure + // + for (int i = 0; i < paramsNumber; ++i) + { + SQLPOINTER paramValue = nullptr; + SQLINTEGER strLenOrInd = 0; + + SQLRETURN result = (*sm_getOutputParamFuncPtr)( + *m_sessionId, + m_taskId, + i, + ¶mValue, + &strLenOrInd); + + ASSERT_EQ(result, SQL_SUCCESS); + EXPECT_EQ(strLenOrInd, strLenOrIndValues[i]); + + if (strLenOrInd != SQL_NULL_DATA) + { + ASSERT_NE(paramValue, nullptr); + SQL_NUMERIC_STRUCT* numericValue = static_cast(paramValue); + + // Validate precision/scale/sign integrity + // + EXPECT_GE(numericValue->precision, 1); + EXPECT_LE(numericValue->precision, SQL_NUMERIC_MAX_PRECISION); + EXPECT_GE(numericValue->scale, 0); + EXPECT_LE(numericValue->scale, numericValue->precision); + EXPECT_TRUE(numericValue->sign == 0 || numericValue->sign == 1); + } + } + } + + //---------------------------------------------------------------------------------------------- + // Name: DecimalPrecisionScaleTest + // + // Description: + // Tests precision (1-38) and scale (0-38) combinations covering min/max, financial, scientific + // + TEST_F(CSharpExtensionApiTests, DecimalPrecisionScaleTest) + { + using TestHelpers::CreateNumericStruct; + + InitializeSession( + 0, // inputSchemaColumnsNumber + 10); // parametersNumber + + // NUMERIC(1,0): 5 (min precision, no scale) + // + SQL_NUMERIC_STRUCT p0 = CreateNumericStruct(5, 1, 0, false); + InitParam(0, p0); + + // NUMERIC(1,1): 0.5 (min precision, scale = precision) + // + SQL_NUMERIC_STRUCT p1 = CreateNumericStruct(5, 1, 1, false); + InitParam(1, p1); + + // NUMERIC(38,0): 12345678901234567 (max precision, no scale) + // + SQL_NUMERIC_STRUCT p2 = CreateNumericStruct(12345678901234567LL, SQL_NUMERIC_MAX_PRECISION, 0, false); + InitParam(2, p2); + + // NUMERIC(38,38): 0.xxx (max precision, max scale) + // + SQL_NUMERIC_STRUCT p3 = CreateNumericStruct(123456789012345678LL, SQL_NUMERIC_MAX_PRECISION, SQL_NUMERIC_MAX_PRECISION, false); + InitParam(3, p3); + + // NUMERIC(19,4): SQL Server MONEY compatible + // + SQL_NUMERIC_STRUCT p4 = CreateNumericStruct(12345678901234567LL, 19, 4, false); + InitParam(4, p4); + + // NUMERIC(10,2): Common financial + // + SQL_NUMERIC_STRUCT p5 = CreateNumericStruct(1234567, 10, 2, false); + InitParam(5, p5); + + // NUMERIC(20,10): Balanced precision/scale + // + SQL_NUMERIC_STRUCT p6 = CreateNumericStruct(123456789012345678ULL, 20, 10, false); + InitParam(6, p6); + + // NUMERIC(20,15): Mostly fractional + // + SQL_NUMERIC_STRUCT p7 = CreateNumericStruct(12345123456789012345ULL, 20, 15, false); + InitParam(7, p7); + + // NUMERIC(28,10): Scientific notation + // + SQL_NUMERIC_STRUCT p8 = CreateNumericStruct(123456789012345678LL, 28, 10, false); + InitParam(8, p8); + + // NUMERIC(18,18): Scale = precision + // + SQL_NUMERIC_STRUCT p9 = CreateNumericStruct(123456789012345678LL, 18, 18, false); + InitParam(9, p9); + } + + //---------------------------------------------------------------------------------------------- + // Name: DecimalBoundaryValuesTest + // + // Description: + // Tests boundary values: zero, very small, very large, negative + // + TEST_F(CSharpExtensionApiTests, DecimalBoundaryValuesTest) + { + using TestHelpers::CreateNumericStruct; + + InitializeSession( + 0, // inputSchemaColumnsNumber + 6); // parametersNumber + + // Zero: 0.00 + // + SQL_NUMERIC_STRUCT zero = CreateNumericStruct(0, 10, 2, false); + InitParam(0, zero); + + // Very small positive: 0.01 + // + SQL_NUMERIC_STRUCT smallPos = CreateNumericStruct(1, 10, 2, false); + InitParam(1, smallPos); + + // Very small negative: -0.01 + // + SQL_NUMERIC_STRUCT smallNeg = CreateNumericStruct(1, 10, 2, true); + InitParam(2, smallNeg); + + // Large positive: 999999999999999999 (near NUMERIC(38) max) + // + SQL_NUMERIC_STRUCT largePos = CreateNumericStruct(999999999999999999LL, SQL_NUMERIC_MAX_PRECISION, 0, false); + InitParam(3, largePos); + + // Large negative: -999999999999999999 + // + SQL_NUMERIC_STRUCT largeNeg = CreateNumericStruct(999999999999999999LL, SQL_NUMERIC_MAX_PRECISION, 0, true); + InitParam(4, largeNeg); + + // Maximum scale: 0.000000000000000001 (10^-18) + // + SQL_NUMERIC_STRUCT maxScale = CreateNumericStruct(1, 18, 18, false); + InitParam(5, maxScale); + } + + //---------------------------------------------------------------------------------------------- + // Name: DecimalStructLayoutTest + // + // Description: + // Verifies SQL_NUMERIC_STRUCT ODBC binary layout: 19 bytes, field offsets + // + TEST_F(CSharpExtensionApiTests, DecimalStructLayoutTest) + { + // ODBC spec: struct size = 19 bytes + // + EXPECT_EQ(sizeof(SQL_NUMERIC_STRUCT), 19); + + // Verify field offsets for binary compatibility + // + SQL_NUMERIC_STRUCT test; + + EXPECT_EQ((size_t)&test.precision - (size_t)&test, 0); // precision at offset 0 + EXPECT_EQ((size_t)&test.scale - (size_t)&test, 1); // scale at offset 1 + EXPECT_EQ((size_t)&test.sign - (size_t)&test, 2); // sign at offset 2 + EXPECT_EQ((size_t)&test.val[0] - (size_t)&test, 3); // val array at offset 3 + EXPECT_EQ(sizeof(test.val), 16); // val array = 16 bytes + + // Verify struct initialization and field access + // + test.precision = 38; + test.scale = 10; + test.sign = 1; + memset(test.val, 0, 16); + test.val[0] = 0x39; // 12345 in little-endian + test.val[1] = 0x30; + + EXPECT_EQ(test.precision, 38); + EXPECT_EQ(test.scale, 10); + EXPECT_EQ(test.sign, 1); + EXPECT_EQ(test.val[0], 0x39); + EXPECT_EQ(test.val[1], 0x30); + } + + //---------------------------------------------------------------------------------------------- + // Name: GetDecimalInputColumnsTest + // + // Description: + // Tests SQL_NUMERIC_STRUCT input columns with mixed precision/scale and NULL values. + // E2E tests had coverage, but unit tests had zero decimal column coverage until this test. + // + TEST_F(CSharpExtensionApiTests, GetDecimalInputColumnsTest) + { + using TestHelpers::CreateNumericStruct; + + // Column 1: Non-nullable NUMERIC(19,4) + // Values: 12345.6789, 9876543.2100, 0.1234, -555.5000, 999999999.9999 + // + vector column1Data = { + CreateNumericStruct(123456789, 19, 4, false), // 12345.6789 + CreateNumericStruct(98765432100LL, 19, 4, false), // 9876543.2100 + CreateNumericStruct(1234, 19, 4, false), // 0.1234 + CreateNumericStruct(5555000, 19, 4, true), // -555.5000 + CreateNumericStruct(9999999999999LL, 19, 4, false) // 999999999.9999 + }; + + // Column 2: Nullable NUMERIC(38,10) with NULL values + // Values: 1234567890.1234567890, NULL, 0.0000000001, NULL, -9999.9999999999 + // + vector column2Data = { + CreateNumericStruct(12345678901234567890ULL, 38, 10, false), // 1234567890.1234567890 + SQL_NUMERIC_STRUCT(), // NULL + CreateNumericStruct(1, 38, 10, false), // 0.0000000001 + SQL_NUMERIC_STRUCT(), // NULL + CreateNumericStruct(99999999999999ULL, 38, 10, true) // -9999.9999999999 + }; + + // Column 1: All non-null + // + vector col1StrLenOrInd(5, SQL_NUMERIC_STRUCT_SIZE); + + // Column 2: Rows 1 and 3 are NULL + // + vector col2StrLenOrInd = { + SQL_NUMERIC_STRUCT_SIZE, // Row 0: valid + SQL_NULL_DATA, // Row 1: NULL + SQL_NUMERIC_STRUCT_SIZE, // Row 2: valid + SQL_NULL_DATA, // Row 3: NULL + SQL_NUMERIC_STRUCT_SIZE // Row 4: valid + }; + + // Create ColumnInfo with decimal data + // + ColumnInfo decimalInfo( + "DecimalColumn1", + column1Data, + col1StrLenOrInd, + "DecimalColumn2", + column2Data, + col2StrLenOrInd, + vector{ SQL_NO_NULLS, SQL_NULLABLE }); + + InitializeSession( + decimalInfo.GetColumnsNumber(), + 0, + m_scriptString); + + InitializeColumns(&decimalInfo); + + // Execute with decimal input columns (tests native to C# DataFrame conversion) + // + Execute( + ColumnInfo::sm_rowsNumber, + decimalInfo.m_dataSet.data(), + decimalInfo.m_strLen_or_Ind.data(), + decimalInfo.m_columnNames); + + // Verify column metadata matches input (SqlDecimal preserves precision/scale) + // + GetResultColumn( + 0, // columnNumber + SQL_C_NUMERIC, // dataType + 19, // columnSize (precision from input) + 4, // decimalDigits (scale) + SQL_NO_NULLS); // nullable + + GetResultColumn( + 1, // columnNumber + SQL_C_NUMERIC, // dataType + 38, // columnSize (precision from input) + 10, // decimalDigits (scale) + SQL_NULLABLE); // nullable + } + + //---------------------------------------------------------------------------------------------- + // Name: GetDecimalResultColumnsTest + // + // Description: + // Tests decimal result column conversion preserves precision/scale + // and proper NULL handling of nullable columns. + // + TEST_F(CSharpExtensionApiTests, GetDecimalResultColumnsTest) + { + using TestHelpers::CreateNumericStruct; + + // Result Column 1: NUMERIC(18,2) - typical financial data + // Max value: 999999999999999.99 requires precision 18 + // + vector resultCol1 = { + CreateNumericStruct(123456789, 18, 2, false), // 1234567.89 + CreateNumericStruct(99999999999999999LL, 18, 2, false), // 999999999999999.99 + CreateNumericStruct(1050, 18, 2, false), // 10.50 + CreateNumericStruct(100, 18, 2, true), // -1.00 + CreateNumericStruct(0, 18, 2, false) // 0.00 + }; + + // Result Column 2: NUMERIC(10,5) - high precision with NULLs + // Max value: 12345.67891 requires precision 10 + // + vector resultCol2 = { + CreateNumericStruct(1234567891, 10, 5, false), // 12345.67891 + SQL_NUMERIC_STRUCT(), // NULL + CreateNumericStruct(1, 10, 5, false), // 0.00001 + SQL_NUMERIC_STRUCT(), // NULL + CreateNumericStruct(9999999999LL, 10, 5, true) // -99999.99999 + }; + + vector col1StrLenOrInd(5, SQL_NUMERIC_STRUCT_SIZE); + vector col2StrLenOrInd = { + SQL_NUMERIC_STRUCT_SIZE, + SQL_NULL_DATA, + SQL_NUMERIC_STRUCT_SIZE, + SQL_NULL_DATA, + SQL_NUMERIC_STRUCT_SIZE + }; + + ColumnInfo decimalResultInfo( + "AmountColumn", + resultCol1, + col1StrLenOrInd, + "PrecisionColumn", + resultCol2, + col2StrLenOrInd, + vector{ SQL_NO_NULLS, SQL_NULLABLE }); + + InitializeSession( + decimalResultInfo.GetColumnsNumber(), + 0, + m_scriptString); + + InitializeColumns(&decimalResultInfo); + + Execute( + ColumnInfo::sm_rowsNumber, + decimalResultInfo.m_dataSet.data(), + decimalResultInfo.m_strLen_or_Ind.data(), + decimalResultInfo.m_columnNames); + + // Verify result column metadata preserved from input + // CSharpOutputDataSet.ExtractNumericColumn() preserves SqlDecimal precision/scale + // + GetResultColumn( + 0, // columnNumber + SQL_C_NUMERIC, // dataType + 18, // columnSize (declared precision from input) + 2, // decimalDigits (scale) + SQL_NO_NULLS); // nullable + + GetResultColumn( + 1, // columnNumber + SQL_C_NUMERIC, // dataType + 10, // columnSize (declared precision from input) + 5, // decimalDigits (scale) + SQL_NULLABLE); // nullable + } + + //---------------------------------------------------------------------------------------------- + // Name: DecimalColumnsWithNullsTest + // + // Description: + // Tests decimal columns with mixed NULL and non-NULL values. SQL_NUMERIC_STRUCT doesn't + // have NULL indicator - NULL tracked via strLenOrInd = SQL_NULL_DATA separately. + // + TEST_F(CSharpExtensionApiTests, DecimalColumnsWithNullsTest) + { + using TestHelpers::CreateNumericStruct; + + // Column 1: First and last NULL - NUMERIC(28,6) + // Pattern: NULL, 12345.678900, 98765.432100, 0.000001, NULL + // + vector col1Data = { + SQL_NUMERIC_STRUCT(), // NULL + CreateNumericStruct(12345678900LL, 28, 6, false), // 12345.678900 + CreateNumericStruct(98765432100LL, 28, 6, false), // 98765.432100 + CreateNumericStruct(1, 28, 6, false), // 0.000001 + SQL_NUMERIC_STRUCT() // NULL + }; + + // Column 2: Middle rows NULL - NUMERIC(15,3) + // Pattern: 999999.999, NULL, NULL, -123.456, 0.001 + // + vector col2Data = { + CreateNumericStruct(999999999, 15, 3, false), // 999999.999 + SQL_NUMERIC_STRUCT(), // NULL + SQL_NUMERIC_STRUCT(), // NULL + CreateNumericStruct(123456, 15, 3, true), // -123.456 + CreateNumericStruct(1, 15, 3, false) // 0.001 + }; + + // Rows 0 and 4 NULL + // + vector col1StrLenOrInd = { + SQL_NULL_DATA, + SQL_NUMERIC_STRUCT_SIZE, + SQL_NUMERIC_STRUCT_SIZE, + SQL_NUMERIC_STRUCT_SIZE, + SQL_NULL_DATA + }; + + // Rows 1 and 2 NULL + // + vector col2StrLenOrInd = { + SQL_NUMERIC_STRUCT_SIZE, + SQL_NULL_DATA, + SQL_NULL_DATA, + SQL_NUMERIC_STRUCT_SIZE, + SQL_NUMERIC_STRUCT_SIZE + }; + + ColumnInfo nullDecimalInfo( + "SparseColumn1", + col1Data, + col1StrLenOrInd, + "SparseColumn2", + col2Data, + col2StrLenOrInd, + vector{ SQL_NULLABLE, SQL_NULLABLE }); + + InitializeSession( + nullDecimalInfo.GetColumnsNumber(), + 0, + m_scriptString); + + InitializeColumns(&nullDecimalInfo); + + Execute( + ColumnInfo::sm_rowsNumber, + nullDecimalInfo.m_dataSet.data(), + nullDecimalInfo.m_strLen_or_Ind.data(), + nullDecimalInfo.m_columnNames); + + // Verify metadata: both columns nullable, precision preserved despite NULLs + // + GetResultColumn( + 0, // columnNumber + SQL_C_NUMERIC, // dataType + 28, // columnSize (declared precision from input NUMERIC(28,6)) + 6, // decimalDigits (scale) + SQL_NULLABLE); // nullable (contains NULLs) + + GetResultColumn( + 1, // columnNumber + SQL_C_NUMERIC, // dataType + 15, // columnSize (declared precision from input NUMERIC(15,3)) + 3, // decimalDigits (scale) + SQL_NULLABLE); // nullable (contains NULLs) + } + + //---------------------------------------------------------------------------------------------- + // Name: DecimalHighScaleTest + // + // Description: + // Tests high scale (29-38) decimal values. + // These are valid SQL Server types and must be handled correctly. + // + TEST_F(CSharpExtensionApiTests, DecimalHighScaleTest) + { + using TestHelpers::CreateNumericStruct; + + InitializeSession( + 0, // inputSchemaColumnsNumber + 6); // parametersNumber + + // NUMERIC(38,29): Boundary at scale = 29 + // + SQL_NUMERIC_STRUCT p0 = CreateNumericStruct(1, SQL_NUMERIC_MAX_PRECISION, 29, false); + InitParam(0, p0); + + // NUMERIC(38,30): 123 at scale 30 + // + SQL_NUMERIC_STRUCT p1 = CreateNumericStruct(123, SQL_NUMERIC_MAX_PRECISION, 30, false); + InitParam(1, p1); + + // NUMERIC(38,35): Very high scale (3 significant digits) + // + SQL_NUMERIC_STRUCT p2 = CreateNumericStruct(123, SQL_NUMERIC_MAX_PRECISION, 35, false); + InitParam(2, p2); + + // NUMERIC(38,38): Maximum scale (smallest non-zero value) + // + SQL_NUMERIC_STRUCT p3 = CreateNumericStruct(1, SQL_NUMERIC_MAX_PRECISION, SQL_NUMERIC_MAX_PRECISION, false); + InitParam(3, p3); + + // NUMERIC(38,31): Negative with high scale + // + SQL_NUMERIC_STRUCT p4 = CreateNumericStruct(1, SQL_NUMERIC_MAX_PRECISION, 31, true); + InitParam(4, p4); + + // NUMERIC(38,32): Zero with high scale (remains zero) + // + SQL_NUMERIC_STRUCT p5 = CreateNumericStruct(0, SQL_NUMERIC_MAX_PRECISION, 32, false); + InitParam(5, p5); + } +} diff --git a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExecuteTests.cpp b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExecuteTests.cpp index 8a89fc7..4ca858b 100644 --- a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExecuteTests.cpp +++ b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExecuteTests.cpp @@ -528,4 +528,18 @@ namespace ExtensionApiTest EXPECT_TRUE(error.find("Error: Unable to find user class with full name:") != string::npos); } } + + //---------------------------------------------------------------------------------------------- + // Name: Execute (Explicit Template Instantiation) + // + // Description: + // Explicit template instantiation for Execute function with SQL_NUMERIC_STRUCT type. + // Required for linking decimal/numeric column tests that use SQL_C_NUMERIC data type. + // + template void CSharpExtensionApiTests::Execute( + SQLULEN rowsNumber, + void **dataSet, + SQLINTEGER **strLen_or_Ind, + vector columnNames, + SQLRETURN SQLResult); } diff --git a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExtensionApiTests.cpp b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExtensionApiTests.cpp index fed15af..6761b5d 100644 --- a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExtensionApiTests.cpp +++ b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExtensionApiTests.cpp @@ -390,6 +390,48 @@ namespace ExtensionApiTest } } + //---------------------------------------------------------------------------------------------- + // Name: CSharpExtensionApiTest::InitializeColumns + // + // Description: + // Template specialization for SQL_NUMERIC_STRUCT to extract precision from the struct + // instead of using sizeof() which gives the struct size. + // + template<> + void CSharpExtensionApiTests::InitializeColumns( + ColumnInfo *columnInfo) + { + SQLUSMALLINT inputSchemaColumnsNumber = columnInfo->GetColumnsNumber(); + for (SQLUSMALLINT columnNumber = 0; columnNumber < inputSchemaColumnsNumber; ++columnNumber) + { + // For NUMERIC columns, extract precision from the first non-NULL value in the column + // columnSize for NUMERIC represents precision (1-38), not bytes + // + SQLULEN precision = SqlDecimalMaxPrecision; // default to SQL Server max precision + const SQL_NUMERIC_STRUCT* columnData = + static_cast(columnInfo->m_dataSet[columnNumber]); + SQLINTEGER* strLenOrInd = columnInfo->m_strLen_or_Ind[columnNumber]; + + // Find first non-NULL value to get precision + // + for (SQLULEN row = 0; row < ColumnInfo::sm_rowsNumber; ++row) + { + if (strLenOrInd[row] != SQL_NULL_DATA) + { + precision = columnData[row].precision; + break; + } + } + + InitializeColumn(columnNumber, + columnInfo->m_columnNames[columnNumber], + SQL_C_NUMERIC, + precision, + columnInfo->m_nullable[columnNumber], + columnInfo->m_partitionByIndexes[columnNumber]); + } + } + //---------------------------------------------------------------------------------------------- // Name: ColumnInfo::ColumnInfo // @@ -485,6 +527,8 @@ namespace ExtensionApiTest ColumnInfo *ColumnInfo); template void CSharpExtensionApiTests::InitializeColumns( ColumnInfo *ColumnInfo); + template void CSharpExtensionApiTests::InitializeColumns( + ColumnInfo *ColumnInfo); template vector CSharpExtensionApiTests::GenerateContiguousData( vector columnVector, diff --git a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpInitParamTests.cpp b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpInitParamTests.cpp index 794c54e..58b95c4 100644 --- a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpInitParamTests.cpp +++ b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpInitParamTests.cpp @@ -771,4 +771,71 @@ namespace ExtensionApiTest return distance; } + + //---------------------------------------------------------------------------------------------- + // Name: InitParam (Template Specialization for SQL_NUMERIC_STRUCT) + // + // Description: + // Specialized template for SQL_NUMERIC_STRUCT that passes precision and scale + // from the struct to InitParam. + // The generic template passes decimalDigits=0, which + // causes InitParam to reject NUMERIC parameters with non-zero scale. + // + // Note: For output parameters with uninitialized structs (precision=0), uses defaults: + // precision=38, scale=0 to allow the C# executor to set the actual values later. + // + template<> + void CSharpExtensionApiTests::InitParam( + int paramNumber, + SQL_NUMERIC_STRUCT paramValue, + bool isNull, + SQLSMALLINT inputOutputType, + SQLRETURN SQLResult) + { + string paramName = "param" + to_string(paramNumber); + string atParam = "@" + paramName; + SQLCHAR *unsignedParamName = static_cast( + static_cast(const_cast(atParam.c_str()))); + + int paramNameLength = atParam.length(); + + SQL_NUMERIC_STRUCT *pParamValue = nullptr; + + if (!isNull) + { + pParamValue = &(paramValue); + } + + // For uninitialized structs (precision=0), use defaults for output parameters + // The C# executor will set the actual values during execution. + // NOTE: T-SQL, SQL Server always provides proper precision/scale metadata. + // This handles unit test scenarios where OUTPUT parameters are initialized with default structs. + // + SQLULEN precision = (isNull || paramValue.precision == 0) ? 38 : paramValue.precision; + SQLSMALLINT scale = (isNull || paramValue.precision == 0) ? 0 : paramValue.scale; + + SQLRETURN result = (*sm_initParamFuncPtr)( + *m_sessionId, + m_taskId, + paramNumber, + unsignedParamName, + paramNameLength, + SQL_C_NUMERIC, + precision, // paramSize = precision (not sizeof) + scale, // decimalDigits = scale from struct + pParamValue, // paramValue + pParamValue != nullptr ? sizeof(SQL_NUMERIC_STRUCT) : SQL_NULL_DATA, // strLenOrInd = 19 bytes + inputOutputType); // inputOutputType + + EXPECT_EQ(result, SQLResult); + } + + // Explicit template instantiations + // + template void CSharpExtensionApiTests::InitParam( + int paramNumber, + SQL_NUMERIC_STRUCT paramValue, + bool isNull = false, + SQLSMALLINT inputOutputType = SQL_PARAM_INPUT_OUTPUT, + SQLRETURN SQLResult = SQL_SUCCESS); }