Skip to content
Draft
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
6 changes: 6 additions & 0 deletions docs/topics/dto-shapes-and-read-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@ This is especially important for generated read models and contract shapes, wher
Coalesce now tolerates EF-owned and complex/value-object shapes during metadata discovery instead of treating them like unsupported roots.

That lets the higher-level DTO and read-shape features work against richer EF models without requiring you to flatten those value objects away first.

## Navigation summary DTOs provide lightweight related-data shapes

Navigation summary DTOs add a smaller, purpose-built way to surface related model information in generated read APIs without always expanding full related entities.

Those summary shapes are the foundation for later flattened-property and auto-projected read-shape features higher in the stack.
58 changes: 56 additions & 2 deletions src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ public override void BuildOutput(CSharpCodeBuilder b)
WriteParameterDto(b);
b.Line();
WriteResponseDto(b);
if (Model.ShouldGenerateSummaryDto)
{
b.Line();
WriteSummaryDto(b);
}
}

b.Line();
Expand Down Expand Up @@ -329,7 +334,7 @@ private void WriteResponseDto(CSharpCodeBuilder b)

foreach (PropertyViewModel prop in ownProps)
{
b.Line($"public {prop.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {prop.Name} {{ get; set; }}");
b.Line($"public {ResponsePropertyType(prop)} {prop.Name} {{ get; set; }}");
}

b.DocComment("Map from the domain object to the properties of the current DTO instance.");
Expand Down Expand Up @@ -362,6 +367,35 @@ private void WriteResponseDto(CSharpCodeBuilder b)
}
}

private void WriteSummaryDto(CSharpCodeBuilder b)
{
var primaryKey = Model.PrimaryKey
?? throw new InvalidOperationException($"Summary DTO generation for {Model.FullyQualifiedName} requires a primary key.");

using (b.Block($"public partial class {Model.SummaryDtoTypeName} : IGeneratedResponseDto<{Model.FullyQualifiedName}>"))
{
b.Line($"public {Model.SummaryDtoTypeName}() {{ }}");
b.Line();
b.Line($"public {primaryKey.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {primaryKey.Name} {{ get; set; }}");

foreach (var prop in Model.SummaryProperties)
{
b.Line($"public {prop.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {prop.Name} {{ get; set; }}");
}

b.DocComment("Map from the domain object to the properties of the current summary DTO instance.");
using (b.Block($"public void MapFrom({Model.FullyQualifiedName} obj, IMappingContext context, IncludeTree tree = null)"))
{
b.Line("if (obj is null) return;");
b.Line($"this.{primaryKey.Name} = obj.{primaryKey.Name};");
foreach (var prop in Model.SummaryProperties)
{
b.Line($"this.{prop.Name} = {prop.AccessExpression("obj")};");
}
}
}
}



void WriteSetters(CSharpCodeBuilder b, IEnumerable<(IEnumerable<string> conditionals, string setter)> settersAndConditionals)
Expand Down Expand Up @@ -515,7 +549,7 @@ private IEnumerable<string> GetPropertySetterConditional(
string setter;
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})])";
: $".MapToDto<{property.Object.FullyQualifiedName}, {GetResponseDtoTypeName(property)}>(context, tree?[nameof({dtoVar}.{name})])";

if (property.Type.IsDictionary)
{
Expand Down Expand Up @@ -622,6 +656,26 @@ string mapCall() => property.Object.IsCustomDto
return (statement, setter);
}

private string ResponsePropertyType(PropertyViewModel property)
{
if (property.UsesDtoReferenceSummary && property.Object is not null)
{
return $"{DtoNamespace}.{property.Object.SummaryDtoTypeName}";
}

return property.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace);
}

private string GetResponseDtoTypeName(PropertyViewModel property)
{
if (property.UsesDtoReferenceSummary && property.Object is not null)
{
return property.Object.SummaryDtoTypeName;
}

return property.Object.ResponseDtoTypeName;
}

private static bool IsImmutableDictionary(TypeViewModel type) =>
type.IsA(typeof(IImmutableDictionary<,>)) || type.IsA(typeof(ImmutableDictionary<,>));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using IntelliTect.Coalesce.CodeGeneration.Generation;
using IntelliTect.Coalesce.CodeGeneration.Vue.Generators;
using IntelliTect.Coalesce.Testing;
using IntelliTect.Coalesce.Testing.Util;
using IntelliTect.Coalesce.TypeDefinition;

namespace IntelliTect.Coalesce.CodeGeneration.Tests;

public class DtoReferenceSummaryGenerationTests : CodeGenTestBase
{
[Test]
public async Task ApiDtos_GenerateReferenceSummaryResponseProperties()
{
var outDir = Path.Combine(Path.GetTempPath(), "Coalesce.DtoReferenceSummaryTests", Guid.NewGuid().ToString("N"), "Api");
var suite = BuildExecutor()
.CreateRootGenerator<ApiOnlySuite>()
.WithModel(ReflectionRepositoryFactory.Symbol)
.WithOutputPath(outDir);

await suite.GenerateAsync();

var caseDtoFile = Directory.GetFiles(outDir, "CaseDto.g.cs", SearchOption.AllDirectories).Single();
var personDtoFile = Directory.GetFiles(outDir, "PersonDto.g.cs", SearchOption.AllDirectories).Single();

var caseContents = await File.ReadAllTextAsync(caseDtoFile);
var personContents = await File.ReadAllTextAsync(personDtoFile);

await Assert.That(caseContents.Contains("PersonSummaryResponse AssignedTo { get; set; }")).IsTrue();
await Assert.That(caseContents.Contains("MapToDto<")).IsTrue();
await Assert.That(caseContents.Contains("PersonSummaryResponse>(context, tree?[nameof(this.AssignedTo)])")).IsTrue();
await Assert.That(personContents.Contains("public partial class PersonSummaryResponse")).IsTrue();
await Assert.That(personContents.Contains("public int? PersonId { get; set; }")).IsTrue();
await Assert.That(personContents.Contains("public string Name { get; set; }")).IsTrue();
await Assert.That(personContents.Contains("public string CompanyName { get; set; }")).IsTrue();
await Assert.That(personContents.Contains("this.CompanyName = obj.Company?.Name;")).IsTrue();

await AssertSuiteCSharpOutputCompiles(suite);
}

[Test]
public async Task VueOutput_GeneratesReferenceSummaryModels()
{
var outDir = Path.Combine(Path.GetTempPath(), "Coalesce.DtoReferenceSummaryTests", Guid.NewGuid().ToString("N"), "Vue");
var suite = BuildExecutor()
.CreateRootGenerator<VueSuite>()
.WithModel(ReflectionRepositoryFactory.Symbol)
.WithOutputPath(outDir);

await suite.GenerateAsync();

var models = await File.ReadAllTextAsync(Path.Combine(outDir, "src", "models.g.ts"));
var metadata = await File.ReadAllTextAsync(Path.Combine(outDir, "src", "metadata.g.ts"));
var viewmodels = await File.ReadAllTextAsync(Path.Combine(outDir, "src", "viewmodels.g.ts"));

await Assert.That(models.Contains("export interface PersonSummary extends Model<typeof metadata.PersonSummary>")).IsTrue();
await Assert.That(models.Contains("companyName: string | null")).IsTrue();
await Assert.That(models.Contains("assignedTo: PersonSummary | null")).IsTrue();
await Assert.That(metadata.Contains("export const PersonSummary = domain.types.PersonSummary =")).IsTrue();
await Assert.That(metadata.Contains("get typeDef() { return (domain.types.PersonSummary as ObjectType & { name: \"PersonSummary\" }) },")).IsTrue();
await Assert.That(viewmodels.Contains("assignedTo: $models.PersonSummary | null;")).IsTrue();
}
}
Loading