Безопасное моделирование реляционных данных в Haskell
Мне очень часто хочется моделировать реляционные данные в моих функциональных программах. Например, при разработке веб-сайта я могу иметь следующую структуру данных для хранения информации о моих пользователях:
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
, но часто база данных структурно гарантирует, что есть значение.
- Это неуклюже.
- Трудно обеспечить ссылочную целостность ваших данных.
- Управление индексами (что очень важно для производительности) и обеспечение их целостности еще сложнее и неуклюже.
Существует ли работа по преодолению этих проблем?
Похоже, что Template Haskell может их решить (как это обычно бывает), но я бы не хотел изобретать колесо.
Ответы
Ответ 1
Библиотека ixset
поможет вам в этом. Это библиотека, которая поддерживает реляционную часть acid-state
, которая также обрабатывает версию сериализации ваших данных и/или concurrency гарантирует, в случае вам это нужно.
Дело в 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 (и удалите поля users
и messages
):
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
, чтобы сравнить это поле, например.
Это также можно использовать для решения проблемы с курицей и яйцом: что, если у вас есть идентификатор, но ни user
, ни Message
?
Затем вы можете просто создать явный индекс для id, найти пользователя по этому id (с помощью userSet @= (12423 :: Id)
), а затем выполнить поиск.
Ответ 2
Другой радикально другой подход к представлению реляционных данных используется пакетом базы данных haskelldb. Это не очень похоже на типы, которые вы описываете в вашем примере, но он предназначен для обеспечения безопасного доступа к SQL-запросам. Он имеет инструменты для генерации типов данных из схемы базы данных и наоборот. Типы данных, такие как те, которые вы описываете, хорошо работают, если вы всегда хотите работать со целыми строками. Но они не работают в ситуациях, когда вы хотите оптимизировать свои запросы, только выбрав определенные столбцы. Именно здесь может быть полезен подход HaskellDB.
Ответ 3
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 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
Ответ 4
Меня попросили написать ответ, используя Opaleye. На самом деле не так много сказать, поскольку код Opaleye довольно стандартный, как только у вас есть схема базы данных. В любом случае, здесь, если предположить, что существует user_table
с столбцами user_id
, name
и birthdate
и a message_table
со столбцами user_id
, time_stamp
и content
.
Этот вид дизайна более подробно объясняется в Основном учебнике 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
У меня нет полного решения, но я предлагаю взглянуть на пакет ixset; он предоставляет заданный тип с произвольным количеством индексов, с которыми могут выполняться поисковые запросы. (Он предназначен для использования с acid-state для сохранения.)
Вам по-прежнему нужно вручную поддерживать "первичный ключ" для каждой таблицы, но вы можете сделать это значительно проще несколькими способами:
-
Добавление параметра типа к Id
, так что, например, a User
содержит Id User
, а не только Id
. Это гарантирует, что вы не будете смешивать Id
для отдельных типов.
-
Создание абстрактного типа Id
и предлагающий безопасный интерфейс для генерации новых в некотором контексте (например, монада State
, которая отслеживает соответствующий IxSet
и текущий самый высокий Id
)..
-
Написание функций-оберток, которые позволяют вам, например, предоставить User
, где ожидается запрос Id User
в запросах, и которые обеспечивают соблюдение инвариантов (например, если каждый Message
содержит ключ к действительному User
, он может позволить вам просмотреть соответствующий User
без обработки значения Maybe
, а "небезопасность" содержится в этой вспомогательной функции).
В качестве дополнительной заметки вам фактически не нужна древовидная структура для регулярных типов данных, поскольку они могут представлять произвольные графики; однако это упрощает такие операции, как обновление имени пользователя.