Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,18 @@ internal static string MapCSharpTypeToTypeScript(
{
var type = StripGlobalPrefix(typeFqn);

// Nullable<T> -> T | null
// Nullable shorthand (e.g. int?, bool?) -> T | null
// Roslyn's FullyQualifiedFormat with UseSpecialTypes produces "int?" not "System.Nullable<System.Int32>"
if (
type.EndsWith("?", StringComparison.Ordinal)
&& !type.EndsWith(">", StringComparison.Ordinal)
)
{
var inner = type.Substring(0, type.Length - 1);
return MapCSharpTypeToTypeScript(inner, knownDtoTypes) + " | null";
}

// Nullable<T> -> T | null (explicit generic form, may appear in some Roslyn configurations)
if (
type.StartsWith("System.Nullable<", StringComparison.Ordinal)
&& type.EndsWith(">", StringComparison.Ordinal)
Expand All @@ -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<K, V>
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
Expand Down Expand Up @@ -76,10 +112,36 @@ knownDtoTypes is not null
or "System.DateOnly"
or "System.TimeOnly" => "string",
"System.Guid" => "string",
"object" or "System.Object" => "unknown",
_ => "any",
};
}

/// <summary>
/// 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&lt;string, int&gt;.
/// </summary>
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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ public class UploadEndpoint : IEndpoint
public void Map(IEndpointRouteBuilder app) =>
app.MapPost(
"/",
async (IFormFile file, string? folder, IFileStorageContracts files) =>
async Task<IResult> (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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
23 changes: 1 addition & 22 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/SimpleModule.UI/components/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ const ChartLegendContent = React.forwardRef<

return (
<div
key={item.value}
key={`${key}-${item.value}`}
className={cn(
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-text-muted',
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,8 @@ public class CollectionDto
}

[Fact]
public void Dto_WithNullableShorthandValueType_MapsToAny()
public void Dto_WithNullableShorthandValueType_MapsToNullUnion()
{
// Note: Nullable<T> 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;

Expand All @@ -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]
Expand Down Expand Up @@ -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<string, int> 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<string, number>;");
}

[Fact]
public void Dto_WithUnknownType_MapsToAny()
{
Expand Down