packages feed

Snusmumrik-0.0.1: DB.hs

{-# LANGUAGE FlexibleContexts #-}
module DB
  ( initDB, insertBook
  , findAuthorInitials, findAuthors, findGenres, findLangs, findBooks
  , Connection(), clone, commit, rollback, withTransaction, disconnect
  , catchSql, SqlError(..)) where

import Control.Applicative ((<$>))
import Data.Char (toUpper, toLower)
import Data.List (sort, nub, foldl1', inits, tails, intercalate, intersperse)
import Data.Maybe (maybeToList)
import System.FilePath
import System.Directory
import Text.Regex.TDFA ((=~))

import Database.HDBC
import Database.HDBC.Sqlite3
import Data.Convertible.Base (Convertible)

import Book
import Utils
import Views

dbdir :: IO FilePath
dbdir = getAppUserDataDirectory "snusmumrik"

dbfile :: IO FilePath
dbfile = do
  dir <- dbdir
  return $ dir </> "library.sqlite"

initDB :: IO Connection
initDB = do
  dir <- dbdir
  createDirectoryIfMissing True dir
  file <- dbfile
  db <- connectSqlite3 file
  createTables db
  return db

-- TODO: normalize DB schema, e.g. many authors, genres, langs per book

createTables :: Connection -> IO ()
createTables db = do
  run db "create table if not exists \
         \  books ( \
         \  id      INTEGER PRIMARY KEY AUTOINCREMENT, \
         \  title   TEXT, \
         \  lang    TEXT, \
         \  date    TEXT, \
         \  archive TEXT, \
         \  path    TEXT, \
         \  size    INTEGER, \
         \  unique (archive, path) on conflict ignore )" []
  run db "create table if not exists \
         \ authors ( \
         \  id      INTEGER PRIMARY KEY AUTOINCREMENT, \
         \  name    TEXT   unique on conflict ignore)" []
  run db "create table if not exists \
         \ bookauthors ( \
         \  author_id  INTEGER REFERENCES authors, \
         \  book_id    INTEGER REFERENCES books)" []
  run db "create table if not exists \
         \ genres ( \
         \  id      INTEGER PRIMARY KEY AUTOINCREMENT, \
         \  genre   TEXT unique on conflict ignore)" []
  run db "create table if not exists \
         \ bookgenres ( \
         \  genre_id   INTEGER REFERENCES genres, \
         \  book_id    INTEGER REFERENCES books)" []
  run db "create index if not exists \
         \ authorix on authors ( name )" []
  run db "create index if not exists \
         \ titleix on books ( title )" []
  run db "create index if not exists \
         \ genreix on genres ( genre )" []
  run db "create index if not exists \
         \ locationix on books ( archive, path )" []
  run db "create index if not exists \
         \ bookauthorix1 on bookauthors (author_id, book_id)" []
  run db "create index if not exists \
         \ bookauthorix2 on bookauthors (book_id, author_id)" []
  run db "create index if not exists \
         \ bookgenresix1 on bookgenres (genre_id, book_id)" []
  run db "create index if not exists \
         \ bookgenresix2 on bookgenres (book_id,genre_id)" []
  return ()

insertBook :: Connection -> Book -> IO ()
insertBook db book = do
  -- insert book
  run db "insert into books \
         \  ( title, lang, date, size, archive, path ) \
         \  values ( ?, ?, ?, ?, ?, ?)" bookValues
  -- or update
  -- TODO: implement DB update
  -- insert authors
  insertAuthors <- prepare db "insert into authors ( name ) values ( ? )"
  executeMany insertAuthors $ map (:[]) $ authorNames
  -- authors to books correspondence
  insertBookAuthors <- prepare db $ "insert into bookauthors \
         \ ( author_id, book_id ) \
         \ select authors.id, books.id \
         \   from authors, books \
         \   where books.archive = ? and books.path = ? \
         \     and authors.name = ?"
  executeMany insertBookAuthors bookAuthors
  -- insert genres
  insertGenre <- prepare db "insert into genres ( genre ) values ( ? )"
  executeMany insertGenre $ map (:[]) genreNames
  -- genres to books correspondence
  insertBookGenres <- prepare db $ "insert into bookgenres \
         \ ( genre_id, book_id ) \
         \ select g.id, b.id \
         \   from genres g, books b \
         \   where b.archive = ? and b.path = ? \
         \     and g.genre = ?"
  executeMany insertBookGenres bookGenres
  return ()
  where bookValues = (map fToSql [ title, lang, date ])
                     ++ [fToSql size]
                     ++ (map fToSql [ archive, path ])
        fToSql :: (Convertible a SqlValue) => (Book -> a) -> SqlValue
        fToSql f = toSql $ f book
        -- archive, path must be last:
        bookLoc = reverse . take 2 . reverse $ bookValues
        authorNames :: [SqlValue]
        authorNames = map toSql $ authors book
        bookAuthors :: [[SqlValue]]
        bookAuthors = map (\a -> bookLoc ++ [a]) authorNames
        genreNames :: [SqlValue]
        genreNames = map toSql $ genres book
        bookGenres :: [[SqlValue]]
        bookGenres = map (\g -> bookLoc ++ [g]) genreNames

-- | Find initials of all authors who satisfy given choices.
findAuthorInitials :: Connection -> [String] -> [View] -> IO [String]
findAuthorInitials _ cs _ | dbg ("findAuthorInitials: " ++ (showL' cs)) = stub
findAuthorInitials db choices views = do
  let qsort = "order by authors.name"
  let (q,params) = buildQuery AuthorInitial choices views []
  rows <- quickQuery' db (trace' (q ++ qsort)) params ::IO [[SqlValue]]
  let names = onlyJusts . map fromSql . column 0 $ rows
  let inis = nub . map (replaceX . toLower) . concatMap head' $ names
  return . trace' . map (:[]) $ inis
  where
  replaceX c | (toLower c) `notElem` romanOrCyrillic = '_'
             | otherwise                             = c
  head' :: [a] -> [a]
  head' [] = []
  head' (x:_) = [x]

-- sqlite does not support case conversion nor cares enough about char ranges
romanOrCyrillic :: [Char]
romanOrCyrillic = ['a'..'z'] ++ ['а'..'я'] ++ ['ё']
-- hack around sqlite limitations and HDBC not being friendly with substr()
startsWith :: [Char] -> String -> String
startsWith chars what =
  let chars' = (map toLower chars) ++ (map toUpper chars)
      anyStart = intercalate " or " $ map (\x->what++" like '"++[x]++"%'") chars'
   in "( " ++ anyStart ++ " )"

-- | Find all authors who satisfy given choices.
findAuthors :: Connection -> [String] -> [View] -> IO [String]
findAuthors _ cs _ | dbg ("findAuthors: " ++ (showL' cs)) = stub
findAuthors db choices views = do
  let qsort = "order by authors.name"
  let (q,params) = buildQuery Author choices views []
  rows <- quickQuery' db (trace' (q ++ qsort)) (trace' params) :: IO [[SqlValue]]
  return . map fixname . onlyJusts . map fromSql . column 1 $ rows

-- | Find all books which satisfy given choices.
findGenres :: Connection -> [String] -> [View] -> IO [String]
findGenres _ cs _ | dbg ("findGenres: " ++ (showL' cs)) = stub
findGenres db choices views = do
  -- only genres with 50+ books (avoids most of the inconsistent metadata)
  let qgroup = "group by genres.id \
               \having count(bookgenres.book_id) > 50 \
               \order by genres.genre"
  let (q,params) = buildQuery Genre choices views ["bookgenres"]
  rows <- quickQuery' db (trace' (q ++ qgroup)) (trace' params) :: IO [[SqlValue]]
  return . map fixname . onlyJusts . map fromSql . column 1 $ rows

findLangs :: Connection -> [String] -> [View] -> IO [String]
findLangs _ cs _ | dbg ("findLangs: " ++ (showL' cs)) = stub
findLangs db choices views = do
  let (q,params) = buildQuery Lang choices views []
  rows <- quickQuery' db (trace' q) (trace' params) :: IO [[SqlValue]]
  let langs = onlyJusts . map fromSql . column 0 $ rows
  -- skip languages which are not two-letter lower-case codes
  return . map fixname . filter ( =~ "^[a-z]{2}$") $ langs

-- | Find all books which satisfy given choices.
findBooks :: Connection -> [String] -> [View] -> IO [Book]
findBooks _ cs _ | dbg ("findBooks: " ++ (showL' cs)) = stub
findBooks db choices views = do
  let qsort   = "order by books.title"
  let (q,params) = buildQuery (last views) choices views []
  rows <- quickQuery' db (trace' (q ++ qsort)) (trace' params) :: IO [[SqlValue]]
  let books = onlyJusts $ map toBook rows :: [(Int,Book)]
  mapM (addAuthorsAndGenres db) books :: IO [Book]

buildQuery :: View     -- ^ what is requested
           -> [String] -- ^ choices made
           -> [View]   -- ^ views for previous choices
           -> [String] -- ^ extra table to select from, usually []
           -> (String,[SqlValue]) -- ^ (query, positional params)
buildQuery w cs ss _
  | dbg ("buildQuery: " ++ (show w) ++ " " ++ (show $ zip cs ss)) = stub
buildQuery what choices views xtratables =
  let cvs = zip choices views
      predicates = map fromWhere cvs
      (begin,basetbl) = selectCols what
      joins = joinWhere $ basetbl:(concatMap fst3 predicates) ++ xtratables
      fws = joinClauses $ joins:predicates
      tables = nub $ basetbl:(fst3 fws) ++ xtratables
      q   = intercalate " " $ [begin]
                           ++ ("from":(intersperse "," tables))
                           ++ (whereClause $ snd3 fws)
  in (q ++ " ", map toSql $ trd3 fws)
  where
  -- column order is important, some fragile code depends on it (see column)
  selectCols (Author) = ( "select distinct authors.id, authors.name"
                          , "authors" )
  selectCols (AuthorInitial) = ( "select distinct authors.name"
                          , "authors" )
  selectCols (Title)  = ( "select distinct books.id, books.title, books.lang, \
                            \  books.date, books.archive, books.path, books.size"
                          , "books" )
  selectCols (Genre)  = ( "select distinct genres.id, genres.genre"
                          , "genres" )
  selectCols (Lang)  = ( "select distinct books.lang", "books" )
  whereClause [] = [""]
  whereClause ps = "where":(intersperse "and" ps)

-- | Produce predicative clauses as ([tables],[conditions],[positional_params])
fromWhere :: (String,View) -> ([String],[String],[String])
fromWhere (c, Author) =
               ( ["authors"]
               , ["authors.name = ?"]
               , [unfixname c])
fromWhere (c, Genre) =
               ( ["genres"]
               , ["genres.genre = ?"]
               , [unfixname c])
fromWhere (c, Lang) =
               ( ["books"]
               , ["books.lang = ?"]
               , [c])
fromWhere (c, Title) =
               let n = unfixname $ titleFromFileName c in
               ( ["books"]
               , ["books.title = ?"]
               , [unfixname n])
fromWhere (c:_, AuthorInitial)
  | c /= '_' = ( ["authors"]
               , [ "(authors.name like ? or authors.name like ?)"]
               , [ (toLower c):"%", (toUpper c):"%" ])
  | c == '_' = ( ["authors"]
               , ["not " ++ (startsWith romanOrCyrillic "authors.name")]
               , [])
fromWhere _  = ([], [], [])

-- | Produce join clauses as ([tables],[conditions],[positional_params])
joinWhere :: [String] -- ^ tables used
          -> ([String],[String],[String]) -- ^ necessary join clause
joinWhere tables =
  let joins = map (uncurry joinWhere') (combos tables)
  in  joinClauses joins
  where
  combos xs =
    let xs' = nub . sort $ xs
    in if length xs' < 2
        then [] -- nothing to join
        else [ (last h,t') | (h,t) <- init.tail $ zip (inits xs') (tails xs')
                           , t' <- t ]
  joinWhere' "authors" "books" = ( [ "authors", "bookauthors", "books" ]
                                 , [ "authors.id = bookauthors.author_id"
                                   , "bookauthors.book_id = books.id" ]
                                 , [])
  joinWhere' "authors" "genres" = ( [ "authors", "bookauthors", "bookgenres", "genres" ]
                                , [ "authors.id = bookauthors.author_id"
                                  , "bookauthors.book_id = bookgenres.book_id"
                                  , "bookgenres.genre_id = genres.id" ]
                                , [])
  joinWhere' "books" "genres" = ( [ "genres", "bookgenres", "books" ]
                                , [ "genres.id = bookgenres.genre_id"
                                  , "bookgenres.book_id = books.id" ]
                                , [])
  joinWhere' "bookgenres" "genres" = ( [ "genres", "bookgenres" ]
                                , [ "genres.id = bookgenres.genre_id" ]
                                , [])
  joinWhere' _ _ = ([], [], [])

joinClauses :: [([String],[String],[String])] -> ([String],[String],[String])
joinClauses [] = ([], [], [])
joinClauses cs = foldl1' (liftT2 (++)) cs

addAuthorsAndGenres :: Connection -> (Int, Book) -> IO Book
addAuthorsAndGenres _ b | dbg ("addAuthorsAndGenres: " ++ (show $ fst b)) = stub
addAuthorsAndGenres db (bid, book) = do
  let authorsQ = "select distinct a.name \
               \  from authors a \
               \       join bookauthors ba on a.id = ba.author_id \
               \  where ba.book_id = ? \
               \  order by a.name"
  let genresQ = "select distinct g.genre \
               \  from genres g \
               \       join bookgenres bg on bg.genre_id = g.id \
               \  where bg.book_id = ? \
               \  order by g.genre"
  authors' <- column 0 <$> quickQuery' db authorsQ [toSql bid]
  genres'  <- column 0 <$> quickQuery' db genresQ [toSql bid]
  return $ book { authors = sort $ map fromSql authors'
                , genres = sort $ map fromSql genres' }

toBook :: [SqlValue] -> Maybe (Int, Book)
toBook vs | length vs /= 7 = Nothing
toBook [id',title',lang',date',archive',path',size']
  | otherwise =
  let book = unknownBook {
      title = fromSql title'
    , date = fromSql date'
    , lang = fromSql lang'
    , archive = fromSql archive'
    , path = fromSql path'
    , size = fromSql size' }
  in Just (fromSql id', book)
toBook _ = Nothing

onlyJusts :: [Maybe a] -> [a]
onlyJusts = concatMap maybeToList

column :: Int -> [[a]] -> [a]
column n xss = onlyJusts $ map (maybeNth n) xss