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 @@ -51,3 +51,9 @@ That makes it practical to define response-shape contracts, transport-specific i
When solutions intentionally restrict generation to a whitelisted DbContext root, Coalesce validation now respects that scope instead of treating out-of-scope types as hard blockers.

That keeps the generated-shape pipeline usable in larger solutions where multiple DbContexts or model roots exist side by side.

## Auto-projected read shapes can model read APIs directly

Coalesce can now generate auto-projected read shapes for scenarios where the response model is intentionally smaller or differently organized than the backing entity.

This is the layer that makes richer read DTOs practical without forcing every read endpoint to rely on hand-written projection types.
50 changes: 45 additions & 5 deletions src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -396,11 +396,10 @@ private void WriteSummaryDto(CSharpCodeBuilder b)
using (b.Block($"public void MapFrom({Model.FullyQualifiedName} obj, IMappingContext context, IncludeTree tree = null)"))
{
b.Line("if (obj is null) return;");
b.Line("var includes = context.Includes;");
b.Line();
b.Line($"this.{primaryKey.Name} = obj.{primaryKey.Name};");
foreach (var prop in Model.SummaryProperties)
{
b.Line($"this.{prop.Name} = {prop.AccessExpression("obj")};");
}
WriteSetters(b, Model.SummaryProperties.Select(ModelToSummaryDtoPropertySetter));
}
}
}
Expand Down Expand Up @@ -473,11 +472,15 @@ private IEnumerable<string> GetPropertySetterConditional(

var includes = string.Join(" || ", property.DtoIncludes.Select(IncludesCheck));
var excludes = string.Join(" || ", property.DtoExcludes.Select(IncludesCheck));
var explicitViewExcludes = string.Join(" || ", property.EffectiveParent.DtoContentViews
.Where(v => !v.Value && !property.DtoIncludes.Contains(v.Key, StringComparer.Ordinal))
.Select(v => IncludesCheck(v.Key)));

var statement = new List<string>();
if (!string.IsNullOrEmpty(roles)) statement.Add($"({roles})");
if (!string.IsNullOrEmpty(includes)) statement.Add($"({includes})");
if (!string.IsNullOrEmpty(excludes)) statement.Add($"!({excludes})");
if (!string.IsNullOrEmpty(explicitViewExcludes)) statement.Add($"!({explicitViewExcludes})");

foreach (var restriction in property.SecurityInfo.Restrictions)
{
Expand Down Expand Up @@ -666,7 +669,44 @@ string mapCall() => property.Object.IsCustomDto
}

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

private (IEnumerable<string> conditionals, string setter) ModelToSummaryDtoPropertySetter(SummaryPropertyViewModel property)
=> (
GetContentViewConditionals(property.Parent, property.ContentViews, property.ExcludedContentViews),
$"this.{property.Name} = {property.AccessExpression("obj")};");

private static IEnumerable<string> GetContentViewConditionals(
ClassViewModel declaringClass,
IEnumerable<string> includes,
IEnumerable<string> excludes)
{
string IncludesCheck(string include) => $"includes == \"{include.EscapeStringLiteralForCSharp()}\"";

var includeList = includes.ToList();
var excludeList = excludes.ToList();
var explicitViewExcludes = declaringClass.DtoContentViews
.Where(v => !v.Value && !includeList.Contains(v.Key, StringComparer.Ordinal))
.Select(v => IncludesCheck(v.Key))
.ToList();

if (includeList.Count > 0)
{
yield return $"({string.Join(" || ", includeList.Select(IncludesCheck))})";
}

if (excludeList.Count > 0)
{
yield return $"!({string.Join(" || ", excludeList.Select(IncludesCheck))})";
}

if (explicitViewExcludes.Count > 0)
{
yield return $"!({string.Join(" || ", explicitViewExcludes)})";
}
}

private string ResponsePropertyType(PropertyViewModel property)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI
var primaryKeyParameter = $"{Model.PrimaryKey.Type.FullyQualifiedName} id";
var dataSourceParameter = $"IDataSource<{Model.BaseViewModel.FullyQualifiedName}> dataSource";
var behaviorsParameter = $"IBehaviors<{Model.BaseViewModel.FullyQualifiedName}> behaviors";
string defaultGetIncludes = DefaultIncludesLiteral(Model.DefaultGetDtoIncludes);
string defaultListIncludes = DefaultIncludesLiteral(Model.DefaultListDtoIncludes);
string defaultCountIncludes = DefaultIncludesLiteral(Model.DefaultCountDtoIncludes);
string defaultSaveIncludes = DefaultIncludesLiteral(Model.DefaultSaveDtoIncludes);
string defaultBulkSaveIncludes = DefaultIncludesLiteral(Model.DefaultBulkSaveDtoIncludes);
string defaultDeleteIncludes = DefaultIncludesLiteral(Model.DefaultDeleteDtoIncludes);
#pragma warning disable CS0618 // Type or member is obsolete
var accessModifier = Model.ApiActionAccessModifier;
#pragma warning restore CS0618 // Type or member is obsolete
Expand Down Expand Up @@ -78,7 +84,7 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI
b.Indented($"{primaryKeyParameter},");
b.Indented($"[FromQuery] DataSourceParameters parameters,");
b.Indented($"{dataSourceParameter})");
b.Indented($"=> GetImplementation(id, parameters, dataSource);");
b.Indented($"=> GetImplementation(id, ApplyDefaultIncludes(parameters, {defaultGetIncludes}), dataSource);");

// ENDPOINT: /list
b.Line();
Expand All @@ -87,7 +93,7 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI
b.Line($"{accessModifier} virtual Task<ListResult<{Model.ResponseDtoTypeName}>> List(");
b.Indented($"[FromQuery] ListParameters parameters,");
b.Indented($"{dataSourceParameter})");
b.Indented($"=> ListImplementation(parameters, dataSource);");
b.Indented($"=> ListImplementation(ApplyDefaultIncludes(parameters, {defaultListIncludes}), dataSource);");

// ENDPOINT: /count
b.Line();
Expand All @@ -96,7 +102,7 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI
b.Line($"{accessModifier} virtual Task<ItemResult<int>> Count(");
b.Indented($"[FromQuery] FilterParameters parameters,");
b.Indented($"{dataSourceParameter})");
b.Indented($"=> CountImplementation(parameters, dataSource);");
b.Indented($"=> CountImplementation(ApplyDefaultIncludes(parameters, {defaultCountIncludes}), dataSource);");
}

if (securityInfo.Save.IsAllowed())
Expand All @@ -111,7 +117,7 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI
b.Indented($"[FromQuery] DataSourceParameters parameters,");
b.Indented($"{dataSourceParameter},");
b.Indented($"{behaviorsParameter})");
b.Indented($"=> SaveImplementation(dto, parameters, dataSource, behaviors);");
b.Indented($"=> SaveImplementation(dto, ApplyDefaultIncludes(parameters, {defaultSaveIncludes}), dataSource, behaviors);");

b.Line();
b.Line("""[HttpPost("save")]""");
Expand All @@ -122,16 +128,14 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI
b.Indented($"[FromQuery] DataSourceParameters parameters,");
b.Indented($"{dataSourceParameter},");
b.Indented($"{behaviorsParameter})");
b.Indented($"=> SaveImplementation(dto, parameters, dataSource, behaviors);");
b.Indented($"=> SaveImplementation(dto, ApplyDefaultIncludes(parameters, {defaultSaveIncludes}), dataSource, behaviors);");
}

if (Model.DbContext != null && securityInfo.IsReadAllowed())
if (Model.DbContext != null && securityInfo.IsReadAllowed() && (securityInfo.Save.IsAllowed() || securityInfo.IsDeleteAllowed()))
{
// Counterintuitively, bulk saves are governed by read permissions. This is for a few reasons:
// - BulkSaveImplementation checks the save/delete permissions for each entity being acted upon at runtime.
// - At the end of a bulk save, a GetImplementation is performed for this controller's type.
// - A particular usage of a bulk save might never be saving the root entity;
// it may be the case that the root entity is immutable and only its children are being mutated.
// Bulk saves are only emitted for readable, mutable roots.
// BulkSaveImplementation still checks save/delete permissions for each entity at runtime,
// but omitting the endpoint entirely for immutable roots keeps read-only slices read-only.

// ENDPOINT: /bulkSave
b.Line();
Expand All @@ -143,7 +147,7 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI
b.Indented($"{dataSourceParameter},");
b.Indented($"[FromServices] IDataSourceFactory dataSourceFactory,");
b.Indented($"[FromServices] IBehaviorsFactory behaviorsFactory)");
b.Indented($"=> BulkSaveImplementation(dto, parameters, dataSource, dataSourceFactory, behaviorsFactory);");
b.Indented($"=> BulkSaveImplementation(dto, ApplyDefaultIncludes(parameters, {defaultBulkSaveIncludes}), dataSource, dataSourceFactory, behaviorsFactory);");
}

if (securityInfo.IsDeleteAllowed())
Expand All @@ -156,7 +160,7 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI
b.Indented($"{primaryKeyParameter},");
b.Indented($"{behaviorsParameter},");
b.Indented($"{dataSourceParameter})");
b.Indented($"=> DeleteImplementation(id, new DataSourceParameters(), dataSource, behaviors);");
b.Indented($"=> DeleteImplementation(id, ApplyDefaultIncludes(new DataSourceParameters(), {defaultDeleteIncludes}), dataSource, behaviors);");
}

if (Model.ClientMethods.Any())
Expand Down Expand Up @@ -286,4 +290,9 @@ private static void WriteEtagProcessing(CSharpCodeBuilder b, MethodViewModel met
}
b.Line();
}

private static string DefaultIncludesLiteral(string includes)
=> string.IsNullOrWhiteSpace(includes)
? "null"
: includes.QuotedStringLiteralForCSharp();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using IntelliTect.Coalesce.CodeGeneration.Generation;
using IntelliTect.Coalesce.Testing;
using IntelliTect.Coalesce.Testing.Util;
using IntelliTect.Coalesce.TypeDefinition;

namespace IntelliTect.Coalesce.CodeGeneration.Tests;

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

await suite.GenerateAsync();

var dtoFile = Directory.GetFiles(outDir, "ContentViewEntityDto.g.cs", SearchOption.AllDirectories).Single();
var controllerFile = Directory.GetFiles(outDir, "ContentViewEntityController.g.cs", SearchOption.AllDirectories).Single();
var personDtoFile = Directory.GetFiles(outDir, "PersonDto.g.cs", SearchOption.AllDirectories).Single();

var dtoContents = await File.ReadAllTextAsync(dtoFile);
var controllerContents = await File.ReadAllTextAsync(controllerFile);
var personDtoContents = await File.ReadAllTextAsync(personDtoFile);

await Assert.That(controllerContents.Contains("GetImplementation(id, ApplyDefaultIncludes(parameters, \"detail\"), dataSource)")).IsTrue();
await Assert.That(controllerContents.Contains("ListImplementation(ApplyDefaultIncludes(parameters, \"list\"), dataSource)")).IsTrue();
await Assert.That(controllerContents.Contains("SaveImplementation(dto, ApplyDefaultIncludes(parameters, \"save\"), dataSource, behaviors)")).IsTrue();
await Assert.That(controllerContents.Contains("CountImplementation(ApplyDefaultIncludes(parameters, \"list\"), dataSource)")).IsTrue();

await Assert.That(dtoContents.Contains("this.ReportedByCompanyName = obj.ReportedBy?.Company?.Name;")).IsTrue();
await Assert.That(dtoContents.Contains("includes == \"detail\"")).IsTrue();
await Assert.That(dtoContents.Contains("includes == \"list\"")).IsTrue();
await Assert.That(dtoContents.Contains("includes == \"save\"")).IsTrue();
await Assert.That(personDtoContents.Contains("this.CompanyName = obj.Company?.Name;")).IsTrue();
await Assert.That(personDtoContents.Contains("includes == \"detail\"")).IsTrue();

await AssertSuiteCSharpOutputCompiles(suite);
}
}
33 changes: 33 additions & 0 deletions src/IntelliTect.Coalesce.Testing/TargetClasses/CaseAutoReadDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using IntelliTect.Coalesce.DataAnnotations;
using IntelliTect.Coalesce.Mapping;
using IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

#nullable enable

namespace IntelliTect.Coalesce.Testing.TargetClasses;

[Coalesce]
[Create(PermissionLevel = SecurityPermissionLevels.DenyAll)]
[Edit(PermissionLevel = SecurityPermissionLevels.DenyAll)]
[Delete(PermissionLevel = SecurityPermissionLevels.DenyAll)]
public class CaseAutoReadDto : AutoReadDto<Case, AppDbContext>
{
[Key]
[DtoSource(nameof(Case.CaseKey))]
public int CaseId { get; set; }

public string? Title { get; set; }

[DtoSource("AssignedTo.Name")]
public string? AssignedToName { get; set; }

[DtoSource("ReportedBy")]
public PersonRecord? ReportedBy { get; set; }

[DtoSource("CaseProducts.Product.Name", OrderBy = "Product.Name")]
public List<string>? ProductNames { get; set; }

public record PersonRecord(int PersonId, string Name);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
using IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext;

#nullable enable

namespace IntelliTect.Coalesce.Testing.TargetClasses;

public class ExternalObjectWithoutListText
{
public string? Value { get; set; }
public NestedExternalObjectWithoutListText? Child { get; set; }
}

public class NestedExternalObjectWithoutListText
{
public string? Value { get; set; }
}

[Coalesce]
public class CaseDtoWithExternalObject : IClassDto<Case, AppDbContext>
{
[Key]
public int CaseKey { get; set; }

public string? Title { get; set; }

public ExternalObjectWithoutListText? ExternalObject { get; set; }

public void MapTo(Case obj, IMappingContext context)
{
obj.Title = Title;
}

public void MapFrom(Case obj, IMappingContext context, IncludeTree? tree = null)
{
CaseKey = obj.CaseKey;
Title = obj.Title;
ExternalObject = new ExternalObjectWithoutListText
{
Value = obj.Title,
Child = new NestedExternalObjectWithoutListText
{
Value = obj.Title
}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using IntelliTect.Coalesce.DataAnnotations;
using Microsoft.EntityFrameworkCore;

namespace IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext;

[Coalesce(IncludeInheritedDbSets = false)]
public class CompanyOnlyDbContext : AppDbContext
{
public CompanyOnlyDbContext() { }

public CompanyOnlyDbContext(DbContextOptions<CompanyOnlyDbContext> options)
: base(options) { }

public new DbSet<Company> Companies => Set<Company>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using IntelliTect.Coalesce.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext;

[Create(PermissionLevel = SecurityPermissionLevels.AllowAll)]
[Edit(PermissionLevel = SecurityPermissionLevels.AllowAll)]
[DtoContentView("list", IncludeByDefault = false)]
[DtoContentView("detail", IncludeByDefault = false)]
[DtoContentView("save", IncludeByDefault = false)]
[DtoActionDefaults(List = "list", Get = "detail", Save = "save", Count = "list")]
[DtoFlatten("ReportedBy.Company.Name", Name = "ReportedByCompanyName", ContentViews = "detail")]
public class ContentViewEntity
{
[DtoIncludes("list,detail,save")]
public int ContentViewEntityId { get; set; }

[DtoIncludes("list,detail,save")]
public string Name { get; set; }

[DtoIncludes("detail,save")]
public string Description { get; set; }

public int? AssignedToId { get; set; }

[DtoIncludes("list,detail")]
[DtoReference]
[ForeignKey(nameof(AssignedToId))]
public Person AssignedTo { get; set; }

public int? ReportedById { get; set; }

[DtoIncludes("detail")]
[ForeignKey(nameof(ReportedById))]
public Person ReportedBy { get; set; }

public string NeverMapped { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext;

[Edit(PermissionLevel = SecurityPermissionLevels.AllowAll)]
[Table("Person")]
[DtoSummary("Company.Name")]
[DtoSummary("Company.Name", ContentViews = "detail")]
public class Person
{
public enum Genders
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public class AppDbContext : DbContext
public DbSet<TimeOnlyPk> TimeOnlyPks { get; set; }

public DbSet<SuppressedDefaultOrdering> SuppressedDefaultOrderings { get; set; }
public DbSet<ContentViewEntity> ContentViewEntities { get; set; }

public DbSet<Student> Students { get; set; }
public DbSet<Advisor> Advisors { get; set; }
Expand Down
Loading