EF Core取代了所有相关的子实体

时间:2019-12-16 20:33:55

标签: sql-server f# entity-framework-core

我正在使用EF Core 3.1和MS SQL Server为RESTful微服务实现存储库,并且在执行PUT更新数据模型中由导航属性支持的数据时遇到一些奇怪的行为。微服务的目的是管理产品信息管理的“类别和子类别”定义。 REST服务中有两个级别的资源,/categories/categories/{id}/subcategories。但是,基础数据结构稍微复杂一点,因为类别和子类别都具有“分区”,并且对于子类别要具有给定的划分,其所属的类别也必须具有该划分。

以下大致是子类别对象的相关部分:

{
  "id": 7083,
  "categoryId": 728,
  "name": "Notebooks",
  "divisions": [
    {
      "divisionId": 1,
      "status": "active"
    },
    {
      "divisionId": 7,
      "status": "inactive"
    }
  ]
}

这是我构造数据库以支持需求的方式:

Database Layout

当子类别是PUT,并且具有与之前不同的划分集时,就会出现问题。例如,以上述实体为例,假设收到了如下所示的PUT请求:

{
  "id": 7083,
  "categoryId": 728,
  "name": "Notebooks",
  "divisions": [
    {
      "divisionId": 1,
      "status": "active"
    },
    {
      "divisionId": 7,
      "status": "inactive"
    },
    {
      "divisionId": 3,
      "status": "active"
    }
  ]
}

基本上,我们应该只为类别728的子类别7083的分区3添加一个CategoryDivisionSubcategory记录。但是,实际上发生的是现有两个部门(1和7的CategoryDivisionSubcategory记录)被删除,仅插入新部门(3)的CategoryDivisionSubcategory记录。这显然是由于我试图管理实体状态并处理REST风格的PUT(其中应更新对象的整个状态)的方式而发生的。

我为数据库中的每个实体创建了一个模块。然后,每个模块中都有以下3个或4个函数:

fromSql:从EF Core实体构造域模型

toSql:根据域模型构建EF Core实体

getOrAdd:检查给定的域模型是否存在于数据库中或当前上下文的跟踪更改,或者如果不存在则添加它,以返回EF Core实体。

addOrUpdate:检查数据库中是否存在与给定域模型的主键匹配的记录(如果已经存在),更新非键字段以匹配域模型,否则插入该记录,并最后返回EF Core实体。

通常,给定模块将具有getOrAddaddOrUpdate,具体取决于该实体的行为方式。这些功能始终通过在幕后调用标准getOrAdd函数来实现:

let getOrAdd<'model, 'entity when 'entity: not struct> toSql (getDbSet: CategoryContext -> DbSet<'entity>) (isEqual: Expr<'entity -> 'entity -> bool>) (context: CategoryContext) (model: 'model) : AsyncResult<'entity, CategoryRepositoryError> =
        asyncResult {
            let entity = model |> toSql
            let dbSet = context |> getDbSet
            let filter = <@ (%isEqual) entity @>

            let! existing = 
                query {
                    for row in dbSet do
                    where ((%filter) row)
                    select row
                }
                |> tryExactlyOneAsync // Wrapper around EF Core .SingleOrDefaultAsync(), returning an Async<'entity option>

            match existing with
            | Some row -> 
                return row
            | None ->
                let checkEquality = filter.Compile()
                let tracked = context.ChangeTracker.Entries<'entity>().Where(fun tracked -> checkEquality tracked.Entity) |> Seq.tryHead
                match tracked with
                | Some trackedEntity ->
                    return trackedEntity.Entity
                | None ->
                    dbSet.Add(entity) |> ignore
                    return entity
        }

然后,子类别addOrUpdate函数多次使用该函数来实现整个PUT操作:

let addOrUpdate context (subcategory: Subcategory) =
        asyncResult {
            let! entity =
                getOrAdd toSql
                    (fun context -> context.Subcategories)
                    <@ fun current row -> current.CategoryId = row.CategoryId && current.Subcategory = row.Subcategory @>
                    context subcategory

            entity.Name <- subcategory.Name |> SubcategoryName.value
            entity.CategoryDivisionSubcategories.Clear()

            let! divisions = subcategory.Divisions |> Seq.map (CategoryDivisionSubcategory.addOrUpdate context entity) |> AsyncResult.join |> AsyncResult.mapError MultipleRepositoryErrors

            for division in divisions do
                entity.CatgoryDivisionSubcategories.Add(division)

            return entity
        }

我希望通过清除CategoryDivisionSubcategories集合(通过导航属性有效地删除与到达相关的记录),然后再添加回PUT请求中存在的每个记录,我最终会得到DB中的状态与请求中对象的状态匹配。但是,事实并非如此,正如我在上面的示例中提到的那样,我最终只获得了新记录,而删除了任何先前存在的记录。在PUT之后,我现在只有1个,而不是现在有3个CategoryDivisionSubcategories。

我可以通过更改逻辑来实际运行该函数,以通过导航属性将相关表中的每个记录与PUT请求中发送的数组中的条目进行实际比较,但这似乎效率很低,实际上,我有很多我需要做更多的地方:

let removedDivisions = entity.CategoryDivisionSubcategories.AsEnumerable() |> Seq.filter (fun scd -> divisions |> Seq.exists(fun d -> scd.SubcategoryId = d.SubcategoryId && scd.DivisionId = d.DivisionId) |> not) |> Seq.toList

for division in divisions do
    entity.CategoryDivisionSubcategories.Add(division)

for removedDivision in removedDivisions do
    entity.CategoryDivisionSubcategories.Remove(removedDivision) |> ignore

是否有某种方式可以避免执行“比较列表中的每个记录”逻辑,以确保最终获得与PUT请求完全匹配的DB状态,否则我将需要在所有地方实现这种类型的逻辑想要完全替换PUT之后导航中的所有子实体?

1 个答案:

答案 0 :(得分:0)

我最终为DbSet编写了一个自定义Merge函数,该函数允许您从任何现有DbSet和具有更新状态的ICollection中生成新集合。实施方法如下:

type DbSetComparison<'entity> =
    {
        Added: 'entity list
        Removed: 'entity list
        Modified: 'entity list
        Unmodified: 'entity list
    }

[<CompileWith(ModuleSuffix)>]
module DbSet =
    type private PartialComparison<'entity> =
        {
            Added: 'entity list
            Removed: 'entity list
            Matching: ('entity * 'entity) list
        }

    let private getRemovedAndAdded<'entity, 'key, 'field when 'key: equality and 'field: equality> (newState: 'entity list) (getKey: 'entity -> 'key) (getField: 'entity -> 'field) (current: ICollection<'entity>) =
        let existing = current.ToList() 

        let removed = 
            existing
            |> Seq.filter (fun e1 -> newState |> Seq.exists(fun e2 -> (getKey e1) = (getKey e2)) |> not) 
            |> Seq.toList

        let added = newState |> List.filter (fun e1 -> existing |> Seq.exists(fun e2 -> (getKey e1) = (getKey e2)) |> not)

        let matching =
            existing
            |> Seq.choose (fun e1 -> newState |> Seq.tryFind (fun e2 -> (getKey e1) = (getKey e2)) |> Option.map (fun e2 -> e1,e2))
            |> Seq.toList

        { Removed = removed; Added = added; Matching = matching }


    let compareBy<'entity, 'key, 'field when 'key: equality and 'field: equality> (newState: 'entity list) (getKey: 'entity -> 'key) (getField: 'entity -> 'field) (current: ICollection<'entity>) =
        let partialResults = getRemovedAndAdded newState getKey getField current

        let modified = partialResults.Matching |> List.filter (fun (e1,e2) -> (getField e1) <> (getField e2))

        let unmodified = partialResults.Matching |> List.filter (fun (e1,e2) -> (getField e1) = (getField e2)) |> List.map snd

        { Added = partialResults.Added; Removed = partialResults.Removed; Modified = modified |> List.map snd; Unmodified = unmodified }


    let compare<'entity, 'key when 'entity: equality and 'key: equality> (newState: 'entity list) (getKey: 'entity -> 'key) (current: ICollection<'entity>) =
        let partialResults = getRemovedAndAdded newState getKey id current

        let modified = partialResults.Matching |> List.filter (fun (e1,e2) -> e1 <> e2)

        let unmodified = partialResults.Matching |> List.except modified |> List.map snd

        { Added = partialResults.Added; Removed = partialResults.Removed; Modified = modified |> List.map snd; Unmodified = unmodified }


[<Extension>]
type DbSetExtensions =
    [<Extension>]
    static member Compare<'entity, 'key when 'entity: equality and 'key: equality>(dbSet: ICollection<'entity>, newState: 'entity list, getKey: 'entity -> 'key) =
        dbSet |> DbSet.compare newState getKey

    [<Extension>]
    static member CompareBy<'entity, 'key, 'field when 'field: equality and 'key: equality>(dbSet: ICollection<'entity>, newState: 'entity list, getKey: 'entity -> 'key, getField: 'entity -> 'field) =
        dbSet |> DbSet.compareBy newState getKey getField

    [<Extension>]
    static member Merge<'entity, 'key when 'entity: equality and 'key: equality>(dbSet: ICollection<'entity>, newState: 'entity list, getKey: 'entity -> 'key, update: 'entity -> 'entity -> unit) =
        let changes = dbSet |> DbSet.compare newState getKey

        for removed in changes.Removed do
            dbSet.Remove(removed) |> ignore

        for modified in changes.Modified do
            let existing = dbSet.SingleOrDefault(fun e -> (getKey e) = (getKey modified))
            if existing |> isNotNull then
                update existing modified

        for added in changes.Added do
            dbSet.Add(added)