haskell relational-database type-safety in-memory-database

Modelado seguro de datos relacionales en Haskell



relational-database type-safety (5)

Encuentro muy común querer modelar datos relacionales en mis programas funcionales. Por ejemplo, al desarrollar un sitio web, es posible que desee tener la siguiente estructura de datos para almacenar información sobre mis usuarios:

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

A continuación, deseo almacenar datos sobre los mensajes que los usuarios publican en mi sitio:

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

Hay muchos problemas asociados con esta estructura de datos:

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

Estos problemas son manejables mientras que nuestros datos se pueden representar como un árbol. Por ejemplo, puedes refactorizar de esta manera:

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

Sin embargo, es posible que sus datos tengan la forma de un DAG (imagine cualquier 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 almacenando mis datos en Map s:

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

Este tipo de trabajo funciona, pero es inseguro y feo por varias razones:

  • Usted es solo una llamada de constructor Id 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.
  • Gestionar í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 suceder), pero me gustaría no reinventar la rueda.


IxSet es el boleto. Para ayudar a otros que puedan tropezar con este post aquí hay un ejemplo más completamente expresado,

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


La biblioteca de 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 "claves" para sus entradas de datos automáticamente.

Para su ejemplo, uno crearía relaciones uno a muchos para sus 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) ]

A continuación, puede encontrar el mensaje de un usuario en particular. Si ha creado un IxSet como este:

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 por user1 con:

user1Messages = toList $ messageSet @= user1

Si necesita encontrar al usuario de un mensaje, solo use la función de user como lo hace normalmente. Esto modela una relación de uno a muchos.

Ahora, para relaciones de muchos a muchos, 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)

... crea un índice con ixFun , que se puede usar con listas de índices. Al igual que:

instance Indexable Message where empty = ixSet [ ixFun users ] instance Indexable User where empty = ixSet [ ixFun messages ]

Para encontrar todos los mensajes de un usuario, todavía usa 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 ] ]

... puedes encontrar a 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, en su lugar debe crear un tipo de datos intermedios que modele la relación entre usuarios y mensajes, al igual que en SQL (y elimine los campos de users y messages ):

data UserMessage = UserMessage { umUser :: User, umMessage :: Message } instance Indexable UserMessage where empty = ixSet [ ixGen (Proxy :: Proxy User), ixGen (Proxy :: Proxy Message) ]

La creación de un conjunto de estas relaciones le permitiría consultar a los usuarios mediante mensajes y mensajes para los usuarios sin tener que actualizar nada.

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

EDITAR: con respecto a los "datos costosos que deben compararse": ixset solo compara los campos que especifica en su índice (para encontrar todos los mensajes de un usuario en el primer ejemplo, compara "al usuario completo").

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

Esto también se puede usar para resolver el problema del huevo y la gallina: ¿qué sucede si tienes una identificación, pero no un User , ni un Message ?

A continuación, puede simplemente crear un índice explícito para la identificación, encontrar al usuario mediante esa identificación (con userSet @= (12423 :: Id) ) y luego hacer la búsqueda.


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 una user_table con columnas user_id , name y birthdate , y una message_table con columnas user_id , time_stamp y content .

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

Una consulta de ejemplo que une la tabla del usuario con 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)


No tengo una solución completa, pero sugiero echarle 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 la persistencia).

Aún necesita mantener manualmente una "clave principal" para cada tabla, pero podría hacerlo mucho más fácil de varias maneras:

  1. Agregar un parámetro de tipo a Id , para que, por ejemplo, un User contenga un Id User lugar de solo un Id . Esto asegura que no confundas Id para tipos separados.

  2. Hacer el tipo de Id abstracto, y ofrecer una interfaz segura para generar nuevos en algún contexto (como una mónada de State que realiza un seguimiento del IxSet relevante y el Id más alto actual).

  3. Escritura de funciones de envoltura que le permiten, por ejemplo, proporcionarle al User dónde se espera un Id User en las consultas, y que imponen invariantes (por ejemplo, si cada Message contiene una clave para un User válido, podría permitirle buscar el correspondiente User sin manejar un valor Maybe ; la "inseguridad" está contenida dentro de esta función auxiliar).

Como nota adicional, en realidad no necesita una estructura de árbol para que los tipos de datos regulares funcionen, ya que pueden representar gráficos arbitrarios; sin embargo, esto hace que operaciones simples como actualizar el nombre de un usuario sean imposibles.


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.