diff --git a/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs b/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs index 38d3ea68..94da9e68 100644 --- a/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs +++ b/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs @@ -19,7 +19,18 @@ internal static string MapCSharpTypeToTypeScript( { var type = StripGlobalPrefix(typeFqn); - // Nullable -> T | null + // Nullable shorthand (e.g. int?, bool?) -> T | null + // Roslyn's FullyQualifiedFormat with UseSpecialTypes produces "int?" not "System.Nullable" + if ( + type.EndsWith("?", StringComparison.Ordinal) + && !type.EndsWith(">", StringComparison.Ordinal) + ) + { + var inner = type.Substring(0, type.Length - 1); + return MapCSharpTypeToTypeScript(inner, knownDtoTypes) + " | null"; + } + + // Nullable -> T | null (explicit generic form, may appear in some Roslyn configurations) if ( type.StartsWith("System.Nullable<", StringComparison.Ordinal) && type.EndsWith(">", StringComparison.Ordinal) @@ -29,7 +40,32 @@ internal static string MapCSharpTypeToTypeScript( "System.Nullable<".Length, type.Length - "System.Nullable<".Length - 1 ); - return MapCSharpTypeToTypeScript("global::" + inner, knownDtoTypes) + " | null"; + // Strip any nested global:: prefix that Roslyn may add + inner = StripGlobalPrefix(inner); + return MapCSharpTypeToTypeScript(inner, knownDtoTypes) + " | null"; + } + + // Dictionary types -> Record + if ( + type.StartsWith("System.Collections.Generic.Dictionary<", StringComparison.Ordinal) + || type.StartsWith("System.Collections.Generic.IDictionary<", StringComparison.Ordinal) + || type.StartsWith( + "System.Collections.Generic.IReadOnlyDictionary<", + StringComparison.Ordinal + ) + ) + { + var start = type.IndexOf('<') + 1; + var inner = type.Substring(start, type.Length - start - 1); + var commaIndex = FindTopLevelComma(inner); + if (commaIndex >= 0) + { + var keyType = inner.Substring(0, commaIndex).Trim(); + var valueType = inner.Substring(commaIndex + 1).Trim(); + var tsKey = MapCSharpTypeToTypeScript("global::" + keyType, knownDtoTypes); + var tsValue = MapCSharpTypeToTypeScript("global::" + valueType, knownDtoTypes); + return $"Record<{tsKey}, {tsValue}>"; + } } // Collection types @@ -76,10 +112,36 @@ knownDtoTypes is not null or "System.DateOnly" or "System.TimeOnly" => "string", "System.Guid" => "string", + "object" or "System.Object" => "unknown", _ => "any", }; } + /// + /// Finds the index of the first comma that is not nested inside angle brackets. + /// Used to split generic type arguments like "string, int" in Dictionary<string, int>. + /// + private static int FindTopLevelComma(string text) + { + var depth = 0; + for (var i = 0; i < text.Length; i++) + { + switch (text[i]) + { + case '<': + depth++; + break; + case '>': + depth--; + break; + case ',' when depth == 0: + return i; + } + } + + return -1; + } + internal static string GetModuleNameFromFqn(string fqn) { // "global::SimpleModule.Products.Contracts.Product" -> "Products" diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/Endpoints/Files/UploadEndpoint.cs b/modules/FileStorage/src/SimpleModule.FileStorage/Endpoints/Files/UploadEndpoint.cs index ec86b27f..6779d7c7 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/Endpoints/Files/UploadEndpoint.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage/Endpoints/Files/UploadEndpoint.cs @@ -12,8 +12,13 @@ public class UploadEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPost( "/", - async (IFormFile file, string? folder, IFileStorageContracts files) => + async Task (IFormFile? file, string? folder, IFileStorageContracts files) => { + if (file is null || file.Length == 0) + { + return TypedResults.BadRequest("A file is required."); + } + await using var stream = file.OpenReadStream(); var storedFile = await files.UploadFileAsync( stream, diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs index 6b2f7677..04b787fb 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs @@ -81,10 +81,22 @@ public async Task DeleteFileAsync(FileStorageId id) var file = await db.StoredFiles.FindAsync(id) ?? throw new InvalidOperationException($"File with ID {id} not found."); + var storagePath = file.StoragePath; + db.StoredFiles.Remove(file); await db.SaveChangesAsync(); - await storageProvider.DeleteAsync(file.StoragePath); + try + { + await storageProvider.DeleteAsync(storagePath); + } +#pragma warning disable CA1031 // Storage deletion is best-effort after successful DB commit + catch (Exception ex) +#pragma warning restore CA1031 + { + LogStorageDeletionFailed(logger, id, storagePath, ex); + return; + } LogFileDeleted(logger, id, file.FileName); } @@ -136,4 +148,7 @@ private static string GetTopSegment(string path) [LoggerMessage(Level = LogLevel.Information, Message = "File deleted: {Id} ({FileName})")] private static partial void LogFileDeleted(ILogger logger, FileStorageId id, string fileName); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to delete storage for file {Id} at path {Path}. Storage may contain orphaned data")] + private static partial void LogStorageDeletionFailed(ILogger logger, FileStorageId id, string path, Exception exception); } diff --git a/package-lock.json b/package-lock.json index a7e863e4..33fc3045 100644 --- a/package-lock.json +++ b/package-lock.json @@ -242,7 +242,6 @@ "version": "7.29.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1066,7 +1065,6 @@ "node_modules/@floating-ui/dom": { "version": "1.7.6", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -1101,7 +1099,6 @@ "node_modules/@inertiajs/react": { "version": "2.3.18", "license": "MIT", - "peer": true, "dependencies": { "@inertiajs/core": "2.3.18", "@types/lodash-es": "^4.17.12", @@ -3388,7 +3385,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.5.tgz", "integrity": "sha512-Pkjd41UJ4F6Z8cPV+gEvqnt1VhY2g66xMjbpxREs0ECA5jRezCNKSZcc2pueQRTMtmn1SaSzGM9U/ifhVlVYOA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3666,7 +3662,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.5.tgz", "integrity": "sha512-yJhDa7Chx2EqJMX/jlewBv0za7slf1dKHWYve1XaApuVHEkxl0Ul3EDbwnx316vIITkuFW/pWSwkSsAplyBeCw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -3883,7 +3878,6 @@ "node_modules/@types/react": { "version": "19.2.14", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3891,7 +3885,6 @@ "node_modules/@types/react-dom": { "version": "19.2.3", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4014,7 +4007,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4485,7 +4477,6 @@ "version": "0.25.12", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4719,7 +4710,6 @@ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.8.tgz", "integrity": "sha512-5/F8wxkNxYtsN0bXfMwIyNLZ9WYsoOYPbmoluqVJqv8KBUbcyKZawJ7uYK4WTX8IHBLYv+VXIwfeNDPy1oKMwQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -4770,7 +4760,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -5295,7 +5284,6 @@ "node_modules/picomatch": { "version": "4.0.3", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5495,7 +5483,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -5525,7 +5512,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -5574,7 +5560,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.7.tgz", "integrity": "sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -5625,7 +5610,6 @@ "node_modules/react": { "version": "19.2.4", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5655,7 +5639,6 @@ "node_modules/react-dom": { "version": "19.2.4", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5685,7 +5668,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5809,8 +5791,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6171,7 +6152,6 @@ "node_modules/use-sync-external-store": { "version": "1.6.0", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -6214,7 +6194,6 @@ "node_modules/vite": { "version": "6.4.1", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/packages/SimpleModule.UI/components/chart.tsx b/packages/SimpleModule.UI/components/chart.tsx index 6b1fa40f..1839ee16 100644 --- a/packages/SimpleModule.UI/components/chart.tsx +++ b/packages/SimpleModule.UI/components/chart.tsx @@ -302,7 +302,7 @@ const ChartLegendContent = React.forwardRef< return (
svg]:h-3 [&>svg]:w-3 [&>svg]:text-text-muted', )} diff --git a/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs b/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs index 6d0f4dcc..1a45f492 100644 --- a/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs +++ b/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs @@ -29,6 +29,13 @@ public static CSharpCompilation CreateCompilation(params string[] sources) MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "System.Collections.dll")) ); + // Add generic collections reference (Dictionary<,> may not be type-forwarded from System.Runtime on all platforms) + references.Add( + MetadataReference.CreateFromFile( + typeof(System.Collections.Generic.Dictionary<,>).Assembly.Location + ) + ); + // Add ASP.NET Core references for IServiceCollection, IEndpointRouteBuilder, IConfiguration var aspNetDir = Path.GetDirectoryName( typeof(Microsoft.Extensions.DependencyInjection.IServiceCollection).Assembly.Location diff --git a/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs b/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs index ad274922..749bbfa5 100644 --- a/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs +++ b/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs @@ -96,10 +96,8 @@ public class CollectionDto } [Fact] - public void Dto_WithNullableShorthandValueType_MapsToAny() + public void Dto_WithNullableShorthandValueType_MapsToNullUnion() { - // Note: Nullable via shorthand (int?) produces FQNs with nested global:: prefixes - // that the TypeMappingHelpers does not currently resolve. This test documents that behavior. var source = """ using SimpleModule.Core; @@ -120,10 +118,7 @@ public class NullableDto var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); - // Current behavior: nullable value types via shorthand (int?) map to `any` - // because Roslyn represents them with nested global:: prefixes that - // MapCSharpTypeToTypeScript doesn't resolve. - tsSource.Should().Contain("optionalCount: any;"); + tsSource.Should().Contain("optionalCount: number | null;"); } [Fact] @@ -264,6 +259,33 @@ public class TestModule : IModule { } result.GeneratedTrees.Should().NotContain(t => t.FilePath.Contains("DtoTypeScript_")); } + [Fact] + public void Dto_WithDictionary_MapsToRecord() + { + var source = """ + using System.Collections.Generic; + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class StatsDto + { + public Dictionary CountByCategory { get; set; } = new(); + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("countByCategory: Record;"); + } + [Fact] public void Dto_WithUnknownType_MapsToAny() {