Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

EF Core: Owned types support #500

Merged
merged 19 commits into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8d6e800
Treat Owned types as Complex Objects
Daniel-Svensson Apr 22, 2024
549c964
delete old file
Daniel-Svensson Apr 22, 2024
994c2c4
Setup initial test for compelx types
Daniel-Svensson Apr 23, 2024
dbff7b2
cleanup OnModelCreating
Daniel-Svensson Apr 23, 2024
3b06161
Simplify conditional NETSTANDARD compilation
Daniel-Svensson Apr 24, 2024
1786fd6
Add OwnedTypes Scenario to NS2 and add codegen tests
Daniel-Svensson Apr 24, 2024
330dd81
be eexplicit about nestandard reference
Daniel-Svensson Apr 24, 2024
dd3e854
disable codegen test för NET472 since output is different
Daniel-Svensson Apr 24, 2024
6e789d9
Ensure AssociationAttribute name is unique if there are multiple
Daniel-Svensson Apr 24, 2024
2aecd25
remove special case of not generating Key attribute on owned entities
Daniel-Svensson Apr 24, 2024
b16cffb
Fix "updatebaselines.bat" file generation
Daniel-Svensson Apr 24, 2024
f0a808f
move blocking of "System.Runtime.CompilerServices" to CustomAttribute…
Daniel-Svensson Apr 24, 2024
8143df9
Add more (all?) variants of Owned entities
Daniel-Svensson Apr 24, 2024
ccd5d8d
Add readme file
Daniel-Svensson Jun 3, 2024
89bc591
make CreateAssociationAttribute static
Daniel-Svensson Jun 3, 2024
a47714a
Add composition to owned entities with explicit key
Daniel-Svensson Jun 3, 2024
007b061
Update version and changelog
Daniel-Svensson Jun 3, 2024
0d195f3
minor readme update
Daniel-Svensson Jun 3, 2024
14042dd
Update src/OpenRiaServices.Server.EntityFrameworkCore/Framework/READM…
Daniel-Svensson Jun 5, 2024
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
17 changes: 17 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
# 5.4.4 / EF Core 3.1.0

### EF Core 3.1.0
* Initial support for Owned Entities for one-to-one navigation properties (#500)
* Owned entities without explicit keys are mapped to OpenRiaServices's [Complex Types]
* Owned entities with explicit keys are generated as normal *"Entities"* but are automatically annotated with `[Composition]`
* EFCore "Complex Types" introduced in EFCore 8.0 does not have any special handling
* Add new helper method `AttachAsModified<TEntity>(TEntity entity)` (#506)
* It works similar to the existing `AttachAsModified` extension methods on `DbSet` but
* is smarter (works with and without original entity)
* reduces code that needs to be written and works both with and without "OriginalEntity" (`RoundTripAttribute`)
* Add package README to `OpenRiaServices.Server.EntityFrameworkCore`

### Code generation
* Log whole Exceptions in DomainServiceCatalog instead of just message (#502), for better error messages on code generation failure
* Call "dotnet CodeGenTask.dll" instead of "CodeGenTask.exe" #503

# EF Core 3.0.0

* Target EF Core 8
Expand Down
39 changes: 0 additions & 39 deletions src/FeaturePackage.targets

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ public override ICustomTypeDescriptor GetTypeDescriptor(Type objectType, ICustom
td = new EFCoreTypeDescriptor(_typeDescriptionContext, entityType, parent);
}
#else
// TODO: Determine if we can handle Complex Types
// - it must "look the same" for all usages (be configured the same in all places to make sense)
// - Should they instead be treated as different types per usage (on the client)
if (model != null && model.FindEntityType(objectType.FullName) is IReadOnlyEntityType entityType)
{
td = new EFCoreTypeDescriptor(_typeDescriptionContext, entityType, parent);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
using Microsoft.EntityFrameworkCore;
using System.Diagnostics;

#if NETSTANDARD
using IReadOnlyNavigation = Microsoft.EntityFrameworkCore.Metadata.INavigation;
using IReadOnlyEntityType = Microsoft.EntityFrameworkCore.Metadata.IEntityType;
using IReadOnlyProperty = Microsoft.EntityFrameworkCore.Metadata.IProperty;
#endif

namespace OpenRiaServices.Server.EntityFrameworkCore
{
/// <summary>
Expand Down Expand Up @@ -45,23 +51,21 @@
}

// Verify that full name is not null since Model.FindEntityType throws argument exception if full name is null
#if NETSTANDARD2_0
public IEntityType GetEntityType(Type type) => type?.FullName != null ? Model.FindEntityType(type) : null;
#else
public IReadOnlyEntityType GetEntityType(Type type) => type?.FullName != null ? ((IReadOnlyModel)Model).FindEntityType(type) : null;
#endif
public IReadOnlyEntityType GetEntityType(Type type) => type?.FullName != null ? Model.FindEntityType(type) : null;

/// <summary>
/// Creates an AssociationAttribute for the specified navigation property
/// </summary>
/// <param name="navigationProperty">The navigation property that corresponds to the association (it identifies the end points)</param>
/// <returns>A new AssociationAttribute that describes the given navigation property association</returns>
internal AssociationAttribute CreateAssociationAttribute(INavigation navigationProperty)
internal static AssociationAttribute CreateAssociationAttribute(IReadOnlyNavigation navigationProperty)
{
var fk = navigationProperty.ForeignKey;

string thisKey;
string otherKey;
string name = fk.GetConstraintName();

#if NETSTANDARD2_0
if (navigationProperty.IsDependentToPrincipal())
#else
Expand All @@ -77,17 +81,26 @@

thisKey = FormatMemberList(fk.PrincipalKey.Properties);
otherKey = FormatMemberList(fk.Properties);
Debug.Assert(fk.IsOwnership == fk.DeclaringEntityType.IsOwned());

// In case there are multiple navigation properties to Owned entities
// and they have explicity defined keys (they mirror the owners key) then
// they will have the same foreign key name and we have to make them unique
if (fk.DeclaringEntityType.IsOwned())
{
name += "|owns:" + navigationProperty.Name;
}
}

var assocAttrib = new AssociationAttribute(fk.GetConstraintName(), thisKey, otherKey);
var assocAttrib = new AssociationAttribute(name, thisKey, otherKey);
assocAttrib.IsForeignKey = IsForeignKey(navigationProperty);
return assocAttrib;
}

#if NETSTANDARD2_0
private static bool IsForeignKey(INavigation navigationProperty) => navigationProperty.IsDependentToPrincipal();
private static bool IsForeignKey(IReadOnlyNavigation navigationProperty) => navigationProperty.IsDependentToPrincipal();
Fixed Show fixed Hide fixed
#else
private static bool IsForeignKey(INavigation navigationProperty) => navigationProperty.IsOnDependent;
private static bool IsForeignKey(IReadOnlyNavigation navigationProperty) => navigationProperty.IsOnDependent;
#endif


Expand All @@ -96,7 +109,7 @@
/// </summary>
/// <param name="members">A collection of members.</param>
/// <returns>A comma delimited list of member names.</returns>
protected static string FormatMemberList(IEnumerable<IProperty> members)
protected static string FormatMemberList(IEnumerable<IReadOnlyProperty> members)
{
string memberList = string.Empty;
foreach (var prop in members)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,10 @@

bool hasKeyAttribute = pd.Attributes[typeof(KeyAttribute)] != null;
var property = _entityType.FindProperty(pd.Name);
// TODO: Review all usage of isEntity to validate if we should really copy logic from EF6
bool isEntity = !_entityType.IsOwned();

if (property != null)
{
if (isEntity && property.IsPrimaryKey() && !hasKeyAttribute)
if (property.IsPrimaryKey() && !hasKeyAttribute)
Fixed Show fixed Hide fixed
{
attributes.Add(new KeyAttribute());
hasKeyAttribute = true;
Expand Down Expand Up @@ -192,7 +190,7 @@
bool isStringType = pd.PropertyType == typeof(string) || pd.PropertyType == typeof(char[]);
if (isStringType &&
pd.Attributes[typeof(StringLengthAttribute)] == null &&
property.GetMaxLength() is int maxLength)
property.GetMaxLength() is int maxLength)
{
attributes.Add(new StringLengthAttribute(maxLength));
}
Expand Down Expand Up @@ -228,33 +226,39 @@
attributes.Add(new RoundtripOriginalAttribute());
}
}
}

// Add the Editable attribute if required
if (editableAttribute != null && pd.Attributes[typeof(EditableAttribute)] == null)
{
attributes.Add(editableAttribute);
// Add the Editable attribute if required
if (editableAttribute != null && pd.Attributes[typeof(EditableAttribute)] == null)
{
attributes.Add(editableAttribute);
}
}

// Add AssociationAttribute if required for the specified property
if (isEntity
&& _entityType.FindNavigation(pd.Name) is INavigation navigation)
if (_entityType.FindNavigation(pd.Name) is { } navigation)
{
#if NETSTANDARD2_0
bool isManyToMany = navigation.IsCollection() && navigation.FindInverse()?.IsCollection() == true;
bool addAssociationAttribute = !isManyToMany;
#else
bool isManyToMany = navigation.IsCollection && navigation.Inverse?.IsCollection == true;
bool addAssociationAttribute = !isManyToMany
// Don't generate association attributes for Owned types (onless they have all FK fields explictly defined, in which case they can be treated as Entities)
// if we generate association attributes then it cannot be treated as a ComplexObject
&& !(navigation.ForeignKey.Properties.Any(static p => p.IsShadowProperty()));
#endif
if (!isManyToMany)

if (addAssociationAttribute)
{
var assocAttrib = (AssociationAttribute)pd.Attributes[typeof(AssociationAttribute)];
if (assocAttrib == null)
if (pd.Attributes[typeof(AssociationAttribute)] is null)
attributes.Add(EFCoreTypeDescriptionContext.CreateAssociationAttribute(navigation));
#if NET
if (navigation.TargetEntityType.IsOwned() && pd.Attributes[typeof(CompositionAttribute)] is null)
{
assocAttrib = TypeDescriptionContext.CreateAssociationAttribute(navigation);
attributes.Add(assocAttrib);
attributes.Add(new CompositionAttribute());
}
#endif
}

Check notice

Code scanning / CodeQL

Nested 'if' statements can be combined Note

These 'if' statements can be combined.

}

return attributes.ToArray();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
<TargetFrameworks>net8.0;net6.0;netstandard2.0</TargetFrameworks>
<DefineConstants>$(DefineConstants);SERVERFX;EFCORE</DefineConstants>
<GeneratePackageOnBuild Condition="'$(Configuration)'=='Release'">true</GeneratePackageOnBuild>
<VersionPrefix>3.0.0</VersionPrefix>
<VersionPrefix>3.1.0</VersionPrefix>
<AssemblyVersion>3.0.0</AssemblyVersion>
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
<PackageLicenseExpression></PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
</PropertyGroup>
<ItemGroup>
Expand Down Expand Up @@ -41,6 +42,7 @@
</ItemGroup>
<ItemGroup>
<None Include="LICENSE.md" Pack="true" PackagePath="\" />
<Content Include="README.md" Pack="true" PackagePath="" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="DbResource.resx">
Expand Down
79 changes: 79 additions & 0 deletions src/OpenRiaServices.Server.EntityFrameworkCore/Framework/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
[![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://vshymanskyy.github.io/StandWithUkraine)


## TERM OF USE

By using this project or its source code, for any purpose and in any shape or form, you grant your **agreement** to all the following statements:

- You **condemn Russia and its military aggression against Ukraine**
- You **recognize that Russia is an occupant that unlawfully invaded a sovereign state**
- You **support Ukraine's territorial integrity, including its claims over temporarily occupied territories of Crimea and Donbas**
- You **do not support the Russian invasion or contribute to its propaganda'**

This excludes usage by the Russian state, Russian state-owned companies, Russian education who spread propaganda instead of truth, anyone who work with the *filtration camps*, or finance the war by importing Russian oil or gas.

- You allow anonymized telemetry to collected and sent during the preview releases to gather feedback about usage



## Getting Started

1. Ensure you have setup and configured `OpenRiaServices.Hosting.AspNetCore`
2. Add a reference to *OpenRiaServices.Server.EntityFrameworkCore*
`dotnet add package OpenRiaServices.Server.EntityFrameworkCore`
3. Add one or more domainservices.
Given that you have a EfCore model named `MyDbContext` with an entity `MyEntity` you add CRUD methods using something similar to below

```csharp
using using Microsoft.EntityFrameworkCore;
using OpenRiaServices.Server.EntityFrameworkCore;

[EnableClientAccess]
public class MyDomainService : DbDomainService<MyDbContext>
{
/* Example of CUD methods */
Daniel-Svensson marked this conversation as resolved.
Show resolved Hide resolved
public void InsertMyEntity(MyEntity entity)
=> DbContext.Entry(entity).State = EntityState.Added; // Or DbContext.Add, but it might add related entities differently

public void UpdateMyEntity(MyEntity entity)
=> AttachAsModified(entity); // This sets state to Modified and set modified status on individual properties based on client changes and if `RountTripOriginal` attribute is specified or not
Daniel-Svensson marked this conversation as resolved.
Show resolved Hide resolved

public void DeleteMyEntity(MyEntity entity)
=> DbContext.Entry(entity).State = EntityState.Deleted;

/* Query:
* Return IQueryable<> to automatically allow the client to add filtering, paging etc.
* The queries are performed async for best performance
*/
[Query]
IQueryable<MyEntity> GetMyEntities()
=> DbContext.MyEntities.OrderBy(x => x.Id); // Sort by id to get stable Skip/Take if client does paging

[Query]
async Task<MyEntity> GetMyEntityById(int id) // CancellationToken can be added as parameter instead for easier testing
=> await DbContext.MyEntities.FindAsync(x.Id, ServiceContext.CancellationToken);
}
```
4. Ensure that `MyDomainService` is mapped, (See [`OpenRiaServices.Hosting.AspNetCore` readme](https://www.nuget.org/packages/OpenRiaServices.Hosting.AspNetCore))

## Owned Entities

* *one-to-one* relations using Owned Entities are fully supported
* Any ef core configuration/metadata applied to the owned entity (such as Required, MaxLength etc) are part of generated client Code
* *one-to-many* relations might work, but it has not been verified at the moment

### Owned Entities without explicit key
EF Core owned entities are mapped to OpenRiaServices's [Complex Types](https://openriaservices.gitbook.io/openriaservices/ee707348/ee707356/gg602753) as long as the owned entity does not have have an explicit key.


### Owned Entities with explicit key
If an explicit key to an owned entity then they are mapped as a normal entity and the navigation property to the owned entity adds `[Composition]`

This makes the owned entity available during Insert, Update and Delete operations and prevents if from accidentaly having all fields set to `null`.

## Complex Types

The *Complex Types* introduced in EF Core 8 is partially supported with some limitations.

1. The types are mapped to to OpenRiaServices's [Complex Types]
2. Any ef core configuration/metadata applied to the ComplexType (as part of fluent configuration) **IS NOT** discovered.The `DbDomainServiceDescriptionProvider` and `DbDomainService` classes does not have any special handling of *Complex Types*. * Attributes on the types are discovered as expected using the normal built in reflection based attribute discovery
Expand Down
Loading
Loading