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 delUser
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:
Agregar un parámetro de tipo a
Id
, para que, por ejemplo, unUser
contenga unId User
lugar de solo unId
. Esto asegura que no confundasId
para tipos separados.Hacer el tipo de
Id
abstracto, y ofrecer una interfaz segura para generar nuevos en algún contexto (como una mónada deState
que realiza un seguimiento delIxSet
relevante y elId
más alto actual).Escritura de funciones de envoltura que le permiten, por ejemplo, proporcionarle al
User
dónde se espera unId User
en las consultas, y que imponen invariantes (por ejemplo, si cadaMessage
contiene una clave para unUser
válido, podría permitirle buscar el correspondienteUser
sin manejar un valorMaybe
; 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.