我正在使用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"
}
]
}
这是我构造数据库以支持需求的方式:
当子类别是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实体。
通常,给定模块将具有getOrAdd
或addOrUpdate
,具体取决于该实体的行为方式。这些功能始终通过在幕后调用标准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之后导航中的所有子实体?
答案 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)