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(想象任何多对多关系),甚至是一般图(好吧,也许不是)。在这种情况下,我倾向于通过将数据存储在Maps 中来模拟关系数据库:

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

这种工作,但由于多种原因是不安全和丑陋的:

  • 您只是一个Id远离无意义查找的构造函数调用。
  • 在查找时您会得到Maybe a,但通常数据库在结构上会确保存在一个值。
  • 它很笨拙。
  • 很难确保数据的引用完整性。
  • 管理索引(这对于性能非常必要)并确保它们的完整性更加困难和笨拙。

是否有克服这些问题的现有工作?

看起来 Template Haskell 可以解决它们(就像通常那样),但我不想重新发明轮子。

4

5 回答 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 中一样(并删除usersandmessages字段):

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,但既没有 aUser也没有 aMessage怎么办?

然后,您可以简单地为该 id 创建一个显式索引,通过该 id (with userSet @= (12423 :: Id)) 找到用户,然后进行搜索。

于 2012-02-10T21:07:15.810 回答
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
于 2014-04-15T03:33:10.890 回答
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)
于 2015-02-03T20:14:07.740 回答
5

数据库包haskelldb使用了另一种完全不同的方法来表示关系数据。它的工作方式与您在示例中描述的类型不太一样,但它旨在允许 SQL 查询的类型安全接口。它具有用于从数据库模式生成数据类型的工具,反之亦然。如果您总是想处理整行,那么您描述的数据类型就可以很好地工作。但它们不适用于您只想通过选择某些列来优化查询的情况。这就是 HaskellDB 方法有用的地方。

于 2012-02-16T17:59:38.763 回答
3

我没有完整的解决方案,但我建议看一下ixset包;它提供了一个集合类型,其中包含可以执行查找的任意数量的索引。(它旨在与酸状态一起使用以保持持久性。)

您仍然需要为每个表手动维护一个“主键”,但您可以通过以下几种方式使其变得更加容易:

  1. 向 中添加类型参数Id,例如,aUser包含一个Id User而不仅仅是一个Id。这可以确保您不会混淆Id不同类型的 s。

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

  3. 编写包装函数,例如,让您提供查询中预期的User位置Id User,并强制执行不变量(例如,如果每个人都Message拥有一个有效的键,它可以让您在不处理值的情况下User查找对应的; “不安全”包含在此辅助函数中)。UserMaybe

作为附加说明,您实际上并不需要常规数据类型的树结构才能工作,因为它们可以表示任意图;但是,这使得更新用户名之类的简单操作变得不可能。

于 2012-02-10T20:46:04.887 回答