Подтвердить что ты не робот

Безопасное моделирование реляционных данных в 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 может их решить (как это обычно бывает), но я бы не хотел изобретать колесо.

4b9b3361

Ответ 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, а "небезопасность" содержится в этой вспомогательной функции).

В качестве дополнительной заметки вам фактически не нужна древовидная структура для регулярных типов данных, поскольку они могут представлять произвольные графики; однако это упрощает такие операции, как обновление имени пользователя.