Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export default defineConfig({

autoTitle("/topics/analyzers"),
autoTitle("/topics/coalesce-json"),
autoTitle("/topics/dto-shapes-and-read-models"),
autoTitle("/topics/eslint-plugin"),
autoTitle("/topics/immutability"),
autoTitle("/topics/startup"),
Expand Down
11 changes: 11 additions & 0 deletions docs/topics/dto-shapes-and-read-models.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# DTO Shapes and Read Models

Coalesce now supports a broader set of DTO and read-shape generation scenarios than the original "entity plus CRUD DTOs" flow.

This page tracks the behavior that matters when your models, projections, and generated contracts become more complex.

## Metadata discovery handles self-referential relationships

Model discovery now avoids recursing forever through self-referential foreign keys.

That matters for DTO generation because it lets Coalesce analyze real-world graphs safely before later read-shape and generated-contract features run on top of the discovered model.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#nullable enable

namespace IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext;

public class SelfOwnedTenant
{
public int Id { get; set; }

public int? TenantId { get; set; }
public SelfOwnedTenant? OwnerTenant { get; set; }
}

public class SelfOwnedTenantConsumer
{
public int Id { get; set; }

public int? TenantId { get; set; }
public SelfOwnedTenant? OwnerTenant { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public class AppDbContext : DbContext
public DbSet<StringIdentity> StringIdentities { get; set; }

public DbSet<RecursiveHierarchy> RecursiveHierarchies { get; set; }
public DbSet<SelfOwnedTenant> SelfOwnedTenants { get; set; }
public DbSet<SelfOwnedTenantConsumer> SelfOwnedTenantConsumers { get; set; }

[InternalUse]
public DbSet<DbSetIsInternalUse> Internals { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -426,4 +426,24 @@ public async Task CollectionNavigation_HasCorrectFkWhenPrincipalAlsoParticipates
await Assert.That(prop.ForeignKeyProperty.Name).IsEqualTo(nameof(OneToOneManyChildren.OneToOneParentId));
}

[Test]
[PropertyViewModelData<SelfOwnedTenant>(nameof(SelfOwnedTenant.OwnerTenant))]
public async Task ReferenceNavigation_SelfReferentialNonConventionalName_DoesNotRecurse(PropertyViewModelData data)
{
PropertyViewModel prop = data;

await Assert.That(prop.ForeignKeyProperty).IsNull();
await Assert.That(prop.Role).IsEqualTo(PropertyRole.Value);
}

[Test]
[PropertyViewModelData<SelfOwnedTenantConsumer>(nameof(SelfOwnedTenantConsumer.OwnerTenant))]
public async Task ReferenceNavigation_TargetWithSelfReferenceAndNonConventionalName_DoesNotRecurse(PropertyViewModelData data)
{
PropertyViewModel prop = data;

await Assert.That(prop.ForeignKeyProperty).IsNull();
await Assert.That(prop.Role).IsEqualTo(PropertyRole.Value);
}

}
79 changes: 63 additions & 16 deletions src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,58 @@ public DatabaseGeneratedOption DatabaseGenerated
}


private string? GetDirectForeignKeyPropertyName()
{
var name =
// Use the foreign key attribute
this.GetAttributeValue<ForeignKeyAttribute>(a => a.Name)

// Use the ForeignKey Attribute on the key property if it is there.
?? EffectiveParent.Properties.SingleOrDefault(p => Name == p.GetAttributeValue<ForeignKeyAttribute>(a => a.Name))?.Name

// Look for a property that follows convention.
?? Name + ConventionalIdSuffix;

if (EffectiveParent.PropertyByName(name) is not null)
{
return name;
}

if (Object?.Name is { } objectName)
{
if (Name.Length > objectName.Length && Name.EndsWith(objectName, StringComparison.Ordinal))
{
var strippedName = Name[..^objectName.Length];
if (EffectiveParent.PropertyByName(strippedName) is not null)
{
return strippedName;
}
}

var typeNameConvention = objectName + ConventionalIdSuffix;
if (EffectiveParent.PropertyByName(typeNameConvention) is not null)
{
return typeNameConvention;
}
}

return null;
}

private PropertyViewModel? GetDirectForeignKeyProperty()
{
var prop = GetDirectForeignKeyPropertyName() is { } name
? EffectiveParent.PropertyByName(name)
: null;

if (prop == null || !prop.Type.IsValidKeyType || !prop.IsDbMapped)
{
return null;
}

return prop;
}

private LazyValue<PropertyViewModel?> _ForeignKeyProperty;
/// <summary>
/// If this is a navigation property, returns the property that holds the foreign key.
Expand Down Expand Up @@ -508,18 +560,7 @@ public DatabaseGeneratedOption DatabaseGenerated
else if (Type.IsPOCO)
{
// `this` may be a reference navigation prop

var name =
// Use the foreign key attribute
this.GetAttributeValue<ForeignKeyAttribute>(a => a.Name)

// Use the ForeignKey Attribute on the key property if it is there.
?? EffectiveParent.Properties.SingleOrDefault(p => Name == p.GetAttributeValue<ForeignKeyAttribute>(a => a.Name))?.Name

// Look for a property that follows convention.
?? Name + ConventionalIdSuffix;

prop = EffectiveParent.PropertyByName(name);
prop = GetDirectForeignKeyProperty();

if (prop == null)
{
Expand All @@ -539,7 +580,7 @@ public DatabaseGeneratedOption DatabaseGenerated
// This is what allows us to think of our own PK as also being a FK
// into the other type (even if that's the exact opposite of the relationship in practice).
// This is admittedly a bit of a hack.
InverseProperty.ForeignKeyProperty?.IsPrimaryKey == true
InverseProperty.GetDirectForeignKeyProperty()?.IsPrimaryKey == true
)
{
return EffectiveParent.PrimaryKey;
Expand All @@ -550,8 +591,9 @@ public DatabaseGeneratedOption DatabaseGenerated
// Handle the similar scenario as above, but when InversePropertyAttribute
// is not present. See "OneToOneParent"/"SharedKeyChild1".
if (Object!.ClientProperties.Any(p =>
p.ForeignKeyProperty == Object.PrimaryKey &&
p.Type == this.EffectiveParent.Type
p != this &&
p.Type == this.EffectiveParent.Type &&
p.GetDirectForeignKeyProperty() == Object.PrimaryKey
))
{
return EffectiveParent.PrimaryKey;
Expand Down Expand Up @@ -598,7 +640,12 @@ public PropertyViewModel? ReferenceNavigationProperty
var prop = EffectiveParent.PropertyByName(name);
if (prop == null || !prop.IsPOCO || !prop.IsDbMapped)
{
return null;
return EffectiveParent.Properties.SingleOrDefault(p =>
p != this &&
p.IsPOCO &&
p.IsDbMapped &&
p.GetDirectForeignKeyProperty() == this
);
}

return prop;
Expand Down