Skip to content

Commit fad55cb

Browse files
Merge pull request #27 from Open-NET-Libraries/feature/10.1-phase5
10.1.0: release finalization + optimizations
2 parents c84d1ea + 81596c1 commit fad55cb

9 files changed

Lines changed: 154 additions & 54 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,22 @@ public static bool TryTransaction()
147147
- .NET 9.0 added to targets to ensure potential compilation and performance improvements are available.
148148
- Impelmented some .NET 8 and 9 specific features.
149149
- Significant cleanup and simplifcation where possible.
150+
151+
## 10.1.0 Release Notes
152+
153+
A modernization and allocation-reduction pass. **Source-compatible with 10.0** — existing code recompiles cleanly.
154+
155+
- **Target frameworks:** `net10.0` is now the primary/modern target. `netstandard2.0` and `netstandard2.1` are retained as shimmed legacy paths (net8/net9 consumers use the `netstandard2.1` build); `Open.Database.Extensions.MSSqlClient` also targets `net472`. The explicit `net8.0`/`net9.0` targets were dropped.
156+
- **Fewer allocations on the hot paths:** case-insensitive column-to-property matching now uses `StringComparer.OrdinalIgnoreCase` (and `FrozenDictionary` on `net10.0`) instead of allocating an upper-cased string per column; assorted per-query LINQ and dead code removed.
157+
- **Ergonomic field-mapping overrides:** `Results<T>`, `ResultsAsync<T>`, `To<T>`, etc. now accept target-typed `new(...)` and collection expressions:
158+
159+
```cs
160+
cmd.Results<Person>(new("FirstName", "first_name"), new("LastName", "last_name"));
161+
cmd.Results<Person>([new("FirstName", "first_name"), new("LastName", "last_name")]);
162+
```
163+
164+
- **Modernized `params`:** ordinal helpers (`Retrieve`, `AsEnumerable`, …) accept `params IEnumerable<int>`/`params IEnumerable<string>` — any enumerable, not just arrays.
165+
- **Read-only inputs:** several read-only `IList<T>` parameters were widened to `IReadOnlyList<T>` (`GetValuesFromOrdinals`, `EnumerateValuesFromOrdinals`, `ToDictionary`). Arrays, `List<T>`, `ImmutableArray<T>`, etc. all still bind. *(A custom type implementing only `IList<T>` — and not also `IReadOnlyList<T>` — would need to recompile; no BCL collection is affected.)*
166+
- **Lower per-query overhead:** `Transformer<T>` caches its per-type reflection once instead of rebuilding it on every query; buffered/data-table paths pre-size collections and avoid redundant `Select` iterators.
167+
- Fixed a wasted-allocation / incorrect-return bug in `CopyToDBNullAsNull`.
168+
- Public API surface is now tracked with `Microsoft.CodeAnalysis.PublicApiAnalyzers`, and unit-test coverage was substantially expanded.

Source/Core/Core/Transformer.cs

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,28 @@ public class Transformer<T>
2727
/// <summary>
2828
/// The type of <typeparamref name="T"/>.
2929
/// </summary>
30-
public Type Type { get; }
30+
public Type Type => typeof(T);
3131

32-
private readonly PropertyInfo[] _properties;
32+
// Reflection is invariant per T; a static on this generic type is per-T and initialized once
33+
// (thread-safe), so GetProperties() and the name->PropertyInfo map aren't rebuilt per query.
34+
// Built once, read on every query (property lookups + Keys) -> frozen on the modern target.
35+
[SuppressMessage("Roslynator", "RCS1158:Static member in generic type should use a type parameter.", Justification = "Per-T reflection cache is intentional.")]
36+
#if NET10_0_OR_GREATER
37+
static readonly FrozenDictionary<string, PropertyInfo> PropertiesByName
38+
= typeof(T).GetProperties().ToFrozenDictionary(p => p.Name);
39+
#else
40+
static readonly Dictionary<string, PropertyInfo> PropertiesByName
41+
= typeof(T).GetProperties().ToDictionary(p => p.Name);
42+
#endif
3343

3444
// Allow mapping key = object property, value = column name.
3545
readonly Dictionary<string, string> _propertyMap;
3646

3747
// Column-name -> property lookup, matched case-insensitively (OrdinalIgnoreCase) so no
38-
// upper-cased key strings are allocated. Built once and never mutated after construction,
39-
// so it is frozen on the modern target for faster reads.
40-
#if NET10_0_OR_GREATER
41-
readonly FrozenDictionary<string, PropertyInfo> _columnToPropertyMap;
42-
#else
48+
// upper-cased key strings are allocated. This map is per-query (a fresh Transformer is built
49+
// per operation) and read only a handful of times, so a plain Dictionary is correct here —
50+
// freezing a short-lived map costs more to build than the few lookups would ever recover.
4351
readonly Dictionary<string, PropertyInfo> _columnToPropertyMap;
44-
#endif
4552

4653
/// <summary>
4754
/// The property names.
@@ -58,31 +65,25 @@ public class Transformer<T>
5865
/// </summary>
5966
protected internal Transformer(IEnumerable<(string Field, string? Column)>? overrides = null)
6067
{
61-
Type = typeof(T);
62-
_properties = Type.GetProperties();
63-
_propertyMap = _properties.Select(p => p.Name).ToDictionary(n => n);
64-
65-
Dictionary<string, PropertyInfo> pm = _properties.ToDictionary(p => p.Name);
68+
// Per-instance copy of the default (property-name -> same column-name) map, sized exactly,
69+
// so the optional overrides can mutate it without touching the shared reflection cache.
70+
_propertyMap = new Dictionary<string, string>(PropertiesByName.Count);
71+
foreach (string name in PropertiesByName.Keys)
72+
_propertyMap[name] = name;
6673

6774
if (overrides != null)
6875
{
6976
foreach ((string Field, string? Column) in overrides)
7077
{
71-
string? cn = Column;
72-
if (cn == null) _propertyMap.Remove(Field); // Null values indicate a desire to 'ignore' a field.
73-
else _propertyMap[Field] = cn;
78+
if (Column == null) _propertyMap.Remove(Field); // Null column indicates 'ignore this field'.
79+
else _propertyMap[Field] = Column;
7480
}
7581
}
7682

77-
// Project column-name -> property straight into the target map with a case-insensitive
78-
// comparer: no upper-cased key strings, and no intermediate Dictionary on the frozen path.
79-
#if NET10_0_OR_GREATER
80-
_columnToPropertyMap = _propertyMap.ToFrozenDictionary(
81-
kvp => kvp.Value, kvp => pm[kvp.Key], StringComparer.OrdinalIgnoreCase);
82-
#else
83+
// Project column-name -> property directly with a case-insensitive comparer (no upper-cased
84+
// key strings). A plain Dictionary — this map lives only for the duration of one query.
8385
_columnToPropertyMap = _propertyMap.ToDictionary(
84-
kvp => kvp.Value, kvp => pm[kvp.Key], StringComparer.OrdinalIgnoreCase);
85-
#endif
86+
kvp => kvp.Value, kvp => PropertiesByName[kvp.Key], StringComparer.OrdinalIgnoreCase);
8687
}
8788

8889
/// <summary>
@@ -207,6 +208,24 @@ public IEnumerable<T> AsDequeueingEnumerable(QueryResult<Queue<object[]>> result
207208
});
208209
}
209210

211+
// Splits the matched (Name, Ordinal) columns into an ordinal array (which hits the IList<int>
212+
// fast path in the reader enumeration) and an exactly-sized immutable name array, in one pass —
213+
// avoiding two lazy Select iterators and a growing ImmutableArray builder per query.
214+
static (int[] Ordinals, ImmutableArray<string> Names) SplitColumns((string Name, int Ordinal)[] columns)
215+
{
216+
int n = columns.Length;
217+
var ordinals = new int[n];
218+
var names = ImmutableArray.CreateBuilder<string>(n);
219+
names.Count = n;
220+
for (int i = 0; i < n; i++)
221+
{
222+
ordinals[i] = columns[i].Ordinal;
223+
names[i] = columns[i].Name;
224+
}
225+
226+
return (ordinals, names.MoveToImmutable());
227+
}
228+
210229
/// <inheritdoc cref="ResultsBuffered(IDataReader, bool)"/>
211230
internal IEnumerable<T> Results(IDataReader reader)
212231
{
@@ -215,11 +234,12 @@ internal IEnumerable<T> Results(IDataReader reader)
215234

216235
// Ignore missing columns.
217236
(string Name, int Ordinal)[] columns = reader.GetMatchingOrdinals(_propertyMap.Values, true);
218-
var processor = new Processor(this, columns.Select(m => m.Name).ToImmutableArray());
237+
(int[] ordinals, ImmutableArray<string> names) = SplitColumns(columns);
238+
var processor = new Processor(this, names);
219239
Func<object?[], T> transform = processor.Transform;
220240

221241
return reader
222-
.AsEnumerable(columns.Select(m => m.Ordinal), LocalPool)
242+
.AsEnumerable(ordinals, LocalPool)
223243
.Select(a =>
224244
{
225245
try
@@ -250,6 +270,9 @@ internal IEnumerable<T> ResultsBuffered(IDataReader reader, bool readStarted)
250270
// Ignore missing columns.
251271
(string Name, int Ordinal)[] columns = reader.GetMatchingOrdinals(_propertyMap.Values, true);
252272

273+
// NOTE: pass the lazy projections straight through — RetrieveInternal materializes the
274+
// ordinals into an ImmutableArray that QueryResult then reuses zero-copy (Immute fast path);
275+
// pre-materializing here would add a redundant array.
253276
return AsDequeueingEnumerable(
254277
CoreExtensions.RetrieveInternal(
255278
LocalPool,
@@ -275,9 +298,10 @@ async IAsyncEnumerable<T> ResultsAsyncCore(DbDataReader reader, [EnumeratorCance
275298
{
276299
// Ignore missing columns.
277300
(string Name, int Ordinal)[] columns = reader.GetMatchingOrdinals(_propertyMap.Values, true);
278-
var processor = new Processor(this, columns.Select(m => m.Name).ToImmutableArray());
301+
(int[] ordinals, ImmutableArray<string> names) = SplitColumns(columns);
302+
var processor = new Processor(this, names);
279303

280-
await foreach (object[] a in reader.AsAsyncEnumerable(columns.Select(m => m.Ordinal), LocalPool, cancellationToken))
304+
await foreach (object[] a in reader.AsAsyncEnumerable(ordinals, LocalPool, cancellationToken))
281305
{
282306
try
283307
{
@@ -303,17 +327,30 @@ public IEnumerable<T> Results(DataTable table, bool clearTable)
303327
if (table is null) throw new ArgumentNullException(nameof(table));
304328
Contract.EndContractBlock();
305329

306-
int columnCount = table.Columns.Count;
307-
IEnumerable<DataColumn> columns = table.Columns.AsEnumerable();
308-
var results = new QueryResult<Queue<object[]>>(
309-
columns.Select(c => c.Ordinal),
310-
columns.Select(c => c.ColumnName),
311-
new Queue<object[]>(table.Rows.AsEnumerable().Select(r =>
312-
{
313-
object[] a = LocalPool.Rent(columnCount);
314-
for (int i = 0; i < columnCount; i++) a[i] = r[i];
315-
return a;
316-
})));
330+
DataColumnCollection cols = table.Columns;
331+
int columnCount = cols.Count;
332+
333+
// Ordinals + names in a single pass (no double Select iterators).
334+
var ordinals = new int[columnCount];
335+
var names = new string[columnCount];
336+
for (int i = 0; i < columnCount; i++)
337+
{
338+
DataColumn c = cols[i];
339+
ordinals[i] = c.Ordinal;
340+
names[i] = c.ColumnName;
341+
}
342+
343+
// The row count is known, so pre-size the queue and fill it directly.
344+
DataRowCollection rows = table.Rows;
345+
var buffer = new Queue<object[]>(rows.Count);
346+
foreach (DataRow r in rows)
347+
{
348+
object[] a = LocalPool.Rent(columnCount);
349+
for (int i = 0; i < columnCount; i++) a[i] = r[i];
350+
buffer.Enqueue(a);
351+
}
352+
353+
var results = new QueryResult<Queue<object[]>>(ordinals, names, buffer);
317354

318355
if (clearTable) table.Rows.Clear();
319356
return AsDequeueingEnumerable(results, LocalPool);

Source/Core/Extensions/DataRecord.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -214,15 +214,15 @@ static IEnumerable<object> EnumerateValuesFromOrdinalsCore(IDataRecord record, I
214214
}
215215

216216
/// <inheritdoc cref="EnumerateValuesFromOrdinals(IDataRecord, IEnumerable{int})"/>
217-
public static IEnumerable<object> EnumerateValuesFromOrdinals(this IDataRecord record, IList<int> ordinals)
217+
public static IEnumerable<object> EnumerateValuesFromOrdinals(this IDataRecord record, IReadOnlyList<int> ordinals)
218218
{
219219
return record is null
220220
? throw new ArgumentNullException(nameof(record))
221221
: ordinals is null
222222
? throw new ArgumentNullException(nameof(ordinals))
223223
: EnumerateValuesFromOrdinalsCore(record, ordinals);
224224

225-
static IEnumerable<object> EnumerateValuesFromOrdinalsCore(IDataRecord record, IList<int> ordinals)
225+
static IEnumerable<object> EnumerateValuesFromOrdinalsCore(IDataRecord record, IReadOnlyList<int> ordinals)
226226
{
227227
// Avoid creating an another enumerator if possible.
228228
int count = ordinals.Count;
@@ -267,7 +267,7 @@ public static Span<object> GetValuesFromOrdinals(this IDataRecord record, ReadOn
267267

268268
/// <returns>The provided list, updated with values matching the ordinal positions requested.</returns>
269269
/// <inheritdoc cref="GetValuesFromOrdinals(IDataRecord, ReadOnlySpan{int}, Span{object})"/>
270-
public static TList GetValuesFromOrdinals<TList>(this IDataRecord record, IList<int> ordinals, TList values)
270+
public static TList GetValuesFromOrdinals<TList>(this IDataRecord record, IReadOnlyList<int> ordinals, TList values)
271271
where TList : IList<object>
272272
{
273273
if (record is null) throw new ArgumentNullException(nameof(record));
@@ -282,7 +282,7 @@ public static TList GetValuesFromOrdinals<TList>(this IDataRecord record, IList<
282282

283283
/// <returns>An array of values matching the ordinal positions requested.</returns>
284284
/// <inheritdoc cref="GetValuesFromOrdinals(IDataRecord, ReadOnlySpan{int}, Span{object})"/>
285-
public static object[] GetValuesFromOrdinals(this IDataRecord record, IList<int> ordinals)
285+
public static object[] GetValuesFromOrdinals(this IDataRecord record, IReadOnlyList<int> ordinals)
286286
{
287287
if (record is null) throw new ArgumentNullException(nameof(record));
288288
if (ordinals is null) throw new ArgumentNullException(nameof(ordinals));
@@ -418,7 +418,7 @@ public static string[] GetDataTypeNames(this IDataRecord record)
418418
/// <param name="record">The <see cref="IDataRecord"/> to extract values from.</param>
419419
/// <param name="ordinalMapping">The column ids and resultant names to query.</param>
420420
/// <returns>The resultant Dictionary of values.</returns>
421-
public static Dictionary<string, object?> ToDictionary(this IDataRecord record, IList<(string Name, int Ordinal)> ordinalMapping)
421+
public static Dictionary<string, object?> ToDictionary(this IDataRecord record, IReadOnlyList<(string Name, int Ordinal)> ordinalMapping)
422422
{
423423
if (record is null) throw new ArgumentNullException(nameof(record));
424424
if (ordinalMapping is null) throw new ArgumentNullException(nameof(ordinalMapping));
@@ -484,7 +484,7 @@ public static string[] GetDataTypeNames(this IDataRecord record)
484484
/// <param name="record">The <see cref="IDataRecord"/> to extract values from.</param>
485485
/// <param name="columnNames">The column names to query.</param>
486486
/// <returns>The resultant Dictionary of values.</returns>
487-
public static Dictionary<string, object?> ToDictionary(this IDataRecord record, IList<string> columnNames)
487+
public static Dictionary<string, object?> ToDictionary(this IDataRecord record, IReadOnlyList<string> columnNames)
488488
{
489489
if (record is null) throw new ArgumentNullException(nameof(record));
490490
if (columnNames is null) throw new ArgumentNullException(nameof(columnNames));

Source/Core/Extensions/Retrieve.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,19 @@ public static QueryResultQueue<object[]> Retrieve(this IDataReader reader)
3434
{
3535
ImmutableArray<string> names = reader.GetNames();
3636
return new QueryResultQueue<object[]>(
37-
Enumerable.Range(0, names.Length), names,
37+
IdentityOrdinals(names.Length), names,
3838
reader.AsEnumerable());
3939
}
4040

41+
// Builds [0, 1, ... n-1] directly as an ImmutableArray (exact capacity, and reused zero-copy by
42+
// QueryResult) instead of an Enumerable.Range that a growing ToImmutableArray would then copy.
43+
static ImmutableArray<int> IdentityOrdinals(int count)
44+
{
45+
var b = ImmutableArray.CreateBuilder<int>(count);
46+
for (int i = 0; i < count; i++) b.Add(i);
47+
return b.MoveToImmutable();
48+
}
49+
4150
/// <summary>
4251
/// Iterates all records within the current result set using an <see cref="IDataReader"/> and returns the desired results.
4352
/// </summary>
@@ -131,7 +140,7 @@ public static async ValueTask<QueryResultQueue<object[]>> RetrieveAsync(this DbD
131140
if (!useReadAsync) cancellationToken.ThrowIfCancellationRequested();
132141

133142
return new QueryResultQueue<object[]>(
134-
Enumerable.Range(0, names.Length),
143+
IdentityOrdinals(names.Length),
135144
names,
136145
buffer);
137146
}

Source/Core/Extensions/_.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ static IEnumerable<DataRow> AsEnumerableCore(DataRowCollection rows)
187187
.Results(table, clearSourceTable);
188188

189189
/// <inheritdoc cref="To{T}(DataTable, IEnumerable{KeyValuePair{string, string?}}?, bool)"/>
190+
[System.Runtime.CompilerServices.OverloadResolutionPriority(1)]
190191
public static IEnumerable<T> To<T>(this DataTable table, params IEnumerable<(string Field, string? Column)> fieldMappingOverrides) where T : new()
191192
=> Transformer<T>
192193
.Create(fieldMappingOverrides)

0 commit comments

Comments
 (0)