diff --git a/LICENSE b/LICENSE
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 The MIT License (MIT)
 
-Copyright (c) 2016-2020 itchyny
+Copyright (c) 2016-2025 itchyny
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/Main.hs b/Main.hs
new file mode 100644
--- /dev/null
+++ b/Main.hs
@@ -0,0 +1,7 @@
+module Main where
+
+import Paths_qhs (version)
+import Qhs qualified
+
+main :: IO ()
+main = Qhs.main version
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,115 @@
+# qhs
+[![CI Status](https://github.com/itchyny/qhs/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/itchyny/qhs/actions?query=branch:main)
+
+### SQL queries on CSV and TSV files
+This is a Haskell implementation of [q](https://github.com/harelba/q) command.
+
+## Installation
+### Homebrew
+```shell
+brew install itchyny/tap/qhs
+```
+
+### Build with stack
+```shell
+stack install qhs
+```
+
+## Usage
+In the beginning, `qhs [QUERY]` is the basic usage.
+```shell
+ $ wc * > wc_out.txt
+ $ qhs "SELECT * FROM ./wc_out.txt"
+66 471 3131 File.hs
+118 649 4962 Main.hs
+61 258 2346 Option.hs
+51 366 2564 Parser.hs
+45 273 1769 SQL.hs
+341 2017 14772 total
+```
+You can specify the file name for the table name.
+The column names are automatically assigned as `c1`, `c2` and so on.
+```shell
+ $ qhs "SELECT c4,c1 FROM ./wc_out.txt WHERE c4 <> 'total' ORDER BY c1 DESC"
+Main.hs 118
+File.hs 66
+Option.hs 61
+Parser.hs 51
+SQL.hs 45
+```
+
+The `qhs` command can read the table from the standard input as well.
+```shell
+ $ wc * | qhs "SELECT c4,c1 FROM - WHERE c4 <> 'total' ORDER BY c1 DESC"
+Main.hs 118
+File.hs 66
+Option.hs 61
+Parser.hs 51
+SQL.hs 45
+```
+
+You can use `-H` flag to make `qhs` regard the head line as the row of column names.
+```shell
+ $ cat basic.csv
+foo,bar,baz
+a0,1,a2
+b0,3,b2
+c0,,c2
+ $ qhs -H "SELECT * FROM basic.csv WHERE bar IS NOT NULL"
+a0 1 a2
+b0 3 b2
+```
+
+You can use the basic SQL operations; `GROUP BY`, `ORDER BY`, `LIMIT` and `COUNT(*)`.
+```shell
+ $ ps -ef | qhs -H -O "SELECT UID,COUNT(*) cnt FROM - GROUP BY UID ORDER BY cnt DESC LIMIT 3"
+UID cnt
+503 102
+0 86
+89 3
+```
+You can also use other SQL operations like `JOIN`, `UNION` and sub-query.
+The command helps you deal with multiple CSV files.
+
+Please refer to `qhs --help` for further options.
+The command respects the behaviour of the original [q](https://github.com/harelba/q) command.
+```shell
+ $ qhs --help
+qhs - SQL queries on CSV and TSV files
+
+Usage: qhs [-H|--skip-header] [-O|--output-header] [-d|--delimiter DELIMITER]
+           [-t|--tab-delimited] [-p|--pipe-delimited]
+           [-D|--output-delimiter OUTPUT_DELIMITER] [-T|--tab-delimited-output]
+           [-P|--pipe-delimited-output] [-k|--keep-leading-whitespace]
+           [-z|--gzipped] [-q|--query-filename QUERY_FILENAME] [QUERY]
+
+Available options:
+  -h,--help                Show this help text
+  -v,--version             Show the version of the command.
+  -H,--skip-header         Skip the header row for row input and use it for
+                           column names instead.
+  -O,--output-header       Output the header line.
+  -d,--delimiter DELIMITER Field delimiter. If not specified, automatically
+                           detected.
+  -t,--tab-delimited       Same as -d $'\t'.
+  -p,--pipe-delimited      Same as -d '|'.
+  -D,--output-delimiter OUTPUT_DELIMITER
+                           Field delimiter for output. If not specified, the
+                           argument of -d DELIMITER is used.
+  -T,--tab-delimited-output
+                           Same as -D $'\t'.
+  -P,--pipe-delimited-output
+                           Same as -D '|'.
+  -k,--keep-leading-whitespace
+                           Keep leading whitespace in values. The leading
+                           whitespaces are stripped off by default.
+  -z,--gzipped             Assuming the gzipped input.
+  -q,--query-filename QUERY_FILENAME
+                           Read query from the provided filename.
+```
+
+## Author
+itchyny (<https://github.com/itchyny>)
+
+## License
+This software is released under the MIT License, see LICENSE.
diff --git a/_qhs b/_qhs
new file mode 100644
--- /dev/null
+++ b/_qhs
@@ -0,0 +1,18 @@
+#compdef qhs
+
+_qhs()
+{
+  _arguments -s -S \
+    '(-H --skip-header)'{-H,--skip-header}'[use the first row for column names]' \
+    '(-O --output-header)'{-O,--output-header}'[output the header line]' \
+    '(-d --delimiter -t --tab-delimited)'{-d,--delimiter=}'[field delimiter]:DELIMITER' \
+    '(-d --delimiter -t --tab-delimited)'{-t,--tab-delimited}'[use tab for field delimiter]' \
+    '(-D --output-delimiter -T --tab-delimited-output)'{-D,--output-delimiter=}'[field delimiter of output]:OUTPUT_DELIMITER' \
+    '(-D --output-delimiter -T --tab-delimited-output)'{-T,--tab-delimited-output}'[use tab for field delimiter of output]' \
+    '(-k --keep-leading-whitespace)'{-k,--keep-leading-whitespace}'[keep leading whitespace in values]' \
+    '(-z --gzipped)'{-z,--gzipped}'[assuming the gzipped input]' \
+    '(-q --query-filename 1)'{-q,--query-filename=}'[read query from file]:QUERY_FILENAME:_files' \
+    '(- 1)'{-v,--version}'[print version]' \
+    '(- 1)'{-h,--help}'[print help]' \
+    '1: :_guard "^-*" "SQL query"'
+}
diff --git a/qhs.cabal b/qhs.cabal
--- a/qhs.cabal
+++ b/qhs.cabal
@@ -1,68 +1,72 @@
+cabal-version:          3.0
 name:                   qhs
-version:                0.3.3
-author:                 itchyny <https://github.com/itchyny>
-maintainer:             itchyny <https://github.com/itchyny>
-license:                MIT
-license-file:           LICENSE
+version:                0.4.0
 category:               Console
-build-type:             Simple
-cabal-version:          >=1.10
 synopsis:               Command line tool qhs, SQL queries on CSV and TSV files.
-description:            This is a Haskell port of q command (https://github.com/harelba/q).
+description:            This is a Haskell port of <https://github.com/harelba/q q command>.
+author:                 itchyny <itchyny@cybozu.co.jp>
+maintainer:             itchyny <itchyny@cybozu.co.jp>
+homepage:               https://github.com/itchyny/qhs
+bug-reports:            https://github.com/itchyny/qhs/issues
+license:                MIT
+license-file:           LICENSE
+extra-source-files:     stack.yaml stack.yaml.lock README.md _qhs
+                        test/tests/*.csv test/tests/*.csv.gz test/tests/*.out
+                        test/tests/*.sh test/tests/*.sql
 
-executable qhs
+common base
+  default-language:     GHC2021
+  default-extensions:   BlockArguments
+                        LambdaCase
+                        NoFieldSelectors
+                        OverloadedRecordDot
+  build-depends:        base >= 4.18 && < 5
+  ghc-options:          -Wdefault -Wall -Wunused-packages
+
+library qhs-lib
+  import:               base
+  visibility:           private
   hs-source-dirs:       src
+  exposed-modules:      Qhs
+                        Qhs.CLI
+                        Qhs.File
+                        Qhs.Option
+                        Qhs.Parser
+                        Qhs.SQL
+                        Qhs.SQLType
+  build-depends:        bytestring >= 0.11 && < 0.13
+                      , containers >= 0.6 && < 0.7
+                      , cryptonite >= 0.30 && < 0.31
+                      , extra >= 1.7 && < 1.9
+                      , optparse-applicative >= 0.18 && < 0.19
+                      , simple-sql-parser >= 0.8 && < 0.9
+                      , sqlite-simple >= 0.4 && < 0.5
+                      , syb >= 0.7 && < 0.8
+                      , text >= 2.0 && < 2.2
+                      , zlib >= 0.6 && < 0.8
+
+executable qhs
+  import:               base
   main-is:              Main.hs
-  ghc-options:          -threaded -Wall
-  default-language:     Haskell2010
-  other-modules:        Parser
-                      , SQL
-                      , SQLType
-                      , File
-                      , Option
-                      , Paths_qhs
-  build-depends:        base >= 4.9 && < 5
-                      , bytestring
-                      , containers
-                      , cryptonite
-                      , optparse-applicative
-                      , simple-sql-parser >= 0.6.0
-                      , split
-                      , sqlite-simple
-                      , syb >= 0.4
-                      , text
-                      , zlib
+  other-modules:        Paths_qhs
+  autogen-modules:      Paths_qhs
+  build-depends:        qhs-lib
 
 test-suite spec
-  hs-source-dirs:       test,src
-  main-is:              Spec.hs
-  ghc-options:          -threaded -Wall
-  other-modules:        Parser
-                      , SQL
-                      , SQLType
-                      , File
-                      , Option
-                      , Paths_qhs
-                      , ParserSpec
-                      , SQLSpec
-                      , FileSpec
-                      , MainSpec
+  import:               base
   type:                 exitcode-stdio-1.0
-  default-language:     Haskell2010
-  build-depends:        base >= 4.9 && < 5
-                      , bytestring
-                      , containers
-                      , cryptonite
-                      , hspec
-                      , optparse-applicative
-                      , process
-                      , simple-sql-parser >= 0.6.0
-                      , split
-                      , sqlite-simple
-                      , syb >= 0.4
-                      , text
-                      , zlib
+  hs-source-dirs:       test
+  main-is:              Spec.hs
+  other-modules:        FileSpec
+                        ParserSpec
+                        QhsSpec
+                        SQLSpec
+  build-depends:        qhs-lib
+                      , containers >= 0.6 && < 0.7
+                      , extra >= 1.7 && < 1.8
+                      , hspec >= 2.11 && < 2.12
+                      , process >= 1.6 && < 1.7
 
 source-repository head
   type:     git
-  location: git@github.com:itchyny/qhs.git
+  location: https://github.com/itchyny/qhs.git
diff --git a/src/File.hs b/src/File.hs
deleted file mode 100644
--- a/src/File.hs
+++ /dev/null
@@ -1,65 +0,0 @@
-module File where
-
-import qualified Codec.Compression.GZip as GZip
-import Control.Applicative ((<|>))
-import Control.Monad (guard, when)
-import qualified Data.ByteString.Lazy as ByteString
-import qualified Data.ByteString.Lazy.Char8 as Char8
-import Data.Char (isSpace)
-import System.Exit (exitFailure)
-import System.IO
-
-import qualified Option as Option
-
-readFromFile :: Option.Option -> Handle -> IO ([String], [[String]])
-readFromFile opts handle = do
-  contents <- joinMultiLines <$> lines <$>
-    if Option.gzipped opts
-       then Char8.unpack <$> GZip.decompress <$> ByteString.hGetContents handle
-       else hGetContents handle
-  let (headLine : secondLine : _) = contents ++ [ "", "" ]
-  let delimiter = guard (Option.tabDelimited opts) *> Just "\t" <|> Option.delimiter opts
-  when (maybe False ((/=1) . length) delimiter) $ do
-    hPutStrLn stderr "Invalid delimiter."
-    exitFailure
-  let splitter = case delimiter of
-                      Just [c] -> (==) c
-                      _ -> detectSplitter headLine secondLine
-  let headColumns = splitFixedSize splitter 0 headLine
-  let size = length headColumns
-  let columns = if Option.skipHeader opts then headColumns else [ 'c' : show i | i <- [1..size] ]
-  let skipLine = if Option.skipHeader opts then tail else id
-  let stripSpaces = if Option.keepLeadingWhiteSpace opts then id else dropWhile isSpace
-  let body = filter (not . null) $ map (map stripSpaces . splitFixedSize splitter size) (skipLine contents)
-  return (columns, body)
-  where joinMultiLines (cs:ds:css) | valid True cs = cs : joinMultiLines (ds:css)
-                                   | otherwise = joinMultiLines $ (cs ++ "\n" ++ ds) : css
-          where valid False ('"':'"':xs) = valid False xs
-                valid False ('\\':'"':xs) = valid False xs
-                valid b ('"':xs) = valid (not b) xs
-                valid b (_:xs) = valid b xs
-                valid b "" = b
-        joinMultiLines css = css
-
-detectSplitter :: String -> String -> Char -> Bool
-detectSplitter xs ys = head $ [ splitter | (x, y, splitter) <- map splitLines splitters
-                                         , 1 < length x && length x <= length y ] ++ splitters
-  where splitLines f = (splitFixedSize f 0 xs, splitFixedSize f 0 ys, f)
-        splitters = [ (==','), isSpace ]
-
-splitFixedSize :: (Char -> Bool) -> Int -> String -> [String]
-splitFixedSize f n = fill . go n
-  where go _ "" = []
-        go k (c:cs@(c':_)) | f c && f c' && not (f ' ' && isSpace c') = "" : go (k - 1) cs
-                           | f c = go k cs
-        go k ('"':cs) = let (ys, xs) = takeQuotedString cs in xs : go (k - 1) ys
-          where takeQuotedString ('"':'"':xs) = fmap ('"':) (takeQuotedString xs)
-                takeQuotedString ('\\':'"':xs) = fmap ('"':) (takeQuotedString xs)
-                takeQuotedString ('"':xs) = (xs, "")
-                takeQuotedString (x:xs) = fmap (x:) (takeQuotedString xs)
-                takeQuotedString "" = ("", "")
-        go k (c:cs) | f c = go k cs
-        go 1 cs = [cs]
-        go k cs = let (xs, ys) = break f cs in xs : go (k - 1) ys
-        fill [] = []
-        fill xs = xs ++ replicate (n - length xs) ""
diff --git a/src/Main.hs b/src/Main.hs
deleted file mode 100644
--- a/src/Main.hs
+++ /dev/null
@@ -1,120 +0,0 @@
-module Main where
-
-import Control.Applicative
-import Control.Monad (forM_, guard, when)
-import Data.Char (isSpace)
-import Data.List (isSuffixOf, intercalate, transpose)
-import qualified Data.Map as Map
-import Data.Maybe
-import qualified Data.Set as Set
-import Data.Set ((\\))
-import qualified Database.SQLite.Simple as SQLite
-import Options.Applicative (execParser, helper, info, fullDesc, header)
-import System.Exit (exitFailure)
-import System.IO
-import Text.Read (readMaybe)
-
-import qualified File as File
-import qualified Option as Option
-import qualified Parser as Parser
-import qualified SQL as SQL
-import qualified SQLType as SQLType
-
-main :: IO ()
-main = runCommand =<< execParser opts
-  where opts = info (helper <*> Option.version <*> Option.options)
-                    (fullDesc <> header "qhs - SQL queries on CSV and TSV files")
-
-runCommand :: Option.Option -> IO ()
-runCommand opts = do
-  queryTableMap <- parseQuery =<< fetchQuery opts
-  conn <- SQL.open ":memory:"
-  runQuery opts conn queryTableMap
-  SQL.close conn
-
-runQuery :: Option.Option -> SQLite.Connection -> (String, Parser.TableNameMap) -> IO ()
-runQuery opts conn (query, tableMap) = do
-  readFilesCreateTables opts conn tableMap
-  ret <- SQL.execute conn query
-  case ret of
-       Right (cs, rs) -> do
-         let outputDelimiter =
-               fromMaybe " " $ guard (Option.tabDelimitedOutput opts) *> Just "\t"
-                            <|> Option.outputDelimiter opts
-                            <|> guard (Option.tabDelimited opts) *> Just "\t"
-                            <|> Option.delimiter opts
-         when (Option.outputHeader opts) $
-           putStrLn $ intercalate outputDelimiter $ cs
-         mapM_ (putStrLn . intercalate outputDelimiter . map show) rs
-       Left err -> do
-         hPutStrLn stderr err
-         exitFailure
-
-fetchQuery :: Option.Option -> IO String
-fetchQuery opts = do
-  when (isJust (Option.query opts) && isJust (Option.queryFile opts)) $ do
-    hPutStrLn stderr "Can't provide both a query file and a query on the command line."
-    exitFailure
-  query <- fromMaybe "" <$> case Option.query opts of
-                                 Just q -> return (Just q)
-                                 Nothing -> mapM readFile (Option.queryFile opts)
-  when (all isSpace query) $ do
-    hPutStrLn stderr "Query cannot be empty."
-    hPutStrLn stderr "For basic information, try the `--help' option."
-    exitFailure
-  return query
-
-parseQuery :: String -> IO (String, Parser.TableNameMap)
-parseQuery qs = do
-  let (query, tableMap) = Parser.replaceTableNames qs
-  case Parser.extractTableNames query "<<query>>" of
-       Left err -> do
-         hPutStrLn stderr $ Parser.replaceBackTableNames tableMap $ Parser.errorString err
-         exitFailure
-       Right tableNames -> do
-         let xs = Set.fromList tableNames
-         let ys = Set.fromList (Map.elems tableMap)
-         if xs == ys
-            then return (query, tableMap)
-            else do
-              hPutStrLn stderr "Invalid table name:"
-              hPutStrLn stderr $ "  " ++ show (Set.union (xs \\ ys) (ys \\ xs))
-              hPutStrLn stderr "Probably a bug of qhs. Please submit a issue report."
-              exitFailure
-
-readFilesCreateTables :: Option.Option -> SQLite.Connection -> Parser.TableNameMap -> IO ()
-readFilesCreateTables opts conn tableMap =
-  forM_ (Map.toList tableMap) $ \(path, name) -> do
-    let path' = unquote path
-    handle <- openFile (if path' == "-" then "/dev/stdin" else path') ReadMode
-    let opts' = opts { Option.gzipped = Option.gzipped opts || ".gz" `isSuffixOf` path' }
-    (columns, body) <- File.readFromFile opts' handle
-    when (length columns == 0) $ do
-      hPutStrLn stderr $ if Option.skipHeader opts
-                            then "Header line is expected but missing in file " ++ path
-                            else "Warning - data is empty"
-      exitFailure
-    when (any (elem ',') columns) $ do
-      hPutStrLn stderr "Column name cannot contain commas"
-      exitFailure
-    when (length columns >= 1) $
-      createTable conn name path columns body
-    hClose handle
-  where unquote (x:xs@(_:_)) | x `elem` "\"'`" && x == last xs = init xs
-        unquote xs = xs
-
-createTable :: SQLite.Connection -> String -> String -> [String] -> [[String]] -> IO ()
-createTable conn name path columns body = do
-  let probablyNumberColumn =
-        [ all isJust [ readMaybe x :: Maybe Float | x <- xs, not (all isSpace x) ]
-                                                  | xs <- transpose body ]
-  let types = [ if b then SQLType.SQLInt else SQLType.SQLChar | b <- probablyNumberColumn ]
-  ret <- SQL.createTable conn name columns types
-  case ret of
-       Just err -> do
-         putStrLn "Error on creating a new table:"
-         putStrLn $ "  " ++ path ++ " (" ++ name ++ ") " ++ show columns
-         putStrLn err
-       Nothing ->
-         forM_ body $ \entry -> do
-           mapM_ (hPutStrLn stderr) =<< SQL.insertRow conn name columns types entry
diff --git a/src/Option.hs b/src/Option.hs
deleted file mode 100644
--- a/src/Option.hs
+++ /dev/null
@@ -1,61 +0,0 @@
-module Option where
-
-import Data.Version (showVersion)
-import Options.Applicative
-
-import qualified Paths_qhs as QHS
-
--- | Command options
-data Option = Option { skipHeader :: Bool,
-                       outputHeader :: Bool,
-                       delimiter :: Maybe String,
-                       tabDelimited :: Bool,
-                       outputDelimiter :: Maybe String,
-                       tabDelimitedOutput :: Bool,
-                       keepLeadingWhiteSpace :: Bool,
-                       gzipped :: Bool,
-                       queryFile :: Maybe String,
-                       query :: Maybe String }
-
--- | Option parser
-options :: Parser Option
-options = Option
-  <$> switch (long "skip-header"
-             <> short 'H'
-             <> help "Skip the header row for row input and use it for column names instead.")
-  <*> switch (long "output-header"
-             <> short 'O'
-             <> help "Output the header line.")
-  <*> optional (strOption (long "delimiter"
-                          <> short 'd'
-                          <> metavar "DELIMITER"
-                          <> help "Field delimiter. If not specified, automatically detected."))
-  <*> switch (long "tab-delimited"
-             <> short 't'
-             <> help "Same as -d $'\\t'.")
-  <*> optional (strOption (long "output-delimiter"
-                          <> short 'D'
-                          <> metavar "OUTPUT_DELIMITER"
-                          <> help "Field delimiter for output. If not specified, the argument of -d DELIMITER is used."))
-  <*> switch (long "tab-delimited-output"
-             <> short 'T'
-             <> help "Same as -D $'\\t'.")
-  <*> switch (long "keep-leading-whitespace"
-             <> short 'k'
-             <> help "Keep leading whitespace in values. The leading whitespaces are stripped off by default.")
-  <*> switch (long "gzipped"
-             <> short 'z'
-             <> help "Assuming the gzipped input.")
-  <*> optional (strOption (long "query-filename"
-                          <> short 'q'
-                          <> metavar "QUERY_FILENAME"
-                          <> help "Read query from the provided filename."))
-  <*> optional (argument str (metavar "QUERY"))
-
--- | Parser for --version/-v
-version :: Parser (a -> a)
-version = abortOption (InfoMsg ("qhs " ++ showVersion QHS.version)) $
-    long "version" <>
-    short 'v' <>
-    help "Show the version of the command." <>
-    hidden
diff --git a/src/Parser.hs b/src/Parser.hs
deleted file mode 100644
--- a/src/Parser.hs
+++ /dev/null
@@ -1,71 +0,0 @@
-module Parser (replaceTableNames, roughlyExtractTableNames, replaceBackTableNames, extractTableNames, errorString, TableNameMap) where
-
-import qualified Crypto.Hash as Crypto
-import qualified Data.ByteString.Char8 as Char8
-import Data.Char (isNumber, isSpace, toUpper)
-import Data.Generics (everything, mkQ)
-import Data.List (unfoldr)
-import qualified Data.Map as Map
-import Data.Tuple (swap)
-import qualified Language.SQL.SimpleSQL.Dialect as Dialect
-import qualified Language.SQL.SimpleSQL.Parse as Parse
-import qualified Language.SQL.SimpleSQL.Syntax as Syntax
-
-type TableNameMap = Map.Map String String
-
--- | Replace all the occurrence of table names (file names, in many cases) into
--- valid table names in SQL. The table names are calculated based on the file
--- names, keeping its length so that error position stays at the same position
--- after we replace them back to the file names. I take `max 5` to avoid conflict.
-replaceTableNames :: String -> (String, TableNameMap)
-replaceTableNames qs = (replaceQueryWithTableMap tableMap qs, tableMap)
-  where genTableName xs = take (max 5 (length xs)) $ dropWhile isNumber $ concat $ tail $ iterate sha1Encode xs
-        tableMap = Map.fromList [ (name, genTableName name) | name <- roughlyExtractTableNames qs ]
-
-sha1Encode :: String -> String
-sha1Encode = show . (Crypto.hash :: Char8.ByteString -> Crypto.Digest Crypto.SHA1) . Char8.pack
-
--- | This function roughly extract the table names. We need this function because
--- the given query contains the file names so the SQL parser cannot parse.
-roughlyExtractTableNames :: String -> [String]
-roughlyExtractTableNames qs = [ ys | (xs, ys) <- zip qss (drop 2 qss), isTableNamePrefix xs ]
-  where qss = splitQuery qs
-
--- | The words after these words are possibly table names.
-isTableNamePrefix :: String -> Bool
-isTableNamePrefix xs = map toUpper xs `elem` ["FROM", "JOIN"]
-
--- | Replace the table names using the tableMap.
-replaceQueryWithTableMap :: TableNameMap -> String -> String
-replaceQueryWithTableMap tableMap qs = query
-  where qss = splitQuery qs
-        query = concat [ if isTableNamePrefix xs then Map.findWithDefault ys ys tableMap else ys | (xs, ys) <- zip ("" : "" : qss) qss ]
-
--- | Split the query string with spaces, taking the quotes into consideration.
-splitQuery :: String -> [String]
-splitQuery = unfoldr split'
-  where split' :: String -> Maybe (String, String)
-        split' ccs@(c:cs) | c `elem` "\"'`" = Just $ splitAt (1 + countUntil c cs) ccs
-                          | isSpace c = Just $ span isSpace ccs
-                          | otherwise = Just $ break (\d -> isSpace d || d `elem` "\"'`") ccs
-        split' [] = Nothing
-        countUntil c ('\\':c':cs) | c == c' = 2 + countUntil c cs
-        countUntil c (c':cs) | c == c' = 1
-                             | otherwise = 1 + countUntil c cs
-        countUntil _ [] = 0
-
--- | Replace the generated table names back into the original file names.
-replaceBackTableNames :: TableNameMap -> String -> String
-replaceBackTableNames tableMap = replaceQueryWithTableMap reverseMap
-  where reverseMap = Map.fromList $ map swap $ Map.toList tableMap
-
--- | Extracts the table names using the rigid SQL parser.
-extractTableNames :: String -> FilePath -> Either Parse.ParseError [String]
-extractTableNames query path = everything (++) ([] `mkQ` tableNames)
-                            <$> Parse.parseQueryExpr Dialect.mysql path Nothing query
-  where tableNames (Syntax.TRSimple (name:_)) = fromName name
-        tableNames _ = []
-        fromName (Syntax.Name _ name) = [name]
-
-errorString :: Parse.ParseError -> String
-errorString = Parse.peFormattedError
diff --git a/src/Qhs.hs b/src/Qhs.hs
new file mode 100644
--- /dev/null
+++ b/src/Qhs.hs
@@ -0,0 +1,3 @@
+module Qhs (main) where
+
+import Qhs.CLI (main)
diff --git a/src/Qhs/CLI.hs b/src/Qhs/CLI.hs
new file mode 100644
--- /dev/null
+++ b/src/Qhs/CLI.hs
@@ -0,0 +1,125 @@
+module Qhs.CLI (main) where
+
+import Control.Applicative ((<|>))
+import Control.Monad (forM_, guard, unless, when)
+import Data.Char (isSpace)
+import Data.List (intercalate, isSuffixOf, transpose)
+import Data.Map qualified as Map
+import Data.Maybe (fromMaybe, isJust)
+import Data.Set as Set (fromList, union, (\\))
+import Data.Version (Version)
+import Database.SQLite.Simple qualified as SQLite
+import Options.Applicative (execParser, fullDesc, header, helper, info)
+import System.Exit (exitFailure)
+import System.IO
+import Text.Read (readMaybe)
+
+import Qhs.File qualified as File
+import Qhs.Option as Option
+import Qhs.Parser qualified as Parser
+import Qhs.SQL qualified as SQL
+import Qhs.SQLType
+
+main :: Version -> IO ()
+main ver = execParser opts >>= runCommand
+  where opts = info (helper <*> Option.version ver <*> Option.options)
+                    (fullDesc <> header "qhs - SQL queries on CSV and TSV files")
+
+runCommand :: Option -> IO ()
+runCommand opts = do
+  queryTableMap <- parseQuery =<< fetchQuery opts
+  conn <- SQL.open ":memory:"
+  runQuery opts conn queryTableMap
+  SQL.close conn
+
+runQuery :: Option -> SQLite.Connection -> (String, Parser.TableNameMap) -> IO ()
+runQuery opts conn (query, tableMap) = do
+  readFilesCreateTables opts conn tableMap
+  SQL.execute conn query >>= \case
+    Right (cs, rs) -> do
+      let outputDelimiter =
+            fromMaybe " " $ guard opts.tabDelimitedOutput *> Just "\t" <|>
+                            guard opts.pipeDelimitedOutput *> Just "|" <|>
+                            opts.outputDelimiter <|>
+                            guard opts.tabDelimited *> Just "\t" <|>
+                            guard opts.pipeDelimited *> Just "|" <|>
+                            opts.delimiter
+      when opts.outputHeader $
+        putStrLn $ intercalate outputDelimiter cs
+      mapM_ (putStrLn . intercalate outputDelimiter . map show) rs
+    Left err -> do
+      hPrint stderr err
+      exitFailure
+
+fetchQuery :: Option -> IO String
+fetchQuery opts = do
+  when (isJust opts.query && isJust opts.queryFile) do
+    hPutStrLn stderr "Can't provide both a query file and a query on the command line."
+    exitFailure
+  query <- fromMaybe "" <$> case opts.query of
+                                 Just q  -> return (Just q)
+                                 Nothing -> mapM readFile opts.queryFile
+  when (all isSpace query) do
+    hPutStrLn stderr "Query cannot be empty."
+    hPutStrLn stderr "For basic information, try the `--help' option."
+    exitFailure
+  return query
+
+parseQuery :: String -> IO (String, Parser.TableNameMap)
+parseQuery qs = do
+  let (query, tableMap) = Parser.replaceTableNames qs
+  case Parser.extractTableNames query "<<query>>" of
+       Left err -> do
+         hPutStrLn stderr $ Parser.replaceBackTableNames tableMap $ Parser.errorString err
+         exitFailure
+       Right tableNames -> do
+         let xs = Set.fromList tableNames
+         let ys = Set.fromList (Map.elems tableMap)
+         if xs == ys
+            then return (query, tableMap)
+            else do
+              hPutStrLn stderr "Invalid table name:"
+              hPutStrLn stderr $ "  " ++ show (Set.union (xs \\ ys) (ys \\ xs))
+              hPutStrLn stderr "Probably a bug of qhs. Please submit a issue report."
+              exitFailure
+
+readFilesCreateTables :: Option -> SQLite.Connection -> Parser.TableNameMap -> IO ()
+readFilesCreateTables opts conn tableMap =
+  forM_ (Map.toList tableMap) \(path, name) -> do
+    let path' = unquote path
+    handle <- if path' == "-" then return stdin else openFile path' ReadMode
+    let opts' = opts { gzipped = opts.gzipped || ".gz" `isSuffixOf` path' }
+    (columns, body) <- File.readFromFile opts' handle
+    when (null columns) do
+      hPutStrLn stderr $ if opts.skipHeader
+                            then "Header line is expected but missing in file " ++ path
+                            else "Warning - data is empty"
+      exitFailure
+    when (any (elem ',') columns) do
+      hPutStrLn stderr "Column name cannot contain commas"
+      exitFailure
+    unless (null columns) $
+      createTable conn name path columns body
+    hClose handle
+  where unquote (x:xs@(_:_)) | x `elem` "\"'`" && x == last xs = init xs
+        unquote xs = xs
+
+createTable :: SQLite.Connection -> String -> String -> [String] -> [[String]] -> IO ()
+createTable conn name path columns body = do
+  let probablyNumberColumn =
+        [ all isJust [ readMaybe x :: Maybe Double | x <- xs, not (all isSpace x) ]
+                                                   | xs <- transpose body ]
+  let types = [ if b then SQLInt else SQLChar | b <- probablyNumberColumn ]
+  SQL.createTable conn name columns types >>= \case
+    Left err -> do
+      hPutStrLn stderr "Error on creating a new table:"
+      hPutStrLn stderr $ "  " ++ path ++ " (" ++ name ++ ") " ++ show columns
+      hPrint stderr err
+      exitFailure
+    Right _ -> do
+      forM_ body \entry -> do
+        SQL.insertRow conn name columns types entry >>= \case
+          Left err -> do
+            hPrint stderr err
+            exitFailure
+          Right _ -> return ()
diff --git a/src/Qhs/File.hs b/src/Qhs/File.hs
new file mode 100644
--- /dev/null
+++ b/src/Qhs/File.hs
@@ -0,0 +1,74 @@
+module Qhs.File (readFromFile, detectSplitter, splitFixedSize) where
+
+import Codec.Compression.GZip qualified as GZip
+import Control.Applicative ((<|>))
+import Control.Monad (guard, when)
+import Data.ByteString.Lazy qualified as ByteString
+import Data.ByteString.Lazy.Char8 qualified as Char8
+import Data.Char (isSpace)
+import Data.List (unsnoc)
+import Data.List.NonEmpty (NonEmpty((:|)), head, prependList, tail, toList,
+                           uncons, (<|))
+import Data.Tuple.Extra (second)
+import System.Exit (exitFailure)
+import System.IO
+import Prelude hiding (head, tail)
+
+import Qhs.Option
+
+readFromFile :: Option -> Handle -> IO ([String], [[String]])
+readFromFile opts handle = do
+  contents <- joinMultiLines . map stripCR . lines <$>
+    if opts.gzipped
+       then Char8.unpack . GZip.decompress <$> ByteString.hGetContents handle
+       else hGetContents handle
+  let delimiter = guard opts.tabDelimited *> Just "\t" <|>
+                  guard opts.pipeDelimited *> Just "|" <|>
+                  opts.delimiter
+  when (maybe False ((/=1) . length) delimiter) do
+    hPutStrLn stderr "Invalid delimiter."
+    exitFailure
+  let splitter = case delimiter of
+                      Just [c] -> (==) c
+                      _ -> detectSplitter headLine secondLine
+                        where (headLine, secondLine) = second (maybe "" head) (uncons contents)
+  let headColumns = splitFixedSize splitter 0 $ head contents
+  let size = length headColumns
+  let columns = if opts.skipHeader then headColumns else [ 'c' : show i | i <- [1..size] ]
+  let skipLine = if opts.skipHeader then tail else toList
+  let stripSpaces = if opts.keepLeadingWhiteSpace then id else dropWhile isSpace
+  let body = filter (not . null) $ map (map stripSpaces . splitFixedSize splitter size) (skipLine contents)
+  return (columns, body)
+  where joinMultiLines (cs:ds:css) | valid True cs = cs <| joinMultiLines (ds:css)
+                                   | otherwise = joinMultiLines $ (cs ++ "\n" ++ ds) : css
+          where valid False ('"':'"':xs)  = valid False xs
+                valid False ('\\':'"':xs) = valid False xs
+                valid b ('"':xs)          = valid (not b) xs
+                valid b (_:xs)            = valid b xs
+                valid b ""                = b
+        joinMultiLines (cs:css) = cs :| css
+        joinMultiLines [] = "" :| []
+        stripCR cs = case unsnoc cs of Just (ts, '\r') -> ts; _ -> cs
+
+detectSplitter :: String -> String -> Char -> Bool
+detectSplitter xs ys = head $ [ s | (x, y, s) <- map splitLines $ toList splitters
+                                  , 1 < length x && length x <= length y ] `prependList` splitters
+  where splitLines f = (splitFixedSize f 0 xs, splitFixedSize f 0 ys, f)
+        splitters = (==',') :| [isSpace]
+
+splitFixedSize :: (Char -> Bool) -> Int -> String -> [String]
+splitFixedSize f n = fill . go n
+  where go _ "" = []
+        go k (c:cs@(c':_)) | f c && f c' && not (f ' ' && isSpace c') = "" : go (k - 1) cs
+                           | f c = go k cs
+        go k ('"':cs) = let (ys, xs) = takeQuotedString cs in xs : go (k - 1) ys
+          where takeQuotedString ('"':'"':xs) = fmap ('"':) (takeQuotedString xs)
+                takeQuotedString ('\\':'"':xs) = fmap ('"':) (takeQuotedString xs)
+                takeQuotedString ('"':xs) = (xs, "")
+                takeQuotedString (x:xs) = fmap (x:) (takeQuotedString xs)
+                takeQuotedString "" = ("", "")
+        go k (c:cs) | f c = go k cs
+        go 1 cs = [cs]
+        go k cs = let (xs, ys) = break f cs in xs : go (k - 1) ys
+        fill [] = []
+        fill xs = xs ++ replicate (n - length xs) ""
diff --git a/src/Qhs/Option.hs b/src/Qhs/Option.hs
new file mode 100644
--- /dev/null
+++ b/src/Qhs/Option.hs
@@ -0,0 +1,70 @@
+module Qhs.Option (Option(..), options, version) where
+
+import Data.Version (Version, showVersion)
+import Options.Applicative
+
+-- | Command options
+data Option =
+  Option {
+    skipHeader            :: Bool,
+    outputHeader          :: Bool,
+    delimiter             :: Maybe String,
+    tabDelimited          :: Bool,
+    pipeDelimited         :: Bool,
+    outputDelimiter       :: Maybe String,
+    tabDelimitedOutput    :: Bool,
+    pipeDelimitedOutput   :: Bool,
+    keepLeadingWhiteSpace :: Bool,
+    gzipped               :: Bool,
+    queryFile             :: Maybe String,
+    query                 :: Maybe String
+  }
+
+-- | Option parser
+options :: Parser Option
+options = Option
+  <$> switch (long "skip-header"
+           <> short 'H'
+           <> help "Skip the header row for row input and use it for column names instead.")
+  <*> switch (long "output-header"
+           <> short 'O'
+           <> help "Output the header line.")
+  <*> optional (strOption (long "delimiter"
+                        <> short 'd'
+                        <> metavar "DELIMITER"
+                        <> help "Field delimiter. If not specified, automatically detected."))
+  <*> switch (long "tab-delimited"
+           <> short 't'
+           <> help "Same as -d $'\\t'.")
+  <*> switch (long "pipe-delimited"
+           <> short 'p'
+           <> help "Same as -d '|'.")
+  <*> optional (strOption (long "output-delimiter"
+                        <> short 'D'
+                        <> metavar "OUTPUT_DELIMITER"
+                        <> help "Field delimiter for output. If not specified, the argument of -d DELIMITER is used."))
+  <*> switch (long "tab-delimited-output"
+           <> short 'T'
+           <> help "Same as -D $'\\t'.")
+  <*> switch (long "pipe-delimited-output"
+           <> short 'P'
+           <> help "Same as -D '|'.")
+  <*> switch (long "keep-leading-whitespace"
+           <> short 'k'
+           <> help "Keep leading whitespace in values. The leading whitespaces are stripped off by default.")
+  <*> switch (long "gzipped"
+           <> short 'z'
+           <> help "Assuming the gzipped input.")
+  <*> optional (strOption (long "query-filename"
+                        <> short 'q'
+                        <> metavar "QUERY_FILENAME"
+                        <> help "Read query from the provided filename."))
+  <*> optional (argument str (metavar "QUERY"))
+
+-- | Parser for --version/-v
+version :: Version -> Parser (a -> a)
+version ver = abortOption (InfoMsg ("qhs " ++ showVersion ver)) $
+    long "version" <>
+    short 'v' <>
+    help "Show the version of the command." <>
+    hidden
diff --git a/src/Qhs/Parser.hs b/src/Qhs/Parser.hs
new file mode 100644
--- /dev/null
+++ b/src/Qhs/Parser.hs
@@ -0,0 +1,82 @@
+module Qhs.Parser
+  (
+    replaceTableNames,
+    roughlyExtractTableNames,
+    replaceBackTableNames,
+    extractTableNames,
+    errorString,
+    TableNameMap
+  ) where
+
+import Crypto.Hash qualified as Crypto
+import Data.ByteString.Char8 qualified as Char8
+import Data.Char (isNumber, isSpace, toUpper)
+import Data.Generics (everything, mkQ)
+import Data.List (unfoldr)
+import Data.List.NonEmpty (iterate, tail)
+import Data.Map qualified as Map
+import Data.Text qualified as Text
+import Data.Tuple (swap)
+import Language.SQL.SimpleSQL.Dialect qualified as Dialect
+import Language.SQL.SimpleSQL.Parse qualified as Parse
+import Language.SQL.SimpleSQL.Syntax qualified as Syntax
+import Prelude hiding (iterate, tail)
+
+type TableNameMap = Map.Map String String
+
+-- | Replace all the occurrence of table names (file names, in many cases) into
+-- valid table names in SQL. The table names are calculated based on the file
+-- names, keeping its length so that error position stays at the same position
+-- after we replace them back to the file names. I take `max 5` to avoid conflict.
+replaceTableNames :: String -> (String, TableNameMap)
+replaceTableNames qs = (replaceQueryWithTableMap tableMap qs, tableMap)
+  where genTableName xs = take (max 5 (length xs)) $ dropWhile isNumber $ concat $ tail $ iterate sha1Encode xs
+        tableMap = Map.fromList [ (name, genTableName name) | name <- roughlyExtractTableNames qs ]
+
+sha1Encode :: String -> String
+sha1Encode = show . (Crypto.hash :: Char8.ByteString -> Crypto.Digest Crypto.SHA1) . Char8.pack
+
+-- | This function roughly extract the table names. We need this function because
+-- the given query contains the file names so the SQL parser cannot parse.
+roughlyExtractTableNames :: String -> [String]
+roughlyExtractTableNames qs = [ ys | (xs, ys) <- zip qss (drop 2 qss), isTableNamePrefix xs ]
+  where qss = splitQuery qs
+
+-- | The words after these words are possibly table names.
+isTableNamePrefix :: String -> Bool
+isTableNamePrefix xs = map toUpper xs `elem` ["FROM", "JOIN"]
+
+-- | Replace the table names using the tableMap.
+replaceQueryWithTableMap :: TableNameMap -> String -> String
+replaceQueryWithTableMap tableMap qs = query
+  where qss = splitQuery qs
+        query = concat [ if isTableNamePrefix xs then Map.findWithDefault ys ys tableMap else ys | (xs, ys) <- zip ("" : "" : qss) qss ]
+
+-- | Split the query string with spaces, taking the quotes into consideration.
+splitQuery :: String -> [String]
+splitQuery = unfoldr split'
+  where split' :: String -> Maybe (String, String)
+        split' ccs@(c:cs) | c `elem` "\"'`" = Just $ splitAt (1 + countUntil c cs) ccs
+                          | isSpace c = Just $ span isSpace ccs
+                          | otherwise = Just $ break (\d -> isSpace d || d `elem` "\"'`") ccs
+        split' [] = Nothing
+        countUntil c ('\\':c':cs) | c == c' = 2 + countUntil c cs
+        countUntil c (c':cs) | c == c' = 1
+                             | otherwise = 1 + countUntil c cs
+        countUntil _ [] = 0
+
+-- | Replace the generated table names back into the original file names.
+replaceBackTableNames :: TableNameMap -> String -> String
+replaceBackTableNames tableMap = replaceQueryWithTableMap reverseMap
+  where reverseMap = Map.fromList $ map swap $ Map.toList tableMap
+
+-- | Extracts the table names using the rigid SQL parser.
+extractTableNames :: String -> FilePath -> Either Parse.ParseError [String]
+extractTableNames query path = everything (++) ([] `mkQ` tableNames)
+                            <$> Parse.parseQueryExpr Dialect.mysql (Text.pack path) Nothing (Text.pack query)
+  where tableNames (Syntax.TRSimple (name:_)) = fromName name
+        tableNames _                          = []
+        fromName (Syntax.Name _ name) = [Text.unpack name]
+
+errorString :: Parse.ParseError -> String
+errorString = Text.unpack . Parse.prettyError
diff --git a/src/Qhs/SQL.hs b/src/Qhs/SQL.hs
new file mode 100644
--- /dev/null
+++ b/src/Qhs/SQL.hs
@@ -0,0 +1,49 @@
+module Qhs.SQL (open, close, createTable, insertRow, execute) where
+
+import Control.Exception (SomeException, try)
+import Control.Monad (forM)
+import Data.List (intercalate)
+import Data.String (fromString)
+import Data.Text qualified as Text
+import Database.SQLite.Simple qualified as SQLite
+
+import Qhs.SQLType
+
+-- | Open a new database connection.
+open :: String -> IO SQLite.Connection
+open = SQLite.open
+
+-- | Close a database connection.
+close :: SQLite.Connection -> IO ()
+close = SQLite.close
+
+-- | Creates a new table.
+createTable :: SQLite.Connection -> String -> [String] -> [SQLType] -> IO (Either SomeException ())
+createTable conn name columns types = do
+  let stmt = "CREATE TABLE " ++ sqlQuote name ++ " "
+           ++ tupled (zipWith quote types columns) ++ ";"
+  try $ SQLite.execute_ conn (fromString stmt)
+    where quote t c = sqlQuote c ++ " " ++ show t
+
+-- | Inserts a row into a table.
+insertRow :: SQLite.Connection -> String -> [String] -> [SQLType] -> [String] -> IO (Either SomeException ())
+insertRow conn name columns types entry = do
+  let stmt = "INSERT INTO " ++ sqlQuote name ++ tupled (map sqlQuote columns)
+           ++ " VALUES " ++ tupled (replicate (length columns) "?") ++ ";"
+  try $ SQLite.execute conn (fromString stmt) (zip types entry)
+
+-- | Executes a SQL statement.
+execute :: SQLite.Connection -> String -> IO (Either SomeException ([String], [[Any]]))
+execute conn query = do
+  e <- try $ SQLite.query_ conn (fromString query)
+  columns <- SQLite.withStatement conn (fromString query) \stmt -> do
+    cnt <- toInteger <$> SQLite.columnCount stmt
+    forM [0..cnt-1] \i ->
+      Text.unpack <$> SQLite.columnName stmt (fromInteger i)
+  return $ (columns,) <$> e
+
+sqlQuote :: String -> String
+sqlQuote xs = "`" ++ xs ++ "`"
+
+tupled :: [String] -> String
+tupled xs = "(" ++ intercalate ", " xs ++ ")"
diff --git a/src/Qhs/SQLType.hs b/src/Qhs/SQLType.hs
new file mode 100644
--- /dev/null
+++ b/src/Qhs/SQLType.hs
@@ -0,0 +1,48 @@
+module Qhs.SQLType (SQLType(..), Any, fromColumnsAndEntries) where
+
+import Control.Applicative ((<|>))
+import Data.Maybe (fromMaybe)
+import Data.String (IsString(..))
+import Data.Text qualified as Text
+import Database.SQLite.Simple
+import Database.SQLite.Simple.FromField
+import Database.SQLite.Simple.Internal (Field(..))
+import Database.SQLite.Simple.Ok
+import Database.SQLite.Simple.ToField
+import Text.Read (readMaybe)
+
+data SQLType = SQLChar | SQLInt
+             deriving Eq
+
+instance Show SQLType where
+  show SQLChar = "CHAR"
+  show SQLInt  = "INTEGER"
+
+instance ToField (SQLType, String) where
+  toField (SQLChar, cs) = SQLText $ Text.pack cs
+  toField (SQLInt, cs)  = maybe SQLNull SQLFloat $ readMaybe cs
+
+data Any = AnyDouble Double | AnyInt Int | AnyString String | AnyNull
+         deriving Eq
+
+instance FromField Any where
+  fromField (Field (SQLFloat d) _) = Ok . AnyDouble $ d
+  fromField (Field (SQLInteger i) _) = Ok . AnyInt . fromIntegral $ i
+  fromField (Field (SQLText s) _) = Ok . AnyString . Text.unpack $ s
+  fromField (Field SQLNull _) = Ok AnyNull
+  fromField f = returnError ConversionFailed f "expecting SQLText column type"
+
+instance IsString Any where
+  fromString "" = AnyNull
+  fromString s = fromMaybe (AnyString s) $
+                   AnyInt <$> readMaybe s <|>
+                   AnyDouble <$> readMaybe s
+
+instance Show Any where
+  show (AnyDouble d) = show d
+  show (AnyInt i)    = show i
+  show (AnyString s) = s
+  show AnyNull       = ""
+
+fromColumnsAndEntries :: [String] -> [[String]] -> ([String], [[Any]])
+fromColumnsAndEntries columns xs = (columns, map (map fromString) xs)
diff --git a/src/SQL.hs b/src/SQL.hs
deleted file mode 100644
--- a/src/SQL.hs
+++ /dev/null
@@ -1,61 +0,0 @@
-module SQL (open, close, createTable, insertRow, execute) where
-
-import Control.Exception (try, SomeException)
-import Control.Monad (forM)
-import Data.List (intersperse)
-import Data.String (fromString)
-import qualified Data.Text as T
-import qualified Database.SQLite.Simple as SQLite
-
-import SQLType
-
--- | Open a new database connection.
-open :: String -> IO SQLite.Connection
-open = SQLite.open
-
--- | Close a database connection.
-close :: SQLite.Connection -> IO ()
-close = SQLite.close
-
--- | Creates a new table.
-createTable :: SQLite.Connection -> String -> [String] -> [SQLType] -> IO (Maybe String)
-createTable conn name columns types = do
-  let stmt = "CREATE TABLE " ++ sqlQuote name ++ " "
-           ++ tupled (map quote (zip types columns)) ++ ";"
-  e <- try $ SQLite.execute_ conn (fromString stmt)
-  return $ either (Just . (show :: SomeException -> String)) (const Nothing) e
-    where quote (t, c) = sqlQuote c ++ " " ++ showType t
-
--- | Inserts a row into a table.
-insertRow :: SQLite.Connection -> String -> [String] -> [SQLType] -> [String] -> IO (Maybe String)
-insertRow conn name columns types entry = do
-  let stmt = "INSERT INTO " ++ sqlQuote name ++ tupled (map sqlQuote columns)
-           ++ " VALUES " ++ tupled (map quote (zip types entry)) ++ ";"
-  e <- try $ SQLite.execute_ conn (fromString stmt)
-  return $ either (Just . (show :: SomeException -> String)) (const Nothing) e
-    where quote (t, "") | isDigitType t = "NULL"
-          quote (t, cs) | isDigitType t = cs
-                        | otherwise = "'" ++ toSQLString cs ++ "'"
-          isDigitType SQLInt = True
-          isDigitType _ = False
-
-toSQLString :: String -> String
-toSQLString "" = ""
-toSQLString ('\'':xs) = '\'':'\'':toSQLString xs
-toSQLString (x:xs) = x : toSQLString xs
-
--- | Executes a SQL statement.
-execute :: SQLite.Connection -> String -> IO (Either String ([String], [[Any]]))
-execute conn query = do
-  e <- try $ SQLite.query_ conn (fromString query)
-  columns <- SQLite.withStatement conn (fromString query) $ \stmt -> do
-    cnt <- toInteger <$> SQLite.columnCount stmt
-    forM [0..cnt-1] $ \i ->
-      T.unpack <$> SQLite.columnName stmt (fromInteger i)
-  return $ either (Left . (show :: SomeException -> String)) (Right . (,) columns) $ e
-
-sqlQuote :: String -> String
-sqlQuote xs = "`" ++ xs ++ "`"
-
-tupled :: [String] -> String
-tupled xs = "(" ++ concat (intersperse ", " xs) ++ ")"
diff --git a/src/SQLType.hs b/src/SQLType.hs
deleted file mode 100644
--- a/src/SQLType.hs
+++ /dev/null
@@ -1,44 +0,0 @@
-module SQLType (SQLType(..), showType, Any, fromColumnsAndEntries) where
-
-import Data.String (IsString(..))
-import qualified Data.Text as T
-import Database.SQLite.Simple
-import Database.SQLite.Simple.FromField
-import Database.SQLite.Simple.Internal (Field(..))
-import Database.SQLite.Simple.Ok
-import Text.Read (readMaybe)
-
-data SQLType = SQLChar | SQLInt
-
-showType :: SQLType -> String
-showType t =
-  case t of
-    SQLChar -> "CHAR"
-    SQLInt -> "INTEGER"
-
-data Any = AnyDouble Double | AnyInt Int | AnyString String | AnyNull
-         deriving (Eq)
-
-instance FromField Any where
-  fromField (Field (SQLFloat d) _) = Ok . AnyDouble $ d
-  fromField (Field (SQLInteger i) _) = Ok . AnyInt . fromIntegral $ i
-  fromField (Field (SQLText s) _) = Ok . AnyString . T.unpack $ s
-  fromField (Field SQLNull _) = Ok AnyNull
-  fromField f = returnError ConversionFailed f "expecting SQLText column type"
-
-instance IsString Any where
-  fromString s =
-    case readMaybe s of
-         Just i -> AnyInt i
-         Nothing -> case readMaybe s of
-                         Just d -> AnyDouble d
-                         Nothing -> if s == "" then AnyNull else AnyString s
-
-instance Show Any where
-  show (AnyDouble d) = show d
-  show (AnyInt i) = show i
-  show (AnyString s) = s
-  show AnyNull = ""
-
-fromColumnsAndEntries :: [String] -> [[String]] -> ([String], [[Any]])
-fromColumnsAndEntries columns xs = (columns, map (map fromString) xs)
diff --git a/stack.yaml b/stack.yaml
new file mode 100644
--- /dev/null
+++ b/stack.yaml
@@ -0,0 +1,3 @@
+resolver: lts-23.24
+extra-deps:
+  - simple-sql-parser-0.8.0
diff --git a/stack.yaml.lock b/stack.yaml.lock
new file mode 100644
--- /dev/null
+++ b/stack.yaml.lock
@@ -0,0 +1,19 @@
+# This file was autogenerated by Stack.
+# You should not edit this file by hand.
+# For more information, please see the documentation at:
+#   https://docs.haskellstack.org/en/stable/topics/lock_files
+
+packages:
+- completed:
+    hackage: simple-sql-parser-0.8.0@sha256:5153612af09edda2af865e6dc1286d64ecea702541a1af824ac518268f0b5cb4,4417
+    pantry-tree:
+      sha256: fba7264bf9753b0c6abf40881e43b0b56e97052487c5ccbfd663892cdcfdfc13
+      size: 2898
+  original:
+    hackage: simple-sql-parser-0.8.0
+snapshots:
+- completed:
+    sha256: 400dfb2174640dd2f9f2ba7cbf9148699f2380ab64faa46f4be298a5d6205316
+    size: 684057
+    url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/23/24.yaml
+  original: lts-23.24
diff --git a/test/FileSpec.hs b/test/FileSpec.hs
--- a/test/FileSpec.hs
+++ b/test/FileSpec.hs
@@ -4,8 +4,8 @@
 import System.IO
 import Test.Hspec (Spec, describe, it, shouldBe, shouldReturn)
 
-import File
-import Option
+import Qhs.File
+import Qhs.Option
 
 spec :: Spec
 spec = do
@@ -15,31 +15,33 @@
 
 readFromFileSpec :: Spec
 readFromFileSpec =
-  describe "readFromFile" $ do
+  describe "readFromFile" do
     let opts = Option { skipHeader = True,
                         outputHeader = False,
                         delimiter = Nothing,
                         tabDelimited = False,
+                        pipeDelimited = False,
                         outputDelimiter = Nothing,
                         tabDelimitedOutput = False,
+                        pipeDelimitedOutput = False,
                         keepLeadingWhiteSpace = False,
                         gzipped = False,
                         queryFile = Nothing,
                         query = Nothing }
 
-    it "should read from a test file" $ do
+    it "should read from a test file" do
       handle <- openFile "test/tests/basic.csv" ReadMode
       let expected = (["foo", "bar", "baz"], [["a0", "1", "a2"], ["b0", "3", "b2"], ["c0", "", "c2"]])
       readFromFile opts handle `shouldReturn` expected
       hClose handle
 
-    it "should read from a gzipped file" $ do
+    it "should read from a gzipped file" do
       handle <- openFile "test/tests/basic.csv.gz" ReadMode
       let expected = (["foo", "bar", "baz"], [["a0", "1", "a2"], ["b0", "3", "b2"], ["c0", "", "c2"]])
       readFromFile (opts { gzipped = True }) handle `shouldReturn` expected
       hClose handle
 
-    it "should read from a test file which contains a multiline cell" $ do
+    it "should read from a test file which contains a multiline cell" do
       handle <- openFile "test/tests/multiline.csv" ReadMode
       let expected = (["foo", "bar", "baz", "qux", "quux"], [["a0", "1", "a2\nb0\",3,\"b2\nc0", "", "c2"]])
       readFromFile opts handle `shouldReturn` expected
@@ -47,34 +49,34 @@
 
 detectSplitterSpec :: Spec
 detectSplitterSpec =
-  describe "detectSplitter" $ do
+  describe "detectSplitter" do
 
-    it "should detect the column splitter space" $ do
+    it "should detect the column splitter space" do
       let (headLine, secondLine) = ("c0 c1 c2 c3 c4", "0 1 2 3 4")
       detectSplitter headLine secondLine ' ' `shouldBe` True
       detectSplitter headLine secondLine '\t' `shouldBe` True
 
-    it "should detect the column splitter comma" $ do
+    it "should detect the column splitter comma" do
       let (headLine, secondLine) = ("c0,c1,c2,c3,c4", "0,1,2,3,4")
       detectSplitter headLine secondLine ',' `shouldBe` True
 
-    it "should detect the column splitter comma even if the column title has spaces" $ do
+    it "should detect the column splitter comma even if the column title has spaces" do
       let (headLine, secondLine) = ("foo bar baz,qux quux,hoge huga,cmd", "100,200,300,foo bar baz qux")
       detectSplitter headLine secondLine ',' `shouldBe` True
 
 splitFixedSizeSpec :: Spec
 splitFixedSizeSpec =
-  describe "splitFixedSize" $ do
+  describe "splitFixedSize" do
 
-    it "should split the String with isSpace" $ do
+    it "should split the String with isSpace" do
       let (input, expected) = ("c0 c1 c2", [ "c0", "c1", "c2" ])
       splitFixedSize isSpace 0 input `shouldBe` expected
 
-    it "should ignore the successive spaces when splitting with isSpace" $ do
+    it "should ignore the successive spaces when splitting with isSpace" do
       let (input, expected) = ("c0 c1   c2   \t\t c3", [ "c0", "c1", "c2", "c3" ])
       splitFixedSize isSpace 0 input `shouldBe` expected
 
-    it "should take the column size into account when splitting with isSpace" $ do
+    it "should take the column size into account when splitting with isSpace" do
       let (input, (n1, expected1), (n2, expected2), (n3, expected3), (n4, expected4))
             = ("c0 c1   c2   \t\t c3  c4  c5  ",
                (1, [ "c0 c1   c2   \t\t c3  c4  c5  " ]),
@@ -86,15 +88,15 @@
       splitFixedSize isSpace n3 input `shouldBe` expected3
       splitFixedSize isSpace n4 input `shouldBe` expected4
 
-    it "should split the String with (==',')" $ do
+    it "should split the String with (==',')" do
       let (input, expected) = ("c0,c1,c2", [ "c0", "c1", "c2" ])
       splitFixedSize (==',') 0 input `shouldBe` expected
 
-    it "should not ignore the successive commas when splitting with (==',')" $ do
+    it "should not ignore the successive commas when splitting with (==',')" do
       let (input, expected) = ("c0,c1,c2,,c3,,,c4", [ "c0", "c1", "c2", "", "c3", "", "", "c4" ])
       splitFixedSize (==',') 0 input `shouldBe` expected
 
-    it "should take the column size into account when splitting with (==',')" $ do
+    it "should take the column size into account when splitting with (==',')" do
       let (input, (n1, expected1), (n2, expected2), (n3, expected3), (n4, expected4))
             = ("c0,c1,,c2,foo bar baz,c4",
                (1, [ "c0,c1,,c2,foo bar baz,c4" ]),
diff --git a/test/MainSpec.hs b/test/MainSpec.hs
deleted file mode 100644
--- a/test/MainSpec.hs
+++ /dev/null
@@ -1,38 +0,0 @@
-module MainSpec (spec) where
-
-import Control.Applicative
-import Control.Monad
-import System.IO
-import System.Process
-import Test.Hspec (Spec, describe, it, shouldReturn)
-
-spec :: Spec
-spec = qhsSpec
-
-qhsSpec :: Spec
-qhsSpec =
-
-  describe "qhs" $ do
-
-    let tests = [
-          "basic", "columns", "stdin", "header", "where", "tab", "tab2",
-          "count", "is_null", "not_null", "output_header", "spaces",
-          "output_delimiter", "tab_delimited_output", "multiline",
-          "query_file", "empty_query", "empty_query_file", "file_spaces",
-          "gzip", "gzip_stdin", "avg", "sum", "avg_sum", "seq",
-          "group", "group_sum", "concat", "join", "invalid"
-          ]
-
-    forM_ tests $ \test -> do
-
-      it ("should be executed correctly: " ++ test) $ do
-        let cp = (shell ("bash " ++ test ++ ".sh")) {
-              cwd = Just "test/tests",
-              std_out = CreatePipe,
-              std_err = CreatePipe
-            }
-        (_, Just out, Just err, _) <- createProcess cp
-        hSetBuffering out NoBuffering
-        hSetBuffering err NoBuffering
-        outExpected <- readFile $ "test/tests/" ++ test ++ ".out"
-        liftA2 (++) (hGetContents out) (hGetContents err) `shouldReturn` outExpected
diff --git a/test/ParserSpec.hs b/test/ParserSpec.hs
--- a/test/ParserSpec.hs
+++ b/test/ParserSpec.hs
@@ -1,10 +1,11 @@
 module ParserSpec (spec) where
 
-import Data.Either
-import qualified Data.Map as Map
-import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy)
+import Data.Either (isLeft)
+import Data.Either.Extra (fromRight')
+import Data.Map qualified as Map
+import Test.Hspec (Spec, describe, it, shouldBe)
 
-import Parser
+import Qhs.Parser
 
 spec :: Spec
 spec = do
@@ -15,51 +16,51 @@
 
 replaceTableNamesSpec :: Spec
 replaceTableNamesSpec =
-  describe "replaceTableNames" $ do
+  describe "replaceTableNames" do
 
-    it "should replace the file names with table names" $ do
+    it "should replace the file names with table names" do
       let query = "SELECT * FROM ./table0 WHERE c0 > 0"
       let expected = ("SELECT * FROM d8dc7ec0 WHERE c0 > 0",
                       Map.fromList [("./table0", "d8dc7ec0")])
       replaceTableNames query `shouldBe` expected
 
-    it "should replace multiple file names with table names" $ do
+    it "should replace multiple file names with table names" do
       let query = "SELECT * FROM ./src/table0.csv\nJOIN /tmp/table1.csv\tON c1 = c2 WHERE c0 > 0"
       let expected = ("SELECT * FROM d0bf242ceb32ba2b\nJOIN bb239869bdfc764\tON c1 = c2 WHERE c0 > 0",
                       Map.fromList [("./src/table0.csv", "d0bf242ceb32ba2b"),("/tmp/table1.csv", "bb239869bdfc764")])
       replaceTableNames query `shouldBe` expected
 
-    it "should replace only the table names" $ do
+    it "should replace only the table names" do
       let query = "SELECT * FROM ./table0 WHERE c0 LIKE '%foo ./table0 bar%'"
       let expected = ("SELECT * FROM d8dc7ec0 WHERE c0 LIKE '%foo ./table0 bar%'",
                       Map.fromList [("./table0", "d8dc7ec0")])
       replaceTableNames query `shouldBe` expected
 
-    it "should take care of multi-byte file names correctly" $ do
+    it "should take care of multi-byte file names correctly" do
       let query = "SELECT * FROM ./テスト"
       let expected = ("SELECT * FROM e4c1e",
                       Map.fromList [("./テスト", "e4c1e")])
       replaceTableNames query `shouldBe` expected
 
-    it "should take care of file name whose sha1 hash has only numbers" $ do
+    it "should take care of file name whose sha1 hash has only numbers" do
       let query = "SELECT * FROM vdbdc.csv"
       let expected = ("SELECT * FROM f8ce01ceb",
                       Map.fromList [("vdbdc.csv", "f8ce01ceb")])
       replaceTableNames query `shouldBe` expected
 
-    it "should not replace inside single quotes" $ do
+    it "should not replace inside single quotes" do
       let query = "SELECT * FROM table0.csv WHERE c0 LIKE '%foo FROM table1.csv bar%'"
       let expected = ("SELECT * FROM ec0f6d989c WHERE c0 LIKE '%foo FROM table1.csv bar%'",
                       Map.fromList [("table0.csv", "ec0f6d989c")])
       replaceTableNames query `shouldBe` expected
 
-    it "should not replace inside double quotes" $ do
+    it "should not replace inside double quotes" do
       let query = "SELECT * FROM table0.csv WHERE c0 LIKE \"%foo FROM table0.csv bar%\""
       let expected = ("SELECT * FROM ec0f6d989c WHERE c0 LIKE \"%foo FROM table0.csv bar%\"",
                       Map.fromList [("table0.csv", "ec0f6d989c")])
       replaceTableNames query `shouldBe` expected
 
-    it "should replace the file name containing spaces" $ do
+    it "should replace the file name containing spaces" do
       let query = "SELECT * FROM `foo/bar baz qux/quux.csv`"
       let expected = ("SELECT * FROM a3ecf94bae7d224b52e9ad3df3",
                       Map.fromList [("`foo/bar baz qux/quux.csv`", "a3ecf94bae7d224b52e9ad3df3")])
@@ -67,59 +68,59 @@
 
 roughlyExtractTableNamesSpec :: Spec
 roughlyExtractTableNamesSpec =
-  describe "roughlyExtractTableNames" $ do
+  describe "roughlyExtractTableNames" do
 
-    it "should roughly extract table name" $ do
+    it "should roughly extract table name" do
       roughlyExtractTableNames "SELECT * FROM table0 WHERE c0 > 0" `shouldBe` [ "table0" ]
       roughlyExtractTableNames "select * from table0 where c0 > 0" `shouldBe` [ "table0" ]
 
-    it "should roughly extract multiple table names" $ do
+    it "should roughly extract multiple table names" do
       roughlyExtractTableNames "SELECT * FROM table0 JOIN table1 ON c1 = c2 WHERE c0 > 0" `shouldBe` [ "table0", "table1" ]
       roughlyExtractTableNames "select * from table0 join table1 on c1 = c2 where c0 > 0" `shouldBe` [ "table0", "table1" ]
 
-    it "should roughly extract table names but ignore inside quotes" $ do
+    it "should roughly extract table names but ignore inside quotes" do
       roughlyExtractTableNames "SELECT * FROM table0 JOIN table1 ON c1 = c2 WHERE c3 LIKE 'A FROM table3 '" `shouldBe` [ "table0", "table1" ]
       roughlyExtractTableNames "select * from table0 join table1 on c1 = c2 where c3 like 'a from table3 '" `shouldBe` [ "table0", "table1" ]
 
-    it "should roughly extract quoted table names" $ do
+    it "should roughly extract quoted table names" do
       roughlyExtractTableNames "SELECT * FROM `src/table 0 .csv` JOIN '/tmp/table 1.csv'" `shouldBe` [ "`src/table 0 .csv`", "'/tmp/table 1.csv'" ]
 
 replaceBackTableNamesSpec :: Spec
 replaceBackTableNamesSpec =
-  describe "replaceBackTableNames" $ do
+  describe "replaceBackTableNames" do
 
-    it "should replace back table names to the file names" $ do
+    it "should replace back table names to the file names" do
       let query = "SELECT * FROM ./table0 WHERE c0 > 0"
       let (query', tableMap) = replaceTableNames query
       replaceBackTableNames tableMap query' `shouldBe` query
 
-    it "should replace back multiple table names with the file names" $ do
+    it "should replace back multiple table names with the file names" do
       let query = "SELECT * FROM ./src/table0.csv\nJOIN /tmp/table1.csv\tON c1 = c2 WHERE c0 > 0"
       let (query', tableMap) = replaceTableNames query
       replaceBackTableNames tableMap query' `shouldBe` query
 
-    it "should replace back only the table names" $ do
+    it "should replace back only the table names" do
       let query = "SELECT * FROM ./table0 WHERE c0 LIKE '%foo d8dc7ec0 bar%'"
       let (query', tableMap) = replaceTableNames query
       replaceBackTableNames tableMap query' `shouldBe` query
 
-    it "should replace back the table name to the file name containing spaces" $ do
+    it "should replace back the table name to the file name containing spaces" do
       let query = "SELECT * FROM `foo/bar baz qux/quux.csv`"
       let (query', tableMap) = replaceTableNames query
       replaceBackTableNames tableMap query' `shouldBe` query
 
 extractTableNamesSpec :: Spec
 extractTableNamesSpec =
-  describe "extractTableNames" $ do
+  describe "extractTableNames" do
 
-    it "should extract table name" $ do
-      extractTableNames "SELECT * FROM table0 WHERE c0 > 0" "" `shouldBe` Right [ "table0" ]
-      extractTableNames "select * from table0 where c0 > 0" "" `shouldBe` Right [ "table0" ]
+    it "should extract table name" do
+      fromRight' (extractTableNames "SELECT * FROM table0 WHERE c0 > 0" "") `shouldBe` [ "table0" ]
+      fromRight' (extractTableNames "select * from table0 where c0 > 0" "") `shouldBe` [ "table0" ]
 
-    it "should extract multiple table names" $ do
-      extractTableNames "SELECT * FROM table0 JOIN table1 ON c1 = c2 WHERE c0 > 0" "" `shouldBe` Right [ "table0", "table1" ]
-      extractTableNames "select * from table0 join table1 on c1 = c2 where c0 > 0" "" `shouldBe` Right [ "table0", "table1" ]
+    it "should extract multiple table names" do
+      fromRight' (extractTableNames "SELECT * FROM table0 JOIN table1 ON c1 = c2 WHERE c0 > 0" "") `shouldBe` [ "table0", "table1" ]
+      fromRight' (extractTableNames "select * from table0 join table1 on c1 = c2 where c0 > 0" "") `shouldBe` [ "table0", "table1" ]
 
-    it "should return a parse error" $ do
-      extractTableNames "SELECT ** FROM table0 WHERE c0 > 0" "" `shouldSatisfy` isLeft
-      extractTableNames "SELECT * FROM table0 WHERE > 0" "" `shouldSatisfy` isLeft
+    it "should return a parse error" do
+      isLeft (extractTableNames "SELECT ** FROM table0 WHERE c0 > 0" "") `shouldBe` True
+      isLeft (extractTableNames "SELECT * FROM table0 WHERE > 0" "") `shouldBe` True
diff --git a/test/QhsSpec.hs b/test/QhsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/QhsSpec.hs
@@ -0,0 +1,38 @@
+module QhsSpec (spec) where
+
+import Control.Monad
+import System.IO
+import System.Process
+import Test.Hspec (Spec, describe, it, shouldReturn)
+
+spec :: Spec
+spec = qhsSpec
+
+qhsSpec :: Spec
+qhsSpec =
+
+  describe "qhs" do
+
+    let tests = [
+          "basic", "columns", "stdin", "header", "where", "tab", "tab2",
+          "count", "is_null", "not_null", "output_header", "spaces", "crlf",
+          "output_delimiter", "tab_delimited_output", "multiline",
+          "pipe_delimited", "pipe_delimited_output", "delimiters",
+          "query_file", "empty_query", "empty_query_file", "file_spaces",
+          "gzip", "gzip_stdin", "avg", "sum", "avg_sum", "seq",
+          "group", "group_sum", "concat", "join", "invalid"
+          ]
+
+    forM_ tests \test -> do
+
+      it ("should be executed correctly: " ++ test) do
+        let cp = (shell ("bash " ++ test ++ ".sh")) {
+              cwd = Just "test/tests",
+              std_out = CreatePipe,
+              std_err = CreatePipe
+            }
+        (_, Just out, Just err, _) <- createProcess cp
+        hSetBuffering out NoBuffering
+        hSetBuffering err NoBuffering
+        outExpected <- readFile $ "test/tests/" ++ test ++ ".out"
+        liftA2 (++) (hGetContents out) (hGetContents err) `shouldReturn` outExpected
diff --git a/test/SQLSpec.hs b/test/SQLSpec.hs
--- a/test/SQLSpec.hs
+++ b/test/SQLSpec.hs
@@ -1,10 +1,11 @@
 module SQLSpec (spec) where
 
 import Control.Monad
+import Data.Either.Extra (fromRight')
 import Test.Hspec (Spec, describe, it, shouldBe)
 
-import SQL
-import SQLType
+import Qhs.SQL as SQL
+import Qhs.SQLType
 
 spec :: Spec
 spec = do
@@ -13,17 +14,17 @@
 
 openCloseSpec :: Spec
 openCloseSpec =
-  describe "open, close" $ do
+  describe "open, close" do
 
-    it "should not throw exception" $ do
+    it "should not throw exception" do
       conn <- SQL.open ":memory:"
       SQL.close conn
 
 tableSpec :: Spec
 tableSpec =
-  describe "createTable, insertRow, execute" $ do
+  describe "createTable, insertRow, execute" do
 
-    it "should create a table" $ do
+    it "should create a table" do
       conn <- SQL.open ":memory:"
       let columns = ["foo", "bar", "baz", "qux"]
       let types = repeat SQLChar
@@ -33,10 +34,10 @@
                       ["f0", "f1", "f2", "f3"] ]
       forM_ entries $ SQL.insertRow conn "test_table" columns types
       ret <- SQL.execute conn "SELECT * FROM test_table"
-      ret `shouldBe` Right (fromColumnsAndEntries columns entries)
+      fromRight' ret `shouldBe` fromColumnsAndEntries columns entries
       SQL.close conn
 
-    it "should take care of null values in a number column" $ do
+    it "should take care of null values in a number column" do
       conn <- SQL.open ":memory:"
       let columns = ["foo", "num", "bar", "baz"]
       let types = cycle [SQLChar, SQLInt]
@@ -44,19 +45,19 @@
       let entries = [ ["c0", "2", "c2", "3"],
                       ["d0", "", "d2", "2"],
                       ["f0", "3", "f2", ""],
-                      ["e0", "", "e2", "4"] ]
+                      ["e0", "", "e2", "4.3"] ]
       forM_ entries $ SQL.insertRow conn "test_table" columns types
       ret0 <- SQL.execute conn "SELECT * FROM test_table"
-      ret0 `shouldBe` Right (fromColumnsAndEntries columns entries)
+      fromRight' ret0 `shouldBe` fromColumnsAndEntries columns entries
       ret1 <- SQL.execute conn "SELECT * FROM test_table WHERE num IS NOT NULL"
-      ret1 `shouldBe` Right (fromColumnsAndEntries columns [e | e <- entries, e !! 1 /= ""])
+      fromRight' ret1 `shouldBe` fromColumnsAndEntries columns [e | e <- entries, e !! 1 /= ""]
       ret2 <- SQL.execute conn "SELECT avg(num) FROM test_table"
-      ret2 `shouldBe` Right (fromColumnsAndEntries ["avg(num)"] [["2.5"]])
+      fromRight' ret2 `shouldBe` fromColumnsAndEntries ["avg(num)"] [["2.5"]]
       ret3 <- SQL.execute conn "SELECT avg(baz) FROM test_table"
-      ret3 `shouldBe` Right (fromColumnsAndEntries ["avg(baz)"] [["3.0"]])
+      fromRight' ret3 `shouldBe` fromColumnsAndEntries ["avg(baz)"] [["3.1"]]
       SQL.close conn
 
-    it "can create a table when name and column names contain spaces" $ do
+    it "can create a table when name and column names contain spaces" do
       conn <- SQL.open ":memory:"
       let columns = ["foo bar", "baz qux"]
       let types = repeat SQLChar
@@ -66,7 +67,7 @@
                       ["f0 f1", "f2 f3"] ]
       forM_ entries $ SQL.insertRow conn "test table" columns types
       ret0 <- SQL.execute conn "SELECT * FROM `test table`"
-      ret0 `shouldBe` Right (fromColumnsAndEntries columns entries)
+      fromRight' ret0 `shouldBe` fromColumnsAndEntries columns entries
       ret1 <- SQL.execute conn "SELECT `foo bar` FROM `test table`"
-      ret1 `shouldBe` Right (fromColumnsAndEntries ["foo bar"] (map (take 1) entries))
+      fromRight' ret1 `shouldBe` fromColumnsAndEntries ["foo bar"] (map (take 1) entries)
       SQL.close conn
diff --git a/test/tests/avg.out b/test/tests/avg.out
new file mode 100644
--- /dev/null
+++ b/test/tests/avg.out
@@ -0,0 +1,1 @@
+2.0
diff --git a/test/tests/avg.sh b/test/tests/avg.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/avg.sh
@@ -0,0 +1,1 @@
+qhs -H "SELECT avg(bar) FROM basic.csv"
diff --git a/test/tests/avg_sum.out b/test/tests/avg_sum.out
new file mode 100644
--- /dev/null
+++ b/test/tests/avg_sum.out
@@ -0,0 +1,2 @@
+avg(bar) sum(bar)
+2.0 4
diff --git a/test/tests/avg_sum.sh b/test/tests/avg_sum.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/avg_sum.sh
@@ -0,0 +1,1 @@
+qhs -H -O "SELECT avg(bar),sum(bar) FROM basic.csv"
diff --git a/test/tests/basic.csv b/test/tests/basic.csv
new file mode 100644
--- /dev/null
+++ b/test/tests/basic.csv
@@ -0,0 +1,4 @@
+foo,bar,baz
+a0,1,a2
+b0,3,b2
+c0,,c2
diff --git a/test/tests/basic.csv.gz b/test/tests/basic.csv.gz
new file mode 100644
Binary files /dev/null and b/test/tests/basic.csv.gz differ
diff --git a/test/tests/basic.out b/test/tests/basic.out
new file mode 100644
--- /dev/null
+++ b/test/tests/basic.out
@@ -0,0 +1,4 @@
+foo bar baz
+a0 1 a2
+b0 3 b2
+c0  c2
diff --git a/test/tests/basic.sh b/test/tests/basic.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/basic.sh
@@ -0,0 +1,1 @@
+qhs "SELECT * FROM basic.csv"
diff --git a/test/tests/big.csv b/test/tests/big.csv
new file mode 100644
--- /dev/null
+++ b/test/tests/big.csv
@@ -0,0 +1,13 @@
+foo,bar,baz
+a0,1,a2
+b1,2,b1
+c1,,c1
+a0,2,a1
+b0,1,b2
+c1,,c2
+a0,3,a3
+c1,,c3
+c0,,c1
+b1,2,b3
+c0,,c2
+a0,2,a3
diff --git a/test/tests/columns.out b/test/tests/columns.out
new file mode 100644
--- /dev/null
+++ b/test/tests/columns.out
@@ -0,0 +1,4 @@
+foo baz
+a0 a2
+b0 b2
+c0 c2
diff --git a/test/tests/columns.sh b/test/tests/columns.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/columns.sh
@@ -0,0 +1,1 @@
+qhs "SELECT c1,c3 FROM basic.csv"
diff --git a/test/tests/concat.out b/test/tests/concat.out
new file mode 100644
--- /dev/null
+++ b/test/tests/concat.out
@@ -0,0 +1,8 @@
+foo||baz
+a0a2
+b1b1
+a0a1
+b0b2
+a0a3
+b1b3
+a0a3
diff --git a/test/tests/concat.sh b/test/tests/concat.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/concat.sh
@@ -0,0 +1,1 @@
+qhs -H -O "SELECT foo||baz FROM big.csv WHERE bar IS NOT NULL"
diff --git a/test/tests/count.out b/test/tests/count.out
new file mode 100644
--- /dev/null
+++ b/test/tests/count.out
@@ -0,0 +1,2 @@
+COUNT(*)
+2
diff --git a/test/tests/count.sh b/test/tests/count.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/count.sh
@@ -0,0 +1,1 @@
+qhs -H -O "SELECT COUNT(*) FROM basic.csv WHERE baz <> 'b2'"
diff --git a/test/tests/crlf.csv b/test/tests/crlf.csv
new file mode 100644
--- /dev/null
+++ b/test/tests/crlf.csv
@@ -0,0 +1,4 @@
+foo bar baz
+a0 1 a2
+b0 3 b2
+c0 c2
diff --git a/test/tests/crlf.out b/test/tests/crlf.out
new file mode 100644
--- /dev/null
+++ b/test/tests/crlf.out
@@ -0,0 +1,4 @@
+foo bar baz
+a0 1 a2
+b0 3 b2
+c0 c2 
diff --git a/test/tests/crlf.sh b/test/tests/crlf.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/crlf.sh
@@ -0,0 +1,1 @@
+qhs -H -O "SELECT * FROM crlf.csv"
diff --git a/test/tests/delimiters.out b/test/tests/delimiters.out
new file mode 100644
--- /dev/null
+++ b/test/tests/delimiters.out
@@ -0,0 +1,3 @@
+a0	1	a2
+b0	3	b2
+c0		c2
diff --git a/test/tests/delimiters.sh b/test/tests/delimiters.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/delimiters.sh
@@ -0,0 +1,1 @@
+qhs -p -T -H "SELECT * FROM pipe_delimited.csv"
diff --git a/test/tests/empty.sql b/test/tests/empty.sql
new file mode 100644
--- /dev/null
+++ b/test/tests/empty.sql
diff --git a/test/tests/empty_query.out b/test/tests/empty_query.out
new file mode 100644
--- /dev/null
+++ b/test/tests/empty_query.out
@@ -0,0 +1,2 @@
+Query cannot be empty.
+For basic information, try the `--help' option.
diff --git a/test/tests/empty_query.sh b/test/tests/empty_query.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/empty_query.sh
@@ -0,0 +1,1 @@
+qhs ""
diff --git a/test/tests/empty_query_file.out b/test/tests/empty_query_file.out
new file mode 100644
--- /dev/null
+++ b/test/tests/empty_query_file.out
@@ -0,0 +1,2 @@
+Query cannot be empty.
+For basic information, try the `--help' option.
diff --git a/test/tests/empty_query_file.sh b/test/tests/empty_query_file.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/empty_query_file.sh
@@ -0,0 +1,1 @@
+qhs -q empty.sql
diff --git a/test/tests/file containing spaces.csv b/test/tests/file containing spaces.csv
new file mode 100644
--- /dev/null
+++ b/test/tests/file containing spaces.csv
@@ -0,0 +1,4 @@
+foo,bar,baz
+a0,1,a2
+b0,3,b2
+c0,,c2
diff --git a/test/tests/file_spaces.out b/test/tests/file_spaces.out
new file mode 100644
--- /dev/null
+++ b/test/tests/file_spaces.out
@@ -0,0 +1,3 @@
+foo baz
+a0 a2
+c0 c2
diff --git a/test/tests/file_spaces.sh b/test/tests/file_spaces.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/file_spaces.sh
@@ -0,0 +1,1 @@
+qhs -O -H "SELECT foo,baz FROM 'file containing spaces.csv' WHERE baz <> 'b2'"
diff --git a/test/tests/group.out b/test/tests/group.out
new file mode 100644
--- /dev/null
+++ b/test/tests/group.out
@@ -0,0 +1,4 @@
+foo cnt
+a0 4
+c1 3
+c0 2
diff --git a/test/tests/group.sh b/test/tests/group.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/group.sh
@@ -0,0 +1,1 @@
+qhs -H -O "SELECT foo,COUNT(*) cnt FROM big.csv GROUP BY foo ORDER BY cnt DESC LIMIT 3"
diff --git a/test/tests/group_sum.out b/test/tests/group_sum.out
new file mode 100644
--- /dev/null
+++ b/test/tests/group_sum.out
@@ -0,0 +1,4 @@
+foo sum(bar) cnt
+a0 8 4
+b1 4 2
+b0 1 1
diff --git a/test/tests/group_sum.sh b/test/tests/group_sum.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/group_sum.sh
@@ -0,0 +1,1 @@
+qhs -H -O "SELECT foo,sum(bar),COUNT(*) cnt FROM big.csv WHERE bar IS NOT NULL GROUP BY foo ORDER BY cnt DESC LIMIT 3"
diff --git a/test/tests/gzip.out b/test/tests/gzip.out
new file mode 100644
--- /dev/null
+++ b/test/tests/gzip.out
@@ -0,0 +1,4 @@
+foo bar baz
+a0 1 a2
+b0 3 b2
+c0  c2
diff --git a/test/tests/gzip.sh b/test/tests/gzip.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/gzip.sh
@@ -0,0 +1,1 @@
+qhs "SELECT * FROM basic.csv.gz"
diff --git a/test/tests/gzip_stdin.out b/test/tests/gzip_stdin.out
new file mode 100644
--- /dev/null
+++ b/test/tests/gzip_stdin.out
@@ -0,0 +1,2 @@
+a0 a2
+b0 b2
diff --git a/test/tests/gzip_stdin.sh b/test/tests/gzip_stdin.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/gzip_stdin.sh
@@ -0,0 +1,1 @@
+cat basic.csv.gz | qhs -H -z "SELECT foo,baz FROM - WHERE bar IS NOT NULL"
diff --git a/test/tests/header.out b/test/tests/header.out
new file mode 100644
--- /dev/null
+++ b/test/tests/header.out
@@ -0,0 +1,3 @@
+a0 a2
+b0 b2
+c0 c2
diff --git a/test/tests/header.sh b/test/tests/header.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/header.sh
@@ -0,0 +1,1 @@
+qhs -H "SELECT foo,baz FROM basic.csv"
diff --git a/test/tests/invalid.out b/test/tests/invalid.out
new file mode 100644
--- /dev/null
+++ b/test/tests/invalid.out
@@ -0,0 +1,7 @@
+<<query>>:1:14:
+  |
+1 | SELECT * FROM
+  |              ^
+unexpected end of input
+expecting table ref
+
diff --git a/test/tests/invalid.sh b/test/tests/invalid.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/invalid.sh
@@ -0,0 +1,1 @@
+qhs "SELECT * FROM"
diff --git a/test/tests/is_null.out b/test/tests/is_null.out
new file mode 100644
--- /dev/null
+++ b/test/tests/is_null.out
@@ -0,0 +1,1 @@
+c0  c2
diff --git a/test/tests/is_null.sh b/test/tests/is_null.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/is_null.sh
@@ -0,0 +1,1 @@
+qhs -H "SELECT * FROM basic.csv WHERE bar IS NULL"
diff --git a/test/tests/join.out b/test/tests/join.out
new file mode 100644
--- /dev/null
+++ b/test/tests/join.out
@@ -0,0 +1,3 @@
+foo	baz	bar
+a0	a2	1
+b0	b2	1
diff --git a/test/tests/join.sh b/test/tests/join.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/join.sh
@@ -0,0 +1,1 @@
+qhs -H -O -T "SELECT basic.foo,big.baz,big.bar FROM basic.csv basic JOIN big.csv big ON basic.baz = big.baz WHERE basic.bar IS NOT NULL"
diff --git a/test/tests/multiline.csv b/test/tests/multiline.csv
new file mode 100644
--- /dev/null
+++ b/test/tests/multiline.csv
@@ -0,0 +1,4 @@
+foo,bar,baz,qux,quux
+a0,1,"a2
+b0\",3,""b2
+c0",,c2
diff --git a/test/tests/multiline.out b/test/tests/multiline.out
new file mode 100644
--- /dev/null
+++ b/test/tests/multiline.out
@@ -0,0 +1,2 @@
+foo bar quux
+a0 1 c2
diff --git a/test/tests/multiline.sh b/test/tests/multiline.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/multiline.sh
@@ -0,0 +1,1 @@
+qhs -H -O "SELECT foo,bar,quux FROM multiline.csv LIMIT 1"
diff --git a/test/tests/not_null.out b/test/tests/not_null.out
new file mode 100644
--- /dev/null
+++ b/test/tests/not_null.out
@@ -0,0 +1,2 @@
+a0 1 a2
+b0 3 b2
diff --git a/test/tests/not_null.sh b/test/tests/not_null.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/not_null.sh
@@ -0,0 +1,1 @@
+qhs -H "SELECT * FROM basic.csv WHERE bar IS NOT NULL"
diff --git a/test/tests/output_delimiter.out b/test/tests/output_delimiter.out
new file mode 100644
--- /dev/null
+++ b/test/tests/output_delimiter.out
@@ -0,0 +1,4 @@
+foo;bar;baz
+a0;1;a2
+b0;3;b2
+c0;;c2
diff --git a/test/tests/output_delimiter.sh b/test/tests/output_delimiter.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/output_delimiter.sh
@@ -0,0 +1,1 @@
+qhs -H -O -D ';' "SELECT * FROM basic.csv"
diff --git a/test/tests/output_header.out b/test/tests/output_header.out
new file mode 100644
--- /dev/null
+++ b/test/tests/output_header.out
@@ -0,0 +1,4 @@
+foo baz
+a0 a2
+b0 b2
+c0 c2
diff --git a/test/tests/output_header.sh b/test/tests/output_header.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/output_header.sh
@@ -0,0 +1,1 @@
+qhs -H -O "SELECT foo,baz FROM basic.csv"
diff --git a/test/tests/pipe_delimited.csv b/test/tests/pipe_delimited.csv
new file mode 100644
--- /dev/null
+++ b/test/tests/pipe_delimited.csv
@@ -0,0 +1,4 @@
+foo|bar|baz
+a0|1|a2
+b0|3|b2
+c0||c2
diff --git a/test/tests/pipe_delimited.out b/test/tests/pipe_delimited.out
new file mode 100644
--- /dev/null
+++ b/test/tests/pipe_delimited.out
@@ -0,0 +1,3 @@
+a0|1|a2
+b0|3|b2
+c0||c2
diff --git a/test/tests/pipe_delimited.sh b/test/tests/pipe_delimited.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/pipe_delimited.sh
@@ -0,0 +1,1 @@
+qhs -p -H "SELECT * FROM pipe_delimited.csv"
diff --git a/test/tests/pipe_delimited_output.out b/test/tests/pipe_delimited_output.out
new file mode 100644
--- /dev/null
+++ b/test/tests/pipe_delimited_output.out
@@ -0,0 +1,4 @@
+foo|bar|baz
+a0|1|a2
+b0|3|b2
+c0||c2
diff --git a/test/tests/pipe_delimited_output.sh b/test/tests/pipe_delimited_output.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/pipe_delimited_output.sh
@@ -0,0 +1,1 @@
+qhs -H -O -P "SELECT * FROM basic.csv"
diff --git a/test/tests/query.sql b/test/tests/query.sql
new file mode 100644
--- /dev/null
+++ b/test/tests/query.sql
@@ -0,0 +1,1 @@
+SELECT foo,baz FROM - WHERE bar IS NOT NULL
diff --git a/test/tests/query_file.out b/test/tests/query_file.out
new file mode 100644
--- /dev/null
+++ b/test/tests/query_file.out
@@ -0,0 +1,3 @@
+foo baz
+a0 a2
+b0 b2
diff --git a/test/tests/query_file.sh b/test/tests/query_file.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/query_file.sh
@@ -0,0 +1,1 @@
+cat basic.csv | qhs -H -O -q query.sql
diff --git a/test/tests/quote.out b/test/tests/quote.out
new file mode 100644
--- /dev/null
+++ b/test/tests/quote.out
@@ -0,0 +1,3 @@
+a0 sep "double quoted" bar a2
+b0 sep "double quoted" bar b2
+c0 sep "double quoted" bar c2
diff --git a/test/tests/quote.sh b/test/tests/quote.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/quote.sh
@@ -0,0 +1,1 @@
+qhs -H "SELECT foo||' sep \"double quoted\" bar',baz FROM basic.csv"
diff --git a/test/tests/seq.out b/test/tests/seq.out
new file mode 100644
--- /dev/null
+++ b/test/tests/seq.out
@@ -0,0 +1,1 @@
+500.5 500500
diff --git a/test/tests/seq.sh b/test/tests/seq.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/seq.sh
@@ -0,0 +1,1 @@
+seq 1 1000 | qhs "SELECT avg(c1),sum(c1) FROM -"
diff --git a/test/tests/spaces.csv b/test/tests/spaces.csv
new file mode 100644
--- /dev/null
+++ b/test/tests/spaces.csv
@@ -0,0 +1,4 @@
+foo     bar     baz
+a0      1       a2
+b0      3       b2
+c0              c2
diff --git a/test/tests/spaces.out b/test/tests/spaces.out
new file mode 100644
--- /dev/null
+++ b/test/tests/spaces.out
@@ -0,0 +1,4 @@
+foo bar baz
+a0 1 a2
+b0 3 b2
+c0 c2 
diff --git a/test/tests/spaces.sh b/test/tests/spaces.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/spaces.sh
@@ -0,0 +1,1 @@
+qhs -H -O "SELECT * FROM spaces.csv"
diff --git a/test/tests/stdin.out b/test/tests/stdin.out
new file mode 100644
--- /dev/null
+++ b/test/tests/stdin.out
@@ -0,0 +1,2 @@
+a0 a2
+b0 b2
diff --git a/test/tests/stdin.sh b/test/tests/stdin.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/stdin.sh
@@ -0,0 +1,1 @@
+cat basic.csv | qhs -H "SELECT foo,baz FROM - WHERE bar IS NOT NULL"
diff --git a/test/tests/sum.out b/test/tests/sum.out
new file mode 100644
--- /dev/null
+++ b/test/tests/sum.out
@@ -0,0 +1,1 @@
+4
diff --git a/test/tests/sum.sh b/test/tests/sum.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/sum.sh
@@ -0,0 +1,1 @@
+qhs -H "SELECT sum(bar) FROM basic.csv"
diff --git a/test/tests/tab.csv b/test/tests/tab.csv
new file mode 100644
--- /dev/null
+++ b/test/tests/tab.csv
@@ -0,0 +1,4 @@
+foo	bar	baz
+a0	1	a2
+b0	3	b2
+c0		c2
diff --git a/test/tests/tab.out b/test/tests/tab.out
new file mode 100644
--- /dev/null
+++ b/test/tests/tab.out
@@ -0,0 +1,3 @@
+a0	1	a2
+b0	3	b2
+c0		c2
diff --git a/test/tests/tab.sh b/test/tests/tab.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/tab.sh
@@ -0,0 +1,1 @@
+qhs -t -H "SELECT * FROM tab.csv"
diff --git a/test/tests/tab2.out b/test/tests/tab2.out
new file mode 100644
--- /dev/null
+++ b/test/tests/tab2.out
@@ -0,0 +1,3 @@
+a0	1	a2
+b0	3	b2
+c0		c2
diff --git a/test/tests/tab2.sh b/test/tests/tab2.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/tab2.sh
@@ -0,0 +1,1 @@
+qhs -d $'\t' -D $'\t' -H "SELECT * FROM tab.csv"
diff --git a/test/tests/tab_delimited_output.out b/test/tests/tab_delimited_output.out
new file mode 100644
--- /dev/null
+++ b/test/tests/tab_delimited_output.out
@@ -0,0 +1,4 @@
+foo	bar	baz
+a0	1	a2
+b0	3	b2
+c0		c2
diff --git a/test/tests/tab_delimited_output.sh b/test/tests/tab_delimited_output.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/tab_delimited_output.sh
@@ -0,0 +1,1 @@
+qhs -H -O -T "SELECT * FROM basic.csv"
diff --git a/test/tests/where.out b/test/tests/where.out
new file mode 100644
--- /dev/null
+++ b/test/tests/where.out
@@ -0,0 +1,2 @@
+a0 a2
+c0 c2
diff --git a/test/tests/where.sh b/test/tests/where.sh
new file mode 100644
--- /dev/null
+++ b/test/tests/where.sh
@@ -0,0 +1,1 @@
+qhs -H "SELECT foo,baz FROM basic.csv WHERE baz <> 'b2'"
