32

Me parece muy común querer modelar datos relacionales en mis programas funcionales. Por ejemplo, al desarrollar un sitio web que puede querer tener la siguiente estructura de datos para almacenar información sobre mis usuarios:Modelado seguro de datos relacionales en Haskell

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

A continuación, quiero para almacenar datos acerca de los mensajes de los usuarios publican en mi sitio:

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

hay varios problemas asociados con esta estructura de datos:

  • no tenemos ninguna manera de distinguir a los usuarios con nombres similares y fechas de nacimiento.
  • Los datos del usuario se duplicarán en la serialización/deserialización
  • La comparación de los usuarios requiere la comparación de sus datos, lo que puede ser una operación costosa.
  • Las actualizaciones de los campos User son frágiles; puede olvidarse de actualizar todas las ocurrencias de User en su estructura de datos.

Estos problemas son manejables mientras que nuestros datos se pueden representar como un árbol. Por ejemplo, se puede refactorizar así:

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

Sin embargo, es posible tener sus datos en forma de un DAG (imaginar una relación de muchos a muchos), o incluso como un gráfico general (OK, tal vez no). En este caso, tiendo a simular la base de datos relacional mediante el almacenamiento de mis datos en Map s:

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

Este tipo de obras, pero no es seguro y fea por múltiples razones:

  • Usted está a sólo una Id llamada de constructor lejos de búsquedas sin sentido.
  • En la búsqueda obtiene Maybe a, pero a menudo la base de datos garantiza estructuralmente que hay un valor.
  • Es torpe.
  • Es difícil garantizar la integridad referencial de sus datos.
  • Administrar índices (que son muy necesarios para el rendimiento) y garantizar su integridad es aún más difícil y torpe.

¿Existe trabajo para superar estos problemas?

Parece que Template Haskell podría resolverlos (como suele ocurrir), pero me gustaría no reinventar la rueda.

Respuesta

21

La biblioteca ixset te ayudará con esto. Es la biblioteca que respalda la parte relacional de acid-state, que también maneja la serialización versionada de sus datos y/o garantías de concurrencia, en caso de que la necesite.

Lo que pasa con ixset es que administra automáticamente las "claves" para sus entradas de datos.

Para su ejemplo, se podría crear relaciones uno-a-muchos de los tipos de datos como este:

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

continuación, puede encontrar el mensaje de un usuario en particular. Si ha creado una IxSet así:

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

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

... a continuación, puede encontrar mensajes de user1 con:

user1Messages = toList $ messageSet @= user1 

Si usted necesita encontrar el usuario de un mensaje, sólo tiene que utilizar el user funciona como normal. Esto modela una relación de uno a muchos.

Ahora, para muchos-a-muchos relación, con una situación como esta:

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

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

... se crea un índice con ixFun, que se puede utilizar con las listas de índices.De este modo:

instance Indexable Message where 
    empty = ixSet [ ixFun users ] 

instance Indexable User where 
    empty = ixSet [ ixFun messages ] 

permite encontrar todos los mensajes de un usuario, que todavía utiliza la misma función:

user1Messages = toList $ messageSet @= user1 

Además, siempre que tenga un índice de usuarios:

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

... puede encontrar todos los usuarios para un mensaje:

messageFooUsers = toList $ userSet @= messageFoo 

Si no desea tener que actualizar los usuarios de un mensaje o los mensajes de un usuario al agregar un nuevo usuario/mensaje, debe crear un tipo de datos intermedios que modele la relación entre usuarios y mensajes, al igual que en SQL (y eliminar los campos y usersmessages):

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

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

Creación de un conjunto de estas relaciones sería entonces que se consulta para los usuarios de mensajes y mensajes para los usuarios sin tener que actualizar nada.

¡La biblioteca tiene una interfaz muy simple teniendo en cuenta lo que hace!

EDIT: En cuanto a sus "datos costosa que necesita ser comparada": ixset sólo compara los campos que se especifican en el índice (por lo que encontrar todos los mensajes de un usuario en el primer ejemplo, se compara "la usuario completo ").

Regula qué partes del campo indexado puede comparar alterando la instancia Ord. Por lo tanto, si comparar usuarios es costoso para usted, puede agregar un campo userId y modificar el instance Ord User para comparar solo este campo, por ejemplo.

Esto también se puede utilizar para resolver el problema de la gallina y el huevo: ¿qué sucede si tiene una identificación, pero ni una User, ni una Message?

Simplemente podría crear un índice explícito para la identificación, encontrar al usuario por esa identificación (con userSet @= (12423 :: Id)) y luego hacer la búsqueda.

+0

no el modelo que se muestra aquí compartir todas las desventajas de uno a muchos del modelo original de la pregunta? – ehird

+0

@ehird, realizo alrededor de 10 ediciones por minuto, así que creo que he respondido a tu preocupación en el camino. – dflemstr

+0

Sí, de hecho. (Por cierto, el estado ácido en realidad no depende de ixset; están diseñados para usarse juntos). – ehird

3

No tengo una solución completa, pero sugiero echar un vistazo al paquete ixset; proporciona un tipo de conjunto con un número arbitrario de índices con los que se pueden realizar búsquedas. (Está destinado a ser utilizado con acid-state para persistencia.)

Usted todavía tiene que mantener manualmente una "clave principal" para cada tabla, pero se puede hacer que sea mucho más fácil de varias maneras:

  1. Adición de un parámetro de tipo de Id, de modo que, por ejemplo, un User contiene un Id User en lugar de solo un Id. Esto garantiza que no mezcle Id s para tipos separados.

  2. Hacer el Id tipo abstracto, y ofreciendo una interfaz segura para generar otros nuevos en un cierto contexto (como una mónada State que realiza un seguimiento de la correspondiente IxSet y la corriente más alta Id).

  3. Escribiendo funciones de contenedor que le permiten, por ejemplo, suministrar una User, donde se espera un Id User en las consultas, y que hacen cumplir las invariantes (por ejemplo, si cada Message sostiene una llave para una válida User, lo que podría permitir a busque el User correspondiente sin manejar un valor de Maybe; la "inseguridad" está contenida dentro de esta función de ayuda).

Como una nota adicional, que no tienen necesariamente una estructura de árbol para tipos de datos regulares para trabajar, ya que pueden representar las gráficas arbitrarias; sin embargo, esto hace que operaciones simples como actualizar el nombre de un usuario sean imposibles.

5

Otro enfoque radicalmente diferente para representar datos relacionales es utilizado por el paquete de base de datos haskelldb. No funciona del mismo modo que los tipos que describe en su ejemplo, pero está diseñado para permitir una interfaz de tipo seguro para las consultas SQL. Tiene herramientas para generar tipos de datos a partir de un esquema de base de datos y viceversa. Los tipos de datos como los que describes funcionan bien si siempre quieres trabajar con filas completas. Pero no funcionan en situaciones en las que desea optimizar sus consultas seleccionando solo ciertas columnas. Aquí es donde el enfoque HaskellDB puede ser útil.

+0

Hoy en día sugiero Opaleye en lugar de HaskellDB (http://hackage.haskell.org/package/opaleye) pero Soy parcial porque lo escribí :) –

+0

@TomEllis: ¿Consideraría escribir una breve respuesta a esta pregunta usando Opaleye? –

+0

Claro, aquí tienes: http://stackoverflow.com/a/28307896/997606 –

5

IxSet es el boleto. Para ayudar a otras personas que puedan tropezar en este post aquí hay un ejemplo más plenamente expresada,

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

me han pedido que escriba una respuesta usando Opaleye. De hecho, no hay mucho que decir, ya que el código Opaleye es bastante estándar una vez que tienes un esquema de base de datos. De todos modos, aquí está, suponiendo que hay un user_table con las columnas user_id, name y birthdate, y un message_table con las columnas user_id, time_stamp y content.

Este tipo de diseño se explica con más detalle en 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" }) 

una consulta de ejemplo, que se une a la tabla de usuario a la tabla de mensajes en el campo 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) 
Cuestiones relacionadas