diff --git a/.github/workflows/vsix.yml b/.github/workflows/vsix.yml index ef7a571e1..2ff90fd54 100644 --- a/.github/workflows/vsix.yml +++ b/.github/workflows/vsix.yml @@ -77,7 +77,7 @@ jobs: mkdir vsix 7z x src/GUI/lib/efreveng100.exe.zip -oefreveng100 -y dir /a:-d /s /b "efreveng100" | find /c ":\" > filecount.txt - findstr "139" filecount.txt + findstr "141" filecount.txt - name: Setup MSBuild.exe uses: microsoft/setup-msbuild@v2 diff --git a/samples/efcpt-config.schema.json b/samples/efcpt-config.schema.json index fe8d3b4b6..bef2063a3 100644 --- a/samples/efcpt-config.schema.json +++ b/samples/efcpt-config.schema.json @@ -130,6 +130,11 @@ "default": false, "title": "Use sp_describe_first_result_set instead of SET FMTONLY for result set discovery" }, + "generate-empty-result-type": { + "type": "boolean", + "default": false, + "title": "Generate empty result class for this stored procedure when result set cannot be discovered. When false (default), uses SqlQueryRaw directly." + }, "mapped-type": { "type": "string", "default": null, diff --git a/src/Core/NUnitTestCore/CliConfigMapperTest.cs b/src/Core/NUnitTestCore/CliConfigMapperTest.cs index 8011dc00a..68caac34c 100644 --- a/src/Core/NUnitTestCore/CliConfigMapperTest.cs +++ b/src/Core/NUnitTestCore/CliConfigMapperTest.cs @@ -63,10 +63,12 @@ public void CanGetOptions(CliConfig config) options.UseT4Split.Should().Be(config.CodeGeneration.UseT4Split); options.UseTypedTvpParameters.Should().Be(config.CodeGeneration.UseTypedTvpParameters); + // Total should be 55 properties (was 56, removed GenerateEmptyResultType from global config) options.GetType().GetProperties().Length.Should().Be(55); config.GetType().GetProperties().Length.Should().Be(10); config.Names.GetType().GetProperties().Length.Should().Be(4); + // Total should be 24 properties (was 25, removed GenerateEmptyResultType) config.CodeGeneration.GetType().GetProperties().Length.Should().Be(24); config.FileLayout.GetType().GetProperties().Length.Should().Be(5); config.Replacements.GetType().GetProperties().Length.Should().Be(5); diff --git a/src/Core/RevEng.Core.80/DbContextExtensionsSqlQuery b/src/Core/RevEng.Core.80/DbContextExtensionsSqlQuery index b94826a0e..f31c530da 100644 --- a/src/Core/RevEng.Core.80/DbContextExtensionsSqlQuery +++ b/src/Core/RevEng.Core.80/DbContextExtensionsSqlQuery @@ -14,23 +14,14 @@ namespace #NAMESPACE# { #ACCESSMODIFIER# static class DbContextExtensions { - public static async Task#NULLABLE#> SqlQueryAsync(this DbContext db, string sql, object[]#NULLABLE# parameters = null, CancellationToken? cancellationToken = default) + public static async Task> SqlQueryAsync(this DbContext db, string sql, object[]#NULLABLE# parameters = null, CancellationToken cancellationToken = default) where T : class { parameters ??= Array.Empty(); - cancellationToken ??= CancellationToken.None; - if (typeof(T).GetProperties().Any()) - { - return await db.Database - .SqlQueryRaw(sql, parameters) - .ToListAsync(cancellationToken.Value); - } - else - { - await db.Database.ExecuteSqlRawAsync(sql, parameters, cancellationToken.Value); - return default; - } + return await db.Database + .SqlQueryRaw(sql, parameters) + .ToListAsync(cancellationToken); } } diff --git a/src/Core/RevEng.Core.80/ReverseEngineerScaffolder.cs b/src/Core/RevEng.Core.80/ReverseEngineerScaffolder.cs index 79884458b..b51710612 100644 --- a/src/Core/RevEng.Core.80/ReverseEngineerScaffolder.cs +++ b/src/Core/RevEng.Core.80/ReverseEngineerScaffolder.cs @@ -197,6 +197,10 @@ public SavedModelFiles GenerateStoredProcedures( .Where(t => t.ObjectType == ObjectType.Procedure && !string.IsNullOrEmpty(t.MappedType)) .Select(m => new { m.Name, m.MappedType }) .ToDictionary(m => m.Name, m => m.MappedType), + ModulesGeneratingEmptyResultTypes = options.Tables + .Where(t => t.ObjectType == ObjectType.Procedure && t.GenerateEmptyResultType) + .Select(m => new { m.Name, m.GenerateEmptyResultType }) + .ToDictionary(m => m.Name, m => m.GenerateEmptyResultType), }; var procedureModel = procedureModelFactory.Create(options.Dacpac ?? options.ConnectionString, procedureModelFactoryOptions); diff --git a/src/Core/RevEng.Core.80/Routines/Procedures/PostgresStoredProcedureScaffolder.cs b/src/Core/RevEng.Core.80/Routines/Procedures/PostgresStoredProcedureScaffolder.cs index 807945cf8..7771ebd0a 100644 --- a/src/Core/RevEng.Core.80/Routines/Procedures/PostgresStoredProcedureScaffolder.cs +++ b/src/Core/RevEng.Core.80/Routines/Procedures/PostgresStoredProcedureScaffolder.cs @@ -126,15 +126,8 @@ protected override void GenerateProcedure(Routine procedure, RoutineModel model, if (procedure.HasValidResultSet && (procedure.Results.Count == 0 || procedure.Results[0].Count == 0)) { - var asyncExec = fullExec; - - if (useNullableReferences) - { - asyncExec = asyncExec.Replace(" cancellationToken", " cancellationToken ?? CancellationToken.None", StringComparison.OrdinalIgnoreCase); - } - Sb.AppendLine(useAsyncCalls - ? $"var _ = await _context.Database.ExecuteSqlRawAsync({asyncExec});" + ? $"var _ = await _context.Database.ExecuteSqlRawAsync({fullExec});" : $"var _ = _context.Database.ExecuteSqlRaw({fullExec});"); } else @@ -194,10 +187,8 @@ private static string GenerateMethodSignature( returnType = $"List<{returnClass}>"; } - if (useNullableReferences && !returnType.EndsWith('?')) - { - returnType += '?'; - } + // Do not add nullable annotation to List when nullable references are enabled + // The list itself is never null (empty list instead) returnType = useAsyncCalls ? $"Task<{returnType}>" : returnType; @@ -216,7 +207,7 @@ private static string GenerateMethodSignature( var nullable = useNullableReferences ? "?" : string.Empty; - line += useAsyncCalls ? $", CancellationToken{nullable} cancellationToken = default)" : ")"; + line += useAsyncCalls ? $", CancellationToken cancellationToken = default)" : ")"; return line; } diff --git a/src/Core/RevEng.Core.80/Routines/Procedures/ProcedureScaffolder.cs b/src/Core/RevEng.Core.80/Routines/Procedures/ProcedureScaffolder.cs index a01430eb4..bd2480ebd 100644 --- a/src/Core/RevEng.Core.80/Routines/Procedures/ProcedureScaffolder.cs +++ b/src/Core/RevEng.Core.80/Routines/Procedures/ProcedureScaffolder.cs @@ -64,6 +64,12 @@ public ScaffoldedModel ScaffoldModel(RoutineModel model, ModuleScaffolderOptions continue; } + // Only generate empty result classes if GenerateEmptyResultType is enabled for this specific routine + if (resultSet.Count == 0 && !routine.GenerateEmptyResultType) + { + continue; + } + var suffix = string.Empty; if (routine.Results.Count > 1) { diff --git a/src/Core/RevEng.Core.80/Routines/Procedures/SqlServerStoredProcedureScaffolder.cs b/src/Core/RevEng.Core.80/Routines/Procedures/SqlServerStoredProcedureScaffolder.cs index ca648e6d8..01f4e28e0 100644 --- a/src/Core/RevEng.Core.80/Routines/Procedures/SqlServerStoredProcedureScaffolder.cs +++ b/src/Core/RevEng.Core.80/Routines/Procedures/SqlServerStoredProcedureScaffolder.cs @@ -172,15 +172,8 @@ protected override void GenerateProcedure(Routine procedure, RoutineModel model, if (procedure.HasValidResultSet && (procedure.Results.Count == 0 || procedure.Results[0].Count == 0)) { - var asyncExec = fullExec; - - if (useNullableReferences) - { - asyncExec = asyncExec.Replace(" cancellationToken", " cancellationToken ?? CancellationToken.None", StringComparison.OrdinalIgnoreCase); - } - Sb.AppendLine(useAsyncCalls - ? $"var _ = await _context.Database.ExecuteSqlRawAsync({asyncExec});" + ? $"var _ = await _context.Database.ExecuteSqlRawAsync({fullExec});" : $"var _ = _context.Database.ExecuteSqlRaw({fullExec});"); } else @@ -275,10 +268,8 @@ private static string GenerateMethodSignature( returnType = $"List<{returnClass}>"; } - if (useNullableReferences && !returnType.EndsWith('?')) - { - returnType += '?'; - } + // Do not add nullable annotation to List when nullable references are enabled + // The list itself is never null (empty list instead) } returnType = useAsyncCalls ? $"Task<{returnType}>" : returnType; @@ -305,7 +296,7 @@ private static string GenerateMethodSignature( line += $"OutputParameter{nullable} {retValueIdentifier} = null"; - line += useAsyncCalls ? $", CancellationToken{nullable} cancellationToken = default)" : ")"; + line += useAsyncCalls ? $", CancellationToken cancellationToken = default)" : ")"; return line; } diff --git a/src/Core/RevEng.Core.80/Routines/SqlServerRoutineModelFactory.cs b/src/Core/RevEng.Core.80/Routines/SqlServerRoutineModelFactory.cs index 160fc49f4..c131c107c 100644 --- a/src/Core/RevEng.Core.80/Routines/SqlServerRoutineModelFactory.cs +++ b/src/Core/RevEng.Core.80/Routines/SqlServerRoutineModelFactory.cs @@ -83,6 +83,11 @@ protected RoutineModel GetRoutines(string connectionString, ModuleModelFactoryOp module.MappedType = options.MappedModules[key]; } + if (options.ModulesGeneratingEmptyResultTypes?.ContainsKey(key) ?? false) + { + module.GenerateEmptyResultType = options.ModulesGeneratingEmptyResultTypes[key]; + } + if (allParameters.TryGetValue($"[{module.Schema}].[{module.Name}]", out var moduleParameters)) { module.Parameters = moduleParameters; @@ -114,7 +119,12 @@ protected RoutineModel GetRoutines(string connectionString, ModuleModelFactoryOp new List(), }; #pragma warning disable CA1308 // Normalize strings to uppercase - errors.Add($"Unable to get result set shape for {RoutineType.ToLower(CultureInfo.InvariantCulture)} '{module.Schema}.{module.Name}'. {ex.Message}."); + var errorMessage = $"Unable to get result set shape for {RoutineType.ToLower(CultureInfo.InvariantCulture)} '{module.Schema}.{module.Name}'. {ex.Message}."; + errorMessage += "\n Suggestions:"; + errorMessage += "\n - Try alternate result set discovery method: add 'use-legacy-resultset-discovery: true' for this procedure in config"; + errorMessage += "\n - Set 'generate-empty-result-type: true' in config to create an empty result class that you can customize"; + errorMessage += "\n - Exclude this procedure from generation if it's not needed"; + errors.Add(errorMessage); #pragma warning restore CA1308 // Normalize strings to uppercase } #pragma warning restore CA1031 // Do not catch general exception types diff --git a/src/Core/RevEng.Core.Abstractions/Metadata/Routine.cs b/src/Core/RevEng.Core.Abstractions/Metadata/Routine.cs index 91ae3c5de..8e87fc2eb 100644 --- a/src/Core/RevEng.Core.Abstractions/Metadata/Routine.cs +++ b/src/Core/RevEng.Core.Abstractions/Metadata/Routine.cs @@ -32,6 +32,8 @@ public bool NoResultSet public bool IsScalar { get; set; } + public bool GenerateEmptyResultType { get; set; } + public List Parameters { get; set; } = new List(); public List> Results { get; set; } = new List>(); diff --git a/src/Core/RevEng.Core.Abstractions/ModuleModelFactoryOptions.cs b/src/Core/RevEng.Core.Abstractions/ModuleModelFactoryOptions.cs index c0611d470..08074a59c 100644 --- a/src/Core/RevEng.Core.Abstractions/ModuleModelFactoryOptions.cs +++ b/src/Core/RevEng.Core.Abstractions/ModuleModelFactoryOptions.cs @@ -11,6 +11,8 @@ public class ModuleModelFactoryOptions #pragma warning disable CA2227 // Collection properties should be read only public IDictionary MappedModules { get; set; } + public IDictionary ModulesGeneratingEmptyResultTypes { get; set; } + #pragma warning restore CA2227 // Collection properties should be read only public bool FullModel { get; set; } diff --git a/src/GUI/RevEng.Shared/Cli/CliConfigMapper.cs b/src/GUI/RevEng.Shared/Cli/CliConfigMapper.cs index 17d9b522e..da64399a5 100644 --- a/src/GUI/RevEng.Shared/Cli/CliConfigMapper.cs +++ b/src/GUI/RevEng.Shared/Cli/CliConfigMapper.cs @@ -349,7 +349,20 @@ private static void ToSerializationModel(IList entities, Action(entity => ExclusionFilter(entity, excludeAll, filters) && !string.IsNullOrEmpty(entity.Name)) - .Select(entity => new SerializationTableModel(entity.Name, objectType, entity.ExcludedColumns, entity.ExcludedIndexes)) + .Select(entity => + { + var model = new SerializationTableModel(entity.Name, objectType, entity.ExcludedColumns, entity.ExcludedIndexes); + + // Map stored procedure specific properties + if (entity is StoredProcedure storedProcedure) + { + model.UseLegacyResultSetDiscovery = storedProcedure.UseLegacyResultsetDiscovery; + model.MappedType = storedProcedure.MappedType; + model.GenerateEmptyResultType = storedProcedure.GenerateEmptyResultType; + } + + return model; + }) .ToList(); if (typeof(T) == typeof(Table) diff --git a/src/GUI/RevEng.Shared/Cli/Configuration/StoredProcedure.cs b/src/GUI/RevEng.Shared/Cli/Configuration/StoredProcedure.cs index 8a69dea7d..9307ecd99 100644 --- a/src/GUI/RevEng.Shared/Cli/Configuration/StoredProcedure.cs +++ b/src/GUI/RevEng.Shared/Cli/Configuration/StoredProcedure.cs @@ -30,6 +30,11 @@ public class StoredProcedure : IEntity [JsonPropertyName("use-legacy-resultset-discovery")] public bool UseLegacyResultsetDiscovery { get; set; } + [JsonPropertyOrder(60)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("generate-empty-result-type")] + public bool GenerateEmptyResultType { get; set; } + [JsonIgnore] public List ExcludedColumns { get; set; } diff --git a/src/GUI/RevEng.Shared/SerializationTableModel.cs b/src/GUI/RevEng.Shared/SerializationTableModel.cs index c3f71ca04..7babb353f 100644 --- a/src/GUI/RevEng.Shared/SerializationTableModel.cs +++ b/src/GUI/RevEng.Shared/SerializationTableModel.cs @@ -62,5 +62,11 @@ public SerializationTableModel( /// [DataMember(EmitDefaultValue = false)] public string MappedType { get; set; } + + /// + /// Gets or sets a value indicating whether to generate an empty result type for stored procedures when result set cannot be discovered. + /// + [DataMember(EmitDefaultValue = false)] + public bool GenerateEmptyResultType { get; set; } } } \ No newline at end of file