Haskell中关系数据的安全建模

时间:2012-02-10 20:11:23

标签: haskell relational-database type-safety in-memory-database

我觉得想要在我的功能程序中建模关系数据是很常见的。例如,在开发网站时,我可能希望使用以下数据结构来存储有关我的用户的信息:

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 s:

中来模拟关系数据库
newtype Id a = Id Integer
type Table a = Map (Id a) a

这种作品,但由于多种原因而不安全和丑陋:

  • 您只是一个Id构造函数,可以远离无意义的查找。
  • 在查找时,您获得Maybe a,但数据库通常会在结构上确保存在值。
  • 笨拙。
  • 很难确保数据的参照完整性。
  • 管理指数(这对于表现非常必要)并确保其完整性更加困难和笨拙。

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

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

5 个答案:

答案 0 :(得分:25)

ixset库可以帮助您解决此问题。它是支持acid-state的关系部分的库,它还处理数据和/或并发保证的版本化序列化,以备不时之需。

关于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,但是UserMessage都没有?

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

答案 1 :(得分:7)

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

答案 2 :(得分:5)

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

答案 3 :(得分:5)

我被要求用Opaleye写一个答案。事实上,没有太多可说的,因为一旦你有了数据库模式,Opaleye代码就相当标准。无论如何,这里假设有user_tableuser_idnamebirthdate以及message_tableuser_idtime_stampcontent

这种设计在the Opaleye Basic Tutorial中有更详细的解释。

{-# 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)

答案 4 :(得分:3)

我没有完整的解决方案,但我建议您查看ixset包。它提供了一个set类型,其中包含可以执行查找的任意数量的索引。 (它旨在与acid-state一起用于持久性。)

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

  1. 将类型参数添加到Id,例如,User包含Id User而不仅仅是Id。这样可以确保您不会将Id混合为不同的类型。

  2. 制作Id类型的摘要,并提供一个安全的界面来在某些上下文中生成新的(如State monad,跟踪相关的IxSet和当前最高Id)。

  3. 编写包装函数,例如,您可以提供User,其中查询中需要Id User,并强制执行不变量(例如,每Message {}持有有效User的密钥,它可以让您在不处理User值的情况下查找相应的Maybe;此辅助函数中包含“不安全”。) p>

  4. 作为补充说明,您实际上不需要树结构来使常规数据类型起作用,因为它们可以表示任意图形;但是,这使得更新用户名称等简单操作变得不可能。