I am fetching nested 1-to-many entities dynamically. Dapper is not mapping 1-to-many nested entities correctly. Within map function, objs has 2 items. objs[0] is the parent. Parent entity is good with valid id value but children collection has 0 items. objs[1] is child item type but it has null values and Id = 0.
The generated query from the below code is OK, I can successfully run it in pgadmin, it returns 2 rows, but Dapper is not populating objs[0] with child items in parent object. And objs[1] is child type object that has all null and Id 0.
public async Task<object?> GetByIdAsync(DynamicGetByIdRequest getByIdReq, CancellationToken cancellationToken = default)
{
Type rootType = ResolveEntityType(getByIdReq.DynamicGetParameter!.EntityName);
var sqlMeta = _metaExtractor.Extract(rootType);
var queryParts = BuildQuery(getByIdReq.DynamicGetParameter, sqlMeta, rootType, "T0");
string sql = $@"SELECT {queryParts.SelectClause}
FROM {sqlMeta.Table.Quote()} AS {"T0".Quote()}
{queryParts.JoinClause}
WHERE {"T0".Quote()}.{"Id".Quote()} = @id";
var lookup = new Dictionary<int, object>();
await _db.QueryAsync(
sql,
types: queryParts.Types.ToArray(),
map: (object[] objs) =>
{
var root = objs[0];
var rootId = (int)root.GetType().GetProperty("Id").GetValue(root);
if (!lookup.TryGetValue(rootId, out object existing))
{
lookup.Add(rootId, root);
existing = root;
// Initialize collection properties
foreach (var prop in root.GetType().GetProperties())
{
if (prop.PropertyType.IsGenericType &&
prop.PropertyType.GetGenericTypeDefinition() == typeof(List<>))
{
prop.SetValue(existing, Activator.CreateInstance(prop.PropertyType));
}
}
}
// Handle child objects
for (int i = 1; i < objs.Length; i++)
{
var child = objs[i];
if (child == null)
continue;
var childType = queryParts.Types[i];
var navProp = rootType.GetProperties()
.FirstOrDefault(p => p.PropertyType.IsGenericType &&
p.PropertyType.GetGenericTypeDefinition() == typeof(List<>) &&
p.PropertyType.GetGenericArguments()[0] == childType);
if (navProp != null)
{
var collection = navProp.GetValue(existing) as IList;
var childId = (int)child.GetType().GetProperty("Id").GetValue(child);
if (!collection.Cast<object>().Any(x =>
(int)x.GetType().GetProperty("Id").GetValue(x) == childId))
{
collection.Add(child);
}
}
}
return existing;
},
param: new { id = getByIdReq.Id },
splitOn: string.Join(",", queryParts.SplitOnColumns)
);
return lookup.Values.FirstOrDefault();
}
private (List<Type> Types, string SelectClause, string JoinClause, List<string> SplitOnColumns)
BuildQuery(DynamicGetParameter param, SqlMeta meta, Type entityType, string alias, string? parentAlias = null)
{
var types = new List<Type> { entityType };
var selectParts = new List<string>();
var joinParts = new List<string>();
var splitOnColumns = new List<string>();
// 1. Select all scalar properties for root entity
foreach (var prop in meta.ScalarProps)
{
if (param.Properties == null || param.Properties.Contains(prop.Name, StringComparer.OrdinalIgnoreCase))
{
selectParts.Add($"{alias.Quote()}.{prop.Name.Quote()} AS \"{prop.Name}\"");
}
}
// Include root ID (only once)
if (!selectParts.Any(sp => sp.Contains($"\"Id\"")))
{
selectParts.Add($"{alias.Quote()}.{"Id".Quote()} AS \"Id\"");
}
// 2. Handle nested properties
if (param.Navigation != null)
{
foreach (var nav in param.Navigation)
{
Type navType = ResolveEntityType(nav.EntityName);
var navMeta = _metaExtractor.Extract(navType);
string navAlias = $"{alias}_{nav.EntityName}";
types.Add(navType);
// Join clause
joinParts.Add($"LEFT JOIN {navMeta.Table.Quote()} AS {navAlias.Quote()} " +
$"ON {alias.Quote()}.{"Id".Quote()} = {navAlias.Quote()}.{"ReferralId".Quote()}");
// Select child properties
foreach (var prop in navMeta.ScalarProps)
{
if (nav.Properties == null || nav.Properties.Contains(prop.Name, StringComparer.OrdinalIgnoreCase))
{
selectParts.Add($"{navAlias.Quote()}.{prop.Name.Quote()} AS \"{prop.Name}\"");
}
}
// Select child ID with correct alias for mapping (only once)
//selectParts.Add($"{navAlias.Quote()}.{"Id".Quote()} AS \"Id\"");
// Select child ID again with unique alias for splitOn
selectParts.Add($"{navAlias.Quote()}.{"Id".Quote()} AS \"{navAlias}_Id\"");
splitOnColumns.Add($"{navAlias}_Id");
}
}
return (
Types: types,
SelectClause: string.Join(", ", selectParts),
JoinClause: string.Join(" ", joinParts),
SplitOnColumns: splitOnColumns
);
}
The generated SQL query is:
SELECT
"r"."Id",
"r"."ReservationStatus",
"r"."Language",
"r"."ReferralSource",
"ra"."Id",
"ra"."AppointmentDate",
"ra"."AppointmentTime",
"ra"."LegSequence",
"ra"."LegStatus",
"ra"."ReferralId"
FROM
"Referral" AS "r"
LEFT JOIN "ReferralAppointment" AS "ra" ON "r"."Id" = "ra"."ReferralId"
WHERE
"r"."Id" = 1
Split on column is Id.
Same query worked with static Types and hard coded query. I used same hard coded query with dynamic function, with same SplitOn. But dynamic function is not populating child collection. It's empty not null. AI saying that in case of static dapper knows ( _db.QueryAsync<Referral, ReferralAppointment, Referral>), the types statically. thats the issue. I asked if I can use my dynamic types in generic function (_db.QueryAsync<types[0], types[1], types[0]>), it suggested me to invoke QueryAsync through reflection. But following return null:
var method = typeof(IDbConnection).GetMethods()
.FirstOrDefault(m => m.Name == "QueryAsync" && m.IsGenericMethod);
Query is returning the correct rows in pgAdmin.
T0_ReferralAppointmentsincesplitOnColumns.Add($"{navAlias}_Id");always appends "_Id". If the split on column isT0_ReferralAppointment_Idthen there's no columns at all afterT0_ReferralAppointment_Id. Column's order in the SELECT statement really matters and this is an cause of the issue here. Also remember that column names in the top SELECT statement may have duplicates and that's perfectly fine. Dapper is aware of that and intentionally uses that feature while mapping 1-to-many.