Безопасное моделирование реляционных данных в Haskell - PullRequest
37 голосов
/ 11 февраля 2012

Я нахожу очень распространенным желание моделировать реляционные данные в моих функциональных программах.Например, при разработке веб-сайта мне может потребоваться следующая структура данных для хранения информации о моих пользователях:

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 ]

26 голосов
/ 11 февраля 2012

Библиотека 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 (и удалите поля 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?

Затем можно просто создать явный индекс для идентификатора, найти пользователя по этому идентификатору (с помощью userSet @= (12423 :: Id)) и затем выполнить поиск.

7 голосов
/ 15 апреля 2014

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
5 голосов
/ 03 февраля 2015

Меня попросили написать ответ, используя Opaleye. На самом деле сказать особо нечего, поскольку код Opaleye достаточно стандартен, если у вас есть схема базы данных. Во всяком случае, здесь, при условии, что есть user_table со столбцами user_id, name и birthdate и 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 голосов
/ 16 февраля 2012

Другой принципиально иной подход к представлению реляционных данных используется пакетом базы данных haskelldb .Он не работает так же, как типы, которые вы описываете в своем примере, но он разработан, чтобы обеспечить безопасный для типов интерфейс для запросов SQL.Он имеет инструменты для генерации типов данных из схемы базы данных и наоборот.Типы данных, такие как те, которые вы описываете, работают хорошо, если вы всегда хотите работать с целыми строками.Но они не работают в ситуациях, когда вы хотите оптимизировать свои запросы, выбирая только определенные столбцы.Вот где подход HaskellDB может быть полезен.

3 голосов
/ 11 февраля 2012

У меня нет полного решения, но я предлагаю взглянуть на пакет ixset ; он предоставляет тип набора с произвольным числом индексов, с которыми можно выполнять поиск. (Он предназначен для использования с кислотным состоянием для стойкости.)

Вам все еще нужно вручную поддерживать «первичный ключ» для каждой таблицы, но вы могли бы значительно упростить его несколькими способами:

  1. Добавление параметра типа к Id, так что, например, User содержит Id User, а не просто Id. Это гарантирует, что вы не перепутаете Id s для отдельных типов.

  2. Создание абстрактного типа Id и предоставление безопасного интерфейса для генерации новых в некотором контексте (например, монады State, которая отслеживает релевантные IxSet и текущие максимальные Id) .

  3. Написание функций-оболочек, которые позволяют, например, предоставить User, где в запросах ожидается Id User, и которые обеспечивают инварианты (например, если каждый Message содержит ключ к действительному User, это может позволить вам найти соответствующий User без обработки значения Maybe; «небезопасность» содержится в этой вспомогательной функции).

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...