Skip to content

Commit af96d32

Browse files
committed
Add SQL Server structured parameter support
1 parent d318000 commit af96d32

15 files changed

Lines changed: 680 additions & 72 deletions
Lines changed: 106 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
using System.Data;
2-
3-
using FluentCommand.Extensions;
2+
using System.Data.Common;
43

54
using Microsoft.Data.SqlClient;
5+
using Microsoft.Data.SqlClient.Server;
66

77
namespace FluentCommand;
88

@@ -13,7 +13,8 @@ public static class SqlCommandExtensions
1313
{
1414
/// <summary>
1515
/// Adds a new SQL Server structured table-valued parameter with the specified <paramref name="name"/> and <paramref name="data"/>.
16-
/// Converts the enumerable data to a <see cref="DataTable"/> and adds it as a parameter.
16+
/// Uses <see cref="SqlDataRecordAdapter{T}"/> internally for maximum efficiency by reusing a single record per row
17+
/// with cached metadata per type.
1718
/// </summary>
1819
/// <typeparam name="TEntity">The type of the data entities.</typeparam>
1920
/// <param name="dataCommand">The <see cref="IDataCommand"/> to extend.</param>
@@ -22,11 +23,88 @@ public static class SqlCommandExtensions
2223
/// <returns>
2324
/// The same <see cref="IDataCommand"/> instance for fluent chaining.
2425
/// </returns>
25-
public static IDataCommand SqlParameter<TEntity>(this IDataCommand dataCommand, string name, IEnumerable<TEntity> data)
26+
public static IDataCommand ParameterStructured<TEntity>(this IDataCommand dataCommand, string name, IEnumerable<TEntity> data)
2627
where TEntity : class
2728
{
28-
var dataTable = data.ToDataTable();
29-
return SqlParameter(dataCommand, name, dataTable);
29+
if (dataCommand is null)
30+
throw new ArgumentNullException(nameof(dataCommand));
31+
32+
if (string.IsNullOrEmpty(name))
33+
throw new ArgumentException($"'{nameof(name)}' cannot be null or empty.", nameof(name));
34+
35+
if (data is null)
36+
throw new ArgumentNullException(nameof(data));
37+
38+
var records = new SqlDataRecordAdapter<TEntity>(data);
39+
return ParameterStructured(dataCommand, name, records);
40+
}
41+
42+
/// <summary>
43+
/// Adds a new SQL Server structured table-valued parameter with the specified <paramref name="name"/> and <paramref name="records"/>.
44+
/// Uses <see cref="IEnumerable{SqlDataRecord}"/> for maximum efficiency with minimal memory allocation.
45+
/// </summary>
46+
/// <param name="dataCommand">The <see cref="IDataCommand"/> to extend.</param>
47+
/// <param name="name">The name of the parameter.</param>
48+
/// <param name="records">The <see cref="IEnumerable{SqlDataRecord}"/> to be added as a table-valued parameter.</param>
49+
/// <returns>
50+
/// The same <see cref="IDataCommand"/> instance for fluent chaining.
51+
/// </returns>
52+
/// <exception cref="InvalidOperationException">
53+
/// Thrown if the underlying command is not a SQL Server command.
54+
/// </exception>
55+
public static IDataCommand ParameterStructured(this IDataCommand dataCommand, string name, IEnumerable<SqlDataRecord> records)
56+
{
57+
if (dataCommand is null)
58+
throw new ArgumentNullException(nameof(dataCommand));
59+
60+
if (string.IsNullOrEmpty(name))
61+
throw new ArgumentException($"'{nameof(name)}' cannot be null or empty.", nameof(name));
62+
63+
if (records is null)
64+
throw new ArgumentNullException(nameof(records));
65+
66+
var sqlParameter = CreateSqlParameter(dataCommand);
67+
68+
sqlParameter.ParameterName = name;
69+
sqlParameter.Value = records;
70+
sqlParameter.Direction = ParameterDirection.Input;
71+
sqlParameter.SqlDbType = SqlDbType.Structured;
72+
73+
return dataCommand.Parameter(sqlParameter);
74+
}
75+
76+
/// <summary>
77+
/// Adds a new SQL Server structured table-valued parameter with the specified <paramref name="name"/> and <paramref name="dataReader"/>.
78+
/// Uses <see cref="DbDataReader"/> for streaming data to the server without materializing to a <see cref="DataTable"/>.
79+
/// </summary>
80+
/// <param name="dataCommand">The <see cref="IDataCommand"/> to extend.</param>
81+
/// <param name="name">The name of the parameter.</param>
82+
/// <param name="dataReader">The <see cref="DbDataReader"/> to be added as a table-valued parameter.</param>
83+
/// <returns>
84+
/// The same <see cref="IDataCommand"/> instance for fluent chaining.
85+
/// </returns>
86+
/// <exception cref="InvalidOperationException">
87+
/// Thrown if the underlying command is not a SQL Server command.
88+
/// </exception>
89+
public static IDataCommand ParameterStructured(this IDataCommand dataCommand, string name, DbDataReader dataReader)
90+
{
91+
if (dataCommand is null)
92+
throw new ArgumentNullException(nameof(dataCommand));
93+
94+
if (string.IsNullOrEmpty(name))
95+
throw new ArgumentException($"'{nameof(name)}' cannot be null or empty.", nameof(name));
96+
97+
if (dataReader is null)
98+
throw new ArgumentNullException(nameof(dataReader));
99+
100+
var sqlParameter = CreateSqlParameter(dataCommand);
101+
102+
sqlParameter.ParameterName = name;
103+
sqlParameter.Value = dataReader;
104+
sqlParameter.Direction = ParameterDirection.Input;
105+
sqlParameter.SqlDbType = SqlDbType.Structured;
106+
107+
return dataCommand.Parameter(sqlParameter);
30108
}
31109

32110
/// <summary>
@@ -41,13 +119,18 @@ public static IDataCommand SqlParameter<TEntity>(this IDataCommand dataCommand,
41119
/// <exception cref="InvalidOperationException">
42120
/// Thrown if the underlying command is not a SQL Server command.
43121
/// </exception>
44-
public static IDataCommand SqlParameter(this IDataCommand dataCommand, string name, DataTable dataTable)
122+
public static IDataCommand ParameterStructured(this IDataCommand dataCommand, string name, DataTable dataTable)
45123
{
46-
var parameter = dataCommand.Command.CreateParameter();
47-
var sqlParameter = parameter as SqlParameter;
48-
if (sqlParameter == null)
49-
throw new InvalidOperationException(
50-
"SqlParameter only supported by SQL Server. Make sure DataSession was created with a valid SqlConnection.");
124+
if (dataCommand is null)
125+
throw new ArgumentNullException(nameof(dataCommand));
126+
127+
if (string.IsNullOrEmpty(name))
128+
throw new ArgumentException($"'{nameof(name)}' cannot be null or empty.", nameof(name));
129+
130+
if (dataTable is null)
131+
throw new ArgumentNullException(nameof(dataTable));
132+
133+
var sqlParameter = CreateSqlParameter(dataCommand);
51134

52135
sqlParameter.ParameterName = name;
53136
sqlParameter.Value = dataTable;
@@ -56,4 +139,15 @@ public static IDataCommand SqlParameter(this IDataCommand dataCommand, string na
56139

57140
return dataCommand.Parameter(sqlParameter);
58141
}
142+
143+
private static SqlParameter CreateSqlParameter(IDataCommand dataCommand)
144+
{
145+
var parameter = dataCommand.Command.CreateParameter();
146+
var sqlParameter = parameter as SqlParameter;
147+
if (sqlParameter == null)
148+
throw new InvalidOperationException(
149+
"SqlParameter only supported by SQL Server. Make sure DataSession was created with a valid SqlConnection.");
150+
151+
return sqlParameter;
152+
}
59153
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using System.Collections;
2+
using System.Collections.Concurrent;
3+
using System.Data;
4+
5+
using FluentCommand.Extensions;
6+
using FluentCommand.Reflection;
7+
8+
using Microsoft.Data.SqlClient.Server;
9+
10+
namespace FluentCommand;
11+
12+
/// <summary>
13+
/// Adapts an <see cref="IEnumerable{T}"/> to an <see cref="IEnumerable{SqlDataRecord}"/> for use
14+
/// as a SQL Server table-valued parameter. Reuses a single <see cref="SqlDataRecord"/> per row
15+
/// for minimal allocation. Caches <see cref="SqlMetaData"/> per type for efficiency.
16+
/// </summary>
17+
/// <typeparam name="T">The type of items being adapted.</typeparam>
18+
public class SqlDataRecordAdapter<T> : IEnumerable<SqlDataRecord> where T : class
19+
{
20+
// ReSharper disable once StaticMemberInGenericType
21+
private static readonly ConcurrentDictionary<Type, (SqlMetaData[] MetaData, IMemberAccessor[] Columns)> _metaDataCache = new();
22+
23+
private readonly IEnumerable<T> _source;
24+
25+
/// <summary>
26+
/// Initializes a new instance of the <see cref="SqlDataRecordAdapter{T}"/> class.
27+
/// </summary>
28+
/// <param name="source">The source collection to adapt.</param>
29+
/// <exception cref="ArgumentNullException">Thrown if <paramref name="source"/> is null.</exception>
30+
public SqlDataRecordAdapter(IEnumerable<T> source)
31+
{
32+
_source = source ?? throw new ArgumentNullException(nameof(source));
33+
}
34+
35+
/// <inheritdoc/>
36+
public IEnumerator<SqlDataRecord> GetEnumerator()
37+
{
38+
var (metaData, columns) = GetCachedMetaData();
39+
var record = new SqlDataRecord(metaData);
40+
41+
foreach (var item in _source)
42+
{
43+
for (int i = 0; i < columns.Length; i++)
44+
{
45+
var value = columns[i].GetValue(item);
46+
if (value is null)
47+
record.SetDBNull(i);
48+
else
49+
record.SetValue(i, value);
50+
}
51+
52+
yield return record;
53+
}
54+
}
55+
56+
/// <inheritdoc/>
57+
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
58+
59+
private static (SqlMetaData[] MetaData, IMemberAccessor[] Columns) GetCachedMetaData()
60+
{
61+
return _metaDataCache.GetOrAdd(typeof(T), _ => BuildMetaData());
62+
}
63+
64+
private static (SqlMetaData[] MetaData, IMemberAccessor[] Columns) BuildMetaData()
65+
{
66+
var typeAccessor = TypeAccessor.GetAccessor<T>();
67+
var properties = typeAccessor.GetProperties().ToList();
68+
69+
var metaData = new SqlMetaData[properties.Count];
70+
var columns = new IMemberAccessor[properties.Count];
71+
72+
for (int i = 0; i < properties.Count; i++)
73+
{
74+
var property = properties[i];
75+
columns[i] = property;
76+
77+
var underlyingType = property.MemberType.GetUnderlyingType();
78+
var sqlDbType = SqlTypeMapping.DbType(underlyingType);
79+
80+
metaData[i] = sqlDbType switch
81+
{
82+
SqlDbType.NVarChar => new SqlMetaData(property.Column, sqlDbType, -1),
83+
SqlDbType.VarBinary => new SqlMetaData(property.Column, sqlDbType, -1),
84+
SqlDbType.Decimal => new SqlMetaData(property.Column, sqlDbType, 18, 6),
85+
_ => new SqlMetaData(property.Column, sqlDbType)
86+
};
87+
}
88+
89+
return (metaData, columns);
90+
}
91+
}

src/FluentCommand.SqlServer/SqlTypeMapping.cs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
1+
using System.Data;
2+
13
namespace FluentCommand;
24

35
/// <summary>
46
/// Provides mapping between .NET types and their corresponding SQL Server native types.
57
/// </summary>
68
public static class SqlTypeMapping
79
{
8-
private static readonly Dictionary<Type, string> _nativeType = new Dictionary<Type, string>
10+
private static readonly Dictionary<Type, string> _nativeType = new()
911
{
1012
{typeof(bool), "bit"},
13+
{typeof(sbyte), "smallint"},
1114
{typeof(byte), "tinyint"},
1215
{typeof(short), "smallint"},
16+
{typeof(ushort), "int"},
1317
{typeof(int), "int"},
18+
{typeof(uint), "bigint"},
1419
{typeof(long), "bigint"},
20+
{typeof(ulong), "decimal(20,0)"},
1521
{typeof(float), "real"},
1622
{typeof(double), "float"},
1723
{typeof(decimal), "decimal"},
1824
{typeof(byte[]), "varbinary(MAX)"},
1925
{typeof(string), "nvarchar(MAX)"},
26+
{typeof(char), "nchar(1)"},
2027
{typeof(TimeSpan), "time"},
2128
{typeof(DateTime), "datetime2"},
2229
{typeof(DateTimeOffset), "datetimeoffset"},
@@ -27,6 +34,33 @@ public static class SqlTypeMapping
2734
#endif
2835
};
2936

37+
private static readonly Dictionary<Type, SqlDbType> _dbType = new()
38+
{
39+
{typeof(bool), SqlDbType.Bit},
40+
{typeof(sbyte), SqlDbType.SmallInt},
41+
{typeof(byte), SqlDbType.TinyInt},
42+
{typeof(short), SqlDbType.SmallInt},
43+
{typeof(ushort), SqlDbType.Int},
44+
{typeof(int), SqlDbType.Int},
45+
{typeof(uint), SqlDbType.BigInt},
46+
{typeof(long), SqlDbType.BigInt},
47+
{typeof(ulong), SqlDbType.Decimal},
48+
{typeof(float), SqlDbType.Real},
49+
{typeof(double), SqlDbType.Float},
50+
{typeof(decimal), SqlDbType.Decimal},
51+
{typeof(byte[]), SqlDbType.VarBinary},
52+
{typeof(string), SqlDbType.NVarChar},
53+
{typeof(char), SqlDbType.NChar},
54+
{typeof(TimeSpan), SqlDbType.Time},
55+
{typeof(DateTime), SqlDbType.DateTime2},
56+
{typeof(DateTimeOffset), SqlDbType.DateTimeOffset},
57+
{typeof(Guid), SqlDbType.UniqueIdentifier},
58+
#if NET6_0_OR_GREATER
59+
{typeof(DateOnly), SqlDbType.Date},
60+
{typeof(TimeOnly), SqlDbType.Time},
61+
#endif
62+
};
63+
3064
/// <summary>
3165
/// Gets the SQL Server native type name for the specified generic .NET type <typeparamref name="T"/>.
3266
/// </summary>
@@ -51,9 +85,39 @@ public static string NativeType<T>()
5185
public static string NativeType(Type type)
5286
{
5387
var dataType = Nullable.GetUnderlyingType(type) ?? type;
88+
dataType = dataType.IsEnum ? Enum.GetUnderlyingType(dataType) : dataType;
5489

5590
_nativeType.TryGetValue(dataType, out var value);
5691

5792
return value ?? "sql_variant";
5893
}
94+
95+
/// <summary>
96+
/// Gets the SQL Server <see cref="SqlDbType"/> for the specified generic .NET type <typeparamref name="T"/>.
97+
/// </summary>
98+
/// <typeparam name="T">The .NET type to map to a SQL Server <see cref="SqlDbType"/>.</typeparam>
99+
/// <returns>
100+
/// The SQL Server <see cref="SqlDbType"/> value.
101+
/// Returns <see cref="SqlDbType.NVarChar"/> if the type is not explicitly mapped.
102+
/// </returns>
103+
public static SqlDbType DbType<T>()
104+
{
105+
return DbType(typeof(T));
106+
}
107+
108+
/// <summary>
109+
/// Gets the SQL Server <see cref="SqlDbType"/> for the specified <see cref="Type"/>.
110+
/// </summary>
111+
/// <param name="type">The .NET <see cref="Type"/> to map to a SQL Server <see cref="SqlDbType"/>.</param>
112+
/// <returns>
113+
/// The SQL Server <see cref="SqlDbType"/> value.
114+
/// Returns <see cref="SqlDbType.NVarChar"/> if the type is not explicitly mapped.
115+
/// </returns>
116+
public static SqlDbType DbType(Type type)
117+
{
118+
var dataType = Nullable.GetUnderlyingType(type) ?? type;
119+
dataType = dataType.IsEnum ? Enum.GetUnderlyingType(dataType) : dataType;
120+
121+
return _dbType.TryGetValue(dataType, out var value) ? value : SqlDbType.NVarChar;
122+
}
59123
}

src/FluentCommand/Extensions/DataRecordExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public static byte[] GetBytes(this IDataRecord dataRecord, string name)
6262
{
6363
int ordinal = dataRecord.GetOrdinal(name);
6464
if (dataRecord.IsDBNull(ordinal))
65-
return Array.Empty<byte>();
65+
return [];
6666

6767
return GetBytes(dataRecord, ordinal);
6868
}

0 commit comments

Comments
 (0)