EF Core非空导航属性 – 最佳实践

huangapple go评论62阅读模式
英文:

EF Core non nullable navigation properties - Best practice

问题

In my database model, I have a navigation property that cannot be null. However, since the variable may not have been loaded yet, it can be null during runtime.

根据Microsoft文档,可以通过在getter中抛出异常来解决这个问题。但是,所有在getter中抛出的异常都不会被全局异常处理程序捕获。在异常被抛出后,代码立即停止执行。

根据Microsoft的建议,应避免在getter中抛出异常。此外,这个Stackoverflow线程也建议不要在getter中使用异常。

另一种方法是将属性也设置为可空。但是,如果这样做,我必须在每个单独的函数中检查属性是否为空,这似乎不太符合DRY原则。

因为我们有一个庞大的代码库,这可能会变得非常混乱。特别是在链式调用中,比如:

return Car.Brand.Adress.Street

我是否遗漏了什么?在这种情况下,什么被认为是最佳实践?

英文:

In my database model I have a navigation property which cannot be null. However, since the variable may not have been loaded yet, it can be null during runtime.

public class Car {

    private Brand? _brand = null
    public Brand Brand {
        set => _brand = value;
        get => _brand ?? throw new InvalidOperationException("Uninitialized property: " + nameof(Brand ));
    }

    public string GetBrandLocation(){
        return this.Brand.Location;
    }

}

According to Microsoft documentation, this problem can be solved by throwing an exception in the getter. But all Exceptions that are thrown in the Getters are not catched by a global Exception handler. The execution of the code immediately stopps, after the exception has been thrown.

According to the Microsoft recommondations, throwing exceptions in Getters should be avoided. Also this Stackoverflow thread, advises against using Exceptions in Getters.

Another method would be, that I make the property also nullable. But if I do so, I have to check in each single function, if the property is null, which seems not to be very DRY.

public class Car {

    private Brand? _brand = null
    public Brand? Brand {
        set => _brand = value;
        get => _brand;
    }

    public string GetBrandLocation(){

        if(this.Brand == null){
            throw new InvalidOperationException("Uninitialized property: " + nameof(Brand ));
        }

        return this.Brand.Location;
        
    }

}

Because we have a big codebase, this can get really messy. Especially with chained calls like:

return Car.Brand.Adress.Street

Do I miss something? What is considered best practice in this use case?

答案1

得分: 1

如果通常尝试访问为null的对象属性,编译器会引发System.NullReferenceException,您可以在全局范围内处理这些错误。但如果您想要抛出自己的异常和消息,您必须在访问属性时明确检查,在我看来,最简单和清晰的方法是使用以下扩展方法。

public static class Extensions
{
    public static T EnsureNotNull<T>(this T? type, string propertyName) where T : class
    {
        if (type == null)
        {
            throw new InvalidOperationException($"Uninitialized property: {propertyName}");
        }

        return type;
    }
}

并使用如下方式:

return car.Brand.EnsureNotNull("brand").Adress.Street;
英文:

If you normally try to access that objects property that is null, the compiler will throw an System.NullReferenceException that you can handle these errors globally. But if you want to throw your own exception and message, you must explicitly check when accessing the property, and in my opinion, the simplest and clean way is to use the Extension Methods as follows.

public static class Extensions
{
    public static T EnsureNotNull&lt;T&gt;(this T? type, string propertyName) where T : class
    {
        if (type == null)
        {
            throw new InvalidOperationException($&quot;Uninitialized property: {propertyName}&quot;);
        }

        return type;
    }
}

and use as follows :

 return car.Brand.EnsureNotNull(&quot;brand&quot;).Adress.Street;

答案2

得分: 1

> 在我的数据库模型中,我有一个导航属性,它不能为null。但是,由于变量可能尚未加载,它在运行时可以为null。

正确。EF导航属性将始终需要是可空的,即使底层的FK约束(和“NOT NULL”列约束)要求数据库中始终存在有效引用,因为加载相关数据从不是必需的。

在这种情况下,与FK列对应的实体类属性将是非可空类型,但是任何引用导航属性必须始终是?(即可空引用类型)。 (注意:这不适用于集合导航属性,这是另一回事)。

> 根据Microsoft的文档,可以通过在getter中引发异常来解决这个问题。但是,所有在Getter中引发的异常都不会被全局异常处理程序捕获。在引发异常后,代码的执行立即停止。

我同意。我对EF的文档甚感失望,竟然建议这样做。然而,这并不是什么新鲜事:自从他们开始严肃建议人们使用= null!来初始化DbContext属性以来(老实说,这真的是_非常非常愚蠢_

> 另一种方法是,我也可以将属性设置为可空。但是如果这样做,我必须在每个单独的函数中检查属性是否为null,这似乎不太符合DRY原则。

是的,但只有在您在应用程序中使用class Car EF类型本身作为传递数据的DTO时才会如此。

...但是如果您_而是_设计一个新的、单独的不可变DTOclass,其中包含非可空属性,并且具有验证这些类不变式的构造函数,那么就可以很好地工作。

您还可以在DTO和实体类型之间使用implicit转换来减少一些摩擦,例如将DTO传递给预期实体的方法,甚至在Linq查询和DbSet等中使用DTO。


因此,如果这是您的EF实体类:

public class Car
{
    [Key]
    [DatabaseGenerated( Identity )]
    public Int32 CarId { get; set; } // CarId int NOT NULL IDENTITY PRIMARY KEY

    public Int32 MakeId { get; set; } // MakeId int NOT NULL CONSTRAINT FK_Car_Make FOREIGN KEY REFERENCES dbo.Makes ( MakeId )

    public Brand? Make { get; set; } // Navigation property for FK_Car_Make
}

...并且您想表示具有加载的Make属性的Car,只需将以下内容添加到项目中:

public class LoadedCarWithMake
{
    public LoadedCarWithMake( Car car, Make make )
    {
        this.Car  = car  ?? throw new ArgumentNullException(nameof(car));
        this.Make = make ?? throw new ArgumentNullException(nameof(make));

        // 确保`make`对应于`car.Make`:
        if( !Object.ReferenceEquals( car.Make, make ) ) throw new ArgumentException( message: &quot;Mismatched Car.Make&quot;, paramName: nameof(make) );
    }

    public Car  Car  { get; } // Immutable property, though `Car` is mutable.
    public Make Make { get; } // Immutable property, though `Make` is mutable.

    // Forward other members:
    public Int32 CarId  =&gt; this.Car.CarId;
    public Int32 MakeId =&gt; this.Car.MakeId;

    // Implicit conversion via reference-returns:
    public static implicit operator Car( LoadedCarWithMake self ) =&gt; self.Car;
}

现在,即使Car实体将其Make导航属性重新设置为null或更改,也没有问题,因为这不会影响使用LoadedCarWithMake的消费者,因为LoadedCarWithMake.Make永远不会null

您还会希望为此添加加载器方法,例如:

public static async Task&lt;LoadedCarWithMake&gt; LoadCarWithMakeAsync( this MyDbContext db, Int32 carId )
{
    Car carWithMake = await db.Cars
        .Include( c =&gt; c.Make )
        .Where( c =&gt; c.CarId == carId )
        .SingleAsync()
        .ConfigureAwait(false);

    return new LoadedCarWithMake( car, car.Make! );
}

如果所有这些额外的重复代码看起来很烦人,不要担心:通常情况下,您不需要手动编写这些代码,可以使用工具如T4或Roslyn Code Generation自动为您创建这些“Loaded...”类型 - 我只希望EF团队能够将其包含在内以使每个人受益。


您还可以通过为每个实体类型定义IReadOnly...接口来进一步改进(因此您将有IReadOnlyCarIReadOnlyMake,其中_不包含_任何导航属性,只有get-only标量/值属性),然后LoadedCarWithMake也将有机会实现IReadOnlyCar

英文:

> In my database model I have a navigation property which cannot be null. However, since the variable may not have been loaded yet, it can be null during runtime.

Correct. EF Navigation properties will always need to be nullable, even if the underlying FK constraint (and NOT NULL column constraints) require a valid reference to always be present in the DB, because of the simple fact that loading related data is never required.

In that situation, the entity class properties corresponding to the FK columns will (of course) be non-nullable types, but any reference navigation properties must always be ? (i.e. nullable reference types). (Note: this does not apply to collection navigation properties, which are another story entirely).

> According to Microsoft documentation, this problem can be solved by throwing an exception in the getter. But all Exceptions that are thrown in the Getters are not catched by a global Exception handler. The execution of the code immediately stopps, after the exception has been thrown.

I agree. I'm disappointed that EF's documentation is even suggesting that. This isn't anything new though: ever since they started seriously suggesting people do = null! to initialize DbContext properties (which, honestly, is just really, really dumb)

> Another method would be, that I make the property also nullable. But if I do so, I have to check in each single function, if the property is null, which seems not to be very DRY.

Yes, but only if you use the `class Car`` EF type itself as a DTO for passing data around in your application.

...but if you instead design a new, separate immutable DTO class, with non-nullable properties, with a constructor that verifies these class-invariants, then that works great.

You can also use implicit conversions between the DTO and the entity-type to reduce some frictions, such as passing a DTO into a method expecting an entity, or to even use the DTOs with Linq Queries and DbSet and more.


So if this is your EF Entity class:

public class Car
{
    [Key]
    [DatabaseGenerated( Identity )]
    public Int32 CarId { get; set; } // CarId int NOT NULL IDENTITY PRIMARY KEY

    public Int32 MakeId { get; set; } // MakeId int NOT NULL CONSTRAINT FK_Car_Make FOREIGN KEY REFERENCES dbo.Makes ( MakeId )

    public Brand? Make { get; set; } // Navigation property for FK_Car_Make 
}

...and you want to represent a Car with a loaded Make propertty, then just add this to your project:

public class LoadedCarWithMake
{
    public LoadedCarWithMake( Car car, Make make )
    {
        this.Car  = car  ?? throw new ArgumentNullException(nameof(car));
        this.Make = make ?? throw new ArgumentNullException(nameof(make));

        // Ensure `make` corresponds to `car.Make`:
        if( !Object.ReferenceEquals( car.Make, make ) ) throw new ArgumentException( message: &quot;Mismatched Car.Make&quot;, paramName: nameof(make) );
    }

    public Car  Car  { get; } // Immutable property, though `Car` is mutable.
    public Make Make { get; } // Immutable property, though `Make` is mutable.

    // Forward other members:
    public Int32 CarId  =&gt; this.Car.CarId;
    public Int32 MakeId =&gt; this.Car.MakeId;

    // Implicit conversion via reference-returns:
    public static implicit operator Car( LoadedCarWithMake self ) =&gt; self.Car;
}

Now, even if that Car entity has its Make navigation-property re-set to null or changed then that's okay because it won't affect consumers that use LoadedCarWithMake because LoadedCarWithMake.Make will never be null.

You'd also want to add a loader method for this too, e.g.:

public static async Task&lt;LoadedCarWithMake&gt; LoadCarWithMakeAsync( this MyDbContext db, Int32 carId )
{
    Car carWithMake = await db.Cars
        .Include( c =&gt; c.Make )
        .Where( c =&gt; c.CarId == carId )
        .SingleAsync()
        .ConfigureAwait(false);

    return new LoadedCarWithMake( car, car.Make! );
}

If all this extra repetitive code looks tedious, don't worry: you shouldn't normally need to write this by-hand: it's straightforward to use tools like T4 - or Roslyn Code Generation - to automatically create these "Loaded..." types for you - I just wish the EF team would include that in-box for the benefit of everyone.


You can improve this further by defining IReadOnly... interfaces for each entity-type (so you'd have IReadOnlyCar and IReadOnlyMake, which do not contain any navigation-properties, only get-only scalar/value properties), then LoadedCarWithMake would also then get to implement IReadOnlyCar.

huangapple
  • 本文由 发表于 2023年7月18日 12:56:02
  • 转载请务必保留本文链接:https://go.coder-hub.com/76709624.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定