Compare commits

..

7 Commits

Author SHA1 Message Date
83e39cbe6c Start implementing channel listing (#2) 2018-08-28 22:38:07 +03:00
e50e234747 Start working opds (#2)
- Relates to #2
2018-08-28 22:37:57 +03:00
0037d4691e #20 Update privacy settings on channels (#22)
- Closes #20
2018-08-15 23:25:06 +03:00
e61fb66c06 #13 Add visibility information to channels (#21)
- closes #13
2018-08-15 22:38:36 +03:00
8b04f3760e Complete functions (#15) 2018-08-15 22:11:30 +03:00
5ff629902c #12 Attach channels to books
- Closes #12
2018-08-14 23:50:01 +03:00
3d7f40eac9 #8 Attach tags to books 2018-08-14 23:50:01 +03:00
10 changed files with 246 additions and 37 deletions

View File

@ -25,6 +25,7 @@ executable ebook-manager
, Database , Database
, Database.Book , Database.Book
, Database.Channel , Database.Channel
, Database.Tag
, Database.Schema , Database.Schema
, Database.User , Database.User
, Datastore , Datastore

View File

@ -0,0 +1 @@
alter table channels add column visibility text NOT NULL default 'Private';

View File

@ -21,9 +21,9 @@ import ClassyPrelude
import Server.Auth import Server.Auth
import Servant.Auth as SA import Servant.Auth as SA
import Data.Aeson import Data.Aeson
import API.Channels (JsonChannel(..))
import Database.Book import Database.Book
import Database.Channel import Database.Channel
import Database.Tag
import Database import Database
import Control.Lens import Control.Lens
import Data.Generics.Product import Data.Generics.Product
@ -38,13 +38,15 @@ data JsonBook = JsonBook { identifier :: BookID
, contentType :: Text , contentType :: Text
, title :: Maybe Text , title :: Maybe Text
, description :: Maybe Text , description :: Maybe Text
, channels :: [JsonChannel] } , channels :: [Text]
, tags :: [Text] }
deriving (Generic, Show) deriving (Generic, Show)
data PostBook = PostBook { contentType :: Text data PostBook = PostBook { contentType :: Text
, title :: Maybe Text , title :: Maybe Text
, description :: Maybe Text , description :: Maybe Text
, channels :: [JsonChannel] } , channels :: [Text]
, tags :: [Text] }
deriving (Generic, Show) deriving (Generic, Show)
@ -95,9 +97,9 @@ postBookMetaHandler auth PostBook{..} = flip requireLoggedIn auth $ \SafeUser{us
putBookMetaHandler :: AuthResult SafeUser -> BookID -> JsonBook -> AppM JsonBook putBookMetaHandler :: AuthResult SafeUser -> BookID -> JsonBook -> AppM JsonBook
putBookMetaHandler auth bookId b@JsonBook{..} putBookMetaHandler auth bookId JsonBook{..}
| bookId == identifier = requireBookOwner auth bookId $ \SafeUser{username=owner} -> | bookId == identifier = requireBookOwner auth bookId $ \SafeUser{username=owner} ->
maybe (throwM err403) (const (return b)) =<< runDB (updateBook UpdateBook{..}) maybe (throwM err403) (return . view (super @JsonBook)) =<< runDB (updateBook UpdateBook{..})
| otherwise = throwM err403 | otherwise = throwM err403
listBooksHandler :: AuthResult SafeUser -> AppM [JsonBook] listBooksHandler :: AuthResult SafeUser -> AppM [JsonBook]
@ -105,5 +107,6 @@ listBooksHandler = requireLoggedIn $ \user -> do
runDB (usersBooks (view (field @"username") user) >>= mapM augment) runDB (usersBooks (view (field @"username") user) >>= mapM augment)
where where
augment Book{identifier=bookId,contentType,title,description} = do augment Book{identifier=bookId,contentType,title,description} = do
channels <- fmap (\Channel{..} -> JsonChannel{..}) <$> booksChannels bookId channels <- fmap (view (field @"channel")) <$> booksChannels bookId
tags <- fmap (view (field @"tag")) <$> booksTags bookId
pure JsonBook{identifier=bookId,..} pure JsonBook{identifier=bookId,..}

View File

@ -96,9 +96,23 @@ instance ToNode (Catalog 1) where
class Monad m => VersionedCatalog m (v :: Nat) where class Monad m => VersionedCatalog m (v :: Nat) where
getChannels :: SafeUser -> m (Catalog v) getChannels :: SafeUser -> m (Catalog v)
getBooks :: Channel.ChannelID -> SafeUser -> m (Catalog v)
instance VersionedCatalog AppM 1 where instance VersionedCatalog AppM 1 where
getChannels = getChannelsV1 getChannels = getChannelsV1
getBooks = getBooksV1
getBooksV1 :: Channel.ChannelID -> SafeUser -> AppM (Catalog 1)
getBooksV1 identifier SafeUser{} = do
updated <- liftIO getCurrentTime
let self = Rel ("/api/current" <> selfUrl)
start = Rel ("/api/current" <> startUrl)
selfUrl = pack . uriPath . linkURI $ safeLink (Proxy @(BaseAPI 1)) (Proxy @(ChannelCatalog 1)) identifier
startUrl = pack . uriPath . linkURI $ safeLink (Proxy @(BaseAPI 1)) (Proxy @(RootCatalog 1))
entries = mempty
pagination = Pagination Nothing Nothing
pure CatalogV1{..}
getChannelsV1 :: SafeUser -> AppM (Catalog 1) getChannelsV1 :: SafeUser -> AppM (Catalog 1)
getChannelsV1 SafeUser{username} = do getChannelsV1 SafeUser{username} = do
@ -133,7 +147,7 @@ handler auth = catalogRoot :<|> catalogChannels
where where
catalogChannels :: Channel.ChannelID -> AppM (Catalog v) catalogChannels :: Channel.ChannelID -> AppM (Catalog v)
-- Channel specific catalog returns tags inside the catalog -- Channel specific catalog returns tags inside the catalog
catalogChannels _ = throwM err403{errBody="Not implemented"} catalogChannels identifier = flip requireLoggedIn auth (getBooks identifier)
catalogRoot :: AppM (Catalog v) catalogRoot :: AppM (Catalog v)
-- catalog root returns channels -- catalog root returns channels
catalogRoot = flip requireLoggedIn auth getChannels catalogRoot = flip requireLoggedIn auth getChannels

View File

@ -11,6 +11,8 @@
{-# Language FlexibleInstances #-} {-# Language FlexibleInstances #-}
{-# Language TypeApplications #-} {-# Language TypeApplications #-}
{-# Language DataKinds #-} {-# Language DataKinds #-}
{-# Language DuplicateRecordFields #-}
{-# Language NamedFieldPuns #-}
module API.Channels (API, handler, JsonChannel(..)) where module API.Channels (API, handler, JsonChannel(..)) where
import Servant import Servant
@ -25,18 +27,37 @@ import Data.Aeson
import Control.Lens import Control.Lens
import Data.Generics.Product import Data.Generics.Product
data JsonChannel = JsonChannel { channel :: Text } deriving (Show, Generic) data JsonChannel = JsonChannel { channel :: Text
, visibility :: Visibility }
deriving (Show, Generic)
data UpdateChannel = UpdateChannel { identifier :: ChannelID
, channel :: Text
, visibility :: Visibility }
deriving (Show, Generic)
instance ToJSON JsonChannel instance ToJSON JsonChannel
instance FromJSON JsonChannel instance FromJSON JsonChannel
instance ToJSON UpdateChannel
instance FromJSON UpdateChannel
type API = Auth '[SA.BasicAuth, SA.Cookie, SA.JWT] SafeUser :> BaseAPI type API = Auth '[SA.BasicAuth, SA.Cookie, SA.JWT] SafeUser :> BaseAPI
type BaseAPI = "channels" :> ReqBody '[JSON] JsonChannel :> Put '[JSON] JsonChannel type BaseAPI = "channels" :> ReqBody '[JSON] JsonChannel :> Post '[JSON] UpdateChannel
:<|> "channels" :> Capture "channel_id" ChannelID :> ReqBody '[JSON] UpdateChannel :> Put '[JSON] UpdateChannel
:<|> "channels" :> Get '[JSON] [JsonChannel] :<|> "channels" :> Get '[JSON] [JsonChannel]
handler :: ServerT API AppM handler :: ServerT API AppM
handler user = newChannelHandler user :<|> listChannelsHandler user handler user = newChannelHandler user :<|> updateChannelHandler user :<|> listChannelsHandler user
requireChannelOwner :: AuthResult SafeUser -> ChannelID -> (SafeUser -> AppM a) -> AppM a
requireChannelOwner auth channelId f = flip requireLoggedIn auth $ \u@SafeUser{username} -> do
unlessM (runDB . channelExists $ channelId) $ throwM err404
runDB (isChannelOwner channelId username) >>= \o -> if o then f u else throwM err403
updateChannelHandler :: AuthResult SafeUser -> ChannelID -> UpdateChannel -> AppM UpdateChannel
updateChannelHandler auth channelId UpdateChannel{visibility} = requireChannelOwner auth channelId $ \_ -> do
mChannel <- fmap toChannel <$> runDB (updateChannelPrivacy channelId visibility)
maybe (throwM err403) return mChannel
listChannelsHandler :: AuthResult SafeUser -> AppM [JsonChannel] listChannelsHandler :: AuthResult SafeUser -> AppM [JsonChannel]
listChannelsHandler = requireLoggedIn $ \user -> listChannelsHandler = requireLoggedIn $ \user ->
@ -44,8 +65,11 @@ listChannelsHandler = requireLoggedIn $ \user ->
-- use the 'channel' accessor somehow or export it -- use the 'channel' accessor somehow or export it
fmap (\Channel{..} -> JsonChannel{..}) <$> runDB (userChannels (view (field @"username") user)) fmap (\Channel{..} -> JsonChannel{..}) <$> runDB (userChannels (view (field @"username") user))
newChannelHandler :: AuthResult SafeUser -> JsonChannel -> AppM JsonChannel newChannelHandler :: AuthResult SafeUser -> JsonChannel -> AppM UpdateChannel
newChannelHandler auth ch@JsonChannel{..} = flip requireLoggedIn auth $ \user -> do newChannelHandler auth JsonChannel{..} = flip requireLoggedIn auth $ \user -> do
$logInfo $ "Creating channel for user " <> pack (show user) $logInfo $ "Creating channel for user " <> pack (show user)
runDB (insertChannel (view (field @"username") user) channel) mChannel <- fmap toChannel <$> runDB (insertChannel (view (field @"username") user) channel visibility)
return ch maybe (throwM err403{errBody="Could not create the channel"}) return mChannel
toChannel :: Channel -> UpdateChannel
toChannel Channel{..} = UpdateChannel{..}

View File

@ -11,6 +11,7 @@ module Database
, fromRel , fromRel
, fromRels , fromRels
, toRel , toRel
, transaction
, SeldaT ) , SeldaT )
where where
@ -18,7 +19,7 @@ import Data.Generics.Product
import Control.Lens (view) import Control.Lens (view)
import Data.Pool (Pool, withResource) import Data.Pool (Pool, withResource)
import Database.Selda.Backend (SeldaConnection, runSeldaT, SeldaT) import Database.Selda.Backend (SeldaConnection, runSeldaT, SeldaT)
import Database.Selda (query, select) import Database.Selda (query, select, transaction)
import Database.Selda.Generic (gen, fromRel, fromRels, toRel) import Database.Selda.Generic (gen, fromRel, fromRels, toRel)
import ClassyPrelude import ClassyPrelude

View File

@ -18,11 +18,17 @@ module Database.Book
, BookID) where , BookID) where
import ClassyPrelude import ClassyPrelude
import Database.Schema import Database.Schema (books, users, Username, Book(..), BookID(..), UserID, HashDigest(..))
import Database import Database
import Database.Selda import Database.Selda
import Database.Selda.Generic import Database.Selda.Generic
import Control.Lens (view)
import Data.Generics.Product
import Database.Tag (booksTags, attachTag, clearTags)
import Database.Channel (booksChannels, attachChannel, clearChannels)
usersBooks :: (MonadSelda m, MonadMask m, MonadIO m) => Username -> m [Book] usersBooks :: (MonadSelda m, MonadMask m, MonadIO m) => Username -> m [Book]
usersBooks username = fromRels <$> query q usersBooks username = fromRels <$> query q
where where
@ -64,7 +70,10 @@ data UpdateBook = UpdateBook { identifier :: BookID
, contentType :: Text , contentType :: Text
, title :: Maybe Text , title :: Maybe Text
, description :: Maybe Text , description :: Maybe Text
, owner :: Username } , owner :: Username
, tags :: [Text]
, channels :: [Text] }
deriving (Show, Generic)
bookExists :: (MonadSelda m, MonadMask m, MonadIO m) => BookID -> m Bool bookExists :: (MonadSelda m, MonadMask m, MonadIO m) => BookID -> m Bool
bookExists identifier = not . null <$> query q bookExists identifier = not . null <$> query q
@ -87,17 +96,32 @@ bookOwner' identifier username = do
return (userId :*: bookId) return (userId :*: bookId)
updateBook :: (MonadSelda m, MonadMask m, MonadIO m) => UpdateBook -> m (Maybe UpdateBook) updateBook :: (MonadSelda m, MonadMask m, MonadIO m) => UpdateBook -> m (Maybe UpdateBook)
updateBook book@UpdateBook{..} = do updateBook UpdateBook{..} = do
mUserId <- query (bookOwner' identifier owner) clearTags identifier >> connectTags
forM (listToMaybe mUserId) $ \_userId -> do clearChannels identifier >> connectChannels
update_ (gen books) predicate (\b -> b `with` [ pContentType := literal contentType updateBook'
, pTitle := literal title getUpdateBook identifier owner
, pDescription := literal description ])
return book
where where
connectTags = mapM_ (attachTag owner identifier) tags
connectChannels = mapM_ (attachChannel owner identifier) channels
updateBook' = do
mUserId <- query (bookOwner' identifier owner)
forM_ (listToMaybe mUserId) $ \_userId -> do
update_ (gen books) predicate (\b -> b `with` [ pContentType := literal contentType
, pTitle := literal title
, pDescription := literal description ])
_ :*: _ :*: pContentType :*: pTitle :*: pDescription :*: _ = selectors (gen books) _ :*: _ :*: pContentType :*: pTitle :*: pDescription :*: _ = selectors (gen books)
predicate (bookId :*: _) = bookId .== literal identifier predicate (bookId :*: _) = bookId .== literal identifier
getUpdateBook :: (MonadMask m, MonadIO m, MonadSelda m) => BookID -> Username -> m (Maybe UpdateBook)
getUpdateBook bookId username = do
mBook <- getBook bookId username
forM mBook $ \Book{..} -> do
channels <- map (view (field @"channel")) <$> booksChannels bookId
tags <- map (view (field @"tag")) <$> booksTags bookId
return UpdateBook{owner=username,..}
setContent :: (MonadSelda m, MonadMask m, MonadIO m) => BookID -> Username -> HashDigest -> m () setContent :: (MonadSelda m, MonadMask m, MonadIO m) => BookID -> Username -> HashDigest -> m ()
setContent identifier owner digest = do setContent identifier owner digest = do
mOwner <- query (bookOwner' identifier owner) mOwner <- query (bookOwner' identifier owner)

View File

@ -1,8 +1,15 @@
{-# Language TypeApplications #-} {-# Language TypeApplications #-}
{-# Language DataKinds #-} {-# Language DataKinds #-}
{-# Language NamedFieldPuns #-}
module Database.Channel module Database.Channel
( userChannels ( userChannels
, insertChannel , insertChannel
, channelExists
, isChannelOwner
, updateChannelPrivacy
, attachChannel
, Visibility(..)
, clearChannels
, booksChannels , booksChannels
, Channel(..) , Channel(..)
, ChannelID ) , ChannelID )
@ -12,35 +19,95 @@ import ClassyPrelude
import Database.Schema import Database.Schema
import Database import Database
import Database.Selda import Database.Selda
import Database.Selda.Generic
import Control.Monad.Trans.Maybe
getChannel :: (MonadSelda m, MonadMask m, MonadIO m) => ChannelID -> m (Maybe Channel)
getChannel identifier = listToMaybe . fromRels <$> query q
where
q = do
ch@(channelId :*: _) <- select (gen channels)
restrict (channelId .== literal identifier)
return ch
channelExists :: (MonadSelda m, MonadMask m, MonadIO m) => ChannelID -> m Bool
channelExists identifier = not . null <$> getChannel identifier
isChannelOwner :: (MonadSelda m, MonadIO m, MonadMask m) => ChannelID -> Username -> m Bool
isChannelOwner identifier username = not . null <$> query q
where
q = do
userId :*: _ :*: username' :*: _ <- select (gen users)
channelId :*: _ :*: channelOwner :*: _ <- select (gen channels)
restrict (userId .== channelOwner)
restrict (username' .== literal username)
restrict (channelId .== literal identifier)
return channelId
userChannels :: (MonadMask m, MonadIO m) => Username -> SeldaT m [Channel] userChannels :: (MonadMask m, MonadIO m) => Username -> SeldaT m [Channel]
userChannels username = fromRels <$> query q userChannels username = fromRels <$> query q
where where
q = do q = do
userId :*: _ :*: username' :*: _ <- select (gen users) userId :*: _ :*: username' :*: _ <- select (gen users)
channel@(_ :*: _ :*: owner) <- select (gen channels) channel@(_ :*: _ :*: owner :*: _) <- select (gen channels)
restrict (owner .== userId) restrict (owner .== userId)
restrict (username' .== literal username) restrict (username' .== literal username)
return channel return channel
insertChannel :: (MonadMask m, MonadIO m) => Username -> Text -> SeldaT m () updateChannelPrivacy :: (MonadMask m, MonadIO m, MonadSelda m) => ChannelID -> Visibility -> m (Maybe Channel)
insertChannel username channel = do updateChannelPrivacy channelId visibility = do
mUserId <- listToMaybe <$> getUser void $ update (gen channels) predicate (\channel -> channel `with` [pVis := literal visibility])
void $ forM mUserId $ \userId -> getChannel channelId
insertUnless (gen channels) (doesNotExist userId) [ def :*: channel :*: userId ]
where where
doesNotExist userId (_ :*: channel' :*: userId') = channel' .== literal channel .&& userId' .== literal userId predicate (channelId' :*: _) = channelId' .== literal channelId
_ :*: _ :*: _ :*: pVis = selectors (gen channels)
insertChannel :: (MonadMask m, MonadIO m, MonadSelda m) => Username -> Text -> Visibility -> m (Maybe Channel)
insertChannel username channel visibility = runMaybeT $ do
userId <- MaybeT (listToMaybe <$> getUser)
channelId <- toChannelId <$> MaybeT (insertUnless (gen channels) (doesNotExist userId) [ def :*: channel :*: userId :*: visibility ])
MaybeT (listToMaybe . fromRels <$> query (q channelId))
where
q channelId = do
ch@(channelId' :*: _) <- select (gen channels)
restrict (channelId' .== literal channelId)
return ch
toChannelId = ChannelID . fromRowId
doesNotExist userId (_ :*: channel' :*: userId' :*: _) = channel' .== literal channel .&& userId' .== literal userId
getUser = query $ do getUser = query $ do
userId :*: _ :*: user :*: _ <- select (gen users) userId :*: _ :*: user :*: _ <- select (gen users)
restrict (user .== literal username) restrict (user .== literal username)
return userId return userId
booksChannels :: (MonadMask m, MonadIO m) => BookID -> SeldaT m [Channel] booksChannels :: (MonadSelda m, MonadMask m, MonadIO m) => BookID -> m [Channel]
booksChannels contentHash = fromRels <$> query q booksChannels bookId = fromRels <$> query q
where where
q = do q = do
channelId :*: contentHash' <- select (gen bookChannels) channelId :*: bookId' <- select (gen bookChannels)
ch@(channelId' :*: _) <- select (gen channels) ch@(channelId' :*: _) <- select (gen channels)
restrict (channelId .== channelId') restrict (channelId .== channelId')
restrict (contentHash' .== literal contentHash) restrict (bookId' .== literal bookId)
return ch return ch
attachChannel :: (MonadMask m, MonadIO m, MonadSelda m) => Username -> BookID -> Text -> m ()
attachChannel username bookId channel = do
mCh <- fromRels <$> query channelQ
forM_ mCh $ \Channel{identifier} ->
whenM (null <$> query (attachQ identifier)) $
void $ insertGen bookChannels [BookChannel identifier bookId]
where
attachQ channelId = do
(channelId' :*: bookId') <- select (gen bookChannels)
restrict (channelId' .== literal channelId .&& bookId' .== literal bookId)
return channelId'
channelQ = do
userId :*: _ :*: username' :*: _ <- select (gen users)
ch@(_ :*: channel' :*: owner :*: _) <- select (gen channels)
restrict (username' .== literal username)
restrict (owner .== userId)
restrict (channel' .== literal channel)
return ch
clearChannels :: (MonadMask m, MonadIO m, MonadSelda m) => BookID -> m Int
clearChannels bookId = deleteFrom (gen bookChannels) (\(_ :*: bookId') -> bookId' .== literal bookId)

View File

@ -44,7 +44,7 @@ newtype UserID = UserID {unUserID :: Int} deriving (Show)
newtype BookID = BookID {unBookID :: Int} deriving (Show, ToJSON, FromJSON, FromHttpApiData, Eq, Ord) newtype BookID = BookID {unBookID :: Int} deriving (Show, ToJSON, FromJSON, FromHttpApiData, Eq, Ord)
newtype ChannelID = ChannelID {unChannelID :: Int} deriving (Show, ToHttpApiData, FromHttpApiData) newtype ChannelID = ChannelID {unChannelID :: Int} deriving (Show, ToHttpApiData, FromHttpApiData, ToJSON, FromJSON)
newtype TagID = TagID {unTagID :: Int} deriving (Show) newtype TagID = TagID {unTagID :: Int} deriving (Show)
@ -124,9 +124,22 @@ data Tag = Tag { identifier :: TagID
, owner :: UserID } , owner :: UserID }
deriving (Show, Generic) deriving (Show, Generic)
data Visibility = Public | Private | Followers
deriving (Show, Read, Generic)
instance ToJSON Visibility
instance FromJSON Visibility
instance SqlType Visibility where
mkLit = LCustom . LText . pack . show
fromSql (SqlString x) = fromMaybe (error "fromSql: Not a valid visibility token") . readMay . unpack $ x
fromSql _ = error "fromSql: Not a valid visibility token"
defaultValue = mkLit Private
data Channel = Channel { identifier :: ChannelID data Channel = Channel { identifier :: ChannelID
, channel :: Text , channel :: Text
, owner :: UserID } , owner :: UserID
, visibility :: Visibility }
deriving (Show, Generic) deriving (Show, Generic)
tags :: GenTable Tag tags :: GenTable Tag

61
src/Database/Tag.hs Normal file
View File

@ -0,0 +1,61 @@
{-# Language TypeApplications #-}
{-# Language TypeOperators #-}
{-# Language DataKinds #-}
{-# Language DuplicateRecordFields #-}
{-# Language NamedFieldPuns #-}
module Database.Tag
( def
, booksTags
, attachTag
, upsertTag
, clearTags
, Tag(..) ) where
import ClassyPrelude
import Database.Schema
import Database
import Database.Selda
import Database.Selda.Generic
import Control.Monad.Trans.Maybe
upsertTag :: (MonadMask m, MonadIO m, MonadSelda m) => Username -> Text -> m (Maybe Tag)
upsertTag username tag = runMaybeT $ do
userId <- MaybeT (listToMaybe <$> query userQ)
void $ lift $ upsert (gen tags) (predicate userId) id [toRel (Tag def tag userId)]
MaybeT (listToMaybe . fromRels <$> query (tagQ userId))
where
predicate userId (_ :*: tag' :*: owner) = tag' .== literal tag .&& owner .== literal userId
tagQ userId = do
t@(_ :*: tag' :*: owner) <- select (gen tags)
restrict (tag' .== literal tag .&& owner .== literal userId)
return t
userQ = do
userId :*: _ :*: username' :*: _ <- select (gen users)
restrict (username' .== literal username)
return userId
booksTags :: (MonadMask m, MonadIO m, MonadSelda m) => BookID -> m [Tag]
booksTags bookId = fromRels <$> query q
where
q = do
tagId :*: bookId' <- select (gen bookTags)
tag@(tagId' :*: _) <- select (gen tags)
restrict (tagId .== tagId')
restrict (bookId' .== literal bookId)
return tag
attachTag :: (MonadMask m, MonadIO m, MonadSelda m) => Username -> BookID -> Text -> m ()
attachTag username bookId tag = do
maybeT <- upsertTag username tag
forM_ maybeT $ \Tag{identifier} -> do
whenM (null <$> query (tagQ identifier)) $
void $ insertGen bookTags [BookTag identifier bookId]
where
tagQ tagId = do
(tagId' :*: bookId') <- select (gen bookTags)
restrict (tagId' .== literal tagId .&& bookId' .== literal bookId)
return tagId'
clearTags :: (MonadMask m, MonadIO m, MonadSelda m) => BookID -> m Int
clearTags bookId = deleteFrom (gen bookTags) (\(_ :*: bookId') -> bookId' .== literal bookId)