Modélisation sûre des données relationnelles dans Haskell

Je trouve très courant de vouloir modéliser des données relationnelles dans mes programmes fonctionnels. Par exemple, lors du développement d'un site web, Je peux avoir la structure de données suivante pour stocker des informations sur mes utilisateurs:

data User = User 
  { name :: String
  , birthDate :: Date
  }

Ensuite, je veux stocker des données sur les messages que les utilisateurs postent sur mon site:

data Message = Message
  { user :: User
  , timestamp :: Date
  , content :: String
  }

Il y a plusieurs problèmes associés à cette structure de données:

  • Nous n'avons aucun moyen de distinguer les utilisateurs ayant des noms et des dates de naissance similaires.
  • Le les données utilisateur seront dupliquées lors de la sérialisation / désérialisation
  • comparer les utilisateurs nécessite de comparer leurs données ce qui peut être une opération coûteuse.
  • les mises à jour des champs de User sont fragiles-vous pouvez oublier de mettre à jour toutes les occurrences de User dans votre structure de données.

Ces problèmes sont gérables alors que nos données peuvent être représentées comme un arbre. Par exemple, vous pouvez refactoriser comme ceci:

data User = User
  { name :: String
  , birthDate :: Date
  , messages :: [(String, Date)] -- you get the idea
  }

Cependant, il est possible d'avoir vos données en forme de Dag (imaginez toute relation plusieurs à plusieurs), ou même comme un graphique Général (OK, peut-être pas). Dans ce cas, j'ai tendance à simuler la base de données relationnelle en stockant mes données dans Map s:

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

Ce genre de travaux, mais est dangereux et laid pour plusieurs raisons:

  • Vous êtes juste un appel de constructeur Id loin des recherches absurdes.
  • lors de la recherche, vous obtenez Maybe a, mais souvent la base de données garantit structurellement qu'il existe une valeur.
  • , Il est maladroit.
  • c'est dur pour assurer l'intégrité référentielle des données.
  • Gérer les indices (qui sont très nécessaires à la performance) et assurer leur intégrité est encore plus difficile et maladroit.

Existe-t-il des travaux pour surmonter ces problèmes?

Il semble que Template Haskell puisse les résoudre (comme d'habitude), mais je voudrais ne pas réinventer la roue.

35
demandé sur JHannes 2012-02-11 00:11:23

5 réponses

Le ixset bibliothèque vous aidera avec cela. C'est la bibliothèque qui soutient la partie relationnelle de acid-state, qui gère également la sérialisation versionnée de vos données et / ou les garanties de concurrence, au cas où vous en auriez besoin.

La chose à propos de ixset est qu'il gère automatiquement les "clés" pour vos entrées de données.

Pour votre exemple, on créerait des relations un à plusieurs pour vos types de données comme ceci:

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) ]

Vous pouvez alors trouver le message de un utilisateur particulier. Si vous avez construit un IxSet comme ceci:

user1 = User "John Doe" undefined
user2 = User "John Smith" undefined

messageSet =
  foldr insert empty
  [ Message user1 undefined "bla"
  , Message user2 undefined "blu"
  ]

... vous pouvez ensuite trouver des messages par user1 avec:

user1Messages = toList $ messageSet @= user1

Si vous avez besoin de trouver l'utilisateur d'un message, il suffit d'utiliser le user fonctionner normalement. Cela modélise une relation un à plusieurs.

Maintenant, pour les relations de plusieurs à plusieurs, avec une situation comme celle-ci:

data User =
  User
  { name :: String
  , birthDate :: Date
  , messages :: [Message]
  } deriving (Ord, Typeable)

data Message =
  Message
  { users :: [User]
  , timestamp :: Date
  , content :: String
  } deriving (Ord, Typeable)

... vous créez un index avec ixFun, qui peut être utilisé avec des listes d'index. Comme ça:

instance Indexable Message where
  empty = ixSet [ ixFun users ]

instance Indexable User where
  empty = ixSet [ ixFun messages ]

Pour trouver tous les messages par un utilisateur, vous utilisez toujours la même fonction:

user1Messages = toList $ messageSet @= user1

De plus, à condition que vous ayez un index d'utilisateurs:

userSet =
  foldr insert empty
  [ User "John Doe" undefined [ messageFoo, messageBar ]
  , User "John Smith" undefined [ messageBar ]
  ]

... vous pouvez trouver tous les utilisateurs d'un message:

messageFooUsers = toList $ userSet @= messageFoo

Si vous ne voulez pas avoir à mettre à jour les utilisateurs d'un message ou les messages d'un utilisateur lors de l'ajout d'un nouvel utilisateur/message, vous devez plutôt créer un type de données intermédiaire qui modélise la relation entre les utilisateurs et les messages, comme dans SQL (et supprimer les users et messages les champs):

data UserMessage = UserMessage { umUser :: User, umMessage :: Message } 

instance Indexable UserMessage where
  empty = ixSet [ ixGen (Proxy :: Proxy User), ixGen (Proxy :: Proxy Message) ]

La création d'un ensemble de ces relations vous permettrait alors d'interroger les utilisateurs par des messages et des messages pour les utilisateurs sans avoir à mettre à jour quoi que ce soit.

La bibliothèque a une interface très simple compte tenu de ce qu'elle fait!

EDIT: en ce qui concerne vos "données coûteuses à comparer": ixset ne compare que les champs que vous spécifiez dans votre index (donc pour trouver tous les messages d'un utilisateur dans le premier exemple, il compare " l'ensemble utilisateur").

Vous réglez les parties du champ indexé qu'il compare en modifiant l'instance Ord. Ainsi, si la comparaison d'utilisateurs est coûteuse pour vous, vous pouvez ajouter un champ userId et modifier le instance Ord User pour ne Comparer que ce champ, par exemple.

Cela peut également être utilisé pour résoudre le problème du poulet et de l'œuf: que faire si vous avez un id, mais ni un User, ni un Message?

, Vous pouvez simplement créer un index explicite pour l'id de l'utilisateur par l'id (avec userSet @= (12423 :: Id)) et puis faire de la recherche.

24
répondu dflemstr 2012-02-10 22:05:30

IxSet est le ticket. Pour aider les autres qui pourraient trébucher sur ce post, voici un exemple plus complet,

{-# 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
6
répondu F. P. Freely 2014-04-15 03:33:10

Une autre approche radicalement différente de la représentation des données relationnelles est utilisée par le paquet de base de données haskelldb. Cela ne fonctionne pas tout à fait comme les types que vous décrivez dans votre exemple, mais il est conçu pour permettre une interface de type sécurisé aux requêtes SQL. Il dispose d'outils pour générer des types de données à partir d'un schéma de base de données et vice versa. Les types de données tels que ceux que vous décrivez fonctionnent bien si vous voulez toujours travailler avec des lignes entières. Mais ils ne fonctionnent pas dans des situations où vous voulez optimisez vos requêtes en sélectionnant uniquement certaines colonnes. C'est là que L'approche HaskellDB peut être utile.

5
répondu mightybyte 2012-02-16 17:59:38

On m'a demandé d'écrire une réponse en utilisant Opaleye. En fait, il n'y a pas beaucoup à dire, car le code Opaleye est assez standard une fois que vous avez un schéma de base de données. De toute façon, ici, il est, en supposant qu'il est un user_table avec des colonnes user_id, name et birthdate, et message_table, avec colonnes user_id, time_stamp et content.

Ce type de conception est expliqué plus en détail dans le tutoriel de base 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" })

Exemple de requête qui relie la table utilisateur à la table des messages sur le champ 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
répondu Tom Ellis 2015-02-03 20:14:07

Je n'ai pas de solution complète, mais je suggère de jeter un oeil au paquet ixset; Il fournit un type set avec un nombre arbitraire d'indices avec lesquels les recherches peuvent être effectuées. (Il est destiné à être utilisé avec acid-state pour la persistance.)

Vous devez toujours maintenir manuellement une "clé primaire" pour chaque table, mais vous pouvez le rendre beaucoup plus facile de plusieurs façons:

  1. Ajout d'un paramètre de type Id, de sorte que, par exemple, un User contient un Id User plutôt que juste un Id. Cela garantit que vous ne mélangez pas Id s pour des types distincts.

  2. Rendre le type Id abstrait, et offrir une interface sûre pour en générer de nouveaux dans un contexte donné (comme une monade State qui garde la trace du IxSet pertinent et du Id actuel le plus élevé).

  3. Écrire des fonctions wrapper qui vous permettent, par exemple, de fournir un User où un Id User est attendu dans les requêtes, et qui appliquent des invariants (par exemple, si chaque Message contient une clé à un User valide, cela pourrait vous permettre de rechercher le User correspondant sans gérer une valeur Maybe; le "unsafety" est contenu dans cette fonction d'assistance).

Comme note supplémentaire, vous n'avez pas besoin d'une arborescence pour que les types de données réguliers fonctionnent, car ils peuvent représenter des graphiques arbitraires; cependant, cela rend les opérations simples comme la mise à jour du nom d'un utilisateur impossible.

3
répondu ehird 2012-02-10 20:51:42