在Haskell中安全地建模关系数据

40

我发现在我的函数式程序中,想要对关系数据进行建模非常常见。例如,在开发网站时,我可能希望有以下数据结构来存储关于用户的信息:

data User = User 
  { name :: String
  , birthDate :: Date
  }

接下来,我想存储有关用户在我的网站上发布的消息的数据:

data Message = Message
  { user :: User
  , timestamp :: Date
  , content :: String
  }

这个数据结构存在多个问题:

  • 我们没有办法区分具有相似姓名和生日的用户。
  • 在序列化/反序列化过程中,用户数据会被重复复制。
  • 比较用户需要比较它们的数据,这可能是一个昂贵的操作。
  • User字段的更新很容易出错--你可能会忘记更新数据结构中所有User的实例。

当我们的数据可以表示为树形结构时,这些问题是可以解决的。例如,你可以进行重构,像这样:

data User = User
  { name :: String
  , birthDate :: Date
  , messages :: [(String, Date)] -- you get the idea
  }

然而,你可以将你的数据形状设计成DAG(想象任意多对多关系),甚至一般图也是可能的(好吧,也许不太实用)。在这种情况下,我倾向于通过将数据存储在Map中来模拟关系数据库:

newtype Id a = Id Integer
type Table a = Map (Id a) a

这种方法可以工作,但有多个不安全和丑陋的原因:

  • 只需调用一个Id构造函数即可进行无意义查找。
  • 在查找时,你会得到一个Maybe a,但通常数据库在结构上保证有一个值。
  • 它很笨拙。
  • 很难确保数据的引用完整性。
  • 管理索引(对于性能非常必要)并确保它们的完整性甚至更加困难且笨重。

是否有已有的解决这些问题的方案?

看起来模板Haskell可以解决这些问题(通常是这样),但我不想重复造轮子。

5个回答

29
ixset 库(或更安全类型的版本ixset-typed)可以帮助您完成此操作。它是支持acid-state关系部分的库,后者还处理数据的版本序列化和/或并发保证(如果需要的话)。

Happstack Book 中有一个IxSet教程


ixset 的优点在于它会自动为您的数据条目管理“键”。对于您的示例,您可以像这样为数据类型创建一对多的关系:
data User =
  User
  { name :: String
  , birthDate :: Date
  } deriving (Ord, Typeable)

data Message =
  Message
  { user :: User
  , timestamp :: Date
  , content :: String
  } deriving (Ord, Typeable)

instance Indexable Message where
  empty = ixSet [ ixGen (Proxy :: Proxy User) ]

你可以找到特定用户的信息。如果你已经建立了一个像这样的 IxSet

user1 = User "John Doe" undefined
user2 = User "John Smith" undefined

messageSet =
  foldr insert empty
  [ Message user1 undefined "bla"
  , Message user2 undefined "blu"
  ]

...你可以通过以下方式找到由user1发送的消息:

user1Messages = toList $ messageSet @= user1
如果需要找到一条消息的用户,只需像平常一样使用user函数。这模拟了一对多的关系。 现在,对于多对多的关系,例如这种情况:
data User =
  User
  { name :: String
  , birthDate :: Date
  , messages :: [Message]
  } deriving (Ord, Typeable)

data Message =
  Message
  { users :: [User]
  , timestamp :: Date
  , content :: String
  } deriving (Ord, Typeable)

使用 ixFun 可以创建一个索引,并可用于索引列表。示例如下:

instance Indexable Message where
  empty = ixSet [ ixFun users ]

instance Indexable User where
  empty = ixSet [ ixFun messages ]

要查找用户发布的所有消息,仍然使用相同的函数:

user1Messages = toList $ messageSet @= user1

此外,假设您拥有用户索引:

userSet =
  foldr insert empty
  [ User "John Doe" undefined [ messageFoo, messageBar ]
  , User "John Smith" undefined [ messageBar ]
  ]

你可以找到一个消息的所有用户:

messageFooUsers = toList $ userSet @= messageFoo

如果您不想在添加新用户/消息时更新消息的用户或用户的消息,则应创建一个中间数据类型来模拟用户和消息之间的关系,就像 SQL 中一样(并删除usersmessages字段):

data UserMessage = UserMessage { umUser :: User, umMessage :: Message } 

instance Indexable UserMessage where
  empty = ixSet [ ixGen (Proxy :: Proxy User), ixGen (Proxy :: Proxy Message) ]
创建这些关联的集合将允许您查询用户和消息,而无需更新任何内容。考虑到其功能,该库具有非常简单的接口!
编辑:关于您提到的“需要比较的昂贵数据”:ixset仅比较您在索引中指定的字段(因此在第一个示例中查找所有消息的用户时,它会比较“整个用户”)。
通过修改Ord实例,您可以调节它所比较的索引字段的部分。 因此,如果对您来说比较用户很昂贵,您可以添加一个userId字段,并修改instance Ord User以仅比较此字段,例如。
这也可用于解决先有鸡还是先有蛋的问题:如果您有一个id,但没有User或Message怎么办?
然后,您可以为id创建一个显式索引,使用userSet @= (12423 :: Id)查找用户,然后进行搜索。

这里展示的一对多模型难道不会共享问题中原始模型的所有缺点吗? - ehird
@ehird,我每分钟大约进行10次编辑,所以我认为我一路上已经回答了你的疑虑。 - dflemstr
是的,确实。(顺便说一句,acid-state 实际上并不依赖于 ixset;它们只是设计成一起使用的。) - ehird
1
在第二个示例中,当我尝试评估user1Messages = toList $ messageSet @= user1时,我遇到了“堆栈溢出”异常。我认为这是因为我无法比较具有无限递归的数据类型(User [Message [User ...)]。第一个和第三个示例对我有用。谢谢! - Johnny Liao

8

IxSet就是解决方案。为了帮助其他可能会遇到这篇文章的人,这里提供一个更详细的例子:

{-# LANGUAGE OverloadedStrings, DeriveDataTypeable, TypeFamilies, TemplateHaskell #-}

module Main (main) where

import Data.Int
import Data.Data
import Data.IxSet
import Data.Typeable

-- use newtype for everything on which you want to query; 
-- IxSet only distinguishes indexes by type
data User = User 
  { userId :: UserId
  , userName :: UserName }
  deriving (Eq, Typeable, Show, Data)
newtype UserId = UserId Int64
  deriving (Eq, Ord, Typeable, Show, Data)
newtype UserName = UserName String
  deriving (Eq, Ord, Typeable, Show, Data)

-- define the indexes, each of a distinct type
instance Indexable User where
   empty = ixSet 
      [ ixFun $ \ u -> [userId u]
      , ixFun $ \ u -> [userName u]
      ]

-- this effectively defines userId as the PK
instance Ord User where
   compare p q = compare (userId p) (userId q)

-- make a user set
userSet :: IxSet User
userSet = foldr insert empty $ fmap (\ (i,n) -> User (UserId i) (UserName n)) $ 
    zip [1..] ["Bob", "Carol", "Ted", "Alice"]

main :: IO ()
main = do
  -- Here, it's obvious why IxSet needs distinct types.
  showMe "user 1" $ userSet @= (UserId 1)
  showMe "user Carol" $ userSet @= (UserName "Carol")
  showMe "users with ids > 2" $ userSet @> (UserId 2)
  where
  showMe :: (Show a, Ord a) => String -> IxSet a -> IO ()
  showMe msg items = do
    putStr $ "-- " ++ msg
    let xs =  toList items
    putStrLn $ " [" ++ (show $ length xs) ++ "]"
    sequence_ $ fmap (putStrLn . show) xs

6
我被要求使用Opaleye编写答案。 实际上,一旦有了数据库模式,Opaleye代码就相当标准。 假设有一个名为user_table的表格,其中包含列user_idnamebirthdate,以及一个名为message_table的表格,其中包含列user_idtime_stampcontent,下面是代码实现。
这种设计在Opaleye基本教程中有更详细的解释。
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE Arrows #-}

import Opaleye
import Data.Profunctor.Product (p2, p3)
import Data.Profunctor.Product.TH (makeAdaptorAndInstance)
import Control.Arrow (returnA)

data UserId a = UserId { unUserId :: a }
$(makeAdaptorAndInstance "pUserId" ''UserId)

data User' a b c = User { userId    :: a
                        , name      :: b
                        , birthDate :: c }
$(makeAdaptorAndInstance "pUser" ''User')

type User = User' (UserId (Column PGInt4))
                  (Column PGText)
                  (Column PGDate)

data Message' a b c = Message { user      :: a
                              , timestamp :: b
                              , content   :: c }
$(makeAdaptorAndInstance "pMessage" ''Message')

type Message = Message' (UserId (Column PGInt4))
                        (Column PGDate)
                        (Column PGText)


userTable :: Table User User
userTable = Table "user_table" (pUser User
  { userId    = pUserId (UserId (required "user_id"))
  , name      = required "name"
  , birthDate = required "birthdate" })

messageTable :: Table Message Message
messageTable = Table "message_table" (pMessage Message
  { user      = pUserId (UserId (required "user_id"))
  , timestamp = required "timestamp"
  , content   = required "content" })

一个将用户表和消息表连接在user_id字段上的查询示例:

usersJoinMessages :: Query (User, Message)
usersJoinMessages = proc () -> do
  aUser    <- queryTable userTable    -< ()
  aMessage <- queryTable messageTable -< ()

  restrict -< unUserId (userId aUser) .== unUserId (user aMessage)

  returnA -< (aUser, aMessage)

5
另一种完全不同的表示关系数据的方法是由数据库包haskelldb使用的。它不像你在例子中描述的那样工作,但它被设计为允许对SQL查询进行类型安全的接口。它具有从数据库模式生成数据类型和反之的工具。像你描述的这些数据类型在您始终想要使用整个行时效果很好。但是,在您只选择某些列来优化查询的情况下,它们无法工作。这就是HaskellDB方法有用的地方。

现在我建议使用Opaleye而不是HaskellDB(http://hackage.haskell.org/package/opaleye),但是因为我写了它,所以有点偏向它 :) - Tom Ellis
@TomEllis:你会考虑使用Opaleye写一个简短的答案回答这个问题吗? - Christian Conkle

3
我没有完整的解决方案,但我建议看一下ixset包;它提供了一个集合类型,其中可以执行任意数量的索引查找。(它旨在与acid-state一起用于持久性。)
您仍然需要手动维护每个表的“主键”,但可以通过以下几种方式使其更加容易:
  1. Id添加一个类型参数,例如,一个User包含一个Id User而不仅仅是一个Id。这确保了您不会混淆不同类型的Id

  2. Id类型设置为抽象,并提供一个安全的接口来在某些上下文中生成新的Id(例如,一个State monad,它跟踪相关的IxSet和当前最高的Id)。

  3. 编写包装函数,让您可以在查询中提供一个User,其中期望一个Id User,并强制执行不变量(例如,如果每个Message都持有指向有效User的键,则它可以允许您查找相应的User而不需要处理Maybe值;“不安全性”包含在这个辅助函数中)。

作为额外的说明,实际上您不需要树形结构来使常规数据类型工作,因为它们可以表示任意图形;但是,这会使像更新用户姓名这样的简单操作变得不可能。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接