From 0db7f4e10e3de730ea85670c57def88c63a86f66 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Wed, 6 May 2026 13:16:36 -0500 Subject: [PATCH 1/2] feat: add flattened DTO properties Generate flattened response properties for opt-in navigation paths and emit the corresponding C# and TypeScript metadata needed by API and Vue consumers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/ClassDto.cs | 14 +- .../DtoFlattenGenerationTests.cs | 52 +++++ .../Generators/Scripts/TsMetadata.cs | 21 +++ .../Generators/Scripts/TsModels.cs | 6 + .../TargetClasses/TestDbContext/Case.cs | 2 + .../DataAnnotations/DtoFlattenAttribute.cs | 25 +++ .../TypeDefinition/ClassViewModel.cs | 4 + .../FlattenedResponsePropertyViewModel.cs | 112 +++++++++++ src/test-targets/api-clients.g.ts | 15 ++ src/test-targets/metadata.g.ts | 178 ++++++++++++++++++ src/test-targets/models.g.ts | 165 ++++++++++++++++ src/test-targets/viewmodels.g.ts | 73 +++++++ 12 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoFlattenGenerationTests.cs create mode 100644 src/IntelliTect.Coalesce/DataAnnotations/DtoFlattenAttribute.cs create mode 100644 src/IntelliTect.Coalesce/TypeDefinition/FlattenedResponsePropertyViewModel.cs diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs index 0681b95d2..8c8f94f06 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs @@ -331,11 +331,19 @@ private void WriteResponseDto(CSharpCodeBuilder b) .ToList(); var ownProps = orderedProps.Where(p => baseType?.PropertyByName(p.Name) is null); + var flattenedProps = Model.FlattenedResponseProperties + .Where(p => baseType?.PropertyByName(p.Name) is null + && !(baseType?.FlattenedResponseProperties.Any(fp => fp.Name == p.Name) ?? false)) + .ToList(); foreach (PropertyViewModel prop in ownProps) { b.Line($"public {ResponsePropertyType(prop)} {prop.Name} {{ get; set; }}"); } + foreach (var prop in flattenedProps) + { + b.Line($"public {prop.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {prop.Name} {{ get; set; }}"); + } b.DocComment("Map from the domain object to the properties of the current DTO instance."); using (b.Block($"public void MapFrom({Model.FullyQualifiedName} obj, IMappingContext context, IncludeTree tree = null)")) @@ -362,7 +370,8 @@ private void WriteResponseDto(CSharpCodeBuilder b) b.Line(); WriteSetters(b, orderedProps - .Select(ModelToDtoPropertySetter)); + .Select(ModelToDtoPropertySetter) + .Concat(flattenedProps.Select(ModelToDtoFlattenedPropertySetter))); } } } @@ -656,6 +665,9 @@ string mapCall() => property.Object.IsCustomDto return (statement, setter); } + private (IEnumerable conditionals, string setter) ModelToDtoFlattenedPropertySetter(FlattenedResponsePropertyViewModel property) + => (Enumerable.Empty(), $"this.{property.Name} = {property.AccessExpression("obj")};"); + private string ResponsePropertyType(PropertyViewModel property) { if (property.UsesDtoReferenceSummary && property.Object is not null) diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoFlattenGenerationTests.cs b/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoFlattenGenerationTests.cs new file mode 100644 index 000000000..d4943a75d --- /dev/null +++ b/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoFlattenGenerationTests.cs @@ -0,0 +1,52 @@ +using IntelliTect.Coalesce.CodeGeneration.Generation; +using IntelliTect.Coalesce.CodeGeneration.Vue.Generators; +using IntelliTect.Coalesce.Testing; +using IntelliTect.Coalesce.Testing.Util; +using IntelliTect.Coalesce.TypeDefinition; + +namespace IntelliTect.Coalesce.CodeGeneration.Tests; + +public class DtoFlattenGenerationTests : CodeGenTestBase +{ + [Test] + public async Task ApiDtos_GenerateFlattenedResponseProperties() + { + var outDir = Path.Combine(Path.GetTempPath(), "Coalesce.DtoFlattenTests", Guid.NewGuid().ToString("N"), "Api"); + var suite = BuildExecutor() + .CreateRootGenerator() + .WithModel(ReflectionRepositoryFactory.Symbol) + .WithOutputPath(outDir); + + await suite.GenerateAsync(); + + var caseDtoFile = Directory.GetFiles(outDir, "CaseDto.g.cs", SearchOption.AllDirectories).Single(); + var contents = await File.ReadAllTextAsync(caseDtoFile); + + await Assert.That(contents.Contains("public string AssignedToName { get; set; }")).IsTrue(); + await Assert.That(contents.Contains("public string ReportedByCompanyName { get; set; }")).IsTrue(); + await Assert.That(contents.Contains("this.AssignedToName = obj.AssignedTo?.Name;")).IsTrue(); + await Assert.That(contents.Contains("this.ReportedByCompanyName = obj.ReportedBy?.Company?.Name;")).IsTrue(); + + await AssertSuiteCSharpOutputCompiles(suite); + } + + [Test] + public async Task VueOutput_GeneratesFlattenedResponseProperties() + { + var outDir = Path.Combine(Path.GetTempPath(), "Coalesce.DtoFlattenTests", Guid.NewGuid().ToString("N"), "Vue"); + var suite = BuildExecutor() + .CreateRootGenerator() + .WithModel(ReflectionRepositoryFactory.Symbol) + .WithOutputPath(outDir); + + await suite.GenerateAsync(); + + var models = await File.ReadAllTextAsync(Path.Combine(outDir, "src", "models.g.ts")); + var metadata = await File.ReadAllTextAsync(Path.Combine(outDir, "src", "metadata.g.ts")); + + await Assert.That(models.Contains("assignedToName: string | null")).IsTrue(); + await Assert.That(models.Contains("reportedByCompanyName: string | null")).IsTrue(); + await Assert.That(metadata.Contains("assignedToName:")).IsTrue(); + await Assert.That(metadata.Contains("reportedByCompanyName:")).IsTrue(); + } +} diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs index ff8ce5cb9..bda9b2067 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsMetadata.cs @@ -272,6 +272,10 @@ private void WriteClassPropertiesMetadata(TypeScriptCodeBuilder b, ClassViewMode { WriteClassPropertyMetadata(b, model, prop); } + foreach (var prop in model.FlattenedResponseProperties) + { + WriteFlattenedClassPropertyMetadata(b, prop); + } } } @@ -431,6 +435,23 @@ TypeDiscriminator.Enum or } } + private void WriteFlattenedClassPropertyMetadata(TypeScriptCodeBuilder b, FlattenedResponsePropertyViewModel prop) + { + using (b.Block($"{prop.Name.ToCamelCase()}:", ',')) + { + b.StringProp("name", prop.Name.ToCamelCase()); + b.StringProp("displayName", prop.DisplayName); + + if (!string.IsNullOrWhiteSpace(prop.LeafProperty.Description)) + { + b.StringProp("description", prop.LeafProperty.Description); + } + + WriteTypeCommonMetadata(b, prop.Type, prop.LeafProperty); + b.StringProp("role", "value"); + } + } + private void WriteSummaryPrimaryKeyMetadata(TypeScriptCodeBuilder b, PropertyViewModel prop) { using (b.Block($"{prop.JsVariable}:", ',')) diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs index 394bf767c..6617065ef 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/Generators/Scripts/TsModels.cs @@ -121,6 +121,12 @@ private void WriteModel(TypeScriptCodeBuilder b, ClassViewModel model, HashSet +/// Adds a read-only flattened DTO property for a nested property path. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class DtoFlattenAttribute : Attribute +{ + public DtoFlattenAttribute(string path) + { + Path = path; + } + + /// + /// Dot-delimited property path to flatten, such as "AssignedTo.Name". + /// + public string Path { get; } + + /// + /// Optional generated property name. Defaults to the concatenated path segments. + /// + public string? Name { get; set; } +} diff --git a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs index 1a7546b42..af2c9a95a 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs @@ -217,6 +217,10 @@ and not TypeDiscriminator.Void and not TypeDiscriminator.Unknown ); + private IReadOnlyList? _flattenedResponseProperties; + public IReadOnlyList FlattenedResponseProperties + => _flattenedResponseProperties ??= FlattenedResponsePropertyViewModel.FromClass(this); + /// /// 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 new file mode 100644 index 000000000..5972f006d --- /dev/null +++ b/src/IntelliTect.Coalesce/TypeDefinition/FlattenedResponsePropertyViewModel.cs @@ -0,0 +1,112 @@ +using IntelliTect.Coalesce.DataAnnotations; +using IntelliTect.Coalesce.Utilities; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace IntelliTect.Coalesce.TypeDefinition; + +public sealed class FlattenedResponsePropertyViewModel +{ + private FlattenedResponsePropertyViewModel( + ClassViewModel declaringClass, + DtoFlattenAttribute attribute, + IReadOnlyList pathProperties) + { + DeclaringClass = declaringClass; + Attribute = attribute; + PathProperties = pathProperties; + Name = string.IsNullOrWhiteSpace(attribute.Name) + ? string.Concat(pathProperties.Select(p => p.Name)) + : attribute.Name!; + } + + public ClassViewModel DeclaringClass { get; } + public DtoFlattenAttribute Attribute { get; } + public IReadOnlyList PathProperties { get; } + public string Name { get; } + public string DisplayName => Name.ToProperCase(); + public string Path => Attribute.Path; + public PropertyViewModel RootProperty => PathProperties[0]; + public PropertyViewModel LeafProperty => PathProperties[^1]; + public TypeViewModel Type => LeafProperty.Type; + public string IncludePath => string.Join(".", PathProperties.Take(PathProperties.Count - 1).Select(p => p.Name)); + + public string AccessExpression(string rootExpression) + { + if (PathProperties.Count < 2) + { + throw new InvalidOperationException($"Flattened DTO property '{Name}' must have at least two path segments."); + } + + var expression = rootExpression; + for (var i = 0; i < PathProperties.Count; i++) + { + var separator = i == 0 ? "." : "?."; + expression += separator + PathProperties[i].Name; + } + + return expression; + } + + public static IReadOnlyList FromClass(ClassViewModel model) + { + var flattened = model + .GetAttributes() + .Select(a => Create(model, a)) + .ToList(); + + return new ReadOnlyCollection(flattened); + } + + public static FlattenedResponsePropertyViewModel 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(DtoFlattenAttribute)}] on {model.FullyQualifiedName} must specify a non-empty path."); + } + + var segments = path + .Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (segments.Length < 2) + { + throw new InvalidOperationException($"[{nameof(DtoFlattenAttribute)}] path '{path}' on {model.FullyQualifiedName} must contain at least one navigation and one leaf property."); + } + + var pathProperties = new List(segments.Length); + ClassViewModel current = model; + + for (var i = 0; i < segments.Length; i++) + { + var segment = segments[i]; + var prop = current.PropertyByName(segment) + ?? throw new InvalidOperationException($"[{nameof(DtoFlattenAttribute)}] path '{path}' on {model.FullyQualifiedName} could not resolve segment '{segment}' on {current.FullyQualifiedName}."); + + pathProperties.Add(prop); + + if (i < segments.Length - 1) + { + if (prop.Type.IsCollection) + { + throw new InvalidOperationException($"[{nameof(DtoFlattenAttribute)}] path '{path}' on {model.FullyQualifiedName} cannot traverse collection property '{prop.Name}'."); + } + + current = prop.Type.ClassViewModel + ?? throw new InvalidOperationException($"[{nameof(DtoFlattenAttribute)}] path '{path}' on {model.FullyQualifiedName} cannot traverse scalar property '{prop.Name}'."); + } + } + + var leaf = pathProperties[^1]; + if (leaf.Type.IsCollection || leaf.Type.IsDictionary || leaf.Type.HasClassViewModel) + { + throw new InvalidOperationException($"[{nameof(DtoFlattenAttribute)}] path '{path}' on {model.FullyQualifiedName} must end on a scalar/enum value, not '{leaf.Type.FullyQualifiedName}'."); + } + + return new FlattenedResponsePropertyViewModel(model, new DtoFlattenAttribute(path) { Name = name }, pathProperties); + } +} diff --git a/src/test-targets/api-clients.g.ts b/src/test-targets/api-clients.g.ts index c04b69c78..60c0d5795 100644 --- a/src/test-targets/api-clients.g.ts +++ b/src/test-targets/api-clients.g.ts @@ -592,6 +592,11 @@ export class EnumPkApiClient extends ModelApiClient<$models.EnumPk> { } +export class FluentConfiguredEntityApiClient extends ModelApiClient<$models.FluentConfiguredEntity> { + constructor() { super($metadata.FluentConfiguredEntity) } +} + + export class MultipleParentsApiClient extends ModelApiClient<$models.MultipleParents> { constructor() { super($metadata.MultipleParents) } } @@ -754,6 +759,16 @@ export class RequiredInternalUseModelApiClient extends ModelApiClient<$models.Re } +export class SelfOwnedTenantApiClient extends ModelApiClient<$models.SelfOwnedTenant> { + constructor() { super($metadata.SelfOwnedTenant) } +} + + +export class SelfOwnedTenantConsumerApiClient extends ModelApiClient<$models.SelfOwnedTenantConsumer> { + constructor() { super($metadata.SelfOwnedTenantConsumer) } +} + + export class SiblingApiClient extends ModelApiClient<$models.Sibling> { constructor() { super($metadata.Sibling) } } diff --git a/src/test-targets/metadata.g.ts b/src/test-targets/metadata.g.ts index 94af7a855..76a5a29bf 100644 --- a/src/test-targets/metadata.g.ts +++ b/src/test-targets/metadata.g.ts @@ -826,6 +826,18 @@ export const Case = domain.types.Case = { }, dontSerialize: true, }, + assignedToName: { + name: "assignedToName", + displayName: "Assigned To Name", + type: "string", + role: "value", + }, + reportedByCompanyName: { + name: "reportedByCompanyName", + displayName: "Reported By Company Name", + type: "string", + role: "value", + }, }, methods: { methodWithJsReservedParamName: { @@ -3364,6 +3376,55 @@ export const EnumPk = domain.types.EnumPk = { dataSources: { }, } +export const FluentConfiguredEntity = domain.types.FluentConfiguredEntity = { + name: "FluentConfiguredEntity" as const, + displayName: "Fluent Configured Entity", + get displayProp() { return this.props.name }, + type: "model", + controllerRoute: "FluentConfiguredEntity", + get keyProp() { return this.props.tenantScopedKey }, + behaviorFlags: 7 as BehaviorFlags, + props: { + tenantScopedKey: { + name: "tenantScopedKey", + displayName: "Tenant Scoped Key", + type: "number", + role: "primaryKey", + hidden: 3 as HiddenAreas, + }, + name: { + name: "name", + displayName: "Name", + type: "string", + role: "value", + }, + ownedValue: { + name: "ownedValue", + displayName: "Owned Value", + type: "object", + get typeDef() { return (domain.types.FluentOwnedValueObject as ObjectType & { name: "FluentOwnedValueObject" }) }, + role: "value", + }, + convertedValue: { + name: "convertedValue", + displayName: "Converted Value", + type: "object", + get typeDef() { return (domain.types.FluentConvertedValueObject as ObjectType & { name: "FluentConvertedValueObject" }) }, + role: "value", + }, + complexValue: { + name: "complexValue", + displayName: "Complex Value", + type: "object", + get typeDef() { return (domain.types.FluentComplexValueObject as ObjectType & { name: "FluentComplexValueObject" }) }, + role: "value", + }, + }, + methods: { + }, + dataSources: { + }, +} export const MultipleParents = domain.types.MultipleParents = { name: "MultipleParents" as const, displayName: "Multiple Parents", @@ -4590,6 +4651,78 @@ export const RequiredInternalUseModel = domain.types.RequiredInternalUseModel = dataSources: { }, } +export const SelfOwnedTenant = domain.types.SelfOwnedTenant = { + name: "SelfOwnedTenant" as const, + displayName: "Self Owned Tenant", + get displayProp() { return this.props.id }, + type: "model", + controllerRoute: "SelfOwnedTenant", + get keyProp() { return this.props.id }, + behaviorFlags: 7 as BehaviorFlags, + props: { + id: { + name: "id", + displayName: "Id", + type: "number", + role: "primaryKey", + hidden: 3 as HiddenAreas, + }, + tenantId: { + name: "tenantId", + displayName: "Tenant Id", + type: "number", + role: "value", + }, + ownerTenant: { + name: "ownerTenant", + displayName: "Owner Tenant", + type: "model", + get typeDef() { return (domain.types.SelfOwnedTenant as ModelType & { name: "SelfOwnedTenant" }) }, + role: "value", + dontSerialize: true, + }, + }, + methods: { + }, + dataSources: { + }, +} +export const SelfOwnedTenantConsumer = domain.types.SelfOwnedTenantConsumer = { + name: "SelfOwnedTenantConsumer" as const, + displayName: "Self Owned Tenant Consumer", + get displayProp() { return this.props.id }, + type: "model", + controllerRoute: "SelfOwnedTenantConsumer", + get keyProp() { return this.props.id }, + behaviorFlags: 7 as BehaviorFlags, + props: { + id: { + name: "id", + displayName: "Id", + type: "number", + role: "primaryKey", + hidden: 3 as HiddenAreas, + }, + tenantId: { + name: "tenantId", + displayName: "Tenant Id", + type: "number", + role: "value", + }, + ownerTenant: { + name: "ownerTenant", + displayName: "Owner Tenant", + type: "model", + get typeDef() { return (domain.types.SelfOwnedTenant as ModelType & { name: "SelfOwnedTenant" }) }, + role: "value", + dontSerialize: true, + }, + }, + methods: { + }, + dataSources: { + }, +} export const Sibling = domain.types.Sibling = { name: "Sibling" as const, displayName: "Sibling", @@ -5493,6 +5626,45 @@ export const ExternalTypeWithDtoProp = domain.types.ExternalTypeWithDtoProp = { }, }, } +export const FluentComplexValueObject = domain.types.FluentComplexValueObject = { + name: "FluentComplexValueObject" as const, + displayName: "Fluent Complex Value Object", + type: "object", + props: { + value: { + name: "value", + displayName: "Value", + type: "string", + role: "value", + }, + }, +} +export const FluentConvertedValueObject = domain.types.FluentConvertedValueObject = { + name: "FluentConvertedValueObject" as const, + displayName: "Fluent Converted Value Object", + type: "object", + props: { + value: { + name: "value", + displayName: "Value", + type: "string", + role: "value", + }, + }, +} +export const FluentOwnedValueObject = domain.types.FluentOwnedValueObject = { + name: "FluentOwnedValueObject" as const, + displayName: "Fluent Owned Value Object", + type: "object", + props: { + value: { + name: "value", + displayName: "Value", + type: "string", + role: "value", + }, + }, +} export const InitRecordWithDefaultCtor = domain.types.InitRecordWithDefaultCtor = { name: "InitRecordWithDefaultCtor" as const, displayName: "Init Record With Default Ctor", @@ -6007,6 +6179,10 @@ interface AppDomain extends Domain { ExternalParentAsInputOnly: typeof ExternalParentAsInputOnly ExternalParentAsOutputOnly: typeof ExternalParentAsOutputOnly ExternalTypeWithDtoProp: typeof ExternalTypeWithDtoProp + FluentComplexValueObject: typeof FluentComplexValueObject + FluentConfiguredEntity: typeof FluentConfiguredEntity + FluentConvertedValueObject: typeof FluentConvertedValueObject + FluentOwnedValueObject: typeof FluentOwnedValueObject InitRecordWithDefaultCtor: typeof InitRecordWithDefaultCtor InputOutputOnlyExternalTypeWithRequiredNonscalarProp: typeof InputOutputOnlyExternalTypeWithRequiredNonscalarProp Location: typeof Location @@ -6029,6 +6205,8 @@ interface AppDomain extends Domain { RecursiveHierarchy: typeof RecursiveHierarchy RequiredAndInitModel: typeof RequiredAndInitModel RequiredInternalUseModel: typeof RequiredInternalUseModel + SelfOwnedTenant: typeof SelfOwnedTenant + SelfOwnedTenantConsumer: typeof SelfOwnedTenantConsumer Sibling: typeof Sibling SimpleModelTarget: typeof SimpleModelTarget StandaloneReadonly: typeof StandaloneReadonly diff --git a/src/test-targets/models.g.ts b/src/test-targets/models.g.ts index 70138a70a..54c39c1bc 100644 --- a/src/test-targets/models.g.ts +++ b/src/test-targets/models.g.ts @@ -216,6 +216,13 @@ export interface Case extends Model { attachment: string | null status: Statuses | null caseProducts: CaseProduct[] | null + + /** + Calculated name of the person. eg., Mr. Michael Stokesbary. + A concatenation of Title, FirstName, and LastName. + */ + assignedToName: string | null + reportedByCompanyName: string | null } export class Case { @@ -597,6 +604,34 @@ export class EnumPk { } +export interface FluentConfiguredEntity extends Model { + tenantScopedKey: number | null + name: string | null + ownedValue: FluentOwnedValueObject | null + convertedValue: FluentConvertedValueObject | null + complexValue: FluentComplexValueObject | null +} +export class FluentConfiguredEntity { + + /** Mutates the input object and its descendants into a valid FluentConfiguredEntity implementation. */ + static convert(data?: Partial): FluentConfiguredEntity { + return convertToModel(data || {}, metadata.FluentConfiguredEntity) + } + + /** Maps the input object and its descendants to a new, valid FluentConfiguredEntity implementation. */ + static map(data?: Partial): FluentConfiguredEntity { + return mapToModel(data || {}, metadata.FluentConfiguredEntity) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.FluentConfiguredEntity; } + + /** Instantiate a new FluentConfiguredEntity, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, FluentConfiguredEntity.map(data || {})); + } +} + + export interface MultipleParents extends Model { id: number | null parent1Id: number | null @@ -1063,6 +1098,58 @@ export class RequiredInternalUseModel { } +export interface SelfOwnedTenant extends Model { + id: number | null + tenantId: number | null + ownerTenant: SelfOwnedTenant | null +} +export class SelfOwnedTenant { + + /** Mutates the input object and its descendants into a valid SelfOwnedTenant implementation. */ + static convert(data?: Partial): SelfOwnedTenant { + return convertToModel(data || {}, metadata.SelfOwnedTenant) + } + + /** Maps the input object and its descendants to a new, valid SelfOwnedTenant implementation. */ + static map(data?: Partial): SelfOwnedTenant { + return mapToModel(data || {}, metadata.SelfOwnedTenant) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.SelfOwnedTenant; } + + /** Instantiate a new SelfOwnedTenant, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, SelfOwnedTenant.map(data || {})); + } +} + + +export interface SelfOwnedTenantConsumer extends Model { + id: number | null + tenantId: number | null + ownerTenant: SelfOwnedTenant | null +} +export class SelfOwnedTenantConsumer { + + /** Mutates the input object and its descendants into a valid SelfOwnedTenantConsumer implementation. */ + static convert(data?: Partial): SelfOwnedTenantConsumer { + return convertToModel(data || {}, metadata.SelfOwnedTenantConsumer) + } + + /** Maps the input object and its descendants to a new, valid SelfOwnedTenantConsumer implementation. */ + static map(data?: Partial): SelfOwnedTenantConsumer { + return mapToModel(data || {}, metadata.SelfOwnedTenantConsumer) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.SelfOwnedTenantConsumer; } + + /** Instantiate a new SelfOwnedTenantConsumer, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, SelfOwnedTenantConsumer.map(data || {})); + } +} + + export interface Sibling extends Model { siblingId: number | null personId: number | null @@ -1445,6 +1532,78 @@ export class ExternalTypeWithDtoProp { } +export interface FluentComplexValueObject extends Model { + value: string | null +} +export class FluentComplexValueObject { + + /** Mutates the input object and its descendants into a valid FluentComplexValueObject implementation. */ + static convert(data?: Partial): FluentComplexValueObject { + return convertToModel(data || {}, metadata.FluentComplexValueObject) + } + + /** Maps the input object and its descendants to a new, valid FluentComplexValueObject implementation. */ + static map(data?: Partial): FluentComplexValueObject { + return mapToModel(data || {}, metadata.FluentComplexValueObject) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.FluentComplexValueObject; } + + /** Instantiate a new FluentComplexValueObject, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, FluentComplexValueObject.map(data || {})); + } +} + + +export interface FluentConvertedValueObject extends Model { + value: string | null +} +export class FluentConvertedValueObject { + + /** Mutates the input object and its descendants into a valid FluentConvertedValueObject implementation. */ + static convert(data?: Partial): FluentConvertedValueObject { + return convertToModel(data || {}, metadata.FluentConvertedValueObject) + } + + /** Maps the input object and its descendants to a new, valid FluentConvertedValueObject implementation. */ + static map(data?: Partial): FluentConvertedValueObject { + return mapToModel(data || {}, metadata.FluentConvertedValueObject) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.FluentConvertedValueObject; } + + /** Instantiate a new FluentConvertedValueObject, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, FluentConvertedValueObject.map(data || {})); + } +} + + +export interface FluentOwnedValueObject extends Model { + value: string | null +} +export class FluentOwnedValueObject { + + /** Mutates the input object and its descendants into a valid FluentOwnedValueObject implementation. */ + static convert(data?: Partial): FluentOwnedValueObject { + return convertToModel(data || {}, metadata.FluentOwnedValueObject) + } + + /** Maps the input object and its descendants to a new, valid FluentOwnedValueObject implementation. */ + static map(data?: Partial): FluentOwnedValueObject { + return mapToModel(data || {}, metadata.FluentOwnedValueObject) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.FluentOwnedValueObject; } + + /** Instantiate a new FluentOwnedValueObject, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, FluentOwnedValueObject.map(data || {})); + } +} + + export interface InitRecordWithDefaultCtor extends Model { string: string | null num: number | null @@ -1917,6 +2076,10 @@ declare module "coalesce-vue/lib/model" { ExternalParentAsInputOnly: ExternalParentAsInputOnly ExternalParentAsOutputOnly: ExternalParentAsOutputOnly ExternalTypeWithDtoProp: ExternalTypeWithDtoProp + FluentComplexValueObject: FluentComplexValueObject + FluentConfiguredEntity: FluentConfiguredEntity + FluentConvertedValueObject: FluentConvertedValueObject + FluentOwnedValueObject: FluentOwnedValueObject InitRecordWithDefaultCtor: InitRecordWithDefaultCtor InputOutputOnlyExternalTypeWithRequiredNonscalarProp: InputOutputOnlyExternalTypeWithRequiredNonscalarProp Location: Location @@ -1939,6 +2102,8 @@ declare module "coalesce-vue/lib/model" { RecursiveHierarchy: RecursiveHierarchy RequiredAndInitModel: RequiredAndInitModel RequiredInternalUseModel: RequiredInternalUseModel + SelfOwnedTenant: SelfOwnedTenant + SelfOwnedTenantConsumer: SelfOwnedTenantConsumer Sibling: Sibling SimpleModelTarget: SimpleModelTarget StandaloneReadonly: StandaloneReadonly diff --git a/src/test-targets/viewmodels.g.ts b/src/test-targets/viewmodels.g.ts index 1cf1e9824..e5843eb8c 100644 --- a/src/test-targets/viewmodels.g.ts +++ b/src/test-targets/viewmodels.g.ts @@ -1050,6 +1050,29 @@ export class EnumPkListViewModel extends ListViewModel<$models.EnumPk, $apiClien } +export interface FluentConfiguredEntityViewModel extends $models.FluentConfiguredEntity { + tenantScopedKey: number | null; + name: string | null; + ownedValue: $models.FluentOwnedValueObject | null; + convertedValue: $models.FluentConvertedValueObject | null; + complexValue: $models.FluentComplexValueObject | null; +} +export class FluentConfiguredEntityViewModel extends ViewModel<$models.FluentConfiguredEntity, $apiClients.FluentConfiguredEntityApiClient, number> implements $models.FluentConfiguredEntity { + + constructor(initialData?: DeepPartial<$models.FluentConfiguredEntity> | null) { + super($metadata.FluentConfiguredEntity, new $apiClients.FluentConfiguredEntityApiClient(), initialData) + } +} +defineProps(FluentConfiguredEntityViewModel, $metadata.FluentConfiguredEntity) + +export class FluentConfiguredEntityListViewModel extends ListViewModel<$models.FluentConfiguredEntity, $apiClients.FluentConfiguredEntityApiClient, FluentConfiguredEntityViewModel> { + + constructor() { + super($metadata.FluentConfiguredEntity, new $apiClients.FluentConfiguredEntityApiClient()) + } +} + + export interface MultipleParentsViewModel extends $models.MultipleParents { id: number | null; parent1Id: number | null; @@ -1556,6 +1579,50 @@ export class RequiredInternalUseModelListViewModel extends ListViewModel<$models } +export interface SelfOwnedTenantViewModel extends $models.SelfOwnedTenant { + id: number | null; + tenantId: number | null; + get ownerTenant(): SelfOwnedTenantViewModel | null; + set ownerTenant(value: SelfOwnedTenantViewModel | $models.SelfOwnedTenant | null); +} +export class SelfOwnedTenantViewModel extends ViewModel<$models.SelfOwnedTenant, $apiClients.SelfOwnedTenantApiClient, number> implements $models.SelfOwnedTenant { + + constructor(initialData?: DeepPartial<$models.SelfOwnedTenant> | null) { + super($metadata.SelfOwnedTenant, new $apiClients.SelfOwnedTenantApiClient(), initialData) + } +} +defineProps(SelfOwnedTenantViewModel, $metadata.SelfOwnedTenant) + +export class SelfOwnedTenantListViewModel extends ListViewModel<$models.SelfOwnedTenant, $apiClients.SelfOwnedTenantApiClient, SelfOwnedTenantViewModel> { + + constructor() { + super($metadata.SelfOwnedTenant, new $apiClients.SelfOwnedTenantApiClient()) + } +} + + +export interface SelfOwnedTenantConsumerViewModel extends $models.SelfOwnedTenantConsumer { + id: number | null; + tenantId: number | null; + get ownerTenant(): SelfOwnedTenantViewModel | null; + set ownerTenant(value: SelfOwnedTenantViewModel | $models.SelfOwnedTenant | null); +} +export class SelfOwnedTenantConsumerViewModel extends ViewModel<$models.SelfOwnedTenantConsumer, $apiClients.SelfOwnedTenantConsumerApiClient, number> implements $models.SelfOwnedTenantConsumer { + + constructor(initialData?: DeepPartial<$models.SelfOwnedTenantConsumer> | null) { + super($metadata.SelfOwnedTenantConsumer, new $apiClients.SelfOwnedTenantConsumerApiClient(), initialData) + } +} +defineProps(SelfOwnedTenantConsumerViewModel, $metadata.SelfOwnedTenantConsumer) + +export class SelfOwnedTenantConsumerListViewModel extends ListViewModel<$models.SelfOwnedTenantConsumer, $apiClients.SelfOwnedTenantConsumerApiClient, SelfOwnedTenantConsumerViewModel> { + + constructor() { + super($metadata.SelfOwnedTenantConsumer, new $apiClients.SelfOwnedTenantConsumerApiClient()) + } +} + + export interface SiblingViewModel extends $models.Sibling { siblingId: number | null; personId: number | null; @@ -1848,6 +1915,7 @@ const viewModelTypeLookup = ViewModel.typeLookup = { DateTimeOffsetPk: DateTimeOffsetPkViewModel, DateTimePk: DateTimePkViewModel, EnumPk: EnumPkViewModel, + FluentConfiguredEntity: FluentConfiguredEntityViewModel, MultipleParents: MultipleParentsViewModel, OneToOneManyChildren: OneToOneManyChildrenViewModel, OneToOneParent: OneToOneParentViewModel, @@ -1862,6 +1930,8 @@ const viewModelTypeLookup = ViewModel.typeLookup = { RecursiveHierarchy: RecursiveHierarchyViewModel, RequiredAndInitModel: RequiredAndInitModelViewModel, RequiredInternalUseModel: RequiredInternalUseModelViewModel, + SelfOwnedTenant: SelfOwnedTenantViewModel, + SelfOwnedTenantConsumer: SelfOwnedTenantConsumerViewModel, Sibling: SiblingViewModel, StandaloneReadonly: StandaloneReadonlyViewModel, StandaloneReadWrite: StandaloneReadWriteViewModel, @@ -1889,6 +1959,7 @@ const listViewModelTypeLookup = ListViewModel.typeLookup = { DateTimeOffsetPk: DateTimeOffsetPkListViewModel, DateTimePk: DateTimePkListViewModel, EnumPk: EnumPkListViewModel, + FluentConfiguredEntity: FluentConfiguredEntityListViewModel, MultipleParents: MultipleParentsListViewModel, OneToOneManyChildren: OneToOneManyChildrenListViewModel, OneToOneParent: OneToOneParentListViewModel, @@ -1903,6 +1974,8 @@ const listViewModelTypeLookup = ListViewModel.typeLookup = { RecursiveHierarchy: RecursiveHierarchyListViewModel, RequiredAndInitModel: RequiredAndInitModelListViewModel, RequiredInternalUseModel: RequiredInternalUseModelListViewModel, + SelfOwnedTenant: SelfOwnedTenantListViewModel, + SelfOwnedTenantConsumer: SelfOwnedTenantConsumerListViewModel, Sibling: SiblingListViewModel, StandaloneReadonly: StandaloneReadonlyListViewModel, StandaloneReadWrite: StandaloneReadWriteListViewModel, From 5f959190171b12046bf77f8e584896b49288b796 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Mon, 11 May 2026 13:29:58 -0500 Subject: [PATCH 2/2] docs: document flattened dto path properties Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/topics/dto-shapes-and-read-models.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/topics/dto-shapes-and-read-models.md b/docs/topics/dto-shapes-and-read-models.md index 872e0c983..700d9fecc 100644 --- a/docs/topics/dto-shapes-and-read-models.md +++ b/docs/topics/dto-shapes-and-read-models.md @@ -27,3 +27,9 @@ That lets the higher-level DTO and read-shape features work against richer EF mo Navigation summary DTOs add a smaller, purpose-built way to surface related model information in generated read APIs without always expanding full related entities. Those summary shapes are the foundation for later flattened-property and auto-projected read-shape features higher in the stack. + +## Flattened navigation-path properties can be emitted directly + +Coalesce can now generate flattened DTO properties from selected navigation paths so callers can bind to the data they actually need without manually creating one-off view types for every combination. + +This keeps generated read models compact while still making important related values available as first-class DTO members.