diff --git a/docs/topics/dto-shapes-and-read-models.md b/docs/topics/dto-shapes-and-read-models.md index 8524e7808..872e0c983 100644 --- a/docs/topics/dto-shapes-and-read-models.md +++ b/docs/topics/dto-shapes-and-read-models.md @@ -21,3 +21,9 @@ This is especially important for generated read models and contract shapes, wher Coalesce now tolerates EF-owned and complex/value-object shapes during metadata discovery instead of treating them like unsupported roots. That lets the higher-level DTO and read-shape features work against richer EF models without requiring you to flatten those value objects away first. + +## Navigation summary DTOs provide lightweight related-data shapes + +Navigation summary DTOs add a smaller, purpose-built way to surface related model information in generated read APIs without always expanding full related entities. + +Those summary shapes are the foundation for later flattened-property and auto-projected read-shape features higher in the stack. diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs index 79def9202..0681b95d2 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs @@ -57,6 +57,11 @@ public override void BuildOutput(CSharpCodeBuilder b) WriteParameterDto(b); b.Line(); WriteResponseDto(b); + if (Model.ShouldGenerateSummaryDto) + { + b.Line(); + WriteSummaryDto(b); + } } b.Line(); @@ -329,7 +334,7 @@ private void WriteResponseDto(CSharpCodeBuilder b) foreach (PropertyViewModel prop in ownProps) { - b.Line($"public {prop.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {prop.Name} {{ get; set; }}"); + b.Line($"public {ResponsePropertyType(prop)} {prop.Name} {{ get; set; }}"); } b.DocComment("Map from the domain object to the properties of the current DTO instance."); @@ -362,6 +367,35 @@ private void WriteResponseDto(CSharpCodeBuilder b) } } + private void WriteSummaryDto(CSharpCodeBuilder b) + { + var primaryKey = Model.PrimaryKey + ?? throw new InvalidOperationException($"Summary DTO generation for {Model.FullyQualifiedName} requires a primary key."); + + using (b.Block($"public partial class {Model.SummaryDtoTypeName} : IGeneratedResponseDto<{Model.FullyQualifiedName}>")) + { + b.Line($"public {Model.SummaryDtoTypeName}() {{ }}"); + b.Line(); + b.Line($"public {primaryKey.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {primaryKey.Name} {{ get; set; }}"); + + foreach (var prop in Model.SummaryProperties) + { + b.Line($"public {prop.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {prop.Name} {{ get; set; }}"); + } + + b.DocComment("Map from the domain object to the properties of the current summary DTO instance."); + using (b.Block($"public void MapFrom({Model.FullyQualifiedName} obj, IMappingContext context, IncludeTree tree = null)")) + { + b.Line("if (obj is null) return;"); + b.Line($"this.{primaryKey.Name} = obj.{primaryKey.Name};"); + foreach (var prop in Model.SummaryProperties) + { + b.Line($"this.{prop.Name} = {prop.AccessExpression("obj")};"); + } + } + } + } + void WriteSetters(CSharpCodeBuilder b, IEnumerable<(IEnumerable conditionals, string setter)> settersAndConditionals) @@ -515,7 +549,7 @@ private IEnumerable GetPropertySetterConditional( string setter; string mapCall() => property.Object.IsCustomDto ? "" // If we hang an IClassDto off an external type, or another IClassDto, no mapping needed - it is already the desired type. - : $".MapToDto<{property.Object.FullyQualifiedName}, {property.Object.ResponseDtoTypeName}>(context, tree?[nameof({dtoVar}.{name})])"; + : $".MapToDto<{property.Object.FullyQualifiedName}, {GetResponseDtoTypeName(property)}>(context, tree?[nameof({dtoVar}.{name})])"; if (property.Type.IsDictionary) { @@ -622,6 +656,26 @@ string mapCall() => property.Object.IsCustomDto return (statement, setter); } + private string ResponsePropertyType(PropertyViewModel property) + { + if (property.UsesDtoReferenceSummary && property.Object is not null) + { + return $"{DtoNamespace}.{property.Object.SummaryDtoTypeName}"; + } + + return property.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace); + } + + private string GetResponseDtoTypeName(PropertyViewModel property) + { + if (property.UsesDtoReferenceSummary && property.Object is not null) + { + return property.Object.SummaryDtoTypeName; + } + + return property.Object.ResponseDtoTypeName; + } + private static bool IsImmutableDictionary(TypeViewModel type) => type.IsA(typeof(IImmutableDictionary<,>)) || type.IsA(typeof(ImmutableDictionary<,>)); diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoReferenceSummaryGenerationTests.cs b/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoReferenceSummaryGenerationTests.cs new file mode 100644 index 000000000..7b99682e4 --- /dev/null +++ b/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoReferenceSummaryGenerationTests.cs @@ -0,0 +1,62 @@ +using IntelliTect.Coalesce.CodeGeneration.Generation; +using IntelliTect.Coalesce.CodeGeneration.Vue.Generators; +using IntelliTect.Coalesce.Testing; +using IntelliTect.Coalesce.Testing.Util; +using IntelliTect.Coalesce.TypeDefinition; + +namespace IntelliTect.Coalesce.CodeGeneration.Tests; + +public class DtoReferenceSummaryGenerationTests : CodeGenTestBase +{ + [Test] + public async Task ApiDtos_GenerateReferenceSummaryResponseProperties() + { + var outDir = Path.Combine(Path.GetTempPath(), "Coalesce.DtoReferenceSummaryTests", Guid.NewGuid().ToString("N"), "Api"); + var suite = BuildExecutor() + .CreateRootGenerator() + .WithModel(ReflectionRepositoryFactory.Symbol) + .WithOutputPath(outDir); + + await suite.GenerateAsync(); + + var caseDtoFile = Directory.GetFiles(outDir, "CaseDto.g.cs", SearchOption.AllDirectories).Single(); + var personDtoFile = Directory.GetFiles(outDir, "PersonDto.g.cs", SearchOption.AllDirectories).Single(); + + var caseContents = await File.ReadAllTextAsync(caseDtoFile); + var personContents = await File.ReadAllTextAsync(personDtoFile); + + await Assert.That(caseContents.Contains("PersonSummaryResponse AssignedTo { get; set; }")).IsTrue(); + await Assert.That(caseContents.Contains("MapToDto<")).IsTrue(); + await Assert.That(caseContents.Contains("PersonSummaryResponse>(context, tree?[nameof(this.AssignedTo)])")).IsTrue(); + await Assert.That(personContents.Contains("public partial class PersonSummaryResponse")).IsTrue(); + await Assert.That(personContents.Contains("public int? PersonId { get; set; }")).IsTrue(); + await Assert.That(personContents.Contains("public string Name { get; set; }")).IsTrue(); + await Assert.That(personContents.Contains("public string CompanyName { get; set; }")).IsTrue(); + await Assert.That(personContents.Contains("this.CompanyName = obj.Company?.Name;")).IsTrue(); + + await AssertSuiteCSharpOutputCompiles(suite); + } + + [Test] + public async Task VueOutput_GeneratesReferenceSummaryModels() + { + var outDir = Path.Combine(Path.GetTempPath(), "Coalesce.DtoReferenceSummaryTests", Guid.NewGuid().ToString("N"), "Vue"); + var suite = BuildExecutor() + .CreateRootGenerator() + .WithModel(ReflectionRepositoryFactory.Symbol) + .WithOutputPath(outDir); + + await suite.GenerateAsync(); + + var models = await File.ReadAllTextAsync(Path.Combine(outDir, "src", "models.g.ts")); + var metadata = await File.ReadAllTextAsync(Path.Combine(outDir, "src", "metadata.g.ts")); + var viewmodels = await File.ReadAllTextAsync(Path.Combine(outDir, "src", "viewmodels.g.ts")); + + await Assert.That(models.Contains("export interface PersonSummary extends Model")).IsTrue(); + await Assert.That(models.Contains("companyName: string | null")).IsTrue(); + await Assert.That(models.Contains("assignedTo: PersonSummary | null")).IsTrue(); + await Assert.That(metadata.Contains("export const PersonSummary = domain.types.PersonSummary =")).IsTrue(); + await Assert.That(metadata.Contains("get typeDef() { return (domain.types.PersonSummary as ObjectType & { name: \"PersonSummary\" }) },")).IsTrue(); + await Assert.That(viewmodels.Contains("assignedTo: $models.PersonSummary | null;")).IsTrue(); + } +} diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs index 7eca04bf1..ff8ce5cb9 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs @@ -49,6 +49,11 @@ public override Task BuildOutputAsync() WriteExternalTypeMetadata(b, model); } + foreach (var model in Model.ClientClasses.Where(m => m.ShouldGenerateSummaryDto).OrderBy(e => e.ClientTypeName)) + { + WriteSummaryTypeMetadata(b, model); + } + foreach (var model in Model.Services.OrderBy(e => e.ClientTypeName)) { WriteServiceMetadata(b, model); @@ -71,6 +76,10 @@ public override Task BuildOutputAsync() { b.Line($"{model.ClientTypeName}: typeof {model.ClientTypeName}"); } + foreach (var model in Model.ClientClasses.Where(m => m.ShouldGenerateSummaryDto).OrderBy(e => e.ClientTypeName)) + { + b.Line($"{model.SummaryViewModelClassName}: typeof {model.SummaryViewModelClassName}"); + } } using (b.Block("services:")) { @@ -175,6 +184,37 @@ private void WriteApiBackedTypeMetadata(TypeScriptCodeBuilder b, ClassViewModel } } + private void WriteSummaryTypeMetadata(TypeScriptCodeBuilder b, ClassViewModel model) + { + using (b.Block($"export const {model.SummaryViewModelClassName} = domain.types.{model.SummaryViewModelClassName} =")) + { + b.StringProp("name", model.SummaryViewModelClassName, asConst: true); + b.StringProp("displayName", model.DisplayName + " Summary"); + + if (!string.IsNullOrWhiteSpace(model.Description)) + { + b.StringProp("description", model.Description); + } + + b.StringProp("type", "object"); + + if (model.SummaryProperties.FirstOrDefault() is { } displayProp) + { + b.Line($"get displayProp() {{ return this.props.{displayProp.Name.ToCamelCase()} }}, "); + } + + using (b.Block("props:", ',')) + { + WriteSummaryPrimaryKeyMetadata(b, model.PrimaryKey!); + + foreach (var prop in model.SummaryProperties) + { + WriteSummaryPropertyMetadata(b, prop); + } + } + } + } + private void WriteEnumMetadata(TypeScriptCodeBuilder b, TypeViewModel model) { using (b.Block($"export const {model.ClientTypeName} = domain.enums.{model.ClientTypeName} =")) @@ -243,78 +283,91 @@ private static string GetClassMetadataRef(ClassViewModel obj = null) return $"(domain.types.{obj.ViewModelClassName} as {(obj.IsDbMappedType ? "ModelType" : "ObjectType")} & {{ name: \"{obj.ClientTypeName}\" }})"; } + private static string GetSummaryMetadataRef(ClassViewModel obj) + => $"(domain.types.{obj.SummaryViewModelClassName} as ObjectType & {{ name: \"{obj.SummaryViewModelClassName}\" }})"; + private void WriteClassPropertyMetadata(TypeScriptCodeBuilder b, ClassViewModel model, PropertyViewModel prop) { using (b.Block($"{prop.JsVariable}:", ',')) { - WriteValueCommonMetadata(b, prop); - - switch (prop.Role) + if (prop.UsesDtoReferenceSummary && prop.Object is not null) { - case PropertyRole.PrimaryKey: - // TS Type: "PrimaryKeyProperty" - b.StringProp("role", "primaryKey"); - break; - - case PropertyRole.ForeignKey: - // TS Type: "ForeignKeyProperty" - var principal = prop.ForeignKeyPrincipalType; - b.StringProp("role", "foreignKey"); - b.Line($"get principalKey() {{ return {GetClassMetadataRef(principal)}.props.{principal.PrimaryKey.JsVariable} as PrimaryKeyProperty }},"); - b.Line($"get principalType() {{ return {GetClassMetadataRef(principal)} }},"); + WriteValueIdentityMetadata(b, prop); + b.StringProp("type", "object"); + b.Line($"get typeDef() {{ return {GetSummaryMetadataRef(prop.Object)} }},"); + b.StringProp("role", "value"); + } + else + { + WriteValueCommonMetadata(b, prop); - if (prop.ReferenceNavigationProperty is { } navProp) - { - b.Line($"get navigationProp() {{ return {GetClassMetadataRef(model)}.props.{navProp.JsVariable} as ModelReferenceNavigationProperty }},"); - } - break; + switch (prop.Role) + { + case PropertyRole.PrimaryKey: + // TS Type: "PrimaryKeyProperty" + b.StringProp("role", "primaryKey"); + break; + + case PropertyRole.ForeignKey: + // TS Type: "ForeignKeyProperty" + var principal = prop.ForeignKeyPrincipalType; + b.StringProp("role", "foreignKey"); + b.Line($"get principalKey() {{ return {GetClassMetadataRef(principal)}.props.{principal.PrimaryKey.JsVariable} as PrimaryKeyProperty }},"); + b.Line($"get principalType() {{ return {GetClassMetadataRef(principal)} }},"); + + if (prop.ReferenceNavigationProperty is { } navProp) + { + b.Line($"get navigationProp() {{ return {GetClassMetadataRef(model)}.props.{navProp.JsVariable} as ModelReferenceNavigationProperty }},"); + } + break; - case PropertyRole.ReferenceNavigation: - // TS Type: "ModelReferenceNavigationProperty" - b.StringProp("role", "referenceNavigation"); - // Note: `prop.ForeignKeyProperty.Role` might be PrimaryKey, not ForeignKey, in the case of 1-to-1 relationships. - b.Line($"get foreignKey() {{ return {GetClassMetadataRef(model)}.props.{prop.ForeignKeyProperty.JsVariable} as {prop.ForeignKeyProperty.Role}Property }},"); - b.Line($"get principalKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.Object.PrimaryKey.JsVariable} as PrimaryKeyProperty }},"); + case PropertyRole.ReferenceNavigation: + // TS Type: "ModelReferenceNavigationProperty" + b.StringProp("role", "referenceNavigation"); + // Note: `prop.ForeignKeyProperty.Role` might be PrimaryKey, not ForeignKey, in the case of 1-to-1 relationships. + b.Line($"get foreignKey() {{ return {GetClassMetadataRef(model)}.props.{prop.ForeignKeyProperty.JsVariable} as {prop.ForeignKeyProperty.Role}Property }},"); + b.Line($"get principalKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.Object.PrimaryKey.JsVariable} as PrimaryKeyProperty }},"); - if (prop.InverseProperty != null) - { - b.Line($"get inverseNavigation() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.InverseProperty.JsVariable} as ModelCollectionNavigationProperty }},"); - } + if (prop.InverseProperty != null) + { + b.Line($"get inverseNavigation() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.InverseProperty.JsVariable} as ModelCollectionNavigationProperty }},"); + } - break; + break; - case PropertyRole.CollectionNavigation: - // TS Type: "ModelCollectionNavigationProperty" - b.StringProp("role", "collectionNavigation"); - b.Line($"get foreignKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); + case PropertyRole.CollectionNavigation: + // TS Type: "ModelCollectionNavigationProperty" + b.StringProp("role", "collectionNavigation"); + b.Line($"get foreignKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); - if (prop.InverseProperty != null) - { - b.Line($"get inverseNavigation() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.InverseProperty.JsVariable} as ModelReferenceNavigationProperty }},"); - } + if (prop.InverseProperty != null) + { + b.Line($"get inverseNavigation() {{ return {GetClassMetadataRef(prop.Object)}.props.{prop.InverseProperty.JsVariable} as ModelReferenceNavigationProperty }},"); + } - if (prop.IsManyToManyCollection) - { - using (b.Block("manyToMany:", ",")) + if (prop.IsManyToManyCollection) { - var nearNavigation = prop.ManyToManyNearNavigationProperty; - var farNavigation = prop.ManyToManyFarNavigationProperty; - - b.StringProp("name", prop.ManyToManyCollectionName.ToCamelCase()); - b.StringProp("displayName", prop.ManyToManyCollectionName.ToProperCase()); - b.Line($"get typeDef() {{ return {GetClassMetadataRef(farNavigation.Object)} }},"); - b.Line($"get farForeignKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{farNavigation.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); - b.Line($"get farNavigationProp() {{ return {GetClassMetadataRef(prop.Object)}.props.{farNavigation.JsVariable} as ModelReferenceNavigationProperty }},"); - b.Line($"get nearForeignKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{nearNavigation.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); - b.Line($"get nearNavigationProp() {{ return {GetClassMetadataRef(prop.Object)}.props.{nearNavigation.JsVariable} as ModelReferenceNavigationProperty }},"); + using (b.Block("manyToMany:", ",")) + { + var nearNavigation = prop.ManyToManyNearNavigationProperty; + var farNavigation = prop.ManyToManyFarNavigationProperty; + + b.StringProp("name", prop.ManyToManyCollectionName.ToCamelCase()); + b.StringProp("displayName", prop.ManyToManyCollectionName.ToProperCase()); + b.Line($"get typeDef() {{ return {GetClassMetadataRef(farNavigation.Object)} }},"); + b.Line($"get farForeignKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{farNavigation.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); + b.Line($"get farNavigationProp() {{ return {GetClassMetadataRef(prop.Object)}.props.{farNavigation.JsVariable} as ModelReferenceNavigationProperty }},"); + b.Line($"get nearForeignKey() {{ return {GetClassMetadataRef(prop.Object)}.props.{nearNavigation.ForeignKeyProperty.JsVariable} as ForeignKeyProperty }},"); + b.Line($"get nearNavigationProp() {{ return {GetClassMetadataRef(prop.Object)}.props.{nearNavigation.JsVariable} as ModelReferenceNavigationProperty }},"); + } } - } - break; + break; - default: - b.StringProp("role", "value"); - break; + default: + b.StringProp("role", "value"); + break; + } } int hiddenAreaFlags = (int)prop.HiddenAreas; @@ -378,6 +431,32 @@ TypeDiscriminator.Enum or } } + private void WriteSummaryPrimaryKeyMetadata(TypeScriptCodeBuilder b, PropertyViewModel prop) + { + using (b.Block($"{prop.JsVariable}:", ',')) + { + WriteValueCommonMetadata(b, prop); + b.StringProp("role", "primaryKey"); + } + } + + private void WriteSummaryPropertyMetadata(TypeScriptCodeBuilder b, SummaryPropertyViewModel prop) + { + using (b.Block($"{prop.Name.ToCamelCase()}:", ',')) + { + b.StringProp("name", prop.Name.ToCamelCase()); + b.StringProp("displayName", prop.DisplayName); + + if (!string.IsNullOrWhiteSpace(prop.LeafProperty.Description)) + { + b.StringProp("description", prop.LeafProperty.Description); + } + + WriteTypeCommonMetadata(b, prop.Type, prop.LeafProperty); + b.StringProp("role", "value"); + } + } + private static List GetValidationRules(ValueViewModel prop, string propName) { // TODO: Handle 'ClientValidationAllowSave' by placing a field on the @@ -677,6 +756,12 @@ private void WriteDataSourceMetadata(TypeScriptCodeBuilder b, ClassViewModel mod /// Write metadata common to all value representations, like properties and method parameters. /// private void WriteValueCommonMetadata(TypeScriptCodeBuilder b, ValueViewModel value) + { + WriteValueIdentityMetadata(b, value); + WriteTypeCommonMetadata(b, value.Type, value); + } + + private void WriteValueIdentityMetadata(TypeScriptCodeBuilder b, ValueViewModel value) { b.StringProp("name", value.JsVariable); b.StringProp("displayName", value.DisplayName); @@ -686,7 +771,6 @@ private void WriteValueCommonMetadata(TypeScriptCodeBuilder b, ValueViewModel va b.StringProp("description", value.Description); } - WriteTypeCommonMetadata(b, value.Type, value); WriteCustomMetadata(b, value); } diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs index 4b86f519d..394bf767c 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs @@ -50,6 +50,13 @@ public override Task BuildOutputAsync() WriteModel(b, model, written); } + foreach (var model in Model.ClientClasses + .Where(m => m.ShouldGenerateSummaryDto) + .OrderBy(e => e.ClientTypeName)) + { + WriteSummaryModel(b, model); + } + using (b.Block("declare module \"coalesce-vue/lib/model\"")) { @@ -67,6 +74,10 @@ public override Task BuildOutputAsync() { b.Line($"{model.ClientTypeName}: {model.ClientTypeName}"); } + foreach (var model in Model.ClientClasses.Where(m => m.ShouldGenerateSummaryDto).OrderBy(e => e.ClientTypeName)) + { + b.Line($"{model.SummaryViewModelClassName}: {model.SummaryViewModelClassName}"); + } } } @@ -107,7 +118,7 @@ private void WriteModel(TypeScriptCodeBuilder b, ClassViewModel model, HashSet")) + { + var primaryKey = model.PrimaryKey + ?? throw new InvalidOperationException($"Summary model generation for {model.FullyQualifiedName} requires a primary key."); + + b.DocComment(primaryKey.Comment ?? primaryKey.Description); + b.Line($"{primaryKey.JsVariable}: {new VueType(primaryKey.Type.NullableValueUnderlyingType).TsType()} | null"); + + foreach (var prop in model.SummaryProperties) + { + b.DocComment(prop.LeafProperty.Comment ?? prop.LeafProperty.Description); + b.Line($"{prop.Name.ToCamelCase()}: {new VueType(prop.Type.NullableValueUnderlyingType).TsType()} | null"); + } + } + + using (b.Block($"export class {name}")) + { + b.DocComment($"Mutates the input object and its descendants into a valid {name} implementation."); + using (b.Block($"static convert(data?: Partial<{name}>): {name}")) + { + b.Line($"return convertToModel<{name}>(data || {{}}, metadata.{name}) "); + } + + b.DocComment($"Maps the input object and its descendants to a new, valid {name} implementation."); + using (b.Block($"static map(data?: Partial<{name}>): {name}")) + { + b.Line($"return mapToModel<{name}>(data || {{}}, metadata.{name}) "); + } + + b.Line(); + b.Line($"static [Symbol.hasInstance](x: any) {{ return x?.$metadata === metadata.{name}; }}"); + + b.DocComment($"Instantiate a new {name}, optionally basing it on the given data."); + using (b.Block($"constructor(data?: Partial<{name}> | {{[k: string]: any}})")) + { + b.Line($"Object.assign(this, {name}.map(data || {{}}));"); + } + } + + b.Line(); + } + + private string GetModelPropertyType(PropertyViewModel prop) + { + if (prop.UsesDtoReferenceSummary && prop.Object is not null) + { + return prop.Object.SummaryViewModelClassName; + } + + return new VueType(prop.Type.NullableValueUnderlyingType).TsType(); + } } diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsViewModels.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsViewModels.cs index c693fbf9a..8c29701e5 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsViewModels.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsViewModels.cs @@ -82,9 +82,7 @@ private void WriteViewModel(TypeScriptCodeBuilder b, ClassViewModel model) foreach (var prop in model.ClientProperties) { b.DocComment(prop.Comment ?? prop.Description); - var vueType = new VueType(prop.Type.NullableValueUnderlyingType); - var typeString = vueType.TsType(modelPrefix: "$models", viewModel: true); - var modelTypeString = vueType.TsType(modelPrefix: "$models", viewModel: false); + var (typeString, modelTypeString) = GetPropertyTsTypes(prop); if (typeString == modelTypeString) { @@ -168,6 +166,21 @@ private void WriteViewModel(TypeScriptCodeBuilder b, ClassViewModel model) b.Line(); } + private static (string typeString, string modelTypeString) GetPropertyTsTypes(PropertyViewModel prop) + { + if (prop.UsesDtoReferenceSummary && prop.Object is not null) + { + var summaryType = $"$models.{prop.Object.SummaryViewModelClassName}"; + return (summaryType, summaryType); + } + + var vueType = new VueType(prop.Type.NullableValueUnderlyingType); + return ( + vueType.TsType(modelPrefix: "$models", viewModel: true), + vueType.TsType(modelPrefix: "$models", viewModel: false) + ); + } + private void WriteListViewModel(TypeScriptCodeBuilder b, ClassViewModel model) { string name = model.ViewModelClassName; diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/Case.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/Case.cs index 52a231d16..57bd55e7f 100644 --- a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/Case.cs +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/Case.cs @@ -47,6 +47,7 @@ public enum Statuses public int? AssignedToId { get; set; } + [DtoReference] [ForeignKey("AssignedToId")] public Person AssignedTo { get; set; } diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/Person.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/Person.cs index e60de999a..d6596d972 100644 --- a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/Person.cs +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/Person.cs @@ -14,6 +14,7 @@ namespace IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext; [Edit(PermissionLevel = SecurityPermissionLevels.AllowAll)] [Table("Person")] +[DtoSummary("Company.Name")] public class Person { public enum Genders diff --git a/src/IntelliTect.Coalesce.Tests/Tests/Mapping/IncludeTreeTests.cs b/src/IntelliTect.Coalesce.Tests/Tests/Mapping/IncludeTreeTests.cs index 7c5d4dddf..98fd5f3e1 100644 --- a/src/IntelliTect.Coalesce.Tests/Tests/Mapping/IncludeTreeTests.cs +++ b/src/IntelliTect.Coalesce.Tests/Tests/Mapping/IncludeTreeTests.cs @@ -96,6 +96,28 @@ public async Task IncludeTree_StaticQueryFor() await AssertBasicChecks(tree); } + [Test] + public async Task IncludeTree_IncludeChildren_IncludesReferenceSummaryPaths() + { + var tree = db.Cases + .IncludeChildren(ReflectionRepositoryFactory.Reflection) + .GetIncludeTree(); + + await Assert.That(tree[nameof(Case.AssignedTo)]).IsNotNull(); + await Assert.That(tree[nameof(Case.AssignedTo)][nameof(Person.Company)]).IsNotNull(); + } + + [Test] + public async Task IncludeTree_IncludeChildren_IncludesReferenceSummaryPaths() + { + var tree = db.Cases + .IncludeChildren(ReflectionRepositoryFactory.Reflection) + .GetIncludeTree(); + + await Assert.That(tree[nameof(Case.AssignedTo)]).IsNotNull(); + await Assert.That(tree[nameof(Case.AssignedTo)][nameof(Person.Company)]).IsNotNull(); + } + [Test] public async Task IncludeTree_CastedIncludes() { diff --git a/src/IntelliTect.Coalesce/DataAnnotations/DtoReferenceAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/DtoReferenceAttribute.cs new file mode 100644 index 000000000..08861739d --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/DtoReferenceAttribute.cs @@ -0,0 +1,8 @@ +using System; + +namespace IntelliTect.Coalesce.DataAnnotations; + +[AttributeUsage(AttributeTargets.Property)] +public sealed class DtoReferenceAttribute : Attribute +{ +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/DtoSummaryAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/DtoSummaryAttribute.cs new file mode 100644 index 000000000..ae38bb8ef --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/DtoSummaryAttribute.cs @@ -0,0 +1,16 @@ +using System; + +namespace IntelliTect.Coalesce.DataAnnotations; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class DtoSummaryAttribute : Attribute +{ + public DtoSummaryAttribute(string path) + { + Path = path; + } + + public string Path { get; } + + public string? Name { get; set; } +} diff --git a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs index f0fa3d75b..1a7546b42 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs @@ -146,6 +146,10 @@ public IEnumerable ClientBaseTypes /// public string ListViewModelClassName => ClientTypeName + "List"; + public string SummaryViewModelClassName => ClientTypeName + "Summary"; + + public string SummaryDtoTypeName => SummaryViewModelClassName + "Response"; + public bool IsService => this.HasAttribute() && this.HasAttribute(); public bool IsStandaloneEntity => this.HasAttribute() && this.HasAttribute(); @@ -263,6 +267,16 @@ public IEnumerable ClientDataSources(ReflectionRepository repo) return Properties.FirstOrDefault(f => string.Equals(f.Name, key, StringComparison.OrdinalIgnoreCase)); } + private IReadOnlyList? _summaryProperties; + public IReadOnlyList SummaryProperties + => _summaryProperties ??= SummaryPropertyViewModel.FromClass(this); + + private bool? _shouldGenerateSummaryDto; + public bool ShouldGenerateSummaryDto + => _shouldGenerateSummaryDto ??= ReflectionRepository?.ClientClasses + .SelectMany(c => c.ClientProperties) + .Any(p => p.UsesDtoReferenceSummary && p.Object == this) == true; + /// /// Returns a client method matching the name if it exists. /// Non-client-exposed methods will not be returned. diff --git a/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs index b918083a3..8fd5b7bdf 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs @@ -129,6 +129,10 @@ public IEnumerable GetCustomMetadata() /// public ClassViewModel? Object => PureType.ClassViewModel; + public bool UsesDtoReferenceSummary => + Role == PropertyRole.ReferenceNavigation && + this.HasAttribute(); + /// /// Returns true if this property is a collection and has the ManyToMany Attribute /// diff --git a/src/IntelliTect.Coalesce/TypeDefinition/SummaryPropertyViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/SummaryPropertyViewModel.cs new file mode 100644 index 000000000..a64be8bf7 --- /dev/null +++ b/src/IntelliTect.Coalesce/TypeDefinition/SummaryPropertyViewModel.cs @@ -0,0 +1,131 @@ +using IntelliTect.Coalesce.DataAnnotations; +using IntelliTect.Coalesce.Utilities; +using System; +using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Linq; + +namespace IntelliTect.Coalesce.TypeDefinition; + +public sealed class SummaryPropertyViewModel +{ + private readonly IReadOnlyList _pathProperties; + + private SummaryPropertyViewModel( + ClassViewModel parent, + IReadOnlyList pathProperties, + string? explicitName + ) + { + Parent = parent; + _pathProperties = pathProperties; + LeafProperty = pathProperties[^1]; + Type = LeafProperty.Type; + Name = explicitName ?? string.Concat(pathProperties.Select(p => p.Name)); + DisplayName = explicitName is null && pathProperties.Count == 1 + ? LeafProperty.DisplayName + : Name.ToProperCase(); + IncludePath = pathProperties.Count > 1 + ? string.Join(".", pathProperties.Take(pathProperties.Count - 1).Select(p => p.Name)) + : null; + } + + public ClassViewModel Parent { get; } + + public string Name { get; } + + public string DisplayName { get; } + + public TypeViewModel Type { get; } + + public PropertyViewModel LeafProperty { get; } + + public string? IncludePath { get; } + + public string AccessExpression(string rootExpression) + { + var expression = $"{rootExpression}.{_pathProperties[0].Name}"; + for (var i = 1; i < _pathProperties.Count; i++) + { + expression += $"?.{_pathProperties[i].Name}"; + } + + return expression; + } + + public static IReadOnlyList FromClass(ClassViewModel model) + { + var summary = model + .GetAttributes() + .Select(a => Create(model, a)) + .ToList(); + + if (model.ListTextProperty is { } listText + && model.PrimaryKey is not null + && !ReferenceEquals(listText, model.PrimaryKey) + && !summary.Any(p => string.Equals(p.Name, listText.Name, StringComparison.OrdinalIgnoreCase))) + { + summary.Insert(0, Create(listText)); + } + + return new ReadOnlyCollection(summary); + } + + public static SummaryPropertyViewModel Create(PropertyViewModel property) + { + EnsureLeafSupported(property.Parent, property.Type, property.Name); + return new SummaryPropertyViewModel(property.Parent, [property], null); + } + + public static SummaryPropertyViewModel Create(ClassViewModel model, AttributeViewModel attribute) + { + var path = attribute.GetValue(a => a.Path); + var name = attribute.GetValue(a => a.Name); + + if (string.IsNullOrWhiteSpace(path)) + { + throw new InvalidOperationException($"[{nameof(DtoSummaryAttribute)}] on {model.FullyQualifiedName} must specify a non-empty path."); + } + + var segments = path + .Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (segments.Length < 1) + { + throw new InvalidOperationException($"[{nameof(DtoSummaryAttribute)}] path '{path}' on {model.FullyQualifiedName} is invalid."); + } + + var current = model; + var pathProperties = new List(segments.Length); + for (var i = 0; i < segments.Length; i++) + { + var segment = segments[i]; + var prop = current.PropertyByName(segment) + ?? throw new InvalidOperationException($"[{nameof(DtoSummaryAttribute)}] path '{path}' on {model.FullyQualifiedName} could not resolve segment '{segment}' on {current.FullyQualifiedName}."); + + pathProperties.Add(prop); + + if (i < segments.Length - 1) + { + if (prop.Type.IsCollection) + { + throw new InvalidOperationException($"[{nameof(DtoSummaryAttribute)}] path '{path}' on {model.FullyQualifiedName} cannot traverse collection property '{prop.Name}'."); + } + + current = prop.Type.ClassViewModel + ?? throw new InvalidOperationException($"[{nameof(DtoSummaryAttribute)}] path '{path}' on {model.FullyQualifiedName} cannot traverse scalar property '{prop.Name}'."); + } + } + + EnsureLeafSupported(model, pathProperties[^1].Type, path); + return new SummaryPropertyViewModel(model, pathProperties, name); + } + + private static void EnsureLeafSupported(ClassViewModel model, TypeViewModel type, string path) + { + if (type.IsCollection || type.IsDictionary || type.HasClassViewModel) + { + throw new InvalidOperationException($"[{nameof(DtoSummaryAttribute)}] path '{path}' on {model.FullyQualifiedName} must end on a scalar/enum value, not '{type.FullyQualifiedName}'."); + } + } +} diff --git a/src/test-targets/metadata.g.ts b/src/test-targets/metadata.g.ts index 676fcd05f..94af7a855 100644 --- a/src/test-targets/metadata.g.ts +++ b/src/test-targets/metadata.g.ts @@ -761,12 +761,9 @@ export const Case = domain.types.Case = { assignedTo: { name: "assignedTo", displayName: "Assigned To", - type: "model", - get typeDef() { return (domain.types.Person as ModelType & { name: "Person" }) }, - role: "referenceNavigation", - get foreignKey() { return (domain.types.Case as ModelType & { name: "Case" }).props.assignedToId as ForeignKeyProperty }, - get principalKey() { return (domain.types.Person as ModelType & { name: "Person" }).props.personId as PrimaryKeyProperty }, - get inverseNavigation() { return (domain.types.Person as ModelType & { name: "Person" }).props.casesAssigned as ModelCollectionNavigationProperty }, + type: "object", + get typeDef() { return (domain.types.PersonSummary as ObjectType & { name: "PersonSummary" }) }, + role: "value", dontSerialize: true, }, reportedById: { @@ -5879,6 +5876,32 @@ export const WeatherData = domain.types.WeatherData = { }, }, } +export const PersonSummary = domain.types.PersonSummary = { + name: "PersonSummary" as const, + displayName: "Person Summary", + type: "object", + get displayProp() { return this.props.name }, + props: { + personId: { + name: "personId", + displayName: "Person Id", + type: "number", + role: "primaryKey", + }, + name: { + name: "name", + displayName: "Name", + type: "string", + role: "value", + }, + companyName: { + name: "companyName", + displayName: "Company Name", + type: "string", + role: "value", + }, + }, +} export const WeatherService = domain.services.WeatherService = { name: "WeatherService", displayName: "Weather Service", @@ -6020,6 +6043,7 @@ interface AppDomain extends Domain { ValidationTargetChild: typeof ValidationTargetChild WeatherData: typeof WeatherData ZipCode: typeof ZipCode + PersonSummary: typeof PersonSummary } services: { WeatherService: typeof WeatherService diff --git a/src/test-targets/models.g.ts b/src/test-targets/models.g.ts index 2dca1afaa..70138a70a 100644 --- a/src/test-targets/models.g.ts +++ b/src/test-targets/models.g.ts @@ -210,7 +210,7 @@ export interface Case extends Model { description: string | null openedAt: Date | null assignedToId: number | null - assignedTo: Person | null + assignedTo: PersonSummary | null reportedById: number | null reportedBy: Person | null attachment: string | null @@ -1851,6 +1851,38 @@ export class WeatherData { } +export interface PersonSummary extends Model { + + /** ID for the person object. */ + personId: number | null + + /** + Calculated name of the person. eg., Mr. Michael Stokesbary. + A concatenation of Title, FirstName, and LastName. + */ + name: string | null + companyName: string | null +} +export class PersonSummary { + + /** Mutates the input object and its descendants into a valid PersonSummary implementation. */ + static convert(data?: Partial): PersonSummary { + return convertToModel(data || {}, metadata.PersonSummary) + } + + /** Maps the input object and its descendants to a new, valid PersonSummary implementation. */ + static map(data?: Partial): PersonSummary { + return mapToModel(data || {}, metadata.PersonSummary) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.PersonSummary; } + + /** Instantiate a new PersonSummary, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, PersonSummary.map(data || {})); + } +} + declare module "coalesce-vue/lib/model" { interface EnumTypeLookup { EnumPkId: EnumPkId @@ -1921,5 +1953,6 @@ declare module "coalesce-vue/lib/model" { ValidationTargetChild: ValidationTargetChild WeatherData: WeatherData ZipCode: ZipCode + PersonSummary: PersonSummary } } diff --git a/src/test-targets/viewmodels.g.ts b/src/test-targets/viewmodels.g.ts index 3460b8d8f..1cf1e9824 100644 --- a/src/test-targets/viewmodels.g.ts +++ b/src/test-targets/viewmodels.g.ts @@ -234,8 +234,7 @@ export interface CaseViewModel extends $models.Case { description: string | null; openedAt: Date | null; assignedToId: number | null; - get assignedTo(): PersonViewModel | null; - set assignedTo(value: PersonViewModel | $models.Person | null); + assignedTo: $models.PersonSummary | null; reportedById: number | null; get reportedBy(): PersonViewModel | null; set reportedBy(value: PersonViewModel | $models.Person | null);