diff --git a/docs/topics/dto-shapes-and-read-models.md b/docs/topics/dto-shapes-and-read-models.md index a5322533f..136d79fd5 100644 --- a/docs/topics/dto-shapes-and-read-models.md +++ b/docs/topics/dto-shapes-and-read-models.md @@ -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. diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs index 8c8f94f06..290f6afa6 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs @@ -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)); } } } @@ -473,11 +472,15 @@ private IEnumerable 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(); 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) { @@ -666,7 +669,44 @@ string mapCall() => property.Object.IsCustomDto } private (IEnumerable conditionals, string setter) ModelToDtoFlattenedPropertySetter(FlattenedResponsePropertyViewModel property) - => (Enumerable.Empty(), $"this.{property.Name} = {property.AccessExpression("obj")};"); + => ( + GetContentViewConditionals(property.DeclaringClass, property.ContentViews, property.ExcludedContentViews), + $"this.{property.Name} = {property.AccessExpression("obj")};"); + + private (IEnumerable conditionals, string setter) ModelToSummaryDtoPropertySetter(SummaryPropertyViewModel property) + => ( + GetContentViewConditionals(property.Parent, property.ContentViews, property.ExcludedContentViews), + $"this.{property.Name} = {property.AccessExpression("obj")};"); + + private static IEnumerable GetContentViewConditionals( + ClassViewModel declaringClass, + IEnumerable includes, + IEnumerable 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) { diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ModelApiController.cs b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ModelApiController.cs index f6c2960aa..09043170f 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ModelApiController.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ModelApiController.cs @@ -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 @@ -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(); @@ -87,7 +93,7 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI b.Line($"{accessModifier} virtual Task> 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(); @@ -96,7 +102,7 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI b.Line($"{accessModifier} virtual Task> 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()) @@ -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")]"""); @@ -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(); @@ -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()) @@ -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()) @@ -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(); } diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs b/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs new file mode 100644 index 000000000..31ff9ff4f --- /dev/null +++ b/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs @@ -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() + .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); + } +} diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/CaseAutoReadDto.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/CaseAutoReadDto.cs new file mode 100644 index 000000000..89c718d4b --- /dev/null +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/CaseAutoReadDto.cs @@ -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 +{ + [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? ProductNames { get; set; } + + public record PersonRecord(int PersonId, string Name); +} diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/CustomDtoWithExternalObject.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/CustomDtoWithExternalObject.cs new file mode 100644 index 000000000..cef39f7c5 --- /dev/null +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/CustomDtoWithExternalObject.cs @@ -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 +{ + [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 + } + }; + } +} diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/CompanyOnlyDbContext.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/CompanyOnlyDbContext.cs new file mode 100644 index 000000000..144bcb06e --- /dev/null +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/CompanyOnlyDbContext.cs @@ -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 options) + : base(options) { } + + public new DbSet Companies => Set(); +} diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs new file mode 100644 index 000000000..3570519f0 --- /dev/null +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs @@ -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; } +} diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/Person.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/Person.cs index d6596d972..7aaab351f 100644 --- a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/Person.cs +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/Person.cs @@ -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 diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/TestDbContext.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/TestDbContext.cs index 9c3450a4b..4ebfd9aa2 100644 --- a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/TestDbContext.cs +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/TestDbContext.cs @@ -55,6 +55,7 @@ public class AppDbContext : DbContext public DbSet TimeOnlyPks { get; set; } public DbSet SuppressedDefaultOrderings { get; set; } + public DbSet ContentViewEntities { get; set; } public DbSet Students { get; set; } public DbSet Advisors { get; set; } diff --git a/src/IntelliTect.Coalesce.Tests/Tests/Api/DataSources/AutoProjectedDtoDataSourceTests.cs b/src/IntelliTect.Coalesce.Tests/Tests/Api/DataSources/AutoProjectedDtoDataSourceTests.cs new file mode 100644 index 000000000..69bc4f8a8 --- /dev/null +++ b/src/IntelliTect.Coalesce.Tests/Tests/Api/DataSources/AutoProjectedDtoDataSourceTests.cs @@ -0,0 +1,114 @@ +using IntelliTect.Coalesce.Api; +using IntelliTect.Coalesce.Mapping; +using IntelliTect.Coalesce.Models; +using IntelliTect.Coalesce.Testing.Fixtures; +using IntelliTect.Coalesce.Testing.TargetClasses; +using IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext; +using Microsoft.EntityFrameworkCore; +using System.Linq; + +namespace IntelliTect.Coalesce.Tests.Api.DataSources; + +public class AutoProjectedDtoDataSourceTests : TestDbContextFixture +{ + private CaseAutoReadSource Source() => new(CrudContext); + + [Test] + public async Task GetMappedListAsync_Projects_AutoReadDto() + { + Db.Cases.Add(new Case + { + Title = "Printer offline", + AssignedTo = new Person { FirstName = "Ada", LastName = "Lovelace", Title = Person.Titles.Ms }, + ReportedBy = new Person { FirstName = "Grace", LastName = "Hopper", Title = Person.Titles.Mrs }, + CaseProducts = + [ + new() { Product = new Product { Name = "Windows" } }, + new() { Product = new Product { Name = "Azure" } }, + ] + }); + Db.SaveChanges(); + Db.ChangeTracker.Clear(); + + var result = await Source().GetMappedListAsync(new ListParameters()); + + await Assert.That(result.List).HasSingleItem(); + var dto = result.List.Single(); + await Assert.That(dto.CaseId).IsGreaterThan(0); + await Assert.That(dto.Title).IsEqualTo("Printer offline"); + await Assert.That(dto.AssignedToName).IsEqualTo("Ms Ada Lovelace"); + await Assert.That(dto.ReportedBy).IsNotNull(); + await Assert.That(dto.ReportedBy!.Name).IsEqualTo("Mrs Grace Hopper"); + await Assert.That(dto.ProductNames).IsEquivalentTo(new[] { "Azure", "Windows" }); + } + + [Test] + public async Task MapFrom_Uses_AutoProjection() + { + var entity = new Case + { + CaseKey = 42, + Title = "VPN issue", + AssignedTo = new Person { FirstName = "Katherine", LastName = "Johnson", Title = Person.Titles.Ms }, + ReportedBy = new Person { PersonId = 7, FirstName = "Margaret", LastName = "Hamilton", Title = Person.Titles.Mrs }, + CaseProducts = + [ + new() { Product = new Product { Name = "Intune" } } + ] + }; + + var dto = new CaseAutoReadDto(); + dto.MapFrom(entity, new MappingContext()); + + await Assert.That(dto.CaseId).IsEqualTo(42); + await Assert.That(dto.AssignedToName).IsEqualTo("Ms Katherine Johnson"); + await Assert.That(dto.ReportedBy).IsEquivalentTo(new CaseAutoReadDto.PersonRecord(7, "Mrs Margaret Hamilton")); + await Assert.That(dto.ProductNames).IsEquivalentTo(new[] { "Intune" }); + } + + [Test] + public async Task ComposedProjection_Overrides_Computed_And_Child_Members() + { + Db.Cases.Add(new Case + { + Title = "SSO issue", + AssignedTo = new Person { FirstName = "Katherine", LastName = "Johnson", Title = Person.Titles.Ms }, + ReportedBy = new Person { PersonId = 7, FirstName = "Margaret", LastName = "Hamilton", Title = Person.Titles.Mrs }, + CaseProducts = + [ + new() { Product = new Product { Name = "Intune" } }, + new() { Product = new Product { Name = "Azure" } }, + ] + }); + Db.SaveChanges(); + Db.ChangeTracker.Clear(); + + var dto = await Db.Cases + .IncludeChildren() + .Select(AutoProjection.For( + c => new CaseAutoReadDto + { + AssignedToName = c.AssignedTo == null ? null : c.AssignedTo.FirstName + " " + c.AssignedTo.LastName, + ProductNames = c.CaseProducts + .OrderBy(cp => cp.Product!.Name) + .Select(cp => cp.Product!.Name + "!") + .ToList(), + })) + .SingleAsync(); + + await Assert.That(dto.Title).IsEqualTo("SSO issue"); + await Assert.That(dto.ReportedBy).IsEquivalentTo(new CaseAutoReadDto.PersonRecord(7, "Mrs Margaret Hamilton")); + await Assert.That(dto.AssignedToName).IsEqualTo("Katherine Johnson"); + await Assert.That(dto.ProductNames).IsEquivalentTo(new[] { "Azure!", "Intune!" }); + } + + private sealed class CaseAutoReadSource(CrudContext context) + : ProjectedDtoDataSource(context) + { + public override IQueryable GetQuery(IDataSourceParameters parameters) + => Db.Cases.IncludeChildren(); + + public override IQueryable ApplyProjection(IQueryable query, IDataSourceParameters parameters) + => query.Select(AutoProjection.For()); + } +} diff --git a/src/IntelliTect.Coalesce.Tests/Tests/Api/DataSources/ContentViewIncludeTests.cs b/src/IntelliTect.Coalesce.Tests/Tests/Api/DataSources/ContentViewIncludeTests.cs new file mode 100644 index 000000000..3ac4aab5f --- /dev/null +++ b/src/IntelliTect.Coalesce.Tests/Tests/Api/DataSources/ContentViewIncludeTests.cs @@ -0,0 +1,36 @@ +using IntelliTect.Coalesce.Api; +using IntelliTect.Coalesce.Testing.Fixtures; +using IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext; +using IntelliTect.Coalesce.Testing.Util; + +namespace IntelliTect.Coalesce.Tests.Api.DataSources; + +public class ContentViewIncludeTests : TestDbContextFixture +{ + private StandardDataSource Source() + where T : class, new() + => new StandardDataSource(CrudContext); + + [Test] + public async Task GetQuery_WhenExplicitListViewRequested_OnlyIncludesListNavigations() + { + var query = Source().GetQuery(new DataSourceParameters { Includes = "list" }); + var tree = query.GetIncludeTree(); + + await Assert.That(tree[nameof(ContentViewEntity.AssignedTo)]).IsNotNull(); + await Assert.That(tree[nameof(ContentViewEntity.AssignedTo)][nameof(Person.Company)]).IsNull(); + await Assert.That(tree[nameof(ContentViewEntity.ReportedBy)]).IsNull(); + } + + [Test] + public async Task GetQuery_WhenExplicitDetailViewRequested_IncludesDetailNavigations() + { + var query = Source().GetQuery(new DataSourceParameters { Includes = "detail" }); + var tree = query.GetIncludeTree(); + + await Assert.That(tree[nameof(ContentViewEntity.AssignedTo)]).IsNotNull(); + await Assert.That(tree[nameof(ContentViewEntity.AssignedTo)][nameof(Person.Company)]).IsNotNull(); + await Assert.That(tree[nameof(ContentViewEntity.ReportedBy)]).IsNotNull(); + await Assert.That(tree[nameof(ContentViewEntity.ReportedBy)][nameof(Person.Company)]).IsNotNull(); + } +} diff --git a/src/IntelliTect.Coalesce.Tests/Tests/Mapping/IncludeTreeTests.cs b/src/IntelliTect.Coalesce.Tests/Tests/Mapping/IncludeTreeTests.cs index daae31d35..8e0a5f748 100644 --- a/src/IntelliTect.Coalesce.Tests/Tests/Mapping/IncludeTreeTests.cs +++ b/src/IntelliTect.Coalesce.Tests/Tests/Mapping/IncludeTreeTests.cs @@ -101,7 +101,7 @@ public async Task IncludeTree_StaticQueryFor() public async Task IncludeTree_IncludeChildren_IncludesReferenceSummaryPaths() { var tree = db.Cases - .IncludeChildren(ReflectionRepositoryFactory.Reflection) + .IncludeChildren(ReflectionRepositoryFactory.Reflection, "detail") .GetIncludeTree(); await Assert.That(tree[nameof(Case.AssignedTo)]).IsNotNull(); diff --git a/src/IntelliTect.Coalesce.Tests/Tests/TypeDefinition/DbContextDiscoveryTests.cs b/src/IntelliTect.Coalesce.Tests/Tests/TypeDefinition/DbContextDiscoveryTests.cs new file mode 100644 index 000000000..81fe94c4c --- /dev/null +++ b/src/IntelliTect.Coalesce.Tests/Tests/TypeDefinition/DbContextDiscoveryTests.cs @@ -0,0 +1,54 @@ +using IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext; +using IntelliTect.Coalesce.Testing.Util; +using IntelliTect.Coalesce.TypeDefinition; +using Microsoft.CodeAnalysis; + +namespace IntelliTect.Coalesce.Tests.TypeDefinition; + +public class DbContextDiscoveryTests +{ + [Test] + public async Task CoalesceContext_CanExcludeInheritedDbSets() + { + var repo = new ReflectionRepository(); + repo.SetRootTypeWhitelist(new[] { nameof(CompanyOnlyDbContext) }); + repo.GetOrAddType(typeof(CompanyOnlyDbContext)); + var entities = repo.Entities.ToList(); + var contextEntities = repo.DbContexts + .Single(context => context.ClassViewModel.Name == nameof(CompanyOnlyDbContext)) + .Entities + .ToList(); + + await Assert.That(entities.Count).IsEqualTo(1); + await Assert.That(entities.Single().Name).IsEqualTo(nameof(Company)); + await Assert.That(contextEntities.Single().ContextPropertyName).IsEqualTo(nameof(CompanyOnlyDbContext.Companies)); + } + + [Test] + public async Task CoalesceContext_CanExcludeInheritedDbSets_OnSymbolDiscovery() + { + var repo = new ReflectionRepository(); + repo.SetRootTypeWhitelist(new[] { nameof(CompanyOnlyDbContext) }); + repo.DiscoverCoalescedTypes( + ReflectionRepositoryFactory.Symbols + .Where(symbol => + symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) is string fullyQualifiedName + && ( + !fullyQualifiedName.Contains("IntelliTect.Coalesce.Testing.TargetClasses") + || (symbol is IArrayTypeSymbol arrayType ? arrayType.ElementType : symbol).ContainingAssembly?.MetadataName + == ReflectionRepositoryFactory.SymbolDiscoveryAssemblyName + )) + .Select(symbol => new SymbolTypeViewModel(repo, symbol)) + ); + + var entities = repo.Entities.ToList(); + var contextEntities = repo.DbContexts + .Single(context => context.ClassViewModel.Name == nameof(CompanyOnlyDbContext)) + .Entities + .ToList(); + + await Assert.That(entities.Count).IsEqualTo(1); + await Assert.That(entities.Single().Name).IsEqualTo(nameof(Company)); + await Assert.That(contextEntities.Single().ContextPropertyName).IsEqualTo(nameof(CompanyOnlyDbContext.Companies)); + } +} diff --git a/src/IntelliTect.Coalesce.Tests/Tests/Validation/CustomDtoExternalObjectValidationTests.cs b/src/IntelliTect.Coalesce.Tests/Tests/Validation/CustomDtoExternalObjectValidationTests.cs new file mode 100644 index 000000000..e82e1417d --- /dev/null +++ b/src/IntelliTect.Coalesce.Tests/Tests/Validation/CustomDtoExternalObjectValidationTests.cs @@ -0,0 +1,59 @@ +using IntelliTect.Coalesce.Testing.TargetClasses; +using IntelliTect.Coalesce.Testing.Util; +using IntelliTect.Coalesce.TypeDefinition; +using IntelliTect.Coalesce.Validation; +using Microsoft.CodeAnalysis; +using System.Linq; + +namespace IntelliTect.Coalesce.Tests.Validation; + +public class CustomDtoExternalObjectValidationTests +{ + [Test] + public async Task ReflectionValidation_AllowsExternalObjectPropertiesWithoutListText() + { + var validationIssues = ValidateContext.Validate(CreateReflectionRepository()) + .Where(issue => !issue.WasSuccessful && !issue.IsWarning) + .Select(issue => issue.ToString()) + .ToList(); + + await Assert.That(validationIssues).IsEmpty(); + } + + [Test] + public async Task SymbolValidation_AllowsExternalObjectPropertiesWithoutListText() + { + var validationIssues = ValidateContext.Validate(CreateSymbolRepository()) + .Where(issue => !issue.WasSuccessful && !issue.IsWarning) + .Select(issue => issue.ToString()) + .ToList(); + + await Assert.That(validationIssues).IsEmpty(); + } + + private static ReflectionRepository CreateReflectionRepository() + { + var repository = new ReflectionRepository(); + repository.SetRootTypeWhitelist(new[] { nameof(CaseDtoWithExternalObject) }); + repository.AddAssembly(); + return repository; + } + + private static ReflectionRepository CreateSymbolRepository() + { + var repository = new ReflectionRepository(); + repository.SetRootTypeWhitelist(new[] { nameof(CaseDtoWithExternalObject) }); + repository.DiscoverCoalescedTypes( + ReflectionRepositoryFactory.Symbols + .Where(symbol => + symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) is string fullyQualifiedName + && ( + !fullyQualifiedName.Contains("IntelliTect.Coalesce.Testing.TargetClasses") + || (symbol is IArrayTypeSymbol arrayType ? arrayType.ElementType : symbol).ContainingAssembly?.MetadataName + == ReflectionRepositoryFactory.SymbolDiscoveryAssemblyName + )) + .Select(symbol => new SymbolTypeViewModel(repository, symbol)) + ); + return repository; + } +} diff --git a/src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs b/src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs index e0e85eef5..6a8105bcd 100644 --- a/src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs +++ b/src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs @@ -54,6 +54,17 @@ protected BaseApiController(CrudContext context) /// protected ClassViewModel? GeneratedForClassViewModel { get; set; } + protected static TParameters ApplyDefaultIncludes(TParameters parameters, string? defaultIncludes) + where TParameters : DataSourceParameters + { + if (string.IsNullOrWhiteSpace(parameters.Includes) && !string.IsNullOrWhiteSpace(defaultIncludes)) + { + parameters.Includes = defaultIncludes; + } + + return parameters; + } + protected ActionResult File(IFile _methodResult) { string? _contentType = _methodResult.ContentType; diff --git a/src/IntelliTect.Coalesce/Api/DataSources/StandardDataSource`2.cs b/src/IntelliTect.Coalesce/Api/DataSources/StandardDataSource`2.cs index 791f1ee91..1e4f22a9a 100644 --- a/src/IntelliTect.Coalesce/Api/DataSources/StandardDataSource`2.cs +++ b/src/IntelliTect.Coalesce/Api/DataSources/StandardDataSource`2.cs @@ -53,7 +53,7 @@ public virtual IQueryable GetQuery(IDataSourceParameters parameters) if (!string.Equals(parameters.Includes, NoDefaultIncludesString, StringComparison.OrdinalIgnoreCase)) { - query = query.IncludeChildren(this.Context.ReflectionRepository); + query = query.IncludeChildren(this.Context.ReflectionRepository, parameters.Includes); } return query; diff --git a/src/IntelliTect.Coalesce/DTOs/AutoReadDto.cs b/src/IntelliTect.Coalesce/DTOs/AutoReadDto.cs new file mode 100644 index 000000000..4277cbaa1 --- /dev/null +++ b/src/IntelliTect.Coalesce/DTOs/AutoReadDto.cs @@ -0,0 +1,22 @@ +using IntelliTect.Coalesce.Mapping; +using Microsoft.EntityFrameworkCore; +using System; + +namespace IntelliTect.Coalesce; + +/// +/// Read-only custom DTO base that can populate itself from an entity using +/// convention-based or -based mapping. +/// Pair with to reuse the same mapping as a SQL-translated projection. +/// +public abstract class AutoReadDto : IClassDto + where T : class + where TContext : DbContext +{ + public virtual void MapTo(T obj, IMappingContext context) + => throw new NotSupportedException( + $"{GetType().Name} is configured as a read-only DTO. Override MapTo if save support is required."); + + public virtual void MapFrom(T obj, IMappingContext context, IncludeTree? tree = null) + => AutoDtoProjectionBuilder.MapToExisting(obj, this); +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/CoalesceAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/CoalesceAttribute.cs index 9f0f0f0c3..39aee044a 100644 --- a/src/IntelliTect.Coalesce/DataAnnotations/CoalesceAttribute.cs +++ b/src/IntelliTect.Coalesce/DataAnnotations/CoalesceAttribute.cs @@ -6,11 +6,18 @@ namespace IntelliTect.Coalesce; /// The targeted class or member should be exposed by Coalesce. /// Different types will be exposed in different ways. See documentation for details. /// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Interface | AttributeTargets.Enum | AttributeTargets.Field)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Interface | AttributeTargets.Enum | AttributeTargets.Field, Inherited = false)] public sealed class CoalesceAttribute : Attribute { /// /// When placed on a type, overrides the name of the type used in client-side code. /// public string? ClientTypeName { get; set; } + + /// + /// When placed on a , controls whether + /// inherited non-Microsoft properties + /// are discovered alongside sets declared directly on the attributed context type. + /// + public bool IncludeInheritedDbSets { get; set; } = true; } diff --git a/src/IntelliTect.Coalesce/DataAnnotations/DtoActionDefaultsAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/DtoActionDefaultsAttribute.cs new file mode 100644 index 000000000..be40cfe40 --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/DtoActionDefaultsAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace IntelliTect.Coalesce.DataAnnotations; + +/// +/// Specifies default DTO content views for generated CRUD controller actions. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class DtoActionDefaultsAttribute : Attribute +{ + public string? Get { get; set; } + public string? List { get; set; } + public string? Count { get; set; } + public string? Save { get; set; } + public string? BulkSave { get; set; } + public string? Delete { get; set; } +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/DtoContentViewAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/DtoContentViewAttribute.cs new file mode 100644 index 000000000..d2be805c2 --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/DtoContentViewAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace IntelliTect.Coalesce.DataAnnotations; + +/// +/// Defines a named DTO content view for a model. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class DtoContentViewAttribute : Attribute +{ + public DtoContentViewAttribute(string name) + { + Name = name; + } + + /// + /// The name of the content view. + /// + public string Name { get; } + + /// + /// If false, properties without explicit view annotations are excluded when this view is active. + /// + public bool IncludeByDefault { get; set; } = true; +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/DtoFlattenAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/DtoFlattenAttribute.cs index c5f3f386d..8a9c839a5 100644 --- a/src/IntelliTect.Coalesce/DataAnnotations/DtoFlattenAttribute.cs +++ b/src/IntelliTect.Coalesce/DataAnnotations/DtoFlattenAttribute.cs @@ -22,4 +22,14 @@ public DtoFlattenAttribute(string path) /// Optional generated property name. Defaults to the concatenated path segments. /// public string? Name { get; set; } + + /// + /// Comma-delimited list of content views this flattened property should be included on. + /// + public string? ContentViews { get; set; } + + /// + /// Comma-delimited list of content views this flattened property should be excluded from. + /// + public string? ExcludedContentViews { get; set; } } diff --git a/src/IntelliTect.Coalesce/DataAnnotations/DtoSourceAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/DtoSourceAttribute.cs new file mode 100644 index 000000000..1db85398a --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/DtoSourceAttribute.cs @@ -0,0 +1,26 @@ +using System; + +namespace IntelliTect.Coalesce.DataAnnotations; + +[AttributeUsage(AttributeTargets.Property)] +public sealed class DtoSourceAttribute : Attribute +{ + public DtoSourceAttribute(string path) + { + Path = path; + } + + /// + /// Dot-separated source path relative to the entity described by the DTO. + /// Collection navigations are supported and will be projected with Select. + /// + public string Path { get; } + + /// + /// Optional dot-separated path, relative to a collection element, used to sort projected collections. + /// + public string? OrderBy { get; init; } + + public DefaultOrderByAttribute.OrderByDirections OrderByDirection { get; init; } + = DefaultOrderByAttribute.OrderByDirections.Ascending; +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/DtoSummaryAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/DtoSummaryAttribute.cs index ae38bb8ef..0877e7e2e 100644 --- a/src/IntelliTect.Coalesce/DataAnnotations/DtoSummaryAttribute.cs +++ b/src/IntelliTect.Coalesce/DataAnnotations/DtoSummaryAttribute.cs @@ -13,4 +13,14 @@ public DtoSummaryAttribute(string path) public string Path { get; } public string? Name { get; set; } + + /// + /// Comma-delimited list of content views this summary property should be included on. + /// + public string? ContentViews { get; set; } + + /// + /// Comma-delimited list of content views this summary property should be excluded from. + /// + public string? ExcludedContentViews { get; set; } } diff --git a/src/IntelliTect.Coalesce/Helpers/QueryableExtensions.cs b/src/IntelliTect.Coalesce/Helpers/QueryableExtensions.cs index b90cb45ed..3645781f8 100644 --- a/src/IntelliTect.Coalesce/Helpers/QueryableExtensions.cs +++ b/src/IntelliTect.Coalesce/Helpers/QueryableExtensions.cs @@ -17,14 +17,14 @@ public static class QueryableExtensions /// Includes immediate children, as well as the other side of many-to-many relationships. /// Does not include navigations or classes that have or set. /// - public static IQueryable IncludeChildren(this IQueryable query, ReflectionRepository? reflectionRepository = null) where T : class + public static IQueryable IncludeChildren(this IQueryable query, ReflectionRepository? reflectionRepository = null, string? includes = null) where T : class { var model = (reflectionRepository ?? ReflectionRepository.Global).GetClassViewModel() ?? throw new ArgumentException("Queried type is not a class"); var includePaths = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var prop in model.ClientProperties.Where(f => f.CanAutoInclude)) + foreach (var prop in model.ClientProperties.Where(f => f.CanAutoInclude && f.IsMappedForContentView(includes))) { if (prop.IsManyToManyCollection && prop.ManyToManyFarNavigationProperty.CanAutoInclude) { @@ -36,12 +36,12 @@ public static IQueryable IncludeChildren(this IQueryable query, Reflect } } - foreach (var flattened in model.FlattenedResponseProperties) + foreach (var flattened in model.FlattenedResponseProperties.Where(f => f.IsMappedForContentView(includes))) { includePaths.Add(flattened.IncludePath); } - AddReferenceSummaryIncludePaths(model, includePaths); + AddReferenceSummaryIncludePaths(model, includePaths, includes); foreach (var includePath in includePaths) { @@ -51,14 +51,17 @@ public static IQueryable IncludeChildren(this IQueryable query, Reflect return query; } - private static void AddReferenceSummaryIncludePaths(ClassViewModel model, HashSet includePaths) + private static void AddReferenceSummaryIncludePaths(ClassViewModel model, HashSet includePaths, string? includes) { - foreach (var prop in model.ClientProperties.Where(p => p.UsesDtoReferenceSummary && p.Object is not null)) + foreach (var prop in model.ClientProperties.Where(p => + p.UsesDtoReferenceSummary + && p.Object is not null + && p.IsMappedForContentView(includes))) { var target = prop.Object!; includePaths.Add(prop.Name); - foreach (var summaryProp in target.SummaryProperties) + foreach (var summaryProp in target.SummaryProperties.Where(p => p.IsMappedForContentView(includes))) { if (!string.IsNullOrWhiteSpace(summaryProp.IncludePath)) { diff --git a/src/IntelliTect.Coalesce/Mapping/AutoDtoProjectionBuilder.cs b/src/IntelliTect.Coalesce/Mapping/AutoDtoProjectionBuilder.cs new file mode 100644 index 000000000..0e4c720d2 --- /dev/null +++ b/src/IntelliTect.Coalesce/Mapping/AutoDtoProjectionBuilder.cs @@ -0,0 +1,527 @@ +using IntelliTect.Coalesce.DataAnnotations; +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; + +namespace IntelliTect.Coalesce.Mapping; + +internal static class AutoDtoProjectionBuilder +{ + private readonly record struct CacheKey(Type SourceType, Type TargetType); + + private static readonly ConcurrentDictionary ProjectionCache = new(); + private static readonly ConcurrentDictionary ObjectProjectorCache = new(); + + public static Expression> GetProjection() + { + return (Expression>)ProjectionCache.GetOrAdd( + new(typeof(TSource), typeof(TTarget)), + _ => BuildProjectionLambda()); + } + + public static Expression> ComposeAutoProjection( + IReadOnlyList>> overrides) + { + ArgumentNullException.ThrowIfNull(overrides); + + if (overrides.Count == 0) + { + return GetProjection(); + } + + var ignoredMembers = overrides + .SelectMany(overrideProjection => GetOverrideBindings(overrideProjection.Body)) + .Select(binding => binding.Member.Name) + .ToHashSet(StringComparer.Ordinal); + + var baseProjection = BuildProjectionLambda(ignoredMembers); + return Compose(baseProjection, overrides); + } + + public static Expression> Compose( + Expression> baseProjection, + IReadOnlyList>> overrides) + { + ArgumentNullException.ThrowIfNull(baseProjection); + ArgumentNullException.ThrowIfNull(overrides); + + if (overrides.Count == 0) + { + return baseProjection; + } + + var source = baseProjection.Parameters.Single(); + var (newExpression, baseBindings) = GetComposableProjection(baseProjection.Body); + + var mergedBindings = new List(baseBindings); + var bindingIndexes = baseBindings + .Select((binding, index) => (binding.Member.Name, index)) + .ToDictionary(x => x.Name, x => x.index, StringComparer.Ordinal); + + foreach (var overrideProjection in overrides) + { + ArgumentNullException.ThrowIfNull(overrideProjection); + + if (overrideProjection.Parameters.Count != 1) + { + throw new InvalidOperationException("Projection overrides must declare exactly one source parameter."); + } + + var reboundBody = new ReplaceParameterVisitor(overrideProjection.Parameters[0], source) + .Visit(overrideProjection.Body)!; + + foreach (var binding in GetOverrideBindings(reboundBody)) + { + if (bindingIndexes.TryGetValue(binding.Member.Name, out var existingIndex)) + { + mergedBindings[existingIndex] = binding; + } + else + { + bindingIndexes[binding.Member.Name] = mergedBindings.Count; + mergedBindings.Add(binding); + } + } + } + + var body = mergedBindings.Count == 0 + ? (Expression)newExpression + : Expression.MemberInit(newExpression, mergedBindings); + + return Expression.Lambda>(body, source); + } + + public static void MapToExisting(TSource source, object target) + { + ArgumentNullException.ThrowIfNull(target); + + var targetType = target.GetType(); + var projector = (Func)ObjectProjectorCache.GetOrAdd( + new(typeof(TSource), targetType), + static key => + { + var factoryMethod = typeof(AutoDtoProjectionBuilder) + .GetMethod(nameof(CreateObjectProjector), BindingFlags.Static | BindingFlags.NonPublic)! + .MakeGenericMethod(key.SourceType, key.TargetType); + + return (Delegate)factoryMethod.Invoke(null, null)!; + }); + + var projected = projector(source); + foreach (var property in GetWritableProperties(targetType)) + { + property.SetValue(target, property.GetValue(projected)); + } + } + + private static Func CreateObjectProjector() + { + var compiled = GetProjection().Compile(); + return source => compiled(source)!; + } + + private static Expression> BuildProjectionLambda( + IReadOnlySet? excludedTargetMembers = null) + { + var source = Expression.Parameter(typeof(TSource), "source"); + var body = BuildObjectInitializer(source, typeof(TTarget), excludedTargetMembers); + return Expression.Lambda>(body, source); + } + + private static (NewExpression NewExpression, IReadOnlyList Bindings) GetComposableProjection( + Expression body) + { + body = StripConvert(body); + + return body switch + { + MemberInitExpression memberInit => (memberInit.NewExpression, ExtractMemberAssignments(memberInit.Bindings)), + NewExpression newExpression => (newExpression, Array.Empty()), + _ => throw new InvalidOperationException( + $"Projection composition requires a projection that creates a new object, but received '{body.NodeType}'.") + }; + } + + private static IReadOnlyList GetOverrideBindings(Expression body) + { + var (newExpression, bindings) = GetComposableProjection(body); + if (newExpression.Arguments.Count > 0) + { + throw new InvalidOperationException( + "Projection override expressions must use a parameterless constructor and object initializer syntax."); + } + + return bindings; + } + + private static IReadOnlyList ExtractMemberAssignments( + IReadOnlyCollection bindings) + { + var assignments = new List(bindings.Count); + foreach (var binding in bindings) + { + if (binding is not MemberAssignment assignment) + { + throw new InvalidOperationException( + $"Projection composition only supports member assignment bindings, but encountered '{binding.BindingType}'."); + } + + assignments.Add(assignment); + } + + return assignments; + } + + private static Expression StripConvert(Expression expression) + { + while (expression is UnaryExpression unary + && (unary.NodeType == ExpressionType.Convert || unary.NodeType == ExpressionType.ConvertChecked)) + { + expression = unary.Operand; + } + + return expression; + } + + private static Expression BuildObjectInitializer( + Expression source, + Type targetType, + IReadOnlySet? excludedTargetMembers = null) + { + if (TryConvertDirectly(source, targetType, out var direct)) + { + return direct; + } + + var targetProperties = GetReadableProperties(targetType) + .ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); + + var parameterlessCtor = targetType.GetConstructor(Type.EmptyTypes); + if (parameterlessCtor is not null) + { + var bindings = targetProperties.Values + .Where(p => p.SetMethod is not null && !(excludedTargetMembers?.Contains(p.Name) ?? false)) + .Select(p => TryBuildPropertyValue(source, p, out var value) + ? Expression.Bind(p, value) + : null) + .Where(b => b is not null) + .Cast() + .ToList(); + + return Expression.MemberInit(Expression.New(parameterlessCtor), bindings); + } + + var ctorCandidate = targetType.GetConstructors() + .OrderByDescending(c => c.GetParameters().Length) + .Select(ctor => new + { + Ctor = ctor, + Args = TryBuildConstructorArgs(source, ctor, targetProperties, out var args) ? args : null, + }) + .FirstOrDefault(c => c.Args is not null); + + if (ctorCandidate is null) + { + throw new InvalidOperationException( + $"Could not build an automatic DTO projection from {source.Type} to {targetType}. " + + $"Add a public parameterless constructor, or a public constructor whose parameters match mappable properties."); + } + + var ctorAssigned = ctorCandidate.Ctor.GetParameters() + .Select(p => p.Name!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var memberInitBindings = targetProperties.Values + .Where(p => p.SetMethod is not null + && !ctorAssigned.Contains(p.Name) + && !(excludedTargetMembers?.Contains(p.Name) ?? false)) + .Select(p => TryBuildPropertyValue(source, p, out var value) + ? Expression.Bind(p, value) + : null) + .Where(b => b is not null) + .Cast() + .ToList(); + + var newExpression = Expression.New(ctorCandidate.Ctor, ctorCandidate.Args!); + return memberInitBindings.Count == 0 + ? newExpression + : Expression.MemberInit(newExpression, memberInitBindings); + } + + private static bool TryBuildConstructorArgs( + Expression source, + ConstructorInfo ctor, + IReadOnlyDictionary targetProperties, + out IReadOnlyList args) + { + var builtArgs = new List(); + foreach (var parameter in ctor.GetParameters()) + { + if (targetProperties.TryGetValue(parameter.Name!, out var property) + && TryBuildPropertyValue(source, property, out var propertyValue)) + { + builtArgs.Add(EnsureType(propertyValue, parameter.ParameterType)); + } + else if (parameter.HasDefaultValue) + { + builtArgs.Add(Expression.Constant(parameter.DefaultValue, parameter.ParameterType)); + } + else + { + args = Array.Empty(); + return false; + } + } + + args = builtArgs; + return true; + } + + private static bool TryBuildPropertyValue(Expression source, PropertyInfo targetProperty, out Expression value) + { + var sourceAttribute = targetProperty.GetCustomAttribute(); + var path = sourceAttribute?.Path ?? targetProperty.Name; + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return TryBuildValueFromPath(source, segments, targetProperty.PropertyType, sourceAttribute, out value); + } + + private static bool TryBuildValueFromPath( + Expression current, + IReadOnlyList segments, + Type targetType, + DtoSourceAttribute? sourceAttribute, + out Expression value) + { + if (segments.Count == 0) + { + value = BuildValueConversion(current, targetType); + return true; + } + + if (TryGetEnumerableElementType(current.Type, out var currentElementType)) + { + value = BuildCollectionProjection(current, currentElementType, segments, targetType, sourceAttribute); + return true; + } + + var member = FindReadableMember(current.Type, segments[0]); + if (member is null) + { + value = default!; + return false; + } + + Expression BuildRemaining(Expression nonNullCurrent) + { + var next = Expression.MakeMemberAccess(nonNullCurrent, member); + return TryBuildValueFromPath(next, segments.Skip(1).ToArray(), targetType, sourceAttribute, out var nested) + ? nested + : throw new InvalidOperationException( + $"Could not map source path '{string.Join(".", segments)}' from {current.Type} to {targetType}."); + } + + value = CanBeNull(current.Type) && segments.Count > 1 + ? Expression.Condition( + Expression.Equal(current, Expression.Constant(null, current.Type)), + Expression.Default(targetType), + BuildRemaining(current)) + : BuildRemaining(current); + + return true; + } + + private static Expression BuildCollectionProjection( + Expression sourceCollection, + Type sourceElementType, + IReadOnlyList remainingSegments, + Type targetType, + DtoSourceAttribute? sourceAttribute) + { + if (!TryGetEnumerableElementType(targetType, out var targetElementType)) + { + throw new InvalidOperationException( + $"Cannot map collection source {sourceCollection.Type} to non-collection target {targetType}."); + } + + var elementParameter = Expression.Parameter(sourceElementType, "item"); + + Expression projectedElements; + if (remainingSegments.Count == 0) + { + projectedElements = sourceCollection; + } + else + { + var elementProjection = TryBuildValueFromPath( + elementParameter, + remainingSegments, + targetElementType, + null, + out var value) + ? value + : throw new InvalidOperationException( + $"Could not map collection path '{string.Join(".", remainingSegments)}' from {sourceCollection.Type} to {targetType}."); + + if (sourceAttribute?.OrderBy is { Length: > 0 } orderByPath) + { + var orderByExpression = TryBuildValueFromPath( + elementParameter, + orderByPath.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + typeof(object), + null, + out var orderValue) + ? EnsureType(orderValue, typeof(object)) + : throw new InvalidOperationException( + $"Could not map collection ordering path '{orderByPath}' from {sourceElementType}."); + + var orderByLambda = Expression.Lambda(orderByExpression, elementParameter); + var orderByMethodName = sourceAttribute.OrderByDirection == DefaultOrderByAttribute.OrderByDirections.Descending + ? nameof(Enumerable.OrderByDescending) + : nameof(Enumerable.OrderBy); + + sourceCollection = Expression.Call( + typeof(Enumerable), + orderByMethodName, + new[] { sourceElementType, typeof(object) }, + sourceCollection, + orderByLambda); + } + + var selectLambda = Expression.Lambda(elementProjection, elementParameter); + projectedElements = Expression.Call( + typeof(Enumerable), + nameof(Enumerable.Select), + new[] { sourceElementType, targetElementType }, + sourceCollection, + selectLambda); + } + + Expression materialized = targetType.IsArray + ? Expression.Call(typeof(Enumerable), nameof(Enumerable.ToArray), new[] { targetElementType }, projectedElements) + : Expression.Call(typeof(Enumerable), nameof(Enumerable.ToList), new[] { targetElementType }, projectedElements); + + if (CanBeNull(sourceCollection.Type)) + { + materialized = Expression.Condition( + Expression.Equal(sourceCollection, Expression.Constant(null, sourceCollection.Type)), + Expression.Default(targetType), + EnsureType(materialized, targetType)); + } + + return EnsureType(materialized, targetType); + } + + private static Expression BuildValueConversion(Expression source, Type targetType) + { + if (TryConvertDirectly(source, targetType, out var direct)) + { + return direct; + } + + if (TryGetEnumerableElementType(source.Type, out var sourceElementType) + && TryGetEnumerableElementType(targetType, out _)) + { + return BuildCollectionProjection(source, sourceElementType, Array.Empty(), targetType, null); + } + + if (CanBeNull(source.Type)) + { + return Expression.Condition( + Expression.Equal(source, Expression.Constant(null, source.Type)), + Expression.Default(targetType), + BuildObjectInitializer(source, targetType)); + } + + return BuildObjectInitializer(source, targetType); + } + + private static bool TryConvertDirectly(Expression source, Type targetType, out Expression result) + { + if (targetType == typeof(object)) + { + result = EnsureType(source, targetType); + return true; + } + + if (targetType.IsAssignableFrom(source.Type)) + { + result = EnsureType(source, targetType); + return true; + } + + var sourceUnderlying = Nullable.GetUnderlyingType(source.Type) ?? source.Type; + var targetUnderlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (sourceUnderlying == targetUnderlying && !TryGetEnumerableElementType(targetType, out _)) + { + result = EnsureType(source, targetType); + return true; + } + + result = default!; + return false; + } + + private static Expression EnsureType(Expression expression, Type targetType) + { + return expression.Type == targetType + ? expression + : Expression.Convert(expression, targetType); + } + + private static PropertyInfo[] GetReadableProperties(Type type) + => type.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.GetMethod is not null && p.GetIndexParameters().Length == 0) + .ToArray(); + + private static PropertyInfo[] GetWritableProperties(Type type) + => type.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.SetMethod is not null && p.GetIndexParameters().Length == 0) + .ToArray(); + + private static MemberInfo? FindReadableMember(Type type, string name) + => (MemberInfo?)type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase) + ?? type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase); + + private static bool TryGetEnumerableElementType(Type type, out Type elementType) + { + if (type == typeof(string)) + { + elementType = default!; + return false; + } + + if (type.IsArray) + { + elementType = type.GetElementType()!; + return true; + } + + var enumerable = type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>) + ? type + : type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + + if (enumerable is not null) + { + elementType = enumerable.GetGenericArguments()[0]; + return true; + } + + elementType = default!; + return false; + } + + private static bool CanBeNull(Type type) + => !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; + + private sealed class ReplaceParameterVisitor(ParameterExpression from, ParameterExpression to) + : ExpressionVisitor + { + protected override Expression VisitParameter(ParameterExpression node) + => node == from ? to : base.VisitParameter(node); + } +} diff --git a/src/IntelliTect.Coalesce/Mapping/AutoProjection.cs b/src/IntelliTect.Coalesce/Mapping/AutoProjection.cs new file mode 100644 index 000000000..d8f47255b --- /dev/null +++ b/src/IntelliTect.Coalesce/Mapping/AutoProjection.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq.Expressions; + +namespace IntelliTect.Coalesce.Mapping; + +public static class AutoProjection +{ + /// + /// Build a projection expression from to + /// using matching property names and optional annotations + /// on the target DTO properties. + /// + public static Expression> For() + => AutoDtoProjectionBuilder.GetProjection(); + + /// + /// Build a projection expression from to + /// and layer one or more explicit member overrides on top of the convention-based projection. + /// Later overrides win when the same target member is assigned more than once. + /// + public static Expression> For( + params Expression>[] overrides) + => overrides is { Length: > 0 } + ? AutoDtoProjectionBuilder.ComposeAutoProjection(overrides) + : For(); + + /// + /// Layer one or more explicit member overrides on top of an existing projection. + /// Later overrides win when the same target member is assigned more than once. + /// + public static Expression> Compose( + Expression> baseProjection, + params Expression>[] overrides) + => overrides is { Length: > 0 } + ? AutoDtoProjectionBuilder.Compose(baseProjection, overrides) + : baseProjection; +} diff --git a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs index af2c9a95a..ee695ac70 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs @@ -179,12 +179,22 @@ internal IReadOnlyCollection Properties int count = 1; foreach (var prop in RawProperties(this)) { - if (properties.Any(f => f.Name == prop.Name)) + if (properties.FirstOrDefault(f => f.Name == prop.Name) is { } existing) { - // This is a duplicate. Keep the one that isn't virtual - if (!prop.IsVirtual) + // Prefer the property declared closest to the effective type so hidden/new members + // on derived types win over the inherited member they're replacing. + var existingDistance = InheritanceDistance(existing.Parent); + var newDistance = InheritanceDistance(prop.Parent); + if ( + newDistance < existingDistance || + ( + newDistance == existingDistance && + existing.IsVirtual && + !prop.IsVirtual + ) + ) { - properties.Remove(properties.First(f => f.Name == prop.Name)); + properties.Remove(existing); prop.ClassFieldOrder = count; properties.Add(prop); } @@ -203,6 +213,22 @@ internal IReadOnlyCollection Properties } } + int InheritanceDistance(ClassViewModel candidate) + { + var distance = 0; + for (var current = this; current is not null; current = current.Type.BaseType?.ClassViewModel) + { + if (current.Equals(candidate)) + { + return distance; + } + + distance++; + } + + return int.MaxValue; + } + /// /// Properties on the class that are permitted to be exposed to the client. /// @@ -221,6 +247,30 @@ and not TypeDiscriminator.Unknown public IReadOnlyList FlattenedResponseProperties => _flattenedResponseProperties ??= FlattenedResponsePropertyViewModel.FromClass(this); + private IReadOnlyDictionary? _dtoContentViews; + public IReadOnlyDictionary DtoContentViews + => _dtoContentViews ??= GetAttributes() + .Select(a => new + { + Name = a.GetValue(x => x.Name), + IncludeByDefault = a.GetValue(x => x.IncludeByDefault) ?? true, + }) + .Where(a => !string.IsNullOrWhiteSpace(a.Name)) + .GroupBy(a => a.Name!, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Last().IncludeByDefault, StringComparer.Ordinal); + + public bool ShouldIncludeUnspecifiedPropertiesForContentView(string? contentView) + => string.IsNullOrWhiteSpace(contentView) + || !DtoContentViews.TryGetValue(contentView, out var includeByDefault) + || includeByDefault; + + public string? DefaultGetDtoIncludes => this.GetAttributeValue(a => a.Get); + public string? DefaultListDtoIncludes => this.GetAttributeValue(a => a.List); + public string? DefaultCountDtoIncludes => this.GetAttributeValue(a => a.Count); + public string? DefaultSaveDtoIncludes => this.GetAttributeValue(a => a.Save); + public string? DefaultBulkSaveDtoIncludes => this.GetAttributeValue(a => a.BulkSave); + public string? DefaultDeleteDtoIncludes => this.GetAttributeValue(a => a.Delete); + /// /// List of method names that should not be exposed to the client. /// diff --git a/src/IntelliTect.Coalesce/TypeDefinition/FlattenedResponsePropertyViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/FlattenedResponsePropertyViewModel.cs index 5972f006d..5c3df31c1 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/FlattenedResponsePropertyViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/FlattenedResponsePropertyViewModel.cs @@ -32,6 +32,28 @@ private FlattenedResponsePropertyViewModel( 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 IEnumerable ContentViews => SplitContentViews(Attribute.ContentViews); + public IEnumerable ExcludedContentViews => SplitContentViews(Attribute.ExcludedContentViews); + + public bool IsMappedForContentView(string? contentView) + { + if (string.IsNullOrWhiteSpace(contentView)) + { + return !ContentViews.Any(); + } + + if (ExcludedContentViews.Contains(contentView, StringComparer.Ordinal)) + { + return false; + } + + if (ContentViews.Any()) + { + return ContentViews.Contains(contentView, StringComparer.Ordinal); + } + + return DeclaringClass.ShouldIncludeUnspecifiedPropertiesForContentView(contentView); + } public string AccessExpression(string rootExpression) { @@ -64,6 +86,8 @@ public static FlattenedResponsePropertyViewModel Create(ClassViewModel model, At { var path = attribute.GetValue(a => a.Path); var name = attribute.GetValue(a => a.Name); + var contentViews = attribute.GetValue(a => a.ContentViews); + var excludedContentViews = attribute.GetValue(a => a.ExcludedContentViews); if (string.IsNullOrWhiteSpace(path)) { @@ -107,6 +131,21 @@ public static FlattenedResponsePropertyViewModel Create(ClassViewModel model, At 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); + return new FlattenedResponsePropertyViewModel( + model, + new DtoFlattenAttribute(path) + { + Name = name, + ContentViews = contentViews, + ExcludedContentViews = excludedContentViews, + }, + pathProperties); } + + private static IEnumerable SplitContentViews(string? contentViews) + => (contentViews ?? "") + .Trim() + .Split(',') + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)); } diff --git a/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs index 8fd5b7bdf..33b156ba8 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs @@ -912,6 +912,26 @@ public bool CanAutoInclude .Select(s => s.Trim()) .Where(s => !string.IsNullOrEmpty(s)); + public bool IsMappedForContentView(string? contentView) + { + if (string.IsNullOrWhiteSpace(contentView)) + { + return !DtoIncludes.Any(); + } + + if (DtoExcludes.Contains(contentView, StringComparer.Ordinal)) + { + return false; + } + + if (DtoIncludes.Any()) + { + return DtoIncludes.Contains(contentView, StringComparer.Ordinal); + } + + return EffectiveParent.ShouldIncludeUnspecifiedPropertiesForContentView(contentView); + } + /// /// Returns the role the property plays in a relational model. /// diff --git a/src/IntelliTect.Coalesce/TypeDefinition/SummaryPropertyViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/SummaryPropertyViewModel.cs index a64be8bf7..ce6f3f666 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/SummaryPropertyViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/SummaryPropertyViewModel.cs @@ -10,15 +10,21 @@ namespace IntelliTect.Coalesce.TypeDefinition; public sealed class SummaryPropertyViewModel { private readonly IReadOnlyList _pathProperties; + private readonly IReadOnlyList _contentViews; + private readonly IReadOnlyList _excludedContentViews; private SummaryPropertyViewModel( ClassViewModel parent, IReadOnlyList pathProperties, - string? explicitName + string? explicitName, + IReadOnlyList? contentViews = null, + IReadOnlyList? excludedContentViews = null ) { Parent = parent; _pathProperties = pathProperties; + _contentViews = contentViews ?? []; + _excludedContentViews = excludedContentViews ?? []; LeafProperty = pathProperties[^1]; Type = LeafProperty.Type; Name = explicitName ?? string.Concat(pathProperties.Select(p => p.Name)); @@ -42,6 +48,30 @@ private SummaryPropertyViewModel( public string? IncludePath { get; } + public IEnumerable ContentViews => _contentViews; + + public IEnumerable ExcludedContentViews => _excludedContentViews; + + public bool IsMappedForContentView(string? contentView) + { + if (string.IsNullOrWhiteSpace(contentView)) + { + return !_contentViews.Any(); + } + + if (_excludedContentViews.Contains(contentView, StringComparer.Ordinal)) + { + return false; + } + + if (_contentViews.Any()) + { + return _contentViews.Contains(contentView, StringComparer.Ordinal); + } + + return Parent.ShouldIncludeUnspecifiedPropertiesForContentView(contentView); + } + public string AccessExpression(string rootExpression) { var expression = $"{rootExpression}.{_pathProperties[0].Name}"; @@ -81,6 +111,8 @@ public static SummaryPropertyViewModel Create(ClassViewModel model, AttributeVie { var path = attribute.GetValue(a => a.Path); var name = attribute.GetValue(a => a.Name); + var contentViews = SplitContentViews(attribute.GetValue(a => a.ContentViews)); + var excludedContentViews = SplitContentViews(attribute.GetValue(a => a.ExcludedContentViews)); if (string.IsNullOrWhiteSpace(path)) { @@ -118,7 +150,7 @@ public static SummaryPropertyViewModel Create(ClassViewModel model, AttributeVie } EnsureLeafSupported(model, pathProperties[^1].Type, path); - return new SummaryPropertyViewModel(model, pathProperties, name); + return new SummaryPropertyViewModel(model, pathProperties, name, contentViews, excludedContentViews); } private static void EnsureLeafSupported(ClassViewModel model, TypeViewModel type, string path) @@ -128,4 +160,12 @@ private static void EnsureLeafSupported(ClassViewModel model, TypeViewModel type throw new InvalidOperationException($"[{nameof(DtoSummaryAttribute)}] path '{path}' on {model.FullyQualifiedName} must end on a scalar/enum value, not '{type.FullyQualifiedName}'."); } } + + private static IReadOnlyList SplitContentViews(string? contentViews) + => (contentViews ?? "") + .Trim() + .Split(',') + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToArray(); } diff --git a/src/IntelliTect.Coalesce/TypeUsage/DbContextTypeUsage.cs b/src/IntelliTect.Coalesce/TypeUsage/DbContextTypeUsage.cs index 807a17df3..1a47a0452 100644 --- a/src/IntelliTect.Coalesce/TypeUsage/DbContextTypeUsage.cs +++ b/src/IntelliTect.Coalesce/TypeUsage/DbContextTypeUsage.cs @@ -10,14 +10,30 @@ public class DbContextTypeUsage public DbContextTypeUsage(ClassViewModel classViewModel) { ClassViewModel = classViewModel; + var coalesceAttributes = classViewModel.GetAttributes().ToList(); + var includeInheritedDbSets = + coalesceAttributes + .FirstOrDefault(attr => attr.GetAllValues().Any(v => v.Key == nameof(CoalesceAttribute.IncludeInheritedDbSets))) + ?.GetValue(nameof(CoalesceAttribute.IncludeInheritedDbSets)) + ?? coalesceAttributes + .FirstOrDefault() + ?.GetValue(nameof(CoalesceAttribute.IncludeInheritedDbSets)) + ?? true; Entities = classViewModel .ClientProperties - // Only use props that were explicitly declared on the dbcontext (and not a base class) - // as well as any props that aren't in the Microsoft namespace. + // Only use props that were explicitly declared on the dbcontext (and not a base class), + // plus inherited non-Microsoft DbSets when the context opts into the legacy behavior. // This prevents us from picking up things from Microsoft.AspNetCore.Identity.EntityFrameworkCore - // that don't have keys & other properties that Coalesce can work with. - .Where(p => p.Parent.Equals(classViewModel) || !p.PureType.FullNamespace.StartsWith(nameof(Microsoft) + ".")) + // that don't have keys & other properties that Coalesce can work with, while still allowing + // narrow derived contexts to intentionally expose only the DbSets they declare themselves. + .Where(p => + p.Parent.Equals(classViewModel) || + ( + includeInheritedDbSets && + !p.PureType.FullNamespace.StartsWith(nameof(Microsoft) + ".") + ) + ) .Where(p => p.Type.IsA(typeof(DbSet<>))) .Select(p => new EntityTypeUsage(this, p.PureType, p.Name)) diff --git a/src/IntelliTect.Coalesce/Validation/ValidateContext.cs b/src/IntelliTect.Coalesce/Validation/ValidateContext.cs index c86ab5bc6..0a857b46b 100644 --- a/src/IntelliTect.Coalesce/Validation/ValidateContext.cs +++ b/src/IntelliTect.Coalesce/Validation/ValidateContext.cs @@ -150,11 +150,20 @@ public static ValidationHelper Validate(ReflectionRepository repository) if (prop.IsPOCO && prop.EntityFrameworkPropertyKind is not EntityFrameworkPropertyKind.Scalar and not EntityFrameworkPropertyKind.Complex) { - assert.IsNotNull( - prop.Object?.ListTextProperty, - $"{prop.Object} has no discernible display text. Add a [ListTextAttribute] to one of its properties." - + (prop.Object?.HasDbSet == false ? " If the type was meant to be an EF entity, add a corresponding DbSet property to your DbContext." : ""), - isWarning: true); + var requiresDisplayText = + prop.ReferenceNavigationProperty is not null + || prop.Object is { IsDbMappedType: true } + || prop.Object is { IsStandaloneEntity: true } + || prop.Object is { IsCustomDto: true }; + + if (requiresDisplayText) + { + assert.IsNotNull( + prop.Object?.ListTextProperty, + $"{prop.Object} has no discernible display text. Add a [ListTextAttribute] to one of its properties." + + (prop.Object?.HasDbSet == false ? " If the type was meant to be an EF entity, add a corresponding DbSet property to your DbContext." : ""), + isWarning: true); + } if (!prop.IsReadOnly && !prop.HasNotMapped && prop.Object?.HasDbSet == true) { // Validate navigation properties diff --git a/src/test-targets/api-clients.g.ts b/src/test-targets/api-clients.g.ts index 60c0d5795..dcd4031be 100644 --- a/src/test-targets/api-clients.g.ts +++ b/src/test-targets/api-clients.g.ts @@ -129,11 +129,21 @@ export class CaseApiClient extends ModelApiClient<$models.Case> { } +export class CaseAutoReadDtoApiClient extends ModelApiClient<$models.CaseAutoReadDto> { + constructor() { super($metadata.CaseAutoReadDto) } +} + + export class CaseDtoStandaloneApiClient extends ModelApiClient<$models.CaseDtoStandalone> { constructor() { super($metadata.CaseDtoStandalone) } } +export class CaseDtoWithExternalObjectApiClient extends ModelApiClient<$models.CaseDtoWithExternalObject> { + constructor() { super($metadata.CaseDtoWithExternalObject) } +} + + export class CaseProductApiClient extends ModelApiClient<$models.CaseProduct> { constructor() { super($metadata.CaseProduct) } } @@ -567,6 +577,11 @@ export class ComplexModelDependentApiClient extends ModelApiClient<$models.Compl } +export class ContentViewEntityApiClient extends ModelApiClient<$models.ContentViewEntity> { + constructor() { super($metadata.ContentViewEntity) } +} + + export class CourseApiClient extends ModelApiClient<$models.Course> { constructor() { super($metadata.Course) } } diff --git a/src/test-targets/metadata.g.ts b/src/test-targets/metadata.g.ts index 76a5a29bf..6531e8186 100644 --- a/src/test-targets/metadata.g.ts +++ b/src/test-targets/metadata.g.ts @@ -943,6 +943,59 @@ export const Case = domain.types.Case = { }, }, } +export const CaseAutoReadDto = domain.types.CaseAutoReadDto = { + name: "CaseAutoReadDto" as const, + displayName: "Case Auto Read Dto", + get displayProp() { return this.props.caseId }, + type: "model", + controllerRoute: "CaseAutoReadDto", + get keyProp() { return this.props.caseId }, + behaviorFlags: 0 as BehaviorFlags, + props: { + caseId: { + name: "caseId", + displayName: "Case Id", + type: "number", + role: "primaryKey", + hidden: 3 as HiddenAreas, + }, + title: { + name: "title", + displayName: "Title", + type: "string", + role: "value", + }, + assignedToName: { + name: "assignedToName", + displayName: "Assigned To Name", + type: "string", + role: "value", + }, + reportedBy: { + name: "reportedBy", + displayName: "Reported By", + type: "object", + get typeDef() { return (domain.types.PersonRecord as ObjectType & { name: "PersonRecord" }) }, + role: "value", + }, + productNames: { + name: "productNames", + displayName: "Product Names", + type: "collection", + itemType: { + name: "$collectionItem", + displayName: "", + role: "value", + type: "string", + }, + role: "value", + }, + }, + methods: { + }, + dataSources: { + }, +} export const CaseDtoStandalone = domain.types.CaseDtoStandalone = { name: "CaseDtoStandalone" as const, displayName: "Case Dto Standalone", @@ -971,6 +1024,41 @@ export const CaseDtoStandalone = domain.types.CaseDtoStandalone = { dataSources: { }, } +export const CaseDtoWithExternalObject = domain.types.CaseDtoWithExternalObject = { + name: "CaseDtoWithExternalObject" as const, + displayName: "Case Dto With External Object", + get displayProp() { return this.props.caseKey }, + type: "model", + controllerRoute: "CaseDtoWithExternalObject", + get keyProp() { return this.props.caseKey }, + behaviorFlags: 7 as BehaviorFlags, + props: { + caseKey: { + name: "caseKey", + displayName: "Case Key", + type: "number", + role: "primaryKey", + hidden: 3 as HiddenAreas, + }, + title: { + name: "title", + displayName: "Title", + type: "string", + role: "value", + }, + externalObject: { + name: "externalObject", + displayName: "External Object", + type: "object", + get typeDef() { return (domain.types.ExternalObjectWithoutListText as ObjectType & { name: "ExternalObjectWithoutListText" }) }, + role: "value", + }, + }, + methods: { + }, + dataSources: { + }, +} export const CaseProduct = domain.types.CaseProduct = { name: "CaseProduct" as const, displayName: "Case Product", @@ -3197,6 +3285,90 @@ export const ComplexModelDependent = domain.types.ComplexModelDependent = { dataSources: { }, } +export const ContentViewEntity = domain.types.ContentViewEntity = { + name: "ContentViewEntity" as const, + displayName: "Content View Entity", + get displayProp() { return this.props.name }, + type: "model", + controllerRoute: "ContentViewEntity", + get keyProp() { return this.props.contentViewEntityId }, + behaviorFlags: 7 as BehaviorFlags, + props: { + contentViewEntityId: { + name: "contentViewEntityId", + displayName: "Content View Entity Id", + type: "number", + role: "primaryKey", + hidden: 3 as HiddenAreas, + }, + name: { + name: "name", + displayName: "Name", + type: "string", + role: "value", + }, + description: { + name: "description", + displayName: "Description", + type: "string", + role: "value", + }, + assignedToId: { + name: "assignedToId", + displayName: "Assigned To Id", + type: "number", + role: "foreignKey", + get principalKey() { return (domain.types.Person as ModelType & { name: "Person" }).props.personId as PrimaryKeyProperty }, + get principalType() { return (domain.types.Person as ModelType & { name: "Person" }) }, + get navigationProp() { return (domain.types.ContentViewEntity as ModelType & { name: "ContentViewEntity" }).props.assignedTo as ModelReferenceNavigationProperty }, + hidden: 3 as HiddenAreas, + }, + assignedTo: { + name: "assignedTo", + displayName: "Assigned To", + type: "object", + get typeDef() { return (domain.types.PersonSummary as ObjectType & { name: "PersonSummary" }) }, + role: "value", + dontSerialize: true, + }, + reportedById: { + name: "reportedById", + displayName: "Reported By Id", + type: "number", + role: "foreignKey", + get principalKey() { return (domain.types.Person as ModelType & { name: "Person" }).props.personId as PrimaryKeyProperty }, + get principalType() { return (domain.types.Person as ModelType & { name: "Person" }) }, + get navigationProp() { return (domain.types.ContentViewEntity as ModelType & { name: "ContentViewEntity" }).props.reportedBy as ModelReferenceNavigationProperty }, + hidden: 3 as HiddenAreas, + }, + reportedBy: { + name: "reportedBy", + displayName: "Reported By", + type: "model", + get typeDef() { return (domain.types.Person as ModelType & { name: "Person" }) }, + role: "referenceNavigation", + get foreignKey() { return (domain.types.ContentViewEntity as ModelType & { name: "ContentViewEntity" }).props.reportedById as ForeignKeyProperty }, + get principalKey() { return (domain.types.Person as ModelType & { name: "Person" }).props.personId as PrimaryKeyProperty }, + dontSerialize: true, + }, + neverMapped: { + name: "neverMapped", + displayName: "Never Mapped", + type: "string", + role: "value", + }, + reportedByCompanyName: { + name: "reportedByCompanyName", + displayName: "Reported By Company Name", + type: "string", + role: "value", + }, + }, + methods: { + }, + dataSources: { + }, +} export const Course = domain.types.Course = { name: "Course" as const, displayName: "Course", @@ -5309,6 +5481,26 @@ export const ExternalChildAsOutputOnly = domain.types.ExternalChildAsOutputOnly }, }, } +export const ExternalObjectWithoutListText = domain.types.ExternalObjectWithoutListText = { + name: "ExternalObjectWithoutListText" as const, + displayName: "External Object Without List Text", + type: "object", + props: { + value: { + name: "value", + displayName: "Value", + type: "string", + role: "value", + }, + child: { + name: "child", + displayName: "Child", + type: "object", + get typeDef() { return (domain.types.NestedExternalObjectWithoutListText as ObjectType & { name: "NestedExternalObjectWithoutListText" }) }, + role: "value", + }, + }, +} export const ExternalParent = domain.types.ExternalParent = { name: "ExternalParent" as const, displayName: "External Parent", @@ -5737,6 +5929,19 @@ export const Location = domain.types.Location = { }, }, } +export const NestedExternalObjectWithoutListText = domain.types.NestedExternalObjectWithoutListText = { + name: "NestedExternalObjectWithoutListText" as const, + displayName: "Nested External Object Without List Text", + type: "object", + props: { + value: { + name: "value", + displayName: "Value", + type: "string", + role: "value", + }, + }, +} export const OutputOnlyExternalTypeWithoutDefaultCtor = domain.types.OutputOnlyExternalTypeWithoutDefaultCtor = { name: "OutputOnlyExternalTypeWithoutDefaultCtor" as const, displayName: "Output Only External Type Without Default Ctor", @@ -5854,6 +6059,29 @@ export const PersonCriteria = domain.types.PersonCriteria = { }, }, } +export const PersonRecord = domain.types.PersonRecord = { + name: "PersonRecord" as const, + displayName: "Person Record", + get displayProp() { return this.props.name }, + type: "object", + props: { + personId: { + name: "personId", + displayName: "Person Id", + type: "number", + role: "value", + }, + name: { + name: "name", + displayName: "Name", + type: "string", + role: "value", + rules: { + required: val => (val != null && val !== '') || "Name is required.", + } + }, + }, +} export const PositionalRecord = domain.types.PositionalRecord = { name: "PositionalRecord" as const, displayName: "Positional Record", @@ -6162,11 +6390,14 @@ interface AppDomain extends Domain { AbstractModelPerson: typeof AbstractModelPerson Advisor: typeof Advisor Case: typeof Case + CaseAutoReadDto: typeof CaseAutoReadDto CaseDtoStandalone: typeof CaseDtoStandalone + CaseDtoWithExternalObject: typeof CaseDtoWithExternalObject CaseProduct: typeof CaseProduct Company: typeof Company ComplexModel: typeof ComplexModel ComplexModelDependent: typeof ComplexModelDependent + ContentViewEntity: typeof ContentViewEntity Course: typeof Course DateOnlyPk: typeof DateOnlyPk DateTimeOffsetPk: typeof DateTimeOffsetPk @@ -6175,6 +6406,7 @@ interface AppDomain extends Domain { ExternalChild: typeof ExternalChild ExternalChildAsInputOnly: typeof ExternalChildAsInputOnly ExternalChildAsOutputOnly: typeof ExternalChildAsOutputOnly + ExternalObjectWithoutListText: typeof ExternalObjectWithoutListText ExternalParent: typeof ExternalParent ExternalParentAsInputOnly: typeof ExternalParentAsInputOnly ExternalParentAsOutputOnly: typeof ExternalParentAsOutputOnly @@ -6187,6 +6419,7 @@ interface AppDomain extends Domain { InputOutputOnlyExternalTypeWithRequiredNonscalarProp: typeof InputOutputOnlyExternalTypeWithRequiredNonscalarProp Location: typeof Location MultipleParents: typeof MultipleParents + NestedExternalObjectWithoutListText: typeof NestedExternalObjectWithoutListText OneToOneManyChildren: typeof OneToOneManyChildren OneToOneParent: typeof OneToOneParent OneToOneSeparateKeyChild: typeof OneToOneSeparateKeyChild @@ -6199,6 +6432,7 @@ interface AppDomain extends Domain { Parent2: typeof Parent2 Person: typeof Person PersonCriteria: typeof PersonCriteria + PersonRecord: typeof PersonRecord PositionalRecord: typeof PositionalRecord Product: typeof Product ReadOnlyEntityUsedAsMethodInput: typeof ReadOnlyEntityUsedAsMethodInput diff --git a/src/test-targets/models.g.ts b/src/test-targets/models.g.ts index 54c39c1bc..644299775 100644 --- a/src/test-targets/models.g.ts +++ b/src/test-targets/models.g.ts @@ -259,6 +259,34 @@ export namespace Case { } +export interface CaseAutoReadDto extends Model { + caseId: number | null + title: string | null + assignedToName: string | null + reportedBy: PersonRecord | null + productNames: string[] | null +} +export class CaseAutoReadDto { + + /** Mutates the input object and its descendants into a valid CaseAutoReadDto implementation. */ + static convert(data?: Partial): CaseAutoReadDto { + return convertToModel(data || {}, metadata.CaseAutoReadDto) + } + + /** Maps the input object and its descendants to a new, valid CaseAutoReadDto implementation. */ + static map(data?: Partial): CaseAutoReadDto { + return mapToModel(data || {}, metadata.CaseAutoReadDto) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.CaseAutoReadDto; } + + /** Instantiate a new CaseAutoReadDto, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, CaseAutoReadDto.map(data || {})); + } +} + + export interface CaseDtoStandalone extends Model { caseId: number | null title: string | null @@ -284,6 +312,32 @@ export class CaseDtoStandalone { } +export interface CaseDtoWithExternalObject extends Model { + caseKey: number | null + title: string | null + externalObject: ExternalObjectWithoutListText | null +} +export class CaseDtoWithExternalObject { + + /** Mutates the input object and its descendants into a valid CaseDtoWithExternalObject implementation. */ + static convert(data?: Partial): CaseDtoWithExternalObject { + return convertToModel(data || {}, metadata.CaseDtoWithExternalObject) + } + + /** Maps the input object and its descendants to a new, valid CaseDtoWithExternalObject implementation. */ + static map(data?: Partial): CaseDtoWithExternalObject { + return mapToModel(data || {}, metadata.CaseDtoWithExternalObject) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.CaseDtoWithExternalObject; } + + /** Instantiate a new CaseDtoWithExternalObject, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, CaseDtoWithExternalObject.map(data || {})); + } +} + + export interface CaseProduct extends Model { caseProductId: number | null caseId: number | null @@ -477,6 +531,38 @@ export class ComplexModelDependent { } +export interface ContentViewEntity extends Model { + contentViewEntityId: number | null + name: string | null + description: string | null + assignedToId: number | null + assignedTo: PersonSummary | null + reportedById: number | null + reportedBy: Person | null + neverMapped: string | null + reportedByCompanyName: string | null +} +export class ContentViewEntity { + + /** Mutates the input object and its descendants into a valid ContentViewEntity implementation. */ + static convert(data?: Partial): ContentViewEntity { + return convertToModel(data || {}, metadata.ContentViewEntity) + } + + /** Maps the input object and its descendants to a new, valid ContentViewEntity implementation. */ + static map(data?: Partial): ContentViewEntity { + return mapToModel(data || {}, metadata.ContentViewEntity) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.ContentViewEntity; } + + /** Instantiate a new ContentViewEntity, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, ContentViewEntity.map(data || {})); + } +} + + export interface Course extends Model { courseId: number | null name: string | null @@ -1416,6 +1502,31 @@ export class ExternalChildAsOutputOnly { } +export interface ExternalObjectWithoutListText extends Model { + value: string | null + child: NestedExternalObjectWithoutListText | null +} +export class ExternalObjectWithoutListText { + + /** Mutates the input object and its descendants into a valid ExternalObjectWithoutListText implementation. */ + static convert(data?: Partial): ExternalObjectWithoutListText { + return convertToModel(data || {}, metadata.ExternalObjectWithoutListText) + } + + /** Maps the input object and its descendants to a new, valid ExternalObjectWithoutListText implementation. */ + static map(data?: Partial): ExternalObjectWithoutListText { + return mapToModel(data || {}, metadata.ExternalObjectWithoutListText) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.ExternalObjectWithoutListText; } + + /** Instantiate a new ExternalObjectWithoutListText, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, ExternalObjectWithoutListText.map(data || {})); + } +} + + export interface ExternalParent extends Model { valueArray: number[] | null valueNullableArray: number[] | null @@ -1681,6 +1792,30 @@ export class Location { } +export interface NestedExternalObjectWithoutListText extends Model { + value: string | null +} +export class NestedExternalObjectWithoutListText { + + /** Mutates the input object and its descendants into a valid NestedExternalObjectWithoutListText implementation. */ + static convert(data?: Partial): NestedExternalObjectWithoutListText { + return convertToModel(data || {}, metadata.NestedExternalObjectWithoutListText) + } + + /** Maps the input object and its descendants to a new, valid NestedExternalObjectWithoutListText implementation. */ + static map(data?: Partial): NestedExternalObjectWithoutListText { + return mapToModel(data || {}, metadata.NestedExternalObjectWithoutListText) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.NestedExternalObjectWithoutListText; } + + /** Instantiate a new NestedExternalObjectWithoutListText, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, NestedExternalObjectWithoutListText.map(data || {})); + } +} + + export interface OutputOnlyExternalTypeWithoutDefaultCtor extends Model { bar: string | null baz: string | null @@ -1784,6 +1919,31 @@ export class PersonCriteria { } +export interface PersonRecord extends Model { + personId: number | null + name: string | null +} +export class PersonRecord { + + /** Mutates the input object and its descendants into a valid PersonRecord implementation. */ + static convert(data?: Partial): PersonRecord { + return convertToModel(data || {}, metadata.PersonRecord) + } + + /** Maps the input object and its descendants to a new, valid PersonRecord implementation. */ + static map(data?: Partial): PersonRecord { + return mapToModel(data || {}, metadata.PersonRecord) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.PersonRecord; } + + /** Instantiate a new PersonRecord, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, PersonRecord.map(data || {})); + } +} + + export interface PositionalRecord extends Model { string: string | null num: number | null @@ -2059,11 +2219,14 @@ declare module "coalesce-vue/lib/model" { AbstractModelPerson: AbstractModelPerson Advisor: Advisor Case: Case + CaseAutoReadDto: CaseAutoReadDto CaseDtoStandalone: CaseDtoStandalone + CaseDtoWithExternalObject: CaseDtoWithExternalObject CaseProduct: CaseProduct Company: Company ComplexModel: ComplexModel ComplexModelDependent: ComplexModelDependent + ContentViewEntity: ContentViewEntity Course: Course DateOnlyPk: DateOnlyPk DateTimeOffsetPk: DateTimeOffsetPk @@ -2072,6 +2235,7 @@ declare module "coalesce-vue/lib/model" { ExternalChild: ExternalChild ExternalChildAsInputOnly: ExternalChildAsInputOnly ExternalChildAsOutputOnly: ExternalChildAsOutputOnly + ExternalObjectWithoutListText: ExternalObjectWithoutListText ExternalParent: ExternalParent ExternalParentAsInputOnly: ExternalParentAsInputOnly ExternalParentAsOutputOnly: ExternalParentAsOutputOnly @@ -2084,6 +2248,7 @@ declare module "coalesce-vue/lib/model" { InputOutputOnlyExternalTypeWithRequiredNonscalarProp: InputOutputOnlyExternalTypeWithRequiredNonscalarProp Location: Location MultipleParents: MultipleParents + NestedExternalObjectWithoutListText: NestedExternalObjectWithoutListText OneToOneManyChildren: OneToOneManyChildren OneToOneParent: OneToOneParent OneToOneSeparateKeyChild: OneToOneSeparateKeyChild @@ -2096,6 +2261,7 @@ declare module "coalesce-vue/lib/model" { Parent2: Parent2 Person: Person PersonCriteria: PersonCriteria + PersonRecord: PersonRecord PositionalRecord: PositionalRecord Product: Product ReadOnlyEntityUsedAsMethodInput: ReadOnlyEntityUsedAsMethodInput diff --git a/src/test-targets/viewmodels.g.ts b/src/test-targets/viewmodels.g.ts index e5843eb8c..05ce917ed 100644 --- a/src/test-targets/viewmodels.g.ts +++ b/src/test-targets/viewmodels.g.ts @@ -281,6 +281,30 @@ export class CaseListViewModel extends ListViewModel<$models.Case, $apiClients.C } +export interface CaseAutoReadDtoViewModel extends $models.CaseAutoReadDto { + caseId: number | null; + title: string | null; + assignedToName: string | null; + reportedBy: $models.PersonRecord | null; + productNames: string[] | null; +} +export class CaseAutoReadDtoViewModel extends ViewModel<$models.CaseAutoReadDto, $apiClients.CaseAutoReadDtoApiClient, number> implements $models.CaseAutoReadDto { + + constructor(initialData?: DeepPartial<$models.CaseAutoReadDto> | null) { + super($metadata.CaseAutoReadDto, new $apiClients.CaseAutoReadDtoApiClient(), initialData) + this.$saveMode = "whole" + } +} +defineProps(CaseAutoReadDtoViewModel, $metadata.CaseAutoReadDto) + +export class CaseAutoReadDtoListViewModel extends ListViewModel<$models.CaseAutoReadDto, $apiClients.CaseAutoReadDtoApiClient, CaseAutoReadDtoViewModel> { + + constructor() { + super($metadata.CaseAutoReadDto, new $apiClients.CaseAutoReadDtoApiClient()) + } +} + + export interface CaseDtoStandaloneViewModel extends $models.CaseDtoStandalone { caseId: number | null; title: string | null; @@ -302,6 +326,28 @@ export class CaseDtoStandaloneListViewModel extends ListViewModel<$models.CaseDt } +export interface CaseDtoWithExternalObjectViewModel extends $models.CaseDtoWithExternalObject { + caseKey: number | null; + title: string | null; + externalObject: $models.ExternalObjectWithoutListText | null; +} +export class CaseDtoWithExternalObjectViewModel extends ViewModel<$models.CaseDtoWithExternalObject, $apiClients.CaseDtoWithExternalObjectApiClient, number> implements $models.CaseDtoWithExternalObject { + + constructor(initialData?: DeepPartial<$models.CaseDtoWithExternalObject> | null) { + super($metadata.CaseDtoWithExternalObject, new $apiClients.CaseDtoWithExternalObjectApiClient(), initialData) + this.$saveMode = "whole" + } +} +defineProps(CaseDtoWithExternalObjectViewModel, $metadata.CaseDtoWithExternalObject) + +export class CaseDtoWithExternalObjectListViewModel extends ListViewModel<$models.CaseDtoWithExternalObject, $apiClients.CaseDtoWithExternalObjectApiClient, CaseDtoWithExternalObjectViewModel> { + + constructor() { + super($metadata.CaseDtoWithExternalObject, new $apiClients.CaseDtoWithExternalObjectApiClient()) + } +} + + export interface CaseProductViewModel extends $models.CaseProduct { caseProductId: number | null; caseId: number | null; @@ -947,6 +993,33 @@ export class ComplexModelDependentListViewModel extends ListViewModel<$models.Co } +export interface ContentViewEntityViewModel extends $models.ContentViewEntity { + contentViewEntityId: number | null; + name: string | null; + description: string | null; + assignedToId: number | null; + assignedTo: $models.PersonSummary | null; + reportedById: number | null; + get reportedBy(): PersonViewModel | null; + set reportedBy(value: PersonViewModel | $models.Person | null); + neverMapped: string | null; +} +export class ContentViewEntityViewModel extends ViewModel<$models.ContentViewEntity, $apiClients.ContentViewEntityApiClient, number> implements $models.ContentViewEntity { + + constructor(initialData?: DeepPartial<$models.ContentViewEntity> | null) { + super($metadata.ContentViewEntity, new $apiClients.ContentViewEntityApiClient(), initialData) + } +} +defineProps(ContentViewEntityViewModel, $metadata.ContentViewEntity) + +export class ContentViewEntityListViewModel extends ListViewModel<$models.ContentViewEntity, $apiClients.ContentViewEntityApiClient, ContentViewEntityViewModel> { + + constructor() { + super($metadata.ContentViewEntity, new $apiClients.ContentViewEntityApiClient()) + } +} + + export interface CourseViewModel extends $models.Course { courseId: number | null; name: string | null; @@ -1905,11 +1978,14 @@ const viewModelTypeLookup = ViewModel.typeLookup = { AbstractModelPerson: AbstractModelPersonViewModel, Advisor: AdvisorViewModel, Case: CaseViewModel, + CaseAutoReadDto: CaseAutoReadDtoViewModel, CaseDtoStandalone: CaseDtoStandaloneViewModel, + CaseDtoWithExternalObject: CaseDtoWithExternalObjectViewModel, CaseProduct: CaseProductViewModel, Company: CompanyViewModel, ComplexModel: ComplexModelViewModel, ComplexModelDependent: ComplexModelDependentViewModel, + ContentViewEntity: ContentViewEntityViewModel, Course: CourseViewModel, DateOnlyPk: DateOnlyPkViewModel, DateTimeOffsetPk: DateTimeOffsetPkViewModel, @@ -1949,11 +2025,14 @@ const listViewModelTypeLookup = ListViewModel.typeLookup = { AbstractModelPerson: AbstractModelPersonListViewModel, Advisor: AdvisorListViewModel, Case: CaseListViewModel, + CaseAutoReadDto: CaseAutoReadDtoListViewModel, CaseDtoStandalone: CaseDtoStandaloneListViewModel, + CaseDtoWithExternalObject: CaseDtoWithExternalObjectListViewModel, CaseProduct: CaseProductListViewModel, Company: CompanyListViewModel, ComplexModel: ComplexModelListViewModel, ComplexModelDependent: ComplexModelDependentListViewModel, + ContentViewEntity: ContentViewEntityListViewModel, Course: CourseListViewModel, DateOnlyPk: DateOnlyPkListViewModel, DateTimeOffsetPk: DateTimeOffsetPkListViewModel,