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 @@ -27,3 +27,9 @@ That lets the higher-level DTO and read-shape features work against richer EF mo
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.

## Flattened navigation-path properties can be emitted directly

Coalesce can now generate flattened DTO properties from selected navigation paths so callers can bind to the data they actually need without manually creating one-off view types for every combination.

This keeps generated read models compact while still making important related values available as first-class DTO members.
Original file line number Diff line number Diff line change
Expand Up @@ -331,11 +331,19 @@ private void WriteResponseDto(CSharpCodeBuilder b)
.ToList();

var ownProps = orderedProps.Where(p => baseType?.PropertyByName(p.Name) is null);
var flattenedProps = Model.FlattenedResponseProperties
.Where(p => baseType?.PropertyByName(p.Name) is null
&& !(baseType?.FlattenedResponseProperties.Any(fp => fp.Name == p.Name) ?? false))
.ToList();

foreach (PropertyViewModel prop in ownProps)
{
b.Line($"public {ResponsePropertyType(prop)} {prop.Name} {{ get; set; }}");
}
foreach (var prop in flattenedProps)
{
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 DTO instance.");
using (b.Block($"public void MapFrom({Model.FullyQualifiedName} obj, IMappingContext context, IncludeTree tree = null)"))
Expand All @@ -362,7 +370,8 @@ private void WriteResponseDto(CSharpCodeBuilder b)
b.Line();

WriteSetters(b, orderedProps
.Select(ModelToDtoPropertySetter));
.Select(ModelToDtoPropertySetter)
.Concat(flattenedProps.Select(ModelToDtoFlattenedPropertySetter)));
}
}
}
Expand Down Expand Up @@ -656,6 +665,9 @@ string mapCall() => property.Object.IsCustomDto
return (statement, setter);
}

private (IEnumerable<string> conditionals, string setter) ModelToDtoFlattenedPropertySetter(FlattenedResponsePropertyViewModel property)
=> (Enumerable.Empty<string>(), $"this.{property.Name} = {property.AccessExpression("obj")};");

private string ResponsePropertyType(PropertyViewModel property)
{
if (property.UsesDtoReferenceSummary && property.Object is not null)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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 DtoFlattenGenerationTests : CodeGenTestBase
{
[Test]
public async Task ApiDtos_GenerateFlattenedResponseProperties()
{
var outDir = Path.Combine(Path.GetTempPath(), "Coalesce.DtoFlattenTests", 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 contents = await File.ReadAllTextAsync(caseDtoFile);

await Assert.That(contents.Contains("public string AssignedToName { get; set; }")).IsTrue();
await Assert.That(contents.Contains("public string ReportedByCompanyName { get; set; }")).IsTrue();
await Assert.That(contents.Contains("this.AssignedToName = obj.AssignedTo?.Name;")).IsTrue();
await Assert.That(contents.Contains("this.ReportedByCompanyName = obj.ReportedBy?.Company?.Name;")).IsTrue();

await AssertSuiteCSharpOutputCompiles(suite);
}

[Test]
public async Task VueOutput_GeneratesFlattenedResponseProperties()
{
var outDir = Path.Combine(Path.GetTempPath(), "Coalesce.DtoFlattenTests", 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"));

await Assert.That(models.Contains("assignedToName: string | null")).IsTrue();
await Assert.That(models.Contains("reportedByCompanyName: string | null")).IsTrue();
await Assert.That(metadata.Contains("assignedToName:")).IsTrue();
await Assert.That(metadata.Contains("reportedByCompanyName:")).IsTrue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,10 @@ private void WriteClassPropertiesMetadata(TypeScriptCodeBuilder b, ClassViewMode
{
WriteClassPropertyMetadata(b, model, prop);
}
foreach (var prop in model.FlattenedResponseProperties)
{
WriteFlattenedClassPropertyMetadata(b, prop);
}
}
}

Expand Down Expand Up @@ -431,6 +435,23 @@ TypeDiscriminator.Enum or
}
}

private void WriteFlattenedClassPropertyMetadata(TypeScriptCodeBuilder b, FlattenedResponsePropertyViewModel prop)
{
using (b.Block($"{prop.Name.ToCamelCase()}:", ','))
{
b.StringProp("name", prop.Name.ToCamelCase());
b.StringProp("displayName", prop.DisplayName);

if (!string.IsNullOrWhiteSpace(prop.LeafProperty.Description))
{
b.StringProp("description", prop.LeafProperty.Description);
}

WriteTypeCommonMetadata(b, prop.Type, prop.LeafProperty);
b.StringProp("role", "value");
}
}

private void WriteSummaryPrimaryKeyMetadata(TypeScriptCodeBuilder b, PropertyViewModel prop)
{
using (b.Block($"{prop.JsVariable}:", ','))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ private void WriteModel(TypeScriptCodeBuilder b, ClassViewModel model, HashSet<C
var typeString = GetModelPropertyType(prop);
b.Line($"{prop.JsVariable}: {typeString} | null");
}
foreach (var prop in model.FlattenedResponseProperties)
{
b.DocComment(prop.LeafProperty.Comment ?? prop.LeafProperty.Description);
var typeString = new VueType(prop.Type.NullableValueUnderlyingType).TsType();
b.Line($"{prop.Name.ToCamelCase()}: {typeString} | null");
}
}

using (b.Block($"export class {name}"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ namespace IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext;

[Table("Case")]
[Create(PermissionLevel = SecurityPermissionLevels.AllowAll)]
[DtoFlatten("AssignedTo.Name")]
[DtoFlatten("ReportedBy.Company.Name")]
public class Case
{
public enum Statuses
Expand Down
25 changes: 25 additions & 0 deletions src/IntelliTect.Coalesce/DataAnnotations/DtoFlattenAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;

namespace IntelliTect.Coalesce.DataAnnotations;

/// <summary>
/// Adds a read-only flattened DTO property for a nested property path.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class DtoFlattenAttribute : Attribute
{
public DtoFlattenAttribute(string path)
{
Path = path;
}

/// <summary>
/// Dot-delimited property path to flatten, such as "AssignedTo.Name".
/// </summary>
public string Path { get; }

/// <summary>
/// Optional generated property name. Defaults to the concatenated path segments.
/// </summary>
public string? Name { get; set; }
}
4 changes: 4 additions & 0 deletions src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ and not TypeDiscriminator.Void
and not TypeDiscriminator.Unknown
);

private IReadOnlyList<FlattenedResponsePropertyViewModel>? _flattenedResponseProperties;
public IReadOnlyList<FlattenedResponsePropertyViewModel> FlattenedResponseProperties
=> _flattenedResponseProperties ??= FlattenedResponsePropertyViewModel.FromClass(this);

/// <summary>
/// List of method names that should not be exposed to the client.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using IntelliTect.Coalesce.DataAnnotations;
using IntelliTect.Coalesce.Utilities;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;

namespace IntelliTect.Coalesce.TypeDefinition;

public sealed class FlattenedResponsePropertyViewModel
{
private FlattenedResponsePropertyViewModel(
ClassViewModel declaringClass,
DtoFlattenAttribute attribute,
IReadOnlyList<PropertyViewModel> pathProperties)
{
DeclaringClass = declaringClass;
Attribute = attribute;
PathProperties = pathProperties;
Name = string.IsNullOrWhiteSpace(attribute.Name)
? string.Concat(pathProperties.Select(p => p.Name))
: attribute.Name!;
}

public ClassViewModel DeclaringClass { get; }
public DtoFlattenAttribute Attribute { get; }
public IReadOnlyList<PropertyViewModel> PathProperties { get; }
public string Name { get; }
public string DisplayName => Name.ToProperCase();
public string Path => Attribute.Path;
public PropertyViewModel RootProperty => PathProperties[0];
public PropertyViewModel LeafProperty => PathProperties[^1];
public TypeViewModel Type => LeafProperty.Type;
public string IncludePath => string.Join(".", PathProperties.Take(PathProperties.Count - 1).Select(p => p.Name));

public string AccessExpression(string rootExpression)
{
if (PathProperties.Count < 2)
{
throw new InvalidOperationException($"Flattened DTO property '{Name}' must have at least two path segments.");
}

var expression = rootExpression;
for (var i = 0; i < PathProperties.Count; i++)
{
var separator = i == 0 ? "." : "?.";
expression += separator + PathProperties[i].Name;
}

return expression;
}

public static IReadOnlyList<FlattenedResponsePropertyViewModel> FromClass(ClassViewModel model)
{
var flattened = model
.GetAttributes<DtoFlattenAttribute>()
.Select(a => Create(model, a))
.ToList();

return new ReadOnlyCollection<FlattenedResponsePropertyViewModel>(flattened);
}

public static FlattenedResponsePropertyViewModel Create(ClassViewModel model, AttributeViewModel<DtoFlattenAttribute> attribute)
{
var path = attribute.GetValue(a => a.Path);
var name = attribute.GetValue(a => a.Name);

if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException($"[{nameof(DtoFlattenAttribute)}] on {model.FullyQualifiedName} must specify a non-empty path.");
}

var segments = path
.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

if (segments.Length < 2)
{
throw new InvalidOperationException($"[{nameof(DtoFlattenAttribute)}] path '{path}' on {model.FullyQualifiedName} must contain at least one navigation and one leaf property.");
}

var pathProperties = new List<PropertyViewModel>(segments.Length);
ClassViewModel current = model;

for (var i = 0; i < segments.Length; i++)
{
var segment = segments[i];
var prop = current.PropertyByName(segment)
?? throw new InvalidOperationException($"[{nameof(DtoFlattenAttribute)}] path '{path}' on {model.FullyQualifiedName} could not resolve segment '{segment}' on {current.FullyQualifiedName}.");

pathProperties.Add(prop);

if (i < segments.Length - 1)
{
if (prop.Type.IsCollection)
{
throw new InvalidOperationException($"[{nameof(DtoFlattenAttribute)}] path '{path}' on {model.FullyQualifiedName} cannot traverse collection property '{prop.Name}'.");
}

current = prop.Type.ClassViewModel
?? throw new InvalidOperationException($"[{nameof(DtoFlattenAttribute)}] path '{path}' on {model.FullyQualifiedName} cannot traverse scalar property '{prop.Name}'.");
}
}

var leaf = pathProperties[^1];
if (leaf.Type.IsCollection || leaf.Type.IsDictionary || leaf.Type.HasClassViewModel)
{
throw new InvalidOperationException($"[{nameof(DtoFlattenAttribute)}] path '{path}' on {model.FullyQualifiedName} must end on a scalar/enum value, not '{leaf.Type.FullyQualifiedName}'.");
}

return new FlattenedResponsePropertyViewModel(model, new DtoFlattenAttribute(path) { Name = name }, pathProperties);
}
}
15 changes: 15 additions & 0 deletions src/test-targets/api-clients.g.ts

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

Loading