32

Trovo molto comune voler modellare i dati relazionali nei miei programmi funzionali. Ad esempio, quando lo sviluppo di un sito web mi può essere utile avere la seguente struttura dei dati per memorizzare informazioni sui miei utenti:Modellazione sicura dei dati relazionali in Haskell

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

Avanti, voglio memorizzare i dati sui messaggi utenti post sul mio sito:

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

ci sono molteplici problemi associati a questa struttura di dati:

  • non abbiamo alcun modo di distinguere gli utenti con nomi simili e date di nascita.
  • I dati dell'utente verranno duplicati in caso di serializzazione/deserializzazione
  • Il confronto degli utenti richiede il confronto dei dati che possono essere un'operazione costosa.
  • Gli aggiornamenti nei campi di User sono fragili: è possibile dimenticare di aggiornare tutte le occorrenze di User nella struttura dei dati.

Questi problemi sono gestibili mentre i nostri dati possono essere rappresentati come un albero. Ad esempio, è possibile refactoring in questo modo:

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

Tuttavia, è possibile avere i dati a forma di DAG (immaginare una relazione molti-a-molti), o anche come un grafico generale (OK, forse non). In questo caso, tendo a simulare il database relazionale per la memorizzazione dei miei dati in Map s:

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

Questo tipo di opere, ma è pericoloso e brutto per molteplici ragioni:

  • Tu sei solo un Chiamata del costruttore Id lontano da ricerche senza senso.
  • Nella ricerca si ottiene Maybe a, ma spesso il database garantisce strutturalmente che esista un valore.
  • È impacciato.
  • È difficile garantire l'integrità referenziale dei dati.
  • Gestire gli indici (che sono molto necessari per le prestazioni) e garantire la loro integrità è ancora più difficile e più clamoroso.

Esiste già un lavoro per superare questi problemi?

Sembra che Template Haskell possa risolverli (come al solito), ma mi piacerebbe non reinventare la ruota.

risposta

21

La libreria ixset ti aiuterà con questo. È la libreria che supporta la parte relazionale di acid-state, che gestisce anche la serializzazione delle versioni dei dati e/o le garanzie di concorrenza, nel caso sia necessario.

La cosa su ixset è che gestisce automaticamente "chiavi" per le voci di dati.

Per esempio, si potrebbe creare relazioni uno-a-molti per i vostri tipi di dati come questo:

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

È quindi possibile trovare il messaggio di un particolare utente. Se si è costruito un IxSet come questo:

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

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

... quindi è possibile trovare i messaggi di user1 con:

user1Messages = toList $ messageSet @= user1 

Se hai bisogno di trovare l'utente di un messaggio, basta utilizzare il user funziona come normale. Questo modella una relazione uno-a-molti.

Ora, per molti-a-molti rapporti, con una situazione come questa:

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

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

... si crea un indice con ixFun, che può essere utilizzato con le liste di indici.In questo modo:

instance Indexable Message where 
    empty = ixSet [ ixFun users ] 

instance Indexable User where 
    empty = ixSet [ ixFun messages ] 

per trovare tutti i messaggi di un utente, si utilizza ancora la stessa funzione:

user1Messages = toList $ messageSet @= user1 

Inoltre, purché si disponga di un indice di utenti:

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

... puoi trovare tutti gli utenti per un messaggio:

messageFooUsers = toList $ userSet @= messageFoo 

Se non si desidera aggiornare gli utenti di un messaggio o i messaggi di un utente quando si aggiunge un nuovo utente/messaggio, si dovrebbe invece creare un tipo di dati intermedio che modella la relazione tra utenti e messaggi, proprio come in SQL (e rimuovere i campi users e messages):

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

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

Creazione di un insieme di questi rapporti sarebbe poi ti permettono di ricerca per gli utenti di messaggi e messaggi per gli utenti senza dover aggiornare nulla.

La libreria ha un'interfaccia molto semplice considerando quello che fa!

EDIT: quanto riguarda la tua "Dati costosa che deve essere confrontato": ixset confronta solo i campi che si specificano nel vostro indice (in modo da trovare tutti i messaggi di un utente nel primo esempio, si confronta ", il intero utente ").

È possibile regolare quali parti del campo indicizzato vengono confrontate modificando l'istanza Ord. Pertanto, se confrontare gli utenti è costoso, puoi aggiungere un campo userId e modificare lo instance Ord User per confrontare solo questo campo, ad esempio.

Questo può essere utilizzato anche per risolvere il problema di gallina e uova: cosa succede se si dispone di un ID, ma non uno User, né uno Message?

Si potrebbe quindi semplicemente creare un indice esplicito per l'id, trovare l'utente da tale id (con userSet @= (12423 :: Id)) e quindi effettuare la ricerca.

+0

non il modello mostrato qui condividere tutti gli svantaggi uno-a-molti del modello originale dalla domanda? – ehird

+0

@ehird, faccio circa 10 modifiche al minuto, quindi penso di aver risposto alla tua preoccupazione lungo la strada. – dflemstr

+0

Sì, davvero. (BTW, lo stato acido non dipende in realtà da ixset, sono solo progettati per essere usati insieme.) – ehird

3

Non ho una soluzione completa, ma suggerisco di dare un'occhiata al pacchetto ixset; fornisce un tipo di set con un numero arbitrario di indici con cui possono essere eseguite le ricerche. (È destinato all'uso con acid-state per la persistenza.)

Non ancora necessario mantenere manualmente una "chiave primaria" per ogni tabella, ma si potrebbe rendere molto più facile in alcuni modi:

  1. Aggiunta di un parametro di tipo a Id, in modo che, ad esempio, uno User contiene uno Id User anziché un Id. Ciò garantisce che non si mischino i tipi Id per tipi separati.

  2. Portando il Id tipo astratto, e offrendo un'interfaccia sicuro per generare nuovi in ​​alcuni contesti (come un State monade che tiene traccia del relativo IxSet e la corrente massima Id).

  3. Scrittura di funzioni wrapper che consentono, ad esempio, la fornitura di un User dove un Id User è previsto nelle query, e che applicano invarianti (ad esempio, se ogni Message contiene una chiave per una valida User, potrebbe consentire di cercare il corrispondente User senza gestire un valore Maybe, la "non sicurezza" è contenuta in questa funzione di supporto).

Come nota aggiuntiva, non è effettivamente necessaria una struttura ad albero per il normale funzionamento dei tipi di dati, poiché possono rappresentare grafici arbitrari; tuttavia, questo rende semplici le operazioni come l'aggiornamento del nome di un utente impossibile.

5

Un altro approccio radicalmente diverso per rappresentare i dati relazionali viene utilizzato dal pacchetto di database haskelldb. Non funziona come i tipi che descrivi nel tuo esempio, ma è progettato per consentire un'interfaccia sicura per tipo alle query SQL. Ha strumenti per generare tipi di dati da uno schema di database e viceversa. Tipi di dati come quelli che descrivi funzionano bene se vuoi sempre lavorare con intere file. Ma non funzionano in situazioni in cui si desidera ottimizzare le query selezionando solo determinate colonne. È qui che l'approccio HaskellDB può essere utile.

+0

Al giorno d'oggi suggerirei Opaleye invece di HaskellDB (http://hackage.haskell.org/package/opaleye) ma Sono di parte perché l'ho scritto :) –

+0

@TomEllis: Prenderesti in considerazione la possibilità di scrivere una breve risposta a questa domanda usando Opaleye? –

+0

Certo, ecco qua: http://stackoverflow.com/a/28307896/997606 –

5

IxSet è il biglietto. Per aiutare gli altri che potrebbero incappare in questo post, ecco un esempio più pienamente espresso,

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

mi è stato chiesto di scrivere una risposta utilizzando Opaleye. In realtà non c'è molto da dire, dato che il codice Opaleye è abbastanza standard una volta che si ha uno schema di database. Comunque, qui è, supponendo che non v'è un user_table con colonne user_id, name e birthdate, e un message_table con colonne user_id, time_stamp e content.

Questo tipo di progettazione è spiegato in modo più dettagliato in 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" }) 

un esempio di query che unisce la tabella degli utenti per la tabella dei messaggi sul user_id campo:

usersJoinMessages :: Query (User, Message) 
usersJoinMessages = proc() -> do 
    aUser <- queryTable userTable -<() 
    aMessage <- queryTable messageTable -<() 

    restrict -< unUserId (userId aUser) .== unUserId (user aMessage) 

    returnA -< (aUser, aMessage)