Mapping rows to objects
Map<T>() reads rows into your classes. It is deferred: nothing runs until you call a terminal such as ToArray(), and rows are mapped as the reader streams.
// Columns are matched to public settable properties by name, ignoring case.
// The extra "views" column has no property, so it is ignored.
Post[] posts = connection
.Command("select * from posts order by id")
.Map<Post>()
.ToArray();
The rules
- Construction.
Tmust have a public parameterless constructor (where T : new()). Each row createsnew T(). - What is set. Only public properties that have a public setter. Public fields are not mapped.
- Matching. A column is matched to the property with the same name, ignoring case. A column with no matching property is ignored. A property with no matching column keeps the value its constructor gave it.
- Overrides. Per call, you can name the column for a property, or ignore a property.
- No other conventions. There is no constructor binding (positional records do not work), no snake_case folding, and no attributes. If a column name differs from the property name, use an override.
Overrides
Pass (Field, Column) pairs to Map<T>. A null column ignores the field:
// Field-to-column overrides for columns that do not share the property name.
Person[] people = connection
.Command("select person_id, first_name, last_name from people order by person_id")
.Map<Person>(("Id", "person_id"), ("FirstName", "first_name"), ("LastName", "last_name"))
.ToArray();
// A null column ignores the field: LastName stays at its default.
Person[] people = connection
.Command("select person_id, first_name, last_name from people order by person_id")
.Map<Person>(("Id", "person_id"), ("FirstName", "first_name"), ("LastName", null))
.ToArray();
Naming a field that is not a settable public property throws ArgumentException when Map is called, unless its column is null:
// A field name that is not a settable public property throws ArgumentException.
connection
.Command("select * from posts")
.Map<Post>(("Headline", "title"))
.ToArray();
Types and NULL
Values are read with the provider's typed getters (GetInt32, GetString, ...), so nothing is boxed on the common path. When the column type and the property type differ:
- integers convert to other integer types and to enums, range-checked (an overflow throws);
- integers and floating-point values widen to
float,doubleanddecimal; - anything else goes through a general conversion: a string to an enum (ignoring case) or a
Guid, abyte[]to aGuid, any value tostring, and otherwiseConvert.ChangeType.
NULL is honored for reference types and for Nullable<T> properties. A NULL in a column mapped to a non-nullable value-type property fails with an InvalidOperationException that names the property and the column.
Terminals
ToArray, ToList, ToImmutableArray, ToDictionary, ToHashSet, FirstOrDefault, SingleOrDefault and Read, each with an Async twin. Prefer ToArray for results you only read: it allocates one exactly sized array. An empty result is the shared empty array.
Post? first = connection.Command("select * from posts order by id").Map<Post>().FirstOrDefault();
// SingleOrDefault returns null for no rows and throws if there is more than one row.
Post? single = connection.Command("select * from posts where id = @id").AddParam("id", 2).Map<Post>().SingleOrDefault();
Skip and Take are part of the query: skipped rows are advanced without being mapped, and reading stops once the take is satisfied. Call Skip before Take, as the types enforce:
// Skipped rows are advanced without being mapped, and reading stops once the take is satisfied.
Post[] page = connection
.Command("select * from posts order by id")
.Map<Post>()
.Skip(1)
.Take(2)
.ToArray();
Read hands you the rows as a lazy sequence that is only valid inside the handler:
// Read hands you a lazy sequence that is only valid inside the handler, while the reader is open.
int total = connection
.Command("select * from posts order by id")
.Map<Post>()
.Read(posts => posts.Sum(post => post.Id));
Dictionary<int, Post> byId = await connection
.Command("select * from posts")
.Map<Post>()
.ToDictionaryAsync(post => post.Id, cancellationToken: cancellationToken);
Taking over: Map(selector)
When the rules are not enough, Map(Func<IDataRecord, T>) runs your function for each row. It has the same deferred terminals:
// Map(selector) is the escape hatch: you read the columns yourself.
string[] titles = connection
.Command("select title, views from posts order by id")
.Map(record => $"{record.GetString(0)} ({record.GetInt64(1)} views)")
.ToArray();
How it works
The mapping from a result-set shape (column names and column types) to T is compiled once and cached, so a repeated query does no reflection and no compilation. The reader is opened with SequentialAccess | SingleResult (plus SingleRow for FirstOrDefault when nothing is skipped). Map(selector) does not use SequentialAccess, because your function may read columns in any order.