Datomic:具有 'reset' 操作的一对多关系的模式

Datomic: schema for a to-many relationship with a 'reset' operation

我正在寻找有关在 Datomic 中对某些对多关系进行建模的方法的反馈。

问题

假设我想为一个域设计 Datomic 模式,其中一个人有一个最喜欢的电影列表。例如,John 最喜欢的电影是 GladiatorStar WarsFight Club

在 Datomic 中对此进行建模的最明显的模式是使用基数多属性,例如:

#{["John" :person/favorite-movies "Gladiator"]
  ["John" :person/favorite-movies "Star Wars"]
  ["John" :person/favorite-movies "Fight Club"]}

这种方法可以很容易地从列表中添加或删除电影(只需使用 :db/add:db/retract),但我发现 重置 整个电影列表 - 你基本上需要计算旧列表和新列表之间的差异,并且必须在事务函数中 运行 。当列表的元素不是标量时,情况会变得更糟。

替代方法

作为替代方法,我正在考虑使用 set entity:

引入间接寻址
#{["John" :person/favorite-movies 42]
  [42 :set.string/contains "Gladiator"]
  [42 :set.string/contains "Star Wars"]
  [42 :set.string/contains "Fight Club"]}

使用这种方法,:person/favorite-movies 是基数一、引用类型的属性,而 :set.string/contains 是基数多、字符串类型的属性。重置列表只是简单地创建一个新的 set entity:

[{:db/id "John"
  :person/favorite-movies {:db/id (d/tempid :db.part/user)
                           :set.string/contains ["Gladiator" 
                                                 "The Lord of the Rings"
                                                 "A Clockwork Orange"
                                                 "True Romance"]}}]

这种对多关系建模的方法是否存在已知限制?


编辑:一个不那么琐碎的用例

在关系是引用类型而不是标量类型的情况下研究这个问题更相关,因为一些问题出现在 Datomic 中的引用类型属性上。

研究关系的 'reset' 操作更有意义的用例也更相关,而 'favorite movies' 并非如此。

示例: 带有复选框的表单,用户可以在其中通过选择一组 OptionQuestion 提供 Answer ]s。用户可以将她的 Answer 更新为 Question。目标是对 Answer - Option 关系建模。

此信息模型的规范 Datomic 模式为:

  • 这项技术比较复杂:您需要管理两个实体而不是一个实体。
  • 如果您使用通用 attr 来保存集合成员(在您的示例中为 :set.string/contains),您将不再有关于 favorite-movies 值的有用索引。要返回有用的索引,您需要一对属性:例如 :person/favorite-movies:person.favorite-movies/items
  • 您对用户最喜爱电影的更改历史重建起来更加复杂。你现在可以不再简单地查看:person/favorite-movies,你需要随时知道它指向什么集合实体,并查看集合实体的历史。
  • 您的应用程序需要区分 "I am resetting a set" 与 "I am changing a set and want the changes merged." 在应用程序模型中实际上可能没有任何此类区别。
  • 您最终可能会得到带有未引用数据的孤立 "set" 实体。例如:同时,一个对等方发送重置(即断言一个新的集合实体),另一个对等方向现有集合添加一个项目。如果第二个点的交易发生在第一个点之后,你现在有一个孤立的数据。

最佳解决方案是进行细化更改。例如,如果用户从集合中添加或删除特定项目,则每次添加或删除都应该是一个仅包含该断言或撤回的事务。集合操作是可交换的,所以两个用户攻击同一个集合不会造成任何伤害。 (除非你有派生数据,在这种情况下竞争条件很重要。)

如果您确实需要 "reset the set, make it look like this" 操作,更好的解决方案是使用一个事务函数,该函数接收您想要的整个设置值,并计算使当前值成为新值所需的添加和收回你想要的价值。这是一个可以执行此操作的 tx 函数:

{:db/ident :db.fn/resetAttribute
 :db/doc   "Unconditionally set an entity's attribute's values to those provided,
retracting all other existing values.

Values must be a collection (list, seq, vector), even for cardinality-one
attributes. An empty collection (or nil) will retract all values. The values
themselves must be primitive, i.e. no map forms are permitted for refs, use
tempids directly. If the attribute is-component, removed values will be
:db.fn/retractEntity-ed."
 :db/fn
 #db/fn {:lang   "clojure"
         :params [db ent attr values]
         :code   (let [eid       (datomic.api/entid db ent)
                       aid       (datomic.api/entid db attr)
                       {:keys [value-type is-component]} (datomic.api/attribute db aid)
                       newvalues (if (= value-type :db.type/ref)
                                   (into #{} (map #(if (string? %) % (d/entid db %))) values)
                                   (into #{} values))
                       oldvalues (into #{} (map :v) (datomic.api/datoms db :eavt eid aid))]
                   (-> []
                       (into (comp
                               (remove newvalues)
                               (map (if is-component
                                      #(do [:db.fn/retractEntity %])
                                      #(do [:db/retract eid aid %]))))
                         oldvalues)
                       (into (comp
                               (remove oldvalues)
                               (map #(do [:db/add eid aid %])))


                    newvalues)))}}

你会像这样使用它:

[:db.fn/resetAttribute [:person/id "John"] :person/favorite-movies
  ["Gladiator" "The Lord of the Rings" "A Clockwork Orange" "True Romance"]]]

;; Or to retract *all* existing values:
[:db.fn/resetAttribute [:person/id "John"] :person/favorite-movies nil]

用这种方法试验了几个月后,这是我的结论。

两种策略(A - 使用直接属性 vs B - 使用中间的、一次性的实体)在读写方面都有实际的优点和缺点,可以在 and 中阅读。但是恕我直言,最重要的原则是:模式应该主要由域模型决定,而不是由读写模式决定

是否存在适合策略 B 的领域模型?我相信。

例如,在问题中提供的 Question/Option/Answer 示例域中,将一组答案解释为一个连贯的整体而不是单独的单个事实可能更有意义。向中间实体添加一个 :submittedTime 即时类型的属性,你现在已经对答案的 revision 进行了建模(你不想依赖 Datomic 历史来建模那个)。


注:

使用策略A,实现'reset'操作需要交易功能;由于与实体生命周期 ('does this entity already exist or not') 相关的棘手问题,在大多数情况下,编写这样的事务函数并非易事。我最好的办法是 found in the Datofu library.