From 51929adc33a2639c5a609ba2f74bd69bf96fe6bb Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Wed, 6 May 2026 07:10:31 -0500 Subject: [PATCH] fix: unblock Immybot generation Resolve runtime assembly loading for EF metadata, retry metadata bootstrap across provider fallbacks, relax missing display text to a warning, discover dictionary-contained external types, and generate DTO code that handles obsolete members, dictionaries, immutable collections, and value-object null checks so Immybot can generate and build successfully. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/ClassDto.cs | 83 +++++++-- .../Generation/ProjectAssemblyTypeResolver.cs | 19 +- ...IntelliTect.Coalesce.CodeGeneration.csproj | 1 + .../EntityFrameworkMetadataProvider.cs | 168 ++++++++++++++++-- .../TypeDefinition/ReflectionRepository.cs | 28 ++- .../Validation/ValidateContext.cs | 3 +- 6 files changed, 275 insertions(+), 27 deletions(-) diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs index d7dc57086..79def9202 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs @@ -4,6 +4,7 @@ using IntelliTect.Coalesce.Utilities; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; namespace IntelliTect.Coalesce.CodeGeneration.Api.Generators; @@ -36,6 +37,7 @@ public override void BuildOutput(CSharpCodeBuilder b) "IntelliTect.Coalesce.Mapping", "IntelliTect.Coalesce.Models", "System", + "System.Collections.Immutable", "System.Linq", "System.Collections.Generic", "System.Security.Claims", @@ -46,6 +48,8 @@ public override void BuildOutput(CSharpCodeBuilder b) b.Line($"using {ns};"); } + b.Line(); + b.Line("#pragma warning disable CS0618"); b.Line(); using (b.Block($"namespace {DtoNamespace}")) @@ -54,6 +58,9 @@ public override void BuildOutput(CSharpCodeBuilder b) b.Line(); WriteResponseDto(b); } + + b.Line(); + b.Line("#pragma warning restore CS0618"); } private void WriteParameterDto(CSharpCodeBuilder b) @@ -328,7 +335,7 @@ private void WriteResponseDto(CSharpCodeBuilder b) b.DocComment("Map from the domain object to the properties of the current DTO instance."); using (b.Block($"public void MapFrom({Model.FullyQualifiedName} obj, IMappingContext context, IncludeTree tree = null)")) { - b.Line("if (obj == null) return;"); + b.Line("if (obj is null) return;"); var derivedTypes = Model.ClientDerivedTypes.ToList(); if (derivedTypes.Any()) @@ -459,18 +466,13 @@ private IEnumerable GetPropertySetterConditional( string setter; if (property.Type.IsDictionary) { - // Dictionaries aren't officially supported by Coalesce. - // This only supports dictionaries of types that require no type mapping. - // This is only a stop-gap to bridge apparent functionality that existed in 2.x versions - // of Coalesce where Dictionaries apparently "accidentally" worked to a limited extent. - // There is no frontend support at all. - setter = $"{name}?.ToDictionary(k => k.Key, v => v.Value)"; + setter = DictionaryDtoToModelExpression(property.Type, name); } else if (property.Object != null) { if (property.Type.IsCollection) { - setter = $"{name}?.Select(f => f.MapToNew(context)).{(property.Type.IsArray ? "ToArray" : "ToList")}()"; + setter = $"{name}?.Select(f => f.MapToNew(context)).{CollectionMaterializerForModel(property.Type)}()"; } else if (modelVar != null) { @@ -484,7 +486,7 @@ private IEnumerable GetPropertySetterConditional( else if (!property.Type.IsArray && property.Type.IsA(typeof(IList<>))) { // Lists of scalar values, whose DTO properties will be ICollection<>, preventing direct assignment. - setter = $"{name}?.ToList()"; + setter = $"{name}?.{CollectionMaterializerForModel(property.Type)}()"; } else { @@ -515,7 +517,11 @@ string mapCall() => property.Object.IsCustomDto ? "" // If we hang an IClassDto off an external type, or another IClassDto, no mapping needed - it is already the desired type. : $".MapToDto<{property.Object.FullyQualifiedName}, {property.Object.ResponseDtoTypeName}>(context, tree?[nameof({dtoVar}.{name})])"; - if (property.Type.IsCollection) + if (property.Type.IsDictionary) + { + setter = $"{dtoVar}.{name} = {DictionaryModelToDtoExpression(property.Type, $"obj.{name}")};"; + } + else if (property.Type.IsCollection) { if (property.Object != null) { @@ -615,4 +621,61 @@ string mapCall() => property.Object.IsCustomDto var statement = GetPropertySetterConditional(property, property.SecurityInfo.Read, "obj"); return (statement, setter); } + + private static bool IsImmutableDictionary(TypeViewModel type) => + type.IsA(typeof(IImmutableDictionary<,>)) || type.IsA(typeof(ImmutableDictionary<,>)); + + private static bool IsImmutableList(TypeViewModel type) => + type.IsA(typeof(IImmutableList<>)) || type.IsA(typeof(ImmutableList<>)); + + private static string CollectionMaterializerForModel(TypeViewModel type) => + type.IsArray ? "ToArray" : IsImmutableList(type) ? "ToImmutableList" : "ToList"; + + private string DictionaryDtoToModelExpression(TypeViewModel type, string sourceExpression) + { + var args = type.GenericArgumentsFor(typeof(IDictionary<,>)) + ?? throw new InvalidOperationException($"Dictionary type '{type}' is missing generic arguments."); + + return $"{sourceExpression}?.{(IsImmutableDictionary(type) ? "ToImmutableDictionary" : "ToDictionary")}(k => k.Key, v => {DictionaryValueDtoToModelExpression(args[1], "v.Value")})"; + } + + private string DictionaryValueDtoToModelExpression(TypeViewModel type, string sourceExpression) + { + if (type.IsDictionary) + { + return DictionaryDtoToModelExpression(type, sourceExpression); + } + + var pureType = type.PureType; + if (pureType.ClassViewModel is not null) + { + return $"{sourceExpression}?.MapToNew(context)"; + } + + return sourceExpression; + } + + private string DictionaryModelToDtoExpression(TypeViewModel type, string sourceExpression) + { + var args = type.GenericArgumentsFor(typeof(IDictionary<,>)) + ?? throw new InvalidOperationException($"Dictionary type '{type}' is missing generic arguments."); + + return $"{sourceExpression}?.ToDictionary(k => k.Key, v => {DictionaryValueModelToDtoExpression(args[1], "v.Value")})"; + } + + private string DictionaryValueModelToDtoExpression(TypeViewModel type, string sourceExpression) + { + if (type.IsDictionary) + { + return $"({type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)}){DictionaryModelToDtoExpression(type, sourceExpression)}"; + } + + var pureType = type.PureType; + if (pureType.ClassViewModel is { } model) + { + return $"{sourceExpression}.MapToDto<{model.FullyQualifiedName}, {model.ResponseDtoTypeName}>(context)"; + } + + return sourceExpression; + } } diff --git a/src/IntelliTect.Coalesce.CodeGeneration/Generation/ProjectAssemblyTypeResolver.cs b/src/IntelliTect.Coalesce.CodeGeneration/Generation/ProjectAssemblyTypeResolver.cs index 573009bf2..03b0d4749 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration/Generation/ProjectAssemblyTypeResolver.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration/Generation/ProjectAssemblyTypeResolver.cs @@ -19,7 +19,10 @@ internal sealed class ProjectAssemblyTypeResolver public ProjectAssemblyTypeResolver(MsBuildProjectContext projectContext) { _assemblyPath = projectContext.AssemblyFullPath; - _loadContext = new ProjectAssemblyLoadContext(projectContext.AssemblyFullPath, projectContext.TargetDirectory); + _loadContext = new ProjectAssemblyLoadContext( + projectContext.AssemblyFullPath, + projectContext.TargetDirectory, + projectContext.ResolvedReferences); _rootAssembly = new Lazy(() => _loadContext.LoadFromAssemblyPath(projectContext.AssemblyFullPath)); _defaultContextAssembly = new Lazy(TryLoadIntoDefaultContext); } @@ -88,17 +91,29 @@ private sealed class ProjectAssemblyLoadContext : AssemblyLoadContext private readonly AssemblyDependencyResolver _resolver; private readonly IReadOnlyDictionary _referencePaths; - public ProjectAssemblyLoadContext(string assemblyPath, string targetDirectory) + public ProjectAssemblyLoadContext(string assemblyPath, string targetDirectory, IEnumerable resolvedReferences) : base($"CoalesceProject:{Path.GetFileNameWithoutExtension(assemblyPath)}", isCollectible: false) { _resolver = new AssemblyDependencyResolver(assemblyPath); _referencePaths = Directory .EnumerateFiles(targetDirectory, "*.dll", SearchOption.TopDirectoryOnly) + .Concat(resolvedReferences.Where(IsRuntimeAssemblyPath)) .Where(File.Exists) .GroupBy(path => Path.GetFileNameWithoutExtension(path), StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); } + private static bool IsRuntimeAssemblyPath(string path) + { + if (!path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var pathSegments = path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return !pathSegments.Contains("ref", StringComparer.OrdinalIgnoreCase); + } + protected override Assembly? Load(AssemblyName assemblyName) { var defaultAssembly = AssemblyLoadContext.Default.Assemblies diff --git a/src/IntelliTect.Coalesce.CodeGeneration/IntelliTect.Coalesce.CodeGeneration.csproj b/src/IntelliTect.Coalesce.CodeGeneration/IntelliTect.Coalesce.CodeGeneration.csproj index 33d5bb8f7..c33cb7f76 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration/IntelliTect.Coalesce.CodeGeneration.csproj +++ b/src/IntelliTect.Coalesce.CodeGeneration/IntelliTect.Coalesce.CodeGeneration.csproj @@ -14,6 +14,7 @@ + diff --git a/src/IntelliTect.Coalesce/TypeDefinition/EntityFrameworkMetadataProvider.cs b/src/IntelliTect.Coalesce/TypeDefinition/EntityFrameworkMetadataProvider.cs index cc9c923a6..a8891c504 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/EntityFrameworkMetadataProvider.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/EntityFrameworkMetadataProvider.cs @@ -81,7 +81,9 @@ private Dictionary BuildMetadata() foreach (var contextUsage in _repository.DbContexts) { - var contextType = _typeResolver(contextUsage.ClassViewModel.Type.VerboseFullyQualifiedName); + var verboseContextName = contextUsage.ClassViewModel.Type.VerboseFullyQualifiedName; + var contextType = (contextUsage.ClassViewModel.Type as ReflectionTypeViewModel)?.Info + ?? _typeResolver(verboseContextName); if (contextType is null || !typeof(DbContext).IsAssignableFrom(contextType)) { continue; @@ -117,11 +119,15 @@ private static void PopulateMetadata(Dictionary 0 } primaryKey && primaryKey.Properties[0].PropertyInfo is { } primaryKeyProperty) { classMetadata.SinglePrimaryKeyPropertyName ??= primaryKeyProperty.Name; } + else if (entityType.GetProperties().FirstOrDefault(property => property.PropertyInfo is not null)?.PropertyInfo is { } fallbackProperty) + { + classMetadata.SinglePrimaryKeyPropertyName ??= fallbackProperty.Name; + } foreach (var property in entityType.GetProperties()) { @@ -152,7 +158,7 @@ private static EntityFrameworkClassMetadata GetOrCreateClassMetadata( Dictionary metadata, Type clrType) { - var verboseName = new ReflectionTypeViewModel(clrType).VerboseFullyQualifiedName; + var verboseName = GetVerboseTypeName(clrType); if (!metadata.TryGetValue(verboseName, out var classMetadata)) { classMetadata = new EntityFrameworkClassMetadata(); @@ -175,18 +181,55 @@ private static IEnumerable GetComplexProperties(IReadOnlyEntityTyp private static DbContext? TryCreateDbContext(Type contextType) { + foreach (var options in CreateDbContextOptionsCandidates(contextType)) + { + if (TryInstantiateDbContext(contextType, options) is { } dbContext) + { + try + { + _ = dbContext.Model; + return dbContext; + } + catch + { + dbContext.Dispose(); + } + } + } + var parameterlessCtor = contextType.GetConstructor(Type.EmptyTypes); - if (parameterlessCtor?.Invoke(null) is DbContext context) + if (parameterlessCtor?.Invoke(null) is not DbContext parameterlessContext) { - return context; + return null; } - var options = CreateDbContextOptions(contextType); - if (options is null) + try + { + _ = parameterlessContext.Model; + return parameterlessContext; + } + catch { + parameterlessContext.Dispose(); return null; } + } + private static IEnumerable CreateDbContextOptionsCandidates(Type contextType) + { + if (CreateDbContextOptions(contextType, TryConfigureSqlite) is { } sqliteOptions) + { + yield return sqliteOptions; + } + + if (CreateDbContextOptions(contextType, TryConfigureInMemory) is { } inMemoryOptions) + { + yield return inMemoryOptions; + } + } + + private static DbContext? TryInstantiateDbContext(Type contextType, DbContextOptions options) + { var genericOptionsType = typeof(DbContextOptions<>).MakeGenericType(contextType); foreach (var ctor in contextType.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) { @@ -205,7 +248,9 @@ private static IEnumerable GetComplexProperties(IReadOnlyEntityTyp return null; } - private static DbContextOptions? CreateDbContextOptions(Type contextType) + private static DbContextOptions? CreateDbContextOptions( + Type contextType, + Action configureBuilder) { var builderType = typeof(DbContextOptionsBuilder<>).MakeGenericType(contextType); if (Activator.CreateInstance(builderType) is not DbContextOptionsBuilder builder) @@ -213,17 +258,66 @@ private static IEnumerable GetComplexProperties(IReadOnlyEntityTyp return null; } - TryConfigureInMemory(builderType, builder); - return builder.Options; + configureBuilder(builderType, builder); + return builder.IsConfigured ? builder.Options : null; + } + + private static void TryConfigureSqlite(Type builderType, DbContextOptionsBuilder builder) + { + var sqliteExtensionsAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(assembly => assembly.GetName().Name == "Microsoft.EntityFrameworkCore.Sqlite") + ?? TryLoadAssembly("Microsoft.EntityFrameworkCore.Sqlite"); + + var sqliteExtensionsType = sqliteExtensionsAssembly + ?.GetType("Microsoft.EntityFrameworkCore.SqliteDbContextOptionsBuilderExtensions"); + + var method = sqliteExtensionsType? + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(m => + { + if (m.Name != "UseSqlite") + { + return false; + } + + var parameters = m.GetParameters(); + return parameters.Length >= 2 + && parameters[0].ParameterType.IsAssignableFrom(builderType) + && parameters[1].ParameterType == typeof(string); + }); + + if (method is null) + { + return; + } + + var parameters = method.GetParameters(); + var args = new object?[parameters.Length]; + args[0] = builder; + args[1] = "Data Source=:memory:"; + for (var i = 2; i < args.Length; i++) + { + args[i] = parameters[i].HasDefaultValue ? parameters[i].DefaultValue : null; + } + + method.Invoke(null, args); } private static void TryConfigureInMemory(Type builderType, DbContextOptionsBuilder builder) { + if (builder.IsConfigured) + { + return; + } + var inMemoryExtensions = AppDomain.CurrentDomain.GetAssemblies() .FirstOrDefault(assembly => assembly.GetName().Name == "Microsoft.EntityFrameworkCore.InMemory") + ?? TryLoadAssembly("Microsoft.EntityFrameworkCore.InMemory"); + + var inMemoryExtensionsType = inMemoryExtensions ?.GetType("Microsoft.EntityFrameworkCore.InMemoryDbContextOptionsExtensions"); - var method = inMemoryExtensions? + var method = inMemoryExtensionsType? .GetMethods(BindingFlags.Public | BindingFlags.Static) .FirstOrDefault(m => { @@ -255,6 +349,56 @@ private static void TryConfigureInMemory(Type builderType, DbContextOptionsBuild method.Invoke(null, args); } + private static Assembly? TryLoadAssembly(string assemblyName) + { + try + { + return Assembly.Load(assemblyName); + } + catch + { + return null; + } + } + + internal static string GetVerboseTypeName(Type type) + { + if (type.IsGenericParameter) + { + return type.Name; + } + + if (type.IsArray) + { + return GetVerboseTypeName(type.GetElementType()!) + "[" + new string(',', type.GetArrayRank() - 1) + "]"; + } + + if (!type.IsGenericType) + { + return type.FullName?.Replace('+', '.') ?? string.Empty; + } + + var builder = new System.Text.StringBuilder(); + var name = type.Name; + var index = name.IndexOf('`'); + builder.AppendFormat("{0}.{1}", type.Namespace, index >= 0 ? name[..index] : name); + builder.Append('<'); + var first = true; + foreach (var arg in type.GetGenericArguments()) + { + if (!first) + { + builder.Append(", "); + } + + builder.Append(GetVerboseTypeName(arg)); + first = false; + } + + builder.Append('>'); + return builder.ToString(); + } + private sealed class EntityFrameworkClassMetadata { public string? SinglePrimaryKeyPropertyName { get; set; } @@ -270,7 +414,7 @@ internal static class LoadedAssemblyTypeResolver public static Type? Resolve(string verboseFullyQualifiedName) => AppDomain.CurrentDomain.GetAssemblies() .SelectMany(GetLoadableTypes) - .FirstOrDefault(type => new ReflectionTypeViewModel(type).VerboseFullyQualifiedName == verboseFullyQualifiedName); + .FirstOrDefault(type => RuntimeEntityFrameworkMetadataProvider.GetVerboseTypeName(type) == verboseFullyQualifiedName); internal static IEnumerable GetLoadableTypes(Assembly assembly) { diff --git a/src/IntelliTect.Coalesce/TypeDefinition/ReflectionRepository.cs b/src/IntelliTect.Coalesce/TypeDefinition/ReflectionRepository.cs index 2d083daa2..5b4e988dd 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/ReflectionRepository.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/ReflectionRepository.cs @@ -320,11 +320,35 @@ private object GetCacheKey(TypeViewModel typeViewModel) => /// private void ConditionallyAddAndDiscoverTypesOn(ValueViewModel typeUsage) { - var type = typeUsage.PureType; + ConditionallyAddAndDiscoverType(typeUsage.PureType, typeUsage); + if (typeUsage.Type.IsDictionary) + { + foreach (var dictionaryArg in typeUsage.Type.GenericArgumentsFor(typeof(IDictionary<,>)) ?? []) + { + ConditionallyAddAndDiscoverType(dictionaryArg); + } + } + } + + private void ConditionallyAddAndDiscoverType(TypeViewModel type, ValueViewModel? usage = null) + { + if (type.IsDictionary) + { + foreach (var dictionaryArg in type.GenericArgumentsFor(typeof(IDictionary<,>)) ?? []) + { + ConditionallyAddAndDiscoverType(dictionaryArg); + } + return; + } + + type = type.PureType; var classViewModel = type.ClassViewModel; if (classViewModel != null) { - classViewModel.Usages.Add(typeUsage); + if (usage is not null) + { + classViewModel.Usages.Add(usage); + } // Don't dig in if: // - This is a known entity type (its not external) diff --git a/src/IntelliTect.Coalesce/Validation/ValidateContext.cs b/src/IntelliTect.Coalesce/Validation/ValidateContext.cs index 1e1cfd5df..950fcd7aa 100644 --- a/src/IntelliTect.Coalesce/Validation/ValidateContext.cs +++ b/src/IntelliTect.Coalesce/Validation/ValidateContext.cs @@ -132,7 +132,8 @@ public static ValidationHelper Validate(ReflectionRepository repository) assert.IsNotNull( prop.Object?.ListTextProperty, $"{prop.Object} has no discernible display text. Add a [ListTextAttribute] to one of its properties." - + (prop.Object?.HasDbSet == false ? " If the type was meant to be an EF entity, add a corresponding DbSet property to your DbContext." : "")); + + (prop.Object?.HasDbSet == false ? " If the type was meant to be an EF entity, add a corresponding DbSet property to your DbContext." : ""), + isWarning: true); if (!prop.IsReadOnly && !prop.HasNotMapped && prop.Object?.HasDbSet == true) { // Validate navigation properties